본문으로 건너뛰기

Rate Limiter 서비스 구현 가이드

1. 개요

이 문서는 Redis를 기반으로 특정 작업에 대한 요청 횟수를 제한하는 RateLimiterService의 구현 가이드라인을 제공합니다. 이 서비스는 다양한 도메인에서 브루트 포스 공격 방지, API 남용 방지 등의 목적으로 재사용될 수 있는 범용 컴포넌트입니다.

주요 목표는 지정된 키(예: 사용자 ID, 디바이스 ID, IP 주소 등)에 대해 정의된 시간 동안 허용된 최대 요청 횟수를 초과하지 않도록 제어하는 것입니다.

1.1 프로젝트 구조

Rate Limiter 코어 모듈의 권장 디렉토리 구조는 다음과 같습니다:

libs/core/rate-limiter/
├── src/
│ ├── lib/
│ │ ├── rate-limiter.module.ts # NestJS 모듈 정의
│ │ ├── redis-rate-limiter.service.ts # Redis 기반 서비스 구현체
│ │ ├── rate-limiter.interface.ts # 서비스 인터페이스 및 주입 토큰
│ │ └── index.ts # lib 내보내기
│ └── index.ts # 모듈 전체 내보내기
├── test/
│ ├── redis-rate-limiter.service.spec.ts # 서비스 유닛 테스트
│ └── integration/
│ └── rate-limiter.module.spec.ts # 모듈 통합 테스트
└── README.md # 모듈 개요 및 사용법 (선택 사항)

이 구조는 다른 코어 모듈(libs/core/database, libs/core/redis 등)과의 일관성을 유지하며, 코드의 모듈성, 테스트 용이성, 유지보수성을 높입니다.

2. 핵심 기능

RateLimiterService는 다음과 같은 핵심 기능을 제공해야 합니다:

  • incrementAttempts(key: string, ttlSeconds: number): Promise<number>: 지정된 키의 시도 횟수를 1 증가시키고, 새로운 TTL(초 단위)을 설정합니다. 현재 시도 횟수를 반환합니다.
  • getAttempts(key: string): Promise<number>: 지정된 키의 현재 시도 횟수를 반환합니다. 키가 없으면 0을 반환합니다.
  • getTTL(key: string): Promise<number>: 지정된 키의 남은 TTL(초 단위)을 반환합니다. 키가 없거나 TTL이 설정되지 않았으면 -1 또는 -2를 반환합니다 (Redis 응답에 따라).
  • resetAttempts(key: string): Promise<void>: 지정된 키의 시도 횟수 카운터를 제거(초기화)합니다.
  • checkLimit(key: string, maxAttempts: number): Promise<{ allowed: boolean; remaining: number; ttl: number }>: 지정된 키가 최대 시도 횟수에 도달했는지 확인합니다. 허용 여부, 남은 횟수, 남은 TTL을 반환합니다. (선택적 편의 메서드)

3. 인터페이스 정의

// 파일 경로 예시: libs/core/rate-limiter/src/lib/rate-limiter.interface.ts
export interface RateLimiterService {
incrementAttempts(key: string, ttlSeconds: number): Promise<number>;
getAttempts(key: string): Promise<number>;
getTTL(key: string): Promise<number>;
resetAttempts(key: string): Promise<void>;
checkLimit(key: string, maxAttempts: number): Promise<{ allowed: boolean; remaining: number; ttl: number }>;
}

export const RATE_LIMITER_SERVICE = Symbol('RateLimiterService');

4. 구현 전략 (Redis 활용)

Redis의 원자적(atomic) 연산을 활용하여 구현합니다.

  • 키 명명 규칙: 서비스 사용처를 구분할 수 있는 접두사(prefix)를 사용합니다. (예: rate_limit:<purpose>:<identifier>)
    • Access Code 검증 예시: rate_limit:access_code_attempt:deviceId123
  • 횟수 증가 및 TTL 설정:
    • INCR key: 키의 값을 1 증가시킵니다. 키가 없으면 1로 초기화됩니다.
    • TTL key: 키의 현재 TTL을 확인합니다.
    • EXPIRE key ttlSeconds: 키가 처음 생성되었거나(INCR 결과가 1) TTL이 없는 경우(-1), TTL을 설정합니다. (원자성을 위해 Lua 스크립트나 Redis 트랜잭션을 사용하는 것이 더 안전할 수 있습니다.)
  • 횟수 조회: GET key (결과가 없으면 0으로 간주)
  • TTL 조회: TTL key
  • 횟수 초기화: DEL key
