urllib 패키지로 인터넷 리소스 가져오기

urllib 패키지로 인터넷 리소스 가져오기 (HOWTO)

인터넷에서 페이지를 가져오는 일은 파이썬에서 아주 자주 하는 작업인데요, 표준 라이브러리에 있는 urllib.request 모듈이 이를 위한 가장 기본적인 수단입니다. 이 HOWTO는 단순한 URL 가져오기부터 인증·쿠키·프록시 같은 다소 복잡한 상황까지, HTTP를 중심으로 urllib을 어떻게 활용하는지 함께 살펴봅니다. 공식 urllib.request 문서를 대체하기보다는, 그 문서를 읽기 전에 길을 터주는 보조 안내서 역할을 한다고 생각하면 좋아요.

출처: Python 공식 문서 — HOWTO Fetch Internet Resources Using The urllib Package

소개 (Introduction)

urllib.request는 URL(Uniform Resource Locator)을 가져오는(fetch) 데 쓰는 파이썬 모듈입니다. urlopen 함수 하나만으로도 아주 간단한 인터페이스를 제공하는데, 이 함수는 여러 다양한 프로토콜로 URL을 가져올 수 있어요. 여기에 더해 기본 인증(basic authentication), 쿠키, 프록시 같은 흔한 상황을 다루기 위한 약간 더 복잡한 인터페이스도 있습니다. 이들은 handler와 opener라는 객체들로 제공됩니다.

urllib.request는 URL에서 ":" 앞에 오는 문자열로 식별되는 여러 "URL scheme"을 지원합니다(예를 들어 "ftp""ftp://python.org/"의 URL scheme이죠). 이 튜토리얼은 그중에서도 가장 흔한 경우인 HTTP에 집중할게요.

단순한 상황이라면 urlopen을 쓰기만 하면 되지만, HTTP URL을 열 때 에러나 특수한 경우를 만나기 시작하면 HyperText Transfer Protocol에 대한 어느 정도의 이해가 필요해집니다. HTTP의 가장 포괄적이고 권위 있는 참고자료는 RFC 2616인데, 이건 기술 문서라 읽기 쉽게 쓰인 건 아니에요. 이 HOWTO는 urllib을 쓰는 방법을 HTTP에 대한 설명과 함께 충분히 담아내는 것을 목표로 합니다.

URL 가져오기 (Fetching URLs)

urllib.request를 쓰는 가장 간단한 방법은 이렇습니다.

import urllib.request
with urllib.request.urlopen('http://python.org/') as response:
    html = response.read()

URL로 자원을 받아서 임시 위치에 저장하고 싶다면 shutil.copyfileobj()tempfile.NamedTemporaryFile()을 조합할 수 있어요.

import shutil
import tempfile
import urllib.request

with urllib.request.urlopen('http://python.org/') as response:
    with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
        shutil.copyfileobj(response, tmp_file)

with open(tmp_file.name) as html:
    pass

urllib의 많은 사용은 이처럼 간단합니다. 물론 'http:' URL 대신 'ftp:', 'file:' 같은 URL을 쓸 수도 있어요. 다만 이 튜토리얼의 목적은 더 복잡한 경우를 설명하는 것이고, 그래서 HTTP에 집중할게요.

HTTP는 요청(request)과 응답(response)을 기반으로 동작합니다. 클라이언트가 요청을 보내면 서버가 응답을 보내는 방식이죠. urllib.request는 이 흐름을 Request 객체로 구현해 놨는데, 이 객체가 곧 당신이 보내려는 HTTP 요청을 나타냅니다. 가장 간단한 형태로, 가져오고 싶은 URL을 지정한 Request 객체를 만들고, 이 객체를 urlopen에 넘기면 그 URL에 대한 응답 객체가 돌아옵니다. 이 응답은 파일처럼 생긴(file-like) 객체라서 예를 들어 .read()를 호출할 수 있어요.

