SitePoint에 게시된, Bruno Skvorc의 Starting a New PHP Package The Right Way 포스트를 번역한 글이다. PHP는 autoload를 이용한 composer를 비롯 다양한 모듈화 방법이 논의되어 실제로 많이 활용하고 있다. PHP의 최근 동향을 살펴 볼 수 있는 포스트라 생각해 한국어로 옮겼다. 연재는 총 3회 분이며 이 포스트는 1회에 해당한다.

Bruno and Ophélie at SitePoint! Thanks for giving me the opportunity to translate your great article. 🙂


시각적 인공지능 기계학습 크롤러인 Diffbot을 이야기 할 때, 이 라이브러리를 사용할 수 있는 많은 프로그래밍 언어를 언급했다. 이 많은 언어를 동시에 보고 있으면 거기엔 미끄러져 흠집이 난, 상한 사과도 있기 마련이다. 그 사과 중 하나인 PHP 라이브러리는 이 연재에 기반해 더 나은 라이브러리를 만들고자 한다.

이 튜토리얼은 좋은 패키지를 어떻게 작성할 것인가에 중점을 두고 있으며 코드는 실제적이고 실무적일 것일 것이지만 Diffbot 자체에 지나치게 집중하진 않을 것이다. Diffbot의 API는 충분히 단순하고 Guzzle의 인터페이스는 PHP 라이브러리 없이도 사용하기 간편하다. 양질의 PHP 패키지를 개발하는데 어떻게 사용할 것인가에 대한 접근 방식은 당신의 프로젝트에서 어떻게 활용해야 할지 배울 수 있다. Diffbot이 패키지의 주제로 선택된 이유는 실제적인 예제로서 입증하는 것이지 단순히 다른 “홍길동” 패키지를 만드는 것을 의미하지 않는다.

좋은 패키지 디자인

최근 몇 년 동안, PHP 패키지 디자인을 위한 좋은 표준들이 많이 나왔다. Composer, Packagist, The league 그리고 가장 최근에 The Checklist까지 말이다. 다음 나오는 항목들을 준수한다면 The League를 제외하고는 패키지를 제출할 수 있다. (물론 여기서 작성하는 패키지는 제출하지 않기 바란다. – 우린 단지 서드 파티 API 제공자를 위해 만들었고 또한 아주 제한적인 컨텍스트만 제공하기 때문이다.) 우리가 따를 규칙은 다음과 같다:

  1. 저작권을 포함할 것
  2. 오픈소스여야 할 것
  3. 개발 관련 부분을 배포본과 분리할 것
  4. PSR-4 autoloading 을 사용할 것
  5. Composer 설치를 위해 Packagist에서 호스팅 할 것
  6. 프레임워크에 상관 없이 사용 가능할 것
  7. PSR-2 코딩 표준을 준수할 것
  8. 깊이 있는 주석을 포함할 것
  9. 유의적 버전을 사용할 것
  10. CI와 유닛 테스트를 사용할 것

이 항목들에 대해 더 자세히 알고 싶다면 다음 문서를 참고한다.

시작하기

여기서 Homestead Improved 환경을 다시 사용할 것인데 이는 가장 빠르게 통일된 환경에서 개발할 수 있도록 도와준다. 참고로 다음과 같은 가상 환경을 통해 이 튜토리얼을 진행할 예정이다:

sites:
  - map: test.app
    to: /home/vagrant/Code/diffbot_lib
  - map: test2.app
    to: /home/vagrant/Code/diffbot_test

이렇게 VM을 통해 hacking을 해보자.

바닥부터 시작하기 위해 League Skeleton을 사용한다. League의 규칙이 적용된 템플릿 패키지로 쉽게 시작하는데 도움이 된다. 원래 리포지터리에서 fork한 이 리포지터리는 원래 템플릿보다 개선된 .gitignore가 첨부되어 있고 몇가지 소소한 수정이 포함되어 있다. 만약 원하지 않는다면 원본을 사용하고 차이점은 직접 비교해보기 바란다.

git clone https://github.com/Swader/php_package_skeleton diffbot_lib

이제 composer.json을 다음과 같이 수정할 차례다:

{
  "name": "swader/diffbot_client",
  "description": "A PHP wrapper for using Diffbot's API",
  "keywords": [
    "diffbot", "api", "wrapper", "client"
  ],
  "homepage": "https://github.com/swader/diffbot_client",
  "license": "MIT",
  "authors": [
    {
      "name": "Bruno Skvorc",
      "email": "bruno@skvorc.me",
      "homepage": "http://bitfalls.com",
      "role": "Developer"
    }
  ],
  "require": {
    "php" : ">=5.5.0"
  },
  "require-dev": {
    "phpunit/phpunit" : "4.*"
  },
  "autoload": {
      "psr-4": {
          "Swader\\Diffbot\\": "src"
      }
  },
  "autoload-dev": {
      "psr-4": {
          "Swader\\Diffbot\\Test\\": "tests"
      }
  },
  "extra": {
      "branch-alias": {
          "dev-master": "1.0-dev"
      }
  }
}

우리는 일반적인 메타 데이터를 설정하고 요구 항목을 정의했고 PSR-4 autoloading을 설정했다. 이 과정이 위 목록에서 1-6번 항목에 해당한다. 여기서 Diffbot API를 호출할 수 있도록 돕는 HTTP 클라이언트 라이브러리 Guzzle을 요구 항목에 추가한다.

"require": {
  "php" : ">=5.5.0",
  "guzzlehttp/guzzle": "~5.0"
},

composer install을 실행하면 모든 의존성을 내려 받는다. 테스트를 위해 포함한 PHPUnit도 포함이 되는데 이 모든 것이 동작하는지 확인하기 위해 다음과 같이 src/SkeletonClass.php를 변경할 수 있다:

