들어가며
사내 업무 자동화 프로젝트에서 Camunda 엔진 API 호출이 전부 WebClient의 .block()으로 처리되고 있었다. 동시 요청이 몰리면 Tomcat 스레드풀이 고갈될 위험이 있어 비동기 전환이 필요했다.
이전 세대에서는 RxJava 3을 사용했지만, Kotlin 프로젝트에서는 Coroutine을 선택했다. 그런데 suspend 함수와 @Transactional의 충돌이라는 예상치 못한 문제를 만났다.
문제: .block()이 위험한 이유
fun findMonitoringByInstanceId(monitoringId: String): MonitoringDto {
val response = httpClient.get<HistoryProcessInstanceDto>(obtainWebClient())
.url("/history/process-instance/$monitoringId")
.build()
.executeAsync(HistoryProcessInstanceDto::class.java)
.block() // ← Tomcat 스레드를 점유한 채 대기
}
.block()은 Mono가 완료될 때까지 현재 스레드를 점유한다. 엔진 API가 느려지면 Tomcat 200개 스레드가 전부 대기하면서 다른 요청을 처리할 수 없게 된다.
선택: RxJava 3 vs Kotlin Coroutine
| 항목 | RxJava 3 | Kotlin Coroutine |
|---|---|---|
| 팀 경험 | 이전 세대에서 사용 | 없음 |
| 코드 스타일 | Operator 체인 | 일반 함수처럼 |
| Spring 지원 | 수동 변환 필요 | awaitSingleOrNull 공식 확장 |
| 러닝 커브 | 높음 (Operator 수백 개) | 낮음 (suspend만 이해하면 됨) |
결정적 이유: 코드가 동기 코드처럼 읽힌다.
// RxJava: Operator 체인
fun getProcess(id: String): Single<ProcessDto> =
Single.fromPublisher(
webClient.get().uri("/process/$id")
.retrieve().bodyToMono(ProcessDto::class.java)
).flatMap { dto -> Single.just(transform(dto)) }
// Coroutine: 그냥 함수
suspend fun getProcess(id: String): ProcessDto {
val dto = webClient.get().uri("/process/$id")
.retrieve().bodyToMono<ProcessDto>()
.awaitSingleOrNull() ?: throw NotFoundException()
return transform(dto)
}

전환 과정
1단계: EngineApiClient 전환 (성공)
10개 엔진 HTTP 호출 메서드를 suspend로 전환, .block() → .awaitSingleOrNull() 교체.
// Before
fun getHistoryProcessInstance(id: String): HistoryProcessInstanceDto? {
return httpClient.get<HistoryProcessInstanceDto>(obtainWebClient())
.url("/history/process-instance/$id")
.build()
.executeAsync(HistoryProcessInstanceDto::class.java)
.block() // ← 스레드 점유
}
// After
suspend fun getHistoryProcessInstance(id: String): HistoryProcessInstanceDto? {
return httpClient.get<HistoryProcessInstanceDto>(obtainWebClient())
.url("/history/process-instance/$id")
.build()
.executeAsync(HistoryProcessInstanceDto::class.java)
.awaitSingleOrNull() // ← 스레드 반환
}
2단계: Service/Controller 전파 (suspend 전염)
suspend는 전염된다. EngineApiClient → Service → Controller 전부 suspend가 되어야 한다.
3단계: suspend + @TenantTransactional 충돌 (롤백)
여기서 예상치 못한 문제가 터졌다.
@TenantTransactional // ← ThreadLocal 기반
suspend fun startProcess(...) { // ← Coroutine은 스레드를 넘나듦
// ThreadLocal에 저장된 TenantContext가 중간에 사라질 수 있음!
}
| 실행 시점 | 스레드 | TenantContext |
|---|---|---|
| suspend 전 | Thread-1 | ✓ 있음 |
| suspend (양보) | — | — |
| resume (재개) | Thread-2 | ✗ 없음 |
suspend 함수는 중간에 스레드를 양보하고 다른 스레드에서 재개될 수 있다. ThreadLocal 기반 트랜잭션과 본질적으로 충돌한다.
복잡한 DTO 패턴을 시도했다가 복잡성이 이득을 초과하여 롤백했다.
최종 결정: 범위를 좁힌다
| 컴포넌트 | suspend | 이유 |
|---|---|---|
| EngineApiClient | ✅ 유지 | 핵심 목적 (스레드 점유 제거) |
| 모니터링 Service | ✅ 유지 | 엔진 호출이 주 작업 |
| 프로세스 시작/배포 | ❌ 제거 | @TenantTransactional과 충돌 |
| Quartz 배치 | runBlocking |
전용 스레드풀이라 허용 |
// Quartz Job — 전용 스레드풀에서 실행되므로 runBlocking 허용
class ProcessCronScheduler : Job {
override fun execute(context: JobExecutionContext) {
runBlocking {
engineApiClient.getExecutionStatistics(...)
}
}
}
runBlocking은 Tomcat 스레드풀에서 쓰면 안티패턴이지만, 전용 스레드풀(Quartz)에서는 합리적 선택이다.
결과
| 항목 | Before | After |
|---|---|---|
| .block() 호출 | 10+ 곳 | 모니터링/대시보드에서 제거 |
| 스레드 점유 | 엔진 응답까지 대기 | suspend 시 스레드 반환 |
| 적용 범위 | — | EngineApiClient + Monitoring + Dashboard |
| 미적용 | — | Process 시작/배포 (트랜잭션 충돌) |
배운 점
- suspend와 ThreadLocal 기반 트랜잭션은 본질적으로 충돌한다 — 이 갭을 인지해야 한다
- "전부 전환"보다 "핵심만 전환"이 현실적일 때가 있다 — 순수주의를 버리고 실용적 범위를 잡는 것도 엔지니어링이다
- 롤백도 의사결정이다 — 복잡성이 이득을 초과하면 되돌리는 게 맞다. 커밋 3개를 롤백한 건 실패가 아니라 판단이다
suspend + 트랜잭션 문제는 Spring 6.1의
@TransactionalCoroutine 지원으로 개선되고 있지만, Exposed ORM + 커스텀 트랜잭션 어노테이션 환경에서는 아직 갈 길이 멀다.
'백엔드 트러블슈팅' 카테고리의 다른 글
| Spring @Transactional과 Exposed ORM이 안 맞을 때: 트랜잭션 전파가 무시되는 구조적 원인과 해결 (Kafka 동기화 삽질기 5편) (0) | 2026.04.12 |
|---|---|
| Tauri + Python 프로세스 간 통신 삽질기 — stdout 파이프가 영원히 안 끝날 때 (0) | 2026.03.29 |
| Windows 환경 Blue-Green 무중단 배포 구축기: 50커밋 삽질 완전 정리 (0) | 2026.03.29 |
| Reactor 파이프라인 설계기: External Task 200건 동시 처리 · ConnectableFlux 삽질 (0) | 2026.03.29 |
| Schema-per-Tenant 멀티테넌시 구현: JPA 대신 Exposed를 선택한 이유와 실전 삽질 (0) | 2026.03.29 |
