백엔드 Nest.js 코드 베이스 - 기본구조

Nest.js로 실패없는 코드 베이스를 짜기 위한 기본 폴더 스트럭쳐
Oct 04, 2023
백엔드 Nest.js 코드 베이스 - 기본구조
 

Nest.js 코드 베이스 - 기본 구조

1. [기본 구조](https://velog.io/@testtesttest/%EA%B0%80%EC%A0%9CNest.js-%EC%8B%A4%EB%AC%B4-boilerplate) [ current ] 2. [Serialization](https://velog.io/@testtesttest/testtest2) 3. [에러 처리](https://velog.io/@testtesttest/SI%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%B0%B1%EC%97%94%EB%93%9C-Nest.js-%EC%BD%94%EB%93%9C-%EB%B2%A0%EC%9D%B4%EC%8A%A4-2%ED%8E%B8-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC) 4. SI를 위한 백엔드 Nest.js 코드 베이스 - logger 속편 목록: 자주쓰는 인프라 모듈들 Logger, Cache, MessageQueue, Socket, FileUpload(S3)
Hyperhire에서 이용하는 API 서버의 기반 코드 구조를 공유해드립니다.
꽤나 많은 분들이 아실만한 글인 node.js bulletproof architecture를 기반으로 저희만의 구조를 만들었습니다.
저희도 처음에는 프로젝트를 진행하는 개발자들에게 거의 모든 자율성을 부여했었습니다. 하지만 시간이 지날수록 여러가지 문제점들이 발생했습니다. 반대로 아키텍처를 확실하게 정해주고 강하게 따르도록 했을 때도 다른 문제가 발생했습니다. 전반적인 문제는 크게 세가지입니다.
1. 새로운 프로젝트 시작할때마다 중복되는 코드가 많다. (시간 절약이 곧 비용절약.) 2. 현재 개발자들에게 제공하는 코드 자율성이 너무 크다. => 개발 리드가 바빠지다보면 코드 퀄리티가 낮아질 수 있다. => 유지보수할때 힘들어진다. 3. 긴급해서 다른 개발자들이 프로젝트에 참여할때 러닝커브를 줄여야한다.
이러한 문제들이 빈번하게 발생하고 계속 켜지다보니 기본 코드 구조를 제공하여 개발자들이 이를 따르도록 강제하고 있습니다.
기반 코드를 만듦에 있어서 위 3가지 문제를 해결함과 동시에 다른 필요조건도 있었습니다. "어느정도의 자율성을 보장해야한다."는 점입니다. 규칙이 너무 많고 지켜야할 기준이 너무 강하면 프로젝트가 늦어질 확률이 높아집니다. 프로젝트를 진행함에 있어 사건사고가 발생할 일이 많은 것은 당연하기때문에 일정이 갑자기 타이트해지는 것도 다반사입니다. 그렇기때문에 프로젝트를 완료하기 위해서 코드 작성 시간을 줄여줄 수 있는 자율성도 필요합니다. (저희는 Service layer에서만 자율성을 허락합니다.)
아직도 위 3가지 문제와 현실적인 제약사항을 동시에 만족시키는 코드 구조를 만들기위해 자주 고민하고 기반 코드 변경이 진행되고 있습니다.
현재 이용중인 기반 폴더,파일 구조와 세부 규칙들 소개해드리겠습니다. 구조는 아래와 같습니다.

프로젝트 구조

Root ⌙.husky (lint, prettier, commitlint...) ⌙src ⌙user (domain module) ⌙user.controller.ts ⌙user.service.ts ⌙user.repository.ts ⌙dtos ⌙create-user.dto.ts ⌙find-user.dto.ts ⌙entities ⌙user.entity.ts ⌙company.entity.ts ⌙employee.entity.ts ⌙constants ⌙user.constant.ts ⌙company.constant.ts ⌙interfaces ⌙user.interface.ts ⌙company.interface.ts ⌙middlewares ⌙some-specific-middleware.middleware.ts ⌙...OtherDomainModules (like employment, product...) ⌙core ⌙authentication (guards) ⌙req-res.wrapper.ts ⌙pagination.ts ⌙infinite-scroll.ts ⌙exceptions ⌙interfaces ⌙middlewares ⌙pipes ⌙dtos ⌙shared (Not injecting module/static methods) ⌙date.util.ts // 아래는 다음글을 참고해주세요. ⌙infrastructure (Injecting module) ⌙cache ⌙message-queue ⌙socket ⌙file-upload ⌙logger ⌙notification(firebase)

도메인 모듈의 기본 구조.

기본적으로 nest.js CLI에서 제공하는 모듈 구조를 이용하고 Repository, Constants, Interfaces, Middleware(optional)을 추가하여 도메인 module을 구성합니다.
// after running command "nest g resource user", it provide below files. ⌙user ⌙dto ⌙create-user.dto.ts ⌙update-user.dto.ts ⌙entities ⌙user.entity.ts ⌙user.module.ts ⌙user.controller.ts ⌙user.service.ts ⌙user.entity.ts // And below folder and file are Additionals on our codebase. ⌙user.repository.ts ⌙constants ⌙interfaces ⌙middlewares
하나하나씩 살펴보도록 하죠.

Controller Layer

@ApiTags('user') @Controller(`${API_VERSION.V1}/user`) export class UserController { constructor( private readonly userService: IUserService, ...) {} @ApiVersion('1') @ApiOperation({ summary: 'Sign up', }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Success signup', type: User, }) @ApiResponse({ status: HttpStatus.UNPROCESSABLE_ENTITY, description: 'When the required parameter not entered', }) @HttpCode(HttpStatus.CREATED) @Post('signup') async signup(@Body() body: CreateUserDto): Promise<IUser> { const createdUser = await this.userService.signup(body); } }
위와 같은 형태입니다. Controller layer는 특별히 공유드릴만한게 있지않으니 넘어가죠.

Service Layer

특별한 코드가 있는 것은 아니지만 저희 아키택처의 핵심 부분입니다.
글 시작점에 말한 것처럼 개발시에는 각 요소의 역할과 책임에 따른 코드 규칙을 지켜야합니다. 하지만 여러 긴급상황이 발생하는 것이 현실이기때문에 빠른 개발을 위한 자율성 부여도 필요합니다. 다만 어쩔수 없이 자율성을 부여하는 것이기에 이를 수습하기 위한 예방책도 만들어둬야합니다. 자율서을 단 한곳에서만 허용함으로 써 최소한의 예방책을 설정해뒀습니다.
Service layer 이용 규칙 4가지는 아래와 같습니다.
1. 모든 비지니스 로직은 service layer에 존재해야합니다.2. 메서드를 못나누겠다면 "트랜잭션 단위"로 자른다.3. 개발자의 자율성이 부여된 곳은 오직 service layer입니다.

1. 모든 비지니스 로직은 service layer에 존재해야합니다.

#익셉션
익셉션 또한 비지니스 로직이기에 최대한 service layer에 있어야 합니다. 다른 여러 node.js 코드들을 보면 controller단에서 if 조건에 따라 익셉션을 발생시키기도 합니다. 하지만 저희는 controller의 구조를 최대한 함수 호출만 나열되도록 정해고, 최대한 서비스단에서 익셉션을 발생시킵니다.
아래 예시는 익셉션 발생 위치에 따른 코드 차이입니다.
BAD
// user.controller.ts @HttpCode(HttpStatus.CREATED) @Post('signup') async signup(@Body() body: SignupBodyDto) { const result = await this.authService.checkOtp(req.body.otp) // Throwing exception on the controller. This is business logic if (!result) throw new BadRequestException(API_EXCEPTION.WRONG_OTP); const validOrNot = await this.checkService.checkSomethingValid(req.body); if (!validOrNot) throw new UnauthorizedException(API_EXCEPTION.NOT_VALID); return this.userService.signup(req.body.name, req.body.phoneNumber); } // user.service.ts ... async checkOtp(phoneNumber, req.body.otp) { return await this.cacheService.matchSavedOtp(phoneNumber, req.body.otp); } async checkSomethingValid(req.body) { return await this.~ }
GOOD
// user.controller.ts @HttpCode(HttpStatus.CREATED) @Post('signup') async signup(@Body() body: SignupBodyDto) { await this.authService.checkOtp(req.body.phoneNumber, req.body.otp) await this.checkService.checkSomethingValid(req.body); return this.userService.signup(req.body.name, req.body.phoneNumber); } // user.service.ts ... // Throw exception at the place working directly async checkOtp(phoneNumber, req.body.otp) { const result = await this.cacheService.matchSavedOtp(phoneNumber, req.body.otp); if (!result) throw new BadRequestException(API_EXCEPTION.WRONG_OTP); } async checkSomethingValid(req.body) { const result = await this.~ if (!result) throw new UnauthroziedException(API_EXCEPIOTN.NOT_VALID); }
API기능이 복잡하다고 생각해보면 여러 exception들이 한 API 컨트롤러 메서드에 존재할것이기때문에 분명히 난잡해집니다. 컨트롤러에서 익셉션 실행없이 함수의 실행만 나열돼있다면 코드 이해에 분명히 도움이 됩니다. 저 또한 API 유지보수나 새로운 API파악 때 컨트롤러단부터 보기때문에 각각의 API가 어떤 일들을 하는지 파악하기 쉬워야합니다. 이해에 방해되는 것들은 안보이는 곳에 두는게 좋습니다.
그리고 제 개인적으로는 해당 업무의 결과를 받은 쪽(controller)에서 익셉션을 발생시키는 것보다 해당 업무를 직접 하는 곳(service)에서 익셉션을 발생시키는 것이 좋다고 봅니다.

2. 메서드를 못나누겠다면 "트랜잭션 단위"로 자른다.

간단한 팁입니다. 메서드를 나누는 데에 익숙하지않다면 최소한 트랜잭션 단위로 자르고 시작하시면 편합니다.

3. 개발자의 자율성 부여된 곳은 오직 service layer입니다.

SI 업무에서는 대부분이 시간이 촉발할 것입니다. 적당했던 일정이 갑자기 촉박해지는 상황이 많이 발생하죠. 그렇게되면 계획했던 아키텍쳐를 지키기 힘들게 됩니다. 기능동작만 신경쓰고 룰은 하등 신경안쓰게돼죠. 이런 상황이 빈번하기 때문에 언제나 지켜야하는 최소한의 규칙을 정해놓으면 좋습니다. 그것이 나중에 고치기도 쉽다면 금상첨화겠죠.
저희의 규칙은 "controller, repository layer에서의 규칙은 꼭 지킨다. 만약 너무 급하다면 Service layer에서만 급한 티를 내야한다." 입니다.
그리고 controller, repository layer의 규칙은 지키기 아주 쉽습니다. 예시 코드 몇개 만들어주고 따르게 하면 잘따를수 있습니다. (비지니스로직이 들어가있지 않으니깐 당연하겠지만 말이죠.)
비지니스 로직이 들어가다보니 바쁘면 어쩔수 없이 난잡해질 곳이니. 나중에 고치기 쉽게 한군데만 망가트리자라는 주의입니다.

Repository Layer

  1. 비지니스 로직은 없다.
  1. 한 메서드에는 하나의 쿼리만 작성해야한다.
여러 레이어드 아키텍쳐들에서 Repository 계층을 이용합니다. 이 계층의 책임은 "데이터베이스 관련 작업"입니다. 주로 비지니스 로직을 절대로 가지지 않은 데이터베이스 쿼리 코드만을 가지고 있어야합니다. 그리고 정말정말정말 한가지일만 해야합니다. typeorm으로 생각한다면 하나의 repository 메서드는 하나의 createQueryBuilder() 만을 이용해야한다라고 생각하시면 됩니다.
// user.repository.ts @Injectable() export class UserRepository { constructor(private readonly globalEntityManager: EntityManager) {} async insertOne(userInput: IUserInput): Promise<IUser> { const user = new User(); user.name = userInput.name; user.phoneNumber = userInput.phoneNumber; ... return this.globalEntityManager.save(user); } async findOneByPhoneNumber(phoneNumber: string): Promise<IUser | null> { return this.globalEntityManager .createQueryBuilder(User, 'user') .where('user.phoneNumber = :phoneNumber', { phoneNumber, }) .getOne(); } }
위 코드처럼 파라미터를 받아 바로 생성하거나, 검색 조건을 인자로 받아 바로 그 값을 이용해서 검색하는 것이 끝입니다. 비지니스 로직이 들어오게되면 유지보수에 어려움이 생깁니다.(+ 타인이 코드를 보게될 때 귀찮아짐) 그러므로 비지니스로직을 최대한 배재해야 합니다.
간단한 팁은 if문을 금지한다면 대체적으로 비지니스로직이 들어오지않게 됩니다. if문은 오로지 메서드의 인자에서 optional인 값이 있을 경우만 허용됩니다.
아래코드는 if가 쓰일수 있는 예시입니다.
// user.interface.ts export interface IUserSearchConditions { type: "student" | "adult"; ageLTE?: number; ageMTE?: number; } // user.repository.ts @Injectable() export class UserRepository { constructor(private readonly globalEntityManager: EntityManager) {} ... // Set the proper method name on situation ! async findListBySpecificConditions(conditions: IUserSearchConditions): Promise<IUser[]> { const queryBuilder = this.globalEntityManager .createQueryBuilder(User, 'user') .where('user.type = :type', { userType: conditions.type, }) // "if" allowed only this kind of situation if (conditions.ageLTE) queryBuilder.andWhere('user.age >= ageMTE', { conditions.ageMTE }) if (conditions.ageMTE) queryBuilder.andWhere('user.age >= ageMTE', { conditions.ageMTE }) return queryBuilder.getMany(); } }
Repository내에서 if문을 이용하지않기때문에 메서드가 많아질 수 있습니다. 하지만 최대한 작은 크기로 메서드를 분리하는 것은 나중을 위해 언제나 옳습니다.
Repository layer를 이용하다보면 service layer 메서드가 단순히 repository 메서드를 호출하는 일밖에 안할때가 많습니다. 간단한 API에서는 당연한 것이니 신경쓰지않으셔도 됩니다. 복잡한 API를 만들 때 repository layer가 빛을 보게 됩니다.

Entity Layer

Database 스키마 정의해놓는 곳입니다. Prisma.js를 이용하면 불필요할수도 있겠네요.
+ORM 같은 것에서 제공하는 trigger들은 유지보수를 위해 절대 사용하면 안됩니다.

Constant Layer

단순히 해당 도메인 모듈에서만 쓰는 상수, enum들을 정의할 때 이용합니다.
다들 잘아시겠지만 Typescript에서는 enum은 성능에 좋지 않습니다. 대신 아래 방식을 이용해주세요.
// BAD export enum NATIONALTIY_KO = { ALBANIA: '알바니아', ARMENIA: '아르메니아', AUSTRIA: '오스트리아', AZERBAIJAN: '아제르바이잔', } // GOOD export const NATIONALITY_KO = { ALBANIA: '알바니아', ARMENIA: '아르메니아', AUSTRIA: '오스트리아', AZERBAIJAN: '아제르바이잔',, SAINT_LUCIA: '세인트루시아', SAINT_VINCENT_AND_THE_GRENADINES: '세인트빈센트 그레나딘', TRINIDAD_AND_TOBAGO: '트리니다드토바고', UNITED_STATES: '미국', } as const;

Interfaces Layer

보통 nest.js Dependency Injection을 할 때 주입하고 싶은 클래스를 바로 집어넣습니다. nest.js 공식 홈페이지에서도 모든 예시들이 그렇구요. 저희는 타입을 주입해서 사용합니다. DI를 사용하는 핵심 이유 중 하나인 의존성 변경 용이함을 이용하기 위함입니다.
그리고 구현과 타입 선언을 따로 분리해두면 코드 관리에도 좋습니다. 다만 코드 양이 많아 지긴 하니 조금의 불편함(지루함)이 생기긴 합니다. 한가지 더 장점이 있다면 개발 리더가 각 개발자들이 어떤 구조로 코드를 짰는지 쉽게 확인할 수 있게 됩니다. 코드 리뷰 하는 시간이 꽤나 짧아집니다.
아래 코드처럼 저희는 모든 계층을 타입으로 DI진행하고 타입과 구현을 분리합니다.
// user-service.interface.ts export abstract class IUserRepository { findUserByTypeId: (userId: number, type: USER_TYPE_TYPES) => Promise<number>; getUserDetail: (userId: number) => Promise<IUser>; deleteUser: (userId: number) => Promise<boolean>; } // user.service.ts @Injectable() export class UserService implements IUserService { constructor( private readonly userRepository: IUserRepository, // 모두 interface 로 주입됐습니다. private readonly loggerService: ILoggerService, // ! private readonly employeeRepository: IEmployeeRepository, // ! private readonly companyRepository: ICompanyRepository, // ! ) {} async findUserByTypeId(userId: number) { ... } ... }
다음편은 Infrastructure 관련 코드들에 대한 공유해드릴 예정입니다. 서버 API에서 보편적으로 이용되는 기능들인 Cache, MessageQueue, Socket, FileUpload(S3), Logger 등이 있습니다. 필수적인 기능들은 모두 미리 만들어두고 무조건 해당 인프라를 이용할 때는 만들어둔 함수만을 이용하게 한다면 통일성을 가질 수 있게됩니다.
다음편에서 뵙겠습니다.
:)
Share article
뉴스레터 구독하고 IT & 글로벌 최신 트랜드 정보를 받아보세요!

글로벌 인재 채용 및 협업 관리 솔루션, Hyperhire