파이썬에서 데코레이터를 정말 자주 사용하고 있지만 다양한 용례는 접해보지 못했었다. Ned Batchelder의 글 Isolated @memoize은 @memoize 데코레이터에 대한 이야기인데 같이 곁들여진 설명과 각 링크가 유익해서 번역했다. 파이썬 데코레이터 모음 위키 페이지도 살펴보면 좋겠다.


파이썬 @memoize 고립된 환경에서 사용하기

실행 비용이 비싼 함수를 호출한다고 생각해보자. 동일한 입력을 했을 때 동일한 결과를 반환하는 함수인 경우에는 사람들 대부분은 @memoize 데코레이터를 사용하는 것을 선호한다. 이 데코레이터는 이전에 실행한 적이 있는 경우에는 동일한 결과를 빠르게 내놓을 수 있도록 캐시해둔다. 다음은 @memoize 구현 모음을 차용해서 만든 간단한 코드다.

def memoize(func):
    cache = {}

    def memoizer(*args, **kwargs):
        key = str(args) + str(kwargs)
        if key not in cache:
            cache[key] = func(*args, **kwargs)
        return cache[key]

    return memoizer

@memoize
def expensive_fn(a, b):
    return a + b        # 물론 이 함수는 그렇게 비싼 연산이 아니다!

이 코드는 원하는 동작을 제대로 수행하는 좋은 코드다. expresive_fn 함수를 동일한 인자로 반복해서 호출하면 실제로 함수를 호출하지 않고 캐시된 값을 사용할 수 있다.

하지만 여기에는 잠재적인 위험이 있다. 캐시 딕셔너리가 전역이라는 점이다. 물론 이 말을 문자 그대로 전역이라고 생각하는 잘못을 범하지 말자. global 키워드를 사용하지 않았고 모듈 단위의 변수인 것도 아니다. 하지만 이 딕셔너리는 프로세스 전체에서 expensive_fn를 대상으로 오직 하나의 캐시 딕셔너리를 갖고 있기 때문에 이 관점에서는 전역 변수라고 말할 수 있을 것이다.

전역은 잘 짜여진 테스트를 방해할 수 있다. 자동화된 테스트에서 가장 이상적인 동작 방식은 각 테스트가 서로 영향을 미치지 않도록 고립된 형태로 테스트를 수행하는 것이다. test1에서 어떤 일이 일어나든지 test99에는 영향이 없어야 한다. 하지만 여기서 test1부터 test99까지 expensive_fn을 (1, 2) 인자를 사용해서 호출했다면 test1은 함수를 호출하지만 test99는 캐시에 저장된 값을 사용한다. 더 나쁜 부분은 전체 테스트를 호출하면 test99는 캐시에 저장된 값을 사용하게 될 것인 반면 test99만 실행하면 함수를 실제로 실행하게 된다는 점이다.

만약 expensive_fn이 정말로 부작용 없는 순수 함수라면 이런 특징이 문제가 되지 않을 것이다. 하지만 때로는 문제가 되는 경우도 있다.

내가 관리하게 된 프로젝트 중에 고정된 데이터를 가져오기 위해 @memoize를 사용하는 웹사이트가 있었다. 자료를 가져올 때 단 한 번만 가져왔기 때문에 @memoize는 적절했고 프로그램을 사용하는데 전혀 문제가 되질 않았다. 테스트는 Betamax를 사용해서 네트워크 접근을 모조로 만들었다.

Betamax는 대단한 라이브러리다. 각 테스트 케이스를 구동할 때 각 테스트에서의 네트워크 접근을 확인한 후, “카세트”에 요청과 반환을 JSON 양식으로 저장한다. 다시 테스트를 수행하면 카세트에 저장되어 있는 정보를 사용해서 네트워크 접근을 모조해서 처리해준다.

문제는 test1의 카세트에서 캐시로 저장될 자원을 네트워크로 요청하게 되고 test99는 @memoize로 인해 네트워크를 통해 데이터를 요청할 필요가 없어졌기 때문에 test99의 카세트가 제대로 생성이 되지 않는다. 이제 테스트를 test99만 구동했을 때 카세트에 저장된 정보가 없기 때문에 테스트가 실패하게 된다. test1과 test99는 각각 고립되서 실행된다고 볼 수 없다. 저장된 캐시를 통해서 전역적으로 값을 공유하기 때문이다.

내 해결책은 @memoize를 사용했을 때 테스트 사이에 캐시 내용을 지우는 방식이다. 이 코드를 직접 작성하기 보다는 functools에 포함되어 있는 lru_cache 데코레이터를 사용할 수 있다. (여전히 2.7 버전의 파이썬을 사용하고 있다면 functools32을 찾아보자.) 이 데코레이터는 전역 캐시의 모든 값을 지울 수 있는 .cache_clear 함수를 제공한다. 이 함수는 각 데코레이터를 사용한 함수에 있기 때문에 사용한 함수를 목록으로 갖고 있어야 한다.

import functools

# memoize를 사용한 함수 목록을 저장. 그런 후
# `clear_memoized_values`로 일괄 비우기를 수행.
_memoized_functions = []

def memoize(func):
    """함수를 호출해서 반환한 값을 캐시로 저장함"""
    func = functools.lru_cache()(func)
    _memoized_functions.append(func)
    return func

def clear_memoized_values():
    """@memoize에 저장된 모든 값을 비워서 각 테스트가 고립된 환경으로 동작할 수 있도록 함"""
    for func in _memoized_functions:
        func.cache_clear()

이제 각 테스트 전에 캐시를 비우기 위해 py.test의 픽스쳐에서, 또는 테스트 케이스의 setUp() 메서드에서 clear_memoized_values() 함수를 사용할 수 있다.

# py.test를 사용하는 경우

@pytest.fixture(autouse=True)
def reset_all_memoized_functions():
    """@memoize에 캐시로 저장된 값을 매 테스트 전에 비움"""
    clear_memoized_values()

# unittest를 사용하는 경우

class MyTestCaseBase(unittest.TestCase):
    def setUp(self):
        super().setUp()
        clear_memoized_values()

사실 @memoize를 사용하는 다양한 이유를 보여주는 것이 더 나을지도 모른다. 순수 함수는 모든 테스트에서 캐시를 사용해서 같은 값을 반환해도 문제가 없을 것이다. 연산이 필요한 경우라면 누가 이런 문제를 신경 쓸까? 하지만 다른 경우에서는 확실히 고립된 환경을 만들어서 사용해야 한다. @memoize는 마술이 아니다. 이 코드가 어떤 일을 하는지, 어떤 상황에서 더 제어가 필요한지 잘 알아야 한다.


오현석(enshahar)님 피드백을 받아 번역을 개선했습니다. 감사 말씀 드립니다.

파이썬을 처음 공부할 때 리스트와 튜플에 대해 비슷한 의문을 가진 적이 있었다. 이 둘을 비교하고 설명하는 Ned Batchelder의 Lists vs. Tuples 글을 번역했다. 특별한 내용은 아니지만 기술적인 차이와 문화적 차이로 구분해서 접근하는 방식이 독특하게 느껴진다.


Python에 입문하는 사람들이 흔하게 하는 질문이 있다. 리스트(list)와 튜플(tuple)의 차이는 무엇인가?

이 질문의 답변은 이렇다. 이 두 타입은 각각 상호작용에 있어 두 가지 다른 차이점이 존재한다. 바로 기술적인 차이와 문화적인 차이다.

먼저 두 타입의 공통점을 확인하자. 리스트와 튜플은 둘 다 컨테이너로 일련의 객체를 저장하는데 사용한다.

>>> my_list = [1, 2, 3]
>>> type(my_list)
<class 'list'>
>>> my_tuple = (1, 2, 3)
>>> type(my_tuple)
<class 'tuple'>

둘 다 타입과 상관 없이 일련의 요소(element)를 갖을 수 있다. 두 타입 모두 요소의 순서를 관리한다. (세트(set)나 딕셔너리(dict)와 다르게 말이다.)

이제 차이점을 보자. 리스트와 튜플의 기술적 차이점은 불변성에 있다. 리스트는 가변적(mutable, 변경 가능)이며 튜플은 불변적(immutable, 변경 불가)이다. 이 특징이 파이썬 언어에서 둘을 구분하는 유일한 차이점이다.

>>> my_list[1] = "two"
>>> my_list
[1, 'two', 3]
>>> my_tuple[1] = "two"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

이 특징은 리스트와 튜플을 구분하는 유일한 기술적 차이점이지만 이 특징이 나타나는 부분은 여럿 존재한다. 예를 들면 리스트에는 .append() 메소드를 사용해서 새로운 요소를 추가할 수 있지만 튜플은 불가능하다.

>>> my_list.append("four")
>>> my_list
[1, 'two', 3, 'four']
>>> my_tuple.append("four")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'

튜플은 .append() 메소드가 필요하지 않다. 튜플은 수정할 수 없기 때문이다.

문화적인 차이점을 살펴보자. 리스트와 튜플을 어떻게 사용하는지에 따른 차이점이 있다. 리스트는 단일 종류의 요소를 갖고 있고 그 일련의 요소가 몇 개나 들어 있는지 명확하지 않은 경우에 주로 사용한다. 튜플은 들어 있는 요소의 수를 사전에 정확히 알고 있을 경우에 사용한다. 동일한 요소가 들어있는 리스트와 달리 튜플에서는 각 요소의 위치가 큰 의미를 갖고 있기 때문이다.

디렉토리 내에 있는 파일 중 *.py로 끝나는 파일을 찾는 함수를 작성한다고 가정해보자. 이 함수를 사용했을 때는 파일을 몇 개나 찾게 될 지 알 수 없다. 그리고 동일한 규칙으로 찾은 파일이기 때문에 항목 하나 하나가 의미상 동일하다. 그러므로 이 함수는 리스트를 반환할 것이다.

>>> find_files("*.py")
["control.py", "config.py", "cmdline.py", "backward.py"]

다른 예를 확인한다. 기상 관측소의 5가지 정보, 식별번호, 도시, 주, 경도와 위도를 저장한다고 생각해보자. 이런 상황에서는 리스트보다 튜플을 사용하는 것이 적합하다.

>>> denver = (44, "Denver", "CO", 40, 105)
>>> denver[1]
'Denver'

(지금은 클래스를 사용하는 것에 대해서 이야기하지 않을 것이다.) 이 튜플에서 첫 요소는 식별번호, 두 번째는 도시… 순으로 작성했다. 튜플에서의 위치가 담긴 내용이 어떤 정보인지를 나타낸다.

C 언어에서 이 문화적 차이를 대입해보면 목록은 배열(array) 같고 튜플은 구조체(struct)와 비슷할 것이다.

파이썬은 네임드튜플(namedtuple)을 제공하는데 이 네임드튜플을 사용하면 튜플에서 각 위치의 의미를 명시적으로 작성할 수 있다.

>>> from collections import namedtuple
>>> Station = namedtuple("Station", "id, city, state, lat, long")
>>> denver = Station(44, "Denver", "CO", 40, 105)
>>> denver
Station(id=44, city='Denver', state='CO', lat=40, long=105)
>>> denver.city
'Denver'
>>> denver[1]
'Denver'

튜플과 리스트의 문화적 차이를 영악하게 정리한다면 튜플은 네임드튜플에서 이름이 없는 것이라고 할 수 있다.

기술적 차이점과 문화적 차이점을 연계해서 생각하기란 쉽지 않은데 종종 이 차이점이 이상할 때가 있기 때문이다. 왜 단일 종류의 일련 데이터는 가변적이고 여러 종류의 일련 데이터는 불변이어야 하는 것일까? 예를 들면 앞에서 저장했던 기상관측소의 데이터는 수정할 수 없다. 네임드 튜플은 튜플이고 튜플은 불변이기 때문이다.

>>> denver.lat = 39.7392
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

때때로 기술적인 고려가 문화적 고려를 덮어쓰는 경우가 있다. 리스트를 딕셔너리에서 키로 사용할 수 없다. 불변 값만 해시를 만들 수 있기 때문에 키에 불변 값만 사용 가능하다. 대신 리스트를 키로 사용하고 싶다면 다음 예처럼 리스트를 튜플로 변경했을 때 사용할 수 있다.

>>> d = {}
>>> nums = [1, 2, 3]
>>> d[nums] = "hello"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> d[tuple(nums)] = "hello"
>>> d
{(1, 2, 3): 'hello'}

기술과 문화가 충돌하는 또 다른 예가 있다. 파이썬에서도 리스트가 더 적합한 상황에서 튜플을 사용하는 경우가 있다. *args를 함수에서 정의했을 때, args로 전달되는 인자는 튜플을 사용한다. 함수를 호출할 때 사용한 인자의 순서가 크게 중요하지 않더라도 말이다. 튜플은 불변이고 전달된 값은 변경할 수 없기 때문에 이렇게 구현되었다고 말할 수 있겠지만 그건 문화적 차이보다 기술적 차이에 더 가치를 두고 설명하는 방식이라 볼 수 있다.

물론 *args에서 위치는 매우 큰 의미를 갖는다. 매개변수는 그 위치에 따라 의미가 크게 달라지기 때문이다. 하지만 함수는 *args를 전달 받고 다른 함수에 전달해준다고만 봤을 때 *args는 단순히 인자 목록이고 각 인자는 별 다른 의미적 차이가 없다고 할 수 있다. 그리고 각 함수에서 함수로 이동할 때마다 그 목록의 길이는 가변적인 것으로 볼 수 있다.

파이썬이 여기서 튜플을 사용하는 이유는 리스트에 비해서 조금 더 공간 효율적이기 때문이다. 리스트는 요소를 추가하는 동작을 빠르게 수행할 수 있도록 더 많은 공간을 저장해둔다. 이 특징은 파이썬의 실용주의적 측면을 나타낸다. 이런 상황처럼 *args를 두고 리스트인지 튜플인지 언급하기 어려운 애매할 때는 그냥 상황을 쉽게 설명할 수 있도록 자료 구조(data structure)라는 표현을 쓰면 될 것이다.

대부분의 경우에 리스트를 사용할지, 튜플을 사용할지는 문화적 차이에 기반해서 선택하게 될 것이다. 어떤 의미의 데이터인지 생각해보자. 만약 프로그램이 실제로 다루는 자료가 다른 길이의 데이터를 갖는다면 분명 리스트를 써야 할 것이다. 작성한 코드에서 세 번째 요소에 의미가 있는 경우라면 분명 튜플을 사용해야 할 상황이다.

반면 함수형 프로그래밍에서는 코드를 어렵게 만들 수 있는 부작용을 피하기 위해서 불변 데이터 구조를 사용하라고 강조한다. 만약 함수형 프로그래밍의 팬이라면 튜플이 제공하는 불변성 때문에라도 분명 튜플을 좋아하게 될 것이다.

자, 다시 질문해보자. 튜플을 써야 할까, 리스트를 사용해야 할까? 이 질문의 답변은 항상 간단하지 않다.

파이썬을 사용하다보면 setup.py와 requirements.txt를 필연적으로 마주하게 된다. 처음 봤을 때는 이 둘의 용도가 비슷하게 느껴져서 마치 둘 중 하나를 골라야 하는지, 어떤 용도로 무엇을 써야 하는지 고민하게 된다. 같은 내용을 이상한모임 슬랙에서 물어봤었는데 Donald Stufft의 글 setup.py vs requirements.txtraccoonyy님이 소개해줬다. 이 두 도구를 사용하는 방식을 명확하게 잘 설명하는 글이라서 허락 받고 번역으로 옮겼다.


setup.py와 requirements.txt의 차이점과 사용 방법

setup.pyrequirements.txt의 역할에 대한 오해가 많다. 대부분의 사람들은 이 두 파일이 중복된 정보를 제공하고 있다고 생각한다. 심지어 이 “중복”을 다루기 위한 도구를 만들기까지 했다.

파이썬 라이브러리

이 글에서 이야기하는 파이썬 라이브러리는 타인이 사용할 수 있도록 개발하고 릴리스하는 코드를 의미한다. 다른 사람들이 만든 수많은 라이브러리는 PyPI에서 찾을 수 있을 것이다. 각각의 라이브러리가 제공될 때 문제 없이 배포될 수 있도록 패키지는 일련의 메타데이터를 포함하게 된다. 이 메타데이터에는 명칭, 버전, 의존성 등을 적게 된다. 라이브러리에 메타데이터를 작성할 수 있도록 다음과 같은 형식을 setup.py 파일에서 사용할 수 있다.

from setuptools import setup

setup(
    name="MyLibrary",
    version="1.0",
    install_requires=[
        "requests",
        "bcrypt",
    ],
    # ...
)

이 방식은 매우 단순해서 필요한 메타 데이터를 정의하기에 부족하지 않다. 하지만 이 명세에서는 이 의존성을 어디에서 가져와 해결해야 하는지에 대해서는 적혀있지 않다. 단순히 “requests”, “bcrypt”라고만 적혀있고 이 의존성이 위치하고 있는 URL도, 파일 경로도 존재하지 않는다. 어디서 의존 라이브러리를 가져와야 하는지 분명하지 않지만 이 특징은 매우 중요한 것이다. 이 특징을 지칭하는 특별한 용어가 있는 것은 아니지만 이 특징을 일종의 “추상 의존성(abstract dependencies)”라고 이야기할 수 있다. 이 의존성에는 의존 라이브러리의 명칭만 사용할 수 있고 선택적으로 버전 지정도 가능하다. 의존성 라이브러리를 사용하는 방식이 덕 타이핑(duck typing)과 같은 접근 방식을 사용한다고 생각해보자. 이 맥락에서 의존성을 바라보면 특정 라이브러리인 “requests”가 필요한 것이 아니라 “requests”처럼 보이는 라이브러리만 있으면 된다는 뜻이다.

파이썬 어플리케이션

여기서 이야기하는 파이썬 어플리케이션은 일반적으로 서버에 배포(deploy)하게 되는 부분을 뜻한다. 이 코드는 PyPI에 존재할 수도 있고 존재하지 않을 수도 있다. 하지만 어플리케이션에서는 재사용을 위해 작성한 부분은 라이브러리에 비해 그리 많지 않을 것이다. PyPI에 존재하는 어플리케이션은 일반적으로 배포를 위한 특정 설정 파일이 필요하다. 여기서는 “배포라는 측면에서의” 파이썬 어플리케이션을 중심으로 두고 살펴보려고 한다.

어플리케이션은 일반적으로 의존성 라이브러리에 종속되어 있으며 대부분은 복잡하게 많은 의존성을 갖고 있는 경우가 많다. 과거에는 이 어플리케이션이 어떤 라이브러리에 의존성이 있는지 확인할 수 없었다. 이렇게 배포(deploy)되는 특정 인스턴스는 라이브러리와 다르게 명칭이 없는 경우도 많고 다른 패키지와의 관계를 정의한 메타데이터도 갖고 있지 않는 경우도 많았다. 이런 상황에서 의존 라이브러리 정보를 저장할 수 있도록 pip의 requirements 파일을 생성하는 기능이 제공되게 되었다. 대부분의 requirements 파일은 다음과 같은 모습을 하고 있다.

# 이 플래그의 주소는 pip의 기본 설정이지만 관계를 명확하게 보여주기 위해 추가함
--index-url https://pypi.python.org/simple/

MyPackage==1.0
requests==1.2.0
bcrypt==1.0.2

이 파일에서는 각 의존성 라이브러리 목록을 정확한 버전 지정과 함께 확인할 수 있다. 라이브러리에서는 넓은 범위의 버전 지정을 사용하는 편이지만 어플리케이션은 아주 특정한 버전의 의존성을 갖는다. requests가 정확하게 어떤 버전이 설치되었는지는 큰 문제가 되지 않지만 이렇게 명확한 버전을 기입하면 로컬에서 테스트하거나 개발하는 환경에서도 프로덕션에 설치한 의존성과 정확히 동일한 버전을 사용할 수 있게 된다.

