NestJS는 스프링과 마찬가지로 Controller, Service, 그리고 Repository 패턴을 통해 코드 구조를 효율적으로 구성한다. 각 레이어가 분리되어 있어 유지보수성과 확장성이 높으며, 모든 로직이 명확하게 역할에 따라 분리된다.
회사 DB fork 따서 서브 서버를 만들기 전에 기본적인 CRUD 기능을 NestJS로 구현해보았다. 그러면서 실제로 NestJS가 스프링(Spring)과 비슷하다는 말을 실감하게 되었다. 가장 크게 느껴졌던 부분은 의존성 주입(Dependency Injection)이었다.
의존성 주입은 제어의 역전(Inversion of Control, IoC) 기술 중 하나로, 개발자가 객체의 인스턴스를 직접 생성하는 대신, 프레임워크가 필요한 외부 자원(클래스, 함수 등)을 알아서 주입해주는 방식이다. 쉽게 말하면, 서비스 파일에 정의된 함수를 컨트롤러에서 편리하게 사용할 수 있도록 만드는 것을 의미하고 목적은 객체 간의 결합도를 낮추고 유지보수성을 높인다.
NestJS에서는 제어의 역전이란, 객체의 생명 주기와 의존성 관리를 개발자가 아닌 프레임워크가 대신 처리하는 것을 말한다. 이것이 바로 NestJS와 같은 프레임워크의 강력함을 만들어주는 핵심 개념이다. 개발자는 객체 간의 복잡한 의존성 관계를 신경 쓰지 않고, 코드에 집중할 수 있기 때문이다.
NestJS에서 의존성 주입은 클래스의 생성자를 통해 이루어진다. 생성자에 필요한 의존성을 주입하고, 이 의존성을 클래스 내 다른 메서드에서도 사용할 수 있도록 하는 방식이다. 아래 코드를 통해 의존성 주입이 어떻게 동작하는지 확인할 수 있다.
TypeScriptimport { Controller } from '@nestjs/common'; import { BoardsService } from './boards.service'; @Controller('boards') export class BoardsController { // boardsService를 클래스 속성으로 선언 boardsService: BoardsService; // 생성자에서 boardsService를 주입하고 클래스 속성에 할당 constructor(boardsService: BoardsService) { this.boardsService = boardsService; } // 다른 메서드에서 boardsService 사용 @Get('/') getAllBoards() { return this.boardsService.findAll(); } }
위 코드를 보면, BoardsService가 컨트롤러로 주입되어, 컨트롤러 내의 다른 메서드에서도 쉽게 사용할 수 있게 된다. 이 때 타입스크립트의 private 키워드를 사용하면 아래 코드처럼 의존성 주입을 더욱 간결하게 처리할 수 있다.
TypeScriptimport { Controller } from '@nestjs/common'; import { BoardsService } from './boards.service'; @Controller('boards') export class BoardsController { // private 키워드를 통해 생성자에서 의존성 주입 constructor(private boardsService: BoardsService) {} // 똑같이 boardsService를 클래스 내 다른 메서드에서도 사용할 수 있음 }
NestJS가 Spring보다 조금 더 귀찮?은 점은 모듈 기반의 구조로 설계되어 있으며, 각각의 기능은 모듈, 컨트롤러, 서비스로 구성된다.
NestJS 프로젝트를 생성하면 기본적으로 다음과 같은 파일과 폴더 구조가 생성된다:
main.ts는 애플리케이션의 진입점, app.module.ts는 애플리케이션의 루트 모듈이다.위에서 잠깐 업급했듯이 모듈은 NestJS에서 어플리케이션을 구성하는 기본 단위이다. 각 기능별로 모듈을 생성하고, 관련된 컨트롤러와 서비스를 해당 모듈에 포함시킨다. 이렇게 하면, 기능별로 코드가 분리되어 유지보수가 수월 해진다.
TypeScriptimport { Module } from '@nestjs/common'; import { BoardsController } from './boards.controller'; import { BoardsService } from './boards.service'; @Module({ controllers: [BoardsController], providers: [BoardsService], }) export class BoardsModule {}
이 코드는 게시판 관련 기능을 처리하는 모듈로, BoardsController와 BoardsService가 포함되어 있다. 모듈 내에서 각 기능이 모듈 단위로 분리되어 관리되기 때문에, 코드의 유지보수성과 확장성이 매우 뛰어나다.
Provider는 NestJS의 기본 개념 중 하나로, 서비스(Service), 리파지터리(Repository), 헬퍼(Helper) 등 여러 기능을 제공하는 클래스들이 포함된다. Provider의 핵심 아이디어 역시, 의존성 주입(Dependency Injection)을 통해 객체 간의 관계를 형성하고, NestJS 런타임 시스템이 이러한 관계를 자동으로 관리하는 것이다.
Provider는 각 모듈의 providers 항목에 등록되어야 다른 클래스(컨트롤러, 서비스 등)에서 사용할 수 있습니다
NestJS 모듈은 주로 controller를 제외한 3가지 중요한 속성인 imports, exports, providers로 구성되며, 각 속성은 모듈이 어떻게 다른 모듈과 상호작용하고, 어떻게 서비스를 공유하는지에 중요한 역할을 하기 때문에 imports와 exports 부분도 기술하고 넘어가겠다.
imports는 모듈이 외부의 다른 모듈을 가져와서 사용할 때 사용하는 속성이다. 이는 외부 모듈에서 제공하는 기능을 현재 모듈 내에서 사용할 수 있도록 하기 위한 방법이다.
TypeScript@Module({ imports: [TypeOrmModule.forFeature([BoardRepository])], }) export class BoardsModule {}
위 코드에서 TypeOrmModule.forFeature([BoardRepository])는 BoardRepository를 외부의 TypeORM 모듈에서 가져와 사용하는 방식이다. 이렇게 하면 현재 모듈 내에서 TypeORM의 기능을 사용하여 데이터베이스와 상호작용할 수 있다.
exports는 현재 모듈에서 정의한 서비스나 Provider를 외부 다른 모듈에서도 사용할 수 있도록 공개하는 기능을 담당한다. 즉, exports에 등록된 항목은 외부 모듈에서 imports를 통해 불러올 수 있다.
TypeScript@Module({ providers: [BoardsService], exports: [BoardsService], }) export class BoardsModule {}
위 코드에서는 BoardsService가 exports 항목에 등록되어 있기 때문에, 외부의 다른 모듈에서도 BoardsService를 사용할 수 있다.
NestJS는 CLI(Command Line Interface)를 제공하여, 명령어 하나만으로 모듈, 컨트롤러, 서비스 등을 쉽게 생성할 수 있다. 사용해보니 확실히 개발 효율성을 크게 향상시키고, 자동으로 프로젝트의 구조를 설정해줬다.
Bash# boards 모듈 생성 nest g module boards # boards 컨트롤러 생성 nest g controller boards --no-spec # boards 서비스 생성 nest g service boards --no-spec
따라서 CLI 명령어 편했다 😁
컨트롤러는 클라이언트로부터 들어오는 요청을 처리하고, 그 결과를 응답하는 역할을 한다. 컨트롤러는 HTTP 요청을 처리하는 다양한 핸들러(@Get, @Post, @Delete 등)를 통해 각각의 요청에 맞는 처리를 수행한다.
TypeScriptimport { Controller, Get } from '@nestjs/common'; @Controller('boards') export class BoardsController { @Get() findAll() { return '모든 게시글을 반환'; } }
위의 예시는 GET /boards 요청을 처리하는 간단한 컨트롤러로, 모든 게시글을 반환하는 역할을 한다.
서비스는 컨트롤러에서 요청한 작업을 처리하는 핵심 로직을 담당한다. 데이터베이스와의 통신이나 비즈니스 로직을 처리하는 역할을 한다. NestJS에서 서비스는 @Injectable 데코레이터를 사용하여 의존성 주입이 가능하도록 설정된다.
TypeScriptimport { Injectable } from '@nestjs/common'; @Injectable() export class BoardsService { findAll() { return '모든 게시글을 반환'; } }
컨트롤러는 서비스의 메서드를 호출하여 비즈니스 로직을 처리하고, 그 결과를 클라이언트에게 반환한다.
NestJS에서는 TypeORM과 같은(또 처음 써보는…) ORM(Object Relational Mapping) 라이브러리를 사용하여 데이터베이스와 상호작용한다.
TypeORM 역시 이름부터 ORM이기 때문에 데이터베이스의 테이블을 객체로 매핑하여 사용할 수 있으며, 이를 통해 SQL 쿼리를 직접 작성하지 않고도 객체 지향적인 방법으로 데이터를 다룰 수 있다.
엔티티(Entity)는 데이터베이스 테이블의 구조를 정의하는 클래스이다. NestJS에서는 @Entity 데코레이터를 사용하여 엔티티를 정의하며, 이 엔티티 클래스는 데이터베이스에서 하나의 테이블로 대응된다.
TypeScriptimport { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; import { BoardStatus } from './board-status.enum'; @Entity() export class Board { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column() description: string; @Column() status: BoardStatus; }
위의 Board 엔티티는 boards 테이블을 나타내며, 각 필드는 데이터베이스 컬럼과 연결됩니다. 예를 들어, id 필드는 기본 키(Primary Key)를 나타내며, title, description, status는 각각 데이터베이스의 컬럼에 대응된다.
리파지터리(Repository)는 엔티티와 상호작용하는 클래스이다. 리파지터리는 데이터베이스에 대한 CRUD(Create, Read, Update, Delete) 작업을 추상화하여, 직접 SQL 쿼리를 작성하지 않고도 데이터베이스와 상호작용할 수 있게 한다.
NestJS도 역시 TypeORM의 리파지터리를 사용하여 엔티티에 대한 데이터 처리를 수행한다.
TypeScriptimport { EntityRepository, Repository } from 'typeorm'; import { Board } from './board.entity'; @EntityRepository(Board) export class BoardRepository extends Repository<Board> { async createBoard(title: string, description: string): Promise<Board> { const board = this.create({ title, description, status: 'PUBLIC', }); await this.save(board); return board; } }
위의 BoardRepository 클래스는 Board 엔티티와 연관된 리파지터리이다. createBoard 메서드를 통해 새로운 게시글을 생성하고, 데이터베이스에 저장하는 역할을 한다.
리파지터리 엔티티와 직접적으로 연관된 데이터베이스 작업을 처리하며, 서비스 계층에서 호출되어 비즈니스 로직에 활용된다.
TypeORM 2버전 이하에서는 @EntityRepository() 데코레이터를 사용하여 리포지터리 클래스를 생성할 수 있어 해당 데코레이터는 특정 엔티티와 연관된 리포지터리를 쉽게 만들 수 있도록 해준다는데, 일단 나의 경우 3버전에서 아래와 같이 사용하니 오류가 났다.
TypeScriptimport { EntityRepository, Repository } from 'typeorm'; import { Board } from './board.entity'; @EntityRepository(Board) export class BoardRepository extends Repository<Board> { // 리포지터리 메서드 작성 }
찾아보니 TypeORM 3버전 이상부터 @EntityRepository() 데코레이터가 제거되어, 이를 사용할 경우 오류가 발생한다고 한다. 대신 @Injectable() 데코레이터와 DataSource를 사용하여 리파지터리를 정의해야 한다.
TypeScriptimport { Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; import { Board } from './board.entity'; @Injectable() export class BoardRepository extends Repository<Board> { constructor(dataSource: DataSource) { super(Board, dataSource.createEntityManager()); } }
주주니어의 나의 입장에서는 NestJS의 개발 경험은 좋게 느껴진다. (초반이라 그런 것 같다.)