💡 PHP 8 이상의 버전에서 유효한 예시 코드들이 작성되어 있습니다.
라라벨에서 DTO(Data Transfer Object)를 작성하기 위해 해당 글에서는 spatie/laravel-data 오픈소스 라이브러리를 사용합니다. PHP의 기본 클래스 만으로도 충분하지만 프로그래밍틱하고 라라벨과 호환이 잘 되어 자주 사용하는 라이브러리 중 하나입니다. 👍
오픈소스 개발 회사 spatie에서 개발된 laravel-data를 다음과 같이 소개하고 있습니다.
Powerful data objects for laravel
… can be used in various ways. Using this package you only need to describe your data once:
instead of a form request, you can use a data objectinstead of an API transformer, you can use a data objectinstead of manually writing a typescript definition, you can use… 🥁 a data object
Laravel을 위한 강력한 데이터 객체
… 여러 가지 방식으로 사용할 수 있습니다. 이 패키지를 사용하면 데이터를 한 번만 정의하면 됩니다:
폼 요청 대신 데이터 객체를 사용할 수 있습니다.API 변환기 대신 데이터 객체를 사용할 수 있습니다.직접 타입스크립트 정의를 작성하는 대신… 🥁 데이터 객체를 사용할 수 있습니다.
설치
composer require spatie/laravel-data
기본 사용법
그럼 laravel-data 라이브러리를 통해 객체를 생성하는 방법에 대해 간단히 알아보겠습니다.
<?php
namespace App\Http\Controllers\Requests;
use Spatie\LaravelData\Data;
class Feedback extends Data
{
public function __construct(
public readonly int $id,
public readonly int $point,
) {
}
}
기본적인 Data 클래스 생성 방법은 위와 같습니다.. Spatie\LaravelData\Data
클래스를 상속 받고 정의할 데이터 타입들을 생성자에 나열합니다. 데이터 클래스가 객체로 생성된 이후에는 속성 값이 변하지 않는다는 "불변 객체 패턴"을 적용하기 위해 readonly
속성을 추가적으로 작성해 주었습니다.
불변 객체 패턴이란? 불변 객체 패턴은 객체의 상태가 한 번 설정되면 변경할 수 없도록 하는 디자인 패턴으로, 이는 코드의 안정성과 예측 가능성을 높여줍니다. Java에서는 final 키워드, C#에서는 readonly 키워드를 사용해 구현할 수 있습니다. 불변 객체를 사용하면 동시성 문제가 줄어들고, 객체를 안전하게 공유할 수 있는 장점이 있습니다.
생성된 데이터 클래스는 아래와 같이 from
static 메소드를 통해 객체를 생성할 수 있다.
$feedback = Feedback::from([
'id' => 1,
'point' => 5,
]);
PHPStorm IDE 기준으로 from
메소드 안에서의 연관 배열 작성시 자동완성이 지원되기 때문에 전혀 불편함이 없습니다.
(VSCode는 요즘 사용하지 않아 잘 모르겠네요. 상용 소프트웨어의 맛을 알아버린 이후에 VSCode를 잘 사용하지 않게되네요. 🥲)
Request 및 Validation 클래스로 분리
전통적인 라라벨의 Request
를 받아 유효성(Validation) 검사를 진행하는 방법은 아래와 같습니다.
<?php
use Illuminate\Http\Request;
class Controller {
public function getFeedback(Request $request)
{
$validated = $request->validate([
'id' => 'required|integer',
'point' => 'require|integer',
]);
...
}
}
간결하고 작성하기 빠른 방법입니다. 하지만, 저는 개인적으로 복잡한 유효성 검사가 들어간 코드는 기본 로직에서 많이 벗어나기 때문에 분리 되었으면 좋겠다는 니즈가 있었습니다. 이럴때 다음과 같이 spatie/laravel-data 라이브러리를 통해 분리가 가능합니다.
<?php
namespace App\Http\Controllers\Requests;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\Validation\In;
use Spatie\LaravelData\Attributes\Validation\IntegerType;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapInputName(SnakeCaseMapper::class)]
class PointRequest extends Data
{
public function __construct(
#[Required, IntegerType]
public readonly int $id,
#[Required, In([1, 2, 3])]
public readonly int $point,
#[Nullable, StringType, Max(50)]
public readonly ?string $userName = null,
) {
}
}
코드를 살펴보면 기본적으로 Spatie\LaravelData\Data
클래스를 상속 받고, PHP 8에서 추가된 Attributes을 이용해서 PointRequest
클래스에 추가적인 동작이 되도록 코드가 작성되었습니다.
#[MapInputName(SnakeCaseMapper::class)]
이는 API 요청 파라메터들의 규칙은 snake_case
로 요청되지만 내부 코드는 camelCase 규칙을 맞추기 위한 부가적인 기능입니다. 예를 들면 다음과 같습니다.
요청 |
설명 |
/api/point?id=1&point=5&user_name=Hello |
user_name 파라메터는 public readonly ?string $userName 과 매핑됨 |
이렇게 작성한 Request 클래스는 컨트롤러에서 다음과 같이 작성이 가능합니다. 정확히 Controller와 Request 파일이 분리되어 보다 규모가 큰 애플리케이션에서는 관리를 보다 효율적으로 할 수 있도록 도와줍니다.
<?php
use App\Http\Controllers\Requests\PointRequest;
class Controller {
public function getFeedback(PointRequest $request)
{
dd($request); // validation이 수행된 후 생성된 객체
...
}
}
컨트롤러 레이어까지 진입하기 전에 유효성 검사가 수행됩니다. (유효성 검사가 통과되지 못하면 라라벨은 422 Unprocessable Entity를 반환함)
본격 DTO(Data Transfer Object) 객체로의 이용
DTO 객체로 사용할 수 있습니다. 기본 사용 방법과 비슷합니다. 여기서는 리스트 형태의 데이터를 DTO를 만들어 어떻게 DTO 인스턴스를 생성하는지에 대해 알아보도록 하겠습니다.
BagDto 클래스를 작성해 봅니다.
<?php
namespace App\Services\Product\Dto;
use Carbon\Carbon;
use App\Services\Product\Dto\BagItemDto;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
use Illuminate\Support\Collection;
#[MapOutputName(SnakeCaseMapper::class)]
class BagDto extends Data
{
public function __construct(
public readonly int $count,
/** @var Collection<BagItemDto> */
public readonly Collection $items,
) {
}
}
array
타입 또는 라라벨의 기본 Collection
클래스를 이용해 리스트 타입을 정의할 수 있습니다. 여기서 PHPDoc을 이용해 타입을 명시해주는걸 권장합니다. 그래야 IDE에서 정확히 타입 추론이 가능합니다. (PHP가 Generic을 아직 지원하지 않는 것은 매우 안타깝게 생각합니다. 🥲)
그리고 BadItemDto 클래스 파일은 아래와 같이 작성합니다.
<?php
namespace App\Services\Product\Dto;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapOutputName(SnakeCaseMapper::class)]
class BagItemDto extends Data
{
public function __construct(
public readonly int $id,
public readonly string $itemName,
) {
}
}
두 가지의 Dto 클래스를 만들었고, 데이터를 다음과 같이 매핑할 수 있습니다.
$items = [
['id' => 1, 'itemName' => 'item1'],
['id' => 2, 'itemName' => 'item2'],
];
$bag = BagDto::from([
'count' => 2,
'items' => $items,
]);
BagDto::from();
메소드를 호출함으로써 $items
에 연관배열 리스트가 자동으로 내부 리스트 객체로 생성되게 됩니다.
또한, 각 Dto 클래스 파일 상단에 작성돼 있는 #[MapOutputName(SnakeCaseMapper::class)]
코드를 통해 객체가 라라벨에서 출력될때 다음의 예시와 같이 snake_case로 변환되어 출력됩니다.
이는 내부의 컨벤션을 camelCase로 유지하면서 API 응답 컨벤션은 snake_case로 컨벤션을 유지하는데 유용합니다. 라라벨 Resources 기본 기능을 활용해도 되지만, DTO 작성을 통해 이런 세세한 부분까지 컨트롤할 수 있다는 점이 제겐 큰 장점으로 보였고, Resources 기능 대신 Attributes을 통해 컨벤션을 유지하고 있습니다.
조금 더 클래스 친화적인 코드가 되는 것 같습니다. 👍
Typescript Definition 자동 생성하기
잘 작성된 DTO 파일들에 Attributes를 추가하면 다음의 명령어를 통해 Typescript 타입 정의 파일 또한 출력 가능한 부가적인 기능이 존재합니다.
php artisan typescript:transform
아! 해당 부가 기능을 사용하기 위해서는 spatie/laravel-typescript-transformer 라이브러리 설치가 필요합니다.
composer require --dev spatie/laravel-typescript-transformer
즉 아래의 DTO 클래스 파일은
<?php
namespace App\Http\Controllers\Requests;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\Validation\In;
use Spatie\LaravelData\Attributes\Validation\IntegerType;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[MapInputName(SnakeCaseMapper::class), TypeScript]
class PointRequest extends Data
{
public function __construct(
#[Required, IntegerType]
public readonly int $id,
#[Required, In([1, 2, 3])]
public readonly int $point,
#[Nullable, StringType, Max(50)]
public readonly ?string $userName = null,
) {
}
}
(TypeScript
Attributes를 추가됨)
export interface PointRequest {
id: number;
point: '1' | '2' | '3';
userName?: string;
}
와 같이 변환된 파일을 출력합니다.
이는 회사의 개발 환경에 따라 프론트엔드 개발자와 협업할때 도움이 될 수도 그다지 필요하지 않을 수도 있습니다. 😅
심화편: 값을 변경하여 객체 복제하기
위의 모든 예시들은 "불변 객체 패턴"을 적용하고 있기 때문에 readonly
속성을 정의해 주었습니다. 당연히 값이 변할 필요가 있는 경우에 새로운 값으로 속성을 덮으려고 하면 오류가 발생합니다. 그럴땐 아래와 같은 방법이 도움이 될 수도 있습니다.
#[MapOutputName(SnakeCaseMapper::class)]
class BagItemDto extends Data
{
public function __construct(
public readonly int $id,
public readonly string $itemName,
) {
}
public function withItemName(string $itemName): self
{
$bagItem = $this->with(); // 원래의 데이터들
$bagItem['itemName'] = $itemName; // 값 변경
return self::from($bagItem); // 새로운 인스턴스 반환
}
}
$bagItem = BagItemDto::from([
'id' => 1,
'itemName' => 'item1',
]);
$bagItem->withItemName('item2');
마치며
어디까지나 해당 라이브러리를 채택하고 사용하고는 선택사항입니다. 규모에 따라서 그리고 필요성에 따라서 기본적인 라라벨 프레임워크의 기능만으로 충분할 수 있습니다.
많은 개발자분들과 같이 협업이 되어야 하는 상황이라면 모두의 동의를 구해야 할 것이고, 1인 규모에서는 불필요한 노동이 될 수도 있습니다.
충분히 검토 후 사용하시는 현명한 개발자가 되시길 바랍니다. :)
모든 글은 다음의 문서 링크에서 보다 정확한 내용 그리고 추가적인 부가 기능을 확인할 수 있습니다.
https://spatie.be/docs/laravel-data/v4/introduction
참조
해당 글은 제 개인 서버 페이지를 참조하여 재작성 되었습니다.