파일 첫 부분에 있는 --index-url https://pypi.python.org/simple/ 부분을 이미 눈치챘을 것이다. requirements.txt에는 PyPI를 사용하지 않는 경우를 제외하고는 일반적으로 인덱스 주소를 명시하지 않는 편이지만 이 첫 행이 requirements.txt에서 매우 중요하다. 이 내용 한 줄이 추상 의존성이었던 requests==1.2.0을 “구체적인” 의존성인 “https://pypi.python.org/simple/에 있는 requests 1.2.0″으로 처리하게 만든다. 즉, 더이상 덕 타이핑 형태로 의존성을 다루는 것이 아니라 isinstance() 함수로 직접 확인하는 방식과 동일한 패키징 방식이라고 할 수 있다.

추상 의존성 또는 구체적인 의존성에는 어떤 문제가 있을까?

여기까지 읽었다면 이렇게 생각할 수도 있다. setup.py는 재배포를 위한 파일이고 requirements.txt는 배포할 수 없는 것을 위한 파일이라고 했다. 그런데 이미 requirements.txt에 담긴 항목이 install_requires=[...]에 정의된 의존성과 동일할텐데 왜 이런 부분을 신경써야 할까? 이런 의문이 들 수 있을 것이다.

추상 의존과 구체적 의존을 분리하는 것은 중요하다. 의존성을 두 방식으로 분리해서 사용하면 PyPI를 미러링해서 사용하는 것이 가능하게 된다. 또한 같은 이유로 회사 스스로 사설(private) 패키지 색인을 구축해서 사용할 수 있는 것이다. 동일한 라이브러리를 가져와서 버그를 고치거나 새로운 기능을 더한 다음에 그 라이브러리를 의존성으로 사용하는 것도 가능하게 된다. 추상 의존성은 명칭, 버전 지정만 있고 이 의존성을 설치할 때 해당 패키지를 PyPI에서 받을지, Create.io에서 받을지, 아니면 자신의 파일 시스템에서 지정할 수 있기 때문이다. 라이브러리를 포크하고 코드를 변경했다 하더라도 라이브러리에 명칭과 버전 지정을 올바르게 했다면 이 라이브러리를 사용하는데 전혀 문제가 없을 것이다.

구체적인 의존성을 추상 의존성이 필요한 곳에서 사용했을 때는 문제가 발생하게 된다. 그 문제에 대한 극단적인 예시는 Go 언어에서 찾아볼 수 있다. go에서 사용하는 기본 패키지 관리자(go get)는 사용할 패키지를 다음 예제처럼 URL로 지정해서 받아오는 것이 가능하다.

import (
    "github.com/foo/bar"
)

이 코드에서 의존성을 특정 주소로 지정한 것을 볼 수 있다. 이제 이 라이브러리를 사용하다보니 “bar” 라이브러리에 존재하는 버그가 내 작업에 영향을 줘서 “bar” 라이브러리를 교체하려고 한다고 생각해보자. “bar” 라이브러리를 포크해서 문제를 수정했다면 이제 “bar” 라이브러리의 의존성이 명시된 코드를 변경해야 한다. 물론 지금 바로 수정할 수 있는 패키지라면 상관 없겠지만 5단계 깊숙히 존재하는 라이브러리의 의존성이라면 일이 커지게 된다. 단지 조금 다른 “bar”를 쓰기 위한 작업인데 다른 패키지를 최소 5개를 포크하고 내용을 수정해서 라이브러리를 갱신해야 하는 상황이 되고 말았다.

Setuptools의 잘못된 기능

Setuptools는 Go 예제와 비슷한 기능이 존재한다. 의존성 링크(dependency links) 라는 기능이며 다음 코드처럼 작성할 수 있다.

Setup

from setuptools import setup

setup(
    # ...
    dependency_links = [
        "http://packages.example.com/snapshots/",
        "http://example2.com/p/bar-1.0.tar.gz",
    ],
)

이 setuptools의 의존성 링크 “기능”은 의존성 라이브러리의 추상성을 지우고 강제로 기입(hardcode)하는 기능으로 이 의존성 패키지를 정확히 어디에서 찾을 수 있는지 url로 저장하게 된다. 이제 Go에서 살펴본 예제처럼 패키지를 조금 수정한 다음에 패키지를 다른 서버에 두고 그 서버에서 의존성을 가져오는 간단한 작업에도 dependency_links를 변경해야 한다. 사용하는 패키지의 모든 의존성 체인을 찾아다니며 이 주소를 수정해야 하는 상황이 되었다.

다시 사용할 수 있도록 만들기, 같은 일을 반복하지 않는 방법

“라이브러리”와 “어플리케이션”을 구분해서 생각하는 것은 각 코드를 다루는 좋은 방식이라고 할 수 있다. 하지만 라이브러리를 개발하다보면 언제든 _그 코드_가 어플리케이션처럼 될 때가 있다. 이제는 setup.py에 기록한 추상 의존성과 requirements.txt에 저장하게 되는 구체적 의존성으로 분리해서 의존성을 저장하고 관리해야 한다는 사실을 알게 되었다. 코드의 의존성을 정의할 수 있고 이 의존성을 받아오고 싶은 경로를 직접 지정할 수 있기 때문이다. 하지만 의존성 목록을 두 파일로 분리해서 관리하다보면 언젠가는 두 목록이 어긋나는 일이 필연적으로 나타난다. 이런 상황을 해결할 수 있도록 pip의 requirements 파일에서 다음 같은 기능을 제공한다. setup.py와 동일한 디렉토리 내에 아래 내용처럼 requirements 파일을 작성하자.

--index-url https://pypi.python.org/simple/

-e .

이렇게 파일을 작성하더라도 pip install -r requirements.txt 명령을 실행해보면 이전과 다르지 않게 동작하게 된다. 이 명령은 먼저 파일 경로 .에 위치한 라이브러리를 설치한다. 그리고 추상 의존성을 확인할 때 --index-url 설정의 경로를 참조해서 구체적인 의존성으로 전환하고 나머지 의존성을 마저 설치하게 된다.

이 방식을 사용하면 또 다른 강력한 기능을 활용할 수 있다. 만약 단위별로 나눠서 배포하고 있는 라이브러리가 둘 이상 있다고 생각해보자. 또는 공식적으로 릴리스하지 않은 기능을 별도의 부분 라이브러리로 분리해서 개발하고 있다고 생각해보자. 라이브러리가 분할되어 있다고 하더라도 이 라이브러리를 참조할 때는 최상위 라이브러리 명칭을 의존성에 입력하게 된다. 모든 라이브러리 의존성은 그대로 설치하면서 부분적으로는 개발 버전의 라이브러리를 설치하고 싶은 경우에는 다음처럼 requirements.txt에 개발 버전을 먼저 설치해서 개발 버전의 부분 라이브러리를 사용하는 것이 가능하다.

--index-url https://pypi.python.org/simple/

-e https://github.com/foo/bar.git#egg=bar
-e .

이 설정 파일은 먼저 “bar”라는 이름을 사용하고 있는 bar 라이브러리를 https://github.com/foo/bar.git 에서 받아 설치한 다음에 현재 로컬 패키지를 설치하게 된다. 여기서도 의존성을 조합하고 설치하기 위해 --index 옵션을 사용했다. 하지만 여기서는 “bar” 라이브러리 의존성을 github의 주소를 사용해서 먼저 설치했기 때문에 “bar” 의존성은 index로부터 설치하지 않고 github에 있는 개발 버전을 사용하는 것으로 계속 진행할 수 있게 된다.

이 포스트는 Yehuda Katz의 블로그 포스트에서 다룬 Gemfilegemspec에서 영감을 얻어 작성했다.


이 글에서 의존성의 관계를 추상적/구체적인 것으로 구분해서 보는 관점과 그 나눠서 다루는 방식에서 얻을 수 있는 이점이 명확하게 와닿았다.

추상과 대비해서 사용하는 concreate는 “구상”으로 번역하게 되는데 추상에 비해 익숙하지 않아서 구체적으로 번역했다. 만약 구상이 더 편한 용어라면 구체적 의존성을 구상 의존성으로 읽으면 도움이 되겠다.

Python을 실무에서 많이 사용하고 있지 않긴 하지만 사용할 때마다 재미있고 깊게 배우고 싶다는 생각이 늘 드는 언어 중 하나다. 관심을 갖기 시작했을 때부터 PyConAU에 다녀오고 싶었는데 이전엔 브리즈번에서 하고 그 전엔 호바트에서 해서 숙박이나 비행기표나 신경 쓸 일이 많아 고민만 하다 가질 못했었다. 올해는 멜번에서 한다고 하길래 얼리버드 티켓이 열리자마자 구입을 했다. 한동안 python과 가까이 지내지 못해서 과연 내가 이 행사를 참여할 자격이 있는가를 끊임 없이 고민했다. (이미 티켓을 샀으면서도.) 다녀오고 나서는 참 잘 다녀왔구나, 참가 안했으면 얼마나 후회했을까 싶을 정도로 좋은 기억이 남았고 긍정적인 힘을 많이 받고 올 수 있었다.

PyCon AU 2016의 모든 세션은 유튜브에 올라와 있어서 관심이 있다면 확인해보는 것도 좋겠다.

1일차

늘 주말에 일이 있어서 집밖을 나서는 날이 거의 없었는데 오랜만에 아침 일찍 챙겨서 행사장으로 갔고 생각보다 일찍 도착했다. 커피 쿠폰 두 장, 티셔츠를 받았다. 자유로운 분위기에서 대기하다가 들어가서 오프닝 키노트를 봤다.

도착! 이렇게 (공간이) 큰 행사는 처음 ㅜㅜ #pyconau

MicroPython: a journey from Kickstarter to Space

오프닝 키노트는 Python을 작은 임베드 장비에서 구동할 수 있도록 최적화를 먼저 시작하는 방식으로 개발한 구현체인 MicroPython에 대한 이야기였다. 구현을 시작한 과정부터 Kickstarter를 거치며 겪은 경험, BBC와 만든 micro, 그 이후 유럽우주국과 함께 진행한 프로젝트와 앞으로의 로드맵을 이야기했다.

임베딩 장비를 좋아하는데 거기서 고급 언어를 사용할 수 없을까 고민하다가 Python을 공부해야지 하는 마음에 Python을 선택했다는 말도 기억에 남는다. 작게 시작했지만 전환점이 되는 위치가 있었다. 요즘은 이미 거의 완제품이 나온 상태에서 킥스타터에 올리는 분위기지만 MicroPython이 펀딩을 진행했을 당시에는 좀 더 언더(?)같은 느낌이었나보더라. 그렇게 펀딩에 성공해서 MicroPython이 탄생했는데 그게 끝이 아니라 유럽우주국에서 연락이 와 투자를 받았단다. 지금까지 우주에서 사용하는 대부분의 디바이스는 중단 후 컴파일한 소프트웨어를 업데이트하고 다시 구동하는 방식인데 인터프리팅이 가능한데다 고급 언어인 파이썬을 사용할 수 있다는 점에 매력이 컸다고. 유럽우주국과 진행한 프로젝트에서 가능성을 확인했고 추후 장기적인 프로젝트를 계속 진행하게 될거라고 했다.

개발을 시작한 이유도 그렇고 킥스타터나 유럽우주국 이야기, 그렇게 우주로 나가는 디바이스를 만들고 있다는 이야기가 마치 소설처럼 느껴졌다. 작지만 세상을 바꾼다, 느낌으로.

RULES FOR RADICALS: CHANGING THE CULTURE OF PYTHON AT FACEBOOK

페이스북의 이야기였는데 Python2.x에서 Python3.x로 어떻게 옮겼는지에 대한 이야기였다. 2013년부터 이전하자는 이야기를 했는데 2016년에 py3를 기본으로 사용하기까지 과정을 보여줬다.

2013

  • Linter에서 __future__를 자동으로 넣어줘서 사람들이 아무 생각없이 쓰기 시작함.
  • 원래는 gevent와 twist가 py3 지원할 때까지 기다리려 했는데 Thrift에서 py3 지원 시작하면서 이전하기 시작.

2014년

  • 지원하기 시작했는데 아무도 안씀.
  • 구버전 패키징 라이브러리인 fbpackage2를 py3에 맞춰 fbpkg로 작성.
  • Be the change you want to see! 마하트마 간디
  • 기존 라이브러리, 사내 부트스트랩 전부 구버전 기준이라 재작성.
  • 고장난 라이브러리 전부 고침.
  • PyFlakes로 forcing comliance. py3 린트 통과 못하면 커밋 안됨. 자연스레 문법에 친해지기 시작.
  • 문제 생기면 적극적으로 도와줌.

2015년

  • 사내 교육을 py3으로 변경

2016년

  • 기본으로 py3 사용
  • 이제 사람들이 py2를 깨먹기 시작함

자신이 원하는게 있으면 자신이 먼저 변해야 한다는 이야기, 미래지향적으로 교육을 해야한다는 부분, 통계를 잘 수집하면 이런 과정이 더 명료해서 도움이 된다는 점이 기억에 남는다.

Python for Bioinformatics for learning Python

제목만 보고 Bioinformatics에 대한 이야기를 할 줄 알고 들어갔는데 좀 실망했다. Bioinformatics에서 c++를 자주 사용하는데 가르치기 어려워서 파이썬을 사용하기 시작했다고. Bioinformatics에서 분석할 때 사용하기 좋은 함수가 어떤 것인지 설명해주고 코드골프만 실컷 보여줬다.

Linear Genetic Programming in Python Bytecode

제네릭도 아니고 제네틱은 무엇인가 싶어서 들어갔는데 너무 충격적이었다. 이전에 본 적 있던 유튜브 영상으로 시작했다. 이게 바로 제네틱 프로그래밍이라고.

아예 처음 듣는 이야기라서 좀 어렵긴 했는데 간단히 설명하면 진화론 아이디어를 반영한 프로그래밍 방식이었다. 먼저 코드에서 가족군을 만들고 나올 수 있는 경우의 수를 모두 만들어낸다. 그리고 각 코드끼리 비교 평가를 수행하고 적자 선택을 반복적으로 수행해서 목적에 맞는 코드를 만드는 방식이라고. 평가와 선택이 적절하면 세대를 거듭할수록 목적에 맞는 코드가 나온다고 한다. 들으면서 하나도 모르겠는데 이게 엄청 멋진건 알겠더라. 이 프로그래밍으로 이용해서 전자기판 최적화를 하는 대회(Humis contest)가 있다고 한다.

이 제네틱 프로그래밍을 사용할 때는 함축적인 언어를 사용할수록 유용하다고 하며 brainfuck을 예로 들었던 것이 기억난다.

제네틱 프로그래밍은 gp-field-guide.org.uk가 가장 잘 정리되어 있다고 한다.

이 정반합 과정을 Python Bytecode를 사용할 수 있도록 만든 라이브러리 DEAP를 소개했다. 시간이 없어서 Bytecode 예제는 보여주지 못했다.

Security Starts With You: Social Engineering

Social Engineering으로 보안에 취약할 수 있는 정보를 빼내는 방법에 대해 설명했다. 흔히 알고 있는 Phishing 외에도 Tailgating(piggybanking), pretexting, Baiting 등 다양한 방식이 있다고 한다.

기억에 남는 부분은 서비스에서 알림과 같은 이메일을 보낼 때 되도록이면 링크를 넣지 않는 방식으로 하는 것이 보안성이 높다는 이야기였다. 물론 이메일을 받으면 보낸 사람이나 내용을 보면 이미 스팸같은 냄새가 나서 사람들이 안누르긴 하지만 내용이 정말 지능적으로 똑같이 만들어졌다면 평소와 같이 누르게 된다는 것이다. 애초에 메일에 디테일을 담지 않고 자세한 내용은 웹사이트에 로그인하면 확인할 수 있다는 식으로 이메일 자체에 내용을 최소화하는 접근 방식을 사용하면 사람들이 피싱 메일을 받아도 아무 링크나 눌러보지 않도록 하는데 도움을 많이 준다고 한다.

Behind Closed Doors: Managing Passwords in a Dangerous World

password는 신원 확인을 위한 것인데 어떤 방식으로 확인을 하는가, 그 확인을 위한 데이터를 어떻게 보관하는가에 대해서 이야기했다. 보안과 관련한 수많은 라이브러리를 설명해줬다. 점심 먹고 졸릴 시간인데다 라이브러리를 너무 많이 이야기해서 그냥 멍했다. 필요한 권한만 최소로 주라는 말이 기억난다.

CPython internals and the VM

Python을 사용하면서 내부는 어떻게 구현되어 있을까 궁금해서 CPython 코드를 열어봤다는 이야기로 시작해서 코드만 실컷 읽고 끝났다. 오픈소스는 구현이 궁금하면 열어볼 수 있다는 매력이 있다. 어떤 방식으로 돌아가나 그런거 보여줄 줄 알고 갔는데 코드만 읽음.

The dangerous, exquisite art of safely handing user-uploaded files

파일을 업로드 할 수 있는 상황에서 어떻게 올린 파일을 다룰 것인지에 대한 이야기. 파일명이나 파일 확장자는 절대 믿지 말고 고립된 환경에서 파일을 다룰 것에 대해 이야기했는데 PHP를 엄청 깠다. 그다지 새롭지 않았음.

Lightning Talks

5분 짜리 라이트닝 토크를 연속해서 진행했다. pyaudioquitnet을 사용해서 스피커로 데이터를 전송하고 마이크로 데이터를 전송 받는 데모도 재밌었다. 여성 스피커로만 진행하는 KatieConf 이야기도 재밌었다. (패러디지만.) 가장 기억에 남는 토크는 Falsehoods Programmers Have about Identity 였다. 이름, 성(Gender), 생일, 국적에 대해 정보를 받을 때 어떤 방식으로 다양성을 존중해야 하는 것인지에 대한 이야기를 했다. 누군가의 정체성을 부정하는 것은 인간임을 부정하는 것과 마찬가지라는 말이 기억에 남는다.

2일차

Python All the Things

파이썬을 사용해서 앱을 개발할 수 있는 PyBee 소개였다. 언어 구현과 레퍼런스 구현을 분리해서 설명했는데 Django와 같이 재단을 만들어서 운영하는 로드맵을 얘기해줬다. 아직 개발이 한창 진행중이라서 실제로 보여주는 부분은 많이 없었지만 그간 파이썬에서 약하다고 생각했던 부분이라 그런지 사람들 관심이 참 많았다.

Evaluating fire simulators using Docker, Dask and Jupyter notebooks!

호주 기상국(Australia Bureau of Meteorology)에서 발표한 세션인데 주제대로 산불 발생 시뮬레이터를 docker와 dask를 사용해서 구현하고 Jupyter로 결과를 가공하는 과정을 보여줬다. docker 때문에 들어갔는데 생각보다 별 내용 없이 개괄적인 이야기를 많이 했다.

Code the Docs: Interactive Document Environments

문서화에 관한 이야기라고 생각했는데 조금 달랐다. 스피커는 사실 Swift 책을 쓰는 사람이었고 Xcode Playground에서 인터렉티브한 개발 문서를 작성해 프로그래밍을 더 쉽게 이해하고 공부하는데 도움이 된다는 내용이었다. 문서와 코드를 오가면서 실제로 동작하는 코드를 확인하는 과정을 문서 내에서 바로 실행할 수 있는, 동적인 문서 환경을 jupyter와 Playground를 사용해서 보여줬다. Oreilly에서 jupyter를 이 용도로 수정해서 교육 프로그램을 운영하고 있다고 Regex golf를 보여줬는데 영상과 문서, 코드가 잘 동기화되어 있었다. 얘기로는 이런 방식의 미디어가 교육을 위한 책을 대체할 것이라고 했는데 교육을 위한 포맷이 다양해져야 한다는 점은 많이 공감했다.

