[Spring] 트랜잭션과 영속성 컨텍스트 — JPA가 save() 없이도 DB를 업데이트하는 비밀

Raha·2026년 3월 22일

Spring

목록 보기
6/7

들어가며

지난 글에서 JPA의 기본 구조와 엔티티 매핑, 그리고 @Transactional의 존재를 처음 만났다. 그런데 코드를 짜다 보면 이런 의문이 생긴다.

  • product.decreaseStock()만 호출했는데 왜 DB에 UPDATE가 날아가지?
  • save()를 안 불렀는데 어떻게 변경사항이 반영되는 거지?
  • Flush와 Commit은 뭐가 다른 거지?

이번 글에서는 이 세 가지 질문에 답하면서, JPA의 핵심 엔진인 영속성 컨텍스트가 어떻게 동작하는지 파헤쳐본다.


1. 트랜잭션 — "전부 아니면 전무"

트랜잭션은 여러 DB 작업을 하나의 논리적 단위로 묶는 안전장치다. 계좌 이체를 예로 들면 이해가 쉽다.

A 계좌 -1만원  →  성공
B 계좌 +1만원  →  시스템 장애 발생

트랜잭션이 없다면 A의 돈은 사라졌는데 B는 못 받는 최악의 상황이 생긴다. 트랜잭션은 이걸 막기 위해 "두 작업이 모두 성공하거나, 하나라도 실패하면 전부 없던 일로 만든다"는 것을 보장한다.

이 보장을 ACID 원칙이라고 부른다.

원칙의미
Atomicity (원자성)전부 성공 아니면 전부 실패
Consistency (일관성)트랜잭션 전후 데이터 규칙이 유지됨
Isolation (격리성)트랜잭션끼리 서로 간섭하지 않음
Durability (지속성)커밋된 결과는 영구 저장됨

2. @Transactional — Proxy가 앞뒤를 감싼다

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("파일 없음"); // 롤백됨
}

3. 영속성 컨텍스트 — JPA의 장바구니

영속성 컨텍스트는 애플리케이션과 DB 사이의 중간 저장소다. 마트에서 장을 볼 때 물건을 집을 때마다 계산대로 달려가지 않고 장바구니에 담아뒀다가 한 번에 계산하는 것과 같다.

엔티티는 영속성 컨텍스트와의 관계에 따라 4가지 상태를 가진다.

비영속 (Transient)  → 장바구니에 안 담긴 상태  ( new Product() )
영속   (Managed)    → 장바구니에 담긴 상태     ( findById(), persist() )
준영속 (Detached)   → 장바구니에서 꺼낸 상태   ( detach(), close() )
삭제   (Removed)    → "이거 빼주세요" 한 상태   ( remove() )

중요한 건 모든 마법은 영속 상태에서만 일어난다는 점이다.


4. Dirty Checking — save() 없이도 UPDATE가 날아가는 이유

영속성 컨텍스트에 엔티티가 들어오는 순간, 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() 불필요. 트랜잭션 종료 시 자동 반영
}

5. Flush — 변경사항을 DB에 밀어넣는 동기화 작업

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 흐름이 자동으로 처리된다.

profile
Backend Developer | Aspiring Full-Stack Enthusiast

0개의 댓글