<?php
namespace Swader\Diffbot;
class SkeletonClass
{
  /**
   * Create a new Skeleton Instance
   */
  public function __consturct()
  {
  }

  /**
   * Friendly welcome
   *
   * @param string $phrase Phrase to return
   * @return string Returns the phrase passed in
   */
  public function echoPhrase($phrase)
  {
    return $phrase;
  }
}

그리고 프로젝트 최상위 경로에 index.php도 다음과 같이 추가한다:

<?php
require_once "vender/autoload.php";
$instance = new \Swader\Diffbot\SkeletonClass();
echo $instance->echoPhrase("It's working");

이제 브라우저로 test.app:8000에 접속해보면 “It’s working” 메시지를 볼 수 있다.

아직 public 디렉토리나 이와 같은 파일을 만들지 않았다고 걱정하지 말자. 패키지를 만들 때에는 중요하지 않다. 라이브러리를 만들 때에는 패키지에만 집중해야 하고 또한 프레임워크나 MVC가 아닌 패키지만 있어야 한다. 그리고 index.php를 잠깐 잠깐 테스트 용도로 사용하겠지만 대부분 PHPUnit을 통해 라이브러리를 개발할 것이다. 여기서는 index.php를 실수로 리포지터리에 보내는 일이 없도록 .gitignoreindex.php를 추가하도록 한다.

PSR-2

현대적인 표준을 따르기 위해 PSR-2를 준수해야 한다. PhpStorm을 사용한다면 엄청 쉬운 일이다. 내장되어 있는 PSR1/PSR2 표준을 선택하거나 CodeSniffer 플러그인을 설치해 PhpStorm 인스팩션으로 활성화시켜 사용할 수도 있다. 아쉽게도 예전 방법을 활용해야 하는데 PHPStorm이 아직 phpcs의 원격 실행을 지원하지 않기 때문이다. (Vagrant VM도 결국 원격 환경이므로. 만약 이 기능을 PHPStorm에 탑재하기 원한다면 여기에서 투표할 수 있다.)

어찌 되었든 CodeSniffer가 평소처럼 프로젝트에 필요하기 때문에 composer를 통해 설치하고 VM의 커맨드라인에서 구동하자:

composer global require "squizlabs/php_codesniffer=*"
phpcs src/
# 코드를 검사해 리포트를 보여준다

계획하기

지금까지 만든 틀로 본격적인 개발을 시작할 수 있다. 이제 필요한 부분을 생각해보자.

시작점

Diffbot API를 어떻게 사용하든지 사용자는 API 클라이언트의 인스턴스를 생성하길 원할 것이다. 이런 경우 미리 만들어진 API를 호출하는 것 이외에는 할 수 있는 것이 없다. 각각의 API를 사용해서 요청을 쿼리 파라미터로 보내려면 폼에서 ?token=xxxxxx와 같이 개발자 토큰이 필요하다. 단일 사용자는 하나의 토큰만 사용할 것이기 때문에 새 API 클라이언트 인스턴스를 생성할 때 토큰을 생성자에 넣어 생성해야 한다. 물론 모든 인스턴스를 생성할 때 활용하기 위해 전역 토큰으로 생성해 사용할 수도 있다. 위에서 이야기 한 두 가지 방법을 다음과 같이 표현 할 수 있다:

$token = xxxx;

// approach 1
$api1 = new Diffbot($token);
$api2 = new Diffbot($token);

// approach 2
Diffbot::setToken($token);
$api1 = new Diffbot();
$api2 = new Diffbot();

전자는 단일 API 클라이언트 인스턴스를 생성할 때 또는 여러 토큰을 사용할 때(예를 들면, 토큰 하나는 Crawlbot에 다른 하나는 일반적인 API를 사용할 때) 도움이 된다. 후자는 자신의 어플리케이션이 여러 API 엔드포인트에서 여러 차례 사용될 때 매번 토큰을 주입하는게 번거로울 때 활용할 수 있다.

위 내용을 생각하며 다음과 같이 첫 클래스를 작성할 수 있다. src/Diffbot.php를 작성한다.

<?php 
namespace Swader\Diffbot;
use Swader\Diffbot\Exceptions\DiffbotException;

/**
 * Class Diffbot
 *
 * The main class for API consumption
 *
 * @package Swader\Diffbot
 */
class Diffbot
{
  /** @var string The API access token */
  private static $token = null;

  /** @var string The instance token, settable once per new instance */
  private $instanceToken;

  /**
   * @param string|null $token The API access token, as obtained on diffbot.com/dev
   * @throws DiffbotException When no token is provided
   */
  public function __consturct($token = null)
  {
    if ($token === null) {
      if (self::$token === null) {
        $msg = 'No token provided, and none is globally set. ';
        $msg .= 'Use Diffbot::setToken, or instantiate the Diffbot class with a $token parameter.';
        throw new DiffbotException($msg);
      }
    } else {
      self::validateToken($token);
      $this->instanceToken = $token;
    }
  }

  /**
   * Sets the token for all future new instances
   * @param $token string The API access token, as obtained on diffbot.com/dev
   * @return void
   */
  public static function setToken($token)
  {
    self::validateToken($token);
    self::$token = $token;
  }

  private static function validateToken($token)
  {
    if (!is_string($token)) {
      throw new \InvalidArgumentException('Token is not a string.');
    }
    if (strlen($token) < 4) {
      throw new \InvalidArgumentException('Token "' . $token . '" is too short, and thus invalid.');
    }
    return true;
  }
}
?>

메소드에서 DiffbotException을 참조한다. src/exceptions/DiffbotException.php를 다음 내용으로 작성한다.

<?php 
namespace Swader\Diffbot\Exceptions;

/**
 * Class DiffbotException
 * @package Swader\Diffbot\Exceptions
 */
