All Articles

Open API를 이용한 회원가입 문자인증 기능 구현

카페24 SMS 발송 API 이용하기

파이썬으로 구현한 카페24 SMS API 예시는 블로그 글 딱 1개밖에 없어서 카페24에서 제공하는 PHP 소스 예제를 참고해 더듬더듬 구현했다.

인증문자 발신 번호, 시크릿 키 등의 정보는 my_settings.py에 저장해두고 settings.py에 연결해준 뒤 views.py에서는 settings.py를 끌어와서 사용했다. 각 코드 설명은 코드블럭 내 주석으로 달았다.

(가독성 있게 바꿔야겠다…)

# 인증문자 발송용 뷰
class SMSVerificationView(View):
  # send_verification 메소드 아래 post 메소드가 먼저 살행된다.
  # 프론트에서 request body에 담아 인증문자 수신인 번호를 보내주면
  # randint로 생성한 랜덤 인증번호를 수신인 번호로 보내는 것이 send_verification 메소드 역할이다.
  def send_verification(self, rphone, verification_number):
    # sms를 발송하는 카페24 url을 적어준다.
    sms_url = 'https://sslsms.cafe24.com/sms_sender.php'
    # 카페24에서는 base64 인코딩을 '권장'한다고 하나, 인코딩을 하지 않았더니 아예 문자 발송이 되지 않았다.
    # 권장이 아니라 필수인듯 하다.
    # base64로 인코딩을 하려고 하니 string type말고 bytes type이어야 한다고 에러가 났다.
    # 따라서 base64 인코딩 전에 utf-8로 인코딩을 해 bytes type으로 만들었다.
    user_id = base64.b64encode(SMS_SENDER_ID.encode('utf-8'))
    secure = base64.b64encode(SECURE_KEY.encode('utf-8')) 
    mode = base64.b64encode('1'.encode('utf-8'))
    # 인증문자 발신 번호는 세 개 또는 두 개 변수에 나눠서 담아야 한다.
    # 1588-0000 형태라면 sphone1, sphone2까지, 070-0000-0000 형태라면 sphone3까지 총 3개 변수에 할당한다.
    # 인덱스를 이용해 하나씩 할당해주기 위해서 my_settings.py에 아예 리스트 형태로 발신 번호를 저장해놓았다.
    # 예 - SMS_SENDER_NUMBER = [070, 0000, 0000]
    sphone1 = base64.b64encode(SMS_SENDER_NUMBER[0].encode('utf-8'))
    sphone2 = base64.b64encode(SMS_SENDER_NUMBER[1].encode('utf-8'))
    sphone3 = base64.b64encode(SMS_SENDER_NUMBER[2].encode('utf-8'))

    # 수신인 번호 또한 인코딩해준다.
    # 카페24 가이드에 따르면 010-1234-1234 형태로 하이픈을 포함해야 한다고 하나,
    # 01012341234 형태로 해도 인증문자를 수신하는 것을 확인했다.
    rphone = base64.b64encode(rphone.encode('utf-8'))
    # 인증문자 메시지 내용을 작성한다. verification_number는 아래 post 메소드에서 넘겨받은 것이다.
    msg = f'인증번호 [{verification_number}]를 입력해주세요.'
    # 메시지 내용 또한 인코딩해준다.
    msg = base64.b64encode(msg.encode('utf-8'))
    # 테스트를 할 때마다 실제 문자를 발송하면 과금 부담이 있으므로 testflag = 'Y'로 해주고
    # 아래 json 형태의 data에 'testflag' 항목을 포함한다.
    # 'Y' 또한 인코딩 해야한다.
    testflag = base64.b64encode('Y'.encode('utf-8'))
    # 위에서 변수에 할당해 인코딩한 것을 그대로 넣어준다.
    data = {
      'user_id': user_id,
      'secure': secure,
      'mode': mode,
      'sphone1': sphone1,
      'sphone2': sphone2,
      'sphone3': sphone3,
      'rphone': rphone,
      'msg': msg,
      'testflag' : testflag
    }
    # testflag Y 상태에서는 문자가 실제로는 발송되지 않으므로
    # 인증번호를 확인하기 위해서는 print로 찍어봐야 한다.
    # 인코딩 과정과는 반대로, base64 디코딩 - utf-8 디코딩 순서로 디코딩해준다.
    # 디코딩 하지 않으면 내용을 제대로 볼 수 없다.
    decoded_data = {
      'user_id' : base64.b64decode(user_id).decode('utf-8'),
      'secure': base64.b64decode(secure).decode('utf-8'),
      'mode': base64.b64decode(mode).decode('utf-8'),
      'sphone1': base64.b64decode(sphone1).decode('utf-8'),
      'sphone2': base64.b64decode(sphone2).decode('utf-8'),
      'sphone3': base64.b64decode(sphone3).decode('utf-8'),
      'rphone': base64.b64decode(rphone).decode('utf-8'),
      'msg': base64.b64decode(msg).decode('utf-8'),
      'testflag' : base64.b64decode(testflag).decode('utf-8')
    }
    # 내용을 확인하기 위해 print를 찍어준다. 오로지 테스트용도다.
    print(decoded_data) 

    # 위에서 작성한 data를 카페24 sms 전송 url로 전달한다.
    # 카페24 sms 전송 api의 status code에 맞는 status code가 HttpResponse 형태로 리턴된다.    
    res = requests.post(sms_url, data)
    return HttpResponse(res.status_code)

