Next.js에서 class-validator로 타입 안전성 높이기

Next.js에서 class-validator로 타입 안전성 높이기

D
dongAuthor
7 min read

Next.js로 개발할 때 API 라우트나 폼에서 넘어오는 데이터의 유효성을 어떻게 검증하고 계신가요? Express나 NestJS와 같은 프레임워크와 달리, Next.js에는 내장된 유효성 검증 계층이 없습니다. 이 때문에 개발자가 직접 데이터의 정합성을 확인하는 코드를 작성해야 하죠.

이 글에서는 NestJS 개발자들에게 익숙한 class-validator 라이브러리를 Next.js 프로젝트에 적용하는 방법을 소개합니다. 데코레이터 기반의 깔끔한 유효성 검증 로직을 통해 어떻게 더 안정적이고 유지보수하기 좋은 코드를 작성할 수 있는지 단계별로 살펴보겠습니다.

실제로 개인적인 경험 상 class-validator 도입 후 제품 안정성과 개발 편의성이 훨씬 증가했습니다. Nextjs는 API 서버를 만드는 데에 불편합니다. 그렇지만 아키텍쳐를 잘 설계하면 Next.js에서도 꽤 쓸만한 API 서버를 만들 수 있습니다.

해당 아티클은 Next.js 15버전을 기준으로 작성되었습니다. 참고해주세요. 😉

왜 Next.js에서 class-validator를 사용해야 할까요?

Next.js에서 API 요청 본문(body)을 처리할 때, 각 필드가 올바른 타입인지, 필수값이 누락되지는 않았는지 일일이 확인하는 코드를 작성하는 것은 번거로운 일입니다.

// 일반적인 유효성 검증 로코직
if (!body.email || typeof body.email !== 'string') {
  // 에러 처리...
}
if (!body.password || body.password.length < 6) {
  // 에러 처리...
}

이런 방식은 코드를 지저분하게 만들고, 새로운 필드가 추가될 때마다 검증 로직을 수정해야 하는 불편함이 따릅니다.

class-validator를 사용하면 이런 문제를 해결할 수 있습니다. DTO(Data Transfer Object) 클래스에 데코레이터를 이용해 유효성 검증 규칙을 선언적으로 정의할 수 있어, 코드가 훨씬 간결하고 직관적으로 변합니다.

class-validator 설정하기

이제 본격적으로 Next.js 프로젝트에 class-validator를 설정해 봅시다.

1. 필요 패키지 설치하기

먼저, 유효성 검증에 필요한 패키지들을 설치해야 합니다.

npm install class-validator class-transformer reflect-metadata
  • class-validator: 데코레이터 기반 유효성 검증 라이브러리입니다.
  • class-transformer: 순수(plain) 자바스크립트 객체를 클래스 인스턴스로 변환해줍니다. 데코레이터가 메타데이터를 제대로 읽으려면 이 변환 과정이 필수적입니다.
  • reflect-metadata: 데코레이터가 런타임에 타입 정보를 읽을 수 있도록 도와주는 라이브러리입니다.

데코레이터 문법을 제대로 사용하기 위해 Babel 관련 패키지도 설치해야 합니다.

npm install --save-dev @babel/core babel-plugin-transform-typescript-metadata @babel/plugin-proposal-decorators babel-plugin-parameter-decorator

2. tsconfig.json 설정하기

TypeScript가 데코레이터 문법을 올바르게 해석하고, 메타데이터를 생성할 수 있도록 tsconfig.json 파일을 수정해야 합니다. compilerOptions에 다음 두 가지 속성을 추가해주세요.

