(8)

Prisma v6.14.0의 성능 개선에 따른 브레이킹 체인지, 이에 기여한 이야기.

서론 들어가기전 기여에 필요해서 Prisma에 대해 간단하게 뜯어본 포스팅이 있으니,전반적인 내용 이해에 도움이 될 것 같아서 정리해놓았으니 필요 시 한 번 훑어보시길 권장드립니다. Prisma는 왜 Type-Safe할까?TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장mag1c.tistory.com 오픈소스 기여모임 9기의 참여자로 nestjs에 5개, loki, gemini-cli, Prisma에 각 1개씩 7개의 PR을 생성했고, 그 중 2개의 PR이 머지되었습니다. 그 중 Prisma에 컨트리뷰터가 된 내용을 다뤘습니..

Prisma는 왜 Type-Safe할까?

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장히 주관적임) 이 주장의 근간에는 Type-Safe한 ORM인것이 메인일 것 같습니다. (물론 다른 장점들도 많을 것 같은데 차차 파헤쳐보죠) 이번에 오픈소스 기여를 통해 처음으로 Prisma 코드를 약간 파보았습니다.TypeORM만 쓰고 거의 겉핥기식으로 사용했었는데, 이번 기회에 제대로 Prisma에 입문해보려고 합니다. PrismaPrisma는 스키마 기반 코드 생성형 ORM입니다. schema.prisma를 바탕으로 런타임 이전에 Prisma Client를 생성해야합니다..

오픈소스 기여하기) NestJS Express↔Fastify 미들웨어 등록 일관성 개선하기

nest의 e2e이 글을 읽으시는 nest를 활용하는 개발자분들은, e2e를 작성하시나요? 어떻게 작성하고 계신가요?보통은 nest의 공식 문서 가이드에 따라 TestingModule을 통해 NestApplication을 init한 후 사용하실 겁니다.beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [CatsModule], }) .overrideProvider(CatsService) .useValue(catsService) .compile(); app = moduleRef.createNestApplication(); app.use(thirdPartyMiddleWare()); // i..

오픈소스 기여가 어렵다구요? AI로 딸깍, 끝? (오픈소스 멘토링 8기 후기)

⭐오픈소스의 판도를 바꿀, AI로 오픈소스 기여 완벽 가이드와 프롬프트 공유!직접 AI로 오픈소스 기여해본 후 느꼈던 충격을 공유합니다!medium.com 오픈소스에 기여해보고 싶다고 처음 생각했던 때가 2023년 12월 DevFest에서, 첫 PR을 날린건 2024년 11월. 왜 이렇게 오래걸렸을까?오픈소스는 뭔가 어려워보이잖아.. 라고 생각해서? 소위 진입장벽 때문에..? 하지만 이번 오픈소스 멘토링에서 이 진입장벽을 깨부술 엄청난 것을 겪게 되었다. 이슈 선정이 힘들어? AI로 딸깍오픈소스 기여에 막막했을 때를 회고해보자면 기여의 스타트인 이슈 선정부터 막막했다. 정말 막막했다. "내가 이거 해도 되나?""이슈를 어떻게 찾지?""아 전부다 영어네 ㅋㅋ" 오픈소스 멘토링 8기에서는,..

25년 상반기(~4월)의 오픈소스 기여

하루만에 오픈소스에 기여하기 (nest)처음으로 오픈소스에 기여해보았다 (feat. 오픈소스 멘토링)저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.부끄럽지만 너무 다가가기 어렵고 힘들었습니다. 하mag1c.tistory.com 작년, 오픈소스 기여에 첫 발을 떼게 해주신 인제님의 오픈소스 멘토링 이후, 기여할 수 있는 부분들에 꾸준히 기여를 해왔다.이번에도 nest, nestjs/swagger에 기여를 했고, 아직 머지되지 않은 기여도 있고 바로 반영된 머지들도 있다. 어떤 문제가 있었고 어떻게 해결했는지 따로 정리도 할 겸 포스팅을 남긴다. 파일 타입 검증 강화에 따른 추가 기여최근 포스팅중에 Nest의 파일 타입 검증에 대한 보안 취약점에 기여할 뻔한 이야기라는 ..

Redis Client 모듈을 직접 만들고 NPM에 배포하기 (NestJS + ioredis)

동기  패키지 설치 시 경고 문구가 나오는 것을 정말 싫어한다. 새로운 서버 구축을 위해 Nest에서 기존에 사용하던 redis 모듈 오픈소스를 자연스레 설치했는데, 위와 같은 경고 문구가 발생했다. 경고에 따르면 이 오픈소스에서 terminus를 사용하는데, terminus의 의존성 버전들이 나의 현재 프로젝트 버전과 맞지 않는다고 한다. terminus?terminus를 간략히 설명하자면 NestJS에서 Health Check를 제공하는 모듈이다. 다양한 Health Indicator로 특히 마이크로서비스나 인프라등 애플리케이션의 기능들이 정상적으로 동작하는지 확인하는 기능을 제공하는 모듈이다. 단순 Redis Client Module에서 필요한 것은 아니라는 생각이 들었고, 위의 warning 의존..

하루만에 오픈소스에 기여하기 (nest)

처음으로 오픈소스에 기여해보았다 (feat. 오픈소스 멘토링)저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.부끄럽지만 너무 다가가기 어렵고 힘들었습니다. 하여 누구나 오픈소스에 쉽게 접했으면 하는 마음에 다소 가mag1c.tistory.com 오픈소스 멘토링 때 선정했던 두 가지 이슈 중에, 선택하지 않았던 nest의 file-validation-pipe의 이슈를 다시 살펴보았다.누군가가 PR을 하겠다고 코멘트가 달려있어서 선택하지 않았던 이슈였지만 누가 먼저 PR을 보내느냐가 중요하다던 말이 떠올랐다. 간단한 이슈였기 때문에 바로 PR을 보냈고, 곧바로 머지 되었다. (멤버분의 코멘트로 보아 11버전에서 업데이트 될 것 같다)    이슈 정의기존 NestJS의 파일 V..

처음으로 오픈소스에 기여해보았다 (feat. 오픈소스 멘토링)

저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.부끄럽지만 너무 다가가기 어렵고 힘들었습니다. 하여 누구나 오픈소스에 쉽게 접했으면 하는 마음에 다소 가벼운 스타일로 포스팅을 진행하려 합니다.  오픈소스에 기여하게 된 계기우리는 오픈소스를 쉽게 접하고 사용한다. 특히 node 진영에서는 npm install 딸깍 한 번이면 오픈소스를 쉽게 받아 사용할수 있다. 어제도 메세지큐를 사용하기 위해 bullmq @nestjs/bullmq를, UI Board를 위해 @bull-board/api와@bull-board/express를 갖다 썻으니 벌써 4개의 오픈소스를 사용한 셈이다.     동물은 죽어서 가죽을 남기고 사람은 죽어서 이름을 남긴다는데, 개발자로 살면서 나도 죽기전에 ..

Prisma v6.14.0의 성능 개선에 따른 브레이킹 체인지, 이에 기여한 이야기.

OpenSource 2025. 8. 22. 14:44
728x90
728x90

 

 

 

 

서론

오픈소스 메인 브렌치에 박제!

 


들어가기전 기여에 필요해서 Prisma에 대해 간단하게 뜯어본 포스팅이 있으니,

전반적인 내용 이해에 도움이 될 것 같아서 정리해놓았으니 필요 시 한 번 훑어보시길 권장드립니다.

 

Prisma는 왜 Type-Safe할까?

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장

mag1c.tistory.com

 

 

 

 

오픈소스 기여모임 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의 벤치마크 코드에도 잘 드러나있습니다.

// Prisma의 benchmark 코드 일부
bench('log config applied', () => {
  const client = new PrismaClientConstructor({
    log: [
      { level: 'query', emit: 'event' },
      { level: 'error', emit: 'stdout' },
      { level: 'warn', emit: 'stdout' },
    ],
  })

  /**
   * TypeScript는 덕타이핑을 사용하기 때문에,
   * passClientAround(client) 호출 시점에 client가 
   * PrismaClient 타입과 구조적으로 동일한지 모든 프로퍼티와 메서드를 비교합니다.
   */ 
  const passClientAround = (prisma: PrismaClient) => {
    return prisma
  }

  return passClientAround(client)
}).types([13720983, 'instantiations']) // 1300만건

 

 

