donghakim.dev — zsh
← ls ../blog

Valkey Serverless의 SCAN cursor가 Java long 범위를 넘어서 NPE까지 가는 길

spring-data-redis가 cursor를 long으로 다루는 한 줄이 만든 결제·주문 분기 사고

Valkey Serverless의 SCAN cursor가 Java long 범위를 넘어서 NPE까지 가는 길 · cover

오전 9시쯤 슬랙이 울렸을 때, 저는 처음엔 단순히 PG사 이슈인 줄 알았습니다. "결제는 됐는데 주문이 안 잡혀요"라는 사용자 문의가 몇 건 들어왔다는 거였죠. 그런데 운영 로그를 띄워보니, 우리 서비스 안에서 NPE가 같은 자리에서 일관되게 터지고 있었습니다. 한참 따라간 끝에 도착한 곳은 — "운영 금지" 패턴을 피하려고 골라 쓴 SCAN 명령이 거꾸로 우리를 물고 있는 광경이었습니다.

발생: 운영 환경, 오전 9시대 증상: 주문 생성 API에서 500 다발. 결제는 떨어졌지만 주문이 생성되지 않음.

이 글은 SCAN cursor가 깨지면서 발생한 NPE의 사고 분석에 초점이 있습니다. 우리가 SCAN을 그렇게 자주 호출하고 있었는지 — 검색 결과를 어떤 키로 캐시하고 어떤 트리거로 비우는지 — 에 대한 캐시 전략 자체는 항공권 검색 캐시 전략 — 무엇을 캐시하고, 언제 비우는가에서 별도로 정리했습니다. 이 글의 사고 흐름이 어색하게 느껴진다면 그쪽을 먼저 읽으셔도 좋습니다.

TL;DR

  • 캐시 무효화는 SCAN 명령으로 패턴에 매칭되는 키를 찾아 지우는 구조였습니다.
  • AWS Valkey Serverless가 돌려준 cursor 값이 너무 커서 spring-data-redisLong.parseLong으로 변환하다 깨졌습니다.
  • 예외를 삼킨 scanKeysnull을 반환했고, 호출자의 addAll(null)이 NPE로 터졌습니다.
  • 사용자에게는 결제는 됐는데 주문이 안 잡히는 모양으로 보였습니다.

핫픽스는 두 곳에 넣었습니다.

  1. scanKeys가 실패해도 null 대신 빈 Set 반환 → 호출자가 addAll(null)로 깨지지 않게.
  2. 호출자 측에서도 try/catch로 한 번 더 감싸기 → 캐시 단계가 어떻게 망가져도 본 흐름은 멈추지 않게.

근본 원인(cursor 자체가 큰 값으로 오는 것)은 spring-data-redis ↔ Valkey Serverless 호환성 문제라서 별도 트랙으로 미뤘습니다.


1. 사전 지식 — 본문 들어가기 전에 이것만

이번 사고를 깔끔하게 이해하려면 작은 개념 네 개만 잡고 가면 됩니다. 다 익숙한 분이라면 2번 챕터로 건너뛰셔도 됩니다.

"cursor"가 도대체 뭔가

이번 사고의 주인공이 cursor라는 값입니다. cursor = 다음 페이지 토큰. 그 이상도 이하도 아닙니다.

일상의 비유

두꺼운 책을 친구에게 빌려줬다고 해 봅시다. 친구는 한 번에 다 못 읽으니까 책갈피를 끼워가며 조금씩 읽겠죠.

친구: "오늘 어디까지 읽었지? 책갈피 보니까 187쪽이네. 내일 여기서 이어 읽으면 되겠다."

이 책갈피가 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) 64bit18,446,744,073,709,551,615 (≈ 1.84 × 10¹⁹) — 20자리

Java는 unsigned 정수 타입이 없습니다. 그래서 unsigned 64bit 값을 받으면 long으로 표현 못 하고 깨집니다.

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을 넘긴 거고, 호출 코드 한 줄만 찾으면 끝나는 일이니까요. 그런데 직전 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자리9286422431637976196Long.MAX_VALUE(≈ 9.22 × 10¹⁸) 살짝 초과 (~0.7%)
20자리99020052958589981810, 42080649734741140470unsigned 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,808Long.MAX_VALUE를 단 1 초과합니다.

여기에 Valkey Serverless가 cursor의 일부 비트에 클러스터 노드/슬롯 메타데이터를 추가로 끼워 넣어서 더 큰 값 영역으로 이동시킵니다. 그래서 운영에서는 19~20자리 값이 흔하게 들어옵니다.

요약: cursor가 큰 건 Redis 명세대로의 정상 동작입니다. 다만 우리 클라이언트가 이걸 처리 못 한 게 문제였습니다.

