본문 바로가기

API 서버 구현

버전을 고정한 NestJS 11 프로젝트에 PostgreSQL을 연결하고, 사용자의 엔티티와 입력 DTO, 공개 응답, 서비스, 관리자용 컨트롤러를 구현하면서 저장하는 모양과 보여 주는 모양을 나누고 관리자 여부를 공개 입력에서 받지 않도록 설계합니다.

이제 앞 절에서 설계한 구조의 server 패키지에 API 서버를 만듭니다. API 서버는 클라이언트의 요청을 받아 검사하고, 업무 규칙에 따라 데이터베이스를 읽고 쓴 뒤, 그 결과를 클라이언트에 돌려줍니다. 이 절은 사용자의 데이터를 다루는 모듈을 만들고, 다음 절은 인증과 인가를 더한 뒤 서버를 실행합니다.

사용자 정보가 데이터베이스에 저장되는 모양과 클라이언트에 보여 줄 모양은 다릅니다. 비밀번호 해시와 관리자 여부는 서버 안에만 두고, 응답에는 id, email, name만 담습니다. 사용자를 관리하는 /users 경로는 관리자만 쓰도록 다음 절의 가드와 연결하므로, 이 절의 파일만 만든 상태에서는 서버를 실행하지 말고 인증·인가와 서버 실행까지 이어서 작성합니다.

NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다 — 상태 추적

unknown HTTP 값이 ValidationPipe 검증·서비스·저장·공개 DTO로 변한다. 실제 입력과 반환 필드, 400/404 단락을 보여 줄 가치가 있다.

NESTJS RESPONSIBILITY PIPELINE

NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다

본문의 ValidationPipe·Controller·UserService·TypeORM Repository를 책임별로 읽습니다. Service는 Entity를 내부에서 사용하고 Controller가 공개 필드만 응답합니다. 사용자 관리 경로는 인증 뒤 현재 관리자 여부를 확인합니다. 전체 Nest lifecycle을 그린 것은 아닙니다.

guard/pipe/controller/service/repository 책임과 400·404 오류, 반환 DTO 경계를 요청과 응답 두 방향으로 연결한다.input→pipe: Guard 뒤 입력 검증。pipe→controller: 검증된 DTO를 handler에 전달。pipe→invalid: 저장 전 거부。controller→service: 업무 처리는 Service。service→missing: Service의 not found 판단。service→repo: 저장소 접근。repo→dto: Entity에서 공개 DTO 명시적 선택통과실패반환HTTP 입력: unknownHTTP 입력: unknown관리 Guard는 Pipe 앞ValidationPipeValidationPipedecorator · whitelist 검사ControllerController경로 · DTO 연결400400입력 검사 실패ServiceService정책 · 조회 · 404 판단404404대상 사용자 없음TypeORM 저장소TypeORM 저장소Entity 데이터 접근Service → ControllerService → ControllerPublic DTO: id · email · name
guard/pipe/controller/service/repository 책임과 400·404 오류, 반환 DTO 경계를 요청과 응답 두 방향으로 연결한다.input→pipe: Guard 뒤 입력 검증。pipe→controller: 검증된 DTO를 handler에 전달。pipe→invalid: 저장 전 거부。controller→service: 업무 처리는 Service。service→missing: Service의 not found 판단。service→repo: 저장소 접근。repo→dto: Entity에서 공개 DTO 명시적 선택통과실패반환HTTP 입력: unknownHTTP 입력: unknown관리 Guard는 Pipe 앞ValidationPipeValidationPipedecorator · whitelist 검사ControllerController경로 ·DTO 연결400400입력검사실패ServiceService정책 ·조회 ·404 판단404404대상사용자없음TypeORM 저장소TypeORM저장소Entity데이터접근Service → ControllerService → ControllerPublic DTO: id · email ·name

타입 주석만으로 Entity 필드가 제거되지 않는다. isAdmin은 공개 입력에 없고 Service 정책으로만 설정한다.

