본문으로 건너뛰기

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최초 작성