JPA - save()는 언제 필요할까?

StrayCat·2026년 3월 23일

JPA Dirty Checking과 @Transactional — save() 없이도 DB에 반영될까?

JPA를 사용하면서 헷갈렸던 부분을 정리한다.
"find()로 조회한 엔티티를 수정했는데, save()를 안 불렀는데도 DB에 반영됐어요. 이거 정상인가요?"

결론부터 말하면, 조건에 따라 정상이다. 그리고 그 조건을 이해하는 것이 JPA를 제대로 다루는 핵심 중 하나다.


1. 핵심 개념 먼저 — 영속성 컨텍스트(Persistence Context)

JPA가 "자동으로 DB에 반영해 준다"는 동작의 뒤에는 영속성 컨텍스트라는 개념이 있다.

영속성 컨텍스트는 쉽게 말하면 엔티티 객체를 관리하는 메모리 상의 공간이다. EntityManager가 이 컨텍스트와 연결되어 있고, JPA는 여기에 올라간 객체들의 상태를 추적한다.

비유하자면, 영속성 컨텍스트는 "회사 장부"다. 장부에 등록된 물건(엔티티)은 누가 가격을 바꿨는지, 수량이 달라졌는지를 알아서 기록해 둔다.

트랜잭션이 커밋되는 시점에, 장부는 "처음과 달라진 항목"을 자동으로 DB에 반영한다. 이것이 Dirty Checking(변경 감지) 이다.


2. 엔티티의 생명주기 상태 (Entity Lifecycle)

JPA 엔티티는 항상 아래 4가지 상태 중 하나에 있다. 이 상태를 이해해야 "언제 자동 반영되고, 언제 안 되는지"가 명확해진다.

상태설명
Transient (비영속)new 키워드로 막 생성된 객체. 영속성 컨텍스트와 무관.
Managed (영속)영속성 컨텍스트에 등록되어 JPA가 추적 중인 상태. 변경 시 자동 반영 대상.
Detached (준영속)한때 영속 상태였지만, 트랜잭션 종료 등으로 컨텍스트에서 분리된 상태. 추적 안 됨.
Removed (삭제)remove() 호출로 삭제 예약된 상태. 커밋 시 DELETE 실행.

핵심은 이것이다. Dirty Checking은 오직 Managed 상태의 엔티티에만 동작한다.


3. Dirty Checking(변경 감지)의 동작 원리

Hibernate가 엔티티를 조회할 때, 스냅샷(snapshot) 을 함께 저장한다.
트랜잭션 커밋 직전 flush() 시점에, 현재 객체 상태와 스냅샷을 비교한다.
달라진 필드가 있으면 자동으로 UPDATE SQL을 생성하고 실행한다.

[DB에서 조회] → 스냅샷 저장 + 영속 상태 전환
      ↓
[필드 수정]  → 현재 상태 변경
      ↓
[flush 시점] → 스냅샷 vs 현재 상태 비교
      ↓
[변경 감지]  → UPDATE SQL 자동 생성 → DB 반영

이 덕분에 아래처럼 save() 없이도 UPDATE가 동작한다.

@Transactional  // 트랜잭션 시작
public void updateUsername(Long userId, String newName) {

    // find() → DB에서 조회 → 영속 상태(Managed)로 전환됨
    User user = userRepository.findById(userId)
                              .orElseThrow();

    // 필드 수정 → Dirty(변경됨) 상태로 마킹됨
    user.setUsername(newName);

    // save() 호출 없음.
    // 트랜잭션 종료 시점에 flush → Dirty Checking → UPDATE SQL 자동 실행
}

위 코드에서 save()를 추가로 호출해도 동작에는 문제가 없다. 다만, 이미 영속 상태인 엔티티에 save()를 부르면 내부적으로 merge()가 실행되어 불필요한 오버헤드가 생길 수 있다. 따라서 조회 후 수정 패턴에서는 save() 생략이 더 올바른 방식이다.


4. save()가 꼭 필요한 경우

Dirty Checking이 만능은 아니다. 아래 경우에는 반드시 save() (또는 persist())를 명시해야 한다.

4-1. 새 엔티티 INSERT

@Transactional
public void createUser(String name) {

    // new 키워드로 생성 → Transient(비영속) 상태
    User user = new User();
    user.setUsername(name);

    // 영속성 컨텍스트에 등록(persist)이 필요
    // save() 내부에서 entityManager.persist() 호출
    userRepository.save(user); // ← 없으면 INSERT 안 됨
}

