tag: php

ReactPHP로 고성능 PHP 앱 만들기

2017년 8월 3일

Marc Johannes Schmidt가 쓴 Bring High Performance Into Your PHP App (with ReactPHP)을 번역했다. 2014년 초 글이라서 아마 php7을 사용한다면 여기에 언급된 벤치마킹보다 더 나은 수치가 나오지 않을까 생각한다.


ReactPHP로 고성능 PHP 앱 만들기

이 글에서는 PHP 어플리케이션의 성능을 어떻게 최대화 하는지 살펴보려고 한다. 대부분 앱은 PHP의 성능을 완전히 사용하지 않는다. 대신 APC를 켜는 정도가 최선이라고 생각한다. 이 글을 읽어보면 아마 놀랄 것이다.

요약

대규모 심포니 앱에서 초당 130회 정도 요청을 처리할 수 있었는데 이 접근 방식으로 초당 2,000여 회 요청을 처리할 수 있다.

아키텍처

먼저 과거를 살펴보자.

근래 PHP를 사용하는 일반적인 방식은 Apache, Nginx, lighttpd와 같은 웹서버를 통해서 HTTP 프로토콜을 처리하고 동적 요청을 PHP로 전달하는 식으로 사용한다. Apache의 mod_rewrite와 같은 리라이트 엔진을 사용한다면 더 강력하게 사용할 수 있다.

웹서버에서 PHP를 구동하기 위해 설정하려면 다음과 같은 방법이 있다.

  • mod_php (apache 만)
  • f(ast)cgi
  • PHP-FPM

SuExec와 함께 FCGI를 설정하는 방식은 보안상 가장 많이 사용한다. 각 인터프리터 프로세스는 각 사이트 사용자 아래서 구동된다. 이렇게 분리된 환경은 VM 없이도 각각의 사용자에 대응해 대규모 호스팅 형태로 운영 가능하다. 이 접근 방식이 매우 일반적인 탓에 mod_php나 PHP-FPM을 로컬 개발 머신이나 단일 앱을 구동하는 웹서버(한 조직이 만든 앱인데 서버에서 이런 형식으로 웹서버에 올려 구동하는 경우)에서도 동일한 방식을 사용한다.

물론 이 접근 방식은 업계 전반에 걸쳐 상당히 일반적인 방식이다. 이 방식에서 가장 큰 손실은 “연산코드 캐시(opcode cache)”가 존재한다고 하더라도 클래스를 선언하고 객체를 초기화하며 캐시를 읽는 등의 작업을 매 요청마다 수행해야 한다는 점이다. 이 과정이 시간을 많이 소비하고 고성능의 완전한 환경과는 거리가 멀다는 점을 쉽게 상상할 수 있을 것이다.

틀을 깨고 생각하기

그럼 왜 이런 일을 하는 것일까? 왜 매 요청마다 사용하는 메모리를 정리하고 다시 생성하는 일을 반복해야 하는 것일까? 물론 PHP가 서버 자체로 디자인된 것이 아니라 템플릿 엔진, 도구 모음 정도로 만들었기 때문이다. 또한 PHP 자체가 비동기 형태로 디자인되지 않았기 때문에 대부분 함수는 “블로킹(blocking)”이 발생한다. 수년 동안 상황이 많이 달라졌다. PHP로 작성된 강력한 템플릿 엔진이 있다. 수 만 가지의 유용한 라이브러리를 Composer로 설치할 수 있는 커다란 생태계를 갖게 되었다. Java와 다른 언어에서 구현된, 아주 강력한 디자인 패턴도 PHP에서 구현되었다. (안녕, Symfony와 동료들!) 심지어 PHP의 비동기 웹서버를 위한 라이브러리도 존재한다.

잠깐, 뭐라고요?

잠깐, PHP를 위한 비동기 도구가 있다고? 그렇다. ReactPHP는 내가 가장 기대하는 라이브러리 중 하나다. 이 라이브러리는 이벤트 주도, 넌-블로킹 입출력 개념을 PHP로 가져왔다. (안녕, NodeJS!) 이 기술을 사용하면 HTTP 스택을 PHP에서 직접 작성할 수 있으며 각 요청마다 파괴할 필요 없이 메모리를 제어할 수 있게 된다.

morpheus php webserver

매번 객체를 초기화 하거나 캐시를 읽는 것처럼 어플리케이션을 시작하기 위해 해야 하는 대부분의 작업이 응답 시간에 있어 많은 분량을 차지하고 있는데 이 시간을 줄여 성능을 올리는 것은 쉽게 이해가 되리라 믿는다. 이런 과정을 Java, NodeJs와 그 친구들처럼 없앨 수 있다면 성능이 향상될 것이란 얘기다. 만세!

어떻게 해야 하나

간단하다. ReactPHP는 http://reactphp.org에서 받을 수 있다. Composer를 사용해서도 설치할 수 있다.

$ composer require 'react/react=*'

server.php 파일을 다음처럼 생성한다.

<?php
require_once(__DIR__. '/vendor/autoload.php');

$i = 0;
$app = function ($request, $response) use ($i) {
    $response->writeHead(200, array('Content-Type' => 'text/plain'));
    $response->end("Hello World $i\n");
    $i++;
};

$loop = React\EventLoop\Factory::create();

$socket = new React\Socket\Server($loop);
$http = new React\Http\Server($socket, $loop);

$http->on('request', $app);
echo "Server running at http://127.0.0.1:1337\n";

$socket->listen(1337);
$loop->run();

