메인 브라우저로 Google Chrome을 상당히 오래 사용해왔다. OS와 상관 없이 일관적으로 제공하는 수많은 핫키들과 특히, 강력한 개발자도구로 편리하게 이용하고 있었다.

하지만 근래들어 불안하다 느껴질 정도로 메모리 사용량도 증가하고 멈추는 경우도 종종 있어서 브라우저를 Apple Safari로 사용해보려고 찾아봤다.

일반 단축키

  • 특정 순서 탭 바로 이동하기 : Cmd + 1,2,3,4확장 설치 필요
  • 바로 전에 닫은 창 다시 열기 : Cmd + Z (크롬서는 Cmd + Shift + T)
  • 앞탭 뒷탭 이동하기 : Ctrl + Tab, Ctrl + Shift + Tab (모든 브라우저 동일)
  • 주소창 바로가기 : Cmd + L (모든 브라우저 동일)
  • Reader 화면 바로보기 : Cmd + Shift + R

개발자도구 단축키

Apple Safari Developer Tools

개발자도구가 더욱 xcode스러워졌다. xcode에서 개발하기 익숙한 사람은 대다수의 UI를 유사하게 차용한 사파리의 개발자도구가 크롬의 도구보다 사용하기 편할 듯 싶다. 이처럼 일관적인 개발 경험을 제공하는 것도 좋은 전략으로 보인다. (이런 면에서 IE의 개발자도구는 개선의 여지가 많다.)

  • 개발자도구 열기 : Alt + Cmd + I
  • 개발자도구 콘솔 바로 열기 : Alt + Cmd + C
  • 인스펙팅 사용하기 : 개발자도구 연 상태에서 Shift + Cmd + C

Pocket extesion 설치

스크랩을 위해 자주 사용하는 Pocket은 Safari를 위한 확장도 제공한다. Pocket 웹사이트에서 확장을 설치할 수 있다.

Safari 확장 관련 사이트

AngularJS{{}} 방식의 인터폴레이션 마크업을 사용하는데 flask(jinja)django 등에서 {{ foo }} 형태의 템플릿 마크업을 이미 사용하고 있어서 문제가 된다. 이런 경우를 위해 AngularJS에서 $interpolateProvider를 지원하는데 이를 이용해 문제를 회피할 수 있다.

var customInterpolationApp = angular.module('customInterpolationApp', []);

customInterpolationApp.config(function($interpolateProvider) {
    $interpolateProvider.startSymbol('{@');
    $interpolateProvider.endSymbol('@}');
});

자세한 내용은 AngularJS의 $interpolateProvider 문서에서 확인할 수 있다.

요즘 한참 핫(!)한 빅데이터 스터디에 참여하게 되었다. AWS에서는 사실 EMR을 지원하는 등 직접 설치할 일이 없다고 하는데 1EC2 Micro 인스턴스에 Hadoop을 실습을 위해 설치했다. 예/복습 차원에서 간략하게 스터디 내용을 정리해보려고 한다.


Apache Hadoop

**Apache Hadoop(High-Availability Distributed Object-Oriented Platform)**은 Apache 재단에서 진행하는 오픈소스 프로젝트로 대량의 자료(빅데이터)를 처리할 수 있는 분산 시스템을 구축할 수 있게 돕는 소프트웨어 라이브러리다. 이 프로젝트는 다음의 모듈을 포함하고 있다.

  • Hadoop Common: Hadoop 모듈을 지원하기 위한 일반적인 유틸리티들
  • Hadoop Distributed File System (HDFS): 어플리케이션 데이터를 대량으로 처리할 수 있게 접근이 가능한 분산 파일 시스템
  • Hadoop YARN: job 스케쥴링과 클러스터 리소스 관리를 위한 프레임워크
  • Hadoop MapReduce: YARN을 기반으로 한 대형 데이터 셋 병렬처리 시스템

Hadoop HDFS 간단히 알기

HDFS는 namenodedatanode로 구성되어 있다. Namenode는 HDFS의 파일 구조를 저장하는 영역이고 datanode는 실제 파일을 저장하는 영역이다. 일반적으로 사용되는 NTFS나 FAT같은 파일 시스템은 파일 구조의 위치에 실제 파일이 저장되지만 2HDFS는 그 파일 경로와 실제 파일이 분리되어 각각의 노드에 저장되는 형태이다. Namenode에는 datanode가 어디에 존재하는지 저장되어 있으며, 클라이언트가 해당 파일을 namenode에 요청하면 저장되어 있는 datanode를 알려줘 파일을 내려받을 수 있도록 돕는다. 이와 같이 HDFS는 구조(namenode)와 데이터(datanode)를 분리했기 때문에 데이터의 분산 처리가 가능하다.