코드·조건·비교표와 보충 설명
NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다HTTP 입력을 ValidationPipe로 검사하고 Controller가 Service와 Repository를 거쳐 결과를 받는다. 응답은 id·email·name만 고른 공개 DTO로 바꾼다. 관리 경로의 가드는 pipe 전에 실행된다.
HTTP requestunknown 입력
연결 관계HTTP request · unknown 입력 → ValidationPipe · decorator 규칙 · 실패하면 400
ValidationPipedecorator 규칙실패하면 400
연결 관계ValidationPipe · decorator 규칙 · 실패하면 400 → Controller · transport 조정
Controllertransport 조정
연결 관계Controller · transport 조정 → Service · 조회·업무 규칙 · 없으면 404
Service조회·업무 규칙없으면 404
연결 관계Service · 조회·업무 규칙 · 없으면 404 → Repository · 저장·조회 · Entity 반환
Repository저장·조회Entity 반환
연결 관계Repository · 저장·조회 · Entity 반환 → Public DTO · 허용 필드 mapper · id · email · name
Public DTO허용 필드 mapperid · email · name

호출·응답 책임 요약: guard가 있다면 pipe보다 먼저 실행됩니다.

process 핵심 계약
  1. HTTP 입력 → ValidationPipe

    class-validator decorator가 있는 속성을 검증합니다. whitelist·forbidNonWhitelisted로 허용되지 않은 입력을 거절합니다.

  2. Controller → Service

    Controller는 transport를 조정합니다. Service는 업무 규칙과 not-found 404를 처리합니다.

  3. Service → Repository → 결과

    TypeORM이 Entity를 조회·저장합니다. 반환 결과는 Service·Controller로 돌아옵니다.

  4. 공개 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을 그린 것은 아닙니다.

guard/pipe/controller/service/repository 책임과 400·404 오류, 반환 DTO 경계를 요청과 응답 두 방향으로 연결한다.input→pipe: Guard 뒤 입력 검증。pipe→controller: 검증된 DTO를 handler에 전달。pipe→invalid: 저장 전 거부。controller→service: 업무 처리는 Service。service→missing: Service의 not found 판단。service→repo: 저장소 접근。repo→dto: Entity에서 공개 DTO 명시적 선택통과실패반환HTTP 입력: unknownHTTP 입력: unknown관리 Guard는 Pipe 앞ValidationPipeValidationPipedecorator · whitelist 검사ControllerController경로 · DTO 연결400400입력 검사 실패ServiceService정책 · 조회 · 404 판단404404대상 사용자 없음TypeORM 저장소TypeORM 저장소Entity 데이터 접근Service → ControllerService → ControllerPublic DTO: id · email · name
guard/pipe/controller/service/repository 책임과 400·404 오류, 반환 DTO 경계를 요청과 응답 두 방향으로 연결한다.input→pipe: Guard 뒤 입력 검증。pipe→controller: 검증된 DTO를 handler에 전달。pipe→invalid: 저장 전 거부。controller→service: 업무 처리는 Service。service→missing: Service의 not found 판단。service→repo: 저장소 접근。repo→dto: Entity에서 공개 DTO 명시적 선택통과실패반환HTTP 입력: unknownHTTP 입력: unknown관리 Guard는 Pipe 앞ValidationPipeValidationPipedecorator · whitelist 검사ControllerController경로 ·DTO 연결400400입력검사실패ServiceService정책 ·조회 ·404 판단404404대상사용자없음TypeORM 저장소TypeORM저장소Entity데이터접근Service → ControllerService → ControllerPublic DTO: id · email ·name

타입 주석만으로 Entity 필드가 제거되지 않는다. isAdmin은 공개 입력에 없고 Service 정책으로만 설정한다.

