16. Adapter — 배민, 쿠팡이츠, 요기요, 셋 다 API 형식이 다르다

작성일:2026.05.21|수정일:2026.08.04|조회수:0

16. Adapter — 배민, 쿠팡이츠, 요기요, 셋 다 API 형식이 다르다

에이든 피자가 입소문을 타면서, 드디어 배달 앱들과 연동할 기회가 찾아왔다. 배달의민족, 쿠팡이츠, 요기요... 이름만 들어도 든든한 파트너들이지만, 막상 API 문서를 열어본 순간 나는 깊은 한숨을 내쉴 수밖에 없었다. 세상에 만만한 일은 없다더니, 세 앱이 보내주는 주문 데이터 형식이 약속이라도 한 듯 전부 달랐기 때문이다.

배민은 orderNomenuItems라는 이름을 쓰고, 쿠팡이츠는 iditems를 선호하며, 요기요는 order_idproducts라는 독자적인 길을 걷고 있었다. 우리 매장의 세련된 POS 시스템은 DeliveryOrder라는 하나의 표준화된 인터페이스로만 대화하고 싶은데, 외부에서 들어오는 데이터들은 각자 자기만의 언어로 떠들고 있는 셈이었다.

아래 배달 앱 이름과 필드 구조는 Adapter 패턴을 설명하기 위해 단순화한 가상 스키마다. 실제 배달 앱의 API 계약이나 응답 형식을 나타내지 않는다.

TS
// 우리 시스템이 기대하는 모양interface DeliveryOrder {  orderId: string;  items: OrderItem[];  address: string;}// 하지만 실제로 들이닥치는 것들{ orderNo: 'BM-001', menuItems: [...], userAddress: '...' } // 배민{ id: 'CE-001', items: [...], deliveryInfo: { address: '...' } } // 쿠팡{ order_id: 'YG-001', products: [...], destination: '...' } // 요기요

무엇이 불편한가

가장 먼저 떠오른 해결책은 주문이 들어오는 곳마다 각 앱에 맞는 파싱 코드를 작성하는 것이었다. "만약 배민이라면 이렇게 읽고, 쿠팡이라면 저렇게 읽어라" 식의 if-else 문이 POS 시스템 여기저기에 침투하기 시작했다. 비즈니스 로직은 순수하게 "주문을 처리하는 법"만 알고 있어야 하는데, 이제는 배민의 API 응답 구조가 어떻게 생겼는지까지 외워야 하는 처지가 된 것이다.

문제는 배달 앱 중 하나가 API 형식을 바꾸는 순간 터진다. 배민이 userAddressaddress로 이름만 바꿔도, 우리 시스템의 수많은 파일이 줄줄이 에러를 뿜어내며 멈춰 설 것이다. 새로운 배달 플랫폼을 하나 추가할 때마다 그 투박한 파싱 로직을 복사해서 붙여넣어야 하는 건 덤이다.

우리는 우리만의 '표준 규격'을 지키고 싶었다. 외부의 무질서한 데이터들을 우리 매장의 질서 정연한 인터페이스로 변환해 줄 장치가 간절했다. 해외여행을 갈 때 110V 플러그와 220V 콘센트 사이에 끼우는 '돼지코' 어댑터처럼 말이다.

Adapter 패턴

Adapter 패턴은 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환하는 패턴이다. GoF의 원래 의도는 다음과 같다.

클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환하여, 호환되지 않는 인터페이스 때문에 함께 동작할 수 없던 클래스들이 협력할 수 있게 한다.

에이든 피자의 상황에 대입해 보자. 우리 POS 시스템은 TargetDeliveryOrder 인터페이스를 요구한다. 외부 배달 앱 API들은 Adaptee에 해당한다. 우리는 이 둘 사이를 연결해 줄 Adapter 클래스들을 만들면 된다. BaeminAdapter는 배민의 데이터를 품고 있으면서, 겉으로는 DeliveryOrder처럼 행동한다.

이렇게 하면 POS 시스템은 자신이 배민 주문을 처리하는지, 쿠팡 주문을 처리하는지 알 필요가 없다. 그저 눈앞에 놓인 DeliveryOrder 객체의 orderIdaddress를 호출할 뿐이다. 데이터의 기원이 어디든 상관없이, 어댑터가 그 뒤에서 묵묵히 데이터를 변환해 주고 있기 때문이다.

TypeScript에서의 어댑터: 상속보다는 합성

GoF의 원전에서는 어댑터를 구현하는 두 가지 방법으로 '클래스 어댑터'와 '객체 어댑터'를 제시한다. 클래스 어댑터는 다중 상속을 이용해 TargetAdaptee를 모두 상속받는 방식인데, TypeScript(및 JavaScript)는 클래스의 다중 상속을 지원하지 않는다.

따라서 TypeScript에서는 '객체 어댑터' 방식이 표준이다. 어댑터가 변환할 대상(Adaptee)을 내부 멤버 변수로 들고 있는(합성) 방식이다. 이 방식은 유연할 뿐만 아니라, 어댑터 하나가 해당 인터페이스를 따르는 여러 하위 클래스까지 한꺼번에 감쌀 수 있다는 장점이 있다.

TS
// 배민 어댑터 예시class BaeminAdapter implements DeliveryOrder {  constructor(private raw: BaeminRawOrder) {}  get orderId() { return this.raw.orderNo; }  get address() { return this.raw.userAddress; }  // ... items 변환 로직}

이렇게 implements 키워드를 사용하면, 어댑터가 우리 시스템의 표준을 제대로 따르고 있는지 컴파일 타임에 엄격하게 체크할 수 있다. 실수로 orderId를 빠뜨렸다면 TypeScript가 바로 알려줄 것이다.

