이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 긴 트랜잭션은 왜 DB 성능과 운영 안정성을 해치는가?
idle in transaction은 왜 위험한가?- 대량 작업과 리포트 조회는 어떻게 나눠야 하는가?
개요
긴 트랜잭션은 백엔드 DB 장애의 조용한 원인이다. 트랜잭션이 오래 열리면 커넥션을 오래 점유하고, lock을 오래 유지하며, PostgreSQL 같은 MVCC DB에서는 오래된 row version 정리를 방해할 수 있다.
개발 환경에서는 잘 보이지 않지만 운영에서는 pool 고갈, lock wait, vacuum 지연, replica lag로 이어질 수 있다.
원리
트랜잭션은 시작부터 커밋 또는 롤백까지 하나의 경계다.
@Transactional
public void processLargeJob() {
List<JobItem> items = jobRepository.findAllPending();
for (JobItem item : items) {
process(item);
}
}이 코드는 모든 pending 작업을 하나의 트랜잭션으로 처리한다. 작업이 오래 걸리면 커넥션과 변경 lock이 오래 유지된다. 실패하면 롤백 범위도 커진다.
운영에서는 다음 쿼리로 긴 트랜잭션을 먼저 확인할 수 있다.
SELECT pid,
state,
now() - xact_start AS tx_age,
wait_event_type,
wait_event,
left(query, 200) AS query_sample
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY tx_age DESC;긴 트랜잭션이 항상 나쁜 것은 아니지만, 사용자 API와 같은 primary에서 오래 유지되면 pool, lock, vacuum, replica lag의 공통 원인이 될 수 있다.
작은 배치로 나누기
대량 작업은 작은 트랜잭션으로 나눈다.
public void processLargeJob() {
while (true) {
List<Long> ids = jobRepository.findNextPendingIds(1000);
if (ids.isEmpty()) {
break;
}
jobChunkProcessor.process(ids);
}
}
@Service
public class JobChunkProcessor {
@Transactional
public void process(List<Long> ids) {
List<JobItem> items = jobRepository.findAllByIdForUpdate(ids);
items.forEach(JobItem::complete);
}
}Chunk 단위로 나누면 lock 보유 시간, rollback 범위, WAL, replica lag를 줄일 수 있다. 진행률 저장과 재시작 가능성도 좋아진다.
chunk 작업은 “몇 건씩”만 정하면 부족하다. batch size, sleep 간격, lock timeout, 실패 재시작 기준, 이미 처리한 row를 다시 처리하지 않는 멱등 조건을 함께 둔다.
idle in transaction
idle in transaction은 트랜잭션을 열어둔 채 아무 작업도 하지 않는 상태다. 예를 들어 DB client에서 BEGIN 후 커밋하지 않고 창을 닫지 않는 경우, 애플리케이션 코드가 커넥션을 잡고 대기하는 경우에 생긴다.
PostgreSQL에서는 다음처럼 확인할 수 있다.
SELECT pid, state, now() - xact_start AS tx_age, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY tx_age DESC;오래된 idle transaction은 vacuum을 방해하고 lock을 유지할 수 있다.
개발자 로컬 DB client에서 열린 transaction도 운영에서는 사고가 될 수 있다. 운영 접속 권한이 있는 조직일수록 autocommit 설정, read-only 계정, 세션 timeout, bastion 감사 로그 같은 운영 장치가 필요하다.
PostgreSQL에서는 세션 단위로 다음 같은 안전장치를 둘 수 있다.
SET idle_in_transaction_session_timeout = '30s';
SET statement_timeout = '5min';
SET lock_timeout = '3s';애플리케이션 전체 기본값으로 둘지, 운영 콘솔 계정에만 둘지, migration 세션에는 다르게 둘지는 팀 정책이다. 중요한 것은 “사람이 실수로 열어둔 트랜잭션”도 운영 장애 원인이 될 수 있음을 설정과 권한으로 줄이는 것이다.
리포트와 스트리밍
대량 리포트 API는 트랜잭션과 커넥션 예산을 먼저 정해야 한다.
@Transactional(readOnly = true)
public void downloadCsv(OutputStream out) {
orderRepository.streamAll().forEach(order -> {
csvWriter.write(out, order);
});
}스트리밍은 메모리를 줄일 수 있지만 트랜잭션과 커넥션을 오래 열 수 있다. 운영 DB에서 긴 리포트 다운로드를 직접 수행하면 사용자 API와 커넥션을 경쟁한다. 별도 replica, 별도 batch, 사전 생성, object storage 다운로드 방식 등을 검토한다.
사용자 요청에서 CSV를 즉시 생성해야 한다면 제한을 둔다. 최대 기간, 최대 row 수, timeout, 비동기 요청 전환 기준, 다운로드 파일 만료 시간을 제품 정책으로 만든다. “메모리를 아끼기 위해 stream”이 “DB 커넥션을 20분 점유”로 바뀌면 더 큰 장애가 된다.
실전 팁
- 사용자 요청 트랜잭션은 짧게 끝낸다.
- 대량 변경은 chunk 단위로 나누고 진행률을 기록한다.
- 긴 읽기 작업은 read replica나 분석 DB를 검토한다.
idle in transaction알림을 둔다.- 트랜잭션 timeout을 설정한다.
- batch에는 batch size, 중단 기준, 재시작 기준, progress table을 둔다.
- CSV/export는 primary 동기 API보다 replica, async job, object storage 전달을 우선 검토한다.
- transaction timeout, statement timeout, lock timeout, API timeout의 순서를 문서화한다.
- 운영 콘솔과 애플리케이션 계정에
idle in transaction을 줄이는 timeout 정책을 둔다.
위험 신호!
- 하나의 트랜잭션에서 수십만 row를 업데이트한다.
- CSV 다운로드가 운영 DB 커넥션을 오래 잡는다.
- 트랜잭션 안에서 sleep, 외부 API, 파일 I/O가 있다.
idle in transaction이 몇 분 이상 유지된다.- 실패 시 롤백이 너무 오래 걸린다.
- stream 조회가 readOnly transaction 안에서 수십 분 동안 connection을 잡는다.
- batch가 실패 후 어디까지 처리됐는지 몰라 처음부터 다시 실행한다.
- timeout이 없어 장애 시 사람이 직접 세션을 찾아 죽여야 한다.
- 운영 콘솔 접속이 autocommit/off 상태인지 모른 채 UPDATE를 실행한다.
확인 질문
- 긴 트랜잭션이 connection pool에 미치는 영향은 무엇인가?
- 커넥션을 오래 점유해 다른 요청이 커넥션을 기다리게 만들고 pool 고갈을 유발할 수 있다.
- 대량 작업을 chunk로 나누는 이유는 무엇인가?
- lock 보유 시간, rollback 범위, WAL과 I/O 부하, replica lag를 줄이고 재시작 가능성을 높이기 위해서다.
idle in transaction이 위험한 이유는 무엇인가?- 아무 작업을 하지 않아도 트랜잭션이 열려 있어 lock이나 MVCC 정리를 방해할 수 있기 때문이다.
- 스트리밍 조회가 메모리 측면에서는 좋아도 DB 운영에 위험할 수 있는 이유는 무엇인가?
- 애플리케이션 메모리는 줄일 수 있지만 DB 커넥션과 트랜잭션 snapshot을 오래 유지해 pool 고갈, vacuum 지연, 사용자 API 지연을 만들 수 있기 때문이다.
- 운영 DB 접속 계정에 idle transaction timeout을 두는 이유는 무엇인가?
- 실수로 열린 트랜잭션이 lock과 MVCC snapshot을 오래 유지해 장애로 이어지는 것을 자동으로 줄이기 위해서다.