몇년전부터 깊은 추상화 레벨의 아키텍처가 유행하고, nest.js의 등장으로 node.js 씬에도 각 레이어별로 클래스를 나누어 개발하는 방식이 보편화 되었지만, 레이어 구조만 나뉘어진 채로 세부 로직은 여전히 절차적인 코드의 형태로 개발하는 프로젝트가 많은 것 같습니다.
레이어를 나누는 것도 물론 중요하지만 그 이전에 메서드 내부의 로직을 어떻게 잘 관리하는지, 각 메서드가 어떻게 적은 책임만 가질 수 있도록 로직을 잘 분리할 수 있는지 를 먼저 고민해야 한다고 생각합니다.
해당 포스트에서는 명세(Specification) 패턴
을 통해 아키텍처의 추상화 레벨에 상관없이 복잡한 도메인 유효성 검증 로직을 효율적으로 관리하는 방법에 대해 예제를 통해 이야기하고자 합니다.
※ 여기서 설명하는 유효성 검증은 프레젠테이션 레이어 레벨의 요청 객체에 대한 검증이 아닌
비즈니스 룰
에 대한 검증을 의미합니다.
해당 포스트의 전체 코드 예제는 여기 에서 확인하실 수 있습니다.
- 이메일 중복 불가
- 사용자 닉네임 중복 불가
- 비밀번호 복잡도
- 최소 길이 8자
- 적어도 하나의 대문자 포함
- 적어도 하나의 소문자 포함
- 적어도 하나의 숫자 포함
- 적어도 하나의 특수문자 포함
- 이메일이 금지된 도메인('example.com', 'test.com') 인 경우 가입 불가
- 어드민 인 경우 허가된 도메인('june.io')으로 된 이메일만 가입 가능
export class UserService {
constructor(private readonly userRepository: IUserRepository) {}
async create(email: string, username: string, password: string, role: Role): Promise<User> {
// 이메일 중복 확인
const existingUserByEmail = await this.userRepository.findUserByEmail(email);
if (existingUserByEmail) {
throw new BadRequestException('Email is already in use.');
}
// 사용자 이름 중복 확인
const existingUserByUsername = await this.userRepository.findUserByUsername(username);
if (existingUserByUsername) {
throw new BadRequestException('Username is already in use.');
}
// 비밀번호 복잡성 검사
const minLength = 8;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (password.length < minLength || !hasUpperCase || !hasLowerCase || !hasNumber || !hasSpecialChar) {
throw new BadRequestException('Password does not meet complexity requirements.');
}
// 금지된 이메일 도메인 확인
const bannedDomains = ['example.com', 'test.com'];
const domain = email.split('@')[1];
if (bannedDomains.includes(domain)) {
throw new BadRequestException('Email domain is not allowed.');
}
// 어드민 인 경우 허가된 도메인으로 된 이메일만 가입 가능
if (role == Role.ADMIN) {
const domain = email.split('@')[1];
if (domain !== 'june.io') {
throw new BadRequestException('Email domain is not allowed.(ADMIN)');
}
}
// 새로운 사용자 생성 및 저장
const user = new User();
user.email = email;
user.username = username;
user.password = password; // 실제로는 해싱된 비밀번호 저장 필요
user.role = Role;
await this.userRepository.save(user);
return user;
}
}
아마 위의 코드가 요구사항을 만족시키기 위해 많이 볼 수 있는 레거시스러운(?) 접근법일것입니다. 이런 함수는 하나의 함수에 너무 많은 로직을 포함하고 있어 가독성이 떨어지는 것은 물론이고 해당 로직들을 다른 곳에서도 필요로 하는 경우 중복 로직이 발생할 수 있습니다. 또한 규칙이 변경되면 모든 유효성 검증 로직을 찾아다니며 수정을 해야합니다. 당연히 로직을 유닛테스트 하기에도 매우 까다로운 형태입니다.
명세 패턴은 비즈니스 규칙을 캡슐화하여 검증 로직을 보다 명확하고 재사용 가능하게 만드는 디자인 패턴입니다. 이를 통해 복잡한 검증 로직을 간결하게 표현하고 조합할 수 있습니다.
ISpecification
은 모든 specification 클래스가 구현해야하는 메서드를 정의합니다.export interface ISpecification<T> {
isSatisfiedBy(candidate: T): Promise<boolean> | boolean;
and(other: ISpecification<T>): ISpecification<T>;
or(other: ISpecification<T>): ISpecification<T>;
not(): ISpecification<T>;
}
CompositeSpecification
추상 클래스는 명세들의 공통 기능을 구현하며, 구체적인 명세 클래스가 이를 상속받습니다.import { ISpecification } from './specification.interface';
export abstract class CompositeSpecification<T> implements ISpecification<T> {
public abstract isSatisfiedBy(candidate: T): Promise<boolean> | boolean;
public and(other: ISpecification<T>): ISpecification<T> {
return new AndSpecification<T>(this, other);
}
public or(other: ISpecification<T>): ISpecification<T> {
return new OrSpecification<T>(this, other);
}
public not(): ISpecification<T> {
return new NotSpecification<T>(this);
}
}
AndSpecification
클래스는 두 명세를 결합하여 둘 다 만족하는 경우를 검증합니다.import { ISpecification } from './specification.interface';
import { CompositeSpecification } from './composite.specification';
export class AndSpecification<T> extends CompositeSpecification<T> {
constructor(
private left: ISpecification<T>,
private right: ISpecification<T>
) {
super();
}
public async isSatisfiedBy(candidate: T): Promise<boolean> {
return (
(await this.left.isSatisfiedBy(candidate)) &&
(await this.right.isSatisfiedBy(candidate))
);
}
}
OrSpecification
클래스는 두 명세를 결합하여 하나라도 만족하는 경우를 검증합니다.import { ISpecification } from './specification.interface';
import { CompositeSpecification } from './composite.specification';
export class OrSpecification<T> extends CompositeSpecification<T> {
constructor(
private left: ISpecification<T>,
private right: ISpecification<T>
) {
super();
}
public async isSatisfiedBy(candidate: T): Promise<boolean> {
return (
(await this.left.isSatisfiedBy(candidate)) ||
(await this.right.isSatisfiedBy(candidate))
);
}
}
NotSpecification
클래스는 명세를 부정하여 만족하지 않는 경우를 검증합니다.
import { ISpecification } from './specification.interface';
import { CompositeSpecification } from './composite.specification';
export class NotSpecification<T> extends CompositeSpecification<T> {
constructor(private specification: ISpecification<T>) {
super();
}
public async isSatisfiedBy(candidate: T): Promise<boolean> {
return !(await this.specification.isSatisfiedBy(candidate));
}
}
/**
* 이메일 중복 검증 명세
*/
@Injectable()
export class UniqueEmailSpecification extends CompositeSpecification<User> {
constructor(private userRepository: UserRepository) {
super();
}
public async isSatisfiedBy(candidate: User): Promise<boolean> {
const user = await this.userRepository.findOneByEmail(candidate.email);
if (!!user)
throw new CoreException('USER:ALREADY_EXISTS', 'User already exists');
return !user;
}
}
/**
* 닉네임 중복 검증 명세
*/
@Injectable()
export class UniqueNicknameSpecification extends CompositeSpecification<User> {
public constructor(private userRepository: UserRepository) {
super();
}
public async isSatisfiedBy(candidate: User): Promise<boolean> {
const user = await this.userRepository.findOneByNickname(
candidate.nickname,
);
if (!!user)
throw new CoreException('USER:ALREADY_EXISTS', 'User already exists');
return !user;
}
}
/**
* 비밀번호 복잡성 검증 명세
*/
@Injectable()
export class PasswordComplexitySpecification extends CompositeSpecification<User> {
passwordRegex =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
public isSatisfiedBy(candidate: User): boolean {
const isPasswordValid = this.passwordRegex.test(candidate.password);
if (!isPasswordValid) {
throw new CoreException('USER:INVALID_PASSWORD', 'Invalid password');
}
return isPasswordValid;
}
}
/**
* 금지된 이메일 도메인 검증 명세
*/
@Injectable()
export class BannedEmailDomainSpecification extends CompositeSpecification<User> {
private BANNED_DOMAINS = ['example.com', 'test.com'];
public async isSatisfiedBy(candidate: User): Promise<boolean> {
const domain = candidate.email.split('@')[1];
if (this.BANNED_DOMAINS.includes(domain)) {
throw new CoreException(
'USER:BANNED_EMAIL_DOMAIN',
'Banned email domain',
);
}
return true;
}
}
/**
* 어드민 계정 이메일 도메인 검증 명세
*/
export class AdminEmailDomainSpecification extends CompositeSpecification<User> {
private readonly ADMIN_DOMAIN = 'june.io';
public isSatisfiedBy(candidate: User): boolean {
const domain = candidate.email.split('@')[1];
if (candidate.role === Role.ADMIN && domain !== this.ADMIN_DOMAIN) {
throw new CoreException(
'USER:INVALID_ADMIN_EMAIL_DOMAIN',
'Invalid admin email domain',
);
}
return true;
}
}
각 명세 클래스들을 조합하여 유저 생성에 필요한 전체 명세를 담당하는 클래스를 정의합니다.
@Injectable()
export class UserCreationSpecification extends CompositeSpecification<User> {
public constructor(
private readonly bannedEmailDomainSpecification: BannedEmailDomainSpecification,
private readonly adminEmailDomainSpecification: AdminEmailDomainSpecification,
private readonly passwordComplexitySpecification: PasswordComplexitySpecification,
private readonly uniqueNicknameSpecification: UniqueNicknameSpecification,
private readonly uniqueEmailSpecification: UniqueEmailSpecification,
) {
super();
}
public isSatisfiedBy(candidate: User): boolean | Promise<boolean> {
return this.passwordComplexitySpecification
.and(this.uniqueEmailSpecification)
.and(this.uniqueNicknameSpecification)
.and(this.adminEmailDomainSpecification)
.and(this.bannedEmailDomainSpecification)
.isSatisfiedBy(candidate);
}
}
최종적으로 만들어진 유저 생성에 필요한 전체 명세를 담당하는 클래스(UserCreationSpecification) 를 서비스에서 주입받아 사용합니다.
@Injectable()
export class UserService {
public constructor(
private readonly userRepository: UserRepository,
private readonly userCreationSpecification: UserCreationSpecification,
) {}
public async create(user: User): Promise<User> {
const satisfied = await this.userCreationSpecification.isSatisfiedBy(user);
if (!satisfied) {
throw new CoreException(
'USER:UNKNOWN_CREATE_ERROR',
'User creation failed',
);
}
return await this.userRepository.save(user);
}
}
결과적으로 복잡한 검증 로직이 서비스에서 분리되어 서비스의 함수가 간결해졌고, 해당 명세들을 재조합하거나 추가하여 여러 비즈니스 상황에 대응하기 쉬워졌고, 반복되는 로직을 재사용하기 쉬워졌습니다.
또한 유닛테스트도 각 명세를 담당하는 클래스 별로 진행할 수 있어 테스트 용이성도 증가했습니다.
명세 패턴을 사용하여 유효성 검증 로직을 구현하면 복잡한 비즈니스 규칙을 명확하고 재사용 가능하게 표현할 수 있습니다. NestJS의 모듈화된 구조와 의존성 주입 기능을 활용하면, 각 명세 클래스를 독립적으로 관리하고 필요한 곳에서 쉽게 조합할 수 있습니다. 이를 통해 코드는 더욱 유지보수하기 쉬워지고, 새로운 검증 로직을 추가하거나 기존 로직을 수정할 때 발생할 수 있는 부작용을 최소화할 수 있습니다.
이번 포스트에서는 이메일 중복 확인, 사용자 이름 중복 확인, 비밀번호 복잡성 검증, 금지된 이메일 도메인 검증, 관리자 이메일 도메인 검증 등의 다양한 명세를 체이닝하여 유저 생성 시의 유효성 검증을 구현했습니다. 이러한 접근 방식은 NestJS 뿐만 아니라 다른 프레임워크나 라이브러리에서도 유효성 검증 로직을 관리하는 데 유용하게 적용될 수 있습니다.
명세 패턴을 사용하면 검증 로직을 보다 명확하게 정의하고, 테스트하기 쉬운 작은 단위로 분리할 수 있습니다. 이를 통해 애플리케이션의 안정성과 유지보수성을 크게 향상시킬 수 있습니다. 앞으로 프로젝트에서 유효성 검증이 필요한 상황이 발생하면, 명세 패턴을 활용하여 보다 효율적이고 효과적인 검증 로직을 구현해 보시길 바랍니다.