트랜잭션은 짧게 — 경계가 곧 비용이다
커넥션 부족, 테이블 팽창, 락 경합이 한꺼번에 나서 하나씩 따로 조사했는데, 알고 보니 원인은 트랜잭션이 길다는 것 하나였어요. 경계를 어디에 두고 어떻게 줄이는지 정리했어요.
트랜잭션 범위를 넓게 잡아 뒀어요. 요청이 시작될 때 열고 끝날 때 닫는 방식이요. 어디서 예외가 나도 전부 롤백되니 안전하다고 생각했어요.
기능은 문제가 없었어요. 그런데 트래픽이 오르면서 커넥션이 모자라고, 테이블이 부풀고, 락 경합이 늘어요. 각각을 따로 조사했는데 원인이 다 달라 보였어요.
알고 보니 원인은 하나였어요. 트랜잭션이 길다는 것.
지금까지 본 문제들 — 커넥션 점유, 청소 차단, DDL 대기, 락 보유 — 이 전부 트랜잭션 길이라는 한 변수에 걸려 있었어요. 증상을 하나씩 고치느라 몇 주를 쓴 뒤에야 알았습니다.
왜 그런가
트랜잭션을 여는 순간 세 가지가 동시에 잡혀요.
첫째, 커넥션. 트랜잭션이 끝날 때까지 커넥션 하나가 묶여 있어요. 처리량이 커넥션 수를 점유 시간으로 나눈 값이 되니, 길이가 곧 처리량의 역수예요.
둘째, 스냅샷. 열린 트랜잭션은 자기 시점의 스냅샷을 붙잡고 있고, 그게 청소의 경계를 정해요. 긴 트랜잭션 하나가 DB 전체의 회수를 막아요.
셋째, 락. 트랜잭션 안에서 잠근 것은 커밋 전까지 안 풀려요. 쓰기 하나가 트랜잭션 맨 앞에 있으면 그 락이 트랜잭션 끝까지 유지돼요.
그래서 트랜잭션 길이는 단일 지표가 아니라 여러 문제의 공통 원인이에요. 개별 증상을 하나씩 고치는 것보다 길이를 줄이는 게 훨씬 효율적인 이유예요.
특히 중요한 게 락의 순서예요. 트랜잭션 안에서 쓰기를 언제 하느냐에 따라 락 보유 시간이 달라져요. 맨 앞에서 잠그고 뒤에서 오래 계산하면 그 시간 내내 락이 유지되지만, 계산을 먼저 하고 마지막에 쓰면 락 보유가 짧아집니다. 같은 코드, 순서만 바꿔서 경합이 크게 줄어요.
코드로 보면
필요한 만큼만 열고, 쓰기는 마지막에:
@Transactional
void handle() {
var data = repo.find(); // 조회
repo.lockAndUpdate(); // 여기서 잠근다
var result = heavyCompute(data); // 여기서 3초
repo.save(result);
} // 락을 3초 넘게 쥔다
// 1) 읽기와 계산은 트랜잭션 밖에서
var data = repo.find();
var result = heavyCompute(data);
// 2) 쓰기만 짧은 트랜잭션으로
@Transactional
void write(result) {
repo.lockAndUpdate();
repo.save(result);
} // 락 보유가 밀리초 단위✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
트랜잭션을 짧게 만드는 구체적인 방법이 몇 가지 있어요.
경계를 서비스 계층에 두세요. 요청 전체를 감싸면 뷰 렌더링이나 직렬화까지 트랜잭션 안에 들어가요. 프레임워크가 요청 범위로 컨텍스트를 유지하는 설정을 기본으로 두는 경우가 있는데, 이걸 끄는 것만으로 크게 좋아집니다.
읽기와 쓰기를 분리하세요. 조회는 트랜잭션 없이 하고, 계산도 밖에서 하고, 쓰기만 짧게 묶어요. 다만 읽은 값을 근거로 쓰는 흐름이면 앞서 본 잃어버린 갱신이 생기니, 조건부 갱신이나 버전 검사를 함께 써야 해요.
쓰기 순서를 조정하세요. 락을 잡는 쓰기를 트랜잭션 마지막에 두면 보유 시간이 최소가 돼요. 여러 행을 잠근다면 순서를 고정해 교착을 줄이고요.
그리고 원자성이 정말 필요한 범위를 다시 물어야 해요. "다 같이 롤백돼야 한다"고 생각한 것들이 실제로는 각각 독립적인 경우가 많아요. 주문 저장과 통계 갱신이 반드시 원자적이어야 할까요? 통계는 나중에 다시 계산할 수 있다면 분리하는 게 맞아요.
측정 방법도 알아 둘 필요가 있어요. 트랜잭션 길이의 분포를 보세요. 평균이 아니라 최댓값과 상위 백분위수를요. 대부분 짧은데 몇 개가 아주 긴 경우가 가장 흔하고, 그 몇 개가 문제를 만듭니다.
마지막으로, 짧게 만들라는 게 잘게 쪼개라는 뜻은 아니에요. 원자적이어야 할 것을 쪼개면 정합성이 깨져요. 줄일 것은 트랜잭션 안에서 하는 불필요한 일이지 필요한 범위가 아닙니다.
처방
- 트랜잭션 경계를 서비스 계층에 두고 요청 전체를 감싸지 마세요. 요청 범위 컨텍스트 유지 설정을 끄세요.
- 트랜잭션 안에서 네트워크와 디스크를 건드리지 마세요. 외부 호출, 파일 처리, 메일 발송을 밖으로 빼세요.
- 무거운 계산을 밖으로 옮기세요. 읽고 계산하고 쓰는 흐름이면 계산을 트랜잭션 밖에서 하고, 쓸 때 조건부 갱신이나 버전 검사로 정합성을 지키세요.
- 락을 잡는 쓰기를 마지막에 두세요. 순서만 바꿔도 락 보유 시간이 크게 줄어요.
- 원자성이 필요한 범위를 다시 확인하세요. 함께 롤백되어야 한다고 믿었던 것들이 실제로는 독립적인 경우가 많습니다.
- 트랜잭션 길이를 지표로 보세요. 평균이 아니라 최댓값과 상위 백분위수를요. 임계값을 넘는 트랜잭션에 알림을 걸면 새로 생긴 문제를 바로 잡을 수 있어요.
- 타임아웃을 걸어 두세요. idle in transaction 타임아웃은 방치된 트랜잭션을 끊고, 문장 타임아웃은 폭주하는 쿼리를 끊어요. 둘 다 안전망입니다.
지금 확인해 보세요
트랜잭션 길이의 분포를 본다:
· 지금 열려 있는 트랜잭션의 나이 — 최댓값이 중요하다
SELECT max(now() - xact_start) AS longest,
count(*) FILTER (
WHERE now() - xact_start > interval '1 second') AS over_1s
FROM pg_stat_activity WHERE xact_start IS NOT NULL;
· 상태별로 나눠 본다
SELECT state, count(*),
max(now() - xact_start) AS longest
FROM pg_stat_activity
WHERE xact_start IS NOT NULL GROUP BY state;
idle in transaction 이 길면 안에서 뭔가 기다리는 중
· 안전망 — 둘 다 걸어 둔다
idle_in_transaction_session_timeout = '5min'
statement_timeout = '30s'
· 읽기 전용도 길면 청소를 막는다
리포트·분석 세션의 트랜잭션 나이도 같이 본다핵심 한 줄트랜잭션 길이는 여러 문제의 공통 원인이다. 하나만 줄여도 여럿이 낫는다.