2. 개선

위에서 정리한 내용을 바탕으로, 이 문제의 원인은 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에 디폴트 타입을 추가해줬습니다.

// Options, LogOpts에 디폴트 옵션 추가.
export interface PrismaClientConstructor {
  ${indent(this.jsDoc, TAB_SIZE)}
  new <
    Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
    LogOpts extends LogOptions<Options> = 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?: Prisma.Subset<Options, Prisma.PrismaClientOptions> ): PrismaClient<LogOpts, OmitOpts, ExtArgs>

 

 

이렇게 해서, 바로 PR이 merge가 되었고, 제 PR만 단독으로 머지된 탓에, 아마 Latest Commit에 제 프로필 사진이 올라가지 않았나 싶네요.

 

 

 

정리

Prisma를 거의 처음 사용해보면서, 오픈소스 기여를 위해 관련 코드를 깊이 파헤쳐보고 궁금증이 생겨 Prisma가 왜 Type-Safe한 ORM인지까지 돌아봤습니다. 물론 프리즈마 엔진 코드가 생소한 Rust이고, Prisma에 익숙하지 않아 분석이 다소 완벽하지 않았네요.

 

단 한 줄, 두 줄의 코드 변경으로 대다수의 개발자들에게 breaking changes 없이 확장된 기능, 더 좋은 퍼포먼스를 제공할 수 있는 기여를 했다는 생각에 현재까지 기여 중에 코드 길이 대비 가장 뿌듯했던 기여 순간이었던 것 같습니다. 더불어 TypeScript와도 조금 더 친해지는 계기가 되었던 것 같아요.

 

오픈소스 기여는 이렇게 단 한줄의 변경으로 수 억명의 사람들에게 임팩트를 줄 수 있고, 더불어 사용하고 있는 기술에 대한 깊은 이해, 기술의 기반이 되는 더 깊이 있는 지식까지도 습득할 수 있는 좋은 기회인 것 같습니다. 앞으로도 여기저기 사용하는 기술들에 대해 관심 있게 둘러 볼 예정입니다.

 

 

오픈 소스 기여에 어려움을 겪고 계신 분들이 있다면, 인제님이 운영하시는 오픈소스 기여 모임에 참여해보시는 것은 어떨까요?

다양한 분야에서 여러 기여를 하신 운영진분들과 참여자분들과 소통하면서, 이슈 선정부터 PR 기여까지 많은 도움을 얻을 수 있습니다!

 

 

 

 

 

 

참조

https://www.prisma.io/

https://www.typescriptlang.org/docs/handbook/2/generics.html

https://github.com/prisma/prisma/releases/tag/6.14.0

https://github.com/prisma/prisma/pull/27777

https://stackoverflow.com/questions/70545982/why-am-i-getting-type-instantiation-is-excessively-deep-and-possibly-infinite?utm_source=chatgpt.com

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Prisma는 왜 Type-Safe할까?

OpenSource 2025. 8. 21. 23:52
728x90
728x90

 

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.

제가 눈팅하는 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에서 실행합니다.

// packages/client/src/getDmmf.ts
const dmmf = await wasm.getDMMF({ datamodel: schema })

 

직접 wasm이 구현되어 있는 Rust코드를 당장에는 이해할 수 없어, 축약하자면 getDMMF를 통해 스키마가 해석되고, 결과는 JSON 형태의 DMMF로 변환됩니다. 이 JSON형태 위에 모델, 필드, 연관 관계등을 쉽게 탐색할 수 있도록 DMMF 클래스로 래핑되고, 모델/타입/연산을 빠르게 조회할 수 있는 Map이 만들어집니다.

 

 

3. TypeScript 코드 생성(PrismaClient)

이 과정 후에, PrismaClientTsGenerator에 의해 Prisma Client 코드를 생성하는데요.

export class TSClient {
  protected readonly dmmf: DMMFHelper
  protected readonly genericsInfo: GenericArgsInfo

  constructor(protected readonly options: TSClientOptions) {
    this.dmmf = new DMMFHelper(options.dmmf)
    this.genericsInfo = new GenericArgsInfo(this.dmmf)
  }

  generateClientFiles(): FileMap {
    const context = new GenerateContext({
      dmmf: this.dmmf,
      genericArgsInfo: this.genericsInfo,
      runtimeImport: `${this.options.runtimeBase}/${this.options.runtimeName}`,
      outputFileName: generatedFileNameMapper(this.options.generatedFileExtension),
      importFileName: importFileNameMapper(this.options.importFileExtension),
      generator: this.options.generator,
    })

    const modelNames = Object.values(context.dmmf.typeAndModelMap)
      .filter((model) => context.dmmf.outputTypeMap.model[model.name])
      .map((model) => model.name)

    const modelsFileMap: FileMap = modelNames.reduce((acc, modelName) => {
      acc[context.outputFileName(modelName)] = createModelFile(context, modelName)
      return acc
    }, {})

    return {
      [context.outputFileName('client')]: createClientFile(context, this.options),
      [context.outputFileName('enums')]: createEnumsFile(context),
      [context.outputFileName('commonInputTypes')]: createCommonInputTypeFiles(context),
      [context.outputFileName('models')]: createModelsFile(context, modelNames),
      models: modelsFileMap,
      internal: {
        [context.outputFileName('prismaNamespace')]: createPrismaNamespaceFile(context, this.options),
        [context.outputFileName('class')]: createClassFile(context, this.options),
      },
    }
  }
}

 

