17 KiB
DB Transaction 기준
1. 목적
이 문서는 Spring + JPA/Hibernate + PostgreSQL 환경에서 트랜잭션을 어떤 기준으로 시작하고 끝낼지 정의한다.
이 문서의 목표는 다음과 같다.
- 트랜잭션 경계를 repository 호출 단위가 아니라 use case의 일관성 경계로 잡는다
- @Transactional의 기본 의미, rollback 규칙, readOnly 의미를 정확히 해석한다
- long-running transaction, self-invocation, REQUIRES_NEW 남용 같은 신뢰도 낮은 패턴을 줄인다
- 실제 DB 트랜잭션과 JPA persistence context의 관계를 혼동하지 않게 만든다
Spring Data JPA는 보통 여러 repository 호출을 묶는 facade/service가 비-CRUD 작업의 transactional boundary를 정의한다고 설명하고, Hibernate는 물리적 DB 트랜잭션은 가능한 한 짧아야 한다고 설명한다.
2. 근거 수준
- Official: Spring Framework / Spring Data JPA / Hibernate / PostgreSQL 공식 문서에서 직접 확인되는 내용
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
이번 문서는 Spring Framework의 @Transactional, rollback rules, propagation semantics, Spring Data JPA의 transaction boundary guidance, Hibernate의 transaction / flush 문서, PostgreSQL의 transaction isolation 문서를 기준으로 작성한다.
3. 기본 원칙
3.1 트랜잭션은 “코드 묶음”이 아니라 “일관성 경계”다
트랜잭션은 여러 repository 메서드를 그냥 감싸는 편의 기능이 아니라, 하나의 use case가 모두 반영되거나 모두 취소되어야 하는 일관성 경계를 표현해야 한다. Spring Data JPA도 여러 repository 호출을 묶는 facade/service가 transactional boundary를 정의한다고 설명한다.
3.2 물리적 DB 트랜잭션은 가능한 짧게 유지한다
Hibernate는 DB 트랜잭션은 lock contention을 줄이기 위해 가능한 한 짧아야 하고, end-user think time 동안 열어 두지 말라고 설명한다. 또한 Spring의 imperative @Transactional은 현재 실행 스레드에 바인딩되며, 새로 시작한 스레드로는 전파되지 않는다. 프로젝트 기본 원칙은 “트랜잭션은 짧고, 한 스레드 안에서, 필요한 DB 작업만 감싼다”이다.
3.3 트랜잭션은 애플리케이션 서비스 메서드에서 시작하는 것을 기본으로 한다
Spring Data JPA는 transaction boundary를 non-CRUD 작업의 시작점, 즉 facade/service 쪽에서 선언하는 방식을 권장한다. 따라서 프로젝트 기본값은 controller도 아니고 repository도 아니라 application service / use case 메서드에서 트랜잭션을 선언하는 것이다.
4. 트랜잭션 경계 위치 기준
4.1 기본 위치는 application service / use case 메서드
하나의 유스케이스가 여러 repository, domain operation, event publication 준비를 묶는다면 그 외곽 application service 메서드가 트랜잭션 경계가 된다. Spring Data JPA도 여러 repository 호출을 묶는 facade/service가 transactional boundary를 정의한다고 설명한다.
프로젝트 규칙:
- command use case는 application service 메서드에 @Transactional
- 복수 repository를 묶는 read use case도 필요하면 service 메서드에 @Transactional(readOnly = true)
- repository는 데이터 접근 구현 세부이며, 외곽 일관성 경계의 기본 소유자가 아니다
4.2 controller에 트랜잭션을 두지 않는다
Hibernate는 물리적 트랜잭션을 짧게 유지하라고 설명하고, Spring은 트랜잭션이 thread-bound execution 안에서 동작한다고 설명한다. controller에 트랜잭션을 두면 request parsing, 외부 호출, response mapping까지 DB 트랜잭션이 불필요하게 길어지기 쉽다. 프로젝트 기본값은 controller에서 입력을 해석하고, service가 트랜잭션을 시작하는 구조다.
4.3 domain model 내부에서 트랜잭션을 시작하지 않는다
Spring의 선언적 트랜잭션은 AOP proxy 기반이며, 서비스 계층 객체에 적용되는 것이 일반적이다. transaction boundary는 인프라 관심사이므로 entity / value object / domain service가 직접 시작점이 되지 않게 한다. 이는 Spring의 proxy 기반 transaction model과 계층 분리에 맞는 프로젝트 권장안이다.
5. @Transactional 적용 기준
5.1 기본 propagation은 REQUIRED
Spring은 PROPAGATION_REQUIRED가 현재 스레드의 공통적인 call stack arrangement에서 좋은 기본값이며, outer service-level transaction에 참여한다고 설명한다. 프로젝트 기본 propagation도 REQUIRED다. 같은 유스케이스 안에서 호출되는 내부 service/repository는 기본적으로 하나의 물리 트랜잭션에 참여한다고 본다.
5.2 isolation / timeout 선언은 “새로 시작하는 트랜잭션”에서만 의미가 있다
Spring @Transactional javadoc은 isolation과 timeout이 REQUIRED 또는 REQUIRES_NEW처럼 새로 시작된 트랜잭션에만 적용된다고 설명한다. 기존 트랜잭션에 참여하는 inner scope에서는 outer scope의 특성을 따르며, 기본적으로 local declaration이 조용히 무시될 수 있다.
프로젝트 규칙:
- isolation/timeout은 outer use case 메서드에서 선언하는 것을 기본으로 한다
- inner helper 메서드에서 isolation/timeout을 바꿔도 실제로 반영되지 않을 수 있음을 전제로 한다
- 상세 기준은 별도 isolation.md, lock.md에서 다룬다
5.3 REQUIRES_NEW는 독립 커밋이 정말 필요할 때만 사용한다
Spring은 REQUIRES_NEW가 항상 독립적인 물리 트랜잭션을 만들고, outer transaction과 무관하게 commit/rollback 되며, inner transaction의 lock도 완료 시 즉시 해제된다고 설명한다. 따라서 REQUIRES_NEW는 단순 편의 옵션이 아니라 “부분 커밋을 허용하겠다”는 강한 의미다.
프로젝트 규칙:
- 기본값은 REQUIRED
- REQUIRES_NEW는 outer rollback과 무관하게 남아야 하는 audit 기록, 독립 보상 기록 등 정말 독립 커밋이 필요한 경우에만 예외적으로 허용
- “현재 트랜잭션과 충돌하니 일단 REQUIRES_NEW” 같은 사용은 금지한다
6. read/write use case 기준
6.1 쓰기 use case는 기본적으로 @Transactional
여러 row 변경, aggregate 변경, domain event 적재, 상태 전이 같은 쓰기 use case는 기본적으로 하나의 트랜잭션 안에서 처리해야 한다. Spring Data JPA가 service/facade에서 transactional boundary를 정의하라고 권장하는 이유도 이런 일관성 단위를 서비스 메서드에서 닫기 위해서다.
6.2 읽기 use case는 필요 시 @Transactional(readOnly = true)
Spring의 readOnly는 최적화 힌트이며, Spring Data JPA는 대부분의 query method는 읽기이므로 readOnly=true가 일반적이라고 설명한다. 또한 Hibernate와 함께 쓰면 flush mode를 NEVER로 두어 dirty check를 건너뛰는 최적화가 가능하다고 설명한다.
프로젝트 규칙:
- 단순 read query는 readOnly=true를 기본 검토
- 여러 query를 묶거나, lazy association 초기화가 service 안에서 필요하거나, read model 조립이 필요한 경우 service 메서드에 @Transactional(readOnly = true)
- 상세 조회/목록 조회의 fetch plan은 별도 fetch/N+1 문서 기준을 따른다
6.3 readOnly=true는 쓰기 차단 장치가 아니다
Spring @Transactional javadoc은 readOnly가 실제 트랜잭션 서브시스템에 대한 힌트일 뿐이며, 쓰기 시도를 반드시 실패시키는 것은 아니라고 설명한다. Spring Data JPA도 readOnly는 조작 query를 막는 검사 장치가 아니라고 명시한다.
프로젝트 규칙:
- readOnly=true를 “절대 write 불가”라고 해석하지 않는다
- readOnly 트랜잭션 안에서 managed entity를 수정하는 코드를 금지한다
- write를 막고 싶다면 설계를 분리하고, 코드 리뷰로 read path와 write path를 명확히 구분한다
7. rollback 기준
7.1 기본 rollback 규칙을 정확히 이해한다
Spring은 기본적으로 RuntimeException과 Error에서만 rollback하고, checked exception은 기본 설정에서 rollback하지 않는다고 설명한다. 또한 Spring이 권장하는 rollback 신호는 현재 트랜잭션 안에서 예외를 던지는 것이라고 설명한다.
프로젝트 규칙:
- unchecked exception이면 기본 rollback
- checked exception은 기본적으로 rollback되지 않음을 전제로 한다
- checked exception이 곧 유스케이스 실패라면 rollbackFor를 명시하거나 예외 계층을 재설계한다
7.2 rollbackFor = Exception.class를 기본값으로 두지 않는다
Spring은 rollback 규칙을 세밀하게 지정할 수 있다고 설명하지만, 기본 규칙은 unchecked exception 기반이다. 모든 checked exception까지 일괄 rollback 대상으로 바꾸면 “복구 가능한 예외”와 “트랜잭션 자체를 취소해야 하는 예외” 구분이 흐려진다. 이 문서의 프로젝트 권장안은 정말 rollback이 필요한 checked exception만 좁게 지정하는 것이다.
7.3 rollback 여부는 예외 의미와 맞아야 한다
트랜잭션 rollback 규칙은 기술 설정이 아니라 business outcome을 반영해야 한다. “이 예외가 발생하면 지금까지의 상태 변경을 모두 되돌려야 하는가?”를 기준으로 정해야 한다. Spring이 rollback rules를 예외 타입별로 선언적으로 제어하게 한 이유도 그 의미를 코드 밖에서 명확히 표현하기 위해서다.
8. flush / commit 기준
8.1 flush와 commit을 같은 것으로 보지 않는다
Hibernate는 flush를 “persistence context 상태를 DB와 동기화하는 과정”으로 정의하고, 변경사항은 먼저 메모리에 반영된 뒤 flush 시 INSERT/UPDATE/DELETE SQL로 변환된다고 설명한다. commit은 flush 이후 실제 트랜잭션 완료와 durability까지 포함하는 더 큰 경계다.
8.2 SQL은 commit 직전에만 나간다고 가정하지 않는다
Hibernate는 기본 AUTO flush 모드에서 flush가 transaction commit 직전뿐 아니라, queued entity action과 겹치는 JPQL/HQL query 실행 전, 그리고 EntityManager의 native query 실행 전에도 발생할 수 있다고 설명한다. 따라서 “아직 commit 안 했으니 SQL도 안 나갔을 것”이라는 가정은 안전하지 않다.
프로젝트 규칙:
- 같은 트랜잭션 안에서 조회 query가 flush를 유발할 수 있음을 전제로 한다
- write use case 중간에 불필요한 query를 많이 넣지 않는다
- flush timing에 의존하는 로직보다 명시적 순서와 명확한 트랜잭션 설계를 우선한다
9. PostgreSQL 관점의 기본 해석
9.1 기본 isolation은 PostgreSQL 기본값을 따른다
Spring @Transactional의 isolation 기본값은 Isolation.DEFAULT이고, PostgreSQL의 기본 isolation level은 Read Committed다. PostgreSQL은 Read Uncommitted를 요청해도 내부적으로 Read Committed처럼 동작한다고 설명한다.
9.2 Read Committed에서는 한 트랜잭션 안의 두 SELECT가 서로 다른 결과를 볼 수 있다
PostgreSQL은 Read Committed에서 각 SELECT가 query 시작 시점의 snapshot을 보기 때문에, 같은 트랜잭션 안에서도 두 번의 SELECT가 서로 다른 결과를 볼 수 있다고 설명한다. 따라서 “한 트랜잭션 안이면 읽기 결과가 항상 고정된다”는 가정은 틀릴 수 있다. 상세 기준은 별도 isolation 문서에서 다룬다.
프로젝트 규칙:
- 일반 서비스 기본값은 DB 기본 isolation을 따른다
- stronger isolation이 정말 필요할 때만 명시적으로 올린다
- isolation 문제를 transaction 길이나 REQUIRES_NEW 남용으로 우회하지 않는다
10. 프록시 / self-invocation 기준
10.1 @Transactional은 프록시를 통과한 외부 호출에서만 기본적으로 동작한다
Spring 공식 문서는 proxy mode가 기본이고, proxy를 통과하는 external method call만 interception 대상이라고 설명한다. 따라서 같은 클래스 안의 self-invocation은 호출된 메서드에 @Transactional이 있어도 실제 트랜잭션이 적용되지 않는다.
프로젝트 규칙:
- transactional method는 같은 클래스 내부에서 자기 자신이 호출하는 구조로 설계하지 않는다
- helper 분리가 필요하면 별도 bean으로 분리하거나 outer service에서 경계를 다시 설계한다
- @PostConstruct 같은 초기화 코드에서 트랜잭션을 기대하지 않는다
10.2 트랜잭션 애노테이션은 구체 클래스 메서드에 두는 것을 기본으로 한다
Spring은 구체 클래스 메서드에 @Transactional을 두는 것을 권장하고, interface 선언에만 의존하면 AspectJ mode 등에서 무시될 수 있다고 설명한다. 또한 proxy mode에서는 보통 public method 중심으로 사용하는 것이 자연스럽다.
프로젝트 규칙:
- 기본은 concrete service class의 public method에 @Transactional
- interface에만 선언해 두고 동작을 기대하지 않는다
- method visibility와 proxy 종류 차이를 이해하지 못한 채 비공개 메서드에 남용하지 않는다
11. 외부 호출 / 비동기 / after-commit 기준
11.1 트랜잭션 안에서 원격 호출이나 오래 걸리는 작업을 길게 잡지 않는다
Hibernate는 DB 트랜잭션을 길게 유지하지 말라고 설명하고, Spring은 imperative 트랜잭션이 현재 스레드에만 바인딩되며 새 스레드에는 전파되지 않는다고 설명한다. 또한 Spring은 transaction context가 remote call로 전파되지 않는다고 설명한다. 따라서 프로젝트 기본값은 DB 작업과 외부 네트워크 호출을 무분별하게 한 트랜잭션 안에 길게 묶지 않는 것이다.
프로젝트 규칙:
- 외부 HTTP/API 호출, 메일 발송, 파일 업로드, 오래 걸리는 연산을 DB 트랜잭션 안에 오래 물고 있지 않는다
- 새 스레드나 @Async성 작업이 같은 트랜잭션에 참여할 것이라고 기대하지 않는다
- 외부 연동 결과 반영은 별도 integration / outbox 기준과 함께 설계한다
11.2 commit 이후에만 일어나야 하는 후속 작업은 after-commit에 연결한다
Spring은 @TransactionalEventListener가 AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION 같은 phase를 지원한다고 설명한다. 프로젝트에서는 “DB commit이 성공한 뒤에만 발행되어야 하는 후속 처리”는 inline side effect로 섞기보다 after-commit 시점과 연결하는 것을 기본 검토한다.
12. 문서 경계
이 문서는 트랜잭션 경계, rollback, readOnly, propagation의 기본 해석을 다룬다.
다음 내용은 별도 문서에서 확장한다.
- isolation level 상세
- optimistic/pessimistic locking
- 동시성 충돌 처리
- outbox / transactional event / integration retry 설계
- migration 실행 트랜잭션 정책
13. 금지 규칙
다음은 기본 금지다.
- controller에 @Transactional을 두는 것
- repository 개별 메서드가 outer use case boundary를 소유하게 두는 것
- readOnly=true를 쓰기 방지 장치처럼 오해하는 것
- rollbackFor = Exception.class를 습관적으로 선언하는 것
- helper 메서드 충돌을 피하려고 무심코 REQUIRES_NEW를 붙이는 것
- self-invocation 구조에서 @Transactional이 동작할 것이라 기대하는 것
- 트랜잭션 안에서 새 스레드를 시작하고 같은 트랜잭션 참여를 기대하는 것
- long-running external call을 DB 트랜잭션 안에 오래 포함시키는 것
- flush와 commit을 같은 것으로 보는 것
이 금지 규칙은 Spring의 proxy/rollback/propagation semantics, Hibernate의 short transaction 및 flush semantics, PostgreSQL의 isolation 기본 동작을 실무 운영 기준으로 압축한 것이다.
14. 체크리스트
다음 질문에 “예”로 답할 수 있어야 한다.
- 이 메서드는 하나의 use case 일관성 경계를 대표하는가?
- 트랜잭션이 controller가 아니라 application service에서 시작되는가?
- transaction scope가 불필요하게 길지 않은가?
- readOnly=true를 최적화 힌트로만 해석하고 있는가?
- checked exception rollback 여부를 명시적으로 판단했는가?
- REQUIRES_NEW가 정말 독립 커밋 의미와 맞는가?
- flush가 commit 전에 일어날 수 있음을 고려했는가?
- self-invocation 때문에 @Transactional이 무시되지 않는가?
- 새 스레드/비동기 작업이 같은 트랜잭션에 참여한다고 가정하지 않는가?
- isolation/lock 요구를 transaction boundary 문제와 분리해서 생각하고 있는가?