🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Adapter Pattern 실전 프로젝트 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 18 Views

Adapter Pattern 실전 프로젝트 가이드

기존 코드를 수정하지 않고 호환되지 않는 인터페이스를 연결하는 Adapter Pattern의 실무 활용법을 다룹니다. 레거시 시스템 통합부터 서드파티 라이브러리 연동까지, 실전 예제와 함께 배워보세요.


목차

  1. Adapter Pattern 기본 개념
  2. Object Adapter 구현
  3. Class Adapter 구현
  4. 레거시 API 통합
  5. 서드파티 라이브러리 래핑
  6. 데이터 포맷 변환
  7. 다중 어댑터 체이닝
  8. 테스트 가능한 어댑터 설계

1. Adapter Pattern 기본 개념

시작하며

여러분이 새로운 결제 시스템을 도입하려는데, 기존 코드가 완전히 다른 인터페이스로 작성되어 있는 상황을 겪어본 적 있나요? 전체 코드를 수정하자니 시간도 부족하고 위험 부담도 크고, 그렇다고 새 시스템을 포기하자니 아쉽습니다.

이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 레거시 시스템과 신규 시스템의 통합, 서드파티 라이브러리의 인터페이스 불일치, 외부 API의 변경 등 인터페이스 호환성 문제는 개발자들의 영원한 숙제입니다.

무작정 코드를 수정하면 기존 시스템이 깨질 위험이 있고, 수정 범위가 방대해져 유지보수가 어려워집니다. 바로 이럴 때 필요한 것이 Adapter Pattern입니다.

마치 해외 여행 시 사용하는 전원 어댑터처럼, 서로 다른 인터페이스를 중간에서 연결해주어 기존 코드는 그대로 두고도 새로운 시스템을 사용할 수 있게 해줍니다.

개요

간단히 말해서, Adapter Pattern은 호환되지 않는 인터페이스를 가진 클래스들이 함께 동작할 수 있도록 중간에서 변환 역할을 수행하는 디자인 패턴입니다. 실무에서 레거시 코드를 유지하면서 새로운 라이브러리나 API를 도입해야 할 때 이 패턴은 필수적입니다.

예를 들어, 기존 시스템이 XML 형식의 데이터를 기대하는데 새로운 API가 JSON을 반환하는 경우, 어댑터를 통해 JSON을 XML로 변환하여 기존 코드는 전혀 수정하지 않고도 새 API를 사용할 수 있습니다. 기존에는 인터페이스가 맞지 않으면 모든 클라이언트 코드를 수정해야 했다면, 이제는 어댑터 하나만 만들어 중간에 끼워 넣으면 됩니다.

이 패턴의 핵심 특징은 단일 책임 원칙(SRP)을 지키면서 개방-폐쇄 원칙(OCP)을 실현한다는 점, 기존 코드를 수정하지 않아 안정성이 보장된다는 점, 그리고 인터페이스 변환 로직을 한 곳에 집중시켜 유지보수가 쉽다는 점입니다. 이러한 특징들이 복잡한 시스템 통합 상황에서 리스크를 최소화하고 개발 생산성을 극대화하는 데 중요한 역할을 합니다.

코드 예제

// 기존 시스템이 사용하는 인터페이스
class OldPaymentSystem {
  makePayment(amount) {
    return `Old system: Processing $${amount}`;
  }
}

// 새로운 결제 시스템 (인터페이스가 다름)
class NewPaymentGateway {
  processTransaction(paymentDetails) {
    return `New gateway: Processing ${paymentDetails.amount} ${paymentDetails.currency}`;
  }
}

// Adapter: 새 시스템을 기존 인터페이스에 맞춤
class PaymentAdapter {
  constructor(newGateway) {
    this.gateway = newGateway;
  }

  // 기존 인터페이스와 동일한 메서드명
  makePayment(amount) {
    const details = { amount, currency: 'USD' };
    return this.gateway.processTransaction(details);
  }
}

// 사용: 기존 코드는 변경 없이 새 시스템 사용
const newGateway = new NewPaymentGateway();
const adapter = new PaymentAdapter(newGateway);
console.log(adapter.makePayment(100));

설명

이것이 하는 일: 이 코드는 기존 결제 시스템의 인터페이스를 유지하면서 새로운 결제 게이트웨이를 사용할 수 있도록 중간 변환 계층을 만듭니다. 첫 번째로, OldPaymentSystem과 NewPaymentGateway는 서로 다른 인터페이스를 가지고 있습니다.

기존 시스템은 단순히 amount만 받는 makePayment 메서드를 사용하지만, 새 시스템은 paymentDetails 객체를 받는 processTransaction 메서드를 사용합니다. 이런 인터페이스 차이 때문에 직접 교체가 불가능합니다.

두 번째로, PaymentAdapter 클래스가 생성자에서 NewPaymentGateway 인스턴스를 받아 내부에 저장합니다. 그리고 기존 시스템과 동일한 makePayment 메서드를 제공하되, 내부적으로는 받은 amount를 paymentDetails 객체로 변환한 후 새 게이트웨이의 processTransaction을 호출합니다.

이렇게 인터페이스 변환이 어댑터 내부에서 투명하게 처리됩니다. 세 번째로, 클라이언트 코드는 어댑터를 마치 기존 시스템처럼 사용합니다.

makePayment(100)을 호출하면 어댑터가 내부적으로 { amount: 100, currency: 'USD' } 객체를 만들어 새 게이트웨이에 전달하고, 결과를 반환합니다. 클라이언트는 내부에서 새 시스템이 사용되는지 전혀 알 필요가 없습니다.

여러분이 이 코드를 사용하면 레거시 코드베이스 전체를 수정하지 않고도 최신 결제 시스템으로 업그레이드할 수 있습니다. 기존 코드가 안정적으로 유지되며, 문제 발생 시 어댑터만 수정하면 되고, 필요하다면 여러 어댑터를 만들어 다양한 결제 게이트웨이를 동시에 지원할 수도 있습니다.

실전 팁

💡 어댑터는 단순히 인터페이스만 변환하고, 비즈니스 로직은 추가하지 마세요. 추가 로직이 필요하다면 Decorator 패턴을 고려하세요.

💡 런타임에 어댑터를 교체할 수 있도록 설계하면 테스트와 A/B 테스팅이 훨씬 쉬워집니다.

💡 어댑터 내부에서 에러 처리와 로깅을 통일하면 시스템 전환 시 문제를 빠르게 파악할 수 있습니다.

💡 인터페이스 변환 시 데이터 손실이 발생할 수 있으니, 변환 전후 데이터의 완전성을 항상 검증하세요.

💡 어댑터가 너무 많은 변환을 담당한다면 설계를 재검토하세요. 하나의 어댑터는 하나의 인터페이스 변환만 담당하는 것이 이상적입니다.


2. Object Adapter 구현

시작하며

여러분이 기존 클래스를 상속받을 수 없는 상황에 처한 적 있나요? 서드파티 라이브러리의 final 클래스이거나, 다중 상속이 필요한데 언어가 지원하지 않는 경우입니다.

