tag: 개발 이야기

500 마일 이메일 문제

2017년 8월 2일

The case of the 500-mile email을 번역했다.


여기 불가능처럼 들리는 문제가 있습니다. 이 이야기를 공개적인 곳에 올리는걸 분명 후회할겁니다. 왜냐면 이 이야기는 컨퍼런스 갔을 때 술마시면서 하기 좋은 대단한 이야기기 때문이니까요. 🙂 이 이야기는 잘못된 부분, 관련 없고 지루한 내용은 좀 정리하고 전체적인 내용을 좀 더 흥미롭게 만들었습니다.

저는 학내 이메일 서비스를 운영하는 일을 하고 있던 몇 년 전에 통계학부 주임교수에게 전화를 받았습니다.

“지금 학부 외부로 메일을 보내는데 문제가 발생했습니다.”

“무슨 문제인가요?” 제가 물었습니다.

“500 마일 (역주. 800km 가량) 이상 되는 거리엔 메일을 보낼 수가 없어요.” 주임교수가 말했습니다.

난 마시던 커피를 뿜을 뻔 했습니다. “뭐라고 하셨죠?”

“500마일보다 먼 거리에는 메일을 보낼 수가 없다고 했어요.”, 교수가 다시 말했습니다. “정확히는 조금 더 멀어요. 520 마일. 하지만 그 보다 먼 곳으로는 보낼 수가 없어요.”

“음… 이메일은 그런 방식으론 동작하진 않습니다. 일반적으로는,” 내 놀란 목소리를 억누르며 말했습니다. 학부 주임교수에게 놀란 모습을 보이지 않았습니다. 비록 통계학부가 상대적으로 빈곤하긴 했지만 말입니다. “어떤 점이 500여 마일보다 먼 거리에 메일을 보낼 수 없게 한다고 생각하시나요?”

“내가 그렇게 _생각_하는게 아니라,” 주임교수가 무의식적으로 답변했습니다. “보세요. 이 문제를 처음으로 알게 된 것은 며칠 전입니-”

“며칠을 기다렸다고요?” 떨리는 목소리로 교수의 말을 잘라버렸습니다. “그리고 매번 메일을 보낼 수 없었다는 건가요?”

“메일은 보낼 수 있어요. 단지 더 먼 거리–”

“아 500마일, 네.” 교수의 말을 제가 대신 정리했습니다. “이제 알겠습니다. 하지만 왜 더 일찍 전화하지 않으셨죠?”

“아, 어떤 점이 문제인지, 무슨 일이 나타나고 있는 것인지 지금까지 충분한 자료를 모으지 못했기 때문입니다.” 맞습니다. 지금 통계학 전임교수랑 통화하고 있었습니다. “아무튼, 이 문제를 지리통계학자에게 물어봤습니다–”

“지리통계학자들요….”

“–네, 그분은 우리가 이메일을 발송한 범위를 지도 위에 반경으로 그렸는데 500 마일을 약간 넘는 거리였습니다. 반경 내에서도 이메일이 도달하지 않은 곳도 산발적으로 있긴 했지만 절대 500 마일 범위를 넘기지는 못했습니다.”

“알겠습니다.” 대답하며 머리에 손을 얹었다. “언제부터 이런 문제가 생겼나요? 아까 며칠 전이라 말씀하셨는데 그 기간 동안 시스템이 달라진 부분은 없었나요?”

“한번은 컨설턴트가 와서 서버를 패치하고 재부팅을 했습니다. 그분에게 전화해서 물어봤는데 메일 시스템은 전혀 만지지 않았다고 하더군요.”

“알겠습니다, 제가 살펴보고 다시 전화 드리죠.” 이 말을 점점 믿게 되는 게 두려웠습니다. 만우절 장난도 아니었습니다. 혹시나 이전에 이런 장난을 쳤던 적이 있었나 생각해봤습니다.

그 부서 서버에 접속한 후에 테스트 메일을 발송했습니다. 이 서버는 노스 케롤라이나의 연구소 삼각지역에 있었고 테스트 메일은 제 메일로 문제 없이 들어왔습니다. 같은 메일을 리치몬드, 아틀란타와 워싱턴에 전송했습니다. 프린스턴 (400 마일)에도 문제 없었습니다.

그리고 멤피스에 이메일을 보냈습니다. (600 마일) 실패했습니다. 보스턴, 실패. 디트로이트, 실패. 제 연락처 목록을 보면서 범위를 좁혀 나갔습니다. 뉴욕(420 마일)은 수신에 성공했고 프로비던스(580 마일)은 실패했습니다.

