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

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

프로젝트 구조 잡기

새로운 프로젝트를 시작하든 레거시 프로젝트를 리팩토링하든 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를 최상위에 두는 방식으로도 작성한다.

김용균

안녕하세요, 김용균입니다. 문제를 해결하기 위해 작고 단단한 코드를 작성하는 일을 합니다. 웹의 자유로운 접근성을 좋아합니다. 프로그래밍 언어, 소프트웨어 아키텍처, 커뮤니티에 관심이 많습니다.

이 글 공유하기

이 글이 유익했다면 주변에도 알려주세요!

페이스북으로 공유하기트위터로 공유하기링크드인으로 공유하기Email 보내기

주제별 목록

같은 주제의 다른 글을 읽어보고 싶다면 아래 링크를 확인하세요.

August 02, 2017

500 마일 이메일 문제

The case of the 500-mile email 을 번역했다. 여기 불가능처럼 들리는 문제가 있습니다. 이 이야기를 공개적인 곳에 올리는걸 분명 후회할겁니다. 왜냐면 이 이야기는 컨퍼런스 갔을 때 술마시면서 하기 좋은 대단한 이야기기 때문…

July 07, 2017

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

PHP에서도 다른 타입 언어처럼 함수 인자에 타입을 지정할 수 있도록 타입 선언(Type declaration)을 지원한다. 1 동적 타입 언어에서 왜 이런 문법을 사용해야 하는가에 대한 이야기는 여전히 많지만 타입 선언을 사용하는 쪽을 선호한다…