CodeOnWeb
로그인

챕터 12. XML

XML 문서에 대해서 알아봅시다.

Park Jonghyun 2015/09/02, 00:06

내용

파이썬 3에 뛰어들기 (22)

챕터 -1. “파이썬 3로 뛰어들기”에서 달라진 점 챕터 0. 파이썬 설치하기 챕터 1. 첫 파이썬 프로그램 챕터 2. 고유 자료형 챕터 3. 컴프리헨션(Comprehensions) 챕터 4. 문자열(Strings) 챕터 5. 정규표현식(Regular Expressions) 챕터 6. 클로저와 제너레이터(Closures & Generators) 챕터 7. 클래스와 반복자(Classes & Iterators) 챕터 8. 고급 반복자(Advanced Iterators) 챕터 9. 단위 테스트(Unit Testing) 챕터 10. 리팩토링(Refactoring) 챕터 11. 파일 챕터 12. XML 챕터 13. 파이썬 객체 직렬화 챕터 14. HTTP 웹서비스 챕터 15. 사례 연구: chardet을 파이썬 3로 이식하기 챕터 16. 파이썬 라이브러리 패키징하기 부록 A. 2to3를 이용해서 코드를 파이썬 3로 이식하기 부록 B. 특수 함수 이름 부록 C. 이제 어디로 가야 할까요? 부록 D. 문제 해결

"아리스타르코스의 권한으로 드라코(Draco)는 법령을 제정했다."
- 아리스토텔레스(Aristotle)

뛰어들기

이 책의 거의 모든 챕터는 예제 코드를 중심으로 구성되어 있습니다. 하지만 이번 XML 챕터는 코드가 아니라 데이터에 대한 내용을 담고 있습니다. 블로그나 포럼 등 자주 업데이트 되는 사이트에서 최신 뉴스를 구독할 때 XML을 접해보신 분이 있을겁니다. 대부분의 유명한 블로그 소프트웨어는 블로그의 내용을 담고 있는 피드(feed)를 생성하고, 새로운 포스트, 댓글 등이 작성될 때마다 업데이트 합니다. 이 "피드"를 구독(subscribing) 함으로써 블로그의 최신 내용을 따라갈 수 있고, 피들리(Feedly)와 같은 피드 구독기를 이용하면 여러 개의 블로그를 한 곳에서 구독해 볼 수 있습니다.

이 챕터에서 다룰 XML 데이터는 다음과 같습니다. 피드의 한 예이지요. 좀 더 구체적으로 말하면 아톰 신디케이션 피드입니다.

feed.xml 다운로드 하기

XML에 대한 5분 코스

여러분이 이미 XML에 대해 알고 있다면 이 부분은 건너 뛰어도 됩니다.

XML은 계층 구조가 있는 데이터를 기술할 때 사용하는 일반화된 방법입니다. XML 문서는 하나 이상의 엘리멘트(element)로 구성되어 있고, 각 엘리멘트는 시작과 종료를 의미하는 태그로 구분됩니다. 이 정의에 따르면, 아래 예제는 간단하기는 해도 하나의 완전한 XML 문서입니다.

  1. foo 엘리멘트의 시작 태그입니다.
  2. 시작 태그에 대응하는 foo 엘리멘트의 종료 태그입니다. 글이나 수학 기호를 쓸 때 괄호를 열고 닫는 것처럼, 모든 시작 태그는 그에 대응되는 종료 태그를 가져야만 합니다.

엘리멘트는 다른 엘리멘트 내에 중첩되어 쓸 수 있으며, 그 깊이에 제한은 없습니다. foo 엘리멘트 내의 bar 엘리멘트는 foo의 서브 엘리멘트(subelement) 혹은 자식(child) 엘리멘트라고 부릅니다.

모든 XML 문서의 첫 번째 엘리멘트는 루트(root) 엘리멘트라고 합니다. 한 XML 문서는 오직 하나의 루트 엘리멘트를 가지게 됩니다. 아래 예시 코드는 XML 문서가 아닙니다. 두 개의 루트 엘리멘트를 가지고 있기 때문이죠.

엘리멘트는 속성(attribute)을 가질 수도 있는데, 속성은 이름-값의 쌍으로 이루어져 있습니다. 속성은 시작 태그 내에 존재하고 빈 칸으로 여러 개의 속성을 구분합니다. 속성 이름은 한 엘리멘트 내에서는 중복될 수 없습니다. 속성 값은 따옴표로 감싸는 것이 원칙입니다. 다만 홑따옴표와 겹따옴표 둘 다 쓸 수 있습니다.

  1. foo 엘리멘트는 lang이라는 이름의 속성 하나를 가지고 있습니다. lang 속성을 값은 en이군요.
  2. bar 엘리멘트는 id, lang이라는 이름의 속성 두 개를 가지고 있습니다. lang 속성의 값은 fr입니다. 서로 다른 엘리멘트이므로, 이 lang은 foo 엘리멘트의 lang 속성과 충돌하지 않습니다. 각 엘리멘트는 자기만의 독립된 속성을 가집니다.

엘리멘트가 하나 이상의 속성을 가질 때, 속성 간의 순서는 별로 중요하지 않습니다. 엘리멘트의 속성은 파이썬의 사전과 비슷하게 정렬되지 않은 키-값의 쌍으로 구성됩니다. 한 엘리멘트에 들어가는 속성의 수에는 제한이 없습니다.

