API 서버 구현
NestJS로 엔티티·DTO·서비스·컨트롤러를 연결하고 인증 토큰과 역할 기반 인가를 갖춘 API 서버를 구현합니다.
이전 절에서 풀 스택 프로젝트의 전반적인 구조를 설계하고, 클라이언트와 서버 간의 코드 공유 전략을 포함한 모노레포(Monorepo) 구성을 살펴보았습니다.
이제 설계된 구조를 바탕으로, 애플리케이션의 핵심 백본인 API 서버를 실제로 구현해 볼 차례입니다.
API 서버는 클라이언트 요청을 받아 비즈니스 로직을 처리하고 DB와 상호작용한 뒤, 결과를 다시 클라이언트에 전달하는 역할을 합니다.
여기서는 타입스크립트 기반 백엔드 프레임워크 NestJS로 RESTful API 서버를 구현하는 과정을 다룹니다.
NestJS는 모듈화 구조, 의존성 주입(DI), 타입스크립트 지원이 강해 대규모 애플리케이션 개발에 적합합니다.
사용자 정보가 DB에 저장되는 모양과 클라이언트에게 보여줄 모양은 다릅니다. 비밀번호 해시와 관리자 여부는 서버 안에 두고, 응답에는 id·email·name만 담겠습니다. 사용자 관리 경로는 관리자만 호출하도록 다음 인증·인가 절의 가드와 연결합니다. 이 절의 파일만 만든 상태에서는 서버를 실행하지 말고 다음 절까지 이어서 작성해 주세요.
NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다 — 상태 추적
unknown HTTP 값이 ValidationPipe 검증·서비스·저장·공개 DTO로 변한다. 실제 입력과 반환 필드, 400/404 단락을 보여 줄 가치가 있다.
NESTJS RESPONSIBILITY PIPELINE
NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다
본문의 ValidationPipe·Controller·UserService·TypeORM Repository를 책임별로 읽습니다. Service는 Entity를 내부에서 사용하고 Controller가 공개 필드만 응답합니다. 사용자 관리 경로는 인증 뒤 현재 관리자 여부를 확인합니다. 전체 Nest lifecycle을 그린 것은 아닙니다.
타입 주석만으로 Entity 필드가 제거되지 않는다. isAdmin은 공개 입력에 없고 Service 정책으로만 설정한다.
코드·조건·비교표와 보충 설명
호출·응답 책임 요약: guard가 있다면 pipe보다 먼저 실행됩니다.
- HTTP 입력 → ValidationPipe
class-validator decorator가 있는 속성을 검증합니다. whitelist·forbidNonWhitelisted로 허용되지 않은 입력을 거절합니다.
- Controller → Service
Controller는 transport를 조정합니다. Service는 업무 규칙과 not-found 404를 처리합니다.
- Service → Repository → 결과
TypeORM이 Entity를 조회·저장합니다. 반환 결과는 Service·Controller로 돌아옵니다.
- 공개 DTO로 응답
Controller는 id·email·name만 고른 새 객체를 응답합니다. 입력 DTO는 isAdmin을 받지 않고, Service가 관리자 여부를 정합니다.
- 핵심 경계
- 진행·선택 경로
입력 DTO에는 isAdmin이 없고 공개 응답은 세 필드를 직접 고릅니다. 저장 모델에 응답 타입을 붙이는 것만으로 내부 필드가 없어지지는 않습니다. 사용자 관리 가드는 다음 인증 절의 파일과 함께 연결합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | HTTP 입력 → ValidationPipe: class-validator decorator가 있는 속성을 검증합니다. whitelist·forbidNonWhitelisted로 허용되지 않은 입력을 거절합니다. |
| 원도표 조건·주석 | Controller → Service: Controller는 transport를 조정합니다. Service는 업무 규칙과 not-found 404를 처리합니다. |
| 원도표 조건·주석 | Service → Repository → 결과: TypeORM이 Entity를 조회·저장합니다. 반환 결과는 Service·Controller로 돌아옵니다. |
| 원도표 조건·주석 | 공개 DTO로 응답: Controller는 id·email·name만 고른 새 객체를 응답합니다. 입력 DTO는 isAdmin을 받지 않고, Service가 관리자 여부를 정합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 받은 HTTP 값 | 입력: raw HTTP / 업무: 실행 전 | unknown 값을 ValidationPipe의 경계로 전달한다 | 검증과 변환은 실제 등록된 pipe의 계약에 따른다 |
| 서비스와 저장 | 입력: 검증 값 / 실패: 400/404의 다른 위치 | 검증을 통과한 값으로 업무를 수행하고 없는 대상은 404로 종료한다 | 잘못된 형식과 없는 대상을 같은 실패로 합치지 않는다 |
| 공개 DTO | 저장값: 내부 객체 / 응답: 선택된 공개 필드 | 허용한 필드만 공개 값으로 변환해 응답한다 | entity 전체 반환과 공개 필드 선택은 별개 동작이다 |
NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다 — 상태 추적
unknown HTTP 값이 ValidationPipe 검증·서비스·저장·공개 DTO로 변한다. 실제 입력과 반환 필드, 400/404 단락을 보여 줄 가치가 있다.
NESTJS RESPONSIBILITY PIPELINE
NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다
본문의 ValidationPipe·Controller·UserService·TypeORM Repository를 책임별로 읽습니다. Service는 Entity를 내부에서 사용하고 Controller가 공개 필드만 응답합니다. 사용자 관리 경로는 인증 뒤 현재 관리자 여부를 확인합니다. 전체 Nest lifecycle을 그린 것은 아닙니다.
타입 주석만으로 Entity 필드가 제거되지 않는다. isAdmin은 공개 입력에 없고 Service 정책으로만 설정한다.
코드·조건·비교표와 보충 설명
호출·응답 책임 요약: guard가 있다면 pipe보다 먼저 실행됩니다.
- HTTP 입력 → ValidationPipe
class-validator decorator가 있는 속성을 검증합니다. whitelist·forbidNonWhitelisted로 허용되지 않은 입력을 거절합니다.
- Controller → Service
Controller는 transport를 조정합니다. Service는 업무 규칙과 not-found 404를 처리합니다.
- Service → Repository → 결과
TypeORM이 Entity를 조회·저장합니다. 반환 결과는 Service·Controller로 돌아옵니다.
- 공개 DTO로 응답
Controller는 id·email·name만 고른 새 객체를 응답합니다. 입력 DTO는 isAdmin을 받지 않고, Service가 관리자 여부를 정합니다.
- 핵심 경계
- 진행·선택 경로
입력 DTO에는 isAdmin이 없고 공개 응답은 세 필드를 직접 고릅니다. 저장 모델에 응답 타입을 붙이는 것만으로 내부 필드가 없어지지는 않습니다. 사용자 관리 가드는 다음 인증 절의 파일과 함께 연결합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | HTTP 입력 → ValidationPipe: class-validator decorator가 있는 속성을 검증합니다. whitelist·forbidNonWhitelisted로 허용되지 않은 입력을 거절합니다. |
| 원도표 조건·주석 | Controller → Service: Controller는 transport를 조정합니다. Service는 업무 규칙과 not-found 404를 처리합니다. |
| 원도표 조건·주석 | Service → Repository → 결과: TypeORM이 Entity를 조회·저장합니다. 반환 결과는 Service·Controller로 돌아옵니다. |
| 원도표 조건·주석 | 공개 DTO로 응답: Controller는 id·email·name만 고른 새 객체를 응답합니다. 입력 DTO는 isAdmin을 받지 않고, Service가 관리자 여부를 정합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 받은 HTTP 값 | 입력: raw HTTP / 업무: 실행 전 | unknown 값을 ValidationPipe의 경계로 전달한다 | 검증과 변환은 실제 등록된 pipe의 계약에 따른다 |
| 서비스와 저장 | 입력: 검증 값 / 실패: 400/404의 다른 위치 | 검증을 통과한 값으로 업무를 수행하고 없는 대상은 404로 종료한다 | 잘못된 형식과 없는 대상을 같은 실패로 합치지 않는다 |
| 공개 DTO | 저장값: 내부 객체 / 응답: 선택된 공개 필드 | 허용한 필드만 공개 값으로 변환해 응답한다 | entity 전체 반환과 공개 필드 선택은 별개 동작이다 |
NestJS 서버 초기 설정
NestJS 프로젝트 시작하기에서 만든 서버 프로젝트를 사용합니다. TypeORM과 PostgreSQL 연결은 ORM 사용의 설정을 먼저 확인해 주세요.
이 절에서는 packages/server 폴더에서 작업합니다. 패키지는 Node.js 24.21.0, TypeScript 5.9.3, NestJS 11.2.7을 기준으로 고정합니다. 다른 버전을 사용한다면 설치된 패키지의 지원 범위와 lockfile을 먼저 대조해 주세요.
기본 포트 확인 및 변경:
src/main.ts 파일에서 애플리케이션이 실행될 포트를 설정합니다.
기본적으로 3000번 포트를 사용하지만, 프론트엔드 애플리케이션과 충돌을 피하기 위해 다른 포트(예: 4100)를 사용할 수 있습니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 전역 파이프를 적용하여 DTO 유효성 검사 활성화
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // DTO에 정의되지 않은 속성은 제거
forbidNonWhitelisted: true, // DTO에 정의되지 않은 속성이 있으면 에러 발생
transform: true, // DTO 타입에 따라 자동으로 변환 (예: string -> number)
}));
// CORS 설정 (프론트엔드와 통신을 위해 필수)
app.enableCors({
origin: 'http://localhost:3000', // 클라이언트 애플리케이션의 주소
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // 쿠키/인증 헤더 전송 허용
});
const PORT = process.env.PORT || 4100; // 환경 변수 또는 4100 포트 사용
await app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
bootstrap();ValidationPipe: NestJS의 강력한 기능 중 하나로,class-validator와class-transformer패키지를 사용하여 들어오는 요청 본문(DTO)의 유효성을 자동으로 검사합니다.enableCors(): 서로 다른 오리진(Origin) 간의 요청을 허용하는 CORS(Cross-Origin Resource Sharing) 설정을 합니다. 프론트엔드 애플리케이션이 다른 포트나 도메인에서 실행될 경우 필수입니다.
환경 변수 관리: 데이터베이스 연결 정보, API 키 등 민감하거나 환경별로 달라지는 설정값은 환경 변수(.env)로 관리하는 것이 좋습니다.
NestJS는 @nestjs/config 패키지를 통해 .env 파일을 쉽게 로드할 수 있도록 지원합니다.
npm install --save-exact @nestjs/[email protected] @nestjs/[email protected] @nestjs/[email protected] @nestjs/[email protected] @nestjs/[email protected] [email protected] [email protected] [email protected] [email protected] @nestjs/[email protected] [email protected] [email protected] [email protected]
npm install --save-dev --save-exact [email protected] @types/[email protected] @types/[email protected]이 설치 명령은 뒤의 엔티티·DTO·비밀번호 해싱 예제에서 사용하는 패키지도 포함합니다. package.json과 생성된 package-lock.json을 함께 보관하고, 같은 환경을 다시 설치할 때는 npm ci를 사용합니다.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module'; // UserModule 임포트
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 어디서든 ConfigService를 주입받아 사용 가능
envFilePath: '.env', // .env 파일 경로 지정
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'], // 모든 엔티티 파일을 자동으로 찾음
synchronize: process.env.NODE_ENV === 'development', // 개발 환경에서만 동기화
logging: process.env.NODE_ENV === 'development', // 개발 환경에서만 로깅
}),
UserModule, // 사용자 모듈 추가
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}프로젝트 루트에 .env 파일을 생성하고 데이터베이스 정보를 추가합니다.
NODE_ENV=development
PORT=4100
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=myuser
DATABASE_PASSWORD=mypassword
DATABASE_NAME=mydb핵심 비즈니스 로직 구현
이전 장에서 TypeORM을 사용한 사용자 모듈 예시를 기반으로, NestJS의 컨트롤러, 서비스, 엔티티, DTO를 통해 CRUD(Create, Read, Update, Delete) API를 구현합니다.
엔티티 정의
데이터베이스 테이블과 매핑될 엔티티를 정의합니다.
shared 패키지의 인터페이스를 확장하거나 참조할 수 있습니다.
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
// import { IUser } from '@my-fullstack-app/shared/interfaces'; // shared 패키지에서 인터페이스 임포트
@Entity()
export class User { // implements IUser (IUser 인터페이스를 구현할 수도 있습니다.)
@PrimaryGeneratedColumn()
id!: number;
@Column({ unique: true })
email!: string;
@Column()
password!: string; // 실제 앱에서는 해싱된 비밀번호 저장
@Column()
name!: string;
@Column({ default: false })
isAdmin!: boolean;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}API 계약용 DTO 정의
회원가입 입력과 공개 응답을 따로 정의합니다. 입력 DTO의 데코레이터는 ValidationPipe가 요청을 검사할 때 사용하고, 공개 응답 DTO는 클라이언트에게 돌려줄 필드를 정합니다. 입력·저장 모델·응답 분리에서 살펴본 경계를 이 서버에 적용하겠습니다.
class-validator 데코레이터를 활용합니다.
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsEmail({}, { message: '유효한 이메일 형식이 아닙니다.' })
email!: string;
@IsString({ message: '비밀번호는 문자열이어야 합니다.' })
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password!: string;
@IsString({ message: '이름은 문자열이어야 합니다.' })
name!: string;
}회원가입 요청에는 이메일·비밀번호·이름만 받습니다. isAdmin을 보내면 앞의 forbidNonWhitelisted: true 설정이 400으로 거절합니다. 관리자 여부는 서버가 정하는 값이며, 이 예제에는 관리자 승격 API를 만들지 않습니다.
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto, {
skipNullProperties: false,
}) {}수정할 필드만 보내면 됩니다. skipNullProperties: false는 생략한 필드와 명시적으로 보낸 null을 구분하기 위한 설정입니다. 예를 들어 { "name": null }은 문자열 검사를 통과하지 않습니다. 수정 DTO에도 isAdmin은 없습니다.
응답은 입력 DTO나 DB 엔터티를 그대로 보내지 않고, 공개할 세 필드를 직접 골라 만듭니다.
export interface PublicUserDto {
id: number;
email: string;
name: string;
}
export function toPublicUser(user: PublicUserDto): PublicUserDto {
return {
id: user.id,
email: user.email,
name: user.name,
};
}User 엔터티처럼 더 많은 필드를 가진 값도 이 함수에 전달할 수 있습니다. 중요한 것은 반환 타입 이름이 아니라 함수가 새 객체에 담는 세 필드입니다. as PublicUserDto라는 타입 단언만 붙이면 실제 객체의 비밀번호 해시가 사라지는 것은 아닙니다.
서비스 구현
비즈니스 로직을 포함하며, TypeORM의 Repository를 사용하여 데이터베이스와 상호작용합니다.
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const hashedPassword = await bcrypt.hash(createUserDto.password, 10); // 비밀번호 해싱
const newUser = this.usersRepository.create({
email: createUserDto.email,
name: createUserDto.name,
password: hashedPassword,
isAdmin: false, // 관리자 여부는 공개 입력에서 가져오지 않음
});
return this.usersRepository.save(newUser);
}
async findAll(): Promise<User[]> {
return this.usersRepository.find();
}
async findOneById(id: number): Promise<User> {
const user = await this.findOneByIdOrNull(id);
if (!user) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
return user;
}
async findOneByIdOrNull(id: number): Promise<User | null> {
return this.usersRepository.findOneBy({ id });
}
async findOneByEmail(email: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { email } });
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOneById(id); // 먼저 사용자를 찾고 없으면 예외 발생
const changes: Partial<User> = {};
if (updateUserDto.email !== undefined) changes.email = updateUserDto.email;
if (updateUserDto.name !== undefined) changes.name = updateUserDto.name;
if (updateUserDto.password !== undefined) {
changes.password = await bcrypt.hash(updateUserDto.password, 10);
}
// 수정할 공개 입력 필드만 병합하며 관리자 여부는 유지
this.usersRepository.merge(user, changes);
return this.usersRepository.save(user);
}
async remove(id: number): Promise<void> {
const result = await this.usersRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
}
}이메일에 해당하는 사용자가 없으면 findOneByEmail은 null을 반환합니다. 다음 절의 인증 서비스는 이 값을 확인한 뒤 비밀번호를 비교합니다. findOneByIdOrNull도 사용자가 없으면 null을 반환하므로 인증 전략이 삭제된 계정을 401로 처리할 때 사용합니다. 관리 경로의 조회는 findOneById를 사용해 없는 사용자를 404로 구분합니다. PostgreSQL에서 onUpdate: CURRENT_TIMESTAMP 옵션만으로 갱신 시각이 자동 유지된다고 가정하지 말고 갱신 컬럼 동작을 별도로 구현·확인해야 합니다.
컨트롤러 구현
/users는 관리자가 사용자를 조회·생성·수정·삭제하는 경로로 두겠습니다. 공개 회원가입은 다음 절의 /auth/register, 일반 사용자의 자기 정보 확인은 /auth/profile로 분리합니다. 컨트롤러는 서비스가 돌려준 엔터티를 공개 DTO로 바꾼 뒤 응답합니다.
import { Controller, Get, Post, Body, Param, Put, Delete, HttpCode, HttpStatus, UseGuards, ParseIntPipe } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PublicUserDto, toPublicUser } from './dto/public-user.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
@Controller('users') // 전역 prefix가 없는 이 예제의 '/users' 경로
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin') // 이 컨트롤러의 모든 관리 작업에 적용
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@HttpCode(HttpStatus.CREATED) // 201 Created 응답
async create(@Body() createUserDto: CreateUserDto): Promise<PublicUserDto> {
return toPublicUser(await this.userService.create(createUserDto));
}
@Get()
async findAll(): Promise<PublicUserDto[]> {
const users = await this.userService.findAll();
return users.map(toPublicUser);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number): Promise<PublicUserDto> {
return toPublicUser(await this.userService.findOneById(id));
}
@Put(':id')
async update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto): Promise<PublicUserDto> {
return toPublicUser(await this.userService.update(id, updateUserDto));
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // 204 No Content 응답
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.userService.remove(id);
}
}이 예제는 전역 /api prefix를 설정하지 않아 실제 경로는 /users입니다. 내장 ParseIntPipe는 12abc 같은 정수가 아닌 경로 값을 400으로 거절하지만 양수 ID 정책까지 검사하지는 않습니다. 존재하지 않는 ID는 서비스의 404로 구분합니다. 인증·권한 가드는 파이프보다 먼저 실행하므로, 토큰이 없는 요청에는 잘못된 ID보다 먼저 401이 돌아옵니다.
모듈 설정
User 모듈을 구성하여 서비스와 컨트롤러, 엔티티를 등록합니다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { User } from './entities/user.entity';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
@Module({
imports: [TypeOrmModule.forFeature([User])], // User 엔티티를 이 모듈에서 사용 가능하게 등록
controllers: [UserController],
providers: [UserService, JwtAuthGuard, RolesGuard],
exports: [UserService], // 다른 모듈(예: AuthModule)에서 UserService를 주입받을 수 있도록 내보내기
})
export class UserModule {}위 컨트롤러와 모듈이 가져오는 가드·역할 데코레이터 파일은 다음 절에서 작성합니다. UserModule에는 두 가드를 등록하고 UserService를 내보내되, AuthModule을 import하지 않습니다. 다음 절의 AuthModule이 UserModule을 가져와 인증 전략에서 사용자 서비스를 쓰는 방향으로 연결합니다.