코드·조건·비교표와 보충 설명
NestJS 요청은 검증·정책·영속성 경계를 순서대로 지난다HTTP 입력을 ValidationPipe로 검사하고 Controller가 Service와 Repository를 거쳐 결과를 받는다. 응답은 id·email·name만 고른 공개 DTO로 바꾼다. 관리 경로의 가드는 pipe 전에 실행된다.
HTTP requestunknown 입력
연결 관계HTTP request · unknown 입력 → ValidationPipe · decorator 규칙 · 실패하면 400
ValidationPipedecorator 규칙실패하면 400
연결 관계ValidationPipe · decorator 규칙 · 실패하면 400 → Controller · transport 조정
Controllertransport 조정
연결 관계Controller · transport 조정 → Service · 조회·업무 규칙 · 없으면 404
Service조회·업무 규칙없으면 404
연결 관계Service · 조회·업무 규칙 · 없으면 404 → Repository · 저장·조회 · Entity 반환
Repository저장·조회Entity 반환
연결 관계Repository · 저장·조회 · Entity 반환 → Public DTO · 허용 필드 mapper · id · email · name
Public DTO허용 필드 mapperid · email · name

호출·응답 책임 요약: guard가 있다면 pipe보다 먼저 실행됩니다.

process 핵심 계약
  1. HTTP 입력 → ValidationPipe

    class-validator decorator가 있는 속성을 검증합니다. whitelist·forbidNonWhitelisted로 허용되지 않은 입력을 거절합니다.

  2. Controller → Service

    Controller는 transport를 조정합니다. Service는 업무 규칙과 not-found 404를 처리합니다.

  3. Service → Repository → 결과

    TypeORM이 Entity를 조회·저장합니다. 반환 결과는 Service·Controller로 돌아옵니다.

  4. 공개 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 전체 반환과 공개 필드 선택은 별개 동작이다

그림은 이 절에서 만들 코드가 요청을 처리하는 순서입니다. 요청의 본문은 ValidationPipe가 DTO의 규칙대로 검사해 맞지 않으면 400으로 거절하고, 컨트롤러는 검사를 통과한 DTO를 서비스에 넘깁니다. 서비스는 업무 규칙을 적용하며 대상이 없으면 404로 끝내고, TypeORM의 저장소로 엔티티를 읽고 씁니다. 돌아온 엔티티는 컨트롤러가 공개할 세 필드만 골라 응답합니다.

프로젝트 만들기와 버전 고정

이 장의 서버는 따라 할 때마다 같은 결과가 나오도록 버전을 고정합니다. 기준은 Node.js 24.21.0, TypeScript 5.9.3, NestJS 11.2.7입니다. 앞의 NestJS 프레임워크는 최신인 NestJS 12를 썼지만, 이 장은 고정한 버전들이 서로 맞는지 확인한 NestJS 11로 만들며, 다른 버전을 쓴다면 각 패키지가 지원하는 범위와 잠금 파일을 먼저 대조합니다.

mkdir -p my-fullstack-app/packages
cd my-fullstack-app
git init
cd packages
npx @nestjs/[email protected] new server --package-manager npm --skip-git --strict
cd server
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]

앞의 네 줄은 저장소의 루트 폴더와 packages 폴더를 만들고 루트를 Git 저장소로 초기화합니다. 그다음 명령은 NestJS CLI 11로 packages/server에 프로젝트를 만들며, 저장소 전체를 Git으로 관리하므로 --skip-git으로 새 Git 저장소를 만들지 않게 하고, --strict로 타입스크립트의 엄격한 검사를 켭니다. 이어지는 두 명령은 뒤의 엔티티와 DTO, 비밀번호 해싱에 쓰는 패키지까지 정확한 버전으로 설치합니다. package.json과 함께 만들어진 package-lock.json을 저장소에 넣고, 같은 환경을 다시 설치할 때는 npm ci를 씁니다.

서버의 시작점

src/main.ts에서 요청 검사와 CORS, 서버의 포트를 설정합니다. NestJS 앱은 NestFactory.create()로 루트 모듈인 AppModule에서 시작하며, 다음 코드는 앱을 만든 뒤 요청을 받기 전에 적용할 설정을 차례로 붙이고 마지막에 listen()으로 포트를 엽니다.

packages/server/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);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  app.enableCors({
    origin: process.env.CORS_ORIGIN ?? "http://localhost:3000",
    methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
    credentials: true,
  });

  const port = Number(process.env.PORT ?? 4100);
  await app.listen(port);
  console.log(`Server is running on http://localhost:${port}`);
}
void bootstrap();

