0. Naver SMS API 선택 이유
SMS 서비스에 ‘Cool SMS’를 많이 사용하지만, Nava SMS API의 경우 매월 50건의 무료메세지 발송 및 Naver Cloud Platform 첫 가입 시 10만 크레딧을 제공한다고 하여 선택하게 됐다. (할인 크레딧 신청)
해당 크레딧은 네이버 클라우드 플랫폼에서 제공하는 다른 솔루션에도 사용할 수 있으니 유용하게 쓸 수 있다. (단, 실제로 결제가 가능한 카드를 연동해야 한다.)
1. NCP 세팅
1.1. SMS 프로젝트 생성
- 콘솔 > Services > ‘Simple & Easy Notification Service’ 선택
- 프로젝트 생성하기
1.2. 발신번호 등록
SMS 메시지를 발송하기 위해서는 발신번호를 등록해야한다.
- Project > 발신번호 등록할 프로젝트 ‘SMS’ 선택
- Calling Number > ‘발신번호 등록’ 선택
- 본인 휴대전화 인증 > 확인
1.3. 액세스 키 발급
- 마이페이지 > 계정 관리 > 인증키 관리 > 신규 API 인증키 생성
- 생성된 액세스 키와 시크릿 키를 저장해 놓는다.
1.4. 서비스 ID 발급
- 콘솔 > Simple & Easy Notification Service > 서비스 ID
- 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
'study > Spring' 카테고리의 다른 글
[Spring Boot] 이메일 인증 with NCP & Redis (0) | 2023.09.25 |
---|---|
[Spring WebSocket] Redis를 이용한 채팅 고도화 (0) | 2023.09.01 |
[Spring Boot] AWS S3 이미지 업로드 (0) | 2023.08.03 |
[Spring WebSocket] Spring WebSocket STOMP 적용 (3) | 2023.06.19 |
[Spring REST Docs] Spring REST Docs 적용기 (0) | 2023.05.31 |