Java

자바(Java) 비동기 처리에 대하여

나맘임 2025. 1. 20. 16:20

🌱 해당 포스트는 한걸음 스터디에서 발표한 내용입니다. 발표 내용을 아래 영상에서 확인하실 수 있습니다!

 

 

🌱한걸음은 각자 학습한 내용을 토대로 블로그 글을 작성하고, 대면으로 모여서 발표하며, 녹화해 유튜브에 업로드하는 스터디입니다.

 

한걸음 알아보기

 

 

들어가며

프로그램에서 작업을 처리하는 방식으로 비동기와 동기처리가 있습니다.

이 글에선 자바에서 비동기 처리에 대해 공부하여 정리해 보았습니다.

 

동기? 비동기? 처리가 뭘까요?

결국 동기와 비동기에 대한 이야기는 작업 방식에서의 차이점을 말합니다.

그림 1. 라면 끓이는 방법

 

만약 라면을 끓인다고 했을 때, 순서가 있습니다.

 

1. 물을 끓인다.

2. 끓여진 물에 면과 수프를 넣는다.

3. 달걀을 넣는다.

 

면을 넣기 전에 물은 먼저 끓여야겠죠.

그렇다면 면과 수프를 넣는 작업으로 넘어가기 전에 반드시 물을 끓여야 하고

우리는 물이 끓여질 때까지 기다릴 수밖에 없습니다.

즉, 요청했던 결과가 나올 때까지 기다리는 상황을 동기 처리 방식이라고 말합니다.

 

근데, 현실의 우리는 물이 끓어질 때까지 파, 달걀 등을 준비하는 것처럼 다른 작업들을 하곤 합니다.

물이 끓는 건, 가스레인지가 알아서 잘해줄 것이기 때문입니다.

즉, 다 될 때까지 기다리지 않고 자기가 해야 할 작업을 하고 있다가 결과만 받는 방식을 비동기 처리 방식이라고 합니다.

 

컴퓨터도 다르지 않습니다.

그림 2. 컴퓨터에서 비동기와 동기 처리 방식

 

작업을 처리하는 주체인 프로세스 A와 B가 있습니다.

프로세스 A가 B에게 작업 처리를 요청해요.

동기 처리 방식은 B의 결과를 받을 때까지 가만히 기다리고 있습니다.

비동기 처리 방식은 B의 결과를 기다리되 자기가 하던 일을 계속하고 있습니다.

 

여기까지 보면 이런 생각이 드실 겁니다.

그러면 비동기 처리가 사기 아닌가요?? 왜 동기 처리를 사용하는 거죠?

비동기 처리는 멀티태스킹을 하는 거니까 당연히 일도 빠르게 처리할 수 있습니다.

심지어 CPU와 같은 리소스들을 효율적으로도 사용할 수 있죠.

 

하지만, 비동기 처리 방식엔 큰 문제점이 하나가 있습니다.

설계가 엄청 어렵고, 로직이 엄청 복잡해집니다.

라면과 같은 간단한 작업이면 설계에 큰 문제점이 없을 겁니다.

잔인한 현실의 프로그램은 정말 수많은 로직으로 구성되어 있죠.

거기다가, 중간중간 요청한 결과가 다음 진행에 앞서 반드시 필요한 동기식 구성도 요구될 겁니다.

 

이후에 설명할 스레딩 기법을 보시면 왜 설계가 어려운지 감이 오실 겁니다.

그럼에도 현대의 프로그래밍 기법에선 매우 중요한 설계 모델이자 주축인 것은 틀림없습니다.

 

자바에서의 비동기 처리

프로세스란?

먼저, 자바에서 작업 처리를 알기 전에 프로세스라는 용어를 아셔합니다.

프로세스는 실행 중인 프로그램의 인스턴스라고 생각하시면 됩니다.

그림 3. 프로세스 구성 요소

그리고 그 프로세스 안에 여러 가지 메모리 영역이 존재합니다.

  • Code : 실행 프로그램의 코드가 저장되는 영역
  • Data : 전역 변수, 정적 변수 저장 영역
  • Heap : 동적으로 할당되는 메모리의 영역
  • Stack : 메서드 호출 시 생성되는 지역변수 및 반환 값의 참조 주소 저장 

즉, 프로세스는 우리가 만든 자바 어플리케이션이라고 생각하시면 됩니다.

