본문 바로가기
쿤즈 Dev/Design pattern

[Design Pattern] 어댑터 패턴: 호환성 없는 인터페이스의 연결 (feat. 스프링 시큐리티)

by Koonz:) 2024. 8. 9.
728x90

소프트웨어 개발에서는 종종 기존의 클래스를 재사용하려고 할 때, 해당 클래스의 인터페이스가 현재 요구사항에 맞지 않는 경우가 종종 발생합니다. 이럴 때 유용하게 사용할 수 있는 디자인 패턴이 어댑터 패턴입니다.

 

이번 글에서는 어댑터 패턴의 개념과 필요성, 그리고 이를 자바로 구현하는 방법을 알아볼게요.


🚀 어댑터 패턴

어댑터 패턴(Adapter Pattern)은 이름 그대로 어댑터처럼 사용됩니다. 보통은 콘센트를 이야기하는데요. 우리나라에서 220V에서 사용가능한 전자제품을 다른 나라로 가져갈 경우 110V에서 사용해야 할 때가 있습니다. 이때 어댑터를 연결해서 사용하죠.

 

어댑터 패턴은 기존 클래스를 수정하지 않고, 호환되지 않는 인터페이스를 사용하여 함께 작동할 수 있도록 하는 구조적 디자인 패턴입니다. 이 패턴은 클래스의 인터페이스를 클라이언트에서 기대하는 다른 인터페이스로 변환하여 호환성을 제공합니다.


🔌 어댑터 패턴의 필요성

어댑터 패턴은 다음과 같은 경우 필요로 합니다.

 

레거시 코드 통합

기존의 레거시 코드를 새로운 시스템이나 모듈과 통합할 때 인터페이스의 불일치 문제를 해결할 수 있습니다.

 

코드 재사용성 향상

기존 클래스를 수정하지 않고도 새로운 인터페이스에 맞춰 사용할 수 있으므로 코드 재사용성을 높일 수 있습니다.

 

유연한 설계

다양한 인터페이스를 가진 클래스를 유연하게 사용할 수 있으며, 새로운 요구사항에 쉽게 대응할 수 있습니다.


🧑🏻‍💻 어댑터 패턴 적용하기

이번에도 커피 주문 시스템에서 어댑터 패턴을 적용해 볼게요. 커피 주문 시스템을 위한 어댑터 패턴의 예제입니다.

☕️ github: https://github.com/koonsland/design-patterns

 

🤔 어댑터 패턴 적용 전

먼저 Coffee 클래스를 만들어 볼게요.

package com.koonsland.designpatterns.structural.adapter.example;

public class Coffee {
    private String type;
    private String temperature;
    private boolean complete;

    public Coffee(String type, String temperature) {
        this.type = type;
        this.temperature = temperature;
        this.complete = false;
    }

    public Coffee(String type, String temperature, boolean complete) {
        this(type, temperature);
        this.complete = complete;
    }

    public String getType() {
        return type;
    }

    public String getTemperature() {
        return temperature;
    }

    public boolean isComplete() {
        return complete;
    }

    public void makeCoffee() {
        this.complete = true;
    }

    @Override
    public String toString() {
        return "Coffee{" + "type='" + type + '\'' + ", temperature='" + temperature + '\'' + ", complete=" + complete + '}';
    }
}

 

커피 클래스에서는 타입과 온도만 받을 수 있게 만들어줍니다. 그리고 makeCoffee() 메서드를 통해서 완성을 할 수 있게 구성해 보았습니다. 다음은 CoffeeService 클래스입니다. CoffeeService 클래스에서는 주문을 받을 수 있도록 orderCoffee() 클래스를 만들어 줍니다.

package com.koonsland.designpatterns.structural.adapter.example;

public class CoffeeService {
    public Coffee orderCoffee(String type, String temperature) {
        Coffee coffee = new Coffee(type, temperature);
        coffee.makeCoffee();
        return coffee;
    }
}

 

CoffeeService의 orderCoffee() 메서드는 Coffee 인스턴스를 만들고 커피를 makeCoffee() 메서드를 사용하여 완성 후 Coffee 인스턴스를 return 합니다.

 

이제 클라이언트에서 CoffeeService 인스턴스를 통해서 커피를 주문을 받아볼게요.

