
안녕하세요. 토스의 frontend-fundamentals 디스커션에서 아래와 같은 고민이 있더라구요.
"백엔드에서 준 데이터를 최대한 그대로 쓰는 게 맞아" vs. "프론트는 프론트 도메인으로 따로 가야지"
디스커션 링크
특히 백엔드와 프론트엔드가 분리되어 개발하는 환경에서, 서버에서 내려주는 데이터를 어떻게 다뤄야 할까에 대한 논쟁은 끝이 없죠.
저는 후자 쪽에 손을 들고 싶습니다. 하지만 무조건 변환 레이어가 정답이다라고 말하고 싶은 건 아니에요. 오늘은 어떤 상황에서 DTO에 직접 의존하는 게 위험한지, 그리고 언제 변환 레이어가 필요한지 제 경험을 공유해보려고 합니다.
실제 프로젝트를 진행하다 보면 이런 경험 있으시죠?
1주차:
interface UserDTO {
  name: string;
  email: string;
}
2주차: 백엔드 개발자 : "아, 이름이 firstName, lastName으로 분리됐어요"
interface UserDTO {
  firstName: string;
  lastName: string;
  email: string;
}
3주차: 백엔드 개발자 : "email 필드 삭제하고 contactInfo로 통합했습니다"
interface UserDTO {
  firstName: string;
  lastName: string;
  contactInfo: {
    email: string;
    phone: string;
  }
}
이런 변경이 있을 때마다 프론트엔드는 어떻게 되나요?
// UserProfile.tsx
function UserProfile() {
  const { data: user } = useQuery(['user'], fetchUser);
  
  return (
    <div>
      <h1>{[user.name](http://user.name)}</h1> {/* ❌ 타입 에러! */}
      <p>{[user.email](http://user.email)}</p>  {/* ❌ 타입 에러! */}
    </div>
  );
}
// UserCard.tsx
function UserCard() {
  const { data: user } = useQuery(['user'], fetchUser);
  
  return <span>{[user.name](http://user.name)}</span>; {/* ❌ 또 타입 에러! */}
}
// UserMenu.tsx
function UserMenu() {
  const { data: user } = useQuery(['user'], fetchUser);
  
  return <div>{[user.email](http://user.email)}</div>; {/* ❌ 또또 타입 에러! */}
}
보이시나요? DTO를 직접 사용하는 모든 컴포넌트에서 에러가 터집니다.
개발 단계에서는 DTO가 2주에 한 번씩 변경되기도 하고, 운영 단계에서도 필요에 따라 자주 변경될 수 있어요.
웹 개발자라면 "API 바뀌면 바로 배포하면 되지 않나?"라고 생각할 수 있어요. 맞습니다. 웹은 배포가 자유롭기 때문에 DTO 변경에 상대적으로 유연하게 대응할 수 있죠.
하지만 모바일 앱은 완전히 다른 이야기입니다.
앱스토어나 플레이스토어에 배포된 순간부터, 그 앱은 개발자의 즉각적인 통제를 벗어납니다. 사용자가 언제 업데이트할지, 심지어 업데이트를 할지조차 보장할 수 없어요.
// 2024년 1월: v1.0.0 배포
interface UserDTO {
  name: string;
}
// 2024년 3월: v1.1.0 배포 - API 변경됨
interface UserDTO {
  firstName: string;
  lastName: string;
}
이 상황에서 문제는:
웹이었다면? 배포 한 번으로 모든 사용자가 즉시 새 코드를 사용합니다.
더 복잡한 것은:
결과적으로 언제든 최소 3~4개 버전이 동시에 사용 중입니다.
앤드로이드 개발자의 경험을 공유한 글을 보면, API를 v3.5에서 v3.6으로 업그레이드했더니:
/users 엔드포인트 응답 구조 변경"그거 개발자 잘못 아니야?"라고 할 수 있지만, 앱 개발자 입장에선 선택지가 없어요. 전역 API 버전을 올리면 예측할 수 없는 변경사항들이 한꺼번에 적용되니까요.
이런 상황에서 변환 레이어가 있다면:
// 서버 API가 v3.6으로 변경되어도
function serverToClient(dto: UserDTOv3_6): User {
  return {
    fullName: `${dto.firstName} ${dto.lastName}` // v1.0.0, v1.1.0 모두 대응
  };
}
앱의 모든 버전이 동일한 도메인 타입을 사용하고, 서버 응답만 버전별로 적절히 변환하면 됩니다.
웹에서는 선택사항일 수 있지만, 앱에서 DTO에 직접 의존하는 것은 시한폭탄입니다. API 버전 관리와 변환 레이어는 거의 필수에 가깝습니다.
사실 그렇지 않습니다. 제 경험상 프로젝트 상황에 따라 달라져요.
소규모 팀에서 긴밀하게 일할 때는 DTO를 그대로 써도 괜찮아요.
백엔드 개발자와 한 방에서 일하면서 API 변경 전에 미리 논의할 수 있고, 변경 사항을 바로바로 공유하고 함께 대응할 수 있잖아요. API 개수도 적고, 사용하는 컴포넌트도 몇 개 안 되는 경우라면 오히려 심플하게 가는 게 좋을 수 있어요. 변환 레이어는 그냥 오버엔지니어링이 될 수 있죠.
하지만 제가 변환 레이어를 강력히 추천하는 케이스가 있어요.
대규모 프로젝트에서 여러 API에 의존할 때요.
10개 이상의 서로 다른 API 엔드포인트를 사용한다거나, 외부 API나 파트너사 API 같은 통제 불가능한 API를 쓴다거나, 백엔드 팀이 여러 개로 나뉘어 있어서 변경 사항을 한눈에 파악하기 어려운 경우. 이런 상황에서는 각 API의 변경이 프론트엔드 전체에 파급효과를 일으킬 수 있어요.
특히 이런 경험 있으신가요?
// 프로필 API는 [user.name](http://user.name)
const { data: profile } = useQuery(['profile'], fetchProfile);
// 주문 API는 user.userName  
const { data: order } = useQuery(['order'], fetchOrder);
// 결제 API는 user.fullName
const { data: payment } = useQuery(['payment'], fetchPayment);
같은 "사용자 이름"인데 API마다 필드명이 다른 경우... 정말 흔하죠. 이럴 때 컴포넌트에서 일일이 맞춰 쓰면 코드가 난잡해집니다.
// 😰 이런 코드를 여러 곳에서...
<div>
  {profile?.name || order?.userName || payment?.fullName}
</div>
TkDodo의 React Query Data Transformations 블로그 포스트를 보면 이런 말이 나옵니다.
"Let's face it - most of us are not using GraphQL. If you are working with REST though, you are constrained by what the backend returns."
REST API를 사용한다면, 백엔드가 주는 구조에 제약을 받는다는 거죠. 그래서 TkDodo는 데이터 변환을 위한 여러 방법을 제시하는데, 그중 가장 추천하는 방식이 바로 select 옵션입니다.
const useTodos = () => 
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: (data) => transformData(data) // 여기서 변환!
  })
이 방식의 장점은 변환된 데이터가 변하지 않으면 컴포넌트가 리렌더링되지 않고, 전체 데이터 중 필요한 부분만 선택적으로 구독할 수 있다는 점이에요. 그리고 타입 변환이 useQuery 레벨에서 끝난다는 게 큰 장점이죠.
그럼 실제로 어떻게 구성하면 좋을까요? 제가 쓰는 패턴을 보여드릴게요.
먼저 Repository 레이어를 구조화합니다.
// repositories/user/formatters.ts
import type { UserDTO } from '@/types/dto';
import type { User } from '@/types/domain';
export function toUser(dto: UserDTO): User {
  return {
    id: dto.id,
    fullName: `${dto.firstName} ${dto.lastName}`,
    email: dto.email,
  };
}
// repositories/user/api.ts
import type { UserDTO } from '@/types/dto';
export async function fetchUser(id: string): Promise<UserDTO> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
그리고 Custom Hook에서 TkDodo가 추천한 select 옵션을 활용합니다.
// hooks/useUser.ts
import { useQuery } from '@tanstack/react-query';
import { fetchUser } from '@/repositories/user/api';
import { toUser } from '@/repositories/user/formatters';
export function useUser(id: string) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
    select: toUser, // ✅ Repository의 formatter를 select에서 사용
  });
}
컴포넌트는 도메인 타입만 사용합니다.
// components/UserProfile.tsx
function UserProfile({ userId }: Props) {
  const { data: user } = useUser(userId);
  return (
    <div>
      <h1>{user.fullName}</h1> {/* ✅ 항상 동일한 인터페이스 */}
      <p>{user.email}</p>
    </div>
  );
}
이제 백엔드에서 firstName, lastName을 fullName으로 통합하더라도 어떻게 될까요?
// 수정이 필요한 곳은 Repository의 formatter뿐!
// repositories/user/formatters.ts
export function toUser(dto: UserDTO): User {
  return {
    id: dto.id,
    fullName: dto.fullName, // ✅ 여기만 수정
    email: dto.email,
  };
}
컴포넌트는 손도 안 댔습니다. 타입 에러도 나지 않아요.
이 패턴의 장점은:
formatters.ts에 집중되어 관리가 쉽고변환 레이어를 두면 어디서 문제가 생기는지 명확해집니다.
DTO를 직접 사용하면 15개 컴포넌트에서 각각 타입 에러가 발생하고, 어디서 뭐가 잘못됐는지 파악하기 어려워요. 하지만 변환 레이어를 사용하면 formatter에서만 타입 에러가 발생하고, 한 곳만 보면 되고 수정도 한 곳만 하면 됩니다.
// ❌ DTO를 직접 사용하면
// UserProfile.tsx에서 에러
// UserCard.tsx에서 에러
// UserList.tsx에서 에러
// ... (15곳에서 에러)
// ✅ formatter를 사용하면
// repositories/user/formatters.ts에서만 에러!
특히 여러 API를 사용하는 대규모 프로젝트에서 이 차이가 크게 느껴집니다. 10개 API가 있다면:
에러가 발생하는 위치가 80곳에서 10곳으로 줄어듭니다. 수정도 10곳만 하면 되고요.
profy.dev의 "React Architecture" 시리즈를 보면 이런 말이 나옵니다.
"By passing the data coming from the API directly to the components we tightly couple the UI to the server."
API 데이터를 컴포넌트에 직접 전달하면 UI가 서버에 강하게 결합된다는 거죠.
Stack Overflow에도 "Service layer returns DTO to controller but need it to return model for other services"나 "Should data transformation be on the front or on the back end?" 같은 질문들이 정말 많더라고요. 그리고 답은 명확합니다. 경계(boundary)에서 변환하라는 거예요. 프론트엔드 관점에서 경계는 API 호출 직후, 즉 Repository 레이어입니다.
"그럼 보일러플레이트 코드가 엄청 늘어나지 않아요?"
네, 맞습니다. 변환 함수를 작성하고, 도메인 타입을 정의하고, 추가 파일을 관리해야 하죠. 이게 비용입니다.
하지만 서버 변경에 대한 격리, 컴포넌트 안정성, 타입 안전성, 그리고 장기적인 유지보수 비용 감소라는 이득이 있어요.
그래서 제 생각에는:
만약 이 글을 읽고 공감하셨다면, 이렇게 시작해볼 수 있어요.
먼저 도메인 타입을 정의하는 거죠.
// types/domain/user.ts
export interface User {
  id: string;
  fullName: string;
  email: string;
  phone: string | null;
}
그다음 Repository에 formatter를 추가하고요.
// repositories/user/formatters.ts
import type { UserDTO } from '@/types/dto';
import type { User } from '@/types/domain';
export function toUser(dto: UserDTO): User {
  return {
    id: dto.id,
    fullName: `${dto.firstName} ${dto.lastName}`,
    email: dto.contactInfo?.email ?? '',
    phone: dto.contactInfo?.phone ?? null,
  };
}
기존 코드를 점진적으로 마이그레이션하면 됩니다.
// Before
const { data: user } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
});
// user는 UserDTO 타입
// After
const { data: user } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  select: toUser, // ✅ formatter 추가
});
// user는 User 타입
타입이 달라지면서 컴포넌트에서 타입 에러가 날 텐데, 그걸 하나씩 수정하면 돼요. 이게 바로 컴파일 타임에 문제를 발견하는 거죠.
"당신의 프로젝트는 몇 개의 API를 사용하고 있고, 백엔드 개발자와 얼마나 긴밀하게 소통하고 있나요?"
이 질문에 대한 답이 결국 DTO를 직접 쓸지, 변환 레이어를 둘지 결정하는 기준이 될 것 같아요.
제가 이 글을 쓰게 된 이유는, 아마 저뿐만 아니라 많은 프론트엔드 개발자분들이 비슷한 고민을 하고 계실 것 같아서입니다. 모든 상황에 완벽한 답은 없지만, 각자의 프로젝트 상황에 맞는 선택을 하는 데 이 글이 조금이라도 도움이 되었으면 좋겠습니다.
TkDodo의 블로그도 꼭 한번 읽어보세요. 정말 좋은 인사이트가 많더라고요.
궁금하신 점이나 다른 의견이 있으시면 언제든 댓글로 남겨주세요!
감사합니다.
이 글을 작성하는 데 참고한 자료들입니다.
React Query & 데이터 변환
모바일 앱 API 버전 관리
커뮤니티 논의
물론 네이티브 앱에서도, 웹에서 페이지를 캐싱하고 있는 경우에도 API 응답의 변경은 치명적입니다. 그래서 응답의 변경이 필요한 경우에는 새로운 엔드포인트를 제공해야 합니다. (물론 개발 단계에서는 좀 더 자유로울 수 있습니다)
부득이하게 같은 엔드포인트에서 응답이 변경된다고 하더라도, 변환 레이어를 통해 문제가 해결되는 것은 아니구요. 작성해주신 것처럼 응답이 변경되었을 때 빠르게 문제를 찾고, 확실하게 대응할 수 있다는 장점이 있을 수는 있습니다. 하지만 이렇게 빠르게 대응한 코드도 결국에는 iOS, Android 앱이 업데이트가 되거나 웹이 새로운 페이지를 받아왔을 때 적용됩니다. 따라서 사용자의 버전이 파편화 되어있는 것에 대한 해결책으로 작용하지는 않을 것 같습니다..!