CompletableFuture로 비동기 작업 안정화 및 성능 테스트

이동엽·2023년 5월 23일
5

spring

목록 보기
3/15

💡 개요

프로젝트 아보카도에는 로그인 과정에서 이메일 인증을 필요로 하기 때문에, 이메일로 인증 코드를 발급한다.
이때, 이메일 송신 과정을 비동기로 처리하곤 한다. (→ 우아한 테크코스 블로그 참고)


Java 5에서 Future 인터페이스가 비동기 계산을 다루도록 추가되었지만 여러 단점 및 한계들이 존재했다.

  • 비동기 작업 실행
  • 작업 콜백
  • 작업 조합
  • 예외 처리

따라서 Java 8에서는 Future의 단점 및 한계를 개선하도록 CompletableFuture 인터페이스가 도입되었다!

직접 코드에서 도입 전후를 비교하여 살펴보자.



💡 첫 번째 성능 개선기

기존 코드

단순히 emailService.sendCertificatoinCode() 를 호출한다.

@RestController
@RequiredArgsConstructor
public class MailController {
    private final EmailService emailService;

    @PostMapping("/mail/certification-code")
    public ResponseEntity<String> sendCertificationCode(@RequestBody final MailObject mailObject) {
        final String code = emailService.sendCertificationCode(mailObject.email());
        return ResponseEntity.ok(code);
    }
}

Completable 도입 코드

CompletableFuture.supplyAsync() 팩토리 메서드를 이용해 동작시키고, get() 으로 결과를 가져온다.

@RestController
@RequiredArgsConstructor
public class MailController {
    private final EmailService emailService;

		@PostMapping("/mail/certification-code")
    public ResponseEntity<String> sendCertificationCode(@RequestBody final MailObject mailObject) throws ExecutionException, InterruptedException {
        final String code = CompletableFuture.supplyAsync(
                () -> emailService.sendCertificationCode(mailObject.email())).get();

        return ResponseEntity.ok(code);
    }
}

💡 첫 번째 개선기 성능 비교

요청 환경
Number of Threads (사용자 수) : 100
Ramp-up period (요청 시간) : 10
Loop Count (반복 횟수) : 1

기존 코드 : Error 24.00%, Throughput 2.2/sec
개선 코드 : Error 00.00%, Throughput 3.6/sec

아쉬운 점

기존 코드 테스트의 경우 Error 비율이 24%가 나왔다.
→ 발신자를 gmail로 이용 중인데, google에서 email API에 대해 제한사항을 둔 것이 이유인 것으로 판단하고 있다!



💡 두 번째 성능 개선기

기존 코드는 get() 메서드를 이용했는데, 해당 메서드는 동기적인 호출이다.

@RestController
@RequiredArgsConstructor
public class MailController {
    private final EmailService emailService;

		@PostMapping("/mail/certification-code")
    public ResponseEntity<String> sendCertificationCode(@RequestBody final MailObject mailObject) throws ExecutionException, InterruptedException {
        final String code = CompletableFuture.supplyAsync(
                () -> emailService.sendCertificationCode(mailObject.email())).get();

        return ResponseEntity.ok(code);
    }
}

get() 메서드 사용시 주의 사항

  1. 블록킹 호출: get() 메서드는 작업의 완료를 기다리면서 현재 스레드를 블록시킨다.
    → 따라서 이 호출은 동기적인 블록킹 호출이며, 다른 작업을 수행할 수 없는 단점이 있다.
    → 만약 이 코드를 메인 스레드에서 실행한다면, 다른 비동기 작업이나 동시성 이점을 활용할 수 없다.
  2. 타임아웃 처리: get() 메서드는 작업의 완료를 무기한으로 대기하므로, 작업이 지연되거나 무한히 실행되는 경우에는 문제가 될 수 있다.
    → 작업에 타임아웃을 설정하거나 CompletableFuture의 다른 메서드를 사용하여 작업을 처리하는 방법을 고려해야 한다.

개선된 코드

CompletableFuture는 동기적인 호출(get()) 외에 논블로킹 호출(join())도 제공한다.

기존 코드와 비교