그리고 이 프로세스는 운영 체제와 프로세스 사이에 위치한 JVM에 의해 관리가 됩니다.

 

실제로 작업을 처리하는 주체, 스레드(Thread)

위에서 Thread 라는 메모리 영역이 있다는 것을 확인하셨습니다.

그러면 이 Thread가 뭘까요??

 

Thread는 프로세스 안에서 하나의 작업 흐름입니다.

하나의 예를 들어보겠습니다.

자바의 가장 기본인 메인 함수는 메인 스레드라고 부르는 하나의 작업 흐름입니다.

그림 4. 자바 메인 메소드

결국 main이라는 메소드에서 하나의 작업이 시작되고 끝을 맺기 때문입니다.

즉, Thread는 하나의 작업흐름이자 작업의 실행 단위이자 작업을 처리하는 주체입니다.

 

스레드(Thread)가 비동기처리와 동기처리와 무슨 상관인가요?

다시 비동기의 처리와 동기 처리를 복기해 봅시다.

다른 누군가에게 일을 부탁하고 기다리고 있는 것이 동기 처리 방식,

다른 누군가에게 일을 부탁하고 하던 일하고 있는 것이 비동기 처리 방식입니다.

 

다수의 스레드가 있는 환경에서 비동기 처리와 동기 처리가 사용되는 겁니다.

이를, 멀티 스레딩(Multithreading)이라고 부릅니다.

 

비동기 작업이 요청되면 진행되는 과정

JVM은 다음과 같은 과정을 거칩니다.

1. 작업 생성: 사용자가 작업을 정의하고 Runnable이나 Callable 인터페이스를 구현.

2. 작업 큐에 등록: ExecutorService나 다른 비동기 API는 작업을 작업 큐(task queue)에 추가

3. 스레드 선택 및 실행: ThreadPool에서 사용 가능한 스레드를 선택해 작업을 실행.

 

  • 작업 큐가 비어있으면 스레드는 대기 상태가 됩니다.
  • JVM은 OS 호출을 통해 스레드를 실행하고, 필요시 context switching을 수행합니다.

4. 작업 결과 수신: Future, CompletableFuture 등을 통한 추상화된 결과를 수신

 

 

갑자기 수많은 알 수 없는 용어들이 등장했죠.

하나하나 간단하게 알아보겠습니다.

 

스레드 관리하는 ExecutorService

java.util.concurrent 에서 지원하는 스레드 스케줄러 인터페이스입니다.

쉽게 말해, 스레드를 생성하고 삭제할 수 있고 작업을 등록할 수 있게 해주는 라이브러리입니다.

그림 5. Executors 정적 클래스에서 제공하는 팩토리 메소드

 

JVM에선 ThreadPoolExecutor으로 스레드를 관리하고 있습니다.

 ThreadPoolExecutor를 쉽게 사용할 수 있도록 추상화시킨 인터페이스라고 생각하면 됩니다.

그림 6. Executor Service 구성

ExecutorService의 구조는 다음 그림과 같습니다.

내부적으로 작업을 등록할 수 있는 Task Queue가 있습니다. 

ExecutorService의 execute() 메소드를 통해 Task Queue에 작업이 등록되고 자동으로 ThreadPool에 있는 Thread에 배정이 됩니다.

 

비동기 작업의 결과를 추상화한 Future

비동기 작업 상 나중에 결과가 오기 때문에, 이를 처리하기 위한 추상화 작업이 필요했습니다.

비동기 처리가 완료되었는지 확인하고, 처리 완료를 기다리고, 처리 결과를 반환하는 메서드를 제공합니다

심지어, 작업 취소, 진행 상태까지 확인할 수 있습니다.

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);

    boolean isCancelled();

    boolean isDone();

    V get() throws InterruptedException, ExecutionException;

    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

 

메소드 이름 설명
get() 작업이 완료될 때까지 기다린 후 결과를 반환합니다. 블로킹 메소드
get(long timeout, TimeUnit unit) 지정된 시간만큼 기다린 후 결과를 반환하거나 예외를 던집니다.
cancel(boolean mayInterruptIfRunning) 작업을 취소합니다.
isCancelled() 작업이 취소되었는지 확인합니다.
isDone() 작업이 완료되었는지 확인합니다.

 

작업을 뜻하는 Runnable과 Callable 인터페이스

개발자는 스레드에게 처리할 작업이라는 것을 알려줄 필요가 있습니다.

