에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/캐시

[Redis] MULTI/EXEC 트랜잭션과 Lua 스크립트: 롤백 없는 원자성, WATCH, EVALSHA

반응형
[Redis] MULTI/EXEC 트랜잭션과 Lua 스크립트: 롤백 없는 원자성, WATCH, EVALSHA

[Redis] MULTI/EXEC 트랜잭션과 Lua 스크립트: 롤백 없는 원자성, WATCH, EVALSHA

"Redis는 트랜잭션이 안 돼서 Lua를 쓴다"는 말을 자주 듣는데, 정확히는 조금 달라요. Redis에도 트랜잭션(MULTI/EXEC)이 있어요. 다만 우리가 RDB에서 기대하는 것과 빠진 게 있어서 진짜 원자적 로직이 필요할 때 Lua 스크립트를 써요.

MULTI/EXEC가 무엇이고 거기서 무엇이 빠졌는지, WATCH 낙관적 락은 어떤 한계를 메우는지, 그리고 Lua 스크립트가 왜 필요한지를 실무 사례와 함께 따라가 볼게요. 여기서 다루는 원자성은 뒤에 나올 분산 락·레이트 리미팅·스탬피드가 모두 딛고 서는 바닥이에요. Redis 기본은 Spring Data Redis 설정 글을 먼저 보면 좋아요.

01. MULTI/EXEC, 원자적이지만 롤백은 없어요

MULTI로 시작해 명령을 쌓고 EXEC로 한 번에 실행하면, 그 사이 다른 클라이언트가 끼어들지 않아요. 그런 의미에서 원자적이에요.

Redis MULTI EXEC 트랜잭션의 두 가지 한계. 실행 중 한 명령이 실패해도 나머지는 그대로 실행되는 롤백 부재와, 큐에 쌓는 단계라 중간 결과로 분기할 수 없는 제약

그런데 RDB 트랜잭션과 결정적으로 다른 게 두 가지예요. 첫째, 롤백이 없어요. EXEC 중 한 명령이 런타임에 실패해도(예: 타입이 안 맞는 연산) 나머지 명령은 그대로 실행돼요. "다 되거나 다 안 되거나"가 아니에요.

둘째, 중간 결과로 분기를 못 해요. MULTIEXEC 사이는 명령을 큐에 쌓는 단계라, 앞 명령의 결과를 뒤 명령이 못 써요. "값을 읽어보고 조건에 따라 쓴다" 같은 걸 이 안에서 못 한다는 뜻이에요.

02. WATCH, 낙관적 락(CAS)

그 한계를 일부 메우는 게 WATCH예요. 특정 키를 감시하다가, EXEC 직전에 그 키가 바뀌었으면 트랜잭션을 취소해요. 낙관적 락(compare-and-set)이에요.

WATCH balance
val = GET balance        # 먼저 읽고
MULTI
SET balance (val - 100)  # 계산해서 쓰기
EXEC                     # balance가 그 사이 바뀌었으면 취소됨

이러면 "읽고-계산-쓰기"를 안전하게 할 수 있어요. 단 취소되면 애플리케이션이 다시 시도해야 해요. 경쟁이 심하면 재시도가 잦아져 비효율적이에요. 그래서 더 깔끔한 방법을 찾게 돼요.

03. Lua 스크립트, 서버에서 원자적으로

Lua 스크립트(EVAL)는 여러 명령을 하나의 스크립트로 묶어 서버에서 실행해요. Redis는 싱글스레드라, 스크립트가 도는 동안 다른 명령이 끼어들지 않아요. 그래서 스크립트 전체가 원자적이에요.

앱에서 읽고 판단하고 쓰는 3단계와 Lua 스크립트 비교. 분리 실행은 읽기와 쓰기 사이에 다른 서버가 끼어들어 재고가 음수가 되지만, Lua는 전체가 한 덩어리로 실행돼 동시 요청에도 안전하다

핵심 차이는, Lua 안에서는 읽은 값으로 분기하고 계산해서 쓸 수 있다는 거예요. MULTI/EXEC가 못 하던 "조건부 read-modify-write"를 한 번에 원자적으로 해요. 재시도도 필요 없어요.

-- 재고가 충분하면 차감하고 1, 아니면 0
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
end
return 0

이 스크립트는 "재고를 읽고, 충분한지 보고, 충분하면 차감"을 원자적으로 해요. 여러 서버가 동시에 호출해도 재고가 음수가 되지 않아요. 이게 Lua를 쓰는 진짜 이유예요.

04. Lua를 쓰는 곳

조건부 원자 연산이 필요한 곳이면 거의 다 Lua예요.

  • 한도 체크 후 차감 — 재고, 쿠폰 수량처럼 "남았으면 줄이기".
  • 분산 락 안전 해제 — "내가 건 락이 맞으면 푼다"를 원자적으로. Redisson 락 내부가 Lua예요(다음 편).
  • 레이트 리미팅 — "호출 수를 세고 한도 넘으면 거부"를 한 번에.

공통점은 "현재 값을 보고 조건에 따라 바꾼다"예요. 이걸 애플리케이션에서 읽고-판단-쓰기로 나눠 하면 그 사이에 다른 요청이 끼어들어 깨져요. Lua는 그 틈을 없애줘요.

레이트 리미팅을 예로 보면 이래요. "현재 카운트를 1 올리고, 처음이면 만료를 걸고, 한도를 넘으면 거부"를 한 스크립트로 묶어요.

