Skip to content

캐시 테이블 불일치로 인한 전자계약 발송 실패 - Spring Cache의 숨겨진 함정

캐시 테이블 불일치로 인한 전자계약 발송 실패 - 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:164

DB 조회를 했는데 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: 0

4개의 캐시가 존재하는데, legacy_encrypt는 비어있습니다.

Spring Cache 동작 원리

@Cacheablevalue(캐시 이름)와 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")  ← 데이터 없음!

EncryptServiceLegacyUserLegacyRepository(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)

ContractServiceEncryptService(표준)만 주입하도록 변경 + 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는 생각보다 위험합니다. @Cacheablevalue 파라미터 하나 잘못 쓰면, 컴파일 에러도 안 나고, 테스트도 통과하고, 운영에서 간헐적으로 터집니다. 캐시는 빠르지만, 디버깅은 느립니다.

캐시 네이밍은 반드시 상수화하고, 같은 도메인 데이터는 단일 캐시만 사용하세요.

읽어주셔서 감사합니다.🖐

참고 :

Spring Cache 공식 문서
Caffeine Cache 공식 문서


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site