본문 바로가기

Computer Science/기타

비동기와 동기화 - 비동기 카페 주문 구현 (Java)

안녕하세요, 이번 포스팅은 생소하지만 아주 중요한 내용인 비동기/동기의 개념, 그리고 동기 처리 대비 효율적이라는 비동기 처리의 장점과 그렇지만 공유된 자원에 동시 접근하기 때문에 생길 수 있는 부작용에 대해 알아보겠습니다. 그리고 비동기 처리의 부작용을 해결할 수 있는 동기화(Synchronization) 방법에 대해서도 알아보겠습니다. 내용을 살펴보면 처음보신다면 좀 생소하고 헷갈립니다. 프로그래머들이 편하게 비동기와 동기화를  사용할 수 있도록 자바에서도 묶어서 구현해놓은 인터페이스가 존재하는데요, 바로 CompletableFuture라는 API입니다. 이에 대해서도 중요한 메서드들과 내부 구현을 살펴보겠습니다. 마지막으로 API를 사용해서 비동기로 주문을 처리하는 카페를 구현해하면서 실제로 어떻게 구현에 적용될 수 있는지 살펴보겠습니다.

Blocking / Non-blocking - Sync / Async ?

제일 먼저 비동기에 대해서 다루기 전에 동기 / 비동기가 무엇인지 알아야겠죠?

동기의 정의는 사전적으로는 synchronization, 즉 동시에 진행되는 것이라는 의미입니다. 프로그래밍에서는 어떤 것을 의미할까요? 요청과 결과의 반환이 동시에 일어나야 한다는 의미입니다. 다시 말하면, 다른 스레드에 어떤 것을 요청할 때, 요청의 결과가 반환될 때까지 얼마나 걸리는지에 상관없이 요청의 결과가 주어져야합니다. 요청하고 바로 결과를 받을 수 있기 때문에 순차적이라는 장점이 있지만, 요청의 처리 시간이 많이 소요되는 일이라면 오래동안 대기해야하기 때문에 비효율성이 발생합니다. 

반대로, 비동기는 요청과 결과의 반환이 동시에 일어나지 않아도 됩니다. 따라서 다른 사람(스레드)에게 요청을 해두고 자신의 일을 하다가, 요청이 반환되면 그 일을 수행하는 것입니다. 비동기의 특징은 시작, 종료가 일치하지 않고, 끝나는 동시에 시작하지 않습니다. 이 말을 다르게 말하면, 실행 순서를 보장받지 못한다는 것인데, 간단한 예로 1,2,3을 각각 출력하는 세 함수를 작성하면, 1,2,3이 순서대로 출력되는 것을 보장받지 못하는 것이 비동기 실행의 특징입니다.

 

정리하면, 동기 방식은 설계가 매우 직관적이고 간단하지만 결과가 주어질 때까지 대기해야하는 비효율이 발생할 수 있으며, 비동기 방식은 구현이 복잡하지만 자원을 효율적으로 사용할 수 있다는 장점이 있습니다.

 

비슷해보이는데 조금 다른 개념인 Blocking과 Non-Blocking에 대해서도 알아보겠습니다.

Blocking은 막는다는 의미이죠. 즉, 자신의 작업을 진행하다가 다른 주체의 작업이 시작되면 다른 작업이 끝날 때까지 기다렸다가 자신의 작업을 시작하는 것을 Blocking 방식이라고 합니다. 반대로 Non-Blocking 방식은 다른 주체의 작업에 상관없이 자신의 작업을 하는 것을 의미합니다. 비슷해보이지만 어디에 초점을 맞추느냐에 따라 다릅니다. Sync, Async는 요청과 결과가 동시에 일어나는지에 초점을 맞춥니다. 반대로, Blocking vs Non-blocking은 제어권을 돌려받는지 여부에 초점을 맞춥니다. 즉 동시성과 상관없이 다른 주체에게 일을 맡기고 자신의 제어권을 되찾으면 Non blocking이 되는 것입니다. 미묘한 차이의 예시에 대해서는 살펴보겠습니다.

  • Sync vs Async : 요청과 결과의 동시성으로 구분
  • Blocking vs Non-Blocking : 다른 주체가 작업할 때 자신이 제어권이 있는지 없는지로 구분

1. Blocking / Sync

위 경우는 일반적인 동기적인 입력 요청의 경우입니다. Java에서는 Scanner.next 등에서 입력요청 시 제어권의 박탈(Blocking), 그리고 입력이 돌아오면 바로 다음 작업 수행(Sync)합니다.

 

