과릿은 회원가입 시, 본인 확인을 위해 문자 인증 방식을 채택했다.
문자 인증 방식을 도입하게 된 이유는 서비스 내에서 전화번호를 필수적으로 활용하고자 했는데, 소셜 로그인 사용 시 별도의 사업자 정보 등록 없이 전화번호를 받을 수 없었기 때문이다.
문자 인증 방식을 도입한 이후, 구글의 앱 테스트 자동화에 요금 폭탄을 마주하는 경험과 이모지로 인한 발송 오류 등을 경험 및 해결해가면서 "이젠 더 이상 문자 발송 파트를 신경쓰지 않아도 되지 않을까?"라고 생각했다.
요금 폭탄
앱 테스트 자동화로 인해, 서비스 출시 후 문자 요금만 대략 3만원을 납부한 적이 있다.
(참고로 sms 발송은 건당 9원이다. 30000 / 9 = 약 3333건)
구글에게 문자 전송 로직을 지속적으로 테스트하는 경우 비용이 발생되기에 해당 부분은 테스트에서 제외되어야 한다 등의 메일도 보내보았지만, 별다른 반응이 없었고 구글에게 제공한 테스트 계정은 문자 발송이 불가능하도록 막았다가 업데이트가 거절당할 뻔하기도 했다...이모지 제거 시도
네이버의 SENS API는 EUC-KR로 변환되기에 UTF-8 방식의 문자열에 포함된 이모지가 포함될 경우 오류가 발생했다. 사용자가 이름을 이모지를 포함해서 지정하셨는데 오류가 발생한다는 피드백이 날라왔고, 약 일주일동안 이모지 변환 및 제거로직을 시도하였으나 100%의 성공률을 달성하지 못해 사용자 이름을 사용자 역할로 대체하여 문자를 발송하도록 바꾼 경험이 있다.
그러던 어느 날, 네이버 클라우드 SENS API가 개인대상으로는 서비스 종료한다는 문자가 왔다.
문자 발송을 개발 및 운영하면서 겪었던 아찔한 경험들이 생각났지만, SENS API가 사용 중지되는 순간 서비스 운영 중 문제가 발생하기에 전환을 시작했다. 아래는 전환하는 과정에 대한 정리이다.
나는 기존 문자 발송 도입 시 사용할 수 있는 서비스 목록들을 다시 확인해보며, CoolSMS로 전환하기로 결정했다.
CoolSMS를 도입하게 된 이유는 아래와 같다.
가장 큰 단점: 비용 (네이버에 비해 약 2배 이상 비싸다..)
CoolSMS 접속 후 회원가입 진행
API Key 생성하기
로컬에서 테스트를 진행 후, 서버에 배포할 예정이기에 모든 IP 허용으로 API Key를 생성했다. 개발이 완료되면, IP 정책을 변경하면 된다.
CoolSMS의 공식 문서를 보면, 아래의 dependency를 추가해서 개발하면 된다고 한다.
implementation 'net.nurigo:sdk:4.3.0'
그러나 위 dependency를 기반으로 개발을 다하고, Application을 실행하니 아래와 같은 오류를 확인할 수 있었다. Maven Repository를 보니, retrofit 영향을 받는데 해당 부분에서 충돌이 있는 것으로 예상된다. 그래서, Java11로 개발하고 있었던 나는 부득이하게 javaSDK로 변환해서 개발을 진행했다. 변경한 dependency는 아래와 같다.
implementation 'net.nurigo:javaSDK:2.2'
application-secret을 별도로 분리해서 운영하고 있기에, 해당 파일에서 Naver SENS 관련 정보를 삭제하고 coolsms 관련 정보를 추가했다.
coolsms:
api-key: "API Key"
api-secret: "API Secret"
sender-phone: "Sender Phone number"
SMS 전송을 담당하는 컴포넌트를 개발한 뒤, Service에서 해당 컴포넌트를 호출해서 사용하도록 구현했다.
보내는 문자의 종류가 총 3가지이기에, 기존에 Naver SENS 코드를 활용했다.
@Component
public class CoolSMSClient {
@Value("${coolsms.api-key}")
private String apiKey;
@Value("${coolsms.api-secret}")
private String apiSecretKey;
@Value("${coolsms.sender-phone}")
private String senderPhone;
// 단일 메시지 발송 예제
public String sendAuthorizationCode(PostAuthPhoneReq postAuthPhoneReq) {
// 인증 번호 생성
String authorizationCode = Integer.toString((int)(Math.random() * (999999 - 100000 + 1)) + 100000);
Message coolsms = new Message(apiKey, apiSecretKey);
HashMap<String, String> params = new HashMap<>();
params.put("to", postAuthPhoneReq.getPhone());
params.put("from", senderPhone);
params.put("type", "SMS");
params.put("text", "[과릿]" + "\n" + "인증번호: " + authorizationCode + "\n" + "인증 번호를 입력해주세요.");
params.put("app_version", "Gwarit 1.3.4");
try {
coolsms.send(params);
} catch (CoolsmsException e) {
throw new RuntimeException(e);
}
return authorizationCode;
}
public String sendTemporaryPassword(PostAuthCodeReq postAuthCodeReq) {
// 임시 비밀번호 발급
char[] list = new char[] {
'1','2','3','4','5','6','7','8','9','0',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'!','@','#','$','%','^','&','*','(',')'};
StringBuilder temporaryPassword = new StringBuilder();
// 숫자 하나 추가
int idx = (int)(Math.random() * 10); // 0에서 9 사이의 인덱스 선택
temporaryPassword.append(list[idx]);
// 문자 하나 추가
idx = (int)(Math.random() * 26) + 10; // 10에서 35 사이의 인덱스 선택 (알파벳 대문자)
temporaryPassword.append(list[idx]);
// 특수 문자 하나 추가
idx = (int)(Math.random() * 9) + 62; // 36에서 44 사이의 인덱스 선택 (특수 문자)
temporaryPassword.append(list[idx]);
for(int i = 3; i < 10; i++) {
idx = (int) (Math.random() * (list.length));
temporaryPassword.append(list[idx]);
}
// 문자열을 문자 리스트로 변환
List<Character> charList = new ArrayList<>();
for (char c : temporaryPassword.toString().toCharArray()) {
charList.add(c);
}
// 문자 리스트를 섞기
Collections.shuffle(charList);
// 섞인 문자 리스트를 다시 문자열로 변환
StringBuilder shuffledPassword = new StringBuilder();
for (char c : charList) {
shuffledPassword.append(c);
}
Message coolsms = new Message(apiKey, apiSecretKey);
HashMap<String, String> params = new HashMap<>();
params.put("to", postAuthCodeReq.getPhone());
params.put("from", senderPhone);
params.put("type", "SMS");
params.put("text", "[과릿]" + "\n" + "임시비밀번호: " + "\n" + temporaryPassword + "\n" + "\n" + "로그인 이후 비밀번호를 변경해주세요.");
params.put("app_version", "Gwarit 1.3.4");
try {
coolsms.send(params);
} catch (CoolsmsException e) {
throw new RuntimeException(e);
}
return temporaryPassword.toString();
}
public void sendInvitation(PostInviteReq postInviteReq, Boolean type) {
Message coolsms = new Message(apiKey, apiSecretKey);
HashMap<String, String> params = new HashMap<>();
params.put("to", postInviteReq.getPhone());
params.put("from", senderPhone);
params.put("type", "LMS");
params.put("app_version", "Gwarit 1.3.4");
if(type.equals(Boolean.TRUE)) {
params.put("text", "[과릿]" + "\n" + "선생님으로부터 클래스 초대가 도착했습니다." + "\n" + "아래 링크를 통해 앱 설치 및 회원가입을 통해 수업에 참여해보세요!" + "\n" + "\n" + "안드로이드: " + "https://bit.ly/gwarit-android" + "\n" + "\n" + "애플: " + "https://bit.ly/gwarit-apple");
}
if(type.equals(Boolean.FALSE)) {
params.put("text", "[과릿]" + "\n" + "선생님으로부터 클래스 초대가 도착했습니다." + "\n" + "아래 링크를 클릭 후 앱 열기를 통해 수업에 참여해보세요!" + "\n" + "\n" + "안드로이드: " + "https://bit.ly/gwarit-android" + "\n" + "\n" + "애플: " + "https://bit.ly/gwarit-apple");
}
try {
coolsms.send(params);
} catch (CoolsmsException e) {
throw new RuntimeException(e);
}
}
}
과릿 사용해보기
Reference