이런 제약은 실제 프로젝트에서 빈번하게 발생합니다. JavaScript는 단일 상속만 지원하고, 많은 프레임워크들이 클래스를 봉인(seal)해두어 상속을 막습니다.

상속 기반으로 어댑터를 만들려다 벽에 부딪히면 프로젝트 진행이 막히게 됩니다. 바로 이럴 때 필요한 것이 Object Adapter입니다.

상속 대신 컴포지션(composition)을 사용하여 어떤 클래스든 유연하게 감싸고 인터페이스를 변환할 수 있습니다.

개요

간단히 말해서, Object Adapter는 변환하려는 객체를 내부 필드로 보유하고, 컴포지션을 통해 기능을 위임하는 방식의 어댑터입니다. 상속의 제약이 없어 훨씬 유연하며, 런타임에 어댑터가 감쌀 객체를 변경할 수도 있습니다.

예를 들어, 로깅 라이브러리를 교체하거나, 개발 환경과 운영 환경에서 다른 구현체를 사용하는 경우에 매우 유용합니다. 기존에는 상속을 통해 부모 클래스의 기능을 오버라이드했다면, 이제는 객체를 필드로 가지고 필요한 메서드만 위임하면 됩니다.

핵심 특징은 느슨한 결합(loose coupling)을 통해 유연성이 극대화된다는 점, 런타임에 동적으로 adaptee를 교체할 수 있다는 점, 그리고 여러 객체를 조합하여 복잡한 어댑터를 만들 수 있다는 점입니다. 이러한 특징들이 변화가 잦은 실무 환경에서 코드의 확장성과 유지보수성을 크게 향상시킵니다.

코드 예제

// 기존 Logger 인터페이스
class Logger {
  log(message) {
    throw new Error('Must implement log method');
  }
}

// 서드파티 로깅 라이브러리 (인터페이스가 다름)
class ThirdPartyLogger {
  writeLog(level, msg, timestamp) {
    return `[${timestamp}] ${level}: ${msg}`;
  }
}

// Object Adapter: 컴포지션 사용
class LoggerAdapter extends Logger {
  constructor(thirdPartyLogger) {
    super();
    this.logger = thirdPartyLogger; // 객체를 필드로 보유
  }

  log(message) {
    const timestamp = new Date().toISOString();
    // 위임을 통해 기능 구현
    return this.logger.writeLog('INFO', message, timestamp);
  }

  // 런타임에 logger 교체 가능
  setLogger(newLogger) {
    this.logger = newLogger;
  }
}

// 사용
const thirdParty = new ThirdPartyLogger();
const adapter = new LoggerAdapter(thirdParty);
console.log(adapter.log('User logged in'));

// 런타임에 logger 교체
const anotherLogger = new ThirdPartyLogger();
adapter.setLogger(anotherLogger);

설명

이것이 하는 일: 이 코드는 서드파티 로깅 라이브러리의 복잡한 인터페이스를 간단한 Logger 인터페이스로 변환하되, 상속 없이 컴포지션으로 구현합니다. 첫 번째로, Logger 추상 클래스가 프로젝트 전체에서 사용하는 표준 인터페이스를 정의합니다.

단순히 log(message) 하나만 받습니다. 반면 ThirdPartyLogger는 level, msg, timestamp 세 개의 매개변수를 요구하는 writeLog 메서드를 제공합니다.

이 인터페이스 차이를 어댑터가 해결해야 합니다. 두 번째로, LoggerAdapter가 생성자에서 ThirdPartyLogger 인스턴스를 받아 this.logger 필드에 저장합니다.

상속이 아닌 컴포지션을 사용하므로 ThirdPartyLogger가 final 클래스여도 문제없고, 나중에 완전히 다른 클래스의 인스턴스로 교체할 수도 있습니다. log 메서드에서는 간단한 message만 받아, 내부적으로 level을 'INFO'로, timestamp를 현재 시간으로 자동 생성한 후 this.logger.writeLog에 위임합니다.

세 번째로, setLogger 메서드를 통해 런타임에 logger를 교체할 수 있습니다. 이는 Object Adapter의 강력한 장점으로, 개발 중에는 콘솔 로거를, 운영에서는 파일 로거를 사용하거나, A/B 테스트로 여러 로깅 서비스를 비교할 때 매우 유용합니다.

클라이언트 코드는 여전히 동일한 log(message) 인터페이스만 사용하면 됩니다. 여러분이 이 패턴을 사용하면 써드파티 라이브러리 의존성을 격리하여 나중에 교체가 쉬워지고, 테스트 시 mock 객체로 쉽게 교체할 수 있으며, 하나의 어댑터로 여러 구현체를 지원하는 유연한 아키텍처를 구축할 수 있습니다.

특히 마이크로서비스 환경에서 로깅, 모니터링, 알림 등의 인프라 컴포넌트를 추상화할 때 필수적입니다.

실전 팁

💡 생성자에서 adaptee를 받는 것 외에 팩토리 메서드를 제공하면 객체 생성이 더 명확해집니다.

💡 adaptee가 null일 수 있다면 Null Object 패턴과 함께 사용하여 안전성을 높이세요.

💡 여러 메서드를 변환해야 한다면, 공통 변환 로직을 private 헬퍼 메서드로 분리하세요.

💡 컴포지션을 사용하므로 adaptee의 전체 인터페이스를 노출하지 말고, 필요한 메서드만 선택적으로 위임하세요.

💡 런타임에 객체를 교체할 때는 스레드 안전성을 고려하여 적절한 동기화 메커니즘을 사용하세요.


3. Class Adapter 구현

시작하며

여러분이 두 개의 클래스 기능을 모두 활용하고 싶은데, 하나는 상속하고 다른 하나는 감싸야 하는 복잡한 상황을 겪어본 적 있나요? 특히 기존 클래스의 protected 메서드를 활용해야 하는 경우입니다.

이런 상황은 프레임워크 확장이나 레거시 시스템 통합에서 자주 발생합니다. Object Adapter로는 protected 멤버에 접근할 수 없고, 기존 클래스의 내부 기능을 재사용하려면 결국 코드 중복이 발생합니다.

단순한 위임으로는 해결되지 않는 복잡한 상속 관계가 문제입니다. 바로 이럴 때 필요한 것이 Class Adapter입니다.

다중 상속이나 믹스인을 활용하여 여러 클래스의 기능을 직접 결합하고, 보호된 멤버까지 활용할 수 있습니다.

개요

간단히 말해서, Class Adapter는 상속을 통해 adaptee의 기능을 직접 확장하고, 필요한 인터페이스를 구현하는 방식의 어댑터입니다. 기존 클래스의 protected 메서드나 필드에 접근해야 할 때, 또는 adaptee의 일부 메서드를 오버라이드하여 동작을 세밀하게 조정해야 할 때 이 방식이 유용합니다.

예를 들어, UI 프레임워크의 기본 위젯을 상속받아 커스터마이징하면서 동시에 새로운 인터페이스를 제공하는 경우입니다. 기존에는 상속과 컴포지션 중 하나만 선택해야 했다면, 이제는 상속의 장점(protected 접근, 오버라이딩)과 인터페이스 변환을 동시에 활용할 수 있습니다.

