React에서 BE API 변경에 강한 구조 설계하기 (feat. Adapter Pattern)
Front-end 개발을 하다 보면 필연적으로 마주치는 상황이 있습니다. 바로 DB 데이터와의 연동입니다. 대부분의 프로젝트는 이 과정을 BE REST API에 의존하고 있습니다.
BE API가 완성되지 못하면 FE 프로젝트도 완성되지 못하고, 인터페이스가 변경되면 FE 프로젝트도 변경되어야 합니다. 여러분들은 이 의존성을 잘 관리하고 계신가요?
이 글에서는 BE API 의존성을 관리하면서 겪었던 시행착오와 함께, 현재 저희가 나아가고 있는 방향을 소개해드리려고 합니다.
평범했던 개발 과정
1. View 컴포넌트의 생성
저희 회사는 보통 BE와 FE가 비슷한 시기에 개발을 시작합니다. 그래서 먼저 완성되어 있는 기획과 디자인을 확인하고 UI 컴포넌트를 먼저 구현하는 편입니다.
이때 View 컴포넌트는 순수하게 UI만을 담당하도록 prop으로 화면을 렌더링하는 순수한 컴포넌트로 제작합니다. 아직 BE API 스펙이 정해지기 전이기 때문에 prop명은 FE가 자유롭게 설정합니다.
// components/UserCard.tsx
// API 스펙 미확정 - FE가 임의로 prop 타입 생성
interface UserCardProps {
user_name: string;
profile_image: string;
}
const UserCard = ({ user_name, profile_image }: UserCardProps) => {
return (
<div className="user-card">
<img src={profile_image} alt={user_name} />
<span>{user_name}</span>
</div>
);
};
그런데 View 컴포넌트 제작을 하다 보니 일부 API 스펙이 나오기 시작했습니다. API 스펙이 나온 컴포넌트들은 API 스펙에 맞춰 prop명을 정하고 컴포넌트 작성을 완료합니다.
// components/UserProfile.tsx
// API 스펙 확정 - API 필드명을 그대로 prop으로 사용
interface UserProfileProps {
user_id: number;
full_name: string;
avatar_url: string | null;
created_at: string;
}
const UserProfile = ({
user_id,
full_name,
avatar_url,
created_at,
}: UserProfileProps) => {
return (
<div className="user-profile">
<img src={avatar_url ?? "/default-avatar.png"} alt={full_name} />
<h2>{full_name}</h2>
<span>가입일: {new Date(created_at).toLocaleDateString()}</span>
</div>
);
};
이 시점에 컴포넌트는 세 가지 부류로 나뉘어졌습니다.
- API 스펙 미확정 + FE가 임의로 생성한 prop 타입
- API 스펙 확정 + FE가 임의로 생성한 prop 타입 (이미 View를 만든 뒤 스펙 확정)
- API 스펙 확정 + API 스펙을 prop 타입으로 직접 사용
2. Mock API 생성
View 컴포넌트 작성이 모두 끝나고 API 개발도 끝나있으면 좋겠지만, 보통 BE도 한창 작업 중이라 API 연결이 어려운 상황이 많습니다. 그래서 FE는 MSW를 사용해 Mock 데이터를 만들기 시작합니다.
// mocks/handlers/user.ts
import { http, HttpResponse } from "msw";
// API 스펙이 확정된 엔드포인트
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
user_id: Number(params.id),
full_name: "홍길동",
avatar_url: "https://example.com/avatar.jpg",
created_at: "2024-01-15T09:00:00Z",
});
});
// API 스펙이 미확정된 엔드포인트 - FE가 예상해서 작성
http.get("/api/users/:id/stats", ({ params }) => {
return HttpResponse.json({
user_id: Number(params.id),
post_count: 42,
follower_count: 1200,
});
});
이 시점에 MSW 핸들러도 마찬가지로 세 가지 부류로 나뉘어졌습니다.
- API 스펙 미확정 + FE가 임의로 정한 타입 사용
- API 스펙 확정 + FE가 임의로 정한 타입 사용
- API 스펙 확정 + API 타입 사용
3. 화면 구성
Mock API까지 생성하고 Container 컴포넌트와 화면을 구성하려고 보니, View 컴포넌트 3종 + Mock API 3종이 생겼습니다. 그래도 타입 선언은 잘 되어있기 때문에 Container를 구성하는 데에는 큰 문제가 없습니다.
// containers/UserContainer.tsx
const UserContainer = ({ userId }: { userId: number }) => {
const { data: user } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
const { data: stats } = useQuery({
queryKey: ["userStats", userId],
queryFn: () => fetchUserStats(userId),
});
return (
<>
{/* API 스펙 기반 컴포넌트 - 필드명 일치 */}
<UserProfile
user_id={user.user_id}
full_name={user.full_name}
avatar_url={user.avatar_url}
created_at={user.created_at}
/>
{/* FE 임의 타입 컴포넌트 - 필드명 변환 필요 */}
<UserCard
user_name={user.full_name}
profile_image={user.avatar_url ?? "/default.png"}
/>
{/* Mock과 실제 스펙이 다른 컴포넌트 - 일단 Mock 기준으로 */}
<UserStats
post_count={stats.post_count}
follower_count={stats.follower_count}
/>
</>
);
};
열심히 작업을 하다 보니 드디어 API 스펙이 완성되었다는 소식이 들려옵니다. 드디어 실제 API 스펙만 반영해주면 개발이 끝이 날 것 같습니다.
4. 대혼돈의 시작
API 스펙을 반영하려고 보니 코드가 뒤죽박죽입니다.
MSW의 타입이 하나도 맞지 않아 전부 수정해야 합니다. View 컴포넌트도 API 스펙이 반영되지 않은 것들이 많아서 처음부터 다시 변경합니다. 어떤 건 string으로 가정하고 컴포넌트를 만들었는데 실제로는 number 타입입니다. 날짜 포맷도 예상과 다릅니다.
// 수정해야 할 것들의 목록
// 1. UserCard - user_name → full_name, profile_image → avatar_url
// 2. UserStats - post_count → total_posts, follower_count → followers
// 3. UserContainer - 데이터 매핑 로직 변경
// 4. MSW handlers - 반환 타입 변경
// 5. Storybook stories - mock 데이터 변경
// 6. 테스트 코드 - mock 데이터 변경
// ... 끝이 없다
분명 API 스펙만 추가되었을 뿐인데, 프로젝트를 End-to-End까지 모두 수정해야 하는 대공사가 발생했습니다. 어쩌다 이런 일이 발생했을까요? 제가 생각하기에는 코드 응집도를 고려하지 못했기 때문입니다.
현실에서 보는 응집도
전기 콘센트 어댑터
해외여행을 가본 적이 있으신가요? 한국에서 쓰던 전자제품을 유럽에서 사용하려면 어댑터가 필요합니다. 콘센트 모양이 다르기 때문이죠.
만약 어댑터 없이 직접 연결하려고 한다면 어떻게 될까요? 제품의 플러그 자체를 뜯어고쳐야 합니다. 유럽에서 돌아와 한국에서 다시 사용하려면? 다시 뜯어고쳐야 합니다. 반면 어댑터를 사용한다면 콘센트가 어떤 모양이든 어댑터만 교체하면 됩니다. 제품 자체는 전혀 손댈 필요가 없죠.
앞서 우리가 겪은 "대혼돈"은 바로 어댑터 없이 플러그를 직접 뜯어고치려 했기 때문입니다.
| 비유 | 실제 코드 |
|---|---|
| 전자제품 | UI 컴포넌트 |
| 콘센트 | BE API |
| 어댑터 | ??? |
그렇다면 우리 코드에서 "어댑터" 역할을 하는 것은 무엇일까요? 저희는 이 "어댑터"로 ViewModel을 채택했습니다.