package com.koonsland.designpatterns.structural.adapter.example;

public class Client {
    public static void main(String[] args) {
        CoffeeService coffeeService = new CoffeeService();

        Coffee americano = coffeeService.orderCoffee("아메리카노", "ICED");
        System.out.println(americano);

        Coffee latte = coffeeService.orderCoffee("카페라떼", "ICED");
        System.out.println(latte);
    }
}

 

CoffeeService 인스턴스를 만들어주고 메서드를 통해서 아메리카노와 라떼를 만들어 줄 수 있습니다. 아주 단순한 프로그램입니다. 결과를 볼게요.

Coffee{type='아메리카노', temperature='ICED', complete=true}
Coffee{type='카페라떼', temperature='ICED', complete=true}

 

⚙️ 어댑터 패턴 적용

어댑터 패턴은 기존 소스는 그대로 둘 예정입니다. 다만 클래스 이름과 구조만 살짝 변경하면서 진행해 보겠습니다.

먼저 Coffee 클래스와 CoffeeService 클래스는 그대로 둡니다. 변경하지 않고 확장클래스들을 만들어서 기존 클래스들을 그대로 사용할 수 있도록 하는 것이 이번 어댑터 패턴의 목적입니다.

 

가장 먼저 CoffeeMaker 인터페이스를 만들어 볼게요. 이 클래스는 Coffee 클래스의 값이 어떤 것인지 확인할 수 있는 메서드만 만들어 둡니다.

package com.koonsland.designpatterns.structural.adapter.after;

public interface CoffeeMaker {

    String getCoffeeType();

    String getTemperature();
    
    boolean isComplete();
    
    void brewCoffee();
    
    Coffee getCoffee();
}

 

CoffeeMaker 인터페이스의 구현체인 DefaultCoffeeMaker 클래스를 만들어줍니다.

package com.koonsland.designpatterns.structural.adapter.after;

public class DefaultCoffeeMaker implements CoffeeMaker {

    private final Coffee coffee;

    public DefaultCoffeeMaker(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public String getCoffeeType() {
        return coffee.getType();
    }

    @Override
    public String getTemperature() {
        return coffee.getTemperature();
    }

    @Override
    public boolean isComplete() {
        return coffee.isComplete();
    }

    @Override
    public void brewCoffee() {
        coffee.makeCoffee();
    }

    @Override
    public Coffee getCoffee() {
        return coffee;
    }
}

 

Coffee 클래스를 인스턴스로 가지고 있습니다. 이는 외부에서 만들 커피객체를 주입받고 최종적으로 brewCoffee() 메서드를 통해서 커피를 만들어 줍니다. 이제 이 커피메이커를 선택할 수 있는 CoffeeMakerService 인터페이스를 만들어 줍니다.

package com.koonsland.designpatterns.structural.adapter.after;

public interface CoffeeMakerService {

    CoffeeMaker getCoffeeMaker(String type, String temperature);
}

 

CoffeeMaker 인터페이스에서는 getCoffeeMaker() 메서드를 통해서 타입과 온도를 받으면 CoffeeMaker를 리턴하는 메서드를 만들어줍니다. 이제 구현체인 DefaultCoffeeMakerService 클래스를 구현해 볼게요.

package com.koonsland.designpatterns.structural.adapter.after;

public class DefaultCoffeeMakerService implements CoffeeMakerService {

    private final CoffeeService coffeeService;

    public DefaultCoffeeMakerService(CoffeeService coffeeService) {
        this.coffeeService = coffeeService;
    }