-- 카운트 증가 + 첫 요청에 TTL + 한도 체크
local count = redis.call('INCR', KEYS[1])
if count == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])   -- 윈도 시간
end
if count > tonumber(ARGV[2]) then            -- 한도 초과
    return 0
end
return 1

이걸 INCR 따로, EXPIRE 따로, 한도 체크 따로 하면 그 사이에 요청이 끼어들어 카운트가 새거나 TTL이 안 걸려요. Lua로 묶으면 그 모든 게 한 번에 원자적으로 일어나요. 레이트 리미팅 편에서 이 패턴을 더 자세히 다뤄요.

05. 스프링에서 Lua 실행

스프링에서는 RedisScript로 Lua를 실행해요. 스크립트를 등록하고 키와 인자를 넘기면 돼요.

var script = RedisScript.of(luaText, Long.class);
Long result = redisTemplate.execute(
    script,
    List.of("stock:item:1"),   // KEYS
    "3");                       // ARGV
boolean ok = result == 1L;
키는 반드시 KEYS 인자로 넘겨요. 스크립트 안에 키를 하드코딩하면 단일 노드에선 돌아가지만 클러스터로 가는 순간 깨져요. Redis가 "이 스크립트가 어느 키를 건드리는지"를 알아야 올바른 노드로 보낼 수 있거든요. 처음부터 KEYS로 넘기는 습관을 들이는 게 좋아요.

06. EVALSHA, 스크립트를 매번 안 보내요

EVAL로 스크립트를 실행하면 매번 스크립트 전문을 Redis로 보내요. 자주 호출하면 그 자체가 낭비예요. 그래서 Redis는 스크립트를 한 번 등록해두고 해시(SHA)로 호출하는 EVALSHA를 줘요.

EVAL과 EVALSHA 비교. EVAL은 호출마다 Lua 스크립트 전문을 네트워크로 보내지만, EVALSHA는 최초 한 번 등록한 뒤 SHA 해시만 보내 Redis가 캐시된 스크립트를 실행한다

흐름은 이래요. 스크립트를 한 번 보내 SHA 값을 받아두고 이후엔 그 SHA로만 호출해요. Redis가 그 해시에 해당하는 스크립트를 캐시해뒀다 실행하니, 네트워크로 스크립트 전문을 반복해 보낼 필요가 없어요.

스프링의 RedisScript를 빈으로 한 번 만들어 재사용하면, 내부적으로 이 캐싱을 알아서 처리해줘요. 스크립트가 캐시에서 사라졌으면(서버 재시작 등) 자동으로 다시 등록하고요. 그래서 우리는 RedisScript를 매번 새로 만들지 말고 한 번 만들어 재사용하면 돼요.

@Bean
public RedisScript<Long> stockScript() {
    return RedisScript.of(new ClassPathResource("lua/decr-stock.lua"), Long.class);
}

07. Lua 주의점

가장 중요한 함정 — Lua 스크립트가 도는 동안 Redis 전체가 멈춰요. 싱글 스레드라 스크립트 실행 중엔 다른 모든 명령이 대기하거든요. 원자성을 주는 바로 그 성질이, 무거운 스크립트에서는 전체 장애가 돼요. 큰 컬렉션 순회나 긴 루프를 Lua에 넣으면 그 시간만큼 서비스의 모든 Redis 호출이 멈춰요.

그래서 Lua는 "짧고 원자적이어야 하는 핵심 로직"에만 써요. 복잡한 비즈니스 로직은 애플리케이션에서 처리하고 "동시성 때문에 반드시 원자적이어야 하는 작은 조각"만 Lua로 떼어내는 거예요.

08. 자주 만나는 문제

MULTI/EXEC를 썼는데 동시성 문제가 남아요

읽은 값으로 분기하는 로직을 MULTI/EXEC로 하려 한 거예요. 그건 큐 단계라 안 돼요. WATCH 재시도나 Lua로 바꿔요.

Lua 스크립트가 가끔 전체를 느리게 해요

스크립트가 무거운 거예요. 큰 컬렉션을 순회하거나 긴 연산이 들어간 게 아닌지 보고 무거운 부분은 애플리케이션으로 빼요.

클러스터에서 Lua가 에러나요

한 스크립트가 여러 키를 건드리는데 그 키들이 다른 노드에 흩어진 거예요. 같은 슬롯에 묶이게 키를 설계(hash tag)하거나, 키를 KEYS로 정확히 넘겨요.

정리

Redis에 트랜잭션(MULTI/EXEC)이 있긴 하지만 롤백이 없고 읽은 값으로 분기를 못 해요. 그래서 현재 값을 보고 조건부로 바꾸는 원자 연산이 필요하면 Lua 스크립트를 써요. Lua는 서버에서 read-modify-write를 한 덩어리로 처리해요. 그렇지만 그동안 Redis가 통째로 멈추니 짧고 가볍게 짜야 해요. 분산 락도 레이트 리미팅도 재고 차감도 결국 이 원자성 위에 서 있어요.

그러니 동시성 코드를 짤 때 한 줄로 자문해 봐요. "이 읽고-판단-쓰기 사이에 다른 요청이 끼어들면 깨지는가?" 그렇다면 그 조각이 바로 Lua로 떼어낼 부분이에요. 이 원자성을 그대로 활용하는 첫 사례가 분산 락이고 기본기는 Spring Data Redis 설정·캐싱·자료구조에 있어요.

출처: Redis — Transactions · Redis — Scripting with Lua · Spring Data Redis — Scripting

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."