✉️
WebOTP API를 활용한 SMS 본인인증 기능 구현하기

WebOTP API를 활용한다면 사용자에게 보다 편리한 SMS 본인인증 경험을 제공할 수 있습니다.

January 31, 2021


FrontEnd JavaScript


이 글은 Google의 web.dev 아티클 『Verify phone numbers on the web with the Web OTP API』를 번역 및 의역한 것임을 밝힙니다.

WebOTP API란?

처참 일반적인 웹 사이트에서 마주할 수 있는 OTP 인증 UI

오늘날 전 세계 대부분의 사람들은 휴대전화를 갖고 있습니다. 그래서 일반적으로 개발자들은 고유한 사용자들을 식별할 수 있는 방법으로 전화번호를 사용합니다.

전화번호를 확인하는 방법에는 여러 가지가 있겠지만, 임의로 생성된 일회용 비밀번호(OTP, One Time Password)를 실은 SMS 메시지를 발송하는 것이 일반적인 방법 중 하나입니다. 사용자는 이 코드를 다시 서버에 전송하는 방법으로 전화번호의 소유를 입증할 수 있습니다.

WebOTP API는 사실 처음에는 SMS Receiver API라고 불렸습니다. 일부에서는 WebOTP API가 여전히 예전 이름으로 사용되고 있는 것을 볼 수 있습니다. 만약 여러분이 예전 API를 써본 경험이 있다면, 초창기 버전의 SMS Receiver API와 현재 버전의 WebOTP API 사이에는 확연한 차이가 있기 때문에 이 포스트를 반드시 읽어야 합니다.

이러한 아이디어는 다음과 같은 상황을 해결하기 위해 제안되었습니다.

  • 전화번호를 사용자 계정의 식별자(identifier)로 사용하는 경우입니다. 일부 웹 사이트에서는 서비스에 가입할 때 이메일 주소 대신 전화번호를 식별자로 사용합니다.
  • 2단계 검증(Two step verification)이 필요한 경우입니다. 일부 웹 사이트에서는 로그인할 때 비밀번호 외에도 SMS를 통해 전송된 일회용 코드 또는 추가 보안을 위한 기타 인증 방법들을 요청합니다.
  • 구매 확정 요청입니다. 사용자가 웹 사이트에서 결제를 시도할 때, SMS를 통해 전송된 일회용 코드를 요청하는 방식이 구매 요청 의사를 확인하는 데 도움이 될 수 있습니다.

하지만 여태까지 웹에서의 인증 과정은 상당히 불편했습니다. 현재 보고 있는 웹 페이지를 나가서 SMS 메시지 함에 들어가 인증 코드를 찾은 후, 복사하고 웹 페이지에 다시 돌아와 붙여넣는 과정을 거쳐야 했죠. 이 과정은 번거롭기 때문에 중요한 사용자 여정(user journey)에서의 전환율을 낮추기도 합니다. 많은 웹 개발자들은 이 불편함을 완화하기 위한 기능이 웹에서도 도입되기를 간절하게 바라고 있었죠. 사실 안드로이드에는 이와 같은 기능을 하는 SMS Retriever API가 이미 존재합니다. iOSSafari에서도 마찬가지죠.

이러한 불편함을 개선하기 고안된 WebOTP API는 특정 도메인에서만 동작하는 특별한 형식의 SMS 메시지를 웹 브라우저가 인식하여 수신할 수 있게 만들어 줍니다. 이를 통해 프로그래밍 방식으로 SMS 메시지 내의 OTP 코드를 얻어낼 수 있고, 사용자의 전화번호를 더욱 쉽게 ​​확인할 수 있습니다.

하지만 주의해야 합니다. 해커가 SMS 메시지를 흉내 내서 보낼 수도 있고, 전화번호의 주인이 바뀔 수도 있습니다. 이동 통신사가 해지된 전화번호를 신규 사용자에게 할당하는 경우가 많기 때문이죠. WebOTP는 위의 사례에서 전화번호를 확인하는 데 유용하지만, 일반적으로는 N단계 인증이라던가 Web Authentication API 등을 활용한 더 강력한 인증 방식을 추가하는 것이 좋습니다.

