티스토리 뷰
개요
Redis를 사용할 때 성능을 최적화하기 위해 여러 명령어를 한 번에 송신하여 네트워크 RTT(왕복 시간)을 줄일 수 있는 파이프라이닝(Pipelining) 기능이 있다. 만약 RTT시간이 250ms인 경우 서버가 초당 10만개의 요청을 처리할 수 있어도 초당 최대 4개의 요청만 처리할 수 있게 된다. 이런 문제를 파이프라이닝을 사용해 배치처리로 해결할 수 있다.


[파이프라이닝 사용하지 않을 때]
Client: GET key1
SERVER: 100
Client: GET key2
SERVER: 200
Client: GET key3
SERVER: 500
[파이프라이닝 사용할 때]
Client: (파이프라인으로 전송)
GET key1
GET key2
GET key3
SERVER: (응답)
100
200
500
"레디스는 싱글 스레드로 동작하니까, 명령어를 묶어서 보내면 이게 트랜잭션처럼 동작하지 않을까?"라고 오해하기 쉬우나 파이프라이닝은 원자성을 보장하지는 않는다. 즉, 클라이언트 A가 파이프라인 요청을 보내면 단순히 배치성 처리만 수행할 뿐, 클라이언트 B의 요청이 끼어들 수 있다는 것이다.

