본문 바로가기
study/Spring

[Spring Boot] 전화번호 인증 with NCP SMS API & Redis

by eo_neunal 2023. 8. 3.

0. Naver SMS API 선택 이유

SMS 서비스에 ‘Cool SMS’를 많이 사용하지만, Nava SMS API의 경우 매월 50건의 무료메세지 발송 및 Naver Cloud Platform 첫 가입 시 10만 크레딧을 제공한다고 하여 선택하게 됐다. (할인 크레딧 신청)

해당 크레딧은 네이버 클라우드 플랫폼에서 제공하는 다른 솔루션에도 사용할 수 있으니 유용하게 쓸 수 있다. (단, 실제로 결제가 가능한 카드를 연동해야 한다.)

1. NCP 세팅

1.1. SMS 프로젝트 생성

  1. 콘솔 > Services > ‘Simple & Easy Notification Service’ 선택

  1. 프로젝트 생성하기

1.2. 발신번호 등록

SMS 메시지를 발송하기 위해서는 발신번호를 등록해야한다.

  1. Project > 발신번호 등록할 프로젝트 ‘SMS’ 선택

  1. Calling Number > ‘발신번호 등록’ 선택

  1. 본인 휴대전화 인증 > 확인

1.3. 액세스 키 발급

  1. 마이페이지 > 계정 관리 > 인증키 관리 > 신규 API 인증키 생성

 

  1. 생성된 액세스 키와 시크릿 키를 저장해 놓는다.

1.4. 서비스 ID 발급

  1. 콘솔 > Simple & Easy Notification Service > 서비스 ID

  1. ID 값을 저장해 놓는다.

2. Spring Boot SMS 전송 구현

API 요청과 응답에 대한 정보는 공식 문서에서 확인할 수 있다.

2.1. build.gradle 수정

build.gradle에 아래 내용을 추가한다.

implementation 'org.apache.httpcomponents:httpclient:4.5.14'

2.2. application.properties 수정

application.properties에 아래 내용을 추가한다.

ncp.accessKey=${NCP_ACCESS_KEY}
ncp.secretKey=${NCP_SECRET_KEY}
ncp.sms.serviceId=${NCP_SMS_SERVICE_ID}
ncp.sms.sender=${NCP_SMS_SERVICE_SENDER}

2.3. 구현

| RestTemplateConfig.class

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
        return restTemplate;
    }
}

추후 SMS 서비스 교체 가능성을 고려해 SmsService를 추상화했다.

| SmsService.java

public interface SmsService {

    void createVerificationCodeForSignup(SignUpVerificationCodeRequest signUpVerificationCodeRequest) throws JsonProcessingException, UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException;
}

| NCPSmsService.java

@RequiredArgsConstructor
@Service
public class NCPSmsService implements SmsService {

    private static final int VERIFICATION_CODE_LENGTH = 6;
    private static final String TYPE = "SMS";
    private static final String SMS_SEND_SUCCESS = "202";
    private static final String CHARSET_NAME = "UTF-8";
    private static final String SECRET_KEY_ALGORITHM = "HmacSHA256";
    private static final String BLANK = " ";
    private static final String NEW_LINE = "\n";

    private final ObjectMapper objectMapper;
    private final RestTemplate restTemplate;

    @Value("${ncp.accessKey}")
    private String accessKey;

    @Value("${ncp.secretKey}")
    private String secretKey;

    @Value("${ncp.sms.serviceId}")
    private String serviceId;

    @Value("${ncp.sms.sender}")
    private String phone;

    @Override
    public void createVerificationCodeForSignup(SignUpVerificationCodeRequest signUpVerificationCodeRequest) throws JsonProcessingException, UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException {
        if (!sendSms(signUpVerificationCodeRequest).getStatusCode().equals(SMS_SEND_SUCCESS)) {
            throw new SmsSendFailException(ApplicationError.SMS_SEND_FAIL);
        }
    }

    private SmsResponse sendSms(SignUpVerificationCodeRequest signUpVerificationCodeRequest) throws JsonProcessingException, NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException {
        return restTemplate.postForObject(
                URI.create("https://sens.apigw.ntruss.com/sms/v2/services/" + serviceId + "/messages"),
                new HttpEntity<>(objectMapper.writeValueAsString(getSmsRequest(signUpVerificationCodeRequest)), getHttpHeaders()),
                SmsResponse.class);
    }

