본문 바로가기
Spring Framework/스프링

스프링 AWS SES 이메일 인증 시스템 구축 방법 (+Redis)

by 스코리아 2024. 9. 3.
반응형

안녕하세요, 스코리아입니다.

오늘은 스프링 AWS SES Redis를 이용하여 이메일 인증 시스템을 구축해 보겠습니다.

 

AWS SES

 

AWS SES란? (+사용하게 된 이유)

AWS SES는 Sample Email Service의 약자로, AWS의 이메일 전송 서비스입니다. SES는 마케팅, 프로모션, 이메일 인증 등 다양한 목적으로 사용이 되고, 프리티어 기준 매일 2000 통가량을 보낼 수 있습니다. 일일 한도의 경우, 사유와 함께 AWS에 요청을 하면 늘려주고 있습니다.

 

AWS SES는 대량의 메일을 한 번에 전송해도 문제없을 뿐만 아니라 이메일 수와 데이터 전송에 대한 요금만 부과되기 때문에 저렴하고 효율적인 비용으로 이용이 가능합니다.

AWS 공식 사이트 - SES란?

 

저의 경우, 현재 진행하고 있는 프로젝트가 추후 크게 확장될 수 있다는 생각과 함께 SES API가 너무 간단하고 잘 짜여있기 때문에 스프링 프로젝트에 결합하기 매우 적절하다고 생각하였습니다.

 

설계 흐름

여기서 설명하는 '일회용 Token'은 컨트롤러 단에서 세션에 임시저장하거나 프론트단에서 쿠키에 임시저장하시기 바랍니다.

 

- 이메일 인증번호 전송

  1. 유저가 입력한 '이메일 주소'를 가져옵니다. (SignUpVerificationSendEmailDtoRequest: email)
  2. DB에서 이메일 주소가 중복인 게 존재한다면 Exception
  3. 인증번호 코드와 일회용 Token을 랜덤으로 생성한 후 이메일 주소와 함께 Redis에 저장합니다. 이때 Redis Timeout 시간을 30분으로 설정합니다. (EmailVerificationDto: email, verificationToken, verificationNumber)
  4. 이메일(수신자), 메일 제목, 메일 본문이 담긴 Dto를 이용하여 AWS SES를 통해 이메일을 전송합니다. (SenderDto: to, subject, content)
  5. 일회용 Token을 return 합니다. (EmailVerificationDtoResponse: token)

 

- 이메일 인증번호 확인

  1. 일회용 Token과 유저가 입력한 '인증번호(verificationToken)'을 가져옵니다.
  2. 일회용 Token을 이용하여 Redis에 일치하는 DTO 데이터를 가져옵니다. (EmailVerificationDto)
    1. DTO가 Null일 경우 - Exception (인증 시간 초과)
    2. DTO의 isDone 필드가 true일 경우 - Exception (이미 완료된 인증)
    3. DTO의 attemptCount 필드가 10보다 클 경우 - Exception (너무 많은 시도)
    4. DTO의 verificationNumber 필드와 가져온 verificationNumber가 다를 경우 - attemptCount 필드를 1 증가해서 Redis에 다시 저장 + Exception (올바르지 않은 인증번호)
    5. DTO의 isDone 필드값을 true로 변경
  3. DTO를 Redis에 다시 저장 후 반환

 

- 회원가입

  1. 일회용 Token 값을 가져옵니다.
  2. 일회용 Token을 이용하여 Redis에 일치하는 DTO 데이터를 가져옵니다. (EmailVerificationDto)
    1. DTO가 Null일 경우 - Exception (인증 시간 초과)
    2. DTO의 isDone 필드가 false일 경우 - Exception (이메일 인증이 완료되지 않음)

 

AWS SES 설정

AWS SES 페이지로 이동한 후, '자격 증명' 페이지 내 '자격 증명 생성' 버튼을 눌러주세요.

 

도메인이메일 주소 중 인증을 받기 편한 걸로 선택하시기 바랍니다.

저의 경우, 도메인을 선택했습니다. 도메인이 Route R3에 이미 등록되어 있는 경우, 자동으로 인증이 진행됩니다. 만약 타사 DNS를 사용하고 계신 경우, 추가적인 DNS CNAME 인증이 필요합니다.

 

