휴대폰 인증 기능 구현하기 - NestJS

haaaalin·2023년 11월 26일

어쩌다 Nest

목록 보기
1/1
post-thumbnail

우리 스터디 "어쩌다 Nest" 에서는 스터디 리더님이 우아한 테크코스처럼 과제를 내주시고, 일주일 내로 과제를 완료해야 하는 활동을 하고 있다.
이번 주차는 바로 휴대폰 인증 API를 개발하는 것이었다.

과제 내용

기능 요구 사항

1. 010-1234-5678로 인증번호를 전송한다.

  • 휴대전화 번호를 인증번호를 전송하는 API
    - 휴대전화 번호를 입력하면 인증번호가 전송된다고 가정한다.
    - 인증번호은 인증 요청시간으로부터 5분간 유효하다고 가정한다.
    - 인증번호 전송시에는 API 응답으로 인증번호를 리턴한다.

  • 요청

phoneNumber : 010-1234-5678
  • 응답
code : 612131

2. 010-1234-5678로 전송된 인증번호를 입력하면 인증이 완료된다.

  • 휴대전화 번호와 인증번호를 입력받아 인증하는 API

  • 요청

phoneNumber : 010-1234-5678
code : 612131
  • 응답
result : true

공통 필수 예외처리 사항

  • API에 요청받은 Body 값의 타입을 검증하여 올바르지 않은 타입일 경우 400 BadRequest 에러를 리턴해야한다.
  • API에 요청받은 Body 값의 필수 값이 누락되거나/빈 값인 경우 400 BadRequest 에러를 리턴해야한다.

API 요청/응답 요구 사항

  1. 모든 API의 요청/응답은 DTO를 통해 TypeSafe하게 이루어져야한다.
  2. DTO의 타입은 class-validator를 이용하여 검증한다.
  3. DTO 내부 요소의 명칭은 camelCase로 작성한다.

과제 수행

프로젝트 구조

NestJS & Project Structure

Entity 설계

일단 인증번호는 인증 요청 시간으로부터 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;  
}
  • id, createdAt, updatedAt: 레코드의 필수 값
  • phoneNumber: 인증 번호를 요청했던 전화번호
  • verifyCode: 발급했던 인증 번호 (랜덤 생성 6자리)
  • isVerified: 인증 여부 (인증을 이미 완료한 인증 번호로 다시 인증하려는 시도를 막기 위함)
  • expiredAt: 인증 유효 만료 시점

[번외] TypeORM의 패턴, Active Record vs Data Mapper

TypeORM에서는 Active Record 패턴과 Data Mapper 패턴 둘 다 사용할 수 있다. 결론부터 말하자면, Active Record 패턴은 단순하고, 빠르게 개발할 수 있어 소규모 앱을 개발할 때 주로 사용하고, Data Mapper 패턴은 유지 보수에 더 용이하여, 대규모 앱을 개발할 때 주로 사용한다.

Active Record 패턴이란?

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 패턴이란?

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",
})

휴대전화 번호를 인증번호를 전송하는 API

인증 번호 생성 기능

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;  
}
  • 함수 인자: 생성하고자 하는 코드의 길이, 코드를 구성할 문자를 포함하고 있는 alphabet 문자열
  • 생성하고자 하는 코드 길이만큼 반복문을 수행
  • 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회로 제한을 두고 있다. 대기업이니 따라가자.

인증 요청 횟수 제한 걸기 (5회)

NestJS에서는 throttler라는 패키지를 제공해 기본적으로 TTL 및 TTL 내 최대 요청 수를 설정할 수 있다. 더 공부해본 후에 적용할 예정이다.

인증번호와 휴대폰 번호로 인증 API

예외 사항 고려해보기

휴대폰 번호와 인증 번호로 인증 요청을 할 때, 발생할 수 있는 경우는 다음과 같다.

  • 해당 휴대폰 번호와 일치하는 데이터가 없는 경우
  • 휴대폰 번호는 일치하지만, 입력한 인증 코드가 일치하지 않을 경우
  • 번호와 인증 코드 모두 일치하지만, 유효한 인증 코드 데이터가 아닐 경우
    - 이미 인증한 코드 데이터
    - 유효시간이 지나버린 데이터

휴대폰 인증 번호 데이터가 유효한 지 어디서 검사할까?

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;  
}

예외 처리하기

Exception Filter

일단 전역 범위(어플리케이션 범위)에서 발생하는 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());  
}

Custom Exception

현재, NestJS에서 제공하는 Exception을 그대로 사용하지 않고, 상속 받아 직접 정의한 Exception 클래스들을 사용하고 있는 중이다. custom exception에 이어서, 예외 발생 시 반환할 공통 response를 정의할 예정인데, 그 전에 HttpException 은 어떻게 response를 만드는 지 확인해보려고 한다.

💻 AuthFailedException

아래처럼 UnauthorizedException 을 상속 받아, 401 에러를 발생시킬 예외 클래스AuthFailedException를 정의해놨다.

/* 401 Unauthorized */  
export class AuthFailedException extends UnauthorizedException {  
  constructor(message?: string, code?: string) {  
    super(message ?? '인증에 실패하였습니다.', code ?? 'AUTH_FAILED');  
  }  
}
  • message: default -> "인증에 실패하였습니다"
  • code: default -> "AUTH_FAILED"

지금 AuthFailedException의 생성자는 별도의 추가적인 로직 없이, UnauthorizedException 의 생성자를 호출하고 있다.

💻 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,
    );
  }
}

💻 createHttpExceptionBody()

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 };
};
  • message가 object이고, array 형태가 아니라면, message 반환
  • 그 외의 경우엔 { statusCode, error, message} 반환

💻 HttpException의 생성자

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": "인증에 실패했습니다."
}
  • statusCode: custom exception 클래스가 상속 받은 부모 클래스 생성자에서 기본값으로 설정
  • code: custom exception 클래스가 설정해주는 기본값. 필요 시 설정해도 됨
  • message: 예외 throw 할 시에 넣어주는 인자값

참고로 Messages.ERROR_AUTH_FAIL은 아래와 같다.

export const Messages = {  
  ERROR_AUTH_FAIL: '인증에 실패했습니다.',  
};

공통 Error Response

일단 예외 발생 시 공통적으로 반환할 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'];  
  }  
}
  • status: HttpStatusCode
  • code: 해당 에러 코드 (ex. "AUTH_FAILED")
  • message: 에러 코드에 따른 에러 메시지 (ex. "인증에 실패했습니다.")

사실 위의 HttpException이 만들어주는 response와 별다른 것이 없지만, 일단 전역 범위에서 발생하는 HttpException 을 제어하고, 이에 따른 response를 생성하고 있는 것만으로도 추후에 로깅 세팅 또는 추가적으로 제어할 항목이 생겼을 때 쉽게 추가할 수 있다.

참고


profile
한 걸음 한 걸음 쌓아가자😎

0개의 댓글