이 글은 Spring Security + JWT 기반 인증 구조를 Keycloak/OIDC 기반 구조로 전환하며 겪은 과정을 정리한 시리즈입니다.
현재 글: 5편 - 로그아웃에서만 500 에러가 발생한 이유: Caffeine Cache TTL 불일치
시리즈 순서
1. Spring Security + JWT에서 Keycloak/OIDC 구조로 전환한 이유1
2. Direct Access Grants에서 Authorization Code + PKCE로 전환하기
3. issuer-uri 한 줄로 Keycloak JWT가 검증되는 과정
4. JWT에는 ADMIN이 있는데 hasRole(“ADMIN”)이 실패한 이유
5. 로그아웃에서만 500 에러가 발생한 이유: Caffeine Cache TTL 불일치
1. 문제 상황 (Issue)
- 현상: 로그인 후 약 1시간 30분이 경과한 시점에서 다른 API 호출은 정상이나, 로그아웃 시도 시에만 500 Internal Server Error 발생했다.
- 특이사항 : 일반적인 API 호출은 정상, 오직 ‘로그아웃’ 버튼을 누를 때만 문제가 발생했다.
2. 원인 추적 및 분석 (Investigation)
가설 1. Keycloak 세션 만료?
- 검증 : 만료 시간을 10초로 설정 후 테스트 결과, 세션이 끝나도 정상 로그아웃 처리가 됨을 확인다.
- 결과 : 세션이 만료되어도 로그아웃 과정에서 에러는 발생하지 않았다. → Keycloak 설정 문제는 아님을 판단했다.

가설 2. 코드 레벨의 NPE (NullPointerException)
- 검증 : 도커 로그로 문제 발견한 지점에 에러 분석했다.
docker logs --since "2026-04-28T05:00:00" --until "2026-04-28T05:10:00" fc-api
- 결과 : 로그아웃 로직 중 TokenValues 를 참조하는 지점에서 NPE 확인했다.
- 실제 로그 스냅샷 : > java.lang.NullPointerException: Cannot invoke "...TokenValues.getRefreshToken()" because "tokenValues" is null
2026-04-28T14:05:36.228+09:00 TRACE 1 --- [0.0-8080-exec-7] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [9c4e7a25-b30d-44d6-8e7f-9ed10b2bc555]
2026-04-28T14:05:38.856+09:00 ERROR 1 --- [0.0-8080-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.NullPointerException: Cannot invoke "com.fc.fcseoularchive.domain.auth.dto.TokenValues.getRefreshToken()" because "tokenValues" is null] with root cause
java.lang.NullPointerException: Cannot invoke "com.fc.fcseoularchive.domain.auth.dto.TokenValues.getRefreshToken()" because "tokenValues" is null
at com.fc.fcseoularchive.domain.auth.AuthService.logout(AuthService.java:141) ~[!/:0.0.1-SNAPSHOT]
at com.fc.fcseoularchive.domain.auth.AuthController.logout(AuthController.java:73) ~[!/:0.0.1-SNAPSHOT]
3. 원인 분석
- CaffeineCache TTL : 3600초
REFRESH_TOKENS("refreshTokens", 3600, 10_000);
- Keycloak SSo Session : 10시간
- 상황 : 로그인 후 1시간이 지나면 우리 서버의 캐시에서는 더 이상 유저 정보가 남아있지 않다. 하지만 인가 서버에서는 여전히 유효한 유저로 인식하였고, 이때 로그아웃을 시도하면 캐시 미스(Cache miss) 가 발생 했고 이를 통해 NPE 가 발생했다는 것을 확인했다.
|
구분
|
Caffeine Cache (우리 서버)
|
Keycloak Session (인가 서버)
|
|
수명 (TTL)
|
1시간 (3,600s)
|
10시간 (36,000s)
|
|
1시간 이후 상태
|
데이터 삭제 (null)
|
세션 유지 (Valid)
|
|
결과
|
로그아웃 시 캐시 미스 발생 (NPE)
|
NPE 발생 및 500 에러
|
4. 해결 방법 (Solution)
방법1. 캐시 만료 시간 조정
- 전략: 인증 서버의 세션 만료 시간과 애플리케이션 캐시 만료 시간을 동기화했다.
REFRESH_TOKENS("refreshTokens", 36000, 10_000);
- 조치: Refresh_Token 의 TTL을 1시간에서 Keycloak SSO 세션 수명(10시간)에 맞춰 상향 조정한다.
방법2. 코드 방어 로직 추가
- 조치: tokenValues가 null인 경우(이미 캐시가 만료된 경우) 에러를 던지지 않고 즉시 로그아웃 완료 경로로 리다이렉트 시키는 방어 코드 작성했다.
if(tokenValues == null){
log.info("[Logout] 이미 캐시가 만료된 유저입니다. ID: {}", userId);
return redirectUriLogout;
}
5. 결과 (Result)
- 안정성 확보: 캐시 미스 및 세션 불일치 상황에서도 클라리언트에게 에러 메시지를 보내느 것이 아닌 정상적인 로그아웃(리다이렉트)을 제공했다.

이를 통해 로컬 캐시를 사용할 때는 반드시 외부 시스템 (인가서버, DB 등) 과의 생명 주기 (Lifecycle)를 고려해야 한다는 것을 깨달았다.
'Spring' 카테고리의 다른 글
| [Spring] Redis ZSet 기반 대기열 시스템 정리 (0) | 2026.05.19 |
|---|---|
| [Spring] Kafka 기반 예매 시스템 성능 개선기: MySQL 병목, Redis 재고 차감, 그리고 정합성 트레이드오프 (0) | 2026.05.17 |
| [Spring] 예외 처리 종류 & 상태 코드 (0) | 2025.12.28 |
| [Spring] 출금 및 장부 시스템 구현 (2) | 2025.12.08 |
| [Spring] 충전 기능 구현 with. TossPayments (0) | 2025.11.29 |