본문으로 건너뛰기

#7 늘리면 터지고 줄이면 만석인 스레드 풀

스레드 풀을 64개로 늘리고 한 시간 만에 백엔드 파드가 죽었다. 메모리 한도를 넘긴 컨테이너를 커널이 끊어 낸 것이다. 24개로 줄이자 이번에는 검색이 세 건만 겹쳐도 풀이 작업을 거절했다. 일이 밀리면 일꾼을 늘린다는 상식대로 움직인 결과였다.

릴리즈스톡은 중고 카메라 매장 스무 곳 남짓의 매물을 한 번에 검색하는 서비스다. 검색어 하나가 들어오면 백엔드가 매장 사이트들을 동시에 크롤링한다. 일은 그 검색이 10초 넘게 걸리면서 시작됐다. 스레드 풀 설정을 따져 보니, 크롤 작업은 한 번에 7개밖에 돌 수 없었다. 풀을 손본 뒤에는 응답이 29.77초까지 늘기도 했다. 이번에는 스레드가 아니라 내가 넣어 둔 분산 락이 원인이었다.

늘려야 할 것은 스레드가 아니었다. 크롤링을 지휘하는 오케스트레이터와, 매장을 하나씩 맡은 크롤 작업 스무 개가 같은 풀에서 스레드를 받고 있었다. 오케스트레이터는 스레드 하나를 붙든 채 크롤 작업을 기다렸고, 그동안 크롤 작업은 남은 스레드를 나눠 써야 했다. 나는 둘을 갈랐고, 분산 락은 검색 경로에서 뺐다. 그날 안에 고쳐 저녁에 production 에 배포했다.

그날 아침에 정리한 원인에는 이미 '같은 풀'이라는 말이 있었다. 그런데 그날 첫 커밋은 풀의 크기를 바꾸는 것이었다.

1부. 검색 한 건의 흐름

먼저 검색 한 건이 어떻게 처리되는지 보자.

사용자가 검색어를 넣으면 백엔드는 매장마다 크롤 작업을 하나씩 만들어 동시에 돌린다. 검색 한 건이 크롤 작업 스무 개로 불어나는 셈이다. 같은 검색어가 동시에 여러 번 들어오면 크롤링은 한 번만 하고, 뒤에 온 검색은 진행 중인 크롤링의 결과를 함께 기다린다. 이 글에서는 이것을 합류라고 부른다. 크롤링이 끝나면 백엔드는 모은 매물을 DB 에 저장한다.

이 흐름에서 먼저 막힌 곳은 크롤 작업이 스레드를 받는 자리였다.

2부. 7개만 도는 크롤 작업

스레드를 붙든 오케스트레이터

검색이 늘어진 이유는 검색 한 건이 스레드를 쓰는 방식에 있었다. 코드를 간단히 줄이면 이렇다.

java
// 검색 한 건을 지휘하는 오케스트레이터를 검색 풀에 넣는다
searchPool.submit(() -> {
    // 매장마다 크롤 작업을 같은 검색 풀에 넣고
    List<Future<Result>> crawls = shops.stream()
            .map(shop -> searchPool.submit(() -> crawl(shop, keyword)))
            .toList();
    // 스레드를 붙든 채 크롤 작업이 끝나기를 기다린다
    return awaitAll(crawls, budget);
});

오케스트레이터와 크롤 작업은 같은 풀(이하 검색 풀)에서 스레드를 받는다. 오케스트레이터는 크롤 작업을 풀에 넣은 뒤, 작업이 모두 끝날 때까지 스레드 하나를 붙들고 기다린다. 결과를 기다리는 오케스트레이터와 그 결과를 만들어야 하는 크롤 작업이 같은 스레드를 놓고 다투는 셈이다.

이런 구조를 '자바 병렬 프로그래밍'에서는 스레드 부족 데드락(thread starvation deadlock)이라고 부른다. 한 작업이 다른 작업을 같은 풀에 넣고 그 결과를 기다리면, 풀이 작을 때 두 작업이 서로를 기다리다 멈춰 버린다.1 내 경우에는 오케스트레이터가 정해 둔 시간만큼만 기다리고 돌아갔기 때문에 완전히 멈추지는 않았다. 대신 크롤 작업이 빈 스레드를 기다리느라 검색이 느려졌다.

