저는 처음 오픈소스에 기여하겠다!!! 라는 생각을 실천하는데 1년이나 걸렸습니다.
부끄럽지만 너무 다가가기 어렵고 힘들었습니다.
하여 누구나 오픈소스에 쉽게 접했으면 하는 마음에 다소 가벼운 스타일로 포스팅을 진행하려 합니다.
오픈소스에 기여하게 된 계기
우리는 오픈소스를 쉽게 접하고 사용한다. 특히 node 진영에서는 npm install 딸깍 한 번이면 오픈소스를 쉽게 받아 사용할
수 있다. 어제도 메세지큐를 사용하기 위해 bullmq @nestjs/bullmq를, UI Board를 위해 @bull-board/api와
@bull-board/express를 갖다 썻으니 벌써 4개의 오픈소스를 사용한 셈이다.
동물은 죽어서 가죽을 남기고 사람은 죽어서 이름을 남긴다는데, 개발자로 살면서 나도 죽기전에 나의 개발 환경에서 내가 사용하는 오픈소스에 GitHub 닉네임을 어디엔가라도 반드시 남겨야겠다고 생각이 들어 오픈소스에 기여를 결심했다.
농담 반 진담 반 섞인 쳐맞는 소리(?)에 더해서, 내가 사용하고 있는 소스를 더 깊이 알기 위해서 오픈소스에 기여해야겠다는 생각을 하게 되었다. 이 모든 생각의 출발점은 작년 devfest에서 김인제 님의 오픈소스 기여로 수억명 에게 임팩트 만들기 세션을 들으면서다.
작년 요맘때 쯤 devfest가 열렸으니 벌써 1년 전부터 마음을 품었지만, 막상 기여 후기들을 찾아보며 혼자 힘으로 해결하려다보니 막막했다. 처음 개발을 시작했을 때보다 더 크고 웅장한 거대한 벽이었다. 홀로 서기가 도저히 어려울 것 같아 8개월 넘게 눈팅하던 오픈소스 멘토링을 신청하게 되었다.
제목과 부제목만 봐도 든든한, 엄청난 뒷배(?)를 두고 오픈소스 기여에 첫 걸음마를 떼는 순간이었다.
이슈 선정
오픈소스의 코드를 직접 다루는 것도 어려울 수 있지만, 보통의 진입장벽은 이슈 선정에서부터 시작된다. 나 역시 이슈 선정 단계에서 막혔었다. 내가 올리지 않은 이슈인데 뺏는 것 아닌가 싶기도 했고, 도대체 어떤 이슈에 PR 요청을 할 수 있는 것인지 감이 오지 않았다.
멘토링에 선정되고 모든게 변했다. 개안이라도 한 것 처럼 선정할 수 있는 이슈가 눈에 보이기 시작했다.
인제님과 함께하기로 한 순간 이슈들을 잘 골라낼 수 있었고, 내가 기여하고 싶었던 nest진영에서 비교적 최근에 올라온 두 가지의 이슈를 선정할 수 있었다.
https://github.com/nestjs/nest/issues/14070
https://github.com/nestjs/swagger/issues/3157
개인적으로 nest의 이슈(FileValidator 개선)를 선택하고 싶었으나, 이미 누군가 먼저 코멘트를 달아 PR을 생성하려고 하는 것 같아, swagger의 기능 요청 이슈를 선택하였다.
(멘토링 당일 알게 된 사실인데, PR은 먼저 올리는 사람이 임자다. 동방예의지국답게 먼저 PR을 올려도 되는지 세심하게 물어보고, 허가가 떨어지면 진행할 필요가 없다고...)
( 그래서 첫 번째 이슈 또한 최근 PR을 날려 머지되었다. )
이슈를 요약하자면, 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은 아래 참조)
오픈소스 기여가 어려울 것이라는 걱정과는 다르게, 생각보다 이슈 분석도 쉽고 기능을 추가하는데에도, 테스트를 작성하는데에도 막히는 부분이 없었다. 물론 쉬운 이슈를 선정하긴 했지만 생각보다 할만했고 재밌었다.
메인테이너의 리뷰 및 머지 (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 닉네임을 남기는 것에 도움이 되었으면 좋겠다.(?)
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!