어댑터 패턴 실전: 프론트엔드에서 실제로 써먹는 방법

Development

목요일 오후에 이런 공지가 올라옵니다.

"다음 주 배포부터 사용자 API 응답 필드가 snake_case에서 camelCase로 바뀝니다. 프론트 쪽 대응 부탁드립니다."

이 한 줄을 읽고 나서 하는 일이 팀마다 다릅니다. 어떤 팀은 파일 하나를 고치고 끝냅니다. 어떤 팀은 user_name을 전역 검색해서 마흔 몇 개의 컴포넌트를 하나씩 열어봅니다. 저는 후자를 해봤고, 그날 하루를 통째로 쓰고도 QA에서 두 군데를 놓친 게 발견됐습니다.

비슷한 요구는 계속 들어옵니다. "결제 수단을 하나 더 붙여주세요, 기존 흐름은 그대로 두고요." "분석 도구를 다른 걸로 바꿉니다, 이벤트 정의는 유지하고요." "구버전 API는 3개월 뒤에 내립니다, 그전까지 두 버전이 같이 떠 있습니다."

이런 순간에 차이를 만드는 것은 대개 경계가 있었느냐입니다. 외부에서 온 데이터가 우리 코드 안쪽까지 그대로 흘러 들어와 있으면 바깥의 작은 변화가 안쪽 전체를 흔듭니다. 반대로 바깥과 안쪽 사이에 번역하는 층이 하나 있으면 그 층만 고치면 됩니다. 어댑터가 하는 일이 정확히 이것입니다. 외부의 변화가 우리 코드 안쪽까지 번지지 않게 막는 방화벽.

개념 자체는 어렵지 않습니다. 어려운 건 그다음입니다. 이 층을 어디에 두어야 하는지, 어떻게 테스트하는지, 캐시와 어떻게 엮이는지, 데이터가 1만 건일 때도 괜찮은지, 그리고 언제 이 층을 걷어내야 하는지. 이 글은 그 실전 부분만 다룹니다.

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • 어댑터의 값어치는 "변환"이 아니라 변경 지점을 N개에서 1개로 줄이는 것에 있습니다.
  • DTO 타입과 도메인 모델 타입을 분리하는 순간, 어댑터를 안 만들 수 없는 구조가 됩니다.
  • API v1/v2 동시 지원처럼 끝이 예정된 작업에서 어댑터가 가장 큰 값을 합니다. 끝나면 파일 하나만 지우면 되니까요.
  • 외부 SDK를 인터페이스 뒤에 두면 테스트에서 fake 구현을 주입할 수 있습니다. 테스트 용이성은 부수 효과가 아니라 주요 이득입니다.
  • 타입은 컴파일 타임에만 참입니다. 서버가 약속을 깨는 순간 타입은 거짓말이 되므로, 어댑터 경계에서 런타임 검증을 같이 하는 편이 안전합니다.
  • 어댑터에 조건 분기가 쌓이기 시작하면 그건 어댑터가 아니라 숨은 비즈니스 로직입니다.
  • 필드명이 1:1로 같은데 파일만 늘리는 어댑터는 만들지 않는 편이 낫습니다.

실전에서 중요한 것은 어댑터를 아느냐가 아니라 어디에 두고, 어떻게 검증하고, 언제 지울지를 미리 정해두었느냐입니다.

개념은 이전 글에 맡기겠습니다

어댑터 패턴이 무엇인지, 왜 중간층이 필요한지, mapper와는 어떻게 다른지는 이전 글에서 정리했습니다.

한 문장으로 요약하면, A가 가진 인터페이스를 B가 기대하는 인터페이스로 번역해주는 중간층입니다. 이 글은 그 위에서 시작합니다. 아래부터는 전부 실제로 마주치는 상황과 코드입니다.

케이스 1. 응답 필드명이 바뀐다는 공지를 받았을 때

상황

서버가 user_nameuserName으로, created_atcreatedAt으로 바꿉니다. profile_image_url은 이제 null이 올 수 있고, point는 큰 수 정밀도 이슈 때문에 숫자에서 문자열이 됩니다.

어댑터가 없을 때 무슨 일이 벌어지는가

응답 타입을 그대로 컴포넌트까지 흘려보낸 코드는 이렇게 생겼습니다.

// ❌ 컴포넌트가 서버 필드명을 직접 알고 있다
function UserRow({ user }: { user: UserApiResponse }) {
  return (
    <tr>
      <td>{user.user_name}</td>
      <td>{new Date(user.created_at).toLocaleDateString()}</td>
      <td>{user.point.toLocaleString()}</td>
    </tr>
  );
}

이런 컴포넌트가 40개 있으면 수정할 곳이 40개입니다. 게다가 point가 문자열이 되는 변경은 타입 에러조차 안 납니다. 응답 타입 정의를 같이 안 고치면 "120000".toLocaleString()이 조용히 "120000"을 반환하고 끝입니다. 콤마가 사라진 걸 QA가 발견해줘야 합니다.

