Valkey Serverless의 SCAN cursor가 Java long 범위를 넘어 NPE로 이어진 과정
spring-data-redis가 SCAN cursor를 long으로 변환하는 코드 때문에 결제는 승인되고 주문은 생성되지 않는 사고가 발생했습니다.

오전 9시쯤 슬랙 알림이 도착했습니다. 처음엔 PG사 이슈인 줄 알았습니다. "결제는 됐는데 주문이 안 잡혀요"라는 문의가 몇 건 접수됐다는 내용이었습니다. 운영 로그를 확인해 보니 우리 서비스 안에서 NPE가 같은 위치에서 계속 발생하고 있었습니다. 한참 추적해 보니 원인은 "운영 금지" 패턴(KEYS)을 회피하려고 선택한 SCAN 명령 쪽에 있었습니다.
발생: 운영 환경, 오전 9시대 증상: 주문 생성 API에서 500 다발. 결제는 승인됐지만 주문이 생성되지 않음.
이 글은 SCAN cursor 변환 실패에서 시작된 NPE의 사고 분석에 초점이 있습니다. 우리가 왜 SCAN을 그렇게 자주 호출하고 있었는지(검색 결과를 어떤 키로 캐시하고 어떤 트리거로 비우는지)는 항공권 검색 캐시 전략에서 따로 정리했습니다. 이 글의 사고 흐름이 어색하게 느껴진다면 그쪽을 먼저 읽어도 좋습니다.
TL;DR
- 캐시 무효화는
SCAN명령으로 패턴에 매칭되는 키를 조회해 삭제하는 구조였습니다. - AWS Valkey Serverless가 반환한 cursor 값이 Java
long범위보다 커서,spring-data-redis가Long.parseLong으로 변환하다NumberFormatException이 발생했습니다. - 예외를 삼킨
scanKeys가null을 반환했고, 호출자의addAll(null)에서 NPE가 발생했습니다. - 사용자에게는 결제는 됐는데 주문이 생성되지 않은 상황으로 보였습니다.
핫픽스는 두 곳에 적용했습니다.
scanKeys가 실패해도null대신 빈 Set을 반환하게 했습니다. 호출자의addAll(null)에서 NPE가 발생하지 않게 하기 위해서입니다.- 호출자 쪽에서도 캐시 무효화 블록을 try/catch로 한 번 더 감쌌습니다. 캐시 단계에서 어떤 예외가 발생해도 주문 생성 흐름은 중단되지 않게 하기 위해서입니다.
근본 원인(cursor 자체가 큰 값으로 오는 것)은 spring-data-redis와 Valkey Serverless의 호환성 문제라서 별도 작업으로 연기했습니다.
1. 사전 지식: 본문 들어가기 전에 이것만
이번 사고를 이해하려면 아래의 작은 개념 몇 개만 알고 가면 됩니다. 다 익숙하면 2번으로 건너뛰어도 됩니다.
"cursor"가 도대체 뭔가
이번 사고의 핵심이 cursor라는 값입니다. cursor는 다음 페이지를 요청할 때 사용하는 토큰입니다. 서버가 지금까지 조회한 위치를 이 값으로 표시하고, 클라이언트가 다음 호출에 그대로 전달하면 그 위치부터 이어서 조회합니다.
프로그래밍에서의 cursor: 페이지네이션
웹 API에서 본 적이 있을 겁니다.
1차 요청: GET /api/users
응답: { "users": [...100명...], "nextCursor": "abc123" }
2차 요청: GET /api/users?cursor=abc123 ← 1차 응답의 cursor를 그대로 보냄
응답: { "users": [...100명...], "nextCursor": "def456" }
...
N차 요청: GET /api/users?cursor=xyz999
응답: { "users": [...50명...], "nextCursor": null } ← null 이면 끝
요점은 이렇습니다.
- 서버가 한 번에 다 주지 못하는 큰 데이터를 페이지로 분할해서 반환합니다.
- 다음 페이지를 수신하려면 직전 응답의 cursor를 그대로 함께 전송해야 합니다.
- 클라이언트는 cursor 값의 의미를 알 필요가 없습니다. 수신한 값을 다음 요청에 그대로 다시 전송하기만 하면 됩니다. 이런 값을 opaque 토큰이라고 부릅니다. 클라이언트가 내용을 해석하지 않는 값이라는 뜻입니다.
Redis의 SCAN이 뭔가
Redis에서 "특정 패턴에 맞는 키 목록 가져오기"를 하는 방법은 크게 둘입니다.
❌ KEYS pattern: 모든 키를 한 번에 다 조회합니다. 운영 환경에서는 사용 금지입니다. Redis는 single-threaded라서, 키가 수십만 개면 KEYS가 끝날 때까지 다른 클라이언트 요청이 모두 대기합니다.
✅ SCAN: 키를 페이지 단위로 조금씩 조회합니다. 위에서 본 페이지네이션과 같은 방식입니다.
1차 호출: SCAN 0 → 키 100개 + 다음 cursor "12345"
↑ ↑
"처음부터 시작" "다음에 이거 들고 다시 와"
2차 호출: SCAN 12345 → 키 100개 + 다음 cursor "67890"
N차 호출: SCAN 99999 → 키 N개 + 다음 cursor "0" ← 0이면 끝
요점은 이렇습니다.
- 첫 호출은 항상
SCAN 0으로 시작합니다. - 응답에 같이 오는 cursor를 그대로 다음 호출에 전달해 이어서 조회합니다.
- cursor가
0으로 반환되면 순회가 종료됩니다. - 클라이언트는 cursor 값을 해석하지 않습니다. 다음 호출에 그대로 다시 전송하기만 하라는 것이 Redis 명세입니다.
spring-data-redis같은 라이브러리는 이 SCAN 반복 호출을 자동으로 처리하는Cursor객체를 제공합니다. 우리 코드는cursor.hasNext()/cursor.next()만 사용하면 되고, 내부에서 SCAN cursor를 추적·갱신하는 건 라이브러리 책임입니다.
이 cursor 값이 너무 커서 라이브러리가 long으로 변환하다 실패한 것이 이번 사고의 핵심입니다.
Java의 long 범위
Java의 long은 부호가 있는(signed) 64비트 정수입니다. 음수도 표현해야 하니 양수 부분은 64비트 중 63비트만 사용합니다.
| 타입 | 최대값 (양수) |
|---|---|
Java long (signed 64bit) | 9,223,372,036,854,775,807 (≈ 9.22 × 10¹⁸), 19자리 |
| 부호 없는(unsigned) 64bit | 18,446,744,073,709,551,615 (≈ 1.84 × 10¹⁹), 20자리 |
Java에는 unsigned 정수 타입이 없습니다. 그래서 Long.MAX_VALUE보다 큰 unsigned 64bit 값을 long으로 변환하면 NumberFormatException이 발생합니다.
Long.parseLong("9223372036854775807"); // OK (Long.MAX_VALUE)
Long.parseLong("9286422431637976196"); // NumberFormatException
// ↑ Long.MAX_VALUE 보다 약 6.3 × 10¹⁶ 큼
addAll(null)은 NPE
자바 컬렉션의 addAll(Collection)은 인자가 null이면 NPE를 발생시킵니다. 구현을 보면 이유가 명확합니다.
// java.util.AbstractCollection.addAll
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c) { // ← c가 null이면 c.iterator() 호출 시 NPE
if (add(e))
modified = true;
}
return modified;
}
즉 someSet.addAll(null)은 무조건 NPE입니다.
ElastiCache Valkey Serverless가 뭐가 다른가
AWS ElastiCache는 Redis/Valkey의 매니지드 서비스입니다. 그중 Serverless 모드는 인스턴스 사이즈를 신경 쓰지 않고 자동 확장되는 형태이고, 내부적으로는 항상 클러스터(샤딩) 로 동작합니다.
Serverless는 SCAN cursor 안에 어느 노드/슬롯에서 진행 중인지 같은 메타데이터를 비트로 포함합니다. 그래서 일반 standalone Redis보다 cursor 값이 훨씬 큰 범위에서 시작합니다. 이 특성이 이번 사고의 결정적 요인이 됩니다.
2. 증상: 무엇이 보였나
운영에서 주문 생성 API가 500을 반복해서 반환했습니다. 사용자는 이를 "결제는 됐는데 주문이 안 잡혔다" 로 인식했습니다.
스택트레이스부터 확인했습니다.
java.lang.NullPointerException
at java.util.AbstractCollection.addAll(AbstractCollection.java:343)
at ...CreateOrderServiceImpl.createOrder(CreateOrderServiceImpl.java:59)
at ...CreateOrderController.createOrder(CreateOrderController.java:40)
처음엔 단순한 NPE로 보였습니다. addAll에서 NPE라면 누군가 컬렉션 인자에 null을 전달한 것이고, 호출 코드 한 줄만 확인하면 끝나는 일이니까요. 그런데 NPE 직전에 아래 WARN 로그가 거의 항상 함께 기록돼 있었습니다.
WARN RedisComponent :: 패턴 기반 캐시 삭제 실패
(패턴: *:search:*:*<redacted>*): For input string: "9286422431637976196"
두 로그가 항상 한 쌍으로 기록된다는 점이, 이 NPE가 단독 오류가 아니라 앞 단계의 예외에서 파생된 결과라는 첫 단서였습니다.
3. 원인: 4단계로 풀어보기
추적해 보니 사고의 전체 흐름은 이렇습니다.
[1] Valkey Serverless가 큰 cursor 값을 돌려줌
↓
[2] spring-data-redis가 cursor를 long으로 변환 → NumberFormatException
↓
[3] RedisComponent.scanKeys의 catch가 null 반환
↓
[4] 호출자가 addAll(null) → NullPointerException → 500
[1] Valkey Serverless가 큰 cursor 값을 돌려줌
주문 생성 직전에 우리는 검색 결과 캐시를 패턴 매칭으로 무효화합니다(이 무효화 전략 자체는 별도 글에서 설명했습니다). 패턴 매칭으로 키를 조회하는 도구가 SCAN입니다.
운영 로그에서 실제 관측된 cursor 값들입니다(12시간 윈도우, 108건).
| 자릿수 | 예시 값 | 평가 |
|---|---|---|
| 19자리 | 9286422431637976196 | Long.MAX_VALUE(≈ 9.22 × 10¹⁸) 살짝 초과 (~0.7%) |
| 20자리 | 99020052958589981810, 42080649734741140470 | unsigned 64bit max(1.84 × 10¹⁹) 안에는 들어가지만 signed long은 한참 초과 |
처음에는 "혹시 cursor가 무한히 커지는 건가" 싶었는데, 분포를 확인하고 나니 0 ~ 1.84 × 10¹⁹ 사이(unsigned 64bit) 안에서 결정적으로 분포한다는 것을 알 수 있었습니다. 다만 그 값이 거의 항상 Java long 범위를 초과합니다.
왜 cursor가 0, 1, 2 같은 작은 숫자가 아니라 이렇게 큰가
이 부분이 처음엔 제일 의아했습니다. Redis SCAN은 reversed-bit(역순 비트) 카운팅 방식을 사용합니다. 0, 1, 2, ...가 아니라 상위 비트부터 채우는 순서로 cursor를 생성합니다.
평범한 카운팅:
0000_0000 → 0000_0001 → 0000_0010 → 0000_0011 → ...
(0) (1) (2) (3)
SCAN의 reversed-bit 카운팅 (8bit 예시):
0000_0000 → 1000_0000 → 0100_0000 → 1100_0000 → 0010_0000 → ...
(0) (128) (64) (192) (32)
이렇게 하는 이유는, Redis가 SCAN 도중 내부 해시 테이블을 rehash(크기 변경) 해도 키 누락/중복 없이 이어서 순회할 수 있게 하기 위해서입니다. 자세한 이유는 마지막 챕터에서 다시 설명합니다. 지금은 "그래서 첫 페이지 이후 cursor가 큰 값으로 나오는 것이 정상"이라고만 알면 됩니다.
64bit에서 MSB 한 비트만 켜져도 그 값은 0x8000_0000_0000_0000 = 9,223,372,036,854,775,808입니다. Long.MAX_VALUE를 1 초과합니다.
여기에 Valkey Serverless가 cursor의 일부 비트에 클러스터 노드/슬롯 메타데이터를 추가로 포함하기 때문에 값이 더 큰 범위로 이동합니다. 그래서 운영에서는 19~20자리 값이 흔하게 수신됩니다.
요약: cursor가 큰 건 Redis 명세대로의 정상 동작입니다. 우리 클라이언트 라이브러리가 이 값을
long으로 변환하지 못한 것이 문제였습니다.
[2] spring-data-redis가 cursor를 long으로 변환 → NFE
우리 코드는 spring-data-redis의 RedisTemplate.scan(options)를 사용합니다.
try (Cursor<String> cursor = redisTemplate.scan(options)) {
while (cursor.hasNext()) {
keys.add(cursor.next());
}
}
이 안에서 spring-data-redis(또는 그 아래 Lettuce 클라이언트)가 SCAN 응답으로 수신한 cursor 문자열을 Long.parseLong(...)으로 변환합니다. 19~20자리 값은 long 범위를 넘으니 NumberFormatException("For input string: \"9286422431637976196\"")이 발생합니다.
명세상은 클라이언트 책임이 아닙니다: Redis 공식 명세는 "cursor는 클라이언트에 opaque한 토큰"이라고 명시합니다. 즉 클라이언트는 cursor 값을 해석하지 말고 그대로 다음 호출에 전달하기만 하면 됩니다. long으로 parse할 의무가 없습니다.
spring-data-redis/ Lettuce의 일부 구현이 cursor를Long으로 처리하는 것이 호환성 문제의 원인이었습니다.
[3] RedisComponent.scanKeys의 catch가 null 반환
핫픽스 전 코드는 이랬습니다.
public Set<String> scanKeys(String pattern){
Set<String> keys = new HashSet<>();
ScanOptions options = ScanOptions.scanOptions()
.match(pattern)
.count(100)
.build();
try (Cursor<String> cursor = redisTemplate.scan(options)) {
while (cursor.hasNext()) {
keys.add(cursor.next());
}
} catch (Exception e) {
log.warn("패턴 기반 캐시 삭제 실패 (패턴: {}): {}", pattern, e.getMessage());
return null; // ← 호출자에 null 노출
}
return keys;
}
이 코드를 처음 봤을 때, 예외를 삼킨 의도 자체는 맞다고 판단했습니다. 캐시 무효화가 실패해도 본 흐름(주문 생성)은 진행되어야 한다는 graceful degradation 원칙이니까요. 문제는 null을 반환한 것이었습니다. 호출자가 그 값을 그대로 collection 메서드에 전달하면 그 지점에서 NPE가 발생합니다.
[4] 호출자가 addAll(null) → NPE
List<String> removeScanKeyList = rq.getRemoveScanPattern();
Set<String> scanKeySet = new HashSet<>();
for (String removeScanKey : removeScanKeyList) {
scanKeySet.addAll(redisComponent.scanKeys(removeScanKey)); // ← scanKeys가 null 반환
} // → addAll(null) → NPE
여기까지 확인하고 나니 사용자에게 "결제는 됐는데 주문 안 됨"으로 보인 이유가 명확해졌습니다.
이 NPE가 왜 사용자에게는 "결제는 됐는데 주문 안 됨"으로 보였나
결제·주문 흐름은 대략 이런 모양입니다.
[a] 사용자가 결제 진행
[b] 프론트 → PG 결제 승인 (돈 빠짐)
[c] 프론트 → 우리 서비스의 /createOrder 호출
├─ 캐시 무효화 (← [4]의 NPE가 여기서 터짐)
└─ 외부 시스템에 createOrder 호출 ← 여기까지 못 감
[d] 주문 번호 응답 → 사용자에게 주문 완료 화면
NPE가 [c]의 캐시 단계에서 발생하니 외부 시스템의 createOrder 호출이 아예 실행되지 않습니다. 주문은 생성되지 않고 500이 반환됩니다. 결제는 이미 [b]에서 승인됐으니, 사용자 입장에서는 결제 금액은 출금됐는데 주문은 생성되지 않은 상황이 됩니다.
이 사고가 결제·주문 분기 사고로 이어진 이유는 캐시 무효화가 주문 생성 흐름 한가운데에 동기 방식으로 포함돼 있었기 때문입니다. 보조 기능인 캐시 무효화에서 예외가 발생하자 주문 생성 전체가 중단됐습니다. 이 결합에 대한 회고는 캐시 전략 글의 5절에 더 기록했습니다.
4. 해결: 핫픽스
NPE만 즉시 방지하는 핫픽스입니다. 근본 원인(cursor NFE)은 별도 작업으로 연기하고, 두 곳에 방어 코드를 추가했습니다.
A. RedisComponent.scanKeys: null 반환 제거
} catch (Exception e) {
log.warn("패턴 기반 캐시 삭제 실패 (패턴: {}): {}", pattern, e.getMessage());
- return null;
+ return Collections.emptySet();
}
null 대신 빈 Set을 반환합니다. 호출자가 addAll(emptySet)을 해도 NPE가 발생하지 않고 0개가 추가됩니다. 이 한 줄을 변경하면 scanKeys를 호출하는 모든 곳에서 addAll(null) NPE가 해소됩니다.
왜
null보다emptySet이 나은가 "결과가 없다"는 "빈 컬렉션"으로 표현하는 것이 자바 관용입니다. 호출자가 매번 null 체크를 하지 않아도 되니 null 체크 누락으로 인한 NPE가 감소합니다. 정말로 "실패"와 "결과 0개"를 구분하고 싶다면Optional<Set<String>>같은 타입을 사용하는 게 맞습니다. 이번처럼 어느 쪽이든 본 흐름은 진행해야 하는 경우엔 구분할 필요가 없었습니다.
B. 호출자: 캐시 블록 try-catch + null 체크
try {
List<String> removeScanKeyList = rq.getRemoveScanPattern();
if (removeScanKeyList != null) { // ① 입력 자체가 null이어도 안전
Set<String> scanKeySet = new HashSet<>();
for (String removeScanKey : removeScanKeyList) {
Set<String> keys = redisComponent.scanKeys(removeScanKey);
if (keys != null) { // ② scanKeys가 null 줘도 안전
scanKeySet.addAll(keys);
}
}
redisComponent.removeData(scanKeySet);
}
} catch (Exception e) { // ③ 어떤 예외든 본 흐름은 진행
log.warn("createOrder 캐시 삭제 실패 - 주문 흐름은 계속 진행: {}",
e.getMessage());
}
핵심 원칙은 하나입니다. 캐시 단계에서 어떤 예외가 발생해도 주문 생성 흐름은 중단되지 않아야 합니다.
A만으로 안 되나, 왜 B도 같이 하나 A 한 줄로 이번 사고의 NPE는 바로 방지됩니다. 그런데 우리가 모르는 다른 예외 경로가 또 있을 수 있습니다(예: 다른 캐시 호출이 어떤 이유로 실패하거나, 나중에 누가
scanKeys시그니처를 변경해 다시 null이 반환되거나). B는 "어떤 일이 있어도 본 흐름은 진행" 이라는 의도를 호출자 측 코드에 명시해 둔 것입니다. 방어적 프로그래밍 관점에서 반환하는 쪽과 호출하는 쪽 양쪽에 방어 코드를 둔 것입니다.
5. 후속 작업
핫픽스는 NPE만 방지했습니다. 캐시 무효화 자체는 한동안 noop(warn 로그만)으로 동작하니, stale 캐시가 최대 TTL 동안 노출될 수 있다는 영향은 남습니다. 비즈니스 영향은 작지만 근본 원인은 따로 해결해야 합니다.
| 옵션 | 설명 | 메모 |
|---|---|---|
spring-data-redis / Lettuce 업그레이드 | cursor를 long으로 다루지 않는 버전 적용 | 가장 깔끔. 호환성 회귀 검증 필요 |
| SCAN 우회: SET 기반 키 인덱스 | 캐시 키 저장 시 별도 Redis SET에 멤버로도 추가. 무효화 시 SET 멤버를 읽어 명시적으로 삭제 | cursor 이슈 회피. SET 정합성 관리 부담 |
| Designed Cluster로 전환 | Serverless가 아닌 일반 클러스터 모드 사용. cursor 형식이 다를 수 있음 | 인프라 변경. 비용/성능 trade-off 검토 |
| 무효화의 비동기화 | 주문 생성 흐름에서 캐시 무효화를 큐로 분리 | 본 흐름과 캐시 단계의 결합을 약화. 캐시 전략 글 5절 참고 |
6. 더 깊이: Redis SCAN의 reversed-bit cursor가 왜 이렇게 동작하는지
여기서부터는 꼭 알 필요는 없는 내용으로, 관심 있는 분을 위한 추가 설명입니다.
Redis의 키-값은 내부적으로 해시 테이블에 저장됩니다. 키가 늘어나면 해시 테이블 크기를 동적으로 확장하는 rehash가 발생합니다. SCAN 도중에 rehash가 발생하면, 일반적인 0, 1, 2, ... 카운팅 방식으로는 이미 본 키를 다시 보거나, 보지 못하고 넘어가는 문제가 발생합니다.
reversed-bit 카운팅으로 cursor를 발급하면, 해시 테이블 크기가 두 배로 증가하거나 절반으로 감소해도 다음 cursor가 가리키는 슬롯 위치가 일관되게 유지됩니다. 자세한 수학은 Redis 공식 문서를 참고하면 됩니다: https://redis.io/commands/scan/#guarantees
정리하면, 큰 cursor 값은 SCAN이 명세대로 동작하고 있다는 뜻입니다. 작은 값이 올 것이라고 가정하면 안 됩니다.
Redis 명세는 cursor를 unsigned 64bit 정수로 정의했습니다. 자바에는 unsigned 타입이 없으니 long으로 parse하지 말고 String 그대로 처리하는 게 안전합니다. BigInteger로 변환해도 되지만, 어차피 cursor 값을 해석할 일이 없으니 문자열로 두는 것이 가장 단순합니다.
7. 진단할 때 가장 도움됐던 단서
진단을 빨리 완료할 수 있었던 건 단서 세 개 덕분이었습니다. 다음에 비슷한 사고가 발생하면 아래 세 가지를 먼저 확인하면 됩니다.
첫째는 NFE 메시지의 자릿수입니다. For input string: "9286422431637976196"의 19자리를 보고 "이거 long 범위 넘는 거 아니야?" 하는 의심이 바로 들었고, Long.MAX_VALUE와 비교해 보니 초과였습니다. 사실 이 한 줄에 원인이 거의 다 들어 있었습니다.
둘째는 사용자 진술과 스택트레이스가 일치한 점입니다. "결제는 됐는데 주문 안 됨"이라는 표현이, NPE가 외부 호출 직전 단계에서 발생하는 구조와 정확히 일치했습니다. 이 일치 덕분에 같은 시간대의 다른 결제 실패 케이스와 이 NPE 케이스를 분리할 수 있었습니다.
셋째는 WARN과 NPE가 항상 함께 기록된 점입니다. RedisComponent WARN과 직후 NPE가 거의 항상 같은 thread에서 같은 시점에 기록됐습니다. 둘이 인과 관계라는 신호였습니다.
회고
이번 사고에서 기억해 둘 내용은 다음과 같습니다.
- "운영 금지" 패턴(KEYS)을 피하려고 SCAN을 선택했는데, SCAN cursor의 명세(unsigned 64bit, opaque 토큰)와 클라이언트 라이브러리 구현(
long변환)이 일치하지 않아 사고가 발생했습니다. scanKeys가 실패 시null을 반환하면서, 그 값을 받은 호출자의addAll(null)에서 NPE가 발생했습니다. 그래서scanKeys가 실패 시 빈 Set을 반환하도록 변경했습니다.- "그래도 본 흐름은 진행" 원칙(graceful degradation)을
scanKeys한 곳에만 두지 않고 호출자에도 try/catch로 추가했습니다. 한 곳에만 두면 그 코드가 변경됐을 때 같은 NPE가 다시 발생할 수 있기 때문입니다.
제일 기억에 남는 건 "왜 사용자에겐 결제 성공에 주문 실패로 보였나"를 추적한 과정입니다. 스택트레이스 한 줄에서 시작했는데, 결국 Redis 명세와 우리 캐시 전략 구조까지 다시 검토해야 했습니다.