Published on

DTO와 Service의 검증 책임 분리하기 (feat. NestJS, class-validator, Zod)

Authors
  • avatar
    Name
    Mingyu Kim
    Twitter

거리 기반 조회 API를 구현하면서 lat, lng, radiusMeters, sort=distance처럼 여러 필드가 함께 만족해야 하는 Cross Field Validation이 필요했습니다. 처음에는 필드 단위 검증은 DTO에서, 필드 간 조합 검증은 Service에서 처리했지만, 이 구조에서는 요청의 유효성을 파악하기 위해 DTO와 Service를 함께 확인해야 했습니다.

최종적으로는 다음과 같이 책임을 나누었습니다.

책임위치
요청 형태 검증, Cross Field Validation 포함DTO
좌표 여부에 따른 조회 조건과 기본 정렬 결정Service
검증 실패 응답의 구조화와 메시지 노이즈 제거exceptionFactory

중간에 클래스 데코레이터, 필드 단위 Validator, Zod의 superRefine까지 검토했지만, 최종적으로는 class-validator를 유지했습니다. 이유는 Cross Field 규칙 전체를 한 곳에서 보는 것보다, 각 필드를 읽었을 때 해당 필드의 검증 규칙이 함께 보이는 구조를 더 중요하게 봤기 때문입니다.

이 글은 그 결론에 도달하기까지 어떤 문제를 만났고, 왜 그런 선택을 했는지 정리한 기록입니다.

발단: 한 필드만 봐서는 답할 수 없는 규칙

기존 지게차 업체 목록 조회 API에 거리 기준으로 필터링을 할 수 있도록 수정해야 하는 새로운 요구사항이 생겼습니다.

따라서 기존 API의 규칙으로 다음 규칙을 추가하였습니다.

  • (기존 규칙) 검색 문자열을 받아 검색할 수 있어야 한다.
  • ...
  • (New) 위도, 경도, 거리는 셋 다 함께 주거나 셋 다 주지 않아야 한다. 일부만 준다면 400 에러
  • (New) sort=distance(가까운 순)는 좌표가 있어야 의미가 있으니, 좌표 없이 distance면 400 에러

따라서 기존 DTO와 서비스 코드를 다음과 같이 수정하였습니다.

DTO 코드

// forklift-companies.query.dto.ts
export class ForkliftCompaniesQueryDto {
  @IsOptional()
  @IsString()
  q?: string;

  ...

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Min(-90)
  @Max(90)
  lat?: number;

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Min(-180)
  @Max(180)
  lng?: number;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(50000)
  radiusMeters?: number;
}

Service 코드

// forklift-companies.service.ts
async list(query: ForkliftCompaniesQueryDto) {
  const hasCoords = this.hasCoords(query);

  this.validateGeoParams(query, hasCoords);

  const sort =
    query.sort ??
    (hasCoords ? ForkliftCompanySort.DISTANCE : ForkliftCompanySort.RATING);

  // 이후 조회 로직...
}

private hasCoords(query: ForkliftCompaniesQueryDto): boolean {
  return query.lat != null && query.lng != null && query.radiusMeters != null;
}

private validateGeoParams(
  query: ForkliftCompaniesQueryDto,
  hasCoords: boolean,
): void {
  const anyCoord =
    query.lat != null || query.lng != null || query.radiusMeters != null;

  if (anyCoord && !hasCoords) {
    throw new BadRequestException(
      'lat, lng, radiusMeters must be provided together',
    );
  }

  if (query.sort === ForkliftCompanySort.DISTANCE && !hasCoords) {
    throw new BadRequestException(
      'sort=distance requires lat, lng, radiusMeters',
    );
  }
}

구현을 마친 뒤 코드를 다시 살펴보니 검증 책임이 두 곳으로 나누어져 있었습니다. Min, Max, 정수 여부와 같은 필드 단위 검증은 DTO에서 수행하고 있었지만, Cross Field Validation은 Service에서 수행하고 있었습니다.

이 구조에서는 DTO만 봐서는 요청이 만족해야 하는 모든 규칙을 파악할 수 없었습니다. 예를 들어 lat, lng, radiusMeters가 함께 전달되어야 한다거나 sort=distance는 좌표가 있을 때만 허용된다는 규칙은 Service를 확인해야만 알 수 있었습니다. 또한 Service에서는 이러한 규칙을 직접 검사한 뒤 조건을 만족하지 않으면 400 Bad Request를 발생시키고 있었습니다.

