자바스크립트로 네이버 클라우드 플랫폼 SMS API 사용하기
검색해보니 서버에서 문자 메시지 전송 기능을 구현하기 위해서 네이버 클라우드 플랫폼(Naver Cloud Platform, 이하 NCP)이 최선의 선택으로 보였다.
이유는,
- 싸다. 국내 서비스, 외국 서비스가 있는데 공개된 가격표만 봤을때 NCP가 가장 저렴했다.
- SMS 건당 9원, LMS 건당 30원
- API 문서를 봤을 때 잘 설명돼 있는 것 같았다.
- 하지만 구현하다보니 권한을 서명(signature)하는 부분에서 모호하게 작성돼 있었다. API를 자주 사용해보지 않았다면 헤멜것이다.
- 스웨거 UI라는 걸로 모델을 제공하고 시뮬레이션도 가능하다. 스웨거에 익숙하다면 매우 친절한 기능으로 보인다.
- 무료 사용가능한 크레딧 10만원을 준다.
- 이 크레딧 때문에 구현이나 테스트하는데 금전적 부담이 없어서 참 만족스럽다. 하지만 유효기간이 있으니 주의.
- 덤으로 카카오 알림톡 API도 있어서 나중에 확장한다면 사용하는데 일관성이 있어 수월할 것이다.
자세한 서비스 소개와 요금은 <서비스 소개 페이지 링크>를 확인한다.
사전 준비
-
Access Key
와Secret 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로 문자 메시지 전송의 성공 여부를 확인하고 후처리를 한다.
여기서 sendMessage
와 getSendMessageResult
의 makeHeaders
가 받는 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가 나타났다. <오류코드>에 발신번호 변작 방지 서비스에 가입된 휴대폰 개인가입자 번호
라 한다.
실제 해당 번호를 가진 휴대폰이 아닌데 그 번호를 발신번호로 사용하기 때문에 발생하는 문제다. 통신사 부가서비스에서 해당 기능을 해지함으로 해결했다. 바로 적용되지 않았고 다음날? 다다음날? 적용되더라.