개발/REDIS

#3 Strings: 카운터·간단 캐시 만들기

cedis 2026. 3. 17. 21:43

🔥 들어가며 — 조회수 1,000만 번도 Redis면 끄떡없다

유튜브 영상 조회수, 인스타그램 좋아요 수, 웹사이트 방문자 카운터. 이런 숫자들은 매 초마다 수천 번씩 증가합니다. 이걸 매번 MySQL 같은 DB에 UPDATE SET count = count + 1로 처리하면 어떻게 될까요?

DB는 해당 행에 잠금(Lock)을 걸고, 읽고, 더하고, 쓰고, 잠금을 풀어야 합니다. 동시에 1,000명이 좋아요를 누르면 1,000개의 잠금 요청이 줄을 서게 되죠. 결과는 DB 과부하, 응답 지연, 최악의 경우 서비스 장애입니다.

Redis의 String 타입INCR 명령어는 이 문제를 우아하게 해결합니다. Redis는 싱글 스레드로 명령을 처리하기 때문에, INCR잠금 없이도 원자적(Atomic)으로 안전하게 숫자를 증가시킵니다. 경쟁 조건(Race Condition) 걱정 없이 말이죠.

오늘은 Redis String 타입의 핵심 명령어들을 배우고, 실제로 페이지 조회수 카운터API 응답 캐시를 직접 구현해봅니다. String은 Redis에서 가장 기본이 되는 자료구조이며, 공식 문서도 "가장 단순하지만 캐시와 카운터에 매우 자주 쓰이는 타입"이라고 소개합니다.

💡 오늘의 핵심 한 줄: "Redis String의 INCR은 잠금 없이 숫자를 원자적으로 증가시킨다. 조회수·좋아요·요청 카운터에 최적이다."

🧠 핵심 개념 1 — Redis String이란?

Redis의 String은 이름처럼 문자열을 저장하지만, 사실 숫자도 저장할 수 있는 만능 타입입니다. 최대 512MB까지 저장 가능하며, 텍스트·숫자·직렬화된 JSON·이미지 바이너리까지 담을 수 있습니다.

하지만 실무에서 String이 빛나는 순간은 따로 있습니다. 바로 숫자를 문자열로 저장하고, Redis가 직접 증가/감소 연산을 해주는 것입니다. 예를 들어 SET counter 0으로 저장한 뒤 INCR counter를 호출하면 Redis가 내부적으로 숫자로 파싱해서 1을 더한 후 다시 문자열로 저장합니다.

명령어 설명 예시
SET key value 값 저장 SET name "kim"
GET key 값 조회 GET name → "kim"
SET key value EX 초 TTL 함께 설정 SET token "abc" EX 3600
SETNX key value 키 없을 때만 저장 (분산 락에 활용) SETNX lock "1"
GETSET key value 기존 값 반환 후 새 값 저장 원자적 교체
MSET k1 v1 k2 v2 여러 키 한 번에 저장 네트워크 왕복 절약
MGET k1 k2 여러 키 한 번에 조회 네트워크 왕복 절약
APPEND key value 기존 값 뒤에 이어붙이기 로그 누적 등
STRLEN key 값의 길이 조회 바이트 단위
ℹ️ SET의 옵션들
SET key value EX 초 — 초 단위 TTL
SET key value PX 밀리초 — 밀리초 단위 TTL
SET key value NX — 키가 없을 때만 저장 (= SETNX)
SET key value XX — 키가 있을 때만 저장
SET key value GET — 기존 값 반환 후 저장 (Redis 6.2+)

🧠 핵심 개념 2 — INCR의 원자성: 왜 이게 중요한가?

프로그래밍에서 원자적(Atomic)이란 "중간에 끊기지 않고 한 번에 완전히 실행된다"는 의미입니다. 카운터 증가처럼 보이는 단순한 작업도 사실 내부적으로는 세 단계입니다: ① 현재 값 읽기 → ② 1 더하기 → ③ 새 값 쓰기.

