Skip to main content
Summary

한 명령은 안전하지만, 여러 명령은 자동으로 안전하지 않다

Redis는 보통 한 번에 하나의 명령을 처리하는 단일 스레드 실행 모델로 설명된다. 이 특성 덕분에 INCR, DECR, HINCRBY, SADD, ZADD 같은 개별 명령은 실행 중간에 다른 클라이언트 명령이 끼어들지 않는다. 그래서 조회 수 증가, 좋아요 추가, 점수 증가처럼 하나의 Redis 명령으로 끝나는 작업은 자연스럽게 원자적이다.

문제는 서비스 로직이 대개 한 명령으로 끝나지 않는다는 점이다.

예를 들어 재고가 남아 있을 때만 차감하는 코드를 다음처럼 작성했다고 하자.

GET product:1001:stock
# 결과가 0보다 크면
DECR product:1001:stock

클라이언트 A와 B가 거의 동시에 GET을 실행하면 둘 다 재고가 1이라고 읽을 수 있다. 그 뒤 둘 다 DECR을 실행하면 재고는 -1이 된다. GET도 원자적이고 DECR도 원자적이지만, 두 명령 사이의 틈은 보호되지 않는다. 이것이 경쟁 조건(race condition)이다.

Redis에서 동시성 문제를 다룰 때 핵심 질문은 간단하다.

이 작업이 하나의 명령으로 끝나는가, 아니면 조회와 판단과 갱신이 분리되어 있는가?

하나의 명령으로 끝나면 Redis의 기본 원자성을 활용하면 된다. 여러 명령을 조합해야 한다면 MULTI/EXEC, WATCH, Lua 스크립트, Redis Functions 중 하나를 선택해야 한다.

원자성, 트랜잭션, Lua 스크립트AI 생성 이미지

MULTI와 EXEC로 명령을 묶기

Redis 트랜잭션은 MULTI로 시작하고 EXEC로 실행한다. MULTI 이후에 보낸 명령은 바로 실행되지 않고 큐에 쌓인다. 이때 Redis는 각 명령에 대해 QUEUED를 반환한다. 마지막에 EXEC를 보내면 쌓인 명령들이 순서대로 실행된다. 실행 전 취소하려면 DISCARD를 사용한다.1

MULTI
SET user:1001:last-login 2026-05-30
INCR user:1001:login-count
EXPIRE user:1001:last-login 86400
EXEC

EXEC 결과는 큐에 넣은 명령들의 응답 배열이다.

1) OK
2) (integer) 1
3) (integer) 1

이 방식의 장점은 EXEC로 실행되는 동안 다른 클라이언트 명령이 중간에 끼어들지 않는다는 것이다. 예를 들어 쿠폰 발급 성공 후 발급 수를 증가시키고 사용자 목록에 추가하는 작업을 한 덩어리로 실행할 수 있다.

MULTI
SADD coupon:20260530:issued-users user:1001
INCR coupon:20260530:issued-count
EXEC

하지만 Redis 트랜잭션은 관계형 데이터베이스의 트랜잭션과 다르다. 특히 두 가지를 조심해야 한다.

첫째, MULTI 안에서는 앞 명령의 결과를 읽어 다음 명령의 조건으로 사용할 수 없다. 명령은 큐에 쌓일 뿐이고 실제 결과는 EXEC 이후에 나온다. 따라서 "재고를 읽고, 0보다 크면 차감한다" 같은 분기 로직은 단순 MULTI/EXEC만으로 표현하기 어렵다.

둘째, Redis 트랜잭션에는 롤백(rollback)이 없다. EXEC 전에 문법 오류처럼 큐에 넣을 수 없는 오류가 발견되면 트랜잭션 전체가 실행되지 않는다. 하지만 EXEC 이후 실행 중 특정 명령이 타입 오류 등을 내더라도 이미 실행된 명령이나 나머지 명령이 자동으로 되돌아가지는 않는다.1

따라서 MULTI/EXEC는 다음 상황에 적합하다.

  • 여러 쓰기 명령을 중간 끼어들기 없이 순서대로 실행하고 싶다.

  • 각 명령이 앞 명령의 실행 결과에 의존하지 않는다.

  • 실패 시 애플리케이션에서 결과 배열을 확인하고 보정할 수 있다.

반대로 조회 결과에 따라 갱신 여부가 달라지는 작업에는 WATCH나 Lua 스크립트가 더 적합하다.

WATCH로 낙관적 잠금 사용하기

WATCH는 Redis의 낙관적 잠금(optimistic locking) 기능이다. 낙관적 잠금은 먼저 "충돌이 자주 나지 않을 것"이라고 가정하고 작업을 진행한다. 대신 마지막 실행 시점에 감시하던 키가 바뀌었는지 검사한다.

기본 흐름은 다음과 같다.

WATCH product:1001:stock
GET product:1001:stock

애플리케이션은 조회한 재고가 충분한지 판단한다. 재고가 없다면 UNWATCH로 감시를 해제하고 종료한다.