트레이드오프

Adapter 패턴의 이득은 '코드의 격리'다. 외부 시스템의 지저분한 응답 구조가 우리 시스템 내부로 전염되는 것을 어댑터 계층에서 원천 차단한다. API가 바뀌면 어댑터 클래스 하나만 수정하면 그만이다. 비즈니스 로직은 외부의 풍파로부터 안전하게 보호받는다.

하지만 모든 처방전에는 부작용이 있듯, 어댑터가 너무 많아지면 시스템의 전체적인 복잡도가 올라간다. 단순한 변환이라면 그냥 함수 하나로 처리하는 게 나을 수도 있다. 또한, 외부 시스템과 우리 시스템의 데이터 의미 자체가 너무 다를 때는 어댑터만으로 해결되지 않는다. 어댑터는 인터페이스를 맞추는 도구이지, 데이터의 본질적인 결함까지 고쳐주는 도구는 아니기 때문이다.

곧 다가올 결제 시스템 연동에서도 비슷한 고민이 시작될 것이다. 하지만 결제는 단순히 인터페이스를 맞추는 것 이상의 무언가가 필요해 보인다. 진짜 결제를 요청하기 전에 잔액은 충분한지, 로그는 남겼는지, 네트워크 에러 시 재시도는 하는지... 이런 '대리인'의 역할은 어댑터와는 또 다른 패턴의 영역이다.

전체 코드

TS
/** * 에이든 피자 표준 인터페이스 (Target) */interface OrderItem {  name: string;  price: number;  quantity: number;}interface DeliveryOrder {  orderId: string;  items: OrderItem[];  address: string;}/** * 외부 시스템 1: 배달의민족 (Adaptee 1) */interface BaeminRawOrder {  orderNo: string;  menuItems: Array<{ menuName: string; cost: number; count: number }>;  userAddress: string;}/** * 외부 시스템 2: 쿠팡이츠 (Adaptee 2) */interface CoupangEatsRawOrder {  id: string;  items: Array<{ name: string; unitPrice: number; qty: number }>;  deliveryInfo: {    address: string;  };}/** * 외부 시스템 3: 요기요 (Adaptee 3) */interface YogiyoRawOrder {  order_id: string;  products: Array<{ title: string; amount: number; num: number }>;  destination: string;}/** * Baemin Adapter * 배민의 raw 데이터를 DeliveryOrder 인터페이스로 변환한다. */class BaeminAdapter implements DeliveryOrder {  constructor(private raw: BaeminRawOrder) {}  get orderId(): string {    return this.raw.orderNo;  }  get items(): OrderItem[] {    return this.raw.menuItems.map(item => ({      name: item.menuName,      price: item.cost,      quantity: item.count,    }));  }  get address(): string {    return this.raw.userAddress;  }}/** * CoupangEats Adapter */class CoupangEatsAdapter implements DeliveryOrder {  constructor(private raw: CoupangEatsRawOrder) {}  get orderId(): string {    return this.raw.id;  }  get items(): OrderItem[] {    return this.raw.items.map(item => ({      name: item.name,      price: item.unitPrice,      quantity: item.qty,    }));  }  get address(): string {    return this.raw.deliveryInfo.address;  }}/** * Yogiyo Adapter */class YogiyoAdapter implements DeliveryOrder {  constructor(private raw: YogiyoRawOrder) {}  get orderId(): string {    return this.raw.order_id;  }  get items(): OrderItem[] {    return this.raw.products.map(item => ({      name: item.title,      price: item.amount,      quantity: item.num,    }));  }  get address(): string {    return this.raw.destination;  }}type DeliveryAdapterRequest =  | { platform: 'baemin'; raw: BaeminRawOrder }  | { platform: 'coupang'; raw: CoupangEatsRawOrder }  | { platform: 'yogiyo'; raw: YogiyoRawOrder };/** * Adapter Factory * 플랫폼에 따라 적절한 어댑터를 생성해주는 공장 */class DeliveryAdapterFactory {  static create(request: DeliveryAdapterRequest): DeliveryOrder {    switch (request.platform) {      case 'baemin':        return new BaeminAdapter(request.raw);      case 'coupang':        return new CoupangEatsAdapter(request.raw);      case 'yogiyo':        return new YogiyoAdapter(request.raw);    }  }}// 매장 운영 시뮬레이션const rawBaeminData: BaeminRawOrder = {  orderNo: 'BM-12345',  menuItems: [{ menuName: '슈퍼 슈프림 피자', cost: 25000, count: 1 }],  userAddress: '서울시 강남구 역삼동 123-45'};// POS 시스템은 어댑터를 통해 외부 데이터를 표준화된 방식으로 처리한다.const order: DeliveryOrder = DeliveryAdapterFactory.create({  platform: 'baemin',  raw: rawBaeminData,});console.log(`[${order.orderId}] 주문 접수!`);console.log(`배달지: ${order.address}`);order.items.forEach(item => {  console.log(`- ${item.name} (${item.quantity}개)`);});

배달 앱들과의 연동은 어댑터 덕분에 평화롭게 마무리되었다. 이제 외부 시스템이 아무리 제멋대로인 데이터를 던져줘도 우리 매장은 덜 흔들린다. 하지만 배달 주문을 받았으니 이제 '돈'을 받아야 할 차례다. 결제 시스템은 배달 앱보다 훨씬 더 예민하고 까다롭다. 단순히 형식을 맞추는 것을 넘어, 진짜 결제를 진행하기 전에 꼼꼼하게 따져봐야 할 것들이 산더미처럼 쌓여있다.

댓글

댓글을 불러오는 중...