// tsconfig.json
{
  "compilerOptions": {
    // ... 기존 설정
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
  • experimentalDecorators: TypeScript에서 데코레이터 사용을 허용합니다.
  • emitDecoratorMetadata: reflect-metadata가 런타임에 타입 정보를 참조할 수 있도록 메타데이터를 생성합니다.

3. .babelrc 설정하기

프로젝트 루트에 .babelrc 파일을 생성하고, 데코레이터 관련 플러그인을 추가합니다. 이 설정은 Babel이 TypeScript 코드를 변환할 때 데코레이터 메타데이터를 유지하도록 돕습니다.

// .babelrc
{
  "presets": ["next/babel"],
  "plugins": [
    "babel-plugin-transform-typescript-metadata",
    ["@babel/plugin-proposal-decorators", { "legacy": true }],
    "babel-plugin-parameter-decorator",
    [
      "@babel/plugin-transform-runtime",
      {
        "regenerator": true
      }
    ]
  ]
}

4. reflect-metadata 전역으로 불러오기

reflect-metadata는 애플리케이션의 시작점에서 단 한 번만 불러와야 합니다. API 라우트의 진입점이나 공통 유틸리티 파일 상단에 import 구문을 추가하는 것이 좋습니다. 예를 들어 src/lib/validation.ts와 같은 파일을 만들어 관리할 수 있습니다.

// src/lib/validation.ts 또는 app/api/.../route.ts 상단
import "reflect-metadata";

// ... 이후 코드

DTO(Data Transfer Object) 정의하기

이제 유효성 검증 규칙을 담을 DTO 클래스를 만들어 보겠습니다. src/dto/create-user.dto.ts 파일을 생성하고 다음과 같이 작성합니다.

// src/dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty({ message: '이름은 필수 항목입니다.' })
  name: string;

  @IsEmail({}, { message: '올바른 이메일 형식이 아닙니다.' })
  email: string;

  @MinLength(6, { message: '비밀번호는 최소 6자 이상이어야 합니다.' })
  password: string;

  @IsNotEmpty({ message: '학교 정보는 필수 항목입니다.' })
  school: string;

  @IsNotEmpty({ message: '전화번호는 필수 항목입니다.' })
  phoneNumber: string;

  @IsOptional()
  introduce?: string;
}

각 속성 위에 @IsEmail(), @MinLength(6)와 같은 데코레이터를 붙여 간단하게 유효성 검증 규칙을 정의할 수 있습니다. 데코레이터에 커스텀 에러 메시지를 추가하면, 검증 실패 시 더 친절한 피드백을 제공할 수 있어요.

유효성 검증 유틸리티 만들기

NestJS의 ValidationPipe와 같은 기능이 Next.js에는 없기 때문에, DTO를 검증하는 유틸리티 함수를 직접 만들어야 합니다. src/lib/validation.ts 파일에 다음 함수를 추가해봅시다.

// src/lib/validation.ts
import 'reflect-metadata';
import { validate, ValidationError } from 'class-validator';
import { plainToInstance } from 'class-transformer';

export async function validateDto<T extends object>(
  dtoClass: new () => T,
  payload: unknown,
): Promise<{ instance: T; errors: string[] }> {
  const instance = plainToInstance(dtoClass, payload);
  const errors: ValidationError[] = await validate(instance);

  if (errors.length > 0) {
    const errorMessages = errors
      .map((err) => Object.values(err.constraints || {}))
      .flat();
    return { instance, errors: errorMessages };
  }

  return { instance, errors: [] };
}

이 함수는 두 가지 중요한 역할을 합니다.

  1. plainToInstance(dtoClass, payload): 일반 JavaScript 객체(payload)를 우리가 정의한 DTO 클래스(dtoClass)의 인스턴스로 변환합니다. 이 과정을 거쳐야 데코레이터에 정의된 메타데이터가 활성화됩니다.
  2. validate(instance): 변환된 인스턴스의 유효성을 검사하고, 오류가 있다면 ValidationError 객체의 배열을 반환합니다.

API Route에서 사용하기

이제 만든 DTO와 유틸리티 함수를 API 라우트에서 사용해볼 차례입니다. app/api/users/route.ts 파일을 예시로 들어보겠습니다.

// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { CreateUserDto } from '@/dto/create-user.dto';
import { validateDto } from '@/lib/validation';

export async function POST(req: Request) {
  try {
    const body = await req.json();
    const { instance, errors } = await validateDto(CreateUserDto, body);

    if (errors.length > 0) {
      // 유효성 검증 실패
      return NextResponse.json(
        { message: 'Validation failed', errors },
        { status: 400 },
      );
    }

    // 유효성 검증 통과 → DB 저장 또는 비즈니스 로직 호출
    // ...

    return NextResponse.json({
      message: '✅ Validation passed',
      data: instance,
    });
  } catch (err: any) {
    // req.json() 파싱 에러 등 기타 예외 처리
    return NextResponse.json(
      { message: 'An unexpected error occurred' },
      { status: 500 },
    );
  }
}

POST 요청이 들어오면 req.json()으로 받은 요청 본문을 validateDto 함수에 전달합니다. 만약 errors 배열에 내용이 있다면, 400 상태 코드와 함께 에러 메시지를 응답합니다. 검증을 통과했다면, instance 객체를 사용해 다음 비즈니스 로직을 처리하면 됩니다. instanceCreateUserDto 클래스의 인스턴스이므로 타입이 보장됩니다.

Controller 추가하기

라우트와 컨트롤러를 분리해서 컨트롤러에서 검증을 해볼 수도 있습니다:

class UserController {
  async createUser(payload: CreateUserDto) {
    await guardAdmin();
    await validateDto(CreateUserDto, payload);

    return userService.createUserByAdmin(payload);
  }
}

이렇게 하면 관심사를 더욱 명확하게 분리할 수 있습니다. 마찬가지로 ServiceRepository도 만들어볼 수 있겠죠?

더 나은 개발 경험을 위하여

Next.js에서 class-validator를 사용하는 것은 단순히 유효성 검증 코드를 줄이는 것 이상의 의미를 가집니다.

  • 가독성 및 유지보수성 향상: 유효성 검증 규칙이 DTO에 명확하게 선언되어 있어, 코드를 이해하고 수정하기 쉬워집니다.
  • 타입 안전성 확보: plainToInstance를 통해 변환된 객체는 클래스 타입이 보장되므로, 개발 과정에서 타입 관련 실수를 줄일 수 있습니다.
  • 선언적 프로그래밍: “어떻게” 검증할지가 아닌, “무엇을” 검증할지에 집중하게 되어 더 깔끔한 코드를 작성할 수 있습니다.

조금의 초기 설정이 필요하지만, class-validator가 제공하는 안정성과 개발 편의성을 고려하면 충분히 투자할 가치가 있습니다. 여러분의 Next.js 프로젝트에도 도입하여 더 견고하고 타입-안전한 애플리케이션을 만들어보세요.

References