[BooTakHae] 이메일 인증 구현 및 리팩토링

Kim Hyen Su·2024년 6월 9일

BooTakHae

목록 보기
14/22
post-thumbnail

📫 이메일 인증

개요

회원가입 시 본인 인증 절차를 통해 서비스 내 회원의 신원 확인을 통해 보안성을 높이고, 타인이 개인 정보를 확인하지 못하도록 막을 수 있는 수단으로써 사용될 수 있습니다.

해당 포스팅은 "이메일 인증" 기능 관련 필자가 구현했던 내용과 정리한 내용들을 담은 글입니다.

기능 구현

이메일 인증 요청 시 기본적으로 SMTP 프로토콜을 사용하여 인증번호를 전송한 뒤 해당 인증번호를 통해 실제 회원인지 여부를 확인합니다.

간단한 로직은 다음과 같습니다.

  • 이메일로 인증코드 발송
  • 인증코드 입력 후 코드 인증
  • 인증 결과 반환

💡 SMTP(Simple Mail Transfer Protocol) 이란?

  • 인터넷 상에서 이메일 전송을 위해 사용되는 프로토콜,
  • TCP 포트 번호는 일반적으로 25번 사용.
  • 메일 서버 간에 송수신 또는 클라이언트에서 메일 서버로 송수신할 때도 사용.
  • 텍스트 기반 프로토콜로써, Request/Response Message 및 모든 문자가 7bit ASCII로 규정.
  • 문자 표현에 8비트 이상의 코드를 사용하는 언어나 첨부 파일과 자주 사용되는 각종 바이너리는 MIME(마임)이라는 방식으로 변환되어 전달.

개인이 SMTP 서버를 구현하여 서비스를 사용하는 것이 어렵기 때문에, 구글, 네이버 등의 플랫폼에서 SMTP 기능을 제공해줍니다.

Google - Gmail SMTP 사용을 위한 세팅

필자는 Google을 통해 해당 기능을 구현하였으며, 관련 설정은 위의 블로그를 참조하여 수행하였습니다.

application.yml

spring:
  mail:
    host: smtp.gmail.com #SMTP 호스트 서버
    port: 587 #SMTP 서버 포트
    username: ${mail.username_} # 발신자 이메일 ex) test@gmail.com - test
    password: ${mail.password} # App password
    properties:
      mail:
        smtp:
          auth: true # 사용자 인증 시도 여부 (기본값 : false)
          timeout: 5000 # Socket Read Timeout 시간 (기본값 : 무한)
          starttls:
            enable: true # startTLS 활성화 여부 (기본값: false)

상단의 mail.username_과 mail.password는 보안상 Intelli J 실행 환경변수에 추가해줍니다.(추후 config server 추가 시 git과 연동하여 처리 예정)

Properties 설정들은 다음과 같습니다.

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

EmailController

@Slf4j
@RestController
@RequestMapping("/api/v1/users/email")
@RequiredArgsConstructor
public class EmailController {

    private final EmailService emailService;

    /**
     * 인증 코드 메일 전송
     */
    @PostMapping("send")
    public ResponseEntity<String> sendEmail(@RequestBody RequestEmailCheck request) {
        emailService.sendMessage(request.getEmail());
        return ResponseEntity.status(HttpStatus.OK).body("인증 코드가 발송되었습니다.");
    }

    /**
     * 인증 코드 확인
     */
    @PostMapping("verify")
    public ResponseEntity<String> verifyCode(@RequestBody RequestEmailCheck request) {
        boolean isChecked = emailService.verifyCode(request.getEmail(), request.getCode());

        if(isChecked) {
            return ResponseEntity.status(HttpStatus.OK).body("인증이 완료되었습니다.");
        }
        else{
            return ResponseEntity.status(HttpStatus.OK).body("인증에 실패했습니다.");
        }
    }
}

RequestEmailCheck

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class RequestEmailCheck {
    @NotBlank(message="이메일을 입력 바랍니다.")
    private String email; // 전송 이메일
    @NotBlank(message="인증 코드를 입력 바랍니다.")
    private String code; // 검증 코드
}

