3편. Redis String 실습: 캐시, 인증 코드, 카운터, Rate Limiting
3편. Redis String 실습: 캐시, 인증 코드, 카운터, Rate Limiting
📚 목차
1. 사용자 조회 결과 캐싱하기
2. 이메일 인증 코드 저장하기
3. 게시글 조회수 카운터 구현하기
4. Rate Limiting 구현하기

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/redis-examples
1. 사용자 조회 결과 캐싱하기
사용자 정보는 서비스에서 자주 조회되는 데이터입니다.
예를 들어 다음과 같은 API에서는 사용자 정보를 반복적으로 조회할 수 있습니다.
- 마이페이지 조회
- 게시글 작성자 정보 조회
- 댓글 작성자 정보 조회
- 로그인 사용자 기본 정보 조회
이때 매번 DB를 조회하면 불필요한 DB 부하가 발생할 수 있습니다.
그래서 사용자 단건 조회 결과를 Redis에 잠시 저장해 두고, 같은 사용자를 다시 조회할 때는 DB 대신 Redis에서 가져오도록 구성합니다.
전체 흐름은 다음과 같습니다
사용자 조회 요청
↓
Redis cache:user:{userId} 조회
↓
캐시 있음 → Redis 데이터 반환
↓
캐시 없음 → DB 조회
↓
DB 조회 결과를 Redis에 60초 저장
↓
사용자 데이터 반환
✔️ 사용하는 Redis Key
사용자 캐시 key는 RedisKey 유틸에서 관리합니다.
RedisKey.cache.user(userId)
//
cache: {
user: (userId: number) => `cache:user:${userId}`, // 사용자 단건 조회 캐시
},
실제 생성되는 key는 다음과 같습니다.
cache:user:1
cache:user:2
cache:user:3
cache:* prefix를 사용하면 Redis 안에서 캐시 용도의 key를 쉽게 구분할 수 있습니다.
✔️ Redis 캐시 조회 흐름
Cache Hit → Redis 데이터 반환
Cache Miss → DB 조회 후 Redis 저장
// src/services/user.service.ts
/**
* Redis 캐시를 사용하는 사용자 단건 조회
*
* 1. userId로 Redis cache key를 만듭니다.
* 2. Redis에서 사용자 JSON 데이터를 먼저 조회합니다.
* 3. 캐시에 값이 있으면 DB를 조회하지 않고 바로 반환합니다.
* 4. 캐시에 값이 없으면 DB에서 조회합니다.
* 5. DB 조회 결과를 Redis에 60초 동안 저장합니다.
*
* 실습 포인트:
* 자주 조회되는 데이터를 Redis에 잠시 저장하면 DB 조회 횟수를 줄일 수 있습니다.
*/
async getUserByIdWithCache(userId: number): Promise<UserOutput> {
// 사용자별 캐시 key입니다.
// 예: cache:user:1
const cacheKey = RedisKey.cache.user(userId);
// Cache hit: Redis에 데이터가 있으면 DB를 조회하지 않고 바로 반환합니다.
const cachedUser = await this.cacheService.getJson<UserOutput>(cacheKey);
if (cachedUser) {
return cachedUser;
}
// Cache miss: Redis에 없을 때만 DB를 조회합니다.
const user = await this.getUserById(userId);
// TTL 60초로 저장합니다.
// 60초가 지나면 Redis가 key를 자동 삭제하므로 오래된 데이터가 무기한 남지 않습니다.
await this.cacheService.setJson(cacheKey, user, 60);
return user;
}
✔️ 사용자 수정 시 캐시 무효화
캐시를 사용할 때 가장 중요한 부분은 캐시 무효화입니다.
사용자 정보가 수정되었는데 Redis에 예전 데이터가 남아 있으면, 이후 조회에서 오래된 사용자 정보가 반환될 수 있습니다.
그래서 updateUser()에서는 DB 수정 후 Redis 캐시를 삭제합니다
// src/services/user.service.ts
/**
* 사용자 정보 수정
*
* 1. userId에 해당하는 사용자의 name/status를 수정합니다.
* 2. undefined가 아닌 필드만 update data에 포함합니다.
* 3. DB 수정이 끝나면 기존 Redis 캐시를 삭제합니다.
* 4. 수정된 사용자 정보를 UserOutput 형태로 변환해 반환합니다.
*
* 실습 포인트:
* DB 데이터가 바뀌면 Redis에 남아 있는 예전 캐시를 지워야 합니다.
* 그래야 다음 getUserByIdWithCache 호출에서 DB의 최신 값을 다시 읽고 캐싱합니다.
*/
async updateUser(userId: number, input: UpdateUserInput) {
// prisma.user.update()는 대상 사용자가 없으면 null을 반환하지 않고
// P2025 예외를 던집니다.
const user = await prisma.user.update({
where: {
id: userId,
},
data: {
// 값이 undefined인 필드는 업데이트하지 않습니다.
// 예: name만 들어오면 status는 기존 값을 유지합니다.
...(input.name !== undefined && { name: input.name }),
...(input.status !== undefined && { status: input.status }),
},
select: UserSelect,
});
// DB 업데이트 후 캐시 무효화
// 기존 캐시를 지워야 다음 getUserByIdWithCache 호출에서 최신 값을 다시 캐싱합니다.
const cacheKey = RedisKey.cache.user(userId);
await this.cacheService.deleteCache(cacheKey);
return toUserOutput(user);
}
✔️ 테스트: /tests/services/user.service.test.ts

