스프링과 함께 이메일 인증하기

허진혁·2023년 9월 14일
2

givemeticon 프로젝트

목록 보기
1/10

🤔 초기 고민

회원가입 시 이메일 인증을 위한 기능을 만들려고 해요. 사용할 수 있는 방안은 두 가지 있어요.

  1. 인증번호를 입력 받아 인증번호 입력으로 이메일 인증하는 방법
  2. 이메일에 인증링크를 받아 클릭해서 이메일을 인증하는 방법

저는 두 번째 방안을 선택했어요. 그 이유는 다음과 같아요.

  1. 보안 요구사항: 보안이 중요한 경우, 이메일 링크 클릭 방식이 더 적합해요. 이 방식은 사용자의 이메일 계정에 더 강력한 연결을 제공하며, 인증 링크를 통해 유일한 액세스가 가능하도록 해줘요.
  2. 사용자 경험: 사용자가 편리한 경험을 원하는 경우에는 인증번호 입력 방식을 고려할 수 있어요. 그러나 이메일 링크 클릭 방식은 사용자가 별도의 인증번호를 입력할 필요가 없어 편리할 수 있다고 생각했어요.

🕵️‍♂️ 기능 설계

제가 생각한 사용자 인증 과정은 다음과 같아요.

  1. 사용자가 서버측에 이메일 인증을 요청한다.
  2. 서버측에서 무작위로 만들어진 난수 6자리를 만들고 유효시간은 3분으로 정한다.
  3. 저장소에 요청한 이메일(key)에 대한 값으로 난수(value) 6자리를 저장한다.
  4. 사용자 이메일에 인증번호를 전달한다.
  5. 사용자가 이메일을 받은 링크를 클릭한다.
  6. 링크를 클릭하면 이메일과 그에 맞는 인증번호 검증이 이루어 진다.

구현 과정

SMTP 계정 설정

2단계 인증하기

2단계 인증 과정은 다음과 같아요.

  1. Google 계정을 엽니다.
  2. 탐색 패널에서 보안을 선택합니다.
  3. 'Google에 로그인'에서 2단계 인증  시작하기를 선택합니다.
  4. 화면에 표시되는 단계를 따릅니다.

2단계 인증을 거친다면 16자리 앱 비밀번호를 알려줍니다. 꼭 따로 저장해두셔야 해요.

→ 이 비밀번호가 gmail 비밀번호 대신 사용하게 될 비밀번호에요!!

SMTP 설정

우선 SMTP을 사용할 계정 설정을 해줘야 해요. 저는 구글을 활용했어요.

구글 로그인 → 구글 계정 관리 → 검색창에 “앱 비밀번호” 검색

(❗️만약 앱 비밀번호가 나오지 않는다면? 2단계 인증을 해야 하니 위의 과정을 거치고 오면 되요!!)

다음과 같이 설정을 합니다.

구글 Gmail → 설정 → 전달 및 POP/IMAP → 아래 이미지처럼 설정 → 변경사항 저장 클릭

구현 과정

1. 라이브러리 추가

저는 gradle을 사용하기에 다음과 같이 적용했어요.

implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

단, 저는 인증번호를 redis에 저장할 목적이기에 redis 부분도 추가해주었어요.

2. 외부 구성 설정하기

이메일 설정에는 두 가지 방식이 있어요.

  1. Config 파일을 만들어서 설정하기
  2. application.yml 을 활용하여 외부 구성을 설정하기