만약 두 요청이 동시에 이 세 단계를 밟으면 어떻게 될까요? 둘 다 현재 값 0을 읽고, 둘 다 1을 더해서 1을 쓰면 → 결과는 1이 됩니다. 두 번 증가했지만 결과는 1. 이게 Race Condition(경쟁 조건)입니다.

Redis의 INCR은 이 세 단계를 하나의 원자적 명령으로 처리합니다. Redis는 싱글 스레드 이벤트 루프로 명령을 순서대로 처리하기 때문에, 동시에 1,000개의 INCR 요청이 와도 단 하나도 유실 없이 정확하게 카운트됩니다.

명령어 설명 예시 결과
INCR key 1 증가 (없으면 0에서 시작) 0 → 1 → 2 ...
INCRBY key N N만큼 증가 INCRBY cnt 10 → +10
DECR key 1 감소 5 → 4 → 3 ...
DECRBY key N N만큼 감소 DECRBY cnt 5 → -5
INCRBYFLOAT key F 소수점 증가 INCRBYFLOAT price 1.5
⚠️ INCR 주의사항!
값이 숫자가 아닌 문자열이면 ERR value is not an integer 오류가 발생합니다.
또한 Redis의 정수는 64bit 부호 있는 정수 범위를 넘으면 오류납니다. 일반적인 카운터 용도에서는 걱정할 필요가 없습니다.

⚙️ Lab 2 — Strings 카운터·캐시 실습

🎯 목표: INCR/INCRBY로 카운터를 만들고, Cache-Aside 패턴으로 API 응답을 캐싱한다.

🖥️ redis-cli 실습 — 카운터 만들기

1
Redis 접속
TERMINAL
docker exec -it redis redis-cli
2
페이지 조회수 카운터
REDIS-CLI
# 기존 키가 있다면 삭제 (처음 시작)
DEL counter:pageview

# 1씩 증가 — 처음엔 0에서 시작
INCR counter:pageview
# (integer) 1

INCR counter:pageview
# (integer) 2

# 한 번에 10 증가
INCRBY counter:pageview 10
# (integer) 12

# 현재 값 조회 (문자열로 반환됨)
GET counter:pageview
# "12"

# 1 감소
DECR counter:pageview
# (integer) 11
3
TTL 있는 카운터 — 일별 방문자 수
REDIS-CLI
# 오늘 날짜를 키에 포함 (일별 초기화 패턴)
SET counter:visit:20260317 0 EX 86400
# OK  (86400초 = 24시간)

INCR counter:visit:20260317
INCR counter:visit:20260317
INCR counter:visit:20260317
GET counter:visit:20260317
# "3"

TTL counter:visit:20260317
# (integer) 86390  ← 약 24시간 남음
4
MSET / MGET — 여러 키 한 번에 처리
REDIS-CLI
# 여러 키를 한 번에 저장
MSET product:1:name "사과" product:1:price "1500" product:2:name "바나나" product:2:price "800"

# 여러 키를 한 번에 조회 (네트워크 왕복 1번으로 해결!)
MGET product:1:name product:1:price product:2:name product:2:price
# 1) "사과"
# 2) "1500"
# 3) "바나나"
# 4) "800"

🐍 Python — 카운터 API + Cache-Aside (redis-py)

1
라이브러리 설치
TERMINAL
pip install redis
2
카운터 + 캐시 통합 예제 (redis_strings.py)
PYTHON
import redis
import time
import datetime

# Redis 연결
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# ════════════════════════════════════════
# ① 페이지 조회수 카운터
# ════════════════════════════════════════
def increment_pageview(page_id: str) -> int:
    """페이지 조회수를 1 증가시키고 현재 값을 반환"""
    key = f"counter:pageview:{page_id}"
    count = r.incr(key)  # 원자적 증가

    # 처음 생성된 키라면 TTL 설정 (오늘 자정까지)
    if count == 1:
        r.expire(key, 86400)  # 24시간

    return count

