inlee's blog

지원하지 않는 PHP 버전에서 사용할 Microframework 구현

· inlee

충분한 휴식을 가진 후 이직을 했다. 구현된 소스코드를 보고 개발을 하며 새로운 회사에 적응하고 있다. 금수강산이 2번 변할 동안 회사를 지켜온 코드는 그 시대의 모습을 거의 그대로 유지한 채 개발과 유지보수를 해 현재의 modern한 모습과는 매우 거리가 멀었다. PHP가 modern 이라는 것을 적용한 건 생각보다 오래되지 않기 때문에 회사를 지켜온 코드를 만드는 시점에는 기존 방법이 최선이였을 것이다. 또한 규모가 크고 쌓인 역사가 있어 있어 전환이 힘들었을 것이다. 그래서 PHP 버전도 최대한 호환이 되는 범위까지만 올려 사용하는 현실이다.

최신 트렌드에 맞추어 다시 구현한다는 것은 회사 입장에서는 매출과 직결되므로 당장은 불가능하다. 물론 언젠가는 해야겠지만. 하지만 개발자라면 소스코드가 눈에 밟히는 것 또한 현실이다. 그렇다면 개발자 입장에서 어떻게 해야 재미있고 보람있으며 경험치를 높일 수 있을까? 라는 생각을 해 보면 뻔한 답변이지만 유지보수시에는 기존 소스코드를 조금이라도 정리하며 신규 구현 / 리뉴얼은 프레임워크를 이용하는 것이다.

하지만 대부분의 PHP 프레임워크들은 modern 요소를 차용하고 있어 7 버전 이상을 지원한다. 또한 deprecated된 버전의 지원을 하지 않는다. 이런 상황에서 사용할 수 있는 framework는 지원이 끊긴 Legacy 버전인 Codeigniter 2 정도인데 기존 동작중인 모듈과 유연하게 연계되어야 하기 때문에 적용을 고려하지 않았다.

그래서 낮은 버전에서도 사용할 수 있는 매우매우 간단한 마이크로 프레임워크(Microframework)를 만들었으며 그 과정을 작성해보고자 한다.

글쓰기 앞서 (Thank you)

CHRISTOPH STITZ, Thank you for your posting for my working microframework implementation which will be used for PHP legacy version.

REST API 구현은 CHRISTOPH STITZSimple and elegant URL routing with PHP 라는 글의 소스코드를 참조하였으며 필요한 부분은 추가 및 수정을 하였다. 많은 도움이 된 CHRISTOPH STITIZ에게 감사 인사를 드리고 싶다.

요구사항

구현한 마이크로프레임워크는 크게 Codeigniter, Slim, Laravel 그리고 Express를 사용했던 경험을 최대한 녹여내보고자 했다. 또한 Docker 환경을 고려하였다.

  • RESTful 한 요소를 지원해야 하며 낮은 버전의 PHP에서 사용이 가능해야 한다.
  • Slim framework는 지원되는 것이 거의 없었다. 때문에 기본적인 것들을 최대한 지원해야 한다.
  • Laravel과 최대한 동일한 디렉터리 구조여야 한다. Laravel 진입 장벽이 높은 단점을 조금이라도 극복해보기 위함이다.
  • MVC 구조를 적용한다.
  • Slim과 Laravel은 Route 정의 시 많아지면 정리가 안되는데 이를 최대한 깔끔하게 할 수 있어야 한다.
  • 환경설정은 Codeigniter의 방식(config 디렉터리 내 .php 파일)이 아닌 .env를 사용해야 한다.
  • Dependency는 최대한 줄여야 한다.

구현

본 섹션은 구현과 관련된 주요 사항만 기록하였다. 자세한 내용은 저장된 Github 리파지토리에 업로드 하였다.

PHP 버전 (With docker)

낮은 버전의 PHP 버전에서 동작할 수 있어야 하기 때문에 Docker Hub에서 제공하는 가장 낮은 PHP 버전인 5.3.29에서 개발하였다. 현재 5.3.29 버전은 공식적으로 지원하지 않으나 Image는 남아있어 해당 버전의 Apache 기반 이미지를 사용하였다.

Route를 사용하려면 rewrite module이 활성화 되어야 하기 때문에 별도의 Dockerfile에 아래와 같이 rewrite를 활성화하여 사용하였다. 또한 AllowOverride All 옵션은 기본으로 되어 있어야 한다.

  • Docker에서 제공하는 PHP 5.3.29 버전의 공식 Image는 AllowOverride가 All로 설정되어 있다.
FROM php:5.3-apache
RUN a2enmod rewrite

.env

환경설정 정보를 저장하기 위해 .env를 구현하였다. vulcas/phpdotenv 패키지가 널리 사용되지만 본 구현에서는 패키지 없이 단독으로 동작하도록 구현을 목표로 했기 때문에 별도로 구현하여 적용하였다.

Autoloader