그 이유는 다음과 같아요.

  1. 외부 구성 분리: 설정을 외부 파일에 저장하면 애플리케이션의 설정을 프로그램 코드와 분리할 수 있습니다. 이것은 설정을 변경할 때 애플리케이션을 다시 컴파일하거나 다시 빌드할 필요 없이 설정을 업데이트할 수 있는 유연성을 제공합니다.
  2. 설정 관리 도구 통합: 외부 설정 파일을 사용하면 설정 관리 도구와의 통합이 쉬워집니다. 예를 들어, 환경변수 또는 구성 서버를 통해 설정을 관리하고 주입하는 것이 가능하며, 이것은 설정을 중앙에서 관리하고 변경 사항을 추적하기에 용이합니다.
  3. 가독성: YAML 또는 Properties 파일 형식은 사람이 읽기에 좀 더 가독성이 높습니다. 이것은 설정을 쉽게 검토하고 이해할 수 있게 해줍니다.

또한, 보안에 민감한 부분도 포함되어 있기에 yml 파일도 나누어서 관리했어요.

application.yml

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    properties:
      mail:
        debug: true
        smtp.auth: true
        smtp.timeout: 50000 # SMTP 서버에 연결을 시도하고 응답을 기다리는 최대 시간이 50,000ms
        smtp.starttls.enable: true

	data:
	    redis:
	      mail:
	        host: localhost # 임시 방편으로 로컬에서 테스트를 위해 설정
	        port: 6380

application-secret.yml

spring:
	mail:
	    username: {인증 메일을 보낼 계정}
	    password: {앱 비밀번호 (16자리)}

코드 구현하기

맨 위의 사용자 인증 과정에 대해 6가지로 나누었는데 그 중 1번과 5번은 사용자가 해야할 것이고, 구현해야할 과정은 2, 3, 4, 6번 이에요. 이를 다시 정리하면 다음과 같이 4 가지 과정이 있어요.

  1. 서버측에서 무작위로 만들어진 난수 6자리를 만들고 유효시간은 3분으로 정한다.
  2. 저장소에 요청한 이메일(key)에 대한 값으로 난수(value) 6자리를 저장한다.
  3. 사용자 이메일에 인증번호를 전달한다
  4. 링크를 클릭하면 이메일과 그에 맞는 인증번호 검증이 이루어 진다.

이 과정을 하기 전 가장 먼저 해야할 것은 redis를 빈으로 등록하여 설정하는 것이에요. 스프링의 가장 위대한 점 중 하나인 추상화가 있기에 어떠한 구현체를 선택하는지는 개발자의 역할이에요.

그렇다면 저는 왜 reids를 사용했을까요?

저는 다음과 같은 이점이 redis에 있다고 생각했기 때문이에요.

  1. 빠른 응답 속도: Redis는 메모리 기반 데이터베이스로 데이터를 빠르게 읽고 쓸 수 있습니다. 이것은 이메일 인증 난수와 같은 임시 데이터를 빠르게 저장하고 검색하는 데 가장 좋다고 생각합니다.
  2. 만료 기능: Redis는 데이터를 저장할 때 만료 시간을 설정할 수 있습니다. 이것은 인증 난수와 같이 일시적인 데이터에 유용합니다. 만료 시간이 지난 데이터는 자동으로 삭제되므로 데이터 정리에 대한 수고를 줄일 수 있습니다.

RedisConfig

@Configuration
public class RedisConfig {

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

    public RedisConnectionFactory redisMailConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean(name = "redisTemplate")
    public StringRedisTemplate redisTemplate() {
        StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
        stringRedisTemplate.setConnectionFactory(redisMailConnectionFactory());
        return stringRedisTemplate;
    }
}

RedisConnectionFactory로는 Lettuce 와 Jedis 방식이 있어요.

제가 Lettuce를 선택한 기준은 향로(이동욱)님의 블로그에 자세히 나와있어요 👍👍👍

❓redisTemplate에 왜 빈 네임을 붙였나요? → @Bean(name = "redisTemplate")

⚠️ @Bean(name = "redisTemplate") 로 name을 커스터마이징 하지 않으면

A component required a bean named 'redisTemplate' that could not be found. 와 같은 에러가 발생하므로 빈 이름을 설정해주었어요.

CertificationGenerator

이 클래스는 난수를 만드는 역할이에요.