위에서 열심히 만든 JSON 래핑 클래스인 DMMFHelper을 토대로 PrismaClient를 생성합니다. 모델별 CRUD 메서드와 쿼리 옵션, 타입 시그니처들이 생성되게 되죠. 생성되는 대표 파일 구성은 대략 다음과 같습니다.

  • client: class PrismaClient { user: UserDelegate; ... } 및 각 모델 delegate 메서드 시그니처
  • models/*, models.ts: 모델 단위 타입 조각들
  • enums.ts: 스키마 enum → TS enum/type
  • commonInputTypes.ts: WhereInput, OrderBy, ScalarWhereWithAggregates 같은 공용 인풋 타입
  • internal/prismaNamespace.ts: Prisma.UserGetPayload<S>, Prisma.UserSelect 등 타입 네임스페이스
  • 런타임 번들 참조: 환경별(node, edge, react-native, wasm) 런타임 import 경로 세팅

 

 

그래서 왜 Prisma는 type-safe한데?

간략히 살펴본 generate 파이프라인의 결과물이 미리 계산된 타입 정의인데요.

이 타입 안에는 Model, Output, Payload라는 개념이 녹아있습니다.

  • Model: 스키마의 row에 대한 기본 스칼라가 typescript 인터페이스로 정의된 타입.
  • Output: 집계/그룹핑 처럼 형태가 확정적인 연산 결과는 AggregateUser, UserGroupByOutputType같은 명시적 Output 타입으로 노출돼요. row 관점의 원형 출력은 별도 파일/타입명으로 고정되어있지 않고 아래 Payload가 그 역할을 수행합니다.
  • Payload: 기본 정의된 Payload를 기반으로 제네릭 타입이 들어오면, 조건부/매핑 유틸리티 타입이 이를 해석해서 Payload를 즉석해서 추론합니다.
export type $UserPayload<ExtArgs = $Extensions.DefaultArgs> = {
  name: 'User';
  objects: {};                      // 관계(객체) 필드
  scalars: { id: number; name: string };  // 스칼라 필드
  composites: {};
}

type UserGetPayload<S extends boolean | null | undefined | UserDefaultArgs> =
  $Result.GetResult<Prisma.$UserPayload, S>;

 

 

다시 돌아와서, 위의 쿼리를 다시 살펴볼까요?

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 업데이트의 브레이킹 체인지를 해결하기 위한 기여를 했던 경험을 공유해보겠습니다.

 

모자란 글이지만 읽어주셔서 감사합니다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

오픈소스 기여하기) NestJS Express↔Fastify 미들웨어 등록 일관성 개선하기

OpenSource 2025. 7. 16. 23:24
728x90
728x90

nest의 e2e

이 글을 읽으시는 nest를 활용하는 개발자분들은, e2e를 작성하시나요? 어떻게 작성하고 계신가요?
보통은 nest의 공식 문서 가이드에 따라 TestingModule을 통해 NestApplicationinit한 후 사용하실 겁니다.

beforeAll(async () => {
  const moduleRef = await Test.createTestingModule({
    imports: [CatsModule],
  })
  .overrideProvider(CatsService)
  .useValue(catsService)
  .compile();
  
  app = moduleRef.createNestApplication();
  app.use(thirdPartyMiddleWare()); // injected middleware
  await app.init();
});

 

express에서는 미들웨어를 테스트 애플리케이션에 등록해야한다면, 반드시 초기화 전에 등록해야합니다. 그렇지 않으면 미들웨어가 등록되지 않죠.

 

 

 

 

일관되지 않은 개발 경험

아시겠지만, 이 테스팅 과정은 실제 프로덕션의 NestApplication을 생성하는 과정과는 조금 상이합니다.

 

 

nest 컨테이너가 어떻게 생성되고, 모듈 간의 의존성을 어떻게 효율적으로 찾아가는지 등은 이 포스팅에서 다루지 않습니다. 관련해서 궁금하시다면, 정리해놓은 포스팅들(포스팅1, 포스팅2)이 있으니 참고해보시면 좋을 것 같습니다.

 

요약하자면, NestFactory를 통해 NestApplication을 create 함수를 사용하여 내부적으로 초기화가 되어 나온 NestApplication 인스턴스를 사용할 수 있고, 이 애플리케이션에 필요한 미들웨어들을 주입하여 사용하게 됩니다.

const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.use(something);

 

 

 

여기까지 오셨다면, 뭔가 이상함을 느꼈을 수도 있습니다. 테스트 환경에서는 TestingModule의 createNestApplication 함수는 NestApplication을 초기화해주지 않습니다. 그래서 개발자가 직접 create > use > init 과정을 모두 호출하여 테스트에 필요한 애플리케이션을 생성하게 됩니다.

 

제가 감히 추측해보자면, 테스트 환경에서는 초기화 전 상태를 테스트하거나 모킹 등 더 세밀한 제어들이 필요하기 때문에 테스트 환경과 운영 환경의 애플리케이션 초기화 방식이 다를 것이라 생각합니다.

 

다시 돌아와서, 문제는 하나 더 발생합니다. express에서는 이 순서를 지키지 않아도, 테스트 애플리케이션의 초기화 과정에서 에러가 발생하지 않습니다.  하지만 fastify에서는 미들웨어를 등록하는 구간에서 에러가 발생합니다. 

 

describe("express (e2e)", () => {
  let app: NestExpressApplication;
  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = module.createNestApplication<NestExpressApplication>({
      bufferLogs: true,
    });
    await app.init();
    app.use(requestLoggerMiddleware);
  });
  
  describe("health", () => {
    it("should return healthy", async () => {
      await request(app.getHttpServer()).get("/health").expect(200);
    });
  });
});
describe("fastify (e2e)", () => {
  let app: NestFastifyApplication;
  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = module.createNestApplication<NestFastifyApplication>(
      new FastifyAdapter(),
      { bufferLogs: true }
    );
    app.use(requestLoggerMiddleware);
    await app.init();
    await app.getHttpAdapter().getInstance().ready();
  });
  describe("health", () => {
    it("should return healthy", async () => {
      await request(app.getHttpServer()).get("/health").expect(200);
    });
  });
});

 

 

 

 

 

 

왜 다른걸까?

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 향상에 미약하나마 도움이 되는 기여였으면 좋겠습니다.

 

항상 기여하면서 느끼는거지만, 오픈소스 자체에 깊은 이해와 더불어 오픈소스 구현에 사용된 다른 프레임워크나 그 근간이 되는 개념들을 자연스레 학습할 수 있게 되고, 더 깊게 이해할 수 있는 것 같습니다. 오픈소스의 진정한 가치는 이런 데서 나오는 게 아닌가 싶습니다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

오픈소스 기여가 어렵다구요? AI로 딸깍, 끝? (오픈소스 멘토링 8기 후기)

OpenSource 2025. 7. 2. 13:53
728x90
728x90
 

⭐오픈소스의 판도를 바꿀, AI로 오픈소스 기여 완벽 가이드와 프롬프트 공유!

직접 AI로 오픈소스 기여해본 후 느꼈던 충격을 공유합니다!

medium.com

 

 

오픈소스에 기여해보고 싶다고 처음 생각했던 때가 2023년 12월 DevFest에서, 첫 PR을 날린건 2024년 11월.

 

왜 이렇게 오래걸렸을까?

오픈소스는 뭔가 어려워보이잖아.. 라고 생각해서? 소위 진입장벽 때문에..?

 

 

 

하지만 이번 오픈소스 멘토링에서 이 진입장벽을 깨부술 엄청난 것을 겪게 되었다.

 

 

 

이슈 선정이 힘들어? AI로 딸깍

오픈소스 기여에 막막했을 때를 회고해보자면 기여의 스타트인 이슈 선정부터 막막했다. 정말 막막했다.

 

"내가 이거 해도 되나?"

"이슈를 어떻게 찾지?"

"아 전부다 영어네 ㅋㅋ"

 

 

 

 

 

 

오픈소스 멘토링 8기에서는, 위 오픈소스 멘토링의 포스팅을 기반으로 오픈소스의 진입장벽을 다 부숴버렸다.

AI 프롬프트에 원하는 오픈소스 이슈 URL을 같이 넣어주면 위와 같이 알아서 분석해줘서 많이 놀랬다.

이후 PR을 날리는 과정까지 AI가 100% 작업하도록 프롬프트 세팅만 하면 모든 것이 끝난다고 했다.

 

 

 

 

후기

결론부터 말하자면, 정말 AI 만으로 기여가 가능했다.

 

nodejs의 http.Agent에 대한 문서 설명 중, 커넥션 생성 함수의 동작 방식에 부정확한 설명이 있었다.

AI의 도움, 아니 AI만으로 으로 해당 이슈를 분석하고, PR까지 생성했다. 혹시 몰라 http.js와 https.js 등 관련 코드베이스를 직접 들여다보고 크로스 체크했지만 생성된 PR에 틀린 내용은 없었다.

 

오픈소스 입문이 나와 비슷한 이유들로 어렵게 느껴지거나, 단순 기여에 초점을 맞춘 경우라면 얼마든지 AI를 활용해서 기여를 해도 될 것이라는 생각이 든다.

 

하지만 이 경험을 통해 느낀 것은 오픈소스를 사용하는 모두와의 소통, 오픈소스의 기술 철학과 컨벤션을 배워가는 과정과 모든 과정을 통해 성장하는 이 완벽한(?) 사이클이 깨질 수도 있겠다는 것이다.

 

그렇기 때문에 AI를 오픈소스 기여에 활용하더라도, 이슈 선정과 기여 방향성 잡기 정도에 활용하고 딥한 분석과 PR 생성은 내 손으로, 내 시선으로 만들어가야겠다고 생각했다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

25년 상반기(~4월)의 오픈소스 기여

OpenSource 2025. 4. 30. 17:52
728x90
728x90

 

 

하루만에 오픈소스에 기여하기 (nest)

처음으로 오픈소스에 기여해보았다 (feat. 오픈소스 멘토링)저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.부끄럽지만 너무 다가가기 어렵고 힘들었습니다. 하

mag1c.tistory.com

 

 

작년, 오픈소스 기여에 첫 발을 떼게 해주신 인제님의 오픈소스 멘토링 이후, 기여할 수 있는 부분들에 꾸준히 기여를 해왔다.

이번에도 nest, nestjs/swagger에 기여를 했고, 아직 머지되지 않은 기여도 있고 바로 반영된 머지들도 있다. 어떤 문제가 있었고 어떻게 해결했는지 따로 정리도 할 겸 포스팅을 남긴다.

 

 

파일 타입 검증 강화에 따른 추가 기여

최근 포스팅중에 Nest의 파일 타입 검증에 대한 보안 취약점에 기여할 뻔한 이야기라는 제목으로 글을 썻다. 위 포스팅의 기여 내용의 후속으로 개발 편의를 위한 PR을 만들게 되었으며, 기여 내용을 이해하기 위해 이전 포스팅을 간략하게나마 읽어보는 것을 추천한다.

 

파일 검증에 대한 보안 취약점을 개선하기 위해 Nest의 FileTypeValidator에 파일의 Magic Number(바이너리 시그니처)를 기반으로 파일 타입을 검사하도록 변경되었다. 이 변경은 보안적으로 더 개선되었지만, 실제 애플리케이션에서는 다음과 같은 부작용을 낳았다.

  1. .txt, .csv, .json 등 Magic Number가 없거나 너무 짧은 파일들은 파일 타입을 판단하지 못해 업로드 실패로 이어진다. 이로 인해 기존 애플리케이션에서 잘 작동하던 업로드 로직이 갑자기 실패하게 되었다.
  2. 에러메세지가 기존과 같이 file type에 대한 에러 메세지가 나오게 되어 왜 실패했는지 알 수 없다.

 

위 문제를 바로 피부로 겪었는데, 현재 조직에서도 새로 구축하고 있던 애플리케이션에서 잘 동작하던 파일 업로드가 갑자기 되지 않았고, 문제를 직접 해결하기 위해 나는 유연한 검증을 위한 옵션과, 에러 메세지의 적절한 분기 처리를 PR로 제출했다.

 

feat(common): Add fallbackToMimetype support in FileTypeValidator by mag123c · Pull Request #14995 · nestjs/nest

Introduce fallbackToMimetype option to allow fallback to mimetype validation when magic number detection fails (e.g., for small or undetectable buffers like text or CSV files). Also enhanced buildE...

github.com

 

 

 

 

OpenAPI 커스텀 헤더 추가

Swagger은 OpenAPI의 표준이 아닌 확장(사용자 정의) 속성을 지원하고 있는데, SecuritySchema는 이 확장 속성들 중 API 요청을 인증할 때 어떤 방식으로 인증해야하는 지 정의하는 설정이다. Nest에서는 이 옵션을 래핑하는 DocumentBuilder을 메서드 체이닝을 이용하여 보통 아래처럼 스웨거 명세를 만든다.

return new DocumentBuilder()
    .setTitle('API')
    .addBearerAuth(
        {
            type: 'http',
            scheme: 'bearer',
            name: 'JWT',
            in: 'header',
        },
        'access-token',
    )
    .addBasicAuth(
        {
            type: 'apiKey',
            scheme: 'apiKey',
            name: 'apiKey',
            in: 'header',
        },
        'X-Nonce',
    )
    .build();


하지만, OpenAPI Extensions 문서의 예시처럼, 이 API Key에 추가적인 필수 옵션들이 들어가야하는 상황이라면? 예를 들어 AWS API Gateway처럼 특정 플랫폼에 맞춘 커스텀 인증을 구현해야하는 상황에서, 스웨거를 통해서는 헤더에 원하는 커스텀 옵션을 넣을 수 없게 되어있다.

 

장황한 설명을 했지만, 이에 기여하기 위해 작성한 코드는 그저 옵션 추가 단 한 줄이다. (테스트 코드도 없다.)

/**
 * SecuritySchemes Additional extension properties
 * @issue https://github.com/nestjs/swagger/issues/3179
 * @see https://swagger.io/docs/specification/v3_0/openapi-extensions/
 */
