이메일 발송 비동기 적용기
이 글은 우아한테크코스 백엔드 6기 냥인, 명오에 의해 작성되었습니다.
초기 코드의 문제점
💡 2줄 요약
- 기존의 코드는 API 요청 → 이메일 저장 → 이메일 전송 → API 응답의 흐름으로 이루어졌다.
- 이메일 저장과 전송이 한 트랜잭션에서 이루어져, DB 커넥션을 길게 점유하는 문제가 존재했다.
우리가 원하는 기능은 이메일 발송에 성공했을 때만 발송 내역이 저장되는 것이었다.
아래와 같이 EmailFacade에 두 로직을 한 메서드로 묶고, @Transactional을 적용하였다. 이렇게 하면 이메일 발송 실패 시 이메일 발송 내역 저장 로직이 롤백된다.
// EmailFacade.sendAndSave()
@Transactional
public void sendAndSave(Club from, Applicant to, String subject, String content, List<MultipartFile> files) {
emailService.save(from, to, subject, content);
emailService.send(from, to, subject, content, files);
}
그러나 이 코드에는 몇 가지 문제점이 있다.
**첫 번째, 외부 API 통신이 DB 트랜잭션 범위 안에 속해 있다. **
일반적으로 이메일 전송같이 우리가 제어할 수 없는 외부 API 통신은 DB의 트랜잭션 내에서 제거하는 게 좋다. 트랜잭션을 처리하기 위해서는 DB 커넥션이 필요한데, 이 DB 커넥션은 외부 API의 적절한 응답이 올 때까지 기다려야 한다. 이 대기 시간이 길어지면 병목 현상이 발생할 가능성이 높기 때문에 외부 API 통신 코드를 트랜잭션 내에서 제거하는 게 바람직하다.
보통 이에 대한 개선책으로 파사드 패턴이 제시된다. 크루루 서비스에는 이미 모든 도메인에 전역적으로 이 파사드 패턴이 적용되어 있다. 이를 도입하게 된 이유에는 상위 계층을 제공함으로써 코드의 복잡도를 낮추기 위함도 있었지만, 추후 외부 API를 활용한 기능을 구현할 때 앞서 말한 문제점을 방지하기 위함도 있었다. 그러나 위 코드와 같이 외부 API 통신 코드를 트랜잭션 범위에 포함하게 되면서 결국 파사드 패턴을 제대로 활용하지 못했다.
두 번째, 클라이언트의 응답 대기 시간이 길어져 사용자 경험의 저하가 우려된다.
말 그대로 이메일 발송이 완료될 때까지 기다려야 하므로 클라이언트의 응답 대기 시간이 길어지는 문제가 발생했다. 고작 한 명에게 발송하는 데에도 꽤 긴 시간이 걸리는데, 다수의 사용자에게 메일을 발송한다면 클라이언트가 대기해야 하는 시간이 굉장히 길어질 것으로 보였다. 이로 인해 사용자 경험이 저하되는 상황을 예상했다.
해결 방안 1) 이벤트 리스너 + 비동기
첫 번째로 고안해 낸 방안은 이렇다.
- 첫 번째 문제 → 트랜잭션이 완료된 후 이메일 발송을 별도의 이벤트 리스너에서 처리
- 두 번째 문제 → 이메일 발송 작업에 비동기를 적용
그렇게 구현한 코드의 일부이고, 비동기는 아직 적용하지 않은 상태다.
SendEmailEvent
@Getter
public class SendEmailEvent extends ApplicationEvent {
private Club from;
private Applicant to;
private String subject;
private String content;
private List<MultipartFile> files;
public SendEmailEvent(
Object source,
Club from,
Applicant to,
String subject,
String content,
List<MultipartFile> files
) {
super(source);
this.from = from;
this.to = to;
this.subject = subject;
this.content = content;
this.files = files;
}
}
EmailEventListener
@Component
@RequiredArgsConstructor
public class EmailEventListener {
private final EmailService emailService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, classes = SendEmailEvent.class)
public void handleSendEmailEvent(SendEmailEvent event) {
emailService.send(event.getFrom(), event.getTo(), event.getSubject(), event.getContent(), event.getFiles());
}
}
EmailFacade
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class EmailFacade {
private final EmailService emailService;
private final ClubService clubService;
private final ApplicantService applicantService;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void send(EmailRequest request) {
Club from = clubService.findById(request.clubId());
request.applicantIds()
.stream()
.map(applicantService::findById)
.forEach(to -> sendAndSave(from, to, request.subject(), request.content(), request.files()));
}
@Transactional
public void sendAndSave(Club from, Applicant to, String subject, String content, List<MultipartFile> files) {
emailService.save(from, to, subject, content);
//emailService.send(from, to, subject, content, files);
eventPublisher.publishEvent(new SendEmailEvent(this, from, to, subject, content, files));
}
}
@TransactionalEventListener
를 사용하여 트랜잭션이 성공한 경우에만(= 발송 내역이 성공적으로 저장된 경우에만) 메일을 전송할 수 있게 하였고, 트랜잭션이 실패하는 경우에도 메일이 발송되어 버리는 문제를 방지하였다.
그러나 결국 이 방법을 도입하지 않았다. 그 이유는 명오의 아주 예리한 지적 때문이었다.
이렇게 구현해도 이벤트 리스너를 적용한 코드와 결과가 같지 않나?
public void sendAndSave(Club from, Applicant to, String subject, String content, List<MultipartFile> files) {
emailService.save(from, to, subject, content);
emailService.send(from, to, subject, content, files);
}
맞다. 이 코드의 경우 save()에서 예외가 발생하면 이후 코드는 실행되지 않는다. 즉, 이메일이 발송되지 않는다는 것이다. 똑같은 결과를 도출하면 더 간단한 방법을 선택하지 않을 이유가 없어서, 비교적 코드의 복잡도를 올리는 이벤트 리스너를 걷어냈다.
해결 방안 2) 비동기 @Async + CompletableFuture
@Async의 한계
@Async를 메서드에 붙이면 비동기를 적용할 수 있다. 아래와 같이 코드를 작성했다.
// EmailFacade.send()
public void send(Club from, List<Applicant> tos, String subject, String content, List<MultipartFile> files) {
for(Applicant to:tos) {
Email email = emailService.send(from, to, subject, content, files);
emailService.save(email);
}
}
// EmailService.send()
@Async
public Email send(Club from, Applicant to, String subject, String content, List<MultipartFile> files) {
try {
// 전송 로직
...
// 전송 실패 유무에 따라 다르게 반환한다.
return new Email(from, to, subject, content, SUCCESS);
} catch (Exception e) {
return new Email(from, to, subject, content, FAIL);
}
}
하지만 이와 같은 방법은 경고가 발생한다.
Method annotated with @Async should return 'void' or "Future-like" type
@Async 메서드는 일반적인 반환값을 가질 수 없고, 위와 같이 반환하는 경우 비동기가 적용되지 않는다.
CompletableFuture
@Async가 적용된 메서드의 반환값을 사용하면서, 비동기의 특징을 살리기 위해 Future 객체를 반환할 수 있다.
→ CompletableFuture 객체
// EmailFacade.send()
public void send(Club from, List<Applicant> tos, String subject, String content, List<MultipartFile> files) {
tos.stream()
.map(to -> emailService.send(from, to, subject, text, tempFiles))
.forEach(future -> future.thenAccept(emailService::save));
}
// EmailService.send()
@Async
public CompletableFuture<Email> send(Club from, Applicant to, String subject, String content, List<MultipartFile> files) {
try {
...
// 이메일 전송 성공 여부를 함께 저장한다.
return new Email(from, to, subject, content, SUCCESS);
} catch (Exception e) {
return new Email(from, to, subject, content, FAIL);
}
}
Future 객체를 사용하여 save() 메서드가 실제로 호출되기 전에 응답을 보낼 수 있다. Future 객체에 실제 값이 생기는 시점에서 save() 메서드가 호출된다. 일괄 전송 후 저장을 하고 있기 때문에 parallelStream()을 사용하지 않아도 병렬적으로 전송된다.
→ CompletableFuture 객체를 사용하여 이메일 발송이 완료되기 전에 클라이언트에게 응답을 보낼 수 있다. 발송이 완료된 시점에서 save() 메서드가 호출되어 이메일 발송 내역이 저장된다. 이메일 발송은 각 send() 메서드 호출이 비동기로 처리되기 때문에 parallelStream()을 사용하지 않아도 동시에 발송되는 효과를 얻을 수 있다.
난관 봉착 1) NoSuchFileException
이메일 발송 시 파일 첨부 기능을 위해 MultipartFile을 활용하였다.
그런데 이 과정에서 자꾸 java.nio.file.NoSuchFileException
이 발생했다.
이는 MultipartFile로 전달된 파일이 임시 저장소에 저장되는 특징 때문이었다.
크루루의 경우 application.yml에 아래와 같이 설정을 해주었다.
servlet:
multipart:
enabled: true
file-size-threshold: 2KB
max-file-size: 25MB
max-request-size: 50MB
file-size-threshold를 2KB로 설정했기 때문에, 첨부 파일이 2KB 초과할 경우 임시 저장소에 저장된다. 사실상 거의 모든 파일이 메모리가 아닌 임시 저장소에 저장된다는 것이다.
문제는 임시 저장소에 저장된 임시 파일은 HTTP 요청이 종료되거나 처리 메서드가 종료되면 자동으로 삭제된다는 점이다. 그래서 비동기적으로 이메일을 발송하는 로직에서 파일을 첨부하려고 할 때, 파일이 이미 삭제되어 더 이상 접근할 수 없게 되고 NoSuchFileException이 발생한다.
해결 방법은 간단하다. 임시 파일을 영구 저장해 주면 된다.
크루루는 아래와 같이 EmailFacade와 FileUtil에 임시 파일을 저장하는 메서드를 만들어서 비동기 작업이 시작되기 전 파일을 저장해 주었다.
// EmailFacade.saveTempFiles()
private List<File> saveTempFiles(Club from, String subject, List<MultipartFile> files) {
try {
return FileUtil.saveTempFiles(files);
} catch (IOException e) {
throw new EmailAttachmentsException(from.getId(), subject);
}
}
// FileUtil.saveTempFiles()
public static List<File> saveTempFiles(List<MultipartFile> files) throws IOException {
if (files == null) {
return new ArrayList<>();
}
List<File> tempFiles = new ArrayList<>();
for (MultipartFile file : files) {
File tempFile = File.createTempFile(FILE_PREFIX, FILE_SUFFIX + file.getOriginalFilename());
file.transferTo(tempFile);
tempFiles.add(tempFile);
}
return tempFiles;
}
그리고 비동기 작업이 완료된 후 파일을 명시적으로 삭제해 주었다.
// EmailFacade.sendAndSave()
private void sendAndSave(Club from, List<Applicant> tos, String subject, String text, List<MultipartFile> files) {
// ... 이메일 전송 및 발송 내역 저장 로직 ...
// 비동기 작업 완료 후 파일 삭제
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenRun(() -> FileUtil.deleteFiles(tempFiles));
}
// FileUtil.deleteFiles()
public static void deleteFiles(List<File> files) {
if (files != null) {
files.forEach(FileUtil::deleteFile);
}
}
// FileUtil.deleteFile()
private static void deleteFile(File file) {
if (file.exists()) {
file.delete();
return;
}
log.info("삭제할 파일이 존재하지 않습니다: {}", file.getAbsolutePath());
}
난관 봉착 2) 비동기 테스트 작성
@Async를 사용한다고 해서 비동기로 동작한다는 것을 확신할 수 없기 때문에 테스트를 작성했다. 비동기로 동작하는지 확인하는 테스트를 작성하기 위해 아래 방식들을 시도했다.
시도 1: 메서드 실행 시간을 직접 측정하는 방식
@Test
void send() {
// given
Mockito.doAnswer(invocation -> {
TimeUnit.SECONDS.sleep(1);
return null;
}).when(javaMailSender).send(any(MimeMessage.class));
EmailRequest emailRequest = new EmailRequest{
// 이메일을 한 번 전송하는 요청
}
...
// when
long before = System.currentTimeMillis();
emailFacade.send(emailRequest);
long after = System.currentTimeMillis();
// then
// 총 시간이 1초보다 적게 걸렸는지
assertThat(after - before).isLessThan(1000);
}
위 방식은 이메일을 전송하는 메서드가 이메일을 모두 전송하기 전에 완료되는지 확인하는 메서드이다. 하지만 이와 같은 방법은 이메일 전송 혹은 저장이 실제로 이루어졌는지 확인할 수 없다. 이메일 관련 로직이 실행되기 전에 메서드가 종료된다.
시도 2: 메일 전송 완료까지 기다린 후 검증하는 방식
@Test
void send() {
// given
// 위와 동일
...
// when
emailFacade.send(emailRequest);
// then
verify(javaMailSender, times(0)).send(any(MimeMessage.class));
TimeUnit.SECONDS.sleep(2);
verify(javaMailSender, times(1)).send(any(MimeMessage.class));
verify(emailService, times(1)).save(any(Email.class));
}
위 방식은 메서드가 호출된 직후 send() 메서드가 호출되지 않았는지 확인하고, 2초를 기다린 이후 send() 메서드와 save() 메서드가 호출되었는지 확인한다. 이 방식은 테스트가 최소 2초가 걸린다는 단점이 존재한다.
시도 3: Awaitility 이용
위 방식은 untilAsserted() 메서드를 활용하여 2초가 지나지 않더라도 내부 조건이 충족되면 메서드를 종료한다. 이를 사용하여 테스트의 시간을 줄일 수 있다.
@Test
void send() {
// given
// 위와 동일
...
// when
emailFacade.send(request);
// then
verify(javaMailSender, times(0)).send(any(MimeMessage.class));
await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> {
verify(javaMailSender, times(1)).send(any(MimeMessage.class));
verify(emailService, times(1)).save(any(Email.class));
});
}