모던 PHP에서 배열 대신 DTO 사용하기

MUSINSA tech
MUSINSA tech
Published in
19 min readNov 1, 2021

PHP 7.4에서 자료전달구조체로서 배열을 사용할때의 모호성 문제와 해결방안

안녕하세요. 무신사 주문개발팀에서 교환/환불 도메인을 담당하는 유승태입니다.

무신사는 수많은 프로모션으로 고객에게 다양한 할인과 구매 혜택을 제공하고 있습니다. 교환/환불 로직은 다양한 프로모션에 대응해야 하며, 단 하나의 오류가 커다란 파장을 불러오는 만큼 견고한 프로그램을 만드는 것이 중요합니다.

무신사의 교환/환불 도메인은 PHP7.4를 활용해 개발하고 있습니다. 코드는 모던한 개발방법론을 도입하여 개선하고 있는데요. 이 과정에서 함수의 결과값이 배열인 코드를 다루는 것에 몇가지 어려움을 겪었습니다. 배열 안의 형태와 값을 알지 못해 타입 에러가 발생하는 경우가 발생했으며, 이를 피하기 위해 강제 형변환 코드를 반복하기도 했습니다. 데이터의 흐름 분석과 디버깅 역시 쉽지 않았습니다.

무신사는 이러한 부분을 DTO(Data Transfer Object)를 활용해 개선했습니다. 자료전달 구조체로 배열대신 DTO를 도입한 것인데요. 이번 글에서는 무신사가 DTO를 활용하여 배열의 아쉬운 점을 보완한 방법과 DTO를 쉽게 관리하기 위해 고민한 과정을 공유해 드리도록 하겠습니다.

자료전달 구조체로서 배열이 지닌 모호성

약타입 언어인 PHP는 7로 올라가면서 타입 힌팅에 대한 방향성을 설립합니다. PHP 7.4부터 객체 속성에 기본형 타입선언이 가능하며, 불필요한 보일러플레이트 코드(Boilerplate code)를 줄이는 방향으로 발전하고 있습니다.

무신사는 안정적인 서비스를 위해 타입힌팅 도입과 코드 개선을 진행하던 중 기존에 자료전달 구조체로 사용하던 배열에서 단점을 발견합니다. PHP 배열은 색인배열(indexed array)이자 연관배열(Associative Array)로 아주 편리하고 강력한 자료형이며 PHP가 빠른 생산성을 가질 수 있는 부분이기도 합니다. 그러나 엔터프라이즈급 어플리케이션에 자료전달 구조체로 배열을 사용하는 경우 크게 네 가지의 아쉬운 상황이 발생합니다. 지금부터 하나하나 알아보겠습니다.

1. 값을 직접 확인해보기 전까지 어떤 구조인지 알 수 없다.

배열을 함수 인자로 넘기면 List인지 Map인지 다차원 연관 배열인지 알 수 없습니다. 매개변수로 받은 배열이 어떤 구조인지 전혀 예측이 불가하여 블랙박스와 다름없습니다.

배열이 무엇을 담고 있는지 알기 위해서는 디버깅 툴을 이용하거나, 배열이 생성되는 코드까지 거슬러 올라가야 합니다. 때로는 IDE(Integrated Development Environment)를 사용하더라도 이초자 쉽지 않은 경우도 있습니다. 추적이나 파악이 쉽지 않은 코드는 버그가 생기기 쉽습니다.

2. 값이 가진 타입을 보장할 수 없다.