이제 PHP 서버를 php server.php로 실행한다. 이제 http://127.0.0.1:1337로 접속하면 “Hello World” 문구를 볼 수 있을 것이다. $app은 “main” 함수로 서버에 들어오는 각 요청을 받는 엔트리 포인트 역할을 한다.

이게 전부다. 이렇게 쉽다. 끝.

벤치마크

아마 이런 생각이 들 것이다. “음, PHP는 하나의 cpu 코어/스레드를 사용하니깐 다중 코어 서버의 성능을 전부 사용하진 못할 거야.” 사실이긴 하지만 여러 대의 서버를 실행해서 프록시가 일감을 분배할 수 있다면 어떨까? 이제는 다중 코어를 지원하는 여러 워커를 실행할 수 있는 서버를 만들어야 한다. 이 작업을 위해 프로세스 매니저를 만들었는데 간단하게 Symfony를 위한 브릿지를 제공해서 핵 원자로 같이 강력하게 사용할 수 있게 되었다.

다음과 같은 형태로 구동된다.

reactphp with nginx

테스트를 한 환경은 이렇다.

  • Intel(R) Xeon(R) CPU L5630, 6 Cores
  • 8GB RAM
  • PHP 5.4.4 with APC
  • Debian 7.1
  • nginx/1.2.1

각 PHP-FPM과 React 서버는 6개의 워커를 사용했다.

테스트는 Apache HTTP 서버 벤치마킹 도구인 ab를 사용했다.

테스트는 Symfony 2.4+로 작성된 웹앱을 사용했다. 이 프로젝트는 꽤 규모가 있는 CMS 번들을 사용했다. 이 번들에는 많은 서비스, 이벤트 리스너, 캐싱, 템플릿, 데이터베이스 엑세스 등 많은 기능이 들어있다. (이 번들은 jarves/jarves로 변경되었다.)

캐시는 기본적인 부분에만 적용되어 있으며, 이 벤치마크 결과에서 뷰는 캐싱되지 않았다.

react 서버를 실행할 때 다음 명령을 사용했다.

$ php ./bin/ppm start /path/to/symfony/ --bridge=symfony -vvv

hhvm은 다음처럼 구동했다.

$ hhvm ./bin/ppm start /path/to/symfony/ --bridge=symfony -vvv

결과는 이렇다.

초당 요청 횟수

reactphp benchmark requests

메모리 사용량

reactphp benchmark memory

  • fpm – nginx 서버 뒤에 구동한 일반적인 PHP-FPM 서버.
  • react – 내장 로드 벨런서로 실행한 react 서버. (위 구성 이미지에서 확인할 수 있음.)
  • react+nginx – nginx를 로드 밸런서로 사용한 react 서버. react 서버는 워커 프로세스만 사용했고 nginx는 이 워커에 직접 통신함.
  • hhvmreact와 동일하지만 hhvm으로 실행.
  • hhvm+nginxreact-nginx와 동일하지만 hhvm으로 실행.

hhvmstream_select 이벤트 루프를 사용하고 phplibevent를 사용한다. 그래서 hhvm의 내장 웹서버는 사용하지 않았다.

위 결과에서 확인할 수 있는 것처럼 nginx를 로드벨런서로 사용한 react 서버는 전통적인 PHP-FPM + APC 구성보다 15배나 빨랐다.

react+nginx (libevent PHP 모듈)에서는 거의 초당 2,000 여 회 요청을 처리했다.

        2,000 requests / second
    7,200,000 requests / hour
   86,400,000 requests / half day
  172,800,000 requests / day
2,678,400,000 requests / half month
5,356,800,000 requests / month

메모리 사용량은 동일하다. 결과 중 메모리가 꾸준히 증가하는 부분은 CmsBundle에 있는 메모리 유출이 문제일 것이다. 이 문제는 아직 코드가 이 환경에 최적화되지 않은 탓이다. 이 접근 방식에 관한 문제는 아래에서 더 언급했다.

이 의미는 Symfony 앱에 엄청난 성능 향상을 끌어낼 수 있다는 이야기다. 시도해볼 만한 가치가 있다. 더 설명할 필요 없이 위 결과가 수천 마디 말을 대신한다고 생각한다.

Nginx를 로드 벨런서로 사용하기

react+nginxhhvm+nginx를 사용할 때 다음 설정을 사용했다. 단순히 요청을 프록시로 넘겨줬을 뿐 특정 파일을 지정하지 않았다.

upstream backend  {
    server 127.0.0.1:5501;
    server 127.0.0.1:5502;
    server 127.0.0.1:5503;
    server 127.0.0.1:5504;
    server 127.0.0.1:5505;
    server 127.0.0.1:5506;
}

server {
    root /path/to/symfony/web/;
    server_name servername.com
    location / {
        if (!-f $request_filename) {
            proxy_pass http://backend;
            break;
        }
    }
}

이 접근 방식의 문제점

이 방식에서 나타날 수 있는 몇 가지 문제점이 있다.

첫째로는 메모리 유출을 막기 위해 메모리 처리를 잘 해야한다는 점이다. 최신 버전의 PHP는 상당히 잘 처리하고는 있지만 요청을 처리한 다음에 전체 어플리케이션의 메모리를 제거하는 이전 방식에서는 그다지 지적되지 않았던 부분이다. 그러므로 변수 내에 있는 자료에 대해 주의를 기울일 필요가 있다.

둘째로 파일이 변경되었을 때는 서버를 재시작해야 할 필요가 있다. PHP에서는 클래스나 함수를 재정의하는 것이 불가능하기 때문이다. php-pm에서는 새로운 워커를 실행하는 방식으로 해결하려고 계획하고 있다.

