이번 시간에는 Redis에 JSON String으로 넣은 값과 Hash로 넣은 값을 같은 연산으로 바꿔 보면서, 자료구조를 무엇으로 고를지와 잘못 골랐을 때 생기는 문제를 Docker의 Redis와 Python으로 직접 재현하며 배워가는 시간입니다.
예를 들어서 우리가 장바구니 수량 하나를 바꾸려고 47KB짜리 JSON을 읽어 와 고친 뒤 다시 저장하는 코드를 쓰고 있다고 해볼게요. 두 요청이 동시에 들어오면 한쪽이 바꾼 값이 사라지기도 해요.
Redis는 String 말고도 Hash, List, Set, Sorted Set, Stream 같은 자료구조를 제공하고, 자료구조마다 빠르게 처리하는 연산이 달라요.
그래서 자료구조를 고를 때는 이 키로 어떤 연산을 가장 자주 할지를 먼저 정해야 해요.
실습 준비
이 글의 명령은 모두 실습 코드의 redis-data-structures/ 디렉터리에서 실행합니다.
compose.yaml: Redis 8.10 컨테이너string_vs_hash.py: 큰 프로필의 필드 하나를 JSON String과 Hash로 1000번 바꿔 시간과 주고받은 바이트를 비교하는 스크립트race.py: 스레드 두 개가 같은 JSON의 다른 필드를 500번씩 늘려 덮어쓰기를 보는 스크립트
Docker로 Redis 8.10을 띄울게요. compose.yaml은 이렇습니다.
services:
redis:
image: redis:8.10.2
ports:
- "6391:6379"
compose 프로젝트 이름을 rds로 정하고, 컨테이너 안의 redis-cli를 부르는 셸 함수 rc를 만들어 놓을게요.
-T로 실행하면 redis-cli는 터미널이 아니라고 판단해 (integer) 같은 표시를 빼고 출력해요. 그래서 --no-raw로 대화형과 같은 형식을 유지합니다.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=rds
docker compose up -d
rc() { docker compose exec -T redis redis-cli --no-raw "$@"; }
Python 예제는 redis 패키지(redis-py)로 같은 네트워크에서 실행해요. 아래 py 함수가 Python 컨테이너를 띄워 스크립트를 실행합니다.
py() { docker run --rm --network rds_default -v "$PWD":/app -w /app python:3.12-slim sh -c "pip install -q redis 2> /dev/null; python $1" 2>&1 | grep -v -E "notice|WARNING"; }
실습 1: 큰 JSON의 필드 하나를 바꾸면
String은 키 하나에 값 하나를 저장해요. Hash는 필드마다 따로 읽고 쓸 수 있고, 필드 하나를 다루는 HGET, HSET, HINCRBY는 필드당 O(1)입니다.
필드가 1000개 넘는 사용자 프로필을 JSON String과 Hash로 각각 저장하고, cart_count 하나를 1000번 바꿔 볼게요.
- String:
GET으로 읽어 애플리케이션에서 고친 뒤SET으로 다시 씁니다. - Hash:
HINCRBY를 씁니다.
Redis가 주고받은 바이트 수는 CLIENT INFO의 tot-net-in, tot-net-out으로 세요. string_vs_hash.py가 두 방식을 차례로 실행하고, 방식마다 걸린 시간과 1회당 Redis가 받은 양·보낸 양을 출력합니다.
py string_vs_hash.py
JSON 길이: 47034 bytes
String GET+SET: 1000회 0.59초, 1회당 Redis가 받은 양 47109 bytes, 보낸 양 47051 bytes
Hash HINCRBY: 1000회 0.09초, 1회당 Redis가 받은 양 60 bytes, 보낸 양 6 bytes
출력을 보면 String 방식은 숫자 하나를 바꾸려고 한 번에 약 47KB를 받고 47KB를 다시 보냈어요. Hash는 명령 60바이트를 받고 결과 6바이트를 돌려줬습니다. 시간도 String 방식이 6배 넘게 걸렸어요.
세션처럼 매번 통째로 읽고 통째로 다시 쓰는 값이라면 String으로 충분해요. 필드 하나를 자주 바꾸는 값이라면 Hash가 맞습니다.
실습 2: 두 요청이 같은 JSON을 고치면
문제가 하나 더 있어요. GET과 SET 사이는 원자적이지 않다는 점이에요.
그래서 두 요청이 같은 JSON을 동시에 고치면 나중에 쓴 쪽이 먼저 쓴 쪽의 변경을 덮어씁니다. 서로 다른 필드를 고쳐도 마찬가지예요.
스레드 두 개가 각각 cart_count와 view_count를 500번씩 늘려 볼게요. race.py는 String과 Hash에서 같은 일을 하고 마지막 두 필드 값을 출력해요.
py race.py
String: {"cart_count": 221, "view_count": 296}
Hash: {'cart_count': '500', 'view_count': '500'}
String은 둘 다 500이어야 하는데 221과 296만 남았어요. 왜 그럴까요? 한 스레드가 읽은 뒤 다시 쓰기 전에, 다른 스레드가 쓴 값이 덮였기 때문이에요.
Hash는 각 필드를 Redis 안에서 바로 바꾸니까 둘 다 500입니다. String을 꼭 써야 한다면 WATCH나 Lua 스크립트로 읽기와 쓰기를 묶어 주셔야 해요.
실습 3: List 큐와 Stream, 워커가 죽으면
List를 작업 큐로 쓰면 생산자는 RPUSH로 넣고 워커는 LPOP으로 꺼내요. 워커가 작업을 꺼낸 직후, 처리하기 전에 죽었다고 해볼게요.
rc RPUSH jobs job:1 job:2 job:3
rc LPOP jobs
rc LRANGE jobs 0 -1
(integer) 3
"job:1"
1) "job:2"
2) "job:3"
job:1은 LPOP과 함께 List에서 지워졌고, 이를 가져간 워커는 죽었어요. Redis 어디에도 job:1이 남아 있지 않으니 다시 처리할 방법이 없습니다.
Stream은 뒤에 이어 붙이기만 하는 이벤트 로그예요. Stream의 컨슈머 그룹(여러 워커가 한 Stream을 나눠 읽는 묶음)으로 읽은 메시지는 처리 완료를 알리는 XACK를 받을 때까지 PEL(Pending Entries List)에 남습니다.
같은 상황을 Stream으로 재현해 볼게요.
rc XADD jobs:stream '*' job 1
rc XADD jobs:stream '*' job 2
rc XADD jobs:stream '*' job 3
rc XGROUP CREATE jobs:stream workers 0
rc XREADGROUP GROUP workers w1 COUNT 1 STREAMS jobs:stream '>'
"1790824826938-0"
"1790824830288-0"
"1790824833659-0"
OK
1) 1) "jobs:stream"
2) 1) 1) "1790824826938-0"
2) 1) "job"
2) "1"
워커 w1이 첫 메시지를 읽고 죽었어요. 5초 넘게 기다린 다음, 다른 워커 w2가 5초 넘게 처리되지 않은 메시지를 XAUTOCLAIM으로 가져와 볼게요.
rc XAUTOCLAIM jobs:stream workers w2 5000 0-0 COUNT 10
rc XPENDING jobs:stream workers - + 10
1) "0-0"
2) 1) 1) "1790824826938-0"
2) 1) "job"
2) "1"
3) (empty array)
1) 1) "1790824826938-0"
2) "w2"
3) (integer) 3351
4) (integer) 2
XAUTOCLAIM의 결과는 세 가지예요. 다음에 이어서 찾을 커서(0-0이면 끝까지 봤다는 뜻), 가져온 메시지, 그사이 Stream에서 삭제돼 PEL에서 정리된 ID 목록입니다.
XPENDING의 상세 형식은 메시지 ID, 가져간 컨슈머, 마지막으로 전달된 뒤 지난 시간, 전달 횟수입니다. 이제 이 메시지를 가진 컨슈머는 w2이고 전달 횟수는 2가 됐어요.
Stream은 메시지를 잃지 않게 해 줄 뿐, 정확히 한 번 처리를 보장하지 않아요. 같은 메시지를 다시 받아도 결과가 한 번만 남도록 처리 쪽을 멱등하게 만들어 주셔야 합니다.
List와 Stream은 다음 기준으로 고르시면 돼요.
- List: 작업을 넘기기만 하면 되고, 가끔 잃어도 괜찮거나 다시 만들 수 있는 작업이에요.
- Stream: 처리 확인, 재처리, 여러 컨슈머 그룹이 필요하거나 처리한 이력을 일정 기간 남겨야 하는 작업이에요.
정리
범위: Redis 8.10.2(redis:8.10.2), Python redis 패키지, 노트북 Docker. 시간은 방식 사이의 비교로만 읽어 주세요.
- 필드가 1000개 넘는 프로필의
cart_count를 1000번 바꾸자 JSON String은 한 번에 약 47KB를 받고 다시 보내며 Hash보다 6배 넘게 걸렸고, Hash의HINCRBY는 60바이트를 받고 6바이트를 돌려줬어요. 큰 객체의 필드 하나를 자주 바꾸면 JSON String 대신 Hash를 쓰세요. - 두 스레드가 같은 JSON의 다른 필드를 500번씩 늘리자 String은 221과 296만 남았고 Hash는 둘 다 500이었습니다. String으로 읽고 고쳐 써야 한다면
WATCH나 Lua 스크립트로 읽기와 쓰기를 묶으세요. LPOP으로 꺼낸 워커가 죽자job:1은 Redis 어디에도 남지 않았고, Stream 컨슈머 그룹에서는 PEL에 남은 메시지를 다른 워커가XAUTOCLAIM으로 가져가 전달 횟수가 2가 됐습니다. 작업을 잃으면 안 된다면 Stream 컨슈머 그룹을 쓰고 처리를 멱등하게 만드세요.