이 다툼은 스레드가 넉넉하면 드러나지 않는다. 당시 검색 풀의 설정은 core 8, max 16, 큐 50 이었다. 스레드가 16개까지 늘 수 있으니, 오케스트레이터가 하나를 쓰더라도 크롤 작업 15개는 동시에 돌 수 있을 것처럼 보인다.

이 값을 넣을 때 나는 큐에 작업이 쌓이기 시작하면 스레드도 점점 늘어날 줄 알았다. 그런데 실제로는 그렇지 않았다. 자바의 ThreadPoolExecutor 는 새 작업을 받으면 다음 순서대로 처리한다.2

  1. 스레드가 core 수보다 적으면 새 스레드를 만들어 바로 실행한다.
  2. 스레드가 core 수만큼 있으면 작업을 큐에 넣는다.
  3. 큐까지 가득 차야 max 수까지 스레드를 더 만든다.
  4. 스레드가 max 수만큼 있고 큐도 가득하면 작업을 거절한다.

큐는 스레드를 늘리기 전에 먼저 채우는 대기 공간이다. 큐가 크면 스레드는 core 수에 머물고, max 는 큐가 가득 찬 뒤에야 쓰인다. 검색 한두 건이 넣는 작업으로는 큐 50 칸이 차지 않았다. 결국 max 16 은 쓰이지 않는 설정이었다. 동시에 도는 크롤 작업은 core 8 개에서 오케스트레이터 몫 하나를 뺀 7개뿐이었다. 검색이 겹치면 오케스트레이터가 늘어난 만큼 이 수는 더 줄었다. 매장 스무 곳의 크롤 작업이 스레드 7개를 번갈아 쓰고 있었던 것이다.

검색 두 건이 겹친 순간을 그리면 이렇다.

Before: 오케스트레이터와 크롤 작업이 한 풀을 쓴다. 검색 두 건이 겹친 순간, 오케스트레이터 A·B가 스레드 두 자리를 붙든 채 크롤 작업을 기다리고, 크롤 작업은 남은 여섯 자리에서 돌며 34개가 큐에서 빈

그날 아침에 정리한 원인은 여기까지 정확했다. 원인에는 오케스트레이터가 같은 풀의 스레드를 붙든 채 자기가 넣은 크롤 작업을 기다린다고 적혀 있었다. 문제는 '같은 풀'이었다. 그런데 내가 고른 처방은 core 64, max 96, 큐 6 이었다. 큐를 줄여 max 설정이 실제로 쓰이게 하고, 풀을 키워 크롤 작업에 스레드가 모자라지 않게 하려는 처방이었다. 두 작업이 한 풀을 쓰는 구조는 그대로 둔 채 크기만 바꾼 것이다. 스레드가 모자라 생긴 일이니, 스레드를 늘리면 일단 버틸 거라고 생각했다.

3부. 크기만 바꾼 두 번의 처방

64 라는 숫자

64라는 숫자는 이렇게 계산했다. 검색 한 건은 오케스트레이터 하나와 크롤 작업 스무 개를 합쳐 스레드 21개를 쓴다. 나는 검색 세 건이 동시에 들어와도 바로 처리할 수 있도록, 21 × 3 = 63 을 기준으로 core 를 64 로 잡았다. 네 번째 검색은 max 96 까지 스레드를 늘려 받게 했다. 그러려면 큐가 금방 차야 해서 큐는 6 으로 줄였다.

계산이 틀린 것은 아니었다. 계산에서 빠뜨린 것이 있었다. 64 는 처리할 작업의 수만 센 숫자였고, 나는 그 스레드들이 돌아갈 컨테이너의 메모리는 따져 보지 않았다.

종료 코드 137

develop 에 배포하고 한 시간 만에 백엔드 파드가 종료 코드 137 로 죽었다. 자바의 OutOfMemoryError 가 아니었다. 컨테이너가 메모리 한도인 1536Mi 를 넘자 커널이 프로세스를 강제로 종료한 것이다.

작업 거절

그래서 풀을 core 24, max 36 으로 줄여 봤다. core 24 는 검색 한 건이 쓰는 스레드 21개에 여유분 3개를 더한 숫자다. 그러자 이번에는 연속으로 검색할 때 풀이 작업을 거절했다(RejectedExecutionException).

검색 두 건이면 작업이 42개다. 스레드 36개와 큐 6칸을 더하면 정확히 42개라, 계산상 풀은 검색 두 건만으로 가득 찬다. 세 번째 검색이 넣은 작업은 들어갈 자리가 없어 거절당했다. 스레드와 큐가 모두 가득하면 작업을 거절하는 규칙 그대로였다. 작업 하나가 거절되자 그 검색 전체가 실패했고, 사용자에게는 504 가 돌아갔다.