@Component
public class CertificationGenerator {
    public String createCertificationNumber() throws NoSuchAlgorithmException {
        String result;

        do {
            int num = SecureRandom.getInstanceStrong().nextInt(999999);
            result = String.valueOf(num);
        } while (result.length() != 6);

        return result;
    }
}

난수를 만드는 방법 중에 저는 세 가지 방식이 떠올랐어요.

  • Math.random() 활용
  • java.util.Random 클래스 활용
  • SecureRandom 클래스 활용 → This class provides a cryptographically strong random number generator (RNG)

저는 마지막 방식인 SecureRandom 클래스 활용했어요. 이 클래스는 암호학적으로 안전한 난수를 생성하기에 더 안정적이고 무작위성에 대한 품질이 높다고 생각했기 때문이에요.

CertificationNumberDao

이 클래스는 redis에 데이터를 저장· 조회 · 삭제 하는 역할이에요.

@Repository
@RequiredArgsConstructor
public class CertificationNumberDao {

    private final StringRedisTemplate redisTemplate;

    public void saveCertificationNumber(String email, String certificationNumber) {
        redisTemplate.opsForValue()
                .set(email, certificationNumber,
                        Duration.ofSeconds(EMAIL_VERIFICATION_LIMIT_IN_SECONDS));
    }

    public String getCertificationNumber(String email) {
        return redisTemplate.opsForValue().get(email);
    }

    public void removeCertificationNumber(String email) {
        redisTemplate.delete(email);
    }

    public boolean hasKey(String email) {
        Boolean keyExists = redisTemplate.hasKey(email);
        return keyExists != null && keyExists;
    }
}

“EMAIL_VERIFICATION_LIMIT_IN_SECONDS”은 180으로 설정해서 유효기간은 3분이에요.

redisTemplate의 opsForValue 메서드를 통해 String 자료구조 데이터를 활용할 수 있어요.

MailController

이 클래스는 Mail과 관련된 요청과 그에 대한 로직 처리 후 응답해주는 역할이에요.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/mails")
public class MailController {

    private final MailSendService mailSendService;
    private final MailVerifyService mailVerifyService;

    @PostMapping("/send-certification")
    public ResponseEntity<ApiResponse<EmailCertificationResponse>> sendCertificationNumber(@Validated @RequestBody EmailCertificationRequest request)
            throws MessagingException, NoSuchAlgorithmException {
        mailSendService.sendEmailForCertification(request.getEmail());
        return ResponseEntity.ok(ApiResponse.success());
    }

    @GetMapping("/verify")
    public ResponseEntity<ApiResponse<Void>> verifyCertificationNumber(
            @RequestParam(name = "email") String email,
            @RequestParam(name = "certificationNumber") String certificationNumber
    ) {
        mailVerifyService.verifyEmail(email, certificationNumber);
        return ResponseEntity.ok(ApiResponse.success());
    }
}

위의 과정에서 언급한 것처럼 메일을 보내는 기능과 메일에 관련된 난수를 인증하는 기능이 있어요.

Request와 Response

EmailCertificationRequest

@NoArgsConstructor
@AllArgsConstructor
@Getter
@EqualsAndHashCode
public class EmailCertificationRequest {

    @NotBlank(message = "이메일 입력은 필수입니다.")
    @Email(message = "이메일 형식에 맞게 입력해 주세요.")
    private String email;
}

EmailCertificationResponse

@NoArgsConstructor
@AllArgsConstructor
@Getter
public class EmailCertificationResponse {
    private String email;
    private String certificationNumber;
}

MailSendService

이 클래스는 이메일에 난수를 포함한 링크를 보내주는 역할이에요.

@Service
@RequiredArgsConstructor
public class MailSendService {

    private final JavaMailSender mailSender;
    private final CertificationNumberDao certificationNumberDao;
    private final CertificationGenerator generator;

