자바 아키텍처·Spring 학습 노트 목차

트랜잭션 — 전부 되거나, 전부 안 되거나

계좌에서 돈을 빼고 → 넣는 두 작업 중간에 서버가 죽으면 어떻게 될까? 돈은 빠졌는데 들어가지 않은, 있어서는 안 될 상태가 남는다. 트랜잭션은 여러 작업을 하나로 묶어 전부 성공하거나 전부 취소되게(ACID) 만들어 이런 일을 막는다. Spring은 이걸 @Transactional 한 줄로 선언하게 해 주는데, 그 편리함 뒤의 프록시 동작롤백·전파·격리 규칙을 모르면 트랜잭션이 조용히 안 걸린 채 돌고 만다. 그 규칙들을 DB 커넥션 수준까지 파고들어 본다.

선언만 하면 알아서 — @Transactional

@Transactional
public void transfer(Long from, Long to, long amount) {
    accounts.withdraw(from, amount);   // 둘 중 하나라도 실패하면
    accounts.deposit(to, amount);      // 전부 롤백
}

코드에 begin·commit·rollback을 직접 쓰지 않는다. @Transactional을 붙이면, 02편에서 본 AOP 프록시가 메서드 진입 시 트랜잭션을 시작(getConnection()setAutoCommit(false))하고, 정상 종료 시 커밋, 예외 발생 시 롤백한다. 바로 프록시 기반이라는 이 사실에서 02편의 함정이 그대로 따라온다 — 자기 호출(this.method())에는 트랜잭션이 걸리지 않고, 기본 프록시 모드에서는 public 메서드에만 적용된다. 이 점을 머리에 두고, 이제 트랜잭션이 언제 롤백되는지를 보자.

가장 많이 데는 함정 — 검사 예외는 롤백되지 않는다

@Transactional
public void save() throws IOException {   // 체크 예외
    repository.insert(...);
    throw new IOException("...");          // 롤백 안 됨! 커밋된다
}

Spring의 기본 롤백 규칙은 RuntimeExceptionError에서만 롤백하고, 검사 예외(checked)에서는 오히려 커밋한다. 그래서 위처럼 IOException을 던지면 insert가 그대로 저장돼 의도와 어긋난다. 왜 이런 기본일까? EJB 시절의 관례를 따른 것으로, 검사 예외는 비즈니스적으로 처리 가능한 상황으로 보았기 때문이다. 동의하든 안 하든 기본이 그러하니 알고 써야 한다 — 검사 예외에도 롤백하려면 @Transactional(rollbackFor = Exception.class)로 규칙을 바꾸고, 반대로 특정 런타임 예외를 롤백에서 빼려면 noRollbackFor를 쓴다.

트랜잭션 안에서 또 트랜잭션을 부르면 — 전파의 내부

이미 트랜잭션 중인 메서드가 또 다른 @Transactional 메서드를 부르면 어떻게 될까? 이걸 정하는 게 *전파(propagation)*인데, 그 차이를 물리 커넥션 수준에서 보면 또렷하다. 기본인 REQUIRED진행 중인 트랜잭션이 있으면 합류하고 없으면 새로 시작해, 안쪽과 바깥이 같은 하나의 물리 트랜잭션을 공유한다 — 그래서 안쪽이 롤백하면 바깥도 함께 롤백된다.

REQUIRES_NEW는 다르다. *기존 트랜잭션을 잠시 보류(suspend)*하고 완전히 독립된 새 물리 트랜잭션을 연다 — 내부적으로 getConnection()한 번 더 해서 커넥션 두 개setAutoCommit(false)를 걸고, 둘을 따로 커밋/롤백한다. 그래서 내부가 롤백돼도 바깥과 분리된다(곧 볼 감사 로그가 이 덕을 본다). 반면 NESTED새 물리 트랜잭션이 아니라, *바깥과 같은 커넥션에 세이브포인트(connection.setSavepoint())*를 찍는 것이다 — 중첩 블록만 그 세이브포인트로 부분 롤백할 수 있되, 최종 커밋은 바깥이 끝날 때 함께 일어난다. (출처: Baeldung — Propagation & Isolation, Spring docs — Propagation.) 이 밖에 있으면 참여하되 없어도 그만인 SUPPORTS, 반드시 기존 트랜잭션 안이어야 하는 MANDATORY, 기존 트랜잭션이 있으면 예외인 NEVER, 기존 것을 보류하고 트랜잭션 없이 도는 NOT_SUPPORTED가 상황별로 있다.

이 차이가 실무에서 빛나는 대표가 감사 로그다. 주 작업이 롤백되더라도 반드시 남겨야 하는 기록은 REQUIRES_NEW독립 트랜잭션에 두면, 주 트랜잭션이 통째로 롤백돼도 로그만은 커밋된다. 다만 위에서 봤듯 커넥션을 하나 더 쓰므로, 남발하면 — 05편의 커넥션 풀을 압박한다(바깥 트랜잭션이 보류된 채 두 커넥션을 동시에 점유하기 때문이다).