4-2. 준영속(Detached) 엔티티를 수정하는 경우

트랜잭션 밖에서 조회한 엔티티, 또는 detach()된 엔티티는 변경 감지 대상이 아니다.

// @Transactional 없음 → 트랜잭션 없이 조회
public User getUser(Long id) {
    return userRepository.findById(id).orElseThrow();
    // 메서드 종료 → 트랜잭션 없으므로 즉시 Detached 상태
}

public void updateUser() {
    User user = getUser(1L); // Detached 상태로 반환됨

    user.setUsername("새 이름"); // 변경해도 추적 안 됨

    // Dirty Checking 동작하지 않으므로, 재영속화를 위해 save() 필요
    // save() 내부에서 entityManager.merge() 호출
    userRepository.save(user); // ← 없으면 DB 반영 안 됨
}

Spring Data JPA의 save() 메서드는 엔티티가 새것이면 persist(), 기존 것이면 merge()를 호출한다. 이 구분은 주로 id 값의 null 여부로 판단한다.


5. @Transactional(readOnly = true) 주의사항

읽기 전용 트랜잭션으로 설정된 메서드 안에서는 Dirty Checking이 동작하지 않는다. "안 될 수 있다"가 아니라, 설계상 동작하지 않도록 되어 있다.

이유는 두 가지다.

  1. Spring이 Hibernate에게 FlushMode.MANUAL로 설정하도록 힌트를 전달한다 → 자동 flush 비활성화
  2. session.setDefaultReadOnly(true)를 통해 로드된 엔티티를 읽기 전용으로 마킹한다 → 스냅샷 자체를 저장하지 않아 메모리 절약
@Transactional(readOnly = true)  // flush 비활성화됨
public void tryUpdate(Long id) {

    User user = userRepository.findById(id).orElseThrow();
    // readOnly = true → 스냅샷 저장 안 함, Dirty Checking 안 함

    user.setUsername("바꾸려 해도");
    // 트랜잭션이 끝나도 DB에 반영되지 않음 (설계된 동작)
}

readOnly = true의 장점은 성능이다. 스냅샷을 저장하지 않고, Dirty Checking 비교 과정도 생략되므로 조회 전용 메서드에서는 이쪽이 더 효율적이다.

단, 실제 개발 환경에서 Service 클래스 레벨에 @Transactional(readOnly = true)를 달고, 쓰기 메서드에만 @Transactional을 오버라이드하는 패턴이 자주 쓰인다. 이 방식은 Spring Data JPA의 SimpleJpaRepository 구현 방식과도 일치한다.


6. 케이스별 정리

케이스save() 필요 여부이유
@Transactional 안에서 조회 후 수정❌ 불필요Dirty Checking 동작
새 엔티티 생성 후 INSERT✅ 필요비영속 → 영속 전환 필요
준영속(Detached) 엔티티 수정✅ 필요변경 감지 대상 아님
readOnly = true 안에서 수정❌ 반영 안 됨flush 비활성화

7. 실제 개발 환경에서의 팁

  • 조회 후 수정 패턴에서는 save() 호출을 생략하는 것이 더 올바르다. 불필요한 merge() 호출을 막을 수 있다.
  • 서비스 메서드의 트랜잭션 범위를 명확히 설계하라. 트랜잭션이 끝난 뒤에 반환된 엔티티를 수정하면 반영되지 않는다. 이를 인지하지 못하면 버그가 숨어들기 좋다.
  • 레거시(기존에 구축된 오래된 시스템) 코드베이스에서는 준영속 엔티티를 곳곳에서 주고받는 경우가 많다. 이 때는 명시적인 save() 혹은 merge() 호출이 필요한 위치를 꼼꼼히 파악해야 한다.
  • Spring Boot 3.x / Spring Framework 6.x 기준으로도 위 동작은 동일하다. Jakarta Persistence(구 javax.persistence) 네임스페이스로 전환된 것이 주요 변경점이지만, Dirty Checking과 트랜잭션의 핵심 메커니즘은 동일하게 유지된다.

마치며

save() 하나가 있고 없고의 차이가 단순해 보이지만, 그 내부에는 영속성 컨텍스트, 엔티티 생명주기, flush 타이밍이라는 꽤 깊은 개념들이 맞물려 있다.

JPA를 쓴다면 이 흐름을 머릿속에 그릴 수 있어야 한다. 그래야 "왜 반영이 안 됐지?"라는 상황에서 빠르게 원인을 찾을 수 있다.


참고 자료

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글