Deciding between continuity and change in your Open Source community

지금까지 커뮤니티를 운영하고 컨퍼런스를 개최하며 겪은 이야기를 공유했다. 오고 가는 자원봉사자와 어떻게 함께하는지, 커뮤니티에서 어떤 부분을 지향해야 하는지, 커뮤니티에 관한 다양한 주제를 이야기했다. 이상한모임에 참여하면서 생각했던 많은 고민이 우리 만의 문제가 아니라 커뮤니티를 운영하면 누구나 고민하는 부분이구나 생각할 수 있었다. 자전거 그늘처럼 불필요한데 시간과 자원을 많이 소모하는 일을 최대한 피해야 한다는 이야기도 기억에 남는다.

Functional Programming for Pythonistas

루비 개발자인데 파이썬의 함수형 프로그래밍에 대해서 이야기했다. map과 같은 함수랑 lambda 설명하는 수준이었다.

The Evolution of Python Packaging at Facebook

페이스북에서 Python 패키지를 어떤 방식으로 배포하는지에 대한 설명이었다. 좀 더 특별한 방식을 사용할거라 생각했는데 ZIP 압축을 그대로 사용한다고 한다. ZIP을 에디터로 열어보면 PK라는 매직 키워드가 존재하는데 그 앞에 shell script를 넣어도 zip파일엔 영향이 없다고 한다. zip파일에 쉘 스크립트를 포함한 후에 쉘로 그 파일을 실행하면 압축을 해제하기 전에 해야 하는 환경 설정이나 라이브러리 처리를 한 다음 self extract 하는 방식으로 사용한다고 한다. 얼마 전 zip을 대체하는 라이브러리를 페이스북에서 발표했다는 소식이 있었는데 아마 이 환경에서 좀 더 편하게 쓸 수 있도록 개선한게 아닌가 싶다.

그 쉘 스크립트를 포함한 zip을 PAR이라 부르고 페이스북 내에서 사용하는 아카이빙 파일로 표준처럼 사용하고 있다고 한다. 배포해야 하는 서버가 많으니까 별도의 프로그램 없이 최소한의 자원을 써서 설치할 수 있는 환경을 만든 모양이다. 이렇게 만든 파일을 rsync하거나 torrent로 배포한다고. 페이스북이 종종 내려가는 이유가 여기에 있다고 하는데 서버에서 한 번 실행하는 함수라서 작은 환경에서 실행했을 때는 문제가 없다가도 수십 만 대 서버에 배포되면 순식간에 수십 만 번 실행하기 때문이라고 한다.

이 발표에서 페이스북의 문화를 중간 중간 이야기했는데 자신이 하고 싶은 일을 스스로 찾아서 재미있게 한다는 점이 참 인상적이고 부러웠다.

Predicting the TripleJ Hottest 100 With Python

이틀 꼬박 달리니까 좀 지쳐서 쉬는 마음으로 들어간 세션이었다. 모 사이트에서 음악 인기투표를 진행하는데 인기투표 후에 소셜로 공유하는 기능이 있어서 그 내용을 분석해 실제 발표 이전에 해당 발표를 예측하는 서비스를 만든 후기였다. 나는 처음 듣는 사이트였지만 그 순위 가지고 베팅도 하고 그렇다고. raw 데이터를 어떻게 가공하고 분석했는지 설명하는데 그냥 수작업으로 보였다.

Hitting the Wall and How to Get Up Again – Tackling Burnout and Strategies

왜 개발자는 번아웃 되고 힘들게 되는가에 대한 이야기였고 바쁘고 힘든 시기여서 그랬는지 많이 위로가 되었다. 힘든게 당연한 일이고 힘들면 꼭 심리치료사를 찾으라고.

Lightning Talks

역시나 다양하고 재미있는 이야기가 많았다.

  • How well do we (in this room) represent python programmers?: 다양성에 대해 이야기하지만 개발 환경의 다양성에 대한 논의는 적다고. Python을 사용하는 환경이 대부분 리눅스나 맥인 경우가 많은데 실제 학습 환경에서는 윈도를 더 많이 사용하고 있다는 이야기.
  • 5 Languages in 5 minutes: 5가지 언어를 보여줬는데 ARNOLDC, TRUMPSCRIPT, LOLCAT, BRAINF* 4가지와 직접 만든 ANGUISH를 보여줬다. ANGUISH는 공백 언어로 만든 프로그래밍 언어, 매우 안전(?).
  • Open source and social responsibility: 오픈소스에 사용된 코드 라인 수를 보여줬다. Ubuntu, nginx, postgreSQL, python의 코드를 합치면 72,258,475 라인이라고. 이런 코드를 사용하는데 있어서 좀 더 사회적인 책임 의식을 갖고 사용해야 하지 않을까에 대해 이야기했다. 무슨 사회적인 문제를 해결할 수 있을지 짚어 이야기하며 지금 세상을 바꾸라는 이야기로 마무리했다.

개발을 하다보면 눈 앞에 있는 일에 치여서 재미를 느끼지 못할 때가 있다. 세상은 넓고 멋진 사람들을 많은데 내 작업은 작게만 느껴지고 세상을 바꾼다는 포부와는 동떨어진 삶을 사는 기분이 들 때가 있다. 한동안 너무 바빠서 내가 개발을 얼마나 좋아하고 재미있어 하는지, 즐거워하는지 돌아볼 시간조차 없었다는 것을 파이콘에 참석해서야 알게 되었다. 다들 즐겁게, 자신이 하는 일을 이야기하고 공유하는 것을 보고 기분이 많이 좋아졌고 한편으로 위로도 되었다. 부지런히 배워서 컨퍼런스에서 만난 사람들처럼 누군가에게 좋은 영향력을 주는 사람이 되고 싶다.

IMG_1015

IMG_1018

IMG_1019

IMG_1020

IMG_1024

IMG_1022

IMG_1025

IMG_1031

IMG_1032

IMG_1034

IMG_1038

IMG_1057

IMG_1051

IMG_1056

IMG_1060

이상한모임에서 진행할 사이드 프로젝트에 Django를 사용하게 되었는데 제대로 살펴본 경험이 없어서 그런지 영 익숙해지질 않았다. 이전에 Django Girls 튜토리얼 – django로 블로그 만들기 포스트를 본 것이 생각나서 살펴보다가 튜토리얼까지 보게 되었다.

이 포스트는 장고걸스 서울에서 번역된 장고 걸스 튜토리얼을 따라 진행하며 내가 필요한 부분만 정리했기 때문에 빠진 내용이 많다. 튜토리얼은 더 자세하게 세세한 부분까지 설명이 되어 있으니 만약 Django를 학습하려 한다면 꼭 튜토리얼을 살펴볼 것을 추천한다.


Django 및 환경 설정하기

$ python3 -m venv myvenv
$ source myvenv/bin/activate
$ pip install django==1.8

프로젝트 시작하기

django-admin startproject mysite .

생성된 프로젝트 구조는 다음과 같다.

djangogirls
├───manage.py # 사이트 관리 도구
└───mysite
        settings.py  # 웹사이트 설정
        urls.py      # 라우팅, `urlresolver`를 위한 패턴 목록
        wsgi.py
        __init__.py

기본 설정하기

settings.py를 열어서 TIME_ZONE을 수정한다.

TIME_ZONE = 'Asia/Seoul'

정적 파일 경로를 추가하기 위해 파일 끝에 다음 내용을 추가한다.

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')

데이터베이스는 기본값으로 sqlite3이 설정되어 있다. 데이터베이스 생성은 manage.py를 활용한다.

$ python manage.py migrate

서버를 실행해서 확인한다.

$ python manage.py runserver 0:8000

어플리케이션 생성하기

$ python manage.py startapp blog

mysite/settings.py를 열어 INSTALLED_APPS에 방금 생성한 어플리케이션을 추가한다.

INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'blog',
)

블로그 글 모델 만들기

from django.db import models
from django.utils import timezone


class Post(models.Model):
    author = models.ForeignKey('auth.User')
    title = models.CharField(max_length=200)
    text = models.TextField()
    created_date = models.DateTimeField(
            default=timezone.now)
    published_date = models.DateTimeField(
            blank=True, null=True)

    def publish(self):
        self.published_date = timezone.now()
        self.save()

    def __str__(self):
        return self.title

여기서 사용할 수 있는 필드는 모델 필드 레퍼런스를 참조한다.

생성한 모델을 사용하기 위해 마이그레이션 파일을 생성하고 마이그레이션을 수행한다.

$ python manage.py makemigrations blog
$ python manage.py migrate blog

Django 관리자 사용하기

새로 추가한 모델을 관리자 패널에서 접근하기 위해서 blog/admin.py에 다음 코드를 추가한다.

from django.contrib import admin
from .models import Post

admin.site.register(Post)

관리자 패널에 로그인하기 위한 아이디를 생성한다. 프롬프트에 따라 생성한 후 웹서버를 실행해서 관리자 패널(http://127.0.0.1:8000/admin/)에 접속한다.

$ python manage.py createsuperuser
$ python manage.py runserver 0:8000

테스트를 위해 Posts에 글을 추가한다.

Git 설정하기

리포지터리를 초기화하고 커밋한다. 튜토리얼은 GitHub 사용하는 방법이 설명되어 있다. .gitignore은 아래처럼 작성할 수 있다.

*.pyc
__pycache__
myvenv
db.sqlite3
.DS_Store

PythonAnywhere 설정하기

PythonAnywhere는 Python을 올려 사용할 수 있는 PaaS 서비스로 개발에 필요한 다양한 서비스를 제공한다.

먼저 서비스에 가입해서 콘솔에 접속한다. GitHub에 올린 코드를 clone한 다음 가상환경을 설치하고 진행한다.

$ git clone https://github.com/<your-github-username>/my-first-blog.git
$ cd my-first-blog
$ virtualenv --python=python3.4 myvenv
$ source myvenv/bin/activate
$ pip install django whitenoise

whitenoise는 정적 파일을 CDN처럼 사용할 수 있도록 돕는 패키지다. 먼저 django의 관리자 도구로 모든 패키지에 포함된 정적 파일을 수집한다.

$ python manage.py collectstatic

데이터베이스와 관리자를 생성한다.

$ python manage.py migrate
$ python manage.py createsuperuser

이제 PythonAnywhere 대시보드에서 web app을 추가하고 manual configuration을 python3.4로 설정한다. 가상환경 경로는 앞서 생성했던 /home/<your-username>/my-first-blog/myvenv/로 입력한다.

WSGI 프로토콜도 사용할 수 있는데 Web > Code 섹션을 보면 WSGI 설정 파일 경로가 있다. 경로를 클릭해서 다음처럼 내용을 변경한다.

import os
import sys

path = '/home/<your-username>/my-first-blog'
if path not in sys.path:
    sys.path.append(path)

os.environ['DJANGO_SETTINGS_MODULE'] = 'mysite.settings'

from django.core.wsgi import get_wsgi_application
from whitenoise.django import DjangoWhiteNoise
application = DjangoWhiteNoise(get_wsgi_application())

URL 작성하기

django는 URL을 위해 정규표현식을 사용한다. 앞서 생성한 blog를 mysite로 다음처럼 불러온다.

from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'', include('blog.urls')),
]

blog/urls.py를 추가하고 다음 내용을 추가한다.

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^$', views.post_list, name='post_list'),
]

View 작성하기

blog/views.py를 열고 post_list를 생성한다.

from django.shortcuts import render

def post_list(request):
    return render(request, 'blog/post_list.html', {})

템플릿 작성하기

blog/templates/blog 밑에 post_list.html을 생성한다. 디렉토리 구조에 유의한다.

Django ORM과 QuerySets

Django에서의 모델 객체 목록을 QuerySets이라 하며 데이터를 정렬하거나 처리할 때 사용한다.

콘솔에서 다음 명령어로 쉘에 접근한다.

$ python manage.py shell

모델은 먼저 불러온 다음에 쿼리셋을 사용할 수 있다.

>>> from blog.models import Post
>>> Post.objects.all()
[<Post: Hello World>, <Post: Koala>]

>>> from django.contrib.auth.models import User
>>> User.objects.all()
[<User: edward>]
>>> me = User.objects.get(username='edward')

>>> Post.objects.create(author=me, title='Goodbye my friend', text='bye')
<Post: Goodbye my friend>
>>> Post.objects.all()
[<Post: Hello World>, <Post: Koala>, <Post: Goodbye my friend>]

쿼리셋을 다음 방법으로 필터링 할 수 있다.

>>> Post.objects.filter(author=me)
[<Post: Hello World>, <Post: Koala>, <Post: Goodbye my friend>]
>>> Post.objects.filter(title__contains='Koala')
[<Post: Koala>]

>>> from django.utils import timezone
>>> Post.objects.filter(published_date__lte=timezone.now())
[]
>>> post = Post.objects.get(title__contains="Goodbye")
>>> post.publish()
>>> Post.objects.filter(published_date__lte=timezone.now())
[<Post: Goodbye my friend>]

정렬도 가능하며 체이닝으로 한번에 호출하는 것도 가능하다.

>>> Post.objects.order_by('created_date')
[<Post: Hello World>, <Post: Koala>, <Post: Goodbye my friend>]
>>> Post.objects.order_by('-created_date')
[<Post: Goodbye my friend>, <Post: Koala>, <Post: Hello World>]
>>> Post.objects.filter(published_date__lte=timezone.now()).order_by('published_date')

템플릿에서 동적 데이터 활용하기

blog/views.py를 수정한다.

from django.shortcuts import render
from django.utils import timezone
from .models import Post


def post_list(request):
    posts = Post.objects.filter(published_date__lte=timezone.now()).order_by('published_date')
    return render(request, 'blog/post_list.html', {'post': posts})

blog/templates/blog/post_list.html을 수정한다.

<div>
    <h1><a href="/">Django Girls Blog</a></h1>
</div>

{% for post in posts %}
    <div>
        <p>published: {{ post.published_date }}</p>
        <h1><a href="">{{ post.title }}</a></h1>
        <p>{{ post.text|linebreaks }}</p>
    </div>
{% endfor %}

CSS 추가하기

정적 파일은 blog/static 폴더에 넣는다. blog/static/css/blog.css를 작성한다.

h1 a {
    color: #FCA205;
}

템플릿에 적용한다.

{% load staticfiles %}
<!doctype html>
<html lang="ko">
<head>
    <meta charset="utf-8">
    <title>Django Girls Blog</title>
    <link rel="stylesheet" href="{% static 'css/blog.css' %}">
</head>
<body>
  <div>
      <h1><a href="/">Django Girls Blog</a></h1>
  </div>

  {% for post in posts %}
      <div>
          <p>published: {{ post.published_date }}</p>
          <h1><a href="">{{ post.title }}</a></h1>
          <p>{{ post.text|linebreaks }}</p>
      </div>
  {% endfor %}
</body>
</html>

템플릿 확장하기

base.html을 다음과 같이 작성한다.

{% load staticfiles %}
<!doctype html>
<html lang="ko">
<head>
    <meta charset="utf-8">
    <title>Django Girls Blog</title>
    <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
    <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
    <link rel="stylesheet" href="{% static 'css/blog.css' %}">
</head>
<body>
    <div class="page-header">
        <h1><a href="/">Django Girls Blog</a></h1>
    </div>
    <div class="content container">
        <div class="row">
            <div class="col-md-8">
            {% block content %}
            {% endblock %}
            </div>
        </div>
    </div>
</body>
</html>

이제 post_list.html에서는 위 base 파일을 불러오고 content 블럭 안에 내용을 넣을 수 있다.

{% extends 'blog/base.html' %}

{% block content %}
    {% for post in posts %}
        <div class="post">
            <div class="date">
                {{ post.published_date }}
            </div>
            <h1><a href="">{{ post.title }}</a></h1>
            <p>{{ post.text|linebreaks }}</p>
        </div>
    {% endfor %}
{% endblock content %}

post detail 페이지 만들기

이제 블로그 포스트를 볼 수 있는 페이지를 만든다. blog/templates/blog/post_list.html의 링크를 수정한다.

<h1><a href="{% url 'post_detail' pk=post.pk %}">{{ post.title }}</a></h1>

blog/urls.py에 post_detail을 추가한다. 뒤에 입력하는 내용을 모두 pk 변수에 저장한다는 의미다. pk는 primary key를 뜻한다.

from django.conf.urls import url
from . import views

urlpatterns = [
  url(r'^$', views.post_list, name='post_list'),
  url(r'^post/(?P<pk>[0-9]+)/$', views.post_detail, name='post_detail'),
]

blog/views.py에 post_detail을 추가한다. 만약 키가 존재하지 않는다면 404 Not Found 페이지로 넘겨야 하는데 get_object_or_404 함수를 사용할 수 있다.

from django.shortcuts import render, get_object_or_404

# ...

def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk)
    return render(request, 'blog/post_detail.html', {'post': post})

이제 blog/templates/blog/post_detail.html을 생성한다.

{% extends 'blog/base.html' %}

{% block content %}
    <div class="post">
        {% if post.published_date %}
            <div class="date">
                {{ post.published_date }}
            </div>
        {% endif %}
        <h1>{{ post.title }}</h1>
        <p>{{ post.text|linebreaks }}</p>
    </div>
{% endblock %}

모델 폼 사용하기

Django는 간단하게 폼을 생성할 수 있는 기능을 제공한다. blog/forms.py를 생성한다.

from django import forms

from .models import Post


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ('title', 'text',)

blog/templates/blog/base.html에 이 폼에 접근하기 위한 링크를 추가한다.

<a href="{% url 'post_new' %}" class="top-menu"><span class="glyphicon glyphicon-plus"></span></a>

blog/urls.py에 규칙을 추가한다.

    url(r'^post/new/$', views.post_new, name='post_new'),

blog/views.py에 form과 post_new 뷰를 추가한다.

from django.shortcuts import redirect
from .forms import PostForm

#...

def post_new(request):
    if request.method == "POST":
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.published_date = timezone.now()
            post.save()
            return redirect('blog.views.post_detail', pk=post.pk)
    else:
        form = PostForm()
    return render(request, 'blog/post_edit.html', {'form': form})

blog/templates/blog/post_edit.html을 추가한다.

{% extends 'blog/base.html' %}

{% block content %}
    <h1>New post</h1>
    <form method="POST" class="post-form">{% csrf_token %}
        {{ form.as_p }}
        <button type="submit" class="save btn btn-default">Save</button>
    </form>
{% endblock %}

수정 페이지 추가하기

blog/templates/blog/post_detail.html에 다음 링크를 추가한다.

<a class="btn btn-default" href="{% url 'post_edit' pk=post.pk %}"><span class="glyphicon glyphicon-pencil"></span></a>

blog/urls.py에 다음 코드를 추가한다.

    url(r'^post/(?P<pk>[0-9]+)/edit/$', views.post_edit, name='post_edit'),

blog/views.py에 post_edit 뷰를 추가한다.

def post_edit(request, pk):
    post = get_object_or_404(Post, pk=pk)
    if request.method == "POST":
        form = PostForm(request.POST, instance=post)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.published_date = timezone.now()
            post.save()
            return redirect('blog.views.post_detail', pk=pk)
    else:
        form = PostForm(instance=post)
    return render(request, 'blog/post_edit.html', {'form': form})

PostForm()에서 instance를 추가하는 것으로 내용을 미리 초기화한다.

보안