class DiffbotException extends \Exception
{

}
?>

Diffbot 클래스에 대해 간단하게 설명하면, token 정적 프로퍼티는 새 인스턴스를 생성하면서 토큰을 넣지 않았을 때 기본값으로 사용한다. 인스턴스를 생성하면서 토큰을 넣으면 instanceToken 프로퍼티로 저장한다.

생성자에서 토큰을 전달 받는지 확인한다. 만약 받지 않았다면, 미리 선언된 기본 토큰이 있는지 확인하고 만약 없다면 DiffbotException 예외를 발생하게 된다. 이 예외를 위해 위 예외 코드를 작성했다. 만약 토큰이 괜찮다면 인스턴스에 토큰이 설정된다. 반면 토큰이 전달 되었다면 instanceToken으로 저장된다. 위 두 경우 모두 validateToken 정적 메소드를 통해 검증 절차를 거치게 된다. 토큰의 길이가 3글자 이상인지 체크하는, 단순한 프라이빗 메소드로 통과하지 못하면 아규먼트 검증 실패 예외를 발생하게 된다.

마지막으로 setToken 정적 메소드로 앞에서 말한 전역 토큰으로 활용한다. 이 역시 토큰 검증 과정을 거친다.

Diffbot 토큰을 보면 API를 호출하는 상황에서 기존에 있던 token을 변경이 가능한데 이렇게 되면 기존에 존재하는 Diffbot 인스턴스는 의도와 다르게 동작할 수 있다. 이런 경우를 대비해 토큰을 인스턴스 생성할 때마다 주입을 하거나 전역적으로 주입하고 Diffbot을 사용하거나 해야 한다. 물론 토큰을 전역적으로 설정하면 인스턴스의 이 설정을 덮어 쓸 수 있다. 물론 전역 토큰도 수정 가능하기 때문에 여러가지 환경에 따라 인스턴스의 토큰이 변경되도록, 이미 존재하는 인스턴스에는 영향을 주진 않을 것이다.

이 모든 내용이 블럭 주석으로 문서화 된 것을 볼 수 있는데 과하게 문서화 할 필요는 없지만 모두가 이해할 수 있도록 간단하게 작성을 해야 한다.

결론

이 파트에서 몇가지 간단한 기능과 환경설정이 들어 있는 스켈레톤 프로젝트로 PHP 패키지 개발을 시작했다. 파트 1에서의 결과는 다음 링크에서 받을 수 있다. 파트 2는 테스트와 실질적인 기능들을 작성하고 기초적인 TDD를 할 예정이다.

점심 먹으며 글을 읽다가 참 인상적인 내용의 포스트를 보게 되어 허락받고 글을 옮겨봤다. 꼭 이 글에서 얘기하는 특정적인 상황 뿐만 아니라, 우리가 일상적으로 자주 겪는 상황에도 충분히 적용될 수 있는 이야기다. 원문은 Why ask why? – Ned Batchelder에서 확인할 수 있다.


나는 파이썬 질문을 하는 사람들에게 도움을 주고 있다. 보스턴 파이썬 모임의 지인이든, IRC 채널에서 만난 완전히 모르는 사람이든 말이다. 이 상황에서 간혹 뜬금없게 오해할 때가 있다. “왜”라고 질문하는걸 오해하는 경우도 여기에 포함된다.

답을 찾기 위해 돕는 동안, 종종 질문자에게 “왜?” 라는 질문을 하게 된다. 예를 들면, 누군가가 두개의 파이썬을 랩탑에 설치해야 한다면서 도움을 요청했다. 그런 상황에서 나는 “왜 두번째 파이썬이 필요해요?” 라고 물어볼 것이다. 그럼 다른 사람이 그 글을 보고 웃게 되는데 내가 “너는 두번째 파이썬을 설치할 필요가 없다.” 라고 말한 것으로 생각하기 때문이다.

이는 일반적인 반응이다. 왜냐고 되물어보는 것은 비난처럼 느껴질 수 있다. “왜 XYZ로 했나요?”라고 말을 하면 “이 멍청이, 넌 XYZ로 하지 말았어야해.” 라고 이해한다는 얘기다. 그러나 내가 왜냐고 되물어보는 것은 정말로 다음과 같은 의도에서 물어보는 것이다. “나는 당신이 XYZ를 한 이유에 대해서 이해하길 원합니다.”

영어는 위와 같은 이유로 어려운 경우가 있다. 특히 IRC와 같이 비언어적 표현이 없는, 온전히 문자로만 전달되는 상황에서 특히 그렇다. 이런 질문을 누가 봐도 부드럽다 느낄 정도로 물어보기 위해 단어를 더 덧붙여 질문해야 한다. 예를 들면,

왜 다른 파이썬을 설치해야 하나요?

이보다 다음처럼 되물어보는 쪽이 낫다.

왜 다른 파이썬을 설치해야 했는지 물어봐도 될까요? 이유를 이해하는 것은 중요한 단서를 제공하거든요.

그러므로 내가 만약 왜냐고 되물어본다면, 이를 개인적인 의미로 듣지 말자. 난 정말로 무슨 이유에서 그런건지 알고 싶을 뿐이다. 만약 내가 정말 당신의 잘못을 꾸짖기 원했다면, “당신은 XYZ로 하지 말았어야 했어요.”, “XYZ는 좋은 아이디어가 아니에요.” 라고 말했을 것이다.

때로는 사람들이 내가 왜냐고 되물어보는 이유를 알게 될 때, 오히려 그 질문에 발끈하는 경우가 있다. 그들은 이유는 중요하지 않다고 고집하고, 왜 단순한 질문에 간단한 답을 내놓지 못하는가에 대해 의아해한다.

