회사에서 발생했던 경합으로 인한 이슈를 정리하기 위해 글을 작성하게 되었습니다.
1. 어떤 문제가 발생했을까?
회사에서 스레드 풀 경합으로 인한 연쇄 장애가 발생했습니다. 하나의 API가 이미지 처리를 비동기로 수행하고 있었는데, 공유 스레드 풀을 사용하면서 서로의 태스크가 스레드를 차지하게 되는 구조였습니다.
1
2
3
4
5
6
7
8
9
10
11
12
parent()
├─ threadA()
│ ├─ supplyAsync(이미지1 S3 업로드) ← 자식 스레드
│ ├─ supplyAsync(이미지2 S3 업로드) ← 자식 스레드
│ ├─ supplyAsync(이미지3 S3 업로드) ← 자식 스레드
│ ├─ supplyAsync(이미지4 S3 업로드) ← 자식 스레드
│
└─ threadB()
├─ supplyAsync(이미지1 S3 업로드) ← 자식 스레드
├─ supplyAsync(이미지2 S3 업로드) ← 자식 스레드
├─ supplyAsync(이미지3 S3 업로드) ← 자식 스레드
├─ supplyAsync(이미지4 S3 업로드) ← 자식 스레드
하나의 작업이 10개의 비동기 태스크를 실행하고 모두 완료되어야 작업이 끝나는 구조 인데, 9개가 끝나도 마지막 1개의 스레드를 다른 스레드가 가져가버려서 작업 완료가 안되는 상황이었습니다. 한 자리를 두고 경합을 벌이다보니 작업이 끝나지 않았고, 이로 인해 연쇄 장애가 발생한 것이죠. 이전에 비슷한 문제를 겪은 적이 있어 팀원 분께 해결 방법을 공유드렸고, 잘 해결됐는데요. 해당 이슈에 대해 간단히 살펴보겠습니다.
1
2
3
4
5
[A][A][A][A][A][A][A][A][A][ ]
↑
이 마지막 자리 하나를
A / B / C 작업이 서로 경쟁
2. 코드로 재현해보기
아래와 같이 A, B, C가 크기 10짜리 하나의 스레드 풀을 공유합니다. 각 배치는 10개의 태스크를 allOf.join( ) 으로 모두 완료할 때 까지 대기합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Slf4j
public class ThreadPoolContentionExample {
private static final ExecutorService executor = Executors.newFixedThreadPool(10);
public static void main(String[] args) {
CompletableFuture.allOf(
CompletableFuture.runAsync(() -> runBatch("A", 1_000), executor),
CompletableFuture.runAsync(() -> runBatch("B", 3_000), executor),
CompletableFuture.runAsync(() -> runBatch("C", 1_000), executor)
).join();
log.info("모든 배치 완료");
executor.shutdown();
}
private static void runBatch(String batchName, long taskDuration) {
log.info("[{}] 배치 시작", batchName);
long startTime = System.currentTimeMillis();
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (int index = 0; index < 10; index++) {
int taskIndex = index;
futures.add(CompletableFuture.runAsync(() -> {
log.info("[{}] task {} start", batchName, taskIndex);
sleep(taskDuration);
log.info("[{}] task {} end", batchName, taskIndex);
}, executor));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
long elapsed = System.currentTimeMillis() - startTime;
log.info("[{}] 배치 완료 ({}ms)", batchName, elapsed);
}
}
이를 실행하면 결과는 다음과 같습니다. 비동기로 작업하기 때문에 매 번 결과가 달라질 수 있습니다.
1
2
3
[A] 배치 완료 (3006ms) ← 기대: 1초
[B] 배치 완료 (6008ms) ← 기대: 3초
[C] 배치 완료 (7008ms) ← 기대: 1초
앞에서 본 그림을 다시 한 번 살펴보면, 각 배치가 독립된 풀을 사용했다면 A 1초, B 3초, C 1초면 끝납니다. 하지만 실제로는 A가 3초, C가 7초 걸렸습니다. 이는 allOf.join() 의 구조 때문인데요. 각 배치는 10개 태스크가 전부 끝나야 완료됩니다.
1
2
3
4
5
[A][A][A][A][A][A][A][A][A][ ]
↑
이 마지막 자리 하나를
A / B / C 작업이 서로 경쟁
9개의 A가 끝나도 마지막 1개가 스레드를 못 잡으면 배치 전체가 완료되지 않습니다. 각 배치는 모든 태스크가 끝날 때까지 join으로 대기하기 때문입니다. 문제는 부모 태스크와 자식 태스크가 같은 스레드 풀을 공유한다는 점입니다. 여러 배치가 동시에 실행되면 스레드 풀이 서로의 작업으로 채워지면서 마지막 태스크가 실행 기회를 얻지 못하는 상황이 발생할 수 있습니다.
이는 기아(Starvation)와는 다른데요. 기아는 실행 자체가 되지 않기 때문입니다.
3. 해결 방법
가장 간단하면서도 좋은 방법은 비동기를 사용하지 않는 것인데요. 이 외에도 몇 가지 기술적인 부분이 있는데 간단히 살펴보겠습니다.
- Bulkhead 패턴
- 동시 실행 수 제한
- Timeout 설정
3-1. Bulkhead 패턴
가장 직접적인 해결책은 Bulkhead 패턴 입니다. 배치마다 별도 스레드 풀을 사용하는 것입니다.
The Bulkhead pattern is a type of application design that is tolerant of failure. In a bulkhead architecture, also known as cell-based architecture, elements of an application are isolated into pools so that if one fails, the others will continue to function.
스레드 풀을 분리하면 한 배치의 지연이 다른 배치에 영향을 주지 않기 때문입니다.
1
2
3
ExecutorService poolA = Executors.newFixedThreadPool(5);
ExecutorService poolB = Executors.newFixedThreadPool(5);
ExecutorService poolC = Executors.newFixedThreadPool(5);
3-2. 동시 실행 수 제한
하나의 요청이 여러 개의 비동기 작업을 동시에 생성하면, 여러 요청이 들어왔을 때 스레드 풀을 빠르게 고갈시킬 수 있습니다. 예를 들어, 스레드 풀이 20개인데 요청 하나가 10개의 비동기 작업을 만든다면 단 두 개의 요청만으로도 스레드 풀이 모두 사용됩니다. 이런 구조에서는 작업을 한 번에 생성하지 않고, 일정 개수씩 처리하도록 제한하는 것이 중요합니다. 이렇게 하면 동시에 실행되는 작업 수를 제어할 수 있어 스레드 풀 고갈을 방지할 수 있으니까요.
1
2
3
4
5
6
7
8
9
10
Semaphore semaphore = new Semaphore(5);
CompletableFuture.runAsync(() -> {
semaphore.acquire();
try {
doWork();
} finally {
semaphore.release();
}
});
3-3. Timeout 설정
비동기 작업이 외부 API 호출이나 DB 쿼리처럼 블로킹될 수 있는 작업을 포함하면, 응답이 오지 않을 때 스레드가 무한정 점유됩니다. 스레드 풀에 여유가 있어도 블로킹된 스레드는 반환되지 않기 때문에, 시간이 지나면서 사용 가능한 스레드가 점점 줄어들고 결국 고갈됩니다. 이 경우, timeout을 설정하면 지정된 시간 내에 완료되지 않는 작업을 강제로 포기하고 스레드를 반환할 수 있습니다.
1
2
3
4
5
try {
String result = future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
}
4. 정리
이전에 비슷한 문제를 프로젝트 하다가 만나서 빠르게 찾을 수 있었는데요. AI가 발전하는 와중에도 기초체력은 여전히 중요한 것 같습니다.