1. 동기 코드의 문제
상상해 봅시다: 인터넷에서 데이터를 가져오거나 큰 파일을 읽어야 하는 프로그램이 있다고 합시다. 다음과 같이 작성합니다:
String data = readFromFile("bigfile.txt");
System.out.println("데이터: " + data);
모든 게 좋아 보이지만, 파일이 크거나 네트워크가 느리면 프로그램은 읽기 줄에서 그냥 멈춥니다. 사용자는 “멈춘” 인터페이스를 보게 되고, 서버는 다른 요청을 처리하지 못하며, 개발자는... 슬퍼집니다.
이런 상황을 블로킹이라고 합니다. 스레드(예: 애플리케이션의 메인 스레드)는 작업이 끝날 때까지 기다려야 합니다. 이런 작업이 많아지면—끝입니다. 렉이 발생하고 성능이 떨어집니다.
마치 카페에서 주문을 하고… 커피가 나올 때까지 카운터 앞에서 계속 서 있어야 하는 것과 같습니다. 다른 손님들도 당신 뒤에 줄을 서서 바리스타가 당신 주문을 끝내길 기다려야 하죠. 비효율적이지 않나요?
비동기 프로그래밍: 어떻게 문제를 해결할까
비동기 프로그래밍은 오래 걸리는 작업(예: 파일 읽기, 서버 요청, 데이터베이스 접근)을 백그라운드 스레드에서 수행하고, 메인 스레드는 계속 동작하게 하는 접근입니다. 즉, 사용자 응대를 하거나 새로운 요청을 받고, 이벤트에 반응할 수 있습니다.
즉, 당신은 주문(작업 시작)을 하고 자신의 할 일을 하다가, 커피가 준비되면(작업이 끝나면) “완료!”라고 알려주는 거죠.
Java에서는 CompletableFuture가 등장하기 전까지 이 부분이 썩 편하지 않았습니다. 어떻게 발전해 왔는지 살펴봅시다.
2. 과거 접근: Future와 그 한계
Java 5에서는 Future 인터페이스가 등장했습니다. 비동기 작업을 조금이나마 편하게 다루려는 첫 시도였죠. 작업을 스레드 풀에 맡기고 언젠가 결과를 받을 수 있게 해줍니다.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // 주의: 작업이 완료될 때까지 스레드가 블로킹됨!
아이디어는 좋아 보이지만, 실제로는 Future가 오래된 우편함과 비슷했습니다. 편지는 보냈지만 답장이 왔는지 알려면 계속 들여다봐야 하니까요.
결과가 준비되었을 때 알려주지 못하고, “이걸 하고 나서 저걸 해” 같은 액션 체이닝도 지원하지 않으며, 오류 처리도 깔끔하지 않습니다. 결국 모든 것이 그 블로킹 호출 get()에 막혀 비동기가 다시 기다림으로 바뀝니다.
3. CompletableFuture의 등장: 새로운 비동기 스타일
Java 8에서는 구식 Future를 대체할 진정한 비동기 히어로—CompletableFuture가 등장했습니다. java.util.concurrent 패키지의 이 클래스는 결과를 “수동으로” 기다리는 데 지친 개발자들이 아름답고, 간결하며, 이해하기 쉬운 비동기 코드를 작성할 수 있게 해주는 범용 도구가 되었습니다.
CompletableFuture는 거의 모든 것을 해냅니다. 다른 스레드에서 작업을 시작하고, 그 작업들을 체인으로 연결할 수 있습니다—예를 들어 먼저 결과를 계산하고, 그다음 처리하고, 그다음에 또 다른 일을 하도록요. 여러 작업을 쉽게 조합할 수도 있습니다. 모두 끝날 때까지 기다리거나 가장 먼저 끝난 하나만 기다릴 수도 있죠. 오류 처리도 우아합니다—불필요한 try-catch 없이요. 전체 스타일은 더 함수형에 가깝습니다. 지루한 호출과 대기 대신 thenApply, thenAccept 같은 표현력 있는 메서드가 등장하니까요.
flowchart LR
A[작업을 비동기적으로 시작] --> B[결과 처리]
B --> C[다음 작업]
C --> D[오류 처리]
이렇게 CompletableFuture는 비동기를 번거로운 수공업에서 편리하고 유연한 도구로 바꾸어, 코드가 마침내 숨통을 틔우게 했습니다.
4. 가장 단순한 예: CompletableFuture로의 첫걸음
비동기 작업의 최소 예제를 살펴봅시다:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// 작업을 비동기로 시작
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// 결과를 가져옴(스레드가 블로킹됨!)
try {
int result = future.get();
System.out.println("결과: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
이 코드는 이미 별도 스레드에서 계산을 수행합니다—작업을 시작하는 순간에는 메인 스레드가 블로킹되지 않죠. 하지만 get() 호출은 결과가 준비될 때까지 스레드를 블로킹합니다.
스레드를 블로킹하지 않으려면?
간단합니다. 작업이 끝났을 때 호출되는 콜백 메서드를 사용하세요:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("결과: " + result));
System.out.println("나는 블로킹되지 않고 다른 것도 할 수 있어요!");
요약:
- thenAccept — “결과를 구독한다”: 작업이 끝나면 이 코드를 실행.
- 메인 스레드는 작업 완료를 기다리지 않고 계속 실행된다.
시각화(이벤트 의사코드)
[메인 스레드] --> [작업 시작]
| |
v v
[다른 작업 수행] [백그라운드 스레드가 2+2 계산]
| |
v v
[출력 "나는 블로킹되지 않고..."]
| |
v v
[계산이 끝나면 — thenAccept 호출]
5. 애플리케이션에서는 어떻게 보일까?
콘솔 애플리케이션을 개발한다고 가정해 봅시다. 사용자가 데이터 로드를 요청(예: DB나 서버에서)할 수 있고, 데이터가 로드되는 동안 프로그램은 “멈추지” 않고 계속 명령을 받습니다.
예: 오래 걸리는 작업 모의
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("데이터 로드를 시작합니다...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// 오래 걸리는 로드 모의
try {
Thread.sleep(2000); // 2초
} catch (InterruptedException e) {
return "로드 오류";
}
return "데이터가 성공적으로 로드되었습니다!";
});
// 결과에 구독
dataFuture.thenAccept(result -> System.out.println("결과: " + result));
// 프로그램은 계속 동작
System.out.println("데이터를 로드하는 동안에도 나는 다른 일을 할 수 있어요!");
// 프로그램이 너무 일찍 종료되지 않도록(데모용!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
콘솔에서 보이는 내용:
데이터 로드를 시작합니다...
데이터를 로드하는 동안에도 나는 다른 일을 할 수 있어요!
[2초 후]
결과: 데이터가 성공적으로 로드되었습니다!
6. 유용한 팁
내부의 스레드에 대해 조금
CompletableFuture.supplyAsync(...)를 호출하면, 기본적으로 작업은 이른바 ForkJoinPool에서 실행됩니다. 이는 Java가 병렬 작업을 위해 사용하는 특별한 스레드 풀입니다. 더 많은 제어가 필요하다면(예: 자체 ExecutorService), 두 번째 인자로 전달할 수 있습니다:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
하지만 간단한 작업에는 기본 풀로도 충분합니다.
결과 가져오기: get(), join(), thenAccept
- get() — 결과가 준비될 때까지 스레드를 블로킹함(검사된 예외를 던짐).
- join() — 역시 블로킹하지만, 비검사 예외(RuntimeException)를 던짐.
- thenAccept(), thenApply() 등 — 블로킹하지 않고, 결과가 준비되면 전달된 함수를 호출.
실제 비동기 애플리케이션에서는 메인 스레드에서 get()/join() 사용을 피하세요!
7. CompletableFuture 초보자가 자주 하는 실수
오류 1: 메인 스레드에서 get() 또는 join() 사용.
이렇게 하면 프로그램을 다시 블로킹하게 되어 비동기의 이점을 잃습니다. 대신 결과 처리를 위해 thenAccept, thenApply 등의 메서드를 사용하세요.
오류 2: 오류 처리를 잊음.
비동기 작업에서 예외가 발생해도, 그 예외는 메인 스레드로 “튀어나오지” 않습니다. exceptionally 또는 handle로 처리하지 않으면 문제가 생겼다는 걸 알 수 없습니다.
오류 3: 프로그램 종료를 기다리지 않음.
데모에서는 종종 메인 스레드를 Thread.sleep으로 “잠깐 멈춰” 두어야 합니다—그렇지 않으면 작업이 끝나기 전에 프로그램이 종료될 수 있습니다. 실제 애플리케이션(예: 웹 서버)에서는 문제가 되지 않지만, 콘솔 데모에서는 이를 고려하세요.
오류 4: thenAccept와 thenApply를 혼동.
thenAccept는 “부수 효과”용(아무 것도 반환하지 않음), thenApply는 결과 변환용(새 결과를 반환)입니다.
오류 5: 불필요하게 비동기 코드와 동기 코드를 섞음.
비동기로 작성하기 시작했다면—특별한 경우(예: 테스트) 외에는 get()/join()으로 다시 동기로 끌어오지 마세요.
GO TO FULL VERSION