전역 ValidationPipe의 whitelist는 DTO에 규칙이 없는 속성을 지우고, forbidNonWhitelisted는 그런 속성이 있으면 아예 400으로 거절하며, transform은 검사한 본문을 DTO 클래스의 인스턴스로 바꿉니다. enableCors는 다른 출처(origin)의 브라우저 요청을 허용하는 CORS 설정으로, 환경 변수 CORS_ORIGIN에 적은 주소만 허용하며, 개발 중에는 클라이언트가 실행되는 http://localhost:3000을 적습니다. 서버의 포트는 환경 변수 PORT가 없으면 클라이언트와 겹치지 않는 4100을 씁니다.

데이터베이스 연결

데이터베이스의 주소와 비밀번호처럼 환경마다 다르거나 숨겨야 하는 값은 .env 파일에 두고, @nestjs/config의 ConfigModule로 읽습니다. 다음 AppModule에서는 ConfigModule이 .env의 값을 환경 변수로 읽어 들이고, 이어지는 TypeOrmModule.forRoot()가 그 값으로 PostgreSQL에 연결합니다. isGlobal: true를 주었으므로 다른 모듈에서 ConfigModule을 다시 가져오지 않아도 설정 값을 쓸 수 있습니다.

packages/server/src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserModule } from "./user/user.module";

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ".env",
    }),
    TypeOrmModule.forRoot({
      type: "postgres",
      host: process.env.DATABASE_HOST,
      port: Number.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,
  ],
})
export class AppModule {}
packages/server/.env
NODE_ENV=development
PORT=4100
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=myuser
DATABASE_PASSWORD=mypassword
DATABASE_NAME=mydb
CORS_ORIGIN=http://localhost:3000

ConfigModule.forRoot는 .env의 값을 process.env에 넣고, isGlobal: true로 어느 모듈에서든 ConfigService를 주입받을 수 있게 합니다. imports 배열은 앞에서부터 평가되므로, 그 뒤의 TypeOrmModule.forRoot가 process.env에서 데이터베이스 설정을 읽을 수 있습니다. entities의 경로 패턴은 .entity.ts나 .entity.js로 끝나는 파일을 모두 엔티티로 찾습니다.

synchronize와 logging은 NODE_ENV가 development일 때만 켭니다. 이 실습은 개발용 데이터베이스에서 테이블을 자동으로 만들지만, 운영에서는 ORM 사용에서 본 마이그레이션으로 스키마를 바꿉니다. .env는 .gitignore에 넣어 저장소에 올리지 않고, 위의 사용자 이름과 비밀번호는 로컬 실습용 데이터베이스에 맞게 바꿉니다.

사용자 엔티티

src/user/entities/user.entity.ts에 사용자 테이블에 대응하는 엔티티를 만듭니다. @Entity()를 붙인 클래스 하나가 테이블 하나에, @Column() 같은 데코레이터를 붙인 속성 하나가 열 하나에 대응합니다. 속성 이름 뒤의 !는 생성자에서 값을 넣지 않아도 나중에 채워진다고 컴파일러에 알리는 확정 할당 단언(definite assignment assertion)이며, 여기서는 TypeORM이 값을 채웁니다. CLI의 --strict가 만든 tsconfig.json은 생성자에서 채우지 않은 속성을 오류로 만드는 strictPropertyInitialization을 켜지 않으므로 지금은 !가 없어도 컴파일되지만, strict를 켜면 이 검사도 켜지므로 미리 붙여 둡니다.

packages/server/src/user/entities/user.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true })
  email!: string;

  @Column()
  password!: string;

  @Column()
  name!: string;

  @Column({ default: false })
  isAdmin!: boolean;

  @CreateDateColumn()
  createdAt!: Date;

  @UpdateDateColumn()
  updatedAt!: Date;
}

