본문으로 건너뛰기
개발 머꼬
개발 노트Redis
hohyeon.dev29

MULTI/EXEC 안에서 명령 하나가 실패했는데 나머지가 그대로 실행된 이유

  • #Common Pitfall
  • #Engineering Note
  • #Redis

문제 발생

재고 차감과 주문 생성을 묶었습니다.

MULTI
DECR stock:1
LPUSH orders "..."
EXEC

stock:1이 문자열이 아닌 타입이라 첫 명령이 실행 중 실패했는데, 주문은 그대로 쌓였습니다. 관계형 DB의 트랜잭션을 기대했다가 데이터가 어긋났습니다.

원인 분석

Redis 트랜잭션이 보장하는 것은 "격리"이지 "원자적 되돌리기"가 아닙니다. 문서는 두 가지를 보장한다고 적습니다 — 모든 명령이 직렬화되어 순차 실행되며 다른 클라이언트의 요청이 트랜잭션 실행 중간에 처리되지 않는다는 것, 그리고 EXEC 호출 전에 연결이 끊기면 아무 연산도 수행되지 않고, EXEC가 호출되면 모든 연산이 수행된다는 것입니다.

롤백은 없습니다 — Redis는 트랜잭션 롤백을 지원하지 않으며, 롤백 지원은 Redis의 단순성과 성능에 상당한 영향을 주기 때문입니다.

오류 처리도 시점에 따라 갈립니다. 큐잉 단계의 오류(문법 오류 등)는 EXEC 시점에 오류를 반환하고 트랜잭션을 폐기하지만, EXEC 이후 실행 중 발생한 오류는 특별 취급하지 않습니다 — 문서가 굵게 적습니다 — 명령이 실패해도 큐의 다른 모든 명령은 처리되며 Redis는 명령 처리를 멈추지 않습니다.

해결 방안

  1. 타입과 조건을 미리 보장합니다. 롤백이 없으므로 "실패할 수 있는 명령을 섞지 않는" 설계가 먼저입니다.

  2. 읽고 판단해서 쓰는 흐름에는 WATCH를 씁니다. 문서가 설명하는 낙관적 잠금입니다 — WATCH한 키가 EXEC 전에 수정되면 트랜잭션 전체가 중단되고 EXEC가 Null을 반환합니다.

WATCH stock:1
val = GET stock:1
MULTI
SET stock:1 <val-1>
EXEC        # nil 이면 다시 시도
  1. 더 단순한 길은 Lua입니다. 문서도 마지막에 이렇게 적습니다 — Redis 트랜잭션으로 할 수 있는 모든 것은 스크립트로도 할 수 있고, 보통 스크립트가 더 단순하고 빠릅니다. 조건 분기가 있으면 특히 그렇습니다.

  2. 최신 Redis에는 전용 명령도 있습니다. 8.4부터 문자열 키에 대한 원자적 compare-and-set(SET ... IFEQ)과 compare-and-delete(DELEX)가 있어, 값이 그대로일 때만 갱신하는 패턴을 한 명령으로 처리할 수 있습니다.

  3. WATCH의 해제 시점을 압니다. EXEC가 호출되면 성공·중단과 무관하게 모든 키가 UNWATCH되고, 연결이 닫혀도 해제됩니다.

  4. 진짜 트랜잭션이 필요하면 그 데이터를 Redis에 두지 않습니다. 주문·결제처럼 일관성이 핵심인 데이터는 관계형 DB의 몫입니다 — Redis는 그 앞의 캐시와 카운터에 쓰는 것이 맞습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.