[extension: `x-${string}`]: any;

 

 

feat(swagger): add extension in SecuritySchemeObject by mag123c · Pull Request #3248 · nestjs/swagger

PR Checklist Please check if your PR fulfills the following requirements: The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md Tests for the chan...

github.com

 

 

 

 

Swagger의 컴파일 타임 옵션 추가

@ApiProperty({
  enum: SubjectEnum,
  enumName: 'Subject',
})
readonly __caslSubjectType__: SubjectEnum = this.constructor.name as SubjectEnum;

// Swagger Schemes
"__caslSubjectType__": {
  "default": "Function",
  "allOf": [
    {
      "$ref": "#/components/schemas/Subject"
    }
  ]
}

 

Nest의 Swagger Plugin은 컴파일 타임에 ts의 Transformer을 사용해서 Swagger 명세를 추상 구문 트리로 만들어낸다. JavaScript 엔진을 공부할 때 나오는 그 추상 구문 트리(Abstract Syntax Tree) 이다. 이렇기에 위 예제처럼 런타임 시에 정해지는 값은 추론되지 않아 JavaScript 함수의 name값인 Function을 넣어버리게 된다.

 

컴파일 타임에 제어하기 위해, Swagger Plugin Option을 추가하고, 옵션에 따라 아예 옵션을 활성화하면, Default로 추론할 수 없는 값들은 Swagger 명세에 Default로 추가되지 않는다.

// 1. 초기값이 없음
@ApiProperty()
readonly type: string;

// 2. 추론이 어려운 표현식
@ApiProperty()
readonly subjectType: SubjectEnum = this.constructor.name as SubjectEnum;

 

 

feat(swagger-plugin): add skipDefaultValues option to omit unspecified default fields and corresponding test by mag123c · Pull

PR Checklist Please check if your PR fulfills the following requirements: The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md Tests for the chan...

github.com

 

 

 

 

 

 

마무리

(위 내용보다 더 간단하거나 공식 문서에 대한 기여 등은 작성하지 않았다.)

 

기여를 계속 하다보니 자연스레 깨닫게 된 점이 있다. 바로 조직 내에서 사용하는 기술 스택에 익숙해져감에 따라, 그것이 나의 개발 판단 기준을 점점 좁히고 있었다는 점이다. 예를 들어, 런타임 타입 검사를 위한 Zod를 typia로 변경하여 훨씬 빠르고 간결한 코드 작성이 가능한 오픈소스도 있고, 항상 tsc로 트랜스파일하던 것을 swc로 바꿔본다던가 하는 등의 퍼포먼스 개선이 가능하다. 당장 적용해도 충분히 기술 선택의 근거가 명확하고, 임팩트가 있는 선택지들이 많다는 걸 느낀다.

 

오픈 소스 기여는 단지 코드 변경에 머무르지 않는다. 생태계의 흐름을 눈으로 확인하고, 직접 만지며 왜 이런 기능이 추가됐는지를 맥락과 함께 이해하는 경험이라고 생각한다. 이런 과정을 겪으며 자연스럽게 내 기술의 선택 기준도 명확해지고 조직 내 기술뿐 아니라 개인적으로 사용할 기술에도 더 많은 관심을 기울이게 된다.

 

내가 사용하는 기술을 불편 없이 쓰는 것에서 멈추는 것이 아니라, 더 나은 방향으로 기여하거나 기존에 없는 기능을 직접 만들어내는 것.

결국 불편함을 느끼고 나만의 방식으로 개선해보는 경험을 하나씩 쌓아나가는 것이 가장 이상적인 형태가 아닐까 싶다.

 

장황하게 나열했는데, 여튼 다음에는 조금 더 다양한 기여 경험을 가지고 돌아오도록 하겠다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Redis Client 모듈을 직접 만들고 NPM에 배포하기 (NestJS + ioredis)

OpenSource 2025. 3. 6. 20:30
728x90
728x90

동기

 

 

패키지 설치 시 경고 문구가 나오는 것을 정말 싫어한다. 새로운 서버 구축을 위해 Nest에서 기존에 사용하던 redis 모듈 오픈소스를 자연스레 설치했는데, 위와 같은 경고 문구가 발생했다. 경고에 따르면 이 오픈소스에서 terminus를 사용하는데, terminus의 의존성 버전들이 나의 현재 프로젝트 버전과 맞지 않는다고 한다.

 

terminus?

terminus를 간략히 설명하자면 NestJS에서 Health Check를 제공하는 모듈이다. 다양한 Health Indicator로 특히 마이크로서비스나 인프라등 애플리케이션의 기능들이 정상적으로 동작하는지 확인하는 기능을 제공하는 모듈이다. 단순 Redis Client Module에서 필요한 것은 아니라는 생각이 들었고, 위의 warning 의존성 문구와 더불어 이런 이유들로 인해 직접 NestJS의 11버전과 호환되는 모듈을 직접 만들어 배포하기로 했다.

 