내가 되물어보는 경우의 75% 가량은 내가 큰 그림을 알게 되고 나서 질문의 답변이 달라진다. 일반적으로 사람들이 문제가 발생하고 해결책을 찾다가 막다른 길에 닿았을 때, 사람들은 막다른 길에 닿았다는 사실에 대해서만 질문을 한다. 당연한 일이다. 그러고 그들이 더이상 해결하지 못하는 상태에 빠지 이유는 문제의 처음부터 다시 출발해서 살펴보지 않기 때문에 더 나은 해결책을 찾지 못하는 것이다. 더 나은 길을 찾는걸 돕기 위해서는 그 질문의 이유를 이해해야 한다.

왜냐고 되물어보는 과정을 통해서 문제가 나타나기 이전에 선택했던 부분에 대해 살펴보고, 이러한 방법으로 전체적인 문제를 살펴보는 과정은 문제 해결에 도움이 된다. 문제에 관한 모든 정보를 알게 된다는 것은 함께 더 좋은 해결책을 찾을 수 있다는 뜻이다.

내가 더 큰 문제에 대해 설명해달라고 물어보면 개인적으로 받아들이지 말자. 복잡한 문제를 해결하는 것은 힘들다. 간단하게 선택한 첫번째 과정이 두번째 과정을 필요보다 더 어렵게 만드는 경우가 있기 때문이다. 도와주는 사람이 더 큰 그림에 대해 물어볼 때, 질문하는 사람을 면박 주려고 물어보는 것이 아니라, 최고의 해결책을 찾는데 도움을 주기 위해 물어보는 것이다.

이런 현상에 대해 XY 문제라는 표현을 쓴다. X라는 문제에 대해 Y라는 해결책을 선택했는데, 이게 동작하지 않을 때, X라는 문제점을 물어보는 것이 아니라 Y라는 해결책이 왜 안되는가에 대해 물어본다는 말이다. 몇 사람들은 자신도 모르게 문제점과 해결책의 관계에 대해 중요하게 생각하지 않는다. 이는 아주 일반적인 상황이며 자신의 질문에 대해 고집을 부린다. 그래서 때때로 질문하는 사람은 대안을 빨리 알아채지 못한다. 또 때로는 질문하는 사람이 답하려는 사람보다 시간을 안쓰려고 명확하게 질문하지 않는 경우도 있다. 이유가 어찌되었든, 이런 일은 항상 일어나고, 답변하는 사람들이 XY 문제와 같은 이유로 화가 나면 다시는 질문에 답하는 일을 하지 않을 것이다.

질문하는 사람들에게: 만약 누군가 왜냐고 물어보거나, 당신은 “XY 문제”를 가지고 있다고 얘기할 때, 그들은 당신에게 최적의 해결책을 찾는 것을 도와주려고 하는 것이다. 기분 나빠하지 말자. 우리는 힘겹게 학습하고 있고 복잡한 상황을 극복하려고 함께 노력하고 있기 때문이다.


내 스스로도 알면서 잘 안되는 부분이라 글을 읽으면서 뜨끔뜨끔 했다.

위에서 이야기한 XY 문제는 대다수의 커뮤니케이션에서 쉽게 발생한다. 단순하게 보면 별 이야기도 아닌데 의외로 직간접적으로 자주 겪는다. (내가 질문자 일 때도, 답변자 일 때도 있었던 것 보면 정말 흔한 상황이다.) 유대가 모자란 상황에서도 발생하지만 친밀한 경우에도 이와 같은 오해는 종종 일어난다. 그게 친구와도 나타나고, 직장 생활에서, 학교에서, 어디서든 쉽게 나타나는 일이다.

배움에 있어서는 적극적인 어린이가 되어야 한다. 부끄러움 없이 지금 내가 아는 부분을 모두 보여주고 물어봐야 빠르게 답을 얻을 수 있다. 내가 무엇을 모르는지 정확히 아는 것이 현명한 사람이란 이야기가 있다. 아는 척 숨기고 있는다면 평생 배우지 못한 상태로 살게 되는 것과 같고 모르는 것보다 더 부끄러운 일이다.

왜라는 질문에 솔직해지자. 타인에게도 나에게도.

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

Composer라는 PHP 의존성 관리도구가 있다고 하길래 재빨리 찾아 Getting Started만 발번역했다. npm이나 apt, pip같은 것들과는 닮았지만 다른 부분이 많은데 그만큼 PHP라는 언어에 대한 고민의 흔적을 느낄 수 있다.


Composer는 PHP를 위한 의존성 관리도구다. 이 도구를 사용해 해당 프로젝트에서 요구하는, 의존적인 라이브러리를 선언해 프로젝트에서 설치해 사용할 수 있도록 돕는다.

의존성 관리도구

Composer는 패키지 관리도구가 아니다. 물론 각 프로젝트 단위로 패키지나 라이브러리를 다룬다면 그런 역할을 할 수 있다. 하지만 이 패키지나 라이브러리는 프로젝트 내 디렉토리 단위로 설치된다. (예로 vender) 기본적으로 composer는 절대 전역적으로 사용하도록 설치하지 않는다. 그러므로 의존성 관리도구라고 부른다.

이 아이디어는 새로운 것이 아니며 Composer는 nodejs의 npm이나 ruby의 bundler에 커다란 영감을 얻어 만들어졌다. 그러나 이러한 도구는 PHP에 적합하지 않았다.

Composer가 해결한 문제는 다음과 같다:

a) 프로젝트가 여러개의 라이브러리에 의존적이다 b) 몇 라이브러리가 다른 라이브러리에 의존성이 있다 c) 무엇에 의존성이 있는지 선언할 수 있다 d) Composer는 설치할 필요가 있는 패키지 버전을 찾아 설치한다. (프로젝트 안으로 설치한다는 뜻이다)

의존성 선언

