10 KiB
10 KiB
DTO (Data Transfer Object) 사용 규칙
백엔드 가이드 | 목차로 돌아가기
TL;DR (5초 요약)
1. DTO 두 패턴 — Value Object(불변 1회 전달) vs Data Carrier(다단계 변형/누적). 도메인 본질에 맞게 선택
2. 위치: app/{도메인}/DTO/ 또는 app/Extension/{영역}/DTO/, 모듈/플러그인은 src/DTO/. 네이밍 접미사(Dto/Data) 금지
3. 외부 라이브러리(Spatie Data 등) 도입 금지 — readonly class + constructor promotion 으로 충분
4. Eloquent 모델 직렬화는 ApiResource, 모델 아닌 값 묶음만 DTO. 두 계층 겹치지 않음
5. 모든 DTO 는 toArray() 정의. fromArray() 는 캐시·스냅샷 재구성 필요할 때만
목차
개요
DTO 는 계층 간 데이터 전달용 객체로, Eloquent 모델이 아닌 값 묶음을 표현합니다. G7 에는 두 종류의 DTO 가 본질적으로 다른 도메인에 사용되며, 두 패턴 모두 정당합니다 — 한 패턴을 강제하면 다른 도메인을 망칩니다.
현재 G7 의 DTO 사용처:
| 위치 | 개수 | 패턴 | 용도 |
|---|---|---|---|
app/Extension/IdentityVerification/DTO/ |
2 | Value Object | 본인인증 challenge/result — provider 가 만들어 한 번 전달 |
modules/_bundled/sirsoft-ecommerce/src/DTO/ |
21 | Data Carrier | 주문 9단계 계산 파이프라인 — 단계마다 변형/누적, 캐시·스냅샷 재구성 |
DTO vs ApiResource vs Eloquent Model 경계
| 계층 | 표현 대상 | G7 표준 |
|---|---|---|
| Eloquent Model | DB 테이블 1행 | Illuminate\Database\Eloquent\Model 상속 |
| ApiResource | 모델의 API 직렬화 | BaseApiResource / BaseApiCollection 상속 (api-resources.md) |
| DTO | 모델이 아닌 값 묶음 (계산 결과, 외부 응답, 다단계 입력 등) | 본 가이드 |
원칙: 같은 데이터에 두 계층이 겹치면 안 됩니다.
// ❌ 금지: Eloquent 모델을 DTO 로 한 번 더 감싸기
$dto = new ProductDto($product->toArray());
// ❌ 금지: DTO 를 ApiResource 로 한 번 더 감싸기
return new ProductResource($dto); // ApiResource 는 모델 전용
// ✅ 모델은 ApiResource 로 직렬화
return new ProductResource($product);
// ✅ DTO 는 toArray() 로 직접 직렬화
return ResponseHelper::success('messages.ok', $orderCalculationResult->toArray());
두 가지 DTO 패턴
패턴 A: Value Object (VO)
언제 쓰는가: 한 번 만들어서 한 번 전달하고 끝. 변형 없음. 외부 입력 → 내부 처리 → 응답까지 단방향 흐름.
적합 도메인 예시:
- 본인인증 Challenge/Result (provider 가 발급 → 컨트롤러 → 응답)
- 외부 API 응답 파싱 결과 (PG 응답, 외부 인증 callback 등)
- 도메인 이벤트 페이로드
- 예외에 첨부하는 컨텍스트 정보
골격 코드 (실제 레퍼런스: app/Extension/IdentityVerification/DTO/VerificationChallenge.php)
namespace App\Extension\IdentityVerification\DTO;
use Carbon\CarbonInterface;
final readonly class VerificationChallenge
{
/**
* @param string $id challenge UUID
* @param string $providerId 프로바이더 식별자
* @param CarbonInterface $expiresAt 만료 시각
* @param array $publicPayload 프론트에 노출되는 공개 페이로드
*/
public function __construct(
public string $id,
public string $providerId,
public CarbonInterface $expiresAt,
public array $publicPayload = [],
) {}
/** 프론트/ResponseHelper 에 그대로 넘길 수 있는 직렬화 배열 */
public function toArray(): array
{
return [
'id' => $this->id,
'provider_id' => $this->providerId,
'expires_at' => $this->expiresAt->toIso8601String(),
'public_payload' => $this->publicPayload,
];
}
}
규칙:
final readonly class필수 (PHP 8.2+)- 모든 속성
public readonly(constructor promotion) toArray()만 정의 (단방향)fromArray()추가 금지 (필요해지는 순간 패턴 B 후보)- 비즈니스 메서드 추가 금지 (순수 값)
패턴 B: Data Carrier
언제 쓰는가: 다단계 파이프라인에서 단계마다 변형·누적, 캐시/스냅샷에서 재구성, 중간 헬퍼 메서드 필요.
적합 도메인 예시:
- 주문 9단계 계산 결과 (각 단계가 누적 변형)
- 환불 재계산 (스냅샷에서 DTO 재구성)
- 장바구니 적용 프로모션 누적 합산
골격 코드 (실제 레퍼런스: modules/_bundled/sirsoft-ecommerce/src/DTO/OrderCalculationResult.php)
namespace Modules\Sirsoft\Ecommerce\DTO;
class OrderCalculationResult
{
/**
* @param ItemCalculation[] $items 아이템별 계산 결과
* @param Summary|null $summary 합계 정보 (생성자에서 기본값 정규화)
* @param array $metadata 플러그인 확장용
*/
public function __construct(
public array $items = [],
public ?Summary $summary = null,
public array $metadata = [],
) {
$this->summary = $summary ?? new Summary;
}
/** 도메인 헬퍼 — 검증 오류 존재 여부 확인 */
public function hasValidationErrors(): bool
{
return count($this->validationErrors) > 0;
}
public function toArray(): array
{
return [
'items' => array_map(fn ($i) => $i->toArray(), $this->items),
'summary' => $this->summary->toArray(),
'metadata' => $this->metadata,
];
}
/** 캐시·스냅샷에서 재구성 */
public static function fromArray(array $data): self
{
return new self(
items: array_map(fn ($i) => ItemCalculation::fromArray($i), $data['items'] ?? []),
summary: isset($data['summary']) ? Summary::fromArray($data['summary']) : null,
metadata: $data['metadata'] ?? [],
);
}
}
규칙:
class(final/readonly 없음 — 변형 허용)- 모든 속성
public(mutable, constructor promotion) toArray()+static fromArray()양방향 (캐시/스냅샷 왕복)- 도메인 헬퍼 메서드 허용 (
hasXxx,getXxx, 집계 등) - 생성자 본문에서 nullable 객체 기본값 정규화 패턴 허용 (
$this->summary = $summary ?? new Summary;) - 상속 허용 (예:
CancellationAdjustment extends OrderAdjustment)
패턴 선택 기준
아래 표의 항목 중 하나라도 "예" 이면 패턴 B (Data Carrier).
| 질문 | "예" → 패턴 B |
|---|---|
| 객체가 만들어진 후 단계별로 값이 누적/변형되는가? | Carrier |
| 캐시·스냅샷에서 다시 객체로 재구성되는가? | Carrier |
객체에 대해 자주 호출되는 도메인 헬퍼 메서드가 있는가? (hasXxx, getXxx) |
Carrier |
| 동일 계열 객체들의 상속 트리가 자연스러운가? | Carrier |
플러그인이 이 객체에 추가 데이터를 끼워넣을 수 있어야 하는가? (metadata 확장 슬롯) |
Carrier |
| 한 번 생성 후 변형 없이 응답까지 흘러가는가? | VO |
VO 가 자라서 Carrier 가 되어야 할 신호: 처음엔 VO 였는데 시간이 지나며 with() 메서드가 늘어나거나, 캐시 직렬화가 필요해지거나, 헬퍼 메서드가 3개 이상 추가되면 패턴 B 로 전환을 검토합니다.
공통 강제 규칙
위치
| 코드 위치 | DTO 위치 |
|---|---|
| 코어 도메인 | app/{도메인}/DTO/ (예: app/Order/DTO/) |
| 코어 확장 영역 | app/Extension/{영역}/DTO/ (예: app/Extension/IdentityVerification/DTO/) |
| 코어 공통 | app/DTO/ (도메인 분류가 모호한 범용 DTO 한정 — 가급적 도메인 하위로) |
| 모듈/플러그인 | {확장 루트}/src/DTO/ (예: modules/_bundled/sirsoft-ecommerce/src/DTO/) |
네이밍
- ✅ 의미 단어만:
VerificationChallenge,OrderCalculationResult,ShippingAddress - ❌ 접미사 금지:
VerificationChallengeDto,OrderCalculationData,ShippingAddressObject
외부 라이브러리
- ❌ Spatie Laravel Data, Spatie Data Transfer Object 등 외부 패키지 도입 금지
- ✅ PHP 8.2+ readonly class + constructor promotion 으로 충분
- 사유: 한 번 도입되면 코어 의존성 영구화. 단순 DTO 는 외부 라이브러리 가치보다 의존성 비용이 큼
직렬화
- 모든 DTO 는
toArray(): array정의 —ResponseHelper::success(..., $dto->toArray())패턴 통일 JsonSerializable인터페이스 구현은 필요 시점에만 (DTO 가 직접json_encode()대상이 되어야 할 때)fromArray()는 캐시·스냅샷 재구성 필요 시에만 (패턴 B 한정 권장)
검증
- DTO 자체에 검증 로직 두지 않음 — 검증은 FormRequest 단계의 책임
- 생성자에서 던지는 예외는 타입 강제(타입힌트) 수준만 허용. 비즈니스 검증 금지
불변성 선택
| 패턴 | 키워드 | 사유 |
|---|---|---|
| VO | final readonly class + public readonly |
변형 차단, 재사용 안전 |
| Carrier | class + public |
다단계 변형 허용, 생성자 본문 정규화 가능 |
관련 문서
- README.md — 백엔드 가이드 인덱스
- api-resources.md — Eloquent 모델 직렬화 (DTO 와 경계)
- service-repository.md — Service 가 DTO 를 만들고 반환하는 패턴
- validation.md — DTO 입력 단계의 검증 책임