(아래는 이번 포스팅의 주제인 직접 만든 Redis Module 라이브러리)

 

 

nestjs-ioredis-module

Redis Module With Nest, ioredis. Latest version: 1.0.14, last published: 3 hours ago. Start using nestjs-ioredis-module in your project by running `npm i nestjs-ioredis-module`. There are no other projects in the npm registry using nestjs-ioredis-module.

www.npmjs.com

 

 


 

 

NestJS Module System

NestJS는 기본적으로 Static Module과 Dynamic Module을 통해 모듈을 생성할 수 있다. 이 모듈들은 모두 런타임에 모듈을 스캔하여 내부 구조를 구성하고, 각 모듈마다 식별자인 불투명 키를 생성한다.

(모듈 구성에 대한 자세한 설명은 Nest11 릴리즈 포스팅 분석글에서 확인할 수 있다.)

 

 

NestJS의 DynamicModule은 동적으로 옵션을 받아서 런타임에 원하는 모듈을 구성할 수 있다.
물론 StaticModule 역시 런타임의 환경 변수 값 등의 변동에 따른 동적인 모듈 세팅을 할 수 있으나, 아래처럼 Redis를 상황에 따라 클러스터 모드로 모듈을 구성해야하는 경우, 극단적으로 외부 API 호출 값에 따라 변동되는 모듈 세팅 등 실행 환경에 따라 모듈 내부의 Providers 등을 다르게 주입해야한다면 StaticModule로는 불가능하다. (Nest에는 TypeOrmModule, ConfigModule 등 다양한 곳에 DynamicModule이 사용되고 있다.)

(Nest에는 TypeOrmModule, ConfigModule 등 다양한 곳에 DynamicModule이 사용되고 있다.)

 

// Static Module
@Module({
  providers: [
    {
      provide: 'REDIS_OPTIONS',
      useValue: {
        host: process.env.REDIS_HOST || 'localhost',
        port: process.env.REDIS_PORT || 6379,
      },
    },
    RedisService,
  ],
  exports: [RedisService],
})
export class RedisModule {}
// Dynamic Module
@Module({})
export class RedisModule {
  static forRoot(options: { cluster: boolean; nodes?: RedisOptions[]; single?: RedisOptions }): DynamicModule {
    const redisProvider: Provider = options.cluster
      ? {
          provide: 'REDIS_CLIENT',
          useFactory: () => new Redis.Cluster(options.nodes),
        }
      : {
          provide: 'REDIS_CLIENT',
          useFactory: () => new Redis(options.single),
        };

    return {
      module: RedisModule,
      providers: [redisProvider],
      exports: [redisProvider],
    };
  }
}

@Module({
  imports: [
    RedisModule.forRoot({
      cluster: process.env.USE_REDIS_CLUSTER === 'true',
      nodes: process.env.USE_REDIS_CLUSTER === 'true' ? [{ host: 'node1' }, { host: 'node2' }] : undefined,
      single: process.env.USE_REDIS_CLUSTER !== 'true' ? { host: 'localhost', port: 6379 } : undefined,
    }),
  ],
})
export class AppModule {}

 

 

 

ioredis

라이브러리를 만들어보기 전에, 코어가 되는 의존성인 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을 분리하여 제작하였다.

// redis.module.ts
import { DynamicModule, Module, Provider } from '@nestjs/common';
import { REDIS_OPTIONS } from './redis.constasnts';
import { RedisProvider } from './redis.providers';
import { RedisModuleAsyncOptions, RedisModuleOptions } from './redis.interface';

@Module({})
export class RedisModule {
    static forRoot(options: RedisModuleOptions): DynamicModule {
        const optionsProvider: Provider = {
            provide: REDIS_OPTIONS,
            useValue: { ...options },
        };

        return {
            module: RedisModule,
            providers: [optionsProvider, RedisProvider],
            exports: [RedisProvider],
        };
    }

    static forRootAsync(options: RedisModuleAsyncOptions): DynamicModule {
        const asyncProvider: Provider = {
            provide: REDIS_OPTIONS,
            useFactory: options.useFactory,
            inject: options.inject || [],
        };

        return {
            module: RedisModule,
            imports: options.imports || [],
            providers: [asyncProvider, RedisProvider],
            exports: [RedisProvider],
        };
    }
}

 

 

Redis Module은 외부에서는 Redis Client를 동적으로 옵션을 넣어 생성할 수 있는 형태로, RedisProvider은 신경쓰지 않고, 옵션만 넣어서 레디스 모듈을 구성할 수 있게 만들었다.

 

// redis.interface.ts
import { ClusterNode, ClusterOptions, RedisOptions } from 'ioredis';

export interface RedisSingleOptions {
    type: 'single';
    host: string;
    port: number;
    commonOptions?: RedisOptions;
}

export interface RedisClusterOptions {
    type: 'cluster';
    nodes: ClusterNode[];
    commonOptions: ClusterOptions;
}

export type RedisModuleOptions = RedisSingleOptions | RedisClusterOptions;

export interface RedisModuleAsyncOptions {
    imports: any[];
    inject: any[];
    useFactory: (...args: any[]) => RedisModuleOptions | Promise<RedisModuleOptions>;
}

 

 

 

// redis.provider.ts
import { Provider } from '@nestjs/common';
import { REDIS_CLIENT, REDIS_OPTIONS } from './redis.constasnts';
import { createRedisClient } from './redis.factory';
import { RedisModuleOptions } from './redis.interface';

export const RedisProvider: Provider = {
    provide: REDIS_CLIENT,
    useFactory: async (options: RedisModuleOptions) => createRedisClient(options),
    inject: [REDIS_OPTIONS],
};

 

 

모듈 생성과 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 { Global, Module } from '@nestjs/common';
import { RedisModule } from 'nestjs-ioredis-module';
import { RedisStringManager } from './managers/string.manager';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Global()
@Module({
    imports: [
        RedisModule.forRootAsync({
            imports: [ConfigModule],
            useFactory: async (configService: ConfigService) => ({
                type: 'single',
                host: configService.get<string>('REDIS_HOST') ?? 'localhost',
                port: configService.get<number>('REDIS_PORT') ?? 6379,
            }),
            inject: [ConfigService],
        }),
    ],
    providers: [RedisStringManager],
    exports: [RedisStringManager],
})
export class CustomRedisModule {}
import { InjectRedis } from 'nestjs-ioredis-module';
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { RedisStringOperation } from '../interfaces/redis-operation.interface';

@Injectable()
export class RedisStringManager implements RedisStringOperation {
    constructor(@InjectRedis() protected readonly redis: Redis) {}

    protected getInstance(): Redis {
        return this.redis;
    }

    async set<T>(key: string, value: T): Promise<T | null> {
        return await this.redis.set(key, JSON.stringify(value)).then((context) => (context === 'OK' ? value : null));
    }

    async setex<T>(key: string, value: T, expire: number): Promise<T | null> {
        return await this.redis
            .setex(key, expire, JSON.stringify(value))
            .then((context) => (context === 'OK' ? value : null));
    }

    async get<T>(key: string): Promise<T | null> {
        return await this.redis.get(key).then((value) => (value ? JSON.parse(value) : null));
    }

    async del(key: string): Promise<boolean> {
        return await this.redis
            .del(key)
            .then(() => true)
            .catch(() => false);
    }
}



현재 만들고 있는 서버 내부에서 레디스 관련 모듈의 응집을 위해 사용되는 CustomRedisModule에 적용시켰다.

적용시킨 CustomRedisModule을 바탕으로 각 모듈에서 사용할 수 있는지 또한 확인해보기 위해 레디스가 정상 연결되었는지, 또 주입받아 사용하는 구간들에서 잘 적용이 되었는지 테스트 해보기로 했다.

 

import { Module } from '@nestjs/common';
import { AuthController } from './controllers/auth.controller';
import { AuthService } from './services/auth.service';
import { RedisAuthStore } from './services/redis-auth-store';
import { SmsModule } from '../sms/sms.module';
import { NONCE_STORAGE, OTP_STORAGE } from '../../common/constants/tokens';