EmailServiceImpl

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailServiceImpl implements EmailService {

    private final JavaMailSender emailSender;
    private final Environment env;
    private final JavaMailSender javaMailSender;
    private final HttpSession session;

    @Override
    public void sendMessage(String to){
        log.debug("이메일 인증 : 메시지 전송");
        try {
            MimeMessage mimeMessage = createMessage(to);
            javaMailSender.send(mimeMessage);
        }catch(MessagingException e){
            throw new RuntimeException("이메일 인증 : 생성 및 전송 중 오류발생");
        }
    }

    @Override
    public boolean verifyCode(String email, String code){
        String savedCode = String.valueOf(session.getAttribute(email));
        log.debug("이메일 인증 코드 확인 : {}", savedCode);
        if(Objects.isNull(savedCode)){
            return false;
        }
        return code.equals(savedCode);
    }

    private MimeMessage createMessage(String to) throws MessagingException{
        log.debug("이메일 인증 : 메시지 생성");

        String subject = "[BooTakHae] 회원가입 인증 안내";
        String from = env.getProperty("spring.mail.username")+"@gmail.com";
        String code = makeCode();

        MimeMessage message = emailSender.createMimeMessage();
        MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message,true,"UTF-8");

        mimeMessageHelper.setTo(to);
        mimeMessageHelper.setSubject(subject);
        mimeMessageHelper.setFrom(from);
        mimeMessageHelper.setText(setContext(code),true);

        session.setAttribute(to,code);

        return message;
    }

        int leftLimit = 48; // number '0'
        int rightLimit = 122; // alphabet 'z'
        int targetStringLength = 6;
        Random random = new Random();

        return random.ints(leftLimit, rightLimit + 1)
                .filter(i -> (i <= 57 || i >= 65) && (i <= 90 | i >= 97))
                .limit(targetStringLength)
                .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
                .toString();
    }

    private String setContext(String code){
        return "<p> 안녕하세요. \n"
                + "<p> 귀하께서 요청하신 이메일 인증을 위해 </p> \n"
                + "<br>"
                + "<p> 발송된 메일 입니다.</p> \n"
                + "<p> 인증코드 : <h2>\"" + code +"\"</h2></p>"
                + "<br>";
    }
}

해당 기능을 구현하면서 HTTP의 무상태성으로 인해 인가코드를 잠깐 저장해야할 저장소가 필요했습니다. 초반에 구현은 서버 내 Map에 잠시 저장하도록 구현했습니다.

그렇다면, 인증 코드를 HttpSession에 저장해도 될까요? 결론부터 말하면, 아닙니다.

이것은 서버에 부하를 많이 줄뿐만 아니라 관리도 어려워집니다. 메모리는 한정적이므로 서버 자체의 성능저하의 원인이 될 수 있습니다.

리팩토링

필자는 기존의 코드를 Redis를 연동하여 수정하려 합니다. 이유는 서버의 부하 감소를 위함입니다. 서버에서 인증 코드 관리 시 서버의 자원을 사용하는 것이기 때문에 전체 서비스 성능에 좋지 않을 것이라고 판단했습니다.

따라서, 저장 기간이 길지 않아도 되고, 일정 시간 후 데이터가 자동으로 삭제되도록 하기 위해서 Redis를 활용한 인증코드 저장을 구현하려합니다.

Spring Boot에 Redis 연동하기

[Redis] Redis + Spring boot 연동 (2)

[Redis] Redis를 이용한 임시번호 발급(OTP, 임시비밀번호, 인증문자) - Spring Boot

프로젝트 환경

  • Spring Boot 3.2.5
  • Redis 7.2.4
  • Spring Boot Starter Data Redis 3.2.5
  • Docker
  • WSL 2.0(Ubuntu)

Spring Boot - Redis 설정

Java에서 Redis 를 사용하기 위한 Client 2가지가 있습니다.

  • Lecttuce
  • Jedis

필자는 Lettuce를 사용하여 구현하였는데, 이유는 다음과 같습니다. Jedis는 멀티스레드 환경에서 불안정하며, Connection Pool에 한계가 있다는 단점이 존재합니다. Lecttuce는 Netty 기반 환경으로 비동기를 지원하고, Jedis보다 빠른 속도로 안정성이 있습니다.

라이브러리 추가

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

application.yml 파일 설정

spring:
  redis:
    host: localhost
    port: 6379

docker가 실행 중이더라도 Local과 Docker는 포트가 연결되어 있습니다.

Redis Config 설정 - Redis 저장소와 연결

import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisRepositoryConfig {

    private final RedisProperties redisProperties;

    // lettuce
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    // Redis template 
    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());   //connection
        redisTemplate.setKeySerializer(new StringRedisSerializer());    // key
        redisTemplate.setValueSerializer(new StringRedisSerializer());  // value
        return redisTemplate;
    }
}

key,value Serializer 관련 설정 추가를 해줍니다.

