Redis 데이터 구조 7종 — Sorted Set·Hash 한 번에

2026-05-02AWS SAA-C03 스터디

Redis 핵심 정리 시리즈 2편. String·Hash·List·Set·Sorted Set·HyperLogLog·Stream — 7가지 데이터 구조를 책상 위 봉투·폴더·대기열·순위 명단 비유로 풀어가며 각자의 자리·실전 패턴·시간 복잡도까지 처음 보는 사람도 따라올 수 있게 친절하게 정리한 글.

📚 Redis 핵심 정리 · 2편 / 14편 — Sorted Set·Hash 한 번에

이 글은 Redis 핵심 정리 시리즈의 두 번째 편입니다. 1편에서 Redis의 정체와 빠른 이유를 잡아 두었으니, 이번에는 그 위에 얹는 데이터 구조 7종을 하나씩 풀어 갈 차례예요. String·Hash·List·Set·Sorted Set·HyperLogLog·Stream — 이 일곱 가지가 Redis로 풀 수 있는 거의 모든 문제의 답입니다.

처음에는 7종이라는 숫자에 압도될 수 있는데, 각 자료 구조마다 자기 자리가 명확해서 한 번 매핑이 잡히면 의외로 외우기 쉬워요. 리더보드는 Sorted Set, 객체는 Hash, 큐는 List 같은 식으로요. 이번 편의 목표는 그 매핑을 머리에 단단히 박는 겁니다.

왜 데이터 구조 단원이 처음엔 어렵게 느껴질까요

이유는 세 가지예요.

첫째, 이름이 대부분 익숙하면서 살짝 다릅니다. Hash·Set·List는 다른 언어(Python·Java)에서 본 단어인데, Redis에서는 약간 다른 행동을 해요. "내가 아는 Hash랑 같은 건가?"부터 헷갈립니다.

둘째, Sorted Set이라는 새 친구가 처음 보입니다. 일반 Set에 점수를 붙여 자동 정렬하는 자료 구조인데, 다른 언어에서 직접 비슷한 게 없어서 처음 보면 "이게 왜 따로 있어야 하지?" 의문이 들죠.

셋째, HyperLogLog와 Stream은 이름부터 무서워요. 이름만 봐서는 뭘 하는지 전혀 안 보입니다.

해결법은 한 가지예요. 각 자료 구조를 사물함 안의 봉투·폴더·대기열·순위 명단 같은 일상 비유로 한 번씩 잡고 가는 겁니다. String은 일반 봉투, Hash는 칸 나눠진 폴더, List는 양면 출입 대기열, Set은 중복 안 되는 명단, Sorted Set은 순위가 매겨진 명단 — 이 비유가 잡히면 실전 매핑은 자연스럽게 따라옵니다.

데이터 구조 7종 한눈에 — 큰 그림 먼저

본문에 들어가기 전 7종의 자리를 한 줄씩 먼저 짚을게요. 이 표가 시리즈 전체의 지도예요.

데이터 구조비유주요 사용처
String일반 봉투 (텍스트·숫자 한 덩어리)캐싱·카운터·세션
Hash칸 나눠진 폴더 (필드-값 쌍)사용자·아이템 같은 객체
List양면 출입 대기열 (이중 연결 리스트)시계열·작업 큐·최신 피드
Set중복 없는 명단태그·고유 사용자명·블랙리스트
Sorted Set점수 붙은 순위 명단리더보드·시간순 정렬·랭킹
HyperLogLog대략 인원 카운터 (12KB로 수백만)UV(고유 방문자) 추정
Stream시간순 이벤트 일지메시지 큐·이벤트 소싱

여기서 시험 단골 — 리더보드 = Sorted Set, 고유 방문자 수 = HyperLogLog, 객체 한 덩어리 = Hash. 이 세 매핑은 시나리오 문제 절반의 답이에요.

각 구조의 자세한 사용법은 Redis 공식 데이터 타입 문서에서도 확인할 수 있어요. 이번 글은 그걸 한국어 비유 톤으로 풀어 가는 학습 노트입니다.

String — 가장 기본적인 한 봉투