Datanode는 자료를 분산해서 저장함과 동시에 복제본을 담아둬 데이터를 유실했을 때를 대비한다.3

Namenode는 HDFS의 모든 파일 명령 수행 목록을 Edits에 저장을 한다. FsImage는 Edits를 병합해 기록해둔 파일이다. Secondary namenode는 Namenode를 대체하거나 하진 않고 Edits 파일을 불러다가 FsImage로 병합해 namenode에게 돌려주는 역할을 한다.

Hadoop MapReduce 간단히 알기

구조와 데이터를 분산해서 처리한 것과 같이 데이터를 다루는 프로그램도 분산해서 각각의 데이터를 처리할 수 있도록 도와주는 것이 MapReduce이다.

MapReduce는 Job trackerTask tracker로 구성이 되어 있는데 Job tracker는 Task tracker의 할 일을 관리하는 역할이고 Task tracker는 각 분산된 datanode에서 연산과정을 처리하는 역할을 한다.

AWS에 Hadoop 설치하기

설치 준비하기

AWS에서 인스턴스 생성해서 콘솔로 접속한다. AWS 인스턴스를 시작하는 것은 검색하면 많이 나온다. ubuntu 12.04 LTS를 선택했다.

$ ssh ubuntu@instance-address

jdk를 설치한다.

$ sudo apt-get update
$ sudo apt-get install openjdk-6-jdk

Hadoop을 아파치 다운로드 사이트에서 찾아 내려받고 압축을 해제한다.

$ wget http://apache.mirror.uber.com.au/hadoop/common/hadoop-1.2.1/hadoop-1.2.1.tar.gz
$ tar xvfz hadoop-1.2.1
$ cd hadoop-1.2.1

Hadoop 환경 설정

Hadoop은 Java 기반이므로 사용할 환경의 경로를 지정해야 한다. conf/hadoop-env.sh을 열어 경로를 지정한다.

$ vim conf/hadoop-env.sh

JAVA_HOME의 주석을 지우고, jdk의 경로를 정해준다.

# Set Hadoop-specific environment variables here.

# The only required environment variable is JAVA_HOME.  All others are
# optional.  When running a distributed configuration it is best to
# set JAVA_HOME in this file, so that it is correctly defined on
# remote nodes.

# The java implementation to use.  Required.
export JAVA_HOME=/usr/lib/jvm/java-6-openjdk-amd64/jre

# Extra Java CLASSPATH elements.  Optional.
# export HADOOP_CLASSPATH=

Pseudo-Distibuted로 동작할 것이므로 각각의 configuration을 작성해준다.

core-site.xml에서 fs.default.name 프로퍼티는 namenode가 동작하는 서버를 적어준다. secondary namenode, datanode, task tracker는 이 프로퍼티를 참고한다.

$ vim conf/core-site.xml

파일에서 아래 부분을 추가한다.

<configuration>
    <property>
        <name>fs.default.name</name>
        <value>hdfs://localhost:9000</value>
    </property>
</configuration>

hdfs-site.xml에서 dfs.replication 프로퍼티는 복제 개수를 의미한다. 이 숫자가 3이면 동일한 데이터를 3개로 중복으로 저장해 유실을 방지할 수 있다.

$ vim conf/hdfs-site.xml

파일에서 아래 부분을 추가한다.

<configuration>
    <property>
        <name>dfs.replication</name>
        <value>1</value>
    </property>
</configuration>

mapred-site.xml에서 mapred.job.tracker 프로퍼티는 task tracker를 위해 job tracker가 동작하는 서버를 적어준다.

$ vim conf/mapred-site.xml

파일에서 아래 부분을 추가한다.

<configuration>
    <property>
        <name>mapred.job.tracker</name>
        <value>localhost:9001</value>
    </property>
</configuration>

SSH 설정하기

각각의 node와 tracker끼리 데이터를 주고 받을 때를 위해 ssh key를 등록해준다.