# ════════════════════════════════════════
# ② 일별 방문자 카운터 (날짜 자동 포함)
# ════════════════════════════════════════
def increment_daily_visit() -> int:
    """오늘 날짜 기준 방문자 수 증가"""
    today = datetime.date.today().strftime("%Y%m%d")
    key = f"counter:visit:{today}"
    count = r.incr(key)

    # 처음 생성 시 자정까지 TTL 설정
    if count == 1:
        r.expire(key, 86400)

    return count

# ════════════════════════════════════════
# ③ API 응답 캐시 (Cache-Aside 패턴)
# ════════════════════════════════════════
def fake_api_call(query: str) -> str:
    """외부 API 호출 흉내 (느린 작업)"""
    print(f"  [API 실제 호출] query={query} ...")
    time.sleep(0.3)  # 외부 API 지연 흉내
    return f"API 결과: {query} 관련 데이터 (가짜)"

def get_api_cached(query: str, ttl: int = 300) -> str:
    """API 응답을 캐시에서 먼저 조회, 없으면 실제 호출 후 캐시 저장"""
    key = f"cache:api:{query}"

    # 캐시 조회
    cached = r.get(key)
    if cached:
        print(f"  [캐시 HIT] key={key}")
        return cached

    # 캐시 MISS → 실제 API 호출
    result = fake_api_call(query)
    r.set(key, result, ex=ttl)
    print(f"  [캐시 저장] key={key}, TTL={ttl}초")
    return result

# ════════════════════════════════════════
# ④ MGET으로 여러 카운터 한 번에 조회
# ════════════════════════════════════════
def get_stats() -> dict:
    """여러 통계를 한 번의 MGET으로 조회"""
    keys = [
        "counter:pageview:home",
        "counter:pageview:about",
        "counter:pageview:contact",
    ]
    values = r.mget(keys)  # 네트워크 왕복 1번!
    return {k: (v or "0") for k, v in zip(keys, values)}

# ════════ 테스트 실행 ════════
if __name__ == "__main__":
    print("=== 페이지 조회수 카운터 ===")
    for _ in range(5):
        cnt = increment_pageview("home")
    print(f"home 조회수: {cnt}\n")

    print("=== 일별 방문자 ===")
    print(f"오늘 방문자: {increment_daily_visit()}명\n")

    print("=== API 응답 캐시 ===")
    result1 = get_api_cached("redis란무엇인가")  # MISS
    result2 = get_api_cached("redis란무엇인가")  # HIT
    print(f"결과: {result2}\n")

    print("=== 통계 일괄 조회 ===")
    stats = get_stats()
    for k, v in stats.items():
        print(f"  {k}: {v}")
TERMINAL
python redis_strings.py

# 기대 출력:
# === 페이지 조회수 카운터 ===
# home 조회수: 5
#
# === 일별 방문자 ===
# 오늘 방문자: 1명
#
# === API 응답 캐시 ===
#   [API 실제 호출] query=redis란무엇인가 ...
#   [캐시 저장] key=cache:api:redis란무엇인가, TTL=300초
#   [캐시 HIT] key=cache:api:redis란무엇인가
# 결과: API 결과: redis란무엇인가 관련 데이터 (가짜)
#
# === 통계 일괄 조회 ===
#   counter:pageview:home: 5
#   counter:pageview:about: 0
#   counter:pageview:contact: 0

🟢 Node.js — 카운터 API + Cache-Aside (node-redis)

NODE.JS
import { createClient } from 'redis';

const client = createClient({ url: 'redis://localhost:6379' });
client.on('error', (err) => console.error('Redis 오류:', err));
await client.connect();

// ════════════════════════════════════════
// ① 페이지 조회수 카운터
// ════════════════════════════════════════
async function incrementPageview(pageId) {
  const key = `counter:pageview:${pageId}`;
  const count = await client.incr(key); // 원자적 증가

  // 처음 생성된 키라면 TTL 설정
  if (count === 1) {
    await client.expire(key, 86400); // 24시간
  }
  return count;
}

// ════════════════════════════════════════
// ② API 응답 캐시 (Cache-Aside)
// ════════════════════════════════════════
async function fakeApiCall(query) {
  console.log(`  [API 실제 호출] query=${query} ...`);
  await new Promise(r => setTimeout(r, 300)); // 지연 흉내
  return `API 결과: ${query} 관련 데이터 (가짜)`;
}

