동기와 비동기 처리에 대해 알아보자

동기와 비동기 방식의 차이에 대한 쉬운 예시로 카페에서 커피를 주문할 때를 생각해 볼 수 있다. 사람들이 줄 지어 주문을 하려고 카운터 앞에 서 있을 때, 한 명에게 주문을 받고 커피를 전달한 후에 다음 사람의 주문을 받는 건 동기 방식이다. 반면에 일단 주문을 연속적으로 받고 커피 전달은 다른 곳에서 제조 되는대로 담당 하는 건 비동기 방식이다.
그렇다면 언제나 비동기 방식이 효율적인 걸까? 이 카페에서 주문한 음료 수만큼 쿠폰 스탬프를 찍어준다고 생각해보자. 아직 주문을 안 한 사람에게 스탬프를 막 찍어줄 수는 없다. 반드시 주문이 완료 된 후에, 주문한 잔 수 만큼 스탬프를 찍어주어야 한다. 이렇게 사전 작업이 완료된 후에 처리해야 하는 작업의 경우에는 동기식 처리가 적절하다.

동기식 처리의 장단점 : 직관적이고 프로그램 작성이 쉽지만 과정이 완료되기 전까지 다른 작업을 할 수 없다.
비동기식 처리의 장단점 : 구현이 좀 더 어렵지만 동시에 다른 작업이 가능하므로 효율적이다. 작업에 순서가 없고 히스토리가 남지 않는다. 연속으로 요청이 전달 될 경우 부하가 걸릴 수 있다.

1. JAVA에서 비동기 방식을 사용해보자

1
2
3
4
5
//controller  
@GetMapping("/logs")
    public ResponseEntity<Object> sendAll(){
        return new ResponseEntity<>(sendService.sendAll(), HttpStatus.OK);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//serviceImpl    
@Override
    public long sendAll() {
        List<Notice> notices = noticeService.getAllNotices();
        long beforeTime = System.currentTimeMillis();

        /* 동기 방식 */
        notices.forEach(notice ->
                sendLog(notice.getTitle())
        );

        long afterTime = System.currentTimeMillis();
        long diffTime = afterTime - beforeTime;
        log.info("실행 시간(ms): " + diffTime);
        return diffTime;
    }

    public void sendLog(String message) {
        try {
            Thread.sleep(5); // 임의의 작업시간을 주기위해 설정
            log.info("message : {}", message);
        }catch (Exception e) {
            log.error("[Error] : {} ",e.getMessage());
        }
    }

우선 동기을 살펴보자. 위 예제는 실제로 비동기 방식을 많이 사용하는 카톡, 문자 발송을 log처리로 대체한 예제이다. 발송 처리에 드는 작업시간은 Thread sleep으로 대체하였고, api가 호출되면 전체 데이터를 조회하여 그만큼을 로그 발송 처리하는 프로그램이다. api를 호출해보면 전체 처리에 다음과 같은 시간이 소요된다.

이미지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public long sendAll() {
    List<Notice> notices = noticeService.getAllNotices(); // 5000건
    long beforeTime = System.currentTimeMillis();

	  /* 비동기 방식 */
      //runAsync는 리턴값이 없는 경우 사용할 수 있는 비동기 처리방식임
    notices.forEach(notice ->
            CompletableFuture.runAsync(() -> sendLog(notice.getTitle()))
                    .exceptionally(throwable -> {
                        // 어떤 발송처리가 실패하였는지 
						// 이슈 발생을 담당자가 인지 할수 있도록 추가적인 코드가 필요
                        log.error("Exception occurred: " + throwable.getMessage());
                        return null;
                    })
    );

    long afterTime = System.currentTimeMillis();
    long diffTime = afterTime - beforeTime;
    log.info("실행 시간(ms): " + diffTime);
    return diffTime;
}

public void sendLog(String message) {
    try {
        Thread.sleep(5); // 발송처리 시간이라고 가정하고 처리
        log.info("message : {}", message);
    }catch (Exception e) {
        log.error("[Error] : {} ",e.getMessage());
    }
}

이번에는 비동기 방식으로 처리했을 때의 코드이다. CompletableFuture라이브러리의 runAsync는 리턴값이 없을 때 사용할 수 있는 비동기 처리 방식이다. 아래의 실행 시간을 확인해보면 훨씬 단축된 것을 확인 할 수 있다.
이미지

1
2
3
***runAsync 사용시 주의점***  
thread pool 설정을 따로 해주지 않으면 common pool을 사용하게 되는데, 그러지 않도록 사전에 따로 thread pool을 사용하도록 설정해 주는 게 권장된다.  
=> common pool은 JVM에서 제공하는 기본 스레드풀이다. 여러 비동기 작업들이 동시에 실행되도록 관리해주지만, 여러 작업들이 기본 스레드풀을 공유하게 되면 자원 경쟁이 발생할 수 있고 부하가 생길 수 있기 때문에 꼭 따로 커스텀 스레드 풀을 만들어주자.  
1
2
3
4
//custom thread pool 설정 
    int processors = Runtime.getRuntime().availableProcessors();
    int threadPoolSize = Math.max(2, processors); // 최소한 2개의 스레드는 사용
    ExecutorService customThreadPool = Executors.newWorkStealingPool(threadPoolSize); 

2. POSTMAN으로 응답 속도 비교

포스트맨으로 동기/비동기 방식으로 API를 호출해 보았을 때의 차이를 보면, (25.25s -> 38.85ms)상당한 속도 차이가 나는 것을 확인 할 수 있다.
이미지 이미지