가상 스레드를 배치 잡에 붙였다가 떼낸 이야기 — JDBC와 HikariCP 앞에서 가상 스레드가 약속하지 못한 것
Java 21 가상 스레드를 Quartz 배치 잡의 묶음 단위 집계에 붙였다가, 결국 다시 떼어냈습니다. 본문 코드는 131줄에서 15줄로 줄었고, 동시성 관련 YAML 프로퍼티 두 개가 사라졌습니다. 솔직히 말하면 가상 스레드가 나빴던 것이 아니라, 제가 가상 스레드가 뭘 약속하고 뭘 약속하지 않는지를 잘 모른 채 일단 붙여본 쪽에 가까웠습니다. 그 사실을 잡 하나로 다시 배운 이야기입니다.
1. 배경: 묶음 단위로 도는 집계 잡
먼저 어떤 잡인지부터 적어둡니다.
- 최근 7일 동안 플래너별로 발생한
BEFORE_CALL건수를 집계해서, 결과를 별도 테이블에 사진 찍듯 저장해 두는 잡입니다 (이 테이블을 보통 스냅샷 테이블이라고 부릅니다). - 활성 플래너의
planner_id목록을 작은 묶음(chunk)으로 나눠서, 묶음 단위 집계 SQL 을 돌립니다. - Quartz 의
DisallowConcurrent옵션이 걸려 있어서, 같은 잡의 인스턴스가 동시에 두 번 돌 일은 없습니다. - 일부 묶음이 실패하면 정해진 횟수만큼 재시도하고, 그래도 실패하면 그 묶음만 이번 실행의 저장 대상에서 제외합니다 (부분 성공).
지금 와서 다시 보면 이 잡의 핵심 가치는 "한 묶음의 실패가 다른 묶음의 반영을 막지 않는 것" 이었는데, 처음 코드를 만질 때는 그게 진짜 가치라는 걸 충분히 의식하지 못했습니다.
2. "가상 스레드 있으니까 일단 붙여보자" 라는 가설
Java 21 + Spring Boot 3.x 환경이라 가상 스레드는 익숙해져야 할 도구라는 생각이 있었습니다. 거기에 묶음이 순차로 돌면 묶음 수 × 묶음 하나당 쿼리 시간 만큼이 그대로 누적되는 게 눈에 보였습니다.
이쯤에서 자연스럽게 떠오른 가설이 이거였습니다.
묶음 하나하나의 집계는 SELECT 한 번 쿼리니까, 가상 스레드로 동시에 띄우면 잡이 끝나는 데 걸리는 시간이 줄지 않을까?
도입하고 며칠은 만족했습니다. 실제로 잡이 끝나는 데 걸리는 시간이 줄긴 했습니다. 그런데 코드를 다시 들여다보니, 제가 "성공" 이라고 본 것은 "걸린 시간" 한 줄뿐이었지, 잡의 본질에 가까운 가치는 무엇 하나 좋아진 게 없었습니다.
3. 도입 당시 구조 — 그리고 제 코드가 이미 자인하고 있던 것
빈 자체는 단순했습니다.
@Configuration
public class ExecutorConfig {
@Bean(destroyMethod = "shutdown")
public ExecutorService executorService() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}서비스 안에서는 가상 스레드 실행기 위에 Semaphore(동시에 일할 수 있는 인원수를 제한해 주는 자바 클래스)를 얹어서, 묶음을 동시에 몇 개까지 돌릴지 상한을 걸어 뒀습니다.
Semaphore semaphore = new Semaphore(effectiveMaxConcurrency);
List<Future<ChunkExecutionResult>> futures = virtualThreadExecutor.invokeAll(
plannerIdChunks.stream()
.<Callable<ChunkExecutionResult>>map(chunk ->
() -> executeChunkWithRetry(chunk, cutoffDateTime, semaphore, effectiveMaxRetry))
.toList()
);그리고 그 동시 실행 상한 값을 계산하려고, 잡이 돌 때마다 HikariCP(자바에서 가장 많이 쓰이는 DB 커넥션 풀 라이브러리)의 풀 상태를 읽어서 상한을 깎는 메서드까지 만들어 뒀습니다.
private int calculateDynamicConcurrency(int requestedConcurrency) {
HikariPoolMXBean poolMxBean = hikariDataSource.getHikariPoolMXBean();
if (poolMxBean == null) return requestedConcurrency;
int totalConnections = poolMxBean.getTotalConnections();
int rawActiveConnections = poolMxBean.getActiveConnections();
int adjustedActiveConnections = Math.max(rawActiveConnections - EXTERNAL_SERVICE_CONNECTION_BUFFER, 0);
int configuredPoolBuffer = plannerChunkProperties.dbPoolBuffer();
int availableConnections = Math.max(totalConnections - adjustedActiveConnections - configuredPoolBuffer, 1);
return Math.min(requestedConcurrency, availableConnections);
}전체 구조를 그림으로 그려보면 대략 이런 모양이었습니다.
[도입 당시 구조]
plannerIds ──▶ 묶음 분할
│
▼
┌────────────────────────────┐
│ 커넥션 풀 상태를 보고 │
│ "지금 동시에 몇 개까지 OK" │
│ 를 계산 │
└──────────┬─────────────────┘
▼
┌────────────────────────────┐
│ 가상 스레드 + Semaphore(N) │
│ 로 묶음들을 동시에 실행 │
└──────────┬─────────────────┘
▼
묶음 집계 결과
│
▼
한 번에 묶어서 저장
│
▼
비활성 플래너 삭제 표시지금 다시 보면, 이 코드는 사실 "진짜 동시 실행 상한은 가상 스레드 수가 아니라 DB 커넥션 풀 크기다" 라는 사실을 코드 차원에서 이미 인정하고 있었던 것 같습니다. 그 사실을 인정하고 있었다면, 처음부터 가상 스레드를 끼워 넣을 것이 아니라 커넥션 풀에 작업을 줄 세우는 모양이 더 자연스러웠을 텐데, 그때는 그 신호를 알아채지 못했습니다.
YAML 에도 그 흔적이 남아 있었습니다.
planner:
chunk:
beforeCallCountChunkSize: ...
maxConcurrency: ...
dbPoolBuffer: ...
maxRetry: ...maxConcurrency 와 dbPoolBuffer 라는 두 개의 운영 값이 dev / live / test 세 환경에 모두 떠 있었습니다. 운영 중에 누군가 잡 성능이 이상하다고 느꼈을 때 제일 먼저 만지게 될 손잡이가 두 개나 생긴 셈이었습니다.
4. 가상 스레드가 약속하는 것, 그리고 약속하지 않는 것
이번 일로 가장 크게 정리된 부분이 여기입니다. 시작하기 전에, 가상 스레드가 어떻게 빠를 수 있는지부터 제 말로 풀어보겠습니다.
가상 스레드는 혼자서 못 돕니다. 실제로는 진짜 OS 스레드 한 명을 빌려 타고 돌아갑니다. 가상 스레드가 빠른 이유는, 결과를 기다려야 하는 작업(DB 쿼리, 외부 API 호출 같은 것)을 만났을 때 타고 있던 진짜 OS 스레드에서 잠깐 내려와 다른 가상 스레드에게 그 OS 스레드를 양보해 줄 수 있기 때문입니다. 그래서 OS 스레드 수보다 훨씬 많은 가상 스레드가 동시에 진행 중일 수 있습니다.
가상 스레드의 약속을 제 말로 다시 적어보면 대략 이런 두 줄인 것 같습니다.
결과를 기다리는 동안 진짜 OS 스레드에서 잠깐 내려와 다른 가상 스레드에게 양보해 준다. 그래서 동시에 진행 중인 작업 수를 진짜 OS 스레드 수와 분리할 수 있다.
이 약속이 작동하려면 두 가지 전제가 깨지지 않아야 한다는 걸 이번에 알게 됐습니다.
- 결과를 기다리는 동안 가상 스레드가 진짜 OS 스레드에서 진짜로 내려올 수 있어야 합니다. 알아보니
synchronized블록 안에서의 대기, 일부 네이티브 호출, 일부 파일 입출력 같은 경로에서는 가상 스레드가 진짜 OS 스레드에서 못 내려오고 그대로 묶여 있게 된다고 합니다. 그런 경로에서는 가상 스레드의 약속이 그대로 작동하지 않습니다. - 하부 자원이 가상 스레드 수만큼 늘어날 수 있어야 합니다. 그런데 DB 커넥션 풀은 그렇지가 않습니다. 풀이 10개면 11번째 가상 스레드는 결국 커넥션을 받으러 가서 줄을 서게 됩니다.
특히 두 번째 부분이 제 경우에 매우 분명했습니다. 가상 스레드를 100개를 띄우든 10,000개를 띄우든, 실제로 동시에 일할 수 있는 한계는 DB 커넥션 풀 크기에서 막힙니다. 그림으로 보면 이런 모양입니다.
가상 스레드를 거의 무한히 만들 수 있어도,
실제로 동시에 일하는 수는 DB 커넥션 풀에서 막힙니다.
┌──────────────────────────────┐
│ 가상 스레드 N 개 │ ── 만들기는 거의 무제한
└──────────────┬───────────────┘
│ 커넥션을 받으러
▼
┌──────────────────────────────┐
│ DB 커넥션 풀 (예: 10) │ ── 진짜 동시 실행 상한은 여기
└──────────────┬───────────────┘
│ 11 번째부터
▼
⋯ 줄 서서 대기 ⋯그리고 제가 그 사실을 인정하기 시작한 순간이 바로, 풀 상태를 보고 가상 스레드 동시 실행 수를 깎는 메서드(calculateDynamicConcurrency(...))를 짜기로 결정한 순간이었습니다. 한 줄로 정리해 두면 이렇게 될 것 같습니다.
"커넥션 풀 크기에 맞춰 가상 스레드 수를 깎고 있다면, 가상 스레드를 굳이 거치지 않아도 되는 작업일 가능성이 높다."
지금 와서 보니까 꽤 명확한 신호였는데, 그때는 그저 "성실하게 안전장치를 단 코드" 정도로 만족하고 있었습니다.
5. 두 번째 깨달음 — 잡의 본질은 동시성이 아니었습니다
그리고 한 번 더 잡을 들여다봤더니, 재시도 메서드가 이런 모양이었습니다.
private ChunkExecutionResult executeChunkWithRetry(
List<Long> plannerIds,
LocalDateTime cutoffDateTime,
Semaphore semaphore,
int effectiveMaxRetry
) {
RuntimeException lastException = null;
for (int attempt = 1; attempt <= effectiveMaxRetry + 1; attempt++) {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
// 실제 집계 + 결과 보정 ...
return ChunkExecutionResult.success(plannerIds, results);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("...", e);
} catch (Exception e) {
lastException = unwrapRuntimeException(e);
// 재시도 간격 + 재시도 판정
} finally {
if (acquired) {
semaphore.release();
}
}
}
return ChunkExecutionResult.failure(plannerIds, lastException);
}여기서 좀 멈추게 됐습니다. 재시도 본문 한가운데에 acquired 플래그가 들어와 있고, semaphore.acquire() / release() 가 있고, 작업이 강제로 중단됐을 때 던져지는 예외 한 갈래가 따로 잡혀 있습니다. 이 메서드를 처음 보는 사람이 "이 함수는 뭐 하는 함수입니까?" 라고 물었을 때 한 줄로 답이 잘 안 나오는 게 느껴졌습니다. 재시도인지, 동시 실행 제한인지, 둘 다인지 가 본문에서 안 보였기 때문입니다.
여기서 한 발 더 들어가 다시 물어봤습니다.
이 잡에 동시 실행이 진짜 필요한가?
- Quartz
DisallowConcurrent가 잡 인스턴스 동시 실행을 이미 막아주고 있음 - 잡은 주기 실행이고, 묶음 수도 운영 데이터 규모상 큰 수가 아님
- 이 잡이 약속해야 하는 진짜 기준은 "잡이 얼마나 빨리 끝나는가" 가 아니라, "한 묶음의 실패가 다른 묶음의 반영을 막지 않는 것"
이렇게 적고 보니 답이 정해진 느낌이었습니다. 이 잡의 본질은 "같은 시간 안에 얼마나 많이 처리하느냐" 가 아니라 부분 실패 격리였고, 가상 스레드는 사실 잘못 잡은 문제를 풀고 있었던 셈입니다.
6. 빼면서 사라진 것들 — diff 가 가장 정직한 회고였습니다
가상 스레드를 떼낸 커밋의 변경 요약은 이렇게 나왔습니다.
8 files changed, 15 insertions(+), 139 deletions(-)
PlannerBeforeCallCountService.java | 131 ++-------------------본문 코드 131줄이 사라졌고, 그중 절반 이상이 동시 실행을 받쳐주기 위한 보조 코드였습니다. 함께 사라진 것들을 적어두면 이런 목록입니다.
virtualThreadExecutor,hikariDataSource의존성Semaphore도입과acquire/release/acquired플래그- 풀 상태 기반 동시 실행 수 계산 메서드 약 30 줄
Future결과를 꺼내며 예외를 풀어 던지는 보조 코드- 작업 중단 예외 한 갈래
- YAML 의
planner.chunk.maxConcurrency,planner.chunk.dbPoolBuffer(dev / live / test 3 환경) Hikari pool 상태 기반 concurrency 계산. ...한 줄짜리 운영 로그
남은 본문의 핵심은 거의 이게 전부입니다.
return plannerIdChunks.stream()
.map(chunk -> executeChunkWithRetry(chunk, cutoffDateTime, effectiveMaxRetry))
.toList();구조를 다시 그림으로 그려보면 이렇게 단순해졌습니다.
[제거 후 구조]
plannerIds ──▶ 묶음 분할
│
▼
순차 stream
│
▼
묶음 집계 결과
│
▼
한 번에 묶어서 저장
│
▼
비활성 플래너 삭제 표시재시도와 부분 성공 본문은 그대로 두면서, 동시 실행 보조 코드만 통째로 빠졌습니다. 처음 보는 사람이 "이 함수 뭐 하는 함수입니까?" 라고 다시 물어도 이제는 한 줄로 답이 나옵니다. "묶음 단위로 집계해서 모으는 함수입니다. 일부가 실패해도 성공한 것만 반영합니다." 그게 잡의 진짜 가치였습니다.
7. 사족 — 빈은 그대로 남아 있습니다
흥미로운 사족 하나만 적어둡니다. 가상 스레드 실행기 빈과 그 빈을 감싼 AsyncBatchExecutor 유틸 클래스는 제거하지 않았습니다. "다른 잡에 필요할 수도 있으니까" 라는, 흔하지만 약한 이유였습니다.
이 글을 쓰면서 다시 grep 해 봤더니, AsyncBatchExecutor 의 호출처가 src/main 전체에서 0 건이었습니다. 빈도 살아 있고, 유틸 안의 동시 실행 제어 로직도 살아 있는데, 누가 쓰는지 보면 아무도 안 쓰고 있는 상태입니다.
이번 회고의 마지막 청소는 아마 이 빈과 유틸을 같이 지우는 일이 될 것 같습니다. 글에는 일부러 남겨두고 적었습니다 — 본문에서 코드는 깔끔하게 정리됐는데, 그 옆에 안 쓰는 빈이 그대로 있다는 사실이 다음 정리로 자연스럽게 이어지는 도입부였기 때문입니다.
8. 주니어로서 이번에 배운 것들
① 가상 스레드는 만능 가속기가 아니라, "결과를 기다리는 시간 동안 진짜 OS 스레드를 양보해 주는 도구" 였습니다
이번 일 전에는 막연히 "가상 스레드 쓰면 알아서 빨라진다" 정도로만 알고 있었습니다. 그런데 DB 커넥션 풀처럼 풀 크기가 진짜 동시 실행 한계가 되는 경로에서는 그 약속이 그대로 작동하지 않는다는 걸 이번에 알게 됐습니다. 다음번에 가상 스레드를 다시 꺼낼 일이 생기면, "진짜 OS 스레드는 양보된다 쳐도, 그 뒤의 자원(커넥션 풀, 외부 API 한도 같은 것)이 가상 스레드 수만큼 늘어날 수 있는 작업인가" 를 먼저 물어보려고 합니다.
② 도구를 도입하기 전에 "이 잡에 동시 실행이 정말 필요한가" 부터 적어두기
Quartz DisallowConcurrent 가 걸려 있고 묶음 수가 크지 않은 주기 잡이라면, 동시 실행으로 얻을 게 그렇게 크지 않다는 걸 이번에 처음 정리해 봤습니다. "잡이 끝나는 데 걸리는 시간이 줄었다" 는 사실에 만족하기 전에, 이 잡이 약속해야 하는 진짜 기준이 무엇인지를 먼저 한 줄로 적어보는 게 순서였던 것 같습니다.
③ 재시도 본문에 semaphore.acquire()/release() 가 끼어들고 있다면, 메서드의 주연이 바뀌어 있다는 신호였습니다
제가 이번에 가장 늦게 알아챈 부분입니다. 함수를 한 줄로 설명할 수 없으면 보통 그 함수가 두 가지 일을 동시에 하고 있는 것이고, 그러면 본문에서 가장 중요한 일이 무엇이어야 했는지를 다시 묻게 됩니다. 재시도와 부분 성공이 메인이라면 그게 본문에서 한눈에 보여야 했습니다.
④ "풀 상태를 보고 동시 실행 수를 동적으로 깎는" 코드를 쓰고 있다면 한 번 멈춰서 의심해 보기
풀 상태를 보고 동시 실행 수를 깎는 코드를 쓰고 있다는 건, 풀 크기가 진짜 동시 실행 한계라는 사실을 이미 내 코드가 인정한 것입니다. 그렇다면 가상 스레드를 굳이 거칠 이유가 약해집니다. 다음에 비슷한 코드를 다시 짜게 되면, 이 사실을 한 번 의심해 보는 단계를 넣으려고 합니다.
⑤ 추상화는 추가가 쉽고, 제거가 비싸다는 걸 직접 보게 됐습니다
빈을 만들지 않았다면, 빼는 청소도 필요 없었을 것 같습니다. "혹시 다음에 쓸지도" 라는 이유로 남긴 추상화가 보통 어떤 모습으로 남는지를 이번에 직접 보게 됐습니다. 다음에 비슷한 빈을 만들기 전에는, "이 빈을 다른 잡이 쓸 시나리오를 지금 한 줄로 적을 수 있는가" 를 먼저 스스로에게 물어보려고 합니다.
마무리
이번 회고에서 가장 크게 남는 한 줄은 이거였습니다.
도구는 약속한 만큼만 해 주고, 약속하지 않은 것은 해 주지 않는다.
가상 스레드는 좋은 도구지만, 이번 잡에서는 그 약속의 경계 바깥에서 일하고 있었던 것 같습니다. 그 사실을 잡, 그리고 사라진 두 개의 YAML 프로퍼티로 직접 보게 된 게 이번 작업의 가장 큰 소득이었습니다.
다음번에 비슷한 도구를 꺼낼 일이 생기면, 코드부터 짜기 전에 종이 한 장에 이 세 줄을 먼저 적어 보려고 합니다.
- 이 작업에서 진짜 OS 스레드를 점유하는 것은 무엇인가
- 진짜 동시 실행 상한은 어디에 있는가 (스레드 수? 커넥션 풀? 외부 API 한도?)
- 이 잡이 진짜 약속해야 하는 기준이 "같은 시간 안에 더 많이 처리하기" 인가, "한 묶음의 실패가 다른 묶음의 반영을 막지 않는 것" 인가
이 세 줄만 먼저 적어 두었어도 이번 같은 우회는 한 번 줄였을 것 같습니다. 다음의 저에게 적어 두는 메모로 글을 마칩니다.