요약 

CoolSMS를 활용한 SMS 인증

  • 6자리 인증 코드 생성 및 서버에 저장
  • HashMap을 사용해 전화번호와 만료시간 관리
  • SMS 발송 및 인증번호 검증 구현
  • API 키와 발신 번호 환경 변수로 관리

이메일 인증 기능을 성공적으로 완수하였다.

이제 핸드폰 번호 인증이 남아있다. Naver Cloud SMS에 도전!

 

이전 프로젝트에서 사용했던 경험이 있기 때문에 무리 없이 진행되길 바라면서 시작하도록 하겠다.

먼저, Naver Cloud Platform에 가서 빠르게 네이버로 간편 로그인 하고, Console로 이동한다.

콘솔을 클릭하려다보니, 결제 수단을 등록해야 접근이 가능하다고 한다. 

문자를 보내려면 요금이 추가되겠구나 싶었다. 그래서 결제 수단을 등록해보았더니 할인 크레딧~~~~~? 과연 문자 보내기에 사용이 될지 !! 기대된다. 결제수단 등록이 완료 되었다면

SMS, 알림톡 등 메시지 알림 기능을 구현하는 서비스 선택!

익숙한 화면이 나왔다. 이제 프로젝트를 생성하여 시작해보자.

는 무슨 프로젝트 생성 안되서 전화해보니까 올해 4월부터 사업자에게만 지원해주는 정책으로 바뀌었다고 한다....

 

카카오로 간다.

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

카카오 디벨로퍼스에서 애플리케이션을 하나 만들고,

플랫폼 -> web에 도메인을 추가한다. 앱 키에서 발급 후, 키를 복사해 놓는다.

 

는 심사를 해야하는데 2~3일이 소요 예정이라고 한다.

............

 

다른 메세지 API를 찾아야겠다. coolSMS를 사용하기로 한다.

회원 가입을 완료하니 튜토리얼로 이어진다.

API Key 생성 포함 사용법이 쉽고 자세하게 나와있고 특히, 개발/연동에서 여러 언어의 컨트롤러 예제 코드를 제공해주기 때문에 처음 시작하기에도 어렵지 않을 것 같다.

다운로드하거나 Git에서 코드를 확인할 수 있으며 중요한 부분인 요금 부분에서도 큰 메리트가 있었다. 가입 시 300 포인트를 지급해주고 한 건당 20 포인트씩 차감되어 개발자 포트폴리오를 준비하는 나에게는 빛과 소금이다.

 

Java용 SDK(Software Development Kit) 를 확인해보니 단일 발송, 다중 발송, MMS 발송 등 사용할 수 있는 여러 예제 코드가 잘 정리되어 있었다. 무엇보다 SDK를 사용하면 REST API를 직접 호출하지 않아도 서버 연동을 할 수 있다는 점이 편리했다.서버와의 통신은 SDK가 대신 처리해주기 때문에 개발자는 자바 코드만으로 CoolSMS 서버와 쉽게 연동할 수 있다. 즉, 자바스크립트로 HTTP 요청을 작성할 필요 없이 간단한 메서드 호출만으로 메시지 발송 기능을 구현할 수 있다는 것이다.

 

우선, 단일 발송으로 메시지 발송을 구현하기로 했다.

단일 메시지 발송 기능을 SmsService로 분리해 구현하기로 했다.

또, API Key와 Secret Key는 코드에 직접 작성하지 않고 환경 변수를 사용해 불러왔다.

설정에는 ${}으로 작성하고, 불러올 떄는 @Value로 불러온다.

package com.community.ukae.service.sms;

