해외여행을 가서 110V 플러그를 220V 콘센트에 꽂으려면 변환 어댑터(돼지코)가 필요합니다. 소프트웨어에서도 마찬가지입니다.
**"호환되지 않는 인터페이스를 가진 객체들이 함께 동작할 수 있도록 감싸주는 것"**이 어댑터 패턴의 핵심입니다. 하지만 이 어댑터를 **"어떻게 연결하느냐"**에 따라 구조와 장단점이 완전히 달라집니다.
1. 문제 상황 (Scenario)
우리는 새로운 MediaPlayer 인터페이스를 따르는 플레이어를 만들고 있습니다. 그런데, 예전부터 쓰던 성능 좋은 OldAudioPlayer 클래스를 재사용하고 싶습니다. 하지만 메서드 이름이 다릅니다.
- Target (우리가 쓸 인터페이스): play(filename)
- Adaptee (가져다 쓸 기존 클래스): playMusic(filename)
2. 방식 1: 클래스 어댑터 (Class Adapter) - "상속"
자바의 상속(extends) 기능을 이용해 기존 클래스를 물려받아 인터페이스를 맞추는 방식입니다.
특징
- **"다중 상속"**의 개념을 구현해야 하므로, 자바에서는 extends(기존 클래스)와 implements(새 인터페이스)를 동시에 사용합니다.
코드 예시
// 1. 기존 클래스 (Adaptee)
class OldAudioPlayer {
void playMusic(String file) {
System.out.println("구형 플레이어 재생: " + file);
}
}
// 2. 목표 인터페이스 (Target)
interface MediaPlayer {
void play(String file);
}
// 3. 클래스 어댑터 (상속 활용)
// "나는 OldAudioPlayer이기도 하면서 MediaPlayer이기도 하다"
public class ClassAdapterImpl extends OldAudioPlayer implements MediaPlayer {
@Override
public void play(String file) {
// 부모의 메서드를 그대로 호출하거나 재정의 가능
playMusic(file);
}
}
장단점
- 장점: 어댑터 내부에서 OldAudioPlayer의 메서드를 오버라이딩(재정의)하여 동작을 입맛대로 바꿀 수 있습니다.
- 단점:
- 자바는 다중 상속이 불가능하므로, 이미 다른 클래스를 상속받고 있다면 이 방식을 쓸 수 없습니다.
- 부모 클래스(OldAudioPlayer)의 모든 상위 메서드가 외부에 노출되어 캡슐화가 깨질 수 있습니다.
3. 방식 2: 객체 어댑터 (Object Adapter) - "합성" (추천)
상속을 받지 않고, 기존 클래스의 객체를 **내부 필드(멤버 변수)**로 가지고 있는 방식입니다. GoF 디자인 패턴에서 권장하는 "상속보다는 합성(Composition)" 원칙을 따릅니다.
특징
- 생성자에서 기존 객체(Adaptee)를 주입받아 사용합니다.
코드 예시
public class ObjectAdapterImpl implements MediaPlayer {
// 상속 대신 내부에 객체를 품음 (Composition)
private final OldAudioPlayer oldPlayer;
public ObjectAdapterImpl(OldAudioPlayer oldPlayer) {
this.oldPlayer = oldPlayer;
}
@Override
public void play(String file) {
// 내가 직접 안 하고, 가지고 있는 객체에게 시킴 (위임, Delegate)
oldPlayer.playMusic(file);
}
}
장단점
- 장점:
- 상속을 쓰지 않으므로 클래스 계층 구조가 깔끔해집니다.
- OldAudioPlayer뿐만 아니라 그 자식 클래스들까지도 이 어댑터 하나로 다룰 수 있습니다.
- 단점: 어댑터 객체를 생성할 때, 내부에서 사용할 Adaptee 객체도 함께 생성(또는 주입)해줘야 하는 번거로움이 아주 조금 있습니다.
4. 실무 예시 (Java & Spring)
우리가 알게 모르게 자주 쓰고 있는 어댑터들입니다.
Java: InputStreamReader
바이트 스트림(InputStream)을 문자 스트림(Reader)으로 바꿔주는 대표적인 객체 어댑터입니다.
// System.in(바이트) -> InputStreamReader(어댑터) -> BufferedReader(문자)
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
Spring MVC: HandlerAdapter
스프링은 컨트롤러가 다양한 방식(@RequestMapping, Controller 인터페이스, Servlet 등)으로 구현되어 있어도 실행할 수 있어야 합니다. 이때 HandlerAdapter가 중간에서 다양한 컨트롤러의 형식을 맞춰주는 역할을 합니다.
요약: 무엇을 선택해야 할까?
| 구분 | Class Adapter (상속) | Object Adapter (합성) |
| 구현 방식 | extends Adaptee | private Adaptee instance |
| 결합도 | 높음 (부모-자식 관계) | 낮음 (느슨한 연결) |
| 유연성 | 부모 메서드 오버라이딩 가능 | 런타임에 Adaptee 교체 가능 |
| 추천 여부 | 부모의 protected 메서드를 써야 할 때만 | 대부분의 경우 (기본 추천) |
결론
특별히 기존 클래스의 메서드를 뜯어고쳐야 하는 상황이 아니라면, 유연하고 안전한 객체 어댑터(Object Adapter) 방식을 사용하는 것이 정석입니다. "상속은 강력하지만, 결합도를 높이는 주범"이라는 사실을 기억하세요!
'프로그래밍 > 디자인패턴' 카테고리의 다른 글
| 빌더 패턴(Builder Pattern) 총정리 (0) | 2025.12.07 |
|---|---|
| 컴포지트 패턴(Composite Pattern) (0) | 2025.12.07 |
| 프록시 패턴(Proxy Pattern) (0) | 2025.12.07 |
| 옵저버 패턴 vs Pub/Sub 패턴 (0) | 2025.12.07 |
| 팩토리 패턴(Factory Pattern) 총정리 (0) | 2025.12.07 |