이처럼 검증 책임이 DTO와 Service에 분산되어 있으면 요청의 유효성을 파악하기 위해 여러 계층을 함께 살펴봐야 합니다. 그 결과 코드의 가독성이 떨어지고, 검증 로직이 누락될 가능성이 커질 뿐만 아니라 요청 검증과 비즈니스 로직이 뒤섞여 테스트 코드 작성도 어려워질 수 있다고 생각했습니다.

따라서 DTO는 **"요청이 올바른 형태인가"**를 검증하는 책임을, Service는 검증이 완료된 요청을 바탕으로 어떤 비즈니스 로직을 수행할 것인가에 대한 책임을 갖도록 역할을 분리하였습니다.

이렇게 역할을 분리하면 요청의 유효성은 DTO만 확인해도 파악할 수 있고, Service는 입력값 검증 대신 비즈니스 로직에만 집중할 수 있습니다. 또한 새로운 API에서 동일한 DTO를 재사용하더라도 검증 규칙이 함께 적용되므로 검증 누락 가능성을 줄일 수 있으며, 요청 검증과 비즈니스 로직을 각각 독립적으로 테스트할 수 있어 유지보수성도 향상됩니다.

1차 시도: Cross Field 검증을 DTO 클래스 데코레이터로 옮기기

먼저 class-validator를 사용하고 있었기 때문에, 해당 라이브러리에서 클래스 단위 검증을 지원하는 데코레이터가 있는지 찾아보았습니다. Cross Field Validation은 의미상 특정 필드에 대한 제약이 아니라 쿼리 전체에 대한 제약이라고 생각했기 때문입니다.