프로젝트를 생성할 때 필요로 하는 라이브러리를 적어줘야 한다. 예를 들어 monolog를 프로젝트에서 사용하기로 결정했다고 치자. 그렇다면 필요로 하는 것은 composer.json 파일을 생성하고 프로젝트의 의존성을 명시적으로 작성해주면 된다.

{
    "require": {
        "monolog/monolog": "1.2.*"
    }
}

시스템 요구사항

Composer는 동작하기 위해 PHP 5.3.2 이상을 요구한다. 또한 몇가지의 php 세팅과 컴파일 플래그를 필수적으로 요구하며 설치할 때 적합하지 않은 부분에 대해 경고해줄 것이다.

소스로부터 패키지를 설치할 때 단순히 zip 압축파일을 받는 대신 어떻게 패키지가 버전관리 되는지에 따라 git, svn 또는 hg가 필요할 것이다.

Composer는 멀티플랫폼을 지원하며 Windows, Linux와 OSX에서 동일하게 동작하도록 만들기 위해 노력하고 있다.

*nix 환경 설치

실행 가능한 composer 다운로드하기

지역 설치 (locally)

Composer를 받기 위해서는 두가지가 필요하다. 첫째로 Composer를 설치하는 것이다. (프로젝트에 Composer를 내려받는다는 의미):

$ curl -sS https://getcomposer.org/installer | php

이 과정은 요구되는 PHP 세팅 몇가지를 확인한 후 composer.phar를 작업 디렉토리에 내려받는다. 이 파일은 Composer 바이너리이며 PHAR(PHP 아카이브)로 PHP를 커맨드 라인으로 실행할 수 있도록 해주는 아카이브 포맷이다.

당신은 Composer를 --install-dir 옵션과 함께 경로 디렉토리를 입력해 특정 디렉토리에 설치가 가능하다 (절대경로와 상대경로 모두 가능):

$ curl -sS https://getcomposer.org/installer | php -- --install-dir=bin

전역 설치 (Globally)

이 파일은 어디든 원하는 곳에 위치할 수 있다. 이 파일의 위치를 PATH 환경변수에 지정된 곳에 넣어두면 전역적으로 사용할 수 있다. unix와 같은 시스템에서 php 없이 실행할 수 있도록 만들 수도 있다.

아래의 명령어는 composer로 시스템 어디에서든 쉽게 실행할 수 있도록 한다:

$ curl -sS https://getcomposer.org/installer | php
$ mv composer.phar /usr/local/bin/composer

노트: 권한 문제가 있다면 mv 부분은 sudo를 이용해 다시 실행한다.

그리고 php composer.phar로 실행하는 대신 composer로 실행하면 된다.

전역 설치 (homebrew를 이용해 OSX에서 설치)

Composer는 homebrew-php 프로젝트의 일부다.

  1. homebrew-php가 아직 설치되지 않았다면 brew를 통해 설치: brew tap josegonzalez/homebrew-php
  2. brew install josegonzalez/php/composer를 실행
  3. composer 명령어로 사용

노트: PHP53 또는 그 이상의 버전이 존재하지 않는다는 에러가 나타나면 brew install php53-intl로 설치한다.

Windows 환경 설치

인스톨러 이용

Composer를 설치하기 가장 쉬운 방법이다.

Composer-Setup.exe 를 내려받아 실행한다. 이 인스톨러는 가장 최신 버전의 Composer를 PATH로 설정된 경로에 설치해 어느 경로에서든 composer 명령어를 사용할 수 있도록 해준다.

수동 설치

PATH 경로로 이동해 설치 스니핏을 실행하여 composer.phar를 내려받는다:

C:\Users\username>cd C:\bin
C:\bin>php -r "eval('?>'.file_get_contents('https://getcomposer.org/installer'));"

노트: 위 내용 중 file_get_contents 함수가 동작하지 않는다면 http 주소로 내려받거나 php.ini에 php_openssl.dll를 활성화한다.

composer.phar를 위한 composer.bat를 생성한다:

C:\bin>echo @php "%~dp0composer.phar" %*>composer.bat

현재 터미널을 닫고 새 터미널에서 아래와 같이 테스트한다:

C:\Users\username>composer -V
Composer version 27d8904

C:\Users\username>

Composer 사용하기

이제 Composer를 사용해 프로젝트에서 의존하고 있는 라이브러리를 내려받는다. composer.json 파일이 현재 디렉토리에 존재하지 않는다면 Basic Usage 챕터로 넘어가도 된다.

의존적인 라이브러리를 내려받기 위해서, install 명령어를 실행한다:

$ php composer.phar install

전역 설치를 했다면 phar 없이 아래와 같이 실행한다:

$ composer install

위에서 예로 들었던 부분에 따라, 위 명령어를 통해 monolog를 vendor/monolog/monolog 디렉토리로 내려받게 된다.

자동 불러오기

라이브러리를 다운받는 것 이외에 Composer는 어떤 라이브러리든 자동으로 적합한 라이브러리를 불러와 사용하도록 돕는다. 자동 불러오기를 사용하려면 단지 아래의 코드를 넣어준다:

require 'vendor/autoload.php';

이제 monolog를 바로 사용할 수 있다. Composer에 대해 더 배우기 위해서는 Basic Usage 챕터를 참고한다.


더 읽을 거리

Xpressengine에서 Composer 문서를 전문 번역했다.

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

앞서 작성한 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')>)]

이후 읽어볼 만한 문서

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

오라일리 책을 구입해두고 안보고 있다가 이제야 보니 다른 부분이 너무나도 많아서 문서 보면서 배우기로 급 선회했다. 한글 문서로 먼저 훑어보면 좋을텐데 검색 능력이 부족해서 찾질 못하겠더라. 문서 보면서 대충 날림 번역으로 남겨놨다. 하루면 페이지 다 따라해볼 수 있을 것 같았는데 딴짓하느라 하루에 다 완료를 못해서 파트를 쪼개기로. 주중에 짬짬이 나머지를 보기로 하고 일단 먼저 업로드!

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