import urllib.request

req = urllib.request.Request('http://python.org/')
with urllib.request.urlopen(req) as response:
    the_page = response.read()

urllib.request는 모든 URL scheme을 처리할 때 같은 Request 인터페이스를 사용한다는 점을 기억해 두세요. 예를 들어 FTP 요청도 이렇게 만들 수 있습니다.

req = urllib.request.Request('ftp://example.com/')

HTTP의 경우 Request 객체로 추가로 할 수 있는 일이 두 가지 있습니다. 첫째로 서버에 보낼 데이터(data)를 전달할 수 있고, 둘째로 데이터에 대한 부가 정보("메타데이터")나 요청 자체에 대한 설명을 서버에 보낼 수 있는데, 이 정보는 HTTP "헤더(header)"로 전송됩니다. 각각을 차례로 살펴볼게요.

데이터 (Data)

때로는 URL로 데이터를 보내고 싶을 때가 있습니다(흔히 그 URL은 CGI(Common Gateway Interface) 스크립트나 다른 웹 애플리케이션을 가리키죠). HTTP에서는 이럴 때 보통 POST 요청을 씁니다. 웹에서 작성한 HTML 폼을 제출할 때 브라우저가 하는 일이 바로 이것이에요. 모든 POST가 폼에서 나오는 건 아닙니다. 자신의 애플리케이션에 임의의 데이터를 전달하는 데 POST를 쓸 수도 있어요. HTML 폼의 일반적인 경우에는 데이터를 표준 방식으로 인코딩한 뒤 Request 객체의 data 인자로 넘겨주면 됩니다. 인코딩은 urllib.parse 라이브러리의 함수로 합니다.

import urllib.parse
import urllib.request

url = 'http://www.someserver.com/cgi-bin/register.cgi'
values = {'name' : 'Michael Foord',
          'location' : 'Northampton',
          'language' : 'Python' }

data = urllib.parse.urlencode(values)
data = data.encode('ascii') # data should be bytes
req = urllib.request.Request(url, data)
with urllib.request.urlopen(req) as response:
    the_page = response.read()

때로는 다른 인코딩이 필요한 경우도 있습니다(HTML 폼에서 파일을 업로드할 때 등. 자세한 내용은 HTML 명세의 Form Submission 부분을 참고하세요).

data 인자를 넘기지 않으면 urllib은 GET 요청을 사용합니다. GET과 POST 요청의 차이 중 하나는 POST 요청이 자주 "부수 효과(side-effect)"를 가진다는 점입니다. 즉 어떤 방식으로든 시스템의 상태를 바꾼다는 뜻이죠(예를 들어 웹사이트에 100파운드짜리 통조림 스팸을 문 앞까지 배달하도록 주문을 넣는 경우가요). HTTP 표준은 POST가 항상 부수 효과를 일으키도록 의도되었고 GET은 절대 부수 효과를 일으키지 않도록 의도되었음을 분명히 하지만, 실제로 GET 요청이 부수 효과를 갖는 것도 막을 수 없고 POST 요청이 부수 효과를 갖지 않는 것도 막을 수 없어요. GET 요청의 경우에도 URL 자체에 데이터를 인코딩해서 넘길 수 있습니다.

이렇게 하면 됩니다.

>>> import urllib.request
>>> import urllib.parse
>>> data = {}
>>> data['name'] = 'Somebody Here'
>>> data['location'] = 'Northampton'
>>> data['language'] = 'Python'
>>> url_values = urllib.parse.urlencode(data)
>>> print(url_values) # The order may differ from below.
name=Somebody+Here&language=Python&location=Northampton
>>> url = 'http://www.example.com/example.cgi'
>>> full_url = url + '?' + url_values
>>> data = urllib.request.urlopen(full_url)

완성된 URL은 원래 URL에 ?를 붙이고 그 뒤에 인코딩된 값들을 이어 붙여서 만든다는 걸 확인할 수 있어요.

