[간병인 프로젝트 리팩토링] 휴대폰 인증 발송( NestJS )

윤학·2023년 7월 25일
0

간병인 프로젝트

목록 보기
1/8
post-thumbnail

졸업작품당시 진행했었던 프로젝트를 계속해서 리팩토링을 하고있다.

원래는 하나의 Api가 완료되면 변경된 로직에 대해서 블로그를 작성하려 했지만 하나만 더 하나만 더 하다가 4개정도를 완료하고 첫 글을 작성하게 되었는데

기존코드를 보고 프론트엔드코드까지 변경하다보니 어느정도 시간이 드는 것 같다.

리팩토링은 다음과 같은 기준을 가지고 수행하고 있다.

  • 기존에 작성했던 Api에 맞춰서 프론트엔드코드의 수정을 최대한 줄인다.
  • 관심사를 분리해보고 하나의 클래스는 하나의 책임만 가지도록 한다.
  • 변경된 코드의 테스트를 위해 유닛테스트를 최대한 작성한다.

그럼 휴대폰 인증을 수행하던 코드가 어떻게 변경되었는지 살펴보자.

첫 주제로 휴대폰 인증을 선택한 이유는 아래와 같이 로그인이 휴대폰 인증을 통해 이루어지기 때문에 로그인과 회원가입 시 먼저 수행되기 때문이다.

그 중에서도 이번편에서는 인증 코드 발송부분을 먼저 살펴보자.

로직 설명

전체적인 로직은 Naver Cloud Api를 통해 인증 코드를 발송하는데 일일 발송 횟수를 제한했기에 검사하는 로직을 사전에 거치게 된다.

로그인 시 발송

새로운 사용자면 인증이 완료된 채로 회원가입 창으로 이동시키기 위해 코드를 발송하고 사용자의 가입 여부를 반환한다.

기존 코드

auth.controller.ts

	//로그인 아이디 인증 요청
    @Get('login/:id')
    async loginAuthId(@Param('id') id: string): Promise<{ status: string, message: string }> {

        const dayCount = await this.sendService.checkDayCount(id);

        //해당 아이디에 대해서 일일 문자 인증 횟수가 남았는데
        if (dayCount['status'] === 'remain') {
            const findResult = await this.userService.findId(id);
            //기존 회원이면
            if (findResult) {
                return await this.sendService.sms(id, 'exist');
            }
            //새로운 회원일경우
            else {
                return await this.sendService.sms(id, 'newuser');
            }
        }
    }

일일 발송했던 횟수를 Controller에서 SendService의 checkDayCount 메소드를 호출해 받아오고 sms 메소드를 통해 인증 코드를 발송하면서 사용자 가입 여부를 반환해주도록 작성했다.