하지만 이를 위한 공식 데코레이터는 제공되지 않았습니다. 저와 비슷한 필요를 느낀 사람들이 있을 것이라 생각해 관련 GitHub 이슈도 찾아보았고, 실제로 클래스 단위 검증에 대한 요구와 논의(#1761)를 확인할 수 있었습니다. 다만 아직까지 명확한 결론이나 공식적인 지원 계획은 발표되지 않은 상태였습니다.

또한 메인테이너가 작성한 #1775를 살펴보면, 현재 프로젝트는 제한된 리소스 안에서 유지보수와 프로젝트 안정화에 우선순위를 두고 운영되고 있음을 알 수 있습니다. 이러한 상황을 고려했을 때 클래스 단위 검증 기능이 가까운 시일 내에 공식 기능으로 해결되기를 기대하기는 어렵다고 판단했습니다.

결국 공식 지원을 기다리기보다는, 커스텀 데코레이터를 구현하여 문제를 해결하기로 하였습니다.

// forklift-companies.query.dto.ts
@ValidatorConstraint({ name: 'isValidGeoQuery', async: false })
class IsValidGeoQueryConstraint implements ValidatorConstraintInterface {
  validate(_v: unknown, args: ValidationArguments): boolean {
    const o = args.object as Dto;
    const provided = [o.lat, o.lng, o.radiusMeters].filter((v) => v != null).length;
    if (provided > 0 && provided !== 3) return false;
    if (o.sort === 'distance' && provided !== 3) return false;
    return true;
  }
}

@IsValidGeoQuery()                 // ← 클래스 선언부. "쿼리 전체의 불변식"으로 읽힘
export class ForkliftCompaniesQueryDto { /* ... */ }
// forklift-companies.service.ts
async list(query: ForkliftCompaniesQueryDto) {
  const page = query.page ?? 1;
  const limit = query.limit ?? 20;

  const hasCoords = this.hasCoords(query);

  const sort =
    query.sort ??
    (hasCoords ? ForkliftCompanySort.DISTANCE : ForkliftCompanySort.RATING);

  const qb = this.repo.createQueryBuilder('c');

  if (query.q) {
    qb.andWhere('c.name ILIKE :q', { q: `%${this.escapeLike(query.q)}%` });
  }

  if (hasCoords) {
    const point = 'ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography';

    qb.addSelect(`ST_Distance(c.location, ${point})`, 'distance')
      .andWhere(`ST_DWithin(c.location, ${point}, :radius)`)
      .setParameters({
        lat: query.lat,
        lng: query.lng,
        radius: query.radiusMeters,
      });
  }

  this.applyOrder(qb, sort);

  // 이후 페이징 및 응답 매핑...
}

이제 검증 책임은 DTO로 성공적으로 옮겨졌고 Service에서는 검증 로직이 제거되었습니다. 하지만 이번에는 400 Bad Request 응답이 새로운 문제가 되었습니다.

{ "message": ["lat, lng, radiusMeters는 함께 제공해야 합니다"] }

클래스 레벨에서 검증을 수행하다 보니 검증 오류의 property가 빈 문자열("")로 내려왔습니다. 그 결과 응답에는 어느 필드에서 검증이 실패했는지에 대한 정보가 포함되지 않았고, 클라이언트는 어떤 필드를 수정해야 하는지 알 수 없었습니다.

검증 책임은 DTO로 성공적으로 분리했지만, 클라이언트가 활용하기에는 에러 응답이 충분하지 않았습니다. 결국 검증 책임 분리사용하기 좋은 에러 응답이라는 두 가지 목표를 모두 만족하는 방법이 필요했습니다.

2차 시도: 필드에서 검증하기

에러를 누락된 필드 자체에 귀속시키려면, 해당 필드에서 직접 검증을 수행해야 했습니다. 그래서 @ValidateIf@IsDefined를 조합하여 조건부 필수(Conditional Required) 검증을 구현했습니다.

const hasAnyCoord = (o: Dto) => o.lat != null || o.lng != null || o.radiusMeters != null;

@ValidateIf(hasAnyCoord)
@IsDefined({ message: 'lng은 lat·radiusMeters와 함께 제공해야 합니다' })
@Min(-180) @Max(180)
lng?: number;

이제 ?lat=37.5와 같이 일부 좌표만 전달되면, 검증 오류가 lng, radiusMeters와 같이 누락된 필드에 정확하게 귀속되었습니다. 이전 문제였던 에러의 property가 비어 있는 문제는 해결되었습니다.

sort=distance에 대한 검증은 조금 성격이 달랐습니다. 거리 기준 정렬은 사용자 좌표가 있어야만 의미가 있기 때문에, lat, lng, radiusMeters가 모두 제공된 경우에만 허용되어야 했습니다.

이 경우 에러의 원인은 좌표 필드가 아니라 sort 값에 있다고 판단했습니다. 사용자가 sort=distance를 선택했지만, distance 정렬에 필요한 좌표 정보를 함께 제공하지 않았기 때문입니다. 따라서 이 검증은 sort 필드에 귀속시키는 것이 자연스럽다고 생각했습니다.

const hasAllCoords = (o: ForkliftCompaniesQueryDto): boolean =>
  o.lat != null && o.lng != null && o.radiusMeters != null;

@ValidatorConstraint({ name: 'distanceSortRequiresCoords', async: false })
class DistanceSortRequiresCoordsConstraint
  implements ValidatorConstraintInterface
{
  validate(value: unknown, args: ValidationArguments): boolean {
    if (value !== ForkliftCompanySort.DISTANCE) return true;

    return hasAllCoords(args.object as ForkliftCompaniesQueryDto);
  }

  defaultMessage(): string {
    return 'sort=distance를 사용하려면 lat, lng, radiusMeters가 모두 필요합니다';
  }
}

@IsOptional()
@IsEnum(ForkliftCompanySort)
@Validate(DistanceSortRequiresCoordsConstraint)
sort?: ForkliftCompanySort;

이렇게 하면 sort=distance가 전달되었지만 좌표 세트가 완성되지 않은 경우, 검증 오류가 sort 필드에 정확히 귀속됩니다.

즉, 좌표 필드의 누락은 누락된 필드에 붙이고, sort=distance를 사용할 수 없는 상황은 sort 필드에 붙였습니다. 같은 Cross Field Validation이라도 실패 원인이 어느 필드에 가까운지에 따라 에러를 귀속시키는 위치를 다르게 가져간 것입니다.

하지만 또 다른 문제가 발생했습니다. 누락된 필드에 대해 다음과 같은 응답이 반환되었습니다.

{
  "message": [
    "radiusMeters는 lat·lng와 함께 제공해야 합니다",
    "radiusMeters must be an integer number",
    "radiusMeters must not be less than 1",
    "radiusMeters must not be greater than 50000"
  ]
}

문제는 값이 단순히 존재하지 않을 뿐인데, @IsDefined뿐만 아니라 @IsInt, @Min, @Max까지 모두 실행되어 불필요한 에러 메시지가 함께 반환된다는 점이었습니다.

그 이유는 @ValidateIf가 조건을 만족하면 해당 필드에 선언된 모든 Validator가 실행되기 때문입니다. 또한 stopAtFirstError를 사용하지 않는 환경에서는 모든 검증 결과를 수집하므로, 하나의 문제에 대해 여러 개의 에러 메시지가 생성되었습니다.

결국 에러의 귀속은 해결했지만, 이번에는 에러 메시지의 노이즈라는 새로운 문제가 생겼습니다.

3차 시도: Zod로 전환 검토하기

class-validator만으로는 지금까지 발견한 두 가지 문제를 모두 만족스럽게 해결하기 어려워 보였습니다. 그래서 대안으로 스키마 기반 검증 라이브러리인 Zod 도입을 검토하기 시작했습니다.

특히 Zod의 superRefine은 Cross Field Validation을 한 곳에서 정의하면서도 path를 통해 검증 오류를 원하는 필드에 귀속시킬 수 있어, 지금까지의 문제를 해결할 수 있을 것으로 보였습니다.

lat: z.coerce.number().min(-90).max(90).optional(),  // 여기엔 범위만
// ...
.superRefine((o, ctx) => {
  if (provided > 0 && !hasAllCoords)
    for (const f of ['lat','lng','radiusMeters'])
      if (o[f] == null) ctx.addIssue({ code: 'custom', message: '...', path: [f] });
});

superRefine을 사용하면 Cross Field Validation을 한 곳에서 관리하면서도, path를 통해 검증 오류를 원하는 필드에 귀속시킬 수 있었습니다. 또한 각 필드는 .optional()로 선언되어 있기 때문에 값이 누락되더라도 범위 검증이 함께 실행되지 않아 불필요한 에러 메시지도 발생하지 않았습니다.

Cross Field Validation을 한 곳에서 관리하면서도 필드 단위의 에러 응답까지 제공할 수 있다는 점에서, 지금까지 겪었던 두 가지 문제를 모두 해결할 수 있는 깔끔한 방법처럼 보였습니다.

하지만 실제로 적용해 보니 아쉬운 점이 있었습니다. 하나의 필드에 대한 검증이 두 곳으로 분산된다는 점이었습니다.

예를 들어 lat를 보면 선언부에는 min, max와 같은 필드 단위 검증만 존재하고, **"다른 좌표와 함께 전달되어야 한다"**는 Cross Field Validation은 superRefine에 별도로 작성되어 있습니다. 즉, lat 필드만 읽어서는 해당 필드에 적용되는 모든 검증 규칙을 파악할 수 없었습니다.

제가 더 중요하게 생각한 기준은 **"필드를 읽으면 그 필드에 대한 모든 검증을 이해할 수 있어야 한다"**는 것이었습니다. 다시 말해, 검증 규칙은 가능한 한 필드별로 함께(co-location) 존재하는 것이 좋다고 생각했습니다.

결과: class-validator로 회귀

이 기준에서는 데코레이터 기반 방식이 더 적합했습니다. 조건부 필수 여부, 값의 유효성 판정, 범위 검증까지 모두 해당 필드 위에 선언적으로 쌓아 둘 수 있기 때문입니다.

결국 Zod로 마이그레이션하지 않고, class-validator를 끝까지 다듬어 사용하는 방향을 선택했습니다.

물론 이 선택에도 분명한 트레이드오프가 있습니다. **"세 필드는 반드시 함께 전달되어야 한다"**는 규칙은 하나의 장소에 모여 있지 않고, 각 필드에 분산되어 작성됩니다. 따라서 Cross Field Validation의 전체 규칙을 한눈에 조망하기는 어려워집니다.

대신 저는 **"필드를 읽으면 그 필드에 대한 모든 검증을 이해할 수 있어야 한다"**는 기준을 더 중요하게 생각했습니다. 즉, 규칙 전체의 조망을 일부 포기하는 대신, 필드별 조망을 선택했고 그 기준에 따라 class-validator를 유지하기로 결정하였습니다.

노이즈는 표현 단계에서 정리하기

이제 다시 class-validator로 돌아와, 앞에서 마주했던 에러 메시지 노이즈를 개선해야 했습니다. 앞선 시도에서 만난 메시지 노이즈는 검증 자체의 문제라기보다, 검증 실패를 어떤 형태의 응답으로 표현할 것인가의 문제에 가까웠습니다.

검증은 정확히 동작하고 있었습니다. radiusMeters가 누락되었을 때 @IsDefined가 실패하는 것은 의도한 동작이었고, @IsInt, @Min, @Max까지 함께 실패하는 것도 class-validator의 동작 방식상 자연스러운 결과였습니다. 문제는 이 모든 메시지를 그대로 클라이언트에 전달하면, 실제로 수정해야 할 원인이 흐려진다는 점이었습니다.

그래서 검증 정책 자체를 바꾸기보다는, 전역 ValidationPipeexceptionFactory에서 응답을 정리하기로 했습니다. 기준은 단순했습니다.

한 필드에 isDefined 에러가 있으면, 해당 필드는 누락이 핵심 원인이므로 isDefined 메시지만 남긴다.

// validation-exception.factory.ts
export function flattenValidationErrors(
  errors: ValidationError[],
  parentPath = '',
): FieldValidationError[] {
  return errors.flatMap((e) => {
    const field = parentPath ? `${parentPath}.${e.property}` : e.property;
    const messages = e.constraints
      ? e.constraints.isDefined
        ? [e.constraints.isDefined]        // 누락이면 isDefined 메시지만 → 범위 노이즈 제거
        : Object.values(e.constraints)     // 그 외엔 전부 유지
      : [];
    const self = messages.length ? [{ field, messages }] : [];
    const children = e.children?.length    // 중첩 DTO는 점 경로로 재귀
      ? flattenValidationErrors(e.children, field)
      : [];
    return [...self, ...children];
  });
}

export function validationExceptionFactory(errors: ValidationError[]) {
  return new BadRequestException({
    statusCode: 400,
    error: 'Bad Request',
    message: '검증 실패',
    errors: flattenValidationErrors(errors),
  });
}

마지막으로 전역 ValidationPipe에 exceptionFactory를 연결했습니다. 이렇게 하면 DTO에 작성한 검증 로직은 그대로 유지하면서, 검증 실패 시 반환되는 응답 형식만 원하는 구조로 조립할 수 있습니다.

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    transform: true,
    exceptionFactory: validationExceptionFactory,
  }),
);

