동기 / 비동기

niireymik·2024년 3월 3일


😺 동기(Synchronous : 동시에 일어나는)

: 동기 방식은 서버에서 요청을 보냈을 때 응답이 돌아와야 다음 동작을 수행할 수 있다. 즉 A 작업이 모두 진행될 때까지 B 작업은 대기해야 한다.

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

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

이러한 프로세스가 동기 방식이다.



😼 비동기(Asynchronous : 동시에 일어나지 않는)

: 비동기 방식은 반대로 요청을 보냈을 때 응답 상태와 상관없이 다음 동작을 수행할 수 있다. 즉 A 작업이 시작하면 동시에 B 작업이 실행된다. A 작업은 결괏값이 나오는 대로 출력된다.

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

  • 주문을 받는 직원에게 커피를 주문
  • 주문을 받는 직원은 커피를 내리는 직원에게 주문 전달하고, 새로운 주문을 받음
  • 제조된 커피가 있다면 수령

이러한 프로세스가 비동기 방식이다.

👉 동기는 디자인이 비동기보다 간단하고 직관적일 수 있지만 결과가 주어질 때까지 아무것도 못하고 대기해야 하는 문제가 있다. 비동기는 동기보다 복잡하지만 결과가 주어지는 데 시간이 걸려도 그 시간 동안 다른 작업을 할 수 있어 소요시간을 획기적으로 단축할 수 있다.



📝 잠시 비교-정리! ✨

동기
: 요청에 대한 응답이 와야 다음 작업을 하는 것
-> '작업 완료'를 신경 쓰는 건 '호출한 함수'

  • 결괏값과 제어권이 함께 돌아온다.
  • 이런 맥락에서 '동기', Sync라 칭한다.

비동기
: 요청에 대한 응답이 없어도 다음 요청을 하는 것이 비동기
-> '작업 완료'를 신경 쓰는 건 '호출된 함수'

  • 제어권이 먼저 돌아오고, 결괏값이 이후에 돌아온다.
  • 이런 맥락에서 '비동기', Async라 칭한다.

Blocking / Non-blocking?

  • Blocking
    : 호출자가 호출한 functionA가 실행되는 동안 호출자의 행동이 가로막히는 것 == 제어권이 호출자에게 있다가 functionA에게 넘어가고, 코드가 모두 실행되면 '제어권'은 '결과'를 들고 다시 '호출자'에게 돌아간다.
  • Non-blocking
    : 호출자가 호출한 functionA에게 제어권이 넘어갔다가, 바로 다시 호출자에게 돌아간다. functionA는 스레드 등을 통해서 독립적으로 실행되는 동안, 호출자에게 돌아가 제어권은 바로 functionB가 실행되며 functionB에게 제어권이 넘어갔다가, 전과 동일하게 '제어권'은 바로 호출자로 돌아오고 functionB는 독립적으로 실행, 제어권은 호출자 내부의 다음 단계로 계속해서 넘어간다.

사실 이러한 맥락에서 보자면 Blocking은 동기, Non-blocking은 비동기와 같은 개념이라고 생각하게 될 수도 있다.

✅ 그러나 정확히 짚고 넘어가야 할 것은, 동기-비동기는 "제어권과 결괏값이 함게 움직이는가"로 구분하고, 블로킹-논블록킹은 "다른 메서드가 실행되는 동안 호출자의 동작이 막히느냐"로 구분한다는 것이다.

결국 같은 것을 가리킬 수도 있지만, 결론적으로는 관점 [기준]이 다르다는 것이 중요하며, 모호함이 생기는 것은 (개념 자체가 추상적이기에) 어찌할 수 없다는 것이 사실이다.



😹 Spring 비동기 구현

어떻게 사용해?

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

Spring @Async - 단순한 방법

Application 클래스에 @EnableAsync 을 선언한다. 이 어노테이션은 비동기식을 활성화한다는 설정이다.

@EnableAsync
@SpringBootApplication
public class MySpringApplication {
	...
}

이 상태에서, 비동기식으로 구현하고 싶은 기능에 @Async 어노테이션을 선언한다.

public class AsyncService {
	@Async
    public void asyncMethod(){
    	...
    }
}

이 방식은 단순하지만, 스레드를 관리하지 않는다는 문제가 있다. 왜냐하면 @Async 의 기본 설정은 SimpleAsyncTaskExecutor를 사용하도록 되어 있는데, 이것은 스레드 풀이 아니고 단순히 스레드를 만들어내는 역할을 하기 때문이다.