제가 점점 정신이 나가고 있나 생각이 들었습니다. 저는 노스 케롤라이나에 있지만 시애틀에 있는 ISP를 사용하는 친구에게 이메일을 보냈습니다. 감사하게도, 실패했습니다. 메일 서버가 아니라 실제로 메일을 수신한 사람의 지리적 위치가 문제였다면 저는 울어버렸을 겁니다.

이 문제는 –믿을 수 없지만– 실제로 존재하고 반복 가능한 상황이었습니다. sendmail.cf 파일도 확인했지만 평범했습니다. 파일 내용은 심지어 친숙하게 느껴졌습니다.

제 홈 디렉토리에 있는 sendmail.cf랑 비교해보니 이 sendmail.cf와 토씨 하나 다르지 않는 것 보니 제가 작성한 것에 틀림 없습니다. 제가 “500마일_이상_전송_불가” 설정을 해놓지 않았다는 것은 분명했습니다. 포기하는 심정으로 SMTP 포트에 텔넷 접속을 했습니다. 서버는 SunOS 샌드메일 문구를 행복하게 보여줬습니다.

잠깐, SunOS의 샌드메일 문구를 보게 되었습니다. 당시에 Sun은 Sendmail 8이 상당히 성숙했지만 Sendmail 5를 운영체제와 함께 배부하고 있었습니다. 저는 좋은 시스템 관리자로서 Sendmail 8을 표준으로 사용했습니다. 그리고 또한 좋은 시스템 관리자로서 Sendmail 5에서 쓰던 암호같은 코드로 짜여진 설정 파일 대신 sendmail.cf에 각 설정과 변수를 길게 설명하는 Sendmail 8의 설정 파일을 사용했습니다.

문제 조각이 하나씩 들어맞기 시작할 때 이미 다 차가워진 커피에 사레 걸렸습니다. 컨설턴트가 “서버를 패치했다”고 말했을 때 SunOS 버전을 업그레이드 한 것은 분명했지만 샌드메일을 _다운그레이드_도 했던 것입니다. 업그레이드 동작에서 친절하게 sendmail.cf는 그대로 남게 되었고 전혀 맞지 않는 버전과 함께 돌아가게 되었습니다.

Sun에서 제공한 Sendmail 5는 몇가지 차이가 있긴 했지만 Sendmail 8에서 사용하는 sendmail.cf도 별 문제 없이 그대로 사용할 수 있었습니다. 하지만 새로운 설정 내역의 경우는 쓸모 없는 정보로 처리하고 넘겨버렸습니다. sendmail의 바이너리에는 컴파일에 기본 설정이 포함되어 있지 않아서 적당한 설정을 sendmail.cf 파일에 적지 않은 경우는 0으로 설정하고 있습니다.

0으로 설정된 것 중 하나로 원격 SMTP 서버에 접속하기 위한 대기시간(timeout)이 있었습니다. 이 장비에서 일정 사용량이 있는 상황으로 가정하고 몇가지 시험을 수행했습니다. 대기시간이 0으로 설정된 경우에는 3 밀리초가 조금 넘으면 접속에 실패한 것으로 처리되고 있었습니다.

당시 캠퍼스 네트워크의 특이한 기능 중 하나는 100% 스위치라는 점이었습니다. 외부로 나가는 패킷은 POP에 닿기 전이나 라우터로부터 한참 떨어진 곳이 아닌 이상에야 라우터 지연이 발생하지 않았습니다. 그래서 네트워크에서 가까운, 부하가 약간 있는 상태의 원격 호스트에 접속하는 상황이라면 문제가 될 만한 라우터 지연없이 광속에 가까운 속도로 접속할 수 있었습니다.

심호흡을 하고 쉘에서 계산해봤습니다.

$ units
1311 units, 63 prefixes

You have: 3 millilightseconds
You want: miles
        * 558.84719
        / 0.0017893979

“500 마일, 또는 그보다 조금 더.”


번역에 피드백을 주신 Raymundo 님 감사 말씀 드립니다.