    private SmsRequest getSmsRequest(SignUpVerificationCodeRequest signUpVerificationCodeRequest) {
        return SmsRequest.builder()
                .type(TYPE)
                .from(phone)
                .content(getVerificationCodeMessage())
                .messages(List.of(signUpVerificationCodeRequest))
                .build();
    }

    private static String getVerificationCodeMessage() {
        return String.format("[직팅]\n인증번호: %s", VerificationCodeGenerator.generate(VERIFICATION_CODE_LENGTH));
    }

    private HttpHeaders getHttpHeaders() throws NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException {
        String time = String.valueOf(System.currentTimeMillis());
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("x-ncp-apigw-timestamp", time);
        headers.set("x-ncp-iam-access-key", accessKey);
        headers.set("x-ncp-apigw-signature-v2", getSignature(time));
        return headers;
    }

    private String getSignature(String time) throws NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException {
        Mac mac = Mac.getInstance(SECRET_KEY_ALGORITHM);
        mac.init(new SecretKeySpec(secretKey.getBytes(CHARSET_NAME), SECRET_KEY_ALGORITHM));
        return Base64.encodeBase64String(mac.doFinal(getMessage(time).getBytes(CHARSET_NAME)));
    }

    private String getMessage(String time) {
        return HttpMethod.POST.name() + BLANK + String.format("/sms/v2/services/%s/messages", serviceId)
                + NEW_LINE + time
                + NEW_LINE + accessKey;
    }
}

| VerificationCodeGenerator.java

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class VerificationCodeGenerator {

    private static final Random RANDOM = new Random();
    private static final String DELIMITER = "";
    private static final int VERIFICATION_CODE_RANGE = 9;

    public static String generate(int verificationCodeLength) {
        return String.join(DELIMITER, IntStream.range(0, verificationCodeLength)
                .mapToObj(n -> RANDOM.nextInt(VERIFICATION_CODE_RANGE))
                .map(String::valueOf)
                .toArray(CharSequence[]::new));
    }
}

| SmsRequest.java

@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SmsRequest {

    private String type;
    private String from;
    private String content;
    private List<SignUpVerificationCodeRequest> messages;
}

| SmsResponse.java

@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SmsResponse {

    private String requestId;
    private LocalDateTime requestTime;
    private String statusCode;
    private String statusName;
}

| SignUpVerificationCodeRequest.java

@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SignUpVerificationCodeRequest {

    private String to;
}

| SmsSendFailException.java

public class SmsSendFailException extends RuntimeException {

    public SmsSendFailException(ApplicationError error) {
        super(error.getMessage());
    }
}

| ApplicationError.java

@Getter
@RequiredArgsConstructor
public enum ApplicationError {

    ...
    SMS_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "C012", "SMS 전송 요청에 실패했습니다.");

    private final HttpStatus status;
    private final String code;
    private final String message;
}

| MemberController.java

@RequiredArgsConstructor
@RestController
@RequestMapping("/members")
public class MemberController {

    private final SmsService smsService;

    ...

    @PostMapping("/code")
    public ResponseEntity<Void> createVerificationCodeForSignup(@RequestBody SignUpVerificationCodeRequest signUpVerificationCodeRequest) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException {
        smsService.createVerificationCodeForSignup(signUpVerificationCodeRequest);
        return ResponseEntity.ok().build();
    }
}

2.4. 테스트 with 포스트맨

3. 인증번호 저장 with Redis

인증번호 발송 후 사용자가 입력한 인증번호가 서비스에서 해당 사용자에게 발급한 인증번호인지 확인하기 위해 저장을 해야한다.

이때 만료기간을 설정할 수 있는 Redis에 저장하도록 했다.

3.1. build.gradle 수정

build.gradle에 아래 내용을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

3.2. application.properties 수정

application.properties에 아래 내용을 추가한다.

spring.redis.host=${REDIS_HOST}
spring.redis.port=${REDIS_PORT}

3.3. 구현

| RedisConfig.java

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration();
        redisConfiguration.setHostName(host);
        redisConfiguration.setPort(port);
        return new LettuceConnectionFactory(redisConfiguration);
    }
}

| RedisConnector.java

@RequiredArgsConstructor
@Component
public class RedisConnector {

    private final RedisTemplate<String, String> redisTemplate;

    public void set(String key, String value, int expireTime) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        valueOperations.set(key, value, Duration.ofMinutes(expireTime));
    }
}

