Skip to content

[준섭] 1121(화) 개발기록 ‐ SignUpDto에 ClassValidator 적용

송준섭 edited this page Nov 21, 2023 · 1 revision

Validation Pipe 규칙을 위한 패키지 설치

$ yarn workspace server add class-validator class-transformer

class-validator 패키지의 데코레이터들

  1. @IsNotEmpty(): 값이 비어있지 않은지 확인합니다.
  2. @IsString(): 문자열인지 확인합니다.
  3. @IsInt(): 정수인지 확인합니다.
  4. @IsBoolean(): 부울(Boolean) 값인지 확인합니다.
  5. @IsEmail(): 이메일 형식인지 확인합니다.
  6. @IsEnum(): 열거형(enum) 값인지 확인합니다.
  7. @MinLength(): 최소 길이를 설정하여 문자열 타입의 값의 길이가 해당 값보다 큰지 확인합니다.
  8. @MaxLength(): 최소 길이를 설정하여 문자열 타입의 값의 길이가 해당 값보다 큰지 확인합니다.
  9. @Min(): 최소값을 설정하여 숫자 타입의 값이 해당 값보다 큰지 확인합니다.
  10. @Max(): 최대값을 설정하여 숫자 타입의 값이 해당 값보다 작은지 확인합니다.
  11. @IsOptional(): 값이 선택적인지 확인합니다.
  12. @IsNumber(): 숫자인지 확인합니다.
  13. @IsDate(): 날짜 형식인지 확인합니다.
  14. @IsArray(): 배열인지 확인합니다.
  15. @Length(): 문자열의 길이를 확인합니다.
  16. @IsUUID(): UUID 형식인지 확인합니다.
  17. @IsNotEmptyObject(): 빈 객체가 아닌지 확인합니다.
  18. @Matches(): 정규식 패턴에 맞는지 확인합니다.
  19. @IsFQDN: 완전한 도메인 이름인지 확인합니다.

class-transformer 패키지의 데코레이터들

class-transformer는 객체의 속성을 변환하거나 객체 간의 변환, 객체의 일부 속성을 숨기는 역할을 할 수 있음

  1. 객체의 속성 변환:
    • 객체의 특정 속성을 원하는 형식으로 변환합니다. 예를 들어, 날짜 형식의 문자열을 JavaScript Date 객체로 변환하거나, 반대로 Date 객체를 문자열로 변환하는 등의 작업을 할 수 있습니다.
  2. 객체 간의 변환:
    • 하나의 객체를 다른 형태의 객체로 변환합니다. 이는 DTO 객체로의 변환 또는 서로 다른 API 응답 형식으로의 변환이 될 수 있습니다.
  3. 객체의 일부 속성 숨기기:
    • 객체의 특정 속성을 숨기거나 선택적으로 노출합니다. 이를 통해 응답 객체에서 일부 정보를 제외하거나 가공할 수 있습니다.
import { Transform, Type } from 'class-transformer';

class User {
  name: string;

  @Transform(value => new Date(value)) // 문자열을 Date 객체로 변환
  createdAt: Date;

  @Type(() => Address) // Address 클래스로 객체 변환
  address: Address;
}

SignUpUserDto에 규칙 설정

// auth/dto/signup-user.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength, MaxLength, Matches } from 'class-validator';

export class SignUpUserDto {
	@ApiProperty({
		description: '로그인용 아이디',
		example: 'test user',
		required: true,
	})
	@IsNotEmpty()  // not null
	@IsString()  // string 타입이여야 함
	@MaxLength(50)  // 최대 길이 50
	@MinLength(4)  // 최소 길이 4
	@Matches(/^[a-zA-Z0-9]*$/, {  // 영어와 숫자로만 이루어져야 한다
        message: 'Password must contain only letters and numbers',
    })
	username: string;

	@ApiProperty({
		description: '비밀번호',
		example: 'test password',
		required: true,
	})
	@IsNotEmpty()
	@IsString()
	@MaxLength(100)  // 최대 길이 100
	@MinLength(8)  // 최소 길이 8
	password: string;

	@ApiProperty({
		description: '닉네임',
		example: 'test nickname',
		required: true,
	})
	@IsNotEmpty()
	@IsString()
	@MaxLength(50)  // 최대 길이 50
	@MinLength(2)  // 최소 길이 2
	@Matches(/^[a-zA-Z0-9-]+$/, {
		message: '닉네임은 영문자, 숫자, 한글만 사용할 수 있습니다.',
	})
	nickname: string;
}

Controller 로직에 Validation Pipe 적용

// auth/auth.controller.ts
@Post()
@UsePipes(ValidationPipe)  // 파이프 사용
signUp(@Body() signUpUserDto: SignUpUserDto): Promise<Partial<User>> {
    return this.authService.signUp(signUpUserDto);
}