타입을 두 겹으로 나눈다

첫 단계는 서버가 주는 모양우리가 쓰는 모양을 다른 타입으로 선언하는 것입니다.

// entities/user/api/dto.ts — 서버가 주는 모양. 우리 마음대로 못 바꾼다.
export type UserDto = {
  id: number;
  userName: string;
  createdAt: string;
  profileImageUrl: string | null;
  point: string;
  status: 'ACTIVE' | 'INACTIVE' | 'DORMANT';
};
 
// entities/user/model/types.ts — 우리가 쓰기 좋은 모양. 우리가 정한다.
export type User = {
  id: string;
  name: string;
  joinedAt: Date;
  profileImageUrl: string;
  point: number;
  isActive: boolean;
};

이름이 비슷하다고 같은 타입이 아닙니다. UserDto는 계약서고, User는 우리 앱의 언어입니다. 계약서는 상대방이 고치고, 앱의 언어는 우리가 고칩니다.

어댑터를 쓴다

// entities/user/api/adapter.ts
import type { UserDto } from './dto';
import type { User } from '../model/types';
 
const DEFAULT_PROFILE_IMAGE = '/images/default-profile.png';
 
export function toUser(dto: UserDto): User {
  return {
    id: String(dto.id),
    name: dto.userName,
    joinedAt: new Date(dto.createdAt),
    profileImageUrl: dto.profileImageUrl ?? DEFAULT_PROFILE_IMAGE,
    point: Number(dto.point),
    isActive: dto.status === 'ACTIVE',
  };
}
 
export function toUserList(dtos: UserDto[]): User[] {
  return dtos.map(toUser);
}

어댑터가 흡수하는 것은 네 가지입니다.

  • 이름: userNamename. 서버가 또 바꿔도 이 줄만 고칩니다.
  • 타입: stringDate, stringnumber. 컴포넌트가 new Date()를 부를 일이 없어집니다.
  • 널 처리: 기본 이미지 정책이 한 곳에 있습니다. 40개 컴포넌트에 ?? '/images/default.png'가 흩어지지 않습니다.
  • 의미: status === 'ACTIVE'라는 문자열 비교가 isActive라는 도메인 개념으로 바뀝니다.

컴포넌트는 이제 user.name, user.joinedAt.toLocaleDateString(), user.point.toLocaleString()만 씁니다. 서버 필드명도, null 여부도, point가 원래 문자열이었다는 사실도 모릅니다.

얻은 것과 치른 것

얻은 건 명확합니다. 다음번 스키마 변경 때 고칠 파일이 dto.tsadapter.ts 두 개입니다. 치른 것도 있습니다. 파일이 늘었고, UserUserDto라는 비슷한 타입이 생겨 새로 온 팀원이 헷갈릴 수 있습니다. 이 비용은 도메인 하나당 한 번이지만, 도메인이 20개인데 전부 이렇게 만들면 20배가 됩니다. 뒤에서 "언제 만들지 말아야 하는가"를 따로 다루는 이유입니다.

케이스 2. v1과 v2를 동시에 지원해야 할 때

어댑터가 정말 값을 하는 지점은 여기라고 생각합니다. 서버가 주문 API v2를 냈고, 응답 구조가 꽤 바뀌었고, v1은 3개월 뒤에 내립니다. 그동안 두 버전이 같이 떠 있고 우리는 피처 플래그로 점진적으로 넘어가야 합니다.

type OrderV1Dto = {
  order_id: string;
  total_price: number;
  ordered_at: string;
  state: 'READY' | 'SHIPPING' | 'DONE' | 'CANCEL';
};
 
type OrderV2Dto = {
  id: string;
  amount: { value: number; currency: 'KRW' };
  createdAt: string;
  status: { code: 'ready' | 'shipping' | 'delivered' | 'canceled'; label: string };
};

어댑터가 없으면

컴포넌트마다 이런 분기가 생깁니다.

// ❌ 화면이 API 버전을 알고 있다
const price = isV2 ? order.amount.value : order.total_price;
const isDone = isV2 ? order.status.code === 'delivered' : order.state === 'DONE';

이런 삼항 연산자가 화면 열 곳에 퍼지면, 마이그레이션이 끝나도 지우기가 겁납니다. 어디에 몇 개 있는지 아무도 모르니까요. 실제로는 그냥 안 지웁니다. 그리고 1년 뒤에 isV2가 뭔지 아무도 설명하지 못하는 상태가 됩니다.

두 어댑터를 같은 도메인 모델로 수렴시킨다

도메인 모델 하나를 먼저 정합니다. 이것이 우리 앱의 "주문"입니다.

// entities/order/model/types.ts
export type OrderStatus = 'ready' | 'shipping' | 'done' | 'canceled';
 
export type Order = {
  id: string;
  totalPrice: number;
  orderedAt: Date;
  status: OrderStatus;
};

그리고 버전별 어댑터를 각각 씁니다.