public function doSomeThing(array $data) 
{
if(!is_string($data['orderNo']) { throw ...} // 타입 검증처리
if(!is_int($data['goodsCode’]) { throw ...}
exchangeGoods($data['orderNo'], $data['goodsCode']);

// 또는 강제 형변환을 한다.
exchangeGoods((string)$data['orderNo'], (int)$data[...]);
// 그러나 강제 형변환은 데이터를 의도치 않게 바꿀 수 있다.
}

배열의 key-value는 타입을 강제할 수 없습니다. 기존에는 암시적 형변환(Implicit conversion) 지원을 받아 정상 동작하는 코드였지만, 타입 힌팅 사용으로 인하여 배열값을 검증하는 로직이 필요해졌습니다. 많은 데이터를 배열로 넘기면 바깥 쪽에서는 간단할지 모르지만, 검증에 대한 책임을 뒤로 미룰 뿐입니다. 타입 오류를 피하기 위해 타입 힌팅을 사용하지 않거나 강제 형변환을 사용하기도 하는데, 이 또한 버그에 취약한 구조가 됩니다.

3. 불변성(Immutability)이 보장되지 않는다.

public function doSomeThing() 
{
$data = [
‘orderNo’ => ‘주문번호’,
‘goodsCode’ => 24 // int 형
];

// 교환할 새로운 상품코드를 얻는다.
$data['goodsCode'] = getNewGoodsCode();

// 변경된 상품코드로 교환 진행
exchangeGoods($data['orderNo'], $data[‘goodsCode’]);
// Type Error가 안난다고 보장할 수 있는가? 없다.
}

배열은 불변성을 보장하지 않습니다. int 24인 goodsCode 값을 getNewGoodsCode()로 다시 담을 때 리턴 타입이 string이라면 exchangeGoods 함수는 또 다시 타입 에러가 발생합니다.

// 교환할 새로운 상품코드를 얻는다.
$data['goodsCode'] = newGoodsCode(); // return “25”
exchangeGoods($data['orderNo'], $data[‘goodsCode’]);// Type Error !

exchangeGoods($data['orderNo'], (int)$data[‘goodsCode’]);
// 결국 형변환을 다시 한다.

우리는 변이(Mutation) 데이터와 불변 데이터를 구분하여 코드를 만듭니다. 하지만 PHP 배열은 언제든 내부 변이가 가능합니다. 규모가 커질수록 데이터 생산자와 소비자가 다르기에 자료전달구조체로서의 배열로 인한 불필요한 개발비용이 증가하며 버그도 발생할 수 있습니다.

4. 값의 사용처를 확인할 수 없다.

public function doSomeThing()
{
$data = [
‘orderNo’ => ‘주문번호’,
‘goodsCode’ => 24 // int 형
];
// 교환 상품코드 얻기
newGoodsCode($data);
// 교환
exchangeGoods($data);
// 배송
delivery($data);
// $data[‘goodsCode’] 라는 값이 어디에서 사용되었는지를 확인하려면
// newGoodsCode(), exchangeGoods(), delivery() 모든 코드 내부를
// ‘직접' 확인 하는 수 밖에 없다.
}

배열은 IDE에서 빠른 추적을 지원하지 않습니다. 연관된 코드를 모두 따라가며 직접 확인해야 합니다. 당사자가 작성한 코드거나 이미 익숙한 코드는 감각적으로 찾겠지만, 우리는 많은 개발자들과 함께 협업합니다. 새로운 코드를 접할 때마다 분석 비용이 증가할 뿐만 아니라, 불필요한 검증 로직이 반복됩니다.

배열 대신 DTO를 사용해보자

“DTO는 굉장히 유용한 구조체다. 특히 데이터베이스와 통신하거나, 소켓에서 받은 메세지 구문을 분석할 때 유용하다.” 『클린코드』 p126

로버트 C. 마틴의 저서 『클린코드』에 나오는 DTO와 관련된 유명한 구절입니다. 저자의 말처럼 프로세스 간 많은 데이터를 주고 받을때 가장 좋은 방법이 바로 DTO입니다. 무신사는 DTO를 매개변수로 사용하여 앞서 말한 배열의 네 가지 아쉬운 점을 보완했습니다.

예시를 위해 먼저 ExchangeDto를 만들어 보겠습니다.

class ExchangeDto
{
private string $orderNo;
private int $goodsCode;
public function __construct(string $orderNo, string $goodsCode)
{
$this->orderNo = $orderNo;
$this->goodsCode = (int)$goodsCode;
}
public function getOrderNo(): string
{
return $this->orderNo;
}
public function getGoodsCode(): int
{
return $this->goodsCode;
}
}

외부에서 속성을 직접 참조할 수 없도록 접근 제어자를 private 선언하고, 생성자를 통해서만 데이터를 할당합니다. 배열 대신 ExchangeDto를 이용하면 어떻게 달라지는지 살펴보겠습니다.

1. DTO에 위임한 타입 관리

$exchange = new ExchangeDto($_POST['orderNo'], $_POST['goodsCode']);exchangeGoods($exchange->getOrderNo(), $exchange->getGoodsCode());

먼저 형 변환을 DTO 내부로 숨겨 겉에 드러나지 않습니다. PHP7.4 이상부터는 객체의 속성에 기본형 타입을 선언하여 사용할 수 있으므로, ExchangeDto를 사용하는 코드는 형변환 책임에서 완전히 자유로워집니다.

2. 명확한 데이터 구조

public function exchangeGoods(ExchangeDto $exchange): bool
{
$exchange->getOrderNo();

$exchange->getGoodsCode();
// $exchange의 내부 구조를 바로 인지할 수 있음.
}

매개변수인 배열은 데이터 구조를 파악하기 위한 역추적이 선행되어야 하지만, DTO를 활용하면 그럴 필요가 없게 됩니다.

3. 불변성 보장

$exchange = new ExchangeDto('주문번호' , ’24’);

newGoodsCode($exchange);
exchangeGoods($exchange);delivery($exchange);assertTrue($exchange->getGoodsCode() === 24); // 항상 참이다.

객체는 자신의 속성을 직접 관리하는게 기본이므로 public setter는 만들지 않습니다. 속성 접근 제한자가 private일지라도 public setter가 존재한다면 외부에서 얼마든지 조작할 수 있어서 앞서 살펴본 문제를 야기합니다. 불변성을 보장 받는 DTO는 언제 어디에서나 신뢰할 수 있습니다.

4. 값의 사용처를 바로 파악할 수 있다.

public function getGoodsCode() : int
{
return $this->goodsCode;
}

newGoodsCode(), exchangeGoods(), delivery() 등 다양한 함수에서 데이터를 사용할 때 더이상 헤매지 않게 됩니다. ExchangeDto에 모든 정보가 담겨있고 이를 확인하면 되니까요. 대부분의 IDE가 함수 호출부로 바로 이동하는 기능을 제공하니 데이터 흐름 파악이 훨씬 수월해집니다. 이 외에도 잘못된 key 입력 등 휴먼에러를 방지하며, 런타임 에러로 나아갈 오류를 초기에 잡을 수 있다는 장점이 있습니다.

DTO, 더 쉽게 사용할 수 없을까?

지금까지 DTO의 장점을 살펴봤습니다. 그럼에도 불구하고 많은 분들이 DTO를 잘 사용하지 않습니다. 그 이유는 무엇일까요? 정답은 초기 개발생산성에 있습니다.

예시를 위해 배열과 DTO에 각각 상품가격 속성값을 추가해보겠습니다.

// 배열에 새로운 속성 추가 방법.
$exchange = [ ‘orderNo’ => '주문번호' , 'goodsCode' => 24 ];
// 1. 한줄이면 작성완료. 역시나 배열은 간단하다.
$exchange[‘goodsPrice’] = 20000;
// Dto에 새로운 속성 추가 방법.
class ExchangeDto
{
private string $orderNo;
private int $goodsCode;

// 1. 속성 추가
private int $goodsPrice;
// 2. 생성자 매개변수 추가
public function __construct(string $orderNo,
string $goodsCode,
string $goodsPrice)
{
$this->orderNo = $orderNo;
$this->goodsCode = (int)$goodsCode;

// 3. 생성자를 통한 값 할당 추가
$this->goodsPrice = (int)$goodsPrice;
}
// 4. getter 추가
public function getGoodsPrice(): int
{
return $this->goodsPrice;
}
}
// 5. 생성자 수정
$exchange = new ExchangeDto('주문번호', ‘24’, '20000');

배열은 단 한 줄을 추가하는 것만으로 새로운 속성을 추가할 수 있지만 DTO는 무려 다섯 군데를 수정해야 합니다. 아무리 IDE의 자동 생성 기능을 활용한다고 해도 코드의 양이 절대적으로 많아집니다. 하지만 앞서 언급하였듯이 배열을 자료전달 구조체로 사용하면 단점이 존재하기 때문에 엔터프라이즈급 애플리케이션에서는 번거롭더라도 DTO를 지향하는 것이 좋습니다. DTO 생성과 관리의 번거로움은 PHP뿐만 아니라 다른 언어도 동일하게 겪는 문제인데요. 무신사는 리플렉션을 이용해 위 문제를 해결하였습니다.

Reflection을 활용한 DTO Mapper 개발

리플렉션은 구조와 행위를 관리하고 수정할 수 있는 기능입니다. PHP도 Reflection API를 사용하면 블랙박스인 객체를 직접 다룰 수 있습니다.

/** Class ExchangeDto */
class ExchangeDto
{
/** order_number */
private string $orderNo;
public function __construct(string $orderNo)
{
$this->orderNo = $orderNo;
}
public function getOrderNo(): string
{
return $this->orderNo;
}
}

위와 같은 형태의 객체는 public 함수로만 외부에서 내부에 접근 할 수 있습니다. 그러나 Reflection API를 활용하면 객체의 모든 부분에 접근 가능합니다. Class 이름과 Class document 뿐만 아니라, private 속성 정보도 꺼낼 수 있습니다.

$reflectionClass = new ReflectionClass(ExchangeDto::class);// Class name 확인
assertTrue($reflectionClass->getName() === "ExchangeDto");
// Class 내부 document 읽기
assertTrue($reflectionClass->getDocComment() === "/** Class ExchangeDto */");
// Class property에 대한 정보 확인
foreach($reflectionClass->getProperties() as $property){
// 속성 명 확인
assertTrue($property->getName() === "orderNo");

// 타입 확인
assertTrue($property->getType()->getName() === "string");

// 접근제어자 확인
assertTrue($property->isPrivate());
}

더 나아가 속성에 값을 할당하는 과정을 . 쉽게 처리할 수 있습니다. 무신사는 ReflectionClass를 활용하여 DTO의 생성과 관리를 대신 해주는 DataTransferObjectMapper를 만들어 사용합니다. (자세한 소스 내용은 github을 참고하세요. ▷ https://github.com/styoo4001/php-dto-mapper)

이 Mapper는 다음과 같은 특징을 갖습니다.

  1. setter 또는 생성자에서 private 속성을 직접 할당해주지 않아도 됩니다.
  2. 값 할당시 속성이 가진 타입에 맞춰 형변환, 또는 객체 생성을 자동으로 처리합니다. (PHP 7.4 이상)

Mapper를 이용한 Dto 생성과 관리

다시 ExchangeDto를 생성해봅시다. ExchangeDto에 Point 객체를 갖는 속성 추가가 필요한 상황입니다.

class ExchangeDto
{
private string $orderNo;
private int $goodsCode;

//나중에 추가가 되더라도 속성을 추가하고 getter만 생성해주면 된다.
private Point $point
public function getPoint() : Point
{
return $this->point;
}
}
class Point
{
private int $amount; // 생성자가 필요하지 않음

public getAmount()
{
return $this->amount;
}
}
public function exchange()
{
$exchangeData = ['order_no' => ‘주문번호’ , 'goods_code' => 24];
$exchangeData['point'] = '1,000'; // 현재 데이터의 타입은 string 이다.

$mapper = new DataTransferObjectMapper();
$mapper->mapping($userData, UserDto::class);
/** @var ExchangeDto $exchange */
$exchange = $mapper->getClass();
$exchangePoint = $exchange->getPoint();
assertTrue($user->getOrderNo() === 주문번호);
assertTrue($exchangePoint->getAmount() === 1000);
// “1,000” 에서 자동변환됨

Dto 생성이 간결해졌습니다. Point라는 속성이 새로 추가되더라도 Dto를 생성하는 코드는 그대로입니다. 맵핑에 사용되는 데이터를 배열에 추가만 해주면 됩니다. Dto의 Point 속성 또한 Mapper에서 생성합니다.

기존에 배열 데이터를 다루는 코드도 Mapper를 통해 쉽게 리팩토링할 수 있습니다.

public function exchangeInfo(string $orderNo) : ExchangeDto
{
// getExchangeData에서 가져온 데이터를 ExchangeDto로 변환
$data = $model->getExchangeData($orderNo); // return array;
$mapper = new DataTransferObjectMapper(); return $mapper->mapping($data, ExchangeDto::class)->getClass();
}

DTO 유지보수가 훨씬 간편해집니다. exchangeInfo()의 결과를 사용하는 곳은 같은 데이터를 포함하고 있어도 모호한 array보다 명확한 ExchangeDto인 편이 훨씬 사용하기 좋습니다.
Mapper를 더 응용하면 Http Request도 바로 Dto에 담을 수 있습니다.
라라벨(Laravel) 같은 모던 프레임워크는 의존성을 주입하는 프로바이더 계층이 존재합니다. 만약 주입되는 객체가 RequestCommand라면 DTO로 변환해줍시다.

class provider implements ServiceProvider
{
public function boot(Application $app): void
{
$app->resolving(function ($object, $app) {
if ($object instanceof RequestCommand) {
$mapper = new DataTransferObjectMapper();
$mapper->mapping($_REQUEST, $object);
return $mapper->getClass();
}
});
}
}

이제 Http Request를 바로 변환할 Dto에 RequestCommand를 상속하면 모든 처리는 마무리됩니다.

class ExchangeDto implements RequestCommand
{
private string $orderNo;
public function getOrderNo(): string
{
return $this->orderNo;
}
}

컨트롤러에 접근할때부터 http request가 ExchangeDto 속성 타입에 따라 바인딩되고, DTO에 정해진 rule에 의하여 유효성 검증도 완료됩니다. 이제 controller는 생성된 ExchangeDto를 활용하기만 하면 됩니다.

class controller
{
// url/exchange?orderNo=주문번호&goodsCode=24
public function exchange(ExchangeDto $exchange)
{
if($exchange->hasErrors()){
throw Exception...
}
// 컨트롤러 내부에 Reqeust Data를 처리하거나 Dto를 만드는 부분이 사라짐
assertTrue ($exchange->getOrderNo() === "주문번호");
}
}

개발자는 DTO 생성/관리의 번거로움을 Mapper에게 위임하고 생성된 DTO를 편하게 사용할 수 있습니다.

마치며

지금까지 배열의 아쉬운 점과 DTO를 사용해 이를 어떻게 보완할 수 있는지 소개해드렸습니다. PHP 배열은 분명 생성과 수정도 편리하고 내장 함수도 많아 기능 구현시 좋은 수단이 됩니다. 그러나 자료전달구조체 그대로 사용하는 경우 오히려 생산성을 떨어트릴 수 있습니다.

배열 대신 DTO를 도입한 후, 데이터를 사용할 때 어떤 데이터를 포함하고 있는지 명확해졌으며, 타입관리를 위한 반복적인 코드도 줄어 생산성이 높아졌습니다. 또한 불변성을 확보하여 안정적인 시스템을 구축할 수 있게 되었으며, Mapper의 활용으로 개발자가 비즈니스 로직에 집중할 수 있는 환경을 만들 수 있게 되었습니다. 결과적으로 기능을 구현해야 할 때에는 강력한 배열을 사용하고, 단점을 커버해야 할 때에는 바로 DTO로 변환하여 효율적으로 개발을 할 수 있게 되었습니다.

무신사는 고객에게 안정적인 서비스를 제공하기 위해 늘 새로운 개발 방법을 시도하고, 생산성 올리는 방법을 고민하고 있습니다. DTO 도입 역시 무신사가 한 다양한 시도 중 하나였는데요. 저희와 비슷한 고민을 하는 많은 분들께 이 글이 도움이 되길 바랍니다.

감사합니다.

--

--