send.service.ts

	//일일 최대 문자 인증 횟수 체크
    async checkDayCount(id: string): Promise<{ status: string }> {

        let key: string;

        if (id.includes('@'))
            key = id + 'emailcount';
        else
            key = id + 'smscount';

        const dayCount: string = await this.redis.get(`${key}`);
        //처음으로 보내는거면
        if (dayCount === null) {
            await this.redis.set(`${key}`, 1, {
                EX: 86400,
            })
            return { status: 'remain' };
        }
        //보낸 횟수가 4번 이하인경우
        else if (parseInt(dayCount) <= 2) {
            await this.redis.incr(`${key}`);
            return { status: 'remain' };
        }
        //이미 보낸횟수가 5번인 경우 
        else {
            const key: string = id + 'blocktime';
            const blocktime: string = await this.redis.get(`${key}`);
            //5번째에서 시도한 경우
            if (blocktime === null) {
                const today: Date = new Date();
                today.setSeconds(today.getSeconds() + 86400);
                const newBlockTime = getNewBlockTime(today);
                await this.redis.set(`${key}`, `${newBlockTime}`, {
                    'EX': 86400
                });
                throw new HttpException(
                    `횟수 초과, ${newBlockTime}이후에 다시 시도해 주세요`,
                    HttpStatus.FORBIDDEN
                );
            }
            throw new HttpException(
                `횟수 초과, ${blocktime}이후에 다시 시도해 주세요`,
                HttpStatus.FORBIDDEN
            );
        }
    }
    
        //문자발송 서비스
    async sms(id: string, state: string): Promise<{ status: string, message: string }> {

        const accessKey = this.configService.get<string>('naver.sms.accessKey');
        const secretKey = this.configService.get<string>('naver.sms.secretKey');
        const serviceId = this.configService.get<string>('naver.sms.serviceId');
        const myPhoneNumber = this.configService.get<string>('naver.sms.myPhoneNumber');
        const timestamp = Date.now().toString();
        const signature = this.makeSignature(secretKey, accessKey, timestamp, serviceId);
        const authCode = Math.floor(Math.random() * 900000 + 100000);

        await axios({
            method: 'POST',
            url: `https://sens.apigw.ntruss.com/sms/v2/services/${serviceId}/messages`,
            headers: {
                'Content-Type': 'application/json; charset=utf-8',
                'x-ncp-apigw-timestamp': timestamp,
                'x-ncp-iam-access-key': accessKey,
                'x-ncp-apigw-signature-v2': signature,
            },
            data: {
                type: 'SMS',
                contentType: 'COMM',
                conturyCode: '82',
                from: myPhoneNumber,
                content: `인증번호 [${authCode}]를 입력해주세요.`,
                messages: [
                    {
                        to: id
                    }
                ]
            },
        }).then(async (res) => {
            //문자 발송에 성공했으면 인증번호 저장
            await this.redis.set(`${id}`, `${authCode}`, {
                'EX': 90
            });
            const tryBlockKey: string = id + 'tryblock'; //인증번호 일치 시도 횟수 초과여부
            const tryCountKey: string = id + 'trycount'; //인증번호 일치 시도 횟수

            await this.redis.del(`${tryBlockKey}`); //그 전 기록 삭제
            await this.redis.del(`${tryCountKey}`);
        })
            .catch((err) => {
                throw new HttpException(
                    '네트워크 오류로 문자 발송에 실패했습니다',
                    HttpStatus.INTERNAL_SERVER_ERROR
                );
            })

        return { status: state, message: '인증번호를 1분 30초안에 입력해주세요.' };
    }

모든 로직을 SendService내에서 수행하고 있기 때문에 처음 코드를 봤을 때 굉장히 가독성이 떨어졌었다.

그럼 하나씩 변경해보자.

리팩토링 코드

1. 휴대폰 번호 유효성 검사

기존 코드에는 없었던 휴대폰 번호의 유효성검사로 class-transformer을 적용했었는데 guard를 통해 일일 발송 횟수를 검사해서 pipe보다 먼저 실행되는 guard의 순서때문에 middleware로 변경했다.

phone-validator.middleware.ts

export function phoneValidate(req: Request, res: Response, next: NextFunction) {
    const { phoneNumber } = req.body;
    /* 휴대폰 형식 검사이후 400에러 */
    if( !phoneRegExp.test(phoneNumber) )
        throw new BadRequestException(ErrorMessage.PhoneNumberFormat);
    next();
}

단순히 정규표현식을 통해 검사만 수행하기 때문에 함수형 미들웨어로 만들었다.

그리고 일일 발송 횟수 guard가 적용되는 로그인, 회원가입 router에 적용해보자.

auth.module.ts

export class AuthModule implements NestModule{
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(phoneValidate)
      .forRoutes(
        { path: 'auth/register', 'method': RequestMethod.POST },
        { path: 'auth/login', 'method': RequestMethod.POST }
      )
  }
}

2. 휴대폰 일일 발송 횟수 Guard

router에 도착하기 전에 제한된 사용자를 막는 것이 맞다고 생각해서 횟수를 체크하는 로직을 guard에 작성했다.

authentication-send.guard.ts

/* 휴대폰 인증 일일 발송 횟수 제한 */
@Injectable()
export class PhoneAuthenticationSendGuard implements CanActivate {
    constructor(
        private readonly verificationUsageService: VerificationUsageService
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const { phoneNumber } = context.switchToHttp().getRequest().body;
        const phoneVerificationUsage = await this.verificationUsageService.getPhoneUsageHistory(phoneNumber);

        if( phoneVerificationUsage )
            phoneVerificationUsage.dayAttempCheck(); // 일일 인증 횟수 체크

        return true;
    }
}