이는 RedisTemplate를 사용 하는 경우 스프링과 redis 사이에 데이터의 직렬화, 역직렬화 방식이 Redis 방식이 아닌 Spring 기준으로 생성되므로, redis-cli 를 통해 직접 데이터를 조회할때 데이터 형식을 사람이 알아볼 수 없는 형태로 출력되기 때문에 해당 설정을 적용해주어야 합니다.

Spring Boot 에서 Redis를 사용하는 방법은 RedisTemplate 과 RedisRepository 2가지 방식이 존재합니다.

RedisRepository

  • JPA와 구조가 아주 비슷합니다.(동일한 Spring Boot Starter Data에서 제공하기 떄문인듯)
  • 객체를 담아서 저장합니다.
  • 트랜잭션을 지원하지 않습니다.

RedisTemplate

  • 특정 자료구조에 값을 저장하는 방식입니다.
  • 트랜잭션을 지원하지 않습니다.

필자는 OTP를 Strings 타입으로 설정하고자 RedisTemplate을 도입하기로 했습니다.

  • 변경 전
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailServiceImpl implements EmailService {

    private final JavaMailSender emailSender;
    private final JavaMailSender javaMailSender;
    private final Environment env;
    private final HttpSession session;
//    private final RedisTemplate<String, String> redisTemplate;

    ...
    
    @Override
    public boolean verifyCode(String email, String code){

        if(email.isBlank() || code.isBlank()) throw new CustomException(ErrorCode.NOT_BLANK);

        String savedCode = String.valueOf(session.getAttribute(email));
        log.debug("이메일 인증 코드 확인 : {}", savedCode);
        if(Objects.isNull(savedCode)){
            return false;
        }
        return code.equals(savedCode);
    }

    private MimeMessage createMessage(String to) throws MessagingException{
        log.debug("이메일 인증 : 메시지 생성");

        String subject = "[BooTakHae] 회원가입 인증 안내";
        String from = env.getProperty("spring.mail.username")+"@gmail.com";
        String code = makeCode();

        MimeMessage message = emailSender.createMimeMessage();
        MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message,true,"UTF-8");

        mimeMessageHelper.setTo(to);
        mimeMessageHelper.setSubject(subject);
        mimeMessageHelper.setFrom(from);
        mimeMessageHelper.setText(setContext(code),true);

        session.setAttribute(to,code);

        return message;
    }
    
    ...
}
  • 변경 후
    private final StringRedisTemplate stringRedisTemplate;
    private static final String OTP_PREFIX = "otp:";
    
    @Override
    public boolean verifyCode(String email, String code){
        log.debug("이메일 인증 : 인증 코드 검증");
        if(email.isBlank() || code.isBlank()) throw new CustomException(ErrorCode.NOT_BLANK);
        
        String key = OTP_PREFIX + email;
        
        if(Boolean.TRUE.equals(stringRedisTemplate.hasKey(key))){
            String savedCode = stringRedisTemplate.opsForValue().get(OTP_PREFIX + email);
            log.debug("이메일 인증 코드 확인 : {}", savedCode);
            return code.equals(savedCode);
        }
        else{
            return false;
        }
    }

    private MimeMessage createMessage(String to) throws MessagingException{
        log.debug("이메일 인증 : 메시지 생성");

        String subject = "[BooTakHae] 회원가입 인증 안내";
        String from = env.getProperty("spring.mail.username")+"@gmail.com";
        long limitTime = Long.parseLong(Objects.requireNonNull(env.getProperty("redis.ttl")));
        String code = makeCode();

        MimeMessage message = emailSender.createMimeMessage();
        MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message,true,"UTF-8");

        mimeMessageHelper.setTo(to);
        mimeMessageHelper.setSubject(subject);
        mimeMessageHelper.setFrom(from);
        mimeMessageHelper.setText(setContext(code),true);
        
        stringRedisTemplate.opsForValue().set(OTP_PREFIX + to, code, limitTime, TimeUnit.SECONDS);

        return message;
   
  • Test

정상적으로 값이 저장된 것을 확인했고, TTL도 설정된 것을 확인할 수 있습니다.

  • GET key : value 조회
  • TTL key : 남은 TTL 조회

그렇다면, 인증 코드가 발급되는 부분까지는 정상적으로 처리가 됐습니다.

  • 시간 내에 입력
    • 인증 코드 검사
      • 성공 : 이메일 인증 성공
      • 실패 : 인증코드가 일치하지 않습니다.(400)
  • TTL 초과 후 입력
    • 실패 : 인증 코드 유효 시간이 초과됐습니다. 인증 코드를 재발급 후 진행바랍니다.
profile
백엔드 서버 엔지니어

0개의 댓글