새 결제 화면을 만들면서 우리 시스템은 PaymentGateway라는 인터페이스로 결제를 다루기로 정했다. pay(long amount) 하나면 화면도 주문 서비스도 이 얼굴만 알면 된다. 그런데 실제로 카드를 긁는 일은 우리가 짠 코드가 아니라 결제 대행사가 준 SDK가 한다. 그 SDK의 클래스는 NicePayClient이고, 메서드 이름은 pay가 아니라 requestApproval(NicePayRequest req)이며, 금액은 long이 아니라 문자열 "50000"으로 받고, 결과는 우리가 원하는 boolean이 아니라 ResultCode 열거형으로 돌려준다. 하는 일은 정확히 같은데 — 돈을 승인받는다 — 얼굴이 안 맞는다.

여기서 선택지는 셋이다. 우리 인터페이스를 대행사 SDK 모양에 맞춰 뜯어고치거나, SDK 소스를 우리 규격대로 수정하거나(대개 불가능하다 — 서드파티다), 아니면 둘 사이에 번역기 한 겹을 세우는 것이다. 어댑터(Adapter)는 세 번째 길이다. 우리 세계는 PaymentGateway만 알고, 대행사 세계는 NicePayClient만 안다. 그 사이에서 payrequestApproval로 옮겨 통역하는 객체 하나를 끼워, 양쪽 어느 코드도 건드리지 않고 붙인다.

무엇이 문제인가 — 동기

어댑터가 필요한 자리는 늘 “이미 존재하는 것”과 “내가 원하는 것”의 어긋남에서 나온다. 상황이 두 갈래로 온다.

첫째, 레거시. 오래된 사내 라이브러리에 LegacyLogger가 있고, 이미 수십 군데가 그 writeLog(int level, String msg)를 부르고 있다. 새 코드에서는 표준 Logger.info(String) / Logger.error(String) 인터페이스로 통일하고 싶다. 레거시를 다 뜯어 새 인터페이스로 바꾸는 건 파급이 너무 크다 — 수십 곳이 함께 깨진다.

둘째, 서드파티. 방금의 결제 SDK처럼, 소스를 고칠 권한이 아예 없는 외부 클래스다. 벤더가 준 .jar 안의 클래스는 내 규격을 알 리 없고, 알 필요도 없다. 그런데도 내 시스템의 PaymentGateway 자리에 그것을 꽂아야 한다.

어댑터 없이 이 어긋남을 그냥 감당하면 어떻게 되나. 결제 화면 코드 안에 벤더의 얼굴이 직접 새어 들어온다.

// 어댑터가 없을 때 — 클라이언트가 벤더의 얼굴을 직접 안다
public class CheckoutService {
    private NicePayClient nicePay;   // 벤더 클래스에 직접 의존
 
    public boolean checkout(Order order) {
        NicePayRequest req = new NicePayRequest();
        req.setAmountString(String.valueOf(order.getAmount()));   // long → String 변환
        req.setTid(order.getId());
        ResultCode code = nicePay.requestApproval(req);           // 벤더 메서드 직접 호출
        return code == ResultCode.SUCCESS;                        // 벤더 열거형 직접 해석
    }
}

세 가지가 동시에 아프다. 하나, 벤더의 규격이 클라이언트로 번진다. 금액을 문자열로 바꾸는 일, NicePayRequest를 조립하는 일, ResultCode를 해석하는 일이 전부 결제 흐름 한복판에 들어앉는다. 둘, 대행사를 갈아 치우면 이 코드를 다 고쳐야 한다. 내일 TossPayClient로 바꾸면 CheckoutService가 통째로 흔들린다 — 결제 화면이 특정 벤더에 결박됐다. 셋, 같은 변환 코드가 여러 곳에 복제된다. 결제를 부르는 자리가 화면·배치·정산 셋이면, 이 문자열 변환과 열거형 해석이 세 번 반복된다.