템플릿에서 사용자 권한이 있는 경우만 확인할 수 있도록 작성할 수 있다. blog/templates/blog/base.html에서 새 포스트 링크를 다음과 같이 변경한다.

{% if user.is_authenticated %}
    <a href="{% url 'post_new' %}" class="top-menu"><span class="glyphicon glyphicon-plus"></span></a>
{% endif %}

이 튜토리얼 뒤엔 보안을 강화하고 새로운 기능을 추가하는 등의 심화편이 있다. Django Girls Tutorial: Extensions에서 그 내용을 볼 수 있다.

WSGI는 Web Server Gateway Interface의 약어로 웹서버와 웹어플리케이션이 어떤 방식으로 통신하는가에 관한 인터페이스를 의미한다. 웹서버와 웹어플리케이션 간의 소통을 정의해 어플리케이션과 서버가 독립적으로 운영될 수 있게 돕는다. WSGI는 파이썬 표준인 PEP333, PEP3333에 의해 제안되었고, 이 이후에 여러 언어로 구현된 프로젝트가 생겨나기 시작했다.

WSGI 어플리케이션은 uWSGI라는 컨테이너에 담아 어플리케이션을 실행하게 되며, uWSGI가 각각의 웹서버와 소통하도록 설정하면 끝이다. Flask, django와 같은 프레임워크는 이미 WSGI 표준을 따르고 있기 때문에 바로 웹서버에 연결해 사용할 수 있다.

이 글에서는 Flask를 nginx에 연결하는 방법을 설명한다. 이 글은 기록용이라 상당히 불친절하기 때문에 관련된 내용에 관심이 있다면 다음 페이지들을 참고하자.

uWSGI 설치하기

먼저 uwsgi가 설치되어 있는지 확인한다. 현재 데비안 기반(우분투 등)의 환경이라면 uwsgi가 이미 설치되어 있는데 고대의 버전이라 기존 설치본을 삭제하든 변경하든 해서 새 버전으로 설치해줘야 한다.

$ mv /usr/bin/uwsgi /usr/bin/uwsgi-old

그리고 uwsgi를 설치해준다.

$ pip install uwsgi
$ ln -s /usr/local/bin/uwsgi /usr/bin/uwsgi

이제 uwsgi로 해당 어플리케이션을 실행한다.

$ uwsgi -s /tmp/uwsgi.sock --module yourapplication --callable app --venv .venv

--socket, -s는 통신을 위한 소켓, --venv, -H는 virtualenv 경로, --module은 어플리케이션, --callable은 WSGI의 시작점을 설정해주는 파라미터이다. 내 경우에는 파일명이 이상(?)해서인지 다음의 방식으로 실행했다.

$ uwsgi -s /tmp/uwsgi.sock --wsgi-file app.py --callable app -H .venv

파라미터를 매번 입력하면 번거로우므로 다음과 같이 ini 파일을 작성해 저장해놓고 실행해도 된다.

[uwsgi]
chdir=/home/ubuntu/helloWorld
chmod-socket=666
callable=app
module=app
socket=/tmp/uwsgi.sock
virtualenv=/home/ubuntu/helloWorld/.venv

이렇게 ini파일로 저장한 후 다음 명령어로 실행한다.

$ uwsgi <filename> &

nginx 설치, 설정하기

nginx를 apt-get 등을 통해 설치한다.

$ apt-get install nginx-full

/etc/nginx/sites-available/default 파일을 열어 설정을 해준다.

server {
        listen   8080;

        server_name helloworld.haruair.com;

        location / {
                try_files $uri @helloworld;
        }

        location @helloworld {
                include uwsgi_params;
                uwsgi_pass unix:/tmp/uwsgi.sock;
        }
}

위 설정을 통해 nginx로 들어오는 모든 요청을 uWSGI로 보내고 또 돌려받아 nginx를 통해 클라이언트에 전달하게 된다. nginx를 재구동하면 적용된다.

/etc/init.d/nginx restart

사실 Flask에서의 nginx 설치 문서에 있는 글로도 충분한데 명령어가 계속 에러를 내는 탓에 한참 검색하게 되었다. 문제는 uwsgi의 낮은 버전이었고, 앞서 언급한 바와 같이 최신 버전으로 설치하면 해결된다.

PyPy는 들을 때마다 호기심을 자극하는 프로젝트 중 하나인데 Python으로 Python을 작성한다는 간단히 이해하기 힘든 방식(?)의 프로젝트다. 최근들어 긴 인고의 노력 끝에 좋은 결실을 맺고 있다는 소식도 들려오고 있어서 관심을 가지고 찾아보게 되었다. 여러 글을 읽어봤지만 PyPy 공식 블로그에 올라와 있던 이 포스트가 왠지 와닿아 서둘러 번역했다.

여전히 의역도, 엉터리도 많은 발번역이지만 PyPy가 어떤 이유로 CPython보다 빠르게 동작하는지에 대한 이해에 조금이나마 도움이 되었으면 좋겠다.

For English, I translated the article to Korean. So you can see the original english article: http://morepypy.blogspot.com.au/2011/04/tutorial-writing-interpreter-with-pypy.html


PyPy와 함께 인터프리터 작성하기

Andrew Brown brownan@gmail.com가 작성했으며 pypy-dev 메일링 리스트의 PyPy 개발자들로부터 도움을 받았다.

이 튜토리얼의 원본과 파일은 다음 리포지터리에서 확인할 수 있다: https://bitbucket.org/brownan/pypy-tutorial/

내가 PyPy 프로젝트에 대해 처음으로 배웠을 때, 한동안은 정확히 어떤 프로젝트인지 살펴보는데 시간을 썼다. 그전까지 알지 못했던 것은 다음 두가지였다:

  • 인터프리트 될 언어를 위한 인터프린터 구현을 위한 도구 모음
  • 이 툴체인을 이용한 파이썬 구현

대부분의 사람들이 두번째를 PyPy라고 생각한다. 하지만 이 튜토리얼은 파이썬 인터프리터에 대한 설명이 아니다. 이 글은 당신이 만들 언어를 위한 인터프리터를 작성하는 방법을 다루는 튜토리얼이다.

다시 말해 이 글은 PyPy를 깊게 이해하기 위한 방법으로, PyPy가 무엇에 관한 것인지, 어떻게 구현되고 있는지를 살펴보는데 목적을 둔 튜토리얼이다.

이 튜토리얼은 당신이 PyPy에 대해 어떻게 동작하는지 아주 조금 알고 있다고 가정하고 있다. (그게 PyPy의 전부 일지도 모른다.) 나 또한 초심자와 같은 시각으로 접근할 것이다.

PyPy는 무엇을 하는가

PyPy가 어떤 역할을 하는지에 관한 개론이다.지금 인터프리터 언어를 작성하고 싶다고 가정해보자. 이 과정은 일종의 소스 코드 파서와 바이트코드 통역 루프, 그리고 엄청나게 많은 양의 표준 라이브러리 코드를 작성해야 한다.

적절하게 완전한 언어를 위한 약간의 작업이 필요하며 동시에 수많은 저수준의 일들이 뒤따르게 된다. 파서와 컴파일러 코드를 작성하는건 일반적으로 안재밌고, 이것 바로 파서와 컴파일을 만들다가 집어 치우게 되는 이유다.

그런 후에도, 당신은 여전히 인터프리터를 위한 메모리 관리에 대해 반드시 걱정해야 하며, 임의 정밀도 정수, 좋고 편리한 해시 테이블 등의 데이터 타입 같은걸 원한다면 수많은 코드를 다시 구현해야 한다. 이와 같은 작업들은 그들 스스로의 언어를 구현하겠다는 아이디어를 그만 두기에 충분한 일이다.

만약 당신의 언어를 이미 존재하는 고수준의 언어, 가령 파이썬을 사용해서 이 작업을 한다면 어떨까? 메모리 관리나 풍부한 데이터 타입을 원하는대로 자유롭게 쓸 수 있는 등, 고수준 언어의 모든 장점을 얻을 수 있기 때문에 이상적인 선택일 수 있다. 아, 물론 인터프리터 언어를 다른 인터프리터 언어로 구현한다면 분명 느릴 것이다. 코드를 이용할 때 통역(interpreting)이 두번 필요하기 때문이다.

당신이 추측할 수 있는 것과 같이, PyPy는 위와 같은 문제를 해결했다. PyPy는 똑똑한 툴체인으로 인터프리터 코드를 분석하고 C코드(또는 JVM, CLI)로 번역한다. 이 과정을 “번역(translation)”이라 하며 이 과정은 수많은 파이썬 문법과 표준 라이브러리를 (전부는 아니지만) 번역하는 방법을 의미한다. 당신이 해야 할 일은 만들고자 하는 인터프리터를 RPython으로 작성하는 것이다. RPython은 파이썬의 위에서 이야기한 분석과 번역을 위해 주의깊게 작성된, 파이썬의 하위 언어이며, PyPy는 아주 유능한 인터프리터다. 유능한 인터프리터는 코드를 작성하는데 있어서 어렵지 않게 도와준다.

언어

내가 구현하고자 하는 언어는 초 단순하다. 언어 런타임은 테잎의 숫자, 초기화를 위한 0, 싱글 포인터를 위한 하나의 테잎 셀들로 구성되어 있다. 언어는 8개의 커맨드를 지원한다 :

: 테잎의 포인터를 오른쪽으로 한 칸 이동

< : 테잎의 포인터를 왼쪽으로 한 칸 이동

: 포인터가 가리키는 셀의 값을 증가

– : 포인터가 가리키는 셀의 값을 감소

