본문 바로가기

Design pattern

유연하게 전략을 바꾸고 싶다고요? 전략(Strategy) 패턴

최근에 헤드퍼스트 디자인패턴이라는 책을 읽으면서, 디자인 패턴을 정리해보려 합니다.
제가 생각하는 디자인패턴의 가장 좋은 점은 내 코드를 다른 동료들에게 좀 더 잘 알려줄 수 있는 표현이라는 점인데요.
그중 GOF 디자인 패턴에 속한 패턴들에 대해서 헤드퍼스트 디자인패턴 책에서 소개하는 순서로 정리해 보겠습니다. 같이 공부하시죠 :)

 

전략 패턴을 내가 이해한대로 정의해보자.

전략패턴 UML

 

전략패턴은 객체의 행위를 캡슐화하여 런타임시에 행위를 동적으로 변경할 수 있도록 설계하는 패턴

풀어서 설명하면, 객체에서 어떤 일을 수행하는 방법이 여러 가지 일때, 변화하는 부분(행위)을 분리하여 인터페이스로 정의(캡슐화)하고 객체에 구성(컴포지션)으로 추가하여 여러가지 행위의 구현체를 만들어 필요에 따라서 선택할 수 있도록 하는 것입니다.

 

언제 사용을 고려해 볼 수 있을까?

  1. 객체 내의 상태 변경에 따라 행위의 조건처리가 크게 달라지고, 이 조건들이 계속 늘어날여지가 있을 때
  2. 객체 내의 변화될 수 있는 부분(행위)이 실행하는 방식에서만 차이가 있는 유사한 클래스들이 많은 경우
  3. 객체 내의 변화될 수 있는 부분(행위)을 애플리케이션 실행(런타임) 시에 다른 행위로 변경하고 싶을 때
코드로 예시를 들어볼게요. 😆

 

전략패턴을 고려해볼 수 있는 코드

  • 해당 코드는 여행계획 객체에서 다양한 교통수단에 따라 각각의 동작을 if-else 조건문으로 처리한 코드입니다.
  • 교통수단이 airplane이면 비행기를 타고 날고, train이면 기차를 타고 ca r면 자동차를 타도록 동작합니다.
public class TravelPlanner {

    private String modeOfTransport;

    public TravelPlanner(String modeOfTransport) {
        this.modeOfTransport = modeOfTransport;
    }

    public void startTravel() {
        if (modeOfTransport.equalsIgnoreCase("airplane")) {
            travelByAirplane();
        } else if (modeOfTransport.equalsIgnoreCase("train")) {
            travelByTrain();
        } else if (modeOfTransport.equalsIgnoreCase("car")) {
            travelByCar();
        } else {
            System.out.println("지원되지 않는 교통 수단입니다.");
        }
    }

    private void travelByAirplane() {
        System.out.println("비행기를 타고 하늘을 나는 중입니다.");
    }

    private void travelByTrain() {
        System.out.println("기차를 타고 기차 선로를 따라가는 중입니다.");
    }

    private void travelByCar() {
        System.out.println("자동차를 타고 도로를 주행하는 중입니다.");
    }
}

 

  • 클라이언트 코드
public class Main {
    public static void main(String[] args) {
        // 비행기 이동
        TravelPlanner planner = new TravelPlanner("airplane");
        System.out.println("여행 시작:");
        planner.startTravel();

        // 기차 이동
        planner = new TravelPlanner("train");
        System.out.println("여행 시작:");
        planner.startTravel();

        // 자동차 이동
        planner = new TravelPlanner("car");
        System.out.println("여행 시작:");
        planner.startTravel();
    }
}

 

 

뭐가 문제냐구요? ㅎㅎ 그럼 이 코드에서 만약에 다른 교통수단으로 배(Ship)가 추가된다면 어떨까요?
기존코드에 ship일때 처리하는 else if 문을 추가해야 된다고 생각하셨죠? 그렇게 된다면 객체지향의 변경에는 닫혀있고, 확장에는 열려있어야 한다는 OCP 원칙도 지켜지지 않을뿐더러 계속 지저분한 코드가 계속 늘어날 것입니다.
그렇다면 전략패턴을 적용해보겠습니다.

 

