디자인패턴
Adapter Pattern(어댑터 패턴)
빈코더
2024. 12. 11. 15:40
728x90
Adapter Pattern
소프트웨어 디자인 패턴 중 하나로, 기존의 클래스나 인터페이스를 변경하지 않고도 다른 인터페이스와 호환되도록 만드는 데 사용됩니다. 주로 서로 호환되지 않는 인터페이스를 연결하여 시스템 간 통합을 쉽게 만드는 데 활용됩니다.
쉽게 설명하자면 Adapter Pattern은 중간 통역사라고 생각하면 편합니다.
- 예시로 이해해보기:
- A는 한국사람이고 영어를 못하고, B는 외국사람이고 영어만 가능합니다.
이때 통역사가 필요한데, 통역사는 한국어를 영어로, 영어를 한국어로 바꿔서 전달해줍니다.
결국, A(클라이언트)는 B와 원할하게 대화할 수 있게됩니다. - A: Client (타겟 인터페이스를 원하는 사용자)
B: Adaptee (타겟과 다른 인터페이스를 가지고 있는 클래스, 호환되지 않는 기존 객체)
통역사: Adapter (두 인터페이스를 연결해주는 중간다리) - 기존에 있는 것(Adaptee)을 수정하지 않고, 새로운 요구사항(Target)에 맞춰주는 중간 역할을 합니다.
- A는 한국사람이고 영어를 못하고, B는 외국사람이고 영어만 가능합니다.
예시
- 예시를 확인을 해봅시다.
// Target: 우리가 필요로 하는 USB 충전 인터페이스
interface USBCharger {
fun chargePhone()
}
// Adaptee: 벽 콘센트(220V 전기)
class WallSocket {
fun provideElectricity() {
println("Providing 220V electricity")
}
}
// Adapter: 충전기(220V를 USB로 변환)
class ChargerAdapter(private val socket: WallSocket) : USBCharger {
override fun chargePhone() {
socket.provideElectricity() // 220V를 받아서
println("Converting 220V to USB and charging phone") // USB로 변환
}
}
// Client: 핸드폰(USB 충전만 필요)
fun main() {
val wallSocket = WallSocket()
val charger: USBCharger = ChargerAdapter(wallSocket)
charger.chargePhone() // 핸드폰 충전 성공!
}
1. Target: USBCharger 인터페이스
- 클라이언트(핸드폰)가 기대하는 기능입니다.
- chargePhone() 메서드를 제공하며, USB 충전을 위한 통일된 인터페이스를 정의합니다.
- 클라이언트는 이 인터페이스만 알면 되며, 내부 구현 방식은 신경 쓰지 않습니다.
// Target: 우리가 필요로 하는 USB 충전 인터페이스
interface USBCharger {
fun chargePhone()
}
2. Adaptee: WallSocket 클래스
- 기존에 이미 존재하는 클래스입니다.
- 벽 콘센트에서 220V 전기를 제공하는 provideElectricity() 메서드를 가지고 있습니다.
- 그러나 USBCharger 인터페이스와는 호환되지 않습니다.
// Adaptee: 벽 콘센트(220V 전기)
class WallSocket {
fun provideElectricity() {
println("Providing 220V electricity")
}
}
3. Adapter: ChargerAdapter 클래스
- Target(USBCharger)과 Adaptee(WallSocket) 사이에서 중간다리 역할을 합니다.
- USBCharger 인터페이스를 구현하여 클라이언트가 기대하는 인터페이스를 제공합니다.
- 내부적으로 WallSocket의 provideElectricity()를 호출하여 USB 충전이 가능하도록 변환 로직을 수행합니다.
// Adapter: 충전기(220V를 USB로 변환)
class ChargerAdapter(private val socket: WallSocket) : USBCharger {
override fun chargePhone() {
socket.provideElectricity() // 220V를 받아서
println("Converting 220V to USB and charging phone") // USB로 변환
}
}
4. Client: main 함수
- 클라이언트는 USBCharger 인터페이스를 통해 충전기를 사용합니다.
- 실제로는 WallSocket의 전력을 사용하고 있지만, 클라이언트는 이를 알 필요가 없습니다.
- ChargerAdapter가 이를 추상화하여 처리해줍니다.
// Client: 핸드폰(USB 충전만 필요)
fun main() {
val wallSocket = WallSocket() // 기존 WallSocket 생성
val charger: USBCharger = ChargerAdapter(wallSocket) // Adapter로 감싸기
charger.chargePhone() // 클라이언트는 USBCharger 인터페이스만 사용
}
Adapter pattern의 장단점
1. 장점
- 프로그램의 기본 비즈니스 로직에서 인터페이스 또는 데이터 변환 코드를 분리할 수 있기 때문에 단일 책임 원칙 (SRP: Single Responsibility Priciple)을 만족합니다.
- SRP란? - 객체지향의 5대 원칙의 SOLID의 5가지 원칙 중 하나로 '클래스(혹은 객체)는 반드시 하나의 책임만 가져야 한다' 라는 원칙으로 클래스의 역할을 분리함으로써 코드 복잡성을 줄이고 유지보수와 테스트를 용이하게 만듭니다.
- 기존 클래스 코드를 건들지 않고 클라이언트 인터페이스를 통해 어댑터와 작동하기 때문에 개방-폐쇄 원칙 (OCP: Open Close Priciple)를 만족합니다.
- OCP란? - SRP와 같이 SOLID의 5가지 원칙중 하나로 '확장에는 열려 있고, 수정에는 닫혀 있어야 한다' 라는 원칙입니다. 즉 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확정(상속, 구현, 전략 패턴 등)을 통해 기능을 추가해야 합니다. 이를 통해 코드 변경으로 인한 부작용을 줄이고, 유지 보수성을 향상시킵니다.
2. 단점
- 코드가 복잡성이 증가합니다. 새로운 인터페이스와 어댑터 클래스 세트를 도입해야 하기 때문에 코드의 복잡성이 증가합니다.
- 직접 서비스(Adaptee) 클래스를 변경하는 것이 간단할 수 있는 경우가 있기 때문에 신중히 선택하여야 합니다.
실제 예제
- 실제 프로젝트상에서 어떻게 쓰이는지 확인해봅시다.
로그인 예제
예로 Adapter 패턴을 사용하지 않고 여러 플랫폼에 대한 로그인을 구성한다고 가정을 해보면
아래와 같이 코드가 작성될 겁니다.
fun login(platform)
if(platform == kakao) {
val kakao: Kakao = new Kakao()
kakao.loginPage()
kakao.Auth()
kakao.redirect()
} else if(platform == naver) {
val naver: Naver = new Naver()
naver.loginPage()
naver.login()
} else {
...
}
platform에 맞는 객체를 생성해서 로그인하도록 하는 코드입니다.
위 예제는 platform이 2개이지만 google, facebook 등등이 더 추가되면 코드가 길어지고
플랫폼이 추가될때 else if문을 추가해줘야 하는 불편함이 생깁니다.
그럼 위 코드에 adapter pattern을 적용하여 변경해봅시다.
1. LoginAdapter 추상화
// 어댑터 인터페이스 선언
interface LoginAdapter {
fun goLoginPage(): LoginAdapter
fun requestLogin(id: String, pw: String): LoginAdapter
fun redirect(url: String): String
}
// 각각 어댑터 인터페이스를 구현
class KakaoLoginAdapterImpl: LoginAdapter {
private val kakaoLoginService = KakaoLoginService()
override fun goLoginService() {
kakaoLoginService.goLoginPage()
return this
}
override fun requestLogin(id: String, pw: String): LoginAdapter {
kakaoLoginService.requestLogin(id, pw)
return this
}
override fun redirect(url String) = kakaoService.redirect(url)
}
class NaverLoginAdapterImpl: LoginAdapter {
private val naverLoginService = NaverLoginService()
override fun goLoginService() {
naverLoginService.goLoginPage()
return this
}
override fun requestLogin(id: String, pw: String): LoginAdapter {
naverLoginService.requestLogin(id, pw)
return this
}
// 네이버 로그인은 redirect하지 않기 때문에 빈 메소드 정의
override fun redirect(url String): String = url
}
2. 추상화된 LoginAdapter를 사용하는 Client
class LoginService(private val loginAdapter: LoginAdapter) {
fun login(id: String, pw: String, rediredctUrl: String) = loginAdapter
.goLoginPage()
.requesetLogin(id, pw)
.redirect(redirectUrl)
}
val kakaoLoginService = LoginService(KakaoLoginAdapterImpl())
val naverLoginService = LoginService(naverLoginAdapterImpl())
kakaoLoginService.login("kakao_id", "kakao_pw", "kakao.com")
naverLoginService.login("naver_id", "naver_pw", "naver.com")
위와 같이 구현하게 되면 클라이언트는 어떤 로그인 서비스를 사용할지 스스로 판단하여 필요한 로직을 추가 구현할 필요 없이
로그인 어댑터 인터페이스만 사용하면 됩니다.
platform이 추가되어도 클라이언트 코드가 변경될 필요가 없습니다.
728x90