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 님 감사 말씀 드립니다.

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

프로젝트 구조 잡기

새로운 프로젝트를 시작하든 레거시 프로젝트를 리팩토링하든 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에서도 다른 타입 언어처럼 함수 인자에 타입을 지정할 수 있도록 타입 선언(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에서 사용된 명칭이다. 
  • 최근 프로젝트에서 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네임스페이스에 코드를 기여했다. 이 컴포넌트는 프록시 클래스와 객체를 기존 클래스의 메타데이터를 기반으로 생성해준다. 지연 로딩을 기존 코드 변경 없이도 바로 사용할 수 있을 것이다.

    예전에도 테스트주도개발에 관한 글을 인터넷에서도 한참 찾아보고 읽었었다. 글을 읽고서 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를 사용하면서 가장 아쉬운 부분은 인터페이스다. 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는 문자열로 전달한 경우에는 해당 문자열을 사용해 클래스 또는 인터페이스를 찾으므로 객체임을 확인하는 코드가 필요하다. 
  • 최근 아키텍처에 관한 책을 읽고 있는데 레퍼런스로 나온 글 중 하나로 Hadi Hariri의 글 Refactoring to Functional–Why Class?을 번역했다. 이 글은 함수형으로 리펙토링하기라는 코틀린 연재 중 일부라서 그다지 공정한 느낌으로 쓰여진 글은 아니지만 객체지향이라는 패러다임에서 논쟁점이 되는 여러 부분을 잘 보여주고 있어 옮겨봤다.


    함수형으로 리팩토링하기 – 왜 클래스죠?

    대학에서

    교수: 우린 실제 세계에서 객체로 둘러쌓여 있습니다. 이 객체는 자동차, 집, 기타 등등이 될 수 있죠. 그런 이유에서 객체 지향 프로그래밍에서 클래스를 통해 실제 세계에 존재하는 객체를 연결하는 방식이 매우 쉬운 이유입니다.

    2주 후

    제이크: 저 이 객체와 문제가 좀 있는데요. 도와주시겠어요?

    교수: 물론이죠. 객체를 만드는데 도움이 되는 여러 일반적인 방법이 있는데 요약하자면 명사를 찾아요. 그리고 동사를 찾으면 클래스에서 사용할 수 있는 메소드가 될 수 있어요. 말하는 그대로죠.

    제이크: 어 말씀한 내용이 합당하네요. 감사합니다.

    신입 제이크

    : 제이크 씨, 당신이 작성한 클래스를 확인했습니다. 좀 크기가 큰 것 같은데요.

    제이크: 죄송합니다. 어떤 부분이 문제죠?

    : 음… 너무 많은 책임을 갖는 게 문제에요. 너무 많은 일을 합니다.

    제이크: 그리고요?

    : 잘 생각해보세요. 하나에 너무 많은 책임이 있으면 이 부분 하나가 시스템의 많은 부분과 연결되어 있다는 뜻이에요. 즉 이 클래스를 변경할 가능성도 상당히 높다는 뜻이고 그건 무언가를 고장내게 될 가능성 또한 높다는 의미죠. 거기다 단일 클래스를 1000줄이 넘도록 작성하면 물론 30줄 짜리 코드에 비해 이해하기 어려울 것이고요.

    제이크: 맞는 말이네요.

    : 이 코드를 작은 클래스로 나누세요. 각각의 클래스는 한 가지 일만 하고 그 클래스 홀로 쓰여야 합니다.

    1년 후

    메리: 제이크 씨, 방금 당신이 작성한 클래스를 확인했는데요. 그다지 행동(behavior)이 많이 들어있지 않네요.

    제이크: 네, 동작이 Customer 클래스에 속하는지 Accounts 클래스에 포함해야 하는지 확신이 없어서 CustomerService라는 클래스를 별도로 만들어 거기에 넣었습니다.

    메리: 네, 적당한 방법이네요. 하지만 Customer 클래스를 더 이상 클래스라고 보기 어려워졌어요. DTO에 더 가까워요.

    제이크: DTO요?

    메리: 네, 데이터 전달 객체(Data Transfer Object)요. 클래스와 비슷하긴 하지만 행동이 없는 경우에요.

    제이크: 음, 그럼 구조체나 레코드에 가깝다는 말씀이시죠?

    메리: 네, 그런 느낌이에요. 클래스를 만들 때는 행동이 있어야 해요. 그러지 않고서는 클래스라고 하기 어려워요. DTO죠.

    제이크: 알겠습니다.

    2년 후

    메튜: 제이크 씨, 이 클래스를 봤는데 특정 구현과 결합(coupled)이 상당히 강하군요.

    제이크: 네?

    메튜: 음, 지금 RepositoryController 내에서 생성하고 있어요. 이 부분은 어떻게 테스트하시겠어요.

    제이크: 음… 시험용 데이터베이스를 사용하면 되지 않을까요?

    메튜: 아뇨. 가장 먼저 해야 하는 부분은 프로그램을 클래스가 아닌 인터페이스를 사용하도록 하는 겁니다. 이 접근 방식이 특정 구현에 매여 있지 않은 코드를 장성하는 방법이에요. 그런 후에 의존성 주입을 사용해서 특정 구현을 전달해 사용하도록 하는겁니다. 그러면 구현을 언제든지 필요할 때 변경할 수 있게 되는 거죠.

    제이크: 그렇군요.

    메튜: 실무에서는 IoC 컨테이너를 사용해서 다른 클래스의 인스턴스를 연결하는 것이 가능할겁니다.

    3년 후

    프랜시스: 제이크 씨, 이 클래스에 너무 많은 의존성을 집어넣고 있군요.

    제이크: 네, 그래도 IoC 컨테이너가 다 처리할겁니다.

    프랜시스: 네, 저도 알고 있습니다. 하지만 가능하다고 해서 옳은 방법이라고 말하기는 어렵네요. 이 클래스는 여러 종류의 구현체를 사용할 수 있다고 하더라도 여전히 너무 많은 다른 클래스에 의존하고 있어요. 하나에서 최대 3개로 유지하도록 해요.

    제이크: 네, 알겠습니다. 감사합니다.

    4년 후

    안나: 제이크 씨, 이 클래스 이름은 왜 Utils인가요?

    제이크: 음. 그 코드는 정말 어디에 놔야 할 지 알 수 없어서 그렇게 이름 붙였어요.

    안나: 그래요. 이미 그런 코드를 위한 클래스가 있어요. RandomStuff라는 이름이에요.

    맥주 마시며

    제이크: 피터, 내가 생각해봤는데 말이지. 학교에서 배울 땐 객체로 생각하고 명사를 분석하라는 등 기법을 얘기했는데 말야. 그러고 나서는 이름을 잘 붙였는지, 작게 작성했는지, 단일 책임으로 작성했는지, 너무 많은 의존성을 주입하고 있는 것은 아닌지 생각해야 한단 말이야. 이제 와서는 동시성에 좋지 않다고 상태를 갖지 않는 코드를 작성해야 한다고 말하지. 처음부터 궁금했는데 이럴거면 도대체 왜 클래스를 사용하는 걸까?

    피터: 헛소리 하지 마 제이크. 만약 클래스가 없다면 어디에 함수를 선언할 수 있겠어? 맥주나 한 잔 더 마실래?

    다음 시간에 계속.

    Google Chrome 58 에서 정책 EnableCommonNameFallbackForLocalAnchors의 기본 설정이 변경되었다. 이 변경으로 개발 환경에서 https에 사용하는 사내 자가서명 인증서에 missing_subjectAltName 문제로 접근이 차단되었다.

    보안상 이 설정을 변경하지 않는 것이 옳지만 인증서를 다시 발급받는 과정이 오래 걸리고 그 동안 가만히 있을 순 없기 때문에 해법을 검색했다. 최근 변경사항이라 글이 많지 않았지만 답을 찾을 수 있었다. 앞서 언급한 EnableCommonNameFallbackForLocalAnchors 설정을 활성화하면 된다.

    각 운영체제에 따라 크롬의 정책을 변경하는 방법이 다르기 때문에 각 운영체제의 Quick Start를 참고한다. 다만 맥에서는 Workgroup Manager를 더이상 지원하지 않는다. 대신 아래 방식으로 설정을 전환할 수 있다. 터미널에서 다음 명령을 실행한다.

    $ defaults write com.google.Chrome EnableCommonNameFallbackForLocalAnchors -bool true
    

    변경된 설정은 chrome://policy/에 접속하면 확인할 수 있다.

    다음 페이지를 참고했고 더 자세한 내용을 확인할 수 있다.

    새로 옮긴 회사에서 열심히 레거시를 정리하고 있다. 기존 코드는 관리가 전혀 되지 않는 인하우스 프레임워크를 사용하고 있어서 전반적으로 구조를 개편하기 위해 고심하고 있다. 이 포스트는 Mark SeemannService Locator is an Anti-Pattern를 번역한 글로 최근 읽었던 포스트 중 이 글을 레퍼런스로 하는 경우를 자주 봐서 번역하게 되었다.


    서비스 로케이터는 안티패턴입니다.

    서비스 로케이터는 마틴 파울러가 설명한 이후로 잘 알려진 패턴이니까 분명 좋은 패턴일 것입니다.

    아쉽게도 실제로는 그렇지 않습니다! 이 패턴은 안티 패턴으로 되도록 피해야 하는 방식입니다.

    왜 안티 패턴인지 더 살펴보도록 합시다. 서비스 로케이터를 사용했을 때 나타나는 문제는 클래스의 의존성을 숨긴다는 점입니다. 다시 말해 컴파일 중에는 오류가 나타나지 않았지만 런타임에서는 오류가 발생할 여지가 있다는 이야기입니다. 이전에 작성한 코드와 호환이 되지 않는 방식으로 코드를 변경했다고 가정해봅시다. 어느 클래스가 어떤 클래스에 의존하고 있는지 명확하게 들어나지 않고 있는 상황에서는 그 변경이 어느 클래스에 영향을 미치는지 확인하기 어렵습니다. 그로 인해서 새로운 코드를 작성할 때마다 어디가 고장나는지 정확히 알 수 없어서 유지보수가 더 어려워질 수 밖에 없습니다.

    OrderProcessor 예제

    예제로 요즘 의존성 주입에서 가장 이슈라고 볼 수 있는 OrderProcessor를 살펴봅시다. 주문을 진행하기 위해서는 OrderProcessor에서 주문을 검증하고, 검증 결과에 문제가 없으면 배송을 처리합니다. 정적 서비스 로케이터의 예제는 다음과 같습니다.

    public class OrderProcessor : IOrderProcessor
    {
        public void Process(Order order)
        {
            var validator = Locator.Resolve<IOrderValidator>();
            if (validator.Validate(order))
            {
                var shipper = Locator.Resolve<IOrderShipper>();
                shipper.Ship(order);
            }
        }
    }
    

    위 코드에서 new 오퍼레이터를 대체하려고 서비스 로케이터를 사용했습니다. Locator는 다음처럼 구현되어 있습니다.

    public static class Locator
    {
        private readonly static Dictionary<Type, Func<object>>
            services = new Dictionary<Type, Func<object>>();
    
        public static void Register<T>(Func<T> resolver)
        {
            Locator.services[typeof(T)] = () => resolver();
        }
    
        public static T Resolve<T>()
        {
            return (T) Locator.services[typeof(T)]();
        }
    
        public static void Reset()
        {
            Locator.services.Clear();
        }
    }
    

    LocatorRegister 메소드를 사용해서 설정할 수 있습니다. 물론 ‘실제’ 서비스 로케이터는 위 코드보다 훨씬 진보된 방식으로 구현되어 있지만 여기서는 이 간단한 예제 코드로도 충분히 문제를 확인할 수 있습니다.

    이 로케이터는 확장이 가능하도록 유연하게 구현되었습니다. 또한 테스트 더블(Test Doubles)의 역할도 수행할 수 있어서 서비스를 테스트하는 것도 가능합니다.

    이렇게 좋은 점이 많은데 어떤 부분이 문제가 될 수 있을까요?

    API 사용 문제

    단순하게 OrderProcessor 클래스를 사용한다고 가정해봅시다. 서드파티에서 제공한 어셈블리라면 우리가 코드를 직접 작성하지 않았기 때문에 Reflector를 사용해서 구현을 확인해야 할 것입니다.

    비주얼 스튜디오의 인텔리센스는 다음 그림처럼 동작합니다.

    자동완성을 보면 클래스가 기본 생성자를 포함하고 있습니다. 다시 말하면 이 클래스로 새 인스턴스를 생성한 다음에야 Process 메소드를 올바르게 실행할 수 있다는 뜻입니다.

    var order = new Order();
    var sut = new OrderProcessor();
    sut.Process(order);
    

    이 코드를 실행하면 예상하지 못한 KeyNotFoundException이 발생하는데 IOrderValidatorLocator에 등록되지 않았기 때문입니다. 심지어 소스 코드에 접근할 수 없는 라이브러리나 패키지라면 어떤 부분으로 오류가 발생한 것인지 정확하게 판단하기 어렵게 됩니다.

    소스 코드를 (또는 Reflector를 사용해서) 찬찬히 들여다 보거나, 문서를 참고해서 결국 IOrderValidator 인스턴스를 전혀 관련 없어 보이는 정적 클래스 Locator에 등록해야 한다는 사실을 아마도 발견할 수도 있을 겁니다.

    유닛 테스트에서는 다음처럼 작성할 수 있습니다.

    var validatorStub = new Mock<IOrderValidator>();
    validatorStub.Setup(v => v.Validate(order)).Returns(false);
    Locator.Register(() => validatorStub.Object);
    

    Locator의 내부 저장소도 정적이라서 테스트를 작성하는 과정도 번거롭습니다. 매 유닛 테스트가 끝나는 순간마다 Reset 메소드를 실행해야 하기 때문인데요. 이 경우는 유닛 테스트의 경우에만 주로 해당되는 문제긴 합니다.

    여기까지 살펴본 내용으로도 이 방식의 API는 긍정적인 개발 경험을 제공한다고 말하기엔 어렵다고 말할 수 있습니다.

    관리 문제

    사용자 관점에서도 서비스 로케이터를 사용하는 일이 문제가 가득하다는 것을 확인했지만 이 관점은 유지보수하는 개발자에게도 쉽게 영향을 미치게 됩니다.

    OrderProcessor의 동작을 확장해서 IOrderCollector.Collect 메소드를 호출한다고 가정해봅시다. 쉽게 기능을 추가할 수 있을…까요?

    public void Process(Order order)
    {
        var validator = Locator.Resolve<IOrderValidator>();
        if (validator.Validate(order))
        {
            var collector = Locator.Resolve<IOrderCollector>();
            collector.Collect(order);
            var shipper = Locator.Resolve<IOrderShipper>();
            shipper.Ship(order);
        }
    }
    

    단순하게 본다면 매우 간단한 구현입니다. 그저 Locator.Resolve를 한 번 더 호출하고 IOrderCollector.Collect를 실행하는 코드로 끝납니다.

    여기서 질문이 있습니다. 이 새로운 기능은 변경 전의 코드와 호환이 될까요?

    놀랍게도 이 질문은 답변하기 어렵습니다. 일단 컴파일에서는 문제가 생기지 않지만 유닛 테스트는 실패하게 됩니다. 실제 프로그램에서도 문제가 발생했을까요? IOrderCollector 인터페이스가 다른 컴포넌트에서 사용되어서 이미 서비스 로케이터에 등록되어 있는 상황이라면 이 코드는 문제 없이 동작하게 됩니다. 그렇다면 정 반대의 상황도 가정해볼 수 있을 겁니다. 테스트는 통과하면서 실제로는 오류가 나는 경우도 완전 없다고 말하기는 어렵습니다.

    결론적으로 서비스 로케이터를 사용하면 지금 변경한 코드가 문제를 만드는 변경인지 아닌지 판단하기 더욱 어려워지게 됩니다. 코드를 수정하거나 작성하기 위해서는 서비스 로케이터를 사용하는 어플리케이션 전체를 모두 이해해야만 합니다. 이 상황에서는 컴파일러도 도움을 줄 수 없겠죠.

    변형: 구체적인 서비스 로케이터

    이 문제를 해결할 방법은 없을까요?

    문제를 해결할 방법을 찾아봅시다. 정적 클래스가 아닌 구체적인(구상, concreate) 클래스로 변경하면 가능할 것 같습니다.

    public void Process(Order order)
    {
        var locator = new Locator();
        var validator = locator.Resolve<IOrderValidator>();
        if (validator.Validate(order))
        {
            var shipper = locator.Resolve<IOrderShipper>();
            shipper.Ship(order);
        }
    }
    

    하지만 여전히 설정이 필요해서 다음과 같은 정적 필드(services)를 활용하게 됩니다.

    public class Locator
    {
        private readonly static Dictionary<Type, Func<object>>
            services = new Dictionary<Type, Func<object>>();
    
        public static void Register<T>(Func<T> resolver)
        {
            Locator.services[typeof(T)] = () => resolver();
        }
    
        public T Resolve<T>()
        {
            return (T) Locator.services[typeof(T)]();
        }
    
        public static void Reset()
        {
            Locator.services.Clear();
        }
    }
    

    정리하면, 구체적인 클래스로 정의한 서비스 로케이터는 앞에서 작성한 정적 구현과 크게 구조적인 차이가 없습니다. 즉, 여전히 동일한 문제가 나타납니다.

    변형: 추상 서비스 로케이터

    다른 변형은 실제 의존성 주입이 동작하는 방식과 비슷합니다. 서비스 로케이터는 다음 IServiceLocator 인터페이스를 구현합니다.

    public interface IServiceLocator
    {
        T Resolve<T>();
    }
    
    public class Locator : IServiceLocator
    {
        private readonly Dictionary<Type, Func<object>> services;
    
        public Locator()
        {
            this.services = new Dictionary<Type, Func<object>>();
        }
    
        public void Register<T>(Func<T> resolver)
        {
            this.services[typeof(T)] = () => resolver();
        }
    
        public T Resolve<T>()
        {
            return (T) this.services[typeof(T)]();
        }
    }
    

    이 변형은 결과적으로 서비스 로케이터를 필요로 하는 곳에 직접 주입하는 방식으로 동작합니다. **생성자 주입(Constructor Injection)**은 의존성 주입에서 좋은 방식이기 때문인데요. 이제 OrderProcessor를 다음처럼 변경해서 서비스 로케이터를 OrderProcessor 내에서 활용할 수 있게 됩니다.

    public class OrderProcessor : IOrderProcessor
    {
        private readonly IServiceLocator locator;
    
        public OrderProcessor(IServiceLocator locator)
        {
            if (locator == null)
            {
                throw new ArgumentNullException("locator");
            }
    
            this.locator = locator;
        }
    
        public void Process(Order order)
        {
            var valiator =
                this.locator.Resolve<IOrderValidator>();
    
            if (validator.Validate(order))
            {
                var shipper =
                    this.locator.Resolve<IOrderShipper>();
                shipper.Ship(order);
            }
        }
    }
    

    그럼 이제 충분한가요?

    개발자 관점으로는 이제 인텔리센스에서 알려주는 정보가 조금 더 많아졌습니다.

    인텔리센스가 OrderProcessor.OrderProcessor(IServiceLocator locator)를 보여줌

    이 정보가 유익한가요? 사실, 별 영양가가 없습니다. OrderProcessorServiceLocator를 필요로 한다는 정보는 조금 더 알 수 있겠지만 실제로 이 서비스 로케이터에서 무슨 서비스를 꺼내 사용하고 있는지는 알 수 없습니다. 다음 코드는 컴파일이 가능하지만 코드를 실행하면 앞서 나타났던 KeyNotFoundException가 동일하게 발생하게 됩니다.

    var order = new Order();
    var locator = new Locator();
    var sut = new OrderProcessor(locator);
    sut.Process(order);
    

    유지보수를 하는 개발자 입장에서도 향상된 부분이 딱히 없습니다. 다른 서비스에 의존적인 코드를 추가하더라도 문제가 발생하는지 안하는지 확답해서 말하기 여전히 어렵습니다.

    정리

    서비스 로케이터 문제는 특정 서비스 로케이터 구현을 사용한다고 해서 발생 유무가 달라지는 문제가 아닙니다. 이 패턴을 사용하면 언제든 문제가 나타나는, 진정한 안티-패턴입니다. (물론 특정 구현에 더 문제가 있는 경우는 얼마든지 있습니다.) 이 패턴은 API를 소비하는 모든 사용자에게 끔찍한 개발 경험을 제공합니다. 유지보수를 하는 개발자 입장에서는 변경 하나 하나를 만들 때마다 두뇌를 풀가동해서 변경이 미치는 모든 영향을 파악해야 하므로 더욱 고통스러워질 것입니다.

    생성자 주입을 사용한다면 컴파일러는 코드를 소비하는 사람과 생산하는 사람 모두에게 도움이 됩니다. 서비스 로케이터에 의존하는 API라면 이런 도움을 전혀 받을 수 없습니다.

    • 의존성 주입 패턴과 안티 패턴에 대해 더 알고 싶다면 제 책에서 확인할 수 있습니다.
    • 또한 서비스 로케이터는 SOLID 원칙을 위반한다는 점에서 부정적입니다.
    • 서비스 로케이터의 근본적인 문제는 캡슐화 위반에서 나타납니다.

    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로 발행하는 등 다양한 방법으로 활용할 수 있을 것 같다.

    웹사이트 설정

    웹페이지 색상을 선택하세요

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