우리 스터디 "어쩌다 Nest" 에서는 스터디 리더님이 우아한 테크코스처럼 과제를 내주시고, 일주일 내로 과제를 완료해야 하는 활동을 하고 있다.
이번 주차는 바로 휴대폰 인증 API를 개발하는 것이었다.
휴대전화 번호를 인증번호를 전송하는 API
- 휴대전화 번호를 입력하면 인증번호가 전송된다고 가정한다.
- 인증번호은 인증 요청시간으로부터 5분간 유효하다고 가정한다.
- 인증번호 전송시에는 API 응답으로 인증번호를 리턴한다.
요청
phoneNumber : 010-1234-5678
code : 612131
휴대전화 번호와 인증번호를 입력받아 인증하는 API
요청
phoneNumber : 010-1234-5678
code : 612131
result : true
400 BadRequest 에러를 리턴해야한다.400 BadRequest 에러를 리턴해야한다.class-validator를 이용하여 검증한다.camelCase로 작성한다.일단 인증번호는 인증 요청 시간으로부터 5분간 유효하기 때문에 DB에 저장을 해놓아야 한다. DB에는 어떤 걸 저장해야 할까?
고려해야할 점은 다음과 같다.
위 사항을 고려해 설계한 엔티티는 아래와 같다.
@Entity()
export class PhoneVerify {
@PrimaryGeneratedColumn()
id: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column()
phoneNumber: string;
@Column()
verifyCode: string;
@Column({ default: false })
isVerified: boolean;
@Column()
expiredAt: Date;
}
TypeORM에서는 Active Record 패턴과 Data Mapper 패턴 둘 다 사용할 수 있다. 결론부터 말하자면, Active Record 패턴은 단순하고, 빠르게 개발할 수 있어 소규모 앱을 개발할 때 주로 사용하고, Data Mapper 패턴은 유지 보수에 더 용이하여, 대규모 앱을 개발할 때 주로 사용한다.
Active Record 패턴을 사용하면, model 내에서 모든 쿼리 메서드를 정의하고, model 내의 메서드를 사용해 save, remove, find 등의 작업을 할 수 있다. 한 마디로, 대부분의 Repository의 메서드를 통해 수행하던 작업을 model 내에서 수행하는 것이다.
TypeORM에서는 아래처럼 BaseEntity를 extends 하면 Active Record 패턴을 사용할 수 있다.
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from "typeorm"
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
firstName: string
@Column()
lastName: string
@Column()
isActive: boolean
}
// save
const user = new User()
user.firstName = "Timber"
user.lastName = "Saw"
user.isActive = true
await user.save()
// remove
await user.remove()
// find
const users = await User.find({ skip: 2, take: 5 })
const newUsers = await User.findBy({ isActive: true })
const timber = await User.findOneBy({ firstName: "Timber", lastName: "Saw" })
만약, 제공하는 메서드 외에 다른 기능을 추가하고 싶다면, 아래처럼 static 을 이용해 메서드를 추가하면 된다. 하지만 model 내에 모든 메서드를 정의해야 하다보니, 복잡한 쿼리 작업이 많아지면 그만큼 model 클래스가 커질 수 있기 때문에 프로젝트 규모가 커질 가능성이 있다면 추천하지 않는 패턴이다.
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
firstName: string
@Column()
lastName: string
@Column()
isActive: boolean
static findByName(firstName: string, lastName: string) {
return this.createQueryBuilder("user")
.where("user.firstName = :firstName", { firstName })
.andWhere("user.lastName = :lastName", { lastName })
.getMany()
}
}
Data Mapper 패턴을 사용하면 모든 쿼리 메서드를 repository 에서 정의하고, 사용한다. 즉 한 마디로 Active Record 패턴에서는 model 내에서 DB에 접근했다면, Data Mapper 패턴에서는 repository 에서 접근한다. 따라서 model과 DB의 의존성이 낮아져책임 분리가 뚜렷한 편이라, 유지 보수하기 용이하다고 할 수 있다.
아래처럼 BaseEntity를 extends 하지 않고 정의한다.
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
firstName: string
@Column()
lastName: string
@Column()
isActive: boolean
}
model이 아닌 따로 Repository의 쿼리 메서드를 호출한다.
const userRepository = dataSource.getRepository(User)
// save
const user = new User()
user.firstName = "Timber"
user.lastName = "Saw"
user.isActive = true
await userRepository.save(user)
// remove
await userRepository.remove(user)
// find
const users = await userRepository.find({ skip: 2, take: 5 })
const newUsers = await userRepository.findBy({ isActive: true })
const timber = await userRepository.findOneBy({
firstName: "Timber",
lastName: "Saw",
})
nanoid의 customAlphabet 의 코드를 이용해 구현했다.
export function generateNumericToken(
length: number = 6,
alphabet: string = '1234567890',
): string {
let id = '';
let i = length;
while (i--) {
id += alphabet[(Math.random() * alphabet.length) | 0];
}
return id;
}
random() 메서드를 이용해 alphabet 문자열에서 랜덤으로 id(변수)에 문자 추가 후 return서비스 내에 해당 로직 메서드를 추가해야 한다. 하지만, mapper를 사용해야만 할 것 같은데.. 레퍼런스가 없다. 일단은 mapper를 사용하지 않고 코드를 작성해보려고 한다.
일단 발급된 인증 코드는 5분 동안 유효하기 때문에 관련 로직이 필요하다. 하지만 5분은 상수로 관리하는 게 좋다. const 를 사용해 상수로 관리하자.
const VERIFY_CODE_VALID_TIME = 5;
async sendVerifyCode(
dto: VerifyCodeRequestDto,
): Promise<VerifyCodeResponseDto> {
const verifyCode = generateNumericToken();
const expiredDate = new Date();
expiredDate.setMinutes(expiredDate.getMinutes() + VERIFY_CODE_VALID_TIME);
const phoneVerify = new PhoneVerify();
phoneVerify.verifyCode = verifyCode;
phoneVerify.phoneNumber = dto.phoneNumber;
phoneVerify.expiredAt = expiredDate;
await this.phoneVerifyRepository.save(phoneVerify);
const response = new VerifyCodeResponseDto();
response.code = verifyCode;
return response;
}
인증 번호 전송이 실패할 경우, 사용자는 인증 번호를 수신하지 못한다. 따라서, 인증번호 재전송은 허용되어야 한다. 하지만 무제한으로 재전송해줄 수 없으니, 제한 횟수를 두자.
카카오를 확인해보면, 아래처럼 1일 인증 요청 횟수를 5회로 제한을 두고 있다. 대기업이니 따라가자.

