본문 바로가기
코비의 개발일지

스프링 부트로 메일 발송 기능 구현하기

by 규난 2023. 6. 24.
728x90

이번에 작은 쇼핑몰 개발을 하고 있는데 메일 발송 기능을 구현하면서 제가 고민한 부분과 이 고민들을 어떻게 해결하였는지 공유하기 위해 글을 적어봅니다.

 

요구사항 중 유저가 상품을 결제하게 되면 구매한 유저와 관리자에게 주문 정보를 메일로 보내달라는 요구사항이 있었습니다.

처음에는 메일 서버에 결제 메일 발송에 관한 API를 하나 만들어서 요청 바디에 결제 메일 발송에 관련된 데이터를 받은 후 메일 템플릿을 생성해서 구매한 유저와 관리자에게 메일 발송을 해주면 되겠네!!라고 생각하였지만 다시 한번 곰곰이 생각을 해보니 이렇게 결제 메일에만 종속된 API를 만들 경우 추후에 회원가입시 메일을 발송해달라는 요청이 오면 또 회원가입시 메일 요청을 처리하는 API를 따로 만들어야 하는 상황이 오겠네... 라는 문제점을 발견하여 어떻게 하면 특정 메일 발송에 종속적이지 않은 확장이 가능한 API로 쓸 수 있을까 라는 고민을 하게되었습니다.

 

이 고민을 해결하기 위해 생각한 방법들

첫 번째 방법

각 서버(메인 서버나 어드민 서버)에 bulid.gradle에

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

타임리프 의존성을 추가해 메일 템플릿을 생성해서 메일 요청 객체에 넣어 메일 발송 요청을 하게 되면 메일 서버에서는 하나의 메일 발송 API로 여러 형태의 메일 템플릿으로 메일 발송이 가능하겠다는 생각을 하였지만 각 서버마다 타임리프 의존성이 추가돼야 한다는 것도 마음에 들지 않았고 메일 템플릿을 만드는 것은 각 서버의 관심사가 아니고 메일 서버가 가지고 가야 할 관심사라는 생각을 들어 이 방법은 과감하게 버렸습니다.

 

두 번째 방법

메일 서버의 메일 발송 API에서 @RequestBody를 상황에 따라 사용하려는 객체로 교체해서 사용할 수 없을까? 라는 생각을 하였습니다.

즉 상속 또는 구현의 다형성을 이용하는 방법입니다. 이 방법이 가능하면 메일 서버에서 하나의 메일 발송 API로 여러 형태의 메일 템플릿으로 메일이 발송 가능하기 때문에 각 서버에서 타임리프의 대한 의존성 추가와 메일 템플릿을 만드는 코드를 제거할 수 있고 메일에 관련된 관심사는 오직 메일 서버에서만 가져가고 그에 대한 요청을 처리할 수 있기 때문에 좋은 방법이라는 생각이 들어 위에서 언급한 상속을 통한 다형성을 사용해서 상황에 맞게 @RequestBody의 객체를 교체하는 방법을 찾아보았습니다.

 

구글링을 하면서 찾은 결과 역시 스프링은 웬만한 기능은 다 만들어 놓은 거 같습니다 👍

@JsonTypeInfo, @JsonSybTypes 어노테이션을 이용해서 제가 생각한 부분을 적용할 수 있었습니다.

밑의 코드를 보시면 각 서버의 요청 시 바디에 mailType이라는 key의 value로 들어온 값으로 @JsonSubTypes의 name 속성과 일치한 하위 타입의 클래스를 찾아 요청 바디를 하위 타입으로 convert 시켜주는 코드입니다. 즉, 이렇게 두 개의 어노테이션을 사용하면 제가 위에서 언급한 상속을 통한 다형성을 이용할 수 있습니다.

@Getter
@Setter
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "mailType")
@JsonSubTypes({
        @JsonSubTypes.Type(value = PaymentsRequestMailDto.class, name = "PAYMENTS")
})
public class RequestMailDto {