전략패턴을 적용한 코드

  • 여행에 교통을 이용하는 여러 가지 방법이 있을 때, 다양한 교통수단에 따라 행위를 분리해서 캡슐화한 인터페이스를 정의해 주고
public interface TravelStrategy {
    void travel();
}

 

  • 정의한 인터페이스를 Context 객체에 컴포지션으로 추가해 줍니다.
public class TravelContext {
	
    // TravelStrategy 인터페이스를 컴포지션으로 추가
    private TravelStrategy strategy;

    public TravelContext(TravelStrategy strategy) {
        this.strategy = strategy;
    }

    public void setStrategy(TravelStrategy strategy) {
        this.strategy = strategy;
    }

    public void startTravel() {
        strategy.travel();
    }
}

 

  • 이제 다양한 교통수단으로 구현된 TravelStrategy 인터페이스의 구현체를 필요에 따라 만들어준다면?
class AirplaneStrategy implements TravelStrategy {
    @Override
    public void travel() {
        System.out.println("비행기를 타고 하늘을 나는 중입니다.");
    }
}

class TrainStrategy implements TravelStrategy {
    @Override
    public void travel() {
        System.out.println("기차를 타고 기차 선로를 따라가는 중입니다.");
    }
}

class CarStrategy implements TravelStrategy {
    @Override
    public void travel() {
        System.out.println("자동차를 타고 도로를 주행하는 중입니다.");
    }
}

 

  • 클라이언트 코드
public class Main {
    public static void main(String[] args) {
        // 비행기 전략을 설정
        TravelContext context = new TravelContext(new AirplaneStrategy());
        System.out.println("여행 시작:");
        context.startTravel();

        // 기차 전략으로 변경
        context.setStrategy(new TrainStrategy());
        System.out.println("여행 시작:");
        context.startTravel();

        // 자동차 전략으로 변경
        context.setStrategy(new CarStrategy());
        System.out.println("여행 시작:");
        context.startTravel();
    }
}

 

이제 추가할 교통수단이 있다면 TravelStrategy 인터페이스를 구현한 구현체만 만들어주고, 필요에 따라서 setter로 교통수단만 변경해 주어 런타임시간에도 행위를 변경해줄 수 있습니다. 전략 패턴을 활용하니 더 객체지향스러운 코드로 변경되어, 유지보수하기 쉬워질 것 같지 않나요? ㅎㅎ

 

장점, 장점을 깔끔하게 정리해 보면...

장점

  • 새로운 전략을 추가해도 기존의 코드가 변경되지 않는다 (OCP 원칙을 지킬 수 있음)
  • 상속대신 위임을 사용할 수 있다. (Strategy 인터페이스를 컴포지션으로 받아서 사용)
  • 런타임 중에 전략의 변경이 가능해진다. (Setter를 활용해서 교체)

 

단점

  • 전략이 많아질수록 복잡도가 높아진다.
  • 클라이언트 코드에서 구체적인 전략을 알아야 한다. (그래야 갈아낄수 있으니까)

 

좀 더 적용한 예시를 보면 좋을 텐데? :)

자바에서 전략패턴 예시

정렬을 위한 Collections.sort 메서드를 사용할 때 두 객체를 비교하는 Comparator를 선택할 때 활용한 예시를 보자

public class StrategyInJava {

  public static void main(String[] args) {
    List<Integer> numbers = new ArrayList<>();
    numbers.add(10);
    numbers.add(5);

    System.out.println(numbers);

    // 새로운 익명객체를 넣어주는 방식으로 활용
    Collections.sort(numbers, new Comparator<Integer>() {
      @Override
      public int compare(Integer o1, Integer o2) {
        return o1 - o2;
      }
    });

    // static 메서드를 이용하여 내림차순 정렬
    // static final ReverseComparator REVERSE_ORDER = new ReverseComparator(); 객체를 넣어줌
    Collections.sort(numbers, Comparator.reverseOrder());

    System.out.println(numbers);
  }
}

 

스프링 시큐리티에서 전략패턴 예시

AuthenticationProvider 예시는 제가 실제 실무에서 비슷하게 활용했던 예시를 가져왔는데요. 제가 커스텀한 부분은 전략 행위를 제대로 분리했다라기에는 커스텀한 것에 가까워서, AuthenticationProvider 인터페이스는 전략패턴으로 활용될 수 있는데 커스텀한 객체를 비슷하게 활용했다 정도로 참고하시면 좋을 것 같아요. :)

 

  • AuthenticationProvider

