photocard backend server 개발일기

[인증메일] 메일 구현 및 테스트 코드 작성

한둥둥 2025. 1. 8. 00:34

우선적으로 Jmeter를 사용하여 Redis, DB로 사용하였을 때, 각각 부하테스트를 진행해보았다. 기존에 /v1 을 /v2로 변경중이다. 

 

Controller 코드이다. 

package com.realworld.web.auth.mail.controller;

import com.realworld.application.auth.mail.service.AuthMailService;
import com.realworld.v1.global.code.SuccessCode;
import com.realworld.v1.global.response.ApiResponse;
import com.realworld.web.auth.mail.payload.request.AuthMailRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/v2/auth")
public class AuthController {

    private final AuthMailService authMailService;

    @PostMapping(value="/email")
    public ResponseEntity<Object> authMail(@RequestBody AuthMailRequest request) {
        authMailService.send(request);

        return ResponseEntity.status(HttpStatus.CREATED).body(new ApiResponse<>(null,
                SuccessCode.INSERT_SUCCESS.getStatus(), SuccessCode.INSERT_SUCCESS.getMessage()));
    }

    @GetMapping(value="/email/{auth_number}")
    public ResponseEntity<ApiResponse<String>> checkAuthMail(@RequestParam("user_email") String userEmail, @PathVariable("auth_number") String authNumber) {
        authMailService.check(userEmail, authNumber);

        return ResponseEntity.ok(new ApiResponse<>(null, 200, "이메일 인증 성공하였습니다."));
    }

}

 

MailService 인터페이스 

package com.realworld.application.auth.mail.service;


import com.realworld.web.auth.mail.payload.request.AuthMailRequest;

public interface AuthMailService {

    void send(AuthMailRequest request);

    void check(String userEmail, String authNumber);

}

메일 보내기, 인증 메일 체크 로직만 필요하기에 해당 방식으로 구현해주었다. 

 

 

Service코드 

package com.realworld.application.auth.mail.service;

import com.realworld.application.auth.mail.port.AuthMailRepository;
import com.realworld.common.holder.date.DateTimeHolderImpl;
import com.realworld.common.holder.auth.key.MailKeyGeneratorHolderImpl;
import com.realworld.feature.auth.mail.entity.AuthMail;
import com.realworld.infrastructure.mail.MailSender;
import com.realworld.v1.global.code.ErrorCode;
import com.realworld.v1.global.config.exception.CustomMemberExceptionHandler;
import com.realworld.web.auth.mail.payload.request.AuthMailRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthMailServiceImpl implements AuthMailService {

    private final AuthMailRepository authMailRepository;
    private final MailSender mailSender;

    @Override
    public void send(AuthMailRequest request) {
        AuthMail authMail = AuthMail.createMail(request.getUserEmail(), new MailKeyGeneratorHolderImpl(), new DateTimeHolderImpl());
        authMail.send(mailSender);

        authMailRepository.save(authMail);
    }

    @Override
    public void check(String userEmail, String authNumber) {
        AuthMail authMail = authMailRepository.findByUserEmail(userEmail).orElseThrow(() -> new CustomMemberExceptionHandler(ErrorCode.EMAIL_REQUEST_ERROR));

        authMail.authCheck(authNumber);
    }

}

도메인을 통해서 메시지를 입력하면 값을 보내는 형식으로 코드를 작성해주었다. 

 

Mail 도메인 코드 

package com.realworld.feature.auth.mail.entity;

import com.realworld.common.holder.auth.key.MailKeyGeneratorHolder;
import com.realworld.common.holder.date.DateTimeHolder;
import com.realworld.infrastructure.mail.MailSender;
import com.realworld.v1.global.code.ErrorCode;
import com.realworld.v1.global.config.exception.CustomMailExceptionHandler;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import java.time.LocalDateTime;

@Slf4j
@Getter
@ToString
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@RedisHash(value="authMail", timeToLive = 360)
public class AuthMail {
    @Id
    private String userEmail;

