ecsimsw

Spring Mail 전송 / Google Smtp, 비동기 메일 인증 본문

Spring Mail 전송 / Google Smtp, 비동기 메일 인증

JinHwan Kim 2020. 10. 10. 17:08

Spring Mail / Google Smtp server / @Async

 

Send email from a printer, scanner, or app - Google Workspace Admin Help

Set up a device or app to send email through G Suite This article is for G Suite administrators. If you're trying to send email from a device or app using your Gmail account, ask your G Suite admin for help. As a G Suite admin, you can set up devices

support.google.com

스프링 부트로 사용자의 메일 인증을 위해 메일을 보내야하는 상황에서 공부한 내용을 정리한 글입니다.

 

Spring Mail과 Google Smtp를 사용해서 간단하게 메일을 보낼 수 있는 방법을 사용하였고, 제가 이를 구현하면서 생긴 문제, 고민, 그것을 해결하기 위한 해결 방안을 정리해 보았습니다.

 

Spring Mail / Google Smtp server

springframework.mail / Google Smtp server를 이용하면 쉽게 메일을 전송할 수 있다. 우선 Spring framework mail을 사용하기 위한 의존성을 추가한다.

 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-mail'
}

 

 

Google Smtp server를 사용하기 위해 application 설정 파일에 아래 설정을 추가한다. 

 

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: 
    password:
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true

 

설정 중 username에는 보낸 사람에 해당할 메일의 주소를, password는 해당 메일의 비밀번호를 입력한다.

 

이메일 유효함 확인 정책

내가 고민해본 이메일이 유효함을 확인하는 정책 다음의 세가지 였다.

 

1. 서버만 알 수 있는 메일을 암호화 방식을 고정하면 메일 주소와 key를 직접 관리할 필요가 없어진다. 즉 메일 주소에 대항하는 키를 1:1로 고정하는 방법이다.

 

예를 들면 메일 주소를 암호화 할 수 있는 임의의 해시 함수를 만들어두고, 해당 함수에 대입하여 나온 값만 보내주는 것이다. 이미 메일 주소에 대한 key 값이 정해져 있기 때문에, DB에 주소-키를 따로 저장하여 관리할 필요가 없어진다.

 

물론 중복처리를 위해 이미 사용된 메일에 대한 정보는 저장해야하지만, 2번과 3번 방식처럼 메일 인증을 시도하고 실제 키를 입력하지 않고 쌓이는 더미 메일에 대한 처리가 불필요하다.

 

더미 메일에 대한 처리를 필요로 하지 않고, 한 메일에 서로 다른 인증 시도에, 키를 업데이트 하지 않아도 되는 장점이 있는 반면에, 암호화 방식이 노출되면 메일 인증 없이도 지정된 키를 얻을 수 있어 보안에 가장 취약한 방식이다.

 

2. 해당 이메일에 키를 포함한 URL을 전송하고, 일정 시간 안에 서버에 해당 요청이 들어온다면 인증된 메일으로 처리한다. 

 

예를 들면, 유저의 메일로 "http://~?id=13&key=password" 식의 메일과 키를 확인할 수 있는 Url을 전송하고, 시간 내에 서버로 해당 파라미터를 갖는 요청이 도착할 경우, 유효한 메일으로 처리할 수 있다.

 

3. 사용자의 메일에 직접 생성한 키를 전송한다.

 

메일 인증이 요청된 경우 새로운 키를 매번 생성하여 메일 주소에 대한 키를 업데이트 해주고, 메일로 키를 전송하는 방법이다.

 

사실 2번과 DB를 관리하는 방식, 키를 생성하고 업데이트를 관리하는 방식은 같으나, 서버 주소를 직접 메일에 넣지 않아도 되서 서버 주소가 변경되어도 db의 정보와 키만 동일하다면 문제가 생기지 않는다.

 

@Transactional
public void sendAuthMail(String to) {
    Email email = memberRepository.findEmailByAddress(to);
    String key = makeRandomKey();

    if(email != null){
        if(email.isUsed()==true){
            throw new RuntimeException("Mail already used");
        }

        // 인증 요청만 하고, 실제 인증하지 않은 더미 이메일
        // 새로운 키 값 업데이트 -> 병합
        
        email.setKey(key);
    }
    else{
        // 아예 새로 요청한 이메일 주소
        email = new Email();
        email.setKey(key);
        email.setAddress(to);
        email.setUsed(false);
    }
    memberRepository.saveEmail(email);

    SimpleMailMessage message = new SimpleMailMessage();
    message.setFrom("geeksecsimsw@gmail.com");
    message.setTo(to);
    message.setSubject("[From Giggle] 이메일 인증");
    message.setText("인증 번호는 "+key+" 입니다.");

    javaMailSender.send(message);
}

 

나의 경우, 3번 방식을 사용하였다.

Email, makeRandomKey는 직접 정의한 엔티티, 메소드이고, SimpleMailMessage 사용법은 코드로 쉽게 알 수 있을 것이다.

 

문제 사항

1. 서로 다른 유저가 같은 메일로 인증을 요청하는 경우

 

서로 다른 유저가 같은 메일로 인증하는 경우를 처리할 필요가 있을까? A라는 유저가 메일 주소 a를 통해 인증을 완료하면 서버에 a가 유효한 메일임을 표시한다.

 