판단 기준: “하는 일은 내가 원하는 그것과 같은데 부르는 방법(메서드 이름·인자 타입·반환값)만 다른” 기존 클래스가 있고, 그 클래스를 고칠 수 없거나 고치기엔 파급이 크면 어댑터를 의심한다. 함정: 하는 일 자체가 다른데(기능이 부족하거나 아예 없는데) 어댑터로 억지로 맞추려 들면, 그건 변환이 아니라 없는 기능을 어댑터 안에 새로 구현하는 것이다 — 어댑터가 통역기를 넘어 로직 덩어리가 되면 신호다.

패턴의 구조 — 누가 무엇을 하나

어댑터는 네 배역으로 이뤄진다. 이름은 낯설어도 관계는 단순하다.

Target(목표 인터페이스). 클라이언트가 쓰고 싶어 하는 얼굴이다. PaymentGatewaypay(long). 우리 세계의 규격이고, 클라이언트는 오직 이것만 안다.

Client(클라이언트). Target 인터페이스에 대고 프로그래밍하는 쪽. CheckoutServicePaymentGateway.pay만 부르지, 그 뒤에 무엇이 있는지 모른다.

Adaptee(적응 대상). 이미 있지만 얼굴이 안 맞는 클래스. NicePayClient. 레거시거나 서드파티라 손댈 수 없고, 손대지 않는 게 이 패턴의 요점이다.

Adapter(어댑터). Target 인터페이스를 구현하면서, 그 구현 안에서 Adaptee를 호출한다. NicePayAdapterPaymentGateway를 구현하고, pay(long) 안에서 인자를 문자열로 바꿔 nicePay.requestApproval을 부르고 ResultCodeboolean으로 돌려 번역한다. 이 객체 하나가 두 세계의 통역사다.

협력의 핵심은 어댑터가 두 얼굴을 동시에 쓴다는 데 있다. 바깥으로는 Target의 얼굴(pay)을 내밀어 클라이언트를 받고, 안으로는 Adaptee의 얼굴(requestApproval)을 불러 실제 일을 시킨다. 클라이언트는 통역사가 껴 있는 줄도 모르고 자기 말(pay)만 하면 되고, 벤더는 자기 말(requestApproval)로만 대답하면 된다. 어긋남의 부담을 어댑터 한 곳에 몰아넣은 것이다.

그런데 어댑터가 Adaptee를 “부르는” 방법이 두 가지다. 이것이 어댑터의 두 변종을 가른다. Adaptee를 필드로 들고 있다가 위임하면 객체 어댑터(합성), Adaptee를 상속해서 자기가 곧 Adaptee이면서 Target도 구현하면 클래스 어댑터(다중 상속)다. 자바는 클래스 다중 상속이 없어 후자가 제한되므로, 아래 코드는 객체 어댑터를 축으로 간다.

판단 기준: 클라이언트가 이미 Target 인터페이스에 대고 프로그래밍하고 있는가를 먼저 본다. 그렇다면 어댑터를 끼우는 순간 클라이언트는 한 줄도 안 바뀐다 — 이게 어댑터가 값을 내는 전제다. 함정: 클라이언트가 아직 아무 인터페이스에도 안 맞춰져 있고 벤더 클래스를 직접 부르고 있다면, 어댑터를 넣기 전에 먼저 Target 인터페이스를 정의하는 일이 선행돼야 한다 — 어댑터는 인터페이스가 이미 있을 때 그 뒤를 채우는 물건이다.

코드로 — 패턴이 자라나는 과정

벤더 얼굴이 클라이언트로 새어 든 상태에서 출발해, 통역 한 겹을 세워 클라이언트를 벤더로부터 떼어 내는 과정을 단계로 따라간다.

Refactoring Step 부적합 호출 — 클라이언트가 벤더의 얼굴을 직접 안다
1/4
// 우리가 원하는 규격(이미 정해 둔 것). 클라이언트는 이걸 쓰고 싶다.public interface PaymentGateway {    boolean pay(long amount);}// 벤더가 준 서드파티 클래스 — 손댈 수 없다. 얼굴이 우리와 다르다.public class NicePayClient {    public ResultCode requestApproval(NicePayRequest req) { /* ... */ }}public class CheckoutService {    private NicePayClient nicePay;   // Target이 아니라 벤더 클래스에 직접 의존    public boolean checkout(Order order) {        NicePayRequest req = new NicePayRequest();        req.setAmountString(String.valueOf(order.getAmount()));        req.setTid(order.getId());        ResultCode code = nicePay.requestApproval(req);        return code == ResultCode.SUCCESS;    }}// PaymentGateway를 만들어 놓고도 클라이언트가 그걸 안 쓴다. 문자열 변환·요청// 조립·열거형 해석이라는 '통역'이 결제 흐름 한복판에 흩어져 있다.