guard에서는 VerificationUsageService를 통해 해당 전화번호의 사용내역을 받아와서 존재한다면 횟수를 체크하고 존재하지 않는다면 해당 날짜의 첫 발송이기 때문에 guard를 통과시켰다.

일일 사용횟수를 초과했다면 403에러를 던지도록 하였다.

phone-verification-usage.entity.ts

export class PhoneVerificationUsage {
    private readonly MAX_DAILY_SEND_LIMIT = 5;
    
    private dayAttemp: number;

    private codeAttemp: number;

    constructor(dayAttemp: number = 0, codeAttemp: number = 0) {
        this.dayAttemp = dayAttemp;
        this.codeAttemp = codeAttemp;
    }

    /* 일일 가능횟수 */
    dayAttempCheck(): void {
        if( this.dayAttemp == this.MAX_DAILY_SEND_LIMIT )
            throw new ForbiddenException(ErrorMessage.ExceededPhoneDailyLimit);
    };

    /* 사용내역 추가 */
    addHistory() {
        this.dayAttemp += 1;
        this.resetCodeAttemp();
    };

    /* 새로 인증번호를 발송하면 이전 시도횟수 초기화 */
    private resetCodeAttemp(): void { this.codeAttemp = 0; };
};

verification-usage.service.ts

@Injectable()
export class VerificationUsageService {
    constructor(
        private readonly phoneVerificationRepository: PhoneVerificationRepository
    ) {}

    /* 휴대폰인증 사용횟수 조회 */
    async getPhoneUsageHistory(phoneNumber: string): Promise<PhoneVerificationUsage> {
        return await this.phoneVerificationRepository.findByPhoneNumber(phoneNumber);
    };

사용 내역의 저장소로 redis를 사용해서 조회가 이루어질 때는 직렬화시켜 저장한 값을 파싱하고 인스턴스로 변환해서 넘겨주었다.

phone-verification.repository.ts

@Injectable()
export class PhoneVerificationRepository implements IPhoneVerificationRepository {
    constructor(
        @Inject('REDIS_CLIENT')
        private readonly redis: RedisClientType,
    ) {}
    
    async findByPhoneNumber(phoneNumber: string): Promise<PhoneVerificationUsage | null> {
        return this.parseToInstance( await this.redis.HGET(`phone:auth:${DateTime.getToday()}:usage`, phoneNumber) );
    };

    /* 문자열로 저장된 객체 파싱 후 인스턴스로 */
    private parseToInstance(serializedString: string): PhoneVerificationUsage {
        return plainToInstance(
            PhoneVerificationUsage,
            JSON.parse(serializedString)
        )
    }
}

redis를 사용한 이유는...

조회 속도의 차이도 있지만 저장해야 하는 모델의 사이즈도 작고, 초기화를 진행할 때 더 편할거라 생각하여 사용하게 되었다.

그럼 초기화는 어떻게?

매일 밤 자정에 전날의 사용내역을 초기화했는데 처음에는 HSCAN 명령어를 찾아 보았지만 초기화가 진행되는 동안 추가된 데이터가 다음 SCAN시 포함되어 삭제될 수 있겠다고 생각해서 제외했다.

BGSAVE와 트랜잭션 역시 아직 초기화 되지 않은 데이터를 읽을 수 있다고 생각하여 날짜별로 Key를 생성하고 Expire을 통해 해당 키의 만료시간을 정하는것으로 결정했다.

(다시 짜라고 한다면 Mysql로 짤 것 같다..)

그래서 자정에 실행되도록 설정한 Task를 보자.

task.service.ts

    /* 매일 자정 하루동안 유지되는 그날의 휴대폰 인증 사용량 Key 만료시간 설정 */
    @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
    async DailyPhoneAuthUsageExpiredTask() {
        const today = DateTime.getToday();

        /* Key가 없을 경우 임의의 값을 넣고 저장 후 만료시간 설정 */
        if( !await this.setDailyPhoneAuthUsageExpired(today) ) {
            await this.phoneVerificationUsageRepository.save('default', new PhoneVerificationUsage());
            await this.setDailyPhoneAuthUsageExpired(today);
        }
    };

