Bldev's Blog

코틀린 · 스프링

[코틀린/스프링] 트랜잭션 관리

2025. 7. 30.

트랜잭션 관리

트랜잭션 관리란 데이터베이스의 상태를 변경하는 작업을 수행하는 과정인 트랜잭션을 관리하는 것을 의미한다. 데이터베이스의 상태 변경 작업에는 쓰기, 수정, 삭제가 있다. 일련의 작업을 수행하는 메서드 내에서 여러 작업 실행을 하나의 트랜잭션으로 실행할지 결정하여 작업 실행이 하나라도 실패하면 전체 작업 실행이 실패하도록 트랜잭션을 롤백하고 작업 실행이 모두 성공하면 트랜잭션을 커밋해야 한다. 이렇게 여러 작업을 하나의 트랜잭션으로 묶고 하나의 작업 실패 시 다른 작업을 어떻게 처리할지에 대한 관리가 바로 트랜잭션 관리이다.

선언적 트랜잭션 관리

선언적 트랜잭션 관리란 트랜잭션을 관리하기 위한 코드를 별도로 작성하지 않고, 라이브러리의 트랜잭션 관리 기능을 사용하여 트랜잭션을 관리하는 것을 의미한다. 대표적인 예로 스프링 프레임워크의 @Transactional 어노테이션을 사용하여 선언적 트랜잭션 관리를 수행할 수 있다. 이 어노테이션은 메서드에 적용하여 해당 메서드 내에서 실행되는 작업에 대한 트랜잭션의 시작, 종료, 롤백 등의 작업을 자동으로 수행한다.

선언적 트랜잭션 관리는 런타임에 적용되며, 애플리케이션 코드는 트랜잭션 관리에 의존하지 않게 된다. 이 경우 트랜잭션 관리는 교차(횡단) 관심사가 된다.

프로그래밍 방식 트랜잭션 관리

프로그래밍 방식 트랜잭션 관리란 트랜잭션을 관리하기 위한 코드를 별도로 작성하여 트랜잭션을 관리하는 것을 의미한다. 대표적인 예로 스프링 프레임워크의 PlatformTransactionManager 인터페이스를 사용하여 프로그래밍 방식 트랜잭션 관리를 수행할 수 있다.

일반적으로 프로그램 방식의 트랜잭션 관리는 트랜잭션 관리 코드가 특정 데이터 접근 기술에 종속되며 기술 변경 시 관련된 코드를 모두 변경해야 하는 의존성 문제가 발생할 수 있다. 스프링과 같은 추상화 프레임워크는 데이터 접근 기술에 독립적으로 간편하게 트랜잭션 관리 작업을 할 수 있게 도와준다.

트랜잭션 관리자는 새로운 트랜잭션의 시작, 트랜잭션 커밋 및 롤백을 통해 트랜잭션을 관리하는 역할을 수행한다. 트랜잭션 템플릿은 트랜잭션 관리자를 통해 트랜잭션을 직접 시작, 커밋, 롤백하는 코드를 반복적으로 작성할 필요 없이 트랜잭션을 적용할 코드 블록을 인자로 전달하여 호출만 하면 트랜잭션을 자동으로 관리해주는 메서드를 제공함으로써 트랜잭션 관리를 보다 간편하게 할 수 있게 도와준다.

코루틴과 선언적 트랜잭션 관리

코루틴에서도 선언적 트랜잭션 관리를 수행할 수 있다. 코루틴의 경우 중단(suspend) 함수에 대해 @Transactional 어노테이션을 사용하여 선언적 트랜잭션 관리를 수행할 수 있으며, 코틀린에서도 5.3 버전부터 중단 함수 + 트랜잭션 조합을 지원하였다. 하지만 이러한 지원은 스레드의 블로킹과는 별개의 문제이다. 코루틴에 대해 선언적 트랜잭션 관리를 지원하지만, 코루틴의 장점이 유지되는 것은 보장되지 않는다. JDBC와 같은 블로킹 라이브러리를 사용할 경우 스레드가 블로킹되어 코루틴의 장점이 유지되지 않는다. 제대로된 트랜잭션 지원을 위해서는 R2DBC와 같은 논블로킹-리액티브 라이브러리가 필요하다. 트랜잭션 지원은 “커밋/롤백 경계를 잡고 같은 작업 단위를 묶어줄 수 있는가”의 문제이며, 스레드 블로킹 여부는 “DB나 외부 API 응답을 기다리는 동안 스레드가 블로킹되는가”의 문제이다. 이 둘은 독립적이다.

