本記事は、韓国の実践型開発者育成プログラム「우아한테크코스(ウアハンテックコース)」の「テクニカルライティング」活動を通じて執筆したものです。韓国語で執筆した記事を日本語に翻訳したものです。そのため、不自然な表現が含まれている可能性があります。
「クルル」サービスにおけるメール送信機能に非同期処理を適用する中で直面した課題と、その解決までの過程をご紹介します。
この記事では、メール送信機能そのものの実装方法ではなく、なぜ非同期処理が必要なのか、そして適用する際に発生した問題や解決策に焦点を当てています。
初期コードの問題点
私たちが実現したかった機能は、メール送信が成功した場合にのみ送信履歴を保存することでした。
以下のように EmailFacade に2つのロジックを1つのメソッドにまとめ、@Transactional を適用することで、メール送信に失敗した場合には送信履歴保存のロジックがロールバックされるように実装しました。
// EmailFacade.sendAndSave()
@Transactional
public void sendAndSave(Club from, Applicant to, String subject, String content, List<MultipartFile> files) {
emailService.send(from, to, subject, content, files);
emailService.save(from, to, subject, content);
}
しかし、このコードには次のような問題がありました。
1. 外部API通信をDBトランザクションの範囲内で実行している
一般的に、メール送信のような制御できない外部API通信は、DBトランザクションから切り離す方が望ましいです。
トランザクションを処理するにはDBコネクションが必要ですが、外部APIのレスポンスを待っている間もそのコネクションが占有され続けます。
この待ち時間が長くなるほどボトルネックが発生するリスクが高まるため、外部API呼び出しはトランザクションの外側に分離することが推奨されます。
通常、この改善策として「ファサードパターン」が用いられます。
クルルサービスでも、全てのドメインにこのパターンを適用することでコードの複雑性を下げ、保守性を高めることを目指していました。
また、外部APIを利用する機能を実装する際に、上記のような問題を未然に防ぐ目的もありました。
しかし、上記のコードのように外部API呼び出しがトランザクション範囲に含まれてしまったため、ファサードパターンの利点を十分に活かすことができませんでした。
2. クライアントのレスポンス待ち時間が長くなり、ユーザー体験が低下する恐れがある
メール送信が完了するまでクライアントが待機しなければならないため、レスポンス待ち時間が長くなる問題が発生しました。
たった一人にメールを送るだけでもかなりの時間がかかり、複数ユーザーに送信する場合には待ち時間が非常に長くなることが予想されました。
その結果、ユーザー体験が低下するリスクが懸念されました。
解決策 1) イベントリスナーと @Async の活用
最初に考えたアプローチは次のとおりです。
- 1つ目の問題: 「メール送信履歴の保存」というトランザクションを完了した後、メール送信を別のイベントリスナーで処理する
- 2つ目の問題: メール送信処理に非同期処理を適用する
以下は、そのアプローチを実装したコードの一部ですが、まだ非同期処理は適用されていません。
-
SendEmailEvent.java
@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.java
@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.java
@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); eventPublisher.publishEvent(new SendEmailEvent(this, from, to, subject, content, files)); } }
ここで鋭い方なら、コードの流れに疑問を持たれるかもしれません。
通常想定される順序は 「メール送信 → 送信履歴の保存」 ですが、実際のコードでは 「送信履歴の保存 → メール送信」 の順で処理されています。
その理由は、データ不整合を防ぐためです。
もしメールを先に送信してから送信履歴保存の段階で例外が発生すると、DBトランザクションはロールバックされますが、一度送信されたメールは取り消せません。
そこで、@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 アノテーションを使うことでした。
下記のように、メール送信ロジックに単純に @Async を付与しました。
-
EmailService.java
@Async public void send(Club from, Applicant to, String subject, String content, List<MultipartFile> files) { try { System.out.println("非同期メソッド実行開始..."); MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true); helper.setTo(to.getEmail()); helper.setSubject(subject); helper.setText(content); if (hasFile(files)) { addAttachments(helper, files); } mailSender.send(message); System.out.println("非同期メソッド実行完了..."); } catch (Exception e) { // ... } } ... @Transactional public void save(Club from, Applicant to, String subject, String content, List<MultipartFile> files) { Email email = new Email(from, to, subject, content, true); emailRepository.save(email); }
-
EmailFacade.java
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())); } public void sendAndSave(Club from, Applicant to, String subject, String content, List<MultipartFile> files) { System.out.println("メール送信開始..."); emailService.send(from, to, subject, content, files); System.out.println("メール送信完了..."); emailService.save(from, to, subject, content, files); System.out.println("送信履歴保存完了"); }
-
出力結果
Hibernate: select c1_0.club_id,c1_0.member_id,c1_0.name from club c1_0 where c1_0.club_id=? Hibernate: select a1_0.applicant_id,a1_0.created_date,a1_0.email,a1_0.is_rejected,a1_0.name,a1_0.phone,a1_0.process_id,a1_0.updated_date from applicant a1_0 where a1_0.applicant_id=? メール送信開始... メール送信完了... 非同期メソッド実行開始... Hibernate: insert into email (content,created_date,club_id,is_succeed,subject,applicant_id,updated_date) values (?,?,?,?,?,?,?) 送信履歴保存完了 Hibernate: select a1_0.applicant_id,a1_0.created_date,a1_0.email,a1_0.is_rejected,a1_0.name,a1_0.phone,a1_0.process_id,a1_0.updated_date from applicant a1_0 where a1_0.applicant_id=? メール送信開始... メール送信完了... 非同期メソッド実行開始... Hibernate: insert into email (content,created_date,club_id,is_succeed,subject,applicant_id,updated_date) values (?,?,?,?,?,?,?) 送信履歴保存完了 非同期メソッド実行完了... 非同期メソッド実行完了...
このように、メール送信ロジックに非同期処理が適用されていることは確認できました。
しかし、この方法には問題がありました。
メール送信の成否に関わらず送信履歴が保存されてしまうのです。
例えば、ネットワーク障害やSMTPサーバーの問題でメール送信に失敗しても、DBには正常に送信されたかのように記録されるリスクがあります。
つまり、ユーザーはメールを受け取っていないのに、システム上は「正常送信」と扱われてしまう可能性がありました。
そのため、メール送信処理の成功可否を確認できる仕組みが必要でした。
そこで EmailService.send() メソッドが送信結果を含む Email オブジェクトを返却し、そのオブジェクトを送信履歴テーブルに保存する方式を考えました。
しかし、@Async には 戻り値は void でなければならない という制約があるため、この方式は適用できませんでした。
つまり、メール送信処理の結果を直接返却することができなかったのです。
CompletableFuture の利用
そこで追加で活用したのが CompletableFuture クラスです。
CompletableFuture は Java が提供する非同期プログラミングのためのツールで、非同期処理の結果を柔軟に扱うことができます。
これを利用することで、EmailService.send() が送信結果として Email オブジェクトを返し、そのオブジェクトを履歴テーブルに保存するロジックを実現できました。
-
EmailService.java
@Async // メール送信結果として CompletableFuture<Email> を返却 public CompletableFuture<Email> send( Club from, Applicant to, String subject, String content, List<MultipartFile> files) { try { System.out.println("非同期メソッド実行開始..."); MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true); helper.setTo(to.getEmail()); helper.setSubject(subject); helper.setText(content); if (hasFile(files)) { addAttachments(helper, files); } mailSender.send(message); System.out.println("非同期メソッド実行完了..."); // Email(from, to, subject, content, isSucceed) // メール送信に成功した場合、Email エンティティの isSucceed フィールドを true に設定します。 return CompletableFuture.completedFuture(new Email(from, to, subject, content, true)); } catch (MessagingException | MailException e) { // メール送信に失敗した場合、Email エンティティの isSucceed フィールドを false に設定します。 return CompletableFuture.completedFuture(new Email(from, to, subject, content, false)); } }- メール送信結果として
CompletableFutureオブジェクトを返却します。 - メール送信に成功した場合は
EmailエンティティのisSucceedフィールドをtrueに、失敗した場合はfalseに設定します。
- メール送信結果として
-
EmailFacade.java
ここでは、
public void send(EmailRequest request) { Club from = clubService.findById(request.clubId()); List<Applicant> applicants = request.applicantIds() .stream() .map(applicantService::findById) .toList(); sendAndSave(from, applicants, request.subject(), request.content(), request.files()); } private void sendAndSave(Club from, List<Applicant> tos, String subject, String text, List<MultipartFile> files) { System.out.println("sendAndSave開始..."); tos.stream() .map(to -> emailService.send(from, to, subject, text, files)) .map(future -> future.thenAccept(emailService::save)) .toList(); System.out.println("sendAndSave完了..."); }CompletableFutureが完了するたびにemailService.save()が呼び出されます。thenAccept()は非同期処理が正常に完了した際に後続処理を定義できるメソッドです。
-
出力結果
このように、非同期処理が完了した後にのみ送信履歴が保存されることを確認できました。
sendAndSave 開始... 非同期メソッド実行開始... sendAndSave 完了... 非同期メソッド実行開始... 非同期メソッド実行完了... 送信履歴保存開始... Hibernate: insert into email (content,created_date,club_id,is_succeed,subject,applicant_id,updated_date) values (?,?,?,?,?,?,?) 送信履歴保存完了... 非同期メソッド実行完了... 送信履歴保存開始... Hibernate: insert into email (content,created_date,club_id,is_succeed,subject,applicant_id,updated_date) values (?,?,?,?,?,?,?) 送信履歴保存完了...
難関に直面 1) NoSuchFileException
メール送信時のファイル添付機能には MultipartFile を使用していました。
しかし、非同期処理を適用したところ、添付ファイル付きのメールを送信するたびに java.nio.file.NoSuchFileException が発生しました。
Failed messages: jakarta.mail.MessagingException: IOException while sending message;
nested exception is:
java.nio.file.NoSuchFileException: /private/var/folders/q9/256wydx95tbgjsx737_3zysc0000gp/T/tomcat.8080.*/upload_*.tmp
これは、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;
}
// 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());
}
// EmailFacade.sendAndSave()
private void sendAndSave(Club from, List<Applicant> tos, String subject, String text, List<MultipartFile> files) {
List<File> tempFiles = saveTempFiles(from, subject, files);
List<CompletableFuture<Void>> futures = tos.stream()
.map(to -> emailService.send(from, to, subject, text, files))
.map(future -> future.thenAccept(emailService::save))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenRun(() -> FileUtil.deleteFiles(tempFiles));
}
難関に直面 2) 非同期テストの作成
前述のように、単純に System.out.println() を埋め込んでメソッドの実行順序を確認する方法は非常に非効率的です。
そこで、メール送信処理が非同期的に正しく動作するかどうかを効率的に検証するために、テストコードを作成しました。以下では試みた方法を順に紹介します。
試み 1: メソッド呼び出し前後の時刻差で非同期を判定
@DisplayName("メールを非同期で送信する")
@Test
void sendEmailMany() throws IOException {
// given
// javaMailSender.send() が呼び出されるたびに 1秒の遅延を発生させ、
// 実際にメールが非同期で送信されているかのように見せます。
Mockito.doAnswer(invocation -> waitSeconds(1))
.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);
}
private Object waitSeconds(long timeout) {
try {
TimeUnit.SECONDS.sleep(timeout);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return null;
}
この方法は emailFacade.send() の呼び出し前後の時刻差に依存して非同期処理を確認するものです。
しかし、このやり方では メール送信や履歴保存が実際に行われたかどうか を確認することはできません。
試み 2: Mockito.verify() を活用して送信・保存を検証
@DisplayName("メールを非同期で送信し、履歴を保存する")
@Test
void sendAndSave() {
// given
Mockito.doAnswer(invocation -> waitSeconds(1))
.when(javaMailSender).send(any(MimeMessage.class));
...テストデータ生成...
EmailRequest request = new EmailRequest(...);
// when
emailFacade.send(request);
// then
verify(javaMailSender, times(0)).send(any(MimeMessage.class));
waitSeconds(2);
verify(javaMailSender, times(1)).send(any(MimeMessage.class));
verify(emailService, times(1)).save(any(Email.class));
}
private Object waitSeconds(long timeout) {
try {
TimeUnit.SECONDS.sleep(timeout);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return null;
}
ここでは Mockito.verify() を利用し、メール送信と履歴保存処理がそれぞれ実際に呼び出されたか を検証しました。これにより、試み1で挙げた問題は解決されました。
ただし、この方法にも課題が残りました。
TimeUnit.SECONDS.sleep(2) を使用すると、テストが必ず最低2秒かかってしまうのです。
試み 3: Awaitility の利用
@DisplayName("メールを非同期で送信し、履歴を保存する")
@Test
void sendAndSave() {
...
// 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));
});
}
Awaitility を使用すると、指定した時間内で条件が満たされるまでのみ待機する ように実装できます。
上記のコードのように await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { … }); と書けば、2秒以内に javaMailSender.send() と emailService.save() メソッドがそれぞれ1回ずつ呼び出されれば、検証はその時点で完了します。
もしメール送信と履歴保存の処理が1秒で終了した場合、残りの時間を待たずに即座にテストを終了できます。
結果として、メール送信および履歴保存処理の完了を効率的に確認しつつ、不必要な待機時間を削減することができました。
まとめ
これまで、メール送信機能に非同期処理を適用する中で直面した課題と、その解決策をご紹介しました。
トランザクションの中から外部API呼び出しを分離することで、パフォーマンス低下やボトルネックを防止し、
非同期処理の導入によって 性能最適化 と ユーザー体験の向上 という二つの目標を達成することができました。
さらに、メール送信の成否を記録するという要件を満たすために、CompletableFuture と @Async を組み合わせた非同期処理を実装し、その過程で発生した一時ファイル処理の問題についても解決しました。
この経験を通じて、課題解決のためにどのような技術的選択が必要かを深く考える力を養うことができ、何よりも 「ユーザー体験を改善する」 というビジネス目標に集中する姿勢を学ぶことができました。
今後はアプリケーションのさらなる性能向上と拡張性のあるアーキテクチャを目指し、メッセージキューの導入 を検討しています。
これにより、より複雑な非同期処理も効果的に管理し、変化の激しい環境においても 安定性とスケーラビリティを備えたサービス を構築できると期待しています。