    @NotBlank(message = "이름은 필수입니다.")
    private String name;
    @NotBlank(message = "이메일은 필수입니다.")
    private String email;
    @NotBlank(message = "전화번호는 필수입니다.")
    private String phoneNumber;
}

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class PaymentsRequestMailDto extends RequestMailDto {

    @NotNull(message = "주문 정보는 필수입니다.")
    @Size(min = 1, message = "주문 정보는 필수입니다.")
    private List<String> ordersInfos;
}

밑 사진을 보시면 요청을 보낼 때 바디에 mailType이라는 키에 PAYMENTS라는 값을 넣어서 요청을 보내게 되면

컨트롤러의 파라미터 중 하나인 requestMailDto가 RequestMailDto의 하위 타입인 PaymentsRequestMailDto로 잘 변환이 되어서 받은 것을 확인할 수 있습니다.

포스트맨 요청 바디
send mail controller

이렇게 요청 바디에 다형성을 사용해서 하나의 API로 다향한 형태의 메일 템플릿을 생성해 메일을 발송할 수 있게 되었습니다.

 

메일 발송 프로세스

현재 메일 발송 기능의 프로세스를 말씀드리면 mail controller에서 퍼사드 패턴이 적용된 mail facade로 접근하고 mail facade에서는 메일 타입에 따라 메일 제목을 다르게 생성해야 하기 때문에 전략 패턴을 사용해 메일 타입에 맞는 MessageSourceService interface에 구현체를 사용해 메일의 제목을 생성하고 TemplateService로 내용을 생성한 후 MailService를 통해 최종적으로 메일을 발송하게 됩니다.

 

리팩토링 후 메일 발송 프로세스

  1. MailController로 요청을 받음
  2. MailController에서 MailFacade를 이용해 복잡한 하위 비즈니스로직에 조금 더 쉽게 접근
  3. MailFacade에서 파라미터 검증을 한 번 더 진행
  4. SubjectArgsFactory를 통해 RequestMailDto 하위 타입에 따라 메일 제목에 들어갈 arguments를 생성
  5. MessageSourceService에서 메일 제목을 생성
  6. TemplateService에서 메일 내용을 생성
  7. 6, 7에서 생성한 제목과 내용을 가지고 상품을 구매한 유저에게 메일 발송

밑의 코드는 메일 발송에 관련된 코드들입니다.

 

MailController

@Slf4j
@RestController
@RequestMapping("/mail")
@RequiredArgsConstructor
public class MailController {

    private final MailFacade mailFacade;

    /**
     * 메일 전송 API
     *
     * @param mailType 메일 타입
     * @param requestMailDto 메일 내용 객체
     */
    @PostMapping("/{mailType}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void sendMail(@PathVariable MailType mailType, @RequestBody @Validated RequestMailDto requestMailDto) {
        mailFacade.sendMail(mailType, requestMailDto);
    }
}

 

MailFacade

@Slf4j
@Component
@RequiredArgsConstructor
public class MailFacade {

    private final MailService mailService;
    private final TemplateService templateService;
    private final SubjectArgsFactory subjectArgsFactory;
    private final MessageSourceService messageSourceService;

    /**
     * 메일 전송 API
     *
     * @param mailType 메일 타입
     * @param requestMailDto 메일 내용 객체
     */
    public void sendMail(MailType mailType, RequestMailDto requestMailDto) {
        Stream.of(MailType.values()).filter(type -> type == mailType).findAny()
                .orElseThrow(() -> new ServiceException(ServiceError.BAD_REQUEST, "지원하지 않는 메일타입입니다."));
        if (requestMailDto == null) {
            throw new ServiceException(ServiceError.BAD_REQUEST, "메일 내용 객체는 필수입니다.");
        }
        if (!StringUtils.hasText(requestMailDto.getEmail())) {
            throw new ServiceException(ServiceError.BAD_REQUEST, "이메일은 필수입니다.");
        }

        Object[] args = subjectArgsFactory.createArgs(mailType, requestMailDto);
        String subject = messageSourceService.createSubject(mailType, args, Locale.getDefault());
        String text = templateService.createText(mailType, requestMailDto);
        mailService.sendMail(requestMailDto.getEmail(), subject, text);
    }
}

 