스프링의 기존 트랜잭션 관리인 PlatformTransactionManager 인터페이스는 특정 스레드에 바운드된 트랜잭션 관리이므로 스레드가 변경될 경우 기존 트랜잭션이 전파되지 않는 특성이 있다. 따라서 리액티브 환경에서 트랜잭션을 관리하기 위해서는 리액티브 트랜잭션 관리를 위한 ReactiveTransactionManager 인터페이스가 존재한다. 스레드가 전환되는 리액티브 환경에서는 실행 컨텍스트 유지 및 전파가 필수적이므로 별도의 관리 방식이 필요하다.

스프링은 @Transactional 어노테이션을 AOP 프록시로 처리하고, 반환 타입을 고려하여 트랜잭션을 처리하는 특성에 주의해야 한다. TransactionInterceptor가 함수의 반환 타입을 확인하여 트랜잭션의 방식을 결정한다. 리액터 타입(Mono, Flux)이거나 코틀린의 Flow 타입일 경우 리액티브 트랜잭션 관리 대상으로 간주된다. 그 외 반환 타입은 명령형 트랜잭션 관리 대상으로 간주되어 정상적으로 처리된다. void를 포함한 그 외 모든 반환 타입은 명령형 트랜잭션 관리로 처리된다.

트랜잭션 관리에 있어 중요한 것은 함수가 중단 함수인지 아닌지가 아니라 리액티브한 특성을 갖는지 아닌지이다. 코루틴 그 자체로는 리액티브한 특성을 갖지 않는다. 코루틴은 하나의 스레드에서 실행 중에 일시 중단 되었다가 다른 스레드에서 다시 재개될 수 있는 추상화일 뿐이다. 따라서 트랜잭션 관리 측면에서 스프링의 선언적 트랜잭션 관리에 의해 자동으로 처리되지 않는다는 점을 주의해야 한다.

트랜잭션 관리가 정상적으로 이루어지는가 아닌가는 바로 트랜잭션 실행이 특정 스레드에 바운드되는가, 아니면 여러 스레드에 의해 걸쳐 실행되는가에 달려있다. 중단 함수는 하나의 스레드에 의한 실행이 보장되지 않는 특성을 가지므로 스프링의 선언적 트랜잭션 관리에 의해 자동으로 처리되지 않는다.

공식적으로 스프링은 중단 함수 및 코루틴에 대한 트랜잭션을 리액티브 트랜잭션 관리의 프로그래밍 방식 변형을 통해 지원한다. 중단 함수 전용으로 TransactionalOperator.executeAndAwait을 제공하고 있다. 이를 사용하여 중단 함수의 작업을 트랜잭션 내에 포함시킬 수 있다. 이를 사용하면 트랜잭션 컨텍스트가 중단 지점을 넘어 올바르게 전파되고 기존 스레드가 차단되지 않는다. 추가적으로 Flow 타입에 대해서 Flow<T>.transactional 확장을 제공하고 있다. 이 확장의 경우 프록시/AOP 방식에 의한 트랜잭션 관리가 아니라 TransactionalOperator를 이용해 Flow 파이프라인 자체에 트랜잭션 경계를 프로그래밍 방식으로 명시적으로 적용하는 것이다.

다음 함수의 시그니처에 따른 트랜잭션 유형을 살펴보자.

  • fun foo(): T: 일반 함수이며 반환 타입이 리액티브 타입이 아니므로 명령형 트랜잭션 관리에 해당된다.
  • fun foo(): Flow<T>: 일반 함수이지만 리액티브 타입을 반환하므로 리액티브 트랜잭션 관리에 해당된다.
  • suspend fun foo(): T: 중단 함수이지만 반환 타입이 리액티브 타입이 아니다. 하지만 명령형 트랜잭션 관리가 아니다. 스프링의 기존 스레드 바운드 트랜잭션 관리가 적용되지 않는다. 스프링은 코루틴 트랜잭션 관리를 위해 별도의 트랜잭션 지원을 제공한다.