셋째로 예외 처리가 되지 않은 예외가 발생했을 때 서버를 재시작해야 할 필요가 있다. 이 문제도 php-pm에서 해결할 예정이다.

넷째로 ReactPHP가 비동기 코드를 작성할 수 있는 기능은 제공하지만 대부분의 라이브러리가 (Symfony 포함) 이런 방식으로 작성되지 않았다. 즉, 정말로 웹 어플리케이션의 성능을 극대화 하고 싶다면 앱을 비동기 형태로 다시 작성해야 할 것이다. 재작성이 불가능한 것은 아니며 일반적으로 빠르게 가능하지만 결국엔 콜백 지옥에 빠지게 될 수도 있다. Node.js 앱에서 이 문제를 어떻게 다루는지 살펴보고 이 접근 방식을 사용하는 것이 가치있는 일인지 고민해봐야 한다. 내 의견으로는 시도해볼 가치가 충분하다.

다섯째로 큰 규모의 프레임워크를 ReactPHP와 함께 작성할 예정이라면 요청에 사용할 수 있던 내부 데이터를 어떻게 분리해서 처리할지 고려해봐야 한다. Symfony는 Request/Response를 사용하는 프레임워크다. 이런 구조로 요청과 응답이 구분된 코드 환경이 아니라면 PHP 어플리케이션을 개발하는 방식 자체에 대해 다시 생각해볼 필요가 있다. 이 변화는 극적으로 크다. 기본적으로 $_POST, $_GET, $_SERVER와 같은 것을 절대 사용할 수 없게 된다는 뜻이다. 현재 프레임워크가 이런 차이점을 지원하지 않는다면 쉽지 않을 것이다. 만약 지원하지 않는다면 집어 던지고 Symfony를 사용하자. 🙂 대단히 가치있는 일이다.

마지막으로

이 접근 방식이 Apache/nginx/lighttpd를 대체하는 것은 아니다. 이 방식은 HTTP 서버를 ReactPHP로 실행하는 것으로 어플리케이션을 실행하도록 준비하는데 가장 비싼 부분들을 제거하는데 그 포인트가 있다. 추가적으로 이 접근 방식을 사용했을 때는 새로운 캐시 레이어를 생각할 수 있다. 바로 PHP 변수다. APC 사용자 캐시를 사용하기 전에 PHP 배열을 캐시처럼 사용했던 것을 생각해보자. 물론 이런 접근 방식은 유효하지 않은 캐시가 발생하지 않도록 어떻게 다룰 것인지 염두해야 한다.

ReactPHP는 100% CPU 문제가 있었는데 고쳐졌다.

Symfony의 Request/Response 객체를 React의 Request/Response로 변환하기 위해서 작성한 Symfony 브릿지는 아직 완벽하진 않다. React의 HTTP 서버를 리팩토링 하는 작업이 필요하다. 물론 Symfony와 동작하는가에 대해서 질문은 “언제 되느냐” 하나만 남았다. 기여는 언제나 환영이다. 🙂

레거시 php 프로젝트를 composer 패키지로 바꾸기

2017년 7월 13일

요즘 작업하는 환경이 상당히 오래된 코드를 접할 수 있는 환경이라서 코드를 정리하는 일이 많은데 최근 버전에서도 돌아갈 수 있도록 코드를 정리하는 김에 패키지로 관리하고 테스트도 작성하도록 팀에 권하고 있다. 특별하다고 볼 만한 부분은 아니지만 정리 겸 작성한다. 사실 제목에 비해 내용이 별로 많질 않다. 나중에 기회가 되면 더 세세하게 작성해보고 싶다.

프로젝트 구조 잡기

새로운 프로젝트를 시작하든 레거시 프로젝트를 리팩토링하든 composer.json을 작성하는 작업으로 시작하게 된다. composer.jsoncomposer init 명령을 사용하면 인터렉티브로 쉽게 생성할 수 있다.

최종적인 프로젝트의 디렉토리/파일 구조는 다음과 같다.

my-project/
    src/     -- 소스 코드
    tests/   -- 테스트 코드
    bin/     -- 실행 파일이 있는 경우
    public/  -- 웹 프로젝트인 경우
        index.php
        .htaccess
    composer.json
    phpunit.xml.dist
    .gitignore
    readme.md

가장 먼저 설치하는 패키지는 phpunit이다. 개발에만 사용하는 패키지로 require-dev로 설치한다.

$ composer require --dev phpunit/phpunit

composer.json 파일을 열어 내 코드를 위한 autoload, autoload-dev 항목을 추가한다.

"autoload": {
    "psr-4": {
        "MyProject\\": "src"
    }
},
"autoload-dev": {
    "psr-4": {
        "MyProject\\Test\\": "tests"
    }
}

autoload 규칙을 갱신한다.

$ composer dump-autoload

이제 각 src, tests 내에 PSR-4에 따라 파일을 작성한다면 네임스페이스를 통해 사용할 수 있게 되었다.

테스트는 주로 phpunit을 사용하고 있다. 기본적으로 사용하는 최소 설정 파일이 있고 그 외 데이터베이스 등을 환경변수로 추가해서 사용하고 있다. phpunit.xml.dist으로 저장한다.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true"
         bootstrap="vendor/autoload.php"
         stderr="true">
    <testsuites>
        <testsuite name="all">
            <directory suffix="Test.php">tests/</directory>
        </testsuite>
    </testsuites>
</phpunit>

.gitignorephpunit.xml을 추가한다. phpunit은 phpunit.xml이 있으면 해당 파일을 설정에 사용하게 된다. 없는 경우에는 phpunit.xml.dist를 사용한다. 여기서는 별다른 설정이 필요 없으니 phpunit.xml을 생성하지 않는다.