즉, 검증 규칙 자체는 건드리지 않고 에러 응답을 만드는 방식만 변경한 것입니다.

그 결과 누락된 필드에 대해서는 다음처럼 필요한 메시지만 응답하게 되었습니다.

{
  "message": "검증 실패",
  "errors": [
    { "field": "lng",          "messages": ["lng은 lat·radiusMeters와 함께 제공해야 합니다"] },
    { "field": "radiusMeters", "messages": ["radiusMeters는 lat·lng와 함께 제공해야 합니다"] }
  ]
}

이렇게 하니 검증 규칙은 DTO에 그대로 두면서도, 클라이언트가 사용하기 좋은 형태로 에러 응답을 정리할 수 있었습니다. 즉, 검증 로직은 건드리지 않고 에러 응답의 표현 방식만 조정하여 메시지 노이즈를 제거한 것입니다.

정리된 책임 경계

여러 번의 시행착오 끝에 남은 것은 결국 무엇을 어디에 둘 것인가에 대한 기준이었습니다.

책임위치이유
요청 형태 검증 (Cross Field Validation 포함)DTO"올바른 입력인가"는 입력 계약의 일부이기 때문
비즈니스 기본값 결정 (좌표가 있으면 distance 정렬)Service"무엇을 조회할 것인가"는 비즈니스 규칙이기 때문
에러 응답 표현 (노이즈 정리·구조화)exceptionFactory"어떻게 보여줄 것인가"는 응답 표현의 문제이기 때문

