안녕하세요! 이번 포스팅에서는 Spring Boot 환경에서 여러 소셜 로그인(카카오, 네이버, 구글 등)을 추가할 때, 중복 코드를 최소화하고 확장성을 극대화할 수 있는 전략 패턴(Strategy Pattern) 기반의 아키텍처에 대해 공유하고자 합니다.
왜 이 아키텍처가 필요한가?
단순히 카카오 로그인 하나만 구현한다면, KakaoLoginService 하나로 충분할 수 있습니다. 하지만 여기에 네이버, 구글 로그인이 추가되면 어떻게 될까요? 각 서비스마다 비슷한 듯 다른 로직이 반복될 겁니다.
- KakaoLoginService, NaverLoginService, GoogleLoginService...
- 각 서비스 안에 비슷한 로직: getAccessToken(), getUserProfile(), registerOrLoginUser()...
- 새로운 소셜 로그인을 추가할 때마다 AuthService나 AuthController의 분기문(if-else, switch)이 계속 늘어납니다.
이런 구조는 OCP(개방-폐쇄 원칙)를 위반하며, 유지보수를 악몽으로 만듭니다. 우리는 *변화하는 것* 과 *변하지 않는 것* 을 분리하여 이 문제를 해결할 수 있습니다.
- 변화하는 것: 각 소셜 플랫폼(카카오, 네이버)과의 통신 방식 및 응답 데이터 형식
- 변하지 않는 것: 우리 서비스의 회원가입/로그인 처리 로직, JWT 발급 로직
바로 이 "변화하는 것"을 '전략'으로 캡슐화하는 것이 우리 아키텍처의 핵심입니다.
1. 설계의 핵심: OAuthProvider 인터페이스 (공통 전략 정의)
모든 소셜 로그인 '전략'들이 따라야 할 공통 계약(인터페이스)을 정의합니다.
public interface OAuthProvider {
SocialType getProviderType(); // 자신이 어떤 플랫폼인지 알려주는 메서드
String getAccessToken(String authorizationCode); // 인가 코드로 액세스 토큰을 받아오는 메서드
OAuthUserProfile getUserProfile(String accessToken); // 액세스 토큰으로 표준화된 유저 프로필을 받아오는 메서드
}
여기서 OAuthUserProfile은 각 플랫폼의 제각각인 유저 정보 응답을 우리 시스템에 맞는 표준화된 객체로 변환한 DTO입니다.
// 표준화된 사용자 프로필 DTO
@Getter
@Setter
public class OAuthUserProfile {
private String providerId; // 플랫폼 고유 ID
private String email;
private String nickname;
}
2. 각 플랫폼별 '전략' 구현
이제 OAuthProvider 인터페이스를 각 플랫폼에 맞게 구체적으로 구현합니다. 이 클래스들은 @Component 어노테이션을 붙여 Spring의 Bean으로 등록합니다.
KakaoOAuthProvider.java (카카오 전략)
@Component
public class KakaoOAuthProvider implements OAuthProvider {
// ... 카카오 API와 통신하기 위한 설정 값(@Value) 및 WebClient ...
@Override
public SocialType getProviderType() {
return SocialType.KAKAO;
}
@Override
public String getAccessToken(String authorizationCode) {
// WebClient로 카카오 token-uri에 요청하여 액세스 토큰 받아오는 로직
}
@Override
public OAuthUserProfile getUserProfile(String accessToken) {
// WebClient로 카카오 user-info-uri에 요청
// 응답 받은 JSON을 파싱하여 표준 DTO인 OAuthUserProfile로 변환 후 반환
}
}
NaverOAuthProvider, GoogleOAuthProvider도 위와 동일한 구조로 각각의 API 명세에 맞게 구현합니다.
3. 전략을 선택하고 관리하는 '공장': OAuthProviderFactory
요청이 들어왔을 때 "카카오", "네이버" 등 이름에 맞는 '전략' 구현체를 찾아주는 역할을 합니다.
@Component
public class OAuthProviderFactory {
private final Map<SocialType, OAuthProvider> providers;
// 생성자 주입의 마법!
public OAuthProviderFactory(List<OAuthProvider> providerList) {
// Spring이 @Component가 붙은 모든 OAuthProvider 구현체를 List로 주입해줌
// 이 List를 SocialType을 Key로 하는 Map으로 변환하여 저장
this.providers = providerList.stream().collect(
Collectors.toUnmodifiableMap(OAuthProvider::getProviderType, Function.identity())
);
}
public OAuthProvider getProvider(SocialType socialType) {
// Map에서 요청된 SocialType에 맞는 구현체를 O(1) 시간 복잡도로 즉시 찾아 반환
return providers.get(socialType);
}
}
핵심 포인트: Spring의 의존성 주입 기능을 활용하면, List<OAuthProvider> 파라미터를 통해 해당 인터페이스의 모든 구현체 Bean을 한 번에 주입받을 수 있습니다. 생성자에서는 이 리스트를 Map으로 변환하여, 나중에 필요한 전략을 쉽게 찾을 수 있도록 준비해 둡니다.
4. 모든 것을 지휘하는 AuthService
이제 우리의 서비스 로직은 매우 깔끔하고 명확해집니다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final OAuthProviderFactory providerFactory;
private final UserRepository userRepository;
// ...
@Transactional
public String login(OAuthLoginRequestDto req) {
// 1. 팩토리를 통해 요청에 맞는 '전략'을 가져옴
OAuthProvider provider = providerFactory.getProvider(req.getSocialType());
// 2. 선택된 '전략'을 실행하여 외부 플랫폼과 통신
String accessToken = provider.getAccessToken(req.getAuthorizationCode());
OAuthUserProfile userProfile = provider.getUserProfile(accessToken);
// 3. "변하지 않는 공통 로직" 실행: 우리 서비스의 회원가입/로그인 처리
User user = registerOrLoginUser(userProfile, req);
// 4. "변하지 않는 공통 로직" 실행: JWT 발급
return createJwtToken(user);
}
private User registerOrLoginUser(OAuthUserProfile profile, OAuthLoginRequestDto req) {
// 이메일 또는 providerId로 사용자를 조회하고, 없으면 새로 생성하는 로직
// 이 로직은 어떤 소셜 플랫폼이든 동일하게 적용됨
}
}
AuthService는 더 이상 if (provider == "kakao") 같은 분기문으로 더러워지지 않습니다. 그저 팩토리에게 올바른 전략을 요청하고, 그 전략을 실행한 뒤, 공통적인 후처리만 담당하면 됩니다.
결론
이 아키텍처를 적용함으로써 우리는 다음과 같은 이점을 얻을 수 있습니다.
- 확장성: 나중에 '애플 로그인'을 추가해야 할 때, 우리는 AppleOAuthProvider 구현체 하나만 만들고 @Component를 붙이면 됩니다. 기존 코드는 단 한 줄도 수정할 필요가 없습니다. (OCP 원칙 준수)
- 유지보수성: 각 소셜 플랫폼의 API 명세가 변경되더라도, 우리는 해당 플랫폼의 '전략' 클래스만 수정하면 됩니다. 다른 코드에 영향을 주지 않습니다. (SRP 원칙 준수)
- 가독성: AuthService의 코드가 간결해져 전체 로그인 흐름을 파악하기 쉬워집니다.
소셜 로그인처럼 기능은 비슷하지만 구현 방식이 다양한 기능을 만들 때, 이 '전략 패턴' 기반의 설계는 매우 강력한 무기가 될 것입니다.
[번외] 소셜 로그인 전체 흐름 정리 (프론트엔드-백엔드)
소셜 로그인은 우리 서비스, 사용자, 그리고 소셜 플랫폼이라는 3자 간의 통신입니다. 전체적인 흐름을 이해하는 것이 중요합니다.
- [사용자 → 프론트엔드] 로그인 요청: 사용자가 우리 웹사이트에서 '카카오로 계속하기' 버튼을 클릭합니다.
- [프론트엔드 → 카카오] 인증 요청: 프론트엔드는 사용자를 카카오의 인증 페이지로 리디렉션시킵니다. 이때 "인증이 끝나면 이 주소(redirect_uri)로 돌려보내줘" 라는 정보를 함께 전달합니다.
- [사용자 ↔ 카카오] 인증 및 동의: 사용자는 카카오 페이지에서 ID/PW를 입력하고, 우리 서비스에 정보 제공을 동의합니다.
- [카카오 → 프론트엔드] 인가 코드(Authorization Code) 발급: 카카오는 인증이 완료된 사용자를 약속된 redirect_uri로 다시 보내줍니다. 이때 URL 쿼리 파라미터로 code=... 형태의 일회성 인가 코드를 포함시켜 줍니다.
- [프론트엔드 → 백엔드] 로그인 API 호출: 프론트엔드는 획득한 인가 코드와 회원가입 시 입력받은 추가 정보(이름, 역할 등)를 함께 담아 우리 백엔드의 로그인 API(예: POST /api/auth/login/kakao)를 호출합니다.
- [백엔드 ↔ 카카오] 서버 간 통신:
- (a) 액세스 토큰 발급: 백엔드는 전달받은 인가 코드로 카카오 서버에 다시 요청하여, 사용자 정보에 접근할 수 있는 **액세스 토큰(Access Token)**을 발급받습니다.
- (b) 사용자 정보 조회: 백엔드는 발급받은 액세스 토큰으로 카카오의 사용자 정보 API를 호출하여 이메일, 닉네임, 고유 ID 등의 정보를 가져옵니다.
- [백엔드] 우리 서비스 회원 처리: 백엔드는 카카오로부터 받은 사용자 정보(이메일 등)로 우리 DB를 조회합니다.
- 기존 회원이면? -> 로그인 처리
- 신규 회원이면? -> 프론트에서 받은 추가 정보와 함께 DB에 새로운 사용자 정보를 저장 (회원가입)
- [백엔드 → 프론트엔드] JWT 발급: 회원 처리가 완료되면, 백엔드는 우리 서비스에서 사용할 자체 인증 토큰(JWT)을 생성하여 프론트엔드에 응답으로 보내줍니다.
- [프론트엔드] 로그인 완료: 프론트엔드는 응답받은 JWT를 안전한 곳(쿠키 등)에 저장하고, 사용자를 로그인 상태로 전환한 뒤 메인 페이지로 이동시킵니다. 이후 모든 API 요청 시 이 JWT를 함께 보내 인증을 유지합니다.
'개발일지 > SPRINGBOOT' 카테고리의 다른 글
[테스트코드 -2]단위 테스트 vs 통합 테스트, 언제 무엇을 써야 할까? (feat. AssertJ) (1) | 2025.06.15 |
---|---|
[테스트코드-1] 제대로 이해하는 단위 테스트(Unit Test)의 모든 것 (1) | 2025.06.14 |
[Spring Security] JWT를 이용한 토큰 기반 인증 (1) | 2025.06.08 |
Builder 패턴을 적용한 엔터티 (2) | 2025.06.08 |
JPA , 값 객체는 무엇이고 어떻게 활용하는가? (1) | 2025.06.04 |