헤더 (Headers)

여기서는 HTTP 요청에 헤더를 어떻게 추가하는지 보여주기 위해 특정한 헤더 하나를 살펴보겠습니다.

어떤 웹사이트는 [1] 프로그램이 자신을 둘러보는 것을 싫어하거나, 브라우저에 따라 다른 버전을 보내주기도 합니다 [2]. 기본적으로 urllib은 스스로를 Python-urllib/x.y(여기서 xy는 파이썬 릴리스의 주·부 버전 번호, 예를 들면 Python-urllib/2.5)라고 밝히는데, 이게 사이트를 혼란스럽게 하거나 아예 동작하지 않게 만들 수 있어요. 브라우저가 스스로를 밝히는 방식은 User-Agent 헤더입니다 [3]. Request 객체를 만들 때 헤더의 딕셔너리를 넘길 수 있어요. 다음 예시는 위와 같은 요청이지만, 스스로를 Internet Explorer의 한 버전이라고 밝힙니다 [4].

import urllib.parse
import urllib.request

url = 'http://www.someserver.com/cgi-bin/register.cgi'
user_agent = 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)'
values = {'name': 'Michael Foord',
          'location': 'Northampton',
          'language': 'Python' }
headers = {'User-Agent': user_agent}

data = urllib.parse.urlencode(values)
data = data.encode('ascii')
req = urllib.request.Request(url, data, headers)
with urllib.request.urlopen(req) as response:
    the_page = response.read()

응답에는 유용한 메서드 두 개가 더 있습니다. 문제가 생겼을 때 어떤 일이 벌어지는지 살펴본 뒤에 다룰 infogeturl에 관한 절을 참고하세요.

예외 처리하기 (Handling Exceptions)

urlopen은 응답을 처리할 수 없을 때 URLError를 일으킵니다(물론 파이썬 API에서 늘 그렇듯 ValueError, TypeError 같은 내장 예외가 일어날 수도 있어요).

HTTPError는 특히 HTTP URL의 경우에 일어나는 URLError의 서브클래스입니다.

이 예외 클래스들은 urllib.error 모듈에서 내보내집니다.

URLError

URLError는 보통 네트워크 연결이 없거나(지정한 서버로 가는 경로가 없거나), 지정한 서버가 존재하지 않을 때 일어납니다. 이 경우 일어난 예외는 reason 속성을 갖는데, 이는 오류 코드와 텍스트 오류 메시지를 담은 튜플입니다.

예를 들면:

>>> req = urllib.request.Request('http://www.pretend_server.org')
>>> try: urllib.request.urlopen(req)
... except urllib.error.URLError as e:
...     print(e.reason)
...
(4, 'getaddrinfo failed')

HTTPError

서버에서 오는 모든 HTTP 응답에는 숫자 "상태 코드(status code)"가 들어 있습니다. 때로는 이 상태 코드가 서버가 요청을 처리할 수 없음을 나타내기도 하죠. 기본 handler들은 이런 응답 일부를 대신 처리해 줍니다(예를 들어 클라이언트가 다른 URL에서 문서를 가져오도록 요청하는 "리다이렉션(redirection)" 응답이라면 urllib이 알아서 처리해 줍니다). 처리할 수 없는 경우에는 urlopenHTTPError를 일으킵니다. 흔한 오류로는 '404'(페이지를 찾을 수 없음), '403'(요청 금지), '401'(인증 필요)이 있어요.

모든 HTTP 오류 코드에 대한 참고 자료는 RFC 2616의 10절을 보세요.

일어난 HTTPError 인스턴스는 서버가 보낸 오류에 해당하는 정수 code 속성을 갖습니다.

오류 코드 (Error Codes)

기본 handler들이 리다이렉트(300번대 코드)를 처리하고, 100–299번대 코드는 성공을 의미하므로, 당신이 보게 될 오류 코드는 대개 400–599번대입니다.