SimpleAsyncTaskExecutor?

  • org.springframework.core.task.SimpleAsyncTaskExecutor 패키지에 존재
  • 각 작업마다 새로운 스레드를 생성하고 비동기 방식으로 동작
  • concurrencyLimit 을 이용해 지정한 수 보다 요청이 넘어설 경우 제한, 디폴트는 unlimit
  • simpleAsyncTaskExecutor는 스레드를 재사용하지 않음

스레드 풀?
: 동시에 실행되는 작업을 관리하는 데 사용되는 메커니즘

  • 병렬 작업 처리가 많아지면 스레드 개수가 증가된다. 병렬 작업의 폭증으로 인한 스레드의 폭증을 막으려면 스레드 풀을 사용해야 한다.
  • 스레드 풀은 작업 처리에 사용되는 스레드의 개수를 (정해진 수만큼으로) 제한해두고, 작업 큐에 들어오는 작업들을 하나씩 스레드가 맡아 처리하는 것이다.
  • 작업 처리가 끝난 스레드는 다시 작업 큐에서 새로운 작업을 가져와 처리한다.
  • 따라서 작업 처리 요청이 폭증해도 작업 큐에서 작업이 대기하다가 여유가 있는 스레드가 그것을 처리하므로 스레드의 전체 개수는 일정하게 유지되어서, 과다한 스레드 생성으로 인한 성능 저하를 막을 수 있다.


    👉 자바는 스레드 풀을 생성하고 사용할 수 있도록 java.util.concurrent 패키지에서 ExcutorService 인터페이스와 Executors 클래스를 제공한다.

스레드 풀을 사용하는 방법

우선 Appication 클래스에서 @EnableAsync 를 제거한다.

//@EnableAsync
@SpringBootApplication
public class MySpringApplication {
	...
}

@Configuration [Config 설정 파일]을 생성해 @EnableAsync 를 붙여준다.
Config 설정 파일에 @Bean을 붙인 클래스에서 ThreadPoolTaskExecutor 클래스의 객체를 생성한 뒤 core 사이즈, max 사이즈, 큐의 사이즈 등을 지정해 스레드 풀 설정을 완료한다.

	// Bean을 생성하는 메서드
    @Bean(name = "taskExecutor")
    public ThreadPoolTaskExecutor executor(){
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);		//기본적인 최대 스레드 수가 5개이고
        executor.setQueueCapacity(100);	//큐에 쌓인 작업 수가 100개 초과 시
        executor.setMaxPoolSize(10);		//스레드 수 제한이 10개로 변경된다.
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
@Configuration
@EnableAsync
public class AsyncConfig {

    private final int CORE_POOL_SIZE = 5;		//기본적인 최대 스레드 수가 5개이고
    private final int QUEUE_CAPACITY = 10000;	//큐에서 대기하는 작업 수가 10000개 초과 시
	private final int MAX_POOL_SIZE = 10;	//스레드 수 제한이 10개로 변경된다.

    @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;
    }
...
}

@Async 어노테이션이 붙은 메서드에서 위 Bean의 이름을 붙이면 된다. ( ex. @Async("testExecutor") )

  • 만약 스레드 풀의 종류를 여러 개 설정하고 싶다면, 이와 같은 Bean 생성 메서드를 여러 개 만들고, @Async 설정을 할 때 원하는 스레드 풀 빈을 넣어주면 된다.

@Async

  • 장점
    개발자는 메서드를 동기 방식으로 작성하다가, 비동기 방식을 원한다면 단순히 @Async 어노테이션을 메서드 위에 붙여 주면 된다. 그래서 동기, 비동기에 대해 유지 보수가 좋은 코드를 만들 수 있다.

  • 주의사항
    @Async 기능을 사용하기 위해서는 @EnableAsync 어노테이션을 선언하는데, 이때 별도의 설정을 하지 않으면 프록시 모드로 동작한다. 즉, @Async 어노테이션으로 동작하는 비동기 메서드는 모두 스프링 AOP(Aspect Oriented Programming)의 제약 사항을 그대로 따르게 된다.



📝 정리
동기는 '동시에 일어나는', 비동기는 '동시에 일어나지 않는'의 의미를 가지며, 요청 시 바로 응답이 이어져야만 하는지, 그렇지 않은지로 구분한다. 비동기 방식은 디자인이 복잡하지만 그만큼 처리시간을 단축할 수 있다. Spring에서는 @EnableAsync , @Async 어노테이션으로 비동기 방식을 구현할 수 있다.

0개의 댓글