Redis의 가장 기본 데이터 타입이 String이에요. 텍스트·정수·부동소수점·바이너리(이미지까지) 모두 한 봉투에 들어갑니다. 봉투 하나의 최대 크기는 512MB예요.

회사 비유로 — 사물함 안에 든 일반 서류 봉투입니다. 안에 무슨 서류가 들었든(글자·숫자·사진) 봉투 자체는 한 종류예요. 라벨(키)만 다르게 붙입니다.

내부 최적화 한 가지가 재밌는데 — Redis는 봉투 안 내용물이 정수면 자동으로 정수로 인코딩해요. 그래서 INCR·DECR 같은 증감 명령이 매우 빠르게 동작합니다.

기본 사용법

# 기본 저장/조회
SET car Toyota
GET car
# "Toyota"

# 공백이 포함된 문자열은 따옴표로 감싸기
SET news "Today's Headlines"

# 만료 시간과 함께 저장 (EX = 초, PX = 밀리초)
SET cache:data "value" EX 3600

# 조건부 저장
SET username "alice" NX     # 키가 없을 때만 (분산 락 패턴)
SET username "bob" XX       # 키가 있을 때만 (업데이트 보장)

NX 옵션은 분산 락 같은 동시성 패턴에서 자주 보입니다. "여러 인스턴스 중 한 명만 작업한다"를 보장할 때 유용해요.

숫자 카운터로 쓰기 — 원자성 보장

SET counter 10
INCR counter          # 11
DECR counter          # 10
INCRBY counter 5      # 15
DECRBY counter 3      # 12
INCRBYFLOAT price 1.5 # 부동소수점도 OK

여기서 시험 함정이 하나 있어요. INCR은 원자적(atomic)으로 동작합니다. 동시에 1000개 클라이언트가 INCR counter를 던져도 정확히 1000번 증가해요. 이건 Redis의 단일 스레드 모델 덕분에 자연스럽게 보장되는 특성입니다. 페이지 조회수 카운터, 좋아요 카운터, 재고 차감 카운터 — 다 이걸로 해결돼요.

다중 키 한 번에 처리

# 여러 키 한 번에 저장
MSET name Alice age 30 city Seoul

# 여러 키 한 번에 조회
MGET name age city

# 문자열 길이
STRLEN name

# 문자열 덧붙이기
APPEND name " Smith"

MSET·MGET은 네트워크 왕복(Round Trip Time)을 줄여 주는 효자 명령이에요. 단 한 번의 요청으로 여러 키를 처리합니다.

Hash — 칸 나눠진 폴더 한 개

Hash는 단일 키 아래에 여러 필드-값(field-value) 쌍을 묶어 두는 자료 구조예요. JSON 객체와 닮았는데, 다단계 중첩이나 배열은 안 된다는 중요한 차이가 있어요.

회사 비유로 — 사물함 안의 폴더 한 개예요. 폴더에는 여러 칸이 있고, 칸마다 라벨(필드)이 붙어 있고, 그 안에 서류(값)가 들어갑니다. 사용자 한 명, 아이템 한 개 같은 엔티티를 통째로 보관할 때 적격이에요.

users#123: {
  "name": "Alice",
  "email": "alice@example.com",
  "age": "30"
}

Hash 명령어

# 단일 필드 설정
HSET users#123 name Alice
HSET users#123 email alice@example.com

# 여러 필드 한 번에 설정
HSET users#123 name Alice email alice@example.com age 30

# 단일 필드 조회
HGET users#123 name

# 여러 필드 조회
HMGET users#123 name email

# 모든 필드-값 조회
HGETALL users#123

# 필드 존재 확인
HEXISTS users#123 name   # 1(있음) / 0(없음)

# 필드 삭제
HDEL users#123 age

# 필드 목록·값 목록·개수
HKEYS users#123
HVALS users#123
HLEN users#123

# 숫자 필드 증감
HINCRBY users#123 loginCount 1
HINCRBYFLOAT users#123 balance 10.5

특히 HINCRBY는 객체 한 칸의 값을 원자적으로 증가시키는 강력한 명령이에요. "이 사용자 로그인 횟수 +1", "이 아이템 조회수 +1" 같은 패턴에 딱입니다.

TypeScript에서 Hash 사용