핵심 특징은 컴포지션보다 더 타이트한 결합으로 성능이 우수하다는 점, protected 멤버에 접근하여 내부 기능을 최대한 활용할 수 있다는 점, 그리고 adaptee의 메서드를 직접 오버라이드하여 세밀한 제어가 가능하다는 점입니다. 다만 JavaScript는 다중 상속을 지원하지 않으므로 믹스인 패턴을 함께 사용하게 됩니다.

이러한 특징들이 프레임워크 확장이나 복잡한 레거시 통합에서 코드 중복을 줄이고 성능을 최적화하는 데 도움을 줍니다.

코드 예제

// 기존 데이터 소스
class LegacyDataSource {
  constructor() {
    this.data = ['item1', 'item2', 'item3'];
  }

  // protected 메서드 (JavaScript에서는 convention)
  _fetchRawData() {
    return this.data;
  }

  getData() {
    return this._fetchRawData().join(', ');
  }
}

// 새로운 인터페이스 (JSON 형태를 요구)
class ModernDataSource {
  getJsonData() {
    throw new Error('Must implement getJsonData');
  }
}

// Class Adapter: 상속을 통한 구현
class DataSourceAdapter extends LegacyDataSource {
  getJsonData() {
    // 부모의 protected 메서드 활용
    const raw = this._fetchRawData();
    // JSON 형태로 변환
    return JSON.stringify({ items: raw, count: raw.length });
  }

  // 필요하다면 부모 메서드 오버라이드 가능
  getData() {
    const original = super.getData();
    return `Adapted: ${original}`;
  }
}

// 사용
const adapter = new DataSourceAdapter();
console.log(adapter.getJsonData()); // {"items":["item1","item2","item3"],"count":3}
console.log(adapter.getData()); // Adapted: item1, item2, item3

설명

이것이 하는 일: 이 코드는 레거시 데이터 소스를 상속받아 내부의 보호된 메서드를 활용하면서, 동시에 JSON 형태의 데이터를 제공하는 현대적인 인터페이스를 구현합니다. 첫 번째로, LegacyDataSource는 _fetchRawData라는 보호된(underscore convention) 메서드를 가지고 있습니다.

이 메서드는 외부에서 직접 호출하면 안 되지만, 서브클래스에서는 활용할 수 있습니다. getData 메서드는 이 보호된 메서드를 사용하여 쉼표로 구분된 문자열을 반환합니다.

Object Adapter 방식으로는 _fetchRawData에 접근할 수 없어 데이터를 다시 파싱해야 하지만, Class Adapter는 직접 접근이 가능합니다. 두 번째로, DataSourceAdapter가 LegacyDataSource를 상속받아 _fetchRawData에 직접 접근합니다.

getJsonData 메서드에서 this._fetchRawData()를 호출하여 원본 배열을 얻고, 이를 JSON 형태로 변환합니다. 중간에 문자열 파싱 과정이 없으므로 훨씬 효율적이고, 데이터 타입도 정확하게 유지됩니다.

이것이 Class Adapter의 핵심 장점입니다. 세 번째로, 필요하다면 부모 클래스의 메서드를 오버라이드할 수도 있습니다.

getData를 오버라이드하여 super.getData()로 원본 동작을 실행한 후, 결과에 "Adapted:" 접두사를 추가합니다. 이런 식으로 기존 동작을 확장하거나 수정할 수 있어 세밀한 제어가 가능합니다.

여러분이 이 패턴을 사용하면 레거시 코드의 내부 로직을 최대한 재사용하여 코드 중복을 방지하고, protected API를 활용하여 더 효율적인 구현이 가능하며, 상속 계층을 통해 타입 안정성도 얻을 수 있습니다. 특히 프레임워크의 기본 클래스를 확장하면서 새로운 인터페이스를 제공해야 하는 경우, UI 컴포넌트 라이브러리나 ORM 같은 도메인에서 매우 유용합니다.

실전 팁

💡 JavaScript는 다중 상속을 지원하지 않으므로, 여러 인터페이스를 구현해야 한다면 믹스인 패턴과 함께 사용하세요.

💡 상속은 강한 결합을 만들므로, adaptee가 자주 변경된다면 Object Adapter를 고려하세요.

💡 protected 메서드를 사용할 때는 문서화를 철저히 하여 내부 API 변경 시 영향을 파악하기 쉽게 하세요.

💡 부모 메서드를 오버라이드할 때는 반드시 super를 호출하여 기존 동작을 유지하거나, 완전히 대체하는 것인지 명확히 하세요.

💡 Class Adapter는 단일 adaptee만 상속할 수 있으므로, 여러 클래스를 조합해야 한다면 Object Adapter와 혼합하여 사용하세요.


4. 레거시 API 통합

시작하며

여러분이 10년 된 레거시 시스템의 API를 호출해야 하는데, 현대적인 REST API나 GraphQL과는 완전히 다른 XML-RPC 방식이라면 어떻게 하시겠어요? 전체 클라이언트 코드를 XML 처리 로직으로 오염시킬 수는 없습니다.

이런 문제는 금융권이나 대기업의 레거시 시스템 통합에서 일상적으로 발생합니다. 레거시 시스템은 SOAP, XML-RPC, 심지어 고정 길이 텍스트 파일 같은 구식 프로토콜을 사용하지만, 현대적인 애플리케이션은 JSON 기반의 RESTful API를 기대합니다.

두 세계를 직접 연결하면 코드가 복잡해지고 유지보수가 악몽이 됩니다. 바로 이럴 때 필요한 것이 레거시 API 어댑터입니다.

구식 프로토콜을 현대적인 인터페이스로 깔끔하게 변환하여, 애플리케이션 코드는 레거시 시스템의 존재조차 모르게 만듭니다.

개요

간단히 말해서, 레거시 API 어댑터는 오래된 시스템의 복잡하고 구식인 인터페이스를 현대적이고 간단한 인터페이스로 변환하는 특화된 어댑터입니다. 실무에서 ERP, 메인프레임, 뱅킹 시스템 같은 레거시 시스템과 통신해야 할 때 필수적입니다.

예를 들어, SOAP 기반의 은행 API를 호출하는데 애플리케이션은 async/await 기반의 간단한 JavaScript 함수를 기대한다면, 어댑터가 XML 생성, SOAP 엔벨로프 처리, 응답 파싱 등을 모두 숨기고 깔끔한 Promise를 반환합니다. 기존에는 레거시 프로토콜 처리 코드가 비즈니스 로직 곳곳에 흩어져 있었다면, 이제는 어댑터 하나에 모든 변환 로직을 집중시켜 관심사의 분리를 실현합니다.

핵심 특징은 복잡한 레거시 프로토콜을 캡슐화하여 현대적 코드와 격리한다는 점, 프로토콜 변환 로직을 한 곳에 집중시켜 유지보수가 쉽다는 점, 그리고 레거시 시스템을 교체할 때 어댑터만 수정하면 된다는 점입니다. 이러한 특징들이 기술 부채를 관리하고 점진적인 시스템 현대화를 가능하게 합니다.

