BoostUs로그인

[NestJS] 시작하기

J003 강동훈10
0
0
2025-05-04
원문 보기
[NestJS] 시작하기 글의 썸네일 이미지

✅ 프로젝트 살펴보기

$ npm i -g @nestjs/cli
$ nest new project-name

새로 프로젝트를 생성하면 기본적으로 폴더 구조는 다음과 같이 생성된다.

src
ㄴ app.controller.spec.ts
ㄴ app.controller.ts
ㄴ app.module.ts
ㄴ app.service.ts
ㄴ main.ts

프로젝트의 시작점인 main.ts의 코드를 살펴보면 다음과 같다.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(process.env.PORT ?? 3000); } bootstrap();

❓ 질문

  1. NestFactory는 무슨 역할을 할 것인가?

    • 가추: NestJS는 Singleton 패턴을 사용하는 것으로 알고 있기에 AppModule을 통해 하나의 애플리케이션 인스턴스를 생성하는 작업.
  2. AppModule을 어떻게 다른 모듈을 통합적으로 관리할 수 있는가?

    • 가추: 다른 모듈들의 의존성을 주입하여 app.module.ts에서 통합적으로 관리
  3. controller, module, service는 어떤 역할을 할 것인가?

    • 가추: controller는 요청/응답 관리, module은 도메인 관리, service는 비즈니스 로직

❗️ 대답

  1. NestFactory는 무슨 역할을 할 것인가?

    • root module을 매개변수로 받아서 dependency graph를 구성하고 내부 DI(Dependency Injection) 컨테이너를 초기화한다.
      • NestFactory.create()를 한 번 호출할 때마다 새로운 애플리케이션이 생성되기에 싱글톤 패턴은 아님.
      • 싱글톤 패턴은 애플리케이션 내부적으로 사용
  2. AppModule을 어떻게 다른 모듈을 통합적으로 관리할 수 있는가?

    • 다른 모듈들을 import를 통해 의존성을 연결하고 dependency graph를 구성
  3. controller, module, service는 어떤 역할을 할 것인가?

    • controller: 클라이언트의 요청/응답 처리
    • module: 관련된 controller, service를 묶는 단위, 의존성 관리
    • service: 핵심 비즈니스 로직 담당

질문에 대한 내용을 찾다보니 반복적으로 나오는 내용을 간추려보았다.

1️⃣ Dependency Graph

AppModule을 통해 의존성이 주입된 모든 모듈에 대해 의존 관계를 나타낸 그래프

// app.module.ts

