프로젝트에서 사용자의 회원가입 시 추후에 사용할 전화번호와 이메일을 저장하고 사용하기 위해 인증 기능을 입하게 되었다.
이메일 관련 부분은 이미 프로젝트에서 카카오 소셜 로그인을 통해 카카오에서 사용하는 이메일을 저장하고 있지만 실제 카카오톡 회원가입 시에 사용한 이메일을 주 이메일로 사용하고 있지 않는 사람도 많다고 생각했기에 인증된 추가 이메일 certification_email을 새로 저장하여 관리하기로 했다.
NAVER CLOUD PlATFORM[SENS API] / COOLSMS 두 가지의 문자발송대행 서비스가 있었지만 네이버의 SENS API는 사용자 등록이 되어있어야 사용이 가능하다고 해서 CoolSMS를 이용하여 구현했다.
→ 여기까지 했으면 coolSMS에서 할 설정은 끝났다 - 매우 간단한 것 같다
build.gradle에 의존성 추가
implementation 'net.nurigo:sdk:4.3.0'
application.yml 설정 추가
coolsms:
api-key: {coolsms.api-key}
api-secret: {coolsms.api-secret}
from-number: {coolsms.from-number}
SmsCertificationUtil
@Component
public class SmsCertificationUtil {
@Value("${coolsms.api-key}")
private String apiKey;
@Value("${coolsms.api-secret}")
private String apiSecret;
@Value("${coolsms.from-number}")
private String fromNumber;
DefaultMessageService messageService;
@PostConstruct
public void init() {
this.messageService = NurigoApp.INSTANCE.initialize(apiKey, apiSecret, "https://api.coolsms.co.kr");
}
public void sendSMS(String to, String certificationCode){
Message message = new Message();
message.setFrom(fromNumber);
message.setTo(to);
message.setText("본인확인 인증번호는 " + certificationCode + "입니다.");
this.messageService.sendOne(new SingleMessageSendingRequest(message));
}
}
구현
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class SmsRequestDTO {
@NotEmpty(message = "휴대폰 번호를 입력해주세요")
private String phoneNum;
}인증번호를 받기 위한 사용자의 휴대폰 번호 입력
@Getter
@Setter
public class SmsVerifyRequestDTO {
@NotEmpty(message = "휴대폰 전화번호를 입력해주세요")
private String phoneNum;
@NotEmpty(message = "인증번호를 입력해주세요")
private String certificationCode;
}
사용자가 문자로 받은 인증번호와 확인에 사용할 발송할 때 사용한 휴대폰 번호
@Service
@RequiredArgsConstructor
@Slf4j
public class SmsService {
private static final int CODE_LEN = 4;
private static final Duration TTL = Duration.ofMinutes(3);
private static final String PREFIX = "otp:sms:";
private final SmsCertificationUtil smsUtil;
private final SmsCertificationCache cache;
private final UserRepository userRepository;
/* 1) 인증번호 발송 */
public void sendCertificationCode(SmsRequestDTO dto) {
String code = generate();
if (!cache.store(PREFIX + dto.getPhoneNum(), code, TTL)) {
throw new BusinessException(ErrorCode.CERTIFICATION_DUPLICATED);
}
smsUtil.sendSMS(dto.getPhoneNum(), code);
log.debug("sms code {} → {}", code, dto.getPhoneNum());
}
/* 2) 인증번호 검증 & 전화번호 저장 */
@Transactional
public void verifyCertificationCode(SmsVerifyRequestDTO dto) {
String key = PREFIX + dto.getPhoneNum();
String saved = cache.get(key);
if (saved == null)
throw new BusinessException(ErrorCode.CERTIFICATION_EXPIRED);
if (!saved.equals(dto.getCertificationCode()))
throw new BusinessException(ErrorCode.CERTIFICATION_MISMATCH);
// 현재 로그인 사용자를 이메일로 식별
String loginEmail = AuthenticatedUserUtils.getAuthenticatedUserEmail();
User user = userRepository.findByEmail(loginEmail)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 동일 전화번호가 다른 사용자에게 이미 등록돼 있는지 검사
userRepository.findByPhoneNumber(dto.getPhoneNum())
.filter(other -> !other.getId().equals(user.getId()))
.ifPresent(any -> {
throw new BusinessException(ErrorCode.PHONE_ALREADY_USED);
});
/* 전화번호 저장 */
user.setPhoneNumber(dto.getPhoneNum());
cache.remove(key); // 성공 후 1회성 삭제
log.debug("✅ {} verified & saved to user {}", dto.getPhoneNum(), loginEmail);
}
/* 4자리 숫자 코드 생성 */
private String generate() {
int bound = (int) Math.pow(10, CODE_LEN);
return String.format("%0" + CODE_LEN + "d",
ThreadLocalRandom.current().nextInt(bound));
}
}
@RestController
@RequestMapping("/api/sms")
@RequiredArgsConstructor
public class SmsController {
private final SmsService smsService;
/** 인증번호 발송 **/
@PostMapping("/send")
public CommonResponse<Void> sendSms(@Valid @RequestBody SmsRequestDTO dto) {
smsService.sendCertificationCode(dto);
return CommonResponse.ok(ResultCode.OK);
}
/**
* 인증번호 검증
**/
@PostMapping("/verify")
public CommonResponse<Void> verifySms(@Valid @RequestBody SmsVerifyRequestDTO dto) {
smsService.verifyCertificationCode(dto);
return CommonResponse.ok(ResultCode.OK);
}
}@Component
@RequiredArgsConstructor
public class RedisCertificationCache implements CertificationCache {
private final StringRedisTemplate redis;
@Override
public boolean store(String key, String code, Duration ttl) {
Boolean ok = redis.opsForValue().setIfAbsent(key, code, ttl); // Lettuce 6+
return Boolean.TRUE.equals(ok);
}
@Override
public String get(String key) {
return redis.opsForValue().get(key);
}
@Override
public void remove(String key) {
redis.delete(key);
}
}
implementation 'org.springframework.boot:spring-boot-starter-mail'spring:
mail:
host: smtp.gmail.com
port: 587
username: .....@gmail.com
password: .....
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
mail.smtp.starttls.required: true
app:
email:
from: @gmail.com@Component
@RequiredArgsConstructor
@Slf4j
public class EmailCertificationUtil {
private final JavaMailSender mailSender;
@Value("${app.email.from}")
private String from;
@Async
public void sendMail(String to, String code) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(
message,
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
StandardCharsets.UTF_8.name()
);
helper.setTo(to);
helper.setFrom(from, "TEST");
helper.setSubject("[TEST] 이메일 인증번호");
helper.setText(buildHtml(code), true)
mailSender.send(message);
log.debug("인증 메일 전송 → {}", to);
} catch (MessagingException | MailException | UnsupportedEncodingException e) {
log.error("❌ 이메일 전송 실패. to={}, cause={}", to, e.getMessage(), e);
throw new IllegalStateException("이메일 전송에 실패했습니다.");
}
}
private String buildHtml(String code) {
return """
<p>안녕하세요, TEST입니다.</p>
<p>아래 인증번호를 입력해 주세요.</p>
<h2 style="letter-spacing:4px">%s</h2>
<p>(유효 시간: 3분)</p>
""".formatted(code);
}
}@Getter
@Setter
public class EmailRequestDTO {
@Email(message = "유효한 이메일을 입력해주세요")
private String email;
}@Getter
@Setter
public class EmailVerifyRequestDTO {
@Email(message = "유효한 이메일을 입력해주세요")
private String email;
@NotEmpty(message = "인증번호를 입력해주세요")
private String certificationCode;
}@Service
@RequiredArgsConstructor
@Slf4j
public class EmailService {
private static final int CODE_LEN = 4;
private static final Duration TTL = Duration.ofMinutes(3);
private static final String PREFIX = "otp:email:";
private final EmailCertificationUtil mailUtil;
private final SmsCertificationCache cache;
private final UserRepository userRepository;
private final SecureRandom random = new SecureRandom();
/* 1) 인증번호 발송 */
public void sendCode(EmailRequestDTO dto) {
String code = generate();
if (!cache.store(PREFIX + dto.getEmail(), code, TTL)) {
throw new BusinessException(ErrorCode.CERTIFICATION_DUPLICATED);
}
mailUtil.sendMail(dto.getEmail(), code);
log.debug("email code {} → {}", code, dto.getEmail());
}
/* 2) 인증번호 검증 */
@Transactional
public void verify(EmailVerifyRequestDTO dto) {
String key = PREFIX + dto.getEmail();
String saved = cache.get(key);
if (saved == null)
throw new BusinessException(ErrorCode.CERTIFICATION_EXPIRED);
if (!saved.equals(dto.getCertificationCode()))
throw new BusinessException(ErrorCode.CERTIFICATION_MISMATCH);
String loginEmail = AuthenticatedUserUtils.getAuthenticatedUserEmail();
User user = userRepository.findByEmail(loginEmail)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
user.setCertificationEmail(dto.getEmail());
cache.remove(key);
log.debug("✅ {} verified & saved to user {}", dto.getEmail(), loginEmail);
}
private String generate() {
int bound = (int) Math.pow(10, CODE_LEN);
return String.format("%0" + CODE_LEN + "d", random.nextInt(bound));
}
}@RestController
@RequestMapping("/api/email")
@RequiredArgsConstructor
public class EmailController {
private final EmailService emailService;
/** 인증번호 발송 */
@PostMapping("/send")
public CommonResponse<Void> send(@Valid @RequestBody EmailRequestDTO dto) {
emailService.sendCode(dto);
return CommonResponse.ok(ResultCode.OK);
}
/** 인증번호 검증 */
@PostMapping("/verify")
public CommonResponse<Void> verify(@Valid @RequestBody EmailVerifyRequestDTO dto) {
emailService.verify(dto);
return CommonResponse.ok(ResultCode.OK);
}
}