password 컬럼에는 비밀번호 자체가 아니라 서비스가 만든 해시를 저장합니다. isAdmin은 기본값이 false이고, 이 값은 공개 입력으로 받지 않고 서버가 정합니다. @CreateDateColumn과 @UpdateDateColumn은 행을 넣을 때와 save로 바꿀 때의 시각을 TypeORM이 채워 줍니다.

입력 DTO와 공개 응답

회원가입의 입력과 클라이언트에 보낼 응답을 따로 정의합니다. 입력 DTO의 데코레이터는 ValidationPipe가 요청을 검사하는 규칙이 되고, 공개 응답은 클라이언트에 돌려줄 필드를 정합니다. 타입 안전 REST API에서 본 입력, 저장 모델, 응답의 분리를 이 서버에 적용합니다.

packages/server/src/user/dto/create-user.dto.ts
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;
}
packages/server/src/user/dto/update-user.dto.ts
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

export class UpdateUserDto extends PartialType(CreateUserDto, {
  skipNullProperties: false,
}) {}

회원가입은 이메일과 비밀번호, 이름만 받으며, 본문에 isAdmin을 넣으면 forbidNonWhitelisted 때문에 400으로 거절됩니다. UpdateUserDto는 PartialType으로 모든 필드를 선택적으로 만든 수정용 DTO로, 바꿀 필드만 보내면 됩니다. skipNullProperties: false는 필드를 보내지 않은 것은 검사를 건너뛰되 null을 보낸 것은 검사하게 하므로, {"name": null}은 문자열 검사에 걸려 거절됩니다.

packages/server/src/user/dto/public-user.dto.ts
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,
  };
}

toPublicUser는 매개변수 타입이 PublicUserDto이지만, User 엔티티처럼 더 많은 필드를 가진 값도 넘길 수 있습니다. 중요한 것은 타입의 이름이 아니라 함수가 새 객체에 세 필드만 옮겨 담는다는 점이며, 엔티티에 as PublicUserDto라고 단언만 해서는 비밀번호 해시가 응답에서 빠지지 않습니다.

사용자 서비스

서비스는 TypeORM의 저장소를 주입받아 사용자를 만들고, 찾고, 바꾸고, 지웁니다. 생성자의 @InjectRepository(User)가 User 엔티티의 저장소를 넣어 주고, 각 메서드는 이 usersRepository를 거쳐 데이터베이스에 접근합니다. 찾는 사용자가 없을 때는 NotFoundException을 던져, NestJS가 그 예외를 404 응답으로 바꾸게 합니다.

packages/server/src/user/user.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import * as bcrypt from "bcryptjs";
import { Repository } from "typeorm";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { User } from "./entities/user.entity";

@Injectable()
export class UserService {
  constructor(@InjectRepository(User) private readonly usersRepository: Repository<User>) {}

