Kotlin과 스프링 MVC

후르츠·2026년 4월 17일

Kotlin 이모저모

목록 보기
1/1

개요

Kotlin은 비동기와 동시성을 표현하기 위해 Coroutine을 제공한다.
한편 국내 서버 개발 생태계의 상당 부분은 여전히 Spring MVC(정확히는 서블릿 스펙) 기반의 전통적인 스레드 모델 위에서 동작한다.

문제는 두 모델의 철학이 정면으로 충돌한다는 점이다. Spring MVC는 "요청 하나 = 스레드 하나"라는 전제 아래 수많은 기능을 ThreadLocal에 의존하도록 설계되어 있다.
반면 Coroutine은 suspend와 resume 사이에서 얼마든지 스레드를 갈아탈 수 있는 경량 실행 단위이다.

이 간극을 메우지 않으면 로그에서 Trace ID가 사라지고, @Transactional이 트랜잭션을 전파하지 못하며, Spring Security의 인증 정보가 유실된다. 이번 포스트에서는 이 간극을 잇기 위한 Kotlin 및 Spring 생태계의 노력을 살펴보고자 한다.

스레드 모델과 코루틴의 간극

Spring MVC는 Tomcat이나 Undertow 같은 서블릿 컨테이너의 워커 스레드 풀 위에서 동작한다. 하나의 HTTP 요청은 특정 워커 스레드를 점유한 채 시작부터 응답 반환까지 실행되며, 그 스레드 위에서 ThreadLocal은 사실상 "요청 범위(Request-scoped)의 전역 저장소"처럼 취급된다.

Kotlin Coroutine은 이와 다르게 suspend fun은 임의의 지점에서 중단되어 Continuation 형태로 변환되고, 재개될 때는 Dispatcher가 지정한 전혀 다른 스레드에서 실행을 이어받는다.

즉, 코루틴이 처음 시작된 스레드의 ThreadLocal에 값을 심어두어도, withContext(Dispatchers.IO)를 한 번 만나는 순간 그 값은 읽을 수 없게 된다.
이것이 Spring MVC와 Coroutine 조합에서 발생하는 모든 맥락 유실 문제의 근원이다.

해결의 방향은 두 가지로 갈린다. 하나는 ThreadLocal 값을 코루틴이 재개되는 스레드마다 자동으로 복원해 주는 방식이고, 다른 하나는 아예 ThreadLocal을 쓰지 않고 Reactor Context나 CoroutineContext처럼 실행 단위를 따라 흐르는 컨테이너로 일원화하는 방식이다.

추적 가능한 로그: MDC와 MDCContext

가장 직관적으로 맥락 유실을 체감할 수 있는 지점은 로그이다.

분산 환경에서 요청 단위로 로그를 상관 지으려면 traceId, spanId, requestId 같은 식별자가 필요하다. SLF4J를 필두로 Logback, Log4j2는 이러한 식별자를 저장하기 위해 MDC(Mapped Diagnostic Context)라는 Key-Value 저장소를 제공하는데, 그 구현체는 다름 아닌 ThreadLocal이다.

따라서 아무런 조치 없이 suspend fun 안에서 MDC.put("traceId", ...)를 호출하면, 함수가 재개되는 스레드에서는 해당 값이 보이지 않는다.

Kotlin은 이 문제를 위해 kotlinx-coroutines-slf4j 모듈을 통해 MDCContext라는 ThreadContextElement를 제공한다. 이는 코루틴이 스레드 위에서 실행을 시작할 때마다 저장된 MDC 스냅샷을 해당 스레드에 주입하고, 중단되는 시점에 원래 값을 복원하는 방식으로 "ThreadLocal을 코루틴-로컬처럼" 동작하게 만든다.

import kotlinx.coroutines.*
import kotlinx.coroutines.slf4j.MDCContext
import org.slf4j.MDC

suspend fun handleRequest(traceId: String) {
    MDC.put("traceId", traceId)
    withContext(MDCContext()) {
        launch(Dispatchers.IO) {
            // IO 디스패처의 다른 스레드로 재개되어도 traceId가 유지된다
            log.info("외부 API 호출 시작")
        }
    }
}

다만 MDCContext는 코루틴이 시작되는 순간의 MDC 스냅샷을 캡처한다. 코루틴 내부에서 MDC에 값을 추가해도 그 변경은 다음 스레드 전환 이후에는 보이지 않을 수 있다. 따라서 MDC는 요청 시작 지점에서 한 번만 주입하고, 이후에는 읽기 전용으로 취급하는 패턴이 안전하다.

@Transactional과 ThreadLocal의 얽힘

@Transactional은 Spring 애플리케이션에서 가장 많이 쓰이는 어노테이션이면서, 동시에 ThreadLocal에 가장 깊이 얽혀 있는 기능이다.