[2] spring-data-redis가 cursor를 long으로 변환 → NFE

우리 코드는 spring-data-redisRedisTemplate.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 메서드에 넣는 순간 모든 게 끝장이거든요.

[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]의 캐시 단계에서 터지므로 외부 호출이 아예 안 일어남 → 주문 미생성 → 500 응답. 결제는 이미 [b]에서 떨어진 상태라서 사용자 입장에선 정확히 "결제는 빠졌는데 주문은 안 잡힌다"가 됐던 것입니다.

이 사고가 결제·주문 분기 사고로 번진 진짜 이유는 캐시 무효화가 본 흐름 한가운데에 동기로 박혀 있었기 때문입니다. 보조 장치가 본 흐름의 운명을 잡고 있었던 셈이죠. 이 결합 자체에 대한 회고는 캐시 전략 글의 5절에 더 자세히 적었습니다.


4. 해결 — 핫픽스

NPE만 즉시 차단하는 핫픽스입니다. 근본 원인(cursor NFE)은 별도 트랙으로 미루고, 두 군데에 이중으로 방어를 박았습니다.

A. RedisComponent.scanKeysnull 반환 제거

 } catch (Exception e) {
     log.warn("패턴 기반 캐시 삭제 실패 (패턴: {}): {}", pattern, e.getMessage());
-    return null;
+    return Collections.emptySet();
 }

null 대신 빈 Set을 반환합니다. 호출자가 addAll(emptySet)을 해도 NPE가 안 나고 그냥 0개 추가됩니다. 이 한 줄로 scanKeys를 부르는 모든 호출자가 자동 보호됩니다.

null보다 emptySet이 나은가요? "결과가 없다"는 "빈 컬렉션"으로 표현하는 게 자바 관용입니다. 호출자가 매번 null 체크를 안 해도 되니 실수가 줄어듭니다. 만약 정말로 "실패"와 "결과 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. 진단 시 가장 도움됐던 단서

진단을 빨리 마무리할 수 있게 도와준 단서들을 꼽으면 셋입니다. 다음에 비슷한 사고를 만나면 이런 신호를 빨리 잡으면 됩니다.

  1. NFE 메시지의 자릿수. For input string: "9286422431637976196"의 19자리. 한눈에 "이게 long 범위 넘는 거 아니야?" 의심 → Long.MAX_VALUE와 비교 → 초과 확인. 이 한 줄에서 사실상 답이 거의 나와 있었습니다.
  2. 사용자 진술 vs 스택트레이스 일치. "결제는 됐는데 주문 안 됨"이라는 사용자 표현이, NPE가 외부 호출 직전 단계에서 터지는 모양과 정확히 들어맞았습니다. 이 일치성이 같은 시간대 다른 결제 실패 케이스와 본 NPE 케이스를 분리하는 데 결정적이었습니다.
  3. WARN과 NPE의 짝. RedisComponent WARN과 직후 NPE가 거의 항상 같은 thread에서 같은 시점에 찍혔다는 점. 둘이 인과 관계라는 강한 시그널이었습니다.

회고

이번 사고가 머리에 남긴 것들을 정리하자면:

  • "운영 금지" 패턴(KEYS)을 피하느라 SCAN을 골랐는데, 그 SCAN cursor의 명세가 클라이언트 라이브러리 구현과 불일치하면서 사고가 났습니다. 명세 → 구현 → 운영 환경 전체 스택에 어떤 가정이 깔려 있는지 한 번 더 의심해 볼 가치가 있다는 걸 다시 배웠습니다.
  • null 반환은 작은 결정 같지만 호출 그래프 전체에 함정을 깔아 둡니다. 자바에서 "없음"은 빈 컬렉션 / Optional 이지 null이 아닙니다.
  • "그래도 본 흐름은 진행" 원칙(graceful degradation)은 한 군데가 아니라 책임 경계마다 명시적으로 박아넣어야 한다는 점. 한 군데만 믿으면 그 한 군데를 누가 바꿀 때 다시 깨집니다.

가장 인상 깊었던 건 — "왜 이게 사용자에겐 결제 성공+주문 실패로 보였나" 를 따라가는 과정 자체였습니다. 스택트레이스 한 줄에서 출발해 Redis 명세, 더 거슬러 올라가 우리 캐시 전략의 구조까지 이르는 길이, 결국 시스템이 어떤 가정 위에 서 있는지를 다시 확인하는 작업이었거든요. 사고는 늘 그런 식으로 배움을 남기는 것 같습니다.

#lettuce#npe#redis#scan#spring-data-redis#troubleshooting#valkey