    private async setDailyPhoneAuthUsageExpired(today: number): Promise<boolean> {
        return await this.redis.EXPIRE(`phone:auth:${today}:usage`, this.PHONE_AUTH_KEY_EXPIRED_TIME)
    }

EXPIRE 명령어가 설정하고자 하는 key가 존재하지 않으면 0을 반환한다고 하여 이런 경우에는 임의의 값을 넣고 key를 생성한 이후 설정했다.

3. 서비스 로직

그럼 이제 guard를 통과한 요청이 router에 도착할 것이다.

auth.controller.ts

    /* 로그인 */
    @Public()
    @UseGuards(PhoneAuthenticationSendGuard)
    @Post('login')
    async login(@Body('phoneNumber') phoneNumber: string): Promise<'newuser' | 'exist'> {
        return await this.authService.login(phoneNumber);
    }

JwtGuard를 전역으로 설정해놨기 때문에 먼저 실행되는 전역 guard를 @public 데코레이터로 패스시키고 문자 발송을 위해 service를 호출했다.

auth.service.ts

    /* 신규회원이면 인증에 성공하면 바로 회원가입창으로 이동 */
    async login(phoneNumber: string): Promise<'newuser' | 'exist'> {
        await this.sendPhoneAuthCode(phoneNumber); // 발송 이후 코드 저장
        if (await this.userAuthCommonService.checkExistingUserByPhone(phoneNumber)) return 'exist'; // 가입 사용자인지 체크
        return 'newuser';
    }

    /* 문자 발송 이후 발송된 코드 저장 */
    private async sendPhoneAuthCode(phoneNumber: string) {
        const authenticationCodeMessage = new AuthenticationCodeMessage(phoneNumber); // 메시지 생성
        await this.smsService.send(authenticationCodeMessage); // 문자 발송
        await Promise.all([
            this.verificationUsageService.addPhoneUsageHistory(phoneNumber), // 일일 휴대폰인증 1회 추가
            this.authenticationCodeService.addPhoneCode( // 발송된 인증코드 저장
                phoneNumber, authenticationCodeMessage.getAuthenticationCode().toString()
            )
        ]);
    }

순서를 간단하게 살펴보면
1. 인증 번호 문자를 생성한다.
2. Naver Cloud Api를 통해 발송한다.
3. 발송된 코드와 휴대폰 인증 사용 내역 추가하여 저장한다.
4. 가입된 유저인지에 따라 반환한다.

인증 번호 문자 객체에는 공통 속성을 가지고 있는 Message 객체를 만들고 상속 받아 인증 번호 문자에서만 필요한 내용을 담았다.

message.ts

export class Message {
    private receiver: string;
    protected content: string;
    constructor(to: string) { this.receiver = to; };

    getReceiver(): string { return this.receiver; };
    getContent(): string { return this.content; };
}

authentication-code-message.ts

export class AuthenticationCodeMessage extends Message {
    private authenticationCode: number;
    constructor(phoneNumber: string) { 
        super(phoneNumber); 
        /* 6자리 인증번호 생성 */
        this.authenticationCode = Math.floor(Math.random() * 900000 + 100000);
        this.content = `인증번호 [${this.authenticationCode}]를 입력해주세요.`
    };  

    getAuthenticationCode(): number { return this.authenticationCode; };
};

그리고 SmsService에서는 Naver Cloud Api를 호출하기 위한 코드들이 담겨있는 NaverSmsService를 만들어 호출하여 발송 로직을 완성하였다.

sms.service.ts

@Injectable()
export class SmsService implements ISMSService {
    constructor(
        private readonly naverSmsService: NaverSmsService
    ) {}

    async send(smsMessage: Message) {
        await this.naverSmsService.send(smsMessage);
    };
}

Naver Clooud Api형식으로 만드는 방법은 문서에 자세히 잘 나와있으니 참고하면 될 것 같다.

naver-sms.service.ts


    async send(message: Message) {
        this.requestNaverApi(message, Date.now().toString());
    };