@Module({ imports: [ TypeOrmModule.forRoot({ type: 'sqlite', database: 'db.sqlite', entities: [__dirname + '/**/*.entity{.ts,.js}'], synchronize: true, }), TodoModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule {}

dependency_graph

현재 작성되어 있는 app.module.ts에서 todo 기능을 담은 TodoModule과 데이터 저장을 위한 TypeOrmModule 모듈이 AppModule에 의존성 주입되어 있는 것을 그래프로 확인해볼 수 있다.

2️⃣ DI & IoC

class A {
  const b = new B();
}

일반적으로 인스턴스를 생성할 때는 const b = new B();처럼 직접 생성자 함수를 호출하여 인스턴스를 생성한다. 하지만 해당 방식은

의 단점이 존재한다.

@Injectable()
export class B {}

export class A { constructor(private b: B) {} }

의존성 주입(DI, Dependency Injection) 은 클래스가 직접 의존 객체를 생성하지 않고, 외부에서 해당 객체를 주입받는 방식이다. (IoC 달성을 위한 디자인 패턴 중 하나)

제어의 역전 (IoC, Inversion of Control) 은 객체 생성 및 실행 흐름 제어의 책임이 개발자가 아닌 프레임워크로 넘어가는 것 (원칙 중 하나)

3️⃣ Singleton

싱글톤 패턴은 하나의 인스턴스만 생성하여 공유하는 디자인 패턴이다.

처음 가추했던 것과 달리, NestFactory.create()는 NestJS 애플리케이션을 부트스트랩하는 역할을 하지만 이 메서드 자체가 싱글톤 패턴을 의미하는 것은 아니며 애플리케이션 내부 동작에서 사용된다.

//a.service.ts
@Injectable()
export class AService {
  private count = 0;

increaseCount() { this.count++; }

getCount() { return this.count; } }

a.service.ts에서는 count 변수와 이를 증가시키는 메서드 increaseCount()와 조회하는 메서드 getCount()를 제공한다.

@Injectable 데코레이터를 설정하면 해당 service를 provider로 만들고 controller에 의존성을 주입하거나 module을 통해 IoC 컨트롤러에 등록하고 관리될 수 있는 상태가 된다.

// b.controller.ts
@Controller()
export class BController {
  constructor(private readonly aService: AService) {}

@Get('increase') increase() { this.aService.increaseCount(); return 'Count increased in B'; } }

// c.controller.ts @Controller() export class CController { constructor(private readonly aService: AService) {}

@Get('count') getCount() { return Current count in C: ${this.aService.getCount()}; } }

만약 AService를 주입한 BControllerCController가 있을 때,

/increase 3번 호출 후, /count로 count 조회

GET /increase 'Count increased in B' GET /increase 'Count increased in B' GET /increase 'Count increased in B' GET /count 'Current count in C: 3'

BControllerCController는 각자 다른 controller에서 AService를 주입받아 로직을 수행하였지만, AService는 싱글톤 패턴에 따라 하나의 인스턴스로 작업이 수행되기 때문에 count 변수는 공유되고 있는 상태라는 것을 알 수 있다.

4️⃣ 전체적인 흐름

  1. NestFactory.create(AppModule)을 실행.
    • IoC 컨테이너 생성
  2. 루트 모듈인 AppModule을 시작으로 의존성 탐색
    • @Injectable()데코레이터가 있으면 IoC 컨테이너에 등록
  3. Dependency Graph 생성
    • IoC 컨테이너의 메타 데이터를 분석하며 생성
    • 각 객체 간 의존 관계를 파악하고 객체의 순차적인 생성에 참고
    • 상호 참조 문제 파악
  4. /increase 요청이 들어오고 BController가 핸들링
  5. BController 의존성 주입
    • BControllerAService 확인
      • IoC 컨테이너는 AService의 인스턴스를 확인하여 BController에 주입 (IoC 원칙)
      • 싱글톤 패턴에 의해 생성된 AService 인스턴스가 없기에 새로 생성하여 count 증가
  6. /count 요청이 들어오고 CController가 핸들링
  7. CController 의존성 주입
    • CController에서 AService를 확인하여 의존성 주입
    • 이미 생성된 AService 인스턴스가 존재하기 때문에 동일한 인스턴스를 재사용하여 증가된 count를 반환

✅ Todo CRUD

Todo CRUD를 간단하게 작업하기 위해서 DB를 먼저 연결해주었다. DB는 간단하게 이용할 수 있는 sqlite3를 사용하였다.

SQlite 는 서버가 따로 필요하지 않아, host나 port가 따로 필요하지 않고 단순히 파일 하나만 생성하여 DB로 사용할 수 있기에 선택하게 되었다.

또한 ORM(Object-Relational Mapping)으로 typeorm을 사용하였다.

npm install --save @nestjs/typeorm typeorm sqlite3

1️⃣ DB 연결

//app.module.ts

@Module({ imports: [ TypeOrmModule.forRoot({ type: 'sqlite', database: 'db.sqlite', entities: [__dirname + '/**/*.entity{.ts,.js}'], synchronize: true, }), TodoModule, CountingModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule {}

TypeOrmModule@nestjs/typeorm에서 import해서 루트 모듈에서 DB의 환경을 설정해준다.

2️⃣ Entity 생성 및 연결

// database/todo.entity.ts
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
} from 'typeorm';

@Entity('todos') export class Todo { //id 자동생성 @PrimaryGeneratedColumn() id: number;

@Column() name: string;

@Column() description: string;

//생성일 자동생성 @CreateDateColumn() createdAt: Date; }

// todo.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Todo])],
  controllers: [TodoController],
  providers: [TodoService],
})
export class TodoModule {}

Todo 엔티티를 생성하여 TodoModule에 추가해주었다.

❓ 질문

  1. forRoot()forFeature()차이점
    • 가추: forRoot()는 전역에서 사용될 엔티티 사용, forFeature()는 해당 모듈에서만 사용할 엔티티 적용
  2. module은 왜 imports, controllers, providers로 구분될까?
    • 가추: 각각의 역할을 구분하기 위해서? controller와 provider는 정확히 구분되지만 import는 그 외 모든 모듈에 해당?

❗️ 대답

  1. forRoot()forFeature()차이점
    • forRoot()는 전역에서 한 번만 설정되는 초기화 메서드로, 모듈의 전역 환경이나 공통 설정을 주입할 때 사용
    • forFeature()는 기능 단위로 필요한 설정 또는 주입 대상(Entity, Provider 등)을 모듈 단위로 등록할 수 있도록 설계된 로컬 설정 메서드
    • TypeOrmModule의 경우 forRoot()는 DB 환경설정과 ORM이 인지하는 엔티티들을 전역에서 등록하고 forFeature()에서는 해당 모듈에서 사용할 엔티티를 배열로 받아서 의존성 주입이 가능하도록 설정
  2. module은 왜 imports, controllers, providers로 구분될까?
    • imports:해당 모듈에서 필요한 provider를 export하는 모듈 리스트
    • providers: 해당 모듈에서 공유되고 인스턴스화되는 provider
    • controllers: 해당 모듈에서 인스턴스화될 controller
    • providerscontrollers는 해당 모듈에서 정의하고 사용하는 것들, imports는 외부에서 가져오는 모듈들

3️⃣ Service 생성

//todo.service.ts

@Injectable() export class TodoService { constructor( @InjectRepository(Todo) private readonly todoRepo: Repository<Todo>, ) {} async findAll(): Promise<Todo[]> { return await this.todoRepo.find(); }

async findById(id: string): Promise<Todo | null> { const todo = await this.todoRepo.findOne({ where: { id: +id }, });

if (!todo) throw new NotFoundException(&#39;Todo Not Found&#39;);

return todo;

}

async create(createTodoDto: CreateTodoDto): Promise<Todo> { return await this.todoRepo.save(createTodoDto); }

async update(id: string, updateTodoDto: Partial<Todo>): Promise<void> { const { affected } = await this.todoRepo.update({ id: +id }, updateTodoDto);

if (affected === 0) throw new NotFoundException(&#39;Todo Not Found&#39;);

}

async delete(id: string): Promise<void> { const { affected } = await this.todoRepo.delete({ id: +id });

if (affected === 0) throw new NotFoundException(&#39;Todo Not Found&#39;);

} }

@Injectable() 데코레이터를 사용하여 TodoService를 IoC 컨테이너에 등록함으로써, 의존성 주입이 가능한 상태로 만들어주었다. @nestjs/typeorm에서 제공하는 @InjectRepository()를 통해 Todo 엔티티에 대한 Repository를 주입받아 todoRepo를 통해 DB에 접근할 수 있도록 구성하였다.

❓ 질문

  1. @InjecRepository의 역할?
    • 가추: 해당 서비스에서 사용할 Entity를 typeorm에 등록하여 repository를 만들어준다.
  2. DTO와 Entity의 차이
    • 가추: DTO는 데이터를 주고 받기 위한 형식, Entity는 DB 스키마
  3. affected는 Not Found를 의미할까?
    • 가추: 데이터를 수정하고 삭제의 성공 여부를 affected로 boolean 값을 반환하기에 not found만을 의미하진 않을 것 같다.

❗️ 대답

  1. @InjecRepository의 역할?
    • injection token: NestJS에서 provider를 식별하는 고유값
    • @Inject(): 해당 데코레이터에 명시적으로 토큰을 지정하여 어떤 provider를 주입할지 알려주는 역할
    • @InjecRepository(): typeorm을 위해서 제공되는 데코레이터이며 내부적으로 @Inject을 래핑하여 사용

모든 provider는 NestJS 내부에서 식별 가능한 토큰으로 등록되며, 주입이 필요할 때 이 토큰을 기반으로 적절한 provider를 찾아 주입한다. 일반적으로 NestJS는 클래스 이름을 토큰으로 사용하지만, 적절한 provider를 찾지 못할 경우 @Inject() 데코레이터를 통해 명시적으로 토큰을 지정할 수 있습니다.

NestJS는 런타임에 의존성을 주입하는 반면, TypeScript의 제네릭은 컴파일 타임에만 존재하기 때문에 런타임에서는 Repository<Todo>와 같은 타입 정보를 알 수 없다. 그렇기 때문에 @InjectRepository(Todo)를 사용하여 명시적으로 토큰 정보를 전달하여 적절한 Repository를 주입시킨다.

@InjectRepository(Todo)는 토큰 정보와 같은 메타 데이터만 전달하기 때문에 실제 주입하는 역할은 TodoModule에서 TypeOrmModule.forFeature([Todo])를 호출하여 주입한다.

  1. DTO와 Entity의 차이
  1. affected는 Not Found를 의미할까?
    • affectedupdate() 혹은 delete() 시에 영향을 받는 row 혹은 document의 수를 의미한다. 즉, 조건에 맞는 row/document의 수를 반환한다.
    • id를 조건절로 쿼리를 실행시키기 때문에 affected가 0일 경우는 Not Found를 제외하고 없을 것이라 간주.

4️⃣ Controller 생성

// todo.controller.ts

@Controller('todo') export class TodoController { constructor(private readonly todoService: TodoService) {}

@Get() async findAll(): Promise<Todo[]> { return await this.todoService.findAll(); }

@Get(':id') async findById(@Param('id') id: string): Promise<Todo | null> { const todo = await this.todoService.findById(id);

return todo;

}

@Post() async create(@Body() createTodoDto: CreateTodoDto): Promise<Todo> { return await this.todoService.create(createTodoDto); }

@Put(':id') async update( @Param('id') id: string, @Body() updateTodoDto: Partial<Todo>, ): Promise<void> { return await this.todoService.update(id, updateTodoDto); }

@Delete(':id') async delete(@Param('id') id: string): Promise<void> { return await this.todoService.delete(id); } }

@Controller() 데코레이터를 사용하여 컨트롤러를 생성할 수 있으며, 인자값으로 'todo'를 전달하면 컨트롤러의 기본 경로(prefix)가 'todo'로 설정된다.

TodoController에서는 TodoService를 의존성 주입하여 해당 서비스의 메서드를 사용할 수 있게 해주었고 이를 통해 findAll(), findById(), create(), delete() 메서드를 생성하여 각각의 요청을 처리하는 기능을 추가하였다.

NestJS에서 제공하는 HTTP 메서드 데코레이터(@Get(), @Post(), @Put(), @Delete(),,)를 사용하여 각 API 엔드포인트를 정의하였다.

컨트롤러의 각 메서드는 매개변수를 설정할 수 있는 데코레이터를 제공한다.

참고