// entities/order/api/adapter.v1.ts
const V1_STATUS: Record<OrderV1Dto['state'], OrderStatus> = {
  READY: 'ready',
  SHIPPING: 'shipping',
  DONE: 'done',
  CANCEL: 'canceled',
};
 
export function toOrderFromV1(dto: OrderV1Dto): Order {
  return {
    id: dto.order_id,
    totalPrice: dto.total_price,
    orderedAt: new Date(dto.ordered_at),
    status: V1_STATUS[dto.state],
  };
}
// entities/order/api/adapter.v2.ts
const V2_STATUS: Record<OrderV2Dto['status']['code'], OrderStatus> = {
  ready: 'ready',
  shipping: 'shipping',
  delivered: 'done',
  canceled: 'canceled',
};
 
export function toOrderFromV2(dto: OrderV2Dto): Order {
  return {
    id: dto.id,
    totalPrice: dto.amount.value,
    orderedAt: new Date(dto.createdAt),
    status: V2_STATUS[dto.status.code],
  };
}

Record로 매핑 테이블을 만든 것이 작지만 중요합니다. 서버가 상태 값을 하나 추가하면 이 객체에서 타입 에러가 납니다. switchdefaultif 체인이었다면 조용히 통과했을 겁니다.

스위칭은 한 곳에서만

// entities/order/api/repository.ts
import { isEnabled } from '@/shared/config/feature-flag';
 
export async function fetchOrders(): Promise<Order[]> {
  if (isEnabled('order-api-v2')) {
    const dtos = await http.get<OrderV2Dto[]>('/api/v2/orders');
    return dtos.map(toOrderFromV2);
  }
 
  const dtos = await http.get<OrderV1Dto[]>('/api/v1/orders');
  return dtos.map(toOrderFromV1);
}

버전을 아는 코드는 이 함수 하나뿐입니다. 화면은 전부 Order만 봅니다.

flowchart LR
  V1[v1 응답 DTO] --> A1[toOrderFromV1]
  V2[v2 응답 DTO] --> A2[toOrderFromV2]
  A1 --> M[도메인 모델 Order]
  A2 --> M
  M --> UI[화면 · 훅 · 비즈니스 로직]
 
  style M fill:#bbf7d0,stroke:#16a34a,color:#111

3개월 뒤

마이그레이션이 끝나면 하는 일은 adapter.v1.ts 삭제, OrderV1Dto 삭제, repository.tsif 한 덩어리 삭제, 피처 플래그 삭제. 이것이 전부입니다. 화면 코드는 한 줄도 안 건드립니다. 그리고 삭제가 끝났는지 확실히 알 수 있습니다. 파일이 없어졌으니까요. 삼항 연산자 방식에서는 이를 확신할 방법이 없습니다.

어댑터의 값어치를 한 줄로 말하라면 저는 이것을 고르겠습니다. 지워야 할 코드가 어디 있는지 알 수 있게 만드는 것.

케이스 3. 외부 SDK를 하나의 인터페이스로 묶을 때

분석 이벤트를 GA4로 보내고 있는데 마케팅팀이 다른 도구를 하나 더 붙이자고 합니다. 나중에 갈아탈 수도 있다고 합니다. 그리고 로컬 개발할 때는 실제로 안 보내고 콘솔에만 찍히면 좋겠습니다.

인터페이스를 먼저 정한다

순서가 중요합니다. SDK를 먼저 보지 말고, 우리 앱이 쓰고 싶은 모양을 먼저 씁니다.

// shared/analytics/types.ts
export type AnalyticsEvent =
  | { name: 'view_product'; props: { productId: string; price: number } }
  | { name: 'add_to_cart'; props: { productId: string; quantity: number } }
  | { name: 'purchase'; props: { orderId: string; amount: number } };
 
export interface AnalyticsClient {
  identify(userId: string): void;
  track<E extends AnalyticsEvent>(name: E['name'], props: E['props']): void;
  reset(): void;
}

이벤트를 유니온으로 묶어두면 track('view_product', { orderId: '...' }) 같은 실수가 컴파일 단계에서 잡힙니다. 분석 이벤트는 잘못 보내도 화면이 안 깨지기 때문에 몇 달 뒤에 데이터 분석가가 발견하는 일이 흔한데, 이런 종류의 버그를 타입으로 막을 수 있는 것은 꽤 큰 이득입니다.

구현체를 어댑터로 만든다

// shared/analytics/ga4.ts
export const createGa4Client = (gtag: Gtag): AnalyticsClient => ({
  identify: (userId) => gtag('set', { user_id: userId }),
  track: (name, props) => gtag('event', name, props),
  reset: () => gtag('set', { user_id: undefined }),
});
// shared/analytics/index.ts
const createConsoleClient = (): AnalyticsClient => ({
  identify: (userId) => console.info('[analytics] identify', userId),
  track: (name, props) => console.info('[analytics] track', name, props),
  reset: () => console.info('[analytics] reset'),
});
 