엘리멘트는 텍스트 내용(text content)을 가질 수 있습니다.

텍스트와 자식 엘리멘트가 없는 엘리멘트는 빈 엘리멘트입니다.

빈 엘리멘트를 쓰는 좀 더 간단한 방법도 있습니다. / 문자를 시작 태그의 끝에 붙여주면 종료 태그를 따로 써주지 않아도 됩니다. 바로 전의 XML 문서 예제는 다음과 같이 쓸 수 있겠지요:

파이썬 함수가 서로 다른 모듈에 정의될 수 있는 것과 마찬가지로, XML 엘리멘트도 다른 네임스페이스(namespace)에 선언될 수 있습니다. 네임스페이스는 일반적으로 URL과 비슷하게 생겼습니다. 기본 네임스페이스를 선언하기 위해 xmlns 사용하세요. 네임스페이스 선언은 속성을 정의하는 것과 비슷하게 생겼지만, 이 둘은 다른 목적을 가지고 있습니다.

  1. feed 엘리멘트는 http://www.w3.org/2005/Atom이라는 네임스페이스에 속합니다.
  2. title 엘리멘트 역시 http://www.w3.org/2005/Atom 네임스페이스에 속합니다. 네임스페이스 선언은 선언된 엘리멘트 및 그 자식 엘리멘트 전체에 영향을 미칩니다.

네임스페이스를 정의하고 접두사(prefix)와 연결시키기 위해서 xmlns:prefix와 같은 형식으로 선언할 수도 있습니다. 그 네임스페이스에 속하는 각 엘리멘트는 접두사를 이용해서 명시적으로 선언해야 합니다.

  1. feed 엘리멘트는 http://www.w3.org/2005/Atom 네임스페이스에 속합니다.
  2. title 엘리멘트 역시 http://www.w3.org/2005/Atom 네임스페이스에 속합니다.

XML 구문 해석기의 입장에서 보면 위의 두 XML 문서는 동일합니다. 네임스페이스 + 엘리멘트 이름 = XML라는 등식이 성립하거든요. 접두사는 네임스페이스를 구분하기 위해서 필요한 것이므로, 접두어의 이름(atom:) 자체는 크게 중요하지 않습니다. 네임스페이스가 일치하고, 엘리멘트 이름이 일치하고, 속성도 일치하므로, 두 XML 문서는 동일합니다.

마지막으로, XML 문서는 문자 인코딩 정보도 포함할 수 있는데, 루트 엘리멘트 보다 앞서 첫 줄에 위치해야 합니다. (문서가 해석되기 이전에 필요한 인코딩 정보가 어떻게 문서 안에 위치할 수 있는지 궁금하다면 XML 명세의 섹션 F를 참고하세요.)

이제 여러분은 XML의 달인이 되었습니다!

아톰 피드(atom feed)의 구조

블로그나 CNN.com 처럼 소식이 빠르게 업데이트 되는 웹페이지를 한 번 생각해봅시다. 웹사이트 이름("CNN.com")이 있고, 웹사이트 내에 큰 제목("Breaking News, U.S., World, Weather, Entertainment & Video News" 등)도 있을 것이고, 마지막으로 업데이트 된 시간("updated 12:43 p.m. EDT, Sat May 16, 2009")과 서로 다른 시간에 작성된 여러 기사의 리스트도 있을 겁니다. 각 기사는 그들 나름대로 제목, 작성 시각(이후 수정했다면 마지막 업데이트 시각도 있겠죠), 접근할 수 있는 URL 등의 정보를 가지고 있을겁니다.

아톰 신디케이션 포맷(Atom syndication format)은 이런 정보를 표준화된 형식으로 담기 위해 만들어졌습니다. 제 블로그나 CNN.com은 엄청나게 다른 디자인과 독자 수를 가지고 있지만, 그 기본 구조를 들여다 보면 거의 같습니다. CNN.com에 이름이 있듯이 제 블로그에도 이름이 있습니다. CNN.com은 기사를 출판하고 저도 제가 쓴 글을 게시하지요.

모든 아톰 피드의 루트 엘리멘트는 feed 엘리멘트이고, 네임스페이스는 http://www.w3.org/2005/Atom로 정해져 있습니다.

  1. http://www.w3.org/2005/Atom은 아톰의 네임스페이스입니다.
  2. 엘리멘트는 xml:lang이라는 속성을 이용해 그 엘리멘트와 자식 엘리멘트에서 사용하는 언어를 선언해줄 수 있습니다. 이 경우, xml:lang 속성은 루트 엘리멘트에서 한 번 선언되었으므로 전체 피드에서 영어를 사용할 것임을 알 수 있습니다.