두 번째 스텝이 이 패턴의 전부다. 첫 스텝에 흩어져 있던 통역 — 문자열 변환, 요청 조립, 열거형 해석 — 이 사라진 게 아니라 NicePayAdapter 한 곳으로 모였다. 어댑터는 어긋남을 없애지 않는다. 어긋남을 감당하는 책임을 클라이언트에서 걷어다 한 클래스에 몰아넣을 뿐이다. 그 대신 클라이언트는 벤더의 존재를 잊고 자기 규격만으로 살고, 벤더 교체가 어댑터 한 개 추가로 끝난다.

마지막 스텝의 클래스 어댑터는 같은 목적지에 상속으로 가는 길이다. 합성 어댑터가 Adaptee를 “가진다”면 클래스 어댑터는 Adaptee “이다”. 객체 하나로 끝나고 Adaptee 내부에 더 깊이 손댈 수 있는 대신, 특정 Adaptee 클래스에 못 박혀 유연성을 잃는다. 자바에서는 다중 상속 제약까지 겹쳐, 특별한 이유가 없으면 객체 어댑터가 기본이다.

판단 기준: 어댑터를 세운 뒤 “벤더를 아는 코드가 조립부 한 줄로 줄었는가”를 확인한다. 어댑터·조립부 말고 다른 곳에서 벤더 클래스 이름이 보이면 통역이 아직 다 안 모인 것이다. 함정: 어댑터 안에서 금액을 변환하다 반올림 규칙을 바꾸거나 없는 필드를 지어내 채우기 시작하면, 그건 번역이 아니라 정책 결정이다 — 어댑터는 얼굴만 바꾸고 의미는 그대로 옮겨야 한다. 의미를 바꾸는 순간 디버깅이 두 세계에 걸쳐 새어 나간다.

결과 — 무엇을 얻고 무엇을 내주나

어댑터가 사는 값은 재사용과 격리다. 고칠 수 없는 레거시·서드파티를 내 규격에 그대로 끼워 재사용하고, 벤더의 규격(이상한 메서드 이름, 문자열 금액, 벤더 열거형)을 어댑터 한 겹 뒤에 가둔다. 클라이언트는 Target 인터페이스만 아니까 벤더 교체에 흔들리지 않는다 — NicePayAdapterTossPayAdapter로 갈아도 클라이언트는 그대로다. 같은 벤더에 어댑터를 여럿 물릴 수도 있고, 반대로 여러 벤더를 하나의 Target으로 통일할 수도 있다. 운영비용 렌즈로 보면 이득은 명확하다 — 벤더의 변경이든 교체든, 그 파급이 어댑터라는 좁은 방 안에 갇힌다.

대가는 간접층 하나다. 클라이언트와 실제 일을 하는 Adaptee 사이에 객체가 한 겹 더 낀다. 호출을 따라가려면 pay에서 어댑터를 거쳐 requestApproval까지 한 번 더 점프해야 하고, 클래스 수가 하나 는다. 통역이 단순한 이름·타입 변환이면 이 대가는 거의 공짜지만, 어긋남이 클수록(양쪽 데이터 모델이 많이 다를수록) 어댑터가 두꺼워지고 그 안의 변환 로직 자체가 새로운 버그 자리가 된다. 어댑터가 얇을수록 좋은 어댑터다.

판단 기준: 얻는 것(재사용·벤더 격리)이 내주는 것(간접층 하나·어댑터 유지비)보다 큰지는 “벤더가 바뀌거나 여럿이 될 가능성”에 달렸다. 벤더가 바뀔 여지가 있거나 이미 여럿이면 어댑터의 격리가 값을 한다. 함정: 벤더가 영영 하나고 그 얼굴이 마침 내 규격과 거의 같다면, 어댑터는 통과만 하는 빈 껍데기가 된다 — 이럴 땐 간접층이 이득 없이 비용만 더한다.

