본문 바로가기
프로젝트/토이 프로젝트

Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 4

by 진꿈청 2024. 2. 21.

이번 글은 이메일 인증을 구현한 내용이다.

 

Google SMTP를 활용하여 구현하였고 Spring에서 제공하는 API를 사용하였다.


이메일 인증의 흐름

  1. 사용자는 회원가입 후 이메일 인증을 해야 기타 작업을 할 수 있다.
  2. 사용자는 서버에 사용자의 이메일로 인증 번호 전송을 요청한다.
  3. 서버는 랜덤 인증 번호를 생성한 뒤 인증 번호를 Redis에 저장한다.
  4. 사용자의 이메일로 해당 인증 번호가 전송한다.
  5. 클라이언트는 서버에 인증 번호에 대한 검증을 요청한다.
  6. 만약 인증번호와 Redis에 저장된 값이 같다면 true를 반환한다.

 

구글 SMTP 계정 설정

 

이메일 전송을 위해선 SMTP 설정을 해야하기에 구글 계정 설정을 해줘야 한다.

 

구글 로그인 -> 프로필 이미지 -> Google 계정 관리 -> 검색창에 "앱 비밀번호" 검색

 

 

 

앱 이름 설정 후 만들기

생성되는 앱 비밀번호는 저장해두는 것이 좋다.

 

 

구글 Gmail -> 톱니바퀴 -> 모든 설정 보기 -> 전달 및 POP/IMAP -> 아래 이미지처럼 설정 -> 저장

 

 

위와 같이 하면 구글 SMTP 기본 설정이 끝난다.

 

 

build.gradle

필자는 SpringBoot 3.1.8 버전을 사용하기 때문에 아래 버전으로 설정했다.

// SMTP 설정
implementation 'org.springframework.boot:spring-boot-starter-mail:3.0.5'
implementation 'org.springframework:spring-context:6.0.7'
implementation 'org.springframework:spring-context-support:6.0.7'
implementation 'com.sun.mail:jakarta.mail:2.0.1'

 

 

application.yml

spring: #띄어쓰기 없음
  mail:
    host: smtp.gmail.com
    port: 587
    username: {앱 비밀번호 생성시 사용 이메일}
    password: {앱 비밀번호 생성시 저장해둔 코드}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          connectiontimeout: 5000
          timeout: 5000
          writetimeout: 5000
    auth-code-expiration-millis: 300000  # 5 * 60 * 1000 == 5분

 

  • host: Gmail의 SMTP 서버 호스트.
  • port: SMTP 서버의 포트 번호. Gmail SMTP 서버는 587 포트를 사용한다.(네이버는 또 다름)
  • username: 앱 비밀번호 생성시 사용했던 이메일을 입력한다.
  • password: 앱 비밀번호 생성시 저장해둔 코드를 사용한다.
  • properties: 이메일 구성에 관한 추가 속성이다.
  • auth: SMTP 서버에 인증이 필요한 경우 true로 지정해야 한다. Gmail SMTP 서버는 인증을 요구한다.
  • starttls: SMTP 서버가 TLS(보다 안전)를 사용하면 true로 설정한다.
    • TLS: 데이터를 암호화하여 보다 안전한 전송을 보장하는 프로토콜이다.
  • connectiontimeout: 클라이언트가 SMTP 서버와의 연결을 설정하는 데 대기해야 하는 시간이다. 밀리세컨즈 단위로, 연결이 불안정할 경우 대기 시간이 길어질 수 있기 때문에 너무 크게 설정하게 되면 전송 속도가 느려진다.
  • timeout: 클라이언트가 SMTP 서버로부터 응답을 대기해야 하는 시간이다. 밀리세컨즈 단위로, 서버에서 응답이 오지 않는 경우 대기 시간을 제한하기 위해 사용한다.
  • writetimeout: 클라이언트가 작업을 완료하는데 대기해야 하는 시간이다. 밀리세컨즈 단위로, 이메일을 SMTP 서버로 전송하는 데 걸리는 시간을 제한하는데 사용한다.
  • auth-code-expiration-millis: 이메일 인증 코드의 만료 시간. 마찬가지로, 밀리세컨즈 단위이다.

 

EmailConfig

 

JavaMailSender 인터페이스를 구현하는 클래스이다. JavaMailSender 인터페이스는 JavaMail API를 사용하여 이메일을 전송한다.

@Configuration
public class EmailConfig {

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

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

    @Value("${spring.mail.username}")
    private String username;

    @Value("${spring.mail.password}")
    private String password;

    @Value("${spring.mail.properties.mail.smtp.auth}")
    private boolean auth;

    @Value("${spring.mail.properties.mail.smtp.starttls.enable}")
    private boolean starttlsEnable;

    @Value("${spring.mail.properties.mail.smtp.starttls.required}")
    private boolean starttlsRequired;

    @Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
    private int connectionTimeout;

    @Value("${spring.mail.properties.mail.smtp.timeout}")
    private int timeout;

    @Value("${spring.mail.properties.mail.smtp.writetimeout}")
    private int writeTimeout;