풀 크기의 문제가 아니었다

풀을 크게 잡으면 메모리가 모자라고, 작게 잡으면 검색 두 건에 작업이 거절된다. 검색 한 건이 스레드를 21개씩 가져가는 한, 풀의 크기로 정할 수 있는 것은 동시에 받을 수 있는 검색의 수뿐이다. 알맞은 크기를 찾는다고 풀릴 문제가 아니었다. 오케스트레이터와 크롤 작업이 한 풀을 함께 쓰는 것 자체가 문제였다.

4부. 풀을 나누다

오케스트레이터 풀과 가상 스레드

나는 오케스트레이터와 크롤 작업이 스레드를 받는 곳을 나눴다. 검색 풀은 오케스트레이터만 쓰게 하고, 크기를 core 4, max 8 로 줄였다. 오케스트레이터는 검색 한 건에 하나뿐이고, 하는 일도 크롤 작업을 넣고 기다리는 것이 전부이기 때문이다. 이제부터 이 풀을 오케스트레이터 풀이라고 부른다. 이 풀의 스레드는 가상 스레드와 구분해 플랫폼 스레드라고 부르는데, 플랫폼 스레드 하나는 OS 스레드 하나를 차지한다. 크롤 작업은 작업마다 가상 스레드를 하나씩 만들어 돌리도록 바꿨다.

나눈 뒤 같은 순간을 그리면 이렇다.

After: 오케스트레이터와 크롤 작업을 나눈다. 오케스트레이터 A·B는 작은 플랫폼 스레드 풀에서 크롤 작업을 기다리고, 크롤 작업은 큐 없이 작업마다 가상 스레드 하나에서 바로 실행된다.

2부의 코드와 견주면, 크롤 작업을 넣는 곳만 검색 풀에서 가상 스레드로 바뀌었다.

java
ExecutorService virtualThreads = Executors.newVirtualThreadPerTaskExecutor();

// 오케스트레이터: 작은 플랫폼 스레드 풀
orchestratorPool.submit(() -> {
    List<Future<Result>> crawls = shops.stream()
            // 크롤 작업: 작업마다 가상 스레드 하나
            .map(shop -> virtualThreads.submit(() -> crawl(shop, keyword)))
            .toList();
    return awaitAll(crawls, budget);
});

크롤 작업은 시간 대부분을 매장 서버의 응답을 기다리며 보낸다. 서로 독립적이라 다른 작업을 기다릴 일도 없고, 타임아웃이 걸려 있어 기다림이 끝없이 늘지도 않는다. 나는 이런 작업마다 OS 스레드를 하나씩 묶어 둘 이유가 없다고 보고, 가상 스레드를 골랐다.3 가상 스레드는 블로킹 I/O 를 기다리는 동안 OS 스레드에서 내려와, 그 OS 스레드를 다른 작업이 쓰게 한다.4

나는 크롤 작업만 쓰는 플랫폼 스레드 풀을 따로 두는 방법도 따져 봤다. 하지만 그 풀도 크기를 정해야 했고, 그러면 64개와 24개 사이에서 겪은 문제가 그 풀에서 되풀이될 뿐이었다.

크롤 작업마다 스레드를 하나씩 만들면 64개 때처럼 파드가 죽지 않을까 하는 걱정이 남는다. 64개로 늘렸을 때 컨테이너가 죽은 원인은 측정하지 않아 확정하지 못했다. 그래서 바꾼 뒤에는 파드가 다시 재시작되는지 지켜봤고, 재시작은 없었다.

질문을 '풀 크기를 얼마로 할까'에서 '어떤 작업을 어디서 돌릴까'로 바꾸자, 한 풀의 알맞은 크기를 찾을 일도 없어졌다. 기다리기만 하는 오케스트레이터는 오케스트레이터 풀에서, 응답을 오래 기다리는 크롤 작업은 가상 스레드에서 돌린다.

5부. 풀을 나눈 뒤에도 남은 대기

29.77초

풀을 나누자 검색 풀이 가득 차 작업이 거절되는 일은 사라졌다. 그런데 매물이 94건 나오는 검색어 '시그마'의 검색이 29.77초나 걸렸다. 원인은 매물을 저장할 때 거는 분산 락이었다.

