Laravel-image

Laravel에 Modern 하게 DTO 사용하기

Modern PHP

최근 가비아가 주 Framework를 Codeigniter에서 Laravel로 전환하면서 많은 서비스가 Laravel로 개발되었습니다. Laravel이란 Containter, Dependency Injection, ORM, Package Manager 등등 PHP가 Modern해 질 수 있는 여러 기능을 지원하는 PHP진영의 주요 Framework입니다.

그렇다면 Modern PHP에서 “Modern”이란 무엇을 의미할까요? “힘들게 하던 것을 간편하게 할 수 있도록 한다.”라는 의미가 아닐까 합니다.

이 글을 통해서 클린 아키텍처의 기본이 되는 DTO사용에 관한 PHP의 생산성 문제를 Modern 하게 풀어내고자 한 과정과 결과에 대해 공유해보려 합니다.

연관 배열

그런데 Modern 한 Framework를 사용하면서도 더 개선해야 하고, 또 충분히 개선 할 수 있다고 생각하는 부분은 PHP의 막강한 기능인 “연관 배열”의 사용을 최소화하는 것이었습니다.

무조건 break point로 함수에 전달된 연관배열이 어떤 Map 구조와 key들을 가졌는지 확인해야 하는 점은 생산성에 너무 치명적이었습니다.

연관 배열 사용의 시작을 살펴보면 Codeigniter, Laravel 모두 대부분 Controller에서 시작합니다.