export const analytics: AnalyticsClient =
  process.env.NODE_ENV === 'production' ? createGa4Client(window.gtag) : createConsoleClient();

앱 코드는 analytics.track('add_to_cart', { productId, quantity }) 하나만 압니다. 도구를 하나 더 붙이는 것은 AnalyticsClient를 구현한 파일을 하나 더 만드는 일이 되고, 여러 개에 동시에 보내야 하면 여러 구현을 순회하며 호출하는 구현체를 하나 더 만들면 됩니다.

테스트가 쉬워지는 것은 부수 효과가 아닙니다

이 구조의 가장 큰 이득은 SDK 교체가 아니라 테스트입니다. 실제로 SDK를 교체하는 일은 몇 년에 한 번이지만, 테스트는 매일 돌립니다.

// 테스트용 fake — 호출 기록만 남긴다
function createFakeClient() {
  const calls: Array<{ name: string; props: unknown }> = [];
  const client: AnalyticsClient = {
    identify: () => {},
    track: (name, props) => calls.push({ name, props }),
    reset: () => {},
  };
  return { client, calls };
}
 
it('장바구니에 담으면 add_to_cart 이벤트를 보낸다', async () => {
  const { client, calls } = createFakeClient();
 
  render(<ProductPage productId="P-1" analytics={client} />);
  await userEvent.click(screen.getByRole('button', { name: '장바구니 담기' }));
 
  expect(calls).toEqual([{ name: 'add_to_cart', props: { productId: 'P-1', quantity: 1 } }]);
});

window.gtag를 전역에서 직접 부르는 코드였다면 이 테스트를 쓰려고 전역 객체를 모킹하고, 테스트마다 초기화하고, 다른 테스트에 새는지 신경 써야 합니다. 인터페이스 하나 사이에 두었을 뿐인데 그 고민이 통째로 사라집니다.

케이스 4. 라이브러리 교체 완충층은 어디까지 만들까

날짜 라이브러리를 바꿔야 하는 상황을 생각해보겠습니다. 프로젝트 전체에서 moment를 import하는 파일이 60개라면 교체는 60개 파일을 여는 일입니다. 얇은 래퍼가 하나 있으면 한 파일입니다.

// shared/lib/date.ts — 우리가 실제로 쓰는 것만 노출
import dayjs from 'dayjs';
 
export function formatDate(value: Date | string, pattern = 'YYYY.MM.DD'): string {
  return dayjs(value).format(pattern);
}
 
export function daysBetween(from: Date | string, to: Date | string): number {
  return dayjs(to).diff(dayjs(from), 'day');
}

핵심은 dayjs 인스턴스를 밖으로 반환하지 않는 것입니다. export function toDayjs(value) 같은 것을 만드는 순간 래퍼는 의미가 없어집니다. 호출부가 다시 라이브러리 객체를 들고 다니게 됩니다. 들어가는 것도 나오는 것도 Date, string, number 같은 표준 타입이어야 합니다.

다만 여기에는 분명한 경계가 필요합니다. 모든 라이브러리를 래핑하라는 얘기가 아닙니다. 판단 기준은 이렇게 잡습니다.

상황 래핑할까 이유
날짜·통화 포맷처럼 쓰는 API가 5개 이하 한다 래퍼가 얇고, 교체 가능성이 실재한다
상태 관리 라이브러리, 라우터 안 한다 프레임워크급 결합이라 래핑해도 교체 못 한다
차트·에디터처럼 API 표면이 거대함 부분만 전체 래핑은 라이브러리를 다시 만드는 일이 된다
lodash 같은 유틸 모음 안 한다 함수 단위라 교체 비용이 이미 낮다
결제·인증처럼 벤더가 바뀔 수 있음 한다 교체가 비즈니스 요구로 실제로 들어온다

기준을 한 줄로 줄이면 이렇습니다. 래퍼를 만드는 비용보다 교체 확률 × 교체 비용이 클 때만 만듭니다. 차트 라이브러리 전체를 감싸겠다고 3주를 쓰는 쪽은 대개 그 반대편입니다.

케이스 5. 타입은 컴파일 타임에만 참입니다

어댑터의 가장 큰 약점을 짚고 넘어가야 합니다. const dto = (await response.json()) as UserDto; 이 한 줄의 as는 아무것도 검증하지 않습니다. 서버가 userName을 빼먹고 보내도, pointnull을 넣어 보내도 타입스크립트는 만족합니다. 그리고 toUser 안에서 Number(null)0이 되어 화면에 "0P"가 찍힙니다. 에러도 안 납니다.

타입은 우리가 서버를 믿는다는 전제일 뿐이고, 서버가 약속을 깨는 순간 그 믿음은 거짓말이 됩니다. 어댑터 경계는 외부 데이터가 우리 코드로 들어오는 유일한 문이므로, 검증하기에도 가장 좋은 자리입니다.

스키마로 파싱한 뒤 변환한다