SQLAlchemy 객체 관계형 매퍼는 데이터베이스 테이블을 이용해 사용자가 정의한 파이썬 클래스의 메소드와 각각의 행을 나타내는 인스턴스로 표현된다. 객체와 각 연관된 행들의 모든 변경점들이 자동으로 동기되어 인스턴스에 반영되며, 그와 동시에 사용자가 정의한 클래스와 각 클래스 사이에 정의된 관계에 대해 쿼리할 수 있는 (Unit of work이라 하는)시스템을 포함하고 있다.

이 ORM에서 사용하는 SQLAlchemy 표현 언어는 ORM의 구성 방식과도 같다. SQL언어 튜토리얼에서는 직접적인 의견을 배제한 채 데이터베이스들의 초기에 어떻게 구성해 나가야 하는지에 대해 설명하는 반면 ORM은 고수준의, 추상적인 패턴의 사용 방식과 그에 따른 표현 언어를 사용하는 방법을 예로 보여준다.

사용 패턴과 각 표현 언어가 겹쳐지는 동안, 초기와 달리 공통적으로 나타나는 사항에 대해 표면적으로 접근한다. 먼저 사용자가 정의한 도메인 모델서부터 기본적인 저장 모델을 새로 갱신하는 것까지의 모든 과정을 일련의 구조와 데이터로 접근하게 해야한다. 또 다른 접근 방식으로는 문자로 된 스키마와 SQL 표현식이 나타내는 투시도로부터 명쾌하게 구성해, 각 개별적인 데이터베이스를 메시지로 사용할 수 있게 해야 한다.

가장 성공적인 어플리케이션은 각각 독자적인 객체 관계형 매퍼로 구성되야 한다. 특별한 상황에서는, 어플리케이션은 더 특정한 데이터베이스의 상호작용을 필요로 하고 따라서 더 직접적인 표현 언어를 사용할 수 있어야 한다.

(제 실력이 미천해 깔끔하게 번역이 안되네요. 공통된 부분에만 집중하고 각 데이터베이스의 특징을 몰개성화 하며 단순히 저장공간으로 치부하는 다른 ORM과 달리 SQLAlchemy는 각 데이터베이스의 특징도 잘 살려내 만든 ORM이다, 대충 이런 내용입니다. 원문 보세요. ㅠㅠ)


버전 확인하기

import sqlalchemy
print sqlalchemy.__version__

접속하기

이 예시는 메모리서만 사용하는 sqlite 데이터베이스를 사용. create_engine()을 이용해 접속.

from sqlalchemy import create_engine
engine = create_engine('sqlite:///:memory:', echo=True)

echo는 로그를 위한 플래그. 파이썬 표준 logging 모듈 사용. 순수 SQL 코드를 보여준다.

engine은 선언만 해서 바로 연결되는게 아니라 첫 실행이 될 때 연결이 됨.

print engine.execute("select 1").scalar()

ORM을 사용할 때는 위처럼 engine을 직접 이용할 필요는 없다. 맨 처음 연결 할 때 작성하고 ORM 사용하면 됨.

매핑 선언

ORM에서는 처음에 데이터베이스 테이블을 써먹을 수 있게 설정한 다음 직접 정의한 클래스에 맵핑을 해야한다. sqlalchemy에서는 두가지가 동시에 이뤄지는데 Declarative 란걸 이용해 클래스를 생성하고 실제 디비 테이블에 연결을 한다.

from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

이러면 준비 끝. 이렇게 해두면 몇개고 매핑 클래스를 만들 수 있다. 매핑 클래스 내에서 디비의 컬럼을 나타내는 Column 클래스, 각 컬럼의 데이터타입을 나타내는 Integer, String 클래스를 불러와야한다.

from sqlalchemy import Column, Integer, String

class User(Base):
    __tablename__ = 'users'

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

    def __init__(self, name, fullname, password):
        self.name = name
        self.fullname = fullname
        self.password = password

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

User 클래스는 __tablename__에서 정의한 테이블에 맵핑되고 primary key인 id와 name, fullname, password 컬럼을 가진다.

메소드는 마음껏 만들어도 상관없다. 파이썬 기본 class와 똑같음. __init____repr__도 만들어도 되고 안만들어도 된다. Base를 상속하지만 이는 단지 최소의 설정만 담당할 뿐이다.

Declarative system으로 만들어진 이 클래스는 table metadata를 가지게 되는데 이게 사용자정의 클래스와 테이블을 연결해주는 구실을 한다. 예전엔 이 metadata를 만들고 클래스에 맵핑해서 썼는데 그 방식을 Classical Mapping이라고 얘기한다. 그 예전 방식에서는 Table이라는 데이터 구조와 Mapper 객체로 클래스와 맵핑한다. (오라일리에서 나온 sqlalchemy 책에선 이 구방식으로 설명한다 ;ㅅ;)

metadata를 보고 싶다면,

User.__table__

mapper 클래스는,

User.__mapper__

Declarative 기반 클래스는 모든 Table 객체들을 MetaData로 정의해두고 .metadata 속성을 통해 접근할 수 있게 도와준다.

아직 위의 예제 클래스는 테이블이 생성이 되지 않은 상태인데 MetaData를 통해 손쉽게 생성할 수 있도록 도와준다. 테이블을 생성할 때 MetaData.create_all() 로 생성할 수 있는데 이 메소드를 호출하면 Engine으로 연결된 데이터베이스에 테이블을 생성해준다.

Base.metadata.create_all(engine)

최소 테이블 묘사 vs. 완전 상세돋는 묘사

sqlite나 postgresql은 테이블을 생성할 때 varchar 컬럼을 길이를 설정하지 않아도 별 문제 없이 데이터타입으로 쓸 수 있지만 그 외 데이터베이스에서는 허용되지 않는다. 그러므로 컬럼 길이가 필요한 데이터베이스의 경우 length가 필요하다.

Column(String(50))

Integer, Numeric 같은 경우에도 위와 동일하게 쓸 수 있다.

덧붙여 Firebird나 오라클에서는 PK를 생성할 때 sequence가 필요한데 Sequence 생성자를 써야 한다.

from sqlalchemy import Sequence
Column(Integer, Sequence('user_id_seq'), primary_key=True)

위에서의 User 클래스를 다시 작성해보면,

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, Sequence('user_id_seq'), primary_key=True)
    name = Column(String(50))
    fullname = Column(String(50))
    password = Column(String(12))

    def __init__(self, name, fullname, password):
        self.name = name
        self.fullname = fullname
        self.password = password

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

파이썬 안에서만 쓸꺼라면, 그리고 디비를 밖에서 이미 생성했다면 이전에 작성한 대로만 작성해도 상관 없다.

매핑된 클래스로 인스턴스 만들기

ed_user = User('haruair', 'Edward Kim', '1234')
ed_user.name        # 'haruair'
ed_user.password    # '1234'
str(ed_user.id)     # 'None'

id는 __init__()에서 정의되지 않았지만 맵핑을 해뒀기 때문에 None으로 존재한다. 기본적으로 ORM에서 생성된 클래스 속성들은 테이블에 맵핑된 것으로 표현된다. 이런 클래스 속성들은 descriptors로서 존재하는데 맵핑된 클래스를 위해 instrumentation을 정의해둔다. 이 instrumentaion은 이벤트를 바꾸거나 변경을 추적하거나 자동으로 새로운 데이터를 불러온다거나 할 때 도움을 주는 기능을 한다.

위의 값에서 ‘Edward Kim’을 디비에 넣기 전까진 id는 None이다. 디비에 넣으면 id값은 알아서 들어오게 된다.

세션 만들기

ORM은 데이터베이스를 session을 이용해 다룰 수 있는데 처음 앱을 작성할 때 create_engine()과 같은 레벨에서 Session 클래스를 factory 패턴으로 생성할 수 있다.

from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=engine)

모듈레벨에서 작성하고 있어서 Engine이 아직 존재하지 않는다면,

Session = sessionmaker()

이후에 engine을 생성하고 session의 configure를 이용한다.

Session.configure(bind=engine)

위처럼 작성한 Session 클래스는 새 객체를 만들어서 데이터베이스와 연결이 된다. 다른 트랜잭션을 위한 것들은 sessionmaker()에서 호출될 때 정의되야 하는데 자세한건 이후 챕터에서 알려준다고. 이제부터 언제든 데이터베이스와의 대화가 필요할 때 Session을 불러서 쓰면 된다.

session = Session()

Session은 Engine과 연결이 되어 있지만 아직 연결이 열린 상태는 아니다. 앞서와 같이 처음으로 사용될 때 Engine과 연결되고 모든 변경을 커밋하고 세션을 종료할 때까지 열려있게 된다.

세션 생성하는 패턴들

Session은 다양한 기반에 다양한 타입의 어플리케이션, 프래임워크에서 다양한 요구사항에서 짱짱 좋다. 그러니까 Session은 오브젝트와 일반적인 데이터베이스 접속에서 쓰면 된다. 어플리케이션 스레드를 저녁 만찬이라 생각하면, 세션은 손님의 접시이고 객체는 놓여질 음식이라 볼 수 있다. (디비는 주방쯤?) 세션을 어떻게 써야할지 고민한다면 다음 링크 참조.

새 객체 추가하기

ed_user= User('haruair', 'Edward Kim', '1234')
session.add(ed_user)

여기선 실제로 데이터베이스에 추가된게 아니라 pending인 상태다. 아직 데이터베이스에 발행되지는 않은 상태인데 입력이 필요한 순간에는 flush라는 과정을 통해 입력이 된다. 만약 디비에 쿼리를 하면 모든 pending 된 정보는 flush되고 접근 가능한 상태가 된다. (실제로 저장된 상태는 아님. 여전히 pending.)

예를 들면 아래 코드는 User 인스턴스를 로드해 새 Query 객체를 생성한다.

our_user = session.query(User).filter_by(name='haruair').first()
our_user     # <User('haruair', 'Edward Kim', 'secret')>

사실 Session은 내부적으로 맵구조의 객체라 반환하는 값이 우리가 기존에 집어넣은 인스턴스랑 동일하다.

ed_user is our_user     # True

ORM의 컨셉이 identity map이라서 session에서 하는 모든 처리들이 실제 데이터셋과 함께 동작한다. Session에서 PK를 가지면 PK 가진 같은 파이썬 객체를 반환한다. 그러니까 이미 있는 PK를 입력하면 에러가 난다.

add_all()로 한방에 추가할 수도 있다.

session.add_all([
    User('wendy', 'Wendy Williams', 'foobar'),
    User('mary', 'Mary Contrary', 'xxg527'),
    User('fred', 'Fred Flinstone', 'blar')])

비밀번호를 함 바꿔보자.

ed_user.password = 'test1234'

Session은 계속 연결되어있는 객체를 계속 주시하고 있다. 위처럼 수정하면 session은 이미 알고있다.

session.dirty        # IdentitySet([<User('Edward', 'Edward Kim', 'test1234')>])

새로 추가한 애들도 볼 수 있다.

session.new
# IdentitySet([<User('mary', 'Mary Contrary', 'xxg527')>,
#              <User('wendy', 'Wendy Williams', 'foobar')>,
#              <User('fred', 'Fred Flinstone', 'blar')>])