아톰 피드는 피드 자체에 대한 몇 가지 정보도 가지고 있습니다. 이런 정보는 루트인 feed 엘리멘트의 자식 엘리멘트로 표현됩니다.

  1. 이 피드의 제목은 dive into mark입니다.
  2. 부제는 currently between addictions이군요.
  3. 모든 피드는 전역 식별자(globally unique identifier)가 필요합니다. 만드는 법에 대해서는 RFC 4151 문서를 참고하세요.
  4. 이 피드는 2009/3/27 21:56 GMT에 마지막으로 업데이트 되었습니다. 최신 기사가 마지막으로 업데이트 된 시간과 보통 일치하지요.
  5. 이제 일이 재미있어지고 있습니다. 이 링크 엘리멘트는 텍스트 내용 대신 세 가지 속성(rel, type, href)을 가지고 있습니다. rel의 값은 이 링크의 종류를 지정합니다. rel='alternate'은 이 링크가 피드를 XML이 아닌 다른 방식으로 표현한 문서(보통 피드를 생성한 원문이 되겠지요)를 가리키고 있음을 의미합니다. type='text/html' 속성을 통해 이 링크는 HTML 페이지로 연결됨을 알 수 있습니다. 링크의 주소는 href 속성에 지정되어 있습니다.

이제 우리는 이 피드가 http://diveintomark.org/에 있는 "dive into mark"라는 사이트의 피드라는 것을 짐작할 수 있습니다. 마지막으로 업데이트 된 날짜는 2009/3/27이고요.

어떤 XML 문서에서는 엘리멘트의 순서가 중요할 수도 있는데, 아톰 피드에서는 상관없습니다.

피드에 대한 메타정보 다음에는 가장 최근에 작성된 글(entry)의 리스트가 등장합니다. 글 하나는 다음과 같은 식으로 생겼습니다.

  1. author 엘리멘트는 이 글의 작성자에 대한 정보를 담고 있습니다. 홈페이지가 http://diveintomark.org/에 있는 Mark라는 이름을 가진 사람이 저자인 것 같네요. (피드의 메타정보에서 본 alternate 링크와 같은 주소인데, 꼭 이럴 필요는 없습니다. 많은 블로그는 여러 명의 저자를 두고 있고, 이들은 모두 각자의 홈페이지를 가지고 있을 수 있겠죠.)
  2. title 엘리멘트는 이 글의 제목을 알려줍니다. "Dive into history, 2009 edition"이네요.
  3. 피드의 메타정보에서 본 것과 비슷하게, 이 링크 엘리멘트는 이 글의 HTML 버전에 대한 링크를 알려줍니다.
  4. 피드와 마찬가지로 각 글은 식별자(id)를 가지고 있습니다.
  5. 글은 두 개의 날짜를 가질 수 있습니다. 처음으로 작성된 시간(published)과 마지막으로 업데이트 된 시간(updated)이요.
  6. 글은 여러 개의 카테고리(category)를 가질 수 있습니다. 이 글은 diveintopython, docbook, html로 분류되겠네요.
  7. summary 엘리멘트는 이 글의 내용에 대한 요약을 담고 있습니다. (이 예제에 나오지는 않았지만, 문서의 텍스트 내용 전체를 content라는 엘리멘트를 이용해 포함할 수도 있습니다.) 이 summary 엘리멘트는 아톰 피드에만 존재하는 type='html' 속성을 가지는데, 이 요약이 일반 텍스트가 아니라 HTML 문서에서 따온 것임을 알려줍니다. 이 구분은 생각보다 중요합니다. HTML에서만 볼 수 있는 특별한 기호(—, … 등)가 등장할 때 이를 제대로 인식해서 "—"과 "…"으로 바꾸어야 하기 때문입니다. type 속성이 제대로 지정되지 않으면 특별한 기호가 보이는 그대로 이상하게 출력됩니다.
  8. 마지막으로 entry 엘리멘트의 종료 태그가 나왔습니다. 이 글의 메타정보가 다 나왔음을 알 수 있습니다.

XML 구문 해석하기(Parsing XML)

파이썬에서 XML 문서를 해석하는 방법에는 몇 가지가 있습니다. 전통적인 DOM이나 SAX 해석기도 있지만, 저는 ElementTree라고 하는 다른 라이브러리에 집중하도록 하겠습니다.

feed.xml 다운로드 하기

  1. ElementTree 라이브러리는 파이썬 표준 라이브러리에 속해 있습니다. 위치는 xml.etree.ElementTree이지요.
  2. ElementTree 라이브러리를 사용할 때 보통 parse() 함수를 호출하면서 시작합니다. 인자로는 파일 이름이나 파일같은 객체를 받습니다. 이 함수는 전체 문서를 한 번에 해석합니다. 메모리에 여유가 없는 경우라면 XML 문서를 조금씩 해석하는 방법도 있습니다.
  3. parse() 함수는 전체 문서에 대한 정보를 담은 객체를 반환합니다. 루트 엘리멘트를 반환하는 것은 아닙니다. 루트 엘리멘트에 대한 참조를 얻으려면 getroot() 메소드를 따로 호출해줘야 합니다.
  4. 예상대로 루트 엘리멘트는 http://www.w3.org/2005/Atom 네임스페이스를 가지는 feed 엘리멘트입니다. 객체를 문자열로 이렇게 표현해보니 중요한 점 한 가지를 분명히 확인할 수 있습니다. XML 엘리멘트는 네임스페이스와 태그 이름(지역 이름이라고도 합니다)이 합쳐져서 구성된다는 사실이요. 이 문서에 있는 모든 엘리멘트는 아톰 네임스페이스에 속해있고, 루트 엘리멘트는 {http://www.w3.org/2005/Atom}feed와 같이 표현됩니다.

