Redis에서 작업을 꺼낸 것과 끝낸 것은 다르다

목차

Redis 리스트에서 작업 X를 꺼낸 직후 워커가 종료됐다. RPOP은 성공해 큐 길이가 줄었지만 작업 결과는 없다. 큐를 설계할 때 가져온 작업을 완료로 처리하면 안 되는 이유와, 재전달·중복 실행을 다룰 상태를 이 실패 순서에서 찾을 수 있다.

가상의 작업 X를 끝까지 따라가 보자. Redis 명령이 어디까지 보장하는지 살펴보고, 같은 작업이 다시 왔을 때 외부 결과의 중복을 어떻게 막을지 Python과 SQLite로 실험한다.

원자적인 명령 두 개가 원자적인 절차는 아니다

리스트 끝의 값을 읽고 나중에 삭제하는 소비자를 만들면 두 워커가 같은 값을 볼 수 있다.

A: 끝의 X를 읽는다.
B: 끝의 X를 읽는다.
A: 끝에서 하나를 제거하고 X를 실행한다.
B: 끝에서 하나를 제거하고 X를 실행한다.

B의 삭제는 다음 항목 Y를 제거할 수도 있다. 중복 실행만 생기는 것이 아니라 서로 다른 항목을 잃을 수 있는 절차다. 읽고 제거하는 동작을 RPOP 하나로 만들면 이런 interleaving이 사라진다. RPOP

그러나 원자성의 범위는 여전히 Redis의 리스트 변경이다. 반환된 X를 워커가 처리할 때까지 Redis가 기다리거나, 그 워커의 외부 DB 결과를 같은 연산으로 확정하는 것은 아니다.

중단 지점큐에 X가 남는가결과가 존재하는가재시작에서 필요한 행동
꺼내기 전예아니오정상 소비
RPOP 직후, 처리 전아니오아니오큐만 보면 복구할 근거가 없음
처리 직후, 완료 확인 전별도 기록에 따라 다름예재전달하면 중복 효과를 막아야 함

“한 번만 꺼냈다”는 확인이 충분하지 않은 이유다.

작업을 지우지 말고 책임이 있는 위치로 옮긴다

대기 리스트에서 처리 중 리스트로 원자적으로 옮기면, 워커가 사라져도 누군가 맡았던 작업을 찾을 수 있다. Redis 6.2부터 제공하는 LMOVE가 이 이동을 지원한다. 처리 완료 뒤 처리 중 항목을 제거하고, 오래 남은 항목을 다시 대기열로 옮기는 reliable queue 패턴도 공식 문서에 설명돼 있다. LMOVE의 reliable queue

ready의 X
   → ready에서 제거하면서 processing에 추가
   → 업무 처리
   → 결과 확정
   → processing에서 제거(ack)

이 흐름에서 processing은 “아직 안 끝난 작업” 목록이다. 실패했다는 확정 목록은 아니다. 느린 작업도, 완료했지만 ack 전에 종료된 작업도 들어 있을 수 있다. 복구자는 오래 남았다는 이유로 원래 worker가 반드시 죽었다고 판단할 수 없다.

일반적인 리스트 패턴에서 시간과 소유자 정보를 관리하는 책임도 애플리케이션에 있다. 단순히 같은 문자열 X를 processing에서 제거하는 것만으로 재할당 세대까지 검사하지는 않는다. A의 작업을 B에게 재전달한 뒤 A가 늦게 ack하면 B가 맡은 기록을 지워 버릴 수 있다. 작업 ID와 처리 시도 ID를 구분하고 현재 시도의 소유권을 원자적으로 검사하거나, 이 관리 기능이 있는 큐를 사용해야 한다.

이 과정은 lease 만료 뒤의 쓰기와 닮았다. 시간 초과는 복구를 시작할 근거이고, 옛 실행이 더 이상 존재하지 않는다는 증거는 아니다.

결과가 확정된 뒤 ack를 잃으면 재전달된다

가상의 업무는 “합계에 10을 더한다”다. X를 처리해 합계를 10으로 만들고 ack 직전에 종료됐다고 하자. processing에 X가 남았으므로 복구자는 X를 다시 실행한다. 단순한 덧셈은 결과를 20으로 만든다.

반대로 ack부터 지우고 합계를 바꾸면, 그 사이 종료됐을 때 작업을 잃는다. 순서만 바꿔 두 실패를 모두 없앨 수는 없다. 재전달을 허용하되 동일 작업의 결과를 한 번만 반영하는 경계가 필요하다.

아래는 작업별 완료 영수증과 합계 갱신을 같은 SQLite 트랜잭션에 두는 부분이다. job에는 payload 문자열이 아니라 논리 작업의 안정된 ID를 사용한다. 전체 실행 파일은 python3 queue_ack.py로 실행할 수 있다.

def apply_once(job):
    with db:
        inserted = db.execute(
            "INSERT OR IGNORE INTO receipt VALUES (?)", (job,)
        ).rowcount
        if inserted:
            db.execute("UPDATE total SET value=value+10")
    return bool(inserted)

