트랜잭션 (Transactions)

트랜잭션 (Transactions)

Redis 트랜잭션은 명령 여러 개를 한 단계로 묶어 실행하게 해 줘요. 트랜잭션은 MULTI, EXEC, DISCARD, WATCH 이 네 명령을 중심으로 동작합니다. Redis 트랜잭션은 중요한 보장 두 가지를 만들어 줍니다.

  • 트랜잭션 안의 모든 명령은 직렬화되어 순서대로 실행돼요. 다른 클라이언트가 보낸 요청이 Redis 트랜잭션 실행 중간에 끼어들어 처리되는 일은 절대 없습니다. 그래서 명령들이 단일 격리(isolated) 연산처럼 실행됩니다.
  • EXEC 명령이 트랜잭션 안의 모든 명령 실행을 시작해요. 그래서 클라이언트가 EXEC을 부르기 전에 트랜잭션 중에 서버와의 연결을 잃으면 아무 연산도 수행되지 않습니다. 반대로 EXEC이 호출되면 모든 연산이 수행되고요. AOF(append-only file)를 사용할 때 Redis는 트랜잭션을 디스크에 쓸 때 write(2) syscall 한 번만 쓰도록 보장합니다. 다만 Redis 서버가 크래시하거나 시스템 관리자가 하드하게 프로세스를 죽인 경우엔 연산 일부만 기록될 수 있어요. Redis는 재시작 시 이 상태를 감지하고 에러와 함께 종료합니다. redis-check-aof 도구로 AOF 파일을 고칠 수 있는데, 부분 트랜잭션을 제거해서 서버가 다시 시작할 수 있게 해 줘요.

버전 2.2부터 Redis는 위 두 가지 보장에 더해 낙관적 잠금(optimistic locking) 형태의 추가 보장을 제공합니다. 이건 check-and-set(CAS) 연산과 아주 비슷한 방식이에요. 자세한 내용은 이 페이지 뒷부분에서 다룰게요.

사용법 (Usage)

Redis 트랜잭션은 MULTI 명령으로 시작합니다. 이 명령은 항상 OK로 응답해요. 이 시점부터 사용자는 여러 명령을 내보낼 수 있는데, Redis는 이 명령들을 실행하지 않고 큐(queue)에 쌓아 둡니다. 모든 명령은 EXEC이 호출되는 순간 실행돼요.

DISCARD를 호출하면 트랜잭션 큐를 비우고 트랜잭션에서 빠져나옵니다.

다음 예시는 foobar 키를 원자적으로 증가시키는 코드예요.

> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1

위 세션에서 보듯이 EXEC은 응답 배열을 돌려줘요. 배열의 각 요소는 트랜잭션 안의 각 명령이 명령을 내보낸 순서 그대로 반환한 응답입니다.

Redis 연결이 MULTI 요청 문맥 안에 있을 때 모든 명령은 QUEUED라는 문자열로 응답합니다(Redis 프로토콜 관점에서는 Status Reply로 전송돼요). 큐에 쌓인 명령은 단순히 EXEC이 호출될 때 실행되도록 예약된 상태예요.

트랜잭션 안에서의 에러 (Errors inside a transaction)

트랜잭션 중에는 두 종류의 명령 에러를 만날 수 있어요.

  • EXEC이 호출되기 에 큐잉이 실패할 수 있습니다. 예를 들어 명령이 문법적으로 틀렸다든지(인자 개수가 틀리거나, 명령 이름이 틀리거나…), 혹은 maxmemory 지시문으로 메모리 제한을 설정한 서버에서 메모리 부족 같은 심각한 조건이 발생한 경우예요.
  • EXEC이 호출된 에 명령이 실패할 수 있습니다. 예를 들어 잘못된 값의 키에 연산을 수행한 경우죠(문자열 값에 리스트 연산을 호출하는 것처럼요).

Redis 2.6.5부터 서버는 명령이 쌓이는 동안 에러를 감지해요. 그러면 트랜잭션 실행을 거부하고 EXEC 중에 에러를 돌려주며 트랜잭션을 버립니다.