get() : CompletableFuture에서 동기적인 호출
- 동기적인 호출은 메서드를 호출한 후 그 호출이 완료될 때까지 대기하는 방식을 의미!
- 동기 호출은 호출한 코드의 흐름을 차단하고 다음 코드를 실행하지 않는다!

join() : CompletableFuture에서 블록킹하지 않는 호출
- 블록킹하지 않는 호출은 호출한 후에 결과를 기다리지 않고 즉시 다음 코드를 실행하는 방식을 의미!
- 호출한 코드는 결과가 준비되지 않았을 때 다른 작업을 수행할 수 있고, 비동기적으로 작업을 처리하고 병렬성을 활용하는 데 유용하다!


💡 두 번째 개선기 성능 비교

요청 환경
Number of Threads (사용자 수) : 100
Ramp-up period (요청 시간) : 1
Loop Count (반복 횟수) : 1

get() 메서드를 이용 시 :Average: 34548 , Error: 0.00%, 처리량 : 1.8/sec

join() 메서드를 이용 시 :Average: 17831 , Error: 0.00%, 처리량 : 2.9/sec



💡 세 번째 성능 개선기

CompletableFuture를 사용하여 비동기 API의 성능을 향상시키려면 여러 방법이 있지만 그 중 한가지는 직접 Executor를 지정하는 것이다!


스레드 풀의 설정을 조정할 때는 프로젝트의 요구사항과 환경을 고려해야 한다.

  1. 동시성 요구사항 : 동시에 처리해야 하는 작업의 수를 고려
  2. 하드웨어 리소스 : 리소스가 제한적인 경우 너무 많은 스레드를 만들면 안된다.
  3. 작업의 성격 : CPU 바운드 작업인지, I/O 바운드 작업인지, 네트워크 작업인지 파악하기
  4. 응답 시간 요구사항 : 작업 처리 시간이 짧고 빠른 응답이 필요한 경우 스레드 풀의 크기를 작게 설정하여 작업 간의 경합을 줄이고 응답 시간을 최소화할 수 있다.
  5. 부하 테스트 : 실제 부하 테스트를 수행하여 적절한 설정을 찾을 수 있다.

기본 스레드풀 설정

# 기본 Executor 스레드풀 관련 설정
spring:
  task:
    execution:
      pool:
        core-size: 10
        max-size: 100
        queue-capacity: 10

커스텀 스레드풀 설정

@Bean
public ThreadPoolTaskExecutor mailExecutor() {
    final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(20);
    executor.setQueueCapacity(10);
    executor.setThreadNamePrefix("MailExecutor-");
    executor.initialize();

		return executor;
}

💡 세 번째 개선기 성능 비교

요청 환경
Number of Threads (사용자 수) : 100
Ramp-up period (요청 시간) : 1
Loop Count (반복 횟수) : 1


1차 시도

2차 시도

요청 수를 10개와 같이 적게 했을 때

  • 기본 Executor 스레드 풀 : Error: 0.00%, Throughput: 1.1/sec
  • 커스텀 Executor 스레드 풀 : Error: 0.00%, Throughput: 1.0/sec 과 같이 별 차이가 없지만,

위 테스트 처럼 100개의 요청을 보냈을 경우

  • 기본 Executor 스레드 풀 : 다시 반복 실행해도 오류 비율이 70%로, 많은 오류가 발생
  • 커스텀 Executor 스레드 풀 : Error: 0.00%, Throughput: 1.7/sec

아직 완성된 게시글이 아닙니다. 추가 개선기가 있을 예정입니다. 잘못된 정보가 있다면 지적 부탁드립니다.

profile
백엔드 개발자로 등 따숩고 배 부르게 되는 그 날까지

2개의 댓글

comment-user-thumbnail
2023년 5월 23일

제목을 바꾸세요. "그래서, CompletableFuture이 뭔데?"

답글 달기
comment-user-thumbnail
6일 전

오잉 ? 첫 번째, 두 번째 성능 개선하신건 네트워크 시간에 차이가 있는건 아닌가요?
get, join은 모두 블로킹 되는 연산이라 CompletableFuture를 적용했을 때 크게 성능 개선이 되진 않을 것 같아요. 지나가는 길에 남겨봅니다 ㅎㅎ

답글 달기