nickname, username 중복 검사 후 저장

// auth/auth.service.ts
async signUp(signUpUserDto: SignUpUserDto): Promise<Partial<User>> {
	const { username, nickname } = signUpUserDto;
	await Promise.all([  // 미리 만들어 놓은 메서드 이용
		this.isAvailableNickname(nickname),
		this.isAvailableUsername(username),
	]);

	const salt = await bcrypt.genSalt();
	const hashedPassword = await bcrypt.hash(signUpUserDto.password, salt);

	const newUser = this.authRepository.create({
		...signUpUserDto,
		password: hashedPassword,
	});

	const createdUser: User = await this.authRepository.save(newUser);
	createdUser.password = undefined;

	return createdUser;
}

위에서 사용한 isAvailableNickname, isAvailableUsername 메서드는 미리 만들어 놓은 메서드로, 각각 nickname과 username에 대하여 중복된 user가 있으면 ConflictException을 발생시킨다.

Promise.all로 구현을 하고 이미 데이터베이스에 저장된 username, nickname을 담아서 signUp 테스트를 해보니 두 에러가 랜덤으로 번갈아 가면서 나온다.

Untitled

Untitled

Promise.all로 묶은 두 메서드 중 더 빨리 Exception이 발생한 쪽의 메세지가 응답되는 것이다.

커스텀 데코레이터로 코드 가독성 높이기

현재 SignUpUserDto의 프로퍼티들 위에 데코레이터들이 너무 많다고 느껴짐

너무 많다보니 가독성이 떨어진다고 느껴져서 커스텀 데코레이터로 데코레이터 수를 조금 줄이고자 함

다른 곳에서도 반복적으로 사용되는 IsNotNull, IsString은 두고 길이, 정규표현식에 대해서만 건들고자 함

// auth/decorators/signup-constraints.decorator.ts
import {
	registerDecorator,
	ValidationOptions,
	ValidatorConstraint,
	ValidatorConstraintInterface,
} from 'class-validator';

// username에 대한 제약조건
@ValidatorConstraint({ name: 'isUsername', async: false })
export class IsUsernameConstraint implements ValidatorConstraintInterface {
	validate(username: string) {
		const isEngAndNum = /^[a-zA-Z0-9]+$/.test(username);  // 영어 또는 숫자로만 이루어져야 함
		const checkLength = username.length >= 4 && username.length <= 50;  // 길이는 4~50
		return isEngAndNum && checkLength;
	}

	defaultMessage() {  // validate 리턴값이 false일 경우 출력되는 메세지
		return '아이디는 영문자와 숫자로 이루어진 4~50자여야 합니다.';
	}
}

// 위의 Validator를 데코레이터로 만들어줌
export function IsUsername(validationOptions?: ValidationOptions) {
	return function (object: Object, propertyName: string) {  
		// Object는 데코레이터가 적용되는 클래스의 인스턴스. propertyName은 데코레이터가 적용되는 프로퍼티의 이름
		registerDecorator({  // 데코레이터로 등록
			target: object.constructor,  // SignUpUserDto 클래스의 생성자가 타겟(생성자에 validate 로직이 들어가는 듯 하다)
			propertyName: propertyName,  // username 프로퍼티
			options: validationOptions,  // 유효성 검사 시 사용될 추가적인 옵션들
			constraints: [],  // 추가적인 제약 조건
			validator: IsUsernameConstraint,  // 위에서 구현한 validator 클래스
		});
	};
}

위 데코레이터는 @IsUsername() 이렇게 쓰이지만 만약 @MinLength(4)처럼 추가적인 옵션을 더 받아오고 싶다면 validate 생성자에 args: ValidationArguments와 같은 파라미터를 넣어주면 된다.

예를 들어 @IsUsername(option1, option2) 이런 식으로 파라미터를 넘겨준다고 하면 아래와 같이 받아올 수 있다.

@ValidatorConstraint({ name: 'isUsername', async: false })
export class IsUsernameConstraint implements ValidatorConstraintInterface {
	validate(username: string, args: ValidateArguments) {  // args를 받아온다.
		args[0] // option1
		args[1] // option2

		// 생략 ..

	}

	defaultMessage(args: ValidationArguments) {
    const [option1, option2] = args.constraints;
    return `${option1}, ${option2}`;
  }
}

SignUp관련 Enum 추가

위 과정에서 문자열이나 숫자형 등을 하드코딩한 부분이 마음에 들지 않아서 Enum을 추가했다.

// auth/enums/signup.enum.ts
export enum SignUpEnum {
	MIN_USERNAME_LENGTH = 4,
	MAX_USERNAME_LENGTH = 50,
	MIN_PASSWORD_LENGTH = 8,
	MAX_PASSWORD_LENGTH = 100,
	MIN_NICKNAME_LENGTH = 2,
	MAX_NICKNAME_LENGTH = 50,