실무에서 자주 쓰는 패턴 — serialize·deserialize 함수로 객체와 Hash 사이를 매핑합니다.

const usersKey = (userId: string) => `users#${userId}`;

interface UserAttrs {
    name: string;
    email: string;
    password: string;
}

// 저장: serialize로 필요한 필드만 추출
function serialize(user: UserAttrs) {
    return {
        name: user.name,
        email: user.email,
        password: user.password
        // id는 키에 이미 들어 있으니 중복 저장 X
    };
}

await client.hSet(usersKey(id), serialize(attrs));

// 조회: hGetAll 후 id 포함 객체로 복원
async function getUserById(id: string) {
    const data = await client.hGetAll(usersKey(id));
    
    // 여기서 정말 중요한 시험 함정 —
    // hGetAll은 키가 없으면 null이 아니라 빈 객체 {} 반환
    if (Object.keys(data).length === 0) {
        return null;
    }
    
    return {
        id,
        name: data.name,
        email: data.email,
        password: data.password
    };
}

여기서 정말 중요한 시험 함정 — hGetAll은 키가 없을 때 null이 아니라 빈 객체 {} 를 반환합니다. JavaScript의 truthy 체크(if (data))로는 잡히지 않아요. 반드시 Object.keys(data).length === 0로 체크해야 합니다.

Hash vs String 비교

같은 사용자 정보를 저장한다고 가정할 때 둘의 차이가 분명해요.

구분StringHash
단일 속성 조회GET users:123:nameHGET users#123 name
여러 속성 저장여러 키 필요 (3개 키)단일 키로 관리
메모리 효율비교적 비효율작은 해시는 효율적
원자적 업데이트별도 처리 필요HSET으로 가능
주 사용처캐싱·단순 값엔티티 저장

대부분의 객체 데이터는 Hash가 정답이에요. 정리 한 줄 — 객체 한 덩어리 = Hash, 단순 값 한 개 = String.

List — 양쪽 끝이 빠른 대기열

List는 순서가 보장된 문자열 컬렉션이에요. 내부적으로는 일반 배열이 아니라 이중 연결 리스트(doubly linked list) 로 구현돼 있어요. 이 구조 때문에 성능 특성이 명확합니다.

연산시간 복잡도
양 끝(머리·꼬리) 추가/제거O(1) — 매우 빠름
중간 요소 접근/수정O(N) — 느림
전체 순회O(N) — 큰 리스트 주의

회사 비유로 — 양쪽 출입이 가능한 대기열이에요. 앞에서도 사람이 들어오고 빠지고, 뒤에서도 들어오고 빠집니다. 그래서 시계열 데이터, 로그, 최신 항목 피드, 작업 큐에 적합해요.

기본 명령어

# 추가
LPUSH temps 25         # 왼쪽(앞)에 추가
RPUSH temps 27         # 오른쪽(뒤)에 추가
RPUSH temps 28 30 24   # 여러 값 한 번에

# 조회
LLEN temps             # 길이
LINDEX temps 0         # 인덱스로 (0부터 시작)
LINDEX temps -1        # 마지막 요소 (-1)
LRANGE temps 0 -1      # 전체 범위
LRANGE temps 0 2       # 처음 3개

# 제거 및 반환
LPOP temps             # 왼쪽에서 제거 + 반환
RPOP temps             # 오른쪽에서 제거 + 반환
LPOP temps 2           # 왼쪽에서 2개

# 수정
LSET temps 0 35        # 인덱스 0의 값 변경

# 범위 트리밍 (조심!)
LTRIM temps 0 9        # 처음 10개만 유지, 나머지 삭제

여기서 시험 함정이 하나 있어요. LTRIM은 범위 외 데이터를 영구 삭제합니다. 잘못된 범위를 넘기면 데이터가 통째로 날아가요. 운영에서는 반드시 LLEN으로 길이를 먼저 확인하고 사용합니다.

리스트 검색

LPOS temps 25          # 첫 번째 25의 인덱스
LPOS temps 25 RANK 2   # 두 번째로 나타나는 25
LPOS temps 25 COUNT 0  # 모든 25의 인덱스
LPOS temps 25 MAXLEN 100  # 처음 100개에서만 검색

실전 패턴 — 입찰 히스토리