http.server.BaseHTTPRequestHandler.responsesRFC 2616이 사용하는 모든 응답 코드를 보여주는 유용한 응답 코드 딕셔너리입니다. 이 딕셔너리의 일부는 아래와 같아요.

responses = {
    ...
    <HTTPStatus.OK: 200>: ('OK', 'Request fulfilled, document follows'),
    ...
    <HTTPStatus.FORBIDDEN: 403>: ('Forbidden',
                                  'Request forbidden -- authorization will '
                                  'not help'),
    <HTTPStatus.NOT_FOUND: 404>: ('Not Found',
                                  'Nothing matches the given URI'),
    ...
    <HTTPStatus.IM_A_TEAPOT: 418>: ("I'm a Teapot",
                                    'Server refuses to brew coffee because '
                                    'it is a teapot'),
    ...
    <HTTPStatus.SERVICE_UNAVAILABLE: 503>: ('Service Unavailable',
                                            'The server cannot process the '
                                            'request due to a high load'),
    ...
}

오류가 발생하면 서버는 HTTP 오류 코드와 오류 페이지를 함께 돌려줍니다. 이 HTTPError 인스턴스를, 반환된 페이지에 대한 응답으로 사용할 수 있어요. 즉 code 속성에 더해 urllib.response 모듈이 돌려주는 것과 같은 read, geturl, info 메서드도 갖습니다.

>>> req = urllib.request.Request('http://www.python.org/fish.html')
>>> try:
...     urllib.request.urlopen(req)
... except urllib.error.HTTPError as e:
...     print(e.code)
...     print(e.read())
...
404
b'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n\n\n<html
  ...
  <title>Page Not Found</title>\n
  ...

정리 (Wrapping it Up)

그럼 HTTPError 또는 URLError에 모두 대비하고 싶다면 기본적인 접근법이 두 가지 있습니다. 저는 두 번째 방법을 선호해요.

1번

from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
req = Request(someurl)
try:
    response = urlopen(req)
except HTTPError as e:
    print('The server couldn\'t fulfill the request.')
    print('Error code: ', e.code)
except URLError as e:
    print('We failed to reach a server.')
    print('Reason: ', e.reason)
else:
    # everything is fine

참고

except HTTPError반드시 먼저 와야 해요. 그렇지 않으면 except URLErrorHTTPError까지 함께 잡아버리거든요.

2번

from urllib.request import Request, urlopen
from urllib.error import URLError
req = Request(someurl)
try:
    response = urlopen(req)
except URLError as e:
    if hasattr(e, 'reason'):
        print('We failed to reach a server.')
        print('Reason: ', e.reason)
    elif hasattr(e, 'code'):
        print('The server couldn\'t fulfill the request.')
        print('Error code: ', e.code)
else:
    # everything is fine

info와 geturl

urlopen이 돌려주는 응답(또는 HTTPError 인스턴스)은 유용한 메서드 두 개, info()geturl()을 가지며, urllib.response 모듈에 정의되어 있습니다.

  • geturl - 가져온 페이지의 실제 URL을 돌려줍니다. urlopen(또는 사용한 opener 객체)이 리다이렉션을 따라갔을 수 있기 때문에 유용해요. 가져온 페이지의 URL이 요청한 URL과 다를 수 있거든요.
  • info - 가져온 페이지를 설명하는, 특히 서버가 보낸 헤더를 담은 딕셔너리처럼 생긴 객체를 돌려줍니다. 현재는 http.client.HTTPMessage 인스턴스입니다.

흔한 헤더에는 'Content-length', 'Content-type' 등이 있습니다. HTTP 헤더의 의미와 용도에 대한 간략한 설명을 담은 유용한 목록은 Quick Reference to HTTP Headers를 참고하세요.

Opener와 Handler