    /* Naver Cloud Api */
    private requestNaverApi(message: Message, timeStamp: string) {
        axios({
            method: 'POST',
            url: this.url,
            headers: this.setHeaders(timeStamp),
            data: this.setData(message)
        })
        .catch(err => {
            throw new InternalServerErrorException('네트워크 오류로 문자 발송에 실패했습니다.')
        })
    };

    /* 요청에 보낼 Header 구성 */
    private setHeaders(timeStamp: string): AxiosRequestHeaders {
        return {
            'Content-Type': 'application/json; charset=utf-8',
            'x-ncp-apigw-timestamp': timeStamp,
            'x-ncp-iam-access-key': this.accessKey,
            'x-ncp-apigw-signature-v2': this.makeSignature(timeStamp),
        };
    };

    /* 요청에 보낼 Data 구성 */
    private setData(message: Message): any {
        return {
            type: 'SMS',
            contentType: 'COMM',
            conturyCode: '82',
            from: this.from,
            content: message.getContent(),
            messages:[{ to: message.getReceiver() }]
        }
    }

회원가입 시 발송

로그인 과정과 유사하지만 회원가입을 진행할 때는 이미 가입된 유저면 막아야 하기 때문에 이미 등록된 사용자인지를 먼저 검사했다.

기존 코드

auth.controller.ts

	//회원가입 아이디 인증 요청 들어올 때
    @Get('register/:id')
    async registerAuthId(@Param('id') id: string): Promise<{ status: string, message: string }> {
        const findResult = await this.userService.findId(id);
        //이미 가입된 정보면
        if (findResult) {
            return { status: 'duplicate', message: '이미 가입된 회원 정보입니다.' };
        }
        //처음 가입인데
        else {
            const dayCount = await this.sendService.checkDayCount(id);
            //1일 문자 인증 횟수를 넘지 않았으면 인증번호 전송
            if (dayCount['status'] === 'remain') {
                return await this.sendService.sms(id, 'newuser');
            }
        }
    }

이미 가입된 사용자면 오류를 날리지는 않고 데이터로 응답한 것 같다.

이후 인증 문자 발송을 위해 똑같은 로직을 수행한다.

user.service.ts

    async findId(id: string): Promise<UserDto | null> {
        if (id.includes('@'))
            return await this.userRepository.findOne({
                where: {
                    email: id
                }
            });
        return await this.userRepository.findOne({
            where: {
                id: id
            }
        });
    }

리팩토링 코드

앞서 로그인에 적용했던 guard를 그대로 이용하여 사전 로직을 수행한다.

auth.controller.ts

    /* 휴대폰으로 회원가입 */
    @Public()
    @UseGuards(PhoneAuthenticationSendGuard)
    @Post('register')
    async register(@Body('phoneNumber') phoneNumber: string) {
        return await this.authService.register(phoneNumber);
    }

그리고 로그인에서와는 달리 먼저 중복 가입자인지를 체크하여 오류를 뱉고, 아니라면 인증 문자를 발송하는 것으로 로직을 완성했다.

auth.service.ts

    async register(phoneNumber: string) {
        /* 이미 가입된 전화번호 인지 확인 이후 인증번호 발송 */
        if (await this.userAuthCommonService.checkExistingUserByPhone(phoneNumber))
            throw new ConflictException(ErrorMessage.DuplicatedPhoneNumber);
        await this.sendPhoneAuthCode(phoneNumber);
    };

공통된 응답과 예외 처리는 Nest문서에 나와있는 코드를 참고하여 처리하고 있다.

추가로 개선할 점이 있다면...

사용자의 일일 인증 사용 내역 서비스(VerificationUsageService)는 휴대폰도 있고, 이메일도 올 수 있으니 하나의 클래스에 작성하기 보다는 공용 인터페이스를 만들고 각각에 대한 서비스를 만드는게 더 좋지 않을까 생각한다.

추후에 시간이 될 때 시도해보는것으로 하자.

마치면서

리팩토링하면서 작년에 작성했던 코드를 보니 잘못 작성한 HTTP Method들도 볼 수 있고, 하나의 클래스에 모두 작성해놓은 걸 분리도 해보니 나름 재밌는 것 같다.

profile
해결한 문제는 그때 기록하자

0개의 댓글