동시에 도는 트랜잭션은 서로를 얼마나 보나 — 격리

여러 트랜잭션이 동시에 돌면 읽기 이상 현상이 생긴다. 격리 수준은 이걸 어디까지 막을지를 정한다.

다이어그램 로딩 중…

이상 현상은 단계적이다. 커밋되지 않은 남의 변경을 읽는 Dirty Read는 READ COMMITTED부터 막히고, 같은 행을 두 번 읽었는데 값이 바뀐 Non-Repeatable Read는 REPEATABLE READ부터, 같은 조건으로 다시 조회했는데 행이 생기거나 사라진 Phantom Read는 SERIALIZABLE에서야 (표준상) 막힌다.

그런데 DB가 이걸 어떻게 구현하느냐를 알면 한층 깊어진다. 옛날 방식은 읽기에도 락을 거는 것이었지만, 현대 DB는 대부분 **MVCC(다중 버전 동시성 제어)**를 쓴다 — 읽는 쪽은 트랜잭션 시작 시점의 일관된 스냅샷을 보고, 쓰는 쪽은 새 버전을 만들어, 읽기와 쓰기가 서로를 거의 막지 않는다. PostgreSQL은 행에 xmin/xmax 버전 정보를 붙여 옛 버전을 테이블 안에 쌓고(그래서 주기적 VACUUM으로 청소한다), MySQL InnoDB는 Undo 로그에 변경 이력을 두고 next-key 락을 더해 REPEATABLE READ에서도 팬텀을 막는다(그래서 InnoDB의 RR은 표준보다 강하다). 기본값도 갈린다 — PostgreSQL은 READ COMMITTED(동시성 우선), MySQL은 REPEATABLE READ(정합 우선). (출처: PostgreSQL MVCC Internals, Percona — InnoDB isolation modes.) 어느 쪽이든 엄격할수록 좋기만 한 게 아니다 — 격리를 올릴수록 락·검증·버전 추적 비용이 늘어 동시성과 처리량이 떨어진다. 그래서 대개 기본값을 두고, 정말 필요한 곳만 올린다.

읽기 전용과 동시 갱신

조회 전용 메서드에는 @Transactional(readOnly = true)를 붙이면 — 05편에서 봤듯 변경 감지 스냅샷과 flush를 끄고 일부는 읽기 복제본으로 보내 최적화된다. 한편 "읽고 → 수정"하는 사이 다른 트랜잭션이 먼저 갱신해 내 갱신이 덮어써지는 *갱신 손실(lost update)*은 격리만으로는 부족할 수 있어, *낙관적 락(@Version)*이나 비관적 락으로 막는다(데이터 경합이 드물면 낙관적, 잦으면 비관적).

실무 — 짧게, 그리고 바깥으로

마지막으로 트랜잭션을 건강하게 쓰는 핵심 두 가지다. 트랜잭션은 짧게 유지해야 한다 — 트랜잭션이 길면 그동안 DB 커넥션과 락(혹은 MVCC의 옛 버전)을 붙잡아 풀 고갈과 교착, VACUUM 지연을 부른다(05편의 풀 사이징과 이어진다). 그리고 트랜잭션 안에서 외부 API를 호출하지 마라 — 느린 외부 호출이 트랜잭션과 커넥션을 통째로 붙잡는다. 외부 호출은 트랜잭션 바깥으로 빼거나, 커밋 이후에 처리하고 싶으면 *@TransactionalEventListener(AFTER_COMMIT)*로 트랜잭션이 성공적으로 커밋된 뒤에만 돌게 한다(롤백되면 후속 작업도 안 나간다). 바로 그 커밋 이후 이벤트가 다음 편 메시징으로 이어진다.

정리하면, @Transactional프록시 기반의 선언적 트랜잭션이라 시작·커밋·롤백을 자동으로 해 주지만 자기 호출에는 안 걸린다. 롤백은 기본적으로 런타임 예외에서만 일어나니 검사 예외는 rollbackFor로 챙기고, 전파는 물리 커넥션 수준에서 합류(REQUIRED)·독립(REQUIRES_NEW, 커넥션 2개)·세이브포인트(NESTED)로 갈리며(감사 로그는 독립), 격리는 MVCC로 구현되어 정확성과 동시성을 저울질해 정한다(PG는 RC, MySQL은 RR 기본). 그리고 무엇보다 트랜잭션은 짧게, 외부 호출은 바깥으로, 후속은 커밋 이후로.

Spring Data JPA — 리포지토리·페이징·프로젝션테스트 — 슬라이스·MockMvc·Testcontainers