DTO Transformer 패턴
프론트엔드에서 폼 데이터를 API 요청으로 보낼 때, 또는 API 응답을 화면에 표시할 때 데이터 형태가 일치하지 않는 경우가 대부분이다. 폼에서는 사용자 친화적인 형태("30초", "한국어")로 데이터를 다루지만, API는 기계 친화적인 형태(30, "ko")를 요구한다. 이 변환 로직이 컴포넌트 안에 흩어져 있으면 어떤 일이 벌어지는지 생각해보자.
// ❌ 컴포넌트 안에 변환 로직이 섞여 있는 경우
function ServiceCreateForm() {
const onSubmit = async (formState) => {
const request = {
name: formState.name,
timeLimit: parseInt(formState.shootingTimeLimit.replace('초', '')),
languages: formState.languages.map(lang => {
if (lang === '한국어') return 'ko';
if (lang === '영어') return 'en';
return lang;
}),
useAi: formState.aiSupport ?? false,
conceptIds: formState.selectedConcepts,
// ... 20줄 더
};
await createService(request);
};
}
이런 코드는 세 가지 문제를 만든다. 첫째, 변환 로직이 UI 로직과 뒤섞여서 둘 다 읽기 어렵다. 둘째, 같은 변환이 생성 폼과 수정 폼에 중복된다. 셋째, 변환 로직만 따로 테스트할 수 없다.
DTO Transformer 패턴은 이 변환 로직을 별도의 순수 함수로 분리하는 패턴이다.
핵심 아이디어
DTO(Data Transfer Object)는 계층 간 데이터를 전달하기 위한 객체다. 프론트엔드에서는 크게 세 가지 형태의 데이터가 존재한다.
- 폼 상태(Form State): 사용자가 입력하는 형태. 문자열 위주, UI 친화적
- 요청 DTO(Request DTO): API 서버가 받는 형태. 타입이 정확하고, 서버 스키마에 맞춤
- 응답 DTO(Response DTO): API 서버가 돌려주는 형태. 서버 내부 구조가 반영됨
이 세 형태 사이의 변환을 담당하는 함수들을 한 곳에 모아놓는 것이 Transformer다.
[Form State] ←→ [Request/Response DTO]
↑
Transformer 함수들
기본 구조
Transformer는 보통 transformers.ts 또는 mappers.ts라는 이름의 파일에 순수 함수로 작성한다.
// features/service/utils/transformers.ts
// 폼 상태 타입 정의
export interface ServiceFormState {
name: string;
description: string;
shootingTimeLimit: string; // "30초" — UI 표시용 문자열
shootingCount: string; // "4" — input은 항상 string
languages: string[]; // ["한국어", "영어"]
aiSupport: boolean | null;
aiModels: string[];
}
// 폼 → 생성 요청 DTO
export function formToCreateRequest(formState: ServiceFormState): CreateServiceDto {
return {
name: formState.name,
description: formState.description,
timeLimit: parseTimeLimit(formState.shootingTimeLimit),
cutLimit: parseInt(formState.shootingCount, 10),
languages: formState.languages.map(languageToCode),
useAi: formState.aiSupport ?? false,
aiModelIds: formState.aiSupport ? formState.aiModels : undefined,
};
}
// API 응답 → 폼 초기값
export function serviceToFormState(service: ServiceDetailDto): ServiceFormState {
return {
name: service.name,
description: service.description || '',
shootingTimeLimit: formatTimeLimit(service.timeLimit),
shootingCount: service.maxCuts.toString(),
languages: service.languages.map(codeToLanguage),
aiSupport: service.useAi,
aiModels: service.aiFeatures?.map(f => f.id) || [],
};
}
핵심은 이 함수들이 순수 함수라는 점이다. 입력을 받아서 출력을 돌려줄 뿐, 사이드 이펙트가 없다. 그래서 테스트하기도 쉽고 재사용하기도 쉽다.
변환 함수의 종류
실제 프로젝트에서 필요한 Transformer 함수는 보통 네 가지 방향으로 나뉜다.
1. Form → Create Request
새로 생성할 때 사용한다. 폼의 모든 필드를 서버가 기대하는 형태로 변환한다.
export function formToCreateRequest(form: ServiceFormState): CreateServiceDto {
return {
name: form.name,
timeLimit: parseTimeLimit(form.shootingTimeLimit),
languages: form.languages.map(languageToCode),
useAi: form.aiSupport ?? false,
};
}
2. Form → Update Request
수정할 때 사용한다. 생성과 거의 같지만, 변경된 필드만 보내는 Partial 구조이거나 기존 리소스 ID를 포함해야 하는 경우가 많다.
export function formToUpdateRequest(
form: ServiceFormState,
existingAssetId?: string,
): UpdateServiceDto {
return {
name: form.name,
timeLimit: parseTimeLimit(form.shootingTimeLimit),
// 이미지를 새로 안 올렸으면 기존 ID 유지
startScreenImageId: existingAssetId,
};
}
Create와 Update가 거의 동일하더라도 별도 함수로 분리하는 게 좋다. 시간이 지나면 두 함수는 반드시 달라진다. Update에는 "변경하지 않은 필드는 보내지 않기", "기존 리소스 참조 유지" 같은 로직이 추가되기 때문이다.
3. Response → Form State
수정 폼을 열 때 API 응답을 폼 초기값으로 변환한다. Create의 역방향이다.
export function serviceToFormState(service: ServiceDetailDto): ServiceFormState {
return {
name: service.name,
shootingTimeLimit: `${service.timeLimit}초`, // 30 → "30초"
languages: service.languages.map(codeToLanguage), // "ko" → "한국어"
aiSupport: service.useAi,
};
}
이 함수가 없으면 수정 폼 컴포넌트에서 useEffect 안에 변환 로직이 들어가게 되는데, 이러면 폼 초기화 타이밍 버그와 변환 로직이 뒤섞여서 디버깅이 어려워진다.
4. 단위 변환 헬퍼
위 세 함수에서 반복적으로 사용되는 작은 변환들은 별도 헬퍼로 분리한다.
export function languageToCode(language: string): string {
const map: Record<string, string> = { '한국어': 'ko', '영어': 'en' };
return map[language] || language;
}
export function codeToLanguage(code: string): string {
const map: Record<string, string> = { ko: '한국어', en: '영어' };
return map[code] || code;
}
export function parseTimeLimit(timeString: string): number {
const match = timeString.match(/(\d+)/);
return match ? parseInt(match[1], 10) : 0;
}
export function formatTimeLimit(seconds: number): string {
return `${seconds}초`;
}
이런 헬퍼 함수들은 단방향 변환의 쌍을 이룬다. languageToCode와 codeToLanguage처럼 정방향/역방향을 함께 만들어두면 Create/Update와 Response→Form 양쪽에서 모두 사용할 수 있다.
컴포넌트에서의 사용
Transformer를 분리하면 컴포넌트는 본연의 역할인 UI 렌더링과 사용자 인터랙션에만 집중할 수 있다.
// ✅ 변환 로직이 분리된 깔끔한 컴포넌트
import { formToCreateRequest } from '../../utils/transformers';
function ServiceCreateForm() {
const { mutate: createService } = useCreateService();
const onSubmit = (formState: ServiceFormState) => {
const request = formToCreateRequest(formState);
createService(request);
};
return <form onSubmit={handleSubmit(onSubmit)}>{/* ... */}</form>;
}
수정 폼에서도 마찬가지다.
import { serviceToFormState, formToUpdateRequest } from '../../utils/transformers';
function ServiceEditForm({ service }: { service: ServiceDetailDto }) {
// API 응답 → 폼 초기값
const defaultValues = serviceToFormState(service);
const form = useForm({ defaultValues });
const onSubmit = (formState: ServiceFormState) => {
const request = formToUpdateRequest(formState);
updateService(request);
};
return <form>{/* ... */}</form>;
}
변환 로직이 import 한 줄로 끝나기 때문에 컴포넌트 코드의 가독성이 크게 향상된다.
중첩 구조 변환
실제 프로젝트에서는 단순한 필드 매핑보다 복잡한 중첩 구조를 다루는 경우가 많다. 예를 들어 서비스 안에 컨셉이 있고, 컨셉 안에 테마가 있고, 테마 안에 프레임이 있는 3단 중첩 구조를 생각해보자.
// 폼 상태: 프론트엔드 편의를 위한 Record 구조
interface ServiceFormState {
selectedConcepts: string[];
// conceptId → themeId → frameIds[]
conceptThemes: Record<string, Record<string, string[]>>;
}
// API DTO: 서버가 기대하는 배열 구조
interface ConceptThemeFrameDto {
conceptId: string;
themeId: string;
frameIds: string[];
}
폼에서는 Record<string, Record<string, string[]>> 형태가 편하다. 특정 컨셉의 특정 테마에 프레임을 추가/삭제할 때 O(1) 접근이 가능하기 때문이다. 하지만 서버는 이런 중첩 Record를 받지 않는다. 배열 형태의 DTO를 기대한다.
function extractConceptThemeFrames(
formState: ServiceFormState,
): ConceptThemeFrameDto[] {
const result: ConceptThemeFrameDto[] = [];
Object.entries(formState.conceptThemes).forEach(([conceptId, themes]) => {
Object.entries(themes).forEach(([themeId, frameIds]) => {
if (frameIds.length > 0) {
result.push({ conceptId, themeId, frameIds: [...frameIds] });
}
});
});
return result;
}
이 변환 로직이 컴포넌트 안에 인라인으로 들어가 있다면 onSubmit 핸들러가 30줄이 넘어가면서 무엇이 UI 로직이고 무엇이 데이터 변환인지 구분이 안 된다. Transformer로 분리하면 formToCreateRequest 안에서 extractConceptThemeFrames를 호출하는 식으로 깔끔하게 정리된다.
중첩이 깊어질수록 Transformer 분리의 효과가 커진다는 점을 기억하자.
파일 업로드와의 조합
파일 업로드가 포함된 폼에서는 Transformer 설계가 조금 달라진다. 대부분의 경우 파일 업로드는 2단계로 진행되기 때문이다.
- 파일을 먼저 업로드해서 서버로부터
assetId를 받는다 - 폼 데이터와 함께
assetId를 요청에 포함한다
export function formToCreateRequest(
formState: ServiceFormState,
assetId?: string, // 업로드 결과로 받은 ID
): CreateServiceDto {
return {
name: formState.name,
startScreenImageId: assetId,
// ...
};
}
Transformer는 파일 업로드 자체를 수행하지 않는다. 업로드 결과인 assetId를 매개변수로 받아서 DTO에 매핑할 뿐이다. 업로드 로직은 컴포넌트나 훅에서 처리하고, Transformer는 순수 함수 상태를 유지한다.
수정 폼에서는 "기존 이미지를 유지할 것인가, 새 이미지로 교체할 것인가"라는 분기가 추가된다.
export function formToUpdateRequest(
formState: ServiceFormState,
newAssetId?: string,
): UpdateServiceDto {
return {
// 새 이미지를 업로드했으면 새 ID, 아니면 기존 ID 유지
startScreenImageId: newAssetId || formState.existingStartScreenImageId,
// ...
};
}
이런 분기 로직도 Transformer 안에 캡슐화하면 컴포넌트에서 신경 쓸 게 줄어든다.
폼 상태 타입 설계
Transformer 패턴에서 가장 중요한 설계 결정 중 하나가 FormState 타입이다. API DTO를 그대로 폼 상태로 쓰면 Transformer가 필요 없어 보이지만, 실제로는 여러 이유로 별도 타입이 필요하다.
API DTO를 그대로 못 쓰는 이유
// 서버 DTO
interface CreateServiceDto {
timeLimit: number; // 숫자
languages: string[]; // ["ko", "en"]
useAi: boolean; // boolean
}
// 폼에서 필요한 형태
interface ServiceFormState {
shootingTimeLimit: string; // "30초" — 표시용 문자열
languages: string[]; // ["한국어", "영어"] — 사용자가 읽는 형태
aiSupport: boolean | null; // null — "아직 선택 안 함" 상태 필요
startScreenImage: File | null; // File 객체 — DTO에는 없는 필드
existingStartScreenImageUrl?: string; // 수정 모드에서만 필요
}
차이가 생기는 이유:
- 타입 불일치: HTML input은 항상 string을 반환한다. 숫자 필드도 string이다.
- 표시 형식: 사용자에게 "30초"라고 보여줘야 하지만 서버에는
30을 보내야 한다. - 추가 상태: 파일 객체, "아직 선택 안 함(null)" 같은 UI 전용 상태가 필요하다.
- 수정 모드 전용 필드: 기존 리소스의 URL이나 ID를 폼에서 참조해야 한다.
이런 차이를 Transformer가 흡수한다. 폼은 UI에 최적화된 타입을 쓰고, API는 서버에 최적화된 타입을 쓰고, Transformer가 둘 사이를 연결한다.
테스트
Transformer가 순수 함수이기 때문에 테스트가 매우 단순하다. 모킹이 필요 없고, 입력을 넣고 출력을 검증하면 끝이다.
describe('formToCreateRequest', () => {
it('폼 상태를 API 요청 형태로 변환한다', () => {
const form: ServiceFormState = {
name: '서비스A',
shootingTimeLimit: '30초',
shootingCount: '4',
languages: ['한국어', '영어'],
aiSupport: true,
aiModels: ['model-1'],
};
const result = formToCreateRequest(form);
expect(result.name).toBe('서비스A');
expect(result.timeLimit).toBe(30); // "30초" → 30
expect(result.cutLimit).toBe(4); // "4" → 4
expect(result.languages).toEqual(['ko', 'en']); // 한국어 → ko
expect(result.useAi).toBe(true);
});
it('AI 미지원이면 aiModelIds를 보내지 않는다', () => {
const form = createMockFormState({ aiSupport: false });
const result = formToCreateRequest(form);
expect(result.aiModelIds).toBeUndefined();
});
});
describe('serviceToFormState', () => {
it('API 응답을 폼 초기값으로 변환한다', () => {
const service = createMockServiceDetail({
timeLimit: 30,
languages: ['ko'],
});
const form = serviceToFormState(service);
expect(form.shootingTimeLimit).toBe('30초');
expect(form.languages).toEqual(['한국어']);
});
});
이 테스트들은 빠르고 안정적이다. 네트워크 요청도 없고, DOM도 필요 없고, 타이밍 이슈도 없다.
파일 구조
Transformer는 해당 기능(feature) 폴더 안에 위치시키는 것이 일반적이다. Feature-Sliced Design을 따르는 경우를 예로 들면:
features/
service-management/
components/
ServiceCreateForm.tsx
ServiceEditForm.tsx
utils/
transformers.ts ← 여기
hooks/
useCreateService.ts
types/
index.ts
여러 기능에서 공유되는 변환(예: 날짜 포맷, 통화 변환)은 shared/utils/에 두면 된다.
shared/
utils/
date-transformers.ts
currency-transformers.ts
기존 방식과 비교
인라인 변환
// 컴포넌트 안에 직접 변환
const onSubmit = (form) => {
api.create({
timeLimit: parseInt(form.time.replace('초', '')),
languages: form.langs.map(l => langMap[l]),
});
};
- 장점: 파일이 적다
- 단점: 중복, 테스트 불가, 가독성 저하
클래스 기반 매퍼
class ServiceMapper {
static toCreateDto(form: FormState): CreateDto { ... }
static toFormState(dto: ResponseDto): FormState { ... }
}
- 장점: 그룹핑이 명확하다
- 단점: 불필요한 클래스 래핑, 트리쉐이킹 불리
함수 기반 Transformer (이 패턴)
export function formToCreateRequest(form: FormState): CreateDto { ... }
export function serviceToFormState(dto: ResponseDto): FormState { ... }
- 장점: 트리쉐이킹 가능, 테스트 용이, 조합 용이
- 단점: 함수가 많아지면 파일이 길어질 수 있음
함수 기반이 가장 실용적이다. 필요한 함수만 import하면 번들에 포함되고, 불필요한 함수는 트리쉐이킹으로 제거된다.
적용 기준
모든 폼에 Transformer가 필요한 건 아니다. 적용 여부를 판단하는 기준:
Transformer를 만들어야 할 때:
- 폼 필드와 API 필드의 타입이나 형식이 다를 때 (string ↔ number, "한국어" ↔ "ko")
- 같은 변환이 생성/수정 폼에서 중복될 때
- 중첩 구조의 형태 변환이 필요할 때 (Record ↔ Array)
- 파일 업로드처럼 2단계 처리가 필요할 때
Transformer가 과할 때:
- 폼 필드와 API 필드가 1:1로 동일할 때
- 변환이 단순한 스프레드 연산(
{ ...form })으로 끝날 때 - 해당 폼이 프로젝트에서 한 번만 사용될 때
패턴을 위한 패턴을 만들지 말자. 변환 로직이 3줄 이내이고 재사용할 곳이 없다면 인라인으로 두는 게 오히려 낫다.
정리
- 폼 상태와 API DTO 사이의 변환 로직을 순수 함수로 분리하면 컴포넌트는 UI에만 집중할 수 있다
- Create/Update/Response→Form 세 방향의 Transformer를 쌍으로 만들되, 단위 변환 헬퍼는 별도로 분리해서 재사용한다
- 폼 필드와 API 필드가 1:1이면 과한 패턴이니, 타입 불일치나 중첩 구조 변환이 있을 때 도입한다