본문 바로가기

모듈 연결과 실행

프로젝트·파일·폴더 모듈을 AppModule에 등록하고 NestJS 서버를 실행합니다.

앞 글에서 프로젝트, 파일, 폴더의 서비스와 controller를 만들었습니다. 이 글에서는 이들을 ProjectModule로 묶어 AppModule에 등록하고, main.ts에 요청 검증과 CORS를 설정한 뒤 서버를 실행해 API를 차례로 호출해 봅니다.


ProjectModule

src/project/project.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { File } from '../file/entities/file.entity';
import { Folder } from '../folder/entities/folder.entity';
import { Project } from './entities/project.entity';
import { ProjectController } from './project.controller';
import { ProjectService } from './project.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([Project, File, Folder]), // 세 엔티티의 저장소를 등록합니다.
  ],
  controllers: [ProjectController],
  providers: [ProjectService],
  exports: [ProjectService], // 다른 모듈에서 ProjectService를 쓸 수 있게 내보냅니다.
})
export class ProjectModule {}

ProjectService가 세 저장소를 주입받으므로 forFeature()에 Project, File, Folder를 모두 넣습니다. 이 프로젝트는 데이터베이스 설정에서 autoLoadEntities: true를 쓰므로, 여기서 등록한 엔티티가 그대로 TypeORM 연결에도 포함됩니다. User 엔티티는 앞에서 AuthModule이 forFeature([User])로 등록했습니다.

파일과 폴더도 엔티티 폴더는 따로 두었지만, 지금은 이를 다루는 코드가 모두 ProjectService에 있으므로 FileModule이나 FolderModule을 따로 만들지 않습니다. 파일 기능이 커져 서비스를 나눌 때 각자의 모듈로 옮기면 됩니다.

exports에 넣은 ProjectService는 다른 모듈이 ProjectModule을 import했을 때 주입받을 수 있습니다. 같은 모듈의 ProjectController는 exports가 없어도 쓸 수 있으며, 다음 글의 실시간 Gateway처럼 다른 모듈에서 쓸 때 필요합니다.


AppModule 최종 구성

AppModule의 imports에 ProjectModule을 추가합니다. 데이터베이스 설정은 앞에서 만든 그대로입니다.

src/app.module.ts (최종)
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { ProjectModule } from './project/project.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.getOrThrow<string>('DATABASE_HOST'),
        port: Number(config.get<string>('DATABASE_PORT', '5432')),
        username: config.getOrThrow<string>('DATABASE_USERNAME'),
        password: config.getOrThrow<string>('DATABASE_PASSWORD'),
        database: config.getOrThrow<string>('DATABASE_NAME'),
        autoLoadEntities: true,
        synchronize: true, // 개발 단계에서만 사용합니다.
        logging: ['query', 'error'],
      }),
    }),
    AuthModule,
    ProjectModule,
  ],
})
export class AppModule {}
AppModule의 네 imports와 ProjectModule 등록

Nest · Module assembly

AppModule과 ProjectModule 등록

최종 AppModule의 네 imports와 ProjectModule의 저장소·controller·provider·export를 대조합니다. File·Folder 저장소는 이 예제에서 ProjectModule에 함께 등록됩니다.