Laravel의 공식 홈페이지 중 Retrieving Input (https://laravel.com/docs/master/requests#retrieving-input)

$input = $request->all();      /// 연관배열로 return
$name = $request->input('name');    /// 여러 parameter를 담으려면 결국 연관 배열에 담게 됨

공식 홈페이지에도 결국 연관 배열 사용을 유도하고 있습니다. 그런데도 왜 연관 배열의 사용을 최소화하려 하는지, 어떻게 최소화했는지에 대해 차례차례 예시를 준비해 보았습니다.

연관 배열의 강력함? 1

///SomeModel.php
function updateContents($param)
{
    $this->db->insert('title, content, date');
    //.......
    $this->db->where(seq, $param['seq']);
    $some_query_result = $this->db->insert('contents');
    return $some_query_result;
}
  • A 개발자가 $param[‘seq’]라는 연관 배열의 key로 동작하는 model을 만들었습니다.
  • 이 기능이 필요해서 해당 함수를 사용하는 API가 많아집니다.
  • 그중 한 API에서 계속 오류가 나서 확인해보니 seq를 seqno로 받아서 undefined 오류가 발생했습니다.
  • 그래서 아래와 같이 소스 코드를 수정합니다.
///SomeModel.php
function updateContents($param)
{
    if($param['seqno']){                  /// validation 코드 추가
         $param['seq'] = $param['seqno'];
    }
    $this->db->insert('title, content, date');
    //.......
    $this->db->where('seq', $param['seq']);
    $some_query_result = $this->db->insert('contents');
    return $some_query_result;
}
  • 버그를 막기 위해 또 validation 코드가 추가되고 이런 상황이 생길 때마다 계속 validation코드가 추가됩니다.

연관 배열의 강력함? 2

///SomeController::writeContent 글 작성하기
public function writeContent()
{
    $param['seq'] = $this->input->post('content');
    $param['title'] = $this->input->post('title');
    //.....
    $this->some_model->writeContent($param);
}
///SomeController::modifyContent 글 수정하기
public function modifyContent()
{
    $param['modify_seqno'] = $this->input->post('modify_seqno');
    $param['modify_title'] = $this->input->post('modify_title');
    $param['modify_content'] = $this->input->post('modify_content');
    //......
    $this->some_model->updateContent($param);
}
  • “글”이라는 동일한 개념에 대한 [글 작성, 글 수정] 두 가지 기능인데, 개발자마다 연관 배열의 게시글 seqno를 포함해 여러 key가 달라지게 됩니다.
  • 작성된 모델의 writeContent와 modifyContent의 parameter는 당연히 연관 배열의 키에 맞게 각각 다르게 작성되었습니다.
  • 위에서 작성된 모델의 writeContent와 modifyContent 함수를 동시에 이용해서 글 작성과 수정을 동시에 하는 컨트롤러를 만들어야 한다면 아래와 같은 코드가 작성됩니다.
///SomeController::writeAndModifyContent
public function writeWithModifyContent()
{
        /// 글 작성 파라미터
    $writeParam['seq'] = $this->input->post('content');
    $writeParam['title'] = $this->input->post('title');
​
        /// 글 수정 파라미터
    $modifyParam['modify_seqno'] = $this->input->post('seq');
    $modifyParam['modify_title'] = $this->input->post('title');
    $modifyParam['modify_content'] = $this->input->post('content');
​
    //......
​
    $this->some_model->writeContent($writeParam);
    $this->some_model->updateContent($modifyParam);
}
  • 위 코드처럼 같은 게시글의 seq이지만 model이 다르기 때문에 중복 코드가 바로 발생하게 됩니다.
  • 이런 상황으로 인해 같은 개념에 대해서는 같은 이름의 객체를 사용하는 “규약”과 관련된 여러 개념이 나왔다고 생각합니다.

그렇다면 DTO를 써볼까?

/// Content DTO Class
class Content
{
    /**
      * @var int
      */
    private $seq;
    /**
      *  @var string
      */
    private $title;
    /// constructor
    /// getter
    /// setter
}
///SomeController::writeWithModifyContent
public function writeWithModifyContent()
{
    $content = new Content()
    $content->setSeq($this->input->post('seq'));
    $content->setContent($this->input->post('content'));
    $content->setTitle($this->input->post('title'));
    //......
    $this->some_model->writeContent($content);
    $this->some_model->updateContent($content);
}
///SomeModel::writeContent
function updateContents(Content $content)
{
    $this->db->where(seq, $content->getSeq());
    $some_query_result = $this->db->insert('content');
    //.....
    return $some_query_result;
}
  • 처음부터 DTO를 사용하여 정해진 Type의 DTO를 인자로 받는 model을 작성하여 추후 수정에 의한 오류를 Runtime이 아닌 Syntax Check 단계에서 잡을 수 있습니다.
  • 하지만 어느 정도 정리된 것처럼 보여도 다음 코드에서 바로 문제가 발생합니다.

DTO property가 계속 많아지면….?

/// Content DTO Class
class Content
{
    /**
      * @var int
      */
    private $seq;
    /**
      * @var string
      */
    private $title;
    /**
      * @var string
      */
    private $title1;
    /**
      * @var string
      */
    private $title2;
​
       ……………………………
       ……………………………
    /// constructor
    /// getter
    /// setter
}
///SomeController::writeWithModifyContent
public function writeWithModifyContent()
{
    $content = new Content()
    $content->setSeq($this->input->post('seq'));
    $content->setContent($this->input->post('content'));
    $content->setTitle($this->input->post('title'));
    $content->setTitle1($this->input->post('title1'));
    $content->setTitle2($this->input->post('title2'));
        /// 하나하나 전부다 손으로 작성해주어야 DTO가 완성됨
​
    //......
    $this->some_model->writeContent($content);
    $this->some_model->updateContent($content);
}
  • 만약 DTO에 property가 계속해서 증가한다면 증가한 만큼 컨트롤러에 계속해서 set 함수를 작성해줘야 합니다.
  • 그리고 해당 DTO를 사용하는 모든 컨트롤러에서 중복된 setter 로직을 작성해야 합니다.
  • 개발 생산성을 현저하게 떨어뜨리는 것은 결코 Modern하다고 할 수 없습니다.

개발 생산성도 잡아야 한다. 그렇다면 어떻게 해야 하지?

  • 파라미터와 DTO의 자료구조 변수명이 동일해야 한다는 규칙이 있다면 DTO에 값을 세팅하는것은 비즈니스 로직이 아닙니다.
  • 아래 예시처럼 파라미터에 따라서 Mapping된 DTO 객체가 알아서 생성되고 이 객체가 Controller 함수의 인자로 전달되어야 한다고 판단했습니다.
/// SomeController::writeWithModifyContent
public function writeWithModifyContent(Content $content) /// 
{
    $this->some_model->writeContent($content);
    $this->some_model->updateContent($content);
}
///SomeModel::writeContent
function updateContents(Content $content)
{
    $this->db->where(“seq”, $content->getSeq());
    $some_query_result = $this->db->insert('contents');
    //.....
    return $some_query_result;
}

Codeigniter에서는?

$this→load→model('some_model');
  • Controller에 DTO가 인자로 전달되려면 기본적으로 Framework에서 Dependency Injection을 지원해야 합니다.
  • 그런데 Codeigniter에서는 load 방식이 Dependency Injection의 개념에 해당하고, 주요 기능이기 때문에 개발자가 원하는 데로 Dependency Injection를 컨트롤 할 수 있는 방법은 Core를 수정하는 방법밖에 없습니다.

Laravel에서 구현해보자

///SomeController::writeContent
public function writeContent(Content $content)
{ 
      //.... 
}
  • Laravel에서는 보통 Provider에서 Dependency Injection을 해결합니다.
  • Laravel의 Controller에서 위와 같이 동작하게 하려면 Provider에서 Dependency Injection에 관한 코드를 작성해야 합니다.
///SomeServiceProvider
public function boot()
{
     $this->app->bind(Content::class, function(Application $app){  /// 의존성 주입
         //...
         $mapper = myMapper();      //직접 만든 mapper
         return $mapper->map(Contents::class, $app->request->all());
     });
}
  • Provider에서 Content DTO의 의존성을 해결해주는 로직에 Request를 DTO에 mapping해주는 기능을 작성해 의존성을 return하도록 구현합니다.
  • 문제점 : DTO가 늘어날수록 Provider에 하나하나 전부 구현해 주어야 합니다. → 생산성 감소 (아래 코드 예시)
///SomeController::writeContent
  public function writeContent(Content $content)
  { //.... }
  public function writeContent1(Content1 $content1)
  { //.... }
  public function writeContent2(Content2 $content2)
  { //.... }
///SomeServiceProvider
  $this->app->bind(Content1::class, function(Application $app){
       $mapper->map(Contents::class, $app->request->all());
  });
  $this->app->bind(Content2::class, function(Application $app){
       $mapper->map(Contents::class, $app->request->all());
  });
​
  $this->app->bind(Content3::class, function(Application $app){
       $mapper->map(Contents::class, $app->request->all());
  });
  // DTO 수 만큼 모든 의존성 해결을 작성해주어야함

생산성이 너무 떨어진다. 더 Modern하게 구현할 수 있을거 같은데?

  • Provider에서 의존성 주입의 여부를 판단할 수 있는 정보는 추상 클래스의 정보뿐입니다.
  • 그래서 일단 비어있는 interface를 만들고 모든 DTO는 해당 interface를 상속받도록 했습니다.
interface LaravelDto { }
  • 그리고 해당 interface가 의존성이 주입될 때는 DTO가 주입되는 것으로 판단하도록 하고 조건이 맞을 때 mapping하여 다시 rebinding하는 방법으로 구현했습니다.
$this->jsonmapper = new JsonMapper();    ///  cweiske/jsonmapper 사용
​
////....
​
$this->app->resolving(function ($object, $app) {    /// 의존성 해결 이벤트 발생시에 호출되는 callback
​
    if ($object instanceof LaravelDto) {   /// 의존성 해결하려는 object가 LaravelDto의 구현체일 경우 DTO로 간주
        $dto = $this->json_mapper->map( $object, $app->request->all() );
        
        $app->rebinding(LaravelDto::class, function () use ($dto) { /// mapping된 dto를 다시 rebinding
            return $dto;
        });
    }
});
  • 가비아 내부에서는 위 로직을 interface화한 composer로 제작하여 사용하고 있습니다.

고민했던 점

DTO의 무조건적인 LaravelDto interface 상속으로 인한 의존성

  • DTO는 의존성이 있으면 안 되는 특성이 있지만, interface의 구현체라는 기준이 없다면 DTO인지 알 수 없다는 이유로 고민 끝에 특정 interace에 의존성을 두게 되었습니다. inteface를 제거하기 위해 고민했던 방안들과 이를 채택하지 않은 이유는 다음과 같습니다.
    1. Controller의 method 의존성이 해결될 때의 parameter를 DTO로 판단하는 방안→ Spring MVC처럼 공식적으로 method의 파라미터로 DTO를 받는 것이 아니기 때문에 마음대로 Controller의 매개변수를 모두 DTO로 취급했을 때는 프레임워크 자체 기능에 대한 많은 변수가 발생했습니다. (대표적으로 routing에서 오는 path variable 사용 불가)
    2. Annotation으로 DTO를 판단하는 방안→ PHP는 아직 공식적으로 Annotation을 지원하지 않고 있으며 doctrine에서 구현 가능하다고 하지만 결국 doctrine에 의존하는 것이기 때문에 근본적인 문제는 해결되지 않는다 생각했습니다.

Request의 Injection 자체를 막아버리면 안될까?

$this->app->bind(Session::class){
        /// Request에서 필요한 정보들은 별도로 구현해서 injection
}
$this->app->bind(Request::class){
    /// throw new Exception('Request사용을 막으려했지만 포기');
}
  • 처음에는 Request에서 필요한 정보들은 별도로 구현하여 Injection하도록 하고 위처럼 Illuminate\Http\Request 객체를 막아서 최대한 DTO 사용을 유도하려 했습니다.
  • 하지만 Laravel 에 의존해있는 여러 Pacakge에서 Request를 사용하는 경우가 많았고, 강제로 코드 레벨에서 막기보다는 코딩 컨벤션과 코드리뷰로 해결하는 게 더 적합하다고 생각해서 결국 추가하지 않았습니다.

마무리하며

클린 아키텍처를 지향하지만, PHP 진영에서 많이 쓰이고 있는 연관 배열(Map 구조)을 이용한 method간의 데이터 전달이 남아있다면 IDE의 도움을 전혀 받을 수 없는 문제를 해결하고자 처음 시작되었습니다. 하지만 문제를 해결하려 하다 보니 오히려 생산성 하락이나 성능 이슈가 발생하는 경우도 많았습니다. DTO와 생산성을 동시에 가져가기 위해 Reflection이 적합하다는 생각이 들었고, 이에 대해서도 많이 학습 할 수 있는 기회가 되었습니다. 결국에는 Reflection은 거의 사용 되지 않은 방법으로 구현되었지만, 기회비용을 생각하며 적절히 Reflection을 사용한다면 생산성에 강력한 무기가 된다는 점도 많이 느낄 수 있었습니다.

Written by
Cobi 하이웍스 개발팀

어플리케이션의 복잡성을 다루는 아키텍쳐, 디자인 패턴, 클린 코드와 협업에 관심이 많습니다. 어제보다 더 나은 개발자가 되고 싶습니다.