2. Non-Blocking / Sync

Non-blocking이기 때문에 제어권을 바로 돌려받지만, 요청한 작업이 완료되면 바로 다음 작업을 동기적으로 수행해야합니다. 따라서 동기적으로 수행하기 위해서 주기적으로 다른 스레드에게 요청한 수행이 완료되었는지 물어보는 경우입니다. 위의 표에서는 예시로 non-block I/O를 예로 들고 있습니다. 간단한 예를 들어보겠습니다. 컴퓨터의 마우스를 만지는 예시입니다. 마우스를 움직일 때까지 컴퓨터는 자신의 일을 하고 있습니다.(Non-block) 그렇지만 마우스의 입력이 들어오면 컴퓨터는 바로 반응을 해야하기 때문에 마우스의 입력이 들어왔는지 계속 확인을 하는거죠. 그리고 확인이 되면 바로 수행합니다.(Sync) 이 예는 적절하지 않을 수 있습니다. 왜냐하면 실제로 컴퓨터의 입출력은 I/O 인터럽트를 기반으로 동작하는데, 이는 사실 아래에서 설명할 Non-blocking Async에 더 가깝습니다. 그렇지만 비동기 I/O와의 차이를 좀 더 명확하게 보여주기 위한 예시입니다.

3. Blocking / Async

Blocking Async는 다른 스레드에게 일을 맡기고 응답을 기다리는 비동기 작업이므로 자신의 일을 할 수 있는 상황인데 제어권은 돌려받지 않아 일을 못하는 상황입니다. 얼핏 봐도 비효율적이고 실제로 이러한 경우는 프로그래머의 실수가 아니고서는 거의 없다고 합니다.

4. Non-Blocking / Async

이 경우는 다른 스레드에게 일을 맡기고 자신의 일이 끝나면 다른 작업의 일을 수행 콜백을 통해 추가작업을 처리하는 경우에 해당합니다. 원래 스레드가 주기적으로 확인하여 동기 수행을 하는 것이 아닌, 콜백을 통해서 비동기적으로 처리된다는 점이 다릅니다. 위의 마우스의 예시에 따르면, 마우스를 만지고 있는지는 컴퓨터가 주기적으로 모니터링하지 않습니다. 하지만 마우스를 만지면 인터럽트가 발생해서 컴퓨터를 깨우게 되죠. 컴퓨터는 기존의 하던 작업을 수행하고 있다가, 마우스의 인터럽트를 통해서 요청한 작업의 수행이 완료되었음을 확인하게 되고 이벤트를 핸들링합니다. 이것이 Async IO에 가까운 방식이라고 할 수 있습니다.

콜백함수

다음으로 비동기에서 많이 등장하는 콜백 함수의 개념에 대해서 살펴보겠습니다. 콜백 함수는 일급 객체로서 함수와 동일한 개념입니다. 즉, 함수를 객체로서 함수를 인자로 받고 다른 함수를 통해 반환될 수 있는 함수를 콜백함수라고 부릅니다.

콜백 지옥(Callback Hell)

비동기 처리에 콜백함수를 이용하게 되면 비동기 처리를 중첩시켜서 코드를 작성하기때문에 에러, 예외처리가 어렵고 중첩으로 인한 복잡도가 증가하게 됩니다.


비동기의 문제점, 동기화란?


위에서 동기, 비동기의 개념에 대해서 알아보았습니다. 얼핏 보면 자원을 효율적으로 쓸 수 있는 비동기가 좋아보이는데, 비동기로 구현하면 생길 수 있는 큰 문제가 있습니다. 바로 순서를 보장받지 못한다는 점 때문에, 공유하는 자원에 대해서 순서가 변경되면 문제가 생길 수 있습니다. 이를 race condition이라고 합니다. 

 

예를 들면, 100이라는 값을 저장하고 있는 저장 상자가 있다고 생각해봅시다. 함수 A는 값을 가져와서, 1을 더한 값을 할당합니다. 함수 B는 값을 가져와서, 1을 뺀 값을 할당합니다.

 

val value = 100;

func A
{
	get value;
    value = value + 1;
}

func B
{
	get value;
    value = value - 1;
}

application async
{
	A.asyncRun(); 
    B.asyncRun();

    //1. A에서 100을 가져온다.
    //2. B에서 100을 가져온다.
    //3. A에서 100 + 1을 value에 할당하여 value=101이 된다.
    //4. B에서 100 - 1을 value에 할당하여 value=99가 된다.
}

