카카오, 네이버, 구글 소셜 로그인을 하나의 아키텍처로 통합합니다. Strategy Pattern을 활용하여 새로운 Provider를 추가할 때 기존 코드 수정 없이 확장할 수 있는 구조를 설계합니다.
이전 글에서 카카오 OAuth2 로그인의 기초를 다뤘습니다. 하지만 실제 서비스에서는 네이버, 구글, 애플 등 여러 소셜 로그인을 지원해야 합니다.
문제는 각 Provider마다 응답 구조가 다르다는 것입니다:
// 카카오 응답
{
"id":123456789,
"kakao_account":{
"email":"user@kakao.com",
"profile":{"nickname":"홍길동"}
}
}
// 네이버 응답
{
"response":{
"id":"abcd1234",
"email":"user@naver.com",
"nickname":"홍길동"
}
}
// 구글 응답
{
"sub":"1234567890",
"email":"user@gmail.com",
"name":"홍길동"
}
이런 상황에서 if-else 분기문으로 처리하면 어떻게 될까요?
// 안티패턴: if-else 지옥
if(provider.equals("kakao")){
// 카카오 파싱 로직
}else if(provider.equals("naver")){
// 네이버 파싱 로직
}else if(provider.equals("google")){
// 구글 파싱 로직
}else if(provider.equals("apple")){
// ... 끝없이 증가
}
새 Provider가 추가될 때마다 기존 코드를 수정해야 하고, 한 파일이 비대해집니다. OCP(Open-Closed Principle) 를 위반하는 대표적인 사례입니다.
이 글에서는 Strategy Pattern을 활용하여 이 문제를 해결합니다.
Strategy Pattern은 행위(알고리즘)를 캡슐화하여 런타임에 교체할 수 있게 하는 디자인 패턴입니다.