MailType

@Getter
@AllArgsConstructor
public enum MailType {
    PAYMENTS("payments-mail-template.html", "payments-subject");

    private final String template;
    private final String subject;
}

 

MessageSourceService

@Slf4j
@Service
@RequiredArgsConstructor
public class MassageSourceServiceImpl implements MassageSourceService {

    private final MessageSource messageSource;

   /**
     * 메일 제목 생성
     *
     * @param mailType 메일 타입
     * @param args arguments
     * @param locale 지역 객체
     * @return 메일 제목
     */
    @Override
    public String createSubject(MailType mailType, Object[] args, Locale locale) {
        Stream.of(MailType.values()).filter(type -> type == mailType).findAny()
                .orElseThrow(() -> new ServiceException(ServiceError.BAD_REQUEST, "지원하지 않는 메일타입입니다."));

        locale = locale == null ? Locale.getDefault() : locale;
        try {
            return messageSource.getMessage(mailType.getSubject(), args, locale);
        } catch (NoSuchMessageException e) {
            log.error("메일 제목 생성 실패: ", e);
            throw new ServiceException(ServiceError.INTERNAL_SERVER_ERROR, "메일 제목 생성에 실패하였습니다.", e);
        }
    }

}

위 코드의 createSubject 메소드에서 메일의 제목을 생성하는 messageSoruce.getMessage 메소드에 들어가는 파라미터인 Object[] args를 생성하는 부분 때문에 메일 타입에 따라 메일 제목을 생성하는 구현체를 동적으로 바꾸기 위해 전략 패턴을 사용하였는데 블로그를 작성하다 보니 args를 생성하는 부분을 따로 분리하고 MessageService는 메일 제목 생성에만 관심을 두도록 리팩토링을 해야 할 거 같습니다.   -> messageSource.getMessage 메소드에 들어가는 arguments를 생성하는 SubjectArgsFactory를 만들어 메일 제목을 생성하는 부분과 arguments를 생성하는 부분을 분리하였습니다. 클래스의 이름의 Factory가 들어가지만 팩토리 패턴이 적용된 것은 아닙니다.

 

SubjectArgsFactory

@Component
public class SubjectArgsFactory {

    /**
     * 메일 제목 생성 시 필요한 arguments 를 생성하기 위한 메소드
     * 
     * @param mailType 메일 타입
     * @param requestMailDto 메일 내용 객체
     * @return arguments
     */
    public Object[] createArgs(MailType mailType, RequestMailDto requestMailDto) {
        Stream.of(MailType.values()).filter(type -> type == mailType).findAny()
                .orElseThrow(() -> new ServiceException(ServiceError.BAD_REQUEST, "지원하지 않는 메일타입입니다."));

        if (requestMailDto == null) {
            throw new ServiceException(ServiceError.BAD_REQUEST, "메일 내용 객체는 필수입니다.");
        }

        Object[] args = null;
        // 추후 메일 타입이 더 추가 되면 switch case문으로 변경 예정
        if (mailType == MailType.PAYMENTS) {
            if (!(requestMailDto instanceof PaymentsRequestMailDto)) {
                throw new ServiceException(ServiceError.BAD_REQUEST, "메일 타입과 메일 내용 객체가 같지 않습니다.");
            }
            PaymentsRequestMailDto paymentsRequestMailDto = (PaymentsRequestMailDto) requestMailDto;

            if (!StringUtils.hasText(paymentsRequestMailDto.getName())) {
                throw new ServiceException(ServiceError.BAD_REQUEST, "이름은 필수입니다.");
            }
            args = new Object[]{paymentsRequestMailDto.getName()};
        }

        return args;
    }

}

 

TemplateServiceImpl
@Slf4j
@Service
@RequiredArgsConstructor
public class TemplateServiceImpl implements TemplateService {

    private final TemplateEngine templateEngine;