composer를 이용하면(composer.json에 autoload 등록 시) autoload 를 지원해준다. 이는 PHP 표준 권고안(PHP-PSR)인 PSR-4 Autoloader에서 정의한 표준이다. 기존에는 PSR-0(Autoloading Standard)에 정의되었으나 현재 PSR-4가 이를 대신하고 있으며 composer는 이를 구현해 적용하고 있다.

하지만 구현하고자 하는 환경에 composer 있다면 이를 쉽게 사용할 수 있으나 없는 관계로 (회사에서 사용하지 않음, 정의한 Dockerfile에서도 별도로 설치해 사용하지 않음) autoloader를 class로 구현되어 있는 예시를 참조해서 사용하였다.

REST Method

은 표준이 아닌 de facto, 사실상 표준과 같은 것이다. 현재 개발 트렌드중 필수이다. 구현 방법은 CHRISTOPH STITZ 의 방법을 참조하였다.

단 GET, POST가 아닌 PUT, PATCH, DELETE 등의 method는 표준이 아니기 때문에 아래와 같이 form post submit시 method 를 지정하여 사용할 수 있도록 하였다.

<!-- PUT method: 사용자 정보 수정 -->
<form action="/user/1" method="POST">
	<input type="hidden" name="_method" value="PUT">
  ...
</form>

<!-- DELETE method: 사용자 정보 삭제 -->
<form action="/user/1" method="POST">
	<input type="hidden" name="_method" value="DELETE">
  ...
</form>

Route 클래스에서 POST method 입력 시 form에 _method 이름으로 value가 PUT이면 Route내 정의된 PUT 메소드로, DELETE이면 DELETE 메소드로 이동하도록 아래와 같이 구현하였다.

// method가 post인 경우, magic method 존재여부 확인 (put, patch, delete 등 확인)
// _method 항목이 존재하면 해당 method 사용, 그 외는 post 사용
if (strtolower($method) == 'post') {
  $method = isset($_REQUEST['_method'])
    ? $_REQUEST['_method']
    : 'post';
}

...
 
// Check method match
if (strtolower($method) == strtolower($route['method'])) {
	// callback 함수 호출. 
	// $match는 callback에서 사용할 매개변수 저장 
	echo self::callCallback($route['callback'], $matches);
}

Route

Slim, Laravel을 사용하며 느꼈던 불편함 중 하나는 Route 개수가 늘어나면 관리가 힘들다는 것이였다. 물론 Laravel의 경우 RouteServiceProvider에서 설정을 통해 별도의 파일에 분리하여 정의할 수 있지만 경우에 따라 Route 정의 파일 내 Route 개수가 늘어나는건 어쩔 수 없다.

생각해보면 Route 정의는 크게 Method, URL로 구성되는 반복적인 구성이다. 그래서 Slim을 사용할 때에는 아래와 같이 배열에 항목들을 넣어두고 loop를 돌며 정의했었다.

$routes = [
	['method' => 'GET', 'url' => '/users', 'controller' => 'App\Controllers\UserController@index'],
	['method' => 'GET', 'url' => '/users/create', 'controller' => 'App\Controllers\UserController@create'],
	['method' => 'POST', 'url' => '/users', 'controller' => 'App\Controllers\UserController@store']
];

foreach ($route as $route) {
	$app->{$route['method']}($route['url'], $route['controller']);
}

하지만 Route 정보를 php 소스코드 파일로 만들어야 하는가에 대한 생각이 들었다. Route는 소스코드의 일부지만 언제든지 변할 가능성이 있는 일종의 환경설정 정보이며 별도의 파일로 분리해 소스코드의 수정 없이 유연하게 대체가 가능한다면 조금 더 좋지 않을까 하는 생각이 들었다.

그래서 이번 구현에서는 아래와 같이 symfony에서 지원하고 있는 방법과 유사한 .xml 파일로 route를 정의하는 방식으로 구현하였다.

<?xml version="1.0" encoding="UTF-8"?>
<document>
    <!-- Route 정보 -->
    <routes baseUrl="/">
        <web>
            <route method="GET" url="/" controller="App\Controllers\HomeController@index" />
            <route method="GET" url="/info.html" controller="App\Controllers\HomeController@info" />
        </web>
    </routes>
    <!-- Error 발생 시 처리할 Handler 정보 -->
    <errors>
        <error code="404" name="Not Found" controller="App\Controllers\ErrorController::notFound" />
        <error code="405" name="Method Not Allowed" controller="App\Controllers\ErrorController::methodNotAllowed" />
    </errors>
</document>

.xml를 이용함으로써 route를 조금 더 깔끔하게 정리할 수 있게 되었지만 xml parsing 과정의 추가 및 적용 과정이 필요해 기존에 사용했었던 .php 방법에 비해 복잡해졌다.

만들고 사용해보며 생각해본 결과 결론은 .xml 방식과 .php 배열 방식은 별 다른 차이가 없다. .xml 방식이 조금 더 복잡하나 체계적으로 정리가 되었다. 성능상 손실은 .xml 쪽이 parsing 하는 별도의 코드가 추가되기 때문에 크지만 체감상의 차이는 없다.