    @Bean
    public JavaMailSender javaMailSender(){
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding("UTF-8");
        mailSender.setJavaMailProperties(getMailProperties());

        return mailSender;
    }

    private Properties getMailProperties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", auth);
        properties.put("mail.smtp.starttls.enable", starttlsEnable);
        properties.put("mail.smtp.starttls.required", starttlsRequired);
        properties.put("mail.smtp.connectiontimeout", connectionTimeout);
        properties.put("mail.smtp.timeout", timeout);
        properties.put("mail.smtp.writetimeout", writeTimeout);

        return properties;
    }

}

 

getMailProperties(): application.yml에 설정한 환경 변수들을 사용하여 JavaMailSenderImpl 객체를 생성한다. 해당 객체를 사용하                                      여 이메일을 보낼 수 있다.

 

 

MailService

 

이메일 발송을 담당하는 클래스이다.

 

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class MailService {

    private final JavaMailSender emailSender;

    public void sendEmail(String toEmail,
                          String title,
                          String text){
        SimpleMailMessage emailForm = createEmailForm(toEmail, title, text);
        try{
            emailSender.send(emailForm);
        } catch (RuntimeException e){
            log.debug("MailService.sendEmail exception occur toEmail: {}, " +
                    "title: {}, text: {}", toEmail, title, text);

            throw new BusinessLogicException(ErrorCode.UNABLE_TO_SEND_EMAIL);
        }
    }

    private SimpleMailMessage createEmailForm(String toEmail, String title, String text) {

        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(toEmail);
        message.setSubject(title);
        message.setText(text);

        return message;
    }
}
  • createEmailForm(): 발송할 이메일 데이터를 설정하는 메서드이다. 이메일 주소, 이메일 제목, 이메일 내용을 입력 받아 SimpleMailMessage 객체를 생성하여 반환한다.
  • sendEmail(): 이메일을 바송하는 메서드 파라미터로 이메일 주소, 이메일 제목, 이메일 내용을 입력 받아 createEmailForm() 메서드로 넘긴다.
    • createEmailForm() -> SimpleMailMessage -> emailSender.send() 

 

UserController

이메일 인증 번호 요청과 인증 번호 검증에 대한 API 생성.

 

@Slf4j
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    ...

    @PostMapping("/emails/verification-requests")
    public ResponseEntity sendMessage(@RequestParam("email") @Valid String email){
        userService.sendCodeToEmail(email);

        return new ResponseEntity<>(HttpStatus.OK);
    }

    @PostMapping("/emails/verifications")
    public ResponseEntity verificationEmail(@RequestParam("email") @Valid String email,
                                            @RequestParam("code") String authCode){
        EmailVerificationResult response = userService.verifiedCode(email, authCode);

        return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
    }

}
  • sendMessage(): 이메일 전송 API로 이메일을 파라미터로 받아 memberService에 넘긴다.
  • verificationEMail(): 이메일 인증을 진행하는 API이다. 이메일과 사용자가 작성한 인증 코드를 전달받아 memberService.verifiedCode() 메서드로 넘긴다.

UserService