NestJS에서는 throttler라는 패키지를 제공해 기본적으로 TTL 및 TTL 내 최대 요청 수를 설정할 수 있다. 더 공부해본 후에 적용할 예정이다.
휴대폰 번호와 인증 번호로 인증 요청을 할 때, 발생할 수 있는 경우는 다음과 같다.
DB 조회를 통해 처리할 수 있는 것들은 모두 쿼리 내에서 끝내도록 하자. 아래 해당되는 데이터를 조회한다면 자동적으로 유효성 검사가 동시에 이루어진다.
async verify(dto: PhoneVerifyRequestDto) {
const phoneVerification = await this.phoneVerifyRepository
.createQueryBuilder('pv')
.where('pv.phoneNumber = :phoneNumber', { phoneNumber: dto.phoneNumber })
.andWhere('pv.verifyCode = :verifyCode', { verifyCode: dto.verifyCode })
.andWhere('pv.isVerified = false')
.andWhere('pv.expiredAt > NOW()')
.orderBy({ createdAt: 'DESC' })
.getOne();
phoneVerification.isVerified = true;
if (!phoneVerification) {
throw new AuthFailedException(Messages.ERROR_AUTH_FAIL);
}
const response = new PhoneVerifyResponseDto();
response.result = true;
return response;
}
일단 전역 범위(어플리케이션 범위)에서 발생하는 HttpException을 catch 할 수 있도록 Filter를 하나 정의해야 한다.
@Catch(HttpException) 데코레이터를 이용해, HttpException 타입의 예외를 잡겠다고 선언해 놓는다.
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const errorResponse = new ErrorResponse(exception);
response.status(errorResponse.status).json(errorResponse.toJson());
}
}
그리고 이 Exception Filter를 모든 controller와 모든 route handler에서 사용할 수 있도록 전역 범위에설정한다.
async function bootstrap() {
initializeTransactionalContext();
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
}
현재, NestJS에서 제공하는 Exception을 그대로 사용하지 않고, 상속 받아 직접 정의한 Exception 클래스들을 사용하고 있는 중이다. custom exception에 이어서, 예외 발생 시 반환할 공통 response를 정의할 예정인데, 그 전에 HttpException 은 어떻게 response를 만드는 지 확인해보려고 한다.
아래처럼 UnauthorizedException 을 상속 받아, 401 에러를 발생시킬 예외 클래스AuthFailedException를 정의해놨다.
/* 401 Unauthorized */
export class AuthFailedException extends UnauthorizedException {
constructor(message?: string, code?: string) {
super(message ?? '인증에 실패하였습니다.', code ?? 'AUTH_FAILED');
}
}
지금 AuthFailedException의 생성자는 별도의 추가적인 로직 없이, UnauthorizedException 의 생성자를 호출하고 있다.
UnauthorizedException 클래스 또한 바로 부모 클래스인 HttpException클래스의 생성자를 호출하고 있다.
import { HttpException } from './http.exception';
import { HttpStatus } from '../enums/http-status.enum';
import { createHttpExceptionBody } from '../utils/http-exception-body.util';
export class UnauthorizedException extends HttpException {
constructor(message?: string | object | any, error = 'Unauthorized') {
super(
createHttpExceptionBody(message, error, HttpStatus.UNAUTHORIZED),
HttpStatus.UNAUTHORIZED,
);
}
}
import { isObject } from './shared.utils';
export const createHttpExceptionBody = (
message: object | string,
error?: string,
statusCode?: number,
) => {
if (!message) {
return { statusCode, error };
}
return isObject(message) && !Array.isArray(message)
? message
: { statusCode, error, message };
};
constructor(
private readonly response: string | Record<string, any>,
private readonly status: number,
private readonly options?: HttpExceptionOptions,
) {
super();
this.initMessage();
this.initName();
this.initCause();
}
결론적으로, 위에서 언급했던 AuthFailedException예외를 아래처럼 발생시킬 경우, filter에 잡히는 exception의 response는 다음과 같이 구성된다.
throw new AuthFailedException(Messages.ERROR_AUTH_FAIL);
{
"statusCode": 401,
"error": "AUTH_FAILED",
"message": "인증에 실패했습니다."
}
참고로 Messages.ERROR_AUTH_FAIL은 아래와 같다.
export const Messages = {
ERROR_AUTH_FAIL: '인증에 실패했습니다.',
};
일단 예외 발생 시 공통적으로 반환할 Error Response를 만들어야 한다.
export class ErrorResponse {
public status: number;
public code: string;
public message: string;
constructor(exception: HttpException) {
this.status = exception.getStatus();
this.code = exception.getResponse()['error'];
this.message = exception.getResponse()['message'];
}
}
사실 위의 HttpException이 만들어주는 response와 별다른 것이 없지만, 일단 전역 범위에서 발생하는 HttpException 을 제어하고, 이에 따른 response를 생성하고 있는 것만으로도 추후에 로깅 세팅 또는 추가적으로 제어할 항목이 생겼을 때 쉽게 추가할 수 있다.