async function getApiCached(query, ttl = 300) {
  const key = `cache:api:${query}`;

  // 캐시 조회
  const cached = await client.get(key);
  if (cached) {
    console.log(`  [캐시 HIT] key=${key}`);
    return cached;
  }

  // 캐시 MISS → 실제 호출
  const result = await fakeApiCall(query);
  await client.set(key, result, { EX: ttl });
  console.log(`  [캐시 저장] key=${key}, TTL=${ttl}초`);
  return result;
}

// ════════════════════════════════════════
// ③ MGET으로 여러 카운터 한 번에 조회
// ════════════════════════════════════════
async function getStats() {
  const keys = [
    'counter:pageview:home',
    'counter:pageview:about',
    'counter:pageview:contact',
  ];
  const values = await client.mGet(keys); // 네트워크 왕복 1번!
  return Object.fromEntries(keys.map((k, i) => [k, values[i] ?? '0']));
}

// ════════ 테스트 실행 ════════
console.log('=== 페이지 조회수 카운터 ===');
let cnt;
for (let i = 0; i < 5; i++) cnt = await incrementPageview('home');
console.log(`home 조회수: ${cnt}\n`);

console.log('=== API 응답 캐시 ===');
await getApiCached('redis란무엇인가');  // MISS
await getApiCached('redis란무엇인가');  // HIT

console.log('\n=== 통계 일괄 조회 ===');
const stats = await getStats();
for (const [k, v] of Object.entries(stats)) {
  console.log(`  ${k}: ${v}`);
}

await client.disconnect();
TERMINAL
node redis_strings.mjs

💼 실무 팁 — String 설계 시 체크리스트

❌ 실수 1: 카운터 키에 TTL을 안 달았다
조회수 카운터가 영원히 쌓이면 메모리가 조금씩 차오릅니다. 일별 카운터는 24시간, 월별 카운터는 31일 TTL을 설정하세요.
❌ 실수 2: 숫자를 JSON으로 감싸서 저장했다
SET counter '{"value":0}'처럼 저장하면 INCR을 못 씁니다. 카운터용 키는 반드시 순수 숫자 문자열로 저장하세요.
❌ 실수 3: 캐시 키를 너무 짧게 지었다
SET u1 "kim"처럼 짧으면 나중에 무슨 키인지 알 수 없습니다. cache:prod:user:1처럼 prefix 패턴을 지켜주세요.
❌ 실수 4: 큰 JSON을 통째로 String에 저장했다
유저 프로필 전체를 JSON 문자열로 저장하면, 이메일 하나 바꿔도 전체를 다시 써야 합니다. 필드별로 조작이 필요한 데이터는 다음 편에서 배울 Hash 타입이 더 적합합니다.
❌ 실수 5: 여러 키를 개별 GET으로 N번 조회했다
10개의 키를 조회할 때 GET을 10번 호출하면 네트워크 왕복이 10번 발생합니다. MGET을 쓰면 한 번의 왕복으로 끝납니다.
❌ 실수 6: 동시성 문제를 무시하고 GET → 계산 → SET을 썼다
GET counter → Python에서 +1 → SET counter 새값 패턴은 Race Condition이 발생합니다. 반드시 INCR/INCRBY를 사용하세요.

✅ String을 쓰면 좋은 경우 vs 다른 타입이 나은 경우

데이터 성격 추천 타입 이유
조회수·좋아요·요청 카운터 String (INCR) 원자적 증가, 단순
단순 텍스트·JSON 캐시 String 전체 교체에 최적
유저 프로필 (필드별 수정) Hash (4편) 필드 단위 조작 가능
알림 목록·작업 큐 List (5편) 순서 보장, push/pop
실시간 랭킹 Sorted Set (6편) 점수 기반 정렬