ElementTree는 XML 엘리멘트를 {namespace}localname와 같은 식으로 표현합니다. ElementTree API를 사용하다 보면 이런 형식의 표현을 곳곳에서 볼 수 있을겁니다.

엘리멘트는 리스트입니다

ElementTree API에서 엘리멘트는 리스트처럼 작동합니다. 리스트의 각 항목은 엘리멘트의 자식입니다.

  1. 이전 예제에서 이어집니다. 루트 엘리멘트는 {http://www.w3.org/2005/Atom}feed입니다.
  2. 루트 엘리멘트의 "길이"는 자식 엘리멘트의 숫자와 같습니다.
  3. 엘리멘트 자체를 반복자(iterator)로 사용해서 순환문을 돌면서 모든 자식 엘리멘트를 출력할 수 있습니다.
  4. 출력된 결과에서 볼 수 있듯이 정말 여덟 개의 자식 엘리멘트가 있습니다. 피드의 메타정보(title, subtitle, id, updated, link)를 담고 있는 엘리멘트가 나온 후 entry 세 개가 나옵니다.

이미 눈치채신 분도 계시겠지만 좀 확실하게 언급해야겠네요. 자식 엘리멘트의 리스트는 직접적인 자식만 포함합니다. 각 entry 엘리멘트에 달린 자식 엘리멘트도 있지만, feed 엘리멘트의 자식 리스트에는 포함되어 있지 않습니다. 이런 엘리멘트를 찾을 수 있는 방법 두 가지를 챕터 후반부에 알아보겠습니다.

속성은 사전입니다

XML은 단순한 엘리멘트의 모음이 아닙니다. 각 엘리멘트는 그들만의 속성을 가질 수 있지요. 특정 엘리멘트에 대한 참조를 얻었다면 그 엘리멘트의 속성을 파이썬 사전 형식으로 쉽게 얻을 수 있습니다.

  1. attrib 속성은 그 엘리멘트 속성의 사전입니다. XML 원본에서 feed 엘리멘트는 이런식으로 생겼지요. xml: 접두어는 모든 XML 문서에 내장되어 있는 네임스페이스로 따로 선언하지 않아도 쓸 수 있습니다.
  2. 다섯 번째 자식(인덱스가 0부터 시작하므로 [4]는 다섯 번째입니다)은 link 엘리멘트네요.
  3. link 엘리멘트는 세 개의 속성을 가지고 있습니다: href, type, rel.
  4. 네 번째 자식(인덱스가 0부터 시작하므로 [3]는 네 번째입니다)은 update 엘리멘트입니다.
  5. update 엘리멘트는 속성을 가지고 있지 않습니다. 그래서 .attrib는 빈 사전을 반환합니다.

XML 문서 내에서 노드(node) 찾기

지금까지 우리는 XML 문서를 "위에서 아래로" 분석했습니다. 루트 엘리멘트에서 시작해 그 자식 엘리멘트에 접근해 보았지요. 하지만 XML을 다룰 때는 순차적으로 작업하기 보다는, 임의의 위치에 있는 특정한 엘리멘트를 찾아 작업하는 경우가 더 많습니다. Etree는 물론 이런 기능을 제공합니다.

  1. findall() 메소드는 주어진 쿼리(query)에 해당하는 자식 엘리멘트를 모두 찾습니다. (쿼리의 형식에 대해서는 잠시 뒤에 이야기 하겠습니다.)
  2. 모든 엘리멘트(루트와 자식 엘리멘트를 포함해서)는 findall() 메소드를 가지고 있습니다. 해당 엘리먼트의 자식 엘리멘트 중에 매칭되는 엘리멘트를 찾아 반환합니다. 결과가 비어 있지요? 여기서 우리가 준 쿼리는 루트 엘리멘트의 자식 엘리멘트만 검색하기 때문입니에 당연한 결과입니다. 루트 엘리멘트인 feed는 이름이 feed인 자식 엘리멘트를 가지고 있지 않으므로 이 쿼리는 빈 리스트를 반환합니다.
  3. 이 결과가 놀랍지 않나요? 이 문서에는 author 엘리멘트가 있습니다. 사실 각 entry 엘리멘트 내부에 하나씩 총 세 개가 있지요. 하지만 이 author 엘리멘트는 루트 엘리멘트의 적접적인 자식이 아니고, 말하자면 "손자"라고 해야할 것 같습니다(자식 엘리멘트의 자식이죠). 얼마나 깊숙히 있는지와 관계없이 모든 author 엘리멘트를 찾고자 한다면, 비슷하지만 조금은 다른 쿼리 형식을 사용해야 합니다.

  1. etree.parse() 함수를 호촐해서 얻었던 tree 객체를 기억하시나요? 이 객체는 루트 엘리멘트에서 보았던 것과 비슷한 메소드를 가지고 있습니다. 이 결과는 tree.getroot().findall() 메소드를 호출했을 때 얻는 결과와 정확히 같습니다.
  2. 하지만 이 쿼리도 문서에 있는 author 엘리멘트를 찾지 못하는군요. 왜 그럴까요. 왜냐하면 이 명령은 조금 짧긴 하지만 tree.getroot().findall('{http://www.w3.org/2005/Atom}author')와 정확히 같은 일을 하기 때문입니다. 루트 엘리멘트의 자식 중에서 author 엘리멘트를 찾으라는 의미이지요. author 엘리멘트는 루트 엘리멘트가 아니라 entry 엘리멘트의 자식입니다. 이 쿼리는 해당되는 아무런 엘리멘트를 찾지 못합니다.

매칭되는 첫 번째 엘리멘트만 반환하는 find() 메소드도 있습니다. 문서에 해당 엘리멘트가 단 하나 있거나, 여러 개 있더라도 가장 먼저 나오는 엘리멘트만 필요한 경우에 사용할 수 있습니다.

  1. 좀전의 예제에서 본 것이죠. 모든 atom:entry 엘리멘트를 찾습니다.
  2. find() 메소드는 ElementTree 쿼리를 인자로 받은 뒤 그 쿼리와 매칭되는 첫 번째 엘리멘트를 반환합니다.
  3. 이 entry 엘리멘트에는 foo라는 이름의 엘리멘트가 없습니다. 그래서 None이 반환됩니다.

find() 메소드를 if 문의 조건 등에 boolean context로 사용할 때 주의해야 할 점이 하나 있습니다. ElementTree의 객체(element라고 합시다)는 자식을 하나도 가지고 있지 않으면(즉, len(element)가 0일 때) False로 평가됩니다. 따라서 element.find('...')는 find() 메소드가 엘리멘트를 찾았는지 여부를 판별하지 않고, 반환된 엘리멘트가 자식 엘리멘트를 가지고 있는지 여부를 판별하려고 합니다. find() 메소드가 엘리멘트를 반환했는지 아닌지 알려면, if element.find('...') is not None과 같은 식으로 코드를 작성하세요.

직접적인 자식이 아닌 엘리멘트까지 모두 찾을 수 있늘 방법이 있습니다. 자식, 손자, 아니면 훨씬 더 멀리 숨어있는 후손들 모두요.

  1. //{http://www.w3.org/2005/Atom}link 쿼리는 이전 예제에서 본 것과 아주 비슷합니다. 다만 쿼리의 시작 부분에 두 개의 슬래시가 추가되어 있네요. 이 두 개의 슬래시는 "자식 엘리멘트만 찾지 말고 전체 엘리멘트에서 찾아라"는 의미입니다. 그래서 결과로 하나가 아닌 네 개의 link 엘리멘트가 반환됩니다.
  2. 반환된 결과의 첫 번째 항목은 루트 엘리멘트의 자식입니다. 속성을 조회해보면 알 수 있지만, 이것은 피드의 html 버전 문서가 있는 웹사이트를 가리키는 alternate link입니다.
  3. 나머지 세 개의 항목은 각 entry 내에 있는 alternate link 엘리멘트입니다. 각 entry는 하나의 link 엘리멘트를 가지고 있고, 쿼리의 시작 부분에 두 개의 슬래시 때문에 결과로 반환되었습니다.

ElementTree의 findall() 메소드는 매우 강력하지만, 거기에 쓰이는 쿼리 언어는 좀 생소하게 느껴질 수 있습니다. ElementTree는 공식적으로 "XPath 표현을 부분적으로 지원한다"고 밝히고 있습니다. XPath는 XML 문서를 쿼리하기 위한 W3C 표준입니다. ElementTree의 쿼리 언어는 기본적인 작업을 하기에는 충분할 정도로 XPath를 지원합니다. 하지만 완전히 지원하지는 못하고 있으므로, 여러분이 이미 XPath를 알고 있다면 약간 짜증이 날 수도 있습니다. 이제 서드파티(third-party) XML 라이브러리를 한 번 둘러보겠습니다. ElementTree API를 확장해서 XPath를 완전히 지원하는 라이브러리들입니다.

LXML과 함께 더 깊이 들어가 봅시다

lxml은 유명한 libxml2 해석기 위에 만들어진 오픈 소스 라이브러리입니다. ElementTree API와 100% 호환되고, 이를 더 확장해서 XPath 1.0을 완벽하게 지원합니다. 몇가지 더 좋은 점도 있고요. 윈도우즈 설치파일은 제공하고 있고, 리눅스 사용자라면 yum이나 apt-get과 같은 패키지 관리 도구를 통해 저장소에 있는 컴파일된 이진 파일을 다운받을 수 있습니다. 그게 아니라면 lxml을 여러분이 직접 설치할 수도 있습니다.

  1. import하고 나면 lxml은 내장 라이브러리인 ElementTree와 같은 API를 제공합니다.
  2. parse() 함수: ElementTree와 같네요.
  3. getroot() 메소드: 역시 같습니다.
  4. findall() 메소드: 완벽하게 같습니다.

lxml은 ElementTree 라이브러리보다 훨씬 빠르므로 큰 XML 문서를 처리하는 데도 적합합니다. ElementTree API만 사용하면서 속도를 높이고 싶다면 lxml을 불러들인 뒤에 그냥 ElementTree와 똑같이 사용해도 됩니다.

하지만 lxml은 단지 빠른 ElementTree 이상의 기능을 제공합니다. 예를 들어, findall() 메소드는 더 복잡하고 다양한 표현을 제공합니다.

  1. 이 예제에서 from lxml import etree라고 모듈을 부르는 대신 import lxml.etree를 사용하겠습니다. lxml에서만 쓸 수 있는 기능을 강조하기 위해서입니다.
  2. 이 쿼리는 문서 전체에서 href 속성을 가지고 있는 Atom 네임스페이스의 모든 엘리멘트를 찾습니다. 첫 부분의 //는 루트 엘리멘트의 자식 뿐만 아니라 모든 엘리멘트를 대상으로 찾으라는 것입니다. {http://www.w3.org/2005/Atom}는 Atom이라는 네임스페이스에 속한 엘리멘트만 골라내라는 의미입니다. [@href]는 href 속성을 가지고 있는 엘리멘트를 뜻하지요.
  3. 이 쿼리는 모든 href의 값이 http://diveintomark.org/인 모든 Atom 엘리멘트를 찾습니다.
  4. 네임스페이스 이름을 별도로 저장한 뒤 문자열 서식을 이용해 길이를 좀 줄였습니다. 그렇지 않으면 너무 길어지거든요. 어쨋든 이 쿼리는 Atom authror 엘리멘트 중 Atom uri 엘리멘트를 자식으로 가지는 것만 골라서 찾습니다. 첫 번째와 두 번째 entry 내에 있는 author 엘리멘트만 반환되네요. 마지막 entry의 author 엘리멘트에는 name만 있고 uri 엘리멘트가 없습니다.

아직 더 알고 싶으신가요? lxml은 XPath 1.0 표현도 아주 잘 지원합니다. XPath 문법에 대해 여기서 깊게 다루지는 않겠습니다. 그 자체만으로 책 한 권을 따로 써야할 테니까요. 다만 lxml에서 어떤 식으로 쓸 수 있는지만 보여드리겠습니다.

  1. 네임스페이스에 속한 엘리멘트를 대상으로 XPath 쿼리를 수행하려면 네임스페이스 접두어를 매핑할 필요가 있습니다. 그냥 파이썬 사전으로 정의하면 됩니다.
  2. XPath 쿼리가 있습니다. 이 XPath 표현은 Atom 네임스페이스의 category 엘리멘트 중에서 term 속성의 값이 accessibility인 것을 찾습니다. 그런데 이게 다가 아닙니다. 쿼리 문자열의 마지막을 보면 /..라고 된 부분이 있습니다. 이것은 "그리고 방금 찾은 category 엘리멘트의 부모 엘리멘트를 반환하라"는 의미입니다. 따라서 이 한 줄의 XPath 쿼리는 인 자식 엘리멘트를 가지고 있는 모든 엘리멘트를 찾아 반환합니다.
  3. xpath() 함수는 ElementTree 객체의 리스트를 반환합니다. 이 문서에서는 term 속성이 accessibility인 category 엘리멘트를 자식으로 가지는 entry 엘리멘트가 하나 있습니다.
  4. XPath 표현식이 언제나 엘리멘트의 리스트를 반환하는 것은 아닙니다. 기술적으로 말하자면, 해석된 XML 문서의 DOM(Document Object Model)은 엘리멘트가 아니라 노드(node)를 가지게 됩니다. 그 형식에 따라 노드는 엘리멘트가 될 수도 있고, 속성, 혹은 텍스트 내용이 될 수도 있습니다. XPath 쿼리의 결과는 사실 노드의 리스트인 셈이지요. 이 쿼리는 텍스트 노드의 리스트를 반환하고 있습니다. 현재 엘리멘트(./)의 자식 중에 title 엘리멘트(atom:title)가 있으면, 그 텍스트 내용(text())을 반환합니다.

XML 생성하기

파이썬에서는 이미 존재하는 XML 문서를 해석하는 것 뿐만 아니라 새롭게 생성하는 것도 가능합니다.

  1. 새로운 엘리멘트를 생성하려면 Element 클래스를 개체화합니다. 첫 번째 인자로 엘리멘트의 이름(namespace + local name)을 넘겨줍니다. 여기서는 Atom 네임스페이스에 feed 엘리멘트를 생성하고 있습니다. 우리가 만들 새 문서의 루트 엘리멘트가 되겠습니다.
  2. 새롭게 생성되는 엘리멘트에 속성을 추가하려면 속성 이름과 값을 사전 형태로 담아 attrib 인자에 넘겨주면 됩니다. 속성의 이름은 ElementTree의 표준 형식({namespace}localname)으로 되어 있어야 합니다.
  3. ElementTree의 tostring() 함수를 이용해서 어떤 엘리멘트(와 그 자식들까지)든지 직렬화(serialize)할 수 있습니다.

이 직렬화의 결과가 기대와 달라 놀라신 분이 계신지 모르겠네요. ElementTree가 네임스페이스를 가지는 XML 엘리멘트를 직렬화하는 방법은 기술적으로 틀린 건 아니지만 최적화되어 있지는 않습니다. 이 챕터의 첫 부분에 나왔던 샘플 XML 문서에서는 기본 네임스페이스(xmlns='http://www.w3.org/2005/Atom')를 정의했습니다. 기본 네임스페이스를 정의하는 것은 (아톰 피드와 같이) 모든 엘리멘트가 같은 네임스페이스에 속하는 문서에서 유용합니다. 네임스페이스를 한 번 선언해두면 모든 엘리멘트를 지역 이름(, , 등)만으로 선언할 수 있기 때문입니다. 다른 네임스페이스의 엘리멘트를 의도적으로 선언하고 싶은 경우가 아니라면 따로 접두사를 붙일 필요가 없습니다.

XML 해석기는 이런 기본 네임스페이스를 가진 XML 문서와 모든 엘리멘트에 네임스페이스 접두어가 붙어있는 XML 문서 사이에 차이를 구분하지 못합니다. 위 예제의 직렬화로 나오는 DOM은 (필요하지 않은) 접두사가 붙어서 나온 경우이지요.

이는 다음과 정확히 같은 의미를 가집니다.

실질적으로 유일한 차이는 두 번째 표현이 좀 짧다는 정도겠네요. 만약 우리가 샘플 피드의 모든 시작/끝 태그에 ns0: 접두어를 붙인다면, 태그 당 4글자 * 79개의 태그 + 4글자(네임스페이스 자체를 정의하는 데 필요한 글자) 해서 총 320 글자가 늘어나게 됩니다. UTF-8 인코딩을 사용한다고 가정하면 320바이트가 추가로 필요하겠지요. (gzip으로 압축하면 차이는 21바이트로 줄어들긴 하지만 21바이트라도 크긴 큰거죠.) 여러분과 별 상관없는 문제일 수도 있지만, 아톰 피드 같은 경우 내용이 업데이트 되면 수 천 번 이상 다운로드 될 수도 있으므로 몇 바이트라도 줄이면 결과적으로 꽤 큰 차이가 날 수도 있습니다.

이렇게 내장 ElementTree 라이브러리로 네임스페이스에 속한 엘리멘트를 직렬화 할 때 최적화하기가 어렵습니다. 하지만 lxml을 사용하면 할 수 있습니다.

  1. 시작하기 전에 네임스페이스 매핑 정보를 사전으로 정의합니다. 사전의 값은 네임스페이스이고, 사전의 키는 원하는 접두어입니다. None을 접두어로 사용하면 기본 네임스페이스를 선언하는 것과 같습니다.
  2. 이제 lxml에서 사용할 수 있는 nsmap 인자를 엘리멘트를 생성할 때 같이 넘겨줍니다. lxml은 여러분이 정의한 네임스페이스 접두어를 잘 기억하고 적용하게 됩니다.
  3. 예상한 대로 Atom 네임스페이스를 기본으로 한 결과를 얻었습니다. 피드의 엘리멘트에 네임스페이스 접두어가 붙지 않았습니다.
  4. 아이고, xml:lang 속성을 추가한다는 게 그만 깜박했네요. 하지만 set() 메소드를 이용해서 언제나 엘리멘트에 속성을 추가할 수 있습니다. 표준 ElementTree 형식으로 된 속성의 이름과 속성의 값, 두 개의 인자가 필요합니다. (이 메소드가 lxml에만 있는 것은 아닙니다. 이 예제에서 lxml은 nsmap이라는 인자를 통해 네임스페이스 접두어를 조절할 수 있도록 해줄 뿐입니다.)

XML 문서에 엘리멘트를 하나만 추가할 수 있나요? 물론 아니죠. 자식 엘리멘트도 손쉽게 추가할 수 있습니다.

  1. 이미 존재하는 엘리멘트에 자식 엘리멘트를 추가하기 위해서는 SubElement 클래스로 개채화 하면 됩니다. 인자로는 부모 엘리멘트(여기서는 new_feed)와 새롭게 생성될 엘리멘트의 이름(title)이 필요합니다. 이 자식 엘리멘트는 부모의 네임스페이스 매핑 정보를 상속하게 되므로 따로 네임스페이스 접두어를 재정의할 필요는 없습니다.
  2. 속성이 정의된 사전을 같이 넘겨줄 수도 있습니다. 키는 속성의 이름이고 값은 속성의 값이 됩니다.
  3. 예상 대로 새로 생성된 title 엘리멘트는 Atom 네임스페이스에 속하고 feed 엘리멘트의 자식에 들어가 있습니다. title 엘리멘트는 텍스트 내용이나 자식이 없으므로 lxml는 이를 빈 엘리멘트로 직렬화 합니다(/>으로 짧게 표현되어 있죠).
  4. 엘리멘트의 텍스트 내용을 설정합니다. .text에 할당해주면 됩니다.
  5. title 엘리멘트는 이제 내용을 가지고 있습니다. 부등호나 앰퍼샌드(&) 같은 특수 문자가 텍스트에 포함되어 있으면 직렬화 하기 전에 자동으로 변환됩니다(escaping한다고 합니다).
  6. 직렬화 할 때 좀 더 보기 좋게 출력하는 옵션(pretty_printing)을 줄 수도 있습니다. 각 종료 태그 뒤와 자식 엘리멘트가 있다면 시작 태그 뒤(텍스트는 제외)에 줄 바꿈 문자를 삽입합니다. 기술적으로 말하자면, lxml이 출력을 좀 더 예쁘게 만들기 위해 "중요하지 않은 공백"을 추가한다고 할 수 있습니다.

XML을 생성하는 다른 서드 파티 라이브러리로 xmlwitch라는 것도 있습니다. 이 라이브러리는 XML 문서를 더 보기 좋도록 만들기 위해 with 문을 광범위하게 사용합니다.

깨진 XML을 해석하기

XML은 모든 XML 해석기가 "드로코니안 오류 처리(draconian error handling)"를 할 수 있도록 의무화하고 있습니다. 즉, XML 문서에서 잘 정의된 오류가 발생하면 해석기는 그 즉시 멈추고 오류를 고치기 위한 처리를 해야 합니다. 잘 정의된 오류라는 것은 시작과 끝 태그가 맞지 않는다던지, 잘못된 유니코드 문자가 발견된다던지 하는 것을 포함한 여러 복잡한 상황을 뜻합니다. 이런 처리를 강제하지 않는 HTML 같은 다른 문서 형식과는 좀 차이가 있습니다. HTML 태그 닫는 것을 잊어버리거나 속성에 있는 앰퍼샌드 문자를 잘못 escaping 했다 하더라도, 여러분의 브라우저가 작동을 멈추고 즉시 오류를 수정하려 들지는 않습니다. (물론, HTML이 오류 처리를 하지 않는 것은 아닙니다. HTML 오류 처리는 사실 꽤 잘 정의되어 있는데, 단순히 "오류 하나가 발견되면 멈춰서 고치는" 식의 처리보다는 훨씬 복잡합니다.)

어떤 사람들은(저도 포함해서요) XML의 개발자가 드로코니안 오류 처리를 강제한 것이 실수라고 생각합니다. 오해하지는 마세요. 오류 처리 규칙을 단순화 하자는 주장을 하는 것은 아닙니다. 하지만 실제로 사용해보면 "잘 정의된 오류"라는 개념에는 보기보다 헛점이 많다는 것을 알 수 있습니다. 특히 웹상에 출판되어 http를 통해 전송되는 (아톰 피드 같은) XML 문서에서는 더욱 그렇습니다. 어쨌든, 1997년에 드로코니안 오류 처리를 정의한 이후 XML이 완숙기에 접어들었음에도 불구하고, 여전히 웹 상에는 "잘 정의된 오류"로 상당수의 아톰 피드가 오염되어 있다는 보고가 있습니다.

그래서 이론적으로나 실용적으로나 XML 문서를 "어떤 값을 치르고서라도" 해석해야 할 필요가 있다고 해봅시다. 잘 정의된 오류 하나가 발견되면 즉시 멈춰서 고쳐야 합니다. 여러분이 이런 방식을 원하신다면 lxml이 도움을 줄 수 있습니다.

여기 오류를 포함한 XML 문서가 하나 있습니다.

…은 XML에 정의되어 있지 않으므로(HTML에 정의되어 있습니다) 오류입니다. 이 피드를 기본 세팅으로 해석하려고 하면 lxml은 오류를 내뿜습니다.

잘 정의된 오류가 있음에도 이 XML 문서를 해석하려면 여러분이 XML 해석기를 만들어야 합니다.

  1. 여러분의 해석기를 생성하려면 lxml.etree.XMLParser 클래스를 개체화 하세요. 여러 가지 종류의 인자를 넘겨줄 수 있지만 우리가 여게서 관심있는 것은 recover 인자입니다. True를 넘겨주면 XML 해석기는 잘 정의되 오류를 "복구"하기 위해 최선을 다 합니다.
  2. 여러분이 만들 해석기를 이용해서 XML 문서를 해석하려면 parser 객체를 parse() 함수의 두 번째 인자로 넘겨주세요. lxml이 …와 관련된 오류를 발생시키지 않는 것을 볼 수 있습니다.
  3. 그래도 해석기는 잘 정의된 오류에 대한 정보를 기록하기는 합니다. (사실 기록은 오류를 발생 시키든 복구를 하든 관계없이 저장됩니다.)
  4. 정의되지 않은 …를 어떻게 처리해야 할지 알지 못하므로, 해석기는 그냥 아무 말 없이 그 부분을 삭제해버렸네요. 이 title 엘리멘트의 텍스트의 내용은 'dive into '가 되었습니다.
  5. 직렬화하면 알 수 있듯이 …은 어딘가로 이동하거나 하지 않고 완전히 없어졌습니다.

XML 해석기가 "복구"하는 방법이 해석기마다 동일하다는 보장이 없다는 점을 아는 것이 중요합니다. 다른 해석기는 …가 HTML에 정의되어 있다는 사실을 알고 …로 바꾸어줄 지도 모릅니다. 이게 좀 더 "나은" 방법일까요? 뭐 그럴 수도 있습니다. 이게 더 "정확한" 방법일까요? 아닙니다. 두 방법 모두 정확하지 않습니다. 정확한 방법은 (XML 문서에 따르자면) 작동을 중지하고 고쳐야 하겠지요. 작동이 중지되는 것을 원하지 않는다면 그냥 갈 길을 가면 됩니다.

더 읽을거리


이 강의는 영어로 된 원문을 기초로 작성되었으며, Creative Commons Attribution Share-Alike 라이센스하에 자유로운 변경, 배포가 가능합니다

5031 읽음
이전 챕터 11. 파일
다음 챕터 13. 파이썬 객체 직렬화

저자

토론이 없습니다

Please log in to leave a comment

16.5.11618.20190612.allo
문제 보고 · 사용조건 · 개인정보보호
래블업 주식회사 · 대한민국 서울 테헤란로 145 · 대표: 신정규 · 사업자번호: 864-88-00080 · +82 70-8200-2587

거절 확인

닫기
좋아요 책갈피 토론

아래 주소를 복사하세요