정적 분석을 위해 phan도 설치한다.

테스트와 코드 작성

test 폴더에 HelloWorldTest.php를 생성하고 예제를 위한 테스트를 작성한다.

<?php
namespace MyProject\Test;

use PHPUnit\Framework\TestCase;
use MyProject\HelloWorld;

class HelloWorldTest extends TestCase
{
    public function testSaySomething()
    {
        $expected = 'Hello world';

        $world = new HelloWorld;
        $actual = $world->saySomething();

        $this->assertEquals($expected, $actual);
    }
}

vendor/bin/phpunit을 실행하면 다음처럼 테스트에 실패하는 것을 확인할 수 있다.

PHPUnit 6.1.3 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 90 ms, Memory: 10.00MB

There was 1 error:

1) MyProject\Test\HelloWorldTest::testSaySomething
Error: Class 'MyProject\HelloWorld' not found

/Users/edward/Documents/php/my-project/tests/HelloWorldTest.php:13

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

에러 메시지에 따라서 MyProject\HelloWorld 클래스를 만들어야 한다. srcHelloWorld.php를 추가한다.

<?php
namespace MyProject;

class HelloWorld
{
}

다시 PHPUnit을 실행한다.

There was 1 error:

1) MyProject\Test\HelloWorldTest::testSaySomething
Error: Call to undefined method MyProject\HelloWorld::saySomething()

/Users/edward/Documents/php/my-project/tests/HelloWorldTest.php:14

이번에는 정의되지 않은 saySomething() 메소드를 호출했다. 메소드를 작성한다.

<?php
namespace MyProject;

class HelloWorld
{
    public function saySomething()
    {
    }
}

다시 phpunit을 실행한다.

There was 1 failure:

1) MyProject\Test\HelloWorldTest::testSaySomething
Failed asserting that null matches expected 'Hello world'.

이제 오류는 없어진 대신 실패가 발생했다. 이제 반환값을 지정한다.

<?php
namespace MyProject;

class HelloWorld
{
    public function saySomething()
    {
        return 'Hello world';
    }
}

phpunit을 구동하면 테스트를 통과하는 것을 확인할 수 있다.


여기서는 예제라는 생각으로 HelloWorld를 가장 먼저 작성했지만 주로 엔티티가 되는 단위를 먼저 작성하고 엔티티를 사용하는 리포지터리, 리포지터리를 사용하는 서비스, 서비스를 사용하는 컨트롤러 순으로 주로 작성하고 있다. 레이어가 많아지면 자연스럽게 의존성 주입을 담당하는 패키지를 사용하게 되는데 php-di를 주로 사용하고 있다.

phpunit은 테스트 데이터베이스를 위해 dbunit을 제공하고 있는데 여기서 쓰는 클래스가 좀 깔끔하질 못해서 DatabaseTestCase를 프로젝트 내에 재정의하는 경우가 많이 있다. 그리고 phpunit에서 의존성 주입을 자체적으로 지원하지 않고 있기 때문에 어쩔 수 없이 서비스 로케이터 패턴처럼 사용해야 한다. 이를 위해 container에 접근할 수 있도록 하는 TestCase도 프로젝트 내에 재정의해서 사용하고 있다.

목킹은 phpunit에서 기본적으로 제공하는 mockBuilder를 사용하고 있다.

레거시 코드에서 컴포저로 변경하는 경우에는 기존 파일 구조에 위에서 언급한 구조대로 생성한 후, 하나씩 정리하고 테스트를 작성하며 src 아래로 옮기는 방식으로 진행하고 있다. 여기서는 phpunit에서 vendor/autoload.php를 바로 불러오고 있지만 그 외 추가적인 작업이 필요한 경우에는 tests/bootstrap.php를 만들어서 테스트에만 필요한 코드를 추가하는 방식으로 많이 작성하고 있다.

레거시 프로젝트는 비지니스 로직을 코드 레벨이 아니라 쿼리 레벨에서 처리하는 경우가 많아 ORM을 바로 도입하기 어려운 경우가 많았다. 그래서 PDO를 사용하는 경우가 많이 있다. PDO를 사용할 때는 PDO::ERRMODE_EXCEPTION를 적용해서 예외 처리를 하는 편이고 PDO::FETCH_CLASS를 사용해서 배열보다 개체 형식으로 데이터를 처리하고 있다. 클래스를 사용하기 어려운 테이블 구조(예로 EAV 모델)인 경우는 어쩔 수 없이 직접 클래스에 주입하는 편이다.

환경설정은 phpdotenv를 사용하는 편인데 팀 내 윈도 사용자들이 어색하다는 언급이 좀 있어서 .env 대신 config.dist.php, config.php를 최상위에 두는 방식으로도 작성한다.

PHP 함수 타입 선언과 정적분석도구 phan 활용하기

2017년 7월 7일

PHP에서도 다른 타입 언어처럼 함수 인자에 타입을 지정할 수 있도록 타입 선언(Type declaration)을 지원한다. 1 동적 타입 언어에서 왜 이런 문법을 사용해야 하는가에 대한 이야기는 여전히 많지만 타입 선언을 사용하는 쪽을 선호한다. TDD를 충실히 한다면 함수에서의 타입 선언이 의미 없다고 생각할 수 있겠지만 여전히 얻을 수 있는 장점도 많기 때문이다. 그 장점 중 하나로 정적 분석을 들 수 있다.

예제