ViewModel 도입하기
Step 1. UI 컴포넌트의 독립 선언
어댑터 패턴의 첫 번째 단계는 전자제품이 특정 콘센트에 의존하지 않게 만드는 것입니다.
BE API 스펙에 딱 맞는 prop을 가진 컴포넌트는 그 API에서만 쓸 수 있습니다. UI 컴포넌트는 자신이 화면에 무엇을 보여줄지만 알면 됩니다. 데이터가 어디서 왔는지, BE가 어떤 필드명을 쓰는지 알 필요가 없습니다.
// UI 관점에서 독립적으로 정의된 컴포넌트 타입
interface UserProfileProps {
id: number;
displayName: string;
avatar: string;
registeredAt: Date;
}
interface UserCardProps {
displayName: string;
avatar: string;
}
const UserProfile = ({
displayName,
avatar,
registeredAt,
}: UserProfileProps) => {
return (
<div className="user-profile">
<img src={avatar} alt={displayName} />
<h2>{displayName}</h2>
<span>가입일: {registeredAt.toLocaleDateString()}</span>
</div>
);
};
이제 UI 컴포넌트는 BE API가 full_name을 쓰든 user_name을 쓰든 상관없이 동작합니다. 전자제품이 표준 플러그를 갖게 된 것입니다.
Step 2. API 타입 정의
다음은 콘센트의 규격을 명확히 정의하는 단계입니다. 실제 콘센트(Real API)와 테스트용 콘센트(Mock API)가 있을 수 있습니다.
// types/api/common.ts
// Mock 타입을 구분하기 위한 유틸리티
type Mock<T> = T & { __mock: true };
export const isMock = <T extends object>(
data: T
): data is Extract<T, { __mock: true }> => {
return "__mock" in data;
};
// types/api/user.ts
// Mock 환경에서 사용할 타입 (FE가 정의)
interface MockApiUser {
user_id: number;
user_name: string;
profile_image: string | null;
created_at: string;
}
// Real 환경에서 사용할 타입 (BE 스펙)
interface RealApiUser {
user_id: number;
full_name: string;
avatar_url: string | null;
created_at: string;
}
// 통합 타입
type ApiUser = Mock<MockApiUser> | RealApiUser;
// Mock만 나온 경우
type ApiUser = Mock<MockApiUser>;
이제 Mock 콘센트와 Real 콘센트의 규격이 명확해졌습니다.
Step 3. 어댑터(ViewModel) 생성
이제 어댑터를 만들 차례입니다.
어댑터는 어떤 콘센트가 들어오든(Mock이든 Real이든) 전자제품이 이해할 수 있는 형태로 변환해줍니다.
class UserViewModel {
// 도메인 상수를 내부에 응집
private static readonly DEFAULT_AVATAR = "/images/default-avatar.png";
// 정규화된 내부 상태
private id: number;
private name: string;
private avatar: string | null;
private createdAt: Date;
constructor(data: ApiUser) {
if (isMock(data)) {
this.id = data.user_id;
this.name = data.user_name;
this.avatar = data.profile_image;
this.createdAt = new Date(data.created_at);
} else {
this.id = data.user_id;
this.name = data.full_name;
this.avatar = data.avatar_url;
this.createdAt = new Date(data.created_at);
}
}
// 각 UI 컴포넌트에 맞는 형태로 변환
toProfile(): UserProfileProps {
return {
id: this.id,
displayName: this.name,
avatar: this.avatar ?? UserViewModel.DEFAULT_AVATAR,
registeredAt: this.createdAt,
};
}
toCard(): UserCardProps {
return {
displayName: this.name,
avatar: this.avatar ?? UserViewModel.DEFAULT_AVATAR,
};
}
}
하나의 API 응답에서 여러 UI 타입을 파생할 수 있게 되었습니다. toProfile(), toCard() 등 필요한 만큼 변환 메소드를 추가하면 됩니다.
ViewModel이 반드시 Class일 필요는 없습니다. namespace 파일을 만들고 그 내부에 함수를 응집시키거나 객체의 메소드로 구현해도 괜찮습니다. 다만 저희는 constructor를 통해 정규화를 한 번만 실행하고, private과 static 키워드를 활용해 내부 로직을 잘 응집시킬 수 있다는 판단 하에 Class를 사용했습니다.
Step 4. 연결하기
마지막으로 어댑터를 실제로 연결합니다. React Query의 select 옵션을 활용하면 데이터를 가져오는 시점에 ViewModel로 변환할 수 있습니다.
// hooks/useUser.ts
export const useUser = (userId: number) => {
return useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
select: (data) => new UserViewModel(data),
});
};
// containers/UserContainer.tsx
const UserContainer = ({ userId }: { userId: number }) => {
const { data: userVM } = useUser(userId);
if (!userVM) return <Loading />;
return (
<>
<UserProfile {...userVM.toProfile()} />
<UserCard {...userVM.toCard()} />
</>
);
};
이제 BE API가 변경되어도 ViewModel의 constructor만 수정하면 됩니다. UI 컴포넌트, Container, Storybook, 테스트 코드는 전혀 손댈 필요가 없습니다.
변경에 강한 구조
최종적으로 우리의 코드는 다음과 같은 구조를 갖게 되었습니다.
콘센트(BE API) → 어댑터(ViewModel) → 전자제품(UI 컴포넌트)
↓ ↓ ↓
변경 잦음 *변경 흡수* 변경 없음
| 변경 원인 | 수정 범위 |
|---|---|
| BE API 필드명 변경 | ViewModel constructor |
| 새로운 UI 컴포넌트 추가 | ViewModel에 메소드 추가 |
| UI 디자인 변경 | View 컴포넌트만 |
| Mock ↔ Real 전환 | Mock 코드 제거 없이 전환 가능 |
응집도가 높다는 것은 같은 이유로 변경되는 코드가 한 곳에 모여있다는 뜻입니다. ViewModel 패턴을 도입하면 BE 의존성이 ViewModel에 응집되고, UI 관심사는 View 컴포넌트에 응집됩니다.
ViewModel 확장하기
기본적인 ViewModel 구조를 갖추었다면, 실무에서 자주 마주치는 패턴들을 살펴보겠습니다.
1. 공용 로직을 private 메소드로 추출
여러 UI 변환 메소드에서 동일한 로직이 반복된다면, private 메소드로 추출할 수 있습니다.
// viewModels/UserViewModel.ts
class UserViewModel {
private static readonly DEFAULT_AVATAR = "/images/default-avatar.png";
// ... 필드 생략
private getAvatarUrl(): string {
return this.avatar ?? UserViewModel.DEFAULT_AVATAR;
}
private getDisplayName(maxLength?: number): string {
if (maxLength && this.name.length > maxLength) {
return `${this.name.slice(0, maxLength)}...`;
}
return this.name;
}
toProfile(): UserProfileProps {
return {
id: this.id,
displayName: this.getDisplayName(),
avatar: this.getAvatarUrl(),
registeredAt: this.createdAt,
};
}
toCard(): UserCardProps {
return {
displayName: this.getDisplayName(10), // 카드에서는 10자 제한
avatar: this.getAvatarUrl(),
};
}
}
2. static 메소드로 도메인 상수 노출
때로는 ViewModel 외부에서 도메인 상수에 접근해야 할 때가 있습니다. 예를 들어 폼 validation이나 UI 힌트 텍스트에서 사용할 수 있습니다.
// viewModels/OrderViewModel.ts
class OrderViewModel {
private static readonly TAX_RATE = 0.1;
static get MIN_ORDER_AMOUNT(): number {
return 10000;
}
static get MAX_ORDER_AMOUNT(): number {
return 10000000;
}
// ... 나머지 구현
}
// 컴포넌트에서 사용
const OrderForm = () => {
const [amount, setAmount] = useState(0);
const isValid =
amount >= OrderViewModel.MIN_ORDER_AMOUNT &&
amount <= OrderViewModel.MAX_ORDER_AMOUNT;
return (
<form>
<input
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
/>
<p>
최소 주문 금액: {OrderViewModel.MIN_ORDER_AMOUNT.toLocaleString()}원
</p>
<button disabled={!isValid}>주문하기</button>
</form>
);
};
이렇게 하면 도메인 상수가 ViewModel에 응집되면서도, 필요한 곳에서 일관되게 사용할 수 있습니다.
3. 다른 API 응답과 결합하기
화면에서 여러 API 데이터를 조합해야 할 때가 있습니다. 이때는 변환 메소드의 파라미터로 다른 ViewModel을 받을 수 있습니다.
class UserViewModel {
// ... 기존 코드 생략
// 다른 ViewModel과 결합
toProfileWithStats(statsVM: UserStatsViewModel): UserProfileWithStatsProps {
return {
...this.toProfile(),
postCount: statsVM.getPostCount(),
followerCount: statsVM.getFollowerCount(),
};
}
}
// 사용하는 곳
const UserContainer = ({ userId }: { userId: number }) => {
const { data: userVM } = useUser(userId);
const { data: statsVM } = useUserStats(userId);
if (!userVM || !statsVM) return <Loading />;
return <UserProfileWithStats {...userVM.toProfileWithStats(statsVM)} />;
};
결합 로직이 복잡하거나 여러 화면에서 동일한 결합이 반복된다면, 별도의 합성 ViewModel을 만드는 것도 고려해볼 수 있습니다.
class UserProfileWithStatsViewModel {
constructor(
private userVM: UserViewModel,
private statsVM: UserStatsViewModel
) {}
toProps(): UserProfileWithStatsProps {
return {
...this.userVM.toProfile(),
...this.statsVM.toStats(),
};
}
}
React에서 Class ViewModel 도입시 주의할 점
ViewModel을 Class로 구현할 때 반드시 지켜야 할 원칙이 있습니다. Setter를 만들지 마세요.
React는 상태 변경을 감지해야 리렌더링합니다. 그런데 Class 인스턴스 내부의 값을 직접 변경하면 참조는 그대로이기 때문에 React가 이를 감지하지 못합니다.
class UserViewModel {
private readonly name: string; // readonly로 명시
constructor(data: ApiUser) {
this.name = data.full_name;
}
// Setter 없음 - 오직 getter와 변환 메소드만
toProfile(): UserProfileProps {
return { displayName: this.name };
}
}
ViewModel은 오직 API 데이터를 UI 형태로 변환하는 역할만 해야 합니다. 상태 변경은 React의 상태 관리에 맡기세요.
마치며
FE-BE 의존성 관리는 프로젝트 규모가 커질수록 중요해집니다.
처음에는 API 타입을 컴포넌트에 직접 주입하는 게 빠르고 편해 보입니다. 하지만 시간이 지나면서 API 스펙은 변경되고, Mock과 Real 환경을 오가며, 하나의 데이터로 여러 UI를 만들어야 합니다. 그때마다 프로젝트 전체를 뒤흔드는 대공사를 치르고 싶지 않다면, ViewModel 어댑터 레이어를 고려해보세요.
전기 어댑터가 콘센트와 제품 사이에서 변환을 담당하듯, ViewModel이 API와 UI 사이에서 변환을 담당합니다. 콘센트가 바뀌어도 제품을 뜯어고칠 필요 없듯, API가 바뀌어도 UI 컴포넌트를 수정할 필요가 없어집니다.