ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

들어가며

사내 업무 자동화 프로젝트에서 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 시작/배포 (트랜잭션 충돌)

배운 점

  1. suspend와 ThreadLocal 기반 트랜잭션은 본질적으로 충돌한다 — 이 갭을 인지해야 한다
  2. "전부 전환"보다 "핵심만 전환"이 현실적일 때가 있다 — 순수주의를 버리고 실용적 범위를 잡는 것도 엔지니어링이다
  3. 롤백도 의사결정이다 — 복잡성이 이득을 초과하면 되돌리는 게 맞다. 커밋 3개를 롤백한 건 실패가 아니라 판단이다

suspend + 트랜잭션 문제는 Spring 6.1의 @Transactional Coroutine 지원으로 개선되고 있지만, Exposed ORM + 커스텀 트랜잭션 어노테이션 환경에서는 아직 갈 길이 멀다.

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.