동기는 데이터 요청의 결과를 기다리는 것을 말합니다. 즉 요청과 결과가 한 자리에서 동시에 일어납니다.
카페에 방문해 커피를 주문할 때, 직원이 한 명 뿐이라면
이러한 프로세스가 진행되고 이처럼 요청과 결과가 동시에 일어나는 것을 동기라고 합니다.
비동기는 데이터를 요청한 후 결과를 기다리지 않는 것을 말합니다. 즉 요청과 결과가 동시에 일어나지 않습니다.
카페에 방문해 커피를 주문할 때, 주문을 받는 직원과 커피를 내리는 직원이 있다면
이처럼 요청과 결과가 동시에 일어나지 않는 것을 비동기라고 합니다.
성능의 향상을 위해서이다. 한 번의 요청마다 3초가 소요되는 것을 100번 요청한다고 가정하면 대략 300초가 소요되는데, 이를 비동기로 처리한다면 (처리할 스레드의 개수에 따라 다르지만) 소요시간을 획기적으로 단축할 수 있다.
Application 클래스에 @EnableAsync
를 붙이거나 AsyncConfig를 직접 정의한 후 비동기 처리를 사용하고자 하는 메서드에 @Async
어노테이션을 붙여 사용한다.
public 메서드에만 사용 가능
@Async
의 동작은 별도의 설정이 없으면 Proxy 모드가 적용되어 Spring의 AOP를 가져가기에 AOP와 관련된 제약사항을 안고 가게 된다. AOP는 Proxy 패턴을 사용하고 Proxy 패턴은 실제 기능을 수행하는 객체 대신 가상의 객체를 사용하기 때문에 private으로의 접근이 불가능하다.
자가 호출 불가능
자가호출 시 Proxy를 거치지 않기 때문에 1번과 같은 이유로 불가능하다.
ThreadLocal 사용 시 내용 복사
@Async
사용 시 새로운 스레드를 생성하여 작동하기 때문에 기존 스레드의 스택에 저장되는 ThreadLocal의 데이터를 사용하지 못한다. 따라서 복사해서 전달해주어야함
비동기 스레드에서 발생한 Exception 처리
비동기 스레드에서 발생한 에러는 메인 메서드까지 반환되지 못하기 때문에 return값이 있는 형태로 정의하거나 별도의 예외 처리가 필요하다.
@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
위에서 적용했던 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;
}
즉 최초 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");
}
}
Future, ListenableFuture, CompletableFuture 3가지 리턴 타입을 통해 return 값을 받을 수 있다.
사실 Future는 blocking 방식으로 동작하기 때문에 비동기 처리 시 잘 사용하지 않는다.
.isDone()을 통해 완료 여부를 boolean값으로 확인 가능하고
.get()을 통해 리턴값을 가져올 수 있다. - 결과값을 받을 때까지 스레드가 결과값을 가지고 대기하고 있다.
콜백메서드를 통해 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
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에는 익숙하니 여기서는 다른 2가지 경우만 다뤄보려 한다.
A 함수가 B 함수를 호출한다고 가정할 때, B 함수는 기존 함수에 제어권을 넘기기 때문에 A 함수는 어떤 동작이든 할 수 있다. 그러나 A 함수는 반환받을 결과와 순서에 관심이 있기 때문에 B 함수에게 결과를 요청하는 행위밖에 할 수가 없다.
즉, B 함수에게 작업 완료 여부를 계속해서 물어보게 되는 것이다.
🐶 : 고양이님 서류처리 부탁드립니다.
🐱 : 강아지님 다른 일을 하고 계세요.
🐶 : 네.
🐱 : (서류 처리 중)
🐶 : 고양이님 서류처리 끝났나요?
🐱 : 강아지님 아직이요.
🐶 : 고양이님 서류처리 끝났나요?
🐱 : 강아지님 아직이요.
🐶 : 고양이님 서류처리 끝났나요?
🐱 : 네.
A 함수가 B 함수를 호출한다고 가정할 때, B 함수가 기존 함수에게 제어권을 넘기지 않기 때문에 A 함수는 결과를 기다리는 것 외에는 할 수 있는 게 없다. 하지만 정작 A 함수는 B 함수의 결과에 관심이 없다.
🐶 : 고양이님 서류처리 부탁드립니다.
🐱 : 강아지님 처리가 끝날 때까지 기다리세요.
🐶 : 고양이님이 어떤 작업을 하던 저와는 관계없지만 기다릴게요.
이런 비효율적인 방식을 사용할 일이 있나 싶지만 의도치 않게 이런 방식으로 동작하는 경우가 있다고 한다. 대표적으로 NodeJs와 MySQL의 조합이다.
비동기로 처리해도 결국 DB작업 호출 시에 MySQL에서 제공하는 드라이버를 호출하게 되는데, 이 드라이버가 Blocking 방식으로 동작하기 때문이다.
프론트를 개발할 때 비동기 처리를 잠깐 사용해보고 Spring 환경에서는 사용해보지 않았었다. 개념 자체는 동일해도 사용법이 다르고 실제 사용했을때 어떤 식으로 동작되는지 몰랐기 때문에 좋은 공부가 되었다.
다만 동기식에 비해 코드 가독성이 떨어지고 복잡한 설계가 필요하기 때문에 확실한 성능 개선이 예상될 때 사용하는 것이 좋을 것 같다.