[ : 만약 현재 포인터의 셀이 0이면 ]를 만나기 전까지의 명령을 건너뜀

] : 이 이전부터 [ 까지의 내용을 건너 뜀 (이것의 상태를 평가)

. : 포인트가 가리키고 있는 셀의 싱글 바이트를 stdout으로 출력

, : 싱글 바이트를 stdin에서 입력받아 포인트가 가리키는 셀에 저장

인지할 수 없는 모든 바이트들은 무시한다.

위 내용을 통해 이 언어를 알 수 있을 것이다. 이것을 BF 언어라고 명명하자.

내가 알아차린 하나는 이 언어는 그 스스로의 바이트코드이기 때문에 소스코드로부터 바이트코드로 번역하는 과정이 없다. 그 의미는 이 언어가 직접적으로 통역될 수 있다는 뜻이며, 우리 인터프리터의 메인 실행 루프가 소스코드를 바로 실행하게 된다. 이런 방법은 구현을 좀 더 쉽게 만든다.

첫걸음

BF 인터프리터를 평범하고 오래된 파이썬 언어로 작성해보자. 첫걸음은 실행 루프를 작성해본다:

def mainloop(program):
    tape = Tape()
    pc = 0
    while pc < len(program):
        code = program[pc]

        if code == ">":
            tape.advance()
        elif code == "<":
            tape.devance()
        elif code == "+":
            tape.inc()
        elif code == "-":
            tape.dec()
        elif code == ".":
            sys.stdout.write(chr(tape.get()))
        elif code == ",":
            tape.set(ord(sys.stdin.read(1)))
        elif code == "[" and value() == 0:
            # Skip forward to the matching ]
        elif code == "]" and value() != 0:
            # Skip back to the matching [

        pc += 1

위에서 볼 수 있는 것처럼, 프로그램 카운터(pc)가 현재 명령 인덱스를 담고 있다. 위 루프에서 명령문을 실행하기 위해 명령문에서 첫 명령을 얻은 후, 명령문을 어떻게 실행할지 결정하게 되면 실행하게 된다.

[]의 구현은 아직 남겨져 있는 상태다. 이 구현은 프로그램 카운터로 대괄호에 맞는지 값을 비교하는 것으로 변경되어야 한다. (그리고 pc는 증가하게 된다. 그래서 루프에 진입할 때 한번 평가하고 각 루프의 종료에서 평가한다.)

다음은 Tape 클래스의 구현으로, 테잎의 값을 테잎 포인터처럼 담고 있다:

class Tape(object):
    def __init__(self):
        self.thetape = [0]
        self.position = 0

    def get(self):
        return self.thetape[self.position]
    def set(self, val):
        self.thetape[self.position] = val
    def inc(self):
        self.thetape[self.position] += 1
    def dec(self):
        self.thetape[self.position] -= 1
    def advance(self):
        self.position += 1
        if len(self.thetape) <= self.position:
            self.thetape.append(0)
    def devance(self):
        self.position -= 1

위에서 보듯, 테잎은 필요한 만큼 오른쪽으로 확장하게 된다. 하지만 이런 방식은 다소 불명확하므로 포인터가 잘못된 값을 가리키지 않게 확신할 수 있도록 에러를 확인하는 코드를 추가해줘야 한다. 지금은 걱정하지 말고 그냥 두자.

[] 구현을 제외하고서 이 코드는 정상적으로 동작한다. 만약 프로그램에 주석이 많이 있다면, 그 주석을 실행하는 동안에 하나씩 건너 뛰어야만 한다. 그러므로 먼저 이 모든 주석을 한번에 제거하도록 하자.

그와 동시에 대괄호 사이를 딕셔너리로 만들어, 대괄호 짝을 찾는 작업 대신 딕셔너리 하나를 살펴보는 작업으로 처리하게 만든다. 다음과 같은 방법으로 한다:

def parse(program):
    parsed = []
    bracket_map = {}
    leftstack = []

    pc = 0
    for char in program:
        if char in ('[', ']', '<', '>', '+', '-', ',', '.'):
            parsed.append(char)

            if char == '[':
                leftstack.append(pc)
            elif char == ']':
                left = leftstack.pop()
                right = pc
                bracket_map[left] = right
                bracket_map[right] = left
            pc += 1

    return "".join(parsed), bracket_map

이 함수는 실행에 필요 없는 코드를 제거한 문자열을 반환하고, 또한 대괄호의 열고 닫는 위치를 저장한 딕셔너리를 반환한다.

이제 우리에게 필요한 것은 위의 내용을 연결하는 코드이다. 이제 동작하는 BF 인터프리터를 가지게 되었다:

def run(input):
    program, map = parse(input.read())
    mainloop(program, map)

if __name__ == "__main__":
    import sys
    run(open(sys.argv[1], 'r'))

혼자 집에서 따라하고 있다면, mainloop()의 서명을 변경해야 하며 if 명령문을 위한 대괄호 브랜치 구현이 필요하다. 이 구현은 다음 예에서 확인할 수 있다: example1.py

이 시점에서 파이썬 아래에서 이 인터프리터를 구동하는게 정상적으로 동작하는지 실행해볼 수 있다. 그러나 미리 경고하는데, 이것은 엄청 느리게 동작하는 것을 다음 예에서 확인할 수 있다:

$ python example1.py 99bottles.b

mandel.b와 여러 예제 프로그램들을 내 리포지터리에서 확인 할 수 있다. (내가 작성하지는 않았다.)

PyPy 번역

하지만 이 글은 BF 인터프리터를 작성하는 것에 관한 이야기가 아니라 PyPy에 대한 글이다. 그러니까, 어떻게 PyPy로 번역이 되는 것은 엄청 빠르게 실행이 되는 것일까?

참고삼아 이야기하면, PyPy 소스 트리에서 pypy/translator/goal 디렉토리에 도움이 될 만한 간단한 예제들이 있다. 학습을 위한 첫 시작점은 targetnopstandalone.py 예제이며 이 코드는 PyPy를 위한 간단한 hello world 코드다.

예를 들어, 모듈은 필수적으로 target이라는 함수를 정의해 시작점을 반환하도록 해야 한다. 번역은 모듈을 불러오고 target이라는 이름을 확인하고, 호출하며, 함수 객체가 번역의 시작점이 어디인지를 반환하는 과정을 통해 진행된다.

def run(fp):
    program_contents = ""
    while True:
        read = os.read(fp, 4096)
        if len(read) == 0:
            break
        program_contents += read
    os.close(fp)
    program, bm = parse(program_contents)
    mainloop(program, bm)

def entry_point(argv):
    try:
        filename = argv[1]
    except IndexError:
        print "You must supply a filename"
        return 1

    run(os.open(filename, os.O_RDONLY, 0777))
    return 0

def target(*args):
    return entry_point, None

if __name__ == "__main__":
    entry_point(sys.argv)

entry_point 함수는 최종 결과물을 실행할 때 커맨드 라인의 아규먼트를 넘겨준다.

여기서 몇가지 내용을 더 변경해야 하는데 다음 섹션을 살펴보자.

RPython에 대하여

이 시점에서 RPython에 대해 이야기해보자. PyPy는 아무 파이썬 코드나 번역할 수는 없다. 파이썬은 동적 타입 언어이기 때문이다. 그래서 표준 라이브러리 함수와 문법 구조에 대한 제약을 통해야만 사용할 수 있다. 여기서 모든 제약 사항을 다루진 않을 것이며 더 많은 정보를 알고 싶다면 다음 페이지를 확인하도록 하자. http://readthedocs.org/docs/pypy/en/latest/coding-guide.html#restricted-python

위에서 본 예에서 몇가지 변경된 점을 확인할 수 있을 것이다. 이제 파일 객체 대신에 os.open과 os.read를 활용한 저레벨의 파일 디스크립터(descriptor)를 사용하려고 한다. .,의 구현은 위에서 살펴본 방식과 다르게 약간 꼬아야 한다. 이 부분이 코드에서 변경해야 하는 유일한 부분이며 나머지는 PyPy를 소화하기 위해 살펴 볼 간단한 부분들이다.

그렇게 어렵진 않다. 그러지 않나? 난 여전히 딕셔너리와 확장 가능한 리스트, 몇 클래스와 객체를 사용할 뿐이다. 또 로우 레벨 파일 디스크립터가 너무 저수준이라 생각되면 PyPy의 _RPython 표준 라이브러리_에 포함되어 있는 rlib.streamio 라는 유용한 추상 클래스가 도움이 된다.

위 내용을 진행한 예는 example2.py 에서 확인할 수 있다.

번역하기

PyPy를 가지고 있지 않다면, bitbucket.org 리포지터리에서 PyPy 최신 버전을 받기 바란다:

$ hg clone https://bitbucket.org/pypy/pypy

(최근 리비전이 필요한데 몇 버그픽스가 있어야만 예제가 동작하기 때문이다)

“pypy/translator/goal/translate.py” 스크립트를 실행한다. 이 스크립트를 실행하면 예제 모듈을 아규먼트로 넣어 실행하면 된다.

$ python ./pypy/rpython/bin/rpython example2.py

(엄청난 속도가 필요하다면 역시 PyPy의 파이썬 인터프리터를 사용하면 되지만 이 일에는 딱히 필요 없다)

PyPy는 맷돌처럼 소스를 갈아내고, 갈아낼 동안 멋져보이는 프랙탈을 사용자의 콘솔에 보여준다. 이 작업은 내 컴퓨터에서 20초 정도 걸렸다.

이 작업의 결과로 BF 인터프리터 프로그램이 실행 가능한 바이너리로 나왔다. 내 리포지터리에 포함된 몇 BF 프로그램 예제를 구동해보면, 예를 들면 mandelbrot 프랙탈 생성기는 내 컴퓨터에서 실행하는데 45초가 걸렸다. 한번 직접 해보자:

$ ./example2-c mandel.b

비교를 위해 번역되지 않은 생 파이썬 인터프리터를 실행해보자:

$ python example2.py mandel.b

직접 해보면 영원히 걸릴 것만 같다.

결국 당신은 해냈다. 우리는 성공적으로 RPython으로 작성된 우리 언어의 인터프리터를 가지게 되었고 PyPy 툴체인을 이용해 번역한 결과물을 얻게 되었다.

JIT 추가하기

RPython에서 C로 번역하는건 정말 쿨하지 않나? 그것 말고도 PyPy의 뛰어난 기능 중 하나는 _지금 만든 인터프리터를 위한 just-in-time 컴파일러를 생성_하는 능력이다. PyPy는 단지 인터프리터가 어떤 구조를 가지고 있는가에 대한 몇가지 힌트를 통해 JIT 컴파일러를 포함해서 생성한다. 이 기능은 실행 시점에 번역될 코드인 BF 언어를 기계어로 번역해주는 일을 해준다.

그래서 이런 일이 제대로 동작하도록 PyPy에게 무엇을 알려줘야 하는걸까? 먼저 바이트코드 실행 루프의 시작점이 어디인지 가르쳐줘야 한다. 이 작업은 목표 언어(여기에서는 BF)에서 명령이 실행되는 동안 계속 추적해갈 수 있도록 돕는다.

또한 개개의 실행 프레임을 정의해 알려줘야 한다. 여기서 만든 언어가 실제로 쌓이는 프레임을 가지고 있지 않지만, 무엇이 각각의 명령어를 실행하는데 변하거나 변하지 않는지에 대해 정리해줘야 한다. 각각 이 역할을 하는 변수를 green, red 변수라고 부른다.

example2.py 코드를 참조하며 다음 이야기를 계속 보자.

주요 루프를 살펴보면, 4개의 변수를 사용하고 있다: pc, program, bracket_map, 그리고 tape. 물론 pc, program, 그리고 bracket_map은 모두 green 변수다. 이 변수들은 개개의 명령을 실행하기 위해 _정의_되어 있다. JIT의 루틴에서 green 변수로서 이전에 확인했던 동일 조합이라면, 건너 뛰어야 하는 부분인지 필수적으로 루프를 실행해야 하는지 알게 된다. 변수 tape은 red 변수인데 실행하게 될 때 처리가 되는 변수다.

PyPy에게 이런 정보를 알려줘보자. JitDriver 클래스를 불러오고 인스턴스를 생성한다:

from rpython.rlib.jit import JitDriver
jitdriver = JitDriver(greens=['pc', 'program', 'bracket_map'],
        reds=['tape'])

그리고 우리는 메인 루프 함수의 최상단에 있는 while 루프에 다음 줄을 추가한다:

jitdriver.jit_merge_point(pc=pc, tape=tape, program=program,
        bracket_map=bracket_map)

또한 JitPolicy를 선언해야 한다. 이 부분에서 특별한 점은 없어서 다음 내용을 파일 어딘가에 넣어주기만 하면 된다:

def jitpolicy(driver):
    from rpython.jit.codewriter.policy import JitPolicy
    return JitPolicy()

example3.py 예제를 확인하자.

이제 번역을 다시 하는데 --opt=jit 옵션을 포함하고서 번역하자:

$ python ./pypy/rpython/bin/rpython --opt=jit example3.py

이 명령은 JIT을 활성화한 번역이기 때문에 엄청나게 긴 시간이 걸리는데 내 컴퓨터에서는 거의 8분이 걸렸고 이전보다 좀 더 큰 결과 바이너리가 나올 것이다. 이 작업이 끝나면, mandelbrot 프로그램을 다시 돌려보자. 이전에 45초가 걸렸던 작업이 12초로 줄어들었다!

충분히 흥미롭게도, 기계어로 번역하는 인터프리터가 JIT 컴파일러를 켜는 순간 얼마나 빠르게 mandelbrot 예제를 처리하는지 직접 확인했다. 첫 몇 줄의 출력은 그럭저럭 빠르고, 그 이후 프로그램은 가속이 붙어서 더욱 빠른 결과를 얻게 된다.

JIT 컴파일러 추적에 대해 조금 더 알아보기

이 시점에서 JIT 컴파일러가 추적을 어떻게 하는지를 더 알아두는 것이 좋다. 이것이 더 명확한 설명이다: 인터프리터는 일반적으로 작성한 바와 같이 인터프리터 코드로 동작한다. 목표 언어(BF)가 실행 될 때, 반복적으로 동작하는 코드를 인지하게 되면 그 코드는 “뜨거운” 것으로 간주되고 추적을 위해 표시를 해둔다. 다음에 해당 루프에 진입을 하면, 인터프리터는 추적 모드로 바꿔 실행했던 모든 명령 기록에서 해당 루프를 찾아낸다.

루프가 종료될 때, 추적도 종료한다. 추적한 루프는 최적화 도구(optimizer)로 보내지게 되며 어셈블러 즉, 기계어로 출력물을 만든다. 기계어는 연달아 일어나는 루프의 반복에서 사용된다.

이 기계어는 종종 가장 일반적인 상황을 위해 최적화 되며, 또 코드에 관한 몇가지 요건을 갖춰야 한다. 그러므로, 기계어는 보호자를 필요로 하며, 이 몇가지 요건에 대한 검증이 필요하다. 만약 보호자가 있는지, 그리고 요건을 충족하는지 확인하는 검증에 실패한다면, 런타임은 일반적인 인터프리터 모드로 돌아가 동작하게 된다.

더 자세한 정보는 다음 페이지에서 제공된다. http://en.wikipedia.org/wiki/Just-in-time_compilation

디버깅과 추적 로그

더 나은 방식이 있을까? JIT이 무엇을 하는지 볼 수 있을까? 다음 두가지를 하면 된다.

먼저, get_printable_location 함수를 추가한다. 이 함수는 디버그 추적을 위한 로깅에서 사용된다:

def get_location(pc, program, bracket_map):
    return "%s_%s_%s" % (
            program[:pc], program[pc], program[pc+1:]
            )
jitdriver = JitDriver(greens=['pc', 'program', 'bracket_map'], reds=['tape'],
        get_printable_location=get_location)

이 함수는 green 변수를 통과하며, 문자열을 반환해야 한다. 여기서 우리가 추가한 코드는, 현재 실행되는 BF코드의 앞과 뒤의 담긴 값도 밑줄과 함께 확인할 수 있도록 작성했다.

example4.py 를 내려받고 example3.py와 동일하게 번역 작업을 실행하자.

이제 테스트 프로그램을 추적 로그와 함께 구동한다. (test.b는 단순히 “A” 문자를 15번 또는 여러번 출력한다.):

$ PYPYLOG=jit-log-opt:logfile ./example4-c test.b

이제 “logfile”을 확인한다. 이 파일은 살짝 읽기 힘들기 때문에 가장 중요한 부분에 대해서만 설명할 것이다.

파일은 실행된 모든 추적 로그가 담겨있고 또 어떤 명령이 기계어로 컴파일 되었는지 확인할 수 있다. 이 로그를 통해서 필요 없는 명령이 무엇인지, 최적화를 위한 공간 등을 확인할 수 있다.

각각의 추적은 다음과 같은 모습을 하고 있다:

[3c091099e7a4a7] {jit-log-opt-loop

그리고 파일 끝은 다음과 같다:

[3c091099eae17d jit-log-opt-loop}

그 다음 행은 해당 명령의 루프 번호를 알려주며 얼마나 많은 실행(ops)이 있는지 확인할 수 있다. 내 경우에는 다음과 같은 첫 추적 로그를 얻을 수 있었다:

1  [3c167c92b9118f] {jit-log-opt-loop
2  # Loop 0 : loop with 26 ops
3  [p0, p1, i2, i3]
4  debug_merge_point('+<[>[_>_+<-]>.[<+>-]<<-]++++++++++.', 0)
5  debug_merge_point('+<[>[>_+_<-]>.[<+>-]<<-]++++++++++.', 0)
6  i4 = getarrayitem_gc(p1, i2, descr=<SignedArrayDescr>)
7  i6 = int_add(i4, 1)
8  setarrayitem_gc(p1, i2, i6, descr=<SignedArrayDescr>)
9  debug_merge_point('+<[>[>+_<_-]>.[<+>-]<<-]++++++++++.', 0)
10 debug_merge_point('+<[>[>+<_-_]>.[<+>-]<<-]++++++++++.', 0)
11 i7 = getarrayitem_gc(p1, i3, descr=<SignedArrayDescr>)
12 i9 = int_sub(i7, 1)
13 setarrayitem_gc(p1, i3, i9, descr=<SignedArrayDescr>)
14 debug_merge_point('+<[>[>+<-_]_>.[<+>-]<<-]++++++++++.', 0)
15 i10 = int_is_true(i9)
16 guard_true(i10, descr=<Guard2>) [p0]
17 i14 = call(ConstClass(ll_dict_lookup__dicttablePtr_Signed_Signed), ConstPtr(ptr12), 90, 90, descr=<SignedCallDescr>)
18 guard_no_exception(, descr=<Guard3>) [i14, p0]
19 i16 = int_and(i14, -9223372036854775808)
20 i17 = int_is_true(i16)
21 guard_false(i17, descr=<Guard4>) [i14, p0]
22 i19 = call(ConstClass(ll_get_value__dicttablePtr_Signed), ConstPtr(ptr12), i14, descr=<SignedCallDescr>)
23 guard_no_exception(, descr=<Guard5>) [i19, p0]
24 i21 = int_add(i19, 1)
25 i23 = int_lt(i21, 114)
26 guard_true(i23, descr=<Guard6>) [i21, p0]
27 guard_value(i21, 86, descr=<Guard7>) [i21, p0]
28 debug_merge_point('+<[>[_>_+<-]>.[<+>-]<<-]++++++++++.', 0)
29 jump(p0, p1, i2, i3, descr=<Loop0>)
30 [3c167c92bc6a15] jit-log-opt-loop}

debug_merge_point 라인은 정말 길어서, 임의로 잘랐다.

자 이제 살펴보도록 하자. 이 추적은 4개의 파라미터를 받았다. 2개의 객체 포인터(p0과 p1) 그리고 2개의 정수(i2 and i3)를 받았다. 디버그 행을 살펴보면, 이 루프의 한 반복이 추적되고 있음을 확인할 수 있다: “[>+<-]”

이 부분은 실행의 첫 명령인 “>”에서 실행되었지만 (4행) 즉시 다음 명령이 실행되었다. “>”는 실행되지 않았고 완전히 최적화 된 것으로 보인다. 이 루프는 반드시 항상 같은 부분의 테잎에서 동작해야 하며, 테잎 포인터는 이 추적에서 일정해야 한다. 명시적인 전진 동작은 불필요하다.

5행부터 8행까지는 “+” 동작을 위한 실행이다. 먼저 배열 포인터인 p1에서 인덱스인 i2(6행)을 이용해 배열에 담긴 값을 가져오고, 1을 추가한 후 i6에 저장(7행), 그리고 다시 배열로 저장(8행)한 것을 확인할 수 있다.

9번째 행은 “<” 명령이지만 동작하지 않는다. 이 동작은 루틴 안으로 통과된 i2와 i3은 두 포인터를 통해 이미 계산된 값으로서 사용하게 된다. 또한 p1을 테잎 배열을 통해 추측한다. p0는 무엇인지 명확하지 않다.

10부터 13행까지는 “-” 동작에 대한 기록으로 배열의 값을 얻고(11행), 추출해(12행) 배열 값으로 저장 (13행)한다.

다음 14행은 “]” 동작에 해당하는 내용이다. 15행과 16행에서 i9가 참인지(0이 아닌지) 확인한다. 확인할 때, i9는 배열값으로 감소한 후 저장되며 루프의 상태가 확인된다. (“]”의 위치를 기억한다.) 16번 행은 보호자로, 해당 실행 요건이 아니라면 실행은 다른 어딘가로 뛰어 넘어가게 된다. 이 경우에는 루틴은 를 호출하고 p0라는 파라미터를 넘겨준다.

17행부터 23행까지는 보호자를 통과한 후 프로그램 카운터가 어디로 넘어갈지를 찾기 위해 bracket_map을 살펴보는 딕셔너리 검색을 하는 부분이다. 사실 내가 이 명령이 실제로 어떻게 동작하는지 친숙하지 않지만 이 모습은 두번의 외부 호출과 보호자 셋으로 이루어져 있다. 이 명령은 비싼 것으로 보인다. 사실 우리는 이미 bracket_map은 앞으로 절대 변경되지 않는다는 사실을 알고 있다. (PyPy는 모르는 부분이다.) 다음 챕터에서는 이 부분을 어떻게 최적화 하는지 살펴본다.

24행은 명령 포인터가 새로 증가되었다는 것을 확인할 수 있다. 25, 26행은 프로그램의 길이보다 작은 것을 확신할 수 있다.

덧붙여, 27행은 i21을 보호하는데 명령 포인터가 증가하는 부분으로, 이 부분은 정확히 86이다. 왜냐하면 시작 부분으로 돌아가는 부분(29행)에 관한 내용이기 때문이고 명령 포인터가 86인 이유는 이 블럭에서 미리 전제되었기 때문이다.

마지막으로 루프는 28행에서 종료된다. JIT은 이 경우를 반복적으로 다루기 위해서 로 다시 넘어가 (29행) 루프의 처음부터 다시 실행을 한다. 이때 4개의 파라미터도 같이 넘어간다. (p0, p1, i2, i3)

최적화

미리 언급했듯, 루프의 모든 반복에는 최종 결과를 위해 맞는 대괄호를 찾아야 하며 그로 인해 딕셔너리 검색을 하게 된다. 이런 반복은 최악에 가까운 비효율이다. 목표로 넘기는 것은 한 루프에서 다음으로 간다고 해서 변경되는 부분이 아니다. 이 정보는 변하지 않으며 다른 것들과 같이 컴파일이 되어야 한다.

이 프로그램은 딕셔너리에서 찾기 시작하는데 PyPy는 이 부분을 불투명한 것처럼 다룬다. 그 이유는 딕셔너리가 변경되지 않고 다른 쿼리를 통해 다른 결과물을 돌려줄 가능성이 전혀 없다는 사실을 모르고 있기 때문이다.

우리가 해야 할 일은 번역기에게 다른 힌트를 주는 일이다. 이 딕셔너리 쿼리는 순수한 함수이고 이 함수는 같은 입력을 넣으면 항상 같은 출력을 낸다는 사실을 알려줘야 한다는 것이다.

이를 위해 우리는 함수 데코레이터인 rpython.rlib.jit.purefunction을 사용해, 해당 딕셔너리를 호출하는 함수를 포장해준다:

@purefunction
def get_matching_bracket(bracket_map, pc):
    return bracket_map[pc]

위와 같은 처리가 된 코드는 example5.py 파일에서 확인할 수 있다.

JIT옵션과 함께 다시 번역해보고 속도가 상승했는지 확인해본다. Mandelbrot은 이제 6초 밖에 걸리지 않는다! (이번 최적화 이전에는 12초가 걸렸다.)

동일한 함수의 추적 로그를 살펴보자:

1  [3c29fad7b792b0] {jit-log-opt-loop
2  # Loop 0 : loop with 15 ops
3  [p0, p1, i2, i3]
4  debug_merge_point('+<[>[_>_+<-]>.[<+>-]<<-]++++++++++.', 0)
5  debug_merge_point('+<[>[>_+_<-]>.[<+>-]<<-]++++++++++.', 0)
6  i4 = getarrayitem_gc(p1, i2, descr=<SignedArrayDescr>)
7  i6 = int_add(i4, 1)
8  setarrayitem_gc(p1, i2, i6, descr=<SignedArrayDescr>)
9  debug_merge_point('+<[>[>+_<_-]>.[<+>-]<<-]++++++++++.', 0)
10 debug_merge_point('+<[>[>+<_-_]>.[<+>-]<<-]++++++++++.', 0)
11 i7 = getarrayitem_gc(p1, i3, descr=<SignedArrayDescr>)
12 i9 = int_sub(i7, 1)
13 setarrayitem_gc(p1, i3, i9, descr=<SignedArrayDescr>)
14 debug_merge_point('+<[>[>+<-_]_>.[<+>-]<<-]++++++++++.', 0)
15 i10 = int_is_true(i9)
16 guard_true(i10, descr=<Guard2>) [p0]
17 debug_merge_point('+<[>[_>_+<-]>.[<+>-]<<-]++++++++++.', 0)
18 jump(p0, p1, i2, i3, descr=<Loop0>)
19 [3c29fad7ba32ec] jit-log-opt-loop}

훨씬 나아졌다! 각각의 루프 반복은 추가, 제거, 두 배열 불러오기, 두 배열 저장하기, 그리고 상태를 종료하는 상황에서의 보호자 확인 정도가 있다. 이게 전부다. 이 코드는 더이상 어떤 프로그램 카운터 계산도 요구하지 않는다.

난 최적화에 관해서는 전문가가 아니다. 이 팁은 Armin Rigo가 pypy-dev 메일링 리스트에서 추천해준 방법이다. Carl Friedrich는 인터프리터를 어떻게 최적화 하는가에 관해서 아주 유용한 시리즈 포스트를 작성했으니 관심이 있다면 참고하기 바란다: http://bit.ly/bundles/cfbolz/1

마무리

이 글이 PyPy가 어떻게 동작하는지, 어떻게 기존의 Python 구현보다 빠르게 동작하는지 이해하는데 도움이 되었으면 한다.

어떻게 프로세스가 동작하는가와 같은 세부적인 과정들을 더 자세히 알고 싶다면, 이 프로세스의 자세한 내용을 설명하는 몇 개의 논문, Tracing the Meta-Level: PyPy’s Tracing JIT Compiler와 같은 논문을 살펴보기를 권장한다.

더 궁금하면 다음 링크를 참조하자. http://readthedocs.org/docs/pypy/en/latest/extradoc.html

근래 간단한 서비스를 만들고 있는데 시작부터 시간대로 인한 문제가 있어 이 기회에 제대로 살펴보게 되었다. 한국에서 개발할 때는 단 한번도 생각해본 적이 없던 시간대 문제에 대해서 찾아볼 수 있게 되어 참 좋았고, 국가가 시간대를 변경함에 따라 역사적으로 사라진 시간들이 존재한다는 점, 동부표준시(EST)와 미동부 시간대(US/Eastern)가 어떻게 다른가 등 상당히 재미있는 (다른 의미로 일관성 없는) 부분들이 있다는 것을 알게 되었다.

pytz는 Olson 시간대 데이터베이스를 기준으로 한, 역사적인 시간대와 현대적인 시간대를 모두 망라하고 있는 라이브러리다. 이 라이브러리 문서를 통해 시간대로 인해 발생할 수 있는 여러 경우를 살펴볼 수 있으므로 꼭 Python 개발자가 아니더라도 시간대 문제에 대해 관심이 있다면 살펴볼만한 이야기가 담겨져있다.

특히 처음에 번역할 때 동부표준시와 미동부 시간대에 대해 정확한 이해가 없어서 대충 옮겼다가 전체적으로 다시 살펴보긴 했는데 여전히 오류가 있는 것 같아 앞서 그 차이를 밝혀두면, 미동부 시간대(US/Eastern)는 동부표준시인 EST와 동부일광절약시인 EDT를 교차로 사용한다. EDT 없이 EST만 사용하는 곳도 존재한다.

결론적인 부분을 먼저 적어보면, UTC로 모든 시간을 관리하고 사용자에 따라 각 시간대에 맞춰 출력해주는 방식이 시간을 다루는 가장 좋은 방법이다. (UTC 만세!)


pytz – 세계 시간대 정의를 위한 Python 라이브러리

Stuart Bishop (stuart@stuartbishop.net)

원문 https://pypi.python.org/pypi/pytz/

소개

pytz는 Olson tz databse를 Python으로 옮겨온 라이브러리다. 이 라이브러리는 정확하게, 크로스 플랫폼을 지원하는 시간대 계산도구로 Python 2.4 이상에서 사용할 수 있다. 또한 일광 절약 시간이 끝날 때 발생하는 시간의 모호한 문제를 해결해주는데 이에 대한 자세한 내용은 Python 표준 라이브러리에서 더 찾아볼 수 있다. (datetime.tzinfo)