2. 이메일 인증 코드 저장하기
이메일 인증 코드는 일정 시간 동안만 유효해야 합니다.
예를 들어 회원가입 이메일 인증에서는 다음 요구사항이 자주 등장합니다.
1. 사용자 이메일로 6자리 인증 코드 발급
2. 인증 코드는 3분 동안만 유효
3. 사용자가 입력한 코드와 Redis에 저장된 코드 비교
4. 인증 성공 후 같은 코드는 다시 사용할 수 없도록 삭제
이런 데이터는 DB에 저장하기보다 Redis String에 TTL과 함께 저장하는 것이 적합합니다.
✔️ 사용하는 Redis Key
RedisKey.string.authCode(email)
#string:auth-code:kim@example.com
✔️ 인증 코드 생성 및 저장
// src/services/auth.service.ts
/**
* 이메일 인증 코드 저장
*
* 1. 6자리 인증 코드를 생성합니다.
* 2. 이메일을 포함한 Redis key를 만듭니다.
* 3. Redis String에 인증 코드를 저장하고 TTL 180초를 설정합니다.
*
* 반환한 authCode는 실제 서비스에서는 이메일/SMS로 발송하고,
* API 응답으로 직접 노출하지 않는 것이 일반적입니다.
*/
async saveEmailAuthCode(email: string): Promise<string> {
const authCode = this.generateAuthCode();
// 이메일별로 인증 코드를 따로 저장하기 위한 key입니다.
// 예: string:auth-code:test@example.com
const key = RedisKey.string.authCode(email);
// EX: 180은 이 key를 180초 후 Redis가 자동 삭제하도록 만드는 옵션입니다.
// 즉, 사용자는 180초 안에 인증 코드를 입력해야 합니다.
await redis.set(key, authCode, {
EX: 180,
});
return authCode;
}
✔️ 인증 코드 검증
Redis에 값이 없으면 다음 두 가지 중 하나입니다.
1. 인증 코드가 발급되지 않음
2. TTL이 지나 인증 코드가 만료됨
/**
* 이메일 인증 코드 검증
*
* 1. 이메일에 해당하는 인증 코드를 Redis에서 조회합니다.
* 2. Redis에 값이 없으면 만료되었거나 발급되지 않은 코드이므로 false를 반환합니다.
* 3. 저장된 코드와 사용자가 입력한 코드를 비교합니다.
* 4. 인증에 성공하면 Redis key를 삭제해 같은 코드를 다시 쓸 수 없게 합니다.
*/
async verifyEmailAuthCode(email: string, inputCode: string): Promise<boolean> {
const key = RedisKey.string.authCode(email);
// TTL 180초가 지나면 Redis가 key를 자동 삭제하므로 savedCode는 null이 됩니다.
const savedCode = await redis.get(key);
if (!savedCode) {
return false;
}
const isValid = savedCode === inputCode;
if (isValid) {
// 인증 성공 후 삭제하면 인증 코드는 1회용처럼 동작합니다.
await redis.del(key);
}
return isValid;
}
✔️테스트 : tests/services/auth.service.test.ts