$ ssh-keygen -t rsa -P ""
# 경로는 기존에 생성한 키가 없다면 기본 경로로 해도 된다.
$ cat /home/ubuntu/.ssh/id_rsa.pub >> /home/ubuntu/.ssh/authorized_keys
# 추가한 ssh key를 허용된 키로 등록해준다.

키가 제대로 설정되었는지는 다음의 명령어로 확인해볼 수 있다.

$ ssh localhost
# 이러면 localhost에 다시 접속된다.
$ exit

Hadoop 실행하기

$ pwd
/home/ubuntu/hadoop-1.2.1

Hadoop을 실행하기 이전에 namenode를 초기화한다.

$ bin/hadoop namenode -format

그다음 start-all.sh를 실행한다.

$ bin/start-all.sh
starting namenode, logging to /home/ubuntu/hadoop-1.2.1/libexec/../logs/hadoop-ubuntu-namenode-ip-10-240-106-45.out
localhost: starting datanode, logging to /home/ubuntu/hadoop-1.2.1/libexec/../logs/hadoop-ubuntu-datanode-ip-10-240-106-45.out
localhost: starting secondarynamenode, logging to /home/ubuntu/hadoop-1.2.1/libexec/../logs/hadoop-ubuntu-secondarynamenode-ip-10-240-106-45.out
starting jobtracker, logging to /home/ubuntu/hadoop-1.2.1/libexec/../logs/hadoop-ubuntu-jobtracker-ip-10-240-106-45.out
localhost: starting tasktracker, logging to /home/ubuntu/hadoop-1.2.1/libexec/../logs/hadoop-ubuntu-tasktracker-ip-10-240-106-45.out

namenode, datanode, jobtracker, tasktracker가 순서대로 실행되는 것을 확인할 수 있다. 현재 실행되고 있는지 확인하려면 jps로 jvm에서 구동되고 있는 프로세스 항목을 확인할 수 있다.

$ jps
10379 NameNode
11044 TaskTracker
10852 JobTracker
11192 Jps
10562 DataNode
10753 SecondaryNameNode

종료는 stop-all.sh를 실행하면 된다.

$ bin/stop-all.sh

Hadoop Mode

Hadoop은 아래 세가지의 모드로 설치할 수 있다.

  • Local (Standalone) Mode
  • Pseudo-Distributed Mode
  • Fully-Distributed Mode

위에서 진행한 방식은 Pseudo-Distributed Mode로 하나의 서버에서 namenode, datanode, job tracker, task tracker를 모두 운용하는 모드다. 진짜(?) hadoop은 3번째 모드라고 하니 다음 글에선 3번째 모드로 진행하는 방법을 알아볼 것이다.

Footnotes

  1. 잘 모른다;;

  2. 내부적으로 어떻게 동작하는지는 잘 모름;

  3. 이 복제 수를 hdfs-site.xml에서의 dfs.replication 프로퍼티로 제어한다.

.Net 스터디를 대비해 개발 환경을 설치한 과정을 기록해둔 포스트. 이전 MonoDevelop에 비해 훨씬 뛰어난 모습의 Xamarin Studio와 각종 add-in package로 mac OSX에서의 .Net 개발 환경을 구축할 수 있었다. 스터디는 Visual Studio 기준이라서 아마 가상 환경을 구축하게 될 것 같지만 그러기엔 에어 용량이 많이 허덕여서 일단 급한대로 mono 환경을 다시 세팅했다.

Mono란?

Mono는 개발자들이 쉽게 크로스 플랫폼 어플리케이션을 만들 수 있도록 고안된 소프트웨어 플랫폼이다. Xamarin사에서 지원하며, Mono는 Microsoft .NET 프레임워크를 구현한 오픈소스이며 ECMA 표준을 따르는 C#과 공용언어 런타임을 기반으로 하고 있다. Mono 환경과 Xamarin Studio와 함께 .Net 개발을 시작할 수 있다. 1현재 안드로이드, iOS 등 광범위한 영역의 크로스 플랫폼을 구현하고 있다.

Mono와 Xamarin Studio 설치

  1. Mono 웹사이트에서 Mono SDK인 MDK를 받는다. JRE, JDK처럼 MRE, MDK가 있는데 MDK를 설치하면 된다.

Xamarin Studio

  1. Xamarin 웹사이트에서 Xamarin Studio를 내려받는다.

둘다 받아 설치하면 일단 끝난다.

