[Spring Boot] 소셜 로그인 구조를 범용으로 리팩토링하기

0-x-14·2026년 3월 30일

CooKeep 개발을 진행하면서, 카카오 로그인 이후에 구글 로그인을 추가해야 하는 상황이 생겼다.

기존에 카카오 로그인은 카카오 OAuth 인증을 담당하는 KakaoOAuthProvider가 OAuthProvider 인터페이스를 구현하는 구조였는데, 구글 로그인 방식을 추가하는 과정에서 구글을 독립적으로 구현할지, 아니면 범용 구조로 리팩토링할지 고민했다.

인터페이스를 활용하지 않고 구글 로그인을 독립적으로 구현한다면, 인터페이스 리팩토링 없이 GoogleOAuthProvider만 새로 구현하면 된다는 장점이 있었다.

KakaoOAuthProvider implements OAuthProvider
GoogleOAuthProvider  // 인터페이스 구현 안 함

와 같은 구조가 되는데, 해당 방식은 기존의 카카오 코드를 건드리지 않아 위험이 적고, 빠른 구현이 가능하다.


하지만, OAuthProvider라는 인터페이스가 이미 존재하는 상황에서 구글만 인터페이스를 구현하지 않으면 구조의 일관성이 깨진다고 판단했다. 둘 다 소셜 로그인이지만 AuthService에서도 카카오는 인터페이스 타입으로, 구글은 구체 클래스 타입으로 주입을 받는 어색한 상황이 생길 것이다.


또, 범용 구조로 리팩토링 하는 건 시간이 조금 더 걸릴지라도, 유지보수와 확장 측면에서 장점이 확실하였다.

유지보수 - 각 Provider는 자신의 API 응답만 파싱하고, AuthService는 공통 DTO만 다루면 된다. 만약 카카오가 API 응답 구조를 변경하더라도 KakaoOAuthProvider 내부만 수정하면 되고, AuthService는 건드릴 필요가 없다. 즉, 변경의 영향 범위가 최소화된다.

확장 - 또, 추후 새로운 소셜 로그인 방식이 추가된다면 OAuthProvider 인터페이스를 구현한 새 Provider 클래스만 만들면 되게 된다. AuthService의 기존 코드를 수정할 필요 없이, 새 Provider를 주입받아 사용할 수 있게 된다. 즉, 기존 코드를 건드리지 않고 기능을 추가할 수 있는 구조가 된다.

객체지향 설계원칙 중 OCP(개방-폐쇄 원칙)을 고려하였을 때도 범용 구조로 리팩토링을 진행하는 것이 좋을 것 같아, 범용 구조로 리팩토링을 진행하게 되었다.




OAuthUserInfoDTO 추가

범용 구조로 리팩토링을 진행하다보니, 한 가지 고민이 생겼다. 소셜 서비스로부터 사용자 정보를 전달 받을 때, 카카오와 구글에서 전달해주는 응답 구조가 달랐기 때문이다.

Kakao는 다음과 같이 중첩 구조로 응답을 주고,

{
  "id": 123456,
  "kakao_account": {
    "email": "test@kakao.com"
  }
}

Google에서는 다음과 같은 플랫 구조로 응답을 준다.

{
  "id": "123456",
  "email": "test@gmail.com"
}




어떻게 할지 고민을 하다 Provider에서 API 응답을 자신에게 맞는 형태로 파싱한 뒤, 공통 DTO로 변환해주는 방식으로 처리하였다.

카카오 응답 → KakaoUserInfoResponseDTO (중첩구조)
                    ↓ 변환
              OAuthUserInfoDTO(id, email)  ← 공통

구글 응답  → GoogleUserInfoResponseDTO (flat구조)
                    ↓ 변환
              OAuthUserInfoDTO(id, email)  ← 공통

KakaoUserInfoResponseDTO와 GoogleUserInfoResponseDTO는 각각 카카오, 구글의 API 응답 파싱용이고, OAuthUserInfoDTO는 유저의 정보를 AuthService에 전달해주는 공통 DTO이다.


현재 서비스에서 소셜 사용자의 소셜ID와 이메일 정보를 필요로 했기 때문에, 최종 코드는 다음과 같이 작성하였다.

KakaoUserInfoResponseDTO (기존 코드 유지)

package com.cookeep.cookeep.domain.user.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

// DTO에서 요구로 하는 필드만 받아오도록 함
@JsonIgnoreProperties(ignoreUnknown = true)
public record KakaoUserInfoResponseDTO(
	@JsonProperty("id")
	Long id,

	@JsonProperty("kakao_account")
	KakaoAccount kakaoAccount
) {
	@JsonIgnoreProperties(ignoreUnknown = true)
	public record KakaoAccount(
		@JsonProperty("email")
		String email
	) {}
}

GoogleUserInfoResponseDTO (추가, 구글 API 응답 파싱용)
package com.cookeep.cookeep.domain.user.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

@JsonIgnoreProperties(ignoreUnknown = true)
public record GoogleUserInfoResponseDTO(
	@JsonProperty("id") String id,
	@JsonProperty("email") String email
) {
}