Session에 pending된 애들을 실행시키려면,

session.commit()

commit()은 모든 변경, 추가 이력을 반영한다. 이 트랜잭션이 모두 실행되면 세션은 다시 connection pool을 반환하고 물려있던 모든 객체들을 업데이트 한다.

암튼, 앞서 id가 ‘None’ 이었던 녀석을 다시 보면,

ed_user.id    # 1

Session이 새 행을 데이터베이스에 입력한 이후에 새로 생성된 행들은 식별자들과 데이터베이스에서 기본으로 설정된 값들을 instance에서 사용할 수 있게 된다. 즉시 사용할 수 있거나 첫 액세스에 로딩될 때 모두 사용할 수 있다. 위 경우엔 commit()을 실행한 이후 새 트랜잭션이 실행되어 모든 행이 다시 로드된 상태다.

sqlalchemy에서는 기본적으로 이전 트랜잭션에서 새 트랜잭션으로 처음 실행될 때 모든 데이터를 새로 가져온다. 그래서 가장 최근의 상태를 바로 사용할 수 있다. 다시 불러오는 레벨을 설정하고 싶으면 세션 사용하기 문서를 확인하자.

세션 객체의 상태들

User 객체가 Session 외부에서 PK 없이 Session 안에 들어가고 실제로 데이터베이스에 추가될 때 까지 각 “객체 상태” 를 가지고 있다. transient, pending, persistent 세가지. 이 상태들을 알고 있으면 도움이 되므로 객체 상태에 대한 설명을 잽싸게 읽어보자.

롤백하기

Session이 트랜잭션으로 동작하고 나서 우린 롤백 하는 것도 가능하다. 롤백해보기 위해 값을 변경해보자.

ed_user.name = 'edkim'

그리고 가짜 유저를 하나 생성한다.

fake_user = User('fakeuser', 'Invalid', '12345')
session.add(fake_user)

Session을 query하면 일단 flush된 현재의 트랜잭션을 확인할 수 있다.

session.query(User).filter(User.name.in_(['edkim', 'fakeuser'])).all()
#[<User('edkim', 'Edward Kim', 'test1234')>, <User('fakeuser', 'Invalid', '12345')>]

롤백을 실행하면 변경하기 전 상태로 돌아간다.

session.rollback()
ed_user.name            # 'haruair'
fake_user in session    # False

쿼리 보내기

Query 객체는 session에서 query() 메소드로 생성한다. 이 함수는 다양한 수의 아규먼트를 가질 수 있는데 다양한 클래스의 조합과 클래스 descriptor를 사용할 수 있다. 사실 QueryUser 인스턴스를 부를 때 이미 써봤다. iterative context를 evaluated할 때, User 객체 리스트를 반환한다.

for instance in session.query(User).order_by(User.id):
    print instance.name, instance.fullname

QueryKeyedTuple 클래스 통해 튜플로 반환하는데 일반적인 파이썬 객체처럼 활용할 수 있다. 각 저장된 값들은 클래스 이름이나 속성 이름과 동일하다.

for row in session.query(User, User.name).all():
    print row.User, row.name

label()을 이용하면 컬럼 이름을 다르게 쓸 수 있다. 어떤 클래스 속성이든 매핑해서 쓸 수 있다. ColumnElement-derived object.

for row in session.query(User.name.label('name_label')).all():
    print row.name_label

컬럼은 위 방식으로 하지만 User 같은 클래스 엔티티는 aliased를 이용해 제어할 수 있다.

from sqlalchemy.orm import aliased
user_alias = aliased(User, name='user_alias')
for row in session.query(user_alias, user_alias.name).all():
    print row.user_alias

LIMIT이나 OFFSET을 포함한 기본적인 Query 동작은 order by와 함께 파이썬 배열에서 쪼개는(slice) 방식처럼 쓰면 된다.

for user in session.query(User).order_by(User.id)[1:3]:
    print user

결과물을 filter 할 때에는 filter_by()를 쓰면 된다.

for name in session.query(User.name).filter_by(fullname='Edward Kim'):
    print name

또는 filter()를 쓰면 되는데 좀더 유연한 SQL 표현을 쓸 수 있다. 매핑클래스에서 사용한 클래스 단위의 속성과 파이썬 표준 연산자를 쓸 수 있다.

for name in session.query(User.name).filter(User.fullname=='Edward Kim'):
    print name

Query 객체는 완전 생산적이라 대부분의 메소드 호출은 새 Query 객체를 반환한다. 따라서 아래와 같이 꼬리를 무는 체이닝 방식으로 사용이 가능하다. (Where … And … 식으로 된다.)

for name in session.query(User).\
            filter(User.name=='haruair').\
            filter(User.fullname=='Edward Kim'):
    print user

일반 필터(filter) 연산자들

equals

query.filter(User.name == 'ed')

not equals

query.filter(User.name != 'ed')

LIKE

query.filter(User.name.like('%ed%'))

IN

query.filter(User.name.in_(['ed', 'wendy', 'jack']))

서브쿼리식으로도 됨

query.filter(User.name.in_(session.query(User.name).filter(User.name.like('%ed%'))))

NOT IN

query.filter(~User.name.in_(['ed', 'wendy', 'jack']))

IS NULL

filter(User.name == None)

IS NOT NULL

filter(User.name != None)

AND

from sqlalchemy import and_
filter(and_(User.name == 'ed', User.fillname == 'Edward Kim'))

또는 위에서 본 체이닝 메소드로

filter(User.name == 'ed').filter(User.fullname == 'Edward Kim')

OR

from sqlalchemy import or_
filter(or_(User.name == 'ed', User.name == 'wendy'))

match

query.filter(User.name.match('wendy'))

SQLAlchemy 시작하기 – Part 2에서 계속.

색상을 바꿔요

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

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