본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

Adapter: 호환되지 않는 인터페이스를 감싸 기존 코드와 연결하는 구조 패턴

어댑터(Adapter) 패턴은 클라이언트가 기대하는 인터페이스와 실제 제공되는 클래스의 인터페이스가 다를 때, 중간에서 변환을 담당하는 래퍼(wrapper)를 두는 구조 패턴이다. 기존 코드를 수정하지 않고 외부 라이브러리나 레거시 시스템을 통합할 수 있게 해준다. 객체 어댑터(composition)와 클래스 어댑터(상속)의 차이, 그리고 결제 SDK 연동

By 송민성

1. 개념

어댑터(Adapter) 패턴은 한 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환하는 구조 패턴(structural pattern)이다. GoF(Gang of Four) 디자인 패턴 책에서는 "호환되지 않는 인터페이스 때문에 함께 동작하지 못하는 클래스들이 함께 동작하도록 한다"고 정의한다.

구성 요소는 네 가지다.

역할설명
타깃(Target)클라이언트가 사용하려는 인터페이스
클라이언트(Client)타깃 인터페이스만 알고 호출하는 쪽
어댑티(Adaptee)기능은 있지만 인터페이스가 맞지 않는 기존 클래스
어댑터(Adapter)타깃을 구현하면서 내부에서 어댑티를 호출해 변환하는 클래스

현실의 비유는 해외여행용 전원 플러그 변환기다. 콘센트(어댑티)의 모양을 바꿔 줄 뿐, 전기 자체를 새로 만들지는 않는다.

2. 왜 사용하는가

  • 기존 코드 보존: 외부 라이브러리나 레거시 코드는 직접 수정할 수 없거나 수정하면 안 되는 경우가 많다. 어댑터는 변환 로직을 별도 클래스에 격리한다.
  • 개방-폐쇄 원칙(OCP): 클라이언트 코드를 바꾸지 않고 새로운 구현체를 추가할 수 있다.
  • 의존성 역전(DIP): 클라이언트가 구체적인 외부 SDK가 아니라 우리가 정의한 인터페이스에 의존하게 된다.
  • 교체 용이성: 결제사, 로깅 서비스, 스토리지 등 공급자를 바꿀 때 어댑터만 새로 작성하면 된다.
  • 단일 책임 원칙(SRP): 데이터 형식 변환과 메서드 이름 매핑 같은 일을 비즈니스 로직에서 분리한다.

3. 동작 원리

객체 어댑터(Object Adapter)

합성(composition)을 사용한다. 어댑터가 어댑티의 인스턴스를 필드로 가지고, 타깃 인터페이스의 메서드가 호출되면 어댑티의 메서드로 위임(delegation)한다.

text
Client → Target(인터페이스) ← Adapter ──has-a──> Adaptee

클래스 어댑터(Class Adapter)

다중 상속(multiple inheritance)을 지원하는 언어에서 어댑터가 타깃과 어댑티를 모두 상속하는 방식이다. C++ 등에서 가능하며, TypeScript나 Java처럼 클래스 단일 상속만 허용하는 언어에서는 일반적으로 객체 어댑터를 쓴다.

변환 과정

  1. 클라이언트가 타깃 인터페이스의 메서드를 호출한다.
  2. 어댑터가 인자를 어댑티가 요구하는 형식으로 변환한다. (단위, 필드명, 타입 등)
  3. 어댑터가 어댑티의 메서드를 호출한다.
  4. 어댑티의 결과를 타깃이 약속한 형식으로 되돌려 반환한다.

4. 코드 예제

우리 서비스는 PaymentGateway 인터페이스로 결제를 처리한다. 외부 결제 SDK는 금액을 센트 단위 정수로 받고, 결과를 다른 필드명으로 돌려준다고 가정한다.

typescript
// 타깃: 우리 서비스가 기대하는 인터페이스 interface PaymentResult { success: boolean; transactionId: string; } interface PaymentGateway { pay(amountWon: number, orderId: string): Promise<PaymentResult>; } // 어댑티: 수정할 수 없는 외부 SDK (가정) class ThirdPartyPaySdk { async createCharge(params: { amountInCents: number; reference: string; }): Promise<{ status: "ok" | "failed"; chargeId: string }> { return { status: "ok", chargeId: `ch_${params.reference}` }; } } // 어댑터: 타깃을 구현하고 내부에서 어댑티에 위임 class ThirdPartyPayAdapter implements PaymentGateway { constructor( private readonly sdk: ThirdPartyPaySdk, private readonly wonPerUnit: number = 1300, // 예시용 고정 환율 ) {} async pay(amountWon: number, orderId: string): Promise<PaymentResult> { const amountInCents = Math.round((amountWon / this.wonPerUnit) * 100); const res = await this.sdk.createCharge({ amountInCents, reference: orderId, }); return { success: res.status === "ok", transactionId: res.chargeId, }; } } // 클라이언트: SDK의 존재를 모른다 async function checkout(gateway: PaymentGateway, orderId: string) { const result = await gateway.pay(26000, orderId); console.log(result); } checkout(new ThirdPartyPayAdapter(new ThirdPartyPaySdk()), "order-1001"); // { success: true, transactionId: 'ch_order-1001' }