도메인 소유권 인증이 확인되면 DKIM 구성체크 표시가 뜹니다.

 

또한 아래와 같이 '프로덕션 액세스 권한 부여'가 체크 표시여야 모든 수신자에게 보낼 수 있습니다. 만약 체크 표시가 없다면, 꼭 권한을 AWS에 요청하여 받으시기 바랍니다.

 

AWS SES 설정은 매우 간편하게 끝났습니다.

 

 

스프링으로 구현하기

스프링은 3.0 이상 버전을 이용해주시고, 저는 Java 언어로 설명드리겠습니다.

Redis와 관련된 설정 및 세팅 방법은 이전 포스팅에 설명드린 적이 있으므로 중요한 설정만 소개드릴 예정입니다. 모르시는 분은 아래 포스팅을 참고해 주세요.

[이전 내용] 스프링에서 Redis 캐시 사용하기 : 바로가기

 

 

1. build.gradle에 dependency 추가

Redis와 AWS SES 관련 dependency를 추가해 줍니다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis' // redis
implementation 'com.amazonaws:aws-java-sdk-ses:1.12.664' // AWS ses
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4' // AWS

 

2. application.yml 설정

Redis 호스트, 포트를 설정하고 AWS SES의 Access, Secret Key를 설정합니다.

(KEY 발급: AWS IAM->정책->'AmazonSESFullAccess' 선택->Access, Secret Key 발급)

spring:
  data:
    redis:
      host: localhost
      port: 6379
aws:
  ses:
    access_key: access_key
    secret_key: secret_key

 

3. RedisConfig 생성

Redis 관련 설정 내용입니다.

@Configuration
public class RedisConfig {

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

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

    @Bean
    public LettuceConnectionFactory lettuceConnectionFactory() {
        LettuceClientConfiguration lettuceClientConfiguration = LettuceClientConfiguration.builder()
                .shutdownTimeout(Duration.ZERO)
                .build();
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(host, port);
        return new LettuceConnectionFactory(redisStandaloneConfiguration, lettuceClientConfiguration);
    }

    @Bean
    public RedisTemplate<Object, Object> redisTemplate() {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(lettuceConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        return template;
    }

    @Bean
    public CacheManager cacheManager() {
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(2))
                .disableCachingNullValues();
        return RedisCacheManager.builder(lettuceConnectionFactory())
                .cacheDefaults(configuration)
                .build();
    }
}

 

4. AwsSesConfig 생성

BasicAWSCredentials을 이용하여 인증정보를 넘겨줍니다.

@Configuration
public class AwsSesConfig {

    @Value("${aws.ses.access_key}")
    private String accessKey;

    @Value("${aws.ses.secret_key}")
    private String secretKey;

    @Bean
    public AmazonSimpleEmailService amazonSimpleEmailService() {
        BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);
        AWSStaticCredentialsProvider awsStaticCredentialsProvider = new AWSStaticCredentialsProvider(basicAWSCredentials);
        return AmazonSimpleEmailServiceClientBuilder.standard()
                .withCredentials(awsStaticCredentialsProvider)
                .withRegion("ap-northeast-2")
                .build();
    }
}

 

5. EmailVerificationDto 생성

이메일(email), 일회용 토큰(verificationToken), 인증번호(verficiationNumber), 시도 횟수(attemptCount), isDone(인증완료여부) 필드가 담긴 DTO입니다.

Redis에 DTO 형태로 저장하기 위해서 Serializable을 상속해 주었습니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmailVerificationDto implements Serializable {
    private String email = "";
    private String verificationToken = "";
    private String verificationNumber = "";
    private int attemptCount = 0;
    private boolean isDone = false;

    public EmailVerificationDto(String email, String verificationToken, String verificationNumber) {
        this.email = email;
        this.verificationToken = verificationToken;
        this.verificationNumber = verificationNumber;
    }

    public EmailVerificationDtoResponse toResponse() {
        return new EmailVerificationDtoResponse(this.verificationToken);
    }
}

 

이메일 인증번호 전송 후 return(response)할 EmailVerificationDtoResponse는 아래와 같이 만들어주겠습니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmailVerificationDtoResponse implements Serializable {
    private String token = "";
}

 

6. EmailVerificationRepositoryRedis 생성

