락과 트랜잭션 경계 분리: 좌석 예약을 구현하며 배운 실무 패턴
락과 트랜잭션 경계 분리: 직접 겪어보며 정리한 실무 패턴
좌석 예약 기능을 만들면서 락과 트랜잭션의 경계를 어디에 둬야 하는지 한참 고민했다.
처음에는 @Transactional 메서드 안에서 분산 락을 잡고 푸는 식으로 짰다. 그런데 코드 리뷰를 받고 구조를 다시 뜯어보니, 이 방식이 왜 문제가 되는지 그리고 보통은 어떤 패턴을 쓰는지가 눈에 들어왔다.
1. 문제의 시작: 트랜잭션 안에서 락을 잡아도 될까?
처음 구현은 대략 이런 순서였다.
@Transactional메서드 진입- 분산 락 획득
- 좌석 조회 및 예약 처리
- 락 해제
- 트랜잭션 커밋
얼핏 문제없어 보였다. 그런데 Spring 트랜잭션이 프록시 기반으로 동작한다는 사실을 떠올리자 흐름이 다르게 보이기 시작했다.
실제 실행 순서를 따라가 보면 이렇다.
- 프록시 진입과 동시에 트랜잭션 시작
- 그 이후에 락 획득 시도
- 메서드 종료 시점에 트랜잭션 커밋
결국 락을 기다리는 동안 트랜잭션은 이미 열려 있었고, 커밋되기도 전에 락이 풀릴 수 있는 구조였다. 내가 그리던 흐름과는 분명 달랐다.
2. 왜 이 구조가 문제가 될 수 있을까?
2-1. 트랜잭션은 생각보다 비싼 자원
트랜잭션이 열리면 DB 커넥션을 점유하고 MVCC 스냅샷을 유지한다. 여기에 락 대기 시간까지 얹히면 커넥션을 붙잡는 시간이 길어진다. 그만큼 전체 처리량은 떨어지고, 타임아웃이나 데드락 위험도 함께 올라간다.
그래서 실무에서는 자주 이런 원칙을 이야기한다.
트랜잭션은 최대한 짧게 유지해야 한다
이 기준으로 보면 락을 기다리는 시간까지 트랜잭션에 밀어 넣는 건 좋은 선택이 아니었다.
2-2. 분산 락과 트랜잭션의 책임은 다르다
두 개념은 맡은 역할이 서로 다르다. 트랜잭션은 데이터 무결성을 보장하고, 분산 락은 비즈니스 레벨의 동시성을 제어한다. 책임이 다르면 코드 구조도 나뉘는 게 자연스럽다.
1
2
3
4
5
[락을 담당하는 계층]
↓
[트랜잭션을 담당하는 계층]
↓
[도메인 로직]
이건 취향의 문제가 아니라, 실무에서 반복적으로 쓰이는 패턴에 가깝다는 걸 알게 됐다.
3. 실무에서 많이 쓰이는 구조
3-1. Facade / UseCase + TxService 패턴
내가 택한 구조는 이렇다.
1
2
3
Facade / UseCase (분산 락 획득 및 해제)
↓
TxService (@Transactional)
락은 트랜잭션 바깥에서 잡고, 트랜잭션은 그 락 안에서 열어 최대한 짧게 끝낸다. 커밋이 끝난 뒤에야 락을 푼다.
이 방식은 예약, 결제, 재고 도메인에서 실제로 많이 쓴다. 코드 리뷰를 할 때도 의도를 읽어내기 쉽다.
3-2. AOP 기반 분산 락은 왜 조심해야 할까?
어노테이션 기반으로 락을 처리하는 방식도 있다.