# 유저가 본인 휴대전화 번호를 입력 후 '인증받기' 버튼을 눌렀을 때
# 백엔드 서버가 그 정보를 받아 휴대전화 번호와 랜덤으로 생성된 인증문자를 DB에 저장하기 위한 메소드다.
# DB에 저장한 휴대전화와 인증번호 정보와 유저가 입력한 인증번호가 일치하는지 여부는
# 별도 뷰(SMSVerificationConfirmView)에서 확인한다.
def post(self, request):
  try:
    data                = json.loads(request.body)
    member_phone        = data['phoneNumber']
    # 6자리 인증번호 랜덤 생성
    verification_number = str(randint(100000, 999999))
    # 같은 휴대전화 번호로 여러 번 인증할 수 있는데,
    # 이때마다 새로운 row를 생성해서 저장하면 안 되므로
    # 휴대전화 번호가 존재하는지 여부를 확인해서 존재한다면 update로 처리해 인증번호만 갈아끼워 저장한다.
    Verification.objects.update_or_create(
      member_phone = member_phone,
      defaults     = {
          'member_phone'        : member_phone,
          'verification_number' : verification_number
      }
    )
    # 휴대전화번호와 인증번호를 담아 같은 클래스 내 send_verification 메소드를 호출한다.
    # member_phone과 verification_number가 send_verification 메소드의 인자가 된다.
    self.send_verification(
        rphone              = member_phone,
        verification_number = verification_number
    )
    return HttpResponse(status=200)
  except KeyError:
    return JsonResponse({'message' : 'Invalide key'}, status=400)

이제 유저가 인증번호 확인란에 입력한 숫자가 발송한 숫자와 일치하는지 확인해야한다. 일치한다면 회원 테이블에 우선 휴대전화번호를 저장해두고 회원가입 뷰에서 나머지 정보를 업데이트하는 방식으로 구현했다.

class SMSVerificationConfirmView(View):
  def post(self, request):
    # 유저가 입력한 휴대전화번호와 인증번호를 전달받는다.
    data                = json.loads(request.body)
    member_phone        = data['phoneNumber']
    verification_number = data['verificationNumber']
    
    # randint로 생성해서 DB에 저장해뒀던 인증번호와 입력한 인증번호가 일치하고,
    if verification_number == Verification.objects.get(
      member_phone=member_phone).verification_number:
      # 이미 가입한 휴대전화번호가 아니라면,
      if not Member.objects.filter(member_phone=member_phone).exists():
        # 회원정보 테이블에 휴대전화번호를 저장한다.
        Member.objects.create(member_phone=member_phone)
        return HttpResponse(status=200)
    return HttpResponse(status=400)

위에서 먼저 저장한 휴대전화번호로 유저를 특정해 나머지 정보(아이디(여기선 이메일), 비밀번호 등)를 해당 row에 업데이트 한다.

약관 동의 부분을 어떻게 처리할지 고민을 많이 했다. 일단 현재는 모든 약관이 필수 동의 약관이고 약관 개수가 2개로 고정돼있기 때문에 프론트로부터 동의한 약관 id(PK)값을 list 형태로 받아 처리하는 방식으로 구현했다. 아직도 어떤 방법이 베스트인지는 모르는 상태다. 장고에서 약관 동의를 다루는 케이스는 대부분 form, template를 백에서 작성하는 케이스라 내 경우에 적용할 수 없었다.