KEY 규칙emailVerification{name}:{verificationToken}와 같습니다.

  • name: SignUp, ChangePassword와 같이 이메일을 발송하게 된 경로를 적습니다.
  • verificationToken: 일회용 토큰

KEY 값은 String, Value 값은 Json 객체인 EmailVerificationDto가 들어갈 예정입니다.

또한 총 3가지의 메소드가 있습니다: 저장(save), 일회용 토큰으로 찾기(findByVerificationToken), 일회용 토큰으로 제거(deleteByVerificationToken)

@Repository
public class EmailVerificationRepositoryRedis {
    private static final String KEY_PREFIX = "emailVerification"; // emailVerification{name}:{verificationToken}
    private final RedisTemplate<Object, Object> redisTemplate;

    public EmailVerificationRepositoryRedis(RedisTemplate<Object, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisTemplate.setKeySerializer(new StringRedisSerializer());
        this.redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    }

    public void save(String name, EmailVerificationDto emailVerificationDto, long timeout) {
        String key = KEY_PREFIX + name + ":" + emailVerificationDto.getVerificationToken();
        redisTemplate.opsForValue().set(key, emailVerificationDto, timeout, TimeUnit.MINUTES);
    }

    public EmailVerificationDto findByVerificationToken(String name, String verificationToken) {
        String keyPattern = KEY_PREFIX + name + ":" + verificationToken;
        String key = (String) Objects.requireNonNull(redisTemplate.keys(keyPattern)).stream().findFirst().orElse(null);
        return key != null ? (EmailVerificationDto) redisTemplate.opsForValue().get(key) : null;
    }

    public void deleteByVerificationToken(String name, String verificationToken) {
        String keyPattern = KEY_PREFIX + name + ":" + verificationToken;
        redisTemplate.delete(Objects.requireNonNull(redisTemplate.keys(keyPattern)));
    }
}

 

7. SenderDto 생성

SenderDto는 이메일을 보낼 때 들어가야 할 필드들이 들어가 있습니다.

  • from: 보내는 사람입니다. ('이름 <이메일주소>' 형식으로 수정해 주세요)
  • to: 받는 사람 이메일 주소입니다. (추후 다른 기능에서 여러 명한테 보낼 수 있도록 List 형태로 설정했습니다)
  • subject: 메일 제목입니다.
  • content: 메일 본문입니다.
import com.amazonaws.services.simpleemail.model.*;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class SenderDto {
    private String from = "스냅캠퍼스 <snapcampus@abz.kr>"; // 보내는사람이름 <이메일주소>
    private ArrayList<String> to;
    private String subject;
    private String content;

    public SenderDto(ArrayList<String> to, String subject, String content) {
        this.to = to;
        this.subject = subject;
        this.content = content;
    }

    public void addTo(String email) {
        to.add(email);
    }

    public SendEmailRequest toSendRequestDto() {
        Destination destination = new Destination().withToAddresses(to);
        Message message = new Message()
                .withSubject(createContent(subject))
                .withBody(new Body().withHtml(createContent(content)));
        return new SendEmailRequest()
                .withSource(from)
                .withDestination(destination)
                .withMessage(message);
    }

    private Content createContent(String text) {
        return new Content().withCharset("UTF-8").withData(text);
    }
}

 

8. MailUtil 생성

MailUtil은 AwsSesConfig을 Bean으로 주입받은 후 send 메소드를 통해 이메일을 전송하는 기능을 담당합니다.

이때 방금 만들었던 SenderDto 규격으로 받고 이를 AWS가 지원하는 SendEmailRequest 객체로 변환해준 후(toSendRequestDto) AWS SES Client를 통해 이메일을 전송하게 됩니다.

@Component
public class MailUtil {
    private static AwsSesConfig awsSesConfig = null;

    public MailUtil(AwsSesConfig awsSesConfig) {
        MailUtil.awsSesConfig = awsSesConfig;
    }

    public static void send(SenderDto senderDto) {
        try {
            AmazonSimpleEmailService client = awsSesConfig.amazonSimpleEmailService();
            client.sendEmail(senderDto.toSendRequestDto());
        } catch (Exception ex) {
            throw new IllegalArgumentException("이메일 전송 서비스가 원활하지 않습니다.");
        }
    }
}

 

9. 이메일 인증번호 발송

