저는 현업에서 혼자 개발하는 환경에 있다 보니, '내가 잘하고 있는 게 맞을까?' 하는 기술적인 갈증이 늘 있었습니다. 그런 저에게 오픈소스는 코드를 통해 전 세계 누구와도 소통할 수 있는 가장 완벽한 소통의 장이자 탈출구가 되어주었습니다. 최근, 오픈소스 기여 모임 9기에서 제가 오픈소스에 기여하는 과정에 대해 '오픈소스 기여로 레벨업'이라는 주제로 발표하는 기회를 가졌습니다.
발표에서 전하려 했던 것
저는 이 발표를 통해, 단순히 코드를 기여한 경험을 넘어, 다음과 같은 저의 고민과 과정을 전달하고 싶었습니다.
1. 기술적 깊이를 더하기
하나의 이슈를 해결하는 것을 넘어, '왜 이 코드가 이렇게 작성되었을까?'라는 질문을 통해 코드의 숨은 의도, 설계 철학등의 새로운 기술적 관점까지 얻어가는 과정을 공유하고 싶었습니다.
Prisma의 Breaking Change를 해결하는 과정에서 '타입 퍼포먼스'라는 새로운 관점을 얻고 거대한 오픈소스가 감수하는 기술적 트레이드오프에 대해 깊이 고민해볼 수 있었습니다.
2. 주도적으로 가치를 만드는 경험
주어진 이슈를 넘어, 직접 이슈를 찾고 개선하며 주도적으로 가치를 만드는 경험을 나누고자 했습니다.
Gemini-CLI의 '첫 실행 시점'처럼 모든 사용자에게 영향을 줄 수 있는 지점을 공략해 실행 과정에서의 문제를 어떻게 발견하고 해결했는지에 대해 공유했습니다.
3. AI 활용
이 모든 과정에서 DeepWiki와 같은 AI 도구를 활용해 방대한 오픈소스 코드를 효율적으로 분석하고 학습한 저만의 노하우를 담아보려 했습니다.
마치며
소심한 성격 탓에 망설임도 많았지만, 발표라는 새로운 시도를 할 수 있도록 기회를 주신 오픈소스 기여 모임의 모든 참여자분들께 진심으로 감사드립니다.
처음 준비하는 발표였기에, 제가 의도했던 이런 고민과 과정이 잘 전달되었을지는 모르겠습니다.
많이 떨리고 부족했지만, 끝까지 귀 기울여 들어주신 모든 분들 덕분에 무사히 마칠 수 있었습니다. 정말 감사합니다.
최근 개발 환경에서 Claude Code와 같은 AI 도구는 선택이 아닌 필수가 되어가고 있습니다. 프로젝트 전체 컨텍스트를 이해하고 코드를 생성해주는 능력은 정말 강력하죠. 하지만 이런 강력함 뒤에는 종종 예기치 못한 문제가 따릅니다. 코딩에 한창 몰입하고 있는데 갑자기 IDE나 터미널이 멈추거나 꺼져버리는 현상(OOM, Out of Memory), 혹은 AI가 대화의 흐름을 잃고 일관성 없는 답변을 내놓는 할루시네이션을 경험해보셨나요?
이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션을 두 달 남짓 몸소 겪고, 직접 레퍼런스들을 뒤져보며 어느정도 개선점을 찾았던 주니어 개발자의 클로드 코드 사용법을 정리하려고 합니다.
(현 시점에서, 공식 문서에 정확히 기술되어있는 내용들을 바탕으로 유추한 내용도 있습니다.)
이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션이라는 세 마리 토끼를 한 번에 잡을 수 있는 메모리 최적화 사용법에 대해 이야기해보려 합니다.
왜 메모리 최적화가 필요한가요?
본격적인 방법에 앞서, 우리가 왜 Claude의 메모리를 신경 써야 하는지 정리해볼 필요가 있습니다.
OOM(Out of Memory)으로 인한 프로세스 종료: Claude Code의 대화 세션은 단일 프로세스로 동작합니다. 즉, 대화가 길어질수록 주고받은 모든 토큰이 메모리에 누적되어 시스템의 한계를 초과하면 IDE나 터미널이 강제 종료될 수 있습니다.
의도치 않은 AutoCompact와 할루시네이션: Claude에는 메모리가 부족해지면 자동으로 대화를 요약하는 AutoCompact 기능이 있습니다. 편리해 보이지만, 이 기능이 내가 원치 않는 시점에 작동하면 중요한 컨텍스트가 소실되어 AI가 엉뚱한 답변을 하는 원인이 되기도 합니다.
비용 절약: 결국 API 사용량은 입출력(I/O) 토큰의 양에 따라 결정됩니다. 불필요한 컨텍스트를 계속 유지하는 것은 곧 비용 낭비로 이어지기 때문에, 효율적인 토큰 관리는 비용 절감의 핵심입니다.
Claude Code의 메모리 관리 매커니즘 이해하기
아래는, 머메이드를 이용해서 CLAUDE CODE의 워크플로우를 만들어봤습니다.
최적화를 위해서는 Claude가 어떻게 컨텍스트를 기억하는지 알아야 합니다. 핵심은 간단합니다.
세션은 하나의 프로세스: claude 명령어로 대화형 모드에 진입하면 하나의 세션(프로세스)이 시작됩니다.
모든 대화는 메모리에: 이 세션 내에서 오고 간 모든 질문과 답변(토큰)은 컨텍스트 유지를 위해 메모리에 계속 쌓입니다.
CLAUDE.md는 항상 로드: 세션을 시작할 때 현재 디렉토리의 CLAUDE.md 파일은 무조건 읽어와 기본 컨텍스트로 사용합니다.
결국 대화가 길어질수록 메모리에 쌓이는 토큰이 많아져 위에서 언급한 문제들이 발생하는 구조입니다. 이제 이 구조를 역이용하여 메모리를 통제하는 방법을 알아봅시다.
문서에 따르면, CLI를 실행한 디렉토리를 기준으로 상향/하향으로 CLAUDE.md를 찾아 메모리에 올려 사용합니다.
다시 말해, CLAUDE.md는 claude CLI를 통해 호출할 때 마다 읽는다는 겁니다.
그러므로, CLAUDE.md는 프로젝트 단위의, 사용자 단위의 공통 룰만 정의하고, 나머지는 각 프로젝트 별 마크다운으로 빼서 관리하는 것이 세션 내 메모리와 토큰 비용을 절약하는 효과적인 방법일 것이라고 생각합니다.
메모리 최적화를 위한 핵심 명령어 3가지
Claude Code는 메모리를 수동으로 관리할 수 있는 강력한 명령어들을 제공합니다. 이 세 가지만 기억하면 충분합니다.
/context: 현재 세션의 '메모리 대시보드'입니다. 이 명령어를 입력하면 현재 컨텍스트가 사용 중인 토큰의 양과 비율을 확인할 수 있습니다. 내비게이션의 지도처럼, 현재 상태를 파악하는 데 필수적입니다.
/clear (또는 /reset): 세션을 초기화하는 '하드 리셋' 버튼입니다. 대화 기록과 컨텍스트가 모두 사라지고, CLAUDE.md를 처음부터 다시 로드한 상태가 됩니다. 완전히 새로운 작업을 시작할 때 유용합니다.
/compact {지시문}: 세션을 압축하는 '스마트 요약' 기능입니다. 단순히 기록을 지우는 것이 아니라, "지금까지의 논의를 바탕으로 핵심 내용을 요약해줘" 와 같은 지시를 통해 대화의 맥락은 유지하면서 토큰 사용량을 획기적으로 줄여줍니다.
현재 제가 사용중인 방식
이제 위 명령어들을 조합하여 제가 지금 시점에 사용하는 클로드 코드 방식을 소개하려고 합니다.
정답은 없지만, 이 흐름을 따른 후 OOM으로 인한 중단이 사라졌고 토큰 사용량도 눈에 띄게 줄었습니다.
핵심: AutoCompact는 끄고, 수동으로 관리하여 워크플로우의 제어권을 가져온다.
자연스러운 중단점 활용: 코딩 작업의 흐름을 Git 워크플로우에 비유해봅시다.
Commit 단위로는 /compact: 특정 기능 개발이나 버그 수정 등 작은 작업 단위를 끝냈을 때, /compact를 사용해 "현재까지 작업한 내용을 요약하고 다음 작업을 준비해줘"라고 지시합니다. 이렇게 하면 컨텍스트는 유지하면서 메모리를 확보할 수 있습니다.
Branch 단위로는 /clear: 하나의 브랜치에서 다른 브랜치로 넘어가는 것처럼, 완전히 다른 주제의 작업을 시작할 때는 /clear를 사용해 컨텍스트를 완전히 비워줍니다. 이전 작업의 컨텍스트가 새 작업에 영향을 주는 것을 막고 메모리를 최적으로 관리할 수 있습니다.
# (작업 중...) 기능 A 개발 완료 후
# 1. 현재 토큰 사용량 확인
/context
# 2. 컨텍스트 요약으로 메모리 확보
/compact 지금까지 논의한 feature-A의 핵심 로직과 구현 내용을 정리해줘.
# (다른 작업 시작 전...)
# 3. 새로운 feature-B 작업을 위해 세션 초기화
/clear
이처럼 작업의 흐름에 맞춰 compact와 clear를 전략적으로 사용하면, AI의 AutoCompact에 의해 작업 흐름이 끊기는 안티 패턴을 방지하고 메모리와 토큰 사용량을 모두 최적화할 수 있습니다.
어떻게 프롬프팅을 해야 할까?
커서맛피아님의 레퍼런스를 정리한 하조은님의 유튜브 영상 일부 발췌
포스팅을 작성하고있는 오늘, 당근에서 개발자로 계신 하조은님의 유튜브 영상을 보다가, 좋은 내용이 있어서 가져왔습니다.
항상, 하지말아야 할 것들을 CLAUDE.md에 정의하는 것에 그쳤었는데, 가끔씩 할루시네이션이 발생했던 것을 몸소 체험한 바 있습니다.
영상에서 정리해준 Constraint부분을 특히 프롬프팅 단위로도 잘 정의해야할 것 같습니다.
마무리
여기까지, 2달 남짓 클로드를 사용하면서 AutoCompact와 OOM 문제 때문에 불필요하게 토큰을 많이 사용하던 어느 주니어 개발자의 이야기었습니다.
Claude Code는 강력한 도구지만, 그 성능을 제대로 이끌어내기 위해서는 내부 동작 방식을 이해하고 사용자가 직접 제어하려는 노력이 필요합니다.
이 중, gemini-cli는 현재 AI를 다루는 능력이 거의 필수 스택으로 자리잡았고, 저에게 가장 친숙한 TypeScript기반이라는 점, 마지막으로 내가 구글에 기여할 수 있다니!!! 와 같은 이유로 기여하기로 했는데요 ㅋㅋ..
그래서, 이번 포스팅은 gemini-cli의 기여에 대한 포스팅입니다.
Gemini-CLI
현 시점 CLI 기반 AI의 양대산맥이라고 한다면, claude code와 gemini-cli가 대표적인데요.
여러 차이가 있겠지만, 오픈소스 성격을 띠는지? 의 차이도 있는 것 같아요.
특히, gemini-cli의 경우 공개적인 로드맵까지 작성되어있어, 관심 있는 이슈를 직접 기여해볼 수 있도록 기여자들의 참여를 적극 장려하고 있는 상황입니다.
저는 오늘, 9개월 전 첫 오픈소스 기여를 시작하면서 막연하게 꿈꿔왔던 목표인
직접 이슈를 발견해서 등록하고, 해결해보기
를 달성하고, 더불어 모든 사용자에게 영향을 줄 수 있었던 이슈를 발견하고,
과정에서 AI를 활용하며 기여했던 과정 전반의 경험을 공유드리려고 합니다.
어떻게 이슈를 발견할까?
오픈소스의 코드는 방대합니다. 주당 몇 만자씩 추가되는 이 방대한 오픈소스에서, 어떻게 이슈를 발견해야할까요? 마냥 하나하나 파일을 분석하기에는, 너무 비효율적입니다.
저는 효과적으로 분석하기 위해 우선 UX의 흐름을 생각해보기로 했습니다.
gemini-cli설치하기
터미널에 "gemini" 명령어 실행하기
질문하고 응답받고의 반복
방대한 코드를, UX의 흐름으로 재정의하고보니 엄청 단순해졌습니다.
저는 이 흐름에서 명령어를 실행한 후 interactive mode(대화형 모드)가 생성되기 전까지의 동작을 확인해보기로 결정했습니다.
즉, 초기화 단계를 중점으로 파헤쳐보기로 한 것이죠.
AI와 함께 분석하기
잘 짜여진 코드 덕분인지 초기화 단계는 금방 찾을 수 있었습니다. 하지만 가시성이 아무리 좋고 좋은 구조로 짜여져 있다고 하더라도, 처음 보는 프로젝트의 모든 상호작용을 파악하면서 정확히 어떤 과정들을 수행하는지 한 눈에 알기는 어려웠습니다.
이 때, 시간 낭비를 줄이기 위해 저는 AI를 활용했습니다.
처음에는 할루시네이션 때문에 오픈소스에서 AI를 활용하는 것은 바람직한가? 라는 의문을 품었습니다.
하지만 부정할 수 없는 사실은 AI는 이미, 어쩌면 처음 등장했을 때 부터 제가 알고있는 프로그래밍 지식보다 훨씬 많은 것을 알고 있다는 것을요. 그리고 AI 활용법에 관한 많은 레퍼런스들에서 공통으로 얘기하듯, AI에게 판단을 맡기지 않고 사실 기반으로만 동작하게 한다면 AI는 최고의 동료가 될 것이라는 것을요.
저는 그래서, 짜여진 코드라는 사실에 기반하여 초기화 과정 자체를 분석하는 일을 AI에게 맡겼습니다.
DeepWiki: AI 기반 깃허브 저장소 위키
DeepWiki는 Devin AI를 만든 Cognition에서 만든 위키 형태의 AI 입니다.
깃허브 저장소를 위키 형태로 변환하여 볼 수 있게 해주는 도구로, 오픈소스의 핵심 아키텍처나 오픈소스 내의 피처들이 어떤 흐름으로 동작하는지 등 오픈소스의 전반적인 것들을 분석해주는 도구입니다.
위 사진은, 애플리케이션의 아키텍처를 DeepWiki가 분석해준 워크플로우이고, 빨간 네모 박스는 실행 시 초기화 과정 전반의 동작들입니다. 이를 기반으로 각 함수들을 하나씩 분석해 본 결과, 단순히 사용자의 세팅을 불러오는 구간들을 배제할 수 있었습니다.
작업할 구간을 좁히고 좁히다보니, memoryDiscovery라는 파일에서 퍼포먼스 개선이 가능해보이는 코드를 발견할 수 있었습니다.
memoryDiscovery는 초기화 시 GEMINI-CLI가 호출된 디렉토리를 기준으로, 상/하향 디렉토리들을 순차적으로 확인하여 GEMINI.md가 있는 경로를 수집합니다. 수집이 완료되면, 수집된 경로에서 순차적으로 마크다운 파일을 처리하여 메모리에 올려 사용합니다.
이 과정을 간단하게 도식화해본다면, 아래와 같은 형태입니다.
모든 디렉토리/파일에 대해 순차적인 처리를 수행하기 때문에, 프로젝트가 커지면 문제가 발생하겠다고 생각했고, 이 구간에 대해 작업을 시작했습니다.
Claude Code: 교차 검증
현재 AI는 아시다시피 할루시네이션이 엄청 심합니다.
DeepWiki를 통해 사실 기반의 워크플로우를 추론할 수 있었지만, 사실 확인이 한 번 더 필요하다고 판단했습니다.
이를 위해, GEMINI-CLI를 로컬에 클론시킨 뒤, CLAUDE CODE를 통해서 해당 작업 구간을 다시 한 번 재확인했습니다.
PR 생성
작업 구간이 명확해졌으니, 코드 작업을 해야겠죠?
제가 작업한 최종 코드는 다음과 같습니다.
기존 순차처리를 병렬로 변경
EMFILE 에러 방지를 위한 동시성 제한 추가
Promise.allSettled 사용으로 문제 발생 시에도 안정적인 파일 처리
PR을 생성하는 과정에서, Promise.allSettled는 gemini가 직접 제안해준 리뷰 내용에 포함되어 있어 추가하였습니다.
(뭔가 제가 만든 요리를 레시피 원작자가 직접 평가하는 기분이네요)
결과
생각보다 금방 머지가되어, GEMINI-CLI의 컨트리뷰터가 될 수 있었습니다.
PR 과정에서 얼마나 개선될까 싶어 벤치마크 테스트를 PR의 커밋에 추가했었는데요,
아래 코드에서 알 수 있듯이, 큰 규모의 테스트는 아니지만 약 60% 가량의 퍼포먼스 향상을 이루어낼 수 있었습니다.
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import { tmpdir } from 'os';
import { loadServerHierarchicalMemory } from './memoryDiscovery.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { processImports } from './memoryImportProcessor.js';
// Helper to create test content
function createTestContent(index: number): string {
return `# GEMINI Configuration ${index}
## Project Instructions
This is test content for performance benchmarking.
The content should be substantial enough to simulate real-world usage.
### Code Style Guidelines
- Use TypeScript for type safety
- Follow functional programming patterns
- Maintain high test coverage
- Keep functions pure when possible
### Architecture Principles
- Modular design with clear boundaries
- Clean separation of concerns
- Efficient resource usage
- Scalable and maintainable codebase
### Development Guidelines
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
`.repeat(3); // Make content substantial
}
// Sequential implementation for comparison
async function readFilesSequential(
filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
const results = [];
for (const filePath of filePaths) {
try {
const content = await fs.readFile(filePath, 'utf-8');
const processedResult = await processImports(
content,
path.dirname(filePath),
false,
undefined,
undefined,
'flat',
);
results.push({ path: filePath, content: processedResult.content });
} catch {
results.push({ path: filePath, content: null });
}
}
return results;
}
// Parallel implementation
async function readFilesParallel(
filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
const promises = filePaths.map(async (filePath) => {
try {
const content = await fs.readFile(filePath, 'utf-8');
const processedResult = await processImports(
content,
path.dirname(filePath),
false,
undefined,
undefined,
'flat',
);
return { path: filePath, content: processedResult.content };
} catch {
return { path: filePath, content: null };
}
});
return Promise.all(promises);
}
describe('memoryDiscovery performance', () => {
let testDir: string;
let fileService: FileDiscoveryService;
beforeEach(async () => {
testDir = path.join(tmpdir(), `memoryDiscovery-perf-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
fileService = new FileDiscoveryService(testDir);
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should demonstrate significant performance improvement with parallel processing', async () => {
// Create test structure
const numFiles = 20;
const filePaths: string[] = [];
for (let i = 0; i < numFiles; i++) {
const dirPath = path.join(testDir, `project-${i}`);
await fs.mkdir(dirPath, { recursive: true });
const filePath = path.join(dirPath, 'GEMINI.md');
await fs.writeFile(filePath, createTestContent(i));
filePaths.push(filePath);
}
// Measure sequential processing
const seqStart = performance.now();
const seqResults = await readFilesSequential(filePaths);
const seqTime = performance.now() - seqStart;
// Measure parallel processing
const parStart = performance.now();
const parResults = await readFilesParallel(filePaths);
const parTime = performance.now() - parStart;
// Verify results are equivalent
expect(seqResults.length).toBe(parResults.length);
expect(seqResults.length).toBe(numFiles);
// Verify parallel is faster
expect(parTime).toBeLessThan(seqTime);
// Calculate improvement
const improvement = ((seqTime - parTime) / seqTime) * 100;
const speedup = seqTime / parTime;
// Log results for visibility
console.log(`\n Performance Results (${numFiles} files):`);
console.log(` Sequential: ${seqTime.toFixed(2)}ms`);
console.log(` Parallel: ${parTime.toFixed(2)}ms`);
console.log(` Improvement: ${improvement.toFixed(1)}%`);
console.log(` Speedup: ${speedup.toFixed(2)}x\n`);
// Expect significant improvement
expect(improvement).toBeGreaterThan(50); // At least 50% improvement
});
it('should handle the actual loadServerHierarchicalMemory function efficiently', async () => {
// Create multiple directories with GEMINI.md files
const dirs: string[] = [];
const numDirs = 10;
for (let i = 0; i < numDirs; i++) {
const dirPath = path.join(testDir, `workspace-${i}`);
await fs.mkdir(dirPath, { recursive: true });
dirs.push(dirPath);
// Create GEMINI.md file
const content = createTestContent(i);
await fs.writeFile(path.join(dirPath, 'GEMINI.md'), content);
// Create nested structure
const nestedPath = path.join(dirPath, 'src', 'components');
await fs.mkdir(nestedPath, { recursive: true });
await fs.writeFile(path.join(nestedPath, 'GEMINI.md'), content);
}
// Measure performance
const startTime = performance.now();
const result = await loadServerHierarchicalMemory(
dirs[0],
dirs.slice(1),
false, // debugMode
fileService,
[], // extensionContextFilePaths
'flat', // importFormat
undefined, // fileFilteringOptions
200, // maxDirs
);
const duration = performance.now() - startTime;
// Verify results
expect(result.fileCount).toBeGreaterThan(0);
expect(result.memoryContent).toBeTruthy();
// Log performance
console.log(`\n Real-world Performance:`);
console.log(
` Processed ${result.fileCount} files in ${duration.toFixed(2)}ms`,
);
console.log(
` Rate: ${(result.fileCount / (duration / 1000)).toFixed(2)} files/second\n`,
);
// Performance should be reasonable
expect(duration).toBeLessThan(1000); // Should complete within 1 second
});
});
마무리
최근 오픈소스 기여를 위해 DeepWiki를 적극 활용하고 있는데요,
위에서도 잠깐 말씀드렸듯이, 사실 기반(=코드베이스 자체만 분석)으로 AI를 활용한다면 엄청난 퍼포먼스를 보이는 것 같습니다.
오픈소스 기여에는 항상 이슈를 해결하기 위한 분석에서 가장 많은 시간을 잡아먹었었는데요, 작업을 위한 분석 구간을 명확하게 좁혀주는 용도로만 사용했지만 거의 PR 생성 속도가 10배 가까이 단축된 것 같아요.
이번 기여에서는, 특히 전 세계 수많은 사용자들의 시간을 매일 조금씩 아껴주었다는 생각에 매우 뿌듯한 경험이었습니다.
모든 gemini-cli의 사용자들이 기존 대비 60%이상 향상된 퍼포먼스를 제 코드를 통해 제공받을 수 있다는 점이 정말 황홀한 것 같아요.
cd ~/Desktop/company-docker
docker compose up -d
docker compose run --rm claude-work claude login
## 성공!!
# 1) Claude account with subscription
# 2) Anthropic Console account
도커 컨테이너를 띄워서, 로그인을 시키고 세팅을 완료했습니다.
확인해보면, 개인 계정(위)과 회사 계정의 기본 세팅이 다른 것을 볼 수 있죠.
ccusage도 잘 동작합니다. 위 사진이 개인 계정(내용이 많아서 짤랐습니다.), 아래가 회사 계정이에요.
도커로 띄워야하나? 로컬로 변경
하필 다 만들고 보니 도커는 기본 메모리 사용량이 엄청 높아서, 굳이 도커로 사용해야하나 라는 생각이 들었습니다. (진작 좀 생각하지 ㅡㅡ)
오픈소스 기여모임 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년 가까이 운영하면서, 언젠가는 티스토리 커스텀 스킨을 만들어야지 생각했습니다. 하지만 저는 프론트, 특히 CSS를 잘 다루지 못해서 언젠간 만들어야지 생각만 했던 것 같아요.
드디어!!! 커스텀 스킨을 제작했습니다. 물론 클로드 코드를 사용해서 바이브 코딩으로 만들었습니다. (CSS가 너무 어렵군요..)
티스토리 커스텀 스킨을 무료로 배포합니다!!! 제발 사용해주세요!!!(??)
제 블로그 적용되어있는 상태이니 둘러보시고 마음에 드신다면 아래 레포의 가이드를 따라 사용해주시면 되겠습니다. 비개발자분들도 접속하셔서 코드를 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% 정확하진 않음. (직전 시간대에 도착한 요청에 대한 계산은 전혀 수행하지 않음)