// entities/user/api/schema.ts
import { z } from 'zod';
 
export const userDtoSchema = z.object({
  id: z.number(),
  userName: z.string(),
  createdAt: z.string(),
  profileImageUrl: z.string().nullable(),
  point: z.string(),
  status: z.enum(['ACTIVE', 'INACTIVE', 'DORMANT']),
});
 
export type UserDto = z.infer<typeof userDtoSchema>;
 
// entities/user/api/repository.ts
export async function fetchUser(id: string): Promise<User> {
  const json = await http.get(`/api/users/${id}`);
  const dto = userDtoSchema.parse(json); // 여기서 계약 위반이면 던진다
  return toUser(dto);
}

DTO 타입을 손으로 쓰지 않고 스키마에서 뽑아냈습니다. 스키마와 타입이 어긋날 수 없게 됩니다. 이제 서버가 약속을 깨면 화면이 이상해지는 대신 명확한 에러가 납니다. 어느 필드가 왜 틀렸는지도 나옵니다. 디버깅 시간이 몇 시간에서 몇 분으로 줄어드는 지점입니다.

(스키마 라이브러리는 메이저 버전 사이에 API가 달라지는 부분이 있습니다. parse, safeParse, infer 같은 기본 축은 안정적이지만, 세부 옵션은 쓰는 버전 문서를 확인하는 편이 좋습니다.)

검증에 실패하면 무엇을 할 것인가

이것이 실제 설계 결정입니다. 세 가지 선택지가 있습니다.

전략 동작 잘 맞는 곳 위험
throw 즉시 에러, 에러 바운더리로 결제 금액, 주문 상태 등 틀리면 안 되는 데이터 필드 하나 때문에 화면 전체가 죽음
fallback 기본값으로 대체하고 진행 배너, 추천, 부가 정보 문제가 조용히 숨음
부분 실패 허용 리스트에서 깨진 항목만 버림 목록·피드처럼 항목이 독립적일 때 사용자가 모르게 개수가 줄어듦

세 번째가 실무에서 가장 자주 쓰이는데, 코드로 보면 이렇습니다.

// 리스트: 깨진 항목만 버리고 나머지는 살린다
export function parseUserList(json: unknown): { users: User[]; droppedCount: number } {
  const rawList = z.array(z.unknown()).parse(json);
 
  const users: User[] = [];
  let droppedCount = 0;
 
  for (const raw of rawList) {
    const result = userDtoSchema.safeParse(raw);
 
    if (result.success) {
      users.push(toUser(result.data));
    } else {
      droppedCount += 1;
      reportError('user-dto-invalid', { issues: result.error.issues });
    }
  }
 
  return { users, droppedCount };
}

reportError가 빠지면 안 됩니다. 조용히 버리는 것은 버그를 숨기는 일과 같습니다. 항목을 버리되 모니터링에는 남겨서, 어느 날 갑자기 20%가 버려지고 있다는 사실을 알 수 있어야 합니다. 저는 이 로그 덕분에 서버가 특정 조건에서 null을 보내던 것을 배포 당일에 잡은 적이 있습니다.

어댑터는 어디에 두어야 할까

파일을 어디에 두느냐가 규칙의 절반입니다. 위치가 애매하면 규칙은 잘 지켜지지 않습니다.

Feature-Sliced Design을 쓴다면 자연스러운 자리가 있습니다.

src/
  shared/api/http.ts          # fetch 래퍼, 인터셉터, 공통 에러
  entities/user/
    api/
      schema.ts               # zod 스키마 + DTO 타입 추론
      adapter.ts              # toUser, toUserList
      repository.ts           # fetchUser — 검증과 변환을 끝내고 도메인 모델 반환
    model/types.ts            # User — 도메인 모델
    ui/UserCard.tsx           # User만 안다

import 방향 규칙은 단순합니다.

  • uimodel만 import합니다. api 안쪽을 직접 보지 않습니다.
  • DTO 타입은 entities/user/api 바깥으로 나가지 않습니다.
  • 상위 레이어인 features, widgets, apprepository와 도메인 모델만 씁니다.
flowchart LR
  SERVER[외부 서버 응답] --> SCHEMA[스키마 검증]
  SCHEMA --> ADAPTER[어댑터 toUser]
  ADAPTER --> MODEL[도메인 모델 User]
  MODEL --> FEAT[features · widgets]
  MODEL --> UIL[entities ui]
 
  style ADAPTER fill:#fde68a,stroke:#d97706,color:#111
  style MODEL fill:#bbf7d0,stroke:#16a34a,color:#111

노란 칸이 경계입니다. 그 왼쪽은 서버가 정하고, 오른쪽은 우리가 정합니다. 레이어 규칙 자체는 이전에 정리한 글들이 있습니다.

FSD를 안 쓰더라도 원리는 같습니다. DTO 타입이 import될 수 있는 범위를 좁게 못 박아두는 것이 핵심입니다.