이는 레디스가 I/O작업을 멀티플렉싱 기반으로 처리하기 때문에 발생한다. 다음과 같이 클라이언트 A가 파이프라인 요청을 보낸 도중, 클라이언트 B의 요청이 어떻게 처리되는지 예시를 들어보자.
1. 클라이언트 A가 여러 명령어를 파이프라이닝으로 전송. (대량의 파이프라인으로 1000개의 요청)
2. 서버의 I/O 멀티 플렉서가 요청 감지하여 A의 요청을 이벤트 루프로 전달
3. 그 사이에 클라이언트 B도 요청을 보냄, I/O 멀티 플렉서에서 요청 감지하여 이벤트 루프로 전달
4. 이벤트 루프는 두 클라이언트의 소켓 모두에서 읽기 이벤트를 감지
5. 이벤트 루프는 클라이언트 A의 소켓버퍼에 도착한 만큼의 명령어를 읽어 테스크 큐에 적재한다.(200개라고 가정)
6. 클라이언트 A의 남은 명령어 800개는 네트워크를 통해 전송중이거나, 아직 도착하지 않은 상태
7. 이벤트 루프는 다음으로 클라이언트 B에 소켓 버퍼를 확인해 명령어를 읽어 테스크 큐에 적재한다.
8. 다시 돌아와 클라이언트 A의 소켓 버퍼를 읽어 테스트 큐에 적재하게 된다.
레디스는 분명히 싱글 스레드로 명령을 처리하는 것은 맞으나, 읽기 타이밍이 교차되면 어려 클라이언트의 요청이 섞일 수 있다.
그렇다면 레디스에서 원자성을 보장하기 위해서는 어떠한 방법을 사용해야할까?
레디스가 원자성을 보장하는 방법
Redis Transaction
레디스 트랜잭션은 다수의 레디스 명령어를 한번에 처리할 수 있도록 MULTI/EXEC 명령어를 제공한다. MULTI/EXEC 명령어 실행 중에는 다른 클라이언트의 간섭 없이 원자적으로 처리할 수 있다.
MULTI
SET foo 10
INCR foo
INCR foo
GET foo
EXEC
MULTI명령어 뒤에 명령어들을 Queue에 넣어처리하고 EXEC명령어를 만나면 큐에 담긴 명령어를 한번에 실행하고 트랜잭션을 끝낸다.
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET foo 10
QUEUED
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> GET foo
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 11
3) (integer) 12
4) "12"
레디스 트랜잭션 MULTI/EXEC사이에 다른 클라이언트가 보낸 요청이 개입할 수는 없지만, RDBMS의 트랜잭션에서 기대하는 all-or-noting특성과는 차이가 있다.
다음 상황처럼 트랜잭션이 실패하는 상황을 보자.
- EXEC명령어 실행 전, 명령어를 큐에 적재 할 때 오류 발생한 경우: 명령어의 문법오류나 Out of Memory
- EXEC명령어 실행 이후 오류 발생한 경우: 잘못된 값을 가진 키를 조작하려고할 때
1번의 경우 큐에 적재할 때 오류가 발생하면 EXEC 명령어 실행 시 트랜잭션 실행을 거부하고 자동으로 파기된다. 아래 예시를 보면 "대충가짜커맨드 foo"처럼 문법오류로 작성되면 EXEC 실행 시점에 오류를 반환하며 해당 트랜잭션을 실행하지 않는 것을 알 수 있다.
MULTI
SET foo 10
INCR foo
GET foo
SET foo "bar"
대충가짜커맨드 foo
INCR foo
GET foo
EXEC
[실행 결과]
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET foo 10
QUEUED
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> SET foo "bar"
QUEUED
127.0.0.1:6379(TX)> 대충가짜커맨드 foo
(error) ERR unknown command '대충가짜커맨드', with args beginning with: 'foo'
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> GET foo
QUEUED
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
2번의 경우 잘못된 값을 가진 키를 조작하려고할 때 상황을 예시를 확인해보자. 키에 문자열이 설정된 상황에서 INCR 명령어를 실행하면, INCR 명령어는 키에 연결된 값은 10진수 정수로 해석한다. 트랜잭션 중 큐에 들어가 있는 명령어가 실행되지만 INCR명령어를 만나는 순간 정수로 해석할 수 없는 문자열("Bar")을 만나기 때문에 해당 실행결과는 오류가 표시된다.
그러나 문제점은 오류로 표시가 되지만, 나머지 작업들은 계속 처리된 채로 유지한다. 이처럼 롤백 기능을 지원하지 않는 점이 RDBMS의 트랜잭션에서 기대하는 all-or-noting특성과는 차이가 있는 것이다.
MULTI
SET foo 10
INCR foo
INCR foo
GET foo
SET foo "bar"
INCR foo
INCR foo
GET foo
EXEC
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET foo 10
QUEUED
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> GET foo
QUEUED
127.0.0.1:6379(TX)> SET foo "bar"
QUEUED
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> INCR foo
QUEUED
127.0.0.1:6379(TX)> GET foo
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 11
3) (integer) 12
4) "12"
5) OK
6) (error) ERR value is not an integer or out of range
7) (error) ERR value is not an integer or out of range
8) "bar"
따라서 Redis Transaction은 RDBMS기준에서 "제한적인 원자성(롤백이 없는)"을 제공한다고 보는 것이 정확할 것이다.
Lua
레디스는 내장 스크립트 언어로 Lua를 채택하고 있다. Lua를 사용하면 모든 작업을 원자적으로 처리할 수 있다. 또한 다음과 같은 특징이 있다.
- 네트워크 왕복시간(RTT)를 줄이면서 원자적으로 처리 가능
- 조건 분기와 같은 복잡한 로직을 기술 가능
동작원리는 생각보다 간단한데 처리할 명령어들을 스크립트로 작성하여 서버에 보내거나, 이미 redis 서버에 보관하고 있는 스크립트를 실행하게 되면 그 동안 redis서버는 블록킹되기 때문이다.
EVAL 명령어 또는 EVALSHA 명령어로 실행한다.
[EVAL 명령어 구조]
EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3]}" 2 key1 key2 value1 value2 value3
EVAL명령어 구조가 의미하는 바는 다음과 같다.
스크립트: "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3]}"
2: 스크립트에서 사용할 키의 개수(즉, key1, key2)
key1 key2: lua 스크립트 내에서 KEYS[1], KEYS[2]로 참조되어 값이 들어감
value1 value2 value3: ARGV[1], ARGV[2], ARGV[3]으로 참조되어 값이 들어감
[실행결과]
127.0.0.1:6379> EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3]}" 2 key1 key2 value1 value2 value3
1) "key1"
2) "key2"
3) "value1"
4) "value2"
5) "value3"
EVALSHA명령어와 EVAL 명령어의 차이점은 스크립트 자체를 보내지 않고 스크립트 해시값(SHA1)을 지정해보낸다.
[EVALSHA 명령어 구조]
EVALSHA "c0d2d6f81be75d67523d7c8ac69a932fbe1aa4e2" 2 key1 key2 value1 value2 value3
해시값은 `SCRIPT LOAD` 명령어로 스크립트를 레디스 서버에 저장하고 SHA1 해시값을 반환하는 명령어로 알 수 있다.
127.0.0.1:6379> SCRIPT LOAD "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3]}"
"0e8e5b92bfe818cf5eb29a03465e71bd8ef3e95a"
EVALSHA로 해시값을 지정해 보낼 경우 장점은, 매번 루아 스크립트를 서버에 전송할 필요 없이 이미 서버에 캐시된 스크립트를 실행할 수 있어 네트워크 대역폭을 절약시킬 수 있다는 점에 있다.
참고 자료
- 레디스 공식 문서(Redis pipelining), https://redis.io/docs/latest/develop/use/pipelining/
- 싱글 쓰레드인 Redis가 빠른 이유: In-Memory, I/O Multiplexing, https://loosie.tistory.com/872
- Why is Redis so Fast?, https://x.com/alexxubyte/status/1498703822528544770/photo/1
- 하야시 쇼고, 실전 레디스, 2024.05.17 한빛 미디어, 203p-241p
- 레디스 공식 문서(Scripting with Lua)
'Database' 카테고리의 다른 글
| 예제로 알아보는 MySQL 트랜잭션 격리 수준과 부정합 문제들 (0) | 2025.03.14 |
|---|---|
| InnoDB 스토리지 엔진 락에 대해(레코드 락, 갭 락, 넥스트 키 락, 자동증가 락) (0) | 2025.02.27 |