오픈소스 기여모임 9기의 참여자로 nestjs에 5개, loki, gemini-cli, Prisma에 각 1개씩 7개의 PR을 생성했고, 그 중 2개의 PR이 머지되었습니다. 그 중 Prisma에 컨트리뷰터가 된 내용을 다뤘습니다.
제가 처음으로 Prisma 생성한 이번 PR은 6.14.0에 반영된 Prisma의 퍼포먼스 개선에서 발생한 이슈를 해결한 내용으로, 퍼포먼스 개선 PR을 분석해보고, 제 PR이 이전 PR의 어떤 문제를 해결했는지의 순서로 작성되었습니다.
문제 원인: 타입 퍼포먼스 개선
6.14.0 릴리즈 노트 안에는, IDE에서 타입 추론에 대한 성능 개선의 내용도 있었습니다.
벤치마크 테스트 코드를 보면, 타입 체킹이 약 1300만회에서 1000회 수준으로 엄청난 퍼포먼스 개선이 있었습니다.
하지만 이 변경사항으로 6.12.0에 Preview로 등장한 ESM 호환 Generator를 사용할 때, 기존 처럼 PrismaClient를 사용할 수 없게 되었습니다.
PR에서, 타입 퍼포먼스 개선을 위해 변경된 주요 코드 PrismaClient 생성자 인터페이스고, 변경 사항은 다음과 같습니다.
// <=6.13.0: ClientOptions와 로그 옵션 제네릭에 기본값이 있었다.
export interface PrismaClientConstructor {
new <
ClientOptions extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
U = LogOptions<ClientOptions>,
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
>(options?: Prisma.Subset<ClientOptions, Prisma.PrismaClientOptions>):
PrismaClient<ClientOptions, U, ExtArgs>
}
// >=6.14.0: 타입 체크 성능을 높이기 위해 개선
// 1. 기존의 ClientOptions을 분리 (기존의 Options, LogOpts, OmitOpts)
// 2. ⭐️ PrismaClientOptions, LogOptions에 타입 기본값이 제거됨 ⭐️
export interface PrismaClientConstructor {
new <
Options extends Prisma.PrismaClientOptions,
LogOpts extends LogOptions<Options>,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
>(options?: Options): PrismaClient<LogOpts, OmitOpts, ExtArgs>
}
아래는, 6.14.0에 반영된 타입 퍼포먼스 개선에 대한 PR과, 개선의 이유를 정리해봤습니다.
타입 퍼포먼스 개선의 이유
1. 문제 원인
제가 Prisma의 의도를 정확히 파악할 수는 없겠으나, TypeScript의 덕 타이핑에서 문제를 찾을 수 있었습니다.
type PrismaClientOptions = {
log?: LogLevel[] | LogDefinition[]
datasourceUrl?: string
omit?: {
[ModelName: string]: {
[FieldName: string]: boolean
}
}
}
// <=6.13.0: 하나의 제네릭
type OldPrismaClient<ClientOptions extends PrismaClientOptions> = {
user: UserDelegate<ClientOptions>
post: PostDelegate<ClientOptions>
// schema.prisma에 정의한 여러 모델들.
}
type UserDelegate<ClientOptions> = {
findMany: (args?: UserFindManyArgs<ClientOptions>) => Promise<User[]>
findFirst: (args?: UserFindFirstArgs<ClientOptions>) => Promise<User | null>
create: (args: UserCreateArgs<ClientOptions>) => Promise<User>
// Prisma에서 지원하는 수많은 인터페이스들
}
type UserFindManyArgs<ClientOptions> = {
where?: UserWhereInput
select?: UserSelect<ClientOptions>
include?: UserInclude<ClientOptions>
omit?: ExtractOmit<ClientOptions, 'user'> // 재귀적으로 Omit하여 Payload 생성
}
type ExtractOmit<ClientOptions, Model> =
ClientOptions extends { omit: infer O }
? O extends { [K in Model]: infer Fields }
? Fields
: never
: never
우리는 PrismaClient 인스턴스를 생성하는 데서 끝이지만, 내부적으로 TypeScript는 거대한 하나의 제네릭 타입을 통해 아래와 같이 타입을 추론합니다. 현재 Nest를 사용하고 있기 때문에, Nest로 예를 들어보겠습니다.
일반적으로 Nest에서 PrimsaClient는 다음과 같은 방식으로 사용됩니다.
// 1. PrismaClient를 상속한 DB Connector Service (NestJS 공식문서 예제)
class PrismaService extends PrismaClient { // 상속 시 타입 체크
constructor() {
super({ log: ['query'] })
}
}
// 2.의존성 주입으로 사용.
class UserService {
constructor(private prisma: PrismaClient) {} // 파라미터 타입 체크
}
// 3. 트랜잭션을 위한 유틸리티 함수
async function withTransaction(
prisma: PrismaClient, // 파라미터 타입 체크
callback: (tx: PrismaClient) => Promise<void>
) {
}
// 4. 혹은 일반적인 타입 명시
let myClient: PrismaClient;
myClient = new PrismaClient({ log: ['query'] });
TypeScript는 덕 타이핑을 통해 타입 호환성을 체크합니다.
이 때, 하나의 거대한 제네릭인 ClientOptions가 모든 곳에 전파되어 모든 모델의 메서드가 ClientOptions에 의존하는 상황이 만들어집니다. 덕 타이핑 때문에, 모든 ClientOptions가 있는 생성된 프리즈마 코드들을 돌면서, 구조 전체를 체크해야만 했습니다.
간단하게 다시 설명해보겠습니다.
model User {
id Int @id @default(autoincrement())
name String
}
위처럼 모델이 user 하나만 있다고 가정해도, 타입 호환성의 체크는 다음과 같이 이루어질 것으로 예상됩니다.
class PrismaService extends PrismaClient {
constructor() {
super({ log: ['query'] })
}
}
// TS는 PrismaService의 인스턴스가 PrismaClient와 호환되는지 체크합니다.
// 프로퍼티, 함수 시그니처, 파라미터 등.
// 프로퍼티 체크
// PrismaService는 PrismaClient의 모든 프로퍼티를 가지는가?
// user: UserDelegate
// $connect(): Promise<void>
// $disconnect(): Promise<void>
// ... 수십 개의 프로퍼티와 메서드
// 메서드 시그니처 체크
// (args?: UserFindManyArgs<{ log: ['query'] }>) => Promise<User[]>
// ↑ ClientOptions 전체가 전파
// 메서드 파라미터의 깊은 체크
// UserFindManyArgs의 모든 프로퍼티 체크
// - where?: UserWhereInput
// - select?: UserSelect<{ log: ['query'] }>
// - include?: UserInclude<{ log: ['query'] }>
// - omit?: ExtractOmit<{ log: ['query'] }, 'user'>
// 중첩된 타입들의 재귀적 체크
// select의 각 필드, include의 관계들 등등....
// 이 과정이 모든 모델, 모든 메서드에 대해 반복됩니다.
모델이 커지면 커질수록, 모델의 필드가 많아질수록 비례해서 증가하게 되겠죠. 이는 Prisma의 벤치마크 코드에도 잘 드러나있습니다.
위에서 정리한 내용을 바탕으로, 이 문제의 원인은 ClientOptions라는 거대한 하나의 타입만 사용했기 때문입니다.
그래서 Prisma에서는 아래와 같이 타입들을 목적별로 분리한 것 같아요.
LogOpts: $on() 메서드에만 영향을 주는 로그 옵션들
OmitOpts: 모델 메서드에만 영향을 주는 옵션들
이를 통해 불필요한 타입 전파를 차단하려고 하는 시도였다고 볼 수 있을 것 같습니다. 기대 효과는, 변경된 벤치마크 코드에서도 볼 수 있다시피 엄청난 개선이 되었습니다. 위의 로그 옵션에 대한 벤치마크 코드를 다시 볼까요?
bench('log config applied', () => {
const client = new PrismaClientConstructor({
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
{ level: 'warn', emit: 'stdout' },
],
})
const passClientAround = (prisma: PrismaClient) => {
// @ts-expect-error - using a non-existent event type is a type error
prisma.$on('foobarbaz', (event) => {
console.log(event)
})
return prisma
}
const passToAnyClientAround = (prisma: PrismaClient<any>) => {
prisma.$on('info', (event) => {
console.log(event)
})
return prisma
}
client.$on('query', (event) => {
console.log(event)
})
// @ts-expect-error - info is not a valid event type because we do not pass it in the client options
client.$on('info', (event) => {
console.log(event)
})
passClientAround(client)
passToAnyClientAround(client)
}).types([697, 'instantiations']) // 1300만 > 697
타입 세분화를 통해 로그 옵션에 대한 타입 호환성을 체크하다보니, 같은 모델을 정의했다고 하더라도 약 1300만 회에서 700회 정도로 말도 안되는 수치로 감소했던 것을 볼 수 있습니다.
breaking changes 해결하기
다시 제 기여 얘기로 돌아와서, 위 변경사항 때문에 6.14.0으로 업데이트를 했을 때 PrismaClient를 사용하는 거의 모든 코드에서 빨간 줄이 등장(?)하게 되었습니다.
(6.12.0에 도입된 ESM-Generator을 사용할 때 발생하며, 기존의 js Generator은 이상 없는 것으로 확인했습니다.)
NestJS 기준의 코드 예시인데요, 어떤 프레임워크든 간에 Prisma를 사용하는 많은 개발자들이 PrismaClient에 제네릭을 명시하지 않고 사용했기 때문에 위 변경 사항은 breaking changes가 될 수 밖에 없습니다. 모든 PrismaClient관련 코드를 수정해야했으니까요. 아래처럼요.
PR 내용만 보면 별 거 없는 딱 두 줄의 추가인데요. 저는 기존 퍼포먼스 향상을 유지하면서도 많은 개발자들이 코드를 수정하지 않고도 기존 코드 그대로 사용이 가능하도록, PrismaClient에 디폴트 타입을 추가해줬습니다.
이렇게 해서, 바로 PR이 merge가 되었고, 제 PR만 단독으로 머지된 탓에, 아마 Latest Commit에 제 프로필 사진이 올라가지 않았나 싶네요.
정리
Prisma를 거의 처음 사용해보면서, 오픈소스 기여를 위해 관련 코드를 깊이 파헤쳐보고 궁금증이 생겨 Prisma가 왜 Type-Safe한 ORM인지까지 돌아봤습니다. 물론 프리즈마 엔진 코드가 생소한 Rust이고, Prisma에 익숙하지 않아 분석이 다소 완벽하지 않았네요.
단 한 줄, 두 줄의 코드 변경으로 대다수의 개발자들에게 breaking changes 없이 확장된 기능, 더 좋은 퍼포먼스를 제공할 수 있는 기여를 했다는 생각에 현재까지 기여 중에 코드 길이 대비 가장 뿌듯했던 기여 순간이었던 것 같습니다. 더불어 TypeScript와도 조금 더 친해지는 계기가 되었던 것 같아요.
오픈소스 기여는 이렇게 단 한줄의 변경으로 수 억명의 사람들에게 임팩트를 줄 수 있고, 더불어 사용하고 있는 기술에 대한 깊은 이해, 기술의 기반이 되는 더 깊이 있는 지식까지도 습득할 수 있는 좋은 기회인 것 같습니다. 앞으로도 여기저기 사용하는 기술들에 대해 관심 있게 둘러 볼 예정입니다.
오픈 소스 기여에 어려움을 겪고 계신 분들이 있다면, 인제님이 운영하시는 오픈소스 기여 모임에 참여해보시는 것은 어떨까요?
다양한 분야에서 여러 기여를 하신 운영진분들과 참여자분들과 소통하면서, 이슈 선정부터 PR 기여까지 많은 도움을 얻을 수 있습니다!
제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장히 주관적임) 이 주장의 근간에는 Type-Safe한 ORM인것이 메인일 것 같습니다. (물론 다른 장점들도 많을 것 같은데 차차 파헤쳐보죠)
이번에 오픈소스 기여를 통해 처음으로 Prisma 코드를 약간 파보았습니다.
TypeORM만 쓰고 거의 겉핥기식으로 사용했었는데, 이번 기회에 제대로 Prisma에 입문해보려고 합니다.
Prisma
Prisma는 스키마 기반 코드 생성형 ORM입니다. schema.prisma를 바탕으로 런타임 이전에 Prisma Client를 생성해야합니다.(prisma generate)
https://www.prisma.io/typescript
그래서일까요? 많은 레퍼런스들에서도 Type-Safe하다고 언급하고, 공식문서에서도 fully type-safe, zero-cost type-safe하다고 명시되어있습니다. 이쪽 진영(?)의 ORM이 익숙하지 않으신 분들을 위해 예시를 준비했습니다.
비교 대상은이름부터 엄청나게 type-safe할 것 같은 TypeORM입니다.
TypeORM vs Prisma
class UserEntity {
@PrimaryGeneratedColumn('increment')
id!: number;
@Column()
name!: string;
}
async getName(id: number) {
const user = await this.userRepostiory.findOne({
select: ['name'],
where: { id }
});
return user;
}
코드를 통해 getName()의 리턴 타입이 Pick<UserEntity, 'name'>일 것으로 예상됩니다.
하지만 TypeORM은 Entity 자체를 타입으로 추론합니다. 엉뚱하죠? 실제 동작과 타입 추론이 어긋납니다.
getName()에서 user를 할당한 후, user.id에 접근도 잘 됩니다. IDE도 올바르게 추적해주고, 경고나 에러도 발생시키지 않죠.
물론, getName을 호출하는 함수의 리턴타입을 강제하는 방법도 있습니다만, 우리는 그걸 원치 않습니다.
물론, TypeORM을 사용하면서 전역으로 타입을 래핑하는 인터페이스를 만들 수도 있습니다만, 우리는 그걸 원치 않습니다.
오픈소스를 사용하는 이유는 다양하지만, 편리하고 다양한 기능을 제공받아 사용하는 이유도 있으니까요. 결국 개발자가 직접 래핑해야 하는 번거로움이 생기죠 (귀찮아요 ㅡㅡ (?))
Prisma는 어떨까요?
model User {
id Int @id @default(autoincrement())
name String
}
class PrismaService extends PrismaClient {
async getUserName(id: number) {
return await this.user.findFirst({
where: { id },
select: {
name: true,
},
});
}
}
Prisma에서 같은 쿼리를 실행시켜보면, 결과는 정확하게 select된 프로퍼티들만 타입으로 추론합니다.
TypeORM의 타입 추론
간략한 이유는 다음과 같습니다.
TypeORM은, 테이블 모델 설계 문서가 아닌 실제 클래스 형태로 엔터티를 작성합니다. 그리고 Repository Interface 사용을 위해 TypeORM의 Repository 클래스의 제네릭으로 엔터티 타입을 명시하여 사용하죠. Repository의 각 인터페이스들은 이 Entity 제네릭 타입을 받아서 타입 추론을 하도록 구성되어있습니다. 아래처럼요.
const userRepository = new Repository<UserEntity>();
class Repository<Entity extends ObjectLiteral> {
findOne(options?: FindOneOptions<Entity>): Promise<Entity | undefined>;
}
ORM에서 FindOptions의 select의 키인 keyof Entity로 타입 추론을 해주지 않는 이상, 위에서 말한 것 처럼 개발자가 직접 타입 좁히기를 통해 type-safe하게 만들어서 사용해야합니다.
Prisma의 타입 추론
반면에 Prisma는 다르죠. Prisma는 스키마 기반 코드 생성 ORM입니다.
런타임 전에 schema.prisma를 해석해서(prisma generate) @prisma/client를 만들어 둡니다. 그래서 쿼리 작성 시점부터 타입이 이미 결정되어 있고, select/include에 따라 반환 타입이 정교하게 내로잉됩니다.
코드를 보면서 간략하게 타입을 만들어내는 파이프라인을 요약해봤습니다.
schema.prisma > Rust/WASM 파서 > DMMF JSON > DMMF Helper 클래스 > TSClient 코드 생성 > 파일 출력(@prisma/client로)
각 과정들을 조금 더 자세히 보도록 하죠.
1. prisma generate
npx prisma generate와 같은 명령어를 사용해보셨을거에요. Prisma의 타입 생성은 이 명령어로 시작됩니다.
이 명령은 CLI 내부의 Generate.ts에서 실행 흐름이 잡히고, 이후 각 generator을 초기화하고 실행시킵니다.
// packages/cli/src/Generate.ts
export class Generate implements Command {
async execute() {
// generator 등록 및 실행 흐름 담당
}
}
이 때, getGenerators 함수가 팩토리 역할을 수행하고, schema.prisma의 generator client { ... } 설정을 읽고 @prisma/client 생성기를 찾아 초기화합니다.
2. Schema 파싱 및 가공
schema.prisma를 중간 포맷인 DMMF(Data Model Meta Format)으로 파싱합니다.
이 과정은 Rust로 구현된 프리즈마 엔진의 파서를 WASM(WebAssembly)로 컴파일해서 NodeJS에서 실행합니다.
직접 wasm이 구현되어 있는 Rust코드를 당장에는 이해할 수 없어, 축약하자면 getDMMF를 통해 스키마가 해석되고, 결과는 JSON 형태의 DMMF로 변환됩니다. 이 JSON형태 위에 모델, 필드, 연관 관계등을 쉽게 탐색할 수 있도록 DMMF 클래스로 래핑되고, 모델/타입/연산을 빠르게 조회할 수 있는 Map이 만들어집니다.
3. TypeScript 코드 생성(PrismaClient)
이 과정 후에, PrismaClientTsGenerator에 의해 Prisma Client 코드를 생성하는데요.
Model: 스키마의 row에 대한 기본 스칼라가 typescript 인터페이스로 정의된 타입.
Output: 집계/그룹핑 처럼 형태가 확정적인 연산 결과는 AggregateUser, UserGroupByOutputType같은 명시적 Output 타입으로 노출돼요. row 관점의 원형 출력은 별도 파일/타입명으로 고정되어있지 않고 아래 Payload가 그 역할을 수행합니다.
Payload: 기본 정의된 Payload를 기반으로 제네릭 타입이 들어오면, 조건부/매핑 유틸리티 타입이 이를 해석해서 Payload를 즉석해서 추론합니다.
const user = await this.user.findFirst({
where: { id },
select: {
name: true,
},
});
>>
findFirst<T extends UserFindFirstArgs>(
args?: SelectSubset<T, UserFindFirstArgs<ExtArgs>>,
): Prisma__UserClient<
$Result.GetResult<
Prisma.$UserPayload<ExtArgs>, // 모델 전체의 스칼라 정의
T, // args의 타입 ( { select: { name: true }}
'findFirst',
GlobalOmitOptions
> | null,
null,
ExtArgs,
GlobalOmitOptions
>;
여기서 일어나는 타입 계산을 단계별로 풀어보면
T 캡처: args 타입인 ({ select: { name: ture } })가 그대로 T로 캡처됩니다.
유효성 검사: SelectSubset에 의해 허용되는 옵션만 통과시키도록 컴파일 타임에 필터링돼요.
반환 타입 계산: 내부의 $Result.GetResult가 $UserPayload에서 T.select를 읽어 name만 뽑아 { name: string } 객체를 만듭니다. findFirst 특성상 결과가 없을 수 있기 때문에 | null이 붙게 돼요.
결과적으로, user.id에 접근하면 IDE에서 바로 타입 에러가 발생하는 메시지가 보이게 되는거죠.
정리
사실 한 번에 뜯어보고 이해를 온전히 할 수 없었어요.
제가 많은 오픈소스를 보지는 않았지만, TS기반의 오픈소스 중 가장 해석하기 힘들었습니다. 하지만 이런 파이프라인을 통해 왜 type-safe하다는 건지 어느정도 실마리를 잡은 것 같아요. 다음엔, 이렇게 Prisma를 뜯어보게 된 원인이 되었던 Prisma 6.14.0 업데이트의 브레이킹 체인지를 해결하기 위한 기여를 했던 경험을 공유해보겠습니다.
기술 블로그를 3년 가까이 운영하면서, 언젠가는 개인 스킨을 만들어야지했는데 반쪽 짜리(Powered By Claude Code ;;) 개인 스킨을 완성했습니다.
디자인은 포스팅 시점부터 제 블로그에 이미 적용되어있는 상태이니 둘러보시고 마음에 드신다면 아래 레포의 가이드를 따라 사용해주시면 되겠습니다. 비개발자분들도 접속하셔서 코드를 ZIP으로 다운로드만 받아서 티스토리에 등록만 하면 사용이 가능합니다. 사용에 어려움이 있으신 분은 댓글 남겨주시면 답변 드리겠습니다.
Fixed Window는 일정한 간격의 윈도우로 구간을 나누고, 각 구간마다 카운터를 붙인다. 예를 들어, 1초마다 윈도우를 나누고 임계치를 3으로 설정하면, 요청이 들어올 때마다 카운터를 증가시키고, 임계치를 초과하면 요청을 거부한다.
장점
구현이 단순하다. (윈도우 시작 시간과 카운터만 관리)
메모리 사용이 매우 효율적이다.
“1초에 3번”, “1분에 100번” 같은 정책을 직관적으로 표현 가능하다.
단점
경계 구간에서 버스트가 발생할 수 있다. 예: 1분에 100회 제한인데 00:59에 100회, 01:00에 100회 요청이 오면 2초 동안 200회 처리가 필요하다.
짧은 시간 내 몰리는 트래픽을 균등하게 제어할 수 없다. 예: 같은 윈도우 안에서 동시에 100회 요청이 들어오면 모두 허용된다.
만들어보기
type FixedWindowClearConfig = {
callCount: number;
maxCount: number;
};
export interface FixedWindowConfig {
threshold: number; // 윈도우 내 허용되는 최대 요청 수
windowSizeMs: number; // 윈도우 크기 (밀리초)
clearConfig?: FixedWindowClearConfig;
}
위의 설명처럼 구현이 단순하다. 윈도우 설정에는 임계치와 윈도우 크기를 지정한다.
clearConfig는 카운팅을 통해 LRU 방식으로 데이터를 삭제하려고 만들어봤다. (Redis의 TTL처럼 구현할 계획이다.)
type FixedWindow = {
counter: number;
windowStart: number;
};
윈도우는, 특정 요청 주체의 counter와 마지막 요청 시간을 기록한다.
export class FixedWindowRateLimiter implements RateLimiter {
private windows: Map<string, FixedWindow> = new Map();
constructor(private config: FixedWindowConfig) {}
tryConsume(key: string): void {
if (
this.config.clearConfig?.callCount &&
this.config.clearConfig?.maxCount &&
this.config.clearConfig.callCount >= this.config.clearConfig.maxCount
) {
this.cleanupExpiredWindows();
this.config.clearConfig.callCount++;
}
if (!this.canConsumeRequest(key)) {
throw new Error(`Rate Limit Exceeded for key: ${key}`);
}
this.increaseCounter(key);
}
private canConsumeRequest(key: string): boolean {
let window = this.windows.get(key);
if (!window) {
window = this.createWindow(key);
}
this.initializeCounter(window);
return window.counter < this.config.threshold;
}
private increaseCounter(key: string) {
const window = this.windows.get(key);
if (!window) {
throw new Error(`Window not found for key: ${key}`);
}
window.counter++;
}
private initializeCounter(window: FixedWindow) {
const now = Date.now();
if (now - window.windowStart >= this.config.windowSizeMs) {
window.counter = 0;
window.windowStart = now;
}
}
private createWindow(key: string): FixedWindow {
const window: FixedWindow = {
counter: 0,
windowStart: Date.now(),
};
this.windows.set(key, window);
return window;
}
// TTL 기반 삭제
private cleanupExpiredWindows() {
const now = Date.now();
const ttl = this.config.windowSizeMs * 10;
for (const [key, window] of this.windows.entries()) {
if (now - window.windowStart >= ttl) {
this.windows.delete(key);
}
}
}
}
구현 또한 매우 단순하다.
요청이 들어올 때 마다 카운터를 초기화해야한다면 초기화한다. 그렇지 않다면 유지한다
카운터 설정에 설정된 임계치를 카운터가 초과했다면 429를 반환한다.
Redis TTL을 비슷하게 구현해보고자 cleanupExpiredWindows()를 clearConfig와 엮어 구현해본 것이 커스터마이징의 전부였고, 구현에 별다른 어려움은 없다.
통합 테스트
import request from "supertest";
import { createApp } from "../../app";
import { createFixedWindowMiddleware } from "../middleware";
describe("Fixed Window Rate Limiter Integration", () => {
afterEach(() => {
jest.useRealTimers();
});
it("윈도우 내의 임계치에 도달하면 429 에러가 발생한다", async () => {
const threshold = 10;
const windowSizeMs = 5000;
const rateLimiter = createFixedWindowMiddleware({
threshold,
windowSizeMs,
});
const app = createApp({
middlewares: [rateLimiter],
});
for (let i = 0; i < threshold; i++) {
const response = await request(app).get("/");
expect(response.status).toBe(200);
}
const rejectedResponse = await request(app).get("/");
expect(rejectedResponse.status).toBe(429);
expect(rejectedResponse.body.error).toBe("Too Many Requests");
});
it("다른 IP 주소는 독립적인 rate limit을 가진다", async () => {
const rateLimiter = createFixedWindowMiddleware({
threshold: 2,
windowSizeMs: 5000,
});
const app = createApp({ middlewares: [rateLimiter] });
await request(app).get("/").set("X-Forwarded-For", "1.1.1.1");
await request(app).get("/").set("X-Forwarded-For", "1.1.1.1");
await request(app).get("/").set("X-Forwarded-For", "2.2.2.2");
await request(app).get("/").set("X-Forwarded-For", "2.2.2.2");
const response1 = await request(app)
.get("/")
.set("X-Forwarded-For", "1.1.1.1");
const response2 = await request(app)
.get("/")
.set("X-Forwarded-For", "2.2.2.2");
expect(response1.status).toBe(429);
expect(response2.status).toBe(429);
});
it("동시 요청 처리 시 정확한 카운팅", async () => {
const threshold = 50;
const rateLimiter = createFixedWindowMiddleware({
threshold,
windowSizeMs: 5000,
});
const app = createApp({ middlewares: [rateLimiter] });
// 50개 동시 요청
const promises = Array(threshold)
.fill(null)
.map(() => request(app).get("/"));
const responses = await Promise.all(promises);
const successCount = responses.filter((r) => r.status === 200).length;
const failCount = responses.filter((r) => r.status === 429).length;
// 정확히 threshold만큼만 성공
expect(successCount).toBe(threshold);
expect(failCount).toBe(0);
// 추가 요청은 실패
const extraResponse = await request(app).get("/");
expect(extraResponse.status).toBe(429);
});
it("커스텀 키 생성기 사용 시 올바르게 동작", async () => {
const rateLimiter = createFixedWindowMiddleware(
{
threshold: 2,
windowSizeMs: 5000,
},
{
keyGenerator: (req) =>
req.headers["api-key"]?.toString() || "anonymous",
}
);
const app = createApp({ middlewares: [rateLimiter] });
// API 키 "key1"로 2번 요청
await request(app).get("/").set("api-key", "key1");
await request(app).get("/").set("api-key", "key1");
// API 키 "key2"로 2번 요청
await request(app).get("/").set("api-key", "key2");
await request(app).get("/").set("api-key", "key2");
// 각 키별로 임계치 확인
const response1 = await request(app).get("/").set("api-key", "key1");
const response2 = await request(app).get("/").set("api-key", "key2");
expect(response1.status).toBe(429);
expect(response2.status).toBe(429);
// anonymous 키는 별도 카운트
const anonymousResponse = await request(app).get("/");
expect(anonymousResponse.status).toBe(200);
});
it("윈도우 경계에서 burst traffic 발생 가능 (Fixed Window의 한계)", async () => {
const threshold = 10;
const windowSizeMs = 1000; // 1초
const rateLimiter = createFixedWindowMiddleware({
threshold,
windowSizeMs,
});
const app = createApp({ middlewares: [rateLimiter] });
// 실제 시간 기반 테스트
const startTime = Date.now();
// 첫 번째 윈도우에서 threshold만큼 요청
for (let i = 0; i < threshold; i++) {
const response = await request(app).get("/");
expect(response.status).toBe(200);
}
// 윈도우가 끝날 때까지 대기
const elapsedTime = Date.now() - startTime;
const remainingTime = windowSizeMs - elapsedTime + 100; // 여유 시간 추가
if (remainingTime > 0) {
await new Promise((resolve) => setTimeout(resolve, remainingTime));
}
// 새 윈도우에서 다시 threshold만큼 요청 가능 (threshold * 2 BURST)
for (let i = 0; i < threshold; i++) {
const response = await request(app).get("/");
expect(response.status).toBe(200);
}
});
it("skip 옵션 사용 시 특정 요청은 rate limit 제외", async () => {
const rateLimiter = createFixedWindowMiddleware(
{
threshold: 2,
windowSizeMs: 5000,
},
{
skip: (req) => req.path === "/health",
}
);
const app = createApp({
middlewares: [rateLimiter],
setupRoutes: (app) => {
app.get("/health", (_req, res) => res.json({ status: "ok" }));
app.get("/api/users", (_req, res) => res.json({ users: [] }));
},
});
// 일반 요청은 rate limit 적용
await request(app).get("/api/users");
await request(app).get("/api/users");
const limitedResponse = await request(app).get("/api/users");
expect(limitedResponse.status).toBe(429);
// health check는 rate limit 제외
for (let i = 0; i < 10; i++) {
const response = await request(app).get("/health");
expect(response.status).toBe(200);
}
});
it("onLimitReached 콜백이 호출된다", async () => {
let callbackCalled = false;
let limitedPath = "";
const rateLimiter = createFixedWindowMiddleware(
{
threshold: 1,
windowSizeMs: 5000,
},
{
onLimitReached: (req, res) => {
callbackCalled = true;
limitedPath = req.path;
res.status(429).json({ error: "Custom limit message" });
},
}
);
const app = createApp({ middlewares: [rateLimiter] });
await request(app).get("/test");
const response = await request(app).get("/test");
expect(callbackCalled).toBe(true);
expect(limitedPath).toBe("/test");
expect(response.status).toBe(429);
expect(response.body.error).toBe("Custom limit message");
});
it("다양한 HTTP 메서드에 대해 동일하게 작동", async () => {
const rateLimiter = createFixedWindowMiddleware({
threshold: 5,
windowSizeMs: 5000,
});
const app = createApp({ middlewares: [rateLimiter] });
// 다양한 메서드로 요청
await request(app).get("/");
await request(app).post("/");
await request(app).put("/");
await request(app).delete("/");
await request(app).patch("/");
// 임계치 도달
const response = await request(app).get("/");
expect(response.status).toBe(429);
});
});
기본적인 기능들과 더불어, 단점들에 대해서도 테스트가 통과되는 모습이다.
Sliding Window Logging
Fixed Window Counter의 가장 큰 한계는 윈도우 경계 부근 버스트를 막을 방법이 없다는 점이다. Sliding Window Logging은 이를 해결한다. 아래 설명들을 보면 알겠지만, 스코어를 저장하여 순서대로 로깅하고 조회할 수 있는 Redis ZSET을 이용하여 구현이 쉽게 가능하다.
동작 원리
요청 시 타임스탬프를 로그에 기록한다. (1, 2)
만료된 타임스탬프는 로그에서 제거한다. (3)
로그 크기가 임계치 이하이면 요청 허용, 초과하면 거부. (3, 4)
이 방식은 정적인 경계가 없으므로 언제나 임계치 이하로만 처리할 수 있다. 다만, 거부된 요청의 타임스탬프도 기록하기 때문에 Fixed Window 대비 메모리를 더 사용한다.
장점
버스트 방지에 강하다.
처리율을 안정적으로 유지할 수 있다.
단점
로그 크기만큼 메모리 사용.
요청 수가 많으면 GC/CPU 부하가 커질 수 있음(배열 filter 기반 구현 시).
만들어보기
export interface SlidingWindowConfig {
threshold: number; // 윈도우 내 허용되는 최대 요청 수
windowSizeMs: number; // 윈도우 크기 (밀리초)
}
윈도우 방식의 알고리즘들은 기본 설정은 비슷하다. 이번에도 역시 임계치와 윈도우 사이즈만 기본 설정에 넣었다.
export class SlidingWindowLoggingRateLimiter implements RateLimiter {
private timestamps: Map<string, number[]> = new Map();
constructor(private config: SlidingWindowLoggingConfig) {}
tryConsume(key: string): void {
const now = Date.now();
if (!this.canConsumeRequest(key, now)) {
throw new Error(`Rate limit exceeded`);
}
this.addTimestamp(key, now);
}
private canConsumeRequest(key: string, now: number): boolean {
let timestamps = this.timestamps.get(key);
if (!timestamps) {
timestamps = [];
this.timestamps.set(key, timestamps);
}
const windowStart = now - this.config.windowSizeMs;
// 윈도우 내의 요청만 필터링
const validTimestamps = timestamps.filter(
(timestamp) => timestamp >= windowStart
);
// 메모리 효율을 위해 오래된 타임스탬프 제거
if (validTimestamps.length !== timestamps.length) {
this.timestamps.set(key, validTimestamps);
}
return validTimestamps.length < this.config.threshold;
}
private addTimestamp(key: string, now: number): void {
const timestamps = this.timestamps.get(key);
if (!timestamps) {
throw new Error(`Timestamps not found for key: ${key}`);
}
timestamps.push(now);
}
}
순수 JS로 구현했기에 filter을 사용했지만, 매커니즘은 똑같다.
요청 시점에 타임스탬프 로그들을 적재하고 제거한다. 그리고 요청이 수행될 수 있는지를 검증하여 작업 요청을 통과시키거나 드랍시킨다.
통합 테스트
import request from "supertest";
import { createApp } from "../../app";
import { createSlidingWindowLoggingMiddleware } from "../middleware";
import { SlidingWindowLoggingConfig } from "../config";
import type { Express } from "express";
describe("SlidingWindowLogging Integration", () => {
let app: Express;
let config: SlidingWindowLoggingConfig;
beforeEach(() => {
jest.useFakeTimers();
config = {
threshold: 5,
windowSizeMs: 1000, // 1초 윈도우
};
const middleware = createSlidingWindowLoggingMiddleware(config);
app = createApp({ middlewares: [middleware] });
});
afterEach(() => {
jest.useRealTimers();
});
it("임계치까지 요청을 허용해야 한다", async () => {
const responses = [];
// 5개의 요청 모두 성공해야 함
for (let i = 0; i < 5; i++) {
const response = await request(app)
.get("/api/test")
.set("X-Forwarded-For", "192.168.1.100");
responses.push(response);
}
responses.forEach((response) => {
expect(response.status).toBe(200);
expect(response.body.message).toBe("Test endpoint");
});
});
it("임계치 초과 시 429 응답을 반환해야 한다", async () => {
// 5개의 요청 성공
for (let i = 0; i < 5; i++) {
await request(app)
.get("/api/test")
.set("X-Forwarded-For", "192.168.1.100");
}
// 6번째 요청은 429 응답
const response = await request(app)
.get("/api/test")
.set("X-Forwarded-For", "192.168.1.100");
expect(response.status).toBe(429);
expect(response.body.error).toBe("Too Many Requests");
});
it("다른 IP 주소는 독립적인 rate limit을 가져야 한다", async () => {
// IP1에 대해 5개 요청
for (let i = 0; i < 5; i++) {
await request(app)
.get("/api/test")
.set("X-Forwarded-For", "192.168.1.100");
}
// IP2는 여전히 요청 가능
const response = await request(app)
.get("/api/test")
.set("X-Forwarded-For", "192.168.1.200");
expect(response.status).toBe(200);
// IP1은 더 이상 요청 불가
const blockedResponse = await request(app)
.get("/api/test")
.set("X-Forwarded-For", "192.168.1.100");
expect(blockedResponse.status).toBe(429);
});
it("슬라이딩 윈도우가 정확하게 동작해야 한다", async () => {
const ip = "192.168.1.100";
// 0ms: 2개 요청
await request(app).get("/api/test").set("X-Forwarded-For", ip);
await request(app).get("/api/test").set("X-Forwarded-For", ip);
// 300ms: 2개 요청
jest.advanceTimersByTime(300);
await request(app).get("/api/test").set("X-Forwarded-For", ip);
await request(app).get("/api/test").set("X-Forwarded-For", ip);
// 700ms: 1개 요청 (총 5개)
jest.advanceTimersByTime(400);
await request(app).get("/api/test").set("X-Forwarded-For", ip);
// 6번째 요청은 실패
const blockedResponse = await request(app)
.get("/api/test")
.set("X-Forwarded-For", ip);
expect(blockedResponse.status).toBe(429);
// 1001ms: 처음 2개가 윈도우를 벗어남
jest.advanceTimersByTime(301);
// 이제 2개 더 요청 가능
const response1 = await request(app)
.get("/api/test")
.set("X-Forwarded-For", ip);
const response2 = await request(app)
.get("/api/test")
.set("X-Forwarded-For", ip);
expect(response1.status).toBe(200);
expect(response2.status).toBe(200);
// 다시 임계치에 도달
const finalBlockedResponse = await request(app)
.get("/api/test")
.set("X-Forwarded-For", ip);
expect(finalBlockedResponse.status).toBe(429);
});
it("동시 요청을 정확히 처리해야 한다", async () => {
const ip = "192.168.1.100";
// 6개의 동시 요청
const promises = Array(6)
.fill(null)
.map(() =>
request(app)
.get("/api/test")
.set("X-Forwarded-For", ip)
.catch((err) => err.response)
);
const responses = await Promise.all(promises);
// 5개는 성공, 1개는 실패
const successCount = responses.filter((r) => r.status === 200).length;
const failCount = responses.filter((r) => r.status === 429).length;
expect(successCount).toBe(5);
expect(failCount).toBe(1);
});
it("커스텀 키 생성기를 사용할 수 있어야 한다", async () => {
const customMiddleware = createSlidingWindowLoggingMiddleware(
config,
{
keyGenerator: (req: any) => req.headers["api-key"] || "anonymous",
}
);
const customApp = createApp({ middlewares: [customMiddleware] });
// 같은 API 키로 5개 요청
for (let i = 0; i < 5; i++) {
await request(customApp).get("/api/test").set("api-key", "user-123");
}
// 6번째 요청은 실패
const blockedResponse = await request(customApp)
.get("/api/test")
.set("api-key", "user-123");
expect(blockedResponse.status).toBe(429);
// 다른 API 키는 성공
const differentKeyResponse = await request(customApp)
.get("/api/test")
.set("api-key", "user-456");
expect(differentKeyResponse.status).toBe(200);
});
it("skip 옵션으로 특정 요청을 제외할 수 있어야 한다", async () => {
const skipMiddleware = createSlidingWindowLoggingMiddleware(
config,
{
skip: (req: any) => req.headers["skip-rate-limit"] === "true",
}
);
const skipApp = createApp({ middlewares: [skipMiddleware] });
const ip = "192.168.1.100";
// 5개의 일반 요청
for (let i = 0; i < 5; i++) {
await request(skipApp).get("/api/test").set("X-Forwarded-For", ip);
}
// skip 헤더가 있는 요청은 rate limit 무시
const skipResponse = await request(skipApp)
.get("/api/test")
.set("X-Forwarded-For", ip)
.set("skip-rate-limit", "true");
expect(skipResponse.status).toBe(200);
// skip 헤더가 없는 요청은 여전히 차단
const blockedResponse = await request(skipApp)
.get("/api/test")
.set("X-Forwarded-For", ip);
expect(blockedResponse.status).toBe(429);
});
it("onLimitReached 콜백이 호출되어야 한다", async () => {
const onLimitReached = jest.fn((_req, res) => {
res.status(429).json({ error: "Too Many Requests" });
});
const callbackMiddleware = createSlidingWindowLoggingMiddleware(
config,
{
onLimitReached,
}
);
const callbackApp = createApp({ middlewares: [callbackMiddleware] });
const ip = "192.168.1.100";
// 5개의 요청
for (let i = 0; i < 5; i++) {
await request(callbackApp).get("/api/test").set("X-Forwarded-For", ip);
}
expect(onLimitReached).not.toHaveBeenCalled();
// 6번째 요청 시 콜백 호출
await request(callbackApp).get("/api/test").set("X-Forwarded-For", ip);
expect(onLimitReached).toHaveBeenCalledTimes(1);
expect(onLimitReached).toHaveBeenCalledWith(
expect.objectContaining({
ip: ip,
}),
expect.any(Object)
);
});
it("다양한 HTTP 메서드를 지원해야 한다", async () => {
const ip = "192.168.1.100";
// 다양한 메서드로 요청
await request(app).get("/api/test").set("X-Forwarded-For", ip);
await request(app).post("/api/test").set("X-Forwarded-For", ip);
await request(app).put("/api/test").set("X-Forwarded-For", ip);
await request(app).delete("/api/test").set("X-Forwarded-For", ip);
await request(app).patch("/api/test").set("X-Forwarded-For", ip);
// 6번째 요청은 메서드와 관계없이 차단
const response = await request(app)
.get("/api/test")
.set("X-Forwarded-For", ip);
expect(response.status).toBe(429);
});
it("Fixed Window와 달리 윈도우 경계에서 버스트가 발생하지 않아야 한다", async () => {
const ip = "192.168.1.100";
// 윈도우 끝 부분에서 5개 요청
jest.advanceTimersByTime(900); // 900ms 시점
for (let i = 0; i < 5; i++) {
await request(app).get("/api/test").set("X-Forwarded-For", ip);
}
// 100ms 후 (새 윈도우 시작)
jest.advanceTimersByTime(100);
// Fixed Window와 달리 여전히 5개가 윈도우 내에 있음
const response = await request(app)
.get("/api/test")
.set("X-Forwarded-For", ip);
expect(response.status).toBe(429); // 여전히 차단됨
// 901ms 더 지나야 첫 요청이 윈도우를 벗어남 (총 1001ms)
jest.advanceTimersByTime(901);
// 이제 요청 가능
const allowedResponse = await request(app)
.get("/api/test")
.set("X-Forwarded-For", ip);
expect(allowedResponse.status).toBe(200);
});
});
Sliding Window Counter
Sliding Window Counter는 Fixed Window Counter와 Sliding Window Logging의 절충안이다. 현재 윈도우와 직전 윈도우의 카운트만 저장해 가중 평균으로 요청률을 추정한다.
계산 공식
추정 요청 수 = 현재 윈도우 카운트 + (직전 윈도우 카운트 × 겹치는 비율)
현재 윈도우 카운트: 현재 윈도우 내 요청 수
직전 윈도우 카운트: 이전 윈도우 내 요청 수
겹치는 비율: 현재 시점에서 이전 윈도우가 겹치는 비율
예시
위 그림은 윈도우 크기가 1000ms, 임계치가 10이며 이전 윈도우에서 요청을 8개 처리했고, 현재 윈도우에서 1500ms에서 요청이 3개 들어온 상황이다.
현재 윈도우의 요청 카운터: 3
직전 윈도우의 요청 카운터: 8
직전 윈도우와의 겹치는 비율: 0.5 (1500ms에서 요청이 왔기 때문에, 딱 절반에 해당함)
3 + 8 x 0.5 = 7이므로 1500ms에서는 3개의 요청을 더 허용할 수 있다.
장점
키당 두 개의 숫자만 저장 → 메모리 효율적.
경계 부근에서도 부드럽게 제한 적용 → Fixed Window의 버스트 문제 해결.
단점
근사치 기반이라 100% 정확하진 않음. (직전 시간대에 도착한 요청에 대한 계산은 전혀 수행하지 않음)
사이드 프로젝트에서 AWS에 100달러 이상의 과금이 된 적이 있다. 이 때 처음으로 Rate Limit을 도입했고, A/B 테스트를 통해 적절한 임계치를 찾았던 기억이 있다. 최근, 가상 면접 사례로 배우는 대규모 시스템 설계 기초 를 다시 읽으면서, Rate Limit에 대해 깊이 있게 정리해놓지 않았다는 것을 깨닫고, 정리하는 글이 되시겠다(?)
만약, MSA 환경이라면 Rate Limiter은 보통 API Gateway에 구현된다. 클라우드 서비스의 API Gateway는 사용자 인증, whitelist 관리, SSL termination 등을 지원하기 때문에 추가하기만 하면 되고, 커스터마이징한 API Gateway일 경우에도, 기존의 다른 Gateway의 미들웨어들처럼 추가해서 운영하면 된다.
Rate Limiter을 서버에 두겠다고 선택했다면, 사용중인 프로그래밍 언어의 효율성을 따져보아야 한다.
우선, Rate Limit은 모든 요청마다 실시간으로 실행되기 때문에 극도로 빠르게 동작하여 즉시 consume될 수 있는지 따져야한다. 언어가 느리면 요청 하나하나에 병목이 발생되어 전체 시스템의 성능이 저하되기 쉽다.
수백만 개의 클라이언트 IP, 사용자별 카운터를 메모리에 유지해야하기 때문에 GC의 stop-the-world 시간이 긴 언어는 요청 지연이 생길 수 있다.
수천 개의 동시 요청을 처리하며 카운터를 원자적으로 업데이트해야 하는데, 이 때 효율적인 락 매커니즘과 동시성을 제어할 수 있어야 한다.
만약 위 내용에 적합하지 않은 인터프리터 기반의 싱글스레드 언어인 Python, Ruby등의 언어를 사용하고 있다면
Redis 등의 외부 인메모리 저장소를 활용한 분산 Rate Limiting 구조를 고려해야할 수도 있다.
Envoy, Nginx와 같은 리버스 프록시 기반 Throttling 방식이 더 적합할 수도 있다.
Race Condition에 주의하라
만약, 분산 환경에서 Rate Limiter을 구축했다면, 동시성을 제어하기 위해 Redis와 같은 외부 인메모리 저장소를 활용하여 처리율을 체크하고 있을 것이라 생각한다. Rate Limit도 마찬가지로 분산 환경에서 공통으로 유의해야 할 Race Condition을 고려해야한다. Redis를 사용한다고 가정하고, Race Condition 문제를 어떻게 해결해야할지 간단히 살펴보자.
Race Condition
Rate Limit은 보통 다음 흐름으로 동작한다.
Redis에서 현재 카운터 값을 조회한다.
요청이 임계값 이하인지 확인한다.
조건을 만족하면 카운터를 증가시킨다.
이 흐름은 매우 간단하지만, 분산 환경에서 동시 요청이 많아질수록 Race Condition은 더 자주 발생한다.예를 들어 동시에 두 요청이 Redis에서 카운터를 읽었을 때, 둘 다 조건을 통과하여 값을 증가시킨다면 실제 카운터는 limit을 초과하게 된다. 이는 Rate Limit이 무력화될 수 있다는 의미이다.
위 그림은, 요청 1이 처리되기 이전에 동시 요청된 요청 2가 같이 수행되었다. 여기에는 여러 문제가 있다.
counter은 11이 되어야한다.
max_count는 10이기 때문에 애초에 요청 2는 처리되었으면 안된다.
Race Condition을 해결하기 위해 일반적으로 Lock을 사용할 수 있다. 하지만 Lock이라는 매커니즘은 읽거나 쓰는 도중에 다른 요청은 대기하는 방식이기 때문에 시스템의 성능을 떨어뜨린다는 문제가 있다. 만약 위 예제처럼 Redis를 사용하는 상황이라면 Lua Script를 사용하거나, SortedSet 자료구조를 사용해서 해결할 수 있다.
Lua Script를 통한 Rate Limit 구현
const luaScript = `
local current = redis.call("GET", KEYS[1])
if current and tonumber(current) >= tonumber(ARGV[1]) then
return 0
else
current = redis.call("INCR", KEYS[1])
if tonumber(current) == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 1
end
`
const result = await redis.eval(luaScript, 1, `rate_limit:user:${userId}`, maxCount, ttlMs);
if (result === 0) {
throw new TooManyRequestsException();
}
Lua Script는 Redis에서 원자적으로 실행되기 때문에 여러 명령을 하나의 트랜잭션처럼 묶어서 실행할 수 있다. 이 때문에 Race Condition으로부터 안전하게 실행될 수 있다.
Lua Script는 실행 중 다른 Redis의 명령을 대기시킨다. 만약 스크립트가 너무 복잡하거나 오래 걸리는 경우, 무한 루프에 빠지는 경우 등을 조심해야한다. 간결하게 작성하여 Redis 처리 성능에 최대한 영향을 끼쳐서는 안된다.
SortedSet을 통한 Rate Limit 구현
Lua Script말고도, SotredSet(ZSET)을 활용한 방법도 있다. 보통 Sliding Window 방식을 ZSET으로 구현할 수 있다.
ZADD로 요청 추가
ZREMRANGEBYSCORE로 현재 시간에서 TTL만큼 지난 요청을 제거
ZCARD로 남아있는 요청 수 계산
limit보다 작으면 허용, 아니라면 차단
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count >= limit then
return 0
end
redis.call("ZADD", key, now, now .. "-" .. math.random())
redis.call("PEXPIRE", key, window)
return 1
const now = Date.now();
const result = await redis.eval(luaScript, 1, `rate_limit:user:${userId}`, now, 60000, 10);
ZSET을 이용한 방식의 장점으로는
score를 기록하기 때문에 정확한 시간 단위로 요청 개수를 제한할 수 있다.
시간 경계의 쏠림 문제가 없다. (Fixed Window의 단점을 보완)
Redis 연산이 원자적이기때문에 Race Condition에 안전하다. (Lua 사용 시)
하지만, 이 방식의 단점도 있다. TTL Window 내의 모든 요청 타임스탬프를 score에 저장하기 때문에 메모리 사용량이 높다. 그리고 기본적인 Redis 연산이 많기 때문에 요청이 많을수록 Redis 부하가 커질 수도 있겠다.
Rate Limiter가 사용하는 HTTP 헤더
Rate Limiter을 사용할 때, 다음의 HTTP 헤더를 클라이언트에게 보내야한다.
X-Ratelimit-Limit: 가능한 총 요청의 수
X-Ratelimit-Remaning: 남은 처리 가능 요청 수
X-Ratelimit-Retry-After: 몇 초 뒤에 다시 요청을 해야하는지
아직 규정되지는 않았기 때문에 헤더의 이름 자체를 조금 수정하는 것은 괜찮을지 모르겠으나, 헤더를 사용하여 클라이언트에게 Rate Limit 관련 정보를 보내는 것은 IETF의 권장사항이다. 또한 사용자가 너무 많은 요청을 보내면 429(Too Many Request) 오류를 X-Ratelimit-Retry-After 헤더와 함께 반환하여야 한다.
돈 쓸 일이 많아서, 최대한 Gemini CLI를 주로 이용하고 GPT 4o를 겜스고에서 결제해서 사용하고 있었는데
개발자들 사이에서 Claude Code가 넘사라는 이야기를 링크드인, X, 유튜브 등의 커뮤니티에서 너무 많이 듣다보니 도저히 안되겠다 싶어서 Claude Code를 결제해서 한 달 사용해봤다.
(겜스고는 클로드 코드 맥스 1인 구독권을 팔아라!!! 제발!!!)
강아지 자랑
처음에는 100달러, 200달러라는 요금이 두 강아지를 키우는 가장(?)에게 너무 비싼 것 같아서 소소하게 Pro 요금제를 결제해서 사용했는데, 문제는 20달러로는 금방 토큰 사용량을 초과해버려서, 갈증이 났다.
큰 맘 먹고 Max 100 달러짜리로 사용해봤다.
초라하기 짝이 없는 성적표(?)를 받았다. (커피한잔 앱으로 유명한 제호님의 한 달 사용 후기 포스팅에 13000달러를 사용하신 분이 ㄷㄷ)
한 달 동안, 현업에서도 사용하고 이래저래 많은 곳에서 사용해보았다.
링크를 저장하는 Shared Extensions 기반 Android 앱 (심사중)
소설 작가를 위한 글쓰기 웹 플랫폼 만들기 (글쓰기 + 히스토리 관리)
현업에서 사용 (코드 컨벤션을 엄격하게 만들어 놓고, 테스트 코드나 기능 확장 등에 주로 사용)
오픈 소스 기여 (이슈를 선정하면, 자동으로 워크 플로우를 정의 해줄 수 있도록 구성하여 사용)
남들이 보기에 많은 사용량은 아닐지 모르겠지만, 서비스를 두 개 만들었고, 현업에서도 거의 10배 정도의 생산성이 향상되었고 거의 단순 작업에는 코드 작성보다 검수하는 일이 더 잦아졌다. 오픈 소스 기여는 아직 할루시네이션이 있는 것 같아서, 절대 신뢰하지는 않지만 교차 검증을 계속 하다보니 기여 속도가 빨라진 것을 체감하고 있다.
앞으로도 더 좋은 AI들이 나오겠지만, 여태 사용했던 AI들 중 가장 만족감이 높은 것 같다.
많은 AI를 사용해보지는 않았지만 개인적으로 처음 GPT 4.0을 사용했을 때의 충격보다 더 크게 다가오는 것 같다.
그래서!!!
이사도 끝냈고, 바쁜 일정들도 다 끝났으니 200달러 짜리 결제해서 일을 더 시켜야겠다 ㅋㅋㅋㅋ 일해라 노예야!!
공유기를 사용하는 집에서 스타크래프트, 워크래프트 같은 게임을 했던 사람이라면, 방능이라는 단어를 한 번씩 들어봤을거라 생각한다. 내가 만든 게임 방에 다른 사람이 들어올 수 있는 상태를 뜻하는 말로, 그 시절에는 방능이 안되면 포트포워딩을 해야했고, 네이버를 뒤져서 방법을 찾아보면 공유기에서 설정할 수 있다는 말을 듣고 마구잡이로 따라했었다.
최근 스타크래프트 영상을 몇 개 보다보니 갑자기 그 시절이 생각났다. 그 때는 단순 따라하기만했던 블로그의 설정들을 개발자가 된 지금 왜 공유기 설정이 필요했는지, 게임을 호스팅한다는 게 무슨 의미인지 정리해볼 수 있었다.
방장이 호스트인 P2P 통신
스타크래프트, 워크래프트는 P2P 방식이다. 서버가 방을 관리하는 것이 아닌, 내가 직접 방장이자 서버가 되는 구조이다.
요즘 게임들 중에서는 대표적으로 콜 오브 듀티가 P2P 방식인 것으로 잘 알려져 있다.
게임 서버 = 나
P2P는 게임의 메인 서버를 거치지 않고 임의의 사용자를 서버로 만들어버린다. 이런 방식은 개발사에서 서버 비용을 절감할 수 있는 장점이 있지만, 서버로 선정된 유저의 IP가 노출되어 보안에 취약할 수 있고, 다른 참여자 플레이어들보다 더 유리한 환경에서 게임을 할 수 있다. 소위핑이 튀지 않는다 는 말이다.
왜 아무도 못들어올까?
"나는 인터넷도 잘 되는데? 왜 못들어오는거지?"
나는 게임 접속도 잘 되고, 인터넷도 잘 된다. 그런데 왜 다른 사용자는 접속하지 못할까?
이런 상황에서의 문제는 대부분 공유기 환경과 NAT 때문이다. 우리가 집에서 사용하는 공유기는 내부에 여러 장비를 연결하고, 이들을 하나의 Public IP로 인터넷에 연결해주는 역할 을 한다. 즉, 모든 트래픽은 공유기를 통과 한다.
내가 방을 만들면,
다른 유저는 내 PC의 IP:PORT가 아닌 공유기의 Public IP:PORT로 접속을 시도한다.
NAT(Network Address Translation) 내부의 Private IP를 외부 인터넷에 통용되는 Public IP로 변환해주는 기술로 하나의 Public IP로 여러대의 PC가 동시에 인터넷을 사용할 수 있다. 외부에서 내 PC에 요청을 보낼 때는, 외부의 라우팅 테이블에 등록되어 있는 내 공유기의 Public IP를 사용하게 된다.
이러한 NAT 구조의 이점을 간략하게 언급하자면
IPv4 주소의 절약: 기기마다 Public IP를 줄 수 없을 만큼 IPv4 주소는 부족하다. 위 그림처럼 NAT를 통해 다수의 Private IP 대역을 Public IP로 묶을 수 있다면, 수십 대의 기기를 Public IP 하나로 인터넷에 연결할 수 있다.
보안 이점: Private IP 대역은 외부에서 바로 접근할 수 없다. 기본적인 방화벽 역할을 한다.
내부망 관리 편의성: 같은 Private IP 대역 안에서는 기기 간 통신이 자유롭다.
그래서 왜 방에 못들어가는데?
우리는 방능자가 아닌 방을 들어가려 할 때 무한 로딩이 걸리다가 결국 방에 입장할 수 없었다. 다시 아래 그림을 보자.
게임 방에 입장하고자 하는 유저는 Public IP:6112로 접속을 시도한다. 그러나 NAT에는 이 포트가 어느 내부 IP:PORT에 매핑되어야 하는지에 대한 정보가 없다. NAT 구조에서는 외부에서 먼저 시작된 연결 요청에 대해 사전 정의된 포트 매핑이 없으면, 공유기는 해당 요청을 내부로 전달하지 않고 조용히 버린다(DROP). 이 때 라우터는 내부 네트워크를 스캔하거나 브로드캐스트하지 않으며 연결은 실패로 끝난다.
그래서 우리는 구글링을 통해 공유기 방능 방법을 찾아보고 포스팅의 내용대로 포트포워딩 설정을 하게 된다.
포트포워딩
포트포워딩이란라우터가 특정 포트로 들어온 요청을 특정 내부 IP:PORT로 연결해주는 규칙을 등록하는 것이라고 표현하겠다. 위 그림처럼 포트포워딩 설정을 등록했을 때, 비로소 공유기를 통해 내부 PC까지 요청이 도달할 수 있다.
부록 - DMZ 설정
방능 설정을 검색하면 DMZ 설정이 간편하다는 내용들이 많다. DMZ도 동일한 문제를 해결하는 하나의 방법이다.
DMZ는 우리에겐 아프지만 친숙한 비무장지대처럼 네트워크에서도 비슷한 의미로 사용된다. 외부 네트워크와 내부 네트워크의 중간 지점으로, DMZ 설정을 통해 특정 IP를 지정할 수 있다. DMZ 설정을 하게 되면 모든 포트를 하나의 내부 IP로 전달하는게 가능해져 편리하게 Private IP를 등록하는 것 만으로 방능이 가능해진다.
하지만 모든 요청을 다 받을 수 있기 때문에, 다른 프로세스도 외부에서 접근이 가능해진다. 극단적인 예시를 들면, 누군가가 내 카카오톡에 접근도 가능하다는 뜻이 되겠다. 그래서 일반적으로는 포트포워딩으로 필요한 포트만 열어두는 것이 더 안전하다.
마무리
어릴 때는 그저 "방능이 안돼!"라는 말만 반복하며 공유기 설정을 따라 했지만, 지금은 그 이면에 있는 NAT 구조와 포트포워딩 개념을 이해하게 되었다. 단순히 게임만 즐기던 시절과는 다르게, 이제는 내가 만든 방이 곧 하나의 서버가 된다는 사실을 인지하게 되었고, 외부 요청이 공유기에서 막히는 구조 또한 명확히 설명할 수 있게 됐다. 그 과정에서 포트포워딩이라는 설정이 왜 필요한지, 그리고 어떻게 작동하는지도 스스로 설명할 수 있다.
그냥 게임을 하던 시절엔 몰랐던 걸, 개발자가 된 지금 다시 돌아보니 더 재밌고, 더 깊이 이해된다. 특히, 단순 CS 지식만 학습해오던 걸 실제 사례들에 적용해보면서 이런 원리였구나!! 라는 생각을 하면서 바라보게 되니 더 머리 속에도 잘 남고 무엇보다도 재밌다. 앞으로도 이런 실제 경험이나 사례를 CS에 녹여내는 방향으로도 포스팅을 해봐야겠다.
여기까지 오셨다면, 뭔가 이상함을 느꼈을 수도 있습니다. 테스트 환경에서는 TestingModule의 createNestApplication 함수는 NestApplication을 초기화해주지 않습니다. 그래서 개발자가 직접 create > use > init 과정을 모두 호출하여 테스트에 필요한 애플리케이션을 생성하게 됩니다.
제가 감히 추측해보자면, 테스트 환경에서는 초기화 전 상태를 테스트하거나 모킹 등 더 세밀한 제어들이 필요하기 때문에 테스트 환경과 운영 환경의 애플리케이션 초기화 방식이 다를 것이라 생각합니다.
다시 돌아와서, 문제는 하나 더 발생합니다. express에서는 이 순서를 지키지 않아도, 테스트 애플리케이션의 초기화 과정에서 에러가 발생하지 않습니다. 하지만 fastify에서는 미들웨어를 등록하는 구간에서 에러가 발생합니다.
express에서는 모든 것이 use를 통해 사용되죠. 이는 미들웨어 뿐 아니라 라우트 등도 모두 동일합니다. 하지만 fastify는 공식 문서에서도 나와있듯이 3.0.0버전 이후부터 @fastify/express, @fastify/middie등의 플러그인을 통해 미들웨어를 주입하도록 되어있습니다.
// FastifyAdapter
public async init() {
if (this.isMiddieRegistered) {
return;
}
await this.registerMiddie();
// Register any pending middlewares that were added before init
if (this.pendingMiddlewares.length > 0) {
for (const { args } of this.pendingMiddlewares) {
(this.instance.use as any)(...args);
}
this.pendingMiddlewares = [];
}
}
private async registerMiddie() {
this.isMiddieRegistered = true;
await this.register(
import('@fastify/middie') as Parameters<TInstance['register']>[0],
);
}
nest는 fastify를 초기화하는 시점에, @fastify/middie 플러그인을 등록하도록 되어있습니다. 이런 이유 때문에, fastify는 정확한 실행 순서를 지키지 않으면 FastifyAdapter에 미들웨어를 등록하지 못하고 에러가 발생하게 됩니다.
일관된 테스트를 위한 기여
처음에는, 단순하게 테스트 환경에서 자동으로 어댑터를 초기화 해보기로 했습니다.
// TestingModule.createNestApplication()
// 첫 번째 시도: TestingModule에 auto-init 로직 추가
if (typeof (httpAdapter as any)?.init === 'function') {
const originalInit = (proxy as any).init;
(proxy as any).init = async function (this: any) {
await (httpAdapter as any).init(); // 어댑터 자동 초기화
if (originalInit) {
return originalInit.call(this);
}
return this;
};
}
하지만 이 방식의 문제는 NestApplication 생성은 동기 함수이고, FastifyAdapter의 init은 비동기 함수이기 때문에, 근본적으로 돌아가지 않습니다. 미들웨어 등록 시점에는 여전히 플러그인 등록이 Pending 상태일 수도 있기 때문이죠. 이러한 프록시 래핑으로는 실제 미들웨어 타이밍을 제어할 수 없었습니다.
위 문제를 해결하기 위해 생각하다, 스스로 내린 결론은 애플리케이션 생성 함수를 비동기함수로 추가하는 방향이었습니다. 그러다보니, 우선 개발자에게 명확한 에러 메시지를 유도하고, PR을 마무리한 후 새로운 이슈를 제기하려고 했습니다. use를 오버로딩해서, 에러메시지만 제공하는 방향으로 다시 커밋을 올렸어요.
// FastifyAdapter
public use(...args: any[]) {
if (!this.isMiddieRegistered) {
Logger.warn(
'Middleware registration requires the "@fastify/middie" plugin to be registered first. ' +
'Make sure to call app.init() before registering middleware with the Fastify adapter. ' +
'See https://github.com/nestjs/nest/issues/15310 for more details.',
FastifyAdapter.name,
);
throw new TypeError('this.instance.use is not a function');
}
return super.use(...args);
}
하지만 우리의(?) 카밀 아저씨(?)가 좋은 아이디어를 제시해줬습니다. 메인테이너가 제안해준 의견 덕분에 미들웨어를 큐에 캐싱해두고 지연등록하는 FastifyAdapter의 미들웨어 등록 시스템 기능을 만들 수 있었습니다.
// FastifyAdapter
private pendingMiddlewares: Array<{ args: any[] }> = [];
public async init() {
if (this.isMiddieRegistered) {
return;
}
await this.registerMiddie();
// Register any pending middlewares that were added before init
if (this.pendingMiddlewares.length > 0) {
for (const { args } of this.pendingMiddlewares) {
(this.instance.use as any)(...args);
}
this.pendingMiddlewares = [];
}
}
public use(...args: any[]) {
// Fastify requires @fastify/middie plugin to be registered before middleware can be used.
// If middie is not registered yet, we queue the middleware and register it later during init.
if (!this.isMiddieRegistered) {
this.pendingMiddlewares.push({ args });
return this;
}
return (this.instance.use as any)(...args);
}
마치며...
글을 정리하다보니 저 혼자 오픈소스의 은사(?)라고 생각하는 김인제님께서 말씀하시던 게 생각납니다.
"오픈소스 기여로 수억명에게 임팩트 만들기"
실제로 몇 명이나 될 지는 모르겠지만, express와 fastify 모두 app.use() 호출 타이밍에 제약이 없어졌기 때문에 동일한 코드 패턴으로 미들웨어 등록이 가능해졌습니다. 서버 프레임워크의 변환 시 조금이나마 코드 변경을 줄일 수 있지 않을까요? DX 향상에 미약하나마 도움이 되는 기여였으면 좋겠습니다.
항상 기여하면서 느끼는거지만, 오픈소스 자체에 깊은 이해와 더불어 오픈소스 구현에 사용된 다른 프레임워크나 그 근간이 되는 개념들을 자연스레 학습할 수 있게 되고, 더 깊게 이해할 수 있는 것 같습니다. 오픈소스의 진정한 가치는 이런 데서 나오는 게 아닌가 싶습니다.