디자인 패턴

어댑터 패턴 (Adapter Pattern)

개발정리 2022. 7. 5. 17:55

일상에서 220V 코드를 110V 콘센트에 꽂을 때 어댑터를 사용해 본 경험이 있을것이다. 

어댑터 패턴도 위 경험과 매우 유사한 사례라고 할 수 있다.

 

 

 

어댑터 패턴 (Adapter Pattern)


구조 패턴인 어댑터 패턴을 설명하면, Client 가 사용하는 Interface 는 정해져있는데 내가 작성한 코드(Adaptee)는 해당 Interface 를 따르지 않을 때  Client 와  Adaptee 사이의 간극을 Adapter 로 매꿔서 Adaptee 를 재사용할 수 있도록 하는 패턴이다.

 

즉, 기존 코드를 클라이언트가 사용하는 인터페이스의 구현체로 바꿔주는 패턴이다.

 

 

 

어댑터 패턴 (Adapter Pattern) 적용 전 


LoginHandler, UserDetails, UserDetailsService  security 패키지에서 제공하는 코드로 라이브러리 코드라고 생각하자.

 

UserDetails

유저의 이름과 패스워드를 반환하는 메서드를 추상화한 인터페이스

public interface UserDetails {
    String getUsername();
 
    String getPassword();
}

 

UserDetailsService

username을 받아서 UserDetails 를 반환하는 메서드를 추상화한 인터페이스

public interface UserDetailsService {
    UserDetails loadUser(String username);
}

 

LoginHandler

UserDetails와 UserDetailsService를 사용해서 Login을 처리하는 클래스

login 메서드는 굉장히 간단한데, UserDetailsService에서 username으로 읽어와서 UserDetails의 패스워드가 일치한지 판단해서 로그인을 처리한다.

public class LoginHandler {
    private UserDetailsService userDetailsService;
 
    public LoginHandler(UserDetailsService userDetailsService){
        this.userDetailsService = userDetailsService;
    }
 
    public String login(String username, String password){
        UserDetails userDetails = userDetailsService.loadUser(username);
 
        if(userDetails.getPassword().equals(password)){
            return userDetails.getUsername();
 
        }else{
            throw new RuntimeException();
        }
    }
}

 

 

 

위 이미지와 같이 두개의 클래스를 정의해보자.

 

Account

애플리케이션을 만들 때 유저 정보를 담는 클래스로 내가 직접 정의한 클래스이다.

@Data
public class Account {
    private String name;
    private String password;
    private String email;
}
public class AccountService {
    public Account findAccountByUsername(String username){
        Account account = new Account();
        account.setName(username);
        account.setPassword(username);
        account.setEmail(username);
 
        return account;
    }
 
    public Account createNewAccount(String username){
        return new Account();
    }
}

 

UserDetails, UserDetailsService, LoginHandler는 security 패키지에서 제공하는 코드로 다른 애플리케이션에서도 공통으로 사용하는 일종의 라이브러리 코드이며, Account AccountService는 애플리케이션 구현에 따라 달라지는 코드로 이 둘의 직접적인 관계는 없다. 

 

security 패키지가 제공하는 login 기능을 우리가 정의한 Account와 AccountService를 가지고도 돌아가도록 만드는 것이 목적이다.

 

정리해보자면, security 패키지가 제공하는 login 로직은 Client 에 해당하며 Client 는 UserDetails 와 UserDetailsService 라는 정해진 인터페이스(Target)를 사용한다.

 

애플리케이션에서 정의한 Account와 AccountService는 Adaptee에 해당하는데, Adapter 를 정의하여 Client와 Adaptee의 간극을 Adpater를 추가해서 메꿔주는 코드를 정의해보자.

 

 

 

 

어댑터 패턴 (Adapter Pattern) 적용


예시코드에서 클라이언트는 LoginHandler 에 해당하고, UserDetails 와 UserDetailsService 는 Target interface 에 해당한다.

 

public class LoginHandler {
    private UserDetailsService userDetailsService;
 
    public LoginHandler(UserDetailsService userDetailsService){
        this.userDetailsService = userDetailsService;
    }
 
    public String login(String username, String password){
        UserDetails userDetails = userDetailsService.loadUser(username);
 
        if(userDetails.getPassword().equals(password)){
            return userDetails.getUsername();
 
        }else{
            throw new RuntimeException();
        }
    }
}
public interface UserDetails {
    String getUsername();
 
    String getPassword();
}
 
public interface UserDetailsService {
    UserDetails loadUser(String username);
}

 

여기까지가 security 패키지에 해당한다.

 

