지난 글에서 JPA의 기본 구조와 엔티티 매핑, 그리고 @Transactional의 존재를 처음 만났다. 그런데 코드를 짜다 보면 이런 의문이 생긴다.
product.decreaseStock()만 호출했는데 왜 DB에 UPDATE가 날아가지?save()를 안 불렀는데 어떻게 변경사항이 반영되는 거지?이번 글에서는 이 세 가지 질문에 답하면서, JPA의 핵심 엔진인 영속성 컨텍스트가 어떻게 동작하는지 파헤쳐본다.
트랜잭션은 여러 DB 작업을 하나의 논리적 단위로 묶는 안전장치다. 계좌 이체를 예로 들면 이해가 쉽다.
A 계좌 -1만원 → 성공
B 계좌 +1만원 → 시스템 장애 발생
트랜잭션이 없다면 A의 돈은 사라졌는데 B는 못 받는 최악의 상황이 생긴다. 트랜잭션은 이걸 막기 위해 "두 작업이 모두 성공하거나, 하나라도 실패하면 전부 없던 일로 만든다"는 것을 보장한다.
이 보장을 ACID 원칙이라고 부른다.
| 원칙 | 의미 |
|---|---|
| Atomicity (원자성) | 전부 성공 아니면 전부 실패 |
| Consistency (일관성) | 트랜잭션 전후 데이터 규칙이 유지됨 |
| Isolation (격리성) | 트랜잭션끼리 서로 간섭하지 않음 |
| Durability (지속성) | 커밋된 결과는 영구 저장됨 |
Spring에서는 @Transactional 어노테이션 하나로 트랜잭션을 관리한다. 내부적으로는 Proxy라는 가상의 대리 객체가 메서드 앞뒤를 감싸서 동작한다.
연예인과 매니저로 비유하면 이렇다. 연예인(실제 Service 객체)한테 직접 연락하는 게 아니라, 매니저(Proxy)를 통해 연락한다. 매니저는 일정 시작 전에 "트랜잭션 시작", 일정이 끝나면 "Commit", 문제가 생기면 "전부 취소(Rollback)"를 처리한다.
// Spring이 내부적으로 하는 일
try {
beginTransaction(); // 트랜잭션 시작
placeOrder(request); // 실제 메서드 실행
commit(); // 성공 → 커밋
} catch (RuntimeException e) {
rollback(); // 실패 → 롤백
}
한 가지 주의할 점이 있다. @Transactional은 기본적으로 RuntimeException에만 롤백한다. Checked Exception(IOException 등)은 롤백하지 않는다.
// RuntimeException → 롤백 O
@Transactional
public void placeOrder() {
throw new IllegalArgumentException("재고 부족"); // 롤백됨
}
// Checked Exception → 기본적으로 롤백 X
@Transactional
public void placeOrder() throws IOException {
throw new IOException("파일 없음"); // 롤백 안됨!
}
// Checked Exception도 롤백하려면
@Transactional(rollbackFor = Exception.class)
public void placeOrder() throws IOException {
throw new IOException("파일 없음"); // 롤백됨
}
영속성 컨텍스트는 애플리케이션과 DB 사이의 중간 저장소다. 마트에서 장을 볼 때 물건을 집을 때마다 계산대로 달려가지 않고 장바구니에 담아뒀다가 한 번에 계산하는 것과 같다.
엔티티는 영속성 컨텍스트와의 관계에 따라 4가지 상태를 가진다.
비영속 (Transient) → 장바구니에 안 담긴 상태 ( new Product() )
영속 (Managed) → 장바구니에 담긴 상태 ( findById(), persist() )
준영속 (Detached) → 장바구니에서 꺼낸 상태 ( detach(), close() )
삭제 (Removed) → "이거 빼주세요" 한 상태 ( remove() )
중요한 건 모든 마법은 영속 상태에서만 일어난다는 점이다.
영속성 컨텍스트에 엔티티가 들어오는 순간, JPA는 엔티티 말고 Snapshot(원본 복사본)을 하나 더 저장해둔다.
[영속성 컨텍스트]
└ [1차 캐시]
└ Key: 1L
├ Entity: Product(id=1, stock=100) ← 코드에서 다루는 객체
└ Snapshot: {stock=100} ← JPA가 들고 있는 원본 사진
트랜잭션이 끝나는 시점에 JPA는 Entity와 Snapshot을 필드별로 비교한다. 달라진 점이 있으면 UPDATE 쿼리를 자동으로 생성한다. 이게 Dirty Checking이다.
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId).get();
// Entity: stock=90, Snapshot: stock=100 → 차이 감지
product.decreaseStock(quantity);
// save() 호출 없음. Dirty Checking이 알아서 UPDATE 생성
}
Before / After로 비교하면 차이가 명확하다.
// Before: JPA 없이 직접 UPDATE
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId).get();
product.decreaseStock(quantity);
productRepository.save(product); // 직접 save() 호출 필요
}
// After: Dirty Checking 활용
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId).get();
product.decreaseStock(quantity);
// save() 불필요. 트랜잭션 종료 시 자동 반영
}
Flush는 영속성 컨텍스트의 변경 내용을 DB에 전송하는 작업이다. 여기서 중요한 구분이 있다.
Flush ≠ Commit
Flush는 쿼리를 DB에 "전송"만 한다. Commit은 그 변경을 DB에 "최종 확정"한다. Flush 이후에도 Rollback이 일어나면 모든 변경은 취소된다.
Flush가 일어나는 시점은 세 가지다.
1. 트랜잭션 Commit 직전 → Spring이 자동 호출
2. JPQL 쿼리 실행 직전 → JPA가 자동 호출
3. entityManager.flush() → 개발자가 직접 호출
2번이 특히 중요하다. Flush 없이 JPQL을 실행하면 데이터 불일치가 생긴다.
@Transactional
public void createAndFind() {
Product product = new Product("키보드");
productRepository.save(product);
// 이 시점: 쓰기 지연 저장소에만 있고 DB에는 없음
// JPQL 실행 직전 → Flush 자동 호출 → DB에 INSERT 먼저 반영
List<Product> products = productRepository.findAllByName("키보드");
// Flush 없었다면? DB에 키보드가 없어서 빈 리스트 반환
}
3번은 DB 프로시저나 트리거를 호출해야 할 때처럼, 트랜잭션이 끝나기 전에 DB 반영이 필요한 특수한 상황에서 사용한다.
@Transactional
public void saveAndCallProcedure(Product product) {
productRepository.save(product);
productRepository.flush(); // DB에 즉시 INSERT 전송
// 이제 DB에 데이터가 있으니 프로시저 정상 실행
storedProcedureRepository.executeProcess(product.getId());
}
이번 글의 핵심은 딱 하나다. 영속 상태의 엔티티는 JPA가 직접 관리하기 때문에, 개발자는 객체의 상태만 바꾸면 Dirty Checking → Flush → Commit 흐름이 자동으로 처리된다.