CompletableFuture를 활용한 비동기 메일 전송 구현
📌 서론
Java Mail Sender를 사용하여 메일 전송 기능을 개발하는 과정에서, 외부 라이브러리의 동기적 특성으로 인해 응답 시간이 길어지는 문제에 직면했다. 이러한 문제를 해결하기 위해, 메일 전송 로직을 비동기적으로 처리하기로 결정하였고, 이 과정에서 CompletableFuture를 도입했다. CompletableFuture의 사용은 비동기 프로그래밍의 복잡성을 줄이고, 애플리케이션의 성능을 향상시키는 데 큰 도움이 되었다.
아래에서 Future와 CompletableFuture의 차이점, 그리고 실제 서비스 코드에 적용한 예를 통해 비동기 프로그래밍의 장점을 설명해보겠다.
Future와 CompletableFuture 차이
기본 차이:
Future는 Java 5에서 소개된 인터페이스로, 비동기 연산의 결과를 나타낸다. 그러나 Future는 결과를 가져올 때까지 블록되는 get() 메서드 외에, 연산이 완료되었는지 확인하거나, 연산이 완료될 때까지 기다리는 것 외에 추가적인 제어 메커니즘이 부족하다. 반면, CompletableFuture는 Java 8에서 소개되어 Future의 제한을 극복하고, 비동기 프로그래밍을 위한 더 강력하고 유연한 도구를 제공한다.
콜백 지원:
Future는 연산 완료 후 콜백을 직접 지정할 수 없지만, CompletableFuture는 thenApply, thenAccept, thenCombine 등의 메서드를 통해 비동기 연산의 결과에 대한 콜백을 쉽게 지정할 수 있다. 이를 통해 연산이 끝난 후의 작업을 선언적으로 구성할 수 있다.
결과 조합과 변환:
CompletableFuture는 여러 비동기 연산의 결과를 조합하고 변환하는 메서드를 제공한다. 예를 들어, thenCompose는 두 비동기 연산을 연쇄적으로 실행하며, thenCombine은 두 연산의 결과를 하나로 결합한다. 이런 기능은 Future에서는 직접 구현해야 한다.
에러 처리:
CompletableFuture는 exceptionally와 handle 메서드를 통해 에러 처리를 보다 세련되게 할 수 있다. 이는 비동기 연산 중 발생할 수 있는 예외를 적절히 관리하고 대응할 수 있게 해준다.
Future 사용 예시 코드
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executor = Executors.newSingleThreadExecutor();
// Future를 사용하여 비동기적으로 태스크 실행
Future<String> future = executor.submit(() -> {
Thread.sleep(1000); // 시뮬레이션을 위한 1초 대기
return "결과값";
});
// 결과값이 준비될 때까지 기다림
System.out.println("처리 중...");
String result = future.get(); // 결과를 가져올 때까지 블록됨
System.out.println("결과: " + result);
executor.shutdown();
}
}
이 코드에서 future.get() 메서드는 비동기 태스크의 결과가 준비될 때까지 현재 스레드를 블록한다.
출력 화면은 다음과 같다:
CompletableFuture의 결과 조합 예시
CompletableFuture를 사용한 결과 조합 예시에서는 두 개의 비동기 연산 결과를 조합하는 방법을 보여준다.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
public class CompletableFutureCombineExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 첫 번째 비동기 작업
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000); // 시뮬레이션을 위한 대기
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Hello";
});
// 두 번째 비동기 작업
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");
// 두 비동기 작업의 결과를 조합
CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + " " + result2);
// 조합된 결과 출력
System.out.println("결과: " + combinedFuture.get()); // "Hello World" 출력
}
}
이 예시에서 thenCombine 메서드는 두 CompletableFuture 객체 future1과 future2의 결과를 조합하여 새로운 결과를 생성한다. 각각의 CompletableFuture는 비동기적으로 실행되며, 두 작업 모두 완료된 후에 결과를 조합한다.
출력 화면은 다음과 같다:
CompletableFuture를 적용한 서비스 코드
이 코드는 Spring의 @Async 어노테이션을 사용하여 비동기 메일 전송 서비스를 구현한 것이다. 이 코드는 JavaMailSender를 사용하여 이메일을 비동기적으로 전송하고, 이 과정의 성공 또는 실패를 나타내는 CompletableFuture<Boolean>를 반환한다.
/**
* mail 전송을 위한 서비스
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class MailService {
private final JavaMailSender emailSender;
@Async
public CompletableFuture<Boolean> sendTemporaryPassword(String to, String temporaryPassword) {
try {
// .. 메일 전송 로직
log.info("{} 로 메일 전송 성공", to);
return CompletableFuture.completedFuture(true);
} catch (Exception e) {
log.error(to + " 로 메일 전송 실패", e);
return CompletableFuture.completedFuture(false);
}
}
}
MailService 클래스의 sendTemporaryPassword 메서드를 호출하는 외부 서비스 코드는 다음과 같다.
@Transactional
@Override
public void sendTempPassword(TempPassword tempPassword) {
// .. 기존 로직
// 회원 이메일로 임시로 발급된 비밀번호 전송 (비동기로 진행)
mailService.sendTemporaryPassword(email, createdTempPassword)
.thenAcceptAsync(result -> {
if (result) { // 메일 전송 성공 시
// .. 메일 전송 성공시 진행할 로직
}
})
.exceptionally(ex -> {
// 로깅 및 에러 처리
log.error("{} 로 메일 전송 과정에서 예외 발생: {}", tempPassword.getTempPassword(), ex.getMessage());
return null;
});
}
@Async 어노테이션의 사용
@Async 어노테이션이 붙은 메서드는 비동기적으로 실행되며, 이는 메서드가 반환하는 즉시 호출 스레드를 해제하고, 실제 로직은 별도의 스레드에서 실행된다. 이렇게 하여 애플리케이션의 반응성과 성능을 향상시킬 수 있다.
☝️ 잠깐! @Async 적용 시 주의사항
@Async 어노테이션이 붙은 sendTemporaryPassword 메서드는 비동기적으로 실행된다. 따라서, 이 메서드를 호출하는 다른 메서드에서 @Async 어노테이션을 추가로 붙일 필요는 없다. 비동기 실행은 @Async 어노테이션이 붙은 메서드 내부에서 관리되므로, 호출하는 측에서는 비동기 메서드가 정상적으로 완료되거나 예외를 처리할 방법만 고려하면 된다.
CompletableFuture의 로직 상세 설명
CompletableFuture는 비동기 연산의 결과를 표현하는 Java 8의 클래스다. 이는 Future 인터페이스를 구현하며, 비동기 연산이 끝난 후에 수행해야 할 작업을 지정할 수 있는 여러 메서드를 제공한다.
MailService에서 completedFuture() 메서드:
completedFuture는 이미 결과가 존재하는 CompletableFuture 인스턴스를 생성한다. 예제 코드에서는 메일 전송 성공 시 CompletableFuture.completedFuture(true), 실패 시 CompletableFuture.completedFuture(false)를 통해 즉시 결과를 반환한다.
이미 결과가 존재하는 CompletableFuture 인스턴스?
'이미 결과가 존재하는 CompletableFuture 인스턴스를 생성한다'는 말은 CompletableFuture 객체가 생성될 때 이미 그 결과가 결정되어 있음을 의미한다. 일반적으로 CompletableFuture는 비동기 연산의 결과를 나타내며, 해당 연산이 완료될 때까지 결과는 알 수 없다. 하지만 completedFuture 메서드를 사용하면, 연산을 수행하지 않고도 결과를 알고 있는 상태에서 CompletableFuture를 생성할 수 있다.
이는 테스트 코드를 작성하거나, 비동기 API를 사용하는 코드에서 특정 조건에 따라 즉시 결과를 반환해야 할 때 유용하다. 예를 들어, 메일 전송이 성공했을 때 true를, 실패했을 때 false를 즉시 반환하는 경우, 이러한 결과를 이미 알고 있으므로 completedFuture(true) 또는 completedFuture(false)를 사용하여 해당 결과를 나타내는 CompletableFuture 인스턴스를 바로 생성할 수 있다.
즉, completedFuture 메서드를 사용하면 비동기 연산을 수행하지 않고도 특정 값을 결과로 가지는 CompletableFuture 객체를 생성할 수 있어, 결과가 이미 결정된 상황에서 비동기 처리 패턴을 유지할 수 있게 해준다. 이 방법은 프로그램의 다른 부분이 CompletableFuture를 기대하는 경우, 동기적인 상황에서도 동일한 타입을 반환하여 일관성을 유지할 수 있게 해준다.
MailService를 호출하는 외부 서비스에서 thenAcceptAsync 메서드:
thenAcceptAsync는 비동기 연산이 성공적으로 완료되었을 때 실행할 작업을 지정한다. 이 메서드는 연산의 결과를 소비(consume)하지만, 다른 값을 반환하지는 않는다. 위 코드에서는 메일 전송이 성공(true)하면, 메일 전송 성공시 진행할 프로세스를 비동기적으로 수행한다.
MailService를 호출하는 외부 서비스에서 exceptionally 메서드:
exceptionally는 비동기 연산 중 발생한 예외를 처리하기 위한 메서드다. 이를 통해, 연산 중 발생한 예외를 캐치하고, 예외가 발생했을 때 수행할 대체 작업을 지정할 수 있다. 위 코드에서는 메일 전송 중 예외가 발생하면, 로깅과 에러 처리를 수행한다.
비동기 프로그래밍의 장점
이러한 비동기 처리 방식은 다음과 같은 이점을 제공한다:
- 성능 향상: 메인 스레드가 비동기 작업의 완료를 기다리지 않고 다른 작업을 계속할 수 있으므로, 애플리케이션의 전반적인 처리량과 반응성이 향상된다.
- 자원 효율성: 비동기 작업을 별도의 스레드에서 처리함으로써, 메인 스레드가 블록되지 않고, 시스템 자원을 보다 효율적으로 사용할 수 있다.
- 복잡한 비동기 흐름 관리: CompletableFuture를 사용하면, 여러 비동기 연산의 결과를 조합하거나, 연속적인 비동기 작업을 쉽게 관리할 수 있다.
🔥 결론
Future와 CompletableFuture는 비동기 프로그래밍을 위한 Java의 두 가지 핵심 인터페이스로, CompletableFuture는 Future의 기능을 확장하여 비동기 연산 후 즉시 실행할 작업을 지정하는 등 더 다양하고 풍부한 기능을 제공한다. @Async 어노테이션을 사용하여 비동기 메서드를 구현할 때는 해당 메서드만 @Async를 붙여주면 되며, 이를 호출하는 측에서는 별도로 @Async를 붙일 필요가 없다. 이 접근 방식은 애플리케이션의 비동기 처리를 간소화하고, 성능을 향상시키는 데 큰 도움이 된다.
만약 메일 전송에 실패했다면 그 결과를 클라이언트에 어떻게 전달할까? 이 방법에 대해서는 다음 글에서 작성해보도록 하겠다.
📣 이 글은 내가 소속된 Team Chillwave에서 진행한 사이드 프로젝트에서 경험한 내용을 정리한 것이다.
다른 팀원인 "개발자의 서랍" 님의 블로그도 방문하면 도움이 될 것 같다 :)
'프로그래밍 언어 > JAVA' 카테고리의 다른 글
중첩 foreach 제거: Function.identity()로 코드 간결화하기 (0) | 2024.05.23 |
---|---|
폴링(Polling) 사용해서 CompletableFuture 결과 확인하기 (0) | 2024.02.07 |
AtomicInteger를 활용한 파일 저장 순서 동기화 문제 해결 (0) | 2024.02.04 |
[JAVA] 코드 최적화를 위한 매직 상수 사용법 (4) | 2023.12.31 |
[JAVA] 자바 인터페이스에서 public과 private 접근 제한자 활용하기 (3) | 2023.12.24 |