
NestJS 시작하기
"NestJS boilerplate"
NestJS를 처음 시작한 사람들은 한 번씩 검색해봤을 법한 키워드일 것이다.
근데 막상 코드를 확인해보면 뭐가 뭔지, 다 필요한 건지 헷갈린다.
그렇다고 `nest new`만 치고 시작하자니, 허전하다.
선배 개발자가 옆에서 "이것만 세팅하면 돼" 라고 알려주는 느낌으로, 정말 필요한 설정들만 정리해봤다.
기본 설정
1. NVM 자동 설정하기
만약 노드 버전을 합의하지 않고 프로젝트를 시작했다고 해보자.
여러 의존성을 설치하다보면 서로의 노드 버전이 맞지 않아 에러가 발생하게 될 수 있다.
이 문제는 NVM(Node Version Manager)으로 해결할 수 있다.
설치하기 (Mac 기준)
homebrew를 통해 nvm을 설치하자.
$ brew install nvm
.zshrc 파일에 nvm 관련 설정을 추가해준다.
$ vim ~/.zshrc
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
아래 스크립트는 nvmrc 파일을 로드해 nvm을 자동으로 설정해주는 코드다.
# Auto NVM
autoload -U add-zsh-hook
load-nvmrc() {
[[ -a .nvmrc ]] || return
local node_version="$(nvm version)"
local nvmrc_path="$(nvm_find_nvmrc)"
if [ -n "$nvmrc_path" ]; then
local nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")
if [ "$nvmrc_node_version" = "N/A" ]; then
nvm install
elif [ "$nvmrc_node_version" != "$node_version" ]; then
nvm use
fi
elif [ "$node_version" != "$(nvm version default)" ]; then
echo "Reverting to nvm default version"
nvm use default
fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc
이제 .zshrc 파일을 적용해주면 된다.
$ source ~/.zshrc
.nvmrc
이제 프로젝트에 .nvmrc 파일을 생성하자.
$ echo 'v22.18.0' > .nvmrc
이제 프로젝트 경로에서 터미널을 열면 자동으로 노드 버전이 v22.18.0로 고정될 것이다.
Tip!
asdf 도구를 쓰시는 분은 .tool-versions 파일 설정을 찾아보세요.
2. Type 설정 (tsconfig.json 기본 설정)
TypeScript는 타입 안정성을 위해 사용한다.
그런데 strict mode를 안 켜놓으면 TypeScript를 사용하는 의미가 반감된다.
따라서 나는 tsconfig.json에서 strict mode를 키는 걸 적극 추천한다.
{
"compilerOptions": {
"strict": true,
}
}
strict mode를 키면 아래 설정들이 활성화된다.
- noImplicitAny: any 타입을 명시하지 않으면 에러
- alwaysStrict: 모든 파일에 'use strict' 적용
- strictFunctionTypes: 함수 타입을 더 엄격하게 검사
- strictBindCallApply: bind, call, apply 타입 검사
- strictPropertyInitialization: 클래스 속성 초기화 검사 (모든 프로퍼티를 생성자에서 선언/non-null 단언 연산자 ! 를 사용/strictPropertyInitialization를 false 둔다)
- strictNullChecks: null, undefined를 명시적으로 처리
- noImplicitThis: this 타입이 any면 에러
그 외에도 아래의 추가 설정 확인해서 설정해주면 더 좋다.
나중에 "아 이거 지워야 하는데 깜빡했네" 하는 일이 없어질 것이다.
{
"compilerOptions": {
"noUnusedLocals": true, // 안 쓰는 변수 있으면 에러
"noUnusedParameters": true, // 안 쓰는 매개변수 있으면 에러
"noFallthroughCasesInSwitch": true, // switch문에 return이나 break 빠뜨리면 에러
"noImplicitReturns": true // 모든 경로에서 return 하도록 강제
}
}
3. 절대 경로 설정하기
따로 경로 설정을 해주지 않았다면 곧 만나게 될 상대 경로 불지옥을 만나게 될 것이다.
import { UserService } from '../../../modules/user/user.service';
import { AuthGuard } from '../../../../common/guards/auth.guard';
이 코드를 아래와 같이 절대 경로로 보이도록 설정할 수 있다.
import { UserService } from '@app/user/user.service';
import { AuthGuard } from '@shared/guards/auth.guard';
설정은 간단하다. tsconfig.json을 수정해주면 된다.
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@app/*": ["src/*"],
"@shared/*": ["src/shared/*"],
"@config/*": ["src/config/*"]
}
}
}
만약 Jest를 사용한다면 package.json나 jest.config.js에 상대 경로에 대한 설정을 추가해줘야 한다.
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1",
"^@config/(.*)$": "<rootDir>/config/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
}
4. 패키지 매니저 설정
대표적인 패키지 매니저 3가지를 비교해보자.
npm
- 장점
- 별도 설치 불필요
- 모든 환경에서 작동
- 단점
- 속도가 느림
- 디스크 공간 많이 차지
- 유령 의존성 발생 가능성
yarn
- 장점
- npm보다 빠름
- 안정적인 lock 파일
- 단점
- 별도 설치 필요
- yarn berry는 러닝커브 있음
- 유령 의존성 발생 가능성
pnpm
- 장점
- 빠르다
- 디스크 공간 효율적 활용
- 심볼릭 링크를 사용해 유령 의존성을 차단한다
- 단점
- 별도 설치 필요
- 가끔 호환성 이슈 발생
장단점을 감안하여 원하는 패키지 매니저를 선택해 설정하자.
5. 코드 스타일 통일
javascript를 사용해봤다면 Prettier와 Lint는 한 번씩 들어봤을 것이다.
솔직히 설정을 어떻게 하든 상관없다. 팀에서 통일만 되면 된다.
Prettier 설정
먼저 .prettierrc 파일부터 살펴보자.
{
"singleQuote": true, // 작은 따옴표 사용
"trailingComma": "all", // 마지막 콤마 항상
"semi": true, // 세미콜론 사용
"printWidth": 80, // 한 줄 최대 길이
"tabWidth": 2, // 탭 너비
"useTabs": false, // 스페이스 사용
"arrowParens": "always", // 화살표 함수 괄호 항상
"endOfLine": "lf" // 개행문자 LF로 통일
}
.prettierignore를 통해 굳이 코드 스타일이 적용될 필요가 없는 곳은 제외해주자.
dist
node_modules
등등등
ESLint 룰 설정
Prettier가 스타일을 담당한다면, ESLint는 코드 품질을 확인한다.
아래 코드 예제는 최신 자바스크립트 모듈, ES Module 전용 eslint.config.mjs 를 기준으로 한다.
import js from '@eslint/js'
export default tseslint.config(
{
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
);
각 룰에 대한 내용은 eslint 공식문서에서 찾아서 설정하자.
ESLint 플러그인 설치하기
미리 룰이 세팅되어 있는 설정이 있다. 가장 유명한 설정 중 하나가 airbnb 설정이다.
$ npm i -D eslint-config-airbnb-typescript
설정 파일에 해당 플러그인을 전달하기만 하면 airbnb 룰을 사용할 수 있다.
export default tseslint.config(
// ...
airbnbTypescript,
// ...
);
Prettier와 ESLint 충돌 해결하기
eslint-config-prettier는 eslint에서 prettier와 충돌하는 eslint 규칙을 전부 꺼준다.
eslint와 prettier의 역할을 정확히 구분하기 위해 사용하는 것이 좋다.
$ npm i -D eslint-config-prettier
eslint.config.mjs 에서 아래 설정을 추가해주면 된다.
export default tseslint.config(
// ...
eslint.configs.recommended,
// ...
);
Prettier를 린터로 사용하기
eslint-config-prettier를 통해 포맷팅에 관련된 eslint 규칙을 전부 제거해야 에러가 발생하지 않는다.
$ npm i -D eslint-plugin-prettier
export default tseslint.config(
// ...
eslintPluginPrettierRecommended,
// ...
);
Tip!
타이핑할 때, IDE에서 전역으로 규칙을 적용해주는 설정도 있다.
.editorconfig를 찾아보라!
6. Husky 설정하기
린트 돌리는 거 깜빡하고 커밋하고 푸시했다가 CI/CD 파이프라인에서 봉변(?)을 당할 수도 있다.
이때 Husky를 사용하면 이 문제를 해결할 수 있다.
Git hook을 활용해 커밋하기 전에 Husky가 알아서 린트를 실행하도록 설정할 수 있다.
$ npm i -D husky
$ npm exec husky init
$ npm i -D lint-staged
Tip!
commitlint를 사용하면 커밋 메시지에 대해서도 제약을 걸 수 있다.
NestJS 필수 코드 만들기
1. ConfigModule 만들기
기본적으로 Nest 문서를 보고선 만들면 된다.
다만 configService에서 문자열 키 값으로 환경변수를 꺼내올 때 실수가 발생할 수 있다.
아래 블로그를 보면 TypeConfigService를 구성해서 문자열 자동 완성과 컴파일 에러까지 잡아주는 시스템을 만들 수 있다.
2. 로그 설정
만약 예기치 못하게 애플리케이션이 종료되었을 때, 로그 파일을 저장하지 않으면 문제 원인을 파악하기 어렵다.
Winston을 사용하면 손 쉽게 파일 로그를 만들 수 있다.
npm install --save nest-winston winston winston-daily-rotate-file
winston-daily-rotate-file 설정을 활용해 로그 파일 저장 설정을 할 수 있다.
Winston은 파일 로그를 쌓을 때, 기본적으로 비동기로 동작하기 때문에 성능 부하가 적다!
import DailyRotateFile from 'winston-daily-rotate-file';
export enum LogLevel {
ERROR = 'error',
WARN = 'warn',
INFO = 'info',
HTTP = 'http',
VERBOSE = 'verbose',
DEBUG = 'debug',
SILLY = 'silly',
}
const createFileTransports = (type: string, level?: LogLevel): winston.transport => {
return new DailyRotateFile({
level,
datePattern: 'YYYY-MM-DD',
dirname: `${process.cwd()}/logs`, // 저장 경로
filename: `%DATE%.${type}.log`, // 파일 이름
maxFiles: MAX_FILES, // 최대 일수
maxSize: MAX_SIZE, // 로그 파일 최대 크기
zippedArchive: true, // 압축 여부
});
};
또한 아래와 같이 로그 포맷을 구체적으로 정할 수 있다.
import * as winston from 'winston';
const { combine, timestamp, label, printf, colorize } = winston.format;
const logFormat = printf(({ level, message, label, timestamp }) => {
return `${timestamp as string} [${label as string}] ${level}: ${message as string}`;
});
const createConsoleFormat = (applicationName: string) =>
combine(timestamp({ format: TIMESTAMP }), label({ label: applicationName }), colorize({ all: true }), logFormat);
이제 로그 포맷과 파일 설정을 LoggerOptions 객체로 엮어 WinstonModule을 생성할 때 전달하면 된다.
export function createWinstonConfig(configService: TypedConfigService): LoggerOptions {
const applicationName = configService.get('applicationName');
const nodeEnv = configService.get('nodeEnv');
const logLevel = configService.get('logLevel');
const consoleTransport = new winston.transports.Console({ format: createConsoleFormat(applicationName) });
const fileTransports = [LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO].map(level =>
createFileTransports(level, level),
);
return {
level: logLevel,
format: createFileFormat(applicationName),
defaultMeta: { environment: nodeEnv },
transports: [consoleTransport, ...fileTransports],
exceptionHandlers: [createFileTransports(FILE_NAME_EXCEPTION)],
exitOnError: false,
};
}
여기에 추가적으로 LoggingInterceptor까지 구현해주면 더 좋다.
- logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(
@Inject(WINSTON_MODULE_NEST_PROVIDER)
private readonly logger: LoggerService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const { method, url, ip } = request;
const requestBody = JSON.stringify(request.body);
this.logger.log({
context: 'HTTP',
message: `[Request] method=${method}, url=${url}, ip=${ip}, body=${requestBody}`,
});
return next.handle().pipe(
tap({
next: () => {
const { statusCode } = response;
this.logger.log({
context: 'HTTP',
message: `[Response] method=${method}, url=${url}: statusCode=${statusCode}`,
});
},
error: (error: Error) => {
this.logger.error({
context: 'HTTP',
message: `[Error] ${method} ${url}: ${error.message}`,
});
},
}),
);
}
}
- shared.module.ts
@Module({
imports: [
WinstonModule.forRootAsync({
inject: [TypedConfigService],
useFactory: createWinstonConfig,
}),
],
providers: [
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],
})
export class SharedModule {}
3. 전역 에러 설정하기
Nest의 Exception Filters 문서를 보면 쉽게 구현할 수 있다.
나는 AllExceptionFilter를 만들어 그 안에서 if-else문으로 분기 처리하는 것보다 여러 개의 ExceptionFilter를 만들어 구체적인 에러가 먼저 잡히도록 구성하는 게 더 좋은 구조라고 생각한다. 그 이유는 아래와 같다.
- 코드가 짧아져 가독성이 좋아진다.
- 에러를 다룰 때 타입 캐스팅을 할 필요가 사라진다.
코드 예시
- custom-exception.filter.ts
@Catch(CustomException)
export class CustomExceptionFilter implements ExceptionFilter {
catch(exception: CustomException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const errorResponse: ErrorResponse = {
statusCode: status,
errorCode: exception.errorCode,
message: exception.message,
timestamp: dayjs().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);
}
}
- http-exception.filter.ts
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const errorResponse: ErrorResponse = {
statusCode: status,
errorCode: status,
message: exception.message,
timestamp: dayjs().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);
}
}
- all-exception.filter.ts
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof Error ? exception.message : 'Internal server error';
const errorResponse: ErrorResponse = {
statusCode,
errorCode: statusCode,
message,
timestamp: dayjs().toISOString(),
path: request.url,
};
response.status(statusCode).json(errorResponse);
}
}
- shared.module.ts
@Module({ // 구체적인 것이 마지막에 오도록
providers: [
{ provide: APP_FILTER, useClass: AllExceptionFilter },
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_FILTER, useClass: CustomExceptionFilter },
],
})
export class SharedModule {}
4. 헬스 체크
NestJS에서 제공해주는 기능이 있다!
$ npm i @nestjs/terminus
Nest 문서의 Health checks에 잘 나와 있으니 이걸 참고해서 구성하면 된다.
준비 끝!
팀 협업을 위한 기본기부터 시작해봤다.
Node 버전은 nvm으로 통일했고, 코드 스타일은 Prettier와 ESLint가 알아서 맞춰준다. 누가 커밋을 하든 Husky가 검사하고, Commitlint가 커밋 규칙을 지키게 만들 것이다.
프로덕션의 기본적인 설정도 함께 했다.
에러가 발생하면 일관된 형식으로 로그가 남고, Winston이 차곡차곡 정리해준다. 헬스 체크를 통해 로드밸런서나 쿠버네티스와 연동하기도 쉬워졌다.
추가적으로 쿼리에 대한 로그 설정, 메트릭 수집 설정 등의 고민도 해보면 좋을 것 같다.
이제 본격적으로 개발을 시작해보자!