브라우저 호환성

처참

WebOTP 기능은 크롬에서 실험적으로 구현하고 있는 기능입니다. 게다가 사파리에서는 WebOTP 기능을 JavaScript API로 제공하지 않기 때문에 브라우저 호환성이 굉장히 낮게 보이는 편입니다.

하지만 이는 웹킷 브라우저가 OTP에 접근하는 JavaScript API를 제공하지 않기 때문에 생기는 함정입니다. 사실 크로뮴과 웹킷은 이미 SMS 텍스트의 포맷 통일에 동의한 바 있고 실제로 이미 구현이 되어 동작하고 있습니다. 즉 크롬과 사파리 모두 휴대전화에 수신된 SMS 코드를 바로 입력할 수 있는 기능이 있지만, 사파리는 JavaScript로 제어할 수 있는 API를 제공하지 않기 때문에 caniuse에서 해당 API의 호환성이 낮게 측정된 것입니다. 애플은 iOS 14 버전 이후부터 이미 이 기능을 지원하고 있죠.

사파리 기본 키보드에서 자동으로 OTP 메시지의 내용을 추출해서 보여줍니다

사파리에서는 <input> 엘리먼트에 autocomplete="one-time-code" 속성을 주는 것만으로 쉽게 기능 구현이 가능합니다. OTP 코드가 포함된 SMS 메시지를 수신하면, 키보드가 자동으로 OTP 코드를 붙여넣을 것인지를 제안하죠. 이 기능은 애플의 풍부한 생태계 지원과 어우러지면서 장점이 되는데, 동일한 iCloud 계정이 연동된 기기라면 iOS 모바일 기기로 수신된 OTP 코드를 데스크탑의 사파리에서도 확인할 수 있기 때문입니다.

때문에 WebOTP API는 현재는 안드로이드에서만 사용할 수 있습니다. 안드로이드 내의 크롬뿐만 아니라 오페라, 비발디 등의 브라우저에서도 WebOTP 기능을 사용할 수 있습니다. 물론 최신 버전이 아니라면 위에서 이야기한 편리한 사용자 경험을 제공하기는 어렵습니다.

동작 예시

어떤 사용자가 웹 사이트에서 자신의 전화번호를 인증하려 한다고 가정해봅시다. 웹 사이트는 SMS를 통해 메시지를 사용자에게 보냈고, 유저는 전화번호 소유권을 인증하기 위해 OTP 코드를 입력하는 상황입니다.

WebOTP API를 사용하면 아래의 영상에서 볼 수 있듯이 한 번의 클릭으로 이 모든 단계를 쉽게 처리할 수 있습니다. 문자 메시지가 도착하면 바텀 시트(Bottom Sheet) 팝업이 떠오르고, 사용자에게 전화번호를 확인하라는 메시지가 표시됩니다. 바텀 시트의 확인 버튼을 클릭하게 되면, 브라우저는 OTP 코드를 폼에 붙여넣은 후 자동으로 다음 단계의 인증 과정을 시도합니다.

전체 과정을 다이어그램으로 나타내면 아래와 같습니다.

과정

데모에서 직접 체험해보세요. 데모 페이지에서 여러분의 휴대전화 번호를 요청하거나 직접 SMS를 보내지는 않습니다. 대신 다른 휴대전화에서 메시지를 보내거나 자기 자신에게 메시지를 보내는 방식을 이용해 체험할 수 있습니다. WebOTP API는 메시지 발송자가 누구든 상관없이 동작하기 때문입니다.

  1. 안드로이드 기기의 크롬 버전 84 이상에서 데모 링크로 이동합니다.
  2. 아래의 텍스트를 복사해서 자기 자신의 전화번호로 SMS 메시지를 전송합니다.
