[SpringBoot] CoolSMS, Google SMTP를 이용한 sms, email 인증번호 발송, 검증 기능 구현

ggwaang·2025년 5월 16일

프로젝트에서 사용자의 회원가입 시 추후에 사용할 전화번호와 이메일을 저장하고 사용하기 위해 인증 기능을 입하게 되었다.

이메일 관련 부분은 이미 프로젝트에서 카카오 소셜 로그인을 통해 카카오에서 사용하는 이메일을 저장하고 있지만 실제 카카오톡 회원가입 시에 사용한 이메일을 주 이메일로 사용하고 있지 않는 사람도 많다고 생각했기에 인증된 추가 이메일 certification_email을 새로 저장하여 관리하기로 했다.

NAVER CLOUD PlATFORM[SENS API] / COOLSMS 두 가지의 문자발송대행 서비스가 있었지만 네이버의 SENS API는 사용자 등록이 되어있어야 사용이 가능하다고 해서 CoolSMS를 이용하여 구현했다.

  • 인증번호를 발송한 후 검증 시에 db에 저장하여 확인하는 것은 매우 비효율적이라고 생각해 redis를 사용해 전송 시에 사용한 이메일 : 인증번호 / 전화번호 : 인증번호 로 검증 전 까지 임시 저장을 해두어 검증이 끝난 후에 삭제하는 방식으로 검증을 구현했다.

[CoolSMS] - https://coolsms.co.kr/

  1. 사이트 접속 후 회원가입을 진행한다
    • 전화번호는 개인 전화번호를 사용했다
  2. 메뉴에서 개발/연동 - API KEY 관리로 들어간 후 API KEY를 발급받는다
    • 일단 모든 IP에서 사용을 허용했다, 추후 서비스 도메인 ip에서만 사용할 수 있도록 수정할 계획이다

→ 여기까지 했으면 coolSMS에서 할 설정은 끝났다 - 매우 간단한 것 같다

  1. build.gradle에 의존성 추가

    implementation 'net.nurigo:sdk:4.3.0'
  2. application.yml 설정 추가

    coolsms:
      api-key: {coolsms.api-key}
      api-secret: {coolsms.api-secret}
      from-number: {coolsms.from-number}
    • from-number는 회원가입 시 사용했던 번호, 실제로 존재하는 번호로 설정 해야된다.
  3. 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));
        }
    }
    
  4. 구현

    • dto - Request
      @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
      @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));
          }
      }
      
      • 4자리 인증 코드 생성하여 인증번호를 발송한다
      • 사용자가 받은 인증번호를 검증하기 전 로그인 된 사용자인지 이메일로 식별
      • 동일한 전화번호가 다른 사용자에게 이미 등록되어 있는지 검사
      • 그 후 인증이 되면 사용자 테이블에 전화번호를 저장
      • 그 후 레디스에 저장해놨던 키를 삭제
  • Controller
    @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);
        }
    }
  • RedisCertificationCache
    @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);
        }
    }

[Google SMTP]

  1. 구글 로그인
  2. 2단계 인증 진행 - 했다면 3번으로 넘어가도 좋다
  3. 검색창에 앱 비밀번호 검색
  4. 앱 비밀번호 생성
  5. 구글 Gmail로 이동하여 설정
  6. 전달 및 POP/IMAP - 설정(이미지처럼)
  7. 구현
    • build.gradle
      	implementation 'org.springframework.boot:spring-boot-starter-mail'
    • application.yml
      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
    • EmailCertificationUtil
      @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);
          }
      }
  • dto
    @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
    @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));
        }
    }
  • Controller
    @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);
        }
    }
  • 이메일도 sms와 같은 형식으로 전송 및 검증 기능을 구현했다

0개의 댓글