최신의 MSBuild를 사용하기 위해서는 Xamarin Studio > Preferences... 로 들어가서 Project > Load/Save 항목을 눌러 최신 버전의 MSBuild를 선택한다.

NuGet을 Xamarin Studio에 설치하기

NuGet은 .Net을 포함한, Microsoft 개발 환경 플랫폼을 위한 패키지 매니저다. PyPI, npm 같이 편리하게 리포지터리에서 받아 사용할 수 있다.

Mac에서 사용하고자 하면 이전엔 CLI를 이용해 설치하는 방법이 있었지만 Xamarin Studio에서 바로 사용할 수 있도록 add-in으로 만들어뒀다. 덕분에 편리하게 설치하고 사용할 수 있다.

  1. Xamarin Studio > Add-in Manager로 들어간다.
  2. Gallery 탭에서 Repository를 선택해 Manage Repositories에 들어간다.
  3. Add를 누른 후 자신의 Xamarin Studio 버전에 맞는 NuGet Add-inNuGet GitHub 페이지에서 찾아 추가한다.
  4. Refresh 버튼을 눌러 갱신한 후 Nuget Package Management를 검색해 설치한다.
  5. 이제 각 Project에서 오른쪽 클릭하면 Manage NuGet Packages를 볼 수 있으며 눌러 설정할 수 있다.

NuGet 실행 화면

기존의 project가 load failed 되는 경우

solution을 불러오면 몇 project에서 불러와지지 않는 문제가 나타나는데 해당 프로젝트의 csproj를 열어 <ProjectTypeGuids> 항목을 지워주면 정상적으로 불러온다.


기본적인 설치를 마치고 .Net 스터디에서 작성했던 코드를 받아 구동해봤는데 정상적으로 잘 동작한다. Xamarin Studio는 예전 MonoDevelop을 생각하면 엄청나게 좋아졌다는 것을 느낄 수 있다.

Footnotes

  1. MS에서 제공하는 것에 비해 모자란 점이 있긴 하지만 꾸준히 성장하고 있다.

다음의 파이썬 코드에서 Bus 클래스가 Base를 상속을 받을 수 있는가. 다시 말해 어떻게 변수를, 또는 인스턴스를 class가 상속을 받을 수 있는가? 라는 질문을 받았다.

Base = declarative_base()
class Bus(Base):
    __tablename__ = 'bus' 
    ....

듣고 나도 혹해서 어떻게 이게 가능하지? 이러고서 모듈을 들여다봐도 이해가 안되게 너무 커서 이해가 전혀 되질 않았다. 잠도 안오고 그래서 메일링 리스트에 올리니 바로 답변이 왔다.

>>> def make_human(): 
...    class Human(object): 
...       def say(self): 
...          print "I'm a human"
...    return Human
... 
>>> dude = make_human() 
>>> edward = dude() 
>>> edward.say() 
I'm a human
>>> 

파이썬에선 클래스도 반환이 가능하다.