React Query와 함께 쓸 때 변환은 어디서 할까

서버 상태 라이브러리를 쓰면 변환 위치가 두 곳으로 갈립니다. queryFn 안에서 변환할 것인가, select에서 변환할 것인가.

// A안: queryFn에서 변환 — 캐시에 도메인 모델이 저장된다
useQuery({
  queryKey: ['users', page],
  queryFn: async () => parseUserList(await http.get(`/api/users?page=${page}`)).users,
});
 
// B안: select에서 변환 — 캐시에는 원본 DTO가 저장된다
useQuery({
  queryKey: ['users', page],
  queryFn: () => http.get(`/api/users?page=${page}`),
  select: (json) => parseUserList(json).users,
});

둘의 차이를 정리하면 이렇습니다.

관점 queryFn에서 변환 select에서 변환
캐시에 저장되는 것 도메인 모델 원본 DTO
변환 실행 시점 요청당 1회 구독하는 컴포넌트마다, 데이터가 바뀔 때
화면마다 다른 모양이 필요할 때 어려움 (캐시가 이미 확정된 모양) 쉬움 (같은 캐시에서 다르게 뽑음)
검증 실패 처리 쿼리 에러로 자연스럽게 흐름 렌더 중 예외라 다루기 까다로움
Date 같은 비직렬화 값 SSR hydration이나 persist 시 주의 필요 원본이 직렬화 가능해서 안전

기본값은 queryFn에서 변환으로 잡는 편이 무난합니다. 검증 실패가 쿼리 에러로 흘러가고, 변환이 한 번만 일어나고, 캐시를 읽는 모든 곳이 같은 모양을 봅니다. select가 나은 경우는 같은 응답을 화면마다 다른 모양으로 써야 할 때입니다. 목록 화면은 요약만, 상세 화면은 전체를 쓰는 식이라면 원본을 캐시해두고 뽑아 쓰는 쪽이 자연스럽습니다.

다만 select를 쓸 때 자주 놓치는 게 있습니다.

// ❌ 매 렌더마다 새 함수 참조 → 재계산 최적화가 잘 안 듣는다
useQuery({ queryKey: ['users'], queryFn: fetchUsersRaw, select: (d) => d.map(toUser) });
 
// ✅ 모듈 스코프로 빼서 참조를 고정
const selectUsers = (dtos: UserDto[]) => dtos.map(toUser);
useQuery({ queryKey: ['users'], queryFn: fetchUsersRaw, select: selectUsers });

라이브러리는 같은 데이터와 같은 함수 참조라면 재계산을 줄이려고 하는데, 인라인 화살표 함수는 렌더마다 참조가 바뀌어서 그 최적화가 기대만큼 동작하지 않을 수 있습니다. 자세한 운영 감각은 관련 글에 정리해두었습니다.

1만 건을 변환하면 어댑터가 병목이 됩니다

어댑터 얘기에서 성능은 잘 안 나오는데, 실제로 문제가 됩니다.

// ❌ 렌더마다 1만 번의 객체 생성 + Date 파싱
function UserTable({ dtos }: { dtos: UserDto[] }) {
  const users = dtos.map(toUser);
  return (
    <>
      {users.map((u) => (
        <UserRow key={u.id} user={u} />
      ))}
    </>
  );
}

new Date(문자열) 파싱은 생각보다 비쌉니다. 1만 건이면 매 렌더마다 1만 번의 객체 생성과 1만 번의 날짜 파싱이 일어나고, 게다가 매번 새 배열·새 객체라서 React.memo를 걸어둔 자식들도 전부 다시 렌더됩니다. 어댑터를 넣었더니 느려졌다는 얘기가 여기서 나옵니다.

대응은 세 단계로 봅니다.

1단계. 변환을 렌더 밖으로 옮깁니다. 앞의 queryFn 변환이 그 자체로 해법입니다. 요청당 한 번만 돌고 캐시에 남습니다. 그래도 렌더 경로에서 변환해야 한다면 const users = useMemo(() => dtos.map(toUser), [dtos]);처럼 메모이제이션을 겁니다. 단, dtos 참조가 안정적이어야 의미가 있습니다. 상위에서 매번 새 배열을 만들고 있으면 useMemo는 아무 일도 안 합니다. 이 부분의 판단 기준은 React.memo, useMemo, useCallback 글에 정리해두었습니다.

2단계. 비싼 변환을 지연시킵니다. 목록에서 실제로 쓰지 않는 필드까지 미리 변환할 이유는 없습니다. toUser와 별개로 toUserSummary처럼 목록용 경량 어댑터를 두고 화면에 보이는 필드만 변환합니다. 날짜 포맷이 셀 안에서만 필요하다면 Date 객체를 1만 개 만들 필요 없이, 문자열로 들고 있다가 보이는 행에서만 포맷하는 편이 낫습니다.

