[redisson] 기여기, 고쳐진 커밋 하나가 알려준 진짜 버그

Halu·2026년 8월 21일

오픈소스

목록 보기
3/3
post-thumbnail

Expiration 하나에 숨어있던 다섯 가지 의미

ShedLock 조사를 마치고 다시 redisson으로 돌아왔습니다.

예전에 자체 발견한 버그(BaseTransactionalMap.isEqual() ByteBuf leak, #7302)를 이슈로만 올렸다가 1시간 43분 만에 다른 기여자에게 선점당한 적이 있어서, 이번엔 이슈와 PR을 동시에 낼 수 있는 후보를 찾았습니다

그러다가 5일 전에 조용히 고쳐진 커밋 하나를 발견했고, 거기서 시작해서 심각한 버그를 찾을 수 있었습니다.

redisson과 Spring Data Redis 통합

redisson은 자바에서 Redis를 쓸 때 단순 클라이언트를 넘어 분산 락, 분산 컬렉션, 분산 서비스(스케줄러, 세마포어 등)까지 제공하는 라이브러리입니다.

Redis를 캐시 정도로만 쓰는 게 아니라, 여러 서버 인스턴스가 안전하게 상태를 공유하도록 돕는 인프라 레이어로 쓰고 싶을 때 많이 선택됩니다.

redisson은 Spring Data Redis가 정의한RedisConnection/ReactiveRedisConnection 인터페이스도 구현하고 있어서, Spring 진영에서 Lettuce 대신 커넥션 팩토리로 바로 갈아끼울 수 있습니다.

이번에 다룰 getEx() 버그는 이 통합 레이어에 Spring Data Redis 버전별로 나뉜 redisson-spring-data-* 모듈에 있었습니다.

오픈소스 기여처를 고를 때 PR 머지율과 처리 속도를 중요하게 보는데, redisson의 경우 활발히 관리되는 편이라 후보에 올랐고, 예전에 BaseTransactionalMap.isEqual() 버그(#7302)로 한 번 기여한 적이 있어서, 이번에도 이 프로젝트로 돌아왔습니다.

형제 코드를 의심해보자

redisson의 git 로그를 훑다가 RedissonConnection.getEx()를 고친 커밋(6c393a51f)을 발견했습니다.

5일 전, 메인테이너 본인이 작성한 커밋이었습니다.

// RedissonConnection.getEx() - 수정 후
public byte[] getEx(byte[] key, Expiration expiration) {
    if (expiration.isPersistent()) {
        return write(key, ByteArrayCodec.INSTANCE, GETEX, key, "PERSIST");
    }
    if (expiration.isKeepTtl()) {
        return write(key, ByteArrayCodec.INSTANCE, GETEX, key);
    }
    if (expiration.isUnixTimestamp()) {
        return write(key, ByteArrayCodec.INSTANCE, GETEX, key,
                "PXAT", expiration.getExpirationTimeInMilliseconds());
    }
    return write(key, ByteArrayCodec.INSTANCE, GETEX, key,
            "PX", expiration.getExpirationTimeInMilliseconds());
}

이 커밋 메시지를 다시 보다가 눈에 걸리는 게 있었습니다.
이슈 번호가 안 달려있었습니다.

redisson 저장소는 보통 "Fixed - RStream.autoClaim() throws ClassCastException. #5789"처럼 관련 이슈를 커밋 메시지에 남기는데, 이 커밋만 그게 없었습니다.

GitHub에서 이 커밋 해시로 검색해봐도 연결된 이슈나 PR이 없었습니다. 메인테이너가 사용자 신고 없이 코드 리뷰 중 스스로 발견해서 고친 것 같았습니다.

여기서 한 가지 든 생각이 "이거 동기(sync) 버전만 고친 거 아닐까?"
ShedLock에 기여할 때도 비슷한 경험을 한 적이 있기 때문입니다.

한쪽이 고쳐졌으면, 구조가 똑같은 형제 코드가 안 고쳐진 채로 남아있을 확률이 꽤 높습니다.

Spring Data Redis는 같은 기능을 두 세트로 제공합니다.

블로킹 API(RedissonConnection)와, Project Reactor 기반으로 논블로킹 스택(WebFlux)에서 쓰는 리액티브 API입니다.

왜 두 개일까?

  • 블로킹 스택은 응답을 기다리는 동안 스레드가 멈춰 있고, 리액티브 스택은 그 시간에 스레드를 다른 요청에 돌려씁니다.
  • 이 차이 때문에 반환 타입부터(byte[] vs Mono<byte[]>) 다르고, 구현도 클래스가 따로 분리돼 있습니다.

방금 고쳐진 getEx()가 블로킹 쪽이었으니, 리액티브 쪽에 같은 이름의 메서드가 따로 있는지부터 확인했습니다.

grep -rn "getEx" --include=*.java redisson-spring-data-41/src/main/java

RedissonReactiveStringCommands.java에 동일한 이름의 getEx()가 나왔습니다.

방금 본 블로킹 버전과 나란히 열어 비교해보니, 블로킹 쪽은 isPersistent()/isKeepTtl()/isUnixTimestamp() 분기가 다 들어가 있는데 리액티브 쪽은 그 분기 자체가 없었습니다. 예상한 대로, 안 고쳐져 있었습니다.

// RedissonReactiveStringCommands.getEx() - 수정 전 (원본)
public Flux<ByteBufferResponse<GetExCommand>> getEx(Publisher<GetExCommand> commands) {
    return execute(commands, command -> {
        Assert.notNull(command.getKey(), "Key must not be null!");
        byte[] keyBuf = toByteArray(command.getKey());
        Mono<byte[]> m = write(keyBuf, ByteArrayCodec.INSTANCE, GETEX, keyBuf,
                                "PX", command.getExpiration().getExpirationTimeInMilliseconds());
        return m.map(v -> new ByteBufferResponse<>(command, ByteBuffer.wrap(v)))
                .defaultIfEmpty(new AbsentByteBufferResponse<>(command));
    });
}

Expiration 하나에 다섯 가지 의미가 숨어있다

Redis의 GETEX 명령은 만료 시간을 여러 방식으로 지정할 수 있습니다.

"몇 초 뒤 만료"(PX), "TTL 완전 제거"(PERSIST), "기존 TTL 유지"(옵션 없음), "정확히 이 시각에 만료"(PXAT).

redisson은 이 네 가지를 Expiration이라는 객체 하나로 표현합니다.

Expiration은 이 네 가지를 어떻게 구분할까?

  • 전부 getExpirationTimeInMilliseconds()라는 메서드 하나로 값을 꺼내는데, PERSIST는 이 메서드가 -1000을, KEEPTTL-2000을 돌려주는 방식으로 인코딩돼 있습니다.
  • 절대 있을 수 없는 음수 값을 "이건 평범한 시간이 아니라 특수 모드다"라는 신호로 쓰는 겁니다. isPersistent()/isKeepTtl()/isUnixTimestamp() 같은 메서드로 이 특수 모드인지 먼저 확인하고 나서, 진짜 숫자인 경우에만 그 값을 써야 합니다.

방금 본 원본 코드는 이 확인 절차를 통째로 건너뛰고, getExpirationTimeInMilliseconds()가 뭘 돌려주든 그냥 PX로 보내버립니다.

그러니까:

요청한 것실제로 Redis에 보내지는 것결과
Expiration.persistent()GETEX key PX -1000Redis가 명령 자체를 거부
Expiration.keepTtl()GETEX key PX -2000Redis가 명령 자체를 거부
Expiration.unixTimestamp(...)GETEX key PX <epoch초 그대로>명령은 실행되지만 TTL이 터무니없이 커짐
평범한 상대 시간(Expiration.milliseconds(60000))GETEX key PX 60000정상

맨 아래 한 줄만 우연히 하드코딩된 경우와 맞아떨어져서 정상 동작하고, 나머지 세 줄은 전부 망가져 있었습니다.

에러 나는 버그와 조용히 틀리는 버그를 재현해보자

PERSIST/KEEPTTL은 그나마 다행입니다. Redis가 바로 에러를 던지니까요.

RedisSystem ERR invalid expire time in 'getex' command. params: [key, PX, -1000]
RedisSystem ERR invalid expire time in 'getex' command. params: [key, PX, -2000]

문제는 unixTimestamp입니다.

이건 에러가 안 납니다. EXAT/PXAT가 원래 절대 시각(epoch 값)을 받는 옵션인데, 이 값을 그대로 상대 지연(PX)으로 해석해버리니까,

"지금부터 120초 뒤"로 설정하려던 TTL이 "지금부터 epoch 값만큼의 밀리초 뒤"로 계산됩니다. 실제로 재현해보니 이렇게 나왔습니다.

기대한 TTL: 약 120,000 ms (2분)
실제로 찍힌 TTL: 1,787,146,328,998 ms (약 56.6년)

캐시 값이 사실상 영원히 안 지워지는 셈입니다.

에러가 안 나니까 로그에도 안 남고, 개발자도 눈치채기 어렵습니다. 에러 나는 버그보다 조용히 틀리는 버그가 더 무섭다는 게 다시 한번 확인됐습니다.

왜 지금까지 아무도 몰랐을까?

여기서 "이렇게 심각한 버그인데 왜 이슈 하나 없었지?"라는 질문이 자연스럽게 들었습니다.

근거 없이 "운이 좋았다"로 넘기고 싶지 않아서, 세 가지를 직접 확인했습니다.

1. 이 경로에 도달하는 게 원래 어렵습니다.
Spring Data Redis의 고수준 API인 ReactiveValueOperationsjavap로 열어봤습니다.

public abstract Mono<Boolean> set(K, V, Expiration);
public abstract Mono<V> setGet(K, V, Expiration);
public abstract Mono<V> getAndExpire(K, Duration);
public abstract Mono<V> getAndPersist(K);

편의 메서드는 상대 시간(getAndExpire)과 PERSIST(getAndPersist)뿐입니다. KEEPTTL이나 EXAT용 편의 메서드는 아예 없습니다.

이 버그를 만나려면 편의 메서드를 건너뛰고 ReactiveStringCommands.GetExCommand를 직접 만들어야 하는데, 이건 대부분의 사용자가 안 쓰는 저수준 API입니다.

2. 동기 쪽 수정도 신고받은 게 아니었습니다.
앞서 확인한 대로, 6c393a51f 커밋엔 연결된 이슈가 없습니다.

메인테이너가 코드 리뷰 중 스스로 발견한 것으로 보입니다. 즉 동기 버전조차 "사용자가 겪고 신고해서" 고쳐진 게 아니라는 뜻입니다.

3. 이 모듈 자체가 신생입니다.
git log로 확인해보니 이 코드가 들어있는 redisson-spring-data-41 모듈(Spring Data Redis 4.1 지원)은 겨우 2주 전에 처음 추가됐습니다.

git log --diff-filter=A --oneline -- redisson-spring-data-41/pom.xml
55e8d6609 Feature - Spring Data Redis 4.1.0 integration #7191

세 조건을 겹쳐보면 명확하게 드문 진입 경로 + 낮은 채택률 + 애초에 사용자 신고로 발견된 적 없는 형제 버그라는 것을 알 수 있습니다.

"아무도 신고 안 했다"는 게 이상한 게 아니라 오히려 자연스러운 상황이었습니다.

10개 모듈 전부에 수정과 검증을 해보자

redisson은 Spring Data Redis 버전마다(2.6부터 4.1까지) 별도 모듈을 두고 있는데, 새 버전 모듈을 만들 때마다 이전 버전 코드를 그대로 복사해온 이력이 있습니다.

그래서 getEx() 버그도 한 곳이 아니라

이 메서드를 가진 10개 모듈(redisson-spring-data-{26,27,30,31,32,33,34,35,40,41}) 전부에 똑같이 퍼져 있었습니다.

수정은 동기 버전이 이미 쓰고 있는 방식을 그대로 따라 했습니다.

if (expiration.isPersistent()) {
    m = write(keyBuf, ByteArrayCodec.INSTANCE, GETEX, keyBuf, "PERSIST");
} else if (expiration.isKeepTtl()) {
    m = write(keyBuf, ByteArrayCodec.INSTANCE, GETEX, keyBuf);
} else if (expiration.isUnixTimestamp()) {
    m = write(keyBuf, ByteArrayCodec.INSTANCE, GETEX, keyBuf,
            "PXAT", expiration.getExpirationTimeInMilliseconds());
} else {
    m = write(keyBuf, ByteArrayCodec.INSTANCE, GETEX, keyBuf,
            "PX", expiration.getExpirationTimeInMilliseconds());
}

10곳에 똑같이 적용하고, 4가지 Expiration 변형(상대시간/PERSIST/KEEPTTL/EXAT)을 검증하는 테스트도 10개 모듈 전부에 복사해 넣었습니다.

Docker로 실제 Redis를 띄워서 10개 모듈을 전부 돌린 결과입니다.

Redisson/Spring Data Redis v2.6.x integration ...... SUCCESS
Redisson/Spring Data Redis v2.7.x integration ...... SUCCESS
Redisson/Spring Data Redis v3.0.x integration ...... SUCCESS
Redisson/Spring Data Redis v3.1.x integration ...... SUCCESS
Redisson/Spring Data Redis v3.2.x integration ...... SUCCESS
Redisson/Spring Data Redis v3.3.x integration ...... SUCCESS
Redisson/Spring Data Redis v3.4.x integration ...... SUCCESS
Redisson/Spring Data Redis v3.5.x integration ...... SUCCESS
Redisson/Spring Data Redis v4.0.x integration ...... SUCCESS
Redisson/Spring Data Redis v4.1.x integration ...... SUCCESS

BUILD SUCCESS
Total time: 02:37 min

10개 모듈 전부 4개 테스트씩 통과했습니다.

이를 기반으로 이슈와 PR을 이번엔 #7302 때처럼 선점당하지 않기 위해 2분 간격으로 같이 올렸습니다.

마무리

이슈(#7308), PR(#7309)
결과적으로 이슈와 PR은 메인테이너에 의해 병합되었습니다.

버그를 찾는 것과, 그 버그가 왜 지금까지 안 알려졌는지 설명하는 것은 다른 능력이라는 걸 이번에 느꼈습니다.

"심각한 버그를 찾았다"고만 하면 "그럼 왜 아무도 신고를 안 했겠어?"라는 당연한 반문이 돌아오게 되고

결국 그 반문에 근거를 갖고 답할 수 있어야, 처음의 주장에도 설득력이 생깁니다.

이번 기여를 하면서 이슈를 찾을 때 몇 가지 원칙을 세울 수 있게 되었습니다.

1. 최근 커밋부터 거꾸로 훑는다.

새로 고쳐진 코드는 그 자체로 "여기 버그가 있었다"는 증거입니다.
특히 이슈 번호가 안 달린 커밋은 메인테이너가 신고 없이 스스로 찾아낸 경우가 많고, 그런 코드 주변에 아직 발견 안 된 형제 버그가 남아있을 확률이 높습니다.

2. 형제 코드를 의심한다.

동기/리액티브, 버전별 모듈처럼 구조가 복제된 코드는 한쪽만 고쳐지고 나머지가 방치되는 경우가 흔합니다. 하나를 고친 흔적을 보면 항상 "다른 어디에 같은 구조가 또 있나"부터 확인하게 되었습니다.

3. 왜 지금까지 안 알려졌는지 스스로 답한다.

진입 경로가 드문지, 채택률이 낮은지, 애초에 신고 기반으로 발견된 적 없는 코드인지 확인합니다. 이 답을 갖고 있어야 버그 리포트에 설득력이 생기고, 메인테이너 입장에서도 재현·판단이 쉬워집니다.

4. 같은 패턴이 퍼진 범위를 끝까지 확인한다.

한 곳에서 발견한 버그를 그 파일 하나로 좁혀서 끝내지 않고, redisson-spring-data-{26...41}처럼 복사된 모든 위치를 찾아서 함께 고쳐서 제출하는 게 좋습니다.

5. 이슈와 PR은 같이, 그리고 빠르게 올린다.

이슈만 먼저 올리면 다른 기여자에게 선점당할 수 있습니다(실제 다른 오픈소스에서 겪은 일입니다).
그래서 현재 오픈소스의 경우 재현과 수정이 끝나면 되도록 이슈와 PR을 거의 동시에 올리려고 합니다.

(하지만, 오픈소스 마다 기여 문화 차이가 있기 때문에 우선 기여 문화를 파악한 후에 수행하는 게 좋습니다.)

2개의 댓글

comment-user-thumbnail
2026년 8월 21일

오픈 소스 관련해서 문외한인데도 글 내용이 이해가 잘 되게 써주셨네요. 좋은 글 잘 보고 갑니다!

1개의 답글