AppModule의 독립 등록 관계 AppModule은 ConfigModule.forRoot, TypeOrmModule.forRoot, AuthModule, ProjectModule을 각각 등록한다. ProjectModule은 세 저장소와 컨트롤러·서비스·export를 등록하며 연결선은 실행 순서가 아니다. AppModule imports 네 모듈 등록 ConfigModule.forRoot 환경 파일 · isGlobal: true TypeOrmModule.forRoot PostgreSQL · synchronize: true User, Project, File, Folder AuthModule 앞 절의 사용자 인증 ProjectModule imports: forFeature([Project, File, Folder]) controllers: [ProjectController] providers: [ProjectService] exports: [ProjectService]
AppModule의 독립 등록 관계 AppModule은 ConfigModule.forRoot, TypeOrmModule.forRoot, AuthModule, ProjectModule을 각각 등록한다. ProjectModule은 세 저장소와 컨트롤러·서비스·export를 등록하며 연결선은 실행 순서가 아니다. AppModule imports 네 모듈 등록 ConfigModule forRoot 환경 파일 선택 isGlobal: true TypeOrmModule forRoot PostgreSQL synchronize: true User, Project File, Folder AuthModule 앞 절의 사용자 인증 ProjectModule imports: forFeature Project / File / Folder controllers: ProjectController providers / exports: ProjectService
AppModule의 네 imports와 ProjectModule 내부 등록 AppModule의 네 imports는 환경 파일을 로드하는 ConfigModule.forRoot, PostgreSQL과 네 엔티티를 설정하는 TypeOrmModule.forRoot, 앞 절의 AuthModule, 세 엔티티 저장소와 컨트롤러·서비스를 등록하는 ProjectModule입니다. AppModule imports 네 모듈 등록 ConfigModule.forRoot 환경 파일 선택 · isGlobal: true TypeOrmModule.forRoot PostgreSQL · synchronize: true entities: [User, Project, File, Folder] AuthModule 앞 절의 사용자 인증 모듈 ProjectModule imports: forFeature([Project, File, Folder]) controllers: [ProjectController] providers: [ProjectService] exports: [ProjectService]
  • AppModule — imports
    • ConfigModule.forRoot

      환경 파일 선택 · isGlobal: true

    • TypeOrmModule.forRoot

      PostgreSQL · synchronize: true

      entities: User, Project, File, Folder

    • AuthModule

      앞 절의 사용자 인증 모듈

    • ProjectModule
      • imports: TypeOrmModule.forFeature로 Project, File, Folder 등록
      • controllers: ProjectController
      • providers: ProjectService
      • exports: ProjectService

forFeature는 TypeOrmModule.forFeature입니다. 현재 File·Folder도 ProjectModule에서 등록하며, 별도 FileModule·FolderModule은 분리하지 않았습니다.

위 그림은 AppModule이 네 모듈을 각각 등록하고, ProjectModule이 세 저장소와 controller, 서비스, export를 등록하는 관계를 보여 줍니다. 그림 아래에 적혀 있듯이 연결선은 실행 순서가 아니라 등록 관계입니다.

그림은 TypeOrmModule.forRoot에 User, Project, File, Folder를 직접 적은 구성을 그렸습니다. 이 프로젝트는 forRootAsync()와 autoLoadEntities: true를 쓰므로, AuthModule과 ProjectModule의 forFeature()에 등록한 같은 네 엔티티가 연결에 포함됩니다. 엔티티 목록을 두 곳에서 관리하지 않아도 되는 대신, 어떤 모듈의 forFeature()에도 없는 엔티티는 연결에서도 빠진다는 점을 기억해 둡니다.

synchronize: true는 엔티티에 맞춰 테이블을 자동으로 고치므로 개발용 데이터베이스에서만 씁니다. 운영 데이터베이스는 이 설정으로 갱신하지 않고 migration으로 관리합니다.


main.ts에 검증과 CORS 설정

DTO의 데코레이터는 메타데이터일 뿐이어서, ValidationPipe를 등록해야 실제로 요청을 검사합니다. 13장 3절의 React 개발 서버는 http://localhost:5173에서 열리므로, 그 출처의 요청을 허용하도록 CORS도 켭니다.

src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // DTO에 없는 속성은 지우고, 규칙에 맞지 않는 요청은 400으로 거부합니다.
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  // Vite 개발 서버의 출처만 허용합니다.
  app.enableCors({ origin: 'http://localhost:5173' });
  await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

whitelist: true는 DTO에 검증 데코레이터가 없는 속성을 요청 본문에서 지웁니다. 예를 들어 프로젝트 생성 요청에 ownerId를 함께 보내도 서비스에는 name만 전달됩니다. enableCors()는 HTTP API에만 적용되며, 다음 글의 WebSocket Gateway는 자신의 옵션에서 CORS를 따로 설정합니다.


서버 실행과 API 확인

PostgreSQL을 실행하고 .env의 접속 정보를 맞춘 뒤 개발 서버를 시작합니다. TypeOrmModule.forRootAsync()는 애플리케이션을 시작할 때 데이터베이스에 연결하므로, PostgreSQL이 꺼져 있거나 계정 정보가 틀리면 HTTP 요청을 받기 전에 연결 오류가 터미널에 나타납니다. 이때 TypeORM 모듈은 몇 차례 연결을 다시 시도한 뒤 시작에 실패합니다. getOrThrow()로 읽는 변수가 .env에 없을 때도 같은 시작 단계에서 멈춥니다.

npm run start:dev

서버가 3000번 포트에서 시작되면 회원 가입부터 파일 수정까지 차례로 호출해 봅니다. 아래 예는 bash의 curl이며, Postman이나 Insomnia 같은 API 클라이언트로 같은 요청을 보내도 됩니다. 먼저 회원 가입을 하고 같은 계정으로 로그인합니다.