특히 "좌표가 있으면 기본 정렬을 distance로 설정한다"는 규칙은 @Transform을 사용해 DTO에 넣을 수도 있었습니다. 하지만 이 방식은 선택하지 않았습니다.

이 규칙은 입력 형식을 검증하는 문제가 아니라, 조회 기준을 결정하는 비즈니스 규칙에 가깝다고 판단했기 때문입니다. DTO에 해당 로직을 넣으면 Service의 조회 로직과 책임이 겹칠 수 있고, @Transform이 검증보다 먼저 실행된다는 점 때문에 의도치 않은 동작이 발생할 여지도 있었습니다.

결국 **"검증 책임을 DTO로 옮긴다"**는 것은 모든 로직을 DTO에 몰아넣는다는 의미가 아니었습니다. 입력 계약에 해당하는 검증만 DTO로 옮기고, 비즈니스 규칙과 에러 응답 표현은 각각의 자리에 남겨두는 것이 핵심이었습니다.

추가: @ValidateNested를 사용하지 않은 이유

Cross Field Validation을 구현하는 과정에서 lat, lng, radiusMeters를 하나의 객체로 묶고 @ValidateNested를 이용해 별도의 객체에서 검증하는 방법도 고려했습니다.

하지만 이 API는 RESTful API 관점에서 조회 API이므로 GET 메서드를 사용하는 것이 적절하다고 판단했습니다. @ValidateNested를 적용하려면 관련 필드를 하나의 객체로 묶는 것이 자연스러웠지만, 이는 GET 요청에서 사용하는 쿼리 파라미터 구조와는 잘 어울리지 않았습니다.

따라서 RESTful API의 특성을 유지하면서도 검증 책임을 DTO에 집중시키기 위해, 별도의 중첩 객체를 도입하는 대신 현재와 같은 방식을 선택하였습니다.