3. 게시글 조회수 카운터 구현하기
게시글 조회수는 자주 증가하는 값입니다.
사용자가 게시글을 볼 때마다 DB에 update를 실행하면 트래픽이 많을수록 DB 쓰기 부하가 커집니다
게시글 조회 1회 → DB update 1회
게시글 조회 1,000회 → DB update 1,000회
게시글 조회 10,000회 → DB update 10,000회
이런 단순 증가 값은 Redis String의 INCR 명령으로 처리하기 좋습니다.
Redis String 값이 숫자 문자열이면 INCR, DECR, INCRBY 같은 명령어로 값을 증가 또는 감소시킬 수 있습니다.
✔️ 사용하는 Redis Key
RedisKey.string.postViewCount(postId)
#string:post-view-count:1
#string:post-view-count:2
✔️ 조회수 증가 / 조회
// src/services/post.service.ts
/**
* Redis String 기반 조회수 증가
*
* 1. 게시글별 조회수 Redis key를 만듭니다.
* 2. Redis INCR 명령으로 조회수를 1 증가시킵니다.
* 3. 증가 후의 값을 반환합니다.
*
* INCR 명령은 Redis에서 원자적으로 처리됩니다.
* 동시에 여러 요청이 들어와도 증가 값이 깨지지 않습니다.
*/
async increaseViewCount(postId: number): Promise<number> {
// 게시글별 조회수 카운터 key입니다.
// 예: string:post-view-count:1
const key = RedisKey.string.postViewCount(postId);
return redis.incr(key);
}
/**
* Redis에 저장된 조회수 조회
*
* 1. 게시글별 조회수 Redis key를 만듭니다.
* 2. Redis String 값을 조회합니다.
* 3. 값이 없으면 아직 조회수가 증가하지 않은 상태로 보고 0을 반환합니다.
*/
async getRedisViewCount(postId: number): Promise<number> {
const key = RedisKey.string.postViewCount(postId);
const value = await redis.get(key);
// Redis get 결과는 문자열 또는 null입니다.
// 조회수 계산에 사용하기 위해 number로 변환합니다.
return value ? Number(value) : 0;
}
✔️ 게시글 조회와 조회수 증가를 함께 처리하기
/**
* 게시글 조회 + 조회수 증가
*
* 1. DB에서 게시글 상세 정보를 조회합니다.
* 2. 게시글이 없으면 null을 반환합니다.
* 3. 게시글이 있으면 Redis 조회수 카운터를 1 증가시킵니다.
* 4. DB 게시글 정보와 Redis 조회수를 함께 반환합니다.
*
* 실습 포인트:
* 상세 조회 요청마다 DB의 viewCount를 바로 update하지 않고,
* Redis에 먼저 빠르게 누적한 뒤 나중에 DB로 반영합니다.
*/
async getPostDetailAndIncreaseViewCount(postId: number) {
const post = await this.getPostById(postId);
if (!post) {
return null;
}
const redisViewCount = await this.increaseViewCount(postId);
// redisViewCount는 DB viewCount가 아니라 Redis에 임시로 쌓인 조회수입니다.
return {
...post,
redisViewCount,
};
}
✔️ Redis 조회수를 DB에 반영하기
/**
* Redis 조회수를 DB에 반영합니다.
*
* 1. Redis에서 게시글 조회수 값을 가져오면서 동시에 삭제합니다.
* 2. Redis에 쌓인 조회수가 없으면 DB를 업데이트하지 않고 null을 반환합니다.
* 3. Redis 조회수를 DB의 viewCount에 increment로 더합니다.
* 4. DB update가 실패하면 Redis에 조회수를 다시 복구합니다.
*
* 실습 포인트:
* GETDEL은 값을 읽고 삭제하는 작업을 한 번에 수행합니다.
* GET 후 DEL을 따로 호출하는 것보다 조회수 유실 위험을 줄일 수 있습니다.
*/
async syncViewCountToDatabase(postId: number) {
const key = RedisKey.string.postViewCount(postId);
// GETDEL은 Redis 값을 가져오면서 key를 삭제합니다.
// value는 문자열 또는 null이므로 숫자로 변환해 사용합니다.
const value = await redis.getDel(key);
const redisViewCount = value ? Number(value) : 0;
if (redisViewCount <= 0) {
return null;
}
try {
return await prisma.post.update({
where: { id: postId, },
data: {
viewCount: { increment: redisViewCount,},
},
});
} catch (error) {
// DB 반영에 실패하면 GETDEL로 삭제했던 조회수를 Redis에 다시 더합니다.
// 이렇게 하지 않으면 실패한 조회수 증가분이 사라질 수 있습니다.
await redis.incrBy(key, redisViewCount);
throw error;
}
}
✔️테스트 : tests\services\post.service.test.ts