이제부터의 관심사는 각각의 애플리케이션마다 따로 정의하는 Account  AccountService 를 각각 UserDetails UserDetailsService 에 어떻게 연결할지에 관한 이슈이다. 대표적으로 두 가지 해결법이 있는 듯하다. 하나씩 살펴보자.

@Data
public class Account {
    private String name;
    private String password;
    private String email;
}
public class AccountService {
    public Account findAccountByUsername(String username){
        Account account = new Account();
        account.setName(username);
        account.setPassword(username);
        account.setEmail(username);
 
        return account;
    }
 
    public Account createNewAccount(String username){
        return new Account();
    }
}

 

 

방법 1 . 어댑터를 별도의 구현체로 정의하는 방법

클라이언트가 사용하는 인터페이스의 규약을 만족하기 위해 Target interface 의 구현체를 만들고 내부에 어댑티에 해당하는 인스턴스를 멤버로 갖는 방식이다.

 

Target interface를 구현한 어댑터 클래스 AccountUserDetails, AccountUserDetailsService

@AllArgsConstructor
public class AccountUserDetails implements UserDetails {
    private Account account;
 
    @Override
    public String getUsername() {
        return account.getName();
    }
 
    @Override
    public String getPassword() {
        return account.getPassword();
    }
}
@AllArgsConstructor
public class AccountUserDetailsService implements UserDetailsService {
    private AccountService accountService;
 
    @Override
    public UserDetails loadUser(String username) {
        Account account = accountService.findAccountByUsername(username);
 
        return new AccountUserDetails(account);
    }
}
public class App {
    public static void main(String[] args) {
        AccountService accountService = new AccountService();
        UserDetailsService userDetailsService = new AccountUserDetailsService(accountService);
 
        LoginHandler loginHandler = new LoginHandler(userDetailsService);
 
        String res = loginHandler.login("hello", "hello");
 
        System.out.println(res);
    }
}

 

어댑터에 해당하는 별도의 구현체를 정의하면 기존의 코드는 전혀 손대지 않고 패턴을 적용할 수 있는 장점이 있다.

어댑티와 Target interface 에 해당하는 코드를 수정할 수 없는 상태라면 이와 같이 별도의 구현체를 정의하는 것이 합리적인 선택이다.

 

 

방법 2 . 어댑티 자체가 Target interface 의 구현체가 되는 방법

@Data
public class Account implements UserDetails {
    private String name;
    private String password;
    private String email;
 
    @Override
    public String getUsername() {
        return name;
    }
 
    @Override
    public String getPassword() {
        return name;
    }
}
public class AccountService implements UserDetailsService {
    public Account findAccountByUsername(String username) {
        Account account = new Account();
        account.setName(username);
        account.setPassword(username);
        account.setEmail(username);
 
        return account;
    }
 
    public Account createNewAccount(String username) {
        return new Account();
    }
 
    @Override
    public UserDetails loadUser(String username) {
        return findAccountByUsername(username);
    }
}
public class App {
    public static void main(String[] args) {
        AccountService accountService = new AccountService();
        LoginHandler loginHandler = new LoginHandler(accountService);
 
        String res = loginHandler.login("hello", "hello");
 
        System.out.println(res);
    }
}

 

첫 번째 방법과 비교했을 때 단점기존 코드가 특정 인터페이스를 구현하도록 강제한다는 점이다.

반면 장점으로 별도의 어댑터 구현체를 두지 않아도 된다는 장점이 있다.

 

단일 책임 원칙 관점에서 보자면 어댑터 구현체를 두는 것이 조금 더 객체지향에 가깝지 않나 싶다.

하지만 항상 원칙을 고수하기보단 현재 상황에 맞는 실용적인 판단도 중요하니 상황을 보고 판단하면 좋을 듯하다.

 

 

 

실무에선 어떻게 쓰이나?


  • java.util.Arrays#asList(T...)
  • java.util.Collections#list(Enumeration), java.util.Collections#enumeration()
  • java.io.InputStreamReader(InputStream)
  • java.io.OutputStreamWriter(OutputStream)
  • HandlerAdpter(Spring) 
    • 우리가 작성하는 다양한 형태의 핸들러 코드를 스프링 MVC가 실행할 수 있 는 형태로 변환해주는 어댑터용 인터페이스.

 

 

 

참고


 

코딩으로 학습하는 GoF의 디자인 패턴 - 인프런 | 강의

디자인 패턴을 알고 있다면 스프링 뿐 아니라 여러 다양한 기술 및 프로그래밍 언어도 보다 쉽게 학습할 수 있습니다. 또한, 보다 유연하고 재사용성이 뛰어난 객체 지향 소프트웨어를 개발할

www.inflearn.com