    public EmailCertificationResponse sendEmailForCertification(String email) throws NoSuchAlgorithmException, MessagingException {

        String certificationNumber = generator.createCertificationNumber();
        String content = String.format("%s/api/v1/users/verify?certificationNumber=%s&email=%s   링크를 3분 이내에 클릭해주세요.", DOMAIN_NAME, certificationNumber, email);
        certificationNumberDao.saveCertificationNumber(email, certificationNumber);
        sendMail(email, content);
        return new EmailCertificationResponse(email, certificationNumber);
    }

    private void sendMail(String email, String content) throws MessagingException {
        MimeMessage mimeMessage = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(mimeMessage);
        helper.setTo(email);
        helper.setSubject(MAIL_TITLE_CERTIFICATION);
        helper.setText(content);
        mailSender.send(mimeMessage);
    }
}

JavaMailSender를 의존하니 그에 대한 내용을 살펴 보아야 해요.

MimeMessage

This class represents a MIME style email message.

MIME은 "Multipurpose Internet Mail Extensions"의 약자로, 이메일 메시지를 보다 풍부하게 표현하고 다양한 데이터 유형을 전송하는 데 사용되는 표준 인터넷 프로토콜이에요. MIME 스타일 이메일 메시지는 이러한 MIME 규격을 사용하여 생성된 이메일 메시지를 나타내는 것이라고 볼 수 있어요.

MimeMessage mimeMessage = mailSender.createMimeMessage();

createMimeMessage 메서드는 보낸 사람의 기본 JavaMail Session에 대한 새 JavaMail MimeMessage를 만들어요. 클라이언트가 준비하고 보낼 수 있는 MimeMessage 인스턴스를 만들려면 호출해야 해요.

MimeMessageHelper

Helper class for populating a MimeMessage.

이 클래스는 MimeMessage를 채우는 것을 도와주는 역할이에요.

주석으로 사용방법을 편리하게 설명해 주고 있어요.

MailVerifyService

이 클래스는 이메일에 대한 인증코드가 일치하는지 확인하는 역할이에요.

저는 다음과 같은 예외사항이 떠올랐기에 메서드들을 다음과 같이 분리했어요.

  1. 이메일이 존재하지 않을 경우에 대한 예외처리
  2. 이메일은 존재하나 인증코드가 일치하지 않을 경우에 대한 예외처리
@Service
@RequiredArgsConstructor
public class MailVerifyService {

    private final CertificationNumberDao certificationNumberDao;

    public void verifyEmail(String email, String certificationNumber) {
        if (!isVerify(email, certificationNumber)) {
            throw new InvalidCertificationNumberException();
        }
        certificationNumberDao.removeCertificationNumber(email);
    }

    private boolean isVerify(String email, String certificationNumber) {
        boolean validatedEmail = isEmailExists(email);
        if (!isEmailExists(email)) {
            throw new EmailNotFoundException();
        }
        return (validatedEmail &&
                certificationNumberDao.getCertificationNumber(email).equals(certificationNumber));
    }

    private boolean isEmailExists(String email) {
        return certificationNumberDao.hasKey(email);
    }
}

테스트하기

먼저 이메일 인증 요청을 하면 다음과 같은 응답을 받아요.

그리고 redis를 통해 값을 확인해도 같아요. redis에 잘 저장된 것도 확인할 수 있어요.

이제 인증코드 검증을 해봐요.

위의 링크를 클릭하면 아래와 같이 요청이 가고 이렇게 인증이 끝나요.

고민사항

그런데 기능을 모두 구현하였지만 한 가지 고민사항이 생겼어요. 이메일 인증 요청을 보내는 시간이 약 3초~6초 사이의 시간이 걸리고, 이는 무시할 수 없는 시간이에요. 이에 대한 고민과 해결 과정은 다음 편에 올릴게요.

참고자료

[스프링] 회원가입, 비밀번호 찾기 이메일 인증 구현하기

profile
Don't ever say it's over if I'm breathing

0개의 댓글