4. Rate Limiting 구현하기
Rate Limiting은 일정 시간 동안 허용 가능한 요청 수를 제한하는 기능입니다.
같은 IP에서 60초 동안 최대 5회 요청 허용
같은 사용자에게 10초 동안 최대 20회 API 요청 허용
✔️ 사용하는 Redis Key
RedisKey.string.rateLimit(key)
#string:rate-limit:login:ip:127.0.0.1
#string:rate-limit:api:user:1
✔️ 요청 횟수 증가
Rate Limiting의 핵심은 요청이 들어올 때마다 Redis 값을 증가시키는 것입니다.
limit이 5라면 결과는 다음과 같습니다.
1번째 요청 → 허용
2번째 요청 → 허용
3번째 요청 → 허용
4번째 요청 → 허용
5번째 요청 → 허용
6번째 요청 → 차단
// src/services/rate-limit.service.ts
/**
* 고정 윈도우 방식 Rate Limiting
*
* 1. 요청을 구분할 key를 Redis rate limit key로 변환합니다.
* 2. Redis INCR 명령으로 요청 횟수를 1 증가시킵니다.
* 3. 현재 Redis key의 TTL을 조회합니다.
* 4. 첫 요청이거나 TTL이 없는 key라면 windowSeconds 만큼 TTL을 설정합니다.
* 5. count가 limit 이하이면 요청을 허용합니다.
*
* 예:
* windowSeconds가 60이고 limit이 5라면,
* 같은 key로 60초 동안 최대 5번까지만 요청을 허용합니다.
*
* 실습 포인트:
* Redis String 값을 카운터로 사용하고,
* TTL을 함께 걸어 일정 시간이 지나면 요청 횟수가 자동 초기화되게 합니다.
*
* 보완 포인트:
* INCR 성공 후 EXPIRE 실행 전에 장애가 나면 TTL 없는 key가 남을 수 있습니다.
* 그래서 ttl === -1인 경우에도 expire를 다시 설정해 제한 key를 복구합니다.
*/
async checkLimit(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult> {
// 요청 제한용 Redis key입니다.
// 예: string:rate-limit:login:ip:127.0.0.1
const redisKey = RedisKey.string.rateLimit(key);
// INCR 후 EXPIRE가 별도 명령이라,
// 아주 드물게 INCR 성공 후 EXPIRE 전에 장애가 나면 TTL 없는 제한 키가 남을 수 있습니다.
// 실서비스라면 Lua script나 Redis transaction으로 묶는 방식을 고려할 수 있습니다.
// INCR은 Redis에서 원자적으로 처리됩니다.
// 동시에 여러 요청이 들어와도 count 증가 값이 깨지지 않습니다.
// 기존 값 없음 → 0으로 간주 → +1 → 1 저장
const count = await redis.incr(redisKey);
// ttl 결과 의미:
// - 양수: key가 만료되기까지 남은 시간(초)
// - -1: key는 있지만 만료 시간이 없음
// - -2: key가 없음
//redis.ttl() → 남은 만료 시간을 조회한다
let ttl = await redis.ttl(redisKey);
if (count === 1 || ttl === -1) {
// count === 1:
// 현재 제한 윈도우의 첫 요청이므로 TTL을 새로 설정합니다.
//
// ttl === -1:
// INCR은 성공했지만 EXPIRE가 누락된 key일 수 있으므로 TTL을 복구합니다.
// redis.expire() → 만료 시간을 설정한다
await redis.expire(redisKey, windowSeconds);
// expire 이후 남은 시간을 다시 조회해 응답 값과 Redis 상태를 맞춥니다.
ttl = await redis.ttl(redisKey);
}
return {
// count가 limit을 초과하면 더 이상 요청을 허용하지 않습니다.
allowed: count <= limit,
count,
limit,
ttl,
};
}
✔️ 로그인 요청 제한과 사용자 API 제한
같은 IP에서 60초 동안 최대 5회 로그인 요청 허용
async checkLoginLimitByIp(ip: string): Promise<RateLimitResult> {
return this.checkLimit(`login:ip:${ip}`, 5, 60);
}
같은 사용자에게 10초 동안 최대 20회 API 요청 허용
async checkApiLimitByUser(userId: number): Promise<RateLimitResult> {
return this.checkLimit(`api:user:${userId}`, 20, 10);
}
✔️ 테스트 : tests\services\rate-limit.service.test.ts

※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.