김코딩

어댑터 패턴(Adapter Pattern) 본문

디자인 패턴

어댑터 패턴(Adapter Pattern)

김코딩딩 2026. 1. 13. 21:12

디자인 패턴 시리즈의 여섯 번째 주제는 어댑터 패턴(Adapter Pattern)이다. 이번부터는 생성 패턴에서 구조 패턴으로 넘어간다.

 

생성 패턴"객체를 어떻게 만들까?"였다면, 구조 패턴"객체들을 어떻게 조합할까?"에 대한 해답이다.

 

예를 들어, 한국에서 쓰던 220V 전자기기를 미국에 가져가면 110V라서 사용할 수 없다. 이때 필요한 게 바로 '변압기(어댑터)'다.

어댑터 패턴도 마찬가지다. 서로 맞지 않는 인터페이스를 중간에서 연결해주는 역할을 한다.

이번 글에서는 어댑터 패턴무엇인지, 왜 필요한지, 어떻게 구현하는지 알아보자.


어댑터 패턴의 정의

어댑터 패턴(Adapter Pattern)은 호환되지 않는 인터페이스를 가진 객체들이 함께 동작할 수 있도록 중간에서 변환해주는 패턴이다.

 

핵심 특징:

  1. 기존 클래스를 수정하지 않고 재사용한다
  2. 서로 다른 인터페이스를 중간에서 변환한다
  3. 클라이언트와 서비스 사이의 호환성을 제공한다
  4. 래퍼(Wrapper) 역할을 한다

현실 비유:

  • 220V → 110V 변압기
  • USB-C → USB-A 변환 젠더
  • 멀티탭 (여러 플러그를 한 곳에)

왜 어댑터 패턴이 필요할까?

음악 플레이어를 만드는데, 기존에 MP3만 재생하는 라이브러리가 있다. 그런데 고객이 MP4, VLC 파일도 재생하고 싶다고 한다.

// 기존 MP3 플레이어 (이미 만들어져 있어서 수정할 수 없음)
public class MP3Player {
    public void playMP3(String fileName) {
        System.out.println("MP3 재생: " + fileName);
    }
}

// 새로운 요구사항: MP4, VLC도 재생해야 함
// 하지만 기존 코드는 MP3만 지원...

// 클라이언트 코드
public class MusicPlayer {
    public void play(String fileType, String fileName) {
        if (fileType.equals("mp3")) {
            MP3Player player = new MP3Player();
            player.playMP3(fileName);
        } else if (fileType.equals("mp4")) {
            // MP4는 어떻게 재생하지?
        } else if (fileType.equals("vlc")) {
            // VLC는 어떻게 재생하지?
        }
    }
}

 

문제점:

  1. 기존 MP3Player는 수정할 수 없다 (라이브러리나 레거시 코드)
  2. MP4Player, VLCPlayer는 다른 인터페이스를 가지고 있다
  3. 매번 새로운 플레이어를 추가하면 클라이언트 코드를 수정해야 한다

어댑터 패턴은 "중간에서 변환해주는 어댑터를 만들자"로 이 문제를 해결한다.


순수 Java로 구현하기

1단계: 타겟 인터페이스 정의

클라이언트가 사용할 통일된 인터페이스를 만든다.

// 미디어 플레이어 인터페이스 (타겟 인터페이스)
public interface MediaPlayer {
    void play(String fileType, String fileName);
}

2단계: 어댑티(Adaptee) - 적응시킬 클래스들

이미 존재하는 클래스들 (수정 불가능하다고 가정)

// MP4 전용 플레이어 (어댑티)
public class MP4Player {
    public void playMP4(String fileName) {
        System.out.println("MP4 재생: " + fileName);
    }
}

// VLC 전용 플레이어 (어댑티)
public class VLCPlayer {
    public void playVLC(String fileName) {
        System.out.println("VLC 재생: " + fileName);
    }
}

3단계: 어댑터 생성

어댑티를 타겟 인터페이스에 맞게 변환한다.

// 미디어 어댑터
public class MediaAdapter implements MediaPlayer {
    private MP4Player mp4Player;
    private VLCPlayer vlcPlayer;
    
    public MediaAdapter(String fileType) {
        if (fileType.equals("mp4")) {
            mp4Player = new MP4Player();
        } else if (fileType.equals("vlc")) {
            vlcPlayer = new VLCPlayer();
        }
    }
    
    @Override
    public void play(String fileType, String fileName) {
        if (fileType.equals("mp4")) {
            mp4Player.playMP4(fileName);
        } else if (fileType.equals("vlc")) {
            vlcPlayer.playVLC(fileName);
        }
    }
}

4단계: 클라이언트 코드