| 장점 | 설명 |
|---|---|
| OCP 준수 | 새 Provider 추가 시 기존 코드 수정 불필요 |
| 단일 책임 | 각 Strategy가 하나의 Provider만 담당 |
| 테스트 용이 | Provider별 독립적인 단위 테스트 가능 |
| 런타임 교체 | 동적으로 Strategy 선택 가능 |
먼저 지원하는 Provider를 열거형으로 정의합니다.
package com.example.oauth2.client.core.provider;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
@Getter
@RequiredArgsConstructor
public enum OAuth2ProviderType {
KAKAO("kakao", true, "<https://kauth.kakao.com>"),
NAVER("naver", false, null),
GOOGLE("google", true, "<https://accounts.google.com>");
private final String registrationId; // application.yml의 registration 이름
private final boolean oidcSupported; // OIDC 지원 여부
private final String issuerUri; // OIDC issuer URI
/**
* registrationId로 Provider 타입을 찾습니다.
*/
public static OAuth2ProviderType from(String registrationId) {
return Arrays.stream(values())
.filter(type -> type.registrationId.equals(registrationId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Unsupported provider: " + registrationId
));
}
}
Provider별 로직을 추상화하는 Strategy 인터페이스를 정의합니다.
package com.example.oauth2.client.core.provider;
import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Set;
public interface OAuth2ProviderStrategy {
/**
* 이 Strategy가 담당하는 Provider 타입
*/
OAuth2ProviderType getProviderType();
/**
* OAuth2User에서 사용자 정보를 추출합니다.
* @param oauth2User OAuth2/OIDC 인증된 사용자
* @param idToken OIDC ID Token (OIDC 모드일 때만 non-null)
* @return 정규화된 사용자 정보
*/
OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken);
/**
* ID Token 검증 (OIDC Provider만 구현)
* 기본 구현은 아무것도 하지 않음
*/
default void validateToken(OidcIdToken idToken) {
// 기본값: 검증 없음 (OAuth2 전용 Provider용)
}
/**
* 이 Provider에 필요한 scope 목록
*/
Set<String> getRequiredScopes();
}
validateToken()은 OIDC Provider만 필요하므로 기본 구현 제공OAuth2ProviderType으로 Provider를 식별package com.example.oauth2.client.kakao;
import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderStrategy;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Set;
@Component
@RequiredArgsConstructor
public class KakaoOAuth2Strategy implements OAuth2ProviderStrategy {
private final KakaoUserInfoExtractor userInfoExtractor;
private final OAuth2ClientProperties oauth2Properties;
@Override
public OAuth2ProviderType getProviderType() {
return OAuth2ProviderType.KAKAO;
}
@Override
public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
// OIDC 모드 (ID Token이 있는 경우)
if (idToken != null && oauth2User instanceof OidcUser oidcUser) {
return userInfoExtractor.extract(oidcUser);
}
// OAuth2 모드 (fallback)
return userInfoExtractor.extract(oauth2User);
}
@Override
public void validateToken(OidcIdToken idToken) {
if (idToken == null) return;
// 1. Issuer 검증
String issuer = idToken.getIssuer().toString();
String expectedIssuer = oauth2Properties.getProvider()
.get("kakao").getIssuerUri();
if (!issuer.equals(expectedIssuer)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("invalid_token",
"Invalid Kakao issuer: " + issuer, null)
);
}
// 2. Audience 검증 (client_id와 일치해야 함)
String clientId = oauth2Properties.getRegistration()
.get("kakao").getClientId();
if (!idToken.getAudience().contains(clientId)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("invalid_token",
"Invalid Kakao audience", null)
);
}
}
@Override
public Set<String> getRequiredScopes() {
return Set.of(
"openid", // OIDC 필수
"profile_nickname",
"profile_image",
"account_email",
"phone_number"
);
}
}
package com.example.oauth2.client.naver;
import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderStrategy;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Set;
@Component
@RequiredArgsConstructor
public class NaverOAuth2Strategy implements OAuth2ProviderStrategy {
private final NaverUserInfoExtractor userInfoExtractor;
@Override
public OAuth2ProviderType getProviderType() {
return OAuth2ProviderType.NAVER;
}
@Override
public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
// 네이버는 OIDC 미지원, OAuth2 모드만 사용
return userInfoExtractor.extract(oauth2User);
}
// validateToken()은 기본 구현 사용 (아무것도 안 함)
@Override
public Set<String> getRequiredScopes() {
return Set.of("name", "email", "profile_image");
}
}
package com.example.oauth2.client.google;
import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderStrategy;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Set;
@Component
@RequiredArgsConstructor
public class GoogleOAuth2Strategy implements OAuth2ProviderStrategy {
private final GoogleUserInfoExtractor userInfoExtractor;
@Override
public OAuth2ProviderType getProviderType() {
return OAuth2ProviderType.GOOGLE;
}
@Override
public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
if (idToken != null && oauth2User instanceof OidcUser oidcUser) {
return userInfoExtractor.extract(oidcUser);
}
return userInfoExtractor.extract(oauth2User);
}
@Override
public Set<String> getRequiredScopes() {
return Set.of("openid", "profile", "email");
}
}
Apple 로그인을 추가한다고 가정해봅시다. 기존 코드를 전혀 수정하지 않고 다음 단계만 진행하면 됩니다.
public enum OAuth2ProviderType {
KAKAO("kakao", true, "<https://kauth.kakao.com>"),
NAVER("naver", false, null),
GOOGLE("google", true, "<https://accounts.google.com>"),
APPLE("apple", true, "<https://appleid.apple.com>"); // 추가!
// ... 나머지 동일
}
@Component
public class AppleUserInfoExtractor implements OAuth2UserInfoExtractor {
@Override
public OAuth2ProviderType getProviderType() {
return OAuth2ProviderType.APPLE;
}
@Override
public OAuth2UserInfo extract(OidcUser oidcUser) {
OidcIdToken idToken = oidcUser.getIdToken();
return OAuth2UserInfo.builder()
.providerId(idToken.getSubject())
.provider(OAuth2ProviderType.APPLE)
.email(idToken.getEmail())
.name(oidcUser.getAttribute("name"))
.attributes(oidcUser.getAttributes())
.build();
}
@Override
public OAuth2UserInfo extract(OAuth2User oauth2User) {
// Apple은 OIDC 전용
throw new UnsupportedOperationException("Apple requires OIDC");
}
}
@Component
@RequiredArgsConstructor
public class AppleOAuth2Strategy implements OAuth2ProviderStrategy {
private final AppleUserInfoExtractor userInfoExtractor;
@Override
public OAuth2ProviderType getProviderType() {
return OAuth2ProviderType.APPLE;
}
@Override
public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
if (!(oauth2User instanceof OidcUser oidcUser)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("invalid_request", "Apple requires OIDC", null)
);
}
return userInfoExtractor.extract(oidcUser);
}
@Override
public Set<String> getRequiredScopes() {
return Set.of("openid", "name", "email");
}
}
spring:
security:
oauth2:
client:
registration:
apple:
client-id: ${APPLE_CLIENT_ID}
client-secret: ${APPLE_CLIENT_SECRET}
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope: openid, name, email
provider:
apple:
issuer-uri: <https://appleid.apple.com>
authorization-uri: <https://appleid.apple.com/auth/authorize>
token-uri: <https://appleid.apple.com/auth/token>
끝입니다! 기존의 OAuth2UserService, SuccessHandler 등은 전혀 수정할 필요가 없습니다.
Strategy Pattern을 적용하여 다음을 달성했습니다:
