NestJS에서 최근 발행된 이슈를 트래킹하다가 아이디어를 얻어 유효성 검사 라이브러리를 하나 만들게 되었습니다.
간단하게 왜 개발하게 되었는지, 어떤 차별점들이 있는지 등을 소개하려고 합니다.
왜 만들었는지?
Javascript 진영에도 무수히 많은 Validation 라이브러리가 존재합니다.
Zod, Valibot, Joi, ArkType, Yup 등등 대표적인 것들만 해도 손에 꼽기 어려울 정도로 많습니다.
이미 커뮤니티에서 검증된 오픈소스들이 있음에도 불구하고, 아래와 같은 이유로 직접 만들게 되었습니다.
커뮤니티의 니즈
NestJS의 이슈를 트래킹하다가 최근 Validation을 다룬 이슈를 발견했습니다. 내용을 요약하자면, NestJS에서 공식적으로 zod를 패키징하기를 원했습니다. nestjs-zod라는 서드파티 라이브러리가 이미 존재했지만, 공식적으로 이관될지 별도의 패키지가 나올지 아무도 모릅니다. 불확실하죠. nestjs-zod 오픈소스에서 논의되었던 내용은 2년이 지난 지금 중단된 상태입니다.
항상 오픈소스 커뮤니티에서 원하는 것은 좋은 기능들과 더불어 꾸준히 관리되는 패키지라고 생각합니다.
TypeScript, NestJS를 떠나면서 남기는 결과물
저는 최근 이직을 했습니다. 새로운 환경에서 전혀 다른 스택들을 사용하기 때문에 잠시 TypeScript 진영을 떠나게 되었습니다. 물론 계속해서 관심을 가지고 있으나, 만 2년 TypeScript로, NestJS로 개발을 했기 때문에 2년 동안 개발자로서 얼마나 성숙하게 되었는지 확인해보고 싶었습니다.
항상 오픈소스를 만들고싶었습니다. 한국에서 TypeScript 진영의 오픈소스 거장(?)이신 삼촌님, 동윤님의 레포를 항상 구경하면서 언젠가 나만의 작은 오픈소스(?)를 만들고 싶었고, 아이디어가 없다는 핑계로 계속 미뤄왔었던 것 같아요.
이번 기회에 작은 니즈를 발견했고, 직접 해결해보면서 꾸준히 운영할 수 있는 오픈소스 운영자가 되어보기로 했습니다. 물론 커뮤니티의 선택을 받아야 하겠지만요
어떤 차별점이 있는지?
기존 NestJS의 방식
먼저 기존 방식을 짚어볼게요.
NestJS는 기본적으로 class-validator + class-transformer 조합과 통합되어 있습니다. ValidationPipe를 글로벌로 등록하면 DTO 클래스의 데코레이터를 읽어 자동으로 검증해줘요.
요약하자면 Validator을 변경하기 위해 NestJS와의 통합 레이어를 처음부터 다시 만들어야 할 수도 있어요.
해결하고자 한 것
저는, 위에서 설명한 이런 불편함들을 해소하고자 standard-schema 기반으로 NestJS 통합 Validator 레이어를 구현했어요.
standard-schema는 JavaScript/TypeScript validation 라이브러리들이 공통으로 구현하는 인터페이스 스펙으로 각 Validator마다 API가 다른 문제들을 해결하기 위한 공통 인터페이스를 정의한 라이브러리에요. 현재 Zod, Valibot, ArkType, TypeBox 등 23개 이상의 validator가 이 스펙을 구현하고 있어요.
import { StandardValidationPipe, createStandardDto } from '@mag123c/nestjs-stdschema';
// Zod
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
// Valibot
import * as v from 'valibot';
const CreateUserSchema = v.object({
name: v.string(),
email: v.pipe(v.string(), v.email()),
});
// DTO 생성 (OpenAPI 메타데이터 자동 연동)
class CreateUserDto extends createStandardDto(CreateUserSchema) {}
// 사용 - Zod든 Valibot이든 동일한 Pipe
@Post()
create(@Body(new StandardValidationPipe(CreateUserSchema)) body: CreateUserDto) {
return body;
}
만약 Validator을 변경하더라도, Pipe와 DTO 구조는 그대로 가져갈 수 있도록 래핑했습니다.
특정 벤더에 종속되지 않고, NestJS의 이슈에서 메인테이너가 제안했던 NestJS 아키텍처 패턴을 반영했다고 보시면 돼요. Pipe 기반 검증, OpenAPI의 통합, Response Serialization(Interceptor 응답 필터링)이 이에 해당해요.
트레이드오프 및 고려사항
장점만 있는건 당연히 아니겠죠. 도입 전 고려해야 할 점들이 있습니다.
자주 바꾸지 않는 Validator
우선 이 오픈소스 자체가 커뮤니티의 니즈이지, 제 니즈는 아니었어요. 풀어서 써보자면,
저는 하나의 프로젝트를 만들 때, Validator을 고르면 바꾼 적이 없어요. cv/cf, typia 등 하나의 Validator을 그대로 가져갔었어요.
그렇기 때문에 cv/cf 외에 다른 라이브러리를 선택해서 NestJS 아키텍처와 맞추기 위해 여러 커스터마이징 작업을 하더라도 한 번 구축하면 거의 손 볼 일이 없어요.
하나의 프로젝트에서 여러 Validator을 사용하거나, 다른 라이브러리로의 전환을 고려하고 있지 않다면 굳이 사용하지 않을 것 같아요.
기존 프로젝트 마이그레이션 비용
위의 연장선으로, NestJS를 사용하신다면 이미 cv(class-validator)로 구축된 프로젝트가 많을 것이라고 생각되는데요, DTO를 스키마 기반으로 전환해야해요.
// Before: class-validator
class CreateUserDto {
@IsString()
name: string;
}
// After: 스키마 기반
const CreateUserSchema = z.object({ name: z.string() });
class CreateUserDto extends createStandardDto(CreateUserSchema) {}
학습 곡선
cv의 데코레이터 방식에 익숙한 개발자라면, 스키마 기반 패턴이 낯설 수 있어요.
OpenAPI 자동 생성의 한계
Zod v4 이전 버전이나 다른 Validator을 사용한다면 OpenAPI 스키마를 직접 정의해야하는 불편함이 남아있어요.
이 중, 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%이상 향상된 퍼포먼스를 제 코드를 통해 제공받을 수 있다는 점이 정말 황홀한 것 같아요.
오픈소스 기여모임 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 업데이트의 브레이킹 체인지를 해결하기 위한 기여를 했던 경험을 공유해보겠습니다.
여기까지 오셨다면, 뭔가 이상함을 느꼈을 수도 있습니다. 테스트 환경에서는 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 향상에 미약하나마 도움이 되는 기여였으면 좋겠습니다.
항상 기여하면서 느끼는거지만, 오픈소스 자체에 깊은 이해와 더불어 오픈소스 구현에 사용된 다른 프레임워크나 그 근간이 되는 개념들을 자연스레 학습할 수 있게 되고, 더 깊게 이해할 수 있는 것 같습니다. 오픈소스의 진정한 가치는 이런 데서 나오는 게 아닌가 싶습니다.
파일 검증에 대한 보안 취약점을 개선하기 위해 Nest의 FileTypeValidator에 파일의 Magic Number(바이너리 시그니처)를 기반으로 파일 타입을 검사하도록 변경되었다. 이 변경은 보안적으로 더 개선되었지만, 실제 애플리케이션에서는 다음과 같은 부작용을 낳았다.
.txt, .csv, .json등 Magic Number가 없거나 너무 짧은 파일들은 파일 타입을 판단하지 못해 업로드 실패로 이어진다. 이로 인해 기존 애플리케이션에서 잘 작동하던 업로드 로직이 갑자기 실패하게 되었다.
에러메세지가 기존과 같이 file type에 대한 에러 메세지가 나오게 되어 왜 실패했는지 알 수 없다.
위 문제를 바로 피부로 겪었는데, 현재 조직에서도 새로 구축하고 있던 애플리케이션에서 잘 동작하던 파일 업로드가 갑자기 되지 않았고, 문제를 직접 해결하기 위해 나는 유연한 검증을 위한 옵션과, 에러 메세지의 적절한 분기 처리를 PR로 제출했다.
Swagger은 OpenAPI의 표준이 아닌 확장(사용자 정의) 속성을 지원하고 있는데, SecuritySchema는 이 확장 속성들 중 API 요청을 인증할 때 어떤 방식으로 인증해야하는 지 정의하는 설정이다. Nest에서는 이 옵션을 래핑하는 DocumentBuilder을 메서드 체이닝을 이용하여 보통 아래처럼 스웨거 명세를 만든다.
하지만,OpenAPI Extensions 문서의 예시처럼, 이 API Key에 추가적인 필수 옵션들이 들어가야하는 상황이라면? 예를 들어 AWS API Gateway처럼 특정 플랫폼에 맞춘 커스텀 인증을 구현해야하는 상황에서, 스웨거를 통해서는 헤더에 원하는 커스텀 옵션을 넣을 수 없게 되어있다.
Nest의 Swagger Plugin은 컴파일 타임에 ts의 Transformer을 사용해서 Swagger 명세를 추상 구문 트리로 만들어낸다. JavaScript 엔진을 공부할 때 나오는 그 추상 구문 트리(Abstract Syntax Tree) 이다. 이렇기에 위 예제처럼 런타임 시에 정해지는 값은 추론되지 않아 JavaScript 함수의 name값인 Function을 넣어버리게 된다.
컴파일 타임에 제어하기 위해, Swagger Plugin Option을 추가하고, 옵션에 따라 아예 옵션을 활성화하면, Default로 추론할 수 없는 값들은 Swagger 명세에 Default로 추가되지 않는다.
기여를 계속 하다보니 자연스레 깨닫게 된 점이 있다. 바로 조직 내에서 사용하는 기술 스택에 익숙해져감에 따라, 그것이 나의 개발 판단 기준을 점점 좁히고 있었다는 점이다. 예를 들어, 런타임 타입 검사를 위한 Zod를 typia로 변경하여 훨씬 빠르고 간결한 코드 작성이 가능한 오픈소스도 있고, 항상 tsc로 트랜스파일하던 것을 swc로 바꿔본다던가 하는 등의 퍼포먼스 개선이 가능하다. 당장 적용해도 충분히 기술 선택의 근거가 명확하고, 임팩트가 있는 선택지들이 많다는 걸 느낀다.
오픈 소스 기여는 단지 코드 변경에 머무르지 않는다. 생태계의 흐름을 눈으로 확인하고, 직접 만지며 왜 이런 기능이 추가됐는지를 맥락과 함께 이해하는 경험이라고 생각한다. 이런 과정을 겪으며 자연스럽게 내 기술의 선택 기준도 명확해지고 조직 내 기술뿐 아니라 개인적으로 사용할 기술에도 더 많은 관심을 기울이게 된다.
내가 사용하는 기술을 불편 없이 쓰는 것에서 멈추는 것이 아니라, 더 나은 방향으로 기여하거나 기존에 없는 기능을 직접 만들어내는 것.
결국 불편함을 느끼고 나만의 방식으로 개선해보는 경험을 하나씩 쌓아나가는 것이 가장 이상적인 형태가 아닐까 싶다.
장황하게 나열했는데, 여튼 다음에는 조금 더 다양한 기여 경험을 가지고 돌아오도록 하겠다.
Redis Client 모듈을 직접 만들고 NPM에 배포하기 (NestJS + ioredis)
OpenSource2025. 3. 6. 20:30
728x90
동기
패키지 설치 시 경고 문구가 나오는 것을 정말 싫어한다. 새로운 서버 구축을 위해 Nest에서 기존에 사용하던 redis 모듈 오픈소스를 자연스레 설치했는데, 위와 같은 경고 문구가 발생했다. 경고에 따르면 이 오픈소스에서 terminus를 사용하는데, terminus의 의존성 버전들이 나의 현재 프로젝트 버전과 맞지 않는다고 한다.
terminus?
terminus를 간략히 설명하자면 NestJS에서 Health Check를 제공하는 모듈이다. 다양한 Health Indicator로 특히 마이크로서비스나 인프라등 애플리케이션의 기능들이 정상적으로 동작하는지 확인하는 기능을 제공하는 모듈이다. 단순 Redis Client Module에서 필요한 것은 아니라는 생각이 들었고, 위의 warning 의존성 문구와 더불어 이런 이유들로 인해 직접 NestJS의 11버전과 호환되는 모듈을 직접 만들어 배포하기로 했다.
NestJS의 DynamicModule은 동적으로 옵션을 받아서 런타임에 원하는 모듈을 구성할 수 있다. 물론 StaticModule 역시 런타임의 환경 변수 값 등의 변동에 따른 동적인 모듈 세팅을 할 수 있으나, 아래처럼 Redis를 상황에 따라 클러스터 모드로 모듈을 구성해야하는 경우, 극단적으로 외부 API 호출 값에 따라 변동되는 모듈 세팅 등 실행 환경에 따라 모듈 내부의 Providers 등을 다르게 주입해야한다면 StaticModule로는 불가능하다. (Nest에는 TypeOrmModule, ConfigModule 등 다양한 곳에 DynamicModule이 사용되고 있다.)
(Nest에는 TypeOrmModule, ConfigModule 등 다양한 곳에 DynamicModule이 사용되고 있다.)
라이브러리를 만들어보기 전에, 코어가 되는 의존성인 ioredis에 대해 먼저 확인해보아야했다.
ioredis란 Node 진영에서 많이 사용되는 Redis Client이다. 내부를 뜯어보니 내가 사용하고자하는 단일 레디스와 더불어 엄청나게 많은 기능을 제공하고 있었다. 우선 만들고자 하는 모듈이 ioredis에 의존적일 수 밖에 없어서, 혹시 직접 Redis Client를 직접 만들 수도 있을까 하고 연결 과정을 살펴보게 되었다.
확인해 본 결과 기본적으로 Node의 net 모듈을 통해 L4 연결을 수립하고 있었고, 필요 시 TLS Handshake가 추가 수립되었다. 추상커넥터를 통해서 단일 Redis와 센티넬 Redis를 자동으로 감지하여 연결을 수립시켜주고 있었다.
차후에 Node의 net 모듈을 통해 OS의 소켓 API를 직접 사용해보면서 레디스에 직접 연결해서 사용해보고, 필요한 기능들만 래핑해서 사용해보면 좋을 것 같았다. 이 부분은 차후에 개발 후 추가 포스팅으로 연계하려고 한다.
Redis Dynamic Module 만들기
위에서 언급한 DynamicModule을 바탕으로 실제 Redis와 연결하는 Dynamic Module을 구현해보았다.
NestJS의 DynamicModule 컨셉에 맞게 동기, 비동기 옵션 주입을 모두 지원하며 Redis 연결을 담당하는 Provider와 옵션을 useValue로 넣어주는 Provider을 분리하여 제작하였다.
모듈 생성과 Providers 주입 등에 사용될 인터페이스를 정의하고, Redis Connection 전용 Provider을 생성했다. RedisProvider을 통해 내가 레디스 클라이언트까지 생각하지 않고 옵션만 원하는 데로 넣어서 사용할 수 있게 유도했다.
// redis.factory.ts
import Redis from 'ioredis';
import { RedisModuleOptions } from './redis.interface';
export async function createRedisClient(options: RedisModuleOptions) {
const { type, commonOptions } = options;
if (type === 'cluster') {
return new Redis.Cluster(options.nodes, commonOptions);
}
if (type === 'single') {
return new Redis(options);
}
throw new Error('Invalid Redis options');
}
현재 개발 환경에서 레디스 클러스터를 사용할 일이 없어 만들지 않으려다가 ioredis에서 Cluster 인스턴스를 만들어서 Redis node별 host, port만 넣으면 되는 것을 확인하여 간단하게 작업이 될 수 있을 것 같아 클러스터 환경의 레디스 클라이언트도 추가해두었다. 이렇게 추가해도 실제 사용하는 레디스 인스턴스는 ioredis의 Redis Client이기 때문에 무리없이 사용이 가능하다.
(하지만 실제 사용하는 환경이 아니다보니 테스트 코드는 작성하지 않았다.)
import { Inject } from '@nestjs/common';
import { REDIS_CLIENT } from './redis.constasnts';
export const InjectRedis = () => Inject(REDIS_CLIENT);
마지막으로, Redis Client를 특정 모듈들에서 주입받아 원하는 구간에서 사용할 수 있도록 주입을 위한 데코레이터를 만들었다. 이제 아래처럼 다른 Providers에서 Redis Module을 통해 생성된 Redis Client를 사용할 수 있다.
import { Injectable } from '@nestjs/common';
import { InjectRedis } from './redis.decorator';
import Redis from 'ioredis';
@Injectable()
export class RedisService {
constructor(@InjectRedis() private readonly redis: Redis) {}
async getValue(key: string): Promise<string | null> {
return this.redis.get(key);
}
}
이 @InjectRedis()는 강제로 REDIS_CLIENT 상수를 넣어두었다. 내가 여러 대의 레디스를 사용하게 되거나, 누군가의 요청으로 인해 변경해야 한다면 상수 대신 옵션으로 넣고 옵션으로 들어온 값이 없다면 디폴트로 사용할 수 있게 구성할 생각이다.
실제 적용해보기
지금까지 만든 라이브러리를 NPM에 배포하고 새로 구축하고 있던 서버에서 기존 오픈소스를 제거한 뒤 내가 만든 라이브러리를 주입시켜보았다.
import { Test } from "@nestjs/testing";
import { RedisStringManager } from "../../src/infra/redis/managers/string.manager";
import { CustomRedisModule } from "../../src/infra/redis/redis.module";
import { setupApp, setupModule } from "../settings/setup";
describe('Redis Module Migration Test', () => {
let redisStringManager: RedisStringManager;
beforeAll(async () => {
const moduleRef = await setupModule([CustomRedisModule]);
redisStringManager = moduleRef.get<RedisStringManager>(RedisStringManager);
})
it ('RedisStringManager가 정상적으로 주입되었는지?', () => {
expect(redisStringManager).toBeDefined();
});
it ('RedisStringManager Migration 결과는!?!?!?', async () => {
await redisStringManager.set('test', 'test');
const sut = await redisStringManager.get('test');
expect(sut).toBe('test');
});
});
import { CustomRedisModule } from '../../src/infra/redis/redis.module';
import { setupModule } from '../settings/setup';
import { RedisAuthStore } from '../../src/app/auth/services/redis-auth-store';
describe('AuthModule Redis 연동 테스트', () => {
let redisAuthStore: RedisAuthStore;
beforeAll(async () => {
const moduleRef = await setupModule([CustomRedisModule]);
redisAuthStore = moduleRef.get<RedisAuthStore>(RedisAuthStore);
});
it('RedisAuthStore가 정상적으로 주입되었는지?', () => {
expect(redisAuthStore).toBeDefined();
});
it('Nonce 저장 및 조회 테스트', async () => {
// given
await redisAuthStore.setNonce('test-user', 'nonce-value', 600);
// when
const nonce = await redisAuthStore.getNonce('test-user');
// then
expect(nonce).toBe('nonce-value');
});
it('OTP 저장 및 조회 테스트', async () => {
// given
const otpData = { otp: '123456', issueDate: Date.now() };
await redisAuthStore.setOtp('test-user', otpData, 300);
// when
const retrievedOtp = await redisAuthStore.getOtp('test-user');
// then
expect(retrievedOtp).toMatchObject(otpData);
});
it('Nonce 삭제 테스트', async () => {
// given
await redisAuthStore.setNonce('delete-user', 'delete-value', 600);
// when
await redisAuthStore.delNonce('delete-user');
const afterDelete = await redisAuthStore.getNonce('delete-user');
// then
expect(afterDelete).toBeNull();
});
it('OTP 삭제 테스트', async () => {
// given
const otpData = { otp: '654321', issueDate: Date.now() };
await redisAuthStore.setOtp('delete-user', otpData, 300);
// when
await redisAuthStore.delOtp('delete-user');
const afterDelete = await redisAuthStore.getOtp('delete-user');
// then
expect(afterDelete).toBeNull();
});
});
둘 다 정상적으로 동작하는 모습을 볼 수 있다. 성공적으로 마이그레이션이 완료된 것 같다.
과제
NestJS 11버전을 기준으로 만들어졌는데, 사내 다른 NestJS 서버의 버전(v7, v10)들에도 사용할 수 있는지, 어느 버전까지 호환이 되는지 확인해보는 과정이 필요하다.
마치며
최근에 토스에서 발행한 NestJS 환경에 맞는 Custom Decorator 만들기 라는 포스팅을 봤다. 특정 모듈, 라이브러리, 기능 등이 필요한 상황에서 현재 사용중인 프레임워크, 라이브러리 전수 조사를 통해 함수 레벨에서 사용 가능한 데코레이터를 커스터마이징하여 사용한 것을 보고 충격을 받았다. 현재 사용중인 기술을 바탕으로 현재 기술에서 지원하지 않는 다른 무언가를 창조해낼 수 있다는 것이 신기하기도 했고 많이 부족하구나 라는 생각이 들었다.
이번 Redis Module을 만든 것은 어찌보면 기존의 nestjs-modules/ioredis라는 꽤 사용량이 높은 오픈소스가 기존에 있기도 했고, 단순 Dynamic Module을 하나 구성하여 ioredis와의 연계만 시켜주면 되기 때문에 쉬운 편이라고 생각한다. 하지만 난이도를 떠나 항상 무엇을 개선할 수 있을지 생각해보고 적용시킬 수 있다면 머리통이 깨지더라도(?) 적용해보는 편이 좋겠다는 생각이 든다. 오픈소스에서 필요 없는 기능을 빼서 커스터마이징하기만 하더라도 작게나마 리소스를 절감할 수 있기도 하고, 과정에서 배우는 것이 많기 때문이다. 단순히 필요 없는 기능을 걷어내고 최적화하는 과정도 소중하지 않을까? 라는 생각으로 마무리해본다.
PipeTransForm 인터페이스를 통해 따로 ValidationPipe을 구현해서 사용해도 되지만, Nest에서는 파일에 대한 표준 내장 ParseFilePipe을 제공하고, ParseFilePipe에 FileValidator 추상 클래스를 파라미터로 받아서 사용할 수 있다.
Nest의 기본 내장 코드들을 활용해서 간단하게 컨트롤러에서 1KB 미만의 jpg,jpeg,png 파일을 업로드할 수 있는 API를 만들 수 있다. 아래는 베이직한 API이다.
@Post('upload-image')
@UseInterceptors(FileInterceptor('file'))
uploadFile(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 }),
new FileTypeValidator({ fileType: '.(png|jpeg|jpg)' }),
],
}),
)
file: Express.Multer.File,
) {
console.log(file);
}
위에서 언급한 것 처럼, Validator에서 파일이 넘어오게 되어있는데, 에러 메세지는 단순 validationOptions에 따른 에러 메세지 밖에 나오지 않기 때문에, 파일 인터페이스에 들어있는 현재 파일의 크기, 타입을 메세지로 나타내어주지 않고 있다.
export class MaxFileSizeValidator extends FileValidator<
MaxFileSizeValidatorOptions,
IFile
> {
buildErrorMessage(): string {
if ('message' in this.validationOptions) {
if (typeof this.validationOptions.message === 'function') {
return this.validationOptions.message(this.validationOptions.maxSize);
}
return this.validationOptions.message;
}
return `Validation failed (expected size is less than ${this.validationOptions.maxSize})`;
}
}
export class FileTypeValidator extends FileValidator<
FileTypeValidatorOptions,
IFile
> {
buildErrorMessage(): string {
return `Validation failed (expected type is ${this.validationOptions.fileType})`;
}
}
해결
FileValidator을 상속받아 사용하는 하위 클래스들에서, 그대로 파일을 넘겨받아 사용할 수 있기 때문에
에러 메세지를 만들 때 File을 넘겨받아 사용할 수 있게 해주고 테스트 코드까지 작성해주었다.
export class FileTypeValidator extends FileValidator<
FileTypeValidatorOptions,
IFile
> {
buildErrorMessage(file?: IFile): string {
if (file?.mimetype) {
return `Validation failed (current file type is ${file.mimetype}, expected type is ${this.validationOptions.fileType})`;
}
return `Validation failed (expected type is ${this.validationOptions.fileType})`;
}
}
export class MaxFileSizeValidator extends FileValidator<
MaxFileSizeValidatorOptions,
IFile
> {
buildErrorMessage(file?: IFile): string {
if ('message' in this.validationOptions) {
if (typeof this.validationOptions.message === 'function') {
return this.validationOptions.message(this.validationOptions.maxSize);
}
return this.validationOptions.message!;
}
if (file?.size) {
return `Validation failed (current file size is ${file.size}, expected size is less than ${this.validationOptions.maxSize})`;
}
return `Validation failed (expected size is less than ${this.validationOptions.maxSize})`;
}
}
이렇게, 선정했던 두 가지의 이슈를 모두 직접 해결할 수 있어서 뿌듯하다.
앞으로도 기여하고 싶은 오픈소스에 기웃거리면서 하나하나 해결하다보면 직접 이슈를 생성할 수 있는 날이 오지 않을까? 하는 기대가 든다. 점점 성장하는 오픈소스 포스팅이 되길 기원한다. 아자아자!