프레임워크/NestJS

[NestJS] 네이버 문자인증 로직 제작하기

트리맨스 2021. 11. 6. 14:50
반응형

 

 

새로 제작할 어플에 문자인증 로직이 새로 생겨서 문자인증 로직을 구현해야 했다. 문자 인증같은 경우에는 처음 시도해 보는 것이라서 생각이 좀 필요했다.

 

로직 구상하기


찾아보니, 문자 인증 자체를 제공하는 API는 거의 찾을 수 없었다. 그 대신 문자를 보낼 수 있는 API는 참 많았다. 그래서 문자를 보내는 API를 내 입맛에 잘 맞추어서 적용하면 문자인증 로직을 충분히 구현할 수 있을 것 같았다. 문자전송 API는 네이버 클라우드 플랫폼에서 제공하는 SENS를 사용하기로 했다.

기존의 문자 인증 메시지를 보면, 특정 자릿수의 난수를 불러주고 시간제한을 두면서 입력을 요구한다. 난수 생성은 간단히 6자리의 숫자로 이루어진 문자열을 반환하고, 시간이 지나면 문자 인증을 할 수 없게 한다. 여기서 기존에 만들었던 난수를 특정 영역에 저장한 다음, 사용자가 입력한 숫자와 일치하는지 확인해야 한다. 여기에 관해서 난수 저장 위치에 대해서 고민이 생겼다.



1. 난수를 DB에 저장하는 경우

난수를 DB에 저장하게 되면 프로그램 어디서나 접근이 가능하고, 데이터가 서버로 제대로 전달되었다면 번호 손실의 위험은 거의 없다고 봐도 무방하다. 하지만 회원가입 시 1회만 사용하는 로직을 위해서 DB에 데이터를 저장하는 것은 자원 낭비라고 생각했다.



2. 난수를 캐시에 저장하는 경우

난수를 캐시에 저장하게 해도 프로그램 어디서나 접근이 가능하게 되고, 응답 속도도 매우 빨라진다. 이에 관해서 NestJS는 캐시를 사용할 수 있게 하는 기능을 제공한다. 여기서 캐시는 '키' : '값' 으로 구성되어 있으며 생성, 삭제, 생존 시간, 생성 가능한 캐시 개수 등의 여러가지 옵션을 지정할 수 있다. 사용법은 공식 홈페이지에 잘 나와 있었다. NestJS는 공식 문서가 꽤 잘 되어 있는 것 같다.
https://docs.nestjs.kr/techniques/caching

 

네스트JS 한국어 매뉴얼 사이트

네스트JS 한국, 네스트JS Korea 한국어 매뉴얼

docs.nestjs.kr


마지막으로 사용자가 입력한 난수와 서버에서 생성한 난수가 같으면 문자인증에 성공한 것이다. 이와 관련해서 에러 처리만 적당히 해 주면 충분히 문자인증 로직 생성이 가능해질 것 같았다.

인증 서비스 제작하기


네이버 문자인증 API는 사용 방식이 독특했다. HTTP header에 암호화된 정보를 담아 보내야 하는데, 이것 때문에 상당히 귀찮았던 기억이 있다. 그래도 API 사용 가이드에 설명이 그럭저럭 잘 되어 있어서 사용에는 무리가 없었다.

먼저 우측 상단의 사용자 페이지에서 엑세스 키 id와 시크릿 키를 받아야 한다. 그 다음 SENS 서비스에서 프로젝트를 하나 생성해서 sms 서비스 id를 받자. 같이 제공되는 sms 서비스 시크릿 키는 필요하지 않았다. 즉 최종적으로 필요한 키는 엑세스 키 id, 시크릿 키, sms 서비스 id 이렇게 3개가 된다.

그리고 HTTP header에 signature를 보내야 하는데, 이는 네이버 API 가이드에 설명이 잘 되어 있었다. 하지만 ts 예시 코드는 없어서, 다른분들의 예시 코드를 참고해 제작했다. 알고리즘은 sha256을 사용하고, 인코딩은 base64로 진행했다.

body 또한 object를 제작해야 하는데, 이것 또한 네이버 API 가이드에 설명이 잘 되어 있다.

마지막으로 rest api를 이용하기 위한 node에서 유명한 패키지인 axios를 이용하여 서버에 요청을 보낸다. 나의 경우에는 종종 문자 발송 요청에 실패하는 경우가 많았던 기억이 있다. 이에 대한 예외처리의 몫은 온전히 코드 작성자에게 달려 있다.

 

예제 코드


문자 인증 로직을 구현한 최종 코드이다.


