-
[Spring] Kakao OIDC 로그인 구현Spring 2025. 9. 25. 15:27반응형
구현기
최근에 소셜 로그인을 구현할 일이 있었는데,
그중에서도 카카오 로그인을 OIDC(OpenID Connect) 방식으로 구현했습니다.
보통은 OAuth 2.0 방식으로 구현하는 경우가 많은데,
이번에는 보안성과 표준성을 더 살리고 싶어서 OIDC 기반 카카오 로그인을 적용했어요.
이 글에서는 제가 직접 구현하면서 정리한 코드와 개념들을 공유하려고 합니다.
특히, Redis 기반 공개키 캐싱, 인터페이스 기반 확장성 설계(타 소셜 로그인 대비) 등을 담았으니
비슷한 작업을 하시는 분들께 도움이 되면 좋겠습니다.📑 목차
- OIDC(OpenID Connect)란?
- OAuth 2.0 vs OIDC
- 카카오 OIDC 로그인 흐름
- 프로젝트 환경
- 아키텍처 개요
- Kakao Developers 설정
- 구현 단계
- Controller
- Service
- OidcProvider 인터페이스 설계
- KakaoOidcProvider
- JwtProvider
- KakaoOidcClient
- PublicKeyProvider
- Redis Cache 적용
- 관련 VO/DTO 클래스
- 예외 처리 및 트랜잭션 설정
- 흐름 요약 (시퀀스 다이어그램)
- 마무리
1. OIDC(OpenID Connect)란?
✅ OAuth 2.0 vs OIDC
- OAuth 2.0: 권한 위임(Authorization) 프로토콜
→ "이 앱이 내 카카오톡 프로필 사진을 볼 수 있도록 허용할게." - OIDC(OpenID Connect): OAuth 2.0을 확장하여 인증(Authentication)까지 제공
→ id_token(JWT)을 통해 "이 사용자가 누구인지"를 증명할 수 있습니다.
✅ 카카오 OIDC 로그인 흐름
- 클라이언트에서 카카오 로그인을 완료하면 id_token을 받습니다.
- 서버는 카카오에서 제공하는 JWKS(공개키 목록)으로 id_token을 검증합니다.
- 검증에 성공하면 sub Claim(고유 카카오 유저 ID)을 추출합니다.
- 이 값을 기반으로 DB에서 유저를 조회하거나 신규 가입을 처리합니다.
2. 프로젝트 환경
- Spring Boot: 3.4.0
- Java: 21
- Redis: OIDC 공개키 캐싱 용도
Gradle 의존성:
implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.4.0' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
3. 아키텍처 개요
- 클라이언트 → 서버: id_token 전달
- 서버: JWKS 가져오기(캐싱) → JWT 검증 → User 조회/가입
- 서버 → 클라이언트: 내부 JWT(Access, Refresh) 반환
4. Kakao Developers 설정
4.1 애플리케이션 추가
4.2 Web 플랫폼 등록
4.3 Redirect URI 추가
4.4 Client Secret Key 생성
4.5 동의항목 설정
닉네임과 프로필 사진을 제외한 나머지 개인정보 항목에 대해서는 카카오 내의 검수가 필요합니다.
사업자 정보를 등록하거나, 비즈니스 인증(비즈앱 전환)을 완료하면, 권한이 필요한 동의항목에 대한 심사를 신청할 수 있습니다.[개인 개발자 비즈 앱 전환] 을 통해 이메일 정보도 수집을 합니다.
4.6 OpenID Connect 활성화
OIDC 방식으로 구현하기 위해서 OpenID Connect 활성화를 진행합니다.
이렇게 해서 OIDC 사용을 위한 설정은 완료 했습니다.
이 외, 설정은 기본값들로 진행하고 애플리케이션 코드 구현 단계를 진행 하겠습니다.
5. 구현 단계5.1 Controller
@PostMapping("/login/social") public ResponseEntity<ApiResult<LoginResponse>> socialLogin( @Valid @RequestBody SocialLoginRequest request ) { OidcUserInfo oidcUserInfo = authService.getOidcUserInfo(request); return ResponseEntity.ok(new ApiResult<>(authService.socialLogin(oidcUserInfo))); }
- /login/social 엔드포인트
- 카카오일 경우 providerType=KAKAO, token=id_token 필수
- authService.socialLogin function에서는 oidcUserInfo에서 providerId를 추출하여 임시 유저 저장 / 로그인 처리를 진행합니다.
5.2 Service
@Transactional(propagation = Propagation.NOT_SUPPORTED) public OidcUserInfo getOidcUserInfo(SocialLoginRequest request) { String providerName = request.getProviderType().name().toLowerCase(); OidcProvider oidcProvider = oidcProviders.get(providerName); return oidcProvider.getUserInfo(request.toOidcParams()); }
- 트랜잭션 필요 없음 → NOT_SUPPORTED 설정
- ProviderType에 따라 OidcProvider를 라우팅
5.3 OidcProvider 인터페이스 설계
타 소셜 로그인(애플 로그인, 등)을 대비해 인터페이스 기반으로 설계했습니다.
public interface OidcProvider { OidcUserInfo getUserInfo(OidcProviderParams params); } public record OidcProviderParams(String token, String authorizationCode) { public static OidcProviderParams ofKakao(String token) { return new OidcProviderParams(token, null); } public static OidcProviderParams ofApple(String authorizationCode) { return new OidcProviderParams(null, authorizationCode); } }
5.4 KakaoOidcProvider
@Component("kakao") public class KakaoOidcProvider implements OidcProvider { final KakaoOidcClient kakaoOidcClient; final CacheManager cacheManager; final PublicKeyProvider publicKeyProvider; final JwtProvider jwtProvider; @Value("${oidc.kakao.issuer-uri}") String issuerUri; @Value("${oidc.kakao.client-id}") String clientId; @Override public OidcUserInfo getUserInfo(OidcProviderParams params) { String idToken = params.token(); Map<String, String> headers = jwtProvider.parseHeaders(idToken); try { // JWKS 가져오기 OidcPublicKeyList keys = kakaoOidcClient.getKakaoPublicKeys(); PublicKey publicKey = publicKeyProvider.generatePublicKey(headers, keys); // JWT 검증 Claims claims = jwtProvider.parseOidcClaims(idToken, publicKey); jwtProvider.validateIdToken(claims, issuerUri, clientId); return OidcUserInfo.kakao(claims.getSubject()); } catch (ApiException e) { if (isNotKeyRetryCandidate(e)) throw e; // 캐시 초기화 후 재시도 Cache cache = cacheManager.getCache("KakaoOIDC"); if (cache != null) cache.evict("publicKeys"); OidcPublicKeyList newKeys = kakaoOidcClient.getKakaoPublicKeys(); PublicKey newKey = publicKeyProvider.generatePublicKey(headers, newKeys); Claims claims = jwtProvider.parseOidcClaims(idToken, newKey); return OidcUserInfo.kakao(claims.getSubject()); } } }
🔎 동작 단계 설명
- 헤더 파싱 (parseHeaders)
- id_token의 헤더 부분을 디코딩해 kid, alg 값을 추출합니다.
- 이 값으로 JWKS에서 적절한 공개키를 찾아야 합니다.
- 카카오 공개키(JWKS) 가져오기
- KakaoOidcClient.getKakaoPublicKeys() 호출
- Redis 캐싱이 적용되어 있어, 자주 바뀌지 않는 키를 매번 네트워크로 가져오지 않아도 됩니다.
- JWT Claims 파싱 및 검증
- JwtProvider.parseOidcClaims()로 서명 검증 및 Claims 파싱
- JwtProvider.validateIdToken()으로 iss, aud 값 검증
- iss: 카카오가 발급한 토큰인지
- aud: 내 애플리케이션 Client ID에 해당하는지
- 유저 정보 생성 (OidcUserInfo.kakao)
- 검증된 Claims에서 sub 값(카카오 고유 사용자 ID)을 추출
- 우리 서비스에서 소셜 로그인 식별자로 사용
- 예외 처리 & 캐시 초기화
- 만약 서명 검증에 실패하거나 키 불일치 예외 발생 시, Redis 캐시를 비우고 다시 JWKS를 조회합니다.
- 이렇게 하면 카카오 측에서 JWKS 키가 변경되었을 때도 대응할 수 있습니다.
⚠️ 중요 포인트
- Redis 캐싱 + 키 갱신 대응
- 보안 키가 바뀌면 기존 캐시를 날리고 새 키를 가져와야 정상적으로 검증이 가능
- 이 부분이 없으면 서비스가 로그인 실패로 이어질 수 있습니다.
5.5 JwtProvider
@Slf4j @Component @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class JwtProvider { ObjectMapper objectMapper; // 1. Claims 파싱 (JWT 본문 검증) public Claims parseOidcClaims(String jwtToken, PublicKey publicKey) { try { return Jwts.parserBuilder() .setSigningKey(publicKey) .build() .parseClaimsJws(jwtToken) .getBody(); } catch (SecurityException | MalformedJwtException e) { throw new ApiException(ErrorCode.INVALID_JWT_TOKEN.getCode()); } catch (ExpiredJwtException e) { throw new ApiException(ErrorCode.EXPIRED_JWT_TOKEN.getCode()); } catch (Exception e) { log.info("JWT parse error", e); throw new ApiException(ErrorCode.JWT_PARSE_ERROR.getCode()); } } // 2. 헤더 파싱 (kid, alg 추출용) public Map<String, String> parseHeaders(String jwtToken) { try { String[] parts = jwtToken.split("\\."); if (parts.length < 2) throw new ApiException(ErrorCode.INVALID_JWT_TOKEN.getCode()); String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); return objectMapper.readValue(headerJson, Map.class); } catch (Exception e) { log.info("Failed to parse JWT headers", e); throw new ApiException(ErrorCode.JWT_PARSE_ERROR.getCode()); } } // 3. iss, aud 값 검증 public void validateIdToken(Claims claims, String expectedIssuer, String expectedAudience) { String issuer = claims.getIssuer(); String audience = claims.getAudience(); // 발행처 검증 if (issuer == null || !issuer.equals(expectedIssuer)) { log.info("Invalid issuer: expected {}, got {}", expectedIssuer, issuer); throw new ApiException(ErrorCode.INVALID_ID_TOKEN.getCode()); } // Client ID 검증 if (audience == null || !audience.equals(expectedAudience)) { log.info("Invalid audience: expected {}, got {}", expectedAudience, audience); throw new ApiException(ErrorCode.INVALID_ID_TOKEN.getCode()); } } }
OIDC 로그인에서 가장 중요한 단계는 id_token 검증입니다.
카카오가 발급해 준 토큰이 정말 유효한지, 변조되지 않았는지, 만료된 건 아닌지 확인해야 합니다.
이를 전담하는 클래스가 바로 JwtProvider입니다.
🔎 코드 설명
- parseOidcClaims
- jjwt 라이브러리를 사용해서 JWT Claims를 파싱합니다.
- PublicKey를 함께 전달해 서명(Signature)이 유효한지도 확인합니다.
- 만료(ExpiredJwtException), 잘못된 서명(SecurityException), 파싱 실패 등 상황에 맞는 예외로 변환합니다.
- parseHeaders
- JWT는 header.payload.signature 구조를 가집니다.
- 여기서 header를 Base64 디코딩해 kid, alg 값을 꺼내옵니다.
- 이 값으로 카카오에서 내려준 JWKS와 매칭하여 공개키를 선택합니다.
- validateIdToken
- iss(토큰 발급자)와 aud(Audience, Client ID)를 검증합니다.
- 카카오가 발급했는지, 내 서비스 Client ID를 대상으로 한 토큰인지 확인하는 단계입니다.
- 위 두 가지가 일치하지 않으면 INVALID_ID_TOKEN 예외 발생.
⚠️ 예외 처리 전략
- INVALID_JWT_TOKEN: 서명 불일치, 구조 문제
- EXPIRED_JWT_TOKEN: 만료된 토큰
- JWT_PARSE_ERROR: 예상치 못한 파싱 오류
- INVALID_ID_TOKEN: iss, aud 불일치
즉, JwtProvider는 JWT 구조 검증 + 공개키 서명 확인 + 발행처/대상 검증이라는 세 가지 단계를 책임지는 핵심 클래스입니다.
카카오뿐 아니라 애플 OIDC에서도 그대로 재사용 가능하도록 범용적으로 설계했습니다.5.6 KakaoOidcClient
@FeignClient( name = "KakaoOidcAuthClient", url = "https://kauth.kakao.com", configuration = OidcClientConfig.class ) public interface KakaoOidcClient { @Cacheable(cacheNames = "KakaoOIDC", key = "'publicKeys'", cacheManager = "oidcCacheManager") @GetMapping("/.well-known/jwks.json") OidcPublicKeyList getKakaoPublicKeys(); }
- https://kauth.kakao.com/.well-known/jwks.json 호출
- Redis 기반 캐싱 적용
5.7 PublicKeyProvider
@Component public class PublicKeyProvider { public PublicKey generatePublicKey(Map<String, String> tokenHeaders, OidcPublicKeyList publicKeys) { String kid = tokenHeaders.get("kid"); String alg = tokenHeaders.get("alg"); OidcPublicKey matchedKey = publicKeys.getMatchedKey(kid, alg); return buildPublicKey(matchedKey); } private PublicKey buildPublicKey(OidcPublicKey key) { byte[] nBytes = Base64.getUrlDecoder().decode(key.getN()); byte[] eBytes = Base64.getUrlDecoder().decode(key.getE()); RSAPublicKeySpec spec = new RSAPublicKeySpec(new BigInteger(1, nBytes), new BigInteger(1, eBytes)); return KeyFactory.getInstance("RSA").generatePublic(spec); } }
5.8 Redis Cache 적용
application.yml
spring: data: redis: host: 127.0.0.1 port: 6380 password: '1234' cache: type: redis redis: enable-statistics: true key-prefix: auth
RedisCacheConfig@Configuration public class RedisCacheConfig { @Bean public RedisCacheManager oidcCacheManager(RedisConnectionFactory redisConnectionFactory) { RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .entryTtl(Duration.ofDays(3)); return RedisCacheManager.RedisCacheManagerBuilder .fromConnectionFactory(redisConnectionFactory) .cacheDefaults(redisCacheConfiguration) .build(); } }
- JWKS는 자주 바뀌지 않음 → 3일 TTL
- Redis 덕분에 멀티 인스턴스 환경에서도 캐싱 공유 가능
5.9 관련 VO/DTO 클래스
OidcPublicKey / OidcPublicKeyList
@Data public class OidcPublicKey { String kid; String kty; String alg; String use; String n; String e; } @Data public class OidcPublicKeyList { private List<OidcPublicKey> keys; public OidcPublicKey getMatchedKey(String kid, String alg) { return keys.stream() .filter(key -> kid.equals(key.getKid()) && alg.equals(key.getAlg())) .findFirst() .orElseThrow(() -> new ApiException(ErrorCode.OIDC_PUBLIC_KEY_NOT_MATCHED.getCode())); } }
OidcUserInfopublic record OidcUserInfo(String providerId, ProviderType providerType, String appleRefreshToken) { public static OidcUserInfo kakao(String providerId) { return new OidcUserInfo(providerId, ProviderType.KAKAO, null); } public static OidcUserInfo apple(String providerId, String refreshToken) { return new OidcUserInfo(providerId, ProviderType.APPLE, refreshToken); } }
SocialLoginRequest@Getter public class SocialLoginRequest { @NotNull(message = "A2001:providerType") ProviderType providerType; String token; // Kakao 전용 String authorizationCode; // Apple 전용 public OidcProviderParams toOidcParams() { return switch (providerType) { case KAKAO -> { if (!StringUtils.hasText(token)) { throw new ApiException(ErrorCode.REQUIRED_KAKAO_TOKEN.getCode()); } yield OidcProviderParams.ofKakao(token); } case APPLE -> { if (!StringUtils.hasText(authorizationCode)) { throw new ApiException(ErrorCode.REQUIRED_APPLE_CODE_PLATFORM.getCode()); } yield OidcProviderParams.ofApple(authorizationCode); } }; } }
6. 예외 처리 및 트랜잭션 설정
- 예외 처리
- JWT 파싱 실패 / 만료 / 공개키 불일치 시 → 사용자 정의 Exception
- 공개키 캐시 초기화 후 재시도 가능
- 트랜잭션 설정
- getOidcUserInfo()는 단순 외부 API 호출 → DB 접근 없음
- 불필요한 트랜잭션 오버헤드 제거 → @Transactional(propagation = NOT_SUPPORTED)
7. 흐름 요약 (시퀀스 다이어그램)
8. 마무리
이번 글에서는 카카오 OIDC 로그인을 Spring Boot 3.4 + Java 21 환경에서 구현한 방법을 공유했습니다.
👉 핵심 포인트는 아래와 같습니다.- id_token 기반으로 안전하게 사용자 인증 가능
- JWKS 공개키 검증으로 보안 강화
- Redis 캐싱으로 성능 및 확장성 확보
- 인터페이스 기반 설계로 애플 로그인 등 확장 용이
다음 글에서는 이어서 🍏 Apple 로그인 (Authorization Code Flow + Refresh Token revoke) 구현기를 다룰 예정입니다.
반응형'Spring' 카테고리의 다른 글
[Spring] Apple OIDC 로그인 구현 (0) 2025.09.25 [Spring] Redis의 Redisson을 활용한 분산락 처리 (0) 2025.05.10 [Spring] Lock 종류 정리 (낙관적 락, 비관적 락, 분산락, 데드락, 등) 및 예시 (0) 2025.05.08 [Aws + Spring] SNS(Simple Notification Service) + Spring Boot 에러 메일 전송 기능 구축하기 (0) 2024.01.20 [Aws + Spring] Spring Boot + Aws Glue + S3를 활용한 방문자 통계 구축 (1) (0) 2024.01.02 - OIDC(OpenID Connect)란?