거의 대부분의 Olson 시간대 데이터베이스를 지원한다.

덧붙여, 이 라이브러리는 Python API의 tzinfo 구현과는 다르다. 만약 지역의 벽시계를 만들고 싶다면 이 라이브러리의 localize() 메소드를 사용해야 한다. 추가적으로, 시간을 산술적으로 계산하는데 일광절약시간의 영역을 넘나든다면 그 결과물은 다른 시간대가 되어야 한다. (예를 들면 2002-10-27 1:00 동부표준시에서 1분을 빼면 2002-10-27 1:59 동부일광절약시가 아닌 2002-10-27 0:59 동부표준시를 반환할 것이다.) 이런 경우 이 라이브러리의 normalize() 메소드가 도움이 된다. 이러한 문제는 Python의 datetime 구현을 수정하지 않는 이상 해결하기 어려운 문제다.

설치

이 패키지는 설치도구를 이용해 .egg로 설치할 수도 있고 Python 표준 distutill로 tarball로부터 설치도 가능하다.

만약 tabll로 설치한다면 관리자 권한으로 아래 명령어를 실행한다::

python setup.py install

만약 설치도구로 설치한다면 Python 패키지 인덱스에서 알아서 최신 버전을 받아 설치해준다::

easy_install --upgrade pytz

.egg파일을 이미 가지고 있다면 아래와 같이 설치가능하다::

easy_install pytz-2008g-py2.6.egg

예제와 사용법

현지 시간과 일자의 계산

>>> from datetime import datetime, timedelta
>>> from pytz import timezone
>>> import pytz
>>> utc = pytz.utc
>>> utc.zone
'UTC'
>>> eastern = timezone('US/Eastern')
>>> eastern.zone
'US/Eastern'
>>> amsterdam = timezone('Europe/Amsterdam')
>>> fmt = '%Y-%m-%d %H:%M:%S %Z%z'

이 라이브러리는 지역 시간을 생성하기 위한 두가지 방법을 지원한다. 첫째는 pytz 라이브러리에서 제공하는 localize() 메소드를 이용하는 방법이다. 이 메소드는 시간대 보정이 없는, 순수한 datetime을 지역화하는데 사용한다:

>>> loc_dt = eastern.localize(datetime(2002, 10, 27, 6, 0, 0))
>>> print(loc_dt.strftime(fmt))
2002-10-27 06:00:00 EST-0500

둘째로 astimezone()메소드를 이용해 이미 만들어 지역화된 시간을 변경하여 사용하는 방법이 있다:

>>> ams_dt = loc_dt.astimezone(amsterdam)
>>> ams_dt.strftime(fmt)
'2002-10-27 12:00:00 CET+0100'

안타깝게도 표준 datetime 생성자에서 사용하는 tzinfo 아규먼트는 pytz의 많은 시간대에서 정상적으로 ”동작하지 않는다”.

>>> datetime(2002, 10, 27, 12, 0, 0, tzinfo=amsterdam).strftime(fmt)
'2002-10-27 12:00:00 AMT+0020'

일광절약시간으로 변경하지 않더라도 UTC와 같은 시간대를 사용하는 것이 안전하다.

>>> datetime(2002, 10, 27, 12, 0, 0, tzinfo=pytz.utc).strftime(fmt)
'2002-10-27 12:00:00 UTC+0000'

시간을 다루는 좋은 방법은 항상 UTC로 시간을 다루고 사람이 보기 위해 출력할 때만 해당 지역 시간으로 변환해 보여주는 것이다.

>>> utc_dt = datetime(2002, 10, 27, 6, 0, 0, tzinfo=utc)
>>> loc_dt = utc_dt.astimezone(eastern)
>>> loc_dt.strftime(fmt)
'2002-10-27 01:00:00 EST-0500'

이 라이브러리는 지역 시간을 이용해 날짜를 산술 계산할 수 있다. UTC에서 계산하고 normalize() 메소드를 이용해 일광절약시간과 다른 시간대로 변환하는 것을 조정하는 것보다는 조금 복잡하지만 말이다. 예를 들면 loc_dt는 미국 동부(US/Eastern) 시간대의 일광 절약 시간이 종료될 때의 시간으로 값을 받는다.

>>> before = loc_dt - timedelta(minutes=10)
>>> before.strftime(fmt)
'2002-10-27 00:50:00 EST-0500'
>>> eastern.normalize(before).strftime(fmt)
'2002-10-27 01:50:00 EDT-0400'
>>> after = eastern.normalize(before + timedelta(minutes=20))
>>> after.strftime(fmt)
'2002-10-27 01:10:00 EST-0500'

지역 시간을 생성하는건 좀 까다롭기 때문에 지역 시간으로 작업하는 것을 권장하지 않는다. 안타깝게도 datetime을 생성할 때 tzinfo 아규먼트를 사용해서는 해결될 수 없다. (다음 섹션에서 더 자세하게 다룬다)

>>> dt = datetime(2002, 10, 27, 1, 30, 0)
>>> dt1 = eastern.localize(dt, is_dst=True)
>>> dt1.strftime(fmt)
'2002-10-27 01:30:00 EDT-0400'
>>> dt2 = eastern.localize(dt, is_dst=False)
>>> dt2.strftime(fmt)
'2002-10-27 01:30:00 EST-0500'

시간대 간 변환을 할 때도 특별한 주의를 요구한다. 여기서도 normalize() 메소드를 활용해 이 변환이 올바르게 되도록 한다.

>>> utc_dt = utc.localize(datetime.utcfromtimestamp(1143408899))
>>> utc_dt.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'
>>> au_tz = timezone('Australia/Sydney')
>>> au_dt = au_tz.normalize(utc_dt.astimezone(au_tz))
>>> au_dt.strftime(fmt)
'2006-03-27 08:34:59 EST+1100'
>>> utc_dt2 = utc.normalize(au_dt.astimezone(utc))
>>> utc_dt2.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'

또한 UTC로 된 시간대 변환이 필요할 때 아래와 같은 지름길을 이용할 수 있다. normalize()localize()는 일광절약시간의 문제가 없다면 꼭 필요한 것은 아니다.

>>> utc_dt = datetime.utcfromtimestamp(1143408899).replace(tzinfo=utc)
>>> utc_dt.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'
>>> au_tz = timezone('Australia/Sydney')
>>> au_dt = au_tz.normalize(utc_dt.astimezone(au_tz))
>>> au_dt.strftime(fmt)
'2006-03-27 08:34:59 EST+1100'
>>> utc_dt2 = au_dt.astimezone(utc)
>>> utc_dt2.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'

tzinfo API

tzinfo 인스턴스는 timezone()함수에 의해 반환되는데 이 함수는 모호한 시간대에 대응하기 위한 is_dst 파라미터를 utcoffset(), dst(), tzname() 와 같은 메소드를 확장한 것이다.

>>> tz = timezone('America/St_Johns')

>>> normal = datetime(2009, 9, 1)
>>> ambiguous = datetime(2009, 10, 31, 23, 30)

is_dst파라미터는 많은 타임스템프들에서 무시된다. 단지 DST 전환에 의해 나타나는 모호한 시간을 해결하기 위해 사용된다.

>>> tz.utcoffset(normal, is_dst=True)
datetime.timedelta(-1, 77400)
>>> tz.dst(normal, is_dst=True)
datetime.timedelta(0, 3600)
>>> tz.tzname(normal, is_dst=True)
'NDT'

>>> tz.utcoffset(ambiguous, is_dst=True)
datetime.timedelta(-1, 77400)
>>> tz.dst(ambiguous, is_dst=True)
datetime.timedelta(0, 3600)
>>> tz.tzname(ambiguous, is_dst=True)
'NDT'

>>> tz.utcoffset(normal, is_dst=False)
datetime.timedelta(-1, 77400)
>>> tz.dst(normal, is_dst=False)
datetime.timedelta(0, 3600)
>>> tz.tzname(normal, is_dst=False)
'NDT'

>>> tz.utcoffset(ambiguous, is_dst=False)
datetime.timedelta(-1, 73800)
>>> tz.dst(ambiguous, is_dst=False)
datetime.timedelta(0)
>>> tz.tzname(ambiguous, is_dst=False)
'NST'

만약 is_dst값이 지정되지 않으면, 모호한 타임스탬프에서 pytz.exceptions.AmbiguousTimeError 예외가 발생한다.

>>> tz.utcoffset(normal)
datetime.timedelta(-1, 77400)
>>> tz.dst(normal)
datetime.timedelta(0, 3600)
>>> tz.tzname(normal)
'NDT'

>>> import pytz.exceptions
>>> try:
...     tz.utcoffset(ambiguous)
... except pytz.exceptions.AmbiguousTimeError:
...     print('pytz.exceptions.AmbiguousTimeError: %s' % ambiguous)
pytz.exceptions.AmbiguousTimeError: 2009-10-31 23:30:00
>>> try:
...     tz.dst(ambiguous)
... except pytz.exceptions.AmbiguousTimeError:
...     print('pytz.exceptions.AmbiguousTimeError: %s' % ambiguous)
pytz.exceptions.AmbiguousTimeError: 2009-10-31 23:30:00
>>> try:
...     tz.tzname(ambiguous)
... except pytz.exceptions.AmbiguousTimeError:
...     print('pytz.exceptions.AmbiguousTimeError: %s' % ambiguous)
pytz.exceptions.AmbiguousTimeError: 2009-10-31 23:30:00

지역시간으로 인한 문제들

시간으로 인해 발생하는 가장 중요한 문제는 특정 일시가 1년에 두 번 나타날 수 있다는 부분이다. 예를 들면 미 동부 시간대에서 10월 마지막 일요일 아침에 아래와 같은 일련의 사건이 나타났다고 가정해보자.

- 01:00am 동부 일광 절약 표준시가 됨
- 1시간 후, 2:00am 시계를 1시간 뒤로 돌리면 또 01:00am가 됨
  (이 시간은 01:00 동부표준시)

사실 모든 인스턴스는 01:00부터 02:00 사이에 두번씩 나타난다. 이 의미는 미동부 시간대에서 표준 datetime 문법을 따르면 일광절약시간이 끝난 시간보다 전의 시간을 정의할 수 있는 방법이 없다는 뜻이다.

>>> loc_dt = datetime(2002, 10, 27, 1, 30, 00, tzinfo=eastern)
>>> loc_dt.strftime(fmt)
'2002-10-27 01:30:00 EST-0500'

위에서 보듯, 시스템은 하나를 골라야만 하고, 이 한시간 이내에 제대로 시간이 표기될 확률은 50%가 된다. 몇 어플리케이션에서는 이런건 문제가 되지 않는다. 하지만 다양한 시간대에 살고 있는 사람들의 미팅 스케쥴을 잡아야 하거나, 로그 파일을 분석해야 한다면 이건 문제가 된다.

최고의 방법이자 가장 단순한 해결책은 UTC를 사용하는 것이다. pytz 패키지는 내부적으로 시간대를 표현하는데 UTC를 사용하기를 권장하며, 특히 Python에서 표준 레퍼런스를 기반으로 구현된 특별한 UTC 구현을 활용하는 것을 권장한다.

UTC 시간대는 같은 인스턴스가 되는 문제가 없지만 다른 pytz tzinfo 인스턴스보다는 큰 사이즈라는 문제가 있다. UTC 구현은 pytz.utc, pytz.UTC 또는 pytz.timezone(‘UTC’)에 포함된다.

>>> import pickle, pytz
>>> dt = datetime(2005, 3, 1, 14, 13, 21, tzinfo=utc)
>>> naive = dt.replace(tzinfo=None)
>>> p = pickle.dumps(dt, 1)
>>> naive_p = pickle.dumps(naive, 1)
>>> len(p) - len(naive_p)
17
>>> new = pickle.loads(p)
>>> new == dt
True
>>> new is dt
False
>>> new.tzinfo is dt.tzinfo
True
>>> pytz.utc is pytz.UTC is pytz.timezone('UTC')
True

덧붙여, 이 UTC 인스턴스는 다른 이름에 같은 의미를 가진 시간대(GMT, 그리니치, 유니버셜 등)와 같은 인스턴스 (또는 같은 구현)이 아니다.

>>> utc is pytz.timezone('GMT')
False

지역 시간으로 표기하고 싶을 때, 이 라이브러리는 시간대들이 모호하지 않도록 편의를 제공할 것이다:

>>> loc_dt = datetime(2002, 10, 27, 1, 30, 00)
>>> est_dt = eastern.localize(loc_dt, is_dst=True)
>>> edt_dt = eastern.localize(loc_dt, is_dst=False)
>>> print(est_dt.strftime(fmt) + ' / ' + edt_dt.strftime(fmt))
2002-10-27 01:30:00 EDT-0400 / 2002-10-27 01:30:00 EST-0500

is_dst 플래그를 None으로 둔 채 localize()를 사용하면, pytz는 결과값을 예측하지 못하게 되고 그로 인해 모호하거나 존재하지 않는 시간을 생성하게 되어 예외가 발생한다.

예를 들면 미국동부시에서 일광절약시간이 종료되어 시계를 한시간 뒤로 돌려 2002년 10월 27일 1:30am이 두번 나타나게 되는 경우에 아래와 같은 예외가 발생하는 것을 확인할 수 있다:

>>> dt = datetime(2002, 10, 27, 1, 30, 00)
>>> try:
...     eastern.localize(dt, is_dst=None)
... except pytz.exceptions.AmbiguousTimeError:
...     print('pytz.exceptions.AmbiguousTimeError: %s' % dt)
pytz.exceptions.AmbiguousTimeError: 2002-10-27 01:30:00

유사한 이유로, 2002년 4월 7일 2:30am은 모든 미국동부 시간대에서 절대 발생하지 않는데 모든 시계가 1시간을 앞당겨 2:00am은 존재하지 않기 떄문이다:

>>> dt = datetime(2002, 4, 7, 2, 30, 00)
>>> try:
...     eastern.localize(dt, is_dst=None)
... except pytz.exceptions.NonExistentTimeError:
...     print('pytz.exceptions.NonExistentTimeError: %s' % dt)
pytz.exceptions.NonExistentTimeError: 2002-04-07 02:30:00

두 예외는 공통적인 기반 클래스를 공유하고 있기 때문에 에러를 다루는데는 큰 문제가 없다:

>>> isinstance(pytz.AmbiguousTimeError(), pytz.InvalidTimeError)
True
>>> isinstance(pytz.NonExistentTimeError(), pytz.InvalidTimeError)
True

localize()로 대다수의 경우를 다룰 수 있지만, 아직까지 모든 경우를 다루지는 못한다. 국가가 시간대 정의를 변경하는 경우, 일광절약시간 종료일 같은 문제들은 어떠한 방법으로도 그 모호성을 없엘 수 없다. 그 예로 1915년 바르샤바(주. 폴란드의 수도)는 바르샤바시에서 중앙유럽시로 변경했다. 1915년 8월 5일 자정을 기해 24분을 뒤로 돌렸는데 이로 인해 정의할 수 없는 모호한 시간 기간이 생겨나게 되었고 그 기간은 축약 시간대나 실제 UTC 표준시 이외에는 표기할 방법이 없게 되었다. 이와 같이 자정이 두번 발생하는 경우는, 일광절약시간으로 발생하는 문제와도 다른 경우다:

>>> warsaw = pytz.timezone('Europe/Warsaw')
>>> loc_dt1 = warsaw.localize(datetime(1915, 8, 4, 23, 59, 59), is_dst=False)
>>> loc_dt1.strftime(fmt)
'1915-08-04 23:59:59 WMT+0124'
>>> loc_dt2 = warsaw.localize(datetime(1915, 8, 5, 00, 00, 00), is_dst=False)
>>> loc_dt2.strftime(fmt)
'1915-08-05 00:00:00 CET+0100'
>>> str(loc_dt2 - loc_dt1)
'0:24:01'

이 잃어버린 24분 사이의 시간을 생성하는 방법은 다른 시간대로부터 변환하는 방법 밖에 없는데 어떤 시간대를 사용한다 하더라도 일광 절약 모드의 API를 활용한다 해도 단순하게 나타낼 방법이 없기 때문이다:

>>> utc_dt = datetime(1915, 8, 4, 22, 36, tzinfo=pytz.utc)
>>> utc_dt.astimezone(warsaw).strftime(fmt)
'1915-08-04 23:36:00 CET+0100'

표준 Python에서 이와 같은 모호함을 처리하는 방법은 다뤄지지 않는데 Python 문서에 나온 미동부 시간대의 예제를 보면 확인할 수 있다. (이 구현은 1987년과 2006년 사이에서만 동작하는데 단지 테스트를 위해 포함되었다):

>>> from pytz.reference import Eastern # pytz.reference only for tests
>>> dt = datetime(2002, 10, 27, 0, 30, tzinfo=Eastern)
>>> str(dt)
'2002-10-27 00:30:00-04:00'
>>> str(dt + timedelta(hours=1))
'2002-10-27 01:30:00-05:00'
>>> str(dt + timedelta(hours=2))
'2002-10-27 02:30:00-05:00'
>>> str(dt + timedelta(hours=3))
'2002-10-27 03:30:00-05:00'

첫 두 결과를 확인해보면, 처음에 슬쩍 봐서는 옳은 결과값이라 생각이 들겠지만 UTC를 기준으로 편차 계산해보면 사실 우리가 요청한 1시간이 아닌 실제로 2시간임을 확인할 수 있다.

>>> from pytz.reference import UTC # pytz.reference only for tests
>>> str(dt.astimezone(UTC))
'2002-10-27 04:30:00+00:00'
>>> str((dt + timedelta(hours=1)).astimezone(UTC))
'2002-10-27 06:30:00+00:00'

국가 정보

ISO 3166 국가 코드를 사용해 개별 국가들이 사용하는 일반적인 시간대를 접근할 수 있도록 지원한다. pytz.timezone()을 이용하면 문자열 리스트를 반환하는데 이 문자열을 관련된 tzinfo 인스턴스를 가져오는데 사용할 수 있다:

>>> print(' '.join(pytz.country_timezones['nz']))
Pacific/Auckland Pacific/Chatham

Olson 데이터베이스는 ISO 3166 국가 코드를 영문 국가명과 맵핑해뒀기 때문에 pytz를 딕셔너리와 같이 사용할 수 있다:

>>> print(pytz.country_names['nz'])
New Zealand

UTC란 무엇인가

‘UTC’는 협정 시간으로, 그리니치 표준시나 영국의 GMT로 많이 알려져 있다. 다른 모든 시간대는 UTC를 기준으로 편차 계산하는 방식이다. UTC에서는 일광절약시간이 존재하지 않기 때문에 산술적으로 계산하는데 아무런 문제가 없어서, 일광절약시간 변환, 국가가 시간대를 변경하는 경우, 또는 이동형 컴퓨터가 다른 여러 시간대로 이동해야 하는 경우에도 아무런 문제를 만들지 않는다.

헬퍼

헬퍼는 두가지 목록의 시간대를 제공한다.

all_timezones는 명확한 시간대명 목록으로 활용 가능하다.

>>> from pytz import all_timezones
>>> len(all_timezones) >= 500
True
>>> 'Etc/Greenwich' in all_timezones
True