MVC

Laravel과 유사한 구조로 Model, View, Controller를 저장한 후 사용하도록 구현하였다.

Security

프레임워크를 만들며 가장 많이 들었던 보안에 대해 적용을 해 보고자 했으나, CSRF의 경우 세션(Session)과 연계되어야 하기 때문에 추후 구현을 하기로 하였다.

Sanitize

Sanitize는 ‘살균하다’ 라는 의미며 소스코드에서 불필요한 부분을 제거하는 것을 말한다. 프레임워크 구현에서는 2가지 영역에서 Sanitize를 하였다.

  • View 파일 용량을 줄이고(공백/탭 제거), 불필요한 주석 제거
  • XSS escaping
CSRF

세션을 이용해 구현할 예정이다.

XSS

XSS(Cross Site Scripting) 공격은 게시판 등에 자바스크립트(Javascript)를 넣어 해당 게시글을 Open 할 시 스크립트가 자동으로 실행되게 하여 쿠키나 세션 등을 탈취하는 공격이다.

본 구현에서는 POST, PUT로 전달되는 value들을 전부 개발자가 수동으로 escaping (strip_tags() 함수 이용) 하여 저장하도록 하였다. 추후 자동으로 적용되도록 수정할 예정이다. 참조

$ret = array_walk_recursive($array, function (&$input) {
	$input = htmlspecialchars(strip_tags($input));
});
SQL Injection

아래와 같이 PDO의 prepare statement를 적용하였다.

$stmt = $db->prepare(“UPDATE test SET name=:name WHERE id=:id“); 
$stmt->bindParam(':name', $name);   
$stmt->bindParam(':id', $id);     
Crypto

AES와 Bcrypt를 적용하였다.

AES의 경우

bcrypt

PHP 5.3.x 버전에서는 password_hash, password_verify 함수를 지원하지 않기 때문에 아래의 모듈을 이용해 해당 함수가 없을 경우 모듈에 구현된 함수를 사용하도록 하였다.

AES

openssl 혹은 mcrypt 계열(최신 버전에서 deprecated)를 사용해서 구현하는 것이 일반적이나 둘 다 없을 경우를 대비해 아래의 순수 AES 구현 모듈을 적용하였다.

기타

결과

구현 결과는 Github Repository에 업로드 하였다.

향후 계획

  • 세션(Session)과 CSRF token을 적용할 예정이다.
  • 사이트 간 요청 위조(또는 크로스 사이트 요청 위조, Cross Site Request Forgery) 방지는 세션과 같이 구현할 예정이다.
  • Svelte를 도입해볼까 한다.

마치며

언어에 상관없이 현재 많은 Legacy 코드들이 Legacy 환경에서 운영되고 있다. 이들을 최신 버전 환경에서 동작하도록 변경하는 것은 꿈만 같은 이야기이다. 현실적으로 어디서 발생할지 모를 Side effect로 인해 변경은 사실상 불가능하다. 새로 구현하는 것이 빠를 수 있다.

Legacy 환경에서의 코드들을 끊임없는 유지보수를 요구하는데 이 과정에서 기존의 형식을 따르는 것 또한 중요하지만 최소 코드 정리를 해 나가며 진행을 해야 한다. 조그만 모듈을 새롭게 개발한다면 단순한 프레임워크를 적용해 구조적이며 효율적으로 개발하는 것이 개발자에게 동기부여가 될 것이다. 또한 Legacy 코드를 보고 유지보수 하는것 또한 엄청난 경험치를 쌓이게 할 것이다.

이번 구현에서는 프레임워크라고 하지만 프레임워크 같지 않은 단순 구현이다. 낮은 버전의 PHP에서 효율적이고 구조적으로 개발하기 위한 목적이며, 추가하고 고도화 할 것들이 있어 아직은 많이 부족하다. 이러한 고민을 하는 누군가에게 이 글과 코드가 미약하지만 도움이 되었으면 좋겠다.

또한 개인적으로는 이번 구현을 하며 Route 구현을 어떻게 하는지 알게 되었으며 CSRF token이 어떻게 구현되고 사용되는지 알 수 있어 많은 것을 배울 수 있었다. 기회가 된다면 PSR-7 message interface 를 이용해 Request, Response 를 구혆을 해 보고 싶다.

마지막으로 회사에서 현재 공식적으로 지원하는 PHP 7.2 버전 이상을 사용한다면 이 프레임워크는 당장 사용하지 않을 것이며 Laravel, Lumen과 같이 이미 검증되고 많은 사람들이 사용하는 프레임워크를 사용할 것이다. 이 글을 보는 분들께서도 Legacy PHP를 사용하지 않는 이상 이 코드를 절대로 사용하지 않길 바란다(많은 사용자의 피드백이 있는 코드를 사용해야 한다).

참고