최근 객체지향 관련 강의를 들으면서 전략 패턴을 알게 됐다. 전략 패턴을 배우자마자 “아, 이거 OAuth 소셜 로그인에 적용하면 딱 적합하겠는데?”라는 생각이 들어서 바로 적용해봤다.
전략 패턴은 변하지 않는 부분을 Context에 두고, 변하는 부분을 Strategy라는 인터페이스로 분리하여 문제를 해결하는 방식이다.
GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 다음과 같다.
알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
소셜 로그인에 대입해보면, 변하지 않는 부분은 회원 저장 및 중복 유무 체크, 변하는 부분은 공급자별 외부 API 호출 및 파싱이다. 그렇기 때문에 전략 패턴을 도입하기에 굉장히 적합한 케이스라고 생각했다.
현재 스팟의 소셜 로그인 코드는 비슷한 책임을 갖고 있음에도 어떤 경우는 구현 클래스로만 작성되어 있었고, 또 어떤 경우는 인터페이스를 두고 구현체가 있었다. 또한 세 종류의 공급자를 사용했는데, 각각 구현 방식이 전부 달랐다. 카카오와 네이버는 핵심 로직이 동일했지만, 두 클래스 모두 그 로직을 따로 가지고 있어서 코드 응집도가 낮았다.
가장 큰 문제는 새로운 로그인 방식이 추가될 때마다 OAuthService 내부 로직을 직접 수정해야 했다는 점이다. 이는 OCP(Open-Closed Principle)를 위반한 것이었고, 확장할 때마다 코드 수정이 발생하는 구조였다.
이 문제를 전략 패턴을 적용해 해결하고자 했다.
전략 패턴 말고 다른 방법은 없었을까?
비슷한 대안으로는 템플릿 메서드 패턴이 있다. 하지만 템플릿 메서드 패턴은 전략 패턴과 달리 추상 클래스 + 상속 구조라서 더 강한 결합을 요구한다.
상속을 받는다는 건 특정 부모 클래스에 의존한다는 뜻이다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않더라도 부모를 알아야 하고, 이는 좋은 설계가 아니라고 판단했다.
또한 공급자별 차이가 많은 경우에는 템플릿 메서드 패턴이 유리하지 않다고 생각했다. 만약 재시도, 로깅 같은 공통 부가 로직을 강하게 도입해야 했다면 템플릿 메서드 패턴도 고려했겠지만, 현재 상황에서는 전략 패턴이 더 적합하다고 봤다.
전략 인터페이스에 getType()
메서드를 정의해서 어떤 타입의 전략인지 조회할 수 있게 설계했다. 이를 기반으로 전략 팩토리를 생성한다.
Spring은 List<Interface>
를 생성자 파라미터로 두면, 해당 타입을 구현한 모든 빈을 컨테이너에서 찾아와 리스트로 모아준다.
@Component
public class OAuthStrategyFactory {
private final Map<LoginType, OAuthStrategy> strategyMap;
public OAuthStrategyFactory(List<OAuthStrategy> strategies) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(OAuthStrategy::getType, s -> s));
}
public OAuthStrategy getStrategy(LoginType type) {
return Optional.ofNullable(strategyMap.get(type))
.orElseThrow(() -> new UnsupportedSocialLoginTypeException(ErrorStatus._MEMBER_UNSUPPORTED_LOGIN_TYPE));
}
}
새 전략이 추가된다 하더라도 팩토리에는 수정할 필요 없이 전략 구현체만 빈으로 등록하면 이를 사용할 수 있다.
전략을 조회할 때는 Map
으로 직접적으로 접근하는 것이 아닌, getStrategy()
를 통해 접근하도록 하여 잘못된 전략 접근에 대한 에러 핸들링도 해당 클래스가 책임을 담당하도록 했다.
@Service
@RequiredArgsConstructor
public class OAuthService {
private final OAuthStrategyFactory strategyFactory;
private final OAuthMemberProcessor memberProcessor;
public String redirectURL(LoginType type) {
return strategyFactory.getStrategy(type).getOauthRedirectURL();
}
public SocialLoginSignInDTO loginOrSignUp(LoginType type, String code) {
OAuthStrategy strategy = strategyFactory.getStrategy(type);
return memberProcessor.processOAuthMember(type, strategy.toMember(code));
}
}
OAuthService
는 외부 전략을 고르고 로그인 정보만 가져오고, 이후 내부 회원 처리 로직은 OAuthMemberProcessor
가 맡도록 경계를 나눴다. Service가 비대해지는 걸 막고 책임을 분리하려는 의도였다.
이렇게만 봤을 때는 OAuthMemberProcessor
가 너무 많은 책임을 갖는게 아닐지 우려할 수 있다.
해당 클래스가 가져야 할 책임은 회원 가입 기록 존재 유무 확인, 회원 저장, 토큰 발급 등 많은 책임을 갖고 있다. 이는 책임을 분리하여 클래스가 하나의 책임만 갖도록 리팩토링했다. 아래와 같이 OAuthMemberProcessor
는 흐름을 총괄하는 정도의 책임을 부여하고, 세부적인 책임은 각각의 클래스가 담당할 수 있도록 했다.
@Transactional
public MemberResponseDTO.SocialLoginSignInDTO processOAuthMember(OAuthProfile oAuthProfile) {
SocialAccountResult socialAccountResult = oAuthMemberConflictProcessor.resolveConflict(oAuthProfile);
Member member = socialAccountResult.member().orElseGet(() -> oAuthMemberCreator.createFrom(oAuthProfile));
boolean isSpotMember = profileCompletenessChecker.isComplete(member.getId());
TokenDTO token = tokenProvider.createToken(member.getId());
refreshTokenStore.replace(member.getId(), token.getRefreshToken());
return getDto(isSpotMember, token, member);
}
그럼 정말 기존 코드를 전혀 고치지 않고 새로운 로그인 타입을 추가할 수 있을까? 예외 케이스는 없을까?
새로운 로그인 타입을 추가할 때도, 해당 공급자에 맞는 전략 구현체와 필요한 DTO 클래스만 있다면 기존 코드 수정 없이 추가 가능하다. 현재는 공급자에서 가져오는 정보가 Email, 닉네임, 프로필 이미지 정도인데, 이는 대부분의 공급자가 제공한다. 만약 특정 공급자가 제공하지 않는 정보가 있더라도 서비스에서 기본 값을 처리하면 되기 때문에 큰 문제는 없다고 판단된다.
결국 새로운 전략 구현체를 빈으로 등록만 하면 Spring이 알아서 주입해주기 때문에, 팩토리나 서비스 로직을 수정할 필요는 없다.
또한 코드 가독성도 크게 개선됐다. 처음에는 OAuthService 안에 공급자 분기 + 외부 호출 + 회원 처리까지 섞여 있어서 읽기 어렵고 수정 범위가 넓었는데, 현재는 공급자별 로직은 전략으로 격리되어 더욱 읽기 좋다.
그리고 기존 레거시 코드에 비해 역할과 책임 분리가 잘 되었기 때문에, 전략 단위로 시나리오를 쉽게 검증할 수 있다. 이전에는 외부 API 호출부터 토큰 발급까지 모든 역할이 다 해당 클래스에 들어있어 테스트 작성도 쉽지 않았다.