curl -X POST http://localhost:3000/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"secret123","nickname":"owner"}'

curl -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"secret123"}'

회원 가입은 {"message":"User registered successfully"}를, 로그인은 {"accessToken":"..."}를 돌려줍니다. 토큰 값을 셸 변수에 넣고, 프로필과 프로젝트 생성 요청에 Authorization 헤더로 붙입니다.

TOKEN='로그인 응답의 accessToken 값'

curl http://localhost:3000/auth/profile -H "Authorization: Bearer $TOKEN"

curl -X POST http://localhost:3000/projects \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"My First Project"}'

프로젝트 생성 응답의 id를 PROJECT_ID에 넣습니다. GET /projects로 목록을, GET /projects/$PROJECT_ID로 상세를 조회할 수 있습니다. 이어서 그 프로젝트에 파일을 하나 만들고, 응답의 파일 id를 FILE_ID에 넣습니다.

PROJECT_ID='프로젝트 생성 응답의 id 값'

curl -X POST http://localhost:3000/projects/$PROJECT_ID/files \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"main.ts","path":"/main.ts","type":"typescript","content":"console.log(1);"}'

마지막으로 파일 내용을 바꾸고, 같은 파일을 다시 조회해 content가 console.log(2);로 바뀌었는지 확인합니다. 수정 요청의 성공 응답만 보고 끝내지 않고 다시 조회하는 이유는, 저장된 값을 서버에서 다시 읽어야 실제로 저장되었는지 알 수 있기 때문입니다. 이 단계는 REST 저장이며 다른 브라우저에 변경을 알리지는 않습니다.

FILE_ID='파일 생성 응답의 id 값'

curl -X PATCH http://localhost:3000/projects/$PROJECT_ID/files/$FILE_ID/content \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"content":"console.log(2);"}'

curl http://localhost:3000/projects/$PROJECT_ID/files/$FILE_ID \
  -H "Authorization: Bearer $TOKEN"

권한도 함께 확인합니다. 토큰 없이 보호된 API를 호출하면 401을 받아야 합니다. 다른 이메일로 두 번째 계정을 만들어 로그인한 뒤, 그 토큰으로 첫 계정의 PROJECT_ID나 FILE_ID를 요청하면 404를 받아야 합니다. 요청이 실패하면 먼저 토큰과 기록한 ID, 요청 경로를 확인하고, 서버가 시작되지 않으면 패키지 설치와 .env의 데이터베이스 설정을 점검합니다.


설정값을 검증하는 위치

.env를 읽었다는 것과 그 값이 올바르다는 것은 별개입니다. 앞 코드의 getOrThrow()는 값이 아예 없을 때만 시작을 멈춥니다. 포트는 Number()로 바꾸는데, '5432abc'는 NaN이, 빈 문자열은 0이 되고, '65536'은 숫자로는 바뀌지만 포트 범위를 넘습니다. 65536은 데이터베이스에 접속하려는 순간에야 오류로 드러납니다. NaN과 0은 더 알아차리기 어렵습니다. PostgreSQL 드라이버 pg는 거짓으로 평가되는 포트 값을 비어 있는 것으로 보고 PGPORT 환경 변수나 기본값 5432를 쓰므로, PostgreSQL이 5432번에서 실행 중이면 잘못된 설정이 오류 없이 지나갑니다.

10장의 환경 변수 관리와 비밀 정보 보호처럼 ConfigModule.forRoot()에 validationSchema를 연결하면, 포트를 1부터 65535 사이의 정수로 검사해 잘못된 값이면 애플리케이션이 시작 단계에서 멈춥니다. 이 예제에는 아직 검증 스키마를 연결하지 않았습니다.

데이터베이스 연결을 여러 개로 늘린다면 연결 이름도 맞춰야 합니다. forRootAsync()에 name: 'editor'를 지정했다면 그 연결의 저장소를 쓰는 곳에서도 forFeature([File], 'editor')와 @InjectRepository(File, 'editor')처럼 같은 이름을 적습니다. 이름을 생략하면 기본 연결을 쓰므로, 이름이 다른 연결의 저장소는 주입되지 않습니다. 자세한 규칙은 NestJS 공식 TypeORM 문서의 여러 데이터베이스 절에 나와 있습니다.


