Spring 동기와 비동기 처리 (Blcoking / Non-Blocking)

정종일·2023년 3월 16일
1

Spring

목록 보기
5/18

개념 및 예시

동기

동기는 데이터 요청의 결과를 기다리는 것을 말합니다. 즉 요청과 결과가 한 자리에서 동시에 일어납니다.

카페에 방문해 커피를 주문할 때, 직원이 한 명 뿐이라면

  • 직원에게 커피를 주문
  • 주문을 받은 직원은 즉시 커피를 제조
  • 제조된 커피 수령

이러한 프로세스가 진행되고 이처럼 요청과 결과가 동시에 일어나는 것을 동기라고 합니다.

비동기

비동기는 데이터를 요청한 후 결과를 기다리지 않는 것을 말합니다. 즉 요청과 결과가 동시에 일어나지 않습니다.

카페에 방문해 커피를 주문할 때, 주문을 받는 직원과 커피를 내리는 직원이 있다면

  • 주문을 받는 직원에게 커피를 주문
  • 주문을 받은 직원은 커피를 내리는 직원에게 주문 전달
  • 이후 새로운 주문을 받음

이처럼 요청과 결과가 동시에 일어나지 않는 것을 비동기라고 합니다.

왜 필요할까?

성능의 향상을 위해서이다. 한 번의 요청마다 3초가 소요되는 것을 100번 요청한다고 가정하면 대략 300초가 소요되는데, 이를 비동기로 처리한다면 (처리할 스레드의 개수에 따라 다르지만) 소요시간을 획기적으로 단축할 수 있다.


비동기 구현

어떻게 사용하나

Application 클래스에 @EnableAsync 를 붙이거나 AsyncConfig를 직접 정의한 후 비동기 처리를 사용하고자 하는 메서드에 @Async 어노테이션을 붙여 사용한다.

사용 전 주의사항

  1. public 메서드에만 사용 가능

    @Async의 동작은 별도의 설정이 없으면 Proxy 모드가 적용되어 Spring의 AOP를 가져가기에 AOP와 관련된 제약사항을 안고 가게 된다. AOP는 Proxy 패턴을 사용하고 Proxy 패턴은 실제 기능을 수행하는 객체 대신 가상의 객체를 사용하기 때문에 private으로의 접근이 불가능하다.

  2. 자가 호출 불가능

    자가호출 시 Proxy를 거치지 않기 때문에 1번과 같은 이유로 불가능하다.

  3. ThreadLocal 사용 시 내용 복사

    @Async 사용 시 새로운 스레드를 생성하여 작동하기 때문에 기존 스레드의 스택에 저장되는 ThreadLocal의 데이터를 사용하지 못한다. 따라서 복사해서 전달해주어야함

  4. 비동기 스레드에서 발생한 Exception 처리

    비동기 스레드에서 발생한 에러는 메인 메서드까지 반환되지 못하기 때문에 return값이 있는 형태로 정의하거나 별도의 예외 처리가 필요하다.

Application 클래스에 적용

@EnableAsync 어노테이션을 Application에 붙이고 비동기 처리가 필요한 메서드 위에 @Async를 적용한다. 하지만 이렇게 하면 default값으로 동작해 SimpleAsyncTaskExecutor를 사용하게 되고 스레드 풀에 의해 스레드를 관리하는 것이 아니라 단순히 스레드를 만들어내는 역할만 하게 된다.

AsyncTestApplication.java

@EnableAsync
@SpringBootApplication
public class AsyncTestApplication {

	public static void main(String[] args) {
		SpringApplication.run(AsyncTestApplication.class, args);
	}
}

AsyncController.java

@RestController
@RequiredArgsConstructor
public class AsyncController {

    private final AsyncService asyncService;

    @GetMapping("/test")
    public void test() {
        for(int i = 1; i <= 10; i ++) {
            asyncService.asyncTest(i+"");
        }
    }
}

AsyncService.java

@Service
public class AsyncService {

    @Async
    public void asyncTest(String message) {
        for(int i = 1; i <= 3; i++) {
            System.out.println(message + "비동기: " + i);
        }
        System.out.println(message + " done");
    }
}
  • 결과
    3비동기: 1
    2비동기: 1
    7비동기: 1
    5비동기: 1
    6비동기: 1
    1비동기: 1
    4비동기: 1
    2비동기: 2
    2비동기: 3
    2 done
    5비동기: 2
    5비동기: 3
    5 done
    6비동기: 2
    6비동기: 3
    6 done
    7비동기: 2
    7비동기: 3
    7 done
    9비동기: 1
    10비동기: 1
    9비동기: 2
    9비동기: 3
    9 done
    10비동기: 2
    10비동기: 3
    10 done
    3비동기: 2
    4비동기: 2
    4비동기: 3
    4 done
    3비동기: 3
    3 done
    1비동기: 2
    1비동기: 3
    1 done
    8비동기: 1
    8비동기: 2
    8비동기: 3
    8 done

ThreadPool 사용

