💬 Project

[이길어때] 전략 패턴으로 확장성있게 소셜 로그인 설계하기

date
Dec 18, 2023
slug
yigil-login-with-strategy-pattern
author
status
Public
tags
Java
Kernel360
Springboot
Strategy Pattern
summary
이길어때 프로젝트를 진행하면서 전략 패턴을 통해 로그인 기능을 확장성있게 설계한 경험을 기록합니다
type
Post
thumbnail
제목을 입력해주세요_-001 (8).png
category
💬 Project
updatedAt
Dec 18, 2023 08:09 AM

 
프로젝트의 로그인 파트 개발을 담당받고 개발을 시작하였습니다.
notion image
 
로그인은 사용자 경험 측면에서의 편리함을 주기위해, 각종 소셜 로그인 서비스를 이용한 로그인 서비스를 제공하기로 했습니다.
소셜 로그인 인증은 client에서 진행하고, 인증 후 받은 정보를 검증 후 문제가 없다면 회원가입 및 세션 로그인을 실행해주는 로직이었습니다.
저는 그래서 평소와 같이 로그인을 개발하다보니 문제점을 찾을 수 있었습니다.

무엇이 문제인가?

LoginController.java
@RestController @RequiredArgsController public class LoginController { private final LoginService loginService; @PostMapping("/api/v1/login/kakao") public ResponseEntity<LoginResponse> loginWithKakao() { //카카오 로그인 로직 } @PostMapping("/api/v1/login/naver") public ResponseEntity<LoginResponse> loginWithNaver() { //네이버 로그인 로직 } @PostMapping("/api/v1/login/facebook") public ResponseEntity<LoginResponse> loginWithFacebook() { //페이스북 로그인 로직 } @PostMapping("/api/v1/login/google") public ResponseEntity<LoginResponse> loginWithGoogle() { //구글 로그인 로직 } }
이렇게 소셜 로그인을 하니 구현할 때마다 로그인 처리를 위한 컨트롤러 메서드를 생성해야 했습니다.
이런식으로 구성하니 같은 동작을 하는 메서드의 중복이 많이 일어났습니다.

Path Variable로 엔드포인트를 통일하자

그래서 모든 요청의 엔드 포인트를 하나의 변수로 받아서, 변수의 값에 따라 다르게 동작하도록 구성을 변경하였습니다.
LoginController.java
@RestController @RequiredArgsConstructor public class LoginController { private final LoginService loginService; @PostMapping("/api/v1/login/{provider}") public ResponseEntity<LoginResponse> login( @PathVariable("provider") final String provider ) { LoginResponse res = new LoginResponse(); switch (provider) { case "kakao": res = loginService.loginWithKakao(); case "naver": res = loginService.loginWithNaver(); case "facebook": res = loginService.loginWithFacebook(); case "google": res = loginService.loginWithGoogle(); default: throw new RuntimeException("잘못된 로그인 요청"); } return ResponseEntity.ok(response); } }
이렇게 작성하니 하나의 메서드로 합칠 수 있었습니다.
이 구조도 물론 아까 전보다 훨씬 좋은 코드라고 볼 수 있지만, 로그인 로직이 추가될 때 마다 매번 컨트롤러에 구분을 위한 문자열 비교로직을 추가해야하다보니 유지보수성이 떨어지고 가독성도 떨어지는 측면이 있었습니다.
이를 해결하기 위해 전략 패턴(strategy pattern) 을 적용하였습니다.

전략 패턴은 무엇인가?

전략 패턴은 객체들이 할 수 있는 행위에 대한 전략 클래스를 생성하고, 유사한 행위들을 캡슐화하는 인터페이스를 정의하여 객체의 행위를 동적으로 바꾸고 싶은 경우 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 하나의 디자인 패턴입니다.
 
이 전략 패턴이 저희 서비스에 아주 적합한 부분이 로그인 요청의 엔드포인트 즉, provider 의 값에 따라 로그인 전략만을 수정하면, 코드의 중복을 줄이고 유연하고 확장성있게 로그인 도메인을 설계할 수 있을 것이라고 판단했습니다.
 
전략 패턴의 자세한 설명에서는 다른 글에서 더 깊이있게 정리해보도록 하겠습니다.

전략 패턴의 적용

LoginStrategy.java
public interface LoginStrategy { LoginResponse login(LoginRequest request, HttpSession session); String getProviderName(); }
먼저, 로그인에 필수적인 행위들을 캡슐화한 인터페이스인 LoginStrategy 를 생성합니다.
 
이후 실제 로직을 실행하기 위한, 전략클래스를 생성해보겠습니다.
(예시로 카카오 로그인 전략을 작성해보겠습니다)
KakaoLoginStrategy.java
@Service @RequiredArgsConstructor @Slf4j public class KakaoLoginStrategy implements LoginStrategy { private final String PROVIDER_NAME = "kakao"; private final MemberRepository memberRepository; @Override public LoginResponse login(LoginRequest request, HttpSession session) { if(isRequestValid(request)){ throw new InvalidLoginRequestException(INVALID_LOGIN_REQUEST); } Member member = memberRepository.findMemberById(request.getId()) .orElseGet(() -> registerNewMember(request)); session.setAttribute("memberId", member.getId()); return new LoginResponse("login succeed"); } @Override public String getProviderName() { return PROVIDER_NAME; } private boolean isRequestValid(LoginRequest request) { // 요청 유효성 검증 로직 } }
이런 식으로, 요청의 유효성을 검증하고 요청이 유효한 요청이라면, 멤버를 생성 혹은 조회 후 해당 멤버에 관한 세션을 생성하는 로직입니다.
이후 provider의 값에 따라 전략을 식별하도록 LoginStrategyManager 클래스를 생성합니다.
LoginStrategyManager.java
@Service public class LoginStrategyManager { private final Map<String, LoginStrategy> loginStrategyMap; public LoginStrategyManager(List<LoginStrategy> strategies) { loginStrategyMap = strategies.stream() .collect(Collectors.toMap(LoginStrategy::getProviderName, Function.identity())); } public LoginStrategy getLoginStrategy(String provider) { return loginStrategyMap.get(provider); } }
이렇게 작성하면, LoginStrategy 를 상속받는 모든 전략 클래스의 getProviderName 을 실행 후 주어진 provider 의 값과 비교하여 전략을 선택할 수 있습니다.
 
LoginController.java
@RestController @RequiredArgsConstructor public class LoginController { private final LoginStrategyManager loginStrategyManager; @PostMapping("/api/v1/login/{provider}") public ResponseEntity<LoginResponse> login( @PathVariable("provider") final String provider, @RequestBody LoginRequest loginRequest, HttpSession session ) { LoginStrategy strategy = loginStrategyManager.getLoginStrategy(provider); LoginResponse response = strategy.login(loginRequest, session); return ResponseEntity.ok(response); } }
최종적으로 이렇게 작성을 하면 로그인 전략이 추가되면 관련 로그인 로직 클래스만 추가하면 해결이 됩니다.
이렇게 작성을 해서 유연하고 확장성있는 로그인 설계를 할 수 있었습니다.
(물론 form 로그인 기능이 생긴다면,,, 달라지려나요….)