@Module({
    imports: [SmsModule],
    providers: [
        AuthService,
        RedisAuthStore,
        {
            provide: NONCE_STORAGE,
            useExisting: RedisAuthStore,
        },
        {
            provide: OTP_STORAGE,
            useExisting: RedisAuthStore,
        },
    ],
    controllers: [AuthController],
})
export class AuthModule {}
import { Injectable } from '@nestjs/common';
import { RedisStringManager } from '../../../infra/redis/managers/string.manager';
import { OtpGenerateDto } from '../dtos/otp.dto';
import { NonceStorage } from '../interfaces/nonce-storage.interface';
import { OtpStorage } from '../interfaces/otp-storage.interface';

@Injectable()
export class RedisAuthStore implements NonceStorage, OtpStorage {
    constructor(private readonly redis: RedisStringManager) {}

    async setNonce(key: string | number, value: string, ttl: number = 600): Promise<string | null> {
        return await this.redis.setex<string>(this.generateNonceKey(key), value, ttl);
    }

    async getNonce(key: string | number): Promise<string | null> {
        return await this.redis.get<string>(this.generateNonceKey(key));
    }

    async delNonce(key: string | number): Promise<boolean> {
        return await this.redis.del(this.generateNonceKey(key));
    }

    async setOtp(key: string | number, value: OtpGenerateDto, ttl: number = 300): Promise<string | null> {
        return await this.redis.setex<string>(this.generateOtpKey(key), JSON.stringify(value), ttl);
    }

    async getOtp(key: string | number): Promise<OtpGenerateDto | null> {
        const response = await this.redis.get<string>(this.generateOtpKey(key));
        return response ? (JSON.parse(response) as OtpGenerateDto) : null;
    }

    async delOtp(key: string | number): Promise<boolean> {
        return await this.redis.del(this.generateOtpKey(key));
    }

    generateNonceKey(key: string | number): string {
        return `freelancer:login:${key}`;
    }

    generateOtpKey(key: string | number): string {
        return `otp:${key}`;
    }
}

 

 

 

 

테스트

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와의 연계만 시켜주면 되기 때문에 쉬운 편이라고 생각한다. 하지만 난이도를 떠나 항상 무엇을 개선할 수 있을지 생각해보고 적용시킬 수 있다면 머리통이 깨지더라도(?) 적용해보는 편이 좋겠다는 생각이 든다. 오픈소스에서 필요 없는 기능을 빼서 커스터마이징하기만 하더라도 작게나마 리소스를 절감할 수 있기도 하고, 과정에서 배우는 것이 많기 때문이다. 단순히 필요 없는 기능을 걷어내고 최적화하는 과정도 소중하지 않을까? 라는 생각으로 마무리해본다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

하루만에 오픈소스에 기여하기 (nest)

OpenSource 2024. 11. 28. 11:15
728x90
728x90

 

 

처음으로 오픈소스에 기여해보았다 (feat. 오픈소스 멘토링)

저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.부끄럽지만 너무 다가가기 어렵고 힘들었습니다. 하여 누구나 오픈소스에 쉽게 접했으면 하는 마음에 다소 가

mag1c.tistory.com

 

오픈소스 멘토링 때 선정했던 두 가지 이슈 중에, 선택하지 않았던 nest의 file-validation-pipe의 이슈를 다시 살펴보았다.

누군가가 PR을 하겠다고 코멘트가 달려있어서 선택하지 않았던 이슈였지만 누가 먼저 PR을 보내느냐가 중요하다던 말이 떠올랐다. 간단한 이슈였기 때문에 바로 PR을 보냈고, 곧바로 머지 되었다.

 

(멤버분의 코멘트로 보아 11버전에서 업데이트 될 것 같다)

 

 

 

 

이슈 정의

기존 NestJS의 파일 Validation에서, 에러 메세지에 실제 파일 정보가 담기지 않는다. 제한된 설정값만 담겨있기 때문에 사용자가 어떤 문제가 발생했는지 명확하게 이해하지 못한다.

 

 

 

 

이슈 분석

nest의 공식문서에 따르면, 파일 업로드 시 인터셉터에서 파일을 컨텍스트에 담고 @UploadFile() 데코레이터를 사용해 request 컨텍스트 내부에서 파일을 추출하기만 하면 된다고 안내되어 있다.

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
  console.log(file);
}

 

 

PipeTransForm 인터페이스를 통해 따로 ValidationPipe을 구현해서 사용해도 되지만, Nest에서는 파일에 대한 표준 내장 ParseFilePipe을 제공하고, ParseFilePipe에 FileValidator 추상 클래스를 파라미터로 받아서 사용할 수 있다.

export class ParseFilePipe implements PipeTransform<any> {
  protected exceptionFactory: (error: string) => any;
  private readonly validators: FileValidator[];
  private readonly fileIsRequired: boolean;

  constructor(@Optional() options: ParseFileOptions = {}) {
    const {
      exceptionFactory,
      errorHttpStatusCode = HttpStatus.BAD_REQUEST,
      validators = [],
      fileIsRequired,
    } = options;

    this.exceptionFactory =
      exceptionFactory ||
      (error => new HttpErrorByCode[errorHttpStatusCode](error));

    this.validators = validators;
    this.fileIsRequired = fileIsRequired ?? true;
  }
}
export interface ParseFileOptions {
  validators?: FileValidator[];
  errorHttpStatusCode?: ErrorHttpStatusCode;
  exceptionFactory?: (error: string) => any;

  fileIsRequired?: boolean;
}

 

 

내장 FileValidator을 사용할 경우 @UploadFile()의 request 컨텍스트의 파일을 넘겨받아 벨리데이션이 가능하다.

(request의 파라미터(@param())에서 파일을 가지고 있고, 공식문서에서는 이를 FileValidator에 넘겨 사용한다고 한다.)

export abstract class FileValidator<
  TValidationOptions = Record<string, any>,
  TFile extends IFile = IFile,