위에서 적용했던 Applcation에 @EnableAsync 제거 해준 뒤, AsyncConfig 생성

AsyncConfig.java

@Configuration
@EnableAsync
public class AsyncConfig {

    private final int CORE_POOL_SIZE = 5;
    private final int MAX_POOL_SIZE = 10;
    private final int QUEUE_CAPACITY = 10000;

    @Bean(name = "testExecutor")
    public Executor threadPoolTaskExecutor() {

        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();

        taskExecutor.setCorePoolSize(CORE_POOL_SIZE);
        taskExecutor.setMaxPoolSize(MAX_POOL_SIZE);
        taskExecutor.setQueueCapacity(QUEUE_CAPACITY);
        taskExecutor.setThreadNamePrefix("Excecutor-");

        return taskExecutor;
    }
  • CorePoolSize: 최초 동작 시 생성될 스레드 개수 (Default: 1)
  • MaxPoolSize: Queue 사이즈 이상의 요청이 들어올 경우 늘릴 스레드의 개수 (Default : Integer.MAX_VAULE)
  • QueueCapacity : CorePoolSize 이상의 요청이 들어올 경우 LinkedBlockingQueue에서 대기하게 되는데 그 Queue의 사이즈 (Default : Integer.MAX_VAULE)
  • • SetThreadNamePrefix : 스레드명 설정

즉 최초 5개의 스레드를 생성해 요청을 처리하되 10000개 이상의 요청이 QueueCapacity에서 대기할 경우 스레드를 10개로 늘려 작업을 수행한다.

AsyncService.java

@Service
public class AsyncService {

	    @Async("testExecutor") // ThreadPoolTaskExecutor의 Bean name
    public void asyncTest(String message) {
        for(int i = 1; i <= 3; i++) {
            System.out.println(message + "비동기: " + i);
        }
        System.out.println(message + " done");
    }
}

Return 값이 필요할 경우

Future, ListenableFuture, CompletableFuture 3가지 리턴 타입을 통해 return 값을 받을 수 있다.

Future

사실 Future는 blocking 방식으로 동작하기 때문에 비동기 처리 시 잘 사용하지 않는다.

.isDone()을 통해 완료 여부를 boolean값으로 확인 가능하고

.get()을 통해 리턴값을 가져올 수 있다. - 결과값을 받을 때까지 스레드가 결과값을 가지고 대기하고 있다.

ListenableFuture

콜백메서드를 통해 non-blocking 처리가 가능하다.

addCallback() 메서드의 첫 번째 파라미터는 성공 시 실행할 로직, 두 번째 파라미터는 실패 시 실행할 로직을 지정해주면 된다.

AsyncController.java

@RestController
@RequiredArgsConstructor
public class AsyncController {

    private final AsyncService asyncService;

    @GetMapping("/test")
    public void test() {
        for(int i = 1; i <= 10; i ++) {
            ListenableFuture<String> listenableFuture = asyncService.asyncTest(i+"");
						// 파라미터로 성공, 실패 시 실행할 것 설정
            listenableFuture.addCallback(result -> System.out.println(result), error -> System.out.println(error.getMessage()));
        }
    }
}

AsyncService.java

@Service
public class AsyncService {

    @Async("testExecutor")
		// 리턴 타입을 ListenableFuture로 설정
    public ListenableFuture<String> asyncTest(String message) {
        for(int i = 1; i <= 3; i++) {
            System.out.println(message + "비동기: " + i);
        }
        return new AsyncResult<>("done" + message);
    }
}
  • 결과
    1비동기: 1
    3비동기: 1
    2비동기: 1
    4비동기: 1
    1비동기: 2
    1비동기: 3
    5비동기: 1
    5비동기: 2
    2비동기: 2
    2비동기: 3
    4비동기: 2
    4비동기: 3
    5비동기: 3
    3비동기: 2
    3비동기: 3
    done4
    6비동기: 1
    6비동기: 2
    6비동기: 3
    done2
    done3
    7비동기: 1
    7비동기: 2
    7비동기: 3
    done1
    done6
    9비동기: 1
    9비동기: 2
    9비동기: 3
    10비동기: 1
    done7
    8비동기: 1
    8비동기: 2
    8비동기: 3
    done8
    done5
    10비동기: 2
    10비동기: 3
    done10
    done9

CompletableFuture

Java 8에 추가 된 것으로, 비동기 작업 이후의 다양한 메서드를 제공한다.

AsyncController.java

@RestController
@RequiredArgsConstructor
public class AsyncController {

    private final AsyncService asyncService;

