# Redis

Redis 자료구조, 전부 String으로 넣어도 괜찮을까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Redis에 값을 넣을 때 자료구조는 무엇을 기준으로 골라야 할까?
답
그 키로 가장 자주 할 연산으로 고르며, 필드가 1000개 넘는 프로필의 필드 하나를 1000번 바꾸자 JSON String은 한 번에 약 47KB를 주고받아 Hash보다 6배 넘게 걸렸다.
조건
Redis 8.10.2(redis:8.10.2), Python redis 패키지, 노트북 Docker, 시간은 방식 사이의 비교로만 읽는다

환경

Redis
8.10.2(redis:8.10.2 이미지), compose 프로젝트 rds
클라이언트
컨테이너 안의 redis-cli, Python redis 패키지(redis-py)
실행
노트북의 Docker, Python 스크립트는 Redis와 같은 네트워크의 컨테이너에서 실행
시간
방식 사이의 비교로만 읽음
목차
  1. 실습 준비
  2. 실습 1: 큰 JSON의 필드 하나를 바꾸면
  3. 실습 2: 두 요청이 같은 JSON을 고치면
  4. 실습 3: List 큐와 Stream, 워커가 죽으면
  5. 정리

이번 시간에는 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배 넘게 걸렸어요.

HashString (JSON)GET약 47KB 응답앱에서 JSON 수정SET약 47KB 전송HINCRBY필드 하나
필드 하나를 바꾸는 데 String은 값 전체가 두 번 오가고, Hash는 그 필드만 바뀝니다.

세션처럼 매번 통째로 읽고 통째로 다시 쓰는 값이라면 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 컨슈머 그룹을 쓰고 처리를 멱등하게 만드세요.
내 링크 만들기