    private String authNumber;

    private LocalDateTime registerDate;

    @Builder
    private AuthMail(String userEmail, String authNumber, LocalDateTime registerDate) {
        this.userEmail = userEmail;
        this.authNumber = authNumber;
        this.registerDate = registerDate;
    }

    public static AuthMail createMail(String userEmail, MailKeyGeneratorHolder keyGeneratorHolder, DateTimeHolder dateTimeHolder) {
        return new AuthMail(userEmail, keyGeneratorHolder.generate(), dateTimeHolder.generate());
    }

    public String send(MailSender mailSender) {
        String message = createMessage();
        mailSender.send(message, this.userEmail);
        return message;
    }

    private String createMessage() {
        String msg = "<div style='margin:100px;'>";
        msg += "<h1> 인증번호 : " + this.authNumber + "</h1>";
        msg += "</div>";
        return msg;
    }

    public void authCheck(String authNumber) {
        if(!this.authNumber.equals(authNumber)) {
            throw new CustomMailExceptionHandler(ErrorCode.EMAIL_AUTH_NUMBER_ERROR);
        }
    }

}

 

여기서 고민이었던 것은 authNumber이다. AuthNumber로 클래스로 따로 빼야하는지 말아야하는지에 대해 고민이 많았고 사실 클래스명이 AuthMail이기때문에 충분히 authNumber필드가 존재하여도 된다고 팀원과 판단하였고, 해당 방식으로 구현하기로 합의 봤다. 추후 인증번호에 대해서 사용하는 클래스가 많아진다면 따로 클래스로 분리하는 것도 좋은 방법인거 같다. 

 

MailSender인터페이스다 추후, 메일 클래스를 쉽게 테스트코드를 작성하기 위하여 메일Sender인터페이스를 만들어주었다. 

 

package com.realworld.infrastructure.mail;

@FunctionalInterface
public interface MailSender {

    void send(String message, String userEmail);

}

 

 

 

StmpMailSender 

package com.realworld.infrastructure.mail;

import com.realworld.v1.global.code.ErrorCode;
import com.realworld.v1.global.config.exception.CustomMailExceptionHandler;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;

import java.io.UnsupportedEncodingException;

@Slf4j
@Component
@RequiredArgsConstructor
public class SmtpMailSender implements MailSender {

    private final JavaMailSender javaMailSender;

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

    @Override
    public void send(String message, String userEmail) {
        javaMailSender.send(createMessage(message, userEmail));
    }

    private MimeMessage createMessage(String message, String userEmail) {
        MimeMessage mimeMessage = javaMailSender.createMimeMessage();

        try {
            mimeMessage.setSubject("포토카드 이메일 인증 코드");
            mimeMessage.addRecipients(Message.RecipientType.TO, userEmail);
            mimeMessage.setText(message, "utf-8", "html");
            mimeMessage.setFrom(new InternetAddress(from, "PhotoCard_Admin"));
        } catch (MessagingException | UnsupportedEncodingException e) {
            log.error("이메일 전송 실패 :: {}", e);
            throw new CustomMailExceptionHandler(ErrorCode.EMAIL_REQUEST_ERROR);
        }
        return mimeMessage;
    }

}

 

SMTP를 통하여 인증 메일을 보내는 코드입니다. 

 

createMessage에서는 메세지를 만들어주고, 이후에 send메소드를 통해서 메세지를 보내줍니다. 

 

AuthMailTest코드입니다. 

package com.realworld.feature.auth.mail.domain.mail;

import com.realworld.feature.auth.mail.entity.AuthMail;
import com.realworld.feature.auth.mail.mock.mail.MockMailData;
import com.realworld.v1.global.config.exception.CustomMailExceptionHandler;
import org.junit.jupiter.api.Test;

import java.time.LocalDateTime;

import static org.assertj.core.api.Assertions.*;

public class AuthMailTest {

