우리는 어댑터라는 단어에 매우 익숙할 것이다. 실생활에서 이미 널리 사용되고 있는 단어이기 때문이다. 충전 어댑터, 이어폰 잭 어댑터 등등 떠오르는 이미지들이 분명 있을 것이다. 디자인 패턴에서의 Adapter 역시 그들과 비슷한 맥락이다. 의미하는 바가 모두 같다.
옛날 옛적, 애플이 아이폰에서 '3.5mm 이어폰 단자'를 제거했다. (그저 에어팟을 끼워팔기 위한 전략) 이 때 즈음 출시한 아이폰의 패키징에는 아래와 같은 녀석을 동봉해주곤 했다. 다들 익숙할 것이다!
그렇다. 기존에 3.5파이 규격의 유선 이어폰을 쓰던 사용자들에게는 이어폰 단자의 제거가 매우 치명적이기 때문에, 충전 포트에 기존 이어폰을 꽂아 사용할 수 있도록 해주는 '어댑터'를 제공해준 것이다.
3.5mm 이어폰을 직접 충전단자에 꽂을 수 없다는 사실은 바보가 아닌 이상 다 알고 있다. 즉, '인터페이스가 맞지 않는 상황'이다. 이 상황에서 3.5mm-라이트닝 어댑터는 '3.5mm 이어폰을 충전단자에 꽂을 수 있도록' 해주는 중간 다리 역할을 한다. 인터페이스가 맞지 않음에도 불구하고 이를 사용할 수 있게 해주는 것이다.
이것이 바로 어댑터 패턴의 개념이다.
어댑터 패턴은 클래스의 인터페이스를 사용자가 원하는, 사용하고자 하는 다른 인터페이스로 변환하는 패턴으로, 서로 호환성이 전혀 없는 인터페이스를 사용하는 클래스들이 상호 호환되게끔 하여 함께 동작할 수 있도록 해준다. Wrapper 패턴이라고도 부른다.
다시 이어폰 잭을 예로 들어 이해를 해보자.
사용자는 3.5mm 이어폰을 갖고 있는데, 이를 활용해 음악을 듣고 싶어 한다. 그러나 본래 3.5mm와 라이트닝 포트는 절대 호환되지 않는다. 그럼에도 불구하고 '이어폰 잭 어댑터'가 있다면, 3.5mm 이어폰을 라이트닝 포트에 꽂을 수 있게 됨으로써 얼마든지 음악을 감상할 수 있다.
여기서 클라이언트는 아이폰 유저고, 클라이언트가 갖고 있는 인터페이스는 '3.5mm' 이다. 그리고 클라이언트가 갖고 있는 인터페이스가 '라이트닝 포트' 로 변환되길 원하는 것이다.
이 상황에서 '이어폰 잭 어댑터'가 호환되지 않는 두 인터페이스 간의 중간 다리 역할을 함으로써, 클라이언트의 요구사항에 맞춰 원하는 동작을 가능케 한다.
Target
클라이언트가 사용하길 원하는 인터페이스
Adaptee
클라이언트가 갖고 있는 인터페이스 (어댑터에서 사용하고자 하는 인터페이스)
Adapter
Target 인터페이스를 구현하는 클래스로, 이 때 Adaptee 의 함수 사용
Client
Target 인터페이스를 사용하는 (사용하고 싶어하는) 주체
이러한 구성요소들을 포함하여, 간단히 구현하자면 아래와 같다. (Kotlin 기준)
// Target Interface
interface Target {
fun operation()
}
// Adaptee
interface Adaptee {
fun specificOperation()
}
// Adaptee 인터페이스를 구현한 클래스
class SampleAdaptee : Adaptee {
override fun specificOperation() {
println("This is Adapter!")
}
}
// Adapter - Target 인터페이스 구현
class SimpleAdapter(val adaptee: Adaptee) : Target {
override fun operation() {
// 전달받은 Adaptee 구현 객체의 specificOperation() 호출
adaptee.specificOperation()
}
}
// Client
class Client {
var targetAdapter: Target = SimpleAdapter(SampleAdaptee())
fun execute() {
targetAdapter.operation()
}
}
fun main() {
val client = Client()
client.execute()
}
Client
는Adaptee
인터페이스를 갖고 있고,Target
인터페이스를 사용하고 싶어한다. 그러나Client
와Target
은 호환되지 않는다고 가정하자.
이 때 Target
인터페이스를 구현한 어댑터를 만들어주는데, Target
의 operation()
안에서 Adaptee 구현체에게 동작을 위임함으로써 Client
에서 Target
을 사용할 수 있도록 하는 어댑터 패턴을 구현해냈다. Target
의 코드를 바꾸지 않고도 이 쾌거를 이루어냈다.
한 번 이 예제 코드를 '3.5mm - 라이트닝' 이어폰 어댑터에 빗대어 생각해보자. 사용자 (클라이언트) 는 이미 3.5mm 이어폰을 갖고 있고, 이 이어폰은 음악을 재생할 수 있는 인터페이스를 탑재하고 있다.
그런데 이 3.5mm 이어폰은 라이트닝 포트와 호환되지 않는다. 따라서 라이트닝 포트와 호환 가능한 어댑터를 정의하여, 이어폰 기능을 덧붙여보자.
// Target Interface (라이트닝 포트 인터페이스)
interface LighteningPort {
fun operation()
}
// Adaptee (이어폰 인터페이스)
interface Earphone {
fun playMusic()
}
// Adaptee 인터페이스를 구현한 클래스 (3.5 이어폰 클래스)
class ThreePointFiveEarphone : Earphone {
override fun playMusic() {
println("노래가 흘러나오는 중..")
}
}
// Adapter - Target 인터페이스 구현 (라이트닝 포트에 이어폰 동작 추가)
class EarphoneAdapter(val earphone: Earphone) : LighteningPort {
override fun operation() {
// 전달받은 Adaptee 구현 객체의 playMusic() 호출
earphone.playMusic()
}
}
// Client
class IPhoneUser {
var adapter: LighteningPort = EarphoneAdapter(ThreePointFiveEarphone())
fun listenMusic() {
adapter.operation()
}
}
fun main() {
val client = IPhoneUser()
client.listenMusic()
}
클라이언트는 3.5 이어폰 인터페이스를 갖고 있고, 라이트닝 포트 인터페이스를 구현하는 이어폰 어댑터에 이를 넘겨줌(꽂음)으로써 라이트닝 포트의 동작에 3.5 이어폰 동작을 붙일 수 있게 되었다.
어댑터 패턴은 두 가지 방식으로 구현할 수 있는데, 위 예제 코드는 그 중 하나인 'Object Adapter' 방식을 사용한다. Adaptee 를 구현 객체를 넘겨줬기 때문이다. 두 가지 방식에 대해 아래에 소개한다.
어댑터 패턴은 변경할 수 없는 내부 구현, 라이브러리 등에 추가적인 기능을 만들고 싶을 때 유용하게 활용할 수 있다. 특히 기존에 사용하던 라이브러리의 동작을 바꿔야 하는 상황에서, 라이브러리의 구현을 바꾸는 것은 꽤나 위험한 선택일 것이다. 어떤 곳에서 사이드 이펙트가 발생할 지 모르기 때문이다.
따라서, 어댑터 패턴을 활용하여 기존 코드를 건드리지 않고 새로운 동작을 구현하면 훨씬 안정적이고 재활용성이 우수하다. 또한 폭넓은 확장 가능성을 고려해볼 수 있다.
다만, 어댑터 패턴을 구현하기 위해 필요한 구성요소들로 인해 클래스 자체가 많아지므로 복잡도가 증가할 수 있다. 하지만 용도에 맞게 적절히 사용한다면 충분히 아름다운 패턴이다.
와... 진짜 쉬운 글 감사합니다