Redis 2.6.5 미만 참고: Redis 2.6.5 이전에는 클라이언트가 큐잉된 명령의 반환값을 확인해서 EXEC 이전에 발생한 에러를 직접 감지해야 했어요. 명령이 QUEUED로 응답하면 제대로 큐잉된 것이고, 그렇지 않으면 Redis가 에러를 돌려줍니다. 명령 큐잉 중 에러가 있으면 대부분의 클라이언트는 트랜잭션을 중단하고 버립니다. 그렇지 않고 클라이언트가 트랜잭션을 계속 진행하기로 했다면, EXEC은 이전 에러와 무관하게 성공적으로 큐잉된 모든 명령을 실행했어요.

EXEC 이후에 발생한 에러는 특별히 처리되지 않습니다. 트랜잭션 중 어떤 명령이 실패해도 다른 모든 명령은 계속 실행돼요.

이건 프로토콜 수준에서 더 명확하게 드러납니다. 다음 예시에서는 문법이 맞는데도 실행할 때 하나의 명령이 실패하는 상황이에요.

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
MULTI
+OK
SET a abc
+QUEUED
LPOP a
+QUEUED
EXEC
*2
+OK
-WRONGTYPE Operation against a key holding the wrong kind of value

여기 EXEC은 두 요소로 된 bulk string 응답을 돌려줬는데, 하나는 OK 코드이고 다른 하나는 에러 응답이에요. 에러를 사용자에게 어떻게 알려줄지는 클라이언트 라이브러리가 알아서 적절한 방법을 찾아야 합니다.

중요한 점은 명령 하나가 실패해도 큐에 있는 다른 모든 명령은 처리된다는 거예요. Redis는 명령 처리를 멈추지 않습니다.

이번엔 telnet으로 와이어 프로토콜을 쓰는 다른 예시를 볼게요. 여기서는 문법 에러가 ASAP로 보고됩니다.

MULTI
+OK
INCR a b c
-ERR wrong number of arguments for 'incr' command

이번에는 문법 에러 때문에 잘못된 INCR 명령이 아예 큐에 쌓이지 않았어요.

롤백은요? (What about rollbacks?)

Redis는 트랜잭션의 롤백을 지원하지 않습니다. 롤백을 지원하면 Redis의 단순함과 성능에 상당한 영향을 주기 때문이에요.

명령 큐 버리기 (Discarding the command queue)

DISCARD는 트랜잭션을 중단할 때 사용할 수 있어요. 이 경우 어떤 명령도 실행되지 않고 연결 상태는 정상으로 복원됩니다.

> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"

낙관적 잠금, check-and-set 사용하기 (Optimistic locking using check-and-set)

WATCH는 Redis 트랜잭션에 check-and-set(CAS) 동작을 제공하는 데 쓰입니다.

WATCH된 키는 변경을 감지하기 위해 모니터링돼요. EXEC 명령 전에 감시 중인 키가 하나라도 수정되면 트랜잭션 전체가 중단되고, EXEC은 실패를 알리기 위해 Null 응답을 돌려줍니다.

예를 들어 키 값을 1만큼 원자적으로 증가시켜야 한다고 해 볼게요(Redis에 INCR이 없다고 가정해요).

첫 시도는 이렇게 할 수 있어요.

val = GET mykey
val = val + 1
SET mykey $val

이건 특정 시점에 클라이언트 하나만 연산을 수행할 때만 믿을 수 있게 동작해요. 여러 클라이언트가 거의 동시에 키를 증가시키려 하면 경쟁 조건(race condition)이 생깁니다. 예를 들어 클라이언트 A와 B가 옛 값인 10을 읽었다고 해 볼게요. 두 클라이언트 모두 값을 11로 증가시키고 마지막에 SET하게 됩니다. 그래서 최종 값은 12가 아니라 11이 되는 거예요.

WATCH 덕분에 이 문제를 아주 잘 모델링할 수 있어요.

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

위 코드를 쓰면 경쟁 조건이 생겨서 WATCH 호출과 EXEC 호출 사이에 다른 클라이언트가 val 결과를 수정했을 때 트랜잭션이 실패합니다.