Spring의 TransactionSynchronizationManager는 현재 활성 상태인 커넥션과 트랜잭션 상태를 전부 ThreadLocal에 저장한다. AOP 프록시가 메서드 진입 시 커넥션을 열고 ThreadLocal에 바인딩하면, 같은 스레드에서 실행되는 JDBC 호출은 이를 꺼내 쓰면서 동일한 트랜잭션 범위에 묶이는 구조이다.

여기에 코루틴을 끼워 넣으면 문제가 즉시 드러난다.

@Transactional
suspend fun transfer(from: Long, to: Long, amount: Long) {
    accountRepository.withdraw(from, amount)
    // withContext로 스레드가 바뀌는 순간 트랜잭션 바인딩이 유실될 수 있다
    withContext(Dispatchers.IO) {
        externalAuditLogger.write(...)
    }
    accountRepository.deposit(to, amount)
}

Spring Framework 6.x는 suspend fun에 대한 @Transactional 지원을 정식으로 제공하고 있으나, 이는 어디까지나 Spring이 생성한 코루틴 스코프 내부를 전제로 한다. 사용자가 임의로 Dispatchers.IODispatchers.Default로 스레드를 전환하는 순간, JDBC 커넥션 바인딩은 여전히 유실 위험에 노출된다.

근본적인 해결책은 둘 중 하나이다. JDBC를 고수할 경우 트랜잭션 경계 전체를 하나의 코루틴 컨텍스트 안에 가두고 스레드 전환을 허용하지 않거나, 아예 트랜잭션 자체를 CoroutineContext 위에 올릴 수 있는 R2DBC 기반 비동기 스택으로 이전하는 것이다.

Micrometer Context Propagation: 범용 브리지

MDCContext가 "MDC 전용"이었다면, 동일한 전파 메커니즘을 임의의 ThreadLocal로 일반화한 것이 Micrometer Context Propagation이다.

이 라이브러리는 ThreadLocalAccessor라는 추상화를 통해 개별 ThreadLocal 값을 등록하고, ContextSnapshot으로 캡처한 뒤 Reactor Context 혹은 CoroutineContext로 옮겨 담을 수 있게 한다. Spring Boot 3부터는 이 위에 Micrometer Observation과 Tracing이 얹히며, 기존 Spring Cloud Sleuth의 역할이 Micrometer로 이관되었다.

Kotlin 진영에서는 Spring Framework 6.1이 PropagationContextElement를 제공하여 CoroutineContextContextSnapshot을 하나로 묶는다.

runBlocking(Dispatchers.IO + PropagationContextElement()) {
    // MDC, Observation, Tracing 등 등록된 모든 ThreadLocalAccessor가 자동 전파된다
    delay(10)
    logger.info("traceId가 포함된 로그")
}

또한 Hooks.enableAutomaticContextPropagation()을 활성화하면 Reactor와 Coroutine 경계를 넘나드는 시점에도 컨텍스트가 자동으로 복사된다. Spring의 CoroutinesUtils가 내부적으로 suspend funMono로 어댑팅할 때 이 경로를 사용한다.

R2DBC와 Reactor Context: 또 다른 선택지

지금까지의 논의가 전부 "ThreadLocal을 코루틴 세계로 끌어오는" 방향이었다면, 반대로 ThreadLocal을 쓰지 않는 스택으로 옮기는 접근도 존재한다.

Spring WebFlux와 R2DBC는 처음부터 Reactor 위에 지어졌고, Reactor는 ThreadLocal 대신 Subscriber 체인을 따라 흐르는 불변의 Reactor Context를 사용한다. 트랜잭션 상태, 보안 컨텍스트, 트레이스 정보 모두 이 Context에 담긴다.

그리고 kotlinx-coroutines-reactor 모듈은 Reactor Context와 CoroutineContext를 상호 변환한다. 즉, R2DBC 기반 애플리케이션에서는 별도의 브리지 없이도 suspend 경계를 넘나드는 트랜잭션 전파가 자연스럽게 이루어진다.

jOOQ와 Exposed: 타입 세이프 SQL과 코루틴의 접점

R2DBC로 전환할 때 마주치는 현실적인 고민 중 하나는 "JPA를 포기한 자리를 무엇으로 채울 것인가"이다. DatabaseClient만으로 복잡한 쿼리를 작성하면 결국 문자열 SQL로 회귀하게 되고, 타입 안전성은 컴파일 타임이 아닌 런타임으로 미뤄진다.

이 간극을 메우는 유력한 후보는 두 가지이다. jOOQExposed가 각각 JVM 진영과 Kotlin 네이티브 진영에서 타입 세이프 SQL DSL을 제공한다.

jOOQ