OAuthUserInfoDTO (추가, AuthService에 전달하는 공통 DTO)
package com.cookeep.cookeep.domain.user.dto;

// 카카오, 구글 모두 공통적으로 소셜ID, 이메일 정보 필요
public record OAuthUserInfoDTO(
	String id,
	String email
) {
}

OAuthUserInfoDTO는 JSON을 직접 파싱하는 게 아니라 각 Provider에서 Java 코드로 직접 생성되므로 Jackson 어노테이션이 필요 없다. 또한 카카오는 id가 숫자(Long)이지만 구글은 문자열(String)로 내려온다. 서로 다른 타입(Long, String)을 일관되게 처리하기 위해 String으로 변환하였다.



OAuthProvider 범용 구조로 리팩토링

다음으로는, OAuthProvider를 범용 구조로 리팩토링해주었다.

기존 코드 (Kakao 단독)

package com.cookeep.cookeep.domain.user.application;

import com.cookeep.cookeep.domain.user.dto.KakaoUserInfoResponseDTO;
import com.cookeep.cookeep.domain.user.entity.Provider;

public interface OAuthProvider {
	Provider provider();
	String getKakaoAccessToken(String code, String redirectUri);
	KakaoUserInfoResponseDTO getKakaoUserInfo(String accessToken);
}


수정된 코드 (리팩토링 후)

package com.cookeep.cookeep.domain.user.application;

import com.cookeep.cookeep.domain.user.dto.OAuthUserInfoDTO;
import com.cookeep.cookeep.domain.user.entity.Provider;

public interface OAuthProvider {
	Provider provider();
	String getAccessToken(String code, String redirectUri);
	OAuthUserInfoDTO getUserInfo(String accessToken);
}



또, 이에 맞춰 KakaoOAuthProvider의 코드도 수정해줬다.

OAuthProvider에서 변경된 메서드명에 맞춰 메서드명과 반환 타입을 수정할 뿐만 아니라, 받아온 유저의 정보값을 OAuthUserInfoDTO에 맞게 변환하는 로직 또한 추가해주었다.

	// 소셜 액세스 토큰을 사용해 유저의 정보값을 받아옴
	public OAuthUserInfoDTO getUserInfo(String accessToken) {
		KakaoUserInfoResponseDTO kakaoUserInfo = WebClient.create(KakaoUserInfoURL)
			.get()
			.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) // access token 인가
			.header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString())
			.retrieve()
			.bodyToMono(KakaoUserInfoResponseDTO.class)
			.block();

		return new OAuthUserInfoDTO(
			String.valueOf(kakaoUserInfo.id()),
			kakaoUserInfo.kakaoAccount().email()
		);
	}





또, GoogleOAuthProvider도 구현하였다. KakaoOAuthProvider와 거의 유사하지만 다른 점이 있다면, 구글 id는 이미 String이기 때문에 getUserInfo에서 String.valueOf()로의 변환이 불필요하다는 점이다. 이메일 구조 또한 중첩이 아닌 플랫 구조이기 때문에 googleUserInfo.email()과 같이 간단하게 값을 받아왔다.





이후에는 AuthService와 AuthController 내에 구글 로그인 메서드를 작성해주었다.

구현 이후 테스트를 진행한 결과, 구글 로그인 또한 정상적으로 회원가입이 처리되는 것을 확인했다.



AuthSerivce 내 메서드 통합

이번에는 AuthService 내에 중복되는 부분을 하나의 메서드로 통합해보려 한다.

먼저, createKakaoUser와 createGoogleUser를 createSocialUser로 통합하였다. 이 부분은 크게 변동사항이 없어 넘어가도록 하겠다.

kakaoLogin과 googleLogin도 코드 자체는 거의 동일하다.

다음은 카카오로그인의 코드이다.

// 카카오 로그인
@Transactional
public SocialLoginResponseDTO kakaoLogin(String code, String redirectUri) {
    String kakaoAccessToken = kakaoOAuthProvider.getAccessToken(code, redirectUri);
    OAuthUserInfoDTO userInfo = kakaoOAuthProvider.getUserInfo(kakaoAccessToken);

    String kakaoId = userInfo.id();

    // provider = KAKAO, providerUserId인 값을 통해 이미 가입된 회원인지 식별
    Optional<UserAuth> existingUserAuth = userAuthRepository.findByProviderAndProviderUserId(KAKAO, kakaoId);

    String email = userInfo.email();

    // 신규 유저일 경우 User, UserAuth값을 새롭게 생성함
    UserAuth userAuth = existingUserAuth
       .orElseGet(() -> {
          User user = createSocialUser(email);

          return userAuthRepository.save(
             UserAuth.builder()
                .user(user)
                .provider(KAKAO)
                .providerUserId(kakaoId)
                .build());
       });

    User user = userAuth.getUser();

    // 액세스 토큰, 리프레쉬 토큰 발급
    TokenPair tokenPair = issueTokensAndUpsertSession(user);

    UserStatus userStatus = user.getUserStatus();
    NextStep nextStep = null;
    Boolean marketingConsent = user.getMarketingConsent();

    // 최초 회원가입한 소셜 로그인 유저일 경우
    if (user.getUserStatus() == UserStatus.CREATED) {
       // 최초 회원가입인 경우 TERMS 페이지로 이동,
       // 회원가입 이후 약관 동의까지 마친 경우 ONBOARDING 페이지로 이동
       nextStep = (marketingConsent == null)
          ? NextStep.TERMS
          : NextStep.ONBOARDING;
    }

    return new SocialLoginResponseDTO(
       user.getUserId(), tokenPair.accessToken(), tokenPair.refreshToken(),
       userStatus, nextStep
    );
}