auth.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import {
  CacheInterceptor,
  CACHE_MANAGER,
  Inject,
  Injectable,
  InternalServerErrorException,
  UseInterceptors,
from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from 'src/model/user.model';
import { PrismaService } from 'src/utils/prisma-serivce/prisma.service';
import { SignUpJwtDto } from './dto/sign-up-jwt';
import { SignUpInput } from './dto/sign-up.input';
import { SignUpOutput } from './dto/sign-up.output';
import * as crypto from 'crypto';
import { Cache } from 'cache-manager';
import axios from 'axios';
import { SMS } from './dto/sms';
 
const ACCESS_KEY_ID = process.env.NAVER_ACCESS_KEY_ID;
const SECRET_KEY = process.env.NAVER_SECRET_KEY;
const SMS_SERVICE_ID = process.env.NAVER_SMS_SERVICE_ID;
 
@Injectable()
@UseInterceptors(CacheInterceptor)
export class AuthService {
  constructor(
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
  ) {}
 
  // SMS 인증 위한 시그니쳐 생성 로직
  makeSignitureForSMS = (): string => {
    const message = [];
    const hmac = crypto.createHmac('sha256', SECRET_KEY);
    const timeStamp = Date.now().toString();
    const space = ' ';
    const newLine = '\n';
    const method = 'POST';
 
    message.push(method);
    message.push(space);
    message.push(`/sms/v2/services/${SMS_SERVICE_ID}/messages`);
    message.push(newLine);
    message.push(timeStamp);
    message.push(newLine);
    message.push(ACCESS_KEY_ID);
    // 시그니쳐 생성
    const signiture = hmac.update(message.join('')).digest('base64');
    // string 으로 반환
    return signiture.toString();
  };
 
  // 무작위 6자리 랜덤 번호 생성하기
  makeRand6Num = (): number => {
    const randNum = Math.floor(Math.random() * 1000000);
    return randNum;
  };
 
  // SMS 발송 로직
  sendSMS = async (phoneNumber: string) => {
    // TODO : 1일 5회 문자인증 초과했는지 확인하는 로직 필요!
    const signiture = this.makeSignitureForSMS();
    // 캐시에 있던 데이터 삭제
    await this.cacheManager.del(phoneNumber);
    // 난수 생성 (6자리로 고정)
    const checkNumber = this.makeRand6Num().toString().padStart(6'0');
 
    // 바디 제작
    const body: SMS = {
      type: 'SMS',
      contentType: 'COMM',
      countryCode: '82',
      from'발신번호',
      content: `인증번호는 [${checkNumber}] 입니다.`,
      messages: [
        {
          to: phoneNumber,
        },
      ],
    };
    // 헤더 제작
    const headers = {
      'Content-Type''application/json; charset=utf-8',
      'x-ncp-apigw-timestamp'Date.now().toString(),
      'x-ncp-iam-access-key': ACCESS_KEY_ID,
      'x-ncp-apigw-signature-v2': signiture,
    };
 
    // 문자 보내기 (url)
    axios
      .post(
        `https://sens.apigw.ntruss.com/sms/v2/services/${SMS_SERVICE_ID}/messages`,
        body,
        { headers },
      )
      .catch(async (e) => {
        // 에러일 경우 반환값
        console.log(JSON.stringify(e));
        throw new InternalServerErrorException();
      });
    // 캐시 추가하기
    await this.cacheManager.set(phoneNumber, checkNumber);
    return 'send end!';
  };
 
  // SMS 확인 로직, 문자인증은 3분 이내에 입력해야지 가능합니다!
  checkSMS = async (
    phoneNumber: string,
    inputNumber: string,
  ): Promise<Boolean> => {
    const sentNumber = (await this.cacheManager.get(phoneNumber)) as string;
    if (sentNumber === inputNumber) return true;
    else return false;
  };
}
 
cs


auth.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { CacheModule, Module } from '@nestjs/common';
import { AuthResolver } from './auth.resolver';
import { AuthService } from './auth.service';
 
@Module({
  imports: [
    CacheModule.register({ ttl: 600, max: 1000 }),
  ],
  providers: [
    AuthService,
    AuthResolver,
  ],
})
export class AuthModule {}
 
cs


auth.resolver.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { AuthService } from './auth.service';
import { SignUpInput } from './dto/sign-up.input';
import { SignUpOutput } from './dto/sign-up.output';
 
@Resolver()
export class AuthResolver {
  constructor(private authService: AuthService) {}
 
  @Query((_) => String, { description: 'sms발송' })
  sendSMS(@Args('phoneNumber') phoneNumber: string): Promise<string> {
    return this.authService.sendSMS(phoneNumber);
  }
 
  @Query((_) => String, { description: 'sms발송' })
  checkSMS(
    @Args('phoneNumber') phoneNumber: string,
    @Args('inputNumber') inputNumber: string,
  ): Promise<Boolean> {
    return this.authService.checkSMS(phoneNumber, inputNumber);
  }
}
 
cs



인증번호 수신 결과



후기


번호인증 로직을 구현해보니, 생각보다 어려운 것은 없었지만 난수를 비교하는 방식에 대해서 효율적인 방법을 찾은 것 같아서 기분이 좋다.

반응형