-- 예시: INCR + EXPIRE Lua 스크립트 (원자성 보장)
local current = redis.call('INCR', KEYS[1])
if tonumber(current) == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current

5. NestJS 모듈 설정

Redis 클라이언트 설정과 RateLimiterService 제공자를 포함하는 모듈을 구성합니다. 이 모듈은 앞서 정의한 CoreRedisModule에 의존합니다. RateLimiterService 구현체는 CoreRedisModule이 제공하는 RedisService를 주입받아 내부 Redis 클라이언트에 접근하여 작업을 수행합니다.

// 파일 경로 예시: libs/core/rate-limiter/src/lib/rate-limiter.module.ts
import { Module, Global } from '@nestjs/common';
import { CoreRedisModule, RedisService } from '@core/redis'; // 정의된 CoreRedisModule 및 RedisService 임포트
import { RedisRateLimiterService } from './redis-rate-limiter.service';
import { RATE_LIMITER_SERVICE } from './rate-limiter.interface';

@Global() // 필요에 따라 전역 모듈로 설정
@Module({
imports: [
CoreRedisModule, // RedisService를 사용하기 위해 CoreRedisModule 임포트
],
providers: [
{
provide: RATE_LIMITER_SERVICE,
useClass: RedisRateLimiterService, // 실제 구현체 지정
},
// RedisService는 CoreRedisModule에서 이미 제공 및 export 되었으므로 여기서 다시 provide할 필요 없음
],
exports: [RATE_LIMITER_SERVICE],
})
export class RateLimiterModule {}
// 파일 경로 예시: libs/core/rate-limiter/src/lib/redis-rate-limiter.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { RedisService } from '@core/redis'; // CoreRedisModule에서 export된 RedisService 주입
import { RedisClientType } from 'redis'; // 실제 사용하는 Redis 클라이언트 타입
import { RateLimiterService } from './rate-limiter.interface';

@Injectable()
export class RedisRateLimiterService implements RateLimiterService, OnModuleInit {
private readonly logger = new Logger(RedisRateLimiterService.name);
private client!: RedisClientType; // 실제 Redis 클라이언트
private incrWithExpireScriptSha!: string;

// Lua 스크립트: INCR과 EXPIRE를 원자적으로 실행
private readonly INCR_WITH_EXPIRE_SCRIPT = `
local current = redis.call('INCR', KEYS[1])
if tonumber(current) == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
`;

constructor(
private readonly redisService: RedisService // CoreRedisModule의 RedisService 주입
) {}

async onModuleInit(): Promise<void> {
try {
this.client = this.redisService.getClient(); // RedisService에서 클라이언트 가져오기
// 시작 시 Lua 스크립트 로드 (성능 향상)
this.incrWithExpireScriptSha = await this.client.scriptLoad(this.INCR_WITH_EXPIRE_SCRIPT);
this.logger.log('Rate limiter Redis client and Lua script initialized.');
} catch (error) {
this.logger.error('Failed to initialize Redis client or load Lua script for Rate Limiter', error);
// 초기화 실패 시 예외 처리 또는 상태 관리 필요
}
}

async incrementAttempts(key: string, ttlSeconds: number): Promise<number> {
if (!this.client) throw new Error('Redis client not initialized for Rate Limiter.');
try {
// 로드된 Lua 스크립트 실행 (SHA 사용)
const result = await this.client.evalSha(
this.incrWithExpireScriptSha,
{ keys: [key], arguments: [String(ttlSeconds)] }
);
return Number(result);
} catch (error: any) {
// EVALSHA 실패 시 (예: Redis 재시작으로 스크립트 소실) 원본 스크립트로 재시도
if (error.message.includes('NOSCRIPT')) {
this.logger.warn(`Lua script SHA ${this.incrWithExpireScriptSha} not found. Reloading and retrying.`);
try {
// 스크립트 다시 로드
this.incrWithExpireScriptSha = await this.client.scriptLoad(this.INCR_WITH_EXPIRE_SCRIPT);
// 원본 스크립트로 EVAL 실행
const result = await this.client.eval(
this.INCR_WITH_EXPIRE_SCRIPT,
{ keys: [key], arguments: [String(ttlSeconds)] }
);
return Number(result);
} catch (retryError) {
this.logger.error(`Failed to execute rate limiter Lua script for key ${key} after retry`, retryError);
throw retryError;
}
} else {
this.logger.error(`Failed to increment rate limit attempts for key ${key}`, error);
throw error;
}
}
}

async getAttempts(key: string): Promise<number> {
if (!this.client) throw new Error('Redis client not initialized for Rate Limiter.');
const attempts = await this.client.get(key);
return attempts ? parseInt(attempts, 10) : 0;
}

async getTTL(key: string): Promise<number> {
if (!this.client) throw new Error('Redis client not initialized for Rate Limiter.');
return this.client.ttl(key);
}

async resetAttempts(key: string): Promise<void> {
if (!this.client) throw new Error('Redis client not initialized for Rate Limiter.');
await this.client.del(key);
}

async checkLimit(key: string, maxAttempts: number): Promise<{ allowed: boolean; remaining: number; ttl: number }> {
// 이 메서드는 여러 Redis 호출을 포함하므로 엄밀히 말해 원자적이지 않음.
// 더 정확한 구현이 필요하면 Lua 스크립트 고려.
const currentAttempts = await this.getAttempts(key);
const ttl = await this.getTTL(key);
const allowed = currentAttempts < maxAttempts;
const remaining = Math.max(0, maxAttempts - currentAttempts);
return { allowed, remaining, ttl };
}
}