    @GetMapping("/test")
    public void test() {
        for(int i = 1; i <= 10; i ++) {
            CompletableFuture<String> stringCompletableFuture = testService.testAsync( i + "" );
						// Exception발생 시 처리
            stringCompletableFuture.exceptionally(
                    throwable -> {
                        log.error( "AsyncError: ", throwable );
                        return null;
                    }
            );

						// 성공, 실패 값 둘다 처리 (반대 값들은 null형태로 들어옴) -> 처리후 반환값 지정 필요 x 이전 Completable 반환됨.
            // peek처럼 그냥 불러와서 별도 처리 가능.
            stringCompletableFuture.whenComplete(
                    (s, throwable) -> {
                        if (Objects.isNull( throwable )) {
                            log.info( s );
                        } else {
                            log.error( "AsyncError: " + throwable );
                        }
                    }
            );

						// 성공, 실패 값 둘다 처리 (반대 값들은 null형태로 들어옴) -> 처리후 반환값 지정 필요
            stringCompletableFuture.handle(
                    (s, throwable) -> {
                        if (Objects.isNull( throwable )) {
                            log.info( s );
                        } else {
                            log.error( "AsyncError: " + throwable );
                        }
                        return null;
                    }
            );

            // 성공했을 시 작업 수행(return 값이 필요 없음)
            stringCompletableFuture.thenAccept( s -> {

            } );

            // 성공했을 시 작업 수행(return 값이 필요함)
            CompletableFuture<Integer> integerCompletableFuture = stringCompletableFuture.thenApply( s -> {
                return 2;
            } );
        }
    }
}

AsyncService.java

@Service
public class AsyncService {

    @Async("testExecutor")
		// 리턴 타입을 CompletableFuture로 설정
    public CompletableFuture<String> asyncTest(String message) {
        for(int i = 1; i <= 3; i++) {
            System.out.println(message + "비동기: " + i);
        }
				if (message == "2") {
            throw new RuntimeException();
        }

        return CompletableFuture.completedFuture( "성공" + message );
    }
}

Blocking / Non-Blocking

동기 / 비동기와 같은 개념이라 생각하기 쉽지만 엄연히 다른 개념이다.

동기 / 비동기의 경우 결과를 돌려줄 때 결과와 순서에 관심이 있는지에 따라 나뉘고

Blocking / Non-Blocking의 경우 제어권을 기존 함수에 넘겨주냐 아니냐에 따라 나뉜다.

대부분이 동기 + Blocking / 비동기 + Non-Blocking에는 익숙하니 여기서는 다른 2가지 경우만 다뤄보려 한다.

동기 + Non-Blocking

  • 동기 : 결과를 돌려줄 때 결과와 순서에 관심 있음
  • Non-Blocking: 제어권을 기존 함수에 넘김

A 함수가 B 함수를 호출한다고 가정할 때, B 함수는 기존 함수에 제어권을 넘기기 때문에 A 함수는 어떤 동작이든 할 수 있다. 그러나 A 함수는 반환받을 결과와 순서에 관심이 있기 때문에 B 함수에게 결과를 요청하는 행위밖에 할 수가 없다.

즉, B 함수에게 작업 완료 여부를 계속해서 물어보게 되는 것이다.

🐶 : 고양이님 서류처리 부탁드립니다.
🐱 : 강아지님 다른 일을 하고 계세요.
🐶 :.
🐱 : (서류 처리 중)
🐶 : 고양이님 서류처리 끝났나요?
🐱 : 강아지님 아직이요.
🐶 : 고양이님 서류처리 끝났나요?
🐱 : 강아지님 아직이요.
🐶 : 고양이님 서류처리 끝났나요?
🐱 :.

비동기 + Blocking

  • 비동기: 결과를 돌려줄 때 결과와 순서에 관심이 없음
  • Blocking: 제어권을 넘겨주지 않음

A 함수가 B 함수를 호출한다고 가정할 때, B 함수가 기존 함수에게 제어권을 넘기지 않기 때문에 A 함수는 결과를 기다리는 것 외에는 할 수 있는 게 없다. 하지만 정작 A 함수는 B 함수의 결과에 관심이 없다.

🐶 : 고양이님 서류처리 부탁드립니다.
🐱 : 강아지님 처리가 끝날 때까지 기다리세요.
🐶 : 고양이님이 어떤 작업을 하던 저와는 관계없지만 기다릴게요.

이런 비효율적인 방식을 사용할 일이 있나 싶지만 의도치 않게 이런 방식으로 동작하는 경우가 있다고 한다. 대표적으로 NodeJs와 MySQL의 조합이다.

비동기로 처리해도 결국 DB작업 호출 시에 MySQL에서 제공하는 드라이버를 호출하게 되는데, 이 드라이버가 Blocking 방식으로 동작하기 때문이다.

마무리

프론트를 개발할 때 비동기 처리를 잠깐 사용해보고 Spring 환경에서는 사용해보지 않았었다. 개념 자체는 동일해도 사용법이 다르고 실제 사용했을때 어떤 식으로 동작되는지 몰랐기 때문에 좋은 공부가 되었다.

다만 동기식에 비해 코드 가독성이 떨어지고 복잡한 설계가 필요하기 때문에 확실한 성능 개선이 예상될 때 사용하는 것이 좋을 것 같다.

profile
제어할 수 없는 것에 의지하지 말자

0개의 댓글