레거시 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네임스페이스에 코드를 기여했다. 이 컴포넌트는 프록시 클래스와 객체를 기존 클래스의 메타데이터를 기반으로 생성해준다. 지연 로딩을 기존 코드 변경 없이도 바로 사용할 수 있을 것이다.

    테스트 주도 개발 : Test-Driven Development by Example

    2017년 6월 13일

    예전에도 테스트주도개발에 관한 글을 인터넷에서도 한참 찾아보고 읽었었다. 글을 읽고서 TDD를 행동으로 옮겨보면 대부분 글이 구호만 잔뜩 나열했지 무슨 일을 어떻게 해야 하는지 과정을 제대로 설명하는 경우가 거의 없었다. 나도 중요하다고는 늘 이야기하지만 현장에서 제대로 사용하지 않고 있었다. 막히는 부분을 어떻게 풀어야 하는지, 어떤 방법으로 고민해야 하는지 생각만 많아지고 해결하질 못했었다. 그래서 지난 번 사온 책 중 테스트 주도 개발 (켄트 백, 인사이트, 김창준 강규영 옮김)을 가장 먼저 읽어보게 되었다.

    이 책에서는 예제로 먼저 시작해 TDD가 어떤 생각의 흐름에 따라서 진행되는지 보여준다. 그 뒤로는 TDD를 하게 될 때 접할 수 있는 의문점과 그 해결책을 나열한다. 대략적으로만 알았었기 때문인지 새로웠던 부분, 기억하고 싶은 내용이 많았다.

    • TDD는 프로그램을 코드 단위로 잘게 쪼개서 실행해볼 수 있는 좋은 방법이다. 작은 단위로 내 의도대로 실행되는지 입력과 결과를 관찰한다. 단, 테스트를 먼저 작성하는 것으로 코드가 동작하지 않는 상태임을 명확하게 확인한다.
    • 빌드가 되지 않는 상태에서 빨간 불이 들어오도록 최소 코드를 작성하는 것, 이 빨간 불을 초록 불로 최대한 빠르게 바꾸기 위해 매직 넘버도 서슴치 않고 사용하는 것, 초록 불이 들어온 후에 작성한 테스트를 통해 리팩토링 하는 과정을 따른다.
    • 매직 넘버를 반환하는 메소드는 삼각측량으로 고친다. 즉 동일한 메소드를 다른 입력과 결과로 테스트를 작성했을 때 두 케이스를 모두 만족하기 위한 리팩토링을 수행한다.
    • 결과를 하드코딩 하는 것은 죄악으로 일반적으로는 죄악으로 여겨지는 일이지만 필요할 때 사용할 수 있어야 한다. 이 단계를 생각하지 않고 먼저 큰 코드를 작성해버리면 당장은 테스트의 단계를 줄일 수 있겠다. 문제는 그렇게 작성한 코드가 생각대로 동작하지 않았을 때 테스트 작성도 어려워지고 고민해야 할 단위도 커진다는 점이다. 이럴 때 다시 매직 넘버를 반환하는 수준으로 돌아올 수 있어야 하는데 생각의 단위를 가볍게 돌리는 일은 쉽지 않다. 테스트를 작성하면서 코드와 멀어진다는 생각을 하게 되는 지점이었는데 이 단순한 답이 큰 도움되었다.
    • 코딩이 안될 땐 쉬어야 한다는 이야기는 참 좋은 미덕이다.

    테스트를 언제 작성하는 것이 좋을까? 테스트 대상이 되는 코드를 작성하기 직전에 작성하는 것이 좋다. (p. 210)

    나중에 작성하면 항상 통과하는 무의미한 테스트를 작성하게 될 수도 있다.

    시스템을 개발할 때 무슨 일부터 하는가? 완료된 시스템이 어떨 거라고 알려주는 이야기부터 작성한다. 특정 기능을 개발할 때 무슨 일부터 하는가? 기능이 완료되면 통과할 수 있는 테스트부터 작성한다. 테스트를 개발할 때 무슨 일부터 하는가? 완료될 때 통과해야 할 단언(assert)부터 작성한다. (p. 211)

    TDD를 가장 간단하고 와닿게 풀어낸 문장이다.

    상향식, 하향식 둘 다 TDD의 프로세스를 효과적으로 설명해 줄 수 없다. 첫째로 이와 같은 수직적 메타보는 프로그램이 시간에 따라 어떻게 변해 가는지에 대한 단순화된 시각일 뿐이다. 이보다 성장(growth)이란 단어를 보자. ‘성장’은 일종의 자기유사성을 가진 피드백 고리를 암시하는데, 이 피드백 고리에서는 환경이 프로그램에 영향을 주고 프로그램이 다시 환경에 영향을 준다. 둘째로, 만약 메타포가 어떤 방향성을 가질 필요가 있다면 (상향 혹은 하향보다는) ‘아는 것에서 모르는 것으로(known-to-unknown)’라는 방향이 유용할 것이다. ‘아는 것에서 모르는 것으로’는 우리가 어느 정도의 지식과 경험을 가지고 개발을 시작한다는 점, 개발 하는 중에 새로운 것을 배우게 될 것임을 예상한다는 점을 암시한다. 이 두 가지를 합쳐보자. 우리는 아는 것에서 모르는 것으로 성장하는 프로그램을 갖게 된다. (p. 218-219)

    TDD가 어떤 순환성을 갖는지 설명하는데 이 부분 탓에 TDD를 더 어렵게 고민하게 만든다는 생각이 들었다.

    첫 걸음으로 현실적인 테스트를 하나 작성한다면 상당히 많은 문제를 한번에 해결해야 하는 상황이 될 것이다. … 정말 발견하기 쉬운 입력과 출력을 사용하면 이 시간을 짧게 줄일 수 있다.(p. 219)

    한번에 모든 것을 작성하려는 습관을 버려야 한다.

    화이트 박스 테스트를 바라는 것은 테스팅 문제가 아니라 설계 문제다. 코드가 제대로 작동하는지를 판단하기 위한 용도로 변수를 사용하길 원한다면 언제나 설계를 향상할 수 있는 기회가 있다. 하지만 두려움 때문에 포기하고 그냥 변수를 사용하기로 결정해 버리면 이 기회를 잃게 된다. 그렇게 말하긴 했지만 정말 설계 아이디어가 떠오르지 않으면 어쩌겠는가. 그냥 변수를 검사하게 만들고 눈물을 닦은 후, 머리가 좀더 잘 돌아갈 때 다시 시도해보기 위해 적어놓고서 다음 작업을 진행할 것이다. (p. 255)

    테스트를 작성하면서 테스트가 코드 내부 구현을 너무 많이 알고 있을 때 코드에 의존적인 테스트를 작성하기 마련이다. 이 책을 읽으면서 항상 느낀 점인데 테스트를 통과하면 일단 두고 넘어가도 된다는 점이다. 리팩토링에서 다시 만나면 코드를 새로 작성하거나 테스트를 새로 작성하면 된다고 계속 이야기한다.

    ‘관측상의 동치성’이 성립되려면 충분한 테스트를 가지고 있어야 한다. 여기에서 충분한 테스트란, 현재 가지고 있는 테스트들에 기반한 리팩토링이 추측 가능한 모든 테스트에 기반한 리팩토링과 동일한 것으로 여겨질 수 있는 상태를 말한다. (p. 292)

    빨간 불에서 초록 불로 바꾸는 과정에서 관측 상의 동치성이 나타난다. 초록 불 상태에서 코드를 바꿔도 초록 불이라면 외부에서 보기에는 동일한 결과를 반환하기 때문에 문제가 없다는 것이다. 초록 불 상태로 어떤 방식으로든 빠르게 만들어내야 한다는 설명이 이 맥락에 닿아 있다.

    TDD 주기(테스트/컴파일/실행/리팩토링)를 수행하기가 힘든 언어나 환경에서 작업하게 되면 단계가 커지는 경향이 있다. 각 테스트가 더 많은 부분을 포함하게 만든다. 중간 단계를 덜 거치고 리팩토링을 한다. 이렇게 하면 개발 속도가 더 빨라질까, 느려질까? (p. 315)

    단기적으로는 빨라지는 것 같지만 결국엔 테스트와 거리가 먼 코드를 작성하게 된다.

    패턴 복사하기 자체는 훌륭한 프로그래밍이 아니다. 이 사실은 내가 패턴에 대해 이야기 할 때면 늘 강조한다. 패턴은 언제나 반숙 상태며, 자신의 프로젝트 오븐 속에서 적응시켜야 한다. 하지만 이렇게 하기 위한 좋은 방법 중 하나는 일단 무턱대고 패턴을 복사하고 나서, 리팩토링과 테스트 우선을 섞어 사용해서 그 적응과정을 수행하는 것이다. 패턴 복사를 할 때 이렇게 하면 해당 패턴에 대해서만 집중할 수 있게 된다(한 번에 하나씩). (p. 340)

    TDD와 리팩토링 과정 속에서 패턴은 자연스럽게 발견되어야 한다는 이야기와 유사하다. 패턴은 반숙이라는 표현이 와닿았다.


    실천 없는 구호는 의미 없다. 이 계기로 앞으로는 TDD 하도록 노력해야겠다.

    제네릭 없는 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는 문자열로 전달한 경우에는 해당 문자열을 사용해 클래스 또는 인터페이스를 찾으므로 객체임을 확인하는 코드가 필요하다.