common_timezones는 현재의 시간대 목록으로 유용하게 사용할 수 있다. 이 목록은 몇가지 일반적으로 필요한 경우를 제외하고, 더이상 존재하지 않는 시간대나 역사적인 시간대를 포함시키지 않았다. 예를 들면 미국동부시의 경우는 포함되어 있다. (만약 생각하기에 여기에 포함되어야 한다고 생각하는 시간대가 있다면 버그리포트를 만들어주기 바란다.) 이 또한 문자열 목록으로 제공된다. (주. 미국동부시의 경우 동부표준시 EST와 동부일광절약시 EDT를 둘 다 사용한다. 같은 시간대에 있는 국가 중 EDT의 적용 없이 EST만 적용하는 경우도 있다.)

>>> from pytz import common_timezones
>>> len(common_timezones) < len(all_timezones)
True
>>> 'Etc/Greenwich' in common_timezones
False
>>> 'Australia/Melbourne' in common_timezones
True
>>> 'US/Eastern' in common_timezones
True
>>> 'Canada/Eastern' in common_timezones
True
>>> 'US/Pacific-New' in all_timezones
True
>>> 'US/Pacific-New' in common_timezones
False

common_timezonesall_timezones 두 목록은 알파벳 순으로 정렬되어 있다:

>>> common_timezones_dupe = common_timezones[:]
>>> common_timezones_dupe.sort()
>>> common_timezones == common_timezones_dupe
True
>>> all_timezones_dupe = all_timezones[:]
>>> all_timezones_dupe.sort()
>>> all_timezones == all_timezones_dupe
True

all_timezonescommon_timezones 두 목록은 set으로도 사용 가능하다:

>>> from pytz import all_timezones_set, common_timezones_set
>>> 'US/Eastern' in all_timezones_set
True
>>> 'US/Eastern' in common_timezones_set
True
>>> 'Australia/Victoria' in common_timezones_set
False

또한 시간대 목록에서 개별 국가를 이용해 사용할 때 country_timezones() 함수를 활용할 수 있다. 이 함수는 ISO-3166 2글자 국가코드를 사용한다.

>>> from pytz import country_timezones
>>> print(' '.join(country_timezones('ch')))
Europe/Zurich
>>> print(' '.join(country_timezones('CH')))
Europe/Zurich

라이센스

MIT license.

This code is also available as part of Zope 3 under the Zope Public License, Version 2.1 (ZPL).

I’m happy to relicense this code if necessary for inclusion in other open source projects.

최신 버전

이 패키지는 Olson 시간대 데이터베이스가 갱신될 때마다 업데이트 될 것이다. 최신 버전은 Python Package Index http://pypi.python.org/pypi/pytz/ 에서 받을 수 있다. 이 배포판을 생성하기 위해 launchpad.net에서 호스트 되고 있으며 Bazaar<br /> 버전 컨트롤 시스템 http://bazaar-vcs.org 에서는 아래와 같이 사용할 수 있다:

bzr branch lp:pytz

버그, 기능 요청과 패치

버그는 다음 경로로 제보 바란다. Launchpad https://bugs.launchpad.net/pytz

이슈와 한계점

  • UTC로부터의 편차계산은 가장 가까운 분을 기준으로 반올림 되는데 그로 인해 1937년 이전 유럽/암스테르담과 같은 시간대들은 30초씩 잃어버리게 된다. 이런 한계는 Python datatime 라이브러리의 한계다.

  • 만약 보기에 시간대 정의가 잘못되었다면, 아마 고칠 수 없으리라 본다. pytz는 Olson 시간대 데이터베이스를 그대로 번역한 것이라 시간대 정의를 변경하고 싶다면 이 데이터베이스를 수정해야 한다. 만약 시간대와 관련된 문제를 찾는다면 다음 링크의 메일링 리스트를 통해 리포트하기 바란다. http://www.iana.org/time-zones

더 읽어보기

시간대에 대한 이해가 더 필요하다면 다음 글이 도움이 될 것이다: http://www.twinsun.com/tz/tz-link.htm

멜버른에도 많은 개발자 모임이 활성화되어 있고 세션이 운영되고 있는데 그 중 MPUG(Melbourne Python Users Group)에서 매월 첫주 월요일에 열리는 meetup에 처음으로 다녀왔다. 장소는 협업 공간인 Inspire9이고 Richmond역에서 3분 정도 거리에 있다.

Don’t do this! — Richard Jones

어떤 방식으로 파이썬 코드를 작성해야 하는가에 대한 세션으로 동일한 내용을 PythonAU 2013에서 진행했었다고. 많은 코드 예제들과 함께 어떤 방식으로 작성하면 좋은지에 대해 세세하게 설명해줬다. 디버깅을 위한 q1와 Java와 같은 방식으로 overload할 수 있게 도와주는 Overload2에 대한 이야기3가 인상적이었다.

Show and Tell: The Great Language Game — Lars Yencken

근래 여러번 트윗에 남겼던, Great Language Game에 대한 세션. 멜버른 대학교에서 개발한 langid.py를 이용하면 해당 문자열이 어떤 언어로 작성되었는지 알 수 있는데 그걸 음성 데이터를 불러와 어떤 언어인지 판단하도록 만들었다고. 슬라이드에 개략적인 내용이 잘 정리되어 있다.

HN에 뜬 이후 방문자에 엄청 몰렸는데 geventflask로 문제 없이 잘 버티고 있다는 후기를 메일링 리스트에 남겼다.

PyPy.js: towards a fast and compliant python shell for your browser — Ryan Kelly

이 날 가장 핫한 주제였던 PyPy.js는 주제도 흥미로웠고 내용도 재미있었다. 발표자는 현재 모질라에서 일하고 있다고. asm.js이라는 low-level subset이 있는데 슬라이드에서처럼 asm.js + PyPy = PyPy.js의 아이디어로 시작했다고 한다. 시연에서 실제로 구동하는 모습을 보여줬는데 Python 표준 라이브러리를 포함해 275MB 크기(…)의 js가 나오고 그걸 구동하는데 FF가 스피너마저 굳는 모습을 보여줬지만 신기하게 잘 동작했다.

JIT에 대한 설명도 있었는데 내가 JIT에 대해 잘 몰라서… 이렇게 깊은 얘기까지 할 줄 몰랐다.

나도 처음 들었을 때 똑같은 의문이 들었는데 역시나 사람들이 물어봤다. 이거 완전 멋지다. 근데 왜만듬ㅋ? 답변도 역시, 브라우저에서 되면 쿨하잖아? (쿨문쿨답)

일단 구현이 완료되고 실용적으로 쓸만큼 안정적인 환경이 된다면 Python을 웹으로 바로 포팅도 가능한데다 Firefox OS에서의 Python app까지 고려할 수 있을 만큼 재미있는 아이디어라고. 앞으로 가능성도, 수효도 분명 많을 프로젝트라는 설명.

생각 조각들

  • 처음이라 어색했지만 꾸준히 와야겠다는 생각. 그래도 분위기가 딱딱하지 않아서 좋았다.
  • 연령대가 상당히 다양했다.
  • 첫 세션에서 C API 부분까지 내려가서 이야기 하는 것을 보고, Python을 잘 하려면 C에 대해서도 잘 알아야겠구나 싶었다.
  • 다녀오기 전까지는 메일링 리스트에 가입되어 있어도 별 관심이 없었는데 다녀오니 내용도 재밌고 관심도 더 생겼다. 좋은 자극.

Footnotes

  1. q — pypi

  2. overload — pypi

  3. 딜리게이터를 이용해 overload를 할 수 있도록 구현해줬는데 다들 왜 이런게 필요하지? 식의 반응. Python 커뮤니티 답다.

앞서 작성한 SQLAlchemy 시작하기 – Part 1에서 이어지는 번역이다.

(여기서 뭔가 모자란 부분이나 틀린게 있으면 틀린게 맞으므로 언제든 지적해주시고, 애매한 표현은 원본 문서를 봐주시면 감사하겠습니다. 원본 문서는 SQLAlchemy Tutorial. 한글로 된 sqlalchemy 튜토리얼 있으면 알려주세요!)


리스트와 Scalars 반환하기

Query 객체의 all(), one(), first() 메소드는 즉시 SQL을 호출하고 non-iterator 값을 반환한다. all()은 리스트를 반환한다.

query = session.query(User).filter(User.name.like('%air')). order_by(User.id)
query.all()
# [<User('haruair', 'Edward Kim', '1234')>, <User('wendy','Wendy Williams', 'foobar')>]

first()는 첫째를 리밋으로 설정해 scalar로 가져온다.

query.first()
# <User('haruair', 'Edward Kim', '1234')>

one()은 모든 행을 참조해 식별자를 값으로 가지고 있지 않거나 여러 행이 동일한 값을 가지고 있는 경우 에러를 만든다.

from sqlalchemy.orm.exc import MultipleResultsFound
try:
    user = query.one()
except MultipleResultsFound, e:
    print e


from sqlalchemy.orm.exc import NoResultFound
try:
    user = query.filter(User.id == 99).one()
except NoResultFound, e:
    print e

문자로 된 SQL 사용하기

문자열을 Query와 함께 유연하게 쓸 수 있다. 대부분 메소드는 문자열을 수용한다. 예를 들면 filter()order_by()에서 쓸 수 있다.

for user in session.query(User).\
            filter("id<224").\
            order_by("id").all():
    print user.name

연결된 파라미터에서는 콜론을 이용한, 더 세세한 문자열 기반의 SQL를 사용할 수 있다. 값을 사용할 때 param() 메소드를 이용한다.

session.query(User).filter("id<:value and name=:name").\
    params(value=1234, name='fred').order_by(User.id).one()

문자열 기반의 일반적인 쿼리를 사용하고 싶다면 from_statement()를 쓴다. 대신 컬럼들은 매퍼에서 선언된 것과 동일하게 써야한다.

session.query(User).from_statement(
                    "SELECT * FROM users WHERE name=:name").\
                    params(name='haruair').all()

또한 from_statement() 아래와 같은 문자열 SQL 방식으로도 쓸 수 있다.

session.query("id", "name", "thenumber12").\
        from_statement("SELECT id, name, 12 as "
                "thenumber12 FROM users WHERE name=:name").\
        params(name='haruair').all()

문자열 SQL의 장단점

Query로 생성해서 쓰는건 sqlalchemy의 이점인데 그렇게 쓰지 않으면 당연히 안좋아지는 부분이 있다. 직접 쓰면 특정하게 자기가 필요한 결과물을 쉽게 만들어낼 수 있겠지만 Query는 더이상 SQL구조에서 아무 의미 없어지고 새로운 문맥으로 접근할 수 있도록 변환하는 능력이 상실된다.

예를 들면 User 객체를 선택하고 name 컬럼으로 정렬하는데 name이란 문자열을 쓸 수 있다.

q = session.query(User.id, User.name)
q.order_by("name").all()

지금은 문제 없다. Query를 쓰기 전에 뭔가 멋진 방식을 사용해야 할 때가 있다. 예를 들면 아래처럼 from_self() 같은 고급 메소드를 사용해, 사용자 이름의 길이가 다른 경우를 비교할 때가 있다.

from sqlalchemy import func
ua = aliased(User)
q = q.from_self(User.id, User.name, ua.name).\
    filter(User.name < ua.name).\
    filter(func.length(ua.name) != func.length(User.name))

Query는 서브쿼리에서 불러온 것처럼 나타나는데 User는 내부와 외부 양쪽에서 불러오게 된다. 이제 Query에게 name으로 정렬하라고 명령하면 어느 name을 기준으로 정렬할지 코드로는 예측할 수 없게 된다. 이 경우에는 바깥과 상관없이 aliased된 User를 기준으로 정렬된다.

q.order_by("name").all()
# [(3, u'fred', u'haruair'), (4, u'haruair', u'mary'), (2, u'mary', u'wendy'), (3, u'fred', u'wendy'), (4, u'haruair', u'wendy')]

User.name 또는 ua.name 같이 SQL 요소를 직접 쓰면 Query가 알 수 있을 만큼 충분한 정보를 제공하기 때문에 어떤 name을 기준으로 정렬해야할지 명확하게 판단하게 된다. 그래서 아래 두가지와 같은 차이를 볼 수 있다.

q.order_by(ua.name).all()
# [(3, u'fred', u'haruair'), (4, u'haruair', u'mary'), (2, u'mary', u'wendy'), (3, u'fred', u'wendy'), (4, u'haruair', u'wendy')]

q.order_by(User.name).all()
# [(3, u'fred', u'wendy'), (3, u'fred', u'haruair'), (4, u'haruair', u'wendy'), (4, u'haruair', u'mary'), (2, u'mary', u'wendy')]

숫자세기

Querycount()라는 숫자를 세는 편리한 메소드를 포함한다.

session.query(User).filter(User.name.like('haru%')).count()

count()는 몇개의 행이 반환될지 알려준다. 위 코드로 생성되는 SQL을 살펴보면, SQLAlchemy는 항상 어떤 쿼리가 오더라도 거기서 행의 수를 센다. SELECT count(*) FROM table 하면 단순해지지만 최근 버전의 SQLAlchemy는 정확한 SQL로 명시적으로 판단할 수 있는 경우 추측해서 처리하지 않는다.

숫자를 세야 할 필요가 있는 경우에는 func.count()로 명시적으로 작성하면 된다.

from sqlalchemy import func
session.query(func.count(User.name), User.name).group_by(User.name).all()
# [(1, u'fred'), (1, u'haruair'), (1, u'mary'), (1, u'wendy')]

SELECT count(*) FROM table만 하고 싶으면

session.query(func.count('*')).select_from(User).scalar()

User의 primary key를 사용하면 select_from 없이 사용할 수 있다.

session.query(func.count(User.id)).scalar() 

관계(relationship) 만들기

이제 User와 관계된, 두번째 테이블을 만들 것이다. 계정당 여러개 이메일 주소를 저장할 수 있게 만들 것이다. users 테이블과 연결되는, 일대다 테이블이므로 테이블 이름을 addresses라고 정하고 전에 작성했던 것처럼 Declarative로 address 클래스를 작성한다.

from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, backref

class Address(Base):
    __tablename__ = 'addresses'
    id = Column(Integer, primary_key=True)
    email_address = Column(String, nullable=False)
    user_id = Column(Integer, ForeignKey('users.id'))

    user = relationship("User", backref=backref('addresses', order_by=id))

    def __init__(self, email_address):
        self.email_address = email_address

    def __repr__(self):
        return "<Address('%s')>" % self.email_address

위 클래스는 ForeignKey를 어떻게 만드는지 보여준다. Column에 직접 넣은 지시자는 이 컬럼의 내용이 대상된 컬럼을 따르도록 만든다. 이 점이 관계 데이터베이스의 주요 특징 중 하나인데 풀과 같은 역할을 해, 연결되지 않은 테이블 사이를 잘 붙여준다. 위에서 작성한 ForeignKeyaddresses.user_id 컬럼이 users.id 컬럼을 따르도록 만든다.

두번째 지시자인 relationship()은 ORM에게 Address 클래스 자체가 User 클래스에 연결되어 있다는 사실을 Address.user 속성을 이용해 알 수 있게 해준다. relationship()은 외래키 연결에서 두 테이블 사이에 Address.user로 다대일 관계임을 결정한다.

덧붙여 relationship()내에서 호출하는 backref()는 역으로 클래스를 이용할 수 있도록, 즉 Address 객체에서 User를 참조할 수 있도록 User.addresses를 구현한다. 다대일 관계의 반대측은 항상 일대다의 관계이기 때문이다. 자세한건 기본 관계 패턴 문서를 참고.

Address.userUser.addresses의 관계는 **양방향 관계(bidirectional relationship)**로 SQLAlchemy ORM의 주요 특징이다. Backref로 관계 연결하기 에서 backref에 대한 자세한 정보를 확인할 수 있다.

relationship()을 원격 클래스를 객체가 아닌 문자열로 연결하는 것에 대해 Declarative 시스템에서 사용하는 것으로 문제가 될 수 있지 않나 생각해볼 수 있다. 전부 맵핑이 완료된 경우, 이런 문자열은 파이썬 표현처럼 다뤄지며 실제 아규먼트를 처리하기 위해 사용된다. 위의 경우에선 User 클래스가 그렇다. 이런 이름들은 이것이 만들어지는 동안에만 허용되고 모든 클래스 이름은 기본적으로 선언될 때 사용이 가능해진다. (주. 클래스의 선언이 순차적으로 진행되기 때문에 클래스 선언 이전엔 에러가 나므로 이런 방식을 사용하는 것으로 보인다.)

아래는 동일하게 “addresses/user” 양방향 관계를 User 대신 Address로 선언한 모습이다.

class User(Base):
    # ...
    addresses = relationship("Address", order_by="Address.id", backref="user")

상세한 내용은 relationship()를 참고.

이건 알고 계시나요?

  • 대부분의 관계형 데이터베이스에선 외래키 제약이 primary key 컬럼이나 Unique 컬럼에만 가능하다.
  • 다중 컬럼 pirmary key에서의 외래키 제약은 스스로 다중 컬럼을 가지는데 이를 합성외래키(composite foreign key)라고 한다. 이 또한 이 컬럼의 서브셋을 레퍼런스로 가질 수 있다.
  • 외래키 컬럼은 연결된 컬럼이나 행의 변화에 자동으로 그들 스스로를 업데이트 한다. 이걸 CASCADE referential action이라고 하는데 관계형 데이터베이스에 내장된 함수다.
  • 외래키는 스스로의 테이블을 참고할 수 있다. 이걸 자기참조(self-referential) 외래키라고 한다.
  • 외래키에 대해 더 알고 싶다면 위키피디아 외래키 항목을 참고.

addresses 테이블을 데이터베이스에 생성해야 하므로 metadata로부터 새로운 CREATE를 발행한다. 이미 생성된 테이블은 생략하고 생성한다.

Base.metadata.create_all(engine)

관계된 객체 써먹기

이제 User를 만들면 빈 addresses 콜렉션이 나타난다. 딕셔너리나 set같은 다양한 컬랙션이 있는데 기본으로 컬랙션은 파이썬의 리스트다. (컬렉션 접근을 커스터마이징 하려면 이 문서 참고)

jack = User('jack', 'Jack Bean', 'sadfjklas')
jack.addresses # [] 빈 리스트를 반환

자유롭게 Address 객체를 User 객체에 넣을 수 있다. 그냥 리스트 사용법이랑 똑같다.

jack.addresses = [
                Address(email_address='jack@gmail.com'),
                Address(email_address='jack@yahoo.com')]

양방향 관계인 경우 자동으로 양쪽에서 접근할 수 있게 된다. 별도의 SQL 없이 양쪽에 on-change events로 동작한다.

jack.addresses[1]       # <Address(email_address='jack@yahoo.com')>
jack.addresses[1].user  # <User('jack', 'Jack Bean', 'sadfjklas')>

데이터베이스에 저장해보자. User인 Jack Bean을 저장하면 두 Address도 알아서 cascading으로 저장된다.

session.add(jack)
session.commit()

Jack을 쿼리해서 다시 불러보자. 이렇게 Query하면 아직 주소들은 SQL을 호출하지 않은 상태다.

jack = session.query(User).\
filter_by(name='jack').one()
Jack        # <User('jack', 'Jack Bean', 'sadfjklas')>

하지만 addressses 컬랙션을 호출하는 순간 SQL이 만들어진다.

jack.addresses
# [<Address(email_address='jack@gmail.com')>, <Address(email_address='jack@yahoo.com')>]

이렇게 뒤늦게 SQL로 불러오는걸 게으른 불러오기 관계(lazy loading relationship)라고 한다. 이 addresses는 이제 불러와 평범한 리스트처럼 동작한다. 이렇게 컬랙션을 불러오는 방법을 최적화하는 방법은 나중에 살펴본다.

Join과 함께 쿼리하기

두 테이블이 있는데 Query의 기능으로 양 테이블을 한방에 가져오는 방법을 살펴볼 것이다. SQL JOIN에 대해 join 하는 방법과 여러가지 좋은 설명이 위키피디아에 있으니 참고.