kakaoOAuthProvider와 googleOAuthProvider만 다를 뿐, 나머지 로직은 완전히 같았기 때문에 하나의 socialLogin() 메서드로 통합하기로 했다.

하나의 메서드로 통합하기 위해서, OAuthProvider 인터페이스를 활용하였다.

Spring은 특정 인터페이스를 구현한 Bean이 여러 개 있을 때, List<인터페이스>를 주입 받으면 해당 구현체들을 모두 자동으로 담아준다. @Service가 붙은 클래스는 Spring이 자동으로 Bean으로 등록하게 되므로, KakaoOAuthProvider와 GoogleOAuthProvider도 모두 Bean으로 등록되어 있을 것이다.
따라서, List를 주입 받으면KakaoOAuthProvider와 GoogleOAuthProvider가 자동으로 리스트에 포함되기 때문에 이와 같은 구현이 가능하다.

private final List<OAuthProvider> oAuthProviders;

private OAuthProvider getProvider(Provider provider) {
    return oAuthProviders.stream()
        .filter(p -> p.provider() == provider)
        .findFirst()
        .orElseThrow(() -> new RuntimeException("지원하지 않는 소셜 로그인입니다."));
}




만약 카카오 로그인이 요청되어 getProvider(Provider.KAKAO)가 호출될 경우, provider() 메서드가 KAKAO를 반환하는 KakaoOAuthProvider가 반환된다. 이처럼 Provider별로 나누어져야 하는 처리는 해당 코드에서 처리되고, AuthService는 소셜 로그인 종류와 상관없이 동일한 방식으로 처리할 수 있게 된다.



기존의 kakaoLogin 메서드에서 다음과 같았던 부분을

    String kakaoAccessToken = kakaoOAuthProvider.getAccessToken(code, redirectUri);
    OAuthUserInfoDTO userInfo = kakaoOAuthProvider.getUserInfo(kakaoAccessToken);

다음과 같이 변경하였다.

		// provider 타입에 따라 KakaoOAuthProvider 또는 GoogleOAuthProvider 반환
		OAuthProvider oAuthProvider = getProvider(provider);
		String accessToken = oAuthProvider.getAccessToken(code, redirectUri);
		OAuthUserInfoDTO userInfo = oAuthProvider.getUserInfo(accessToken);





이메일 일치 시 계정 통합

마지막으로, 현재 이메일은 중복을 허용하지 않고 있다.

기획에 따라, 이메일은 같지만 provider가 다른 경우 회원가입 과정에서 계정이 자동 통합되도록 하였다.

		// 신규 유저일 경우 User, UserAuth값을 새롭게 생성함
		UserAuth userAuth = existingUserAuth
			.orElseGet(() -> {
				// 동일한 이메일로 가입된 User가 존재하는지 확인
				// 존재하지 않을 경우 새로운 유저 생성
				User user = userRepository.findByEmail(email)
					.orElseGet(() -> createSocialUser(email));

				// 기존 유저든 신규 유저든 UserAuth가 추가됨
				return userAuthRepository.save(
					UserAuth.builder()
						.user(user)
						.provider(provider)
						.providerUserId(socialId)
						.build());
			});


로컬 회원가입은 기획 변경으로 수정할 부분이 남아있으므로, 해당 부분을 수정하면서 관련 코드를 추가할 예정이다.



마무리

이와 같은 과정을 통해 소셜 로그인을 범용 구조로 리팩토링하는 작업을 마무리했다.

처음에는 구글 로그인 코드를 추가하는 방식이 더 빠를 것 같아 고민하였지만, 범용으로 리팩토링을 마치고 나니 코드가 훨씬 깔끔해졌다.

이후 새로운 소셜 로그인이 추가되더라도 OAuthProvider를 구현한 Provider 클래스만 추가하면 되므로, 손쉽게 확장할 수 있는 구조가 되었다.

이번 리팩토링을 통해 단순한 기능 구현을 넘어, 확장성과 유지보수성을 고려한 구조 설계의 중요성을 체감할 수 있었다.

2개의 댓글

comment-user-thumbnail
2026년 3월 30일

저도 유사하게 구현했어서 공감이 가네요!

혹시 OAuth 제공자의 accessToken 및 refreshToken은 관리하고 계신가요?
회원 탈퇴 시 revoke / unlink API를 호출해야하는 경우가 실무에서 있었는데, 어떻게 관리하면 좋을 지 고민해보는 것도 좋을 것 같습니다

1개의 답글