크롤링이 끝나면 백엔드는 매물을 하나씩 저장하면서 매물마다 락을 잡는다. 락이 비어 있으면 바로 지나가지만, 같은 매물을 저장하던 다른 스레드가 락을 잡고 있으면 최대 5초를 기다린다. 매물 94건을 차례로 저장하면서 이런 대기가 쌓이면 검색 한 건에 수십 초가 걸린다. 충돌을 막으려고 넣은 락이, 검색하는 사용자를 기다리게 하고 있었다.

락을 건 이유

합류는 검색어가 같을 때만 일어난다. 그런데 '캐논'과 '캐논 AE-1'처럼 검색어가 달라도 결과에는 같은 매물이 섞여 나온다. 백엔드는 매물마다 DB 에 이미 있는지 확인하고, 있으면 갱신하고 없으면 새로 넣는다. 두 검색이 같은 매물을 거의 동시에 확인하면 둘 다 '없음'을 보고 둘 다 넣으려 한다. 그러면 늦게 넣은 쪽이 키 충돌로 실패한다. 클로즈 베타 때 실제로 이 충돌이 났다.

나는 매물마다 락을 걸었다. 한 매물의 확인부터 저장까지는 한 번에 한 검색만 하게 한 것이다. 당시에는 서버가 한 대여서 프로세스 안의 락으로도 충분했지만, 검색 서버가 늘어날 것에 대비해 처음부터 Valkey 를 이용한 분산 락을 골랐다. 락은 검색어가 아니라 매물 단위로 걸었다. 검색어 단위로 걸면 같은 검색어끼리만 차례로 저장하게 할 뿐, 검색어가 다른 두 검색이 같은 매물을 저장하는 충돌은 막지 못한다.

다른 방법도 있었다. DB 의 upsert 구문을 쓰면 확인과 저장을 쿼리 하나로 끝낼 수 있다. 하지만 그때 나는 특정 DB 에 묶이는 네이티브 SQL 을 들이고 싶지 않았다. 낙관적 락(@Version)은 이미 있는 행을 동시에 고치는 충돌을 막는 장치라, 아직 없는 행을 두 번 넣는 충돌에는 쓸 수 없었다.

락이 막던 충돌

나는 락을 더 빠르게 만들 방법을 찾기 전에, 이 락이 실제로 어떤 충돌을 막고 있는지부터 다시 따져 봤다.

같은 매물을 동시에 저장하는 충돌은 이미 DB 가 잡아내고 있었다. 매물 URL 에는 처음부터 유니크 제약이 걸려 있었다. 클로즈 베타 때 본 키 충돌도 DB 의 키 제약이 나중에 들어온 삽입을 막아 낸 결과였다.

남은 과제는 키 충돌이 나도 저장이 실패로 끝나지 않게 하는 것이었는데, 그것도 이미 재시도가 맡고 있었다. 키 충돌이 나면 새 트랜잭션에서 그 매물을 다시 읽고, 삽입 대신 갱신으로 저장한다. 이 재시도는 락을 넣은 뒤에 들어왔고, 락을 거치는 경로 안에도 들어 있었다.

java
try {
    insertOrUpdate(item);
} catch (DataIntegrityViolationException e) {                  // 다른 검색이 먼저 넣었다
    inNewTransaction(() -> repository.findByUrl(item.url()).update(item));  // 새 트랜잭션에서 갱신으로 바꾼다
}

검색 경로에서 락을 뺐다

나는 검색 경로에서 분산 락을 뺐다. 이제 검색 결과의 매물은 락 없이 이 재시도만 거쳐 저장된다. 바꾼 코드는 아홉 줄이었다. 락이 필요한 곳에는 그대로 남겼다. 신규 매물을 찾는 크롤러는 스케줄러가 돌리는데, 실행이 겹쳐 같은 매물이 신규로 두 번 잡히면 신규 매물을 받아 쓰는 다른 기능까지 함께 어긋난다. 그래서 그 경로에는 락을 유지했다.

락을 넣은 것이 잘못된 판단은 아니었다. 다만 락이 지키려던 정합성은 유니크 제약과 재시도가 이미 지키고 있었다. 그 위에서 락은 대기 시간을 더하고 있었다. 나는 확인과 삽입 사이의 틈을 애플리케이션에서 막으려 했지만, 그 틈은 이미 DB 가 막고 있었다.

