tag: 번역

새로운 PHP 패키지를 작성하는 올바른 방법

PHP 패키지가 준수해야 할 점, League Skeleton과 phpcs 활용하기

2015년 4월 6일

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를 할 예정이다.

당신에게 왜냐고 되물어보는 이유

2013년 12월 9일

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Flask(uWSGI)를 nginx에 연결하기

2013년 12월 1일

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와 함께 인터프리터 작성하기

2013년 11월 19일

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

PHP 의존성 관리도구 – Composer 시작하기

2013년 10월 29일

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 문서를 전문 번역했다.

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

2013년 9월 8일

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