캐시 테이블 불일치로 인한 전자계약 발송 실패 - Spring Cache의 숨겨진 함정
TL;DR
- 증상: 전자계약 발송 시 간헐적 NPE, DB에는 데이터 존재
- 원인: 동일 도메인 데이터를 4개 캐시 이름으로 분산 + Repository/Entity 테이블 불일치
- 해결: 단일 캐시로 통합, 올바른 서비스 주입, 레거시 @Deprecated 후 삭제 계획
- 효과: NPE 재현 불가, 캐시 히트율 정상화, 모니터링 지표 확보
- 한계: 로컬 캐시는 중앙 관리 불가 → 네이밍 사고에 여전히 취약
- 트래픽: 피크 타임 100 TPS 미만 (WAS 2대)
- 캐시: Caffeine 로컬 캐시, 분산 무효화는 Lambda 브로드캐스트
- 데이터: 사용자 암호화 데이터 (users 테이블)
글 머리말
전자계약 발송이 간헐적으로 실패했습니다. DB에는 분명히 데이터가 있는데, 서비스 레이어에서는 null이 반환됐습니다. 뭔가 이상했습니다.
결론부터 말하면, 같은 데이터를 4개의 다른 캐시 이름/Repository로 분산한 것이 원인이었습니다.
문제 발생
- 전자계약 발송 API 호출 시 간헐적 NPE
java.lang.NullPointerException: Cannot invoke decrypt() because user is null- DB에는 데이터 존재, 다른 API에서는 정상 조회
로그를 보니 이상한 점이 있었습니다.
[API] EncryptService.getEncryptedUser(12345) - Cache HIT (user_encrypt_cache)
[CONTRACT] EncryptService.getEncryptedUser(12345) - Cache MISS (legacy_encrypt)
[CONTRACT] UserRepository.findById(12345) - Result: Optional.empty
[CONTRACT] NullPointerException at ContractService.sendContract:164DB 조회를 했는데 Optional.empty? DB를 직접 확인하면 데이터가 존재합니다.
원인 분석
캐시 저장소 확인
CaffeineCacheManager에서 캐시 목록을 확인했습니다.
Cache: user_encrypt_cache Size: 1247
Cache: legacy_encrypt Size: 0 ← 비어있음
Cache: encrypt_cache_v2 Size: 83
Cache: encrypt_util_cache Size: 04개의 캐시가 존재하는데, legacy_encrypt는 비어있습니다.
Spring Cache 동작 원리
@Cacheable은 value(캐시 이름)와 key를 조합해서 저장소를 결정합니다.
// API 서버
@Cacheable(value = "user_encrypt_cache", key = "#userId") // → "user_encrypt_cache::12345"
// 전자계약 서비스 (잘못된 서비스)
@Cacheable(value = "legacy_encrypt", key = "#userId") // → "legacy_encrypt::12345" ← 다른 저장소!근본 원인 - Repository까지 달랐다
더 심각한 문제를 발견했습니다. Entity가 다른 테이블을 조회하고 있었습니다.
// User.java → @Table(name = "users") ← 실제 데이터 위치
// UserLegacy.java → @Table(name = "members") ← 데이터 없음!EncryptServiceLegacy는 UserLegacyRepository(members 테이블)를 조회했지만, 실제 데이터는 users 테이블에 있었습니다.
sequenceDiagram
participant Contract as 전자계약 서비스
participant Cache as legacy_encrypt
participant DB as members 테이블
Contract->>Cache: getEncryptedUser
Cache-->>Contract: Miss (비어있음)
Contract->>DB: SELECT (잘못된 테이블)
DB-->>Contract: Optional.empty
Contract->>Contract: NPE 발생Spring Cache의 함정
Java 타입 시스템은 이런 실수를 막아주지 못합니다.
@Autowired
private EncryptService encryptService; // OK
@Autowired
private EncryptServiceLegacy encryptService; // OK - 컴파일러는 모름차이는 오직 어느 캐시/Repository를 사용하느냐뿐입니다. 컴파일 타임에 잡을 수 없고, 호출 순서에 따라 간헐적으로 발생합니다.
해결
1. 긴급 조치 (Hot Fix)
ContractService가 EncryptService(표준)만 주입하도록 변경 + legacy_encrypt 캐시 초기화
2. 레거시 차단
EncryptServiceLegacy를 @Deprecated 표기, 삭제 일정 명시, 사용처 전량 교체
3. 중앙 설정
CacheNames.USER_ENCRYPT 상수화, CacheConfig에서 허용 캐시만 등록, recordStats() 활성화
// 의도: 캐시 이름을 상수로 중앙 관리
public class CacheNames {
public static final String USER_ENCRYPT = "user_encrypt_cache";
}
// 결과: 오타 방지, 허용되지 않은 캐시 사용 시 에러
@Cacheable(value = CacheNames.USER_ENCRYPT, key = "#userId")시스템 점검 체크리스트
- 캐시 이름이 상수화되어 있고 화이트리스트로 등록되어 있는가?
- @Cacheable이 올바른 Repository/Entity를 조회하는가?
- 레거시 서비스가 @Deprecated 표시 후 삭제 일정이 있는가?
- 캐시 히트율/eviction 통계를 주기적으로 모니터링하는가?
- 같은 도메인 데이터를 여러 캐시 이름으로 분산하고 있지 않은가?
결론
서비스 하나 바꾸고 캐시 초기화하면 끝. 원인 파악하는 데 3시간, 수정하는 데 3분이었습니다.
Spring Cache는 생각보다 위험합니다. @Cacheable의 value 파라미터 하나 잘못 쓰면, 컴파일 에러도 안 나고, 테스트도 통과하고, 운영에서 간헐적으로 터집니다. 캐시는 빠르지만, 디버깅은 느립니다.
캐시 네이밍은 반드시 상수화하고, 같은 도메인 데이터는 단일 캐시만 사용하세요.
읽어주셔서 감사합니다.🖐
참고 :