3단계. 애초에 서버에서 변환합니다. BFF나 Next.js의 서버 구간이 있다면 변환을 그쪽으로 밀 수 있습니다. 경계가 사라지는 것이 아니라 위치가 서버로 옮겨가는 것이고, 브라우저 메인 스레드가 하던 일이 줄어듭니다. 이 경우 BFF가 우리 소유라 계약이 안정적이니 클라이언트 검증은 얇게 가져가도 괜찮습니다.

어댑터가 썩고 있다는 신호

어댑터도 방치하면 썩습니다. 자주 보이는 증상 세 가지가 있습니다.

신호 1. 어댑터 안에 조건 분기가 쌓인다

// ❌ 이건 어댑터가 아니라 비즈니스 로직이다
export function toOrder(dto: OrderDto): Order {
  return {
    id: dto.id,
    totalPrice: dto.amount.value,
    // 여기서부터 문제
    canCancel:
      dto.status.code === 'ready' ||
      (dto.status.code === 'shipping' && daysSince(dto.createdAt) < 3 && !dto.isGift),
    displayBadge: dto.isFirstOrder ? '첫 주문' : dto.amount.value > 100000 ? 'VIP' : '',
  };
}

"취소 가능한가"는 번역이 아니라 판단입니다. 정책이 바뀌면 어댑터를 고쳐야 하는데, 어댑터는 스키마 변경 때문에도 고쳐야 하는 파일입니다. 두 가지 이유로 바뀌는 파일은 언젠가 충돌합니다.

처방은 판단을 도메인 모델 쪽 함수로 빼는 것입니다.

// entities/order/model/policy.ts
export function canCancel(order: Order): boolean {
  if (order.status === 'ready') return true;
  if (order.status !== 'shipping') return false;
  return daysSince(order.orderedAt) < 3 && !order.isGift;
}

이때 isGift는 도메인 모델 Order에도 필드로 올라와야 합니다. 어댑터는 isGift, orderedAt, status번역만 하고, canCancel은 도메인이 판단합니다. 이제 정책이 바뀌어도 어댑터는 안 건드립니다. 책임을 하나로 유지하는 것이 SOLID 원칙에서 말하는 단일 책임의 실제 모습이기도 합니다.

신호 2. 어댑터가 어댑터를 부른다

toUser(toUserDto(dto.user_info))처럼 같은 데이터를 두 번 변환하고 있다면, 중간 표현이 하나 더 있다는 뜻이고 대개 리팩터링 중간에 멈춘 흔적입니다. 데이터가 어떤 모양으로 어디를 지나는지 추적이 안 되면 디버깅 비용이 빠르게 올라갑니다. 처방은 중간 단계를 없애고 OrderDto에서 Order로 한 번에 가는 것입니다.

중첩 DTO를 각 entity의 어댑터로 위임하는 것은 다른 얘기입니다. toOrder 안에서 toUser(dto.user)를 부르는 건 자연스러운 합성입니다. 문제는 같은 데이터를 거듭 변환하는 경우입니다.

신호 3. 도메인 모델이 DTO와 똑같아졌다

type UserDto = { id: string; name: string; email: string };
type User = { id: string; name: string; email: string };
 
export const toUser = (dto: UserDto): User => ({ ...dto });

이 어댑터는 아무 일도 안 합니다. 파일 두 개와 import 한 줄만 늘렸습니다. 선택지는 둘입니다. 이 도메인은 원래 변환이 필요 없었던 것이라면 어댑터를 지웁니다. 도메인 모델을 우리 언어로 다시 설계했어야 하는데 안 한 것이라면 모델을 고칩니다. 어느 쪽인지는 팀이 판단해야 하지만, "일단 만들어뒀으니 둔다"는 답은 아닙니다.

언제 어댑터를 만들지 말아야 할까

여기까지 읽으면 전부 어댑터로 감싸고 싶어지는데, 그것이 다음 문제입니다.

  • 필드가 1:1로 같고 이름도 같을 때. 위의 신호 3 그대로입니다. 지금 필요 없으면 필요해질 때 만들면 됩니다.
  • 프로토타입 단계. 스펙이 매주 바뀌는 중에 경계를 미리 굳히면 그 경계까지 매주 고쳐야 합니다. 검증할 게 화면인 시기에는 응답을 그대로 쓰는 쪽이 빠릅니다.
  • API가 우리 소유일 때. 우리 팀이 서버도 만든다면 응답 모양을 우리가 정할 수 있습니다. 어댑터로 번역하기보다 서버 응답을 우리가 쓰기 좋게 바꾸는 편이 낫습니다. 어댑터는 바꿀 수 없는 것을 다루는 도구입니다.
  • 호출부가 하나뿐이고 앞으로도 하나일 때. 변경 지점을 N에서 1로 줄이는 것이 목적인데 N이 이미 1이면 얻을 것이 없습니다.
  • BFF가 이미 정리해서 내려줄 때. 변환 지점이 서버에 있으면 클라이언트에 또 만들 이유가 약해집니다.