코드 예제

// 레거시 XML-RPC 클라이언트 (예시)
class LegacyXMLRPCClient {
  call(method, params) {
    // 실제로는 XML 생성 및 HTTP 요청
    const xml = `<methodCall><methodName>${method}</methodName></methodCall>`;
    // 동기 방식의 구식 API
    return `<response><value>${params.id * 100}</value></response>`;
  }
}

// 현대적인 API 인터페이스
class ModernAPI {
  async getUser(id) {
    throw new Error('Must implement getUser');
  }
}

// 레거시 API 어댑터
class LegacyAPIAdapter extends ModernAPI {
  constructor(legacyClient) {
    super();
    this.client = legacyClient;
  }

  async getUser(id) {
    // 현대적인 async/await 인터페이스 제공
    return new Promise((resolve, reject) => {
      try {
        // 레거시 XML-RPC 호출
        const xmlResponse = this.client.call('user.get', { id });
        // XML 파싱 (실제로는 XML 파서 사용)
        const value = this._parseXML(xmlResponse);
        // JSON 형태로 변환
        resolve({ id, score: value, source: 'legacy' });
      } catch (error) {
        reject(new Error(`Legacy API error: ${error.message}`));
      }
    });
  }

  _parseXML(xml) {
    const match = xml.match(/<value>(\d+)<\/value>/);
    return match ? parseInt(match[1]) : 0;
  }
}

// 사용: 레거시 시스템을 현대적으로 사용
const legacy = new LegacyXMLRPCClient();
const api = new LegacyAPIAdapter(legacy);
api.getUser(5).then(user => console.log(user)); // { id: 5, score: 500, source: 'legacy' }

설명

이것이 하는 일: 이 코드는 동기 방식의 XML-RPC 레거시 API를 비동기 Promise 기반의 현대적인 JavaScript API로 변환하여, 애플리케이션이 레거시 프로토콜을 전혀 의식하지 않고 사용할 수 있게 합니다. 첫 번째로, LegacyXMLRPCClient는 오래된 시스템의 실제 API를 시뮬레이션합니다.

동기 방식의 call 메서드로 XML 문자열을 받고 XML 문자열을 반환합니다. 이런 구식 API는 콜백이나 Promise를 지원하지 않아 현대적인 비동기 코드와 통합이 어렵고, XML 처리는 복잡하고 오류가 발생하기 쉽습니다.

이를 직접 사용하면 애플리케이션 코드가 XML 파싱과 에러 처리로 복잡해집니다. 두 번째로, LegacyAPIAdapter가 생성자에서 레거시 클라이언트를 받아 내부에 저장합니다.

getUser 메서드는 현대적인 async/await 인터페이스를 제공하되, 내부적으로는 Promise를 만들어 레거시 클라이언트의 동기 호출을 감쌉니다. XML-RPC 호출, XML 파싱, JSON 변환, 에러 처리가 모두 어댑터 내부에서 처리되어 클라이언트 코드는 깨끗하게 유지됩니다.

세 번째로, _parseXML 헬퍼 메서드가 XML 응답에서 필요한 값을 추출합니다. 실제 프로젝트에서는 DOMParser나 XML 파싱 라이브러리를 사용하겠지만, 여기서는 정규식으로 간단히 구현했습니다.

파싱 로직이 어댑터 내부에 캡슐화되어 있어, XML 구조가 변경되어도 수정할 곳이 한 곳뿐입니다. 여러분이 이 패턴을 사용하면 레거시 시스템의 복잡성을 애플리케이션으로부터 완전히 격리하고, 레거시 시스템을 교체하거나 업그레이드할 때 어댑터만 수정하면 되며, 테스트 시 mock 어댑터로 쉽게 교체하여 레거시 시스템 없이도 개발할 수 있습니다.

금융, 헬스케어, 물류 등 레거시 시스템이 많은 산업에서 현대화 프로젝트의 핵심 패턴입니다.

실전 팁

💡 레거시 시스템 호출은 느릴 수 있으므로 어댑터에 캐싱 레이어를 추가하여 성능을 개선하세요.

💡 레거시 API는 불안정할 수 있으니 재시도 로직과 circuit breaker 패턴을 함께 구현하세요.

💡 XML/SOAP 파싱은 라이브러리(fast-xml-parser, xml2js 등)를 사용하여 안정성을 높이세요.

💡 레거시 API의 에러 코드를 현대적인 예외 클래스로 매핑하여 일관된 에러 처리를 제공하세요.

💡 레거시 시스템과의 통신을 로깅하여 문제 발생 시 디버깅을 용이하게 하되, 민감한 정보는 마스킹하세요.


5. 서드파티 라이브러리 래핑

시작하며

여러분이 프로젝트에서 특정 서드파티 라이브러리에 깊이 의존하고 있는데, 그 라이브러리가 deprecated 되거나 더 나은 대안이 나타난 상황을 겪어본 적 있나요? 코드베이스 전체에 라이브러리 호출이 흩어져 있어 교체가 거의 불가능합니다.

이런 문제는 빠르게 변화하는 JavaScript 생태계에서 특히 심각합니다. moment.js가 day.js로, request가 axios로, enzyme이 testing-library로 교체되는 식으로 트렌드가 바뀌는데, 코드 곳곳에 직접 의존하고 있으면 마이그레이션 비용이 천문학적으로 커집니다.

라이브러리 벤더 락인(vendor lock-in)에 갇히게 됩니다. 바로 이럴 때 필요한 것이 서드파티 라이브러리 래핑입니다.

외부 라이브러리를 직접 사용하지 않고 어댑터로 감싸면, 나중에 라이브러리를 교체할 때 어댑터만 수정하면 됩니다.

개요

간단히 말해서, 서드파티 라이브러리 래핑은 외부 라이브러리의 API를 프로젝트에 맞는 인터페이스로 감싸서 의존성을 격리하는 기법입니다. 프로젝트 전체가 하나의 일관된 인터페이스를 사용하고, 실제 구현은 어댑터가 담당합니다.

예를 들어, HTTP 클라이언트로 axios를 사용하지만 직접 import하지 않고 자체 HttpClient 인터페이스를 통해 사용하면, 나중에 fetch나 다른 라이브러리로 교체할 때 애플리케이션 코드는 전혀 수정할 필요가 없습니다. 기존에는 import axios from 'axios'가 코드 전체에 퍼져 있었다면, 이제는 import { httpClient } from './adapters'로 통일하여 결합도를 낮춥니다.

핵심 특징은 벤더 락인을 방지하여 기술 선택의 유연성을 유지한다는 점, 라이브러리 업데이트나 교체 시 영향 범위를 어댑터로 제한한다는 점, 그리고 프로젝트 전체에 일관된 API를 제공하여 학습 곡선을 낮춘다는 점입니다. 이러한 특징들이 장기적인 프로젝트 유지보수성과 기술 부채 관리에 결정적인 역할을 합니다.

코드 예제