// 오디오 플레이어 (클라이언트)
public class AudioPlayer implements MediaPlayer {
    private MediaAdapter mediaAdapter;
    
    @Override
    public void play(String fileType, String fileName) {
        // MP3는 기본 지원
        if (fileType.equals("mp3")) {
            System.out.println("MP3 재생: " + fileName);
        }
        // MP4, VLC는 어댑터를 통해 재생
        else if (fileType.equals("mp4") || fileType.equals("vlc")) {
            mediaAdapter = new MediaAdapter(fileType);
            mediaAdapter.play(fileType, fileName);
        }
        else {
            System.out.println("지원하지 않는 형식: " + fileType);
        }
    }
}

// 사용 예시
public class Main {
    public static void main(String[] args) {
        AudioPlayer player = new AudioPlayer();
        
        player.play("mp3", "노래.mp3");
        player.play("mp4", "뮤직비디오.mp4");
        player.play("vlc", "영화.vlc");
        player.play("avi", "비디오.avi");
    }
}

 

 

장점:

  • 기존 MP4Player, VLCPlayer를 수정하지 않음
  • 클라이언트는 통일된 인터페이스만 사용
  • 새로운 플레이어 추가도 쉬움

실전 예제: 결제 시스템 통합

더 실무적인 예시를 보자. 여러 결제 시스템을 하나의 인터페이스로 통합하는 경우다.

// 우리가 사용할 통일된 결제 인터페이스
public interface PaymentProcessor {
    boolean processPayment(int amount);
    String getPaymentStatus();
}

// 기존 토스페이 라이브러리 (수정 불가)
public class TossPaymentService {
    public void sendPayment(int won) {
        System.out.println("토스페이로 " + won + "원 결제 요청");
    }
    
    public String checkStatus() {
        return "토스 결제 완료";
    }
}

// 기존 카카오페이 라이브러리 (수정 불가)
public class KakaoPayService {
    public boolean pay(int amount) {
        System.out.println("카카오페이로 " + amount + "원 결제");
        return true;
    }
    
    public String status() {
        return "카카오 결제 성공";
    }
}

// 토스페이 어댑터
public class TossPaymentAdapter implements PaymentProcessor {
    private TossPaymentService tossService;
    
    public TossPaymentAdapter() {
        this.tossService = new TossPaymentService();
    }
    
    @Override
    public boolean processPayment(int amount) {
        tossService.sendPayment(amount);
        return true;
    }
    
    @Override
    public String getPaymentStatus() {
        return tossService.checkStatus();
    }
}

// 카카오페이 어댑터
public class KakaoPaymentAdapter implements PaymentProcessor {
    private KakaoPayService kakaoService;
    
    public KakaoPaymentAdapter() {
        this.kakaoService = new KakaoPayService();
    }
    
    @Override
    public boolean processPayment(int amount) {
        return kakaoService.pay(amount);
    }
    
    @Override
    public String getPaymentStatus() {
        return kakaoService.status();
    }
}

// 주문 서비스 (클라이언트)
public class OrderService {
    private PaymentProcessor paymentProcessor;
    
    public OrderService(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }
    
    public void checkout(int amount) {
        System.out.println("=== 결제 시작 ===");
        boolean success = paymentProcessor.processPayment(amount);
        
        if (success) {
            System.out.println("상태: " + paymentProcessor.getPaymentStatus());
            System.out.println("=== 주문 완료 ===\n");
        }
    }
}

// 사용
public class Main {
    public static void main(String[] args) {
        // 토스페이로 결제
        OrderService orderService1 = new OrderService(new TossPaymentAdapter());
        orderService1.checkout(15000);
        
        // 카카오페이로 결제
        OrderService orderService2 = new OrderService(new KakaoPaymentAdapter());
        orderService2.checkout(25000);
    }
}

장점:

  • 각 결제 라이브러리의 다른 메서드명을 통일된 인터페이스로 사용
  • 새로운 결제 수단 추가 시 어댑터만 만들면 됨
  • 클라이언트 코드(OrderService)는 변경 없음

클래스 어댑터 vs 객체 어댑터

어댑터 패턴에는 두 가지 방식이 있다.

객체 어댑터 (권장)

지금까지 본 방식. 어댑티 객체를 합성(Composition)으로 사용한다.

public class MediaAdapter implements MediaPlayer {
    private MP4Player mp4Player;  // 객체를 가지고 있음
    
    @Override
    public void play(String fileType, String fileName) {
        mp4Player.playMP4(fileName);  // 위임
    }
}

 

장점:

  • 유연하다
  • 여러 어댑티를 동시에 사용 가능
  • Java에서 권장되는 방식

클래스 어댑터

다중 상속을 사용한다. (Java는 인터페이스로만 가능)

