1. 비동기 작업 시작: supplyAsync와 runAsync
비동기 작업을 시작하는 가장 일반적인 방법은 CompletableFuture.supplyAsync를 사용하는 것입니다. 이 메서드는 결과를 반환하는 람다나 메서드를 받습니다. 예를 들어 서버에서 데이터를 가져오는 작업을 흉내 내 보겠습니다:
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 오래 걸리는 작업을 모사(예: 파일 다운로드)
sleep(1000);
return "서버의 데이터";
});
System.out.println("작업이 시작되었습니다!");
// ... 작업이 실행되는 동안 여기서 다른 일을 할 수 있습니다
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
runAsync: 결과가 필요하지 않을 때
작업이 아무 것도 반환하지 않는다면(예: 로그를 쓰거나, 알림을 보내는 등) runAsync를 사용하세요:
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
sleep(500);
System.out.println("작업이 완료되었습니다!");
});
runAsync는 결과가 없기 때문에 항상 CompletableFuture<Void>를 반환합니다.
2. thenApply, thenAccept, thenRun: 무엇이 다른가요?
비동기 작업이 끝나면 보통 그 결과로 무언가를 하고 싶습니다. 이를 위해 마련된 “처리기” 메서드가 있습니다:
- thenApply — 결과를 변환하고 새 결과를 반환합니다.
- thenAccept — 결과를 받아들이지만 아무 것도 반환하지 않습니다(부수 효과용).
- thenRun — 결과를 받지 않고 아무 것도 반환하지 않습니다(작업 완료 후 동작만 수행).
thenApply: 결과 처리 및 변환
이전 작업의 결과를 변환해야 한다면 thenApply를 사용하세요. 예를 들어 문자열을 받아 이제 그 길이를 알고 싶다면:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Java");
CompletableFuture<Integer> lengthFuture = future.thenApply(s -> {
System.out.println("문자열 길이를 계산합니다...");
return s.length();
});
// 이제 lengthFuture는 "Java" 문자열의 길이(Integer)를 담고 있습니다
lengthFuture.thenAccept(len -> System.out.println("길이: " + len));
무슨 일이 벌어지나요:
- future에는 "Java" 문자열이 들어 있습니다.
- thenApply는 문자열을 그 길이(int)로 변환합니다.
- thenAccept는 결과를 출력합니다.
thenAccept: 결과로 동작만 수행(반환값 없음)
결과로 단지 무언가를 하고(예: 화면에 출력) 반환할 값이 없다면 thenAccept를 사용하세요:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "안녕, 세계!");
future.thenAccept(result -> {
System.out.println("결과: " + result);
});
thenAccept는 일종의 “소비자”입니다: 결과를 받아서 유용한 일을 수행합니다.
thenRun: 결과 없이 동작만
작업이 끝난 뒤 어떤 동작만 수행하고 결과는 필요하지 않다면 thenRun을 사용하세요:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "완료!");
future.thenRun(() -> {
System.out.println("로딩이 완료되었습니다!");
});
주의: thenRun 내부에서는 이전 작업의 결과에 접근할 수 없습니다 — 결과는 단순히 무시됩니다.
3. 호출 체인: 작업으로 파이프라인 만들기
CompletableFuture의 가장 큰 강점은 연산을 체인으로 구성할 수 있다는 점입니다. 각 메서드(thenApply, thenAccept, thenRun)는 새로운 CompletableFuture를 반환하며, 여기에 다시 처리기를 이어 붙일 수 있습니다.
예시: 다단계 처리
우리 애플리케이션을 조금 확장해 봅시다: 데이터를 로드하고, 변환하고, 결과를 출력한 다음 모든 것이 끝났음을 로그에 남깁니다.
CompletableFuture.supplyAsync(() -> {
System.out.println("1단계: 데이터를 로드합니다...");
sleep(500);
return "Java";
})
.thenApply(data -> {
System.out.println("2단계: 데이터를 변환합니다...");
return data.toUpperCase();
})
.thenAccept(result -> {
System.out.println("3단계: 결과 출력: " + result);
})
.thenRun(() -> {
System.out.println("4단계: 모두 완료되었습니다!");
});
콘솔 출력:
1단계: 데이터를 로드합니다...
2단계: 데이터를 변환합니다...
3단계: 결과 출력: JAVA
4단계: 모두 완료되었습니다!
주의:
각 단계는 항상 이전 단계가 끝난 뒤에만 시작됩니다. 덕분에 진짜 데이터 처리 파이프라인을 구축할 수 있습니다.
4. 비동기 버전: thenApplyAsync, thenAcceptAsync, thenRunAsync
기본적으로 처리기(thenApply, thenAccept, thenRun)는 이전 작업이 끝난 그 스레드에서 실행됩니다. 때로는 이것이 불편할 수 있는데 — 처리가 무거울 경우 별도의 스레드로 분리하는 편이 좋습니다.
이를 위해 다음과 같은 비동기 버전이 있습니다:
- thenApplyAsync
- thenAcceptAsync
- thenRunAsync
무엇이 다른가요?
- Async가 없는 경우: 처리기는 이전 작업이 끝난 것과 같은 스레드에서 실행될 수 있습니다(예: 작업이 ForkJoinPool에서 끝났다면 처리기도 그곳에서 실행).
- Async인 경우: 처리기는 반드시 ForkJoinPool(또는 여러분의 Executor)에 있는 다른 스레드에서 실행됩니다.
예시: 일반 처리기와 비동기 처리기 비교
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("로딩... [" + Thread.currentThread().getName() + "]");
return "Hello";
});
future.thenApply(result -> {
System.out.println("thenApply: [" + Thread.currentThread().getName() + "]");
return result + " World";
});
future.thenApplyAsync(result -> {
System.out.println("thenApplyAsync: [" + Thread.currentThread().getName() + "]");
return result + " Async World";
});
일반적인 출력:
로딩... [ForkJoinPool.commonPool-worker-1]
thenApply: [ForkJoinPool.commonPool-worker-1]
thenApplyAsync: [ForkJoinPool.commonPool-worker-2]
결론:
비동기 처리기는 다른 스레드에서 실행됩니다.
Async 메서드는 언제 사용하나요?
- 처리가 리소스를 많이 쓰는 경우(예: 복잡한 계산, 네트워크 작업).
- 이전 작업이 끝난 스레드를 블로킹하고 싶지 않은 경우.
- 스레드를 명시적으로 제어하고 싶은 경우(예: 두 번째 인수로 자신의 Executor를 전달).
5. 유용한 팁
표: thenApply, thenAccept, thenRun 비교
| 메서드 | 결과를 사용하나요? | 값을 반환하나요? | 용도 |
|---|---|---|---|
|
예 | 예 | 결과 변환 |
|
예 | 아니오 | 부수 효과(출력, 로깅) |
|
아니오 | 아니오 | 작업 완료 후 단순 동작 |
|
예 | 예 | 동일하지만 다른 스레드에서 실행 |
|
예 | 아니오 | 동일하지만 다른 스레드에서 실행 |
|
아니오 | 아니오 | 동일하지만 다른 스레드에서 실행 |
질문: 긴 체인은 어떻게 만들까요?
LEGO처럼 메서드를 차례대로 이어 붙이면 됩니다:
CompletableFuture.supplyAsync(() -> "42")
.thenApply(Integer::parseInt)
.thenApply(x -> x * 2)
.thenAccept(x -> System.out.println("결과: " + x));
출력:
결과: 84
각 다음 단계는 이전 단계의 결과를 받아, 그것을 변경하거나 단순히 사용할 수 있습니다.
6. thenApply, thenAccept, thenRun 사용 시 흔한 실수
오류 №1: 반환 타입 혼동.
thenApply는 다음 단계로 넘어갈 값을 반드시 반환해야 합니다. 실수로 thenApply를 사용하면서 값을 반환하지 않으면, 다음 연산은 null을 받거나(혹은 아예 컴파일되지 않거나) 할 수 있습니다. 부수 효과가 목적이라면 thenAccept나 thenRun을 사용하세요.
오류 №2: thenRun에서 결과를 사용하려 함.
thenRun 내부에서는 이전 작업의 결과에 접근할 수 없습니다. 결과를 사용해야 한다면 thenApply 또는 thenAccept를 선택하세요.
오류 №3: 메인 스레드 차단.
메인 스레드에서 get()이나 join()을 호출하면 비동기성의 장점을 잃습니다. 스레드는 작업이 끝날 때까지 기다리게 되며, 결국 동기 코드처럼 됩니다. 블로킹하지 않는 체인과 콜백을 사용하는 편이 좋습니다.
오류 №4: 예외 미처리.
체인에서 예외가 발생했는데 exceptionally, handle, whenComplete 같은 처리기를 추가하지 않았다면, 예외가 “사라져” 버려 오류로 종료돼도 보지 못할 수 있습니다. 체인에서는 항상 예외를 처리하세요.
오류 №5: 예상치 못한 다른 스레드에서의 실행.
비동기 메서드(thenApplyAsync 등)는 다른 스레드에서 실행될 수 있습니다. 스레드 안전하게 보호되지 않은 변수를 다루면 데이터 레이스가 발생할 수 있습니다.
GO TO FULL VERSION