franzliedke/studio는 php 패키지를 개발할 때 로컬에 있는 패키지를 참조할 수 있도록 도와주는 composer 확장 도구다.
물론 composer에서도 composer.json의 repositories 설정을 추가하는 것으로 로컬에 있는 패키지를 참조할 수 있다. 하지만 패키지를 배포할 때마다 이 부분을 다시 정리해야 하는 불편이 있다. 만약 경로가 포함된 상태로 배포가 된다면 해당 리포지터리를 참조할 수 없다고 아예 의존성 설치가 불가능해진다. studio는 이런 문제를 해결한다.
이 도구도 내부적으로는 repositories의 path 타입을 추가하는 방식으로 동작하지만 composer.json 파일은 직접 변경하지는 않으며 studio.json이라는 별도 파일을 생성한다.
설치
다음처럼 전역에 설치할 수 있지만 PATH에 ~/.composer/vendor/bin 경로가 추가되어 있어야 한다.
$ composer global require franzl/studio
또는 지역적으로 설치해서 vendor/bin/studio로 사용하는 것도 가능하다.
$ composer require --dev franzl/studio
사용
사용하려는 다른 패키지를 먼저 studio에 등록한다.
$ studio load path/to/some-package
사용하려는 패키지가 한 폴더 내에 모두 있는 경우에는 와일드카드 사용도 가능하다. packages 폴더에 모두 있다면 다음처럼 불러온다. (대신 따옴표를 잘 사용해야 한다.)
$ studio load 'path/to/packages/*'
이미 패키지가 추가되어 있는 경우에는 업데이트를 하면 된다. 패키지명이 my/some-package라고 한다면,
$ composer update my/some-package
새로 설치하는 경우라면 require를 사용한다. @dev는 가장 마지막 커밋을 참조하도록 dev-master를 사용하는 것과 동일한데 더 자세한 내용은 composer 문서를 참고하자.
요즘 작업하는 환경이 상당히 오래된 코드를 접할 수 있는 환경이라서 코드를 정리하는 일이 많은데 최근 버전에서도 돌아갈 수 있도록 코드를 정리하는 김에 패키지로 관리하고 테스트도 작성하도록 팀에 권하고 있다. 특별하다고 볼 만한 부분은 아니지만 정리 겸 작성한다. 사실 제목에 비해 내용이 별로 많질 않다. 나중에 기회가 되면 더 세세하게 작성해보고 싶다.
프로젝트 구조 잡기
새로운 프로젝트를 시작하든 레거시 프로젝트를 리팩토링하든 composer.json을 작성하는 작업으로 시작하게 된다. composer.json은 composer 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 항목을 추가한다.
.gitignore에 phpunit.xml을 추가한다. phpunit은 phpunit.xml이 있으면 해당 파일을 설정에 사용하게 된다. 없는 경우에는 phpunit.xml.dist를 사용한다. 여기서는 별다른 설정이 필요 없으니 phpunit.xml을 생성하지 않는다.
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 클래스를 만들어야 한다. src에 HelloWorld.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에서 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.
$ 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에서도 확인 가능하다.
위 스크린샷 상단에 보면 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-http을 false로 지정한다 정도의 해결 방법이 있다. (순서는 추천하는 순서. 5번은 권장 안한다.)
/path/to/build는 satis.json이 위치한 로컬 경로고 $COMPOSER_HOME은 컴포저가 설치된 경로로 기본 설치를 한 경우는 ~/.composer에 해당한다. composer를 지정하면 현재 컴퓨터에 설정된 composer를 사용하기 때문에 auth.json에 저장되어 있는 정보를 그대로 사용할 수 있다.
내 경우에는 학교 내 내부망에 위치한 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로 발행하는 등 다양한 방법으로 활용할 수 있을 것 같다.
먼 길을 가도 그 시작은 첫 발을 내딛는 일에서, 구렁이 담 넘어가듯 모던 PHP 넘어가기 시리즈.
흔히 모던 PHP라고 말하는 현대적인 PHP 개발 방식에 대해 많은 이야기가 있다. 새 방식을 사용하면 협의된 명세를 통해 코드 재사용성을 높이고 패키지를 통해 코드 간 의존성을 낮출 수 있는 등 다른 프로그래밍 언어에서 사용 가능했던 좋은 기능을 PHP에서도 활용할 수 있게 된 것이다. 이 방식은 과거 PHP 환경에 비해 확실히 개선되었다. 하지만 아무리 좋은 개발 방식이라 해도 현장에서 쉽게 도입하기 어렵다. 코드 기반이 너무 커서 일괄 전환이 어렵거나, 이전 환경에 종속적인 경우(mysql_* 함수를 여전히 사용), 새로운 개발 방식을 적용하기 위한 재교육 비용이 너무 크고, 신규 프로젝트와 구 프로젝트가 공존하는 동안 전환 비용이 발생할 수 있다는 점이 걸림돌이 된다. 이런 이유로 사내 정책 상 예전 환경을 계속 사용하기로 결정할 수도 있고, 개개인의 선택에 따라 계속 이전 버전을 사용하는 경우도 있을 것이다. 그 결과, 좋은 개발 방식임을 이미 알고 있지만 마치 다른 나라 이야기처럼 느끼는 사람도 많다.
A: 팀장님 모던 PHP 도입합시다 +_+
B:
지금 다니고 있는 회사에서도 모든 코드를 일괄적으로 모던 PHP로 이전할 수 없었다. 가장 큰 문제는 코드란 혼자서만 작성하는 것이 아니며 다른 개발자와의 협업도 고려해야 하기 때문에 구성원 간의 협의가 필요하다. 그래서 일괄적인 도입보다는 점진적으로 코드는 개선해 가면서 새 개발 방식에 천천히 적응하는 방법은 없는지 고민하게 되었다. 작은 코드부터 시작해서 먼저 도입할 수 있는 부분부터 차근차근 도입하기 시작했다. 아직 회사에서 사용하는 대다수의 프레임워크와 CMS가 이전 방식을 기반으로 하고 있기 때문에 100% 모던 PHP를 사용하는 것은 아직 멀었지만, 팀 내에서 작은 크기의 코드인데도 새 방식의 장점과 필요성을 설득하기에 충분한 역할을 할 수 있었다.
레거시 PHP 코드에서 모던 PHP 코드로
이전 PHP 코드를 사용하면서도 현대적인 PHP를 도입하기 위해 고민하고 있다면 도움이 되지 않을까 하는 생각으로 이렇게 정리하게 되었다. 이 글에서 다루는 내용은 내가 소속된 회사에서도 현재 진행형이다. 즉, 이 글을 따라서 해야만 정답인 것은 아니다. 프로젝트의 수 만큼 다양한 경우의 수가 존재하기 때문에 하나의 방법만 고집할 수는 없다. 이 글이 그 정답을 찾기 위한 과정에서 도움이 되었으면 좋겠다.
먼 길을 가도 그 시작은 첫 발을 내딛는 일에서 시작한다. 이전의 코드 기반에서 여전히 작업하고 있고, 그 코드 양이 너무 많다 하더라도 점진적으로 코드를 개선할 수 있는 방법이 있다. 여기에서 다룰 내용은 모던 PHP를 도입하기 위해 회사에서 작업했던 작은 부분을 정리한 것이다. 코드를 개선하면서 전혀 지식이 없는 구성원도 자연스레 학습할 수 있도록 학습 곡선을 완만하게 만들기 위해 노력했던 부분도 함께 정리해보려고 한다. 가장 먼저 뷰를 분리하는 것에 대해 다뤄보려고 한다.
뷰 분리하기
코드를 분리해서 작성하는 과정은 중요하다. 각 코드가 서로에게 너무 의존적이거나 한 쪽이 다른 한 쪽을 너무 잘 아는 경우에는 코드 재사용도 어렵고 제대로 구동되는지 확인하기도 어렵다. PHP는 언어적인 특징 때문인지 몰라도 뷰 부분이 특히 심하게 붙어있는 모습을 자주 발견할 수 있다.
MVC 패턴을 사용하는 PHP 프레임워크 또는 플랫폼을 사용하는 프로젝트라면 별도로 뷰를 분리하는 노력 없이도 자연스럽게 로직과 뷰를 구분해서 작성할 수 있다. 하지만 여전히 많은 PHP 프로젝트는 echo로 HTML 문자열을 직접 출력하거나 include를 사용해서 별도의 파일을 불러오는 방식으로 개발되어 있다. 예를 먼저 확인해 보자.
이 코드를 보면 앞서 방식보다는 뷰가 분리된 것처럼 보인다. 하지만 이 코드는 뷰를 분리했다기 보다는 두개의 PHP 파일로 나눠서 작성하는 방식에 가깝다.
여기서 살펴본 두 경우는 이전에 작성된 코드라면 쉽게 찾을 수 있는 방식이다. 빠르고 간편하게 작성할 수 있을지 몰라도 재사용성이 높지 않고 관리가 쉽지 않다. 그나마 두 번째 방식은 분리되어 있지만 그렇다고 편리하다고는 할 수 없는 구석이 많다. 왜 이렇게 작성된 코드는 불편한 것일까?
전자의 경우는 외부의 함수를 그대로 사용하고 있어서 뷰의 의존도가 높다. 함수의 반환 값이나 사용 방식이 달라지면 뷰에서 해당 함수를 사용한 모든 위치를 찾아서 변경해야 할 것이다. 그리고 이렇게 생성된 html을 다른 곳에서 다시 사용하기는 쉽지 않다. 이 파일을 다른 파일에서 불러오게 되면 파일 내에 포함되어 있는 모든 기능을 호출하게 된다. 이 파일을 다시 사용하더라도 작성했을 당시의 의도를 바꾸기 어렵다.
그래도 후자의 경우는 뷰가 분리되어 있어서 뷰를 다시 사용하는 것은 가능하게 느껴진다. 하지만 뷰에서 전역 변수에 접근하는 방식으로 데이터에 접근하고 있다. 이런 상황에서는 뷰에서 어떤 변수를 사용하고 있는지 뷰 코드를 들여다보기 전까지는 알기 어렵다. 이런 방식으로 뷰를 재사용하게 되면 해당 파일을 include 하기 전에 어떤 변수를 php 내부에서 사용하고 있는지 살펴본 후, 모두 전역 변수로 선언한 다음 include를 수행해야 한다.
결과를 예측할 수 없는 코드
PHP에서는 결과를 출력하는데 수고가 전혀 필요 없다. 위 코드에서 보는 것처럼 <?php ?>로 처리되지 않은 부분과 echo를 사용해서 출력한 부분은 바로 화면에 노출되기 때문이다. 이 특징은 짧은 코드를 작성할 때 큰 고민 없이 빠르게 작성할 수 있도록 하지만 조금이라도 규모가 커지기 시작하면 관리를 어렵게 만든다.
앞에서 확인한 예제처럼 작성한 PHP 코드가 점점 관리하기 어렵게 변하는 이유는 바로 출력되는 결과를 예측하는 것이 불가능하다는 점 에서 기인한다. (물론 e2e 테스트를 수행할 수 있지만 이런 코드를 작성하는 곳에서 e2e 테스트를 사용한다면 특수한 경우다.) 두 파일에서 출력하는 내용은 변수로 받은 후 출력 여부를 결정하는 흐름이 존재하지 않는다. 전자는 데이터를 직접 가져와서 바로 출력하고 있고 후자는 가져올 데이터를 전역 변수를 통해 접근하고 있다. 개발자의 의도에 따라서 통제되는 방식이라고 하기 어렵다. 오히려 물감을 가져와서 종이 위에 어떻게 뿌려지는지 쏟아놓고 보는 방식에 가깝다. 이전 코드를 사용하는 PHP는 대부분 일단 코드를 쏟은 후에 눈과 손으로 직접 확인하는 경우가 많다. 이는 코드가 적을 경우에 문제 없지만 커지면 그만큼 번거로운 일이 된다. 결국에는 통제가 안되는 코드를 수정하는 일은 꺼림칙하고 두려운 작업이 되고 만다.
함수에 대해 생각해보자. 프로그래밍을 하게 되면 필연적으로 함수를 작성하게 된다. 함수는 인자로 값을 입력하고, 가공한 후에 결과를 반환한다. 대부분의 함수는 특수한 용도가 아닌 이상에는 같은 값을 넣었을 때 항상 같은 결과를 반환한다. 수학에서는 함수에 인자로 전달할 수 있는 값의 집합을 정의역으로, 결과로 받을 수 있는 값의 집합을 치역으로 정의한다. 프로그래밍에서의 함수도 동일하게 입력으로 사용할 수 있는 집합과 그 입력으로 출력되는 결과 값 집합이 존재한다. 즉, 입력의 범위를 명확히 알면 출력되는 결과물도 명확하게 알 수 있다는 뜻이다.
뷰를 입력과 출력이 존재하는 함수라는 관점에서 생각해보자. 위에서 작성했던 코드를 다시 보면 $session과 $users를 입력받고 html로 변환한 값을 반환하는 함수로 볼 수 있다. 함수 형태로 뷰를 사용한다면 뷰에서 사용할 변수를 인자로 사용할 수 있어서 입력을 명확하게 통제할 수 있다. 앞서 본 함수의 특징처럼 이 뷰 함수도 입력에 따라 그 결과인 html을 예측할 수 있게 된다. 다시 말해, 그동안 사용한 뷰를 함수처럼 바꾼다면 입력과 출력의 범위를 명확하게 파악할 수 있게 되는 것이다.
뷰 함수로 전환하기
간단하게 뷰를 불러오는 함수를 구현해보자. 파일을 불러오더라도 출력하는 결과를 예측할 수 있도록 만들 수 있다. 다음처럼 include로 불러온 내용을 결과로 반환하도록 작성한다.
<?php
function view($template) {
if (is_file($template)) {
ob_start();
include $template;
return ob_get_clean();
}
return new Exception("Template not found");
}
?>
출력 버퍼를 제어하는 함수 ob_start(), ob_get_clean()을 사용해서 불러온 결과를 반환했다. 이 함수를 사용해서 외부 파일을 불러와도 바로 출력되지 않고 변수로 받은 후 출력할 수 있게 되었다.
이렇게 불러온 php 파일은 함수 내에서 불러왔기 때문에 함수 외부에 있는 전역 변수에 접근할 수 없다. 함수 스코프에 의해 전역 변수로부터 통제된 환경이 만들어진 것이다. 덕분에 외부의 영향을 받지 않는 방식으로 php 파일을 불러올 수 있게 되었다.
이제 뷰 파일 내에서 사용하려는 변수를 뷰 함수의 인자로 넘겨주려고 한다. 넘어온 변수를 해당 php 파일을 불러오는 환경에서 사용할 수 있도록 다음처럼 함수를 수정한다.
<?php
function view($template, $data = []) {
if (is_file($template)) {
ob_start();
extract($data);
include $template;
return ob_get_clean();
}
return new Exception("Template not found");
}
?>
$data로 외부 값을 배열로 받은 후에 extract() 함수를 사용해서 내부 변수로 전환했다. 새로 작성한 함수를 사용한 예시다.
<?php // templates/dashboard.php ?>
<?php if ( isset($user) ): ?>
<div>Welcome, <?php echo $user['nickname'];?>!</div>
<?php else: ?>
<div>I don't know who you are. Welcome, Stranger!</div>
<?php endif; ?>
여기서 사용한 뷰 함수는 간단한 예시로, 뷰를 불러오는 환경을 보여주기 위한 예제에 불과하다. 실제로 사용하게 될 때는 레이아웃 내 다양한 계층과 구성 요소를 처리해야 하는 경우가 많다. 이럴 때는 이 함수로는 부족하며 이런 함수 대신 다양한 환경을 고려해서 개발된 템플릿 패키지를 적용하는 게 바람직하다.
템플릿 패키지 사용하기
다양한 PHP 프레임워크는 각자 개성있고 많은 기능을 가진 템플릿 엔진을 사용하고 있다. Laravel은 Blade, Symfony는 Twig을 채택하고 있다. 모두 좋은 템플릿 엔진이고, PHP와는 다르게 독자적인 문법을 채택해서 작성하는 파일이 뷰의 역할만 할 수 있도록 PHP 전체의 기능을 제한하고 최대한 뷰 역할을 수행하도록 적절한 문법을 제공한다. 이런 템플릿 엔진을 사용할 수 있는 환경이라면 편리하고 가독성 높은 템플릿 문법을 사용할 수 있다.
프레임워크를 사용하지 않는 환경이라면 이런 템플릿 엔진이 학습 곡선을 더한다는 인상을 받을 수 있으며 같이 유지보수 하는 사람에게 부담을 줄 수도 있다. 이런 경우에는 PHP를 템플릿으로 사용하는 템플릿 엔진을 선택할 수 있다. 사용하는 템플릿이 여전히 PHP 파일과 같은 문법을 사용하면서도 앞에서 작성해본 뷰 함수와 같이 통제된 환경을 제공할 수 있는 패키지가 있다. 여기서는 Plates를 사용해서 앞에서 작성한 코드를 수정해볼 것이다.
먼저 Plates를 composer로 설치한다.
$ composer require league/plates
그리고 php 앞에서 autoload.php를 불러와 내려받은 plates를 사용할 수 있도록 한다.
<?php // dashboard.php
require_once('vendor/autoload.php');
require_once('lib.php');
// templates을 기본 경로와 함께 초기화
$templates = new League\Plates\Engine('templates');
$session = get_session();
if ( $session->is_logged_in() ) {
$user = get_user($session->get_current_user_id());
} else {
$user = null;
}
// 앞서 view 함수처럼 사용
echo $templates->render('dashboard', [ 'user' => $user ]);
Plates는 레이아웃, 중첩, 내부 함수 사용이나 템플릿 상속 등 편리한 기능을 제공한다. 자세한 내용은 Plates의 문서를 확인한다.
정리
지금까지 뷰를 분리하는 과정을 간단하게 살펴봤다. MVC 프레임워크처럼 구조가 잘 잡힌 코드를 사용하는 것이 가장 바람직하겠지만, 점진적으로 코드를 개선하려면 여기서 살펴본 방식대로 뷰를 먼저 분리하는 것부터 작게 시작할 수 있다.
뷰 함수 또는 템플릿 엔진을 사용해서 외부 환경의 영향을 받지 않는 독립적인 뷰를 만들 수 있다. 뷰가 결과로 반환되기 때문에 출력 범위를 예측하고 테스트 할 수 있게 된다. 또한 뷰에 집어넣는 값을 통제할 수 있다. 전역 변수 접근을 차단하는 것으로 외부 요인의 영향을 최대한 줄일 수 있다.
그리고 새로운 내용을 배우거나 도입하는데 거리낌이 있는 경우라도 타 템플릿 엔진과 달리 Plates 같은 템플릿 엔진은 PHP 로직을 그대로 사용할 수 있기 때문에 상대적으로 자연스럽게 도입할 수 있다.
이상적인 상황을 가정해보면 이 패키지를 composer를 사용해서 설치하는 것으로 새로운 개발 흐름에 조금씩 관심을 갖게 만들 수 있고 새로운 패키지를 도입하는 것에 대해 좋은 인상을 남길 수 있다. 또한 추후에 MVC 프레임워크를 도입해도 뷰를 분리해서 작성하는 방식에 자연스럽게 녹아들 수 있을 것이다.
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 제공자를 위해 만들었고 또한 아주 제한적인 컨텍스트만 제공하기 때문이다.) 우리가 따를 규칙은 다음과 같다:
여기서 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가 첨부되어 있고 몇가지 소소한 수정이 포함되어 있다. 만약 원하지 않는다면 원본을 사용하고 차이점은 직접 비교해보기 바란다.
우리는 일반적인 메타 데이터를 설정하고 요구 항목을 정의했고 PSR-4 autoloading을 설정했다. 이 과정이 위 목록에서 1-6번 항목에 해당한다. 여기서 Diffbot API를 호출할 수 있도록 돕는 HTTP 클라이언트 라이브러리 Guzzle을 요구 항목에 추가한다.
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를 실수로 리포지터리에 보내는 일이 없도록 .gitignore에 index.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를 할 예정이다.
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를 내려받는다는 의미):