Project : RainMind 개발일지 - 3 [트랜잭션의 원리와 동작]
코드를 짜다 보니 fastAPI와 spring의 트랜잭션 선언 방식과 원리가 비슷한 것 같기도 하고 다른것 같기도 하고… 정리를 한번 해보려 한다.
느끼는 바는 딱 아래 한문장으로 요약 가능할 것 같다.
“Spring은 트랜잭션을 선언적으로 숨기고, FastAPI는 트랜잭션을 명시적으로 드러낸다. 책임의 소재가 누구에게 있느냐가 다르다”
FastAPI
내가 코드에서 사용한 방식처럼
async with session.begin():
...
과 같이 트랜잭션 범위를 코드 블록으로 지정하는 방식이 있고, 같은 session을 사용하지만 수동으로 commit / rollback 하는 방식인
try:
session.add(...)
session.add(...)
...
session.commit()
except:
session.rollback()
이런 방식이 있다. 또 데코레이터(어노테이션)를 이용한 (@transactional) 등의 명시적/선언적 방식이 존재하는 듯 하다.
Spring
Spring에서는 명령형 / 선언형의 두가지 방식으로 트랜잭션을 사용 가능하다.
- Programmatic Management(명령형)
어떤 트랜잭션을 수행할지, 그 내용을 직접 지시한다. Spring에 있는 TransactionalTemplate 이라는 것을 사용하는데, 아래처럼 클래스 내부 변수로 가져와서 lambda 안의 내용을 트랜잭션으로 실행한다.
@Service
class Service(....) {
private val transactionTemplate = TransactionTemplate(....)
fun something() = trnasactionTemplate.execute<...L> {
// do transactional operation
}
}
- Declarative management(선언형)
그냥 이 로직을 트랜잭션 하겠다 라고 선언한다. 구체적인 과정은 시스템이 알아서 처리한다.
@Transactional
fun update(...){
// do transactional operation
}
두 프레임워크가 비슷한 방식의 트랜잭션을 사용한다. 동일하게 명령형 / 선언형 방식으로 트랜잭션을 사용하도록 한 것 같으며, 명령형 방식의 경우 트랜잭션 코드블록을 직접 정의할 수 있다는 것, 선언형 방식의 경우 무엇을 트랜잭션으로 실행할 것인지를 정의하는 것이 비슷한 듯 하다.
하지만 Spring의 경우, 트랜잭션을 위해 프록시 객체를 사용하지만 FastAPI의 경우 명시적으로 트랜잭션의 경계를 보여주는 방식을 채택한다.
Spring Transaction - Proxy
스프링의 트랜잭션은 Spring AOP(Aspect Oriented Programming) 기반으로 동작을 하는데, 즉 트랜잭션과 같은 공통 관심사 기능을 비즈니스 로직과 분리하여 구현하는 것을 말한다.
이 ‘분리해서 구현’하는 과정은, 스프링에서는 proxy(가짜) 기반으로 동작을 하도록 되어있는데, 구체적으로 어떤 클래스의 proxy를 만들어 호출을 감싸도록 한다.
예를 들어 Service라는 클래스가 있으면, Service$$01303###$라는 프록시 클래스가 생겨나서 Service를 호출한다. 즉, Service_proxy 호출 -> 트랜잭션 시작 -> Service_org 호출 -> Service_org 리턴 -> 트랜잭션 commit / rollback -> 프록시 리턴
과 같은 방식이 된다.
이 모든 것이 @Transactional 한방에 된다는 것이다. 그러나 설계를 이렇게 해놓은 탓에 맹점이 좀 있다. 트랜잭션이 안 열릴수도 있다.
만약 같은 객체 안에 a와 b라는 함수가 있다고 할 때 a는 @Transactional이 있고, b는 없으며 b가 a를 내부적으로 호출한다고 하자. 그럼 b에서 트랜잭션은 안열린다. 왜냐하면 프록시를 거쳐서 트랜잭션을 열어야 하는데 같은 객체 내부라 프록시를 거치지 않기 때문이다(…)
FastAPI - Explicit Way
내 코드 안에 (async) with … : 라는 경계가 눈에 보이므로 명시적으로 트랜잭션을 사용한다고 할 수 있다. 장점으로는 트랜잭션의 범위를 코드 작성자가 완벽하게 제어할 수 있지만, 불필요한 중복 코드가 많아지긴 한다.
스프링에도 비슷한 방식이 있는 만큼, 스프링에서도 장단점이 비슷하다. 트랜잭션의 범위를 세밀하게 조정할 수 있지만, 트랜잭션이 비즈니스 로직에 침투하는 형태가 되어 동작 예상도 좀 어렵고 코드 가독성도 선언형 방식에 비해 많이 떨어진다.
특이한게 FastAPI에도 @transactional 같은 것이 있는데, 이 동작이 spring의 proxy와 유사하면서도 좀 다르다.
FastAPI - @transactional
비즈니스 로직 위에 @transactional 데코레이션을 추가하면, python은 실행 시점에 해당 코드 대신 wrapper를 실행하여 wrapper에서 session.begin()과 같은 코드를 삽입한 것처럼 트랜잭션을 열고, wrapper 안에서 비즈니스 로직을 실행하는 방식이다.
이 과정은 spring의 proxy처럼 가짜 객체를 만들고 빈을 어쩌고… 하는 과정이 전혀 없기 때문에 spring 보다는 매우 단순하다. 구체적으로 차이를 짚어보면, Spring은 proxy를 통해 객체 자체를 감싸는 방식, FastAPI의 데코레이터는 함수 앞뒤에 추가적인 코드를 실행하는 느낌(Wrapper)이다.
이렇다보니 FastAPI에서는 트랜잭션의 Propagation을 시스템이 Spring만큼 정교하게 대신 처리해주지 않는다고 한다(좀 더 정확히 말하자면, Spring은 프레임워크 차원에서 Synchronication manager와 같은 객체가 스레드마다 트랜잭션 상태를 정교하게 관리해주며, FastAPI는 이러한 기본 제공되는 전역 관리자가 없다).
실제로 코드 짤때 실수로 session.begin()을 두번 열었는데 바로 에러가 났었다…
- 추가: FastAPI 또한 Nested Transaction을 제공해준다고 한다. 그러나 Spring만큼 정교하게는 아니고, begin_nested()와 같은 함수로 가능하게 만들어주는 대신 개발자가 직접 트랜잭션 포인트를 관리해야 한다.
트랜잭션의 Propagation 개념을 또 한번 정리하고 가면 좋을 듯 하다.
Propagation of transaction
Spring에서는 시스템이 propagation을 정교하게 제어해준다. 즉, nested transaction 같은 구조도 정책만 설정해주면 가능하며 원하지 않으면 해제도 가능하다.
-
PROPAGATION_REQUIRED(default 설정): 트랜잭션 내부에서 트랜잭션을 또 작업할 때, 기존에 열려있던 트랜잭션이 있으면 새로 안열고 그대로 이어 쓴다. 즉, 두 작업은 운명 공동체가 되는 것이다.
-
PROPAGATION_REQUIRES_NEW: 항상 트랜잭션을 새로 여는 옵션이다. 따라서, inner / outer 트랜잭션 두 개가 있을때, inner가 롤백되었다고 해서 outer가 롤백되지는 않는다.
두 옵션 모두 @Transactional(propagation = Propagation.REQUIRED 혹은 Propagation.REQUIRES_NEW)로 설정할 수 있다.
두 방식 모두 경험해보니, 사실 처음에는 @Transactional 하나로 해결되는 문제인 줄 알았는데 아니라는 것을 깨달았다. 트랜잭션을 어디서 어떻게 끝낼지, 어떤 방식을 사용할지는 사용하는 프레임워크의 철학과 프로젝트를 진행하는 본인이 어떤 특성 때문에 어떤 프레임워크를 선택하는 지에 따라 달라지는 것 같다.
우리 프로젝트에서는, 명시적으로 Schedule과 Outbox를 묶어줄 필요가 있었기 때문에, 즉 알람의 정확성이 요구되었기 때문에 명시적으로 session.begin() 방식을 사용하였다.
이렇게 트랜잭션 파트를 Spring과 FastAPI에서 어떻게 구현하고 어떤 방식으로 다루고 있는지 정리해보았다.