근래 간단한 서비스를 만들고 있는데 시작부터 시간대로 인한 문제가 있어 이 기회에 제대로 살펴보게 되었다. 한국에서 개발할 때는 단 한번도 생각해본 적이 없던 시간대 문제에 대해서 찾아볼 수 있게 되어 참 좋았고, 국가가 시간대를 변경함에 따라 역사적으로 사라진 시간들이 존재한다는 점, 동부표준시(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 커뮤니티 답다.

Canon EOS 6D and EF 40mm F/2.8

어릴 때부터 사진에 대한 관심이 많았지만 카메라는 커녕 필름이 비싸서, 정말 특별한 날에 일회용 카메라로 만나는 사진이 전부였다. (그것도 너무나도 행복했는데.) 덕분에 카메라 이론 서적들을 오랜 기간 카메라 없이 탐독해왔는데 디지털 시대로 넘어가면서 여러 디지털 카메라와 마주 할 수 있었다. Kodak LS420을 시작으로 쿨이오라는 애칭으로 유명한 Nikon Coolpix 2500, 밝은 렌즈와 코닥 특유의 색감이 돋보였던 Kodak DX6340, 고질적인 기판 문제가 있던 DX6340을 교환판매 받아 구입한 하이엔드 카메라 Kodak P880까지. 카메라와 함께 한 지난 시간을 돌아보면 디지털의 혜택을 정말 많이 누렸다는 생각이 든다.

코닥 카메라랑은 긴긴 인연이 있었는데 덕분에 코닥동인 코닥포유에서도 많은 활동도 했었다. 코닥에서 더이상 카메라를 생산하지 않게 되면서 여러가지 안타깝게 되었지만. 생각해보면 셀빅이나, 아이비, 자우르스, 코닥 카메라까지 내가 깊게 손댄 것들은 어째 현대에 남아나질 않았다. 내가 마이너스의 손이라도 되는건가.

오랫동안 개인적으로 촬영해 왔지만 전문적인 시야는 군생활에서 생겼다. 공군 40710 전자광학정비 1특기를 받아 정훈계통에서 사진병으로 근무하면서 많이 배우게 되었고 더 많이 촬영해 볼 수 있던 좋은 기간이었다. 군생활동안 5D mark2와 D300를 사용했었는데 전역 후에 DSLR을 구입해야지 계속 생각만 하다가 절대 저렴하지 않는 비용에 계속 미뤄왔었다. 호주에 오면서도 사고 싶었지만 DSLR 대신 Olympus XZ-1를 구입했었다. 나름 요긴하게 사용해오긴 했지만 고민을 계속하다가 결국 상대적으로 저렴한(절대치는 여전히 비싸긴 한) 캐논 EOS 6D를 구입하게 되었다.

바디는 크게 고민하지 않았는데 대신 렌즈를 많이 고민했다. 24-70mm 같은 렌즈를 사용하기엔 크기도 가격도 부담이라서 단렌즈를 두개 구입해야겠다 생각하고 있었는데, 50mm 1.4를 구입할지 40mm 2.8 팬케익을 구입할지 한참 고민하다 결국 팬케익 렌즈를 구입, 그리고 80mm 1.8을 구입했다. 액정커버도 구입해서 붙였고 핸드스트랩도 불렀고(해링본이 국산 브랜드인줄 처음 알았다) 이제 카메라 가방만 사면 마음껏 출사를 나갈 수 있을듯 싶다.

안드로이드 어플로 사용할 수 있는 wifi 무선 릴리즈라든가, DSLR 같지 않은 가벼움이라든가 여러가지 만족중이다. 여태 촬영하지 못했던, 더 많은 것들 촬영할 수 있음 좋겠다.

Footnotes

  1. 지금은 병사도 정훈 특기가 신설되었고 40710은 전자계통으로 통합되어 더이상 존재하지 않는 특기가 되었다.

모두가 더운 여름에 허덕이고 폭우다, 폭염이다 고생하는 이야기를 하는데 겨울 이야기를 쓰자니 조금 민망하긴 하다. 오랜만에 블로그를 보는데 근황을 기록한지 오래된 것 같아 근황을 적어보려고 한다.

8월의, 두번째 겨울

작년 겨울은 유독 길었다. 한국서 겨울이 끝나가는 즈음에 호주에 와서 또 긴 겨울에 접어들어 거의 1년 내내 겨울이다시피 지냈는데 더운 여름을 지나고 두번째 겨울을 맞이했다. 한국의 추위에 비해 그닥 춥지 않은 편이긴 하지만 집이 한국처럼 난방으로 따뜻한 형태가 아니다보니 훨씬 춥게 느껴진다.

매 겨울마다 감기에 놓치지 않고 걸리는 편에다 한 번 걸리면 한 두 달 가까이 고생하는 터라 겨울이 영 반갑지 않다. 지난 달에 감기가 왔었는데 작정하고 일주일 약 챙겨먹고, 밥 잘 챙겨 먹고, 단단하게 입고 다니고, 하루 8시간 이상 숙면했더니 일주일 만에 다 나아버렸다. 감기를 다루는 방법을 배운 것 같다;

호주에서 일하기

이전 근황글에 남겼던 그 회사에서 여전히 일하고 있다. 여기서 일하게 된지 벌써 1년여 되었고 근래에 퍼포먼스 리뷰도 했다. 시간이 참 빠르다.

Joomla!나 WordPress 외 여러 CMS로 작업을 하고 있고, 그런 탓에 실제 개발보다 문서나 코드를 읽는데 쓰는 시간이 근래 들어 더 많은 것 같다. 더군다나 올해들어 프로젝트 수주가 많아지면서 뭔가 무지무지 바쁜 상태가 되어버렸다. 그 덕에 일상도 뭔가 항상 바쁘게 돌아가는 기분.

문서 번역하기

처음엔 영어 공부 일환이라고 운을 띄우고 시작한 일인데 영어 공부보다 한국어 공부에 더 도움이 되는 일 같다. 사실 올해들어 조금씩 해오던 일이라 해둔 것은 많은데 다시 읽어도 영 매끄럽지 않아 저장해둔게 여럿이다. (근래에 올린 SQLAlchemy는 진짜 개판인데도 그게 나아진 결과물이라는게 함정.)

여전히 영어 실력이 서툰데다가 공부도 잘 안하고 있으니 문장을 온전히 이해하기도, 한국어로 옮기기에도 쉽지 않은 과정 중에 있다. 발번역이라고 욕이라도 먹어야 더 부지런히 할 것 같아서 조금씩이라도 포스트 하려고 한다.

Python 공부

PHP 이외에 뭔가 다른걸 배워보고 싶어 일단 Python을 시작했다. 1Python은 언어 자체가 주는 에너지가 참 재미있다. 커뮤니티 구성원도 재미있고 독특하고 문서화가 소소한 부분까지 잘 되어 있다보니 읽을 거리도 많다. 2요즘은 flask, tornado, SQLAlchemy를 유심히 보고 있다. 조만간 간단한 서비스를 이걸로 만들어 볼 것 같다.

미드 챙겨보기, 오피스 종방

미드를 꾸준히 챙겨보는 편3인데 거의 5년 넘게 챙겨본 The Office가 시즌9을 마지막으로 마무리했다. 오랜 기간 봐 온 드라마라 기분도 이상하고 결말도 후다닥 마무리해버린 감이 적잖게 있지만, 종방 기념으로 시즌1부터 정주행하고 있다. 4그 외에도 How I Met Your Mother와 The Big Bang Theory도 시즌 맞춰서 챙겨보고 있다. 미드는 공부 핑계로 계속 챙겨볼 것 같다.

글쓰기

블로그나 트위터, 페이스북에 개인적인 이야기를 잘 안쓰게 된 이유가 사실 남몰래 텀블러를 만들어 그곳에 작성하고 있어서 그렇다. 짧은 글이든 긴 글이든 다 거기에 쓰고 있어서 다른 글쓰기에는 뜸해지고 있다.

그 외

조만간 정든 카레하우스집을 떠나 새로운 곳으로 이사가게 되는데 지금보다 통근 거리가 많이 멀어지는 것 말고는 더 좋은 곳이라 기대하는 중이다. 게다가 요즘 잘 안하고 게으름 피는 모든 일들이 이사를 가면 해결이 될거란 막연한 기대까지 하는 중이다.

요즘 글읽기는 구글리더 이후, Feedly 와 Pocket을 잘 활용하고 있다. Feedly는 어쩔 수 없이 선택한 반면에 Pocket은 정말 환상적. Pocket은 예전 read it later로 글 담아두고 나중에 읽을 수 있도록 해주는, 일종의 스크랩 도구다. 브라우저에서도 간편하게 담을 수 있고, 앱에서 오프라인 캐싱도 되서 편리.

요즘 소셜 큐레이션 서비스를 많이 살펴보고 있다. Editoy, Storify, Pinterest, Paper.li 등등.

DSLR을 살까 고민중이다. 이 고민은 거의 7년 가까이 한 고민인데 구입 시기가 가까웠음을 근래 느끼고 있다. 6D+40mm+85mm로 시작할 것 같다.

무엇이든 꾸준히 하는게 참 쉽지 않아서 꾸준히 하는 것을 도와주는 서비스가 있었음 좋겠다.

Footnotes

  1. 닷넷도 배워보고 싶었는데 맥북 에어에 Visual Studio까지 돌리기엔 하드가 너무 작았다. Mono로도 시도해보긴 했지만 뭔가 잘 모르겠더라.

  2. 위에 문서번역도 사실 Python 공부를 시작하면서 덩달아 시작했다.

  3. 미드도 영어공부의 일환으로 시작했다. 영어공부의 일환이라는 표현이 참 편한 합리화 수단 중 하나인듯.

  4. 시즌 사이 사이 휴방기 때에도 앞서부터 복습을 자주 하는데 그래서 그런지 내용이 들린다. 반복 학습이 짱!

앞서 작성한 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 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.