[NestJS] 시작하기

HoonDong_K·2025년 5월 4일

NestJS

목록 보기
1/3
post-thumbnail

✅ 프로젝트 살펴보기

$ 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) 은 객체 생성 및 실행 흐름 제어의 책임이 개발자가 아닌 프레임워크로 넘어가는 것 (원칙 중 하나)

  • IoC 컨테이너를 통해 클래스 인스턴스 생성과 의존성 주입을 자동으로 관리

  • 클래스에 @Injectable() 데코레이터를 붙이면 IoC 컨테이너가 해당 클래스를 등록하고 필요 시 인스턴스를 생성

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가 있을 때,

  • BController: AService의 count를 증가시키고 로그를 남긴다.
  • CController: AService의 count값을 반환한다.
/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의 환경을 설정해준다.

  • type: DB 타입
  • database: 사용하는 DB의 접속 정보 혹은 파일 경로
  • entities: TypeORM이 테이블로 인식할 엔티티 클래스들의 경로 또는 배열
    - dynamic import로 모든 하위 폴더의 *.entity.ts의 모든 파일 등록
  • synchronize: 애플리케이션 실행 시, 엔티티 정의를 기준으로 DB 스키마를 자동 생성/동기화
    - production상태에서는 false 추천 : 저장되어있는 데이터 훼손될 수 있음.

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('Todo Not Found');

    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('Todo Not Found');
  }

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

    if (affected === 0) throw new NotFoundException('Todo Not Found');
  }
}

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

  • findAll(): 모든 todo 데이터 반환
  • findById(): id에 맞는 데이터 반환
  • create(): createToDto 데이터로 todo 저장
  • update(): id에 맞는 데이터 수정
  • delete(): id에 맞는 데이터 제거

❓ 질문

  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의 차이
  • DTO(Data Transfer Object)
    - 데이터 요청/응답 시에 주고받을 데이터의 형식을 의미.
    • 요청 데이터 유효성 검사에 유용
  • Entity
    - DB 테이블과 직접 매핑되는 클래스
  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 엔드포인트를 정의하였다.

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

  • @Param(): Route parameter
  • @Body(): Body parameter
  • @Query(): Query parameter

참고

profile
도움이 될 수 있는 개발자

0개의 댓글