app.module.ts로 이해한 NestJS 모듈 구조
NestJS의 최상위 모듈인 AppModule을 통해 imports, controllers, providers와 환경변수 검증 흐름을 정리한 글
이전 글에서는 main.ts를 기준으로 NestJS 백엔드 서버가 어떻게 시작되는지 정리했다.
main.ts가 서버를 생성하고 실행하는 진입점이라면, app.module.ts는 백엔드 애플리케이션에 어떤 기능들이 들어가는지 조립하는 최상위 모듈이다.
처음에는 AppModule을 단순히 여러 모듈을 import하는 파일 정도로만 생각했다.
하지만 코드를 하나씩 살펴보니, 이 파일은 NestJS 애플리케이션의 전체 구조를 한눈에 보여주는 중요한 파일이었다.
운영 중인 서비스 코드이기 때문에 실제 코드를 그대로 공개하지 않고, 핵심 흐름만 단순화해서 정리했다.
흐름 정리
main.ts에서는 AppModule을 기준으로 Nest 애플리케이션을 생성했다.
const app = await NestFactory.create(AppModule);
이때 사용되는 AppModule이 바로 app.module.ts에 정의되어 있다.
전체 흐름은 대략 다음과 같다.
main.ts
↓
NestFactory.create(AppModule)
↓
app.module.ts
↓
ConfigModule
PrismaModule
UsersModule
SurveysModule
AuthModule
SchoolsModule
즉, main.ts가 서버를 켜는 시작점이라면, app.module.ts는 이 서버에 어떤 기능 모듈들이 포함되는지 알려주는 조립 설명서에 가깝다.
1. AppModule은 백엔드 전체 조립 설명서다
NestJS는 애플리케이션을 기능 단위의 모듈로 나눠서 관리한다.
예를 들어 프로젝트에는 다음과 같은 기능들이 있을 수 있다.
AuthModule → 로그인, JWT 인증
UsersModule → 회원 관리
SurveysModule → 설문 관리
SchoolsModule → 학교 관리, 학교 추천
PrismaModule → DB 연결
각 기능을 하나의 파일에 전부 몰아넣는 대신, 역할별로 모듈을 나눈다.
그리고 이 모듈들을 한 곳에서 조립하는 최상위 모듈이 AppModule이다.
쉽게 정리하면 다음과 같다.
main.ts → 서버를 생성하고 실행한다
app.module.ts → 서버에 어떤 기능들이 들어갈지 정한다
2. @Module() 데코레이터
AppModule에서 가장 중요한 구조는 @Module() 데코레이터다.
운영 코드 전체를 공개하지 않고 구조만 보면 다음과 같은 형태다.
@Module({
imports: [
// 다른 기능 모듈 등록
],
controllers: [
// 요청을 받을 Controller 등록
],
providers: [
// 비즈니스 로직을 처리할 Service 등록
],
})
export class AppModule {}
@Module()은 NestJS에게 이 클래스가 일반 클래스가 아니라 NestJS 모듈이라는 것을 알려준다.
즉, 아래 클래스는 단순한 TypeScript 클래스가 아니라 NestJS가 읽고 관리하는 모듈이 된다.
export class AppModule {}
처음에는 export class AppModule {}만 보면 비어 있는 클래스처럼 보였다.
하지만 실제 역할은 @Module() 안에 정의된 설정을 통해 결정된다.
3. imports는 다른 모듈을 가져오는 곳이다
imports에는 이 모듈에서 사용할 다른 모듈들을 등록한다.
예를 들면 다음과 같은 형태다.
imports: [
ConfigModule,
PrismaModule,
UsersModule,
SurveysModule,
AuthModule,
SchoolsModule,
]
AppModule은 최상위 모듈이기 때문에 프로젝트에서 사용하는 주요 기능 모듈들이 이곳에 등록된다.
이렇게 등록하면 NestJS는 애플리케이션이 시작될 때 다음과 같이 이해한다.
"이 앱은 환경설정, DB, 사용자, 설문, 인증, 학교 관련 기능을 사용하겠구나."
즉, imports는 다음과 같은 의미라고 볼 수 있다.
우리 앱은 이 기능 모듈들을 사용할 것이다.
만약 AuthModule을 imports에서 제거하면 인증 관련 Controller나 Service가 애플리케이션에 정상적으로 연결되지 않을 수 있다.
예를 들어 로그인 API가 AuthModule 안에 있다면, 해당 모듈이 등록되어 있어야 NestJS가 로그인 API를 인식할 수 있다.
4. 환경변수 관리를 위한 ConfigModule
이 프로젝트에서는 환경변수를 NestJS 방식으로 관리하기 위해 ConfigModule을 사용했다.
구조만 보면 다음과 같은 형태다.
ConfigModule.forRoot({
isGlobal: true,
validationSchema: joi.object({
PORT: joi.number().default(5000),
NODE_ENV: joi.string().valid('development', 'production').default('development'),
// 실제 운영 값은 코드에 작성하지 않고 환경변수로 관리한다.
DATABASE_URL: joi.string().required(),
JWT_SECRET: joi.string().required(),
JWT_EXPIRES_IN: joi.string().default('1h'),
}),
});
환경변수는 보통 .env 파일에서 관리한다.
예를 들면 다음과 같은 값들이 있다.
PORT=5000
NODE_ENV=development
DATABASE_URL=postgresql://...
JWT_SECRET=...
JWT_EXPIRES_IN=1h
이 값들은 코드 안에 직접 작성하기보다 환경변수로 관리하는 것이 좋다.
특히 DB 주소나 JWT 비밀키처럼 민감한 값은 코드에 직접 넣으면 안 된다.
ConfigModule을 사용하면 이런 환경변수들을 NestJS 내부에서 더 체계적으로 사용할 수 있다.
5. forRoot()는 초기 설정을 의미한다
처음에는 forRoot()라는 이름이 어색했다.
ConfigModule.forRoot({
// 설정
});
이 코드는 ConfigModule을 앱 전체에서 사용할 수 있도록 초기화하는 역할을 한다.
비유하면 다음과 같다.
ConfigModule → 환경설정 기능을 가진 모듈
ConfigModule.forRoot() → 이 앱에서 환경설정 기능을 실제로 켜는 설정
즉, 단순히 ConfigModule을 import하는 것에서 끝나는 것이 아니라, forRoot()를 통해 이 프로젝트에서 어떤 방식으로 환경변수를 읽고 검증할지 정하는 것이다.
6. isGlobal: true는 왜 사용할까?
ConfigModule 설정에는 isGlobal: true가 들어간다.
isGlobal: true
이 옵션은 ConfigModule을 전역 모듈로 사용하겠다는 의미다.
전역으로 등록하면 다른 모듈에서 환경설정이 필요할 때마다 매번 ConfigModule을 import하지 않아도 된다.
예를 들어 인증 모듈에서 JWT 관련 환경변수를 사용하거나, Prisma 관련 코드에서 DB 주소를 사용할 수 있다.
AuthModule → JWT_SECRET 필요
PrismaModule → DATABASE_URL 필요
AppModule → PORT, NODE_ENV 필요
이런 값들은 여러 모듈에서 필요할 수 있기 때문에, ConfigModule을 전역으로 등록해두면 편하다.
다만 모든 모듈을 무조건 전역으로 만드는 것이 좋은 것은 아니다.
환경설정처럼 애플리케이션 전반에서 공통으로 사용하는 기능에 한해 전역으로 두는 것이 적절하다.
7. joi로 환경변수 검증하기
이 프로젝트에서 중요하게 느낀 부분은 joi를 이용한 환경변수 검증이었다.
validationSchema: joi.object({
PORT: joi.number().default(5000),
NODE_ENV: joi.string().valid('development', 'production').default('development'),
DATABASE_URL: joi.string().required(),
JWT_SECRET: joi.string().required(),
JWT_EXPIRES_IN: joi.string().default('1h'),
})
처음에는 환경변수는 .env에 적어두고 가져다 쓰면 된다고만 생각했다.
하지만 운영 환경에서는 환경변수가 하나라도 빠지면 서버는 실행되더라도 특정 기능에서 문제가 발생할 수 있다.
예를 들어 DATABASE_URL이 없다면 DB에 연결할 수 없다.
JWT_SECRET이 없다면 JWT 토큰을 안전하게 발급하거나 검증할 수 없다.
그래서 서버가 실행된 뒤에 에러가 발생하는 것보다, 서버 시작 시점에 필요한 환경변수가 있는지 먼저 검사하는 것이 더 안전하다.
joi는 이런 검증 규칙을 정의하는 역할을 한다.
아래부터는 validationSchema에 등록한 환경변수들이 각각 어떤 의미를 갖는지 정리한 내용이다.
8. PORT 환경변수
PORT는 서버가 열릴 포트 번호다.
PORT: joi.number().default(5000)
이 설정은 다음과 같이 이해할 수 있다.
PORT는 숫자여야 한다.
PORT 값이 없으면 기본값으로 5000을 사용한다.
예를 들어 로컬 개발 환경에서는 백엔드 서버를 다음 주소로 실행할 수 있다.
http://localhost:5000
포트 번호는 실행 환경에 따라 달라질 수 있다.
로컬에서는 5000번 포트를 사용하더라도, 배포 환경에서는 실행 환경에서 다른 포트를 주입할 수 있다.
그래서 코드 안에 고정된 값만 사용하기보다 환경변수로 관리하는 방식이 더 유연하다.
9. NODE_ENV 환경변수
NODE_ENV는 현재 실행 환경을 나타낸다.
NODE_ENV: joi.string().valid('development', 'production').default('development')
여기서는 development 또는 production만 허용하도록 설정했다.
development → 개발 환경
production → 운영 환경
만약 .env에 아래처럼 잘못된 값이 들어가면 허용되지 않는다.
NODE_ENV=test-server
왜냐하면 valid('development', 'production')에 포함된 값이 아니기 때문이다.
이렇게 가능한 값을 제한해두면, 오타나 잘못된 환경 설정을 서버 시작 시점에 잡을 수 있다.
10. DATABASE_URL 환경변수
DATABASE_URL은 DB 접속 주소다.
DATABASE_URL: joi.string().required()
이 값은 Prisma가 DB에 연결할 때 필요하다.
실제 운영 주소는 공개하면 안 되기 때문에 예시로만 표현하면 다음과 같은 형태다.
DATABASE_URL=postgresql://...
여기서 중요한 점은 required()다.
DATABASE_URL은 반드시 있어야 한다.
만약 DB 주소가 없는데 서버가 실행된다면, 서버는 켜졌지만 DB를 사용하는 순간 문제가 발생할 수 있다.
그래서 처음부터 필수값으로 검증해두면 문제를 더 빨리 찾을 수 있다.
11. JWT_SECRET 환경변수
JWT_SECRET은 JWT 토큰을 만들고 검증할 때 사용하는 비밀키다.
JWT_SECRET: joi.string().required()
JWT는 대략 다음과 같은 구조를 가진다.
header.payload.signature
여기서 signature는 토큰이 위조되지 않았는지 확인하는 데 사용된다.
이때 서버만 알고 있는 비밀키가 필요한데, 그 값이 JWT_SECRET이다.
쉽게 말하면 다음과 같다.
JWT_SECRET = 토큰 위조를 막기 위한 서버만 아는 비밀키
이 값은 절대 코드에 직접 작성하면 안 된다.
또한 블로그나 GitHub에도 공개하면 안 된다.
그래서 실제 값은 환경변수로 관리하고, 서버 시작 시점에는 값이 존재하는지만 검증한다.
12. JWT_EXPIRES_IN 환경변수
JWT_EXPIRES_IN은 JWT 토큰의 만료 시간을 의미한다.
JWT_EXPIRES_IN: joi.string().default('1h')
이 값이 없다면 기본값으로 1h를 사용한다.
예를 들어 다음과 같은 값들을 사용할 수 있다.
JWT_EXPIRES_IN=1h
JWT_EXPIRES_IN=30m
JWT_EXPIRES_IN=7d
인증 토큰의 만료 시간은 보안과 사용자 경험 사이에서 조정해야 하는 값이다.
너무 짧으면 사용자가 자주 로그아웃될 수 있고, 너무 길면 토큰이 탈취되었을 때 위험이 커질 수 있다.
이 프로젝트에서는 환경변수로 분리해두어 상황에 따라 조정할 수 있도록 했다.
13. 기능 모듈 등록하기
환경설정 외에도 AppModule에는 프로젝트에서 사용하는 기능 모듈들이 등록된다.
구조만 보면 다음과 같다.
imports: [
ConfigModule.forRoot(/* 환경변수 설정 */),
PrismaModule,
UsersModule,
SurveysModule,
AuthModule,
SchoolsModule,
]
각 모듈의 역할은 다음처럼 나눌 수 있다.
PrismaModule → DB 연결
UsersModule → 사용자 관리
SurveysModule → 설문 관리
AuthModule → 로그인, JWT 인증
SchoolsModule → 학교 관리, 학교 추천
이렇게 기능별로 모듈을 나누면 코드 구조를 이해하기 쉬워진다.
예를 들어 인증 문제가 발생하면 AuthModule 쪽을 보면 되고, DB 연결 문제가 발생하면 PrismaModule 쪽을 보면 된다.
처음에는 모듈이 많아지면 복잡해 보였지만, 역할별로 나누어두면 오히려 유지보수하기 쉽다는 걸 느꼈다.
14. controllers는 요청을 받는 입구다
@Module() 안에는 controllers도 있다.
controllers: [AppController]
Controller는 클라이언트 요청을 받는 입구 역할을 한다.
흐름은 보통 다음과 같다.
클라이언트 요청
↓
Controller
↓
Service
↓
DB 또는 외부 로직
예를 들어 사용자가 로그인 요청을 보낸다면, 인증 Controller가 요청을 받고 실제 로직은 인증 Service로 넘기는 식이다.
AppController는 NestJS 프로젝트 생성 시 기본으로 만들어지는 Controller에 가깝다.
프로젝트의 핵심 API는 보통 각 기능 모듈 안에 있는 Controller에서 관리된다.
AuthModule
└─ AuthController
UsersModule
└─ UsersController
SurveysModule
└─ SurveysController
그래서 AppModule에 모든 Controller를 직접 등록하지 않아도 된다.
각 기능 모듈이 자신의 Controller를 관리하고, AppModule은 그 기능 모듈을 import하면 된다.
15. providers는 NestJS가 관리하는 객체다
@Module() 안에는 providers도 있다.
providers: [AppService]
Provider는 NestJS가 직접 생성하고 관리하는 객체다.
대표적으로 Service가 Provider에 해당한다.
Service는 실제 비즈니스 로직을 처리한다.
예를 들어 인증 흐름은 다음과 같이 나눌 수 있다.
AuthController → 로그인 요청 받기
AuthService → 사용자 확인, 비밀번호 비교, JWT 발급
Controller가 모든 일을 직접 처리하면 코드가 복잡해진다.
그래서 Controller는 요청과 응답 흐름을 담당하고, 실제 처리는 Service로 분리하는 것이 일반적이다.
16. 의존성 주입 이해하기
providers를 보면서 가장 중요하게 느낀 개념은 의존성 주입이었다.
NestJS에서는 보통 Service를 직접 new로 만들지 않는다.
const appService = new AppService();
대신 필요한 곳에서 constructor를 통해 주입받는다.
constructor(private readonly appService: AppService) {}
그러면 NestJS가 AppService 객체를 직접 생성하고, 필요한 곳에 넣어준다.
이걸 의존성 주입, 즉 DI라고 한다.
Dependency Injection
= 필요한 객체를 직접 만들지 않고 외부에서 주입받는 방식
처음에는 왜 직접 new로 만들지 않는지 헷갈렸다.
하지만 NestJS가 객체 생성과 연결을 관리해주면, 각 클래스는 자신이 맡은 역할에만 집중할 수 있다.
예를 들어 Controller는 Service를 어떻게 생성할지 알 필요 없이, 주입받은 Service를 사용하기만 하면 된다.
Controller는 요청 처리에 집중
Service는 비즈니스 로직에 집중
NestJS는 객체 생성과 연결을 관리
이 구조가 NestJS의 중요한 특징이라고 느꼈다.
17. 마지막 export class AppModule
마지막에는 AppModule 클래스를 export한다.
export class AppModule {}
처음 보면 내부가 비어 있기 때문에 별 역할이 없어 보일 수 있다.
하지만 이 클래스는 @Module() 데코레이터와 함께 사용되면서 NestJS의 최상위 모듈이 된다.
또한 main.ts에서 이 클래스를 가져와 애플리케이션을 생성한다.
NestFactory.create(AppModule)
즉, 전체 연결은 다음과 같다.
main.ts
↓
NestFactory.create(AppModule)
↓
app.module.ts
↓
각 기능 모듈 등록
정리
이번 글에서는 NestJS의 최상위 모듈인 app.module.ts를 정리했다.
main.ts가 서버를 생성하고 실행하는 진입점이라면, app.module.ts는 백엔드 애플리케이션의 기능들을 조립하는 역할을 한다.
핵심은 세 가지였다.
imports → 다른 기능 모듈을 가져온다
controllers → 요청을 받을 Controller를 등록한다
providers → NestJS가 관리할 Service 같은 객체를 등록한다
그리고 이 프로젝트에서 특히 중요했던 부분은 ConfigModule과 joi를 이용한 환경변수 검증이었다.
ConfigModule → 환경변수를 NestJS 방식으로 관리
joi → 필요한 환경변수가 있는지 서버 시작 시점에 검증
환경변수 검증을 해두면 DATABASE_URL이나 JWT_SECRET 같은 필수값이 빠졌을 때 서버 실행 초기에 문제를 발견할 수 있다.
처음에는 app.module.ts를 단순히 모듈을 모아두는 파일이라고 생각했다.
하지만 실제로는 애플리케이션의 구조, 환경설정, 의존성 주입 흐름을 이해할 수 있는 중요한 파일이었다.