이 글에서 다루는 것

카카오 로그인은 accessToken 방식과 idToken(OIDC) 방식 두 가지로 구현할 수 있다.
각 방식의 차이와 장단점을 살펴보고, idToken 검증 시 서버 시간 불일치로 발생하는
TokenExpiredException 해결 방법을 정리한다.

카카오 간편 로그인 구현 두 가지 방법

카카오 로그인을 서버에서 처리하는 방법은 크게 두 가지다.

1. accessToken 방식

클라이언트가 발급받은 accessToken을 서버로 전달하고, 서버가 카카오 API를 직접 호출해 사용자 정보를 가져오는 방식이다.

클라이언트 → 카카오 로그인 → accessToken 발급
→ 내 서버로 accessToken 전달
→ 내 서버 → 카카오 API (https://kapi.kakao.com/v2/user/me) 호출
→ 사용자 정보 반환

구현이 단순하고 직관적이지만, 로그인할 때마다 카카오 서버에 네트워크 요청이 발생한다.
카카오 API를 추가로 사용해야 하는 경우(친구 목록, 메시지 전송 등)에는 이 방식이 자연스럽다.

2. idToken 방식 (OIDC)

OpenID Connect 표준을 따르는 방식으로, 클라이언트가 발급받은 JWT 형태의 idToken을 서버에서 직접 검증한다.

클라이언트 → 카카오 로그인 (openid 스코프 포함) → idToken 발급
→ 내 서버로 idToken 전달
→ 내 서버에서 JWKS로 서명 검증 (카카오 서버 추가 호출 없음)
→ JWT 클레임에서 사용자 정보 추출

카카오 서버에 추가 요청 없이 로컬에서 검증이 완료되므로 응답이 빠르고, 카카오 서버 장애의 영향을 받지 않는다.

비교

accessToken idToken (OIDC)
사용자 정보 취득 카카오 API 호출 JWT 클레임 직접 파싱
네트워크 비용 매 요청마다 발생 JWKS 캐싱으로 최소화
표준 카카오 자체 OpenID Connect
카카오 장애 영향 있음 없음 (JWKS 캐싱 시)
구현 난이도 낮음 높음
시간 검증 이슈 없음 있음

idToken 방식을 추천하는 이유

순수하게 로그인/회원가입 용도라면 idToken 방식이 낫다. 이유는 세 가지다.

첫째, 카카오 서버 의존성이 없다.accessToken 방식은 로그인할 때마다 카카오 API 서버를 거쳐야 한다. 카카오 서버가 느려지거나 장애가 나면 내 서비스 로그인도 같이 멈춘다. idToken은 JWKS만 캐싱해두면 검증이 로컬에서 끝난다.

둘째, 응답 속도가 빠르다.외부 API 호출이 없으므로 네트워크 레이턴시가 없다. JWKS는 캐싱해두면 재사용되므로 검증 비용이 거의 없다.

셋째, 다른 간편로그인에 재사용할 수 있다.Google, Apple, 카카오 모두 OIDC 표준을 따르기 때문에 아래처럼 OidcProviderMeta만 교체하면 하나의 코드로 모든 소셜 로그인을 처리할 수 있다.

// Google, Apple, 카카오 모두 동일한 verify() 함수 재사용
fun verify(meta: OidcProviderMeta, idToken: String): DecodedJWT

idToken 검증 실패 - 서버 시간 불일치

idToken 방식을 도입하면서 아래 오류가 간헐적으로 발생했다.

com.auth0:java-jwt TokenExpiredException: The Token has expired on ...

원인

JWT는 iat(issued at), exp(expiration), nbf(not before) 클레임으로 유효 시간을 검증한다.
카카오 서버가 토큰을 발급한 시각과 내 서버가 검증하는 시각 사이에 수십 초 오차가 있으면, 서버 입장에서 이미 만료된 토큰으로 판단한다.

카카오 서버 발급 시각: 10:00:00
내 서버 현재 시각:     09:59:45  ← 15초 느림
→ iat 검증 실패 또는 exp가 과거로 인식됨

Raspberry Pi처럼 NTP 동기화가 불안정한 환경에서 특히 자주 발생한다.

해결 1. acceptLeeway 적용

java-jwt 라이브러리의 acceptLeeway(seconds)로 시간 허용 오차를 설정한다.

val verifier = JWT.require(algorithm)
    .withIssuer(meta.issuer)
    .withAudience(meta.clientId)
    .acceptLeeway(30) // exp, nbf, iat 모두 ±30초 허용
    .build()

return verifier.verify(idToken)

클레임별로 다르게 설정하려면 개별 메서드를 사용한다. 개별 설정이 있으면 acceptLeeway보다 우선 적용된다.

.acceptExpiresAt(30)   // exp에만 적용
.acceptIssuedAt(60)    // iat에만 적용
.acceptNotBefore(10)   // nbf에만 적용

해결 2. 서버 NTP 동기화 확인

leeway는 완충제일 뿐, 서버 시간이 크게 틀어져 있다면 근본 원인을 해결해야 한다.

# 현재 시간 동기화 상태 확인
timedatectl status
System clock synchronized: yes   ← 정상
NTP service: active

synchronized: no 인 경우 NTP 서비스를 활성화한다.

sudo systemctl enable --now systemd-timesyncd
sudo timedatectl set-ntp true

최종 검증 코드

fun verify(meta: OidcProviderMeta, idToken: String): DecodedJWT {
    // 1. JWKS 가져오기
    val jwksResponse = webClient.get()
        .uri(meta.jwksUri)
        .retrieve()
        .bodyToMono(JwksResponse::class.java)
        .block() ?: throw IllegalStateException("Failed to load JWKS from ${meta.issuer}")

    // 2. idToken 헤더에서 kid 추출
    val jwt = JWT.decode(idToken)
    val tokenKid = jwt.keyId
        ?: throw IllegalArgumentException("idToken header does not contain kid")

    // 3. JWKS 목록에서 kid 일치 찾기
    val jwk = jwksResponse.keys.find { it.kid == tokenKid }
        ?: throw IllegalArgumentException("No matching JWK for kid=$tokenKid")

    // 4. 공개키 생성
    val publicKey = jwkToPublicKey(jwk)
    val algorithm = Algorithm.RSA256(publicKey, null)

    // 5. iss / aud / signature 검증 + 시간 오차 허용
    val verifier = JWT.require(algorithm)
        .withIssuer(meta.issuer)
        .withAudience(meta.clientId)
        .acceptLeeway(30)
        .build()

    // 6. 검증 성공 시 DecodedJWT 반환
    return verifier.verify(idToken)
}

정리

내용
추천 방식 idToken (OIDC)
추천 이유 카카오 서버 독립, 빠른 응답, 멀티 프로바이더 코드 재사용
발생 문제 서버 시간 불일치로 TokenExpiredException
즉각 해결 acceptLeeway(30) 적용
근본 해결 서버 NTP 동기화 확인 및 활성화

카카오 idToken 만료 시간은 기본 1시간이므로 30초 leeway는 보안상 실질적인 위협이 되지 않는다.