함수 내에서 수행되는 I/O 작업이 논블로킹으로 수행되는 것이 보장되지 않는다는 점을 명심해야 한다. 블로킹 I/O 라이브러리를 사용하면서 완전한 리액티브 구현을 할 수는 없다. 스레드 블로킹 없이 완전한 리액티브 스택으로 논블로킹 I/O으로 작업을 수행하고 트랜잭션을 관리하려면 R2DBC와 같은 논블로킹-리액티브 라이브러리를 사용해야 한다. 데이터베이스 작업 요청 부터, 작업을 처리하는 과정까지, 모든 체인이 논블로킹이라는 전제하에 리액티브 트랜잭션 관리가 정상적으로 이루어질 수 있다.

리액티브 프로그래밍 환경에서 트랜잭션 관리

트랜잭션 작업을 수행하는 함수에 선언적 트랜잭션 관리를 적용할 경우 다음과 같이 함수의 종류, 데이터베이스 라이브러리 종류에 따라 다음과 같이 동작 차이가 발생하게 된다. 리액티브 타입을 사용하지 않고 코루틴 확장 라이브러리를 사용한다고 가정한다.

  • 선언적 트랜잭션 관리 + 일반 함수 + 블로킹 JDBC 라이브러리
    • 장점: 선언적 트랜잭션 관리가 동작한다.
    • 단점: 블로킹 IO 작업이 스레드를 블로킹한다. 함수 내에서 트랜잭션을 유지하면서 블로킹 IO 작업이 스레드를 블로킹하지 않도록 만들 수 없다. 코루틴을 새로 생성할 수는 있지만 함수를 실행하는 스레드와 코루틴을 실행하는 스레드는 동일한 스레드가 아니므로 트랜잭션이 전파되지 않는다.
  • 선언적 트랜잭션 관리 + 일반 함수 + 논블로킹 R2DBC 라이브러리
    • 장점: 선언적 트랜잭션 관리가 동작한다.
    • 단점: R2DBC 라이브러리를 통한 IO 작업을 일반 함수에서 실행하기 위해 스레드를 블로킹해야 한다. 일반 함수 내에서 코루틴을 실행하려면 새로운 코루틴 생성이 필요하다.
  • 선언적 트랜잭션 관리 + 중단(suspend) 함수 + 블로킹 JDBC 라이브러리
    • 장점: 함수 내에서 블로킹 IO 작업을 코루틴을 사용하여 처리함으로써 IO 작업이 현재 스레드를 블로킹하지 않도록 만들 수 있다. 함수 내에서 코루틴을 실행하기 위해 새로운 코루틴 생성이 필요하지 않다.
    • 단점: 선언적 트랜잭션 관리가 동작하지 않는다. 프로그래밍 방식으로 트랜잭션 관리할 경우 블로킹 IO 작업을 코루틴을 사용하여 처리할 수 없다.
  • 선언적 트랜잭션 관리 + 중단 함수 + 논블로킹 R2DBC 라이브러리
    • 장점: 선언적 트랜잭션 관리가 동작한다. IO 작업은 현재 스레드를 블로킹하지 않는다. 함수 내에서 코루틴을 실행하기 위해 새로운 코루틴 생성이 필요하지 않다.

결론적으로 JDBC 블로킹 라이브러리를 사용하는 경우 트랜잭션 관리를 수행하려면 스레드를 블로킹할 수 밖에 없다. 트랜잭션 관리가 필요하지 않다면 코루틴을 사용하여 블로킹 호출을 별도의 스레드에서 수행하도록 하여 스레드 블로킹을 막을 수 있다.

JDBC 블로킹 라이브러리를 사용 시 트랜잭션을 관리하기 위해서는 트랜잭션 정보를 현재 스레드에 저장하는 과정이 필요한데, 트랜잭션 내 작업 실행 도중 스레드가 변경되면 트랜잭션 정보가 유지되지 않는다.

참고