어댑터 패턴이란 무엇이고 프론트엔드에서 어떻게 적용할까
프론트엔드 코드를 작성하다 보면 이런 순간이 자주 옵니다.
- 백엔드 응답 구조가 우리 UI가 기대하는 형태와 다를 때
- 외부 SDK가 제공하는 API 이름이 팀 코드 스타일과 잘 맞지 않을 때
- 레거시 모듈을 새 코드에 붙여야 하는데 인터페이스가 어색할 때
- 비슷한 역할의 데이터인데 서비스마다 필드 이름이 제각각일 때
이럴 때 흔히 두 가지 실수를 하게 됩니다.
- UI 컴포넌트 안에서 응답 구조를 직접 변환한다
- 사용하는 곳마다 제각각 매핑 로직을 복사한다
처음에는 빨라 보입니다. 하지만 시간이 지나면 변환 로직이 여기저기 흩어지고, 어떤 계층이 어떤 형태를 기대하는지도 흐릿해지기 쉽습니다.
이 문제를 정리할 때 자주 떠올리는 패턴이 Adapter Pattern입니다.
이 글에서는 어댑터 패턴을 아래 흐름으로 정리해보겠습니다.
- 어댑터 패턴은 무엇인지
- 왜 필요한지
- 어떤 문제를 푸는지
- 프론트엔드에서 어디에 적용하면 좋은지
TypeScript예제로 어떻게 구현할 수 있는지
한눈에 보면
짧게 정리하면 이렇습니다.
- 어댑터 패턴은 기존 인터페이스를 원하는 인터페이스로 바꿔주는 패턴입니다.
- 핵심은 원본 객체를 뜯어고치는 것이 아니라, 중간에서 형태를 맞춰주는 데 있습니다.
- 프론트엔드에서는 API 응답 변환, 외부 SDK 래핑, 레거시 코드 연결에서 특히 자주 쓸 수 있습니다.
- 이 패턴을 쓰면 UI나 도메인 로직이 외부 시스템의 세부 구조에 덜 의존하게 됩니다.
즉, 어댑터 패턴은 새로운 기능을 만드는 패턴이라기보다, 서로 맞지 않는 경계를 부드럽게 연결하는 패턴에 가깝습니다.
어댑터 패턴은 정확히 무엇일까?
가장 단순하게 말하면 어댑터 패턴은 A가 가진 인터페이스를 B가 기대하는 인터페이스로 바꿔주는 중간층입니다.
예를 들어 어떤 UI가 아래 형태를 기대한다고 해보겠습니다.
type UserCardModel = {
id: string;
name: string;
profileImageUrl: string;
isActive: boolean;
};그런데 서버 응답은 아래처럼 올 수 있습니다.
type UserApiResponse = {
user_id: number;
user_name: string;
image_url: string | null;
status: 'ACTIVE' | 'INACTIVE';
};이 둘은 역할은 비슷하지만, 그대로는 잘 맞지 않습니다.
- 필드 이름이 다르고
- 타입이 다르고
- UI가 바로 쓰기엔 의미가 덜 정리돼 있습니다
이럴 때 어댑터는 "원본 응답"을 "UI가 쓰기 좋은 모델"로 변환해줍니다.
즉, 어댑터 패턴은 기존 것을 재사용하되, 사용하는 쪽에서 기대하는 모양으로 번역하는 패턴이라고 보면 됩니다.
왜 굳이 중간층을 둬야 할까?
처음에는 컴포넌트 안에서 한 번만 매핑하면 될 것처럼 보입니다.
예를 들어:
function UserCard({ user }: { user: UserApiResponse }) {
return (
<div>
<img src={user.image_url ?? '/default.png'} alt={user.user_name} />
<strong>{user.user_name}</strong>
<span>{user.status === 'ACTIVE' ? '활성' : '비활성'}</span>
</div>
);
}이 정도는 단순해 보입니다. 하지만 이런 식으로 UI가 외부 응답 구조를 직접 알기 시작하면 문제가 생깁니다.
- API 구조가 바뀔 때 UI 전반을 같이 수정해야 하고
- 같은 변환 로직이 여러 화면에 반복되고
- UI가 "표현"보다 "데이터 정리" 책임까지 떠안게 됩니다
즉, 어댑터가 필요한 이유는 단순히 예쁘게 분리하려는 것이 아니라, 변경의 파급 범위를 줄이기 위해서입니다.
어떤 문제를 풀어주는 패턴일까?
어댑터 패턴이 특히 잘 맞는 문제는 아래와 같습니다.
1. 이름은 같은데 인터페이스가 맞지 않을 때
둘 다 사용자 데이터인데 필드 이름과 타입이 다를 수 있습니다.
2. 외부 시스템의 세부 구조를 내부 코드에 직접 노출하고 싶지 않을 때
SDK나 API 응답은 바깥 시스템의 사정이 반영돼 있습니다. 내부 코드가 그 사정을 그대로 알게 만들면 결합도가 높아집니다.
3. 여러 데이터 소스를 하나의 공통 인터페이스로 맞추고 싶을 때
예를 들어 GitHub 사용자, 사내 사용자, 관리자 사용자 응답이 각각 다르더라도 UI는 하나의 UserCardModel만 보게 만들 수 있습니다.
즉, 어댑터 패턴은 "형태가 안 맞는다"는 문제를 호출부 곳곳의 조건문으로 푸는 대신, 변환 책임을 한 곳에 모아서 푸는 방식입니다.
프론트엔드에서는 어디에 특히 잘 맞을까?
프론트엔드에서 어댑터 패턴은 생각보다 자주 등장합니다.
1. API 응답 -> UI 모델 변환
가장 흔한 경우입니다.
- 서버 응답 필드를 화면 친화적인 형태로 정리
- null 처리, 기본값 처리
- enum 값을 UI 표현에 맞게 변환
2. 외부 SDK 래핑
예를 들어 결제 SDK나 지도 SDK가 복잡한 메서드 이름과 옵션 구조를 제공할 수 있습니다. 이럴 때 팀 내부에서 쓰기 쉬운 API로 한 번 감싸면 호출부가 훨씬 단순해집니다.
3. 레거시 함수 연결
예전 모듈은 callback 기반인데 새 코드는 Promise 기반일 수 있습니다. 이때도 어댑터를 두면 양쪽을 무리 없이 연결할 수 있습니다.
4. 컴포넌트 라이브러리 교체 완충층
UI 라이브러리를 교체할 때 내부 공통 인터페이스를 먼저 만들고, 라이브러리별 구현을 어댑터로 두면 교체 비용을 줄일 수 있습니다.
즉, 프론트엔드에서 어댑터 패턴은 단순 OOP 패턴 설명용이 아니라, 경계 계층을 정리하는 실전 도구에 가깝습니다.
예제 1. API 응답을 화면 모델로 바꾸기
가장 실용적인 예제를 먼저 보겠습니다.
API 응답
type UserApiResponse = {
user_id: number;
user_name: string;
image_url: string | null;
status: 'ACTIVE' | 'INACTIVE';
};화면에서 쓰고 싶은 모델
type UserCardModel = {
id: string;
name: string;
profileImageUrl: string;
isActive: boolean;
};어댑터 함수
function adaptUserToCardModel(user: UserApiResponse): UserCardModel {
return {
id: String(user.user_id),
name: user.user_name,
profileImageUrl: user.image_url ?? '/images/default-profile.png',
isActive: user.status === 'ACTIVE',
};
}사용 예시
async function getUserCardModel(userId: number): Promise<UserCardModel> {
const response = await fetch(`/api/users/${userId}`);
const data: UserApiResponse = await response.json();
return adaptUserToCardModel(data);
}이 구조의 장점은 분명합니다.
- 컴포넌트는
UserCardModel만 알면 됩니다 - API가 snake_case를 쓰는지 몰라도 됩니다
- null 처리나 기본 이미지 정책이 호출부에서 반복되지 않습니다
즉, UI는 "데이터를 어떻게 표현할까"에 집중하고, 어댑터는 "외부 데이터를 내부 모델로 어떻게 번역할까"를 맡습니다.
예제 2. 여러 API 응답을 하나의 인터페이스로 맞추기
어댑터 패턴이 더 빛나는 순간은 데이터 소스가 여러 개일 때입니다.
예를 들어 우리 서비스에는 아래 두 종류의 사용자 응답이 있다고 해보겠습니다.
type InternalUserResponse = {
id: number;
name: string;
avatarUrl: string | null;
active: boolean;
};
type GithubUserResponse = {
id: number;
login: string;
avatar_url: string;
site_admin: boolean;
};UI는 아래 모델 하나만 원합니다.
type UserListItem = {
id: string;
displayName: string;
imageUrl: string;
badge: string;
};그렇다면 각 소스별 어댑터를 둘 수 있습니다.
function adaptInternalUser(user: InternalUserResponse): UserListItem {
return {
id: String(user.id),
displayName: user.name,
imageUrl: user.avatarUrl ?? '/images/default-profile.png',
badge: user.active ? 'Active' : 'Inactive',
};
}
function adaptGithubUser(user: GithubUserResponse): UserListItem {
return {
id: String(user.id),
displayName: user.login,
imageUrl: user.avatar_url,
badge: user.site_admin ? 'Admin' : 'GitHub User',
};
}이제 UI는 "어디서 왔는가"보다 "공통적으로 어떤 모델인가"에만 집중할 수 있습니다.
즉, 어댑터 패턴은 다양한 외부 시스템을 하나의 내부 언어로 통일하는 데 강합니다.
예제 3. 외부 SDK를 팀 인터페이스에 맞추기
이번에는 데이터가 아니라 기능을 감싸는 예제입니다.
외부 결제 SDK가 아래처럼 생겼다고 해보겠습니다.
type ThirdPartyPaymentSdk = {
openPaymentWidget: (options: {
amount: number;
orderId: string;
customerName: string;
}) => Promise<{ transactionId: string }>;
};그런데 우리 팀은 호출부에서 아래처럼 더 단순한 인터페이스를 쓰고 싶을 수 있습니다.
type PaymentService = {
pay: (input: {
price: number;
orderNumber: string;
buyerName: string;
}) => Promise<{ paymentId: string }>;
};이때 어댑터를 만들면:
function createPaymentServiceAdapter(sdk: ThirdPartyPaymentSdk): PaymentService {
return {
async pay(input) {
const result = await sdk.openPaymentWidget({
amount: input.price,
orderId: input.orderNumber,
customerName: input.buyerName,
});
return {
paymentId: result.transactionId,
};
},
};
}이제 호출부는 SDK 세부 구조를 직접 몰라도 됩니다.
const paymentService = createPaymentServiceAdapter(paymentSdk);
await paymentService.pay({
price: 39000,
orderNumber: 'ORDER-20260408',
buyerName: 'Marco',
});이 구조가 좋은 이유는 외부 SDK 교체 시에도 호출부가 흔들리지 않기 때문입니다. 바뀌는 것은 주로 어댑터 내부입니다.
어댑터 패턴과 Mapper는 같은 걸까?
실무에서는 adapter, mapper, transformer, serializer 같은 이름이 섞여 쓰입니다.
완전히 엄격하게 구분하지 않는 팀도 많습니다. 다만 감각적으로는 아래처럼 이해하면 좋습니다.
adapter: 맞지 않는 인터페이스를 연결하는 데 초점mapper: 데이터 필드 변환 자체에 초점serializer: 특정 포맷으로 직렬화하는 데 초점
즉, API 응답을 UI 모델로 바꾸는 함수는 mapper처럼 보일 수도 있지만, 더 넓게 보면 외부 인터페이스를 내부 인터페이스에 맞추는 어댑터 역할을 한다고 볼 수 있습니다.
이름보다 중요한 것은 책임입니다. "어디서 변환 책임을 가질 것인가"가 먼저입니다.
언제 과할 수 있을까?
어댑터 패턴이 항상 필요한 것은 아닙니다.
아래처럼:
- 필드 한두 개만 그대로 전달하면 되는 경우
- 외부 응답 구조가 거의 변하지 않고 호출부도 하나뿐인 경우
- 중간 계층이 오히려 파일 수만 늘리고 의미 있는 분리를 만들지 못하는 경우
에는 과한 추상화가 될 수 있습니다.
즉, 어댑터 패턴은 "무조건 예쁘게 쪼개기"가 아니라, 외부 변화와 내부 사용 패턴 사이에 실제 마찰이 있을 때 쓰는 편이 좋습니다.
실무 체크리스트
실제로 적용할 때는 아래 질문이 도움이 됩니다.
- UI나 도메인 로직이 외부 API 필드 이름을 직접 알고 있는가?
- 같은 변환 로직이 여러 컴포넌트에 반복되는가?
- 외부 SDK나 응답 구조가 바뀌면 수정 범위가 넓은가?
- 여러 소스를 하나의 공통 모델로 통일할 필요가 있는가?
- 중간 계층을 두었을 때 오히려 책임이 더 선명해지는가?
이 질문에 여러 개가 Yes라면 어댑터 패턴을 고려할 가치가 높습니다.
같이 보면 좋은 글
- 웹 렌더링 방식 (2): 비교표, 하이브리드 전략, 실무 가이드
- Compound Component 패턴은 무엇이고 React에서 어떻게 적용할까
- Render Props vs Custom Hooks vs HOC, 언제 무엇을 선택할까
- JavaScript의 비동기 동작은 어떻게 이해해야 할까
결론
어댑터 패턴은 거창한 객체지향 이론이라기보다, 서로 맞지 않는 인터페이스를 현재 코드베이스가 쓰기 좋은 형태로 번역해주는 패턴입니다.
짧게 정리하면:
- 외부 API나 SDK를 내부 코드에 바로 노출하지 않고
- 중간 어댑터가 형태와 의미를 정리해주며
- 그 결과 UI와 비즈니스 로직은 더 안정적인 인터페이스 위에서 동작할 수 있습니다
결국 어댑터 패턴의 핵심은 추상화 그 자체가 아니라, 변화가 자주 일어나는 경계에서 수정 비용을 줄이고 책임을 선명하게 만드는 것에 있습니다.