이커머스 마켓플레이스 예시에서 자주 보이는 패턴 — 특정 아이템의 입찰 기록을 시간 순으로 쌓을 때 List가 잘 어울려요.

const itemBidsKey = (itemId: string) => `items:bids#${itemId}`;

interface BidData {
    amount: number;
    bidTime: number;
    userId: string;
}

async function createBid(itemId: string, bid: BidData) {
    await client.rPush(
        itemBidsKey(itemId),
        JSON.stringify(bid)
    );
}

async function getBidHistory(itemId: string) {
    const bids = await client.lRange(itemBidsKey(itemId), 0, -1);
    return bids.map(bid => JSON.parse(bid));
}

RPUSH로 꼬리에 계속 쌓으면 시간 순서가 자연스럽게 유지됩니다. 한 줄 정리 — 순서 + 양 끝 빠른 변경 = List.

Set — 중복 없는 명단

Set은 고유한 문자열의 비순서(unordered) 집합이에요. 중복을 자동으로 제거하고, 요소의 존재 여부를 O(1) 로 확인할 수 있습니다. 게다가 집합 간 교집합·합집합·차집합 연산까지 지원해요.

회사 비유로 — 중복 입장이 안 되는 명단이에요. 같은 사람이 두 번 들어오려고 해도 명단에는 한 번만 등록됩니다. 다음 사용처에 잘 맞아요.

  • 유니크 값 보장 — 사용자명 중복 방지
  • 관계 모델링 — 한 사용자가 좋아요 누른 아이템 목록
  • 교집합 연산 — 두 사용자가 공통으로 좋아하는 아이템
  • 블랙리스트 — IP 차단 목록, 금지 도메인 목록

기본 명령어

# 추가/제거
SADD usernames alice bob charlie  # 추가 (추가된 개수 반환)
SREM usernames bob                # 제거

# 조회
SMEMBERS usernames                # 모든 멤버
SCARD usernames                   # 멤버 수

# 존재 확인 (O(1) — 매우 빠름)
SISMEMBER usernames alice         # 1 (있음)
SISMEMBER usernames dave          # 0 (없음)

# 랜덤 조회/제거
SRANDMEMBER usernames             # 랜덤 1개 (삭제 X)
SRANDMEMBER usernames 3           # 랜덤 3개
SPOP usernames                    # 랜덤 1개 제거 + 반환

# 집합 연산
SINTER set1 set2                  # 교집합
SUNION set1 set2                  # 합집합
SDIFF set1 set2                   # 차집합 (set1 - set2)

# 결과를 새 키에 저장
SINTERSTORE result set1 set2

실전 패턴 — 사용자명 유니크·좋아요 시스템

학습용 도메인 예시에서 자주 등장하는 두 가지 활용을 묶어 봅니다.

const usernamesUniqueKey = () => 'users:usernames';
const userLikesKey = (userId: string) => `users:likes#${userId}`;

// 가입 시 — 사용자명 유니크 체크
async function createUser(attrs: { username: string; password: string }) {
    // 1. 중복 확인 (O(1))
    const exists = await client.sIsMember(usernamesUniqueKey(), attrs.username);
    if (exists) {
        throw new Error('이미 사용 중인 사용자명입니다');
    }
    
    // 2. 사용자 생성
    const id = generateId();
    await client.hSet(usersKey(id), { 
        username: attrs.username, 
        password: attrs.password 
    });
    
    // 3. 사용자명을 Set에 추가 (이후 중복 방지)
    await client.sAdd(usernamesUniqueKey(), attrs.username);
    
    return id;
}

// 좋아요 — Set + Hash 카운터 조합
async function likeItem(userId: string, itemId: string) {
    await client.sAdd(userLikesKey(userId), itemId);
    // 동시에 좋아요 수 미리 계산 (이후 조회 빠르게)
    await client.hIncrBy(itemsKey(itemId), 'likes', 1);
}

async function unlikeItem(userId: string, itemId: string) {
    await client.sRem(userLikesKey(userId), itemId);
    await client.hIncrBy(itemsKey(itemId), 'likes', -1);
}

async function userLikesItem(userId: string, itemId: string) {
    return client.sIsMember(userLikesKey(userId), itemId);
}