    @Test
    void 메일_인증번호_생성_성공_테스트() {
        AuthMail authMail = AuthMail.createMail(MockMailData.userEmailMockData1, () -> "testMail", () -> LocalDateTime.of(2025, 1, 2, 12, 8, 0));
        assertThat(authMail).isEqualTo(
                AuthMail.builder()
                        .userEmail(MockMailData.userEmailMockData1)
                        .authNumber("testMail")
                        .registerDate(LocalDateTime.of(2025, 1, 2, 12, 8, 0))
                        .build()
        );
    }

    @Test
    void 메일_인증번호_체크_성공_테스트() {
        AuthMail authMail = AuthMail.createMail(MockMailData.userEmailMockData1, () -> "otirj109",  () -> LocalDateTime.of(2025, 1, 2, 12, 8, 0));

        assertThatCode(() -> authMail.authCheck("otirj109"))
                .doesNotThrowAnyException();

    }

    @Test
    void 메일_인증번호_체크_실패_코드() {
        AuthMail authMail = AuthMail.createMail(MockMailData.userEmailMockData1, () -> "otirj109", () -> LocalDateTime.of(2025, 1, 2, 12, 8, 0));

        assertThatThrownBy(() -> authMail.authCheck("otirj109dd"))
                .isInstanceOf(CustomMailExceptionHandler.class);
    }

    @Test
    void 메일_인증번호_보내기_성공_테스트() {
        AuthMail authMail = AuthMail.createMail(MockMailData.userEmailMockData1, () -> "testMail", () -> LocalDateTime.of(2025, 1, 2, 12, 8, 0));

        String message = authMail.send((message1, userEmail) -> {
            return;
        });

        assertThat(message).isEqualTo("<div style='margin:100px;'><h1> 인증번호 : testMail</h1></div>");

    }

}

도메인에 대한 테스트가 들어있으며, 각각 성공 실패 테스트를 담았습니다. 

 

MockMailData

package com.realworld.feature.auth.mail.mock.mail;

public class MockMailData {

    public static String userEmailMockData1 = "test@naver.com";

}

 

MockMailData 클래스를 통하여 테스트에서 자주 사용되는 것을 만들어 사용했습니다. 대문자로 표기했던게 자바 문법에는 맞는 방법인거 같습니다. 

 

AuthMailRepositoryImpl

package com.realworld.infrastructure.persistence.auth.mail.repository;

import com.realworld.application.auth.mail.port.AuthMailRepository;
import com.realworld.feature.auth.mail.entity.AuthMail;
import com.realworld.feature.auth.mail.mock.mail.MockMailData;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import java.time.LocalDateTime;

import static org.assertj.core.api.Assertions.assertThat;


@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AuthMailRepositoryImplTest {

    @Autowired
    AuthMailRepository repository;

    @Test
    void 메일_저장_테스트() {
        AuthMail authMail = AuthMail.createMail(MockMailData.userEmailMockData1, () -> "otirj109", () -> LocalDateTime.of(2025, 1, 2, 12, 8, 0));
        repository.save(authMail);

        AuthMail expected = repository.findByUserEmail(MockMailData.userEmailMockData1).get();
        assertThat(authMail).isEqualTo(expected);
    }

}

 

레디스 테스트를 진행해주었다. 

 

해당 부분이 DB로 진행하였을 때이고, 

아래 부분이 Redis로 진행하였을 때다. 

 

놀랍게 Redis가 더 오래걸린다. 보니깐 SMTP에서 얼마나 빨리 처리해주냐에따라 시간차가 생기는거 같다. 그래서 적은 데이터로 꽤여러번 진행해본 결과, 큰 의미는 없으나, Redis를 사용하면 TTL을 통하여 만료 로직을 만들어주지도 않아도 된다는 점이 매력적이여서 Redis를 사용하는 걸로 결정하였다. 또한 단순히 메일 인증번호 같은 경우는 데이터의 중요성이 낮기 때문에 해당 방식으로 구현해주었다. 

 

다음번에는 인증번호로 인증할 때, 걸리는 속도로 부하 테스트하여 글에 추가할 예정이다.