	USERNAME_NOTEMPTY_MESSAGE = '아이디는 필수 입력값입니다.',
	PASSWORD_NOTEMPTY_MESSAGE = '비밀번호는 필수 입력값입니다.',
	NICKNAME_NOTEMPTY_MESSAGE = '닉네임은 필수 입력값입니다.',

	USERNAME_ISSTRING_MESSAGE = '아이디는 문자열이어야 합니다.',
	PASSWORD_ISSTRING_MESSAGE = '비밀번호는 문자열이어야 합니다.',
	NICKNAME_ISSTRING_MESSAGE = '닉네임은 문자열이어야 합니다.',

	VIOLATE_USERNAME_MESSAGE = `아이디는 영문자와 숫자로 이루어진 ${MIN_USERNAME_LENGTH}~${MAX_USERNAME_LENGTH}자여야 합니다.`,
	VIOLATE_PASSWORD_MESSAGE = `비밀번호는 ${MIN_PASSWORD_LENGTH}~${MAX_PASSWORD_LENGTH}자여야 합니다.`,
	VIOLATE_NICKNAME_MESSAGE = `닉네임은 영문자, 숫자, 한글로 이루어진 ${MIN_NICKNAME_LENGTH}~${MAX_NICKNAME_LENGTH}자여야 합니다.`,
}
// signup-user.dto.ts
import ...

export class SignUpUserDto {
	@ApiProperty({
		description: '로그인용 아이디',
		example: 'test user',
		required: true,
	})
	@IsNotEmpty({ message: SignUpEnum.USERNAME_NOTEMPTY_MESSAGE as string })
	@IsString({ message: SignUpEnum.USERNAME_ISSTRING_MESSAGE as string })
	@IsUsername()
	username: string;

	@ApiProperty({
		description: '비밀번호',
		example: 'test password',
		required: true,
	})
	@IsNotEmpty({ message: SignUpEnum.PASSWORD_NOTEMPTY_MESSAGE as string })
	@IsString({ message: SignUpEnum.PASSWORD_ISSTRING_MESSAGE as string })
	@IsPassword()
	password: string;

	@ApiProperty({
		description: '닉네임',
		example: 'test nickname',
		required: true,
	})
	@IsNotEmpty({ message: SignUpEnum.NICKNAME_NOTEMPTY_MESSAGE as string })
	@IsString({ message: SignUpEnum.NICKNAME_ISSTRING_MESSAGE as string })
	@IsNickname()
	nickname: string;
}
// signup-constraints.decorator.ts
import ...

@ValidatorConstraint({ name: 'isUsername', async: false })
export class IsUsernameConstraint implements ValidatorConstraintInterface {
	validate(username: string): boolean {
		const isEngAndNum = /^[a-zA-Z0-9]+$/.test(username);
		const checkLength =
			username.length >= SignUpEnum.MIN_USERNAME_LENGTH &&
			username.length <= SignUpEnum.MAX_USERNAME_LENGTH;
		return isEngAndNum && checkLength;
	}

	defaultMessage(): string {
		return SignUpEnum.VIOLATE_USERNAME_MESSAGE;
	}
}

// 생략 ..

@ValidatorConstraint({ name: 'isPassword', async: false })
export class IsPasswordConstraint implements ValidatorConstraintInterface {
	validate(password: string): boolean {
		return (
			password.length >= SignUpEnum.MIN_PASSWORD_LENGTH &&
			password.length <= SignUpEnum.MAX_PASSWORD_LENGTH
		);
	}

	defaultMessage(): string {
		return SignUpEnum.VIOLATE_PASSWORD_MESSAGE;
	}
}

// 생략 ..

@ValidatorConstraint({ name: 'isNickname', async: false })
export class IsNicknameConstraint implements ValidatorConstraintInterface {
	validate(nickname: string): boolean {
		const isEngAndNumAndKor = /^[a-zA-Z0-9-]+$/.test(nickname);
		const checkLength =
			nickname.length >= SignUpEnum.MIN_NICKNAME_LENGTH &&
			nickname.length <= SignUpEnum.MAX_NICKNAME_LENGTH;
		return isEngAndNumAndKor && checkLength;
	}

	defaultMessage(): string {
		return SignUpEnum.VIOLATE_NICKNAME_MESSAGE;
	}
}

// 생략 ..

만약 조건이나 메세지가 바뀌더라도 enum만 수정을 하면 되니 잘 바꾼 것 같다!

소개

규칙

학습 기록

[공통] 개발 기록

[재하] 개발 기록

[준섭] 개발 기록

회의록

스크럼 기록

팀 회고

개인 회고

멘토링 일지

Clone this wiki locally