jOOQ는 3.15부터 R2DBC 드라이버를 정식으로 지원하며, jooq-kotlin-coroutines 모듈을 통해 suspend 함수와 Flow 기반 API를 제공한다. 내부적으로는 ConnectionFactory를 주입받은 DSLContext가 Reactor Publisher를 반환하고, Kotlin 확장 함수가 이를 suspend 또는 Flow로 어댑팅하는 구조이다.

@Bean
fun dslContext(cf: ConnectionFactory): DSLContext =
    DSL.using(cf, SQLDialect.POSTGRES)

class AccountRepository(private val dsl: DSLContext) {
    suspend fun findById(id: Long): Account? =
        dsl.selectFrom(ACCOUNTS)
            .where(ACCOUNTS.ID.eq(id))
            .awaitFirstOrNull()
            ?.into(Account::class.java)
}

다만 Spring Boot의 spring-boot-starter-jooq는 JDBC를 전제로 구성되므로 그대로 쓸 수 없고, jooqjooq-kotlin-coroutines 의존성을 직접 추가한 뒤 ConnectionFactory 기반으로 DSLContext를 수동 구성해야 한다. Flyway 역시 R2DBC를 지원하지 않으므로 마이그레이션 용도의 JDBC DataSource를 별도로 유지하는 패턴이 일반적이다.

jOOQ의 강점은 방대한 SQL 방언 지원과 코드 제너레이터이다. DDL로부터 타입 세이프한 테이블 메타데이터를 생성해 주므로, 컴파일 타임에 컬럼 이름 오타나 타입 불일치를 잡아낼 수 있다. 복잡한 윈도우 함수나 CTE, 벤더 고유 기능을 활용하는 쿼리에서 특히 빛을 발한다.

Exposed

Exposed는 JetBrains가 직접 개발하는 Kotlin-native SQL DSL이다. 별도의 코드 제너레이터 없이 Kotlin 객체로 테이블을 선언하며, Table을 상속한 오브젝트가 곧 메타데이터로 기능한다.

object Accounts : Table("accounts") {
    val id = long("id").autoIncrement()
    val balance = long("balance")
    override val primaryKey = PrimaryKey(id)
}

class AccountRepository(private val db: R2dbcDatabase) {
    suspend fun findById(targetId: Long): Account? = suspendTransaction(db = db) {
        Accounts.selectAll()
            .where { Accounts.id eq targetId }
            .singleOrNull()
            ?.let { Account(it[Accounts.id], it[Accounts.balance]) }
    }
}

Exposed는 전통적으로 JDBC 기반이었으나, 0.57.0부터 R2DBC를 공식 지원하기 시작하면서 suspendTransaction API를 통해 코루틴 네이티브한 트랜잭션 경계를 제공한다. 내부적으로 R2DBC ConnectionFactory를 감싸고, 트랜잭션 상태를 CoroutineContext에 바인딩하는 방식이다.

강점은 Kotlin과의 자연스러운 통합이다. DSL이 Kotlin 문법을 그대로 활용하기 때문에 러닝 커브가 낮고, 가벼운 DSL 레이어와 Entity DSL(경량 ORM) 두 가지 스타일을 모두 제공한다.

선택 기준

두 도구 중 무엇을 고를지는 애플리케이션의 성격에 달려 있다.

jOOQ는 SQL 자체의 표현력을 최대한 끌어내고 싶을 때, 여러 DB 벤더를 동시에 지원해야 할 때, 기존 스키마로부터 코드를 생성하는 워크플로우가 중요할 때 적합하다. 반면 Exposed는 Kotlin-first 프로젝트에서 빠르게 시작하고 싶을 때, 테이블 정의와 애플리케이션 코드를 한 언어로 통일하고 싶을 때, 그리고 R2DBC 기반 코루틴 스택과의 결합을 별도 어댑터 없이 쓰고 싶을 때 적합하다.

어느 쪽을 선택하든, R2DBC + 타입 세이프 DSL 조합은 "JPA를 포기한 자리"를 메우면서 동시에 "코루틴 친화적인 트랜잭션 전파"를 얻는 드문 지점이다. JPA의 영속성 컨텍스트나 Dirty Checking 같은 기능을 포기할 수 있다면, 기술 스택을 상대적으로 가볍게 유지하면서도 타입 안전성을 잃지 않을 수 있다.

맺으며

Spring MVC와 Kotlin Coroutine의 결합은 빠르게 쓰기는 쉽지만, 제대로 쓰기는 까다로운 조합이다.

ThreadLocal에 의존하는 기능 하나하나가 잠재적인 맥락 유실 지점이며, 그 해결책은 MDCContext 같은 개별 브리지, Micrometer Context Propagation 같은 범용 브리지, 그리고 Reactor 스택으로의 전면 전환이라는 세 층위로 존재한다.

어느 층위를 택하든, 핵심은 "내 코드의 어느 경계에서 스레드가 바뀌는가"를 명확히 인지하는 것이다.

profile
Backend Software Engineer

0개의 댓글