application sync
{
	A.syncRun();
    B.syncRun();

	//1. A에서 100을 가져와서 101이 할당된다.
    //2. B에서 101을 가져와서 100이 할당된다.
}

비동기에서는 위와 같이 데이터에 접근하는 순서에 따른 문제가 발생할 수 있기 때문에, 공유 데이터의 접근에 대해서 순서를 정해주거나 데이터에 접근하는 권한을 번갈아가면서 부여해서 문제를 해결할 수 있습니다. 이것을 동기화 문제라고 이야기합니다.


Process Synchronization 문제


위에서 다룬 내용을 요약해보겠습니다.

  • 공유 데이터의 동시 접근은 데이터의 불일치 문제를 일으킬 수 있습니다.
  • 일관성 유지를 위해서는 협력 프로세스 간의 실행 순서를 정해주는 메커니즘이 필요합니다.
  • Race Condition
    • 여러 프로세스들이 동시에 공유 데이터를 접근하는 상황
    • 데이터의 최종 연산 결과는 마지막에 그 데이터를 다룬 프로세스에 따라 달라짐
  • Race Condition을 막기 위해 동시 접근 프로세스는 동기화가 되어야한다.

동기화 문제의 해결방법은 공유 데이터를 다루는 영역을 임계 영역(Critical Section)으로 정의하고, 임계 영역에 접근하는 순서를 정합니다. 그리고 그러한 메커니즘을 정하는데 있어서 충족 조건을 아래와 같이 정의합니다.

  • Mutex(상호 배제)

한 프로세스가 Critical Section 부분을 수행 중이면 다른 모든 프로세스들은 그들의 critical section에 들어가면 안된다.

  • Progress(진행)

아무도 Critical Section에 있지 않은 상태에서 critical section에 들어가고자 하는 프로세스가 있으면 critical section에 들어가게 해주어야 한다.

  • Bounded Waiting(유한 대기)

프로세스가 critical section에 들어가려고 요청한 후부터 그 요청이 허용될 때까지 다른 프로세스들이 critical section에 들어가는 횟수에 한계가 있어야 합니다.

 

위의 내용들을 만족하도록 추상화한 것을 세마포어(Semaphores)라는 추상 자료형으로 만들어 사용합니다.

  • Integer variable(접근 가능한 자원의 개수)를 관리하는 자료형
  • 아래 두가지 atomic 연산으로만 접근 가능
  • P(S) : 공유 데이터를 획득하는 과정
  • V(S) : 공유 데이터를 반납하는 과정

이상으로, 동기, 비동기의 개념을 알아보았고 비동기에서 발생하는 문제를 해결하기 위한 동기화란 무엇인지에 대해 살펴보았습니다.

Thread Pool

비동기 구현을 위한 스레드 풀의 개념과 비동기 java 구현을 위한 라이브러리 CompletableFuture에 대해서 살펴보겠습니다. 한 프로세스를 비동기적으로 수행하기 위해서는 여러 개의 스레드 할당이 필요합니다. 그렇지만 병렬 작업의 수준에 따라 스레드의 개수를 프로세스 중에 조절하게되면 스레드 생성과 스케줄링으로 인한 오버헤드가 발생합니다. 따라서 시스템 성능 저하를 막기 위해 스레드 풀이 필요한데, 프로세스가 한 번에 필요한 만큼의 스레드를 생성하여 스레드 풀(Thread Pool)로 가지고 있고, 새로운 비동기 작업에 스레드를 할당시키는 방식으로 구현하게 됩니다. 이런 방법으로 스레드 개수가 급증하지 않기 때문에 시스템 성능을 안정적으로 유지할 수 있습니다. Java에서는 스레드 풀 생성을 위해서 java.util.concurrent.Executors, java.util.concurrent.ExecutorService 인터페이스 등을 제공합니다. 

CompletableFuture

비동기 실행을 위한 Java5에서 추가되었던 Future의 한계점을 보완하여 외부에서도 작업 완료, 콜백 중첩이 가능한 CompletableFuture 클래스입니다. 간단히 말하면 라이브러리를 이용해서 비동기 실행을 간단하게 할 수 있습니다. 프로그래머가 해야할 것은 비동기로 실행할 부분을 비동기 실행 메서드 안에 집어넣기만 하면됩니다. 위에서 언급한 비동기 실행을 위한 스레드도 ForkJoinPool의 CommonPool() 스레드 풀에서 새로운 비동기 작업이 발생할 때마다 할당하는 방식으로 진행되는데, Executors를 사용하면 스레드 개수를 원하는대로 변경할 수도 있습니다.

 