URL을 가져올 때 당신은 opener(이름이 다소 혼란스러운 urllib.request.OpenerDirector의 인스턴스)를 사용합니다. 지금까지는 urlopen을 통해 기본 opener를 써 왔는데, 직접 커스텀 opener를 만들 수도 있어요. opener는 handler들을 사용합니다. 그리고 실제 "무거운 일"은 모두 handler들이 합니다. 각 handler는 특정 URL scheme(http, ftp 등)의 URL을 여는 방법을 알거나, URL 열기의 한 측면(예를 들어 HTTP 리다이렉션 또는 HTTP 쿠키)을 처리하는 방법을 알아요.

특정 handler들이 설치된 상태로 URL을 가져오고 싶을 때 opener를 만들게 됩니다. 예를 들어 쿠키를 처리하는 opener, 또는 리다이렉션을 처리하지 않는 opener가 필요할 때요.

opener를 만들려면 OpenerDirector를 인스턴스화하고 .add_handler(some_handler_instance)를 반복해서 호출하면 됩니다.

대안으로 함수 하나로 opener 객체를 만들 수 있게 해 주는 편의 함수 build_opener를 쓸 수도 있어요. build_opener는 기본적으로 여러 handler를 추가하지만, 더 추가하거나 기본 handler를 덮어쓰는 빠른 방법도 제공합니다.

그 외에 쓸 만한 다른 종류의 handler로는 프록시, 인증, 그리고 그 밖의 흔하지만 약간 특수한 상황들을 처리하는 것들이 있어요.

install_opener를 쓰면 opener 객체를 (전역) 기본 opener로 만들 수 있습니다. 이제부터 urlopen을 호출하면 설치한 opener를 사용하게 되는 거죠.

opener 객체는 open 메서드를 가지며, urlopen 함수와 같은 방식으로 URL을 가져오기 위해 직접 호출할 수 있습니다. 편의를 위한 것 외에 install_opener를 호출할 필요는 없어요.

기본 인증 (Basic Authentication)

handler를 만들고 설치하는 과정을 보여주기 위해 HTTPBasicAuthHandler를 사용할게요. 이 주제에 대한 더 자세한 논의(기본 인증이 어떻게 동작하는지에 대한 설명 포함)는 Basic Authentication Tutorial을 보세요.

인증이 필요할 때 서버는 (401 오류 코드와 함께) 인증을 요청하는 헤더를 보냅니다. 이 헤더는 인증 방식(scheme)과 'realm'을 지정합니다. 헤더는 이런 형태예요: WWW-Authenticate: SCHEME realm="REALM".

예를 들면:

WWW-Authenticate: Basic realm="cPanel Users"

그러면 클라이언트는 해당 realm에 알맞은 이름과 비밀번호를 요청의 헤더로 포함해 다시 요청을 시도해야 합니다. 이것이 '기본 인증(basic authentication)'이에요. 이 과정을 단순화하기 위해 HTTPBasicAuthHandler의 인스턴스와 이 handler를 사용하는 opener를 만들 수 있습니다.

HTTPBasicAuthHandler는 password manager라는 객체를 사용해 URL과 realm을 비밀번호·사용자 이름에 매핑합니다. (서버가 보낸 인증 헤더에서) realm이 무엇인지 안다면 HTTPPasswordMgr을 쓸 수 있어요. 하지만 흔히 realm이 무엇인지 신경 쓰지 않는 경우가 많습니다. 그럴 때는 HTTPPasswordMgrWithDefaultRealm을 쓰면 편리해요. 이 객체는 URL에 대한 기본 사용자 이름과 비밀번호를 지정할 수 있게 해 줍니다. 특정 realm에 대한 다른 조합을 제공하지 않았을 때 이 값이 사용됩니다. add_password 메서드에 realm 인자로 None을 넘겨주면 이 기본값을 쓰겠다는 뜻이 됩니다.

top-level URL은 인증이 필요한 첫 번째 URL입니다. .add_password()에 넘긴 URL보다 "더 깊은" URL도 매칭됩니다.