간단하게 UserAddress 두 테이블을 완전 조인하는 방법은 Query.filter()로 관계있는 두 컬럼이 동일한 경우를 찾으면 된다.

for u, a in session.query(User, Address).\
                    filter(User.id==Address.user_id).\
                    filter(Address.email_address=='jack@gmail.com').\
                    all():
    print u, a
# <User('jack', 'Jack Bean', 'sadfjklas')> <Address('jack@gmail.com')>

반면 진짜 SQL JOIN 문법을 쓰려면 Query.join()을 쓴다.

session.query(User).join(Address).\
        filter(Address.email_address=='jack@gmail.com').\
        all()
# [<User('jack', 'Jack Bean', 'sadfjklas')>]

Query.join()UserAddress 사이에 있는 하나의 외래키를 기준으로 join한다. 만약 외래키가 없거나 여러개라면 Query.join() 아래같은 방식을 써야한다.

query.join(Address, User.id==Address.user_id)   # 정확한 상태를 적어줌
query.join(User.addresses)                      # 명확한 관계 표기 (좌에서 우로)
query.join(Address, User.addresses)             # 동일, 명확하게 목표를 정해줌
query.join('addresses')                         # 동일, 문자열 이용

외부 join은 outerjoin()을 쓴다.

query.outerjoin(User.addresses)     # left outer join

join()이 궁금하면 문서를 참고하자. 어떤 SQL에서든 짱짱 중요한 기능이다.

별칭(aliases) 사용하기

여러 테이블을 쿼리하면 같은 테이블을 여러개 불러와야 할 떄가 있는데 그럴 때 동일 테이블명에 별칭(alias)를 지정해 다른 테이블과 문제를 이르키지 않도록 해야한다. Query는 별칭으로 된 녀석들도 잘 알아서 처리해준다. 아래 코드는 Address 엔티티를 두번 조인해서 한 행에 두 이메일 주소를 가져오도록 하는 예시다.

from sqlalchemy.orm import aliased
adalias1 = aliased(Address)
adalias2 = aliased(Address)
for username, email1, email2 in \
    session.query(User.name, adalias1.email_address, adalias2.email_address).\
    join(adalias1, User.addresses).\
    join(adalias2, User.addresses).\
    filter(adalias1.email_address=='jack@gmail.com').\
    filter(adalias2.email_address=='jack@yahoo.com'):
    print username, email1, email2
# jack jack@gmail.com jack@yahoo.com

서브쿼리 사용하기

Query는 서브쿼리 만들 때에도 유용하다. User 객체가 몇개의 Address를 가지고 있는지 알고 싶을 때 서브쿼리는 유용하다. SQL을 만드는 방식으로 생각하면 주소 목록의 수를 사용자 id를 기준으로 묶은 후(grouped by), User와 join하면 된다. 이 상황에선 LEFT OUTER JOIN이 사용자의 모든 주소를 가져오므로 적합하다. SQL의 예를 보자.

SELECT users.*, adr_count.address_count
FROM users
LEFT OUTER JOIN (
        SELECT user_id, count(*) AS address_count
        FROM addresses GROUP BY user_id
    ) AS adr_count
    ON users.id = adr_count.user_id

Query를 사용하면 명령문을 안에서 밖으로 빼내듯 쓸 수 있다. 명령문 접근자는 일반적인 Query를 통해 SQL 표현을 나타내는 명령문을 생성해 반환한다. 이건 select()를 쓰는 것과 비슷한데 자세한건 SQL 표현 언어 튜토리얼 문서를 참고.

from sqlalchemy.sql import func
stmt = session.query(Address.user_id, func.count('*').label('address_count')).\
        group_by(Address.user_id).subquery()

func 키워드는 SQL 함수를 만들고 subquery() 메소드는 별칭을 이용해 다른 query에 포함할 수 있는 SELECT 명령문의 형태로 반환해준다. (query.statement.alias()를 줄인 것)

이렇게 만든 서브쿼리는 Table처럼 동작한다. 아래 코드를 잘 모르겠으면 튜토리얼 앞부분에서 Table을 어떻게 다뤘는지 살펴보면 도움이 된다. 여기서는 컬럼에 접근할 때 table.c.컬럼명으로 접근했던, 그 방법처럼 사용한다.

for u, count in session.query(User, stmt.c.address_count).\
    outerjoin(stmt, User.id==stmt.c.user_id).order_by(User.id):
    print u, count
# <User('wendy', 'Wendy Williams', 'foobar')> None
# <User('mary', 'Mary Contrary', 'xxg527')> None
# <User('fred', 'Fred Flinstone', 'blar')> None
# <User('haruair', 'Edward Kim', '1234')> None
# <User('jack', 'Jack Bean', 'sadfjklas')> 2

서브쿼리서 엔티티 선택하기

위에서는 서브쿼리서 컬럼을 가져와서 결과를 만들었다. 만약 서브쿼리가 엔티티를 선택하기 위한 맵이라면 aliased()로 매핑된 클래스를 서브쿼리로 활용할 수 있다.

stmt = session.query(Address).\
                filter(Address.email_address != 'jack@yahoo.com').\
                subquery()
adalias = aliased(Address, stmt)
for user, address in session.query(User, adalias).\
        join(adalias, User.addresses):
    print user, address
# <User('jack', 'Jack Bean', 'sadfjklas')> <Address('jack@gmail.com')>

EXISTS 사용하기

SQL에서 EXISTS 키워드는 불린 연산자로 조건에 맞는 행이 있으면 True를 반환한다. 이건 많은 시나리오에서 join을 위해 쓰는데, join에서 관계 테이블서 적합한 값이 없는 행을 처리하는데에도 유용하다.

외부 EXISTS는 이런 방식으로 할 수 있다.

from sqlalchemy.sql import exists
stmt = exists().where(Address.user_id==User.id)
for name, in session.query(User.name).filter(stmt):
    print name
# jack

Query의 기능 중 몇가지 연산자에서는 EXISTS를 자동으로 사용한다. 위 같은 경우는 User.addresses 관계에 any()를 사용하면 가능하다.

for name, in ssession.query(User.name).\
        filter(User.addresses.any()):
    print name
# jack

any()는 특정 기준이 있어 제한적으로 매치해준다.

for name, in session.query(User.name).\
    filter(User.addresses.any(Address.email_address.like('%gmail%'))):
    print name
# jack

has()any()와 동일한 기능을 하는데 대신 다대일 관계에서 사용한다. (~연산자는 NOT이란 뜻이다.)

session.query(Address).\
    filter(~Address.user.has(User.name=='jack')).all()
# []

일반 관계 연산자

관계(relationship)에서 사용할 수 있는 모든 연산자인데 각각 API 문서에서 더 자세한 내용을 볼 수 있다.

__eq__() 다대일에서의 equals 비교

query.filter(Address.user == someuser)

__ne__() 다대일에서의 not equals 비교

query.filter(Address.user != someuser)

IS NULL 다대일 비교 (__eq__())

query.filter(Address.user == None)

contains() 일대다 컬렉션에서 사용

query.filter(User.addresses.contains(someaddress))

any() 컬렉션에서 사용

query.filter(User.addresses.any(Address.email_address == 'bar'))

# 키워드 아규먼트도 받음
query.filter(User.addresses.any(email_address='bar'))

has() scalar 레퍼런스서 사용

query.filter(Address.user.has(name='ed'))

Query.with_parent() 어떤 관계서든 사용

session.query(Address).with_parent(someuser, 'addresses')

선행 로딩 (Eager Loading)

lazy loading의 반대 개념으로 User.addressesUser 호출할 때 바로 불러오도록 하는 방법이다. eager loading으로 바로 불러오면 쿼리 호출의 수를 줄일 수 있다. SQLAlchemy는 자동화와 사용자정의 기준을 포함해 3가지 타입의 선행 로딩(eager loading)를 제공한다. 3가지 모두 query options로 제어하는데 Query에 불러올 때 Query.options() 메소드를 통해 쓸 수 있다.

서브쿼리 로딩

선행로딩하도록 User.addresses에 표기하는 방법이다. orm.subqueryload()를 이용해서 서브쿼리를 불러올 떄 한번에 연계해 불러오도록 처리한다. 기존의 서브쿼리는 재사용이 가능한 형태지만 이것는 바로 Query를 거쳐 선택되기 때문에 관계된 테이블을 선택하는 것과 상관없이 서브쿼리가 동작한다. 복잡해보이지만 아주 쉽게 쓸 수 있다.

from sqlalchemy.orm import subqueryload
jack = session.query(User).\
                options(subqueryload(User.addresses)).\
                filter_by(name='jack').one()
jack
# <User('jack', 'Jack Bean', 'sadfjklas')>
jack.addresses
# [<Address('jack@gmail.com')>, <Address('jack@yahoo.com')>]

연결된 로딩 (Joined Load)

또 다른 자동 선행로딩 함수로 orm.joinedload()가 있다. join할 때 사용할 수 있는 방법으로 관계된 객체나 컬렉션을 불러올 때 한번에 불러올 수 있다. (LEFT OUTER JOIN이 기본값) 앞서의 addresses를 동일한 방법으로 불러올 수 있다.

from sqlalchemy.orm import joinedload

jack = session.query(User).\
                options(joinedload(User.addresses)).\
                filter_by(name='jack').one()
jack
# <User('jack', 'Jack Bean', 'sadfjklas')>
jack.addresses
# [<Address('jack@gmail.com')>, <Address('jack@yahoo.com')>]

사실 OUTER JOIN 결과라면 두 행이 나타나야 하는데 여전히 User 하나만 얻을 수 있다. 이 이유는 Query는 엔티티를 반환할 때 객체 유일성을 위해 “유일하게 하기(uniquing)” 전략을 취한다.

joinedload()는 오랜동안 써왔지만 subqueryload() 메소드가 더 새로운 형태의 선행로딩 형태다. 둘 다 한 행을 기준으로 관계된 객체를 가져오는 것은 동일하지만 subqueryload()는 적합한 관계 컬렉션을 가져오기에 적합하고 반면 joinedload()가 다대일 관계에 적합하다.

joinedload()join()의 대체재가 아니다.

joinedload()으로 join을 생성하면 익명으로 aliased되어 쿼리 결과에 영향을 미치지 않는다. Query.order_by()Query.filter() 호출로 이런 aliased된 테이블을 참조할 수 없기 때문에 사용자 공간에서는 Query.join()을 사용해야 한다. joinedload()은 단지 관계된 객체 또는 콜랙션의 최적화된 내역을 불러오기 위해 사용하는 용도이기 때문에 추가하거나 제거해도 실제 결과엔 영향을 미치지 않는다. 더 궁금하면 선행 로딩의 도를 참고.

명시적 Join + 선행로딩

세번째 스타일의 선행 로딩은 명시적 Join이 primary 행에 위치했을 때 추가적인 테이블에 관계된 객체나 컬렉션을 불러온다. 이 기능은 orm.contains_eager()를 통해 제공되는데 다대일 객체를 미리 불러와 동일 객체에 필터링 할 경우에 유용하게 사용된다. 아래는 Address행에 연관된 User 객체를 가져오는 코드인데 “jack”이란 이름의 Userorm.contains_eager()를 사용해 user 컬럼을 Address.user 속성으로 선행로딩한다.

from sqlalchemy.orm import contains_eager
jack_addresses = session.query(Address).\
                            join(Address.user).\
                            filter(User.name=='jack').\
                            options(contains_eager(Address.user)).\
                            all()
jack_addresses
# [<Address('jack@gmail.com')>, <Address('jack@yahoo.com')>]
jack_addresses[0].user
# <User('jack', 'Jack Bean', 'sadfjklas')>

기본적으로 어떻게 불러오는지 설정하는 다양한 방법 등 선행 로딩의 추가적인 정보는 관계 불러오기 테크닉 문서를 참고.

삭제하기

jack을 삭제해보자. 삭제하고나면 count는 남은 행이 없다고 표시한다.

session.delete(jack)
session.query(User).filter_by(name='jack').count()
# 0

여기까진 좋다. Address 객체는 어떤지 보자.

session.query(Address).filter(
    Address.email_address.in_(['jack@gmail.com','jack@yahoo.com'])
).count()
# 2

여전히 남아있다. SQL을 확인해보면 해당 Address의 user_id 컬럼은 모두 NULL로 되어 있지만 삭제되진 않았다. SQLAlchemy는 제거를 종속적으로(cascade) 하지 않는데 필요로 한다면 그렇게 할 수 있다.

삭제/삭제-외톨이 종속처리 설정하기

cascade 옵션을 변경하기 위해서는 User.addresses의 관계에서 행동을 변경시켜야 한다. SQLAlchemy는 새 속성을 추가하는 것과 관계를 맵핑하는 것은 언제나 허용되지만 이 경우에는 존재하는 관계를 제거하는게 필요하므로 맵핑을 완전히 새로 시작해야한다. 먼저 Session을 닫는다.

session.close()

그리고 새 declarative_base()를 사용한다.

Base = declarative_base()

다음으로 User 클래스를 선언하고 addresses 관계를 종속처리 설정과 함께 추가한다. (생성자는 대충 두자)

class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    name = Column(String)
    fullname = Column(String)
    password = Column(String)

    addresses = relationship("Address", backref='user', cascade="all, delete, delete-orphan")

    def __repr__(self):
        return "<User('%s','%s','%s'>" % (self.name, self.fullname, self.password)

그리고 Address도 다시 생성한다. 이 경우에는 이미 User에서 관계를 생성했기 때문에 Address.user는 따로 생성할 필요가 없다.

class Address(Base):
    __tablename__ = 'addresses'
    id = Column(Integer, primary_key=True)
    email_address = Column(String, nullable=False)
    user_id = Column(Integer, ForeignKey('users.id'))

    def __repr__(self):
        return "<Address('%s')>" % self.email_address

이제 Jack을 불러오고 삭제하면 Jack의 addresses 컬랙션은 Address에서 삭제된다.

# jack을 primary key로 불러옴
jack = session.query(User).get(5)
# 첫 Address를 삭제 (지연 로딩이 동작한다)
del jack.addresses[1]
# address는 하나만 남는다
session.query(Address).filter(
    Address.email_address.in_(['jack@gmail','jack@yahoo.com'])
).count()
# 1

Jack을 지우면 Jack과 남은 Address도 삭제된다.

session.delete(jack)
session.query(User).filter_by(name='jack').count()
# 0
session.query(Address).filter(
    Address.email_address.in_(['jack@gmail.com','jack@yahoo.com'])
).count()
# 0

종속처리(cascade)에 대해

종속처리에 대한 더 자세한 설정은 Cascades 문서를 참고. 종속처리는 함수적으로 관련된 데이터베이스가 자연스럽게 ON DELETE CASCADE될 수 있도록 통합할 수 있다. Using Passive Deletes 문서 참고

다대다 관계(Many To Many Relationship) 만들기

일종의 보너스 라운드로 다대다 관계를 만드는 방법을 살펴본다. 블로그와 같은걸 만들 때를 예로 들면 BlogPost와 그에 따른 Keyword를 조합해야 하는 경우가 있다.

평범한 다대다 관계를 위해, 맵핑되지 않은 Table 구조를 조합 테이블로 만들 수 있다.

from sqlalchemy import Table, Text
# 조합 테이블
post_keywords = Table('post_keywords', Base.metadata,
    Column('post_id', Integer, ForeignKey('posts.id')),
    Column('keyword_id', Integer, ForeignKey('keywords.id'))
)

위 코드는 맵핑된 클래스를 선언하는 것과는 약간 다르게 Table를 직접 선언했다. Table은 생성자 함수로 각각 개별의 Column 아규먼트를 쉼표(comma)로 구분한다. Column 객체는 클래스의 속성명을 가져오는 것과 달리 이름을 명시적으로 작성해준다.

다음은 BlogPostKeywordrelationship()으로 post_keywords 테이블에 연결해 정의한다.

class BlogPost(Base):
    __tablename__ = 'posts'

    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey('users.id'))
    headline = Column(String(255), nullable=False)
    body = Column(Text)
    # 다대다 관계 : BlogPost <-> Keyword
    keywords = relationship('Keyword', secondary=post_keywords, backref='posts')

    def __init__(self, headline, body, author):
        self.author = author
        self.headline = headline
        self.body = body

    def __repr__(self):
        return "<BlogPost('%r', '%r', '%r')>" % (self.headline, self.body, self.author)

class Keyword(Base):
    __tablename__ = 'keywords'

    id = Column(Integer, primary_key=True)
    keyword = Column(String(50), nullable=False, unique=True)

    def __init__(self,keyword):
        self.keyword = keyword

위에서 BlogPost.keywords는 다대다 관계다. 다대다 관계를 정의하는 기능은 secondary 키워드로 연관 테이블인 Table객체를 참조한다. 이 테이블은 단순히 양측의 관계를 참고하는 형태며 만약 다른 컬럼이 있다면, 예를 들어 자체 primary key가 있거나 foreign key를 가진다면 연관 객체(association object) 라는 다른 형태의 사용패턴을 사용해야 한다. 연관 객체 문서 참고.

그리고 BlogPost 클래스는 author필드를 가진다. 그래서 다른 양방향 관계를 만들 것인데 단일 사용자가 엄청나게 많은 블로그 포스트를 가질 수 있다는 문제점을 처리해야한다. 다시 말해 User.posts에 접근하면 모든 포스트를 불러올 것이 아니라 일부 필터된 결과만 가져와야 한다. 이런 경우를 위해 relationship()lazy='dynamic'을 지원하는데 속성을 불러오는 전략의 대안 중 하나다. 이것을 relationship()의 역방향으로 사용하려면 backref()를 사용하면 된다.

from sqlalchemy.orm import backref
# User에서의 관계를 "다이나믹" 로딩 처리
BlogPost.author = relationship(User, backref=backref('posts', lazy='dynamic'))

그리고 새 테이블을 생성한다.

Base.meta.create_all(engine)

사용 방법은 크게 다르지 않다.

wendy = session.query(User).\
                filter_by(name='wendy').\
                one()
post = BlogPost("Wendy's Blog Post", "This is a test", wendy)
session.add(post)

지금 키워드는 데이터베이스에 각각 유일하게 저장한다. 아직 뭔가 거창한걸 한건 아니고 그냥 생성할 뿐이다.

post.keywords.append(Keyword('wendy'))
post.keywords.append(Keyword('firstpost')) 

이제 키워드가 ‘firstpost’인 모든 글을 찾아볼 것이다. 여기서 any 연산자로 ‘firstpost’인 글을 찾는다.

session.query(BlogPost).\
        filter(BlogPost.keywords.any(keyword='firstpost')).\
        all()
# [BlogPost("Wendy's Blog Post", 'This is a test', <User('wendy','Wendy Williams', 'foobar')>)]

만약 Wendy의 포스트만 보고 싶다면,

session.query(BlogPost).\
        filter(BlogPost.author=wendy).\
        filter(BlogPost.keywords.any(keyword='firstpost')).\
        all()
# [BlogPost("Wendy's Blog Post", 'This is a test', <User('wendy','Wendy Williams', 'foobar')>)]

또는 Wendy가 소유하고 있는 posts 관계 즉 dyanmic 관계를 이용해 불러오는 방법은 아래와 같다.

wendy.posts.\
    filter(BlogPost.keywords.any(keyword='firstpost')).\
    all()
# [BlogPost("Wendy's Blog Post", 'This is a test', <User('wendy','Wendy Williams', 'foobar')>)]

이후 읽어볼 만한 문서

(주. 아마 아래 문서 중 세션과 관계 문서를 먼저 옮길 것 같습니다.)

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.