프롤로그 - 해외여행에서 배운 소프트웨어 원리
미국 여행을 갔을 때의 일입니다. 호텔 방에 들어가서 한국에서 가져온 휴대폰 충전기를 콘센트에 꽂으려는 순간, 멈췄습니다. 구멍이 안 맞았습니다.
한국은 220v 두 갈래, 미국은 110v 세 갈래. 물리적으로 꽂을 수가 없었습니다.
당황한 저는 호텔 프론트에 내려가서 "돼지코 있어요?"라고 물었습니다. 직원이 웃으면서 Travel Adapter(여행용 어댑터)를 빌려줬습니다.
그 순간 깨달았습니다. "아, 이게 소프트웨어의 Adapter Pattern이구나."
어댑터 패턴의 본질 - 변환기
어댑터 패턴은 서로 다른 인터페이스를 연결해주는 변환기입니다.
- 휴대폰 충전기(Original System): 한국식 두 갈래 플러그.
- 미국 콘센트(New System): 세 갈래 소켓.
- 여행용 어댑터(Adapter): 두 갈래를 세 갈래로 바꿔주는 중간 매개체.
이걸 코드로 바꾸면:
- Legacy Code: 기존 시스템(10년 된 낡은 API).
- New API: 신규 시스템(최신 라이브러리).
- Adapter Class: 둘을 이어주는 변환 레이어.
왜 이게 필요한가? (제가 겪은 진짜 문제)
제가 운영하던 쇼핑몰에서 결제 시스템을 업그레이드할 일이 생겼습니다.
기존 시스템 (10년 전 코드)
10년 전, 외주 업체가 만들어준 PaymentService 클래스가 있었습니다.
class OldPaymentService {
void payCash(int cents) {
// 센트(Cent) 단위로 결제
System.out.println("결제: " + cents + "센트");
}
}
이 코드는 쇼핑몰 곳곳에 박혀있었습니다.
주문 처리, 환불 로직, 정산 시스템... 최소 30개 파일에서 payCash를 호출하고 있었습니다.
새로운 결제 API (PG사 제공)
그런데 PG사(Payment Gateway)에서 신규 API를 제공했습니다. 수수료가 30% 저렴하고, 해외 결제도 지원한다고 했습니다.
문제는 인터페이스가 달랐습니다:
interface ModernPaymentGateway {
void processPayment(double dollars);
}
- Old API:
payCash(int cents)- 센트 단위, int형. - New API:
processPayment(double dollars)- 달러 단위, double형.
제가 처음 생각한 해결책 - 뜯어고치기 (최악)
"그냥 모든 파일을 열어서 payCash(cents) 호출을 processPayment(dollars)로 바꾸자."
하지만 30개 파일을 수정하려면:
- 버그 주입 위험: 30곳 중 한 곳이라도 실수하면 결제 오류.
- 테스트 지옥: 30개 파일의 모든 경로를 다시 테스트.
- 시간: 최소 일주일.
게다가 만약 나중에 또 다른 결제 시스템을 도입하면? 또 30개 파일을 수정해야 합니다.
깨달음 - 어댑터 하나만 만들자
"기존 코드는 건드리지 말고, 중간에 변환 레이어(Adapter)만 하나 끼우면 되잖아?"
class PaymentAdapter implements ModernPaymentGateway {
private OldPaymentService oldService;
public PaymentAdapter(OldPaymentService old) {
this.oldService = old;
}
@Override
public void processPayment(double dollars) {
// 1. 달러를 센트로 변환
int cents = (int) (dollars * 100);
// 2. 기존 시스템에 위임
oldService.payCash(cents);
}
}
이제 30개 파일은 건드리지 않고, 어댑터 하나만 새 API에 끼워주면 끝입니다.
// Before: 기존 코드
OldPaymentService payment = new OldPaymentService();
payment.payCash(5000); // 50달러 = 5000센트
// After: 어댑터 적용
ModernPaymentGateway payment = new PaymentAdapter(new OldPaymentService());
payment.processPayment(50.00); // 50달러
어댑터 패턴의 3가지 핵심 가치
1. 기존 코드를 건드리지 않는다 (Open/Closed Principle)
OldPaymentService 클래스는 단 한 줄도 수정하지 않았습니다.
이미 검증된 코드를 건드리면 새로운 버그가 생깁니다.
어댑터는 "확장에는 열려있고, 수정에는 닫혀있다"는 SOLID 원칙을 준수합니다.
2. 테스트 범위 최소화
기존 코드(OldPaymentService)는 이미 10년간 검증됐습니다.
새로 테스트할 부분은 어댑터 하나뿐입니다.
@Test
void testAdapter() {
PaymentAdapter adapter = new PaymentAdapter(new OldPaymentService());
adapter.processPayment(50.00);
// 출력: "결제: 5000센트"
// ✅ 달러→센트 변환이 정확한지만 확인하면 됨
}
3. 미래 확장성
만약 6개월 후 또 다른 결제사(예: Stripe)를 추가하면? 어댑터 하나만 더 만들면 됩니다:
class StripeAdapter implements ModernPaymentGateway {
private StripeAPI stripe;
@Override
public void processPayment(double dollars) {
stripe.charge(dollars);
}
}
기존 코드(OldPaymentService, 30개 파일)는 여전히 안 건드립니다.
실제 경험 - XML→JSON 변환
제가 다른 프로젝트에서 어댑터 패턴을 쓴 사례입니다.
문제 상황
회사 내부 시스템은 XML 형식으로 데이터를 주고받았습니다 (2010년대 레거시).
class LegacySystem {
String getDataAsXML() {
return "<user><name>John</name></user>";
}
}
그런데 신규 웹앱은 JSON만 이해합니다.
// React 컴포넌트는 JSON을 원함
fetch('/api/user')
.then(res => res.json()) // JSON 기대
.then(data => console.log(data.name));
해결책: XML→JSON Adapter
class XmlToJsonAdapter {
private LegacySystem legacy;
public XmlToJsonAdapter(LegacySystem legacy) {
this.legacy = legacy;
}
public String getDataAsJSON() {
String xml = legacy.getDataAsXML();
// XML 파싱 후 JSON으로 변환
return "{\"name\": \"John\"}";
}
}
API 레이어에서:
@GetMapping("/api/user")
public String getUser() {
XmlToJsonAdapter adapter = new XmlToJsonAdapter(new LegacySystem());
return adapter.getDataAsJSON(); // JSON 반환
}
이제 React는 JSON을 받고, 레거시 시스템(LegacySystem)은 코드 수정 없이 그대로 유지됩니다.
6.5. 프론트엔드에서의 어댑터 패턴 (Data Normalization)
요즘 프론트엔드 개발에서 어댑터는 "데이터 정규화(Normalization)"를 위해 필수적입니다. 백엔드 API 응답 구조가 바뀔 때마다 컴포넌트를 다 뜯어고칠 순 없으니까요.
상황 - 백엔드가 응답 포맷을 엉망으로 줌
- API A (유저 정보):
{ user_name: "Kim", user_age: 20 }(Snake case) - API B (상품 정보):
{ productName: "TV", productPrice: 100 }(Camel case) - API C (주문 정보):
{ OrderID: 123 }(Pascal case)
이걸 그대로 컴포넌트에 넣으면 코드가 지저분해집니다. props.user_name, props.productName... 일관성이 없습니다.
해결책 - 어댑터로 표준화
// 1. 우리가 원하는 표준 타입 정의 (Camel Case)
interface User {
id: string;
name: string;
age: number;
}
// 2. 어댑터 함수 작성
const userAdapter = (apiData: any): User => {
return {
id: String(apiData.user_id || apiData.ID), // 다양한 경우의 수 처리
name: apiData.user_name || "Unknown",
age: Number(apiData.user_age) || 0
};
};
// 3. 컴포넌트에서는 항상 '표준화된 User'만 사용
const UserProfile = ({ user }: { user: User }) => {
return <div>{user.name} ({user.age})</div>; // 깔끔!
};
이렇게 API 응답을 어댑터 함수(userAdapter)에 통과시키는 패턴을 쓰면, 백엔드 개발자가 필드명을 user_name에서 username으로 바꿔도, 어댑터 함수 딱 한 곳만 수정하면 프론트엔드 전체가 안전합니다. 이것이 프론트엔드 아키텍처의 핵심입니다.
7. 어댑터의 두 가지 구현 방식 한 걸음 더
GoF 디자인 패턴에서는 어댑터를 두 가지로 나눕니다. 실무에서 질문을 받으면 이렇게 설명하세요.
1) Object Adapter (객체 어댑터) - 추천 👍
- 방식: 합성(Composition)을 사용합니다. 어댑터가 레거시 객체를 멤버 변수로 가지고(Wrap), 일을 위임합니다.
- 장점: 레거시 클래스의 하위 클래스들까지 유연하게 커버할 수 있습니다. 런타임에 갈아끼우기 쉽습니다.
- 코드:
class Adapter implements Target { private Legacy legacy; }
2) Class Adapter (클래스 어댑터) - 비추천 👎
- 방식: 상속(Inheritance)을 사용합니다.
extends Legacy implements Target형태입니다. - 장점: 어댑터 내에서 레거시의 protected 메서드까지 오버라이딩 할 수 있습니다.코드량이 적습니다.
- 단점: 상속은 결합도가 너무 높습니다. 부모(Legacy)가 수정되면 자식(Adapter)도 깨질 수 있습니다. 자바는 다중 상속이 안 되므로 유연성도 떨어집니다.
결론: 무조건 Object Adapter를 쓰세요. "상속보다는 합성을 사용하라(Composition over Inheritance)" 원칙을 따르는 것이 좋습니다.
8. Spring MVC의 HandlerAdapter 제대로 파보기
자바 스프링 프레임워크를 쓴다면, 당신은 매일 어댑터를 쓰고 있습니다. 바로 DispatcherServlet이 컨트롤러를 호출하는 방식입니다.
스프링에는 다양한 종류의 컨트롤러가 있습니다.
@Controller(어노테이션 기반)Controller인터페이스 구현 (과거 방식)HttpRequestHandler(서블릿 스타일)
DispatcherServlet 입장에서는 이 모든 다른 종류의 컨트롤러를 어떻게 다 실행할까요?
if (controller instanceof AnnotationController)... 이렇게 분기처리를 할까요? 아닙니다.
HandlerAdapter 인터페이스
스프링은 HandlerAdapter라는 어댑터 인터페이스를 정의해뒀습니다.
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request, Object handler, ...);
}
그리고 각 컨트롤러 타입에 맞는 구현체를 끼워넣습니다.
RequestMappingHandlerAdapter:@Controller처리용SimpleControllerHandlerAdapter:Controller인터페이스 처리용
덕분에 DispatcherServlet은 "이게 어떤 컨트롤러인지 몰라도" 어댑터에게 handle()만 호출하면 됩니다.
이것이 스프링이 수십 년간 하위 호환성을 유지하며 발전한 비결입니다. OCP(Open Closed Principle)의 정수죠.
9. 아키텍처 레벨의 확장 - 헥사고날 아키텍처 (Ports and Adapters)
어댑터 패턴을 코드 레벨이 아니라 아키텍처 레벨로 확장하면, 그 유명한 헥사고날 아키텍처(Hexagonal Architecture)가 됩니다. 다른 이름으로 "Ports and Adapters Architecture"라고도 부르죠. 이 이름이 본질을 더 잘 설명합니다.
핵심 아이디어 - "도메인을 격리하라"
우리의 소중한 비즈니스 로직(도메인)이 외부 세계(DB, Web, API)에 더럽혀지지 않게 보호하고 싶습니다. 그래서 도메인을 중심에 두고, 외부와의 소통은 오직 포트(Port)를 통해서만 합니다.
- Inside (Domain): 순수한 비즈니스 로직. JPA도, SQL도, HTTP도 모릅니다.
- Ports (Interfaces): 도메인이 외부와 소통하는 인터페이스 (예:
UserRepository,PaymentGateway). - Adapters (Outside): 포트를 구현하여 실제 외부 기술과 연결하는 구현체.
그림으로 이해하기
graph TD
subgraph Core ["Core Domain (Inside)"]
Service[OrderService]
Port[PaymentPort <interface>]
end
subgraph Adapters ["Adapters (Outside)"]
Web[Web Controller]
DB[JPA Repository]
External[PaypalAdapter]
end
Web --> Service
Service --> Port
External -. implements .-> Port
코드 예시
1. Port (도메인 내부)
// 도메인은 '결제'가 필요하다는 것만 알지, 카카오페이인지 페이팔인지는 모름.
public interface PaymentPort {
void pay(int amount);
}
2. Core Logic (도메인 내부)
@Service
public class OrderService {
private final PaymentPort paymentPort; // 인터페이스 의존
public OrderService(PaymentPort paymentPort) {
this.paymentPort = paymentPort;
}
public void placeOrder(int amount) {
// 비즈니스 로직...
paymentPort.pay(amount);
}
}
3. Adapter (도메인 외부, 인프라 계층)
@Component
public class KakaoPayAdapter implements PaymentPort {
private final KakaoClient kakaoClient;
@Override
public void pay(int amount) {
// 실제 외부 API 호출 (변환 로직 포함)
kakaoClient.kakaopay_request(amount);
}
}
왜 이렇게 할까요?
이렇게 하면 기술이 바뀌어도 도메인 코드는 안전합니다.
- DB를 MySQL에서 MongoDB로 바꿔도? -> Persistence Adapter만 갈아끼우면 됨.
- 결제사를 페이팔에서 토스로 바꿔도? -> Payment Adapter만 새로 만들면 됨.
- 웹 대신 CLI로 실행해도? -> Web Adapter 대신 Console Adapter를 쓰면 됨.
결국 "어댑터 패턴"을 시스템 전체 구조에 적용한 것이 바로 현대적인 클린 아키텍처의 핵심입니다. 어댑터 패턴 하나만 제대로 이해해도, 거시적인 아키텍처 설계까지 통달할 수 있습니다.
10. 헷갈리는 패턴 비교 (Adapter vs Proxy vs Facade)
비슷해 보이지만 목적(Intent)이 다릅니다.
| 패턴 | 목적 | 비유 |
|---|---|---|
| Adapter | 호환성. 안 맞는 인터페이스를 맞게 변환함. | 110v 돼지코 전압 변환기 |
| Decorator | 기능 확장. 인터페이스는 그대로 두고 기능만 덧붙임. | 커피 + 우유 + 시럽 |
| Proxy | 제어 & 캐싱. 접근을 제어하거나 무거운 작업을 지연 로딩함. | 비서가 사장님(Real) 스케줄 관리 |
| Facade | 단순화. 복잡한 서브시스템을 쓰기 쉽게 통합 인터페이스 제공. | 리모컨 (내부 복잡한 회로 몰라도 됨) |
- Tip: "인터페이스를 바꾼다" -> Adapter. "인터페이스는 똑같은데 뭘 더 한다" -> Decorator/Proxy.
10. 핵심 정리
Q1. 어댑터 패턴과 브릿지(Bridge) 패턴의 차이는?
- 답변: 시점이 다릅니다.
- 어댑터: 이미 만들어진 두 시스템이 안 맞을 때, 사후(After)에 수습하기 위해 씁니다. (Retrofitted).
- 브릿지: 설계 단계부터 추상화와 구현을 분리하여 사전(Before)에 독립적인 확장을 고려한 것입니다.
Q2. Class Adapter와 Object Adapter 중 무엇을 선호하나요?
- 답변: Object Adapter(합성)를 선호합니다.
- Class Adapter(상속)는 부모 클래스의 모든 메서드가 노출되고, 상속이라는 강력한 결합을 생성하여 유연성이 떨어집니다. 반면 Object Adapter는 필요한 기능만 위임(Delegation)하므로 훨씬 유연하고 안전합니다.
Q3. 어댑터 패턴이 OCP를 만족하는 이유는?
- 답변: 기존 코드(Legacy)를 전혀 수정하지 않고(Closed), 새로운 인터페이스(Adapter)를 추가하여 기능을 확장(Open)했기 때문입니다. 클라이언트 코드는 어댑터 인터페이스만 바라보므로 내부 구현이 바뀌어도 영향을 받지 않습니다.
안티패턴 - 과도한 어댑터 남용
제가 실수했던 사례입니다. "어댑터 좋다!"며 모든 곳에 어댑터를 끼웠습니다.
// ❌ 불필요한 어댑터
class StringAdapter {
private String str;
public String getString() { return str; }
}
이건 단순 Wrapper입니다. 아무런 변환이 없으면 어댑터가 아닙니다.
어댑터를 써야 할 때
- 레거시 시스템과 신규 시스템을 연결할 때.
- 써드파티 라이브러리의 인터페이스가 내 코드와 맞지 않을 때.
- 한쪽을 수정할 수 없을 때 (외부 API, 이미 배포된 라이브러리).
코드 예시 - TypeScript로 본 실제
제 Next.js 블로그에서 실제로 쓴 코드입니다.
문제
Supabase는 날짜를 ISO 8601 형식(2025-06-03T00:00:00Z)으로 반환합니다.
하지만 제 UI 컴포넌트는 YYYY-MM-DD 형식만 이해합니다.
Adapter
class DateAdapter {
constructor(private isoDate: string) {}
getFormattedDate(): string {
return this.isoDate.split('T')[0]; // "2025-06-03"
}
}
// 사용
const post = await supabase.from('posts').select('created_at').single();
const adapter = new DateAdapter(post.created_at);
console.log(adapter.getFormattedDate()); // "2025-06-03"
Supabase 코드(created_at 형식)는 건드리지 않고, 내 UI는 원하는 형식을 받습니다.
마치며 - "뜯어고치지 말고 감싸라"
처음 코딩을 배울 때는 "잘못된 건 고쳐야지!"라고 생각했습니다. 하지만 실제로는 "이미 작동하는 코드는 건드리지 않는 게 최고"라는 걸 배웠습니다.
어댑터 패턴은 바로 그 철학의 구현체입니다.
- 기존 코드: 그대로 둔다 (버그 위험 0%).
- 신규 요구사항: 어댑터로 감싼다.
- 테스트: 어댑터만 집중 테스트.
여행 가방에 돼지코 하나 챙기듯, 코드베이스에도 어댑터 하나 끼워두세요. 그게 '우아한' 개발자입니다.