// Java에서는 인터페이스로만 가능
public interface MP4Playable {
    void playMP4(String fileName);
}

public class MediaAdapter implements MediaPlayer, MP4Playable {
    @Override
    public void play(String fileType, String fileName) {
        playMP4(fileName);  // 직접 호출
    }
    
    @Override
    public void playMP4(String fileName) {
        System.out.println("MP4 재생: " + fileName);
    }
}

 

단점:

  • Java는 다중 상속이 안 됨 (인터페이스만 가능)
  • 덜 유연함

어댑터 패턴의 장점

  1. 개방-폐쇄 원칙: 기존 코드를 수정하지 않고 새로운 어댑터 추가
  2. 단일 책임 원칙: 인터페이스 변환 로직을 분리
  3. 재사용성: 기존 클래스를 그대로 재사용
  4. 호환성: 서로 다른 인터페이스를 함께 사용 가능

어댑터 패턴의 단점

  1. 복잡도 증가: 새로운 클래스들이 추가됨
  2. 간접 호출: 어댑터를 거쳐서 호출하므로 약간의 오버헤드
  3. 과도한 사용: 간단한 경우 오히려 복잡해질 수 있음

Spring에서의 어댑터 패턴

Spring에서도 어댑터 패턴이 많이 사용된다.

// Spring의 HandlerAdapter가 대표적인 예시
public interface HandlerAdapter {
    boolean supports(Object handler);
    ModelAndView handle(HttpServletRequest request, 
                       HttpServletResponse response, 
                       Object handler);
}

// 우리 프로젝트에서 사용 예시
public interface NotificationService {
    void send(String message);
}

// 기존 이메일 서비스 (수정 불가)
@Component
public class EmailService {
    public void sendEmail(String to, String subject, String body) {
        System.out.println("이메일 발송: " + body);
    }
}

// 기존 SMS 서비스 (수정 불가)
@Component
public class SMSService {
    public boolean sendSMS(String phoneNumber, String text) {
        System.out.println("SMS 발송: " + text);
        return true;
    }
}

// 이메일 어댑터
@Component
public class EmailNotificationAdapter implements NotificationService {
    private final EmailService emailService;
    
    public EmailNotificationAdapter(EmailService emailService) {
        this.emailService = emailService;
    }
    
    @Override
    public void send(String message) {
        emailService.sendEmail("user@email.com", "알림", message);
    }
}

// SMS 어댑터
@Component
public class SMSNotificationAdapter implements NotificationService {
    private final SMSService smsService;
    
    public SMSNotificationAdapter(SMSService smsService) {
        this.smsService = smsService;
    }
    
    @Override
    public void send(String message) {
        smsService.sendSMS("010-1234-5678", message);
    }
}

// 사용
@Service
public class NotificationManager {
    private final List<NotificationService> notificationServices;
    
    public NotificationManager(List<NotificationService> notificationServices) {
        this.notificationServices = notificationServices;
    }
    
    public void notifyAll(String message) {
        for (NotificationService service : notificationServices) {
            service.send(message);
        }
    }
}

실무에서 언제 사용할까?

사용하면 좋은 경우:

  • 기존 클래스를 수정할 수 없을 때
  • 서로 다른 인터페이스를 가진 클래스들을 통합할 때
  • 레거시 시스템과 새 시스템을 연결할 때
  • 외부 라이브러리나 API를 통합할 때

사용하지 않아도 되는 경우:

  • 인터페이스를 직접 수정할 수 있을 때
  • 변환 로직이 매우 간단할 때
  • 한 번만 사용할 때

핵심 원칙:

"호환되지 않는 인터페이스를 연결해야 한다면 어댑터 패턴을 고려하라"


어댑터 vs 다른 패턴

패턴 목적 차이점
어댑터 인터페이스 변환 기존 인터페이스를 다른 인터페이스로 변환
데코레이터 기능 추가 인터페이스는 같고 기능만 추가
프록시 접근 제어 인터페이스는 같고 접근을 제어
퍼사드 단순화 복잡한 시스템을 간단한 인터페이스로

결론

어댑터 패턴은 호환되지 않는 인터페이스를 연결하는 패턴이다.

기억해야 할 포인트:

  • 변압기처럼 인터페이스를 변환한다
  • 기존 코드를 수정하지 않고 재사용한다
  • 서로 다른 시스템을 통합할 때 유용하다
  • 객체 어댑터(합성)를 주로 사용한다
  • 레거시 시스템 통합에 필수적이다

다음 글에서는 기존 객체에 새로운 기능을 추가하는 데코레이터 패턴을 알아볼 것이다.

 

다음 글 예고: 데코레이터 패턴 - 객체에 동적으로 기능을 추가하라