판단이 헷갈릴 때 쓰는 질문 하나가 있습니다. "이 필드 이름을 바꾸라는 요청이 들어오면 몇 개 파일을 열어야 하는가?" 답이 1이면 이미 충분합니다. 답이 10 이상이면 만들 때가 됐습니다.

이미 커버린 코드베이스에 나중에 넣으려면

새 프로젝트라면 처음부터 넣으면 됩니다. 문제는 이미 DTO가 컴포넌트까지 다 퍼져 있는 코드베이스입니다. 이를 한 번에 뒤집는 것은 현실적이지 않습니다. 순서가 있습니다.

  1. 가장 자주 바뀌는 도메인 하나를 고릅니다. 최근 6개월 커밋 로그에서 스키마 때문에 고친 횟수가 제일 많은 도메인이면 됩니다. 거기서 효과가 나면 나머지는 설득이 쉬워집니다.
  2. 도메인 모델 타입을 먼저 씁니다. 코드는 안 옮기고 타입만 씁니다. DTO에 필드가 30개인데 화면은 8개만 쓰고 있더라는 발견이 이 단계에서 자주 나옵니다.
  3. 어댑터를 만들고 진입점 한 곳에만 끼웁니다. repository나 쿼리 함수 한 곳입니다. 이때 컴포넌트에서 타입 에러가 쏟아지는데, 그것이 정확히 고쳐야 할 목록이고 에러 개수가 곧 진척도가 됩니다.
  4. DTO를 직접 참조하는 곳을 하나씩 걷어냅니다. 한 PR에 다 넣지 말고 화면 단위로 쪼갭니다. 리뷰 가능한 크기여야 실제로 머지됩니다.
  5. 다 끝났으면 린트로 못 박습니다. 사람의 기억에 맡기면 반년 뒤에 원래대로 돌아옵니다.
// eslint.config.js — DTO import 경로를 물리적으로 막는다
{
  rules: {
    'no-restricted-imports': [
      'error',
      {
        patterns: [
          {
            group: ['*/api/dto', '*/api/schema'],
            message: 'DTO 타입은 entities/<domain>/api 안에서만 씁니다. 도메인 모델을 쓰세요.',
          },
        ],
      },
    ],
  },
}

5번이 빠지면 1~4번이 무의미해집니다. 규칙은 문서가 아니라 CI가 지킵니다. 새로 온 사람이 급할 때 DTO를 그냥 import하는 것을 막아주는 쪽은 코드 리뷰가 아니라 이 설정입니다.

실무 체크리스트

어댑터를 넣기 전이나 리뷰할 때 이 목록을 봅니다.

  1. DTO 타입과 도메인 모델 타입이 다른 파일에 있는가?
  2. 컴포넌트가 서버 필드명(snake_case, 원본 enum 값)을 알고 있지는 않은가?
  3. 어댑터가 순수 함수인가? 안에서 네트워크를 부르거나 전역 상태를 읽지는 않는가?
  4. 어댑터에 조건 분기가 몇 개인가? 세 개를 넘으면 정책이 섞인 건 아닌지 본다.
  5. 런타임 검증이 있는가? 실패했을 때 throw / fallback / 부분 허용 중 무엇인지 정해져 있는가?
  6. 항목을 버릴 때 모니터링에 남기는가?
  7. 리스트 변환이 렌더 경로에 있는가? 1만 건이 와도 괜찮은가?
  8. 버전 마이그레이션이라면, 끝났을 때 지울 파일 목록이 명확한가?
  9. 외부 SDK 어댑터라면, 테스트용 fake 구현이 있는가?
  10. DTO import를 막는 린트 규칙이 걸려 있는가?

10개 중 절반 이상이 걸린다면 어댑터가 아직 방화벽 역할을 못 하고 있을 가능성이 높습니다.

정리하면

  • 어댑터의 목적은 변환이 아니라 변경 지점을 N개에서 1개로 줄이는 것입니다. N이 이미 작으면 만들지 않는 편이 맞습니다.
  • 가장 큰 값을 하는 자리는 끝이 예정된 작업입니다. v1/v2 동시 지원처럼, 끝났을 때 지울 파일이 명확한 구조를 만들어줍니다.
  • 외부 SDK를 인터페이스 뒤에 두면 교체보다 테스트에서 먼저 이득을 봅니다. 매일 돌리는 쪽의 이득이 더 큽니다.
  • 타입은 컴파일 타임에만 참이므로, 어댑터 경계에서 런타임 검증을 같이 하고 실패 전략을 미리 정해둡니다.
  • 어댑터에 정책이 스며들고, 도메인 모델이 DTO와 똑같아지면 그건 걷어낼 신호입니다.

내일 바로 쓸 수 있는 판단 기준은 이 질문 하나입니다. "서버가 이 필드 이름을 바꾸면 나는 몇 개 파일을 열어야 하는가?" 그 숫자가 어댑터가 필요한지, 이미 잘 있는지를 가장 정직하게 알려줍니다.

같이 보면 좋은 글