재고가 있으면 트랜잭션을 시작한다.

MULTI
DECR product:1001:stock
SADD product:1001:buyers user:55
EXEC

WATCH 이후 EXEC 전에 다른 클라이언트가 product:1001:stock을 변경했다면 EXEC는 실행되지 않고 Null 응답을 반환한다. 이 경우 애플리케이션은 처음부터 다시 시도해야 한다. EXEC가 성공하거나 실패하면 감시 상태는 해제된다.1

의사코드로 쓰면 다음과 같다.

반복:
  WATCH product:1001:stock
  stock = GET product:1001:stock

  stock <= 0 이면:
    UNWATCH
    실패 반환

  MULTI
  DECR product:1001:stock
  SADD product:1001:buyers user:55
  result = EXEC

  result가 Null이면:
    재시도

  성공 반환

WATCH는 재고 차감, 포인트 사용, 선착순 쿠폰 발급처럼 "읽고 판단한 뒤 갱신"하는 작업에 사용할 수 있다. 단, 충돌이 매우 많은 상황에서는 재시도가 폭증할 수 있다. 선착순 이벤트처럼 수천 요청이 동시에 같은 키를 수정하면 많은 클라이언트가 EXEC 실패 후 다시 시도한다. 이때는 Lua 스크립트처럼 서버 안에서 조건 검사와 갱신을 한 번에 처리하는 방식이 더 단순하다.

재고 차감과 쿠폰 발급에서 생기는 경쟁 조건

쿠폰을 한 사람에게 한 번만 발급하고, 전체 수량도 제한해야 한다고 하자. 필요한 조건은 두 가지다.

  1. 사용자가 이미 발급받지 않았어야 한다.

  2. 남은 쿠폰 수량이 0보다 커야 한다.

나쁜 구현은 다음과 같다.

SISMEMBER coupon:20260530:users user:55
GET coupon:20260530:stock
# 둘 다 통과하면
DECR coupon:20260530:stock
SADD coupon:20260530:users user:55

이 코드는 명령 하나하나는 안전하지만 전체 흐름은 안전하지 않다. 같은 사용자의 중복 요청이 동시에 들어오면 둘 다 SISMEMBER에서 미발급으로 판단할 수 있다. 남은 수량이 1개일 때 여러 사용자가 동시에 들어와도 모두 재고가 있다고 판단할 수 있다.

이 문제는 "검사(check)"와 "변경(update)"이 분리되어 있기 때문에 발생한다. 안전한 설계에서는 검사와 변경이 하나의 원자적 단위로 실행되어야 한다.

선택지는 세 가지다.

  • 단일 명령으로 모델링한다. 예: 단순 카운터 증가라면 INCR만 사용한다.

  • 충돌이 많지 않다면 WATCH와 재시도를 사용한다.

  • 조건이 복잡하거나 충돌이 많다면 Lua 스크립트 또는 Redis Functions를 사용한다.

실무에서는 단순 카운터는 Redis 기본 명령으로 처리하고, 선착순 쿠폰처럼 조건이 여러 개인 작업은 Lua 스크립트로 묶는 경우가 많다.

Lua 스크립트로 서버 안에서 한 번에 처리하기

Lua 스크립트는 여러 Redis 명령과 조건문을 서버 측에서 실행하게 해 준다. Redis는 스크립트 실행 중 다른 클라이언트 명령을 끼워 넣지 않는다. 즉, 스크립트 전체가 원자적으로 실행된다.2

쿠폰 발급 로직을 Lua로 작성하면 다음과 같다.

EVAL "
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local user_id = ARGV[1]

if redis.call('SISMEMBER', KEYS[2], user_id) == 1 then
  return -1
end

if stock <= 0 then
  return 0
end

redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], user_id)
return 1
" 2 coupon:20260530:stock coupon:20260530:users user:55

여기서 KEYS[1]은 재고 키, KEYS[2]는 발급 사용자 Set 키다. ARGV[1]은 사용자 ID다. 반환값은 애플리케이션에서 다음처럼 해석할 수 있다.

 1  = 발급 성공
 0  = 재고 없음
-1  = 이미 발급받은 사용자

이 방식의 장점은 명확하다. 중복 발급 확인, 재고 확인, 재고 차감, 사용자 기록이 하나의 원자적 작업으로 실행된다. 여러 요청이 동시에 들어와도 Redis는 스크립트를 하나씩 처리하므로 중간 상태가 노출되지 않는다.

다만 여기서 말하는 원자성은 "중간에 다른 명령이 끼어들지 않는다"는 뜻이다. 스크립트 안에서 오류가 발생했을 때 이미 수행한 쓰기를 자동으로 롤백해 준다는 뜻은 아니다. 그래서 스크립트가 다루는 키의 타입과 입력값을 애플리케이션 쪽에서도 일관되게 관리해야 한다.