// 두 사용자의 공통 관심사 — 교집합 한 줄로 끝
async function commonLikes(userId1: string, userId2: string) {
    return client.sInter([userLikesKey(userId1), userLikesKey(userId2)]);
}

여기서 정말 중요한 시험 함정 — Set의 멤버십 체크는 O(1) 이에요. 사용자명이 1억 개여도 중복 체크는 즉시 끝납니다. 같은 일을 List로 하려고 하면 O(N)이라 데이터 커질수록 느려져요.

한 줄 정리 — 중복 없음 + 존재 확인 빠름 + 집합 연산 = Set.

Sorted Set — 점수가 매겨진 순위 명단

자, 이제 시리즈에서 가장 강력한 자료 구조가 등장합니다. Sorted Set(ZSet) — Set에 스코어(score) 라는 숫자를 추가한 자료 구조예요. 모든 멤버는 고유하고, 스코어를 기준으로 자동 정렬됩니다.

회사 비유로 — 점수가 매겨진 순위 명단이에요. 명단에는 사람 이름이 있고 옆에는 그 사람의 점수가 적혀 있어, 점수 순으로 자동 정렬돼 있습니다. 누가 1등인지, 100~200점 구간은 몇 명인지를 즉시 알 수 있어요.

Sorted Set의 자리는 명확합니다.

  • 리더보드 — 사용자 점수 순위
  • 시계열 데이터 — 타임스탬프를 스코어로
  • 사용자명-ID 매핑 — 사용자명(멤버)에 ID(스코어)
  • 아이템 순위 — 좋아요 수, 조회수 기준

Sorted Set 기본 명령어

# 요소 추가 (스코어, 멤버)
ZADD leaderboard 100 alice
ZADD leaderboard 200 bob 150 charlie  # 여러 개 한 번에

# 추가 옵션 (Redis 6.2+)
ZADD leaderboard NX 100 alice   # 없을 때만 추가
ZADD leaderboard XX 100 alice   # 있을 때만 업데이트
ZADD leaderboard GT 300 alice   # 현재보다 클 때만 (최고점만 갱신)
ZADD leaderboard LT 50 alice    # 현재보다 작을 때만

# 스코어 조회·변경
ZSCORE leaderboard alice        # "100"
ZINCRBY leaderboard 50 alice    # alice 스코어 +50

# 순위 조회 (0부터)
ZRANK leaderboard alice         # 오름차순 순위
ZREVRANK leaderboard bob        # 내림차순 순위 (1등이 0)

# 범위 조회
ZRANGE leaderboard 0 -1                    # 전체 (오름차순)
ZRANGE leaderboard 0 -1 WITHSCORES         # 스코어 포함
ZRANGE leaderboard 0 -1 REV                # 내림차순
ZRANGE leaderboard 100 200 BYSCORE         # 스코어 100~200

# 페이지네이션
ZRANGE leaderboard 100 200 BYSCORE LIMIT 0 5

# 개수
ZCARD leaderboard               # 전체 멤버 수
ZCOUNT leaderboard 100 200      # 스코어 범위 내 개수

Sorted Set의 시간 복잡도

여기서 시험 함정이 하나 있어요. Sorted Set의 핵심 연산은 O(log N) 으로 끝납니다. 일반 Set의 O(1)보다는 살짝 느리지만, 정렬 + 순위까지 자동으로 해 주는 걸 생각하면 매우 빠른 거예요. 1억 개 멤버에서 순위를 조회해도 몇십 번의 비교만으로 끝납니다.

고급 활용 — 사용자명-ID 매핑

흥미로운 패턴 한 가지 — 사용자명 → ID 변환을 ZSet 하나로 해결합니다. 멤버에 사용자명, 스코어에 ID를 박는 방식이에요.

const usernamesKey = () => 'usernames';

// 가입 시: 사용자명을 멤버로, ID를 스코어로
// 주의: ZSet 스코어는 숫자 전용 → 16진수 ID를 10진수로 변환
async function createUser(attrs: { username: string; password: string }) {
    const id = generateId(); // 16진수 문자열 (예: "1a2b3c4d")
    
    await client.zAdd(usernamesKey(), {
        value: attrs.username,
        score: parseInt(id, 16)  // 16진수 → 10진수
    });
}