// 서드파티 라이브러리 (예: axios)
class ThirdPartyHttpClient {
  request(config) {
    // axios 스타일 API
    return {
      data: { message: 'Success', status: config.method },
      status: 200,
      headers: {}
    };
  }
}

// 프로젝트 표준 HTTP 인터페이스
class HttpClient {
  async get(url, options) {
    throw new Error('Must implement get');
  }
  async post(url, data, options) {
    throw new Error('Must implement post');
  }
}

// 서드파티 래퍼 어댑터
class AxiosAdapter extends HttpClient {
  constructor(axiosInstance) {
    super();
    this.client = axiosInstance;
  }

  async get(url, options = {}) {
    const response = await this.client.request({
      method: 'GET',
      url,
      ...options
    });
    // 응답 형식을 프로젝트 표준으로 변환
    return this._normalizeResponse(response);
  }

  async post(url, data, options = {}) {
    const response = await this.client.request({
      method: 'POST',
      url,
      data,
      ...options
    });
    return this._normalizeResponse(response);
  }

  _normalizeResponse(response) {
    return {
      body: response.data,
      statusCode: response.status,
      isSuccess: response.status >= 200 && response.status < 300
    };
  }
}

// 사용: axios 대신 표준 인터페이스 사용
const axios = new ThirdPartyHttpClient();
const httpClient = new AxiosAdapter(axios);
httpClient.get('/api/users').then(res => console.log(res));

// 나중에 fetch로 교체할 때 어댑터만 변경
class FetchAdapter extends HttpClient {
  async get(url, options) {
    const response = await fetch(url, { method: 'GET', ...options });
    return {
      body: await response.json(),
      statusCode: response.status,
      isSuccess: response.ok
    };
  }
  // ... post 구현
}

설명

이것이 하는 일: 이 코드는 axios 같은 서드파티 HTTP 클라이언트를 프로젝트 자체 HttpClient 인터페이스로 감싸서, 애플리케이션이 특정 라이브러리에 직접 의존하지 않도록 합니다. 첫 번째로, ThirdPartyHttpClient(실제로는 axios)는 고유한 API 스타일을 가지고 있습니다.

config 객체를 받고, data, status, headers를 포함하는 특정 형태의 응답을 반환합니다. 만약 애플리케이션이 이 API를 직접 사용하면, 나중에 다른 HTTP 클라이언트로 바꾸려 할 때 모든 호출 지점을 찾아 수정해야 합니다.

대규모 프로젝트에서는 수백, 수천 곳이 될 수 있습니다. 두 번째로, HttpClient 추상 클래스가 프로젝트 전체에서 사용할 표준 인터페이스를 정의합니다.

get, post 같은 직관적인 메서드 이름과, 일관된 옵션 전달 방식을 제공합니다. AxiosAdapter가 이 인터페이스를 구현하면서 내부적으로 axios의 request 메서드를 호출합니다.

_normalizeResponse는 axios 특유의 응답 형태를 프로젝트 표준 형태({ body, statusCode, isSuccess })로 변환합니다. 세 번째로, 애플리케이션 코드는 httpClient.get('/api/users')처럼 표준 인터페이스만 사용합니다.

axios의 존재를 전혀 모릅니다. 나중에 fetch API로 교체하고 싶다면 FetchAdapter를 만들고, httpClient 인스턴스 생성 부분만 수정하면 됩니다.

수백 개의 API 호출 코드는 전혀 건드리지 않아도 됩니다. 여러분이 이 패턴을 사용하면 라이브러리 버전 업그레이드나 교체가 안전하고 빠르게 이루어지고, 여러 HTTP 클라이언트를 동시에 지원하거나 점진적으로 마이그레이션할 수 있으며, 테스트 시 실제 HTTP 호출 없이 mock adapter를 주입하여 빠르고 안정적인 테스트를 작성할 수 있습니다.

특히 마이크로프론트엔드나 모노레포 환경에서 여러 팀이 일관된 인터페이스를 사용하면서 각자 다른 구현을 선택할 수 있는 유연성을 제공합니다.

실전 팁

💡 어댑터의 메서드 시그니처는 가장 일반적인 use case를 커버하되, 고급 기능은 options 객체로 전달하세요.

💡 여러 서드파티 라이브러리를 래핑한다면 팩토리 패턴을 함께 사용하여 어댑터 생성을 중앙화하세요.

💡 래퍼 인터페이스를 너무 추상화하지 마세요. 80%의 일반적인 사용 사례에 집중하고, 나머지는 직접 사용을 허용할 수도 있습니다.

💡 타입스크립트를 사용한다면 제네릭을 활용하여 요청/응답 타입을 명확히 하세요.

💡 어댑터에 로깅, 에러 처리, 재시도 로직 같은 공통 관심사를 추가하여 중복 코드를 줄이세요.


6. 데이터 포맷 변환

시작하며

여러분이 마이크로서비스 아키텍처에서 일하는데, 한 서비스는 JSON을, 다른 서비스는 XML을, 또 다른 서비스는 Protocol Buffer를 사용한다면 어떻게 하시겠어요? 각 서비스마다 다른 파싱 로직을 구현하면 코드가 지저분해집니다.

이런 문제는 다양한 시스템을 통합하는 엔터프라이즈 환경에서 매우 흔합니다. 레거시 시스템은 XML을, REST API는 JSON을, 고성능 서비스는 Protobuf를 사용하는데, 이들 간의 데이터 교환이 필요합니다.

포맷 변환 코드가 비즈니스 로직과 섞이면 유지보수가 악몽이 되고, 새로운 포맷 추가가 어려워집니다. 바로 이럴 때 필요한 것이 데이터 포맷 변환 어댑터입니다.

다양한 데이터 포맷을 통일된 내부 모델로 변환하고, 다시 필요한 포맷으로 출력하여 비즈니스 로직은 포맷에 무관하게 유지됩니다.

개요

간단히 말해서, 데이터 포맷 변환 어댑터는 여러 데이터 포맷(JSON, XML, CSV, Protobuf 등)을 통일된 내부 표현으로 변환하고, 필요한 포맷으로 다시 변환하는 특화된 어댑터입니다. 서비스 간 통신에서 포맷 차이를 흡수하여 비즈니스 로직이 순수하게 유지되도록 합니다.

예를 들어, 외부 파트너가 XML로 주문 데이터를 보내오더라도 어댑터가 JSON으로 변환하여 내부 시스템은 일관된 형태로 처리하고, 다시 파트너가 원하는 형태로 응답을 변환합니다. 기존에는 if (format === 'xml') ...

else if (format === 'json') 같은 조건문이 코드 곳곳에 있었다면, 이제는 어댑터가 포맷 감지와 변환을 전담하여 전략 패턴과 함께 깔끔하게 처리합니다. 핵심 특징은 포맷 변환 로직을 비즈니스 로직으로부터 완전히 분리한다는 점, 새로운 포맷 추가가 기존 코드 수정 없이 가능하다는 점(개방-폐쇄 원칙), 그리고 양방향 변환을 지원하여 입력과 출력 모두 처리할 수 있다는 점입니다.