작업 실행

  • runAsync
    • 반환값이 없는 경우, 비동기로 작업 실행 콜
  • supplyAsync
    • 반환값이 있는 경우, 비동기로 작업 실행 콜

작업 콜백(이후 수행)

  • thenApply
    • 반환 값을 받아서 다른 값을 반환
    • 함수형 인터페이스 Function을 파라미터로 받음
  • thenAccept
    • 반환 값을 받아 처리하는 Consumer Type
    • 함수형 인터페이스 Consumer를 파라미터로 받음
  • thenRun
    • 반환 값을 받지 않고 다른 작업을 실행하는 Supplier Type
    • 함수형 인터페이스 Runnable을 파라미터로 받음

예외 처리

  • exeptionally
    • 발생한 에러를 받아서 예외를 처리
    • 함수형 인터페이스 Function을 파라미터로 받음
  • handle, handleAsync
    • (결과값, 에러)를 반환받아 에러가 발생한 경우와 아닌 경우 모두를 처리할 수 있음
    • 함수형 인터페이스 BiFunction을 파라미터로 받음

구현

먼저 구현에 앞서서 전체적인 흐름을 설계했습니다. 비동기적으로 수행해야할 역할들을 선정했는데, 우선 Cashier는 주문을 받아서, OrderQueue에 등록하는 역할입니다. 입력에 대한 유효성 검사도 Cashier 객체가 진행합니다. 그리고 Manager는 1초 간격으로 주문이 들어왔는지를 확인해서 OrderQueue에서 가져와 Barista 객체에게 음료를 만들라고 지시합니다. Barista는 Manager에게 요청이 들어오면 음료를 만듭니다. 이때, 바리스타의 인원은 한정되어있으므로 동시에 여러잔의 음료를 만들지 못하도록 공유된 자원에 대해서 제한을 걸어주어야합니다. 이를 세마포어를 통해서 한 바리스타 객체는 최대 2잔의 음료만 동시에 만들 수 있도록 제한합니다. 저는 AtomicInteger 클래스를 사용했는데, AtomicInteger는 여러가지 연산을 수행하는 동안 CPU가 개입할 수 없도록 atomic 연산을 정의한 클래스입니다. 

 

 

아래에서 간단히 Manager가 수행하는 업무에 대해서 살펴보겠습니다. work 메서드는 1초마다 manager가 checkOrder를 수행하도록 합니다. 여기에서는 Timer.scheduleAtFixedRate로 1초 간격으로 실행되도록 구현하였습니다. checkOrder에서는 Barista가 음료를 제조할 수 있는지 여부와 OrderQueue에 남은 주문이 있는지를 확인해서 동작을 수행합니다.

public void work() {
        Timer timer = new Timer();
        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                checkOrder(); //1초마다 일하기
            }
        };
        CompletableFuture.supplyAsync(() -> {
            timer.scheduleAtFixedRate(task, 0L, 1000);
            return true;
        });
    }

    public void checkOrder() {
        CompletableFuture.supplyAsync(() -> {
            if (Barista.done() && orderQueue.isEmpty()) { //손이 없는데 주문도 없는 경우
                timeOut();
            }
            return orderQueue.peek();
        }).thenAccept(order -> { //손이 있고 주문이 있는 경우
            if (Barista.hasHands() == true && !orderQueue.isEmpty()) {
                orderQueue.dequeue();
                Barista.makeDrink(order);
                System.out.println(orderQueue.printLog());
            }
        });
    }

 

최종 구현한 결과입니다.

> 주문할 음료를 입력하세요. 예) 아메리카노 2개 => 1:2
1:3
/1,1,1/
> 메뉴  =  1. 아메리카노(3s)    2. 카페라떼(5s)    3. 프라프치노(10s)
> 주문할 음료를 입력하세요. 예) 아메리카노 2개 => 1:2
/1,1/
아메리카노 시작
/1/
아메리카노 시작
2:2
/1,2,2/
> 메뉴  =  1. 아메리카노(3s)    2. 카페라떼(5s)    3. 프라프치노(10s)
> 주문할 음료를 입력하세요. 예) 아메리카노 2개 => 1:2
아메리카노 완성
아메리카노 완성
아메리카노 시작
/2,2/
/2/
카페라떼 시작
아메리카노 완성
/
카페라떼 시작
카페라떼 완성
카페라떼 완성
모든 음료를 제조했습니다.

Process finished with exit code 0