Your OTP is: 123456.

@web-otp.glitch.me #123456

SMS 메시지를 수신했을 때 OTP 코드를 입력하라는 바텀 시트를 보셨나요? 이것이 바로 WebOTP API가 동작하는 방식입니다.

WebOTP API가 동작하지 않나요? 발신자의 전화번호가 수신자의 연락처 목록에 포함된 경우, 기본 SMS 사용자 동의 API의 설계로 인해 이 API가 트리거 되지 않을 수 있습니다. 혹은 안드로이드 기기에서 직장 프로필을 사용 중인 경우라면 개인 프로필로 로그인한 후 시도해보세요.

WebOTP API는 크게 세 부분으로 이루어져 있습니다.

  • 적절하게 속성이 설정된 <input> 태그
  • 웹 어플리케이션 내의 JavaScript 코드
  • 적절하게 포매팅 된 SMS 메시지

어노테이션(annotation)이 정확한 표현이지만 편의상 속성(attribute)로 부르겠습니다.

먼저 <input> 태그부터 살펴보죠.

<input> 태그

WebOTP 그 자체는 HTML 속성 없이도 동작합니다. 하지만 브라우저 호환성을 위해 사용자가 OTP를 입력할 것으로 예상되는 <input> 태그에 autocomplete="one-time-code"를 추가하는 것이 좋습니다.

이를 통해, Safari 14 이상에서는 사용자의 브라우저가 WebOTP를 지원하지 않더라도 약속된 형식의 SMS를 수신할 때 <input> 필드에 자동으로 OTP 코드를 입력할 수 있도록 제안할 수 있습니다.

<form>
  <input autocomplete="one-time-code" required />
  <input type="submit" />
</form>

WebOTP API 사용하기

WebOTP API는 간결하기 때문에, 그저 아래의 코드를 복사하고 붙여넣는 것만으로도 잘 동작할 것입니다. 이 코드를 통해 무슨 과정이 일어나는지에 대해서는 천천히 설명해보도록 하겠습니다.

if ("OTPCredential" in window) {
  window.addEventListener("DOMContentLoaded", (e) => {
    const input = document.querySelector('input[autocomplete="one-time-code"]');
    if (!input) return;
    const ac = new AbortController();
    const form = input.closest("form");
    if (form) {
      form.addEventListener("submit", (e) => {
        ac.abort();
      });
    }
    navigator.credentials
      .get({
        otp: { transport: ["sms"] },
        signal: ac.signal,
      })
      .then((otp) => {
        input.value = otp.code;
        if (form) form.submit();
      })
      .catch((err) => {
        console.log(err);
      });
  });
}

해당 기능을 사용할 수 있는지는 다른 API들과 마찬가지의 방법으로 사용합니다. window에서 OTPCredential이 있는지를 먼저 판단하고, DOMContentLoaded 이벤트를 수신하면 OTP가 입력될 엘리먼트를 찾은 후 로직을 적용하면 됩니다.

WebOTP API는 HTTPS 환경을 필요로 합니다.

OTP 인증하기

WebOTP API는 그 자체로도 매우 간단합니다. navigator.credentials.get()을 이용해 OTP 코드를 받아오면 됩니다. WebOTP는 otp 라는 옵션을 해당 메소드에 추가했습니다. 여기에는 transport라는 하나의 속성만을 가진 객체를 넣으며, 해당 값은 문자열 'sms'가 있는 배열이어야 합니다.

navigator.credentials
  .get({
    otp: { transport: ["sms"] }, // 이 부분입니다
  })
  .then((otp) => {
    input.value = otp.code;
    if (form) form.submit();
  });

이렇게 하면 SMS 메시지가 도착할 때 브라우저의 권한 요청이 발생합니다. 권한이 부여된 경우라면, Promise 를 리턴하게 되고 resolve 된 값은 OTPCredential 객체입니다.

{
  code: "123456", // 읽어온 OTP 코드
  type: "otp",  // 타입은 항상 "otp" 입니다
}