Spring Security는 인증 과정을 처리하기 위해 다양한 인증 제공자(Authentication Provider)를 사용하여 여러 가지 인증 방식을 지원 예를 들어, 사용자 이름과 비밀번호 기반 인증, OAuth2 인증, LDAP 인증 등을 각기 다른 제공자로 구현

 

  • 여러 제공자로 변화할 수 있는 부분을 AuthenticationProvider 인터페이스로 정의
    • AuthenticationProvider 인터페이스는 스프링 시큐리티(Spring Security)에서 인증(authentication) 과정을 담당하는 핵심 인터페이스임, 이 인터페이스는 특정한 방식으로 사용자의 인증 정보를 검증하는 역할을 수행
package org.springframework.security.authentication;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

public interface AuthenticationProvider {
  Authentication authenticate(Authentication authentication) throws AuthenticationException;

  boolean supports(Class<?> authentication);
}

 

  • AutenticationProvider의 여러 구현체들

AuthenticationProvider 구현체들

 

  • 기본적으로 사용하기 위해 의존성 주입한 DaoAuthenticationProvider
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
@EnableConfigurationProperties(SecurityProperties.class)
public class WebSecurityConfig {

  @Bean
  public PasswordEncoder passwordEncoder(SecurityProperties properties) {
    return new BCryptPasswordEncoder(properties.getPasswordStrength());
  }

  @Bean
  public AuthenticationProvider authenticationProvider(AuthService authService,
																										   PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(authService);
    provider.setPasswordEncoder(passwordEncoder);
    return provider;
  }

 

  • DaoAuthenticationProvider
    • DaoAuthenticationProvider에서 "DAO"는 Data Access Object를 의미, 이 객체는 데이터베이스나 다른 영속성 계층에서 데이터를 가져오는 역할을 함 (DB에서 사용자 정보를 조회)
    • DaoAuthenticationProvider는 UserDetailsService 인터페이스를 사용하여 데이터베이스에서 사용자 정보를 조회, 이 인터페이스는 사용자의 이름(username)을 입력으로 받아 UserDetails 객체를 반환하는 메서드를 제공
    • 그러나, Credentials를 처리하는 additionalAuthenticationChecks 메서드는 다양한 예외처리를 하지 못함 (코드 수정도 불가)
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
  private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
  private PasswordEncoder passwordEncoder;
  private volatile String userNotFoundEncodedPassword;
  private UserDetailsService userDetailsService;
  private UserDetailsPasswordService userDetailsPasswordService;

  public DaoAuthenticationProvider() {
    this.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
  }

  protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    if (authentication.getCredentials() == null) {
      this.logger.debug("Failed to authenticate since no credentials provided");
      throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    } else {
      String presentedPassword = authentication.getCredentials().toString();
      if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
        this.logger.debug("Failed to authenticate since password does not match stored value");
        throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
      }
    }
  }

  protected void doAfterPropertiesSet() {
    Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
  }

  protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    this.prepareTimingAttackProtection();

    try {
      UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
      if (loadedUser == null) {
        throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
      } else {
        return loadedUser;
      }
    } catch (UsernameNotFoundException var4) {
      UsernameNotFoundException ex = var4;
      this.mitigateAgainstTimingAttack(authentication);
      throw ex;
    } catch (InternalAuthenticationServiceException var5) {
      InternalAuthenticationServiceException ex = var5;
      throw ex;
    } catch (Exception var6) {
      Exception ex = var6;
      throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
    }
  }

  protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
    boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(user.getPassword());
    if (upgradeEncoding) {
      String presentedPassword = authentication.getCredentials().toString();
      String newPassword = this.passwordEncoder.encode(presentedPassword);
      user = this.userDetailsPasswordService.updatePassword(user, newPassword);
    }

    return super.createSuccessAuthentication(principal, authentication, user);
  }

  ...
}

 

 

  • AuthenticationProvider를 커스텀해서 구현한 CustomAuthenticationProvider 클래스
    • 요구사항 변경으로 인해 사용자의 접근 타입(ProviderType) 별로 예외를 다르게 하여, 로그인하려는 타입과 가입한 타입이 다른 경우 가입한 타입을 알려줄 수 있는 예외를 던지는 처리를 할 수 있는 기능을 필요로 하게 됨
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
  private final AuthService authService;
  private final PasswordEncoder passwordEncoder;

  @Autowired
  public CustomAuthenticationProvider(AuthService authService, PasswordEncoder passwordEncoder) {
    this.authService = authService;
    this.passwordEncoder = passwordEncoder;
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
  }

  // 유저의 Authentication 정보를 받아서 인증을 처리하는 부분
  public Authentication authenticate(Authentication authentication, ProviderType providerType) {
    String username = authentication.getName();
    String password = (String) authentication.getCredentials();

    try {
      JWTUserDetails user = authService.loadUserByUsername(username);
      
      // 구현부분 수정
      if (!providerType.equals(user.getProviderType())) {
        throw new AnotherLoginException(user.getProviderType().name());
      }
      if (!this.passwordEncoder.matches(password, user.getPassword())) {
        throw new BadCredentialsException("password is not matched");
      }
      
      return createSuccessfulAuthentication(user, authentication);

    } catch (UsernameNotFoundException usernameNotFoundException) {
      throw new EmailNotFoundException(authentication.getName());
    }
  }

  private Authentication createSuccessfulAuthentication(final JWTUserDetails user, final Authentication authentication) {
    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user,
																																									      authentication.getCredentials(),
																																									      user.getAuthorities());
    token.setDetails(authentication.getDetails());
    return token;
  }

}

 

  • ProviderType (로그인한 접근 유형)
public enum ProviderType {
  LOCAL,
  KAKAO,
  FACEBOOK,
  APPLE,
  GOOGLE,
}

 

  • 클라이언트 코드에서 SecurityContextHolder.getContext(). setAuthentication() 메서드를 이용하여 CustomAuthenticationProvider 구현체로 갈아 끼워줌
public Token signIn(SignInInput input) {
    String email;
    String password;

    if (ProviderType.LOCAL.equals(input.getProviderType())) {
      email = getValidEmail(input.getEmail()).toLowerCase();
      password = getValidPassword(input.getPassword());
    } else {
      SnsUserInfo snsUserInfo = authService.getSnsUserInfo(input.getProviderType(), input.getAccessToken());
      email = snsUserInfo.getEmail();
      password = DUMMY_PASSWORD;
    }

    UsernamePasswordAuthenticationToken credentials =
    				new UsernamePasswordAuthenticationToken(email, password);
    try {
	// 이 부분에서 CustomAuthenticationProvider구현체로 변경
	// 스프링 시큐리티(Spring Security)에서 애플리케이션의 현재 사용자(또는 주체)에 대한 인증 정보를 수동으로 설정
	SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationProvider(authService, passwordEncoder)
                                                             .authenticate(credentials, input.getProviderType()));
                                                  
      User currentUser = userService.getCurrentUser();
      return authService.getNewToken(currentUser);
    } catch (BadCredentialsException badCredentialsException) {
      throw badCredentialsException;
    } catch (InvalidDataFormatException invalidDataFormatException) {
      throw invalidDataFormatException;
    } catch (AnotherLoginException anotherLoginException) {
      throw anotherLoginException;
    }

 

전략 패턴에 대해서 알아보고, 완벽하진 않지만 제가 활용했던 예시를 이야기해 보았는데요 :)
전략 패턴을 언제 고려해봐야 할지 공부했으니, 새로 만들 때나 리팩터링 할 때 고려해 보면 좋을 것 같아요.
혹시 전략 패턴을 적용했었던 기억이 있으신가요? 자랑해 주세요 ㅎㅎ
부족한 글 읽어 주셔서 감사합니다. 또한 잘못된 내용 있으면 지적해 주시면 감사하겠습니다. 🙏

 

 

하얀 종이개발자

 

Reference

https://refactoring.guru/ko/design-patterns/strategy - Refactoring GURU 전략패턴

https://en.wikipedia.org/wiki/Strategy_pattern - 위키피디아 전략패턴

https://www.hanbit.co.kr/store/books/look.php?p_code=B6113501223 - 헤드퍼스트 디자인패턴 (전략패턴 부분)