다른 결제사로 바꾸려면 PaymentGateway를 구현하는 새 어댑터만 추가하면 되고, checkout은 수정하지 않는다.

테스트에서는 어댑터 자리에 가짜 구현을 넣을 수 있다.

typescript
class FakeGateway implements PaymentGateway { async pay(_amountWon: number, orderId: string): Promise<PaymentResult> { return { success: true, transactionId: `fake_${orderId}` }; } } checkout(new FakeGateway(), "test-1");

5. 시간 복잡도 또는 성능 특성

어댑터는 알고리즘이 아니라 구조 패턴이므로 고유한 시간복잡도는 없다. 성능 특성은 다음과 같다.

  • 호출 한 번당 간접 호출(indirection) 한 단계와 변환 작업이 추가된다. 변환이 필드 매핑 수준이면 비용은 상수 시간 O(1)이고, 네트워크 I/O 같은 어댑티의 실제 작업 비용에 비해 보통 무시할 만하다.
  • 컬렉션 전체를 변환하는 어댑터(예: 배열 N개를 다른 형식으로 매핑)는 O(N)이다.
  • 변환 결과를 지연 평가(lazy)하는 뷰 형태로 만들면 복사 비용을 피할 수 있지만, 접근할 때마다 변환 비용이 든다.

6. 실무 사용 사례

  • 외부 서비스 연동: 결제, 메일 발송, 지도, 인증(OAuth 제공자) 등 공급자별 SDK를 공통 인터페이스로 통일한다.
  • 레거시 시스템 통합: 오래된 SOAP/XML API를 새 시스템의 JSON 기반 도메인 모델로 변환한다.
  • DB/ORM 계층: 저장소(Repository) 인터페이스 뒤에 특정 DB 드라이버를 어댑터로 두어 교체 가능하게 한다.
  • 육각형 아키텍처(Hexagonal Architecture): 포트(port)와 어댑터(adapter) 구조에서 외부 세계와의 접점이 어댑터다.
  • 프레임워크 내 사례: Java의 Arrays.asList(배열을 List로 보이게 함), InputStreamReader(바이트 스트림을 문자 스트림으로 변환)가 어댑터의 대표 예로 자주 거론된다. Node.js 생태계에서는 Express 미들웨어를 다른 서버 프레임워크 형식에 맞추는 래퍼도 같은 발상이다.

7. 주의할 점

  • 과도한 적용: 인터페이스를 직접 수정할 수 있는 내부 코드에는 어댑터보다 인터페이스 자체를 고치는 편이 단순하다.
  • 누수되는 추상화: 어댑티의 예외 타입이나 특수 필드가 타깃 인터페이스 밖으로 새면 어댑터의 의미가 사라진다. 에러도 우리 도메인의 에러로 변환해야 한다.
  • 의미 차이 무시: 메서드 이름과 타입만 맞춘다고 해서 동작이 같아지지는 않는다. 트랜잭션, 멱등성(idempotency), 재시도 정책, 단위(통화·시간) 같은 의미 차이를 어댑터에서 명시적으로 처리해야 한다.
  • 어댑터 비대화: 변환 외에 비즈니스 로직이 어댑터에 쌓이면 책임이 흐려진다.
  • 유사 패턴과의 구분:

- 데코레이터(Decorator)는 같은 인터페이스를 유지하며 기능을 추가한다. - 프록시(Proxy)는 같은 인터페이스로 접근을 제어한다. - 퍼사드(Facade)는 복잡한 서브시스템에 단순한 새 인터페이스를 제공한다. - 어댑터는 기존 인터페이스를 다른 인터페이스로 바꾸는 것이 목적이다.

8. 핵심 정리

  • 어댑터는 타깃 인터페이스를 구현하고 내부에서 어댑티에 위임해 인터페이스 불일치를 해소한다.
  • TypeScript, Java 같은 단일 상속 언어에서는 합성 기반의 객체 어댑터가 기본 선택이다.
  • 클라이언트는 타깃 인터페이스에만 의존하므로 외부 SDK 교체와 테스트용 대역(fake) 주입이 쉬워진다.
  • 변환 비용은 대개 O(1)이며, 실제 비용은 어댑티의 작업이 좌우한다.
  • 예외, 단위, 멱등성 같은 의미 차이까지 어댑터 안에서 처리하고, 어댑티의 세부 사항이 바깥으로 새지 않게 해야 한다.