인증 번호를 생성, 검증을 담당하는 클래스이다.

 

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private static final String AUTH_CODE_PREFIX = "AuthCode ";

    private final UserRepository userRepository;
    private final EncryptHelper encryptHelper;
    private final MailService mailService;
    private final RedisService redisService;

    @Value("${spring.mail.auth-code-expiration-millis}")
    private long authCodeExpirationMillis;

    ...

    @Transactional
    public void sendCodeToEmail(String toEmail){
        this.checkDuplicatedEmail(toEmail);
        String title = "Test 이메일 인증 번호";
        String authCode = this.createCode();
        mailService.sendEmail(toEmail, title, authCode);

        // 이메일 인증 요청 시 인증 번호 Redis에 저장 (key = "AuthCode " + Email / value = AuthCode)
        redisService.setValues(AUTH_CODE_PREFIX + toEmail,
                authCode, Duration.ofMillis(this.authCodeExpirationMillis));
    }

    @Transactional
    public void sendCodeToEmailForTest(String toEmail, String code){
        this.checkDuplicatedEmail(toEmail);
        String title = "이메일 인증 번호 테스트";
        String authCode = code;
        mailService.sendEmail(toEmail, title, authCode);

        // 이메일 인증 요청 시 인증 번호 Redis에 저장 (key = "AuthCode " + Email / value = AuthCode)
        redisService.setValues(AUTH_CODE_PREFIX + toEmail,
                authCode, Duration.ofMillis(this.authCodeExpirationMillis));
    }

    private void checkDuplicatedEmail(String email) {
        Optional<User> user = userRepository.findByEmail(email);
        if(user.isPresent()){
            if(user.get().isVerify()){
                log.debug("UserService.checkDuplicatedEmail exception occur email: {}", email);
                throw new BusinessLogicException(ErrorCode.VERIFIED_USER);
            }
        }
    }


    private String createCode(){
        int length = 6;
        try{
            Random random = SecureRandom.getInstanceStrong();
            StringBuilder builder = new StringBuilder();
            for(int i = 0; i < length; i++){
                builder.append(random.nextInt(10));
            }
            return builder.toString();
        } catch(NoSuchAlgorithmException e){
            log.debug("UserService.createCode() exception occur");
            throw new BusinessLogicException(ErrorCode.NO_SUCH_ALGORITHM);
        }
    }

    @Transactional
    public EmailVerificationResult verifiedCode(String email, String authCode){
        this.checkDuplicatedEmail(email);
        String redisAuthCode = redisService.getValues(AUTH_CODE_PREFIX + email);
        boolean authResult = redisService.checkExistsValue(redisAuthCode) && redisAuthCode.equals(authCode);

        if(authResult){
            updateUserEmailVerified(email);
        }

        return EmailVerificationResult.of(authResult);
    }

    private void updateUserEmailVerified(String email) {
        Optional<User> user = userRepository.findByEmail(email);
        if(user.isPresent()){
            User updateUser = user.get();
            updateUser.setVerify(true);
        }
    }
    
    ...
}

 

  • createCode(): 6자리의 랜덤한 인증 코드를 생성하여 반환해주는 메서드이다.
  • checkDuplicatedEmail(): 회원가입하려는 이메일로 이미 가입한 회원이 있는지 확인하고 이미 이메일 인증을 한 사용자인지 확인한다. 만약 해당 이메일을 가진 회원이 존재하면 예외를 발생한다.
  • sendCodeToEmail(): 인증 코드를 생성 후 수신자 이메일로 발송하는 메서드이다. 이후 인증 코드를 검증하기 위해 Redis에 해당 인증 코드를 저장한다.
  • sendCodeTomEmailForTest(): 이메일 인증코드를 전송하기 위해 Test시 사용되는 메서드이다. 생성된 랜덤 인증 코드를 인자로 받는다.(이렇게 해도 될까?)
  • verifiedCode(): 인증 코드를 검증하는 메서드이다. 파라미터로 전달받은 이메일을 통해 Redis에 저장되어 있는 인증 코드를 조회한 후 파라미터로 전달 받은 인증 코드와 비교한다. 코드가 일치하지 않거나 Redis에 코드가 없다면 false를 반환하고 아닌 경우 true를 반환한다.

 

이메일 전송

이메일 도착

 

 

 

이메일 검증

 

 

작성한 테스트 코드

public class UserIntegrationTest extends BaseIntegrationTest {

    private final String BASE_URL = "/users";
    private final String EMAIL = "email@gmail.com";
    private final String SENDER_EMAIL = "hdbstn3055@gmail.com";

    @Autowired
    private UserService userService;

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private AES128Config aes128Config;

    @Autowired
    private UserRepository userRepository;

    private UserPatchDto patchDto;

    @BeforeEach
    void beforeEach(){
        UserDto.SignUp signUpDto = StubData.MockUser.getSignUpDto();
        userService.signUp(signUpDto);

        patchDto = StubData.MockUser.getPatchDto();
    }

    @AfterEach
    void afterEach(){
        userService.deleteUser(EMAIL);
    }

    @Test
    @DisplayName("이메일 코드 검증 성공")
    public void emailCodeSuccessTest() throws Exception{
        //given
        User user = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(user);

        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);

        String authCode = createCode();

        userService.sendCodeToEmailForTest(SENDER_EMAIL, authCode);

        //when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/emails/verifications")
                .build().toUri().toString();

        ResultActions actions = ResultActionsUtils.postRequestWithAuthCodeAndToken(mockMvc, uri, accessToken, encryptedRefreshToken, SENDER_EMAIL, authCode);

        //then
        actions
                .andExpect(status().isOk())
                .andDo(document("email-verification-success",
                        getResponsePreProcessor(),
                        UserResponseSnippet.emailVerificationsResponseSnippet()));
    }

    @Test
    @DisplayName("이메일 코드 검증 실패")
    public void emailCodeFailTest() throws Exception{
        //given
        User user = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(user);

        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);

        String authCode = createCode();

        userService.sendCodeToEmailForTest(SENDER_EMAIL, authCode + "1");

        //when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/emails/verifications")
                .build().toUri().toString();

        ResultActions actions = ResultActionsUtils.postRequestWithAuthCodeAndToken(mockMvc, uri, accessToken, encryptedRefreshToken, SENDER_EMAIL, authCode);

        //then
        actions
                .andExpect(status().isBadRequest())
                .andDo(document("email-verification-fail",
                        getResponsePreProcessor(),
                        UserResponseSnippet.emailVerificationsFailSnippet()));
    }

    private String createCode(){
        int length = 6;
        try{
            Random random = SecureRandom.getInstanceStrong();
            StringBuilder builder = new StringBuilder();
            for(int i = 0; i < length; i++){
                builder.append(random.nextInt(10));
            }
            return builder.toString();
        } catch(NoSuchAlgorithmException e){
            throw new BusinessLogicException(ErrorCode.NO_SUCH_ALGORITHM);
        }
    }

}