언제 쓰고, 언제 과용인가

어댑터가 정확히 맞는 자리는 이렇다. 쓰고 싶은 기존 클래스가 있는데 그 인터페이스가 내 규격과 안 맞을 때. 그 클래스를 고칠 수 없거나(서드파티) 고치기엔 파급이 클 때(레거시). 여러 이질적 클래스를 하나의 공통 인터페이스로 통일해 쓰고 싶을 때. 자바 표준의 예가 널려 있다 — Arrays.asList는 배열을 List 얼굴로 감싸는 어댑터이고, InputStreamReader는 바이트 스트림(InputStream)을 문자 스트림(Reader)으로 통역하는 어댑터다. 둘 다 원본을 고치지 않고 다른 얼굴을 씌운다.

과용의 냄새는 이렇다. 어댑터를 통역이 아니라 기능 추가의 자리로 쓰기 시작하면 어긋난 것이다. 없는 기능을 어댑터 안에 구현하거나, 변환하다 비즈니스 규칙을 바꾸면 그건 어댑터가 아니라 서비스 로직이 잘못 든 것이다. 또 하나 — 아직 존재하지도 않는 벤더 교체를 대비해 “혹시 모르니” 모든 외부 호출을 어댑터로 감싸면, 오지 않을 변경에 거는 보험으로 간접층만 우수수 는다. 어댑터는 어긋남이 실재할 때 그 어긋남을 메우는 물건이지, 미래의 어긋남을 위한 예방 접종이 아니다.

여기서 “감싸는 사촌들”과 경계를 그어 둘 필요가 있다. 어댑터·데코레이터·퍼사드는 모두 다른 객체를 감싸지만 목적이 다르다. 어댑터는 얼굴을 바꾼다 — 인터페이스 A를 B로 통역하되 기능은 그대로. 데코레이터는 얼굴을 유지한 채 기능을 더한다 — 같은 인터페이스를 구현하면서 앞뒤로 책임을 덧씌운다(로깅·캐싱·암호화). 감싸는 구조는 똑 닮았지만, 어댑터의 감싼 것과 감싸인 것은 인터페이스가 다르고 데코레이터는 같다. 퍼사드는 여러 개를 하나로 줄인다 — 복잡한 서브시스템 여럿에 단순한 창구 하나를 낸다. 어댑터가 하나를 하나로 통역하는 1:1이라면, 퍼사드는 여럿을 하나로 묶는 N:1이고, 새 얼굴을 “만드는” 것이지 기존 얼굴에 “맞추는” 게 아니다.

특히 브리지와 헷갈리기 쉬운데, 이 둘은 시점이 정반대다. 어댑터는 사후 교정이다 — 이미 따로 만들어져 얼굴이 안 맞는 것을 나중에 통역해 붙인다. 브리지는 사전 분리다 — 설계 단계에서 추상과 구현을 미리 두 축으로 갈라, 애초에 독립적으로 확장되게 짠다. 어댑터는 “어긋난 걸 발견하고 맞추는” 일이고 브리지는 “어긋나지 않게 처음부터 나누는” 일이다. 그래서 어댑터는 남의 코드(레거시·서드파티)를 상대할 때, 브리지는 내가 처음부터 설계하는 코드에서 두 변화 축을 예상할 때 쓴다.

판단 기준: 통역하려는 두 인터페이스가 이미 존재하고 내가 정하지 않았다면 어댑터, 두 축을 내가 지금 설계하며 나눌 수 있다면 브리지다. 함정: 브리지로 처음부터 나눌 수 있는 상황인데 대충 짜 놓고 나중에 어댑터로 기워 붙이는 것을 반복하면, 통역 계층이 겹겹이 쌓여 “어댑터의 어댑터”가 생긴다 — 어긋남을 미리 막을 수 있었는지부터 물어야 한다.

다음으로 브리지