정리

  • ProjectModule은 forFeature([Project, File, Folder])로 서비스가 쓸 세 저장소를 등록하고, 다른 모듈이 쓸 수 있도록 ProjectService를 내보냅니다.
  • autoLoadEntities: true를 쓰면 각 모듈의 forFeature()에 등록한 엔티티가 연결에 포함되므로, 엔티티 목록을 AppModule에 따로 적지 않습니다.
  • DTO 검증은 main.ts에 ValidationPipe를 등록해야 동작하며, whitelist: true는 DTO에 없는 속성을 지웁니다.
  • enableCors()로 프런트엔드 개발 서버의 출처를 허용하며, 이 설정은 HTTP API에만 적용됩니다.
  • 저장 결과는 수정 요청의 응답이 아니라 같은 리소스를 다시 조회해서 확인하고, 다른 계정의 토큰으로 접근이 거부되는지도 확인합니다.
  • getOrThrow()는 값이 없는 경우만 막으므로, 형식과 범위는 설정 스키마로 따로 검증합니다.

연습 문제

1. forFeature에서 빠진 File

ProjectModule의 forFeature()를 [Project, Folder]로 바꾸고 File을 빠뜨렸습니다. 다른 모듈도 File을 등록하지 않습니다. 어느 단계에서 문제가 드러날까요? 또 ProjectService를 다른 모듈의 Gateway에서 쓰려면 무엇이 필요할까요?

해설 보기

요청을 보내기 전에 애플리케이션 시작 단계에서 실패합니다. Nest는 ProjectService의 생성자가 요구하는 File 저장소를 찾지 못해 의존성을 해결할 수 없다는 오류를 냅니다. autoLoadEntities를 쓰므로 File 엔티티가 연결에서도 빠져, TypeORM이 Project.files 관계의 대상 메타데이터를 찾지 못했다는 오류가 날 수도 있습니다. 어느 쪽이든 요청 본문을 바꾸는 것으로는 해결되지 않고, forFeature()에 File을 다시 넣어야 합니다.

다른 모듈에서 ProjectService를 쓰려면 ProjectModule이 exports에 서비스를 넣고, 사용하는 모듈이 imports에 ProjectModule을 넣어야 합니다. 같은 모듈의 controller는 이 연결 없이도 서비스를 주입받습니다.

2. ValidationPipe를 빠뜨린 경우

main.ts에서 app.useGlobalPipes(...) 줄을 지우고 POST /projects에 {"name":"a"}를 보냈습니다. CreateProjectDto에는 @MinLength(3)이 있습니다. 응답은 어떻게 될까요?

해설 보기

201 Created와 함께 이름이 a인 프로젝트가 저장됩니다. class-validator의 데코레이터는 규칙을 메타데이터로 기록할 뿐이고, 그 규칙으로 요청을 검사하는 것은 ValidationPipe입니다. pipe가 없으면 @MinLength(3)은 아무 일도 하지 않습니다.

pipe를 다시 등록하면 같은 요청은 400 Bad Request와 함께 name이 3자 이상이어야 한다는 메시지를 받습니다.

3. 포트 값의 변환 결과

.env의 DATABASE_PORT가 '5432abc', '', '65536', '5432'일 때 Number(config.get('DATABASE_PORT', '5432'))의 결과를 각각 구해 보세요. 이 중 시작 단계의 getOrThrow()가 막아 주는 값이 있을까요?

해설 보기

순서대로 NaN, 0, 65536, 5432입니다. 포트에는 getOrThrow()가 아니라 기본값이 있는 get()을 쓰므로 어떤 값도 시작 단계에서 막히지 않습니다. 설령 getOrThrow()를 썼더라도 네 값 모두 존재하는 문자열이므로 통과합니다.

마지막 값만 올바른 포트입니다. 65536은 데이터베이스에 접속할 때 포트 범위 오류가 나지만, NaN과 0은 pg 드라이버가 PGPORT나 기본 포트 5432로 바꿔 접속하므로 PostgreSQL이 5432번에서 실행 중이면 오류 없이 접속됩니다. 설정이 틀렸다는 사실이 드러나지 않으므로, 시작할 때 걸러 내려면 설정 스키마에서 1부터 65535 사이의 정수인지 검사합니다.

다음 글 같은 파일의 두 세션 연결하기에서는 Socket.IO Gateway를 추가해 같은 파일을 연 다른 세션에 변경을 전달합니다. 이 글은 선택 내용이므로, REST API만으로 화면을 연결하려면 프론트엔드 통합으로 바로 넘어가도 됩니다.