이를 자바에선 Runnable과 Callable 인터페이스를 구현하는 방식으로 후에 나올 스레드 스케줄러들에게 전달할 수 있습니다.

 

Runnable과 Callable은 반환 및 에러 처리에서 차이가 있습니다.

이름 특징
Runnable 반환값 없이 작업을 수행합니다
메서드: public void run()
Callable 작업 수행 후 값을 반환하거나 예외를 던질 수 있습니다.

메서드:  public V call() throws Exception

 

Runnable 예시

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 작업 내용
        System.out.println("Runnable task is running on thread: " + Thread.currentThread().getName());
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        // MyRunnable 객체 생성
        MyRunnable runnableTask = new MyRunnable();

        // ExecutorService를 사용해 Runnable 실행
        ExecutorService executor = Executors.newSingleThreadExecutor();
        executor.execute(runnableTask);  // 작업 제출
        executor.shutdown();             // ExecutorService 종료
    }
}

 

결과

Runnable: Task is running on thread: pool-1-thread-1

 

 

Callable 예시

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        // 작업 내용
        System.out.println("Callable task is running on thread: " + Thread.currentThread().getName());
        return "Result from Callable";
    }
}

public class CallableExample {
    public static void main(String[] args) {
        // MyCallable 객체 생성
        MyCallable callableTask = new MyCallable();

        // ExecutorService를 사용해 Callable 실행
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Future<String> future = executor.submit(callableTask); // 작업 제출

        try {
            // Callable 작업의 결과 가져오기
            String result = future.get(); // 블로킹 호출
            System.out.println("Callable result: " + result);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executor.shutdown();  // ExecutorService 종료
        }
    }
}

 

결과

Callable: Task is running on thread: pool-1-thread-1
Callable result: Result from Callable

 

더욱 복잡한 결과 처리를 위한 CompletableFuture

기존 Future는 비동기 처리이긴 하나 확인만 할 수 있기 때문에, 여러 개의 Future를 결합하여 결과를 도출하는 작업 체이닝 등엔 사용이 불가능한 문제점이 있었습니다.

그림 7. 여러 재료가 필요한 파스타

 

쉽게 말해, 알리오 올리오를 만들 때, 파스타면을 끓이는 비동기 작업 A와 마늘 굽는 작업 B, 냉동 새우를 해동하는 작업 C 등의 결합이 불가능했습니다. (서로 언제 작업이 끝나는지 연결이 지을 수 없었기 때문에)

 

이를 처리할 수 있도록 확장 인터페이스를 만든 것이 CompletableFuture입니다.

 

CompletableFuture의 주요 메서드

메소드 설명
thenApply() 이전 작업의 결과를 입력으로 받아 후속 작업을 비동기적으로 실행합니다.
thenApplyAsync() 현재의 스레드가 아닌 새로운 스레드에서 후속 작업을 비동기적으로 실행
thenAccept() 이전 작업의 결과를 입력으로 받아 후속 작업을 실행합니다 (결과 반환 없음).
thenRun() 이전 작업의 결과와 관계없이 후속 작업을 실행합니다.
exceptionally() 비동기 작업에서 예외가 발생하면 이를 처리합니다.
allOf() 여러 개의 CompletableFuture 작업을 병렬로 실행하고, 모두 완료되면 결과를 처리합니다.
anyOf() 여러 개의 CompletableFuture 중 첫 번째로 완료된 작업의 결과를 처리합니다.
complete() 비동기 작업을 수동으로 완료할 수 있습니다

 

CompletableFuture 예시

 

import java.util.concurrent.*;

public class CompletableFutureChainingExample {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
            return 10;
        }, executor)
        .thenApplyAsync(result -> {
            return result * 2;  // 10 * 2 = 20
        })
        .thenApplyAsync(result -> {
            return result + 5;  // 20 + 5 = 25
        })
        .thenAccept(result -> {
            System.out.println("Final result: " + result);
        });

        executor.shutdown();
    }
}

 

결과

Final result: 25

 

참고 자료

Synchronous vs Asynchronous Programming: Models, Differences, Use Cases | Ramotion Agency

 

[Java] 프로세스와 스레드: 컴퓨터의 작업 처리 단위 — Bible, Lee, Data

 

투움바파스타 - 우리의식탁 | 레시피

 

 

'Java' 카테고리의 다른 글

JVM 구조  (1) 2025.02.05