    /**
     * 메일 내용 생성
     *
     * @param mailType 메일 타입
     * @param requestMailDto 메일 내용 객체
     * @return 메일 내용
     */
    @Override
    public String createText(MailType mailType, RequestMailDto requestMailDto) {
        Stream.of(MailType.values()).filter(type -> type == mailType).findAny()
                .orElseThrow(() -> new ServiceException(ServiceError.BAD_REQUEST, "지원하지 않는 메일타입입니다."));
        if (requestMailDto == null) {
            throw new ServiceException(ServiceError.BAD_REQUEST, "메일 내용 객체는 필수입니다.");
        }

        try {
            return templateEngine.process(mailType.getTemplate(), createContext(requestMailDto));
        } catch (TemplateEngineException e) {
            log.error("메일 내용 생성 실패: ", e);
            throw new ServiceException(ServiceError.INTERNAL_SERVER_ERROR, "메일 내용 생성에 실패하였습니다.", e);
        }
    }


    /**
     * html value context 생성
     *
     * @param requestMailDto 결제 메일 내용 객체
     * @return context
     */
    private Context createContext(RequestMailDto requestMailDto) {
        return new Context(Locale.getDefault(), new HashMap<>() {{
            put("mailDto", requestMailDto);
        }});
    }
}

 

MailServiceImpl

@Slf4j
@Service
@RequiredArgsConstructor
public class MailServiceImpl implements MailService {

    private final JavaMailSender javaMailSender;

    @Override
    public void sendMail(String email, String subject, String text) {
    	if (!StringUtils.hasText(email)) {
            throw new ServiceException(ServiceError.BAD_REQUEST, "이메일은 필수입니다.");
        }
        if (!StringUtils.hasText(subject)) {
            throw new ServiceException(ServiceError.BAD_REQUEST, "메일 제목은 필수입니다.");
        }
        if (!StringUtils.hasText(text)) {
            throw new ServiceException(ServiceError.BAD_REQUEST, "메일 내용은 필수입니다.");
        }
        try {
            MimeMessage mimeMessage = javaMailSender.createMimeMessage();
            MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
            mimeMessageHelper.setTo(email);
            mimeMessageHelper.setSubject(subject);
            mimeMessageHelper.setText(text, true);
            javaMailSender.send(mimeMessage);
        } catch (MessagingException e) {
            log.error("메일 데이터 생성 실패: ", e);
            throw new ServiceException(ServiceError.INTERNAL_SERVER_ERROR, "메일 데이터 생성에 실패하였습니다", e);
        } catch (MailException e) {
            log.error("메일 전송 실패: ", e);
            throw new ServiceException(ServiceError.INTERNAL_SERVER_ERROR, email + "로 메일 전송 실패하였습니다.", e);
        }
    }
}

 

오랜만에 블로그를 작성하니 굉장히 뿌듯하지만 빨리 리팩토링하러 가야겠습니다!

 

조금 쉬고 리팩토링을 진행하였는데 리팩토링을 진행하면서 관심사를 조금 더 세세하게 분리하니까 각자의 역할이 뚜렷하게 보이는 장점이 있네요.

 

MailFacade는 MailController가 메일을 발송을 위해 필요한 의존성을 줄여주고 sendMail 메소드 하나를 이용해 살짝 복잡한 하위 로직에 접근을 쉽게 하여 기능을 쉽게 사용할 수 있도록 해주었고, SubjectArgsFactory는 메일 타입에 따라서 메일 제목에 들어갈 arguments만 생성하고 MessageSourceService는 arguments와 메일 타입을 이용해 메일 제목만 생성하며 TemplateService는 메일 타입과 메일 내용 객체를 이용해 메일 내용에 들어갈 템플릿만 생성하고 있습니다. 그리고 MailService는 위에서 만들어진 데이터로 오직 메일 발송만 하고 있습니다.

 

이번에 쇼핑몰 개발을 하면서 더 좋은 코드를 작성하기 위해 더 많이 공부해야겠다는 생각이 들게 하는 시간이 된 거 같습니다.

아직 개발이 다 끝나지는 않았지만 남은 부분까지 잘 만들어서 성공적으로 서비스 출시를 할 수 있기를!!

728x90