이번엔 경쟁이 또 생기지 않길 바라면서 연산을 반복하기만 하면 돼요. 이런 형태의 잠금을 낙관적 잠금(optimistic locking) 이라고 불러요. 많은 사용 사례에서 여러 클라이언트는 서로 다른 키에 접근하므로 충돌이 생길 가능성이 낮아요 — 그래서 보통은 연산을 반복할 필요가 없습니다.

버전 8.4부터 Redis는 문자열 키를 위한 원자적 compare-and-set과 compare-and-delete 명령을 새로 제공해요. 클라이언트는 IFEQ/IFNE/IFDEQ/IFDNE 옵션을 붙인 단일 SET 명령으로, 값을 가져온 뒤 값이 바뀌지 않은 경우에만 문자열 키를 원자적으로 갱신할 수 있습니다. 이게 훨씬 단순하고 빨라요. 마찬가지로 클라이언트는 compare-and-delete를 위한 단일 DELEX 명령으로, 값을 가져온 뒤 값이 바뀌지 않은 경우에만 문자열 키를 원자적으로 삭제할 수 있어요.

WATCH 자세히 보기 (WATCH explained)

그럼 WATCH가 실제로 무슨 역할을 하는 걸까요? WATCH는 EXEC조건부로 만드는 명령이에요. 감시 중인 키 중 어느 것도 수정되지 않았을 때만 트랜잭션을 수행하라고 Redis에 요청하는 겁니다. 여기엔 클라이언트가 쓴 변경(예: write 명령)과 Redis 자신이 한 변경(예: 만료나 축출)이 모두 포함돼요. 키가 WATCH된 시점과 EXEC이 받아들여진 시점 사이에 수정되면 트랜잭션 전체가 대신 중단됩니다.

참고: Redis 6.0.9 이전 버전에서는 만료된 키가 트랜잭션을 중단시키지 않았어요. 자세한 내용은 관련 PR에서 확인할 수 있습니다.

트랜잭션 안의 명령은 EXEC이 보내질 때까지 큐에만 쌓여 있으므로 WATCH 조건을 트리거하지 않아요.

WATCH는 여러 번 호출할 수 있습니다. 모든 WATCH 호출은 호출 시점부터 EXEC이 호출되는 순간까지의 변경을 감시하는 효과를 가져요. 단일 WATCH 호출에 키를 여러 개 보낼 수도 있습니다.

EXEC이 호출되면 트랜잭션이 중단됐는지 여부와 관계없이 모든 키가 UNWATCH됩니다. 클라이언트 연결이 닫힐 때도 모든 것이 UNWATCH됩니다.

인자 없이 UNWATCH 명령을 쓰는 것도 가능해요. 이 경우 감시 중인 키 전체를 비웁니다. 낙관적으로 몇 개 키를 잠갔는데, 그 키들의 현재 내용을 읽고 나서 트랜잭션을 진행하고 싶지 않게 된 경우에 유용하죠. 그럴 때 UNWATCH를 호출하면 연결을 새 트랜잭션에 자유롭게 쓸 수 있습니다.

WATCH로 ZPOP 구현하기 (Using WATCH to implement ZPOP)

WATCH가 Redis가 지원하지 않는 새 원자 연산을 만드는 데 어떻게 쓰이는지 보여주는 좋은 예시는 ZPOP를 구현하는 거예요(ZPOPMIN, ZPOPMAX와 그 블로킹 변형은 5.0 버전에서야 추가되었습니다). ZPOP는 정렬 집합에서 점수가 가장 낮은 요소를 원자적으로 꺼내는 명령이에요. 가장 단순한 구현은 이렇습니다.

WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC

EXEC이 실패하면(즉 Null 응답을 돌려주면) 연산을 반복하기만 하면 돼요.

Redis 스크립팅과 트랜잭션 (Redis scripting and transactions)

Redis에서 트랜잭션 같은 연산을 다룰 때 함께 고려할 만한 것이 바로 Redis 스크립트인데, 스크립트는 트랜잭션적이에요. Redis 트랜잭션으로 할 수 있는 일은 무엇이든 스크립트로도 할 수 있고, 보통 스크립트가 더 단순하고 빠릅니다.