결국 경로마다 어떤 비용을 치를지 고른 셈이다. 검색 경로에서는 충돌이 날 때만 재시도로 수습하는 비용이, 저장할 때마다 락을 기다리는 비용보다 쌌다. 신규 매물 경로는 한 번 어긋나면 그 뒤에 붙은 기능까지 영향을 받아서, 그 경로에서는 기다리는 비용을 그대로 치르기로 했다.

6부. 결론

검색 흐름에서 대기가 생기던 두 곳은 이렇게 바뀌었다.

대기가 생기던 곳 전 후
크롤 작업이 스레드를 기다리던 곳 오케스트레이터와 한 풀을 나눠 씀 가상 스레드에서 바로 실행
매물 저장이 락을 기다리던 곳 매물마다 분산 락 락 없이 유니크 제약과 재시도

같은 검색어를 한 번만 크롤링하는 합류 규칙은 그대로 두었다.

단계마다 본 결과를 모으면 이렇다.

단계 설정 결과
처음 검색 풀 core 8 · max 16 · 큐 50 동시에 도는 크롤 작업 7개(설정상), 검색 10초 넘게 걸림
크기를 키움 검색 풀 core 64 · max 96 · 큐 6 파드 종료 코드 137
크기를 줄임 검색 풀 core 24 · max 36 · 큐 6 세 번째 검색부터 작업 거절, 504
풀을 나눔 오케스트레이터 풀 + 가상 스레드 '시그마' 검색 29.77초 (분산 락 대기)
락을 뺌 검색 경로에서 분산 락 제거 '시그마' 검색 0.64초, 동시 5건 전부 200, 재시작 0회

'락을 뺌' 줄의 수치는 develop 에서 문제가 났던 검사를 다시 돌려 확인한 것이다.

production 에서는 배포한 뒤의 지표를 보고 이 구조를 계속 가져갈지 정했다. 그날 저녁 배포한 파드 두 개는 재시작 없이 떴고, 캐시 없이 처리한 첫 검색은 2.42초였다. 그 뒤 며칠 동안 Grafana 에서 검색 응답의 p95 와 평균이 튀는지 지켜봤다. 지표가 튀지 않고 유지돼, 나는 이 구조로 계속 운영하기로 했다. 오케스트레이터와 크롤 작업을 나눈 구조는 지금도 그대로다.

7부. 회고

풀 크기를 바꾸면 한계가 다른 곳으로 옮겨 갈 뿐이었다. 64개로 늘리자 메모리가 먼저 바닥났고, 24개로 줄이자 검색 두 건에 풀이 가득 찼다. 그날 내 처방이 계속 빗나간 건 풀 크기만 만졌기 때문이다. 원인은 처음부터 '같은 풀'이었고, 풀 크기를 아무리 바꿔도 그 원인은 그대로 남았다.

스레드 풀이 막혔을 때 먼저 봐야 했던 것은 스레드 수가 아니라, 어떤 작업이 어떤 작업을 기다리는지였다. 결과를 기다리는 작업과 그 결과를 만드는 작업이 같은 풀을 쓰면, 풀 크기를 어떻게 잡아도 서로 기다리는 구조는 사라지지 않는다. 걷어 낸 락도 마찬가지였다. 정합성은 이미 유니크 제약과 재시도가 지키고 있었고, 락은 그 위에 대기 시간을 보태고 있었다.

이제는 일이 밀리면 일꾼을 늘리기 전에, 그 일꾼이 무엇을 기다리고 있는지부터 본다.

참고

  1. 브라이언 게츠 외, '자바 병렬 프로그래밍' 8.1.1절 '스레드 부족 데드락'. 같은 풀 안에서 다른 작업의 결과를 기다리는 작업이 있으면, 풀이 그 작업들을 다 돌릴 만큼 크지 않을 때 교착에 빠진다. ↩

  2. Java SE 21 ThreadPoolExecutor — Queuing. "If corePoolSize or more threads are running, the Executor always prefers queuing a request rather than adding a new thread." ↩

  3. 가상 스레드 수에는 상한이 없지만, 크롤 작업이 매장에 보내는 동시 요청은 그전부터 bulkhead 와 HTTP 커넥션 풀로 100개까지만 허용하고 있었다. 나중에 한 부하 테스트에서도 처리량의 한계를 먼저 그은 곳은 이 상한이 아니라 DB 쪽이었다. 이 이야기는 추후에 다룬다. ↩

  4. JEP 444: Virtual Threads. 블로킹 I/O 를 만나면 가상 스레드는 캐리어(OS 스레드)에서 내려온다(unmount). ↩

태그
댓글0