디자인패턴

Adapter Pattern(어댑터 패턴)

빈코더 2024. 12. 11. 15:40
728x90

Adapter Pattern

소프트웨어 디자인 패턴 중 하나로, 기존의 클래스나 인터페이스를 변경하지 않고도 다른 인터페이스와 호환되도록 만드는 데 사용됩니다. 주로 서로 호환되지 않는 인터페이스를 연결하여 시스템 간 통합을 쉽게 만드는 데 활용됩니다.

쉽게 설명하자면 Adapter Pattern은 중간 통역사라고 생각하면 편합니다.

 

  • 예시로 이해해보기:
    • A는 한국사람이고 영어를 못하고, B는 외국사람이고 영어만 가능합니다.
      이때 통역사가 필요한데, 통역사는 한국어를 영어로, 영어를 한국어로 바꿔서 전달해줍니다.
      결국, A(클라이언트)는 B와 원할하게 대화할 수 있게됩니다.
    • A: Client (타겟 인터페이스를 원하는 사용자)
      B: Adaptee (타겟과 다른 인터페이스를 가지고 있는 클래스, 호환되지 않는 기존 객체)
      통역사: Adapter (두 인터페이스를 연결해주는 중간다리)
    • 기존에 있는 것(Adaptee)을 수정하지 않고, 새로운 요구사항(Target)에 맞춰주는 중간 역할을 합니다.

예시

  • 예시를 확인을 해봅시다.
// 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