이러한 특징들이 시스템 간 통합의 복잡성을 크게 줄이고 확장성을 높입니다.

코드 예제

// 통일된 내부 데이터 모델
class User {
  constructor(id, name, email) {
    this.id = id;
    this.name = name;
    this.email = email;
  }
}

// 데이터 포맷 어댑터 인터페이스
class DataFormatAdapter {
  parse(data) {
    throw new Error('Must implement parse');
  }
  stringify(user) {
    throw new Error('Must implement stringify');
  }
}

// JSON 어댑터
class JsonAdapter extends DataFormatAdapter {
  parse(jsonString) {
    const obj = JSON.parse(jsonString);
    return new User(obj.id, obj.name, obj.email);
  }
  stringify(user) {
    return JSON.stringify({
      id: user.id,
      name: user.name,
      email: user.email
    });
  }
}

// XML 어댑터
class XmlAdapter extends DataFormatAdapter {
  parse(xmlString) {
    // 실제로는 DOMParser 사용
    const idMatch = xmlString.match(/<id>(\d+)<\/id>/);
    const nameMatch = xmlString.match(/<name>(.+?)<\/name>/);
    const emailMatch = xmlString.match(/<email>(.+?)<\/email>/);
    return new User(
      parseInt(idMatch[1]),
      nameMatch[1],
      emailMatch[1]
    );
  }
  stringify(user) {
    return `<user><id>${user.id}</id><name>${user.name}</name><email>${user.email}</email></user>`;
  }
}

// 포맷 감지 및 자동 변환
class DataConverter {
  constructor() {
    this.adapters = {
      json: new JsonAdapter(),
      xml: new XmlAdapter()
    };
  }

  convert(data, fromFormat, toFormat) {
    const user = this.adapters[fromFormat].parse(data);
    return this.adapters[toFormat].stringify(user);
  }
}

// 사용: JSON을 XML로 자동 변환
const converter = new DataConverter();
const jsonData = '{"id":1,"name":"Alice","email":"alice@example.com"}';
const xmlData = converter.convert(jsonData, 'json', 'xml');
console.log(xmlData); // <user><id>1</id><name>Alice</name><email>alice@example.com</email></user>

설명

이것이 하는 일: 이 코드는 서로 다른 데이터 포맷 간의 변환을 어댑터 패턴으로 구현하여, 비즈니스 로직은 통일된 User 모델만 다루고 포맷 처리는 어댑터가 전담하게 합니다. 첫 번째로, User 클래스가 포맷 중립적인 내부 데이터 모델을 정의합니다.

이것은 도메인 모델로서 비즈니스 로직이 직접 다루는 객체입니다. JSON이든 XML이든 CSV든, 모든 외부 포맷은 최종적으로 이 User 객체로 변환됩니다.

이렇게 통일된 모델을 사용하면 비즈니스 로직은 포맷을 전혀 신경 쓰지 않아도 됩니다. 두 번째로, JsonAdapter와 XmlAdapter가 각각 JSON과 XML 포맷의 파싱과 직렬화를 담당합니다.

parse 메서드는 문자열을 받아 User 객체로 변환하고, stringify는 User 객체를 받아 해당 포맷의 문자열로 변환합니다. 각 어댑터는 자신의 포맷 처리 로직만 알면 되고, 다른 포맷이나 비즈니스 로직은 전혀 몰라도 됩니다.

새로운 포맷(CSV, Protobuf 등)을 추가할 때도 새 어댑터만 만들면 됩니다. 세 번째로, DataConverter가 여러 어댑터를 관리하고 포맷 간 변환을 조율합니다.

convert 메서드는 source 포맷의 어댑터로 데이터를 파싱하여 User 객체를 얻고, target 포맷의 어댑터로 다시 직렬화합니다. 이 과정에서 User 객체가 중간 표현 역할을 하여, N개의 포맷을 지원하는데 N² 개의 변환 함수가 아닌 2N개의 어댑터(각 포맷당 parse와 stringify)만 필요합니다.

여러분이 이 패턴을 사용하면 새로운 데이터 포맷 지원이 매우 쉬워지고, 포맷별 파싱 로직이 격리되어 버그 수정과 최적화가 간단하며, API Gateway나 메시지 브로커 같은 중간 계층에서 포맷 변환을 투명하게 처리할 수 있습니다. 특히 마이크로서비스 환경에서 서비스 간 통신의 복잡성을 크게 줄이고, 레거시 시스템과 현대 시스템을 자연스럽게 연결하는 핵심 패턴입니다.

실전 팁

💡 실제 XML 파싱은 DOMParser나 fast-xml-parser 같은 라이브러리를 사용하세요. 정규식은 예시용입니다.

💡 대용량 데이터 처리 시 스트림 기반 파싱을 지원하는 어댑터를 구현하여 메모리 효율을 높이세요.

💡 스키마 검증을 어댑터 내부에 추가하여 잘못된 포맷의 데이터를 조기에 감지하세요.

💡 포맷 변환 시 데이터 손실이 발생할 수 있으니, 양방향 변환이 가역적인지 테스트하세요.

💡 자주 사용되는 변환은 캐싱하거나 미리 변환해두어 성능을 최적화하세요.


7. 다중 어댑터 체이닝

시작하며

여러분이 복잡한 데이터 변환 파이프라인을 구축해야 하는 상황을 겪어본 적 있나요? 예를 들어, 레거시 XML을 파싱하고, 데이터를 정규화하고, 비즈니스 규칙을 적용하고, 최종적으로 JSON으로 출력하는 다단계 프로세스입니다.

이런 복잡한 변환을 하나의 거대한 함수로 만들면 유지보수가 불가능해집니다. 각 단계의 로직이 섞여 있어 버그 수정이 어렵고, 재사용이 불가능하며, 테스트하기도 힘듭니다.

특정 단계만 수정하거나 순서를 바꾸고 싶어도 전체를 다시 작성해야 합니다. 바로 이럴 때 필요한 것이 다중 어댑터 체이닝입니다.

여러 어댑터를 파이프라인으로 연결하여, 각 어댑터는 하나의 단순한 변환만 담당하고, 이들을 조합하여 복잡한 변환을 수행합니다.

개요

간단히 말해서, 다중 어댑터 체이닝은 여러 개의 단순한 어댑터를 순차적으로 연결하여 복잡한 변환 파이프라인을 구성하는 기법입니다. 각 어댑터는 단일 책임을 가지며, 입력을 받아 변환 후 다음 어댑터로 전달합니다.

예를 들어, 첫 번째 어댑터가 XML을 객체로 변환하고, 두 번째 어댑터가 필드를 정규화하고, 세 번째 어댑터가 비즈니스 규칙을 적용하고, 마지막 어댑터가 JSON으로 직렬화하는 식입니다. 기존에는 거대한 transform 함수 하나에 모든 로직이 들어있었다면, 이제는 작은 어댑터들을 레고 블록처럼 조합하여 유연하게 파이프라인을 구성합니다.

