Search

    Validation error response design
    2026.05.01 10 min read

    프론트 친화적인 Validation 에러 응답 설계(NestJS + class-validator)

    NestJS와 class-validator를 사용할 때 기본 Validation 에러 응답이 프론트엔드에서 왜 다루기 어려운지 살펴보고, 프론트 친화적인 응답 구조로 개선하는 과정을 정리합니다.

    Validation 에러 응답 설계

    일반적으로 NestJS에서 DTO 검증은 ValidationPipe를 이용합니다. ValidationPipe의 기본 에러 응답은 다음과 같습니다.

    {
      "statusCode": 400,
      "error": "Bad Request",
      "message": ["username should not be empty"]
    }

    프론트 입장에서는 어떨까요?

    만약 아래와 같이 하나의 필드에 여러 개의 데코레이터를 붙이면

    @IsString()
    @IsNotEmpty()
    @Length(2, 10)
    username: string;

    빈 문자열('') 값이 들어왔을 때 ‘비어 있음’ 에러와 ‘길이 충족’ 에러가 함께 잡합니다.


    이때 에러 메시지는 다음과 같이 찍힙니다.

    {
      "statusCode": 400,
      "error": "Bad Request",
      "message": [
        "username must be longer than or equal to 2 characters",
        "username should not be empty"
      ]
    }

    (*데코레이터는 위에서 아래로 평가되지만 실제 적용은 아래에서 위로 수행되므로 Length 에러 메시지가 먼저 출력됩니다)

    하지만 이러한 형태의 에러는 프론트 입장에서 사용하기가 까다롭습니다.

    • “username”이 필드명인지 문장 구성 요소인지 구분해서 파싱해야 함
    • 메시지가 단순한 string 배열 구조라서 필드 단위로 렌더링하기가 어려움
    • 서버가 메시지를 결정하는 형태 → UX 제어권을 프론트가 아니라 서버가 담당

    좀 더 프론트 친화적으로 바꿔볼까요?


    목표 응답 구조

    기존 응답을 아래와 같은 구조로 재구성합니다.

    {
      "statusCode": 400,
      "error": "VALIDATION_ERROR",
      "errors": [
        {
          "field": "username",
          "code": "USERNAME_REQUIRED"
        }
      ]
    }

    핵심은 두 가지입니다.

    1. 에러를 필드 단위로 구조화
    2. 메시지 표현은 프론트 책임으로 넘기고 서버는 코드만 전달

    이렇게 하면 프론트에서는 특정 필드에 대한 에러를 매핑하기 쉬워집니다.

    여기서 중요한 건, 하나의 필드에서 여러 검증이 동시에 실패하더라도 대표 에러 하나만 전달한다는 것입니다.

    예를 들어 username이 빈 문자열이라면 required 에러를 우선으로 보여주고, 값은 있지만 길이가 짧으면 length 에러를 보여주는 방식처럼요.


    에러 우선순위

    먼저 자연스러운 UX를 위해 에러가 여러 개 잡혀도 대표 메시지 하나만 전달하도록 우선순위를 설계해보겠습니다.

    @IsString({ message: 'USERNAME_INVALID' })
    @IsNotEmpty({ message: 'USERNAME_REQUIRED' })
    @Length(2, 10, { message: 'USERNAME_INVALID' })
    readonly username!: string;

    위 예시에서 IsNotEmptyLength 에러가 동시에 발생할 때 프론트는 다음과 같이

    • 값이 비어 있으면 → “이름을 입력해주세요”
    • 값은 있으나 길이를 충족하지 못하면 → “2자 이상 10자 이하로 입력해주세요”

    상황에 맞게 의미 있는 대표 메시지 하나만 보여주는 게 더 자연스러울 것입니다.


    우선순위는 다음 순서로 정의했습니다.

    1. 필수값 검증 (e.g. @IsNotEmpty, @IsDefined, @IsOptional)
    2. 타입 검증 (e.g. @IsBoolean, @IsNumber, @IsString)
    3. 포맷 검증 (e.g. @IsEmail, @IsUrl, @IsUUID)
    4. 범위/길이 검증 (e.g. @Length, @MinLength)
    5. 기타

    위 순서대로 먼저 매칭되는 것을 대표로 사용합니다.


    응답 코드 작성

    이제 코드로 구현해보겠습니다.

    에러 코드 정의

    먼저 서버에서 전달할 에러 코드를 정의합니다.

    export const VALIDATION_ERROR_CODE = {
      VALIDATION_ERROR: 'VALIDATION_ERROR',
      USERNAME_REQUIRED: 'USERNAME_REQUIRED',
      USERNAME_INVALID: 'USERNAME_INVALID',
      AGE_REQUIRED: 'AGE_REQUIRED',
      AGE_INVALID: 'AGE_INVALID',
    } as const;

    모든 검증 규칙을 코드와 1:1로 매핑하기 보다는 실제 UI에서 필요한 표현 단위를 기준으로 설계하는 것이 관리하기 좋습니다.

    • ❌ IsString → USERNAME_NOT_STRING, Length → USERNAME_TOO_SHORT
    • ✅ IsString, Length → USERNAME_INVALID

    예시로 유저 DTO를 간단하게 작성합니다. 데코레이터가 기본으로 제공하는 메시지 대신 에러 코드를 넣습니다.

    import { Type } from 'class-transformer';
    import { IsInt, IsNotEmpty, IsString, Length, Min } from 'class-validator';
    
    export class CreateUserDto {
      @IsString({ message: 'USERNAME_INVALID' })
      @IsNotEmpty({ message: 'USERNAME_REQUIRED' })
      @Length(2, 10, { message: 'USERNAME_INVALID' })
      readonly username!: string;
    
      @Type(() => Number)
      @IsInt({ message: 'AGE_INVALID' })
      @Min(0, { message: 'AGE_INVALID' })
      readonly age!: number;
    }

    만약 다음과 같이 잘못된 값을 전송하면

    {
      "username": "",
      "age": "@"
    }

    class-validator는 이런 구조를 반환할 것입니다.

    [
      {
        value: '',
        property: 'username',
        constraints: {
          isLength: 'USERNAME_INVALID',
          isNotEmpty: 'USERNAME_REQUIRED',
        },
      },
      {
        value: null,
        property: 'age',
        constraints: {
          min: 'AGE_INVALID',
          isInt: 'AGE_INVALID',
        },
      },
    ];

    우선순위 매칭

    이제 해당 구조를 필드(property)별로 구분하여 우선순위 매칭을 시킵니다.

    우선순위는 대략 다음과 같이 구성합니다.

    const PRIORITY = [
      'isNotEmpty',
      'isString',
      'isInt',
      'isNumber',
      'isBoolean',
      'isLength',
      'minLength',
      'maxLength',
      'min',
      'max',
    ] as const;

    위 순서대로 먼저 매칭되는 규칙에 대한 코드를 반환하는 함수를 작성합니다.

    export function pickPrimaryConstraint(constraints: Record<string, string>) {
      const keys = Object.keys(constraints);
      for (const rule of PRIORITY) {
        if (keys.includes(rule)) return constraints[rule];
      }
      return keys[0];
    }

    pickPrimaryConstraint 함수는 검증 실패가 여러 개 들어오더라도 서버가 정한 우선순위에 따라 대표 코드 하나를 골라 반환합니다. 우선순위에 없다면 가장 먼저 선언된 데코레이터 에러를 반환합니다.

    데코레이터 작성 순서가 아니라, 서비스에서 정의한 UX 기준으로 대표 에러를 선택하는 것입니다.


    응답 구조 변경

    ValidationPipeexceptionFactory를 설정합니다.

    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      app.useGlobalPipes(
        new ValidationPipe({
          exceptionFactory: (errors) => {
            return new BadRequestException({
              statusCode: 400,
              message: 'VALIDATION_ERROR',
              errors: errors.map((err) => ({
                field: err.property,
                code: pickPrimaryConstraint(err.constraints ?? {}),
              })),
            });
          },
        }),
      );
    }

    이렇게 설정하면 기본 validation 에러 응답을 그대로 사용하지 않고 원하는 구조로 변환할 수 있습니다.


    예시 1) username필드에 빈 값('')을 입력한 경우:

    데코레이터는 IsString, IsNotEmpty, Length 순으로 정의되었지만,

    @IsString({ message: 'USERNAME_INVALID' })
    @IsNotEmpty({ message: 'USERNAME_REQUIRED' })
    @Length(2, 10, { message: 'USERNAME_INVALID' })
    readonly username!: string;

    가장 우선순위가 높은 IsNotEmpty가 매칭되어 USERNAME_REQUIRED 코드를 전달합니다.

    {
      "statusCode": 400,
      "message": "VALIDATION_ERROR",
      "errors": [
        {
          "field": "username",
          "code": "USERNAME_REQUIRED"
        }
      ]
    }

    예시 2) username필드에 길이가 1인 값('a')을 입력한 경우:

    필드 값이 비어 있지 않고 문자열 타입을 충족하지만 길이를 충족하지 못하므로 Length가 매칭되어 USERNAME_INVALID 코드를 전달합니다.

    {
      "statusCode": 400,
      "message": "VALIDATION_ERROR",
      "errors": [
        {
          "field": "username",
          "code": "USERNAME_INVALID"
        }
      ]
    }

    이 구조의 장점

    이렇게 구조화한 방식은 프론트에서 에러를 추론하지 않아도 된다는 장점이 있습니다. 문자열 메시지를 파싱하지 않아도 필드명이 이미 분리되어 있고 에러의 의미도 코드로 전달됩니다.

    프론트에서는 서버가 내려준 코드를 UI 메시지로 매핑만 하면 됩니다.

    const validationMessageMap = {
      USERNAME_REQUIRED: '이름을 입력해주세요.',
      USERNAME_INVALID: '이름은 2자 이상 10자 이하로 입력해주세요.',
      AGE_INVALID: '나이는 숫자로 입력해주세요.',
    } as const;
    • 말투를 바꾸거나 문구를 다르게 보여줘야 하는 경우, 서버 코드를 직접 수정할 필요가 없어 메시지 관리 수월
    • 필드별로 코드가 분리되어 있어 폼 라이브러리와 연결하기 쉬움
    • 다국어 지원 시 서버에서 완성된 문장을 내려주는 방식보다 확장 용이

    TAGS

    NestJS