Lua 스크립트를 사용할 때는 몇 가지 규칙을 지켜야 한다.

  • 스크립트가 접근할 키는 KEYS로 명시적으로 전달한다.

  • 일반 값은 ARGV로 전달한다.

  • 키 이름을 스크립트 안에서 임의로 조합하는 방식은 피한다.

  • 오래 걸리는 반복문, 큰 컬렉션 전체 순회, 외부 호출은 넣지 않는다.

특히 마지막 규칙이 중요하다. Lua 스크립트는 실행되는 동안 Redis 서버를 막는다. 스크립트가 1초 동안 실행되면 그동안 다른 클라이언트의 명령도 기다린다. Redis가 빠르다는 이유로 무거운 비즈니스 로직을 스크립트에 모두 넣으면 전체 지연 시간이 나빠질 수 있다.

Redis Functions는 언제 사용할까

Lua 스크립트는 보통 EVAL로 실행하거나 SCRIPT LOAD로 서버에 올린 뒤 EVALSHA로 호출한다. 다만 스크립트 캐시는 재시작, 장애 조치, SCRIPT FLUSH 등에 의해 사라질 수 있다. 그래서 애플리케이션은 스크립트 본문이나 재로드 로직을 가지고 있어야 한다.

Redis 7부터는 Redis Functions를 사용할 수 있다. Functions는 함수를 Redis에 라이브러리 형태로 로드하고 FCALL로 호출하는 방식이다. Lua 스크립트처럼 원자적으로 실행되지만, Redis 데이터베이스의 구성 요소처럼 저장되고 복제되며 재시작 후에도 유지되는 점이 다르다.3

간단히 구분하면 다음과 같다.

  • EVAL: 간단한 스크립트 실험이나 애플리케이션 내부 로직에 적합하다.

  • SCRIPT LOAD + EVALSHA: 자주 호출되는 Lua 스크립트를 효율적으로 실행할 때 사용한다.

  • Redis Functions: 여러 애플리케이션이 공유하는 서버 측 로직을 안정적으로 배포하고 관리할 때 적합하다.

Functions도 만능은 아니다. 실행 중 Redis를 막는다는 점은 Lua 스크립트와 같다. 따라서 함수는 짧고 예측 가능해야 한다. 복잡한 집계, 오래 걸리는 정리 작업, 대량 데이터 순회는 백그라운드 작업이나 별도 데이터 처리 파이프라인으로 분리하는 편이 안전하다.

파이프라인은 트랜잭션이 아니다

많이 헷갈리는 개념이 파이프라인(pipeline)이다. 파이프라인은 여러 명령을 한 번에 보내 네트워크 왕복을 줄이는 기법이다. 성능 최적화에는 유용하지만 원자성을 제공하지 않는다.

예를 들어 클라이언트가 GET, DECR, SADD를 파이프라인으로 보냈다고 해서 그 묶음 전체가 하나의 트랜잭션처럼 취급되는 것은 아니다. 또한 앞 명령의 결과를 보고 뒤 명령을 바꾸는 조건 분기도 어렵다. 파이프라인은 "빠르게 많이 보내기" 위한 도구이지, "안전하게 한 덩어리로 실행하기" 위한 도구가 아니다.

정리하면 다음과 같다.

방식목적원자성적합한 상황
단일 명령가장 단순한 상태 변경있음카운터 증가, Set 추가, 점수 증가
파이프라인네트워크 왕복 감소없음독립 명령을 많이 보낼 때
MULTI/EXEC여러 명령을 한 번에 실행있음중간 결과가 필요 없는 묶음 쓰기
WATCH + MULTI/EXEC변경 감지 후 조건부 실행성공 시 있음충돌이 적은 조회 후 갱신
Lua / Functions조건과 갱신을 서버에서 실행있음재고 차감, 쿠폰 발급, 복합 검증

안전한 조합을 위한 체크리스트

동시 요청이 많은 기능을 Redis로 만들 때는 다음 질문을 먼저 확인하자.

  • 이 작업은 Redis 단일 명령으로 끝낼 수 있는가?

  • 조회 결과를 바탕으로 갱신 여부를 결정하는가?

  • 같은 키에 동시에 요청이 몰릴 가능성이 큰가?

  • 실패했을 때 재시도해도 사용자 경험이 괜찮은가?

  • Lua 스크립트나 Function이 너무 오래 실행될 가능성은 없는가?

  • 오류가 발생했을 때 애플리케이션이 결과를 명확히 해석할 수 있는가?

Redis의 원자성은 강력하지만 자동으로 모든 비즈니스 로직을 보호하지는 않는다. 한 명령은 안전하다. 여러 명령을 조합하는 순간부터는 설계가 필요하다. 단순한 묶음은 MULTI/EXEC, 충돌 감지는 WATCH, 복잡한 조건부 갱신은 Lua 스크립트나 Redis Functions로 처리하자. 이 기준만 지켜도 재고 음수, 중복 쿠폰, 잘못된 카운터 같은 흔한 동시성 버그를 크게 줄일 수 있다.

참고

1Redis Transactions

2Scripting with Lua

3Redis Functions

책 목록

Redis 입문과 실전 운영

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기