다음으로는 읽어온 OTP 코드의 값을 <input> 엘리먼트에 입력하면 됩니다. 이 과정을 적용하면, WebOTP 기능을 이용한 사용자는 다음 단계로 가는 버튼을 굳이 누르지 않아도 됩니다.

navigator.credentials
  .get({
    otp: { transport: ["sms"] },
  })
  .then((otp) => {
    input.value = otp.code; // 이 부분입니다
    if (form) form.submit();
  });

WebOTP 취소하기

사용자가 수동으로 OTP를 입력하고 폼을 제출하는 경우, 웹에서의 요청을 취소하게 만드는 AbortController 인스턴스에 options 객체를 사용함으로써 get() 호출을 취소하게 할 수 있습니다.

// 새 AbortController를 생성합니다.
const ac = new AbortController();

// 해당 시그널을 credentials.get 요청에 옵션으로 입력합니다.
navigator.credentials.get({
  otp: { transport:['sms'] },
  signal: ac.signal
}).then(otp => {

// submit 액션이 들어왔을 때까지 WebOTP 요청이 끝나지 않았다면 이를 취소합니다.
if (form) {
  form.addEventListener('submit', e => {
    ac.abort();
  });
}

SMS 메시지 포매팅

API 자체는 충분히 단순해 보이지만, 사용하기 전에 알아야 할 몇 가지 사항이 있습니다. 우선 SMS 메시지는 navigator.credentials.get() 이 호출 된 후 전송되어야 하며, get() 이 호출된 장치에서 수신되어야 합니다. 마지막으로 메시지는 다음 형식을 따라야 합니다.

Your OTP is: 123456.

@www.example.com #123456
  • 선택 사항이지만, 사람이 읽을 수 있는 4~10자의 영문, 숫자를 포함하는 것이 좋습니다. SMS 메시지의 마지막 줄은 WebOTP에서 URL과 OTP 코드를 인식하는 라인이기 때문에, 이전 텍스트와 줄 바꿈이 구분되어 있어야 합니다.
  • URL은 API를 호출한 웹 사이트의 도메인을 명시하고, @로 구분합니다. 이렇게 도메인을 명시함으로써 다른 도메인에 잘못된 OTP 코드가 제안되는 것을 방지할 수 있습니다.
  • OTP는 # 기호와 코드를 포함해야 합니다. 이렇게 구분자를 지어줌으로써 브라우저는 안정적으로 코드를 추출할 수 있습니다.

이때 SMS 텍스트 메시지 내용이 140자를 넘지 않게 조심해야 합니다.

기존 API와의 차이점

WebOTP API는 초기 버전 단계에서 SMS Receiver API라고 불렸습니다. 즉 예전 버전의 API에 익숙하다면 변경 사항을 알고 있어야 합니다. 먼저 예전 방식이었던 SMS Receiver API의 SMS 메시지 포맷을 살펴보고, 개선된 사항을 비교해봅시다.

Your OTP is: 123456. This is only valid for 10 minutes. Do not share it with anybody else

For: https://sms-receiver-demo.glitch.me/?otp=123456&xFJnfg75+8v
  • SMS 메시지의 포맷이 URL과 쿼리스트링 기반에서 WebKit의 제안 방식으로 변경되었습니다.
  • xFJnfg75+8v, EvsSSj4C6vl 와 같은 크롬 브라우저의 버전을 식별할 수 있는 해시 코드를 첨부하지 않아도 됩니다.
  • 호출 방식이 navigator.sms.receive() 대신 navigator.credentials.get() 로 변경되었습니다.
  • SMS 메시지의 전체 내용을 불러오는 receive() 메소드 대신 OTP의 내용만 읽어오는 get() 을 사용합니다.

데모

web-otp.glitch.me에서 데모를 확인할 수 있고, 원본을 수정하여 여러분만의 코드를 작성할 수도 있습니다.

참고 자료