지금부터는 MemberAuthService 내에 메소드 형태로 '이메일 인증번호 발송', '이메일 인증번호 확인', '회원가입'을 만들어볼 예정입니다.

@RequiredArgsConstructor
@Transactional
@Service
public class MemberAuthService {
    private final MemberRepository memberRepository;
    private final EmailVerificationRepositoryRedis emailVerificationRepositoryRedis;
    
    // TODO: 이메일 인증번호 발송
    
    
    // TODO: 이메일 인증번호 확인
    
    
    // TODO: 회원가입
}

 

'이메일 인증번호 발송' 메소드를 만들기 위해서 email 필드가 담긴 RequestDto가 필요합니다.

SignUpVerificationSendEmailDtoRequest 클래스를 만들어보겠습니다.

@Getter
@Setter
public class SignUpVerificationSendEmailDtoRequest {
    @NotBlank(message = "이메일은 필수 입력 값입니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String email;
}

 

다음으로 '이메일 인증번호 발송' 메소드입니다.

  • checkDuplicateEmail (이메일 중복검사)
    • DB에서 이메일을 통해 Member 회원을 찾고, 만약에 있으면 중복이므로 exception을 터뜨립니다
  • verificationSendEmail (이메일 인증번호 전송)
    • 일회용 토큰(verificationToken), 인증 번호(verificationNumber)를 랜덤으로 생성 후 EmailVerificationDto에 담아 Redis에 30분 타임아웃으로 저장합니다.
    • MailUtil의 send메소드를 통해서 AWS SES로 해당 유저에게 이메일을 발송합니다.
/**
 * 이메일 인증번호 발송
 */
public EmailVerificationDtoResponse signUpVerificationSendEmail(SignUpVerificationSendEmailDtoRequest signUpVerificationSendEmailDtoRequest){
    checkDuplicateEmail(signUpVerificationSendEmailDtoRequest.getEmail()); // email 중복 검사

    return verificationSendEmail(
            "SignUp",
            signUpVerificationSendEmailDtoRequest.getEmail(),
            "회원가입 인증번호 안내",
            "회원가입을 위해서 아래 인증코드를 입력해주세요."
    );
}

private void checkDuplicateEmail(String email) {
    Member findMember = memberRepository.findByEmail(email);
    if (findMember != null) {
        throw new IllegalArgumentException("이미 등록된 이메일입니다.");
    }
}

public EmailVerificationDtoResponse verificationSendEmail(
        String name,
        String emailAddress,
        String emailSubject,
        String emailContent
) {
    String verificationToken = RandomUtil.generateRandomString(32);
    String verificationNumber = RandomUtil.generateRandomNumber(6);

    EmailVerificationDto emailVerificationDto = new EmailVerificationDto(
            emailAddress,
            verificationToken,
            verificationNumber
    );
    emailVerificationRepositoryRedis.save(name, emailVerificationDto, 30); // 30분 타임아웃 제한

    MailUtil.send(new SenderDto(
            new ArrayList<>(Collections.singletonList(emailAddress)),
            "스냅캠퍼스 - " + emailSubject,
            "안녕하세요, 스냅캠퍼스입니다.<br><br>" + emailContent + "<br>인증번호는 <b>" + verificationNumber + "</b> 입니다."
    ));

    return emailVerificationDto.toResponse();
}

 

10. 이메일 인증번호 확인

  • verificationCheckEmail (인증 확인)
    • 일회용 토큰 (verificationToken)을 통해 Redis에서 DTO 객체를 찾습니다.
    • 포스팅 시작할때 이미 인증 확인 과정을 설명하였으므로 여기서는 생략하겠습니다.
/**
 * 회원가입 - 이메일 인증번호 확인
 */
public EmailVerificationDto signUpVerificationCheckEmail(String token, String verificationNumber){
    EmailVerificationDto emailVerificationDto = verificationCheckEmail("SignUp", token, verificationNumber);
    emailVerificationRepositoryRedis.save("SignUp", emailVerificationDto, 15); // 10분 타임아웃 제한
    return emailVerificationDto;
}

private EmailVerificationDto verificationCheckEmail(String name, String token, String verificationNumber) {
    EmailVerificationDto emailVerificationDto = emailVerificationRepositoryRedis.findByVerificationToken(
            name,
            token
    );

    if (emailVerificationDto == null) {
        throw new IllegalArgumentException("이메일 인증 시간이 초과되었습니다. 재시도해주세요.");
    }

    if (emailVerificationDto.isDone()) {
        throw new IllegalArgumentException("이미 이메일 인증이 완료된 상태입니다.");
    }

    if (emailVerificationDto.getAttemptCount() > 10) {
        throw new IllegalArgumentException("너무 많은 시도를 하였습니다. 처음부터 재시도해주세요.");
    }

    if (!emailVerificationDto.getVerificationNumber().equals(verificationNumber)) {
        emailVerificationDto.setAttemptCount(emailVerificationDto.getAttemptCount() + 1);
        emailVerificationRepositoryRedis.save(name, emailVerificationDto, 30);
        throw new IllegalArgumentException("인증번호가 올바르지 않습니다.");
    }

    emailVerificationDto.setDone(true);
    return emailVerificationDto;
}

 

11. 회원가입

