개발 블로그

발행: 2023/05/09수정: 2023/05/09

자바스크립트로 네이버 클라우드 플랫폼 SMS API 사용하기

자바스크립트로 네이버 클라우드 플랫폼 SMS API 사용하기

검색해보니 서버에서 문자 메시지 전송 기능을 구현하기 위해서 네이버 클라우드 플랫폼(Naver Cloud Platform, 이하 NCP)이 최선의 선택으로 보였다.

이유는,

  1. 싸다. 국내 서비스, 외국 서비스가 있는데 공개된 가격표만 봤을때 NCP가 가장 저렴했다.
    • SMS 건당 9원, LMS 건당 30원
  2. API 문서를 봤을 때 잘 설명돼 있는 것 같았다.
    • 하지만 구현하다보니 권한을 서명(signature)하는 부분에서 모호하게 작성돼 있었다. API를 자주 사용해보지 않았다면 헤멜것이다.
    • 스웨거 UI라는 걸로 모델을 제공하고 시뮬레이션도 가능하다. 스웨거에 익숙하다면 매우 친절한 기능으로 보인다.
  3. 무료 사용가능한 크레딧 10만원을 준다.
    • 이 크레딧 때문에 구현이나 테스트하는데 금전적 부담이 없어서 참 만족스럽다. 하지만 유효기간이 있으니 주의.
  4. 덤으로 카카오 알림톡 API도 있어서 나중에 확장한다면 사용하는데 일관성이 있어 수월할 것이다.

자세한 서비스 소개와 요금은 <서비스 소개 페이지 링크>를 확인한다.

사전 준비

  • <인증키 생성>

    • Access KeySecret Key의 한 쌍으로 된 인증키를 생성한다.
    • 네이버 클라우드 플랫폼 포털의 마이페이지 > 계정 관리에서 생성할 수 있다.

구현

  • <헤더 생성>
    • 인증키를 이용해 헤더를 생성한다.
    • 헤더에는 아래 3가지 값이 필요하다.
      • x-ncp-apigw-timestamp : 1970년 1월 1일 00:00:00 협정 세계시(UTC)부터의 경과 시간을 밀리초(Millisecond)로 나타낸 것. API Gateway 서버와 시간 차가 5분 이상 나는 경우 유효하지 않은 요청으로 간주.
      • x-ncp-iam-access-key : 네이버 클라우드 플랫폼 포털이나 Sub Account에서 발급받은 Access Key ID.
      • x-ncp-apigw-signature-v2 : Body를 Access Key ID와 맵핑되는 Secret Key로 암호화한 서명값. HMAC 암호화 알고리즘은 HmacSHA256 사용.

NCP 가이드에 x-ncp-apigw-signature-v2를 생성하는 자바스크립트 예제가 있지만 crypto-js를 사용한다. 나는 별도의 라이브러리를 설치하기 싫고 node.js 서버에서 구현하고 있기 때문에 내장된 crypto를 사용했다.

import crypto from 'crypto';

makeSignature({ timestamp, accessKey, method, endPoint }) {
    const space = ' ';
    const newLine = '\n';
    // 인증키 생성으로 얻은 `Secret Key`를 입력. 여기서는 환경변수에서 불러왔다.
    const secretKey = process.env.SECRET_KEY;
    const hmac = crypto.createHmac('sha256', secretKey);
    hmac.update(method);
    hmac.update(space);
    hmac.update(endPoint);
    hmac.update(newLine);
    hmac.update(timestamp);
    hmac.update(newLine);
    hmac.update(accessKey);

    const hash = hmac.digest('base64');
    return hash;
}

makeSignature를 사용해서 makeHeader를 완성한다.

makeHeaders({ endPoint, method, contentType }) {
    const timestamp = new Date().getTime().toString();
    // 인증키 생성으로 얻은 `Access Key`를 입력. 여기서는 환경변수에서 불러왔다.
    const accessKey = process.env.ACCESS_KEY_ID;

    const signature = makeSignature({
      timestamp,
      accessKey,
      method,
      endPoint,
    });

    const headers = {
        // contentType은 NCP API의 종류에 따라 바뀐다.
      ...(contentType && { 'Content-Type': contentType }),
      'x-ncp-apigw-timestamp': timestamp,
      'x-ncp-iam-access-key': accessKey,
      'x-ncp-apigw-signature-v2': signature,
    };
    return headers;
  }

실제 문자 메시지를 전송하는 sendMessage 메소드에서 makeHeader를 사용한다.

import got from 'got';

class MessagesService {
  getMessageType(content: string) {
    const byteLength = Buffer.byteLength(content, 'utf8');
    if (byteLength <= 90) {
      return MessageType.SMS;
    }
    if (byteLength >= 2000) {
      return MessageType.LMS;
    }
    throw new Error('Message is too long');
  }

  async sendMessage({
    content,
    to,
  }: SendMessageInput): Promise<SendMessageOutput> {
    const serviceId = process.env.SMS_SERVICE_ID;
    const baseUrl = 'https://sens.apigw.ntruss.com';
    const endPoint = `/sms/v2/services/${serviceId}/messages`;
    const url = baseUrl + endPoint;
    const from = process.env.FROM_NUMBER;
    const method = 'POST';
    const contentType = 'application/json; charset=utf-8';

    const body = {
      type: this.getMessageType(content),
      from,
      content,
      messages: [{ to }],
    };

    // header를 만들때 endpoint를 잘 보내야 한다.
    const headers = this.makeHeaders({
      endPoint,
      method,
      contentType,
    });

    // 최종 문자 메시지 전송 요청
    const _res = await got(url, {
      body: JSON.stringify(body),
      headers,
      method,
    });

    // 이하 전송 요청 응답의 처리
    const res: MessageResponse = JSON.parse(_res.body);

    if (res.statusCode !== '202') {
      throw new Error(_res.body);
    }

    return {
      ok: sendingResult ? true : false,
    };
  }
}