컴파일을 수행하는 언어에서는 이 정적 분석을 통과하지 못하면 컴파일이 되지 않아 실행조차 할 수 없다. 하지만 PHP는 스크립트 언어로 별도의 컴파일 없이 실행할 수 있다. 아래 코드에서는 인터페이스에 선언되지 않은 메소드를 호출하고 있다. 정상적으로 실행이 될까?

<?php
interface FoodInterface
{
}

class FriedChicken implements FoodInterface
{
    public function getName()
    {
        return self::class;
    }
}

class Human
{
    public function eat(FoodInterface $food)
    {
        echo $food->getName();
    }
}

이제 이 코드를 실행해보자.

<?php
$chicken = new FriedChicken;
$me = new Human;
$me->eat($chicken);

위 코드를 php에서 실행하면 FriedChicken이 출력되는 것을 볼 수 있다. 즉, FoodInterfacegetName() 메서드가 선언되어 있지 않더라도 이 메서드를 호출하는 것이 가능하다. 이런 경우라면 getName()가 없지만 FoodInterface를 구현한 다른 인스턴스라면 분명 문제가 생긴다. PHP는 여전히 동적 타입 특성을 갖고 있기 때문에 이런 문제를 해결하기 어렵다.

class Human
{
    public function eat(FoodInterface $food)
    {
        // 타입 선언을 했는데도 덕타이핑을 하는 것은 이상함
        if (!method_exists($food, 'getName')) {
            throw InvalidArgumentException();
        }
        echo $food->getName();
    }
}

여기서는 코드 규모가 작고 간단한 테스트 코드를 작성했기 때문에 쉽게 확인할 수 있었다. 즉, 정적 분석 없이도 테스트를 잘 작성한다면 문제가 없겠지만 제대로 테스트가 작성되어 있지 않거나 코드의 규모가 큰 경우에는 이런 문제를 빠르게 검출하기 어렵다.

이런 상황에서 코드를 실행하지 않고도 문제를 찾기 위해 etsy/phan을 사용할 수 있다.

phan 사용하기

이 패키지는 php-ast 확장을 추가로 요구한다. 맥 또는 리눅스 환경은 리포지터리를 받아 phpize를 통해 간단히 설치할 수 있고 윈도 환경은 미리 컴파일 된 ast.dll을 받아 설치하면 된다. php.ini를 수정하는 것을 잊지 말자.

$ brew install php71
$ git clone https://github.com/nikic/php-ast.git
$ cd php-ast
$ phpize
$ ./configure
$ make install

그리고 사용할 패키지에 phan을 추가한다.

$ composer require --dev etsy/phan
$ vendor/bin/phan --help

phpcs를 사용해본 경험이 있다면 크게 다르지 않게 사용할 수 있다.

$ vendor/bin/phan -l src
src/foodie.php:18 PhanUndeclaredMethod Call to undeclared method \FoodInterface::getName

인터페이스에 정의되지 않은 getName을 호출했다는 사실을 확인 가능하다.