class MemberSignUpView(View):
  def post(self, request):
    data               = json.loads(request.body)
    # 비밀번호 유효성 검사
    # 6~16자 사이, 영숫자 모두 포함
    password_validator = RegexValidator(
      regex="^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,16}")
    try:
      # 모든 약관에 반드시 동의해야 가입이 가능하므로 약관 동의 여부를 받아 DB에 저장해야한다.
      member_email = data['email']
      validate_email(member_email)
      terms_id = data['termsId']

      # 이미 가입한 회원이 아니라면,
      if not Member.objects.filter(member_email=member_email).exists():
        password_validator(data['password'])
        # 입력한 비밀번호를 암호화한다.
        member_pw = bcrypt.hashpw(
          data['password'].encode('utf-8'),
          bcrypt.gensalt()).decode('utf-8')
        # 미리 저장한 휴대전화번호로 유저를 특정해준 뒤
        Member.objects.filter(
          member_phone=data['phoneNumber']
        ).update( # 나머지 정보를 저장한다.
          member_email     = member_email,
          member_pw        = member_pw,
          member_nickname  = data['nickname']
        )
        # [1, 2] 형태로 들어온 약관 id값을 받아 회원과 약관의 중간테이블에 회원 id와 함께 저장했다.
        # 중간테이블에 데이터가 저장됐다는 것이 곧 약관에 동의했다는 의미다.
        for i in range(2):
          MemberTermsManagement.objects.create(
            member_idx_id = Member.objects.get(
              member_email=member_email).member_idx,
            terms_management_idx_id = TermsManagement.objects.get(
              terms_management_idx=data['termsId'][i]
            ).terms_management_idx
          )
        return HttpResponse(status=200)
      return HttpResponse(status=400)
    except ValidationError:
      return HttpResponse(status=400)
    except KeyError:
      return JsonResponse({'message' : 'Invalid key'}, status=400)

네이버 클라우드 플랫폼 SMS 발송 API 이용하기

NAVER CLOUD PLATFORM API 사용 가이드
SMS API v2 가이드

카페24 API 흐름과 큰 틀에서는 같다. 네이버 클라우드 플랫폼은 Python 코드 예시도 있고 참고할 블로그도 많아서 카페24보다는 수월했다.

class SMSVerificationView(View):
  def send_verification(self, phone_number, verification_number):
    # 네이버 클라우드 플랫폼에 가입하면 발급해주는 serviceId를 입력해서 sms를 보내는 주소를 완성해준다.
    SMS_URL    = 'https://sens.apigw.ntruss.com/sms/v2/services/{serviceId}/messages'
    # time.time()*1000은 1970년 1월 1일 00:00:00 협정 세계시(UTC)부터의 경과 시간을
    # 밀리초(Millisecond)단위로 나타낸 것
    # API Gateway 서버와 시간 차가 5분 이상 나는 경우 유효하지 않은 요청으로 처리하기 위해 필요하다
    timestamp  = str(int(time.time()*1000))
    # serviceId와 마찬가지로 클라우드 플랫폼 가입자마다 다른 고유 secret key
    secret_key = bytes(AUTH_SECRET_KEY, 'utf-8')
    
    method = 'POST'
    uri    = '/sms/v2/services/{serviceId}/messages'
    message    = method + ' ' + uri + '\n' + timestamp + '\n' + AUTH_ACCESS_KEY
    
    # message 형태를 풀어쓰면 아래와 같다.
    '''
    GET /photos/puppy.jpg?query1=&query2
    {timeStamp}
    {accessKey}
    '''
    # 암호화하기 위해 bytes type으로 인코딩한다.
    message    = bytes(message, 'utf-8')

    # 위에서 생성한 StringToSign(message)를 HmacSHA256 알고리즘으로 암호화한 후
    # base64로 인코딩해서 signingKey를 만든다.
    signingKey = base64.b64encode(
      hmac.new(secret_key, message, digestmod=hashlib.sha256).digest())
    
    # 요청 헤더
    headers    = {
      'Content-Type'             : 'application/json; charset=utf-8',
      'x-ncp-apigw-timestamp'    : timestamp,
      'x-ncp-iam-access-key'     : AUTH_ACCESS_KEY,
      'x-ncp-apigw-signature-v2' : signingKey,
    }

    # 요청 바디
    body       = {
      # 장문 문자라면 'LMS'
      'type'        : 'SMS',
      'contentType' : 'COMM',
      'countryCode' : '82',
      # 카페 24와 마찬가지로 주요 정보는 my_settings.py에 저장했고
      # settings.py와 연결해 그곳에서 끌어온다.
      'from'        : f'{SMS_SEND_PHONE_NUMBER}',
      'content'     : f'인증번호 [{verification_number}]를 입력해주세요.',
      'messages'    : [
        {
          'to' : phone_number
        }
      ]
    }

    # 만든 바디를 json 형태로 변환한 뒤
    encoded_data = json.dumps(body)
    # 헤더와 함께 post 메소드로 SMS 전송 url에 요청을 보낸다.
    res          = requests.post(SMS_URL, headers=headers, data=encoded_data)
    return HttpResponse(res.status_code)

def post(self, request):
  # 유저 휴대전화번호를 받아 인증번호를 생성하는 과정은 카페24 post 메소드와 동일
  # 코드 이하 생략