📋 오늘의 핵심 요약 5줄

  1. Redis String은 텍스트·숫자·JSON 등을 저장하는 가장 기본적인 자료구조다.
  2. INCR/INCRBY원자적으로 숫자를 증가시켜 Race Condition 없이 카운터를 구현할 수 있다.
  3. MSET/MGET으로 여러 키를 한 번의 네트워크 왕복으로 처리해 성능을 높일 수 있다.
  4. Cache-Aside 패턴으로 API 응답·DB 조회 결과를 캐싱하면 반복 요청의 응답 속도를 획기적으로 높일 수 있다.
  5. 필드별 수정이 필요한 객체 데이터는 String보다 Hash 타입(4편)이 적합하다.

☑️ 오늘의 체크리스트

  • INCR을 여러 번 호출하며 숫자가 정확히 증가하는 것을 확인했다
  • INCRBY로 한 번에 N씩 증가시켜봤다
  • TTL이 있는 일별 카운터 키를 만들었다
  • MSET/MGET으로 여러 키를 한 번에 처리해봤다
  • Python 또는 Node.js로 Cache-Aside 함수를 구현했다
  • 첫 호출(MISS)과 두 번째 호출(HIT)의 속도 차이를 느꼈다

🧩 확인 퀴즈

  1. 두 요청이 동시에 카운터를 증가시킬 때 GET → +1 → SET 방식의 문제점은 무엇인가요? Redis는 이를 어떻게 해결하나요?
  2. 10개의 키 값을 조회해야 할 때, GET을 10번 쓰는 것보다 나은 방법과 그 이유는?
  3. 유저 프로필 데이터(이름, 이메일, 나이)를 Redis에 저장할 때 String보다 적합한 자료구조는 무엇이고 왜인가요?
▶ 정답 확인 (클릭)
  1. Race Condition(경쟁 조건)이 발생합니다. 두 요청이 동시에 같은 값을 읽어 각각 +1 하면 결과가 하나만 반영됩니다. Redis의 INCR은 읽기·증가·쓰기를 원자적으로 처리해 이 문제를 방지합니다.
  2. MGET key1 key2 ... key10을 사용합니다. 네트워크 왕복이 10번에서 1번으로 줄어 성능이 크게 향상됩니다.
  3. Hash 타입이 적합합니다. String은 전체 값을 통째로 교체하므로 이메일 하나만 바꿔도 JSON 전체를 다시 써야 합니다. Hash는 필드 단위로 읽고 쓸 수 있어 훨씬 효율적입니다.
📝 도전 과제 (다음 편 전까지 해보세요!)

1. 오늘·이번 주·이번 달 방문자 카운터를 각각 다른 TTL로 구현해보세요. (일: 86400초, 주: 604800초, 월: 2592000초)

2. Python 코드에서 같은 query로 get_api_cached를 10번 호출해 실제 API 호출이 몇 번 발생하는지 확인해보세요. (정답: 1번)

3. SET counter:test 100INCRBYFLOAT counter:test 1.5를 3번 실행하면 최종 값이 얼마인지 확인해보세요.
📚 Redis 입문 시리즈

✅ #1 Redis 시작하기: 설치·실행·첫 명령(PING)
✅ #2 키·TTL·만료: 캐시의 핵심 규칙
#3 Strings: 카운터·간단 캐시 만들기 ← 현재 글
⬜ #4 Hashes: 세션·프로필 저장소 만들기
⬜ #5 Lists·Sets: 큐와 중복 방지
⬜ #6 Sorted Sets: 랭킹/리더보드
⬜ #7 Pub/Sub vs Streams: 실시간 메시징
⬜ #8 동시성 제어: 트랜잭션(MULTI/EXEC)·WATCH
⬜ #9 퍼시스턴스: RDB·AOF·복구 체크
⬜ #10 복제·고가용성: Replication·Sentinel
⬜ #11 클러스터: 샤딩·해시 슬롯·제약
⬜ #12 보안·성능·관측: ACL·eviction·latency
# Redis # Redis String # INCR # 카운터 # Cache-Aside # MGET # redis-py # node-redis # 조회수 카운터 # 백엔드 입문

📌 Redis 입문 시리즈 #3  |  난이도: ⭐⭐☆☆☆ 초급  |  기준 버전: Redis Open Source 8.6.1