import com.community.ukae.dto.sms.SmsRequestDTO;
import net.nurigo.sdk.NurigoApp;
import net.nurigo.sdk.message.model.Message;
import net.nurigo.sdk.message.request.SingleMessageSendingRequest;
import net.nurigo.sdk.message.response.SingleMessageSentResponse;
import net.nurigo.sdk.message.service.DefaultMessageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class SmsService {

    private final DefaultMessageService messageService;

    // @Value를 통해 환경변수에서 API Key와 Secret 값을 읽어옴
    public SmsService(@Value("${coolsms.api.key}") String apiKey,
                      @Value("${coolsms.api.secret}") String apiSecret) {
        this.messageService = NurigoApp.INSTANCE.initialize(apiKey, apiSecret, "https://api.coolsms.co.kr");
    }

    public SingleMessageSentResponse sendSms(SmsRequestDTO smsRequest) {
        Message message = new Message();
        message.setFrom(smsRequest.getFrom());
        message.setTo(smsRequest.getTo());
        message.setText(smsRequest.getText());

        return messageService.sendOne(new SingleMessageSendingRequest(message));
    }

서비스 작성 시 주의할 점은 Message 클래스의 임포트인데

import net.nurigo.sdk.message.model.Message;

 

Spring의 Message 클래스를 임포트하면 CoolSMS를 사용할 수 없다.

아 그리고 파라미터로 받는 from,to,text는 값은 가독성과 유지보수성을 위해, DTO로 만들어 따로 관리하기로 결정했다.

@RestController
@RequestMapping("api/sms")
@RequiredArgsConstructor
public class SmsRestController {

    private final SmsService smsService;

    @PostMapping("sendSms")
    public ResponseEntity<String> sendSms(@Valid @RequestBody SmsRequestDTO smsRequest){
        smsService.sendSms(smsRequest);
        return ResponseEntity.ok("문자 전송 성공!");
    }

 

작성이 완료 됐다면 Postman에서 확인한다.

URL: POST http://localhost:8080/api/sms/sendSms

Headers: Content-Type: application/json

 

from에는 CoolSMS에 등록된 발신 번호를 입력해야만 정상적으로 발송된다.

Postman에서 200 OK 응답을 확인한 후, CoolSMS 대시보드에서 메시지 발송 내역도 확인할 수 있었다.

구현 성공!
이제 단순한 메시지 발송 기능은 완성되었다.

내가 구현하고자하는 기능은 인증 메시지 기능이다.

CoolSMS는 문자 발송만 지원하고 인증 코드 생성과 검증은 제공하지 않기 때문에, 이 부분은 내가 직접 구현해야 한다.

 

인증 코드를 포함한 메세지 발송은 다음 순서로 진행할 것이다.

  1. 인증 코드 생성
  2. 서버에 코드 저장
  3. SMS로 인증 코드 발송
  4. 사용자가 입력한 코드 검증

인증 코드를 생성하는 메서드는 여러가지 방법으로 구현될 수 있다.

public String createAuthCode(){
    Random random = new Random();
    StringBuilder authCode = new StringBuilder();

    for (int i = 0; i < 6; i++) {
        authCode.append(random.nextInt(10)); // 0~9 사이 숫자 추가
    }
    return authCode.toString();
}

 

Random과 StringBuilder를 사용해서 숫자만 포함된 인증 코드를 생성할 수 있다.

Random을 보자마자 개발 수업 때 배웠던 주사위 랜덤 값 뽑기 구현이 생각 났다. ㅋㅋㅋ

그때 주사위는 랜덤으로 하나의 숫자만 뽑았고, 지금처럼 여러 개의 숫자를 조합하여 써야하니 StringBuilder가 필요하다.

public String createAuthCode(){
    return UUID.randomUUID().toString()
            .replace("-", "")  // 하이픈 제거
            .substring(0, 6);  // 앞 6자리만 추출
}

또는 이메일 인증 토큰 구현에 사용해 보았던 UUID를 활용해서 간단히 구현할 수 있다.

UUID는 고유성이 보장된 문자열을 생성하므로, 앞의 일부분만 잘라내어 사용하면 될 것이다.

 

두 방식 중에 인증 메세지를 받는 사용자의 입장에서 가장 익숙하다고 생각되는 숫자만으로 조합된 방식을 쓰기로 했다.

이제 코드를 생성하였으니, 서버에 이를 저장하고 검증해야한다.

private final Map<String, String> codeStorage = new HashMap<>(); // 전화번호와 인증 코드 저장
private final Map<String, Long> expiryStorage = new HashMap<>(); // 코드 만료 시간 저장
private static final long AUTH_CODE_EXPIRATION_TIME = 5 * 60 * 1000; // 5분

    // 인증번호를 포함한 단일 메세지 발송
    public void sendSmsWithAuthCode(SmsRequestDTO smsRequest){
        String authCode = createAuthCode();
        long expirationTime = System.currentTimeMillis() + AUTH_CODE_EXPIRATION_TIME;
        codeStorage.put(smsRequest.getTo(), authCode); // 번호와 생성된 인증코드 함께 저장
        expiryStorage.put(smsRequest.getTo(), expirationTime);

        Message message = new Message();
        message.setFrom(smsRequest.getFrom());
        message.setTo(smsRequest.getTo());
        message.setText("[유쾌 커뮤니티] 본인확인 인증번호 [" + authCode + "]입니다. \"타인노출 금지\"");

        messageService.sendOne(new SingleMessageSendingRequest(message));
    }

생성된 인증 코드를 HashMap을 이용하여 서버에 저장하고 사용자에게 문자를 전송하는 로직을 작성한다.

smsAuthCode()로 6자리로 된 인증 번호를 생성하고, codeStorage로 지정한 변수에 저장한다.

 

이메일 구현 때는, 토큰과 만료시간을 매핑하여 tokenStorage에 함께 저장하여 관리하였고,

이번 SMS 인증에는 핸드폰 번호와 인증 코드를 매핑하였기 때문에, 따로 만료시간을 추가하여 관리해야 한다.

그리고 coolSMS를 사용하여 인증번호를 포함하여 메시지를 발송한다.

// 인증번호를 포함한 단일 메세지 발송
@PostMapping("sendSmsWithAuthCode")
public ResponseEntity<String> sendSmsWithAuthCode(@RequestBody SmsRequestDTO smsRequest) {
    smsService.sendSmsWithAuthCode(smsRequest);
    return ResponseEntity.ok("인증 코드가 전송되었습니다.");
}

 

POST /api/sms/sendSmsWithAuthCode

Content-Type: application/json

{ "from": "coolSMS에 등록된 발신번호",

   "to": "testNumber" } 로 Postman에서 테스트한다.

 

서버에서의 로직은 끝났다. 이제 HTML과 자바스크립트 함수를 작성하여 서버와 연결하자.

<div class="mb-3">
    <div class="input-group">
        <input type="tel" id="phone" name="phone" class="form-control" required maxlength="20"
               placeholder="전화번호를 입력하세요 (010-1234-5678)"
               pattern="^\d{3}-\d{3,4}-\d{4}$">
        <button type="button" class="btn btn-outline-dark" onclick="sendSmsAuthCode()">발송</button>
    </div>
</div>
<div class="mb-3">
    <div class="input-group">
        <input type="text" id="authCode" name="authCode" class="form-control" required maxlength="6"
               placeholder="인증 번호를 입력하세요 (6자리 숫자)">
        <button type="button" class="btn btn-outline-dark" onclick="checkAuthCode()">인증</button>
    </div>
    <!-- 결과 메시지 출력 -->
    <div id="phoneVerificationResult" class="text-danger mt-2"></div>
</div>

 

form.html에 이미 작성되어있던 전화번호 입력 부분을 약간 수정했다.

발송 버튼의 함수 이름을 sendSmsAuthCode()라고 바꾸었고, 인증 번호를 입력 받을 input과 인증할 버튼을 추가하였다.

 

인증 번호가 담긴 메시지를 "발송"하고, 사용자가 입력한 인증번호를 "인증"하는 두 가지 함수를 만들어야 한다.

먼저, 인증번호를 발송하는 함수를 구현한다.

// 인증 번호를 포함한 메시지 발송 함수
function sendSmsAuthCode() {
    const inputPhone = document.getElementById('phone').value.trim();
    const phoneVerificationResult = document.getElementById('phoneVerificationResult');

    phoneVerificationResult.classList.remove("text-success", "text-danger");

    // 핸드폰 번호 입력 여부
    if(!inputPhone) {
        phoneVerificationResult.textContent = "";
        alert("핸드폰 번호를 입력하세요.");
        return;
    }
    // 숫자만 추출하여 phoneNumber에 저장
    const phoneNumber = inputPhone.replace(/[^0-9]/g, "");
    // 유효성 검사
    if (phoneNumber.length < 10 || phoneNumber.length > 11) {
        phoneVerificationResult.innerText = "유효한 전화번호 형식이 아닙니다. (예: 01012345678)";
        phoneVerificationResult.classList.add("text-danger");
        return;
    }

이 함수는 사용자가 입력한 전화번호를 서버에 전송하고, 서버에서 인증번호를 문자로 발송한다.

메세지 전송에 사용될 발신번호는 coolSMS에 등록한 번호이다. 노출되면 안 될 것이다.

coolsms.api.key=${COOLSMS_API_KEY}
coolsms.api.secret=${COOLSMS_API_SECRET}
coolsms.from.number=${COOLSMS_NUMBER}

 

application.properties이다.

메시지 발송과 관련된 API 키, Secret 키 그리고 발신번호는 보안상 노출되면 안 되는 중요한 내용으로 시스템 환경 변수에 등록하고 Spring에서 이를 설정 파일을 통해 불러와 사용하고 있다.

private final String fromNumber;

public SmsService(@Value("${coolsms.api.key}") String apiKey,
                  @Value("${coolsms.api.secret}") String apiSecret,
                  @Value("${coolsms.from.number}") String fromNumber) {
    this.messageService = NurigoApp.INSTANCE.initialize(apiKey, apiSecret, "https://api.coolsms.co.kr");
    this.fromNumber = fromNumber; // 환경 변수에서 가져온 발신 번호
}

@Value 어노테이션을 통해 설정 파일에 등록된 값을 가져온다. 이 값들은 생성자의 파라미터에 자동으로 주입된다.

이 생성자 주입을 통해, SmsService가 빈으로 등록될 때, 필요한 값이 자동으로 주입된다. 그리고 그 주입된 값은 CoolSMS API를 초기화하는데 사용 된다.

 

Bean이 등록된다는건 어떤 의미인가?

Spring이 실행될 때 @Service를 통해 SmsService 객체를 생성하고, 생성자에 필요한 값은 @Value를 사용해 주입한다.

이렇게 생성된 객체는 Spring 컨테이너에 저장 되고, 다른 클래스에서 SmsService를 사용하려고 하면, Spring이 컨테이너에 저장된 객체를 찾아 자동으로 주입한다.

예를 들어, 컨트롤러에서 SmsService를 사용한다면

@RestController
@RequiredArgsConstructor
public class SmsController {
    private final SmsService smsService;

    @PostMapping("/sendSms")
    public ResponseEntity<String> sendSms(@RequestBody SmsRequestDTO smsRequest) {
        smsService.sendSms(smsRequest);
        return ResponseEntity.ok("문자 발송 완료!");
    }
}

위와 같이 @RequiredArgsConstructor나 @Autowired를 사용하면 Spring이 알아서 SmsService 객체를 주입한다

그렇다면 Spring의 Bean 관리를 사용하지 않으면?

public SmsController() {  
    this.smsService = new SmsService("yourApiKey", "yourApiSecret", "01012345678");  
}

직접 객체를 생성해야 한다. 사용할 때마다 각 클래스에서 반복적으로 생성해야하여 메모리 낭비가 발생한다.

 

자, 이제 메세지로 받은 인증번호를 서버에 저장된 인증번호를 비교해서 검증하는 기능을 구현하자.

HashMap에 저장한 값과 비교하는 로직을 작성하면 끝!

// 인증번호 검증
public boolean verifyAuthCode(String to, String authCode){
    String storageCode = codeStorage.get(to);
    Long expiryTime = expiryStorage.get(to);

    if (storageCode == null || expiryTime == null || System.currentTimeMillis() > expiryTime) {
        codeStorage.remove(to);
        expiryStorage.remove(to);
        return false; // 인증 실패: 코드 없음, 만료됨
    }
    return storageCode.equals(authCode); // 인증 성공 여부 반환
}
// 인증번호 검증
@PostMapping("verifyAuthCode")
public ResponseEntity<String> verifyAuthCode(@RequestParam String to, @RequestParam String authCode) {
    boolean result = smsService.verifyAuthCode(to, authCode);
    if (result) {
        return ResponseEntity.ok("인증에 성공했습니다.");
    } else {
        return ResponseEntity.badRequest().body("인증에 실패했습니다. 코드가 일치하지 않거나 만료되었습니다.");
    }
}
private final Map<String, String> codeStorage = new HashMap<>(); // 전화번호와 인증 코드 저장
private final Map<String, Long> expiryStorage = new HashMap<>(); // 코드 만료 시간 저장

인증번호는 HashMap에 저장되며 만료 시간도 관리된다. 

 

sendSmsWithAuthCode(SmsRequestDTO smsRequest) 메서드로 메세지를 발송하면,

codeStorage.put(smsRequest.getTo(), authCode); 
expiryStorage.put(smsRequest.getTo(), expirationTime);

위의 코드에서 수신번호를 키로, 각각 인증번호와 만료시간을 선언해둔 HashMap에 저장하게 된다.

verifyAuthCode(String to, String authCode) 메서드를 통해 입력된 번호와 인증번호를 확인한다.

인증번호가 존재하지 않거나, 만료 시간이 지났거나, 입력된 번호와 저장된 번호가 일치하지 않으면, 인증에 실패한다.

 

컨트롤러에서는 @PostMapping("/verifyAuthCode")를 사용해 클라이언트로부터 인증번호와 수신번호를 받아 검증한다.

검증이 성공하면 200 OK와 함께 메시지를 반환하고, 실패하면 400 Bad Request와 메시지를 반환한다.

그리고 이 값은 자바스크립트에서 data로 넘겨받아

.then(response => {
    if (response.ok) return response.text();
    else throw new Error("인증에 실패했습니다.");
})

간단하게 표현할 수 있다.

이 로직을 통해 인증번호의 유효성을 확인하고, 만료하는 기능까지 완성하였다!

 

이제 직접 메세지를 발송하고 인증이 되는지 확인해보자.

먼저, 메시지 발송을 성공하였다. 이어 인증 번호를 입력하여 인증 버튼을 클릭하며 검증을 했는데

마찬가지로 성공하였다.


 개발 회고 

이번 SMS 인증 구현은 이메일 구현과 비슷하지만 다른 흐름과 코드를 경험할 수 있었다. 

처음에는 이전에 사용해본 적 있는 Naver Cloud SMS를 사용하려고 했는데, 변경된 약관으로 사업자만 사용할 수 있어 할 수 없었다. 다음으로 선택한 카카오 API는 심사 대기까지 시간이 걸려서 적절하지 않았고, 여러 검색 끝에 CoolSMS를 발견하였다. 가입절차도 간단하고 친절한 튜토리얼이 있어서 빠르게 적응할 수 있었고, 게다가 50건의 무료 문자 발송까지  제공해주다니... 덕분에 부담을 줄일 수 있었다.

 

이번 작업에서도 가장 신경 쓴 부분은 보안과 유지보수성이었다. API 키와 발신번호는 환경 변수로 관리하였고 email에서 처럼 DTO를 사용하여 파라미터를 가독성있고 관리하기 좋게 하였다. 또한, HashMap을 통해 인증번호와 만료시간을 관리하고 인증 성공과 실패에 따른 HTTP 상태코드를 반환하도록 구현하였다. 오늘 개발을 하면서 인증 코드를 생성하는 로직을 작성할 때 Random과 UUID를 떠올렸는데, 배운 내용을 실제로 활용할 수 있어 뿌듯했다. 또, 두 가지 방식 중 숫자만 사용하는 방식이 사용자에게 더  친숙하다고 판단하고 선택한 것도 개발자에 대해 조금은 이해하고 있지 않나 생각이 들었다. 아직은 개념이 좀 흔들리고 코드를 몇 번이고 작성하고, 반복해서 확인해야 하지만, 이런 과정을 통해 개발 흐름에 익숙해지고 있다는 확신이 든다. 회원 가입에 필요한 다음 주소 API, STMP 이메일 인증, CoolSMS를 활용한SMS 인증 그리고 기본적인 유효성 검사를 철저히 진행하고, 설계했던 기능들을 모두 구현하고 나니 기분이 정말 좋다. 이런 작은 성취들이 쌓이면서 곧 프로젝트가 완성될 것이고, 이 경험을 바탕으로 언젠가 더 큰 시스템도 자신있게 만들 수 있을 것이라고 믿는다!

+ Recent posts