6. DTO와 Validation
요약
이전 글에서는
- 유저 배열(Array)을 기반으로 유저 데이터를 관리하는 CRUD 기능을 구현하였습니다.
- 코드 구현 및 동작을 테스트하기 위해 Postman을 활용하여 확인하였습니다.
본 글에서는
- 컨트롤러와 프로바이더에서 주고 받는 데이터를 DTO(Data Transfer Object)로 정의합니다.
- DTO에 대한 데이터 검증(Data Validation)을 추가하여, 올바른 데이터만이 도달하도록 구현합니다.
6-1. DTO(Data Transfer Object)란?
DTO, 데이터 전송 객체는 컨트롤러와 프로바이더 같이 서로 다른 계층(Layer) 간에 데이터를 전송하기 위해 이용하는 객체를 의미합니다.
일반적인 객체의 정의상 객체는 가지는 속성(Attribute)뿐만 아니라 행동(Method)을 같이 정의할 수 있습니다. 그러나 DTO는 말 그대로 데이터 전송만을 위해 이용되는 객체이기 때문에, 일반적으로 별도의 행동을 따로 정의하지 않고 (비즈니스 로직을 포함하지 않고) 순수히 전송하고 싶은 데이터(속성)를 정의합니다.
DTO의 대표적인 예시로는 우리가 Controller에 정의한 호출 함수가 반환하는 데이터 객체가 있습니다. 외부로부터 API 호출에 의하여 비즈니스 로직이 처리되면, 그 기능이 결국 반환하는 데이터는 단순한 전송 객체입니다. 그리고 더 나아가서 구현 내부에서 각 계층(Layer) 간 소통에서도 DTO가 사용될 수 있습니다. 그때에는 각 계층끼리 주고 받는 데이터를 별도의 DTO로 선언함으로써, 우리가 다루고 있는 모델 전체를 돌아다니게 하는 것이 아닌, 필요한 것들만을 주고 받을 수 있게 만들 수 있습니다.
DTO를 사용하는 이유는, 예를 들어, 우리가 누군가에게 코끼리에 대해서 효율적으로 소개한다고 할 때, 코끼리의 어원과 그 조상과 역사 등의 모든 배경지식과 상황을 동반해서 설명하기보다, 코끼리라는 동물의 특징에 대해 중점적으로 이야기하는 것과 같은 이유입니다. 우리가 대화를 할 때에 필요한 중점 정보만을 전달하고 이해하는 부분은 상대방에게 맞긴 것처럼, DTO는 API 소통 및 각 계층 간 소통에서 필요한 정보만을 주고 받을 수 있게 할 수 있습니다.
정리하면, DTO를 별도로 정의함으로써 우리는 서로 주고 받는 데이터에 대한 형태(타입)에 불필요한 정보들을 제거하고 좀더 자유롭고 정교하게 설정할 수 있게 됩니다.
6-2. DTO를 이용하지 않는 코드 예제
이제 코드 예제를 통해 DTO의 필요성에 대해서 확인해 보겠습니다.
위와 같은 `UserController` 코드가 있습니다.
- `createUser` API를 통해 새로운 `User` 를 저장할 수 있습니다.
- `getUser` API를 통해 `id` 를 기반으로 `User` 를 조회할 수 있습니다.
위는 `UserService` 코드입니다.
- `createUser()` 메소드는 입력된 유저를 배열에 저장하고 있습니다.
- `getUserById()` 메소드는 유저 배열에서 입력된 `id` 값을 가진 하나의 유저를 찾아 반환하고 있습니다.
위는 `User` 타입의 모델을 정의하는 Class 입니다.
새로운 유저를 저장하기 위해 Class에 정의된 모든 값들을 넘겨주어야 합니다. 반면, 특정 `User` 의 데이터를 찾기 위해 컨트롤러와 프로바이더를 관통하는 `id` 값을 전달하면, `UserService` 내부에 정의된 `getUserById()` 함수는 `User` 내부에 정의된 모든 속성(데이터)를 그대로 반환하게 됩니다.
이와 같이 DTO 없이 단순히 모델의 타입을 기반으로 동작하게 되면 다음과 같은 문제점 및 궁금증을 발생시키게 됩니다.
`User` 를 저장하고 싶을 때마다 저 모든 데이터들을 하나하나 정의해야 되는거야? 만약 이런 필드들이 늘어나게 되면 너무 번거롭지 않을까? 모델은 그대로 두고 받고 싶은 몇개의 데이터들만 정의하고 싶을 때는 어떡하지?
`id` 를 기반으로 유저를 찾긴 찾았는데 이걸 통째로 다 보내주면 비밀번호가 막 노출되는거 아닌가? 나는 모델은 그대로 두고 반환하고 싶은 별도의 데이터들을 정의하고 싶을 때는 어떡하지?
위와 같은 이유들로 우리가 다루고 있는 도메인 모델(또는 비즈니스 모델)과 별도로 전송만을 위한 별도의 데이터 객체를 정의할 필요성이 생기게 됩니다. 따라서 DTO의 선언과 사용에 대한 필요성이 생기게 됩니다.
6-3. DTO를 이용하는 코드 예제
이제 DTO를 활용하여 위의 코드들을 좀더 개선해 보겠습니다. 개선 목표는 다음과 같습니다.
- `User` 를 새롭게 저장할 때, 닉네임과 이메일만을 입력받고 나머지 값들은 빈 값 또는 자동으로 생성합니다.
- `User` 의 `id` 를 기반으로 조회할 때, 검색된 유저의 닉네임, 이메일, 생일만을 반환합니다.
(참고) DTO는 데이터 전송 객체이기 때문에 사실 Class로 선언하지 않아도 됩니다. 데이터만을 정의한다는 것은 그 데이터가 가지는 형태, 즉, 타입을 지정하는 것과 같기 때문에 인터페이스(Interface)로 정의해도 괜찮습니다. 그러나 후에 Validation 과정에서 우리가 이용할 `class-validator` 패키지는 Class를 기반으로 동작하기 때문에, 본 예제에서는 Class DTO를 이용합니다.
먼저, src/user 디렉토리 하위에 dto 디렉토리를 생성합니다.
`createUser` 로직을 실행하기 위해 입력받는 값들을 위하여 create-user.dto.ts 파일을 생성하고 아래와 같이 구현합니다.
구현된 `CreateUserDto` 는 Class 이지만 DTO의 목적에 따라 단순히 입력되는 필드만을 정의하고 있습니다. 이제 이 DTO를 실제로 컨트롤러 및 프로바이더 코드에서 이용합니다.
기존에 `@Body` 데코레이터에 입력받는 `body` 값의 타입을 `User` 에서 `CreateUserDto` 로 변경합니다. 이를 통해, 해당 API의 입력 값으로 `CreateUserDto` 에 정의된 값만을 수신하여 실행시킬 수 있습니다.
이 때, `UserService` 내에서는 입력받는 값이 `CreateUserDto` 에서 정의된 데이터만을 가지기 때문에, `User` 배열에 저장하기 위한 별도의 로직 처리를 수행해야 합니다. 이러한 로직의 처리는 대부분 비즈니스 도메인의 로직을 포함하게 됩니다. 예를 들어, 새로운 ID를 할당하거나, 이름을 성과 이름으로 분리하거나, 닉네임과 이메일을 이용하여 비밀번호를 자동 생성하는 등의 비즈니스 로직을 구현할 수 있습니다.
예시에서는 `User` 값의 나머지 데이터들에 대하여 `id` 는 "1" 로, 나머지는 빈 값으로 대입하겠습니다. (실제로는 여러 유저들을 생성하고 싶을 때는 ID를 기반으로 조회한다고 가정한다면, 당연히 서로 다른 ID를 가져야 합니다.)
입력 받은 DTO에서 필요한 변수들을 꺼낸 다음, `User` 타입으로 만들어주기 위해 각각 대입하였습니다. 이제 이메일과 닉네임만을 입력하면 임의의 유저를 배열에 추가할 수 있게 되었습니다. 그리고 이 유저의 ID는 "1" 이기 때문에 이 값을 기반으로 조회할 수 있습니다.
위와 같은 맥락으로 유저 ID 조회 기능에 대해서 DTO를 추가해 보겠습니다. 기존의 이 기능에서는 ID를 입력 받고 `User` 데이터를 반환하고 있었습니다. 즉, 입력과 출력 값이 모두 존재하므로 이것들을 모두 DTO로 변경해 보겠습니다.
입력 DTO로 user-id.dto.ts 파일을, 출력 DTO로 user-by-id.dto.ts 파일을 생성합니다. 각각 다음과 같습니다.
위에 선언된 DTO들을 컨트롤러 및 서비스에 적용하겠습니다.
여기서 주의할 점은 `UserController.getUser` 에서 입력 받는 `id` 값은 `@Param` 데코레이터를 이용하여 String 타입으로 받고 있기 때문에 이를 그대로 받고, Controller에서 Service로 주입되는 과정에서 DTO로 변환하여 넘기고 있는 것을 볼 수 있습니다. 즉, DTO는 API(컨트롤러)에서도 컨트롤러와 서비스 사이에서도 데이터 전송 객체로써 이용되고 있는 것입니다.
이제 구현된 코드 예제들을 실행하여 잘 동작하는지 직접 확인해 보겠습니다.
VS Code의 Postman Extension을 이용하여 테스트하였습니다.
반환된 응답의 상태 코드가 201로 올바르게 실행되었음을 알 수 있습니다.
이 과정에서 ID를 "1"로 가지는 새로운 유저가 배열에 추가되었다고 생각할 수 있습니다.
유저 배열에 저장된 유저들 중 ID를 "1"로 가지는 (실제로는 하나밖에 없지만) 유저를 조회하여 그중 이메일, 닉네임, 생일만을 응답 데이터로 전달 받았습니다.
정리하면, 이와 같이 DTO를 활용함으로써 실제 모델과 별개로 그 모델을 생성하기 위해 필요한 데이터만을 입력받고, 조회할 때에는 모델의 모든 데이터가 아닌 전달하고 싶은 데이터만을 출력할 수 있게 되었습니다.
6-4. Validation
우리가 원래 지정한 모델 외에 DTO를 활용하게 되면 다양한 데이터들을 받을 수 있게 되면서, 해당 데이터들에 대한 보장이 필요하게 됩니다. 만약 우리가 예측할 수 없는 값들을 받게 된다면, 그 값들에 대한 처리를 모두 만족시키기 어렵기 때문에 로직을 구현하기가 어려워집니다. 따라서, DTO에 포함되는 데이터에 대한 보장을 위하여 데이터 검증 과정을 필요로 하게 됩니다.
데이터 검증(Validation)은 말 그대로 데이터에 대한 검증을 수행하는 것을 말합니다. DTO는 입력 시에 받도록 설정한 입력 DTO와 그 DTO를 기반으로 로직을 처리하고 내보내는 출력 DTO로 나뉠 수 있는데, 그 입/출력되는 값에 대하여 그 값이 우리가 어느정도 예상하는 값이 맞는지 확인하는 과정이 바로 데이터 검증 과정입니다. 검증 과정은 가볍게는 데이터가 존재하는지부터 시작하여, 데이터가 우리가 원하는 타입을 가졌는지(숫자면 숫자, 문자면 문자, 날짜면 날짜 등등), 데이터가 우리가 원하는 규칙이나 범위를 만족하는지 등 다양한 수준으로 적용될 수 있습니다.
이렇게 자세하게 하려면 자세하게, 가볍게 하려면 가볍게 할 수 있는 데이터 검증은 특히 개발자가 원하는만큼(사실은 정책적 요구사항을 만족하는만큼) 이루어질 수 있어야 합니다. 너무 많은 검증을 강요하면 구현 소요와 유지보수(변화) 측면에서 예민해질 것이고, 너무 널럴한 검증을 적용하면 감지하지 못하는 다른 문제를 일으킬 수도 있습니다. 따라서 상황에 맞게 적당한 수준의 데이터 검증을 적용하는 것이 가장 좋고, 개인/조직의 컨벤션에 따라서 적용해야 하는 부분들을 잘 관리하는 것도 좋습니다.
검증 과정을 구현하면 우리는 함수의 입/출력에 대해 의도치 않은 값을 배제하고 어느정도 예상 가능한 범주에서 값을 이해함으로써 구현에 집중할 수 있게 됩니다. 그러나 일반적으로는 생산성과 유지보수 측면에서 입력 DTO에 대한 Validation만을 수행하는 경우가 많습니다. 입력 DTO는 외부로부터 우리가 구현하는 비즈니스 로직에 들어오는 값이기 때문에 잘못된 값들을 받아들이지 않고 정확한 형태만을 받아들여야 하기 때문에 더 세밀한 검증이 이루어져야 합니다. 반면, 우리가 구현한 비즈니스 로직의 처리에 따라 결과로써 내보내는 출력 DTO는 어느정도 우리가 그 값을 보장할 수 있으며, 특히, TypeScript와 같이 타입(형태)을 지정할 수 있는 언어에서, 적어도 내보내는 값에 대한 형태를 만족시킬 수 있기 때문입니다.
따라서 이 글에서는 기존 NestJS 공식 문서의 Validation 부분을 기반으로 설명하되, 입/출력 DTO를 모두 사용하나 실제로 입력 DTO에 대한 검증만을 중점으로 설명하고자 합니다.
6-5. Validation Pipe (검증 파이프)
데이터 검증에 들어가기 앞서 NestJS에서는 Pipe 라고 하는 개념을 이용하고 있습니다. Pipe는 입력되는 데이터에 대한 검증(Validation) 및 변환(Transformation) 기능을 목적으로 NestJS에서 구현된 프로바이더입니다.
이곳에서는 그 중 전역적으로 설정되어 검증을 수행할 수 있는 `ValidationPipe` 를 이용합니다. 이 Pipe를 전역적으로 설정하면 Controller에 입력되는 DTO에 대한 검증이 자동적으로 수행됩니다. 즉, 해당 Controller의 함수가 호출되어 실행되기 이전에 Pipe를 통해 검증 및 변환이 완료되었을 때, 그 입력 DTO에 대한 보장이 만족됩니다.
Pipe에 대한 더 자세한 설명은 NestJS 공식 문서의 Pipes 부분을 참고하시길 바랍니다.
6-6. DTO의 Validation
이제 구체적으로 NestJS에서 DTO에 대한 Validation을 적용해 보도록 하겠습니다.
먼저, 앞서 설명한 것과 같이 NestJS에서는 Class로 선언된 DTO에 대해서 검사할 수 있는 패키지를 추천하고 있습니다. 다음 두 개의 패키지를 설치하도록 합니다.
$ yarn add class-validator class-transformer
`class-validator` 패키지는 검증(Validation)을 위한 패키지이며, `class-transformer` 패키지는 변환(Transformation)을 위한 패키지입니다. 두 개의 패키지를 이용하여, 우리는 DTO에 존재하는 데이터들을 검증하며, 또는 필요한 경우 변환까지도 수행할 수 있게 됩니다.
사실 본 글에서는 별도로 `class-transformer` 패키지의 사용에 대해서 설명하고 있지 않습니다. 이 패키지는 입력 DTO에 들어온 값의 타입을 변환하거나 그 주어진 값을 다른 값으로 변환하는 역할을 수행하기 때문에, 이곳에서 설명하고자 하는 검증 부분에는 이용되지 않아 설명이 포함되지 않았습니다. 단, 다양한 예시들에서 이를 같이 활용하기도 하기 때문에 존재를 알게 되었다고 생각하시면 좋습니다.
두 패키지 외에 이용해야 하는, 앞서 설명한 `ValidationPipe` 는 NestJS에서 기본적으로 제공하는 `@nestjs/common` 패키지에서 제공하고 있기 때문에 별도로 설치하지 않아도 됩니다.
이제 main.ts 파일에서 전역적으로 `ValidationPipe` 를 적용해 줍니다.
이제 위에서 생성했던 `CreateUserDto` 파일에 대하여 `class-validator` 패키지가 제공하는 몇가지 데코레이터(Decorator)를 적용해 보겠습니다. 데코레이터는 기존에 사용했던 `@Injectable` 이나 `@Body` 등 Class, Method, Parameters 등 단위에 대하여 함께 실행할 수 있는 코드 조각 기능 함수입니다.
위는 기존의 `CreateUserDto` 인데 닉네임과 이메일을 입력 받으면서도 해당 데이터 객체 내에 그 닉네임과 이메일이 존재하는지, 그리고 문자(String) 타입인지, 또는 지정된 닉네임이나 이메일 규칙을 따르는지 알 수가 없습니다. 실제로 앞에 나온 DTO 테스트에 대하여 닉네임을 넣지 않거나 또는 실행에 문제를 일으킬 수 있는 아무 값을 넣어보면 에러가 발생하는 것을 볼 수 있습니다.
이제 다음과 같은 Validation을 추가하여 DTO에 대한 검증이 이루어질 수 있도록 지정합니다.
다음과 같은 Validation이 적용되었습니다.
- DTO에 `nickname` 이 항상 존재하도록 `@IsNotEmpty` 데코레이터를 적용하였습니다.
- `nickname` 은 입력되었을 때, 문자 타입을 만족하도록 `@IsString` 데코레이터를 적용하였습니다.
- DTO에 `email` 이 항상 존재하도록 `@IsNotEmpty` 데코레이터를 적용하였습니다.
- 입력된 `email` 은 이메일 형식이 되도록 `@IsEmail` 데코레이터를 적용하였습니다.
`class-validator` 에서 제공하는 더 다양한 데코레이터들은 해당 패키지 Github 에서 확인할 수 있습니다.
위와 같이 정해진 규칙들을 적용함으로써, 이것들을 위배하는 입력 값이 DTO로 전달될 때, 앞서 전역적으로 설정한 `ValidationPipe` 에 의하여 검증이 이루어질 수 있게 됩니다.
실제로 다음과 같이 테스트해보면 에러를 확인할 수 있습니다.
테스트 결과 400 에러가 반환되면서 `email` 에 대하여 값이 필요하고 그것은 이메일이어야 한다고 알려주는 것을 확인할 수 있습니다. 이처럼 `ValidationPipe` 를 통해 Controller 실행 이전에 DTO에 대한 검사가 이루어지므로, 우리의 구현에 도착한 입력 DTO는 `nickname` 과 `email` 이 각 규칙을 만족하는 값일 것이라고 생각할 수 있게 됩니다.
6-7. DTO의 Validation 코드 예제
이제 이전 코드를 개선하며 Validation을 적용해 보겠습니다.
먼저, `createUser` 기능에 대하여 다음과 같이 `id` 를 무작위로 생성하여 저장하도록 하고, 조회 시 반환되는 출력 DTO에 `id` 를 포함하도록 변경합니다.
이를 통해 기능이 다음과 같이 변경되었습니다.
- `createUser` 는 닉네임과 이메일을 입력 받고, 생성된 유저의 ID, 이메일, 닉네임, 그리고 생일을 반환합니다.
- `getUserById` 는 ID를 입력 받고, 조회된 유저의 ID, 이메일, 닉네임, 그리고 생일을 반환합니다. (반환값 동일)
`UserService` 에서 반환하는 DTO가 생겼기 때문에 `UserController` 에서도 그대로 반환할 수 있도록 적용합니다.
여기서 주의할 점은 `createUser` 의 반환 DTO가 설정되도록 Return한 것뿐만 아니라, 기존 `getUser` 함수에 입력되는 `@Param` 에 대하여 "id" 값을 지정하던 것을 제거한 것을 볼 수 있습니다. `@Param` 은 내부에 입력된 지정 값이 있는 경우 해당 값만을 객체에서 취득하기 때문에, 입력되는 전체 값을 DTO 형태로 받아들이기 위해서는 해당 값 전체를 객체로 인식할 수 있도록 별도로 지정하지 않아야 합니다.
이제 기존 `getUserById` 의 입력 DTO에 대하여 Validation 데코레이터를 적용합니다.
조회할 때 입력할 `id` 는 존재해야 하며, 문자(String) 타입이어야 합니다.
이제 테스트를 통해 동작을 확인해 보겠습니다.
6-7-1. 유저 생성 (+ 입력하는 이메일이 이메일 형태가 아닌 경우)
입력 DTO에 전달되는 `email` 값이 이메일 형태를 띄지 않는 경우 앞서 확인한 것과 같이 이메일 검사 결과 에러가 반환됩니다.
6-7-2. 유저 생성
올바른 닉네임과 이메일을 입력하니, 그대로 저장되고 "671" ID가 무작위로 할당된 것을 확인할 수 있습니다.
6-7-3. 생성된 유저의 ID를 기반으로 조회
입력 DTO에 선언된 `id`에 할당되도록 Param 값을 "671"로 지정하여, 앞선 단계에서 생성한 유저를 조회하여 반환 받았습니다.
결론
본 글에서는 DTO(Data Transfer Object)의 필요성과 사용법, 그리고 DTO에 대한 검증 과정을 수행하기 위해 Pipe 적용 및 `class-validator` 패키지의 적용에 대해서 설명하였습니다. 정리하면 다음과 같습니다.
- 구현부에서 다루는 모델(Model)을 직접적으로 드러내기보다, 실제로 주고 받는데 필요한 데이터만을 정의하기 위해 DTO를 이용합니다. 이는 효율적, 보안적 측면에서 모두 유리합니다.
- DTO를 이용하게 되면 실질적으로 입력되는 입력 DTO에 대한 검증(Validation)이 동반되어야 합니다. 이를 통해, 실제 우리가 구현하는 구현부에 들어오게 된 입력값이 예측 가능한 범주에 있다는 것을 보장할 수 있게 됩니다.
- 반면, 입력 DTO에 설정된 검증을 위반하는 경우 `ValidationPipe` 를 통해 에러를 반환받게 됩니다.
우리는 이제 유저 서비스를 구현할 때에 DTO를 이용하여 데이터를 제어할 수 있고, Validation을 통해 올바른 데이터만을 실제 비즈니스 로직 구현부로 전달할 수 있게 되었습니다.
다음 글에서는 드디어 비즈니스 로직 내부로 들어가 데이터베이스(Database, DB)와 연동에 대해서 설명합니다. 이를 통해, 앞선 예제들에서 사용했던 유저 배열이 아니라, 유저 테이블에 유저를 저장함으로써 영속성(Persistence)을 가질 수 있게 됩니다.
읽어주셔서 감사합니다.
'개발자 💻 > NestJS' 카테고리의 다른 글
class-validator 의 @IsBoolean 데코레이터와 enableImplicitConversion 설정된 Transform 의 문제 (0) | 2024.02.29 |
---|---|
[초보자의 눈으로 보는 NestJS] 5. 유저 서비스의 구현 (0) | 2024.02.24 |
[초보자의 눈으로 보는 NestJS] 4. 유저 서비스의 정의와 의존성 주입 (1) | 2023.11.13 |
[초보자의 눈으로 보는 NestJS] 3+. API 테스트를 위한 Postman (0) | 2023.07.19 |
[초보자의 눈으로 보는 NestJS] 3. 모듈, 컨트롤러, 그리고 프로바이더 (0) | 2023.07.18 |