개발 환경에서의 이런 문제는 제대로 된 IDE(e.g. PHPStorm)를 사용한다면 미리 발견할 수 있다. CI/CD을 하고 있다면 phan을 중간에 추가하는 것도 좋은 아이디어다.

  • 타입 힌트(Type hint)는 php5에서 사용된 명칭이다. 
  • 데이터베이스에서 객체를 지연 로딩(lazy loading) 하기

    2017년 6월 26일

    최근 프로젝트에서 PDO를 사용해 작업하다보니 아무래도 ORM에 비해 아쉬운 점이 많아 ORM의 구현을 살펴보는 일이 잦아졌다. Giorgio Sironi의 글 Lazy loading of objects from database을 번역했다. 좀 오래된 글이긴 하지만 지연 로딩을 위해 프록시 패턴을 사용하는 방식을 설명하고 있다.

    이 번역은 원 포스트의 명시와 같이 CC BY-NC-SA 3.0 US에 따른다.


    데이터베이스에서 객체를 지연 로딩(lazy loading) 하기

    지연 로딩(lazy loading)은 무엇인가? 객체/관계 맵핑에서는 전체 객체의 연결 관계를 메모리상에서 나타내는 방식이 관행이다. 모든 객체를 실제로 만드는 대신 환영을 만드는 방법을 이 글에서 살펴본다.

    예시

    PHP 애플리케이션에서 전형적인 UserGroup 객체가 있다고 생각해보자. 단지 PHP 코드 예제를 사용했을 뿐이지 Java/Hibernate 예제처럼 관계형 데이터베이스를 사용하는 언어라면 이 글의 내용은 유효할 것이다.

    UserGroup은 전형적인 다대다 관계다. 사용자는 여러 그룹에 포함될 수 있고 그룹은 여러 사용자를 구성원으로 할 수 있다. 즉 데이터베이스에서 불러온 객체는 다음처럼 탐색할 수 있다.

    $user = find(42); // id가 42인 사용자를 찾는다
    echo $user->groups[3]->users[2]->groups[4]->name;

    객체 그래프를 무한으로 탐색할 수 있는 경우는 좋은 관례가 아니다. 하지만 종종 다대다 관계에서는 이런 탐색이 필요한 경우가 있으며 단순한 API인데도 자원을 과도하게 사용하게 되는 접근법 중 하나다. 왜 자원을 과도하게 사용하는지 뒤에서 설명한다.

    가장 요점인 문제는 모든 객체 그래프를 불러올 수 없다는 점인데 데이터베이스의 크기에 따라서 서버의 메모리보다 커질 수도 있고 객체로 전환하는 데 시간이 한참 걸릴 수도 있기 때문이다. 그렇다고 관계 일부만 불러올 수도 없는데 그룹과 사용자를 원하는 만큼 탐색하려면 모든 그래프가 필요하기 때문이다. 일부만 불러온 상황에서 그래프의 끝 단까지 간다면 객체가 있어야 할 위치에 null 값/null 포인터를 반환하게 되는 것은 문제가 된다.

    해결책: 지연 로딩

    프록시 패턴을 이 상황에 적용할 수 있다.

    일반적으로 프록시는 다른 무언가와 이어지는 인터페이스의 역할을 하는 클래스이다. 프록시는 어떠한 것(이를테면 네트워크 연결, 메모리 안의 커다란 객체, 파일, 또 복제할 수 없거나 수요가 많은 리소스)과도 인터페이스의 임무를 수행할 수 있다.

    첫 탐색에서는 첫 그룹의 하위 클래스인 프록시를 반환한다. 데이터 맵퍼는 추가적인 명령 없이도 해당 타입의 객체 그래프를 제공하게 된다.

    var_dump($user); // User
    var_dump($user->groups[3]); // Group_SomeOrmToolNameProxy
    var_dump($user->groups[3] instanceof Group); // true

    앞서 이야기한 것처럼 ORM은 프록시 클래스를 사용해서 원래의 클래스를 대체하는 방법으로 지연 로딩을 제공한다. 이 클래스를 위한 코드는 즉석에서 생성하며 대략 다음과 같은 형태가 된다.

    class Group_SomeOrmToolNameProxy
    {
        public function __construct(DataMapper $mapper, $identifier)
        {
            // 참조하는 필드를 인자 형태로 저장
        }
    
        private function _load()
        {
            $this->loader->load($this, $id);
        }
    
        public function sendMessageToAllUsers($text)
        {
            $this->_load();
            parent::sendMessageToAllUsers($text);
        }
    }

    새 클래스는 원래의 메소드를 대신해 호출하긴 하지만 호출하기 전에 _load() 메소드를 호출해서 객체를 사용할 수 있는 상태로 바꾼다. _load()를 호출하기 전이나 프록시 메소드를 호출하기 전에는 이 도메인 객체는 식별자 필드(id)만 내부 데이터 구조에 저장하고 있다.

    이 코드를 사용할 때는 기존 Group과 같은 인터페이스를 제공하기 때문에 사용자 입장에서는 서버 자원에서 자유로운 Group 클래스를 사용한다는 점을 눈치채기도 어렵다.

    무슨 뜻일까?

    첫 단계의 객체는 완전히 불러오지만 두 번째 단계는 해당 객체를 불러올 수 있는 정보만 포함하는 플레이스홀더만 존재한다. 실제로 접근했을 때만 해당 필드를 데이터베이스에서 가져와 처리하게 된다.

    $user = $em->find(42); // user 테이블에서 호출함
    echo $user->groups[3]->name; // groups와 user_groups 테이블에서 호출함

    이 패턴을 원하는 만큼 더 복잡한 환경에서도 적용할 수 있다.

    • join() 명령을 호출 객체에 정의하거나 ‘join’ 선택지를 데이터맵퍼의 메소드로 제공해서 최초 로딩에서 어느 깊이까지 객체를 불러올 것인가 지정할 수 있다. 최초에 사용자의 두 번째 단계 그래프까지 불러올 때 쿼리 한 번으로 불러오는 것이다. 물론 여전히 3번째 단계부터는 ($user->groups[3]->users[2]->role) $user를 다시 구성하지 않는 이상은 데이터베이스에 추가적인 요청을 보내 성능에 영향을 줄 것이다.
    • 지연 로딩을 켜거나 끌 수 있다. 또는 실행 과정을 기록해서 성능에 영향을 주는 지점을 찾을 수 있다.

    Java의 Hibernate는 객체 프로퍼티와 관계의 지연 로딩 기능을 이 접근 방식으로 제공한다. Doctrine 1.x는 더 단순한 방식을 사용하는데 액티브 레코드를 사용하고 있고 Doctrine_Record라는 기반 클래스 상에서 모델을 구현하고 있기 때문이다.

    오늘 Doctrine 2의 ORM\Proxy네임스페이스에 코드를 기여했다. 이 컴포넌트는 프록시 클래스와 객체를 기존 클래스의 메타데이터를 기반으로 생성해준다. 지연 로딩을 기존 코드 변경 없이도 바로 사용할 수 있을 것이다.

    제네릭 없는 PHP 인터페이스

    2017년 5월 25일

    PHP를 사용하면서 가장 아쉬운 부분은 인터페이스다. PHP는 인터페이스를 지원하고 있고 이 인터페이스를 활용한 타입 힌트, 의존성 주입 등 다양한 방식으로 적용 가능하다. 하지만 제네릭 타입이 존재하지 않아서 타입 컬렉션 같이 재사용하기 좋은 인터페이스를 만들 수 없다.

    물론 이 문제를 해결하기 위한 패키지를 찾아보면 존재하긴 한다. 하지만 인터페이스가 아닌 클래스 구현에 의존하고 있어서 타입 검사가 로직 내부에 포함되어 있다. 간략한 구현을 보면 대략 다음과 같다. 1

    <?php
    class Collection
    {
        protected $typeName = null;
        protected $collection = [];
        public function __construct($typeName)
        {
            $this->typeName = $typeName;
        }
        public function add($item) {
            if (! in_array($this->typeName, class_implements($item))) {
                throw new \TypeMismatchException();
            }
            $this->collection[] = $item;
        }
    }

    로직 내에 위치한 타입 검사는 런타임에서만 구동되어 실제로 코드가 실행되기 전까지는 문제가 있어도 찾기가 힘들다. 이런 방식의 구현에서 내부적으로는 인터페이스를 사용해서 입력을 검증하고 있지만 결국 메서드의 유무를 확인하는 덕타이핑과 큰 차이가 없어진다. 결과적으로 인터페이스가 반 쪽짜리 명세로 남아있게 된다. 주석을 잘 달아서 다소 모호한 함수 시그니처를 이해하도록 설득해야 한다.

    조금 다른 부분이긴 하지만 PHP에서는 Type을 위한 타입이 존재하지 않는 대신 string으로 처리하기 때문에 위 방식조차도 깔끔하게 느껴지지 않는다. 즉, 타입::class로 반환되는 값도 타입이 아닌 문자열이며 메서드 시그니처에 적용할 수도 없다.

    물론 매번 인터페이스와 클래스를 작성해서 사용하는 방법도 있겠다.

    <?php
    interface CollectionVehicleInterface implements CollectionInterface
    {
      public function add(VehicleInterface $item);
    }
    
    class CollectionVehicle implements CollectionVehicleInterface
    {
      public function add(VehicleInterface $item) {
        $this->collection[] = $item;
      }
    }

    의도대로 인터페이스를 통해 함수의 입력을 명확하게 정할 수 있게 되었다. 인터페이스는 명세를 명확하게 나타낸다. 다만 이런 방식으로는 모든 경우의 수에 대해 직접 작성해야 하는 수고스러움이 있다. 내부 로직은 동일한데 결국 함수 시그니처가 달라지므로 비슷한 코드를 반복해서 작성해야 한다. 이런 문제를 해결하기 위해 제네릭을 활용할 수 있다.

    <?hh
    namespace Haruair;
    
    interface CollectionInterface<T>
    {
      public function add(T $item) : void;
    }
    
    class Collection<T> implements CollectionInterface<T>
    {
      protected array<T> $collection = [];
      public function add(T $item) : void
      {
        $this->collection[] = $item;
      }
    }
    ?>

    hack에서의 제네릭은 항상 함수 시그니처를 통해서만 사용 가능하며 명시적 선언으로 바로 사용할 수 없어 다른 언어의 제네릭과는 조금 다르다. 물론 hack은 다양한 컬랙션을 이미 제공하고 있으며 array에서도 타입을 적용할 수 있다.

    요즘 제대로 된 타입 시스템이 존재하는 프로그래밍 언어를 사용하고 싶다는 생각을 정말 많이 한다. 최근 유지보수하는 프로젝트는 제대로 된 클래스 하나 없이 여러 단계에 걸친 다중 배열로 데이터를 처리하고 있다. 배열에서 사용하는 키는 전부 문자열로 관리되고 있어서 키가 존재하지 않거나 잘못된 연산을 수행하는지 판단하기 어렵다. 어느 하나 타입을 통해 자료를 확인하는 법이 없어 일일이 값을 열어보고 확인하고 있다. 물론 지금 프로젝트의 문제가 엉성한 타입에서 기인한다고 보기에는 다른 문제도 엄청 많다. 그래도 PHP에 타입이 존재하는 이상 조금 더 단단하게 사용할 수 있도록 만들었으면 이런 상황에 더 좋은 대안을 제시할 수 있지 않았을까 생각이 든다.

    PHP RFC를 보면 기대되는 변경이 꽤 많이 있는데 빈번히 통과되지 않는 기능이 많아 참 아쉽다. 이 제네릭의 경우도 그 중 하나다. 기왕 인터페이스도 있는데 이런 구현도 함께 있으면 좋지 않을까. 정적 타입 언어도 아닌데 너무 많은 것을 바라는건가 싶으면서도 인터페이스도 만들었으면서 왜 이건 안만들어주나 생각도 동시에 든다. 이렇게 딱히 대안 없는 불평글은 별로 쓰고싶지 않다 ?

  • 이 코드는 실무에서 사용하기 어렵다. 가령 class_implements는 문자열로 전달한 경우에는 해당 문자열을 사용해 클래스 또는 인터페이스를 찾으므로 객체임을 확인하는 코드가 필요하다. 
  • Satis로 PHP 패키지 리포지터리 만들기

    2017년 3월 17일

    PHP에서 Composer를 통해 사용할 수 있는 패키지 리포지터리 서비스인 Packagist는 오픈소스로 공개되어 있어서 필요하면 누구든지 받아 사용할 수 있게 되어 있다. 하지만 Solr이라든지 Redis라든지 요구하는 환경이 있어서 Packagist의 모든 기능이 필요한 경우가 아니고서는 또 관리할 거리 하나 늘리는 일이 되는 것 같아 쉽게 마음이 가지 않았다. 그동안 패키지로 만들 만한 개발을 하질 않았었는데 지금 다니는 곳에서는 조금씩 패키지로 갖춰두기 좋은 코드를 작성하고 있어서 찾아보던 중 Satis가 간편했다.

    Satis는 Composer에서 사용 가능한 패키지 리포지터리를 생성해주는 도구다. Packagist와는 다른 점이 있는데 바로 정적 파일로 생성한다는 점이다. jekyll이나 hexo, hugo같은 도구처럼 정적 페이지를 만드는데 단지 그 파일이 composer에서 사용할 수 있는 피드라고 생각하면 이해하기 쉬울지도 모르겠다. 패키지 리포지터리 자체에 패키지를 압축이나 바이너리 형태로 갖고 있지 않기 때문에 이런 점에서는 좀 더 유연할 수 있는 것 같다. (물론 포함하는 방식도 가능하다.)

    composer로 전역 설치해도 되고 제공하는 docker 이미지를 사용해도 된다. Satis를 기반으로 만들어진 패키지도 여럿 있는데 간단하게 만들어 쓰는 부분까지 넣었다.

    전역 설치로 사용하기

    Satis는 composer로 쉽게 설치할 수 있다.

    $ composer global require composer/satis:dev-master

    이제 satis를 사용할 수 있다. 먼저 피드 설정 파일인 satis.json을 생성해야 한다. 이 과정에서 나오는 Home page는 생성된 패키지 리포지터리를 올릴 주소로 입력하면 된다. 나중에 쉽게 바꿀 수 있으니 크게 고민하지 않아도 된다.

    $ satis init
    
      Welcome to the Satis config generator  
    
    This command will guide you through creating your Satis config.
    
    Repository name: hello world
    Home page: http://satis.haruair.com/
    
      Your configuration file successfully created!  
    
    
    You are ready to add your package repositories
    Use satis add repository-url to add them.

    프롬프트로 내용을 입력하고 나면 다음과 같은 json 파일이 생성된다.

    {
        "name": "hello world",
        "homepage": "http://satis.haruair.com/",
        "repositories": [],
        "require-all": true
    }

    먼저 동작을 확인하기 위해 별도의 리포지터리 없이 페이지를 생성한다.

    $ satis build satis.json output
    $ ls output 
    include index.html packages.json

    설정 파일과 페이지를 생성할 경로와 함께 build 명령에 사용하면 위처럼 페이지가 생성된다. index.html을 열어보면 간단한 인터페이스와 함께 생성된 패키지 리포지터리를 확인할 수 있다.

    이제 이 패키지 리포지터리에 등록할 패키지를 추가한다. 물론 해당 패키지는 composer.json 파일이 존재해야 한다. 주소를 등록했으면 다시 빌드를 수행한다.

    $ satis add https://github.com/haruair/wattle.git
    
      Your configuration file successfully updated! It is time to rebuild your repository  
    
    $ satis build satis.json output
    Scanning packages
    wrote packages to output/include/all$c23b16e038827b7e1083abaa05c5997d7f334d23.json
    Writing packages.json
    Pruning include directories
    Deleted output/include/all$96e5c0926e9d7f87094d1ba307e38ea76cd09c53.json
    Writing web view

    패키지 정보가 추가되었다. 생성된 json을 열어보면 해당 패키지에 대한 메타데이터를 확인할 수 있다. index.html에서도 확인 가능하다.

    Satis

    위 스크린샷 상단에 보면 repositories 내용이 있는 json을 확인할 수 있다. 이 리포지터리를 사용할 패키지의 composer.json에 추가해야 하는 내용이다. repositories로 추가하면 새로 추가하는 패키지가 존재하는지 그 경로에서 먼저 확인하고 없는 경우에는 공식 packagist에서 패키지를 받아오는 식으로 동작한다. 이제 output 경로에 있는 파일을 지정했던 Home page 주소로 옮기면 끝난다.

    다만 composer에서 리포지터리로 사용할 수 있는 주소는 기본적으로 https로 제한되어 있다. 만약 배포하는 위치가 https를 사용하지 않는다면 0. github pages를 배포처로 쓴다, 1. letsencrypt를 적용한다, 2. cloudflare를 적용한다, 3. self-signing ssl을 사용한다, 5. composer.json에서 config.secure-httpfalse로 지정한다 정도의 해결 방법이 있다. (순서는 추천하는 순서. 5번은 권장 안한다.)

    Docker로 사용하기

    전역으로 설치하면 늘 찝찝한데 고맙게도 docker 이미지도 공식으로 제공하고 있다.

    $ docker pull composer/satis
    $ docker run --rm -it -v /path/to/build:/build -v $COMPOSER_HOME:/composer composer/satis

    /path/to/buildsatis.json이 위치한 로컬 경로고 $COMPOSER_HOME은 컴포저가 설치된 경로로 기본 설치를 한 경우는 ~/.composer에 해당한다. composer를 지정하면 현재 컴퓨터에 설정된 composer를 사용하기 때문에 auth.json에 저장되어 있는 정보를 그대로 사용할 수 있다.

    자세한 내용은 Satis 리포지터리 설명을 참고한다.

    조금 유연하게 사용하기

    내 경우에는 학교 내 내부망에 위치한 gitlab을 사용하고 있고 내 IP도 특정 주소로 이미 바인딩되어 있었다. 그래서 요청이 왔을 때 새로운 리포지터리를 추가하고 빌드를 수행하도록 php로 작성해서 webhook에 연결했다.

    주석에도 썼지만 이 코드는 서두에 접속 권한을 검증하는 부분이 필요하다.

    <?php
    // Warning: This code is not production-ready!
    // Add more validation process, First.
    
    $input = file_get_contents('php://input');
    $payload = json_decode($input);
    
    if (json_last_error() === JSON_ERROR_NONE || empty($payload)) {
      echo json_encode([
        'ok' => false,
        'message' => 'A payload is invalid',
      ]);
      exit;
    }
    
    exec('satis add ' . $payload->repository->clone_url);
    exec('satis build satis.json .');
    
    echo json_encode(['ok' => true]);

    Satis를 사용하는 방법을 간단하게 확인했다. Packagist를 설치하거나 Private Packagist를 사용하는 것도 좋지만 설정에 시간을 많이 쓰고 싶지 않다면 Satis도 좋은 선택지다. 정적 블로그 생성기에 익숙하다면 비슷한 접근 방식으로 travis-ci에 연동해서 자동 생성한 패키지 리포지터리를 GitHub pages로 발행하는 등 다양한 방법으로 활용할 수 있을 것 같다.