# create a password manager
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()

# Add the username and password.
# If we knew the realm, we could use it instead of None.
top_level_url = "http://example.com/foo/"
password_mgr.add_password(None, top_level_url, username, password)

handler = urllib.request.HTTPBasicAuthHandler(password_mgr)

# create "opener" (OpenerDirector instance)
opener = urllib.request.build_opener(handler)

# use the opener to fetch a URL
opener.open(a_url)

# Install the opener.
# Now all calls to urllib.request.urlopen use our opener.
urllib.request.install_opener(opener)

참고

위 예시에서는 build_openerHTTPBasicAuthHandler만 넘겼어요. 기본적으로 opener는 일반적인 상황을 위한 handler들 — ProxyHandler(설정된 http_proxy 같은 환경 변수로 프록시 설정이 있을 때), UnknownHandler, HTTPHandler, HTTPDefaultErrorHandler, HTTPRedirectHandler, FTPHandler, FileHandler, DataHandler, HTTPErrorProcessor — 을 갖습니다.

top_level_url은 사실 완전한 URL('http:' scheme 요소와 호스트 이름, 선택적으로 포트 번호를 포함) 예를 들어 "http://example.com/" 또는 "authority"(즉 호스트 이름, 선택적으로 포트 번호 포함) 예를 들어 "example.com"이나 "example.com:8080"(후자는 포트 번호를 포함) 일 수 있어요. authority가 있다면 "userinfo" 요소를 포함하면 안 됩니다. 예를 들어 "joe:[email protected]"은 올바르지 않습니다.

프록시 (Proxies)

urllib은 당신의 프록시 설정을 자동 감지해서 사용합니다. 이는 ProxyHandler를 통해 이루어지는데, 프록시 설정이 감지되면 정상적인 handler 체인의 일부가 됩니다. 보통은 좋은 일이지만, 때로는 도움이 되지 않을 수도 있어요 [5]. 그럴 때 한 가지 방법은 프록시를 정의하지 않은 상태로 우리만의 ProxyHandler를 설정하는 것입니다. 기본 인증 handler를 설정하는 것과 비슷한 단계로 처리합니다.

>>> proxy_support = urllib.request.ProxyHandler({})
>>> opener = urllib.request.build_opener(proxy_support)
>>> urllib.request.install_opener(opener)

참고

현재 urllib.request는 프록시를 통한 https 위치 가져오기는 지원하지 않습니다. 다만 레시피 [6]에 나온 것처럼 urllib.request를 확장하면 가능하게 할 수 있어요.

참고

REQUEST_METHOD 변수가 설정되어 있으면 HTTP_PROXY는 무시됩니다. getproxies() 문서를 참고하세요.

소켓과 계층 (Sockets and Layers)

웹에서 자원을 가져오는 파이썬 지원은 계층으로 이루어져 있습니다. urllib은 http.client 라이브러리를 사용하고, http.client는 다시 socket 라이브러리를 사용하죠.

Python 2.3부터 소켓이 응답을 기다리다 시간 초과(timeout)되기까지 얼마나 기다릴지 지정할 수 있습니다. 이는 웹 페이지를 가져와야 하는 애플리케이션에서 유용해요. 기본적으로 socket 모듈은 시간 초과가 없어서 멈춰버릴(hang) 수 있습니다. 현재 소켓 시간 초과는 http.client나 urllib.request 수준에서는 드러나지 않습니다. 다만 모든 소켓에 대한 기본 시간 초과를 전역으로 설정할 수는 있어요.

import socket
import urllib.request

# timeout in seconds
timeout = 10
socket.setdefaulttimeout(timeout)

# this call to urllib.request.urlopen now uses the default timeout
# we have set in the socket module
req = urllib.request.Request('http://www.voidspace.org.uk')
response = urllib.request.urlopen(req)

더 알아보기 (Learn more)