> {
  constructor(protected readonly validationOptions: TValidationOptions) {}

  abstract isValid(
    file?: TFile | TFile[] | Record<string, TFile[]>,
  ): boolean | Promise<boolean>;

  abstract buildErrorMessage(file: any): string;
}
export interface IFile {
  mimetype: string;
  size: number;
}

 

 

 

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})`;
  }
}

 

 

 

이렇게, 선정했던 두 가지의 이슈를 모두 직접 해결할 수 있어서 뿌듯하다.

 

 

앞으로도 기여하고 싶은 오픈소스에 기웃거리면서 하나하나 해결하다보면 직접 이슈를 생성할 수 있는 날이 오지 않을까? 하는 기대가 든다. 점점 성장하는 오픈소스 포스팅이 되길 기원한다. 아자아자!

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

처음으로 오픈소스에 기여해보았다 (feat. 오픈소스 멘토링)

OpenSource 2024. 11. 24. 23:16
728x90
728x90

 
저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.
부끄럽지만 너무 다가가기 어렵고 힘들었습니다.
하여 누구나 오픈소스에 쉽게 접했으면 하는 마음에 다소 가벼운 스타일로 포스팅을 진행하려 합니다.

 
 

오픈소스에 기여하게 된 계기

우리는 오픈소스를 쉽게 접하고 사용한다. 특히 node 진영에서는 npm install 딸깍 한 번이면 오픈소스를 쉽게 받아 사용할
수 있다. 어제도 메세지큐를 사용하기 위해 bullmq @nestjs/bullmq를, UI Board를 위해 @bull-board/api와
@bull-board/express를 갖다 썻으니 벌써 4개의 오픈소스를 사용한 셈이다.
 

 
 
 
 
동물은 죽어서 가죽을 남기고 사람은 죽어서 이름을 남긴다는데, 개발자로 살면서 나도 죽기전에 나의 개발 환경에서 내가 사용하는 오픈소스에 GitHub 닉네임을 어디엔가라도 반드시 남겨야겠다고 생각이 들어 오픈소스에 기여를 결심했다.
 

 
 
 
농담 반 진담 반 섞인 쳐맞는 소리(?)에 더해서, 내가 사용하고 있는 소스를 더 깊이 알기 위해서 오픈소스에 기여해야겠다는 생각을 하게 되었다. 이 모든 생각의 출발점은 작년 devfest에서 김인제 님의 오픈소스 기여로 수억명 에게 임팩트 만들기 세션을 들으면서다.
 
작년 요맘때 쯤 devfest가 열렸으니 벌써 1년 전부터 마음을 품었지만, 막상 기여 후기들을 찾아보며 혼자 힘으로 해결하려다보니 막막했다. 처음 개발을 시작했을 때보다 더 크고 웅장한 거대한 벽이었다. 홀로 서기가 도저히 어려울 것 같아 8개월 넘게 눈팅하던 오픈소스 멘토링을 신청하게 되었다.
 

 
 
제목과 부제목만 봐도 든든한, 엄청난 뒷배(?)를 두고 오픈소스 기여에 첫 걸음마를 떼는 순간이었다.
 

 

OpenSource Contributors – Medium

Stories about how we contribute to open source and make world better!

medium.com

 
 
 

이슈 선정

오픈소스의 코드를 직접 다루는 것도 어려울 수 있지만, 보통의 진입장벽은 이슈 선정에서부터 시작된다. 나 역시 이슈 선정 단계에서 막혔었다. 내가 올리지 않은 이슈인데 뺏는 것 아닌가 싶기도 했고, 도대체 어떤 이슈에 PR 요청을 할 수 있는 것인지 감이 오지 않았다.
 
멘토링에 선정되고 모든게 변했다. 개안이라도 한 것 처럼 선정할 수 있는 이슈가 눈에 보이기 시작했다.

 
 
인제님과 함께하기로 한 순간 이슈들을 잘 골라낼 수 있었고, 내가 기여하고 싶었던 nest진영에서 비교적 최근에 올라온 두 가지의 이슈를 선정할 수 있었다.
 
https://github.com/nestjs/nest/issues/14070
https://github.com/nestjs/swagger/issues/3157
 
개인적으로 nest의 이슈(FileValidator 개선)를 선택하고 싶었으나, 이미 누군가 먼저 코멘트를 달아 PR을 생성하려고 하는 것 같아, swagger의 기능 요청 이슈를 선택하였다.
 
(멘토링 당일 알게 된 사실인데, PR은 먼저 올리는 사람이 임자다. 동방예의지국답게 먼저 PR을 올려도 되는지 세심하게 물어보고, 허가가 떨어지면 진행할 필요가 없다고...)

 

( 그래서 첫 번째 이슈 또한 최근 PR을 날려 머지되었다. )

 

하루만에 오픈소스에 기여하기 (nest)

처음으로 오픈소스에 기여해보았다 (feat. 오픈소스 멘토링)저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.부끄럽지만 너무 다가가기 어렵고 힘들었습니다. 하

mag1c.tistory.com

 

 
이슈를 요약하자면, swaggerUiEnabled라는 옵션을 꺼도 API 문서인 YAML/JSON은 URI로 접근이 가능했고, 어떤 이유로든 API 명세를 공개하고 싶지 않는 경우가 있으니 기능을 추가해달라는 요청이었다.
 
 

기존 소스 분석

public static setup(
    path: string,
    app: INestApplication,
    documentOrFactory: OpenAPIObject | (() => OpenAPIObject),
    options?: SwaggerCustomOptions
)

 
 
nest에서 swagger을 사용할 때, 아래처럼 SwaggerModule.setup()에 원하는 세팅을 인자값으로 넣어 셋업하게 된다.
SwaggerModule은 서버 프레임워크(Express, Fastify)를 추상화한 Nest의 HttpServer을 활용하여 Swagger UI와 API 명세(JSON/YAML)를 클라이언트에 서빙하는 기능을 제공한다.

//사이드 프로젝트의 swagger setup
export const setUpSwagger = (app: INestApplication) => {
    const document = SwaggerModule.createDocument(app, swaggerConfig());
    const swaggerOptions: SwaggerCustomOptions = {
        swaggerOptions: {
            persistAuthorization: true,
        },
    };

    SwaggerModule.setup('/docs', app, document, swaggerOptions);

    if (!isDev()) {
        app.use(['/docs'], swaggerAuthConfig());
    }
};

export const swaggerConfig = () => {
    return new DocumentBuilder()
        .setTitle('Ounwan API V2')
        .setDescription('Migration Since 2024. 10 ~')
        .setVersion('1.0')
        .addTag('[Ounwan API V2]')
        .addBearerAuth(
            {
                type: 'http',
                scheme: 'bearer',
                name: 'JWT',
                in: 'header',
            },
            'accessToken',
        )
        .build();
};

export const swaggerAuthConfig = () => {
    return expressBasicAuth({
        challenge: true,
        users: {
            [process.env.SWAGGER_USER as string]: process.env.SWAGGER_PASSWORD as string,
        },
    });
};

 
 
setup의 인자값 중 SwaggerCustomOptions 인터페이스에는 UI를 ON/OFF하는 swaggerUiEnabled가 있다.
이 값을 false로 설정해도 JSON과 YAML은 반드시 제공되게 되어있다.

export interface SwaggerCustomOptions {
  /**
   * If `false`, only API definitions (JSON and YAML) will be served (on `/{path}-json` and `/{path}-yaml`).
   * This is particularly useful if you are already hosting a Swagger UI somewhere else and just want to serve API definitions.
   * Default: `true`.
   */
  swaggerUiEnabled?: boolean;
  
  //...생략...
}

 

 
 
실제로 swaggerUiEnabled를 false로 테스트를 해보면, UI만 404가 발생하는 것을 알 수 있다.
 
 
소스 코드를 보면서 정리한 SwaggerModule에서의 setup과정은 아래와 같다.

 

  • serveDocument: 문서를 생성한다. 여기에는 UI와 API 명세가 포함된다.
  • serveUi: UI를 생성한다. 우리가 흔히 보는 Swagger UI이며, swaggerUiEnabled가 false라면 생성되지 않는다.
  • serveDefinitions: API 명세를 생성한다.
  • serveStatic: Swagger UI의 정적 파일(css, js)을 담당한다.

 
 
 

코드 작성

분석을 얼추 해보니, 어느 구간에 어떤 코드를 작성해야하는지 명확하게 보였고 세 가지 방향이 생각났다.

  • swaggerUiEnabled 플래그에 JSON/YAML까지 묶어서 처리
  • JSON/YAML용 플래그를 통한 구현
  • 특정 옵션만 켤 수 있게 세분화 (ex: JSON만 ON)

원초적으로 이슈의 기능 요구사항에 가장 부합하다고 판단하여 JSON/YAML 플래그를 따로 구성하여 해결했다.
 
documentEnabled라는 플래그를 만들어서, 위 분석 결과를 바탕으로 serveDocument 내부에서 documentEnabled = true일 때만 API 명세를 만들어주도록 변경하면 될 것 같았다.
 

export interface SwaggerCustomOptions {
  /**
   * If `false`, the Swagger UI will not be served. Only API definitions (JSON and YAML)
   * will be accessible (on `/{path}-json` and `/{path}-yaml`).
   * To fully disable both the Swagger UI and API definitions, use `documentsEnabled: false`.
   * Default: `true`.
   */
  swaggerUiEnabled?: boolean;

  /**
   * If `false`, both the Swagger UI and API definitions (JSON and YAML) will be disabled.
   * Use this option when you want to completely hide all Swagger-related endpoints.
   * Default: `true`.
   */
  documentsEnabled?: boolean;
}

 

import { HttpServer } from '@nestjs/common/interfaces/http/http-server.interface';
import { OpenAPIObject, SwaggerCustomOptions } from './interfaces';

protected static serveDocuments(
    finalPath: string,
    urlLastSubdirectory: string,
    httpAdapter: HttpServer,
    documentOrFactory: OpenAPIObject | (() => OpenAPIObject),
    options: {
        swaggerUiEnabled: boolean;
        documentsEnabled: boolean;
        jsonDocumentUrl: string;
        yamlDocumentUrl: string;
        swaggerOptions: SwaggerCustomOptions;
    }
) {
	//...생략...

    // Skip registering JSON/YAML endpoints if documentsEnabled is false
    if (options.documentsEnabled) {
	this.serveDefinitions(httpAdapter, getBuiltDocument, options);
    }
}

 
SwaggerCustomOptions의 기존 주석도 변경하고 새로운 플래그를 위한 변수를 추가한 뒤 로직에 플래그를 넣었다.
 

 describe('disabled Swagger Documents(JSON, YAML) but served Swagger UI', () => {
    const SWAGGER_RELATIVE_URL = '/apidoc';

    beforeEach(async () => {
      const swaggerDocument = SwaggerModule.createDocument(
        app,
        builder.build()
      );
      SwaggerModule.setup(SWAGGER_RELATIVE_URL, app, swaggerDocument, {
        documentsEnabled: false
      });

      await app.init();
    });

    afterEach(async () => {
      await app.close();
    });

    it('should not serve the JSON definition file', async () => {
      const response = await request(app.getHttpServer()).get(
        `${SWAGGER_RELATIVE_URL}-json`
      );

      expect(response.status).toEqual(404);
    });

    it('should not serve the YAML definition file', async () => {
      const response = await request(app.getHttpServer()).get(
        `${SWAGGER_RELATIVE_URL}-yaml`
      );

      expect(response.status).toEqual(404);
    });

    it.each([SWAGGER_RELATIVE_URL, `${SWAGGER_RELATIVE_URL}/`])(
      'should serve Swagger UI at "%s"',
      async (url) => {
        const response = await request(app.getHttpServer()).get(url);
        expect(response.status).toEqual(200);
      }
    );
  });

  describe('disabled Both Swagger UI AND Swagger Documents(JSON, YAML)', () => {
    const SWAGGER_RELATIVE_URL = '/apidoc';

    beforeEach(async () => {
      const swaggerDocument = SwaggerModule.createDocument(
        app,
        builder.build()
      );
      SwaggerModule.setup(SWAGGER_RELATIVE_URL, app, swaggerDocument, {
        swaggerUiEnabled: false,
        documentsEnabled: false
      });

      await app.init();
    });

    afterEach(async () => {
      await app.close();
    });

    it('should not serve the JSON definition file', async () => {
      const response = await request(app.getHttpServer()).get(
        `${SWAGGER_RELATIVE_URL}-json`
      );

      expect(response.status).toEqual(404);
    });

    it('should not serve the YAML definition file', async () => {
      const response = await request(app.getHttpServer()).get(
        `${SWAGGER_RELATIVE_URL}-yaml`
      );

      expect(response.status).toEqual(404);
    });

    it.each([SWAGGER_RELATIVE_URL, `${SWAGGER_RELATIVE_URL}/`])(
      'should not serve Swagger UI at "%s"',
      async (url) => {
        const response = await request(app.getHttpServer()).get(url);
        expect(response.status).toEqual(404);
      }
    );
  });

 
단위 테스트에는 SwaggerCustomOptions와 관련된 테스트가 없어, E2E 테스트에 서버 프레임워크별로 추가한 플래그의 예상 동작의 테스트 코드까지 작성을 완료하였다.
 
 
 

PR하기

 
오픈소스에는 기여 가이드를 제공해준다. 다른 오픈소스들과 마찬가지로 nest에서도 기여 가이드와 함께 PR 템플릿을 제공해주기 때문에 양식에 맞춰서 PR을 하면 된다.
 
PR 작성 시 인제님이 알려주신 팁은, PR 생성을 하면서 변경 지점의 코드 리뷰와, PR에 스크린샷을 첨부해서 PR을 날리면 딱딱한 PR 템플릿만 작성한 것 보다 더욱 신뢰도가 올라가고 빠른 리뷰가 가능하다고 하셨다. 마찬가지로 해당 이슈에 PR 링크 코멘트를 작성하면 오픈소스 멤버 분들의 빠른 확인이 가능하다고 한다.

 
 
나는 documentEnabled 플래그 기능을 넣었기 때문에, 위 형태로 코드 리뷰 코멘트와 PR에 작성해주었다.
(최종 PR은 아래 참조)
 

 

feat(swagger): add documentsEnabled option to disable JSON/YAML by mag123c · Pull Request #3185 · nestjs/swagger

PR Checklist Please check if your PR fulfills the following requirements: The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md Tests for the chan...

github.com

 
 오픈소스 기여가 어려울 것이라는 걱정과는 다르게, 생각보다 이슈 분석도 쉽고 기능을 추가하는데에도, 테스트를 작성하는데에도 막히는 부분이 없었다. 물론 쉬운 이슈를 선정하긴 했지만 생각보다 할만했고 재밌었다.




 

메인테이너의 리뷰 및 머지 (2024-12-05)

 

요청 사항을 반영하여 실제 로직과 테스트 코드를 변경한 뒤, 다시 반영했고 PR이 12월 5일 새벽 머지가 되었다.

 

 

 

nest는 한 번에 컨트리뷰터가 될 수 없엇는데, swagger는 바로 컨트리뷰터가 될 수 있었다. 추가로 PR이 머지되자마자 8.1버전이 릴리즈되었다.

 

 

 

 

 

 

 

 

오픈소스에 기부하기 (Sponsoring)

 

 

 

약 10년 전인 2014년 부터 즐겨보던 인터넷 방송이 있다. 나에게는 항상 즐거움을 주는 방송이었고, 삶에 여유가 생긴다면 반드시 후원을 통해 그동안의 시청료(?)를 내려고 했고, 5년전 쯤 10만원 정도를 후원했던 기억이 있다.

 

마찬가지로 한 번쯤은 내가 가장 즐겨 사용하는 오픈소스에 후원을 해보고 싶었다. 마침 깃허브에는 Sponsoring이라는, 후원 기능이 있었고, 20달러를 기부했다. 마침 인제님의 멘토링 문화에는 코드 기여와 더불어 금전적인 후원도 권유하고 있어 안성맞춤이었다. 

 

 

 

 

오픈소스에서 유료 상품(?)을 따로 만들지 않는 이상, 오픈소스는 오픈소스 개발자의 열정 하나로 운영된다고 생각한다. 무급인 셈이다. 이런 오픈소스의 유지 관리를 위해서라도, 내가 잘 사용하고 있어요!!! 라는 의미로 한 번쯤은 소량이라도 후원해보는 것이 어떨까 싶다. 지속 가능한 오픈소스 프로젝트를 위한 가장 큰 기여는 후원인 것 같다.

 

 

 


 

오픈소스 멘토링이 끝나고

우선 오픈소스 멘토링을 통해, 막막했던 오픈소스의 진입 장벽을 허물게 되었다. 비록 어려운 이슈들은 아직 해결할 수 없을지 몰라도, 쉬운 것부터 하나하나 해결해 나가면서 단계를 높여나갈 수 있을 것 같다. 당장에는 위에 간략하게 소개했던 nest의 file pipe 이슈부터 해결하면서, 점차 난이도를 높여나가면서, 언젠가 코어에 새로운 feature를 직접 추가하겠다는 새로운 목표가 생겼다. 이렇게 단계를 하나하나 높여나갈 수 있고, 취사선택이 가능한 것도 오픈소스의 장점이자 매력인 것 같다.

앞으로 오픈소스 기여를 계속하다보면 자연스레 오픈소스를 까보면서 다양한 코드 컨벤션을 접하게 될 것이다. 그러다보면 코드를 보는 능력과 좋은 코드를 짜는 능력 또한 자연스레 키워질 것이라는 생각이 들었다. nest를 직접 까보면서 생각보다 구조화가 잘 되어있어서 놀라웠다. 현재 조직의 프로덕션 코드보다 십 수배는 더 큰 프로젝트도 이렇게 관리가 잘 되는데.... 라는 생각과 함께 반성할 수 있는 좋은 계기가 되었다.
 
주말에 시간을 내서 도움을 주신 인제님께 감사드리고, 이 멘토링을 1년 가까이 꾸준히 해오신 것이 존경스럽다. 덕분에 좋은 자극을 받아 오픈소스 생태계에 조금이나마 보탬이 되고자 이 글을 남기고 앞으로도 기여하게 된다면 꾸준히 기여 경험을 공유하고자 한다. 오픈소스 기여 경험들을 온오프라인으로 공유하면서 더 많은 사람들이 오픈소스에 자신의 GitHub 닉네임을 남기는 것에 도움이 되었으면 좋겠다.(?)
 
 
 
 
 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록