receipt.job은 기본 키다. 첫 실행은 영수증을 삽입하고 합계를 바꾼다. 재실행은 이미 있는 ID 때문에 삽입하지 않으므로 덧셈도 하지 않는다. 영수증만 먼저 저장하면 실패한 작업을 완료로 오인하고, 합계만 먼저 저장하면 중복 효과가 생긴다. 둘을 하나의 트랜잭션으로 묶어 함께 반영하거나 함께 롤백하는 이유다. SQLite 트랜잭션

실험은 큐 상태를 메모리에서 순서대로 바꾸고, 결과 저장은 실제 SQLite에서 수행한다. 운영 프로세스를 죽이거나 Redis의 복제를 검증하는 테스트는 아니다. worker가 각 경계에서 사라졌다고 가정했을 때 남은 기록으로 무엇을 판단할 수 있는지 확인한다.

pop then worker loss: no recoverable queue entry
commit then acknowledgement loss: delivered twice, total=10

두 번째 줄에서 큐 전달은 두 번이고 결과 반영은 한 번이다. 이 차이를 “메시지가 한 번만 처리됐다”로 줄이면 재시도 비용과 실제 실행 횟수를 숨기게 된다.

멱등성 키도 설계가 필요하다

같은 내용의 요청 두 개가 언제나 같은 작업은 아니다. 사용자가 같은 상품을 두 번 주문할 수도 있다. 반대로 네트워크 재시도로 들어온 두 메시지는 같은 논리 작업일 수 있다. 중복 제거 키는 업무의 동일성을 표현해야 하며 큐 전달 때마다 새로 만든 ID로는 재전달을 구분할 수 없다.

영수증 보관 기간도 재전달 가능 기간보다 짧으면 안 된다. 1일 뒤 영수증을 지웠는데 일주일 전 작업이 재생되면 새 요청처럼 실행된다. 같은 ID로 서로 다른 payload가 들어오는 경우에는 조용히 무시하기보다 불일치를 확인할 정보가 필요하다.

외부 API 호출은 SQLite 트랜잭션에 자동으로 들어가지 않는다. 영수증을 기록하고 외부 호출을 하면 호출 전 종료 구간이 남고, 호출 뒤 기록하면 응답 유실 구간이 남는다. 외부 시스템의 idempotency key나 operation 상태 조회, 확정한 의도를 나중에 전달하는 outbox 같은 추가 계약이 필요한 지점이다. 트랜잭션 안에 HTTP 호출을 넣는 것만으로 원격 시스템이 함께 롤백되지는 않는다.

리스트 패턴을 유지할지 Streams를 쓸지

직접 구현한 리스트 큐가 단순한 동안에는 ready/processing과 소유권·시간 기록을 관리할 수 있다. 하지만 소비자 그룹, 미확인 메시지 목록, 소유권 인수가 계속 필요하다면 Redis Streams가 제공하는 기능과 비교할 가치가 있다.

Streams의 XACK은 소비자 그룹의 pending entry를 제거하고, XAUTOCLAIM은 일정 시간 이상 미확인 상태인 항목의 소유권을 다른 소비자로 옮긴다. XACK은 stream 본문 자체의 삭제와 같지 않다. 또한 claim은 원래 worker를 중지시키지 않으므로 업무 멱등성은 여전히 필요하다. XACK, XAUTOCLAIM

기능이 더 많다는 이유만으로 이전할 필요는 없다. 현재 직접 관리하는 상태가 무엇인지 나열하고, 제품 기능으로 옮기면 어떤 상태와 경쟁을 줄일 수 있는지 비교한다. 처리 결과를 외부 저장소에 쓰는 한 ack와 결과의 경계는 남는다.

복구 가능성과 저장 내구성은 따로 확인한다

processing 목록이 있어도 Redis 자체의 데이터가 유실되면 복구 근거가 사라진다. RDB의 스냅샷 간격, AOF의 동기화 설정, 장애 조치에서 허용하는 유실 범위는 별도로 판단한다. 처리 절차를 reliable queue로 바꿨다는 사실만으로 모든 장애에서 데이터가 보존되는 것은 아니다. Redis persistence

대기열을 제한할 때도 읽기와 변경의 간격이 생긴다. 길이를 읽고 나서 별도로 넣으면 두 생산자가 같은 빈자리를 볼 수 있다. 엄격한 상한이면 검사와 추가를 같은 원자적 경계에 두며, 상한을 넘은 요청은 대기·거절·별도 저장 중 어느 상태인지 호출자에게 알려야 한다. 조용히 오래된 항목을 잘라 버리는 방식은 처리되지 않은 작업을 잃을 수 있다.

다음 검증에서는 worker 두 개를 동시에 돌리는 것에 더해 꺼낸 직후와 결과 확정 직후에 중단을 넣는다. 첫 중단에서는 작업이 어디에 남는지, 두 번째에서는 같은 작업이 다시 와도 결과가 유지되는지를 확인한다. 큐 길이가 줄었다는 사실보다 이 두 관찰이 완료 보장을 더 정확히 설명한다.