문제는 A가 인증을 완료한 이후, 남은 회원 가입 폼을 확인할 때 B가 메일 주소 a를 입력하고 회원 가입을 진행할 경우, B의 메일이 아님에도 불구하고 a 메일은 유효하지만, A가 회원가입을 완료하긴까지 사용되지 않은 상태이기 때문에, B가 a를 차지할 수 있다.

 

a 메일은 A의 것으로 영구적으로 저장한다면 문제를 해결되겠지만 A가 메일 인증 후 회원 가입 과정에서 회원 가입을 완료하지 않는다면 a 메일은 영원히 아무도 사용하지 못하는 메일 주소가 될 것이다.  

 

이 문제를 해결하기 위해서, 페이지 자체에서 회원 가입을 위해서는 폼을 전송하기 이전에 모든 사용자가 메일 인증 버튼을 누르도록, 또 인증 버튼을 눌렀을 때의 이메일 주소와 실제 회원 가입 폼을 전송할 떄의 이메일 주소가 일치하는지 보장하고, 서버에서는 메일 인증 버튼을 눌렀을 때 해당 메일이 인증되어 있는지의 여부와 상관없이 새로운 키 값을 업데이트 하였다.

 

위의 상황에선 A가 a메일을 인증한 후 회원가입을 마무리 하지 않았을 때  B가 a메일을 포함하여 회원 가입 폼을 전송하려 해도 키 값이 업데이트 되어 다시 인증을 요청할 뿐 아니라, a가 A의 것으로 영구적으로 보존되어 더미 메일이 되는 것을 피할 수 있을 것이다.

 

A가 a메일의 키를 확인한 후, B가 a로 다시 인증을 요청할 경우, 키 값이 변경되어 A는 정상적으로 회원 가입을 완료하지 못하고 다시 인증을 시도한 후 새로운 키를 입력 받아야한다는 문제가 남아 있으나, 인증 시도 별로 키를 업데이트 하는 것이 옳은 것이라고 결정하여 이 방식으로 주소-키를 관리하였다.

 

2. 메일을 보내는 시간 지연

 

회원 가입 폼에서 회원이 메일 인증 요청을 Ajax로 처리한다면 서버에서 메일을 보내는 시간이 처리되는 시간 동안 지연이 생겨 인증 버튼을 누르고 바로 안내 알림을 받을 수 없다.

 

예를 들면, A가 메일 인증 버튼을 누르면 페이지 내에서 "메일을 확인하세요"라는 문구로 alert()로 출력해야한다고 하자. 서버에서 메일을 보내는 시간이 지연되어 버튼을 누르고 바로 문구 알림을 받을 수 있음을 보장하지 못한다.

 

이 경우를 해결하기 위해, 다른 스레드로 메일을 보내는 동작을 비동기로 처리하였다. @Async 어노테이션을 이용해서 메일을 전송하는 동작을 다른 스레드로 처리하면 메일의 전송 시간에 상관없이, 사용자가 인증을 요청하자 마자 메일을 확인하라는 알림을 출력할 수 있다.

 

@Transactional
@Async
public void sendAuthMail(String to) {...}

 

비동기로 처리해야하는 메소드에 @Async를 표시하고, 스레드 풀을 직접 설정해야하는 경우 SpringAsyncConfig 클래스를 설정하여 스레드를 관리하는 방식을 설정한다.

 

@Configuration
@EnableAsync
public class SpringAsyncConfig {
  @Bean
  public Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);
    executor.setMaxPoolSize(2);
    executor.setQueueCapacity(500);
    executor.setThreadNamePrefix("GithubLookup-");
    executor.initialize();
    return executor;
  }
}

 

결과 / 인증 처리 절차

내가 이메일 인증을 위해 선택한 방식은 다음과 같은 절차를 따른다.

 

1. 사용자가 이메일을 입력하고 이메일 인증 메일을 요청하면 Ajax로 서버에 해당 메일이 이미 사용된 메일인지 중복 체크하여 사용 불가능한 메일의 경우 결과를 출력.

 

2. 사용된 메일이 아니라면 해당 메일이 db에 존재하는지 확인하여, 새로운 키를 지정하여 병합 또는 생성한다.

(메일 인증 요청을 이미 했으나 인증을 완료하지 않은 더미 메일 주소가 있어, DB에 남아있으나 미사용된 메일이 있을 수 있다.)

 

3. 메일 인증을 완료하면 주소에 대한 키 값을 암호키가 아닌, 서버에서 지정한 문자열로 값을 변경한다. 이를 테면 "ok"로 변경.

 

4. 실제 회원 가입이 완료될 때는 입력된 메일 주소의 키가 "ok"인지, 즉 메일 주소가 인증된 메일인지 확인하고, 인증된 정상적인 메일이라면 해당 메일의 사용 여부를 "used"로 변경 후 회원 가입을 마무리한다.

 

5. 페이지 내에선 회원 가입 폼을 입력하기 전, 이메일 인증 버튼을 눌렀는지, 회원 가입 폼의 이메일과 이메일 인증 버튼을 눌렀을 때의 이메일 주소가 일치하는지 확인하고, 불일치 또는 인증 시도가 없다면 인증을 요구한다.

 

메일로 전송되는 인증키의 모습

 

Comments