  • validateEmailVerification (이메일 인증 검증)
    • 일회용 토큰 (verificationToken)을 통해 Redis에서 DTO 객체를 찾습니다.
    • 포스팅 시작할때 이미 검증 과정을 설명하였으므로 여기서는 생략하겠습니다.
  • completeEmailVerification (이메일 인증 완료)
    • 일회용 토큰 (verificationToken)을 통해 Redis에서 DTO 객체를 삭제합니다.
/**
 * 회원가입
 */
public void signUp(MemberSignUpDtoRequest request, String verificationToken) {
    EmailVerificationDto emailVerificationDto = validateEmailVerification("SignUp", verificationToken);

    // TODO: 회원가입 로직 추가 작성
    
    completeEmailVerification("SignUp", verificationToken); // 이메일 인증 토큰 제거
}

private EmailVerificationDto validateEmailVerification(String name, String token) {
    EmailVerificationDto emailVerificationDto = emailVerificationRepositoryRedis.findByVerificationToken(
        name,
        token
    );

    if(emailVerificationDto == null){
        throw new IllegalArgumentException("시간이 초과되었습니다. 재시도해주세요.");
    }

    if (!emailVerificationDto.isDone()) {
        throw new IllegalArgumentException("이메일 인증이 완료되지 않았습니다.");
    }

    return emailVerificationDto;
}

private void completeEmailVerification(String name, String token){
    emailVerificationRepositoryRedis.deleteByVerificationToken(
        name,
        token
    );
}

 

12. 컨트롤러 생성

  • API로 구현할 경우: 백엔드단에서는 만들었던 Service 바로 호출해서 구현하시면 되고 프론트단에서 일회용 토큰(verificationToken)을 쿠키에 저장하시면 됩니다.
  • Model을 이용해서 구현할 경우: 백엔드단에서 일회용 토큰(verificationToken)을 세션에 저장한 후 Service를 호출하여 구현해주시면 됩니다.

세션의 경우 다음과 같이 생성, 삭제할 수 있습니다.

httpServletRequest.getSession().setAttribute("emailVerificationToken", emailVerificationDtoResponse.getToken()); // session 저장
String verificationToken = (String) httpServletRequest.getSession().getAttribute("emailVerificationToken"); // session 가져오기

 

세션을 이용한 컨트롤러 구현이 궁금하신 분들은 제 Github에 Code가 올라와 있으니, 참고해주시기 바랍니다.

https://github.com/skorea6/snap-campus/blob/master/src/main/java/com/example/snapcampus/controller/MemberController.java

 

테스트

이메일 발송 로직이 성공적으로 전송 되었고, 이메일 인증번호 확인 및 회원가입 과정도 문제 없이 진행되었습니다.

테스트 이메일 전송 결과

 


이상으로 스프링으로 AWS SES 이메일 인증 시스템을 구축하는 방법에 대해서 소개드렸습니다.

제 Github Repository에 Full Code가 올라와 있으니, 아래 링크를 참고해주시기 바랍니다.

 

https://github.com/skorea6/snap-campus/tree/master/src/main/java/com/example/snapcampus

 

snap-campus/src/main/java/com/example/snapcampus at master · skorea6/snap-campus

대학교 위치기반 SNS, 스냅캠퍼스. Contribute to skorea6/snap-campus development by creating an account on GitHub.

github.com

 

읽어주셔서, 감사합니다.

반응형