// 로그인 시: 사용자명으로 ID 조회
async function getUserByUsername(username: string) {
    const decimalId = await client.zScore(usernamesKey(), username);
    
    if (!decimalId) {
        throw new Error('사용자가 존재하지 않습니다');
    }
    
    const id = decimalId.toString(16);  // 10진수 → 16진수 복원
    return getUserById(id);
}

여기서 정말 중요한 시험 함정 — Sorted Set의 스코어는 반드시 숫자(정수 또는 부동소수점) 입니다. 문자열 ID를 그대로 박으면 에러가 나요. 16진수 ID 같은 걸 쓰려면 10진수로 변환했다가 복원하는 흐름이 필요합니다.

리더보드 실전

# 책 좋아요 순위
ZADD books:likes 999 "book:good" 0 "book:bad" 40 "book:ok"

# 상위 3개 (좋아요 많은 순)
ZRANGE books:likes 0 2 REV WITHSCORES

# 특정 책의 순위 (0이 1등)
ZREVRANK books:likes "book:good"

# 아이템 조회수 순위 — 1등 1개만 조회
ZADD items:views 500 "item:1" 200 "item:2" 800 "item:3"
ZRANGE items:views 0 0 REV

한 줄 정리 — 스코어로 자동 정렬 + 순위 즉시 조회 = Sorted Set. 이거 하나로 거의 모든 랭킹 시스템이 풀려요.

HyperLogLog — 12KB로 수백만 명 카운트

이제 이름이 무서운 친구가 나옵니다. HyperLogLog(HLL) — 집합의 고유 원소 수(cardinality)근사적으로 추정하는 특수 자료 구조예요.

핵심은 메모리 효율이에요. 수백만 개의 고유 값을 추적하는 데 최대 12KB 만 사용합니다. 그 대신 정확한 개수가 아닌 추정값을 반환해요. 표준 오차율은 약 0.81% 입니다.

회사 비유로 — 대략 인원 카운터예요. 행사장 입구에 카운터가 있는데, 정확히 몇 명인지는 안 세고 "한 12,000명쯤 들어왔어요" 같이 추정만 해 줘요. 대신 사람을 명단에 다 적어 두지 않아도 되니 종이가 거의 안 들어요.

비교SetHyperLogLog
모든 요소 저장OX (해시값만 추적)
정확한 개수OX (추정값 ≈ 0.81% 오차)
메모리 사용요소 수에 비례 (큼)항상 ≤ 12KB
합집합 가능O (SUNION)O (PFMERGE)
유스 케이스정확한 명단 필요UV·DAU 같은 통계

명령어

# 값 추가
PFADD unique:visitors user1
PFADD unique:visitors user2 user3 user4

# 중복 추가 (이미 있는 값)
PFADD unique:visitors user1   # 내부 상태 변화 없으면 0, 있으면 1

# 고유 개수 조회 (근사값)
PFCOUNT unique:visitors   # 4

# 여러 HLL 합산
PFMERGE combined:visitors daily:visitors:mon daily:visitors:tue
PFCOUNT combined:visitors

실전 — 페이지 새로고침 중복 방지

const itemsViewsKey = (itemId: string) => `items:views#${itemId}`;
const itemsKey = (itemId: string) => `items#${itemId}`;

async function incrementItemView(itemId: string, userId: string) {
    // HLL에 사용자 ID 추가
    // 새 사용자면 1, 이미 있으면 0 반환
    const inserted = await client.pfAdd(itemsViewsKey(itemId), userId);
    
    if (inserted) {
        // 새로운 고유 사용자만 카운터 증가
        await client.hIncrBy(itemsKey(itemId), 'views', 1);
    }
}

이 패턴이 정말 우아해요. 같은 사용자가 페이지를 100번 새로고침해도 카운터는 1만 올라갑니다. 중복 사용자 추적을 거의 공짜로 해 주는 게 HLL의 진가예요.

여기서 시험 함정이 하나 있어요. HLL은 정확하지 않은 추정값입니다. 재무 데이터, 정확한 트랜잭션 카운트 같이 정확성이 절대적인 곳에는 절대 쓰면 안 돼요. 사용 적합 영역은 조회수·고유 방문자(UV)·DAU·MAU 같은 통계 지표입니다.