6. 사용 예시

// 다른 서비스에서 주입받아 사용
import { Inject, Injectable } from '@nestjs/common';
import { RATE_LIMITER_SERVICE, RateLimiterService } from '@dta/core/rate-limiter';
import { TooManyRequestsException } from '@nestjs/common'; // 예외 클래스 임포트

@Injectable()
export class SomeService {
constructor(
@Inject(RATE_LIMITER_SERVICE)
private readonly rateLimiter: RateLimiterService,
) {}

async performAction(userId: string) {
const key = `rate_limit:some_action:${userId}`;
const maxAttempts = 5;

const limitCheck = await this.rateLimiter.checkLimit(key, maxAttempts);

if (!limitCheck.allowed) {
throw new TooManyRequestsException(`Rate limit exceeded. Try again in ${limitCheck.ttl} seconds.`);
}

try {
// 실제 액션 수행
await this.doSomething();

// 성공 시 카운터 리셋 (선택 사항)
// await this.rateLimiter.resetAttempts(key);
} catch (error) {
// 실패 시 카운터 증가 및 TTL 설정
const ttl = 3600; // 1시간
await this.rateLimiter.incrementAttempts(key, ttl);
throw error; // 원본 에러 다시 throw
}
}

private async doSomething() { /* ... */ }
}

7. 오류 처리

  • Redis 연결 오류: Redis 클라이언트 라이브러리에서 제공하는 오류 처리 메커니즘을 활용합니다. 연결 실패 시 서비스가 정상적으로 동작하지 않을 수 있으므로, 필요에 따라 Fallback 로직(예: 제한 없이 허용 또는 무조건 차단) 또는 Circuit Breaker 패턴 적용을 고려합니다.
  • 타입 오류: Redis에서 가져온 값이 예상 타입(숫자)이 아닐 경우를 대비하여 타입 검사 및 변환을 수행합니다.

8. 테스트 전략

  • 유닛 테스트: RedisRateLimiterService의 각 메서드를 모의(mock) Redis 클라이언트를 사용하여 테스트합니다. 다양한 시나리오(키 없음, TTL 설정됨/안됨, 횟수 초과 등)를 검증합니다.
  • 통합 테스트: 실제 Redis 인스턴스 또는 테스트용 Redis 인스턴스에 연결하여 서비스가 예상대로 동작하는지 검증합니다. 동시에 여러 요청이 발생하는 경우의 원자성(atomicity)을 확인합니다.

9. 변경 이력

버전날짜작성자변경 내용
0.1.02025-04-22bok@weltcorp.com최초 작성