| NCPSmsService.java

기존 코드에 Redis 저장 로직 추가

아직 사용자가 가입하기 전이기 때문에 사용자의 데이터가 없어서 key값으로 요청으로 들어온 전화번호를 사용했다.

@RequiredArgsConstructor
@Service
public class NCPSmsService implements SmsService {

    private static final int VERIFICATION_CODE_LENGTH = 6;
    private static final int EXPIRE_TIME = 3;
    private static final String TYPE = "SMS";
    private static final String SMS_SEND_SUCCESS = "202";
    private static final String CHARSET_NAME = "UTF-8";
    private static final String SECRET_KEY_ALGORITHM = "HmacSHA256";
    private static final String BLANK = " ";
    private static final String NEW_LINE = "\n";

    private final RedisConnector redisConnector;
    private final ObjectMapper objectMapper;
    private final RestTemplate restTemplate;

    @Value("${ncp.accessKey}")
    private String accessKey;

    @Value("${ncp.secretKey}")
    private String secretKey;

    @Value("${ncp.sms.serviceId}")
    private String serviceId;

    @Value("${ncp.sms.sender}")
    private String phone;

    @Override
    public void createVerificationCodeForSignup(SignUpVerificationCodeRequest signUpVerificationCodeRequest) throws JsonProcessingException, UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException {
        if (!sendSms(signUpVerificationCodeRequest).getStatusCode().equals(SMS_SEND_SUCCESS)) {
            throw new SmsSendFailException(ApplicationError.SMS_SEND_FAIL);
        }
    }

    private SmsResponse sendSms(SignUpVerificationCodeRequest signUpVerificationCodeRequest) throws JsonProcessingException, NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException {
        return restTemplate.postForObject(
                URI.create("https://sens.apigw.ntruss.com/sms/v2/services/" + serviceId + "/messages"),
                new HttpEntity<>(objectMapper.writeValueAsString(getSmsRequest(signUpVerificationCodeRequest, generateVerificationCode(signUpVerificationCodeRequest))), getHttpHeaders()),
                SmsResponse.class);
    }

    private String generateVerificationCode(SignUpVerificationCodeRequest signUpVerificationCodeRequest) {
        String verificationCode = VerificationCodeGenerator.generate(VERIFICATION_CODE_LENGTH);
        redisConnector.set(signUpVerificationCodeRequest.getTo(), verificationCode, EXPIRE_TIME);
        return verificationCode;
    }

    private SmsRequest getSmsRequest(SignUpVerificationCodeRequest signUpVerificationCodeRequest, String verificationCode) {
        return SmsRequest.builder()
                .type(TYPE)
                .from(phone)
                .content(getVerificationCodeMessage(verificationCode))
                .messages(List.of(signUpVerificationCodeRequest))
                .build();
    }

    private String getVerificationCodeMessage(String verificationCode) {
        return String.format("[직팅]\n인증번호: %s", verificationCode);
    }

    private HttpHeaders getHttpHeaders() throws NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException {
        String time = String.valueOf(System.currentTimeMillis());
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("x-ncp-apigw-timestamp", time);
        headers.set("x-ncp-iam-access-key", accessKey);
        headers.set("x-ncp-apigw-signature-v2", getSignature(time));
        return headers;
    }

    private String getSignature(String time) throws NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException {
        Mac mac = Mac.getInstance(SECRET_KEY_ALGORITHM);
        mac.init(new SecretKeySpec(secretKey.getBytes(CHARSET_NAME), SECRET_KEY_ALGORITHM));
        return Base64.encodeBase64String(mac.doFinal(getMessage(time).getBytes(CHARSET_NAME)));
    }

    private String getMessage(String time) {
        return HttpMethod.POST.name() + BLANK + String.format("/sms/v2/services/%s/messages", serviceId)
                + NEW_LINE + time
                + NEW_LINE + accessKey;
    }
}

3.4. Redis 저장 테스트

아까와 동일하게 포스트맨으로 요청을 보낸 후 터미널 창에서 redis-cli에 접속한다.

$ redis-cli

get 전화번호로 인증번호가 저장됐음을 확인할 수 있다.

References

[Spring Boot] SMS 전송 - NAVER SMS API 연동

[Spring] 이메일 인증 번호 전송, 유효 시간 (Gmail SMTP Server, Redis)