핵심 특징은 단일 책임 원칙으로 각 어댑터가 하나의 변환만 담당한다는 점, 어댑터를 재사용하고 재조합하여 다양한 파이프라인을 만들 수 있다는 점, 그리고 각 단계를 독립적으로 테스트하고 최적화할 수 있다는 점입니다. 이러한 특징들이 복잡한 데이터 처리 로직을 관리 가능한 수준으로 분해하고, 변경에 유연하게 대응할 수 있게 합니다.

코드 예제

// 파이프라인 단계별 어댑터
class ValidationAdapter {
  constructor(next) {
    this.next = next;
  }

  process(data) {
    // 1단계: 데이터 검증
    if (!data.id || !data.name) {
      throw new Error('Invalid data: missing required fields');
    }
    console.log('Validation passed');
    return this.next ? this.next.process(data) : data;
  }
}

class NormalizationAdapter {
  constructor(next) {
    this.next = next;
  }

  process(data) {
    // 2단계: 데이터 정규화
    const normalized = {
      id: parseInt(data.id),
      name: data.name.trim().toLowerCase(),
      email: data.email ? data.email.toLowerCase() : null
    };
    console.log('Normalization complete');
    return this.next ? this.next.process(normalized) : normalized;
  }
}

class EnrichmentAdapter {
  constructor(next) {
    this.next = next;
  }

  process(data) {
    // 3단계: 데이터 보강
    const enriched = {
      ...data,
      createdAt: new Date().toISOString(),
      source: 'api'
    };
    console.log('Enrichment complete');
    return this.next ? this.next.process(enriched) : enriched;
  }
}

class FormattingAdapter {
  constructor(next) {
    this.next = next;
  }

  process(data) {
    // 4단계: 최종 포맷팅
    const formatted = {
      user_id: data.id,
      user_name: data.name,
      user_email: data.email,
      metadata: {
        created_at: data.createdAt,
        source: data.source
      }
    };
    console.log('Formatting complete');
    return this.next ? this.next.process(formatted) : formatted;
  }
}

// 파이프라인 구성
const pipeline = new ValidationAdapter(
  new NormalizationAdapter(
    new EnrichmentAdapter(
      new FormattingAdapter(null)
    )
  )
);

// 사용
const input = { id: '123', name: '  Alice  ', email: 'ALICE@EXAMPLE.COM' };
const result = pipeline.process(input);
console.log(result);

설명

이것이 하는 일: 이 코드는 데이터 검증, 정규화, 보강, 포맷팅이라는 네 단계의 변환을 각각 독립된 어댑터로 구현하고, 이들을 체인으로 연결하여 복잡한 처리를 단계별로 수행합니다. 첫 번째로, 각 어댑터는 생성자에서 next 어댑터를 받아 체인을 형성합니다.

ValidationAdapter는 필수 필드 검증만 담당하고, 검증이 통과되면 this.next.process()로 다음 단계에 데이터를 전달합니다. 이것은 Chain of Responsibility 패턴과 Adapter 패턴의 조합으로, 각 어댑터는 자신의 책임만 수행하고 나머지는 다음 어댑터에게 위임합니다.

두 번째로, NormalizationAdapter와 EnrichmentAdapter가 순차적으로 데이터를 변환합니다. 정규화 어댑터는 문자열 타입을 올바른 타입으로 변환하고 공백을 제거하며, 보강 어댑터는 타임스탬프와 메타데이터를 추가합니다.

각 어댑터는 독립적으로 동작하므로, 예를 들어 정규화 로직을 수정해도 다른 어댑터는 전혀 영향을 받지 않습니다. 또한 특정 단계를 건너뛰거나 순서를 변경하고 싶다면 파이프라인 구성만 바꾸면 됩니다.

세 번째로, FormattingAdapter가 최종 출력 형태를 결정합니다. API 응답 스키마에 맞춰 필드명을 변경하고 구조를 재구성합니다.

마지막 어댑터이므로 next는 null입니다. 입력 데이터가 파이프라인을 통과하면서 검증 → 정규화 → 보강 → 포맷팅 순서로 변환되어 최종 결과가 만들어집니다.

여러분이 이 패턴을 사용하면 각 변환 로직을 독립적으로 개발하고 테스트할 수 있어 복잡도가 크게 낮아지고, 어댑터를 다른 파이프라인에서 재사용할 수 있으며, 새로운 변환 단계를 추가하거나 기존 단계를 교체하는 것이 매우 쉬워집니다. ETL(Extract-Transform-Load) 파이프라인, 데이터 처리 워크플로우, 미들웨어 체인 등 복잡한 데이터 처리가 필요한 모든 시나리오에서 핵심적인 패턴입니다.

실전 팁

💡 어댑터 체인이 길어지면 빌더 패턴이나 팩토리를 사용하여 파이프라인 구성을 간결하게 만드세요.

💡 에러가 발생했을 때 어느 단계에서 실패했는지 명확히 알 수 있도록 각 어댑터에서 컨텍스트 정보를 포함한 에러를 던지세요.

💡 성능이 중요하다면 특정 어댑터에 캐싱을 추가하거나, 비동기 처리를 위해 Promise를 반환하도록 수정하세요.

💡 파이프라인을 설정 파일로 정의하여 코드 수정 없이 런타임에 파이프라인을 변경할 수 있게 만드세요.

💡 각 단계의 입력/출력 타입을 명확히 정의하여 타입 안정성을 확보하고, 잘못된 어댑터 조합을 컴파일 타임에 잡을 수 있게 하세요.


8. 테스트 가능한 어댑터 설계

시작하며

여러분이 외부 API에 의존하는 코드를 테스트하려는데, API 호출 없이는 테스트가 불가능한 상황을 겪어본 적 있나요? 테스트가 느리고, 불안정하고, API 사용량 제한에 걸립니다.

이런 문제는 외부 의존성이 코드에 직접 박혀있을 때 발생합니다. 결제 게이트웨이, 이메일 서비스, 클라우드 스토리지 등 외부 서비스를 직접 호출하는 코드는 단위 테스트가 거의 불가능하고, 통합 테스트도 느리고 비용이 많이 듭니다.

테스트 커버리지가 낮아지고 버그가 프로덕션으로 흘러갑니다. 바로 이럴 때 필요한 것이 테스트 가능한 어댑터 설계입니다.

의존성 주입과 어댑터 패턴을 결합하여, 프로덕션에서는 실제 서비스를 사용하고 테스트에서는 mock 어댑터를 주입합니다.

개요

간단히 말해서, 테스트 가능한 어댑터 설계는 외부 의존성을 인터페이스로 추상화하고 의존성 주입을 통해 실제 구현과 테스트용 mock을 교체할 수 있게 만드는 설계 기법입니다. 프로덕션 코드는 실제 외부 서비스와 통신하는 어댑터를 사용하고, 테스트 코드는 미리 정의된 응답을 반환하는 mock 어댑터를 사용하여 빠르고 안정적인 테스트를 작성합니다.

