
어댑터 패턴(Adapter): 110v 돼지코의 비밀
서로 다른 인터페이스를 연결해주는 변환기. 레거시 시스템과 신규 시스템을 이어주는 가장 강력한 디자인 패턴.

서로 다른 인터페이스를 연결해주는 변환기. 레거시 시스템과 신규 시스템을 이어주는 가장 강력한 디자인 패턴.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

미국 여행을 갔을 때의 일입니다. 호텔 방에 들어가서 한국에서 가져온 휴대폰 충전기를 콘센트에 꽂으려는 순간, 멈췄습니다. 구멍이 안 맞았습니다.
한국은 220v 두 갈래, 미국은 110v 세 갈래. 물리적으로 꽂을 수가 없었습니다.
당황한 저는 호텔 프론트에 내려가서 "돼지코 있어요?"라고 물었습니다. 직원이 웃으면서 Travel Adapter(여행용 어댑터)를 빌려줬습니다.
그 순간 깨달았습니다. "아, 이게 소프트웨어의 Adapter Pattern이구나."
어댑터 패턴은 서로 다른 인터페이스를 연결해주는 변환기입니다.
이걸 코드로 바꾸면:
제가 운영하던 쇼핑몰에서 결제 시스템을 업그레이드할 일이 생겼습니다.
10년 전, 외주 업체가 만들어준 PaymentService 클래스가 있었습니다.
class OldPaymentService {
void payCash(int cents) {
// 센트(Cent) 단위로 결제
System.out.println("결제: " + cents + "센트");
}
}
이 코드는 쇼핑몰 곳곳에 박혀있었습니다.
주문 처리, 환불 로직, 정산 시스템... 최소 30개 파일에서 payCash를 호출하고 있었습니다.
그런데 PG사(Payment Gateway)에서 신규 API를 제공했습니다. 수수료가 30% 저렴하고, 해외 결제도 지원한다고 했습니다.
문제는 인터페이스가 달랐습니다:
interface ModernPaymentGateway {
void processPayment(double dollars);
}
payCash(int cents) - 센트 단위, int형.processPayment(double dollars) - 달러 단위, double형."그냥 모든 파일을 열어서 payCash(cents) 호출을 processPayment(dollars)로 바꾸자."
하지만 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달러
OldPaymentService 클래스는 단 한 줄도 수정하지 않았습니다.
이미 검증된 코드를 건드리면 새로운 버그가 생깁니다.
어댑터는 "확장에는 열려있고, 수정에는 닫혀있다"는 SOLID 원칙을 준수합니다.
기존 코드(OldPaymentService)는 이미 10년간 검증됐습니다.
새로 테스트할 부분은 어댑터 하나뿐입니다.
@Test
void testAdapter() {
PaymentAdapter adapter = new PaymentAdapter(new OldPaymentService());
adapter.processPayment(50.00);
// 출력: "결제: 5000센트"
// ✅ 달러→센트 변환이 정확한지만 확인하면 됨
}
만약 6개월 후 또 다른 결제사(예: Stripe)를 추가하면? 어댑터 하나만 더 만들면 됩니다:
class StripeAdapter implements ModernPaymentGateway {
private StripeAPI stripe;
@Override
public void processPayment(double dollars) {
stripe.charge(dollars);
}
}
기존 코드(OldPaymentService, 30개 파일)는 여전히 안 건드립니다.
제가 다른 프로젝트에서 어댑터 패턴을 쓴 사례입니다.
회사 내부 시스템은 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));
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)은 코드 수정 없이 그대로 유지됩니다.
요즘 프론트엔드 개발에서 어댑터는 "데이터 정규화(Normalization)"를 위해 필수적입니다. 백엔드 API 응답 구조가 바뀔 때마다 컴포넌트를 다 뜯어고칠 순 없으니까요.
{ user_name: "Kim", user_age: 20 } (Snake case){ productName: "TV", productPrice: 100 } (Camel case){ 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으로 바꿔도, 어댑터 함수 딱 한 곳만 수정하면 프론트엔드 전체가 안전합니다. 이것이 프론트엔드 아키텍처의 핵심입니다.
GoF 디자인 패턴에서는 어댑터를 두 가지로 나눕니다. 실무에서 질문을 받으면 이렇게 설명하세요.
class Adapter implements Target { private Legacy legacy; }extends Legacy implements Target 형태입니다.결론: 무조건 Object Adapter를 쓰세요. "상속보다는 합성을 사용하라(Composition over Inheritance)" 원칙을 따르는 것이 좋습니다.
HandlerAdapter 제대로 파보기자바 스프링 프레임워크를 쓴다면, 당신은 매일 어댑터를 쓰고 있습니다. 바로 DispatcherServlet이 컨트롤러를 호출하는 방식입니다.
스프링에는 다양한 종류의 컨트롤러가 있습니다.
@Controller (어노테이션 기반)Controller 인터페이스 구현 (과거 방식)HttpRequestHandler (서블릿 스타일)DispatcherServlet 입장에서는 이 모든 다른 종류의 컨트롤러를 어떻게 다 실행할까요?
if (controller instanceof AnnotationController)... 이렇게 분기처리를 할까요? 아닙니다.
스프링은 HandlerAdapter라는 어댑터 인터페이스를 정의해뒀습니다.
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request, Object handler, ...);
}
그리고 각 컨트롤러 타입에 맞는 구현체를 끼워넣습니다.
RequestMappingHandlerAdapter: @Controller 처리용SimpleControllerHandlerAdapter: Controller 인터페이스 처리용덕분에 DispatcherServlet은 "이게 어떤 컨트롤러인지 몰라도" 어댑터에게 handle()만 호출하면 됩니다.
이것이 스프링이 수십 년간 하위 호환성을 유지하며 발전한 비결입니다. OCP(Open Closed Principle)의 정수죠.
어댑터 패턴을 코드 레벨이 아니라 아키텍처 레벨로 확장하면, 그 유명한 헥사고날 아키텍처(Hexagonal Architecture)가 됩니다. 다른 이름으로 "Ports and Adapters Architecture"라고도 부르죠. 이 이름이 본질을 더 잘 설명합니다.
우리의 소중한 비즈니스 로직(도메인)이 외부 세계(DB, Web, API)에 더럽혀지지 않게 보호하고 싶습니다. 그래서 도메인을 중심에 두고, 외부와의 소통은 오직 포트(Port)를 통해서만 합니다.
UserRepository, PaymentGateway).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
// 도메인은 '결제'가 필요하다는 것만 알지, 카카오페이인지 페이팔인지는 모름.
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);
}
}
이렇게 하면 기술이 바뀌어도 도메인 코드는 안전합니다.
결국 "어댑터 패턴"을 시스템 전체 구조에 적용한 것이 바로 현대적인 클린 아키텍처의 핵심입니다. 어댑터 패턴 하나만 제대로 이해해도, 거시적인 아키텍처 설계까지 통달할 수 있습니다.
비슷해 보이지만 목적(Intent)이 다릅니다.
| 패턴 | 목적 | 비유 |
|---|---|---|
| Adapter | 호환성. 안 맞는 인터페이스를 맞게 변환함. | 110v 돼지코 전압 변환기 |
| Decorator | 기능 확장. 인터페이스는 그대로 두고 기능만 덧붙임. | 커피 + 우유 + 시럽 |
| Proxy | 제어 & 캐싱. 접근을 제어하거나 무거운 작업을 지연 로딩함. | 비서가 사장님(Real) 스케줄 관리 |
| Facade | 단순화. 복잡한 서브시스템을 쓰기 쉽게 통합 인터페이스 제공. | 리모컨 (내부 복잡한 회로 몰라도 됨) |
제가 실수했던 사례입니다. "어댑터 좋다!"며 모든 곳에 어댑터를 끼웠습니다.
// ❌ 불필요한 어댑터
class StringAdapter {
private String str;
public String getString() { return str; }
}
이건 단순 Wrapper입니다. 아무런 변환이 없으면 어댑터가 아닙니다.
제 Next.js 블로그에서 실제로 쓴 코드입니다.
Supabase는 날짜를 ISO 8601 형식(2025-06-03T00:00:00Z)으로 반환합니다.
하지만 제 UI 컴포넌트는 YYYY-MM-DD 형식만 이해합니다.
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는 원하는 형식을 받습니다.
처음 코딩을 배울 때는 "잘못된 건 고쳐야지!"라고 생각했습니다. 하지만 실제로는 "이미 작동하는 코드는 건드리지 않는 게 최고"라는 걸 배웠습니다.
어댑터 패턴은 바로 그 철학의 구현체입니다.
여행 가방에 돼지코 하나 챙기듯, 코드베이스에도 어댑터 하나 끼워두세요. 그게 '우아한' 개발자입니다.