한 줄 정리 — 수백만 고유값 추적 + 12KB 메모리 = HyperLogLog. 정확성보다 메모리가 중요할 때.

Stream — 시간순 이벤트 일지

마지막 데이터 구조 Stream시계열 이벤트 로그를 저장하는 자료 구조예요. 각 엔트리는 자동 생성된 타임스탬프 기반 ID(예: 1640000000000-0)와 하나 이상의 필드-값 쌍으로 구성됩니다.

핵심 특징 두 가지가 있어요.

  1. 비파괴적 읽기 — 읽어도 데이터가 삭제되지 않아요
  2. 여러 컨슈머 독립 소비 — 컨슈머마다 자기 위치(오프셋)를 따로 관리

회사 비유로 — 시간순 이벤트 일지예요. 모든 사건이 시간 도장과 함께 기록되고, 여러 사람이 동시에 일지를 펼쳐 봐도 서로 방해 없이 읽을 수 있습니다. 일지는 누가 읽었다고 사라지지 않아요.

기본 명령어

# 스트림에 항목 추가 (* = 자동 ID 생성)
XADD fruits * color yellow name banana
# 반환: "1640000000000-0" (타임스탬프-시퀀스)

XADD fruits * color red name apple

# 스트림 길이
XLEN fruits

# 범위 조회 (XRANGE - 포함적)
XRANGE fruits - +          # 전체
XRANGE fruits 0-0 +        # 처음부터
XRANGE fruits - + COUNT 2  # 최대 2개

# 스트림 읽기 (XREAD - 제외적)
XREAD STREAMS fruits 0-0          # 처음부터 전체
XREAD STREAMS fruits [ID]         # 특정 ID 이후
XREAD COUNT 5 STREAMS fruits 0-0  # 최대 5개

# 블로킹 읽기 (새 메시지 대기)
XREAD BLOCK 3000 STREAMS fruits $  # 3초 동안 새 메시지 대기
# $ = 현재 시점 이후 메시지만

# 역방향 조회
XREVRANGE fruits + -

Stream vs List vs Pub/Sub

세 자료 구조가 비슷해 보여서 선택이 헷갈려요. 차이를 비교표로 정리합니다.

구분ListStreamPub/Sub
메시지 유지LPOP으로 삭제삭제되지 않음구독 중에만 수신
여러 컨슈머복잡기본 지원기본 지원
메시지 재처리불가가능불가
오프셋 관리불가ID 기반불가
사용 사례단순 큐이벤트 소싱·재처리실시간 알림

여기서 시험 함정이 하나 있어요. 메시지를 잃어버리면 안 되는 시스템 = Stream, 단순 작업 큐 = List, 실시간 알림(놓쳐도 OK) = Pub/Sub. 이 매핑이 시나리오 문제 답이에요.

XREAD $ 사용 시 메시지 유실 함정

가장 자주 틀리는 패턴 — $ 토큰을 잘못 쓰면 메시지가 사라져요.

# 문제 시나리오:
# 1. XREAD BLOCK $ 실행 → 대기 시작
# 2. 메시지 A 도착 → 읽기 완료
# 3. 처리 중에 메시지 B, C 추가됨
# 4. 다시 XREAD BLOCK $ 실행 → 메시지 B, C를 놓침!

올바른 패턴은 처음 호출 시에만 $, 이후엔 마지막 처리 ID를 사용하는 거예요.

let lastId = '$';

while (true) {
    const results = await client.xRead(
        [{ key: 'events', id: lastId }],
        { BLOCK: 5000, COUNT: 10 }
    );
    
    if (results) {
        for (const { name, messages } of results) {
            for (const message of messages) {
                processMessage(message);
                // 마지막 처리 ID 업데이트
                lastId = message.id;
            }
        }
    }
}

Stream의 더 깊은 활용(Consumer Group, 메시지 ACK 등)은 6편에서 풀어 갑니다. 한 줄 정리 — 이벤트 보존 + 여러 컨슈머 독립 소비 = Stream.

데이터 구조 선택 가이드 — 결정 트리