    @Override
    public CoffeeMaker getCoffeeMaker(String type, String temperature) {
        Coffee coffee = coffeeService.orderCoffee(type, temperature);
        return new DefaultCoffeeMaker(coffee);
    }
}

 

DefaultCoffeeMakerService 클래스는 CoffeeMakerService 인터페이스를 구현하고 CoffeeService 인스턴스를 주입받아서 가지고 있습니다. getCoffeemaker() 메서드에서는 CoffeeService 인스턴스를 이용해서 커피 주문을 받고 커피 객체를 DefaultCoffeeMaker 인스턴스를 만들면서 만든 재료의 커피를 넘겨줍니다.

 

이제 실제 클라이언트에서 테스트를 해볼게요.

package com.koonsland.designpatterns.structural.adapter.after;

public class Client {
    public static void main(String[] args) {
        CoffeeService coffeeService = new CoffeeService();
        CoffeeMakerService coffeeMakerService = new DefaultCoffeeMakerService(coffeeService);

        CoffeeMaker coffeeMaker = coffeeMakerService.getCoffeeMaker("아메리카노", "ICED");
        coffeeMaker.brewCoffee();
        System.out.println(coffeeMaker.getCoffee());

        coffeeMaker = coffeeMakerService.getCoffeeMaker("카페라떼", "ICED");
        coffeeMaker.brewCoffee();
        System.out.println(coffeeMaker.getCoffee());
    }
}

 

클라이언트에서는 CoffeeMakerService 인스턴스를 만들어 줄 때 DefaultCoffeeMakerService를 이용해서 인스턴스를 만들어 줍니다. 이때 주입해야 할 CoffeeService 인스턴스를 먼저 만들어주고 주입해서 넣어줍니다.

 

만들어진 coffeeMakerService 인스턴스에서 getCoffeeMaker를 호출하고 이렇게 만들어지는 coffeeMaker에서 실제 brewCoffee() 메서드를 호출하여 커피를 완성합니다. getCoffee() 메서드를 호출하여 Coffee 인스턴스를 출력해 보면 동일한 결과를 얻을 수 있습니다.

 

모두 인터페이스를 활용하기 때문에 CoffeeMaker와 CoffeeMakerService의 구현체를 확장해서 새로운 클래스를 만들어서 추가적인 기능을 만들어서 사용할 수 있습니다.


🧑🏻‍💻 스프링부트 어댑터 패턴

스프링 부트에서 어댑터 패턴을 볼 수 있습니다. 특히 스프링부트의 스프링 시큐리티에서 사용하는 패턴입니다. UserDetails와 UserDetailsService 클래스입니다. 이 두 클래스는 스프링 시큐리티에서 로그인을 위해 유저를 확인하고 검증하기 위해서 사용하는 인터페이스이며 이를 상속받아서 사용하면 됩니다. 간단하게 예제를 만들어 볼게요.

 

💡아래 설정 내용은 디자인패턴과는 무관한 단순 설정파일이므로 그대로 사용하시기만 하면 됩니다.

 

우선은 스프링 시큐리티를 사용하기 위해서 build.gradle 파일에 의존성을 추가합니다.

dependencies {
    // ...
    
    implementation 'org.springframework.boot:spring-boot-starter-security'
	
    //...
}

 

그리고 시큐리티 접근 권한 설정을 위해서 모두 허용하기 위해 아래와 같이 SecurityConfig 클래스에 설정을 추가합니다.

package com.koonsland.designpatterns.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);
        http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/**")
                .permitAll());
        return http.build();
    }
}

 

 

이제 로그인을 위해서 실제 구현체를 만들어 볼 텐데요. 먼저 회원정보가 필요하기 때문에 간단하게 이메일, 비밀번호를 저장하는 회원 클래스를 만들어 보겠습니다. 이름은 Member입니다.

package com.koonsland.designpatterns.structural.adapter.spring;

public class Member {
    private String email;
    private String password;

    protected Member() {
    }

    public Member(String email, String password) {
        this.email = email;
        this.password = password;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }
}

 

회원은 이메일과 비밀번호를 가지고 있으며 getter 메서드만 추가해 주었고, 생성자를 추가해 주었습니다.

다음은 이 회원을 사용하는 MemberService 클래스를 만들어 줄게요.

package com.koonsland.designpatterns.structural.adapter.spring;

public class MemberService {
    public Member findByEmail(String email) {
        // 실제는 DB의 Repository에서 조회함
        return new Member(email, email);
    }
}

 

MemberService 클래스에서는 findByEmail() 메서드를 만들어서 회원을 조회하는 기능을 만들어 줍니다. 지금은 예제이므로 DB조회 없이 새로운 인스턴스를 만들어서 return 하는 방법을 사용했습니다.

 

회원정보는 로그인 시 여러 가지를 확인할 수 있기 때문에 MemberService 클래스가 계속 수정될 수 있습니다. 그렇기 때문에 보통 스프링 시큐리티의 UserDetails와 UserDetailsService 인터페이스를 상속받아서 공통 메서드로 구현합니다.

 

가장 먼저 UserDetails를 상속받은 MemberUserDetails 구현체를 만들어 줍니다.

package com.koonsland.designpatterns.structural.adapter.spring;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class MemberUserDetails implements UserDetails {
    private final Member member;

    public MemberUserDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("USER"));
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }
}

 

MemberUserDetails는 UserDetails 인터페이스를 상속받아서 메서드를 구현해 줍니다. 가장 필수 요소인 것들만 구현해 보았습니다. 그리고 이 구현체에서는 Member 인스턴스를 주입받아서 사용합니다.

 

💡 Tip! UserDetails 메서드
UserDetails 인터페이스는 총 7개의 메서드가 있습니다. 이 중에서 3개는 필수구현이며 나머지 4개는 구현하지 않아도 됩니다. 이유는 default 메서드로 정의되어 있으며 이는 Java8에서 등장한 기능으로 인터페이스에서도 구현체를 직접 넣어서 구현할 수 있습니다.

 

이제 UserDetailsService 인터페이스의 구현체인 MemberUserDetailsService 클래스를 구현해 보겠습니다.

package com.koonsland.designpatterns.structural.adapter.spring;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

public class MemberUserDetailsService implements UserDetailsService {

    private final MemberService memberService;

    public MemberUserDetailsService(MemberService memberService) {
        this.memberService = memberService;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberService.findByEmail(username);
        return new MemberUserDetails(member);
    }
}

 

UserDetailsService 인터페이스에는 loadUserByUsername() 메서드가 있고 이를 실제로 구현해 주어야 합니다. 이때 MemberService 인스턴스를 주입받아서 사용할 수 있도록 합니다. 따라서 MemberService 클래스가 바뀌면 이 부분만을 수정해 주면 갈아 끼울 수 있습니다.

 

메서드 내부에서는 memberService에서 findByEmail() 메서드를 통해서 Member 인스턴스를 가져오고 MemberUserDetails 클래스에 주입해서 UserDetails를 return 합니다.

 

이제 마지막으로 로그인을 할 수 있는 서비스 클래스를 만들어서 테스트해 보겠습니다.

package com.koonsland.designpatterns.structural.adapter.spring;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

@Service
public class AdapterLoginService {

    public String login(String email, String password) {
        UserDetailsService memberUserDetailsService = new MemberUserDetailsService(new MemberService());
        UserDetails userDetails = memberUserDetailsService.loadUserByUsername(email);
        if (!userDetails.getPassword().equals(password)) {
            throw new IllegalStateException("로그인에 실패했어요.");
        }

        return userDetails.getUsername();
    }
}

 

AdapterLoginService 클래스에서 login() 메서드를 만들어줍니다. 로그인 메서드에서는 email과 password를 받아서 처리하도록 만들어주었습니다. 여기서 우리는 MemberUserDetails와 MemberUserDetailsService 클래스를 어댑터 패턴으로 생각하시면 됩니다. 이 인스턴스를 만들어 주고받은 인스턴스는 인터페이스 형태로 가지고 있다면 언제든지 어댑터를 새로 만들어서 사용할 수 있게 됩니다.


🚀 결론

이번 글에서는 커피 주문 시스템을 이용해서 어댑터 패턴을 알아보았습니다. 그리고 실제 스프링에서 어떻게 어댑터 패턴을 사용하지는 보기 위해서 스프링 시큐리티의 UserDetails와 UserDetailsService 클래스의 사용방법도 확인해 보았습니다.

 

이 모두는 디자인 패턴 중 어댑터 패턴을 이용했으며 객체지향 원칙인 OCP(Open-Close Principle, 개방-폐쇄 원칙)를 따르도록 구현이 되어 있습니다.  어댑터 패턴은 기존 클래스를 수정하지 않고도 새로운 인터페이스에 맞춰 사용할 수 있도록 하는 유용한 디자인 패턴입니다. 이를 통해 레거시 코드와의 호환성을 유지하면서 새로운 시스템을 통합할 수 있습니다.

 

궁금하신 부분이 있다면 댓글로 남겨주세요. 이 글이 도움이 되셨으면 합니다. 감사합니다.

댓글