  async create(dto: CreateUserDto): Promise<User> {
    const user = this.usersRepository.create({
      email: dto.email,
      name: dto.name,
      password: await bcrypt.hash(dto.password, 10),
      isAdmin: false,
    });
    return this.usersRepository.save(user);
  }

  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;
  }

  findOneByIdOrNull(id: number): Promise<User | null> {
    return this.usersRepository.findOneBy({ id });
  }

  findOneByEmail(email: string): Promise<User | null> {
    return this.usersRepository.findOneBy({ email });
  }

  async update(id: number, dto: UpdateUserDto): Promise<User> {
    const user = await this.findOneById(id);
    const changes: Partial<User> = {};
    if (dto.email !== undefined) changes.email = dto.email;
    if (dto.name !== undefined) changes.name = dto.name;
    if (dto.password !== undefined) changes.password = await bcrypt.hash(dto.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.`);
    }
  }
}

create는 비밀번호를 bcrypt.hash로 해시해 저장하고, 관리자 여부는 입력과 상관없이 false로 정합니다. 해시의 두 번째 인수 10은 해시를 만드는 데 드는 계산의 양을 정하는 값으로, 클수록 비밀번호를 하나씩 대입해 보는 공격이 느려집니다. update도 DTO를 그대로 합치지 않고 이메일, 이름, 비밀번호 가운데 보낸 필드만 골라 옮기므로, 수정 요청으로 관리자 여부가 바뀌는 일이 없습니다.

없는 사용자를 다루는 방법은 두 가지로 나누었습니다. findOneById는 관리 경로에서 쓰며 사용자가 없으면 404를 던지고, findOneByIdOrNull과 findOneByEmail은 null을 돌려주어 다음 절의 인증 코드가 직접 판단하게 합니다. 인증에서는 사용자가 없다는 것을 404가 아니라 인증 실패인 401로 다루어야 하기 때문입니다.

더 깊이: bcrypt의 입력 길이와 작업 계수

bcrypt에는 입력의 길이 제한이 있습니다. bcryptjs의 안내에 따르면 입력은 최대 72바이트이고, UTF-8에서 한 글자가 4바이트까지 차지할 수 있으므로 글자 수로는 더 짧아질 수 있습니다. 이 라이브러리는 그 제한을 스스로 막지 않으므로, 필요하다면 bcrypt.truncates(password)로 잘리는 입력인지 확인하라고 안내합니다. 이 절의 CreateUserDto는 최소 길이만 검사하므로, 72바이트를 넘는 비밀번호는 앞의 72바이트만 해시에 쓰입니다. OWASP의 비밀번호 저장 안내도 bcrypt를 쓴다면 비밀번호의 최대 길이를 72바이트로 제한하라고 권합니다.

두 번째 인수인 작업 계수(work factor)는 시간이 지나 서버가 빨라지면 올려서 해시를 더 느리게 만들 수 있는 값입니다. bcryptjs의 genSalt와 hashSync는 이 값을 생략하면 10을 쓰지만, 이 절이 쓰는 비동기 hash에는 생략할 수 없으므로 직접 적어야 합니다. OWASP는 검증하는 서버의 성능이 허락하는 만큼 크게 두되 최소 10을 쓰라고 하고, bcryptjs는 순수 자바스크립트로 만들어 C++ 바인딩보다 약 30% 느리다고 밝힙니다. 또 OWASP는 새로 만드는 시스템이라면 Argon2id를 먼저, 쓸 수 없다면 scrypt를 권하고, bcrypt는 기존 시스템에서 쓰는 방식으로 분류합니다.

관리자용 컨트롤러와 모듈

/users는 관리자가 사용자를 조회하고, 만들고, 바꾸고, 지우는 경로입니다. 공개 회원가입은 다음 절의 /auth/register로, 사용자가 자기 정보를 보는 것은 /auth/profile로 나눕니다.

packages/server/src/user/user.controller.ts
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Put, UseGuards } from "@nestjs/common";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { Roles } from "../auth/roles.decorator";
import { RolesGuard } from "../auth/roles.guard";
import { CreateUserDto } from "./dto/create-user.dto";
import { PublicUserDto, toPublicUser } from "./dto/public-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UserService } from "./user.service";

@Controller("users")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("admin")
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() dto: CreateUserDto): Promise<PublicUserDto> {
    return toPublicUser(await this.userService.create(dto));
  }

  @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() dto: UpdateUserDto): Promise<PublicUserDto> {
    return toPublicUser(await this.userService.update(id, dto));
  }

  @Delete(":id")
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param("id", ParseIntPipe) id: number): Promise<void> {
    await this.userService.remove(id);
  }
}
packages/server/src/user/user.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { RolesGuard } from "../auth/roles.guard";
import { User } from "./entities/user.entity";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UserController],
  providers: [UserService, JwtAuthGuard, RolesGuard],
  exports: [UserService],
})
export class UserModule {}

클래스에 붙인 @UseGuards(JwtAuthGuard, RolesGuard)와 @Roles("admin")은 이 컨트롤러의 모든 처리 메서드에 적용됩니다. 가드는 파이프보다 먼저 실행되므로, 토큰이 없는 요청은 ID가 잘못되었더라도 먼저 401을 받습니다. 인증을 통과한 뒤에는 ParseIntPipe가 12abc 같은 ID를 400으로 거절하고, 형식은 맞지만 없는 ID는 서비스가 404로 구별합니다.

이 서버는 /api 같은 전역 경로 접두사를 두지 않으므로 실제 경로는 /users입니다. 두 가드와 역할 데코레이터의 파일은 다음 절에서 만듭니다. UserModule은 UserService를 exports로 내보내고 인증 모듈을 가져오지 않으며, 다음 절의 AuthModule이 UserModule을 가져와 쓰는 한 방향의 의존을 유지합니다.

정리

  • 이 장의 서버는 Node.js 24.21.0, TypeScript 5.9.3, NestJS 11.2.7로 버전을 고정하고, NestJS CLI 11로 프로젝트를 만든 뒤 정확한 버전으로 패키지를 설치합니다.
  • 전역 ValidationPipe는 DTO에 없는 속성을 거절하고 본문을 DTO 인스턴스로 바꾸며, CORS는 클라이언트의 주소만 허용합니다.
  • 환경마다 다른 설정과 비밀 값은 .env에서 ConfigModule로 읽고, .env는 저장소에 올리지 않습니다.
  • 엔티티는 비밀번호 해시와 관리자 여부를 저장하지만, 입력 DTO는 관리자 여부를 받지 않고 공개 응답은 세 필드만 담습니다.
  • 서비스는 비밀번호를 해시해 저장하고, 수정할 때도 허용한 필드만 옮기며, 관리 경로의 없는 사용자는 404로, 인증의 없는 사용자는 null로 다룹니다.
  • 관리자용 컨트롤러는 가드와 역할 데코레이터를 클래스에 붙여 모든 경로에 적용하며, 가드가 파이프보다 먼저 실행됩니다.

연습 문제

1. 회원가입 본문의 isAdmin

관리자의 토큰을 Authorization 헤더에 넣어 POST /users에 {"email":"[email protected]","password":"password123","name":"A","isAdmin":true}를 보내면 어떻게 되나요? 전역 ValidationPipe에서 forbidNonWhitelisted를 빼고 whitelist만 두면 어떻게 달라지나요?

해설 보기

CreateUserDto에 isAdmin 규칙이 없으므로 forbidNonWhitelisted가 400으로 거절하고 사용자는 만들어지지 않습니다. whitelist만 두면 isAdmin을 조용히 지우고 나머지로 사용자를 만들어 201을 돌려주며, 서비스가 isAdmin을 false로 정하므로 어느 경우든 관리자 계정은 생기지 않습니다. 거절하면 클라이언트가 잘못 보낸 필드를 알 수 있고, 지우면 알지 못한 채 넘어간다는 차이가 있습니다.

2. 타입 단언과 응답

컨트롤러의 findOne에서 toPublicUser(...) 대신 return (await this.userService.findOneById(id)) as PublicUserDto;로 바꾸면 응답의 본문은 어떻게 되나요?

해설 보기

타입 단언은 컴파일러에게 그렇게 믿으라고 알릴 뿐 객체를 바꾸지 않으므로, 응답에는 엔티티의 모든 필드가 그대로 담깁니다. 비밀번호 해시와 isAdmin, 생성과 수정 시각까지 클라이언트에 나가므로, 공개할 필드만 새 객체에 옮기는 toPublicUser를 거쳐야 합니다.

3. 상태 코드의 순서

토큰 없이 GET /users/abc를 보낸 경우와, 관리자의 토큰으로 GET /users/abc와 GET /users/999를 보낸 경우의 상태 코드는 각각 무엇인가요? 999번 사용자는 없다고 가정합니다.

해설 보기

토큰 없이 보낸 요청은 가드가 파이프보다 먼저 실행되므로 ID를 보기 전에 401로 끝납니다. 관리자의 토큰으로 보낸 abc는 가드를 통과한 뒤 ParseIntPipe가 400으로 거절하고, 999는 형식이 맞아 서비스까지 가지만 사용자가 없어 404가 됩니다.

다음 절 인증·인가와 서버 실행에서는 이 서버에 JWT 로그인과 관리자 역할 검사를 더하고, 서버를 실행해 각 요청의 응답을 확인합니다.