지금까지 7종을 본 다음, 실전에서는 어떻게 고를지 결정 트리를 압축해 둡니다.

1. 단순한 값 하나?
   → String (SET/GET)

2. 여러 속성을 가진 객체?
   → Hash (HSET/HGETALL)

3. 순서 보장 + 양 끝에서 추가/제거?
   → List (LPUSH/RPUSH/LPOP)

4. 고유한 값들의 집합 + 교집합/합집합?
   → Set (SADD/SISMEMBER/SINTER)

5. 정렬된 순위 (리더보드)?
   → Sorted Set (ZADD/ZRANGE)

6. 고유 방문자 수 추정 + 메모리 효율?
   → HyperLogLog (PFADD/PFCOUNT)

7. 시계열 이벤트 로그·메시지 큐?
   → Stream (XADD/XREAD)

이 7번 매핑만 외워두면 시나리오 문제 대부분이 풀립니다.

자주 헷갈리는 함정 4가지

1. hGetAll의 빈 객체 반환

// 키가 없을 때 null이 아닌 {} 반환!
const data = await client.hGetAll('nonexistent:key');
console.log(data);  // {} (truthy!)

// 반드시 길이 체크
if (Object.keys(data).length === 0) {
    return null;
}

2. Sorted Set 스코어는 숫자만

// 잘못된 예 — 문자열 스코어 불가
await client.zAdd('usernames', { value: 'alice', score: 'abc123' });  // ERROR

// 올바른 예 — 정수로 변환
const hexId = 'abc123';
await client.zAdd('usernames', { 
    value: 'alice', 
    score: parseInt(hexId, 16)
});
// 복원 시: score.toString(16)

3. List LTRIM은 영구 삭제

LTRIM mylist 0 9   # 10개만 유지, 나머지 영구 삭제
# 반드시 LLEN으로 길이 먼저 확인

4. HyperLogLog는 추정값

# 정확성이 중요한 곳에는 절대 쓰지 말 것
# 재무·결제·트랜잭션 카운트 X
# UV·DAU·조회수 같은 통계 O

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 Redis 2편의 핵심입니다. 시험 직전 압축 노트로 마무리할게요.

  • String = 일반 봉투 (텍스트·숫자·바이너리, 최대 512MB)
  • Hash = 칸 나눠진 폴더 (필드-값 쌍, 객체 저장)
  • List = 양면 대기열 (이중 연결 리스트, 양 끝 O(1))
  • Set = 중복 없는 명단 (멤버십 O(1), 교집합·합집합 지원)
  • Sorted Set = 점수 순위 명단 (자동 정렬, O(log N))
  • HyperLogLog = 대략 인원 카운터 (12KB로 수백만, 약 0.81% 오차)
  • Stream = 시간순 이벤트 일지 (비파괴적, 여러 컨슈머)
  • 리더보드 = Sorted Set, UV = HyperLogLog, 객체 = Hash (시나리오 문제 단골)
  • String INCR원자적 — 동시 1000개 요청도 정확히 1000번 증가
  • String 조건부 저장 — NX(없을 때만), XX(있을 때만)
  • Hash HINCRBY로 객체 한 칸을 원자적 증감
  • hGetAll은 키 없으면 null 아닌 {}Object.keys().length 체크 필수
  • List LTRIM은 범위 외 영구 삭제LLEN으로 길이 확인 후 사용
  • Set 멤버십 체크 O(1) — 1억 개여도 즉시 (List는 O(N))
  • Sorted Set 스코어는 반드시 숫자 — 문자열 ID는 16진수→10진수 변환
  • Sorted Set 핵심 연산 O(log N) — 1억 개에서도 매우 빠름
  • ZADD 옵션 — NX(없을 때), XX(있을 때), GT(클 때만 갱신), LT(작을 때만)
  • HyperLogLog는 추정값 — 재무·결제 X, UV·통계 O
  • Stream $ 토큰은 첫 호출만, 이후엔 마지막 처리 ID 사용 (메시지 유실 방지)
  • Stream vs List vs Pub/Sub — 이벤트 보존 = Stream, 단순 큐 = List, 실시간 알림 = Pub/Sub

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

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

답글 남기기

error: Content is protected !!