예를 들어, 실제 결제 API 대신 항상 성공하거나 실패하는 mock 결제 어댑터를 주입하여 다양한 시나리오를 테스트합니다. 기존에는 new PaymentGateway()가 코드에 박혀있어 테스트가 불가능했다면, 이제는 생성자나 setter로 어댑터를 주입받아 테스트 시 mock으로 교체합니다.

핵심 특징은 외부 의존성을 완전히 격리하여 순수한 단위 테스트가 가능하다는 점, 테스트가 빠르고 안정적이며 비용이 들지 않는다는 점, 그리고 다양한 엣지 케이스(에러, 타임아웃, 네트워크 장애 등)를 쉽게 시뮬레이션할 수 있다는 점입니다. 이러한 특징들이 높은 테스트 커버리지와 코드 품질을 보장하며, 리팩토링과 기능 추가를 안전하게 만듭니다.

코드 예제

// 결제 게이트웨이 인터페이스
class PaymentGateway {
  async charge(amount, currency) {
    throw new Error('Must implement charge');
  }
}

// 실제 결제 어댑터 (프로덕션용)
class StripeAdapter extends PaymentGateway {
  async charge(amount, currency) {
    // 실제 Stripe API 호출
    console.log(`Charging ${amount} ${currency} via Stripe`);
    // 네트워크 지연 시뮬레이션
    await new Promise(resolve => setTimeout(resolve, 100));
    return { success: true, transactionId: 'txn_' + Date.now() };
  }
}

// Mock 결제 어댑터 (테스트용)
class MockPaymentAdapter extends PaymentGateway {
  constructor(shouldSucceed = true) {
    super();
    this.shouldSucceed = shouldSucceed;
    this.chargeHistory = [];
  }

  async charge(amount, currency) {
    // 호출 이력 기록 (테스트 검증용)
    this.chargeHistory.push({ amount, currency });

    if (this.shouldSucceed) {
      return { success: true, transactionId: 'mock_txn_123' };
    } else {
      throw new Error('Payment failed');
    }
  }

  // 테스트 헬퍼 메서드
  getChargeCount() {
    return this.chargeHistory.length;
  }
}

// 비즈니스 로직 (의존성 주입 사용)
class OrderService {
  constructor(paymentGateway) {
    this.paymentGateway = paymentGateway;
  }

  async processOrder(order) {
    try {
      const result = await this.paymentGateway.charge(order.amount, order.currency);
      return { orderId: order.id, paid: true, transactionId: result.transactionId };
    } catch (error) {
      return { orderId: order.id, paid: false, error: error.message };
    }
  }
}

// 프로덕션 사용
const stripeAdapter = new StripeAdapter();
const orderService = new OrderService(stripeAdapter);

// 테스트 사용
const mockAdapter = new MockPaymentAdapter(true);
const testService = new OrderService(mockAdapter);
testService.processOrder({ id: 1, amount: 100, currency: 'USD' }).then(result => {
  console.log('Test result:', result);
  console.log('Charge count:', mockAdapter.getChargeCount()); // 1
});

// 실패 시나리오 테스트
const failingAdapter = new MockPaymentAdapter(false);
const failTestService = new OrderService(failingAdapter);
failTestService.processOrder({ id: 2, amount: 100, currency: 'USD' }).then(result => {
  console.log('Failure test result:', result); // { orderId: 2, paid: false, error: 'Payment failed' }
});

설명

이것이 하는 일: 이 코드는 결제 게이트웨이를 추상 인터페이스로 정의하고, 프로덕션용 실제 구현과 테스트용 mock 구현을 제공하며, 비즈니스 로직은 생성자를 통해 어댑터를 주입받아 테스트 가능하게 만듭니다. 첫 번째로, PaymentGateway 인터페이스가 결제 기능을 추상화합니다.

StripeAdapter는 실제 Stripe API를 호출하는 프로덕션 구현으로, 네트워크 통신이 발생하고 실제 비용이 청구됩니다. 만약 OrderService가 내부에서 new StripeAdapter()를 직접 생성한다면, 테스트할 때마다 실제 API를 호출하게 되어 테스트가 느리고, 불안정하고, Stripe 계정이 필요하며, API 사용량 제한에 걸릴 수 있습니다.

두 번째로, MockPaymentAdapter가 테스트를 위한 가짜 구현을 제공합니다. 생성자에서 성공/실패 여부를 설정할 수 있어 다양한 시나리오를 테스트할 수 있습니다.

chargeHistory 배열에 모든 호출을 기록하여 "charge가 올바른 인자로 호출되었는가?", "몇 번 호출되었는가?" 같은 검증이 가능합니다. 네트워크 통신이 없으므로 테스트가 밀리초 단위로 완료되고, 항상 일관된 결과를 반환합니다.

세 번째로, OrderService가 생성자에서 paymentGateway를 주입받습니다. 이것이 의존성 주입(Dependency Injection)의 핵심입니다.

OrderService는 PaymentGateway 인터페이스만 알고, 실제 구현이 Stripe인지 mock인지 모릅니다. 프로덕션에서는 StripeAdapter를 주입하고, 테스트에서는 MockPaymentAdapter를 주입하여 동일한 비즈니스 로직을 다른 의존성으로 실행합니다.

여러분이 이 패턴을 사용하면 외부 서비스 없이도 모든 비즈니스 로직을 테스트할 수 있고, 네트워크 오류, 타임아웃, API 변경 같은 엣지 케이스를 쉽게 시뮬레이션할 수 있으며, CI/CD 파이프라인에서 빠르고 안정적인 테스트를 실행하여 배포 신뢰도를 높일 수 있습니다. TDD(Test-Driven Development)를 실천하고, 높은 테스트 커버리지를 달성하며, 리팩토링을 안전하게 수행하는 데 필수적인 패턴입니다.

실전 팁

💡 의존성 주입 컨테이너(InversifyJS, TSyringe 등)를 사용하면 어댑터 주입을 자동화하고 코드를 더 깔끔하게 만들 수 있습니다.

💡 mock 어댑터에 spy 기능을 추가하여 메서드 호출 횟수, 인자, 순서 등을 자동으로 추적하세요.

💡 테스트 픽스처를 만들어 일반적인 테스트 시나리오(성공, 실패, 타임아웃 등)를 재사용하세요.

💡 환경 변수나 설정 파일로 어댑터를 선택하여 개발, 스테이징, 프로덕션에서 다른 구현을 사용할 수 있게 하세요.

💡 통합 테스트에서는 실제 어댑터를 사용하되, 테스트 전용 계정이나 샌드박스 환경을 사용하여 프로덕션 데이터를 보호하세요.


#TypeScript#Adapter#DesignPattern#Refactoring#Architecture

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

마이크로서비스 아키텍처 완벽 가이드

모놀리식에서 마이크로서비스로의 전환은 현대 소프트웨어 개발의 핵심 화두입니다. 초급 개발자도 쉽게 이해할 수 있도록 실무 관점에서 마이크로서비스의 개념, 장단점, 설계 원칙을 스토리텔링으로 풀어냅니다.

Application Load Balancer 완벽 가이드

AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.

고객 상담 AI 시스템 완벽 구축 가이드

AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.

에러 처리와 폴백 완벽 가이드

AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.