문자 메시지 전송 요청의 결과문자 메시지의 전송 결과다르다. NCP의 문자 메시지 전송결과 조회 API로 문자 메시지 전송의 성공 여부를 확인하고 후처리를 한다. 여기서 sendMessagegetSendMessageResultmakeHeaders가 받는 endPoint의 형태를 주의한다.

class MessagesService {
  // ...

  async getSendMessageResult(requestId): Promise<GetSendMessageResultOutput> {
    const serviceId = process.env.SMS_SERVICE_ID;
    const baseUrl = 'https://sens.apigw.ntruss.com';
    // sendMessage와 endpoint의 차이를 주의한다
    const endPoint = `/sms/v2/services/${serviceId}/messages?requestId=${requestId}`;

    const url = baseUrl + endPoint;
    const method = 'GET';

    const headers = this.makeHeaders({ endPoint, method, contentType: null });

    const _res = await got(url, {
      headers,
      method,
    });

    const res = JSON.parse(_res.body);
    if (res.messages[0].status === 'PROCESSING') {
      return null;
    }
    return res;
  }
}

전체 코드는 아래와 같다.

import crypto from 'crypto';
import got from 'got';

class MessagesService {
  makeSignature({ timestamp, accessKey, secretKey, method, endPoint }) {
    const space = ' ';
    const newLine = '\n';
    const hmac = crypto.createHmac('sha256', secretKey);
    hmac.update(method);
    hmac.update(space);
    hmac.update(endPoint);
    hmac.update(newLine);
    hmac.update(timestamp);
    hmac.update(newLine);
    hmac.update(accessKey);
    const hash = hmac.digest('base64');
    return hash;
  }

  makeHeaders({ endPoint, method, contentType }) {
    const secretKey = process.env.SECRET_KEY;
    const timestamp = new Date().getTime().toString();
    const accessKey = process.env.ACCESS_KEY_ID;
    const signature = this.makeSignature({
      timestamp,
      accessKey,
      secretKey,
      method,
      endPoint,
    });
    const headers = {
      ...(contentType && { 'Content-Type': contentType }),
      'x-ncp-apigw-timestamp': timestamp,
      'x-ncp-iam-access-key': accessKey,
      'x-ncp-apigw-signature-v2': signature,
    };
    return headers;
  }

  getMessageType(content: string) {
    const byteLength = Buffer.byteLength(content, 'utf8');
    if (byteLength <= 90) {
      return MessageType.SMS;
    }
    if (byteLength >= 2000) {
      return MessageType.LMS;
    }
    throw new Error('Message is too long');
  }

  async getSendMessageResult(requestId): Promise<GetSendMessageResultOutput> {
    const serviceId = process.env.SMS_SERVICE_ID;
    const baseUrl = 'https://sens.apigw.ntruss.com';
    const endPoint = `/sms/v2/services/${serviceId}/messages?requestId=${requestId}`;

    const url = baseUrl + endPoint;
    const method = 'GET';

    const headers = this.makeHeaders({ endPoint, method, contentType: null });

    const _res = await got(url, {
      headers,
      method,
    });

    const res = JSON.parse(_res.body);
    if (res.messages[0].status === 'PROCESSING') {
      return null;
    }
    return res;
  }

  async sendMessage({
    content,
    to,
  }: SendMessageInput): Promise<SendMessageOutput> {
    const serviceId = process.env.SMS_SERVICE_ID;
    const baseUrl = 'https://sens.apigw.ntruss.com';
    const endPoint = `/sms/v2/services/${serviceId}/messages`;
    const url = baseUrl + endPoint;
    const from = process.env.FROM_NUMBER;
    const method = 'POST';
    const contentType = 'application/json; charset=utf-8';

    const body = {
      type: this.getMessageType(content),
      from,
      content,
      messages: [{ to }],
    };

    const headers = this.makeHeaders({
      endPoint,
      method,
      contentType,
    });

    const _res = await got(url, {
      body: JSON.stringify(body),
      headers,
      method,
    });
    const res: MessageResponse = JSON.parse(_res.body);

    if (res.statusCode !== '202') {
      throw new Error(_res.body);
    }

    const sendingResult = await new Promise((resolve, reject) => {
      let retries = 10;
      const retryInterval = 1000;

      const retry = async () => {
        const result = await this.getSendMessageResult(res.requestId);
        if (result?.messages?.[0].statusCode === '0') {
          resolve(result);
        }
        if (retries > 0) {
          retries -= 1;
          setTimeout(retry, retryInterval);
        } else {
          reject(
            new Error(
              `statusName: ${result.messages[0].statusName}; statusCode: ${result.messages[0].statusCode}; statusMessage: ${result.messages[0].statusMessage}; to: ${to}; from: ${from}; messageId: ${result.messages[0].messageId};
              `
            )
          );
        }
      };

      setTimeout(retry, retryInterval);
    });

    return {
      ok: sendingResult ? true : false,
    };
  }
}

오류해결

전송하니 오류코드 3018가 나타났다. <오류코드>발신번호 변작 방지 서비스에 가입된 휴대폰 개인가입자 번호라 한다.

실제 해당 번호를 가진 휴대폰이 아닌데 그 번호를 발신번호로 사용하기 때문에 발생하는 문제다. 통신사 부가서비스에서 해당 기능을 해지함으로 해결했다. 바로 적용되지 않았고 다음날? 다다음날? 적용되더라.