MySQL 공부 7 - JPA 엔티티 생명주기 완전 분석

Chu Sang Yoon·2026년 3월 20일

MySQL

목록 보기
7/9

MySQL 공부 7 - JPA 엔티티 생명주기 분석

6편에서 N+1 문제와 해결 전략을 다뤘다. 이번 편에서는 JPA 엔티티가 어떤 상태를 거치며 관리되는지, 그리고 각 상태 전환 시 내부에서 무슨 일이 일어나는지를 파고든다.


엔티티의 4가지 상태

    new User()
        │
        ▼
┌──────────────────┐
│   비영속 (New)    │  ← 순수한 자바 객체, 영속성 컨텍스트와 무관
└────────┬─────────┘
         │ persist()
         ▼
┌──────────────────┐
│   영속 (Managed)  │  ← 영속성 컨텍스트가 관리, 1차 캐시 + 변경 감지 활성화
└────┬────┬────────┘
     │    │
     │    │ detach() / clear() / close()
     │    ▼
     │  ┌──────────────────┐
     │  │ 준영속 (Detached) │  ← 영속성 컨텍스트에서 분리, 변경 감지 비활성화
     │  └────────┬─────────┘
     │           │ merge()
     │           └──────────▶ 영속 상태로 복귀
     │
     │ remove()
     ▼
┌──────────────────┐
│  삭제 (Removed)   │  ← 삭제 예약 상태, flush 시 DELETE 실행
└──────────────────┘

비영속 (Transient)

순수한 자바 객체다. JPA가 전혀 모르는 상태고, 영속성 컨텍스트와 무관하다.

@Test
public void transientState() {
    User user = new User();
    user.setName("Alice");
    user.setEmail("alice@test.com");

    // 1차 캐시에 없음
    // 변경 감지 X
    // DB와 무관
    System.out.println("DB에는 아무 영향 없음");
}

영속 (Managed)

영속성 컨텍스트가 관리하는 상태다. 1차 캐시에 저장되고 변경 감지가 활성화된다.

영속 상태로 만드는 3가지 방법

persist()

@Test
public void persistState() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    User user = new User();
    user.setName("Alice");

    em.persist(user);
    // SQL은 아직 실행 안 됨! (쓰기 지연)

    boolean contains = em.contains(user);
    System.out.println("영속 상태? " + contains);  // true

    User found = em.find(User.class, user.getId());
    System.out.println(user == found);  // true (1차 캐시에서 같은 객체 반환)

    em.getTransaction().commit();
    // ← 여기서 INSERT SQL 실행
    em.close();
}

find()

@Test
public void findMakesManaged() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    User user = em.find(User.class, 1L);
    // DB에서 조회 → 자동으로 영속 상태

    System.out.println(em.contains(user));  // true

    user.setName("Updated Name");
    // em.update(user) 호출 없이도 변경 감지가 동작

    em.getTransaction().commit();
    // ← UPDATE SQL 자동 실행
    em.close();
}

JPQL 조회

@Test
public void jpqlMakesManaged() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    List<User> users = em.createQuery(
        "SELECT u FROM User u", User.class
    ).getResultList();

    for (User user : users) {
        System.out.println(em.contains(user));  // true (전부 영속 상태)
    }

    em.getTransaction().commit();
    em.close();
}

준영속 (Detached)

한 번은 영속 상태였던 엔티티가 영속성 컨텍스트에서 분리된 상태다. 식별자(ID)는 갖고 있지만 변경 감지가 비활성화된다.

준영속 상태로 만드는 3가지 방법

detach() — 특정 엔티티만 분리

@Test
public void detachState() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    User user = em.find(User.class, 1L);
    System.out.println(em.contains(user));  // true

    em.detach(user);

    System.out.println(em.contains(user));  // false

    user.setName("New Name");

    em.getTransaction().commit();
    // UPDATE SQL 실행 안 됨!
    em.close();
}

clear() — 영속성 컨텍스트 전체 초기화

@Test
public void clearAll() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    User user1 = em.find(User.class, 1L);
    User user2 = em.find(User.class, 2L);

    em.clear();

    System.out.println(em.contains(user1));  // false
    System.out.println(em.contains(user2));  // false

    em.getTransaction().commit();
    em.close();
}

close() — EntityManager 종료

User user;

{
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();
    user = em.find(User.class, 1L);
    em.getTransaction().commit();
    em.close();  // ← EntityManager 종료
}

// user는 이제 준영속 상태
user.setName("New Name");
// 변경 감지 안 됨 (DB 업데이트 안 됨)

준영속 상태의 문제점

LazyInitializationException

User user;

{
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();
    user = em.find(User.class, 1L);
    // user.posts는 Proxy (초기화 안 됨)
    em.getTransaction().commit();
    em.close();  // Session 종료
}

int size = user.getPosts().size();
// 💥 LazyInitializationException!
// Session이 없으니 DB에서 가져올 수 없음

이 외에도 준영속 상태가 되면:

  • Lazy 로딩 불가
  • 변경 감지 비활성화
  • merge() 해야 영속 상태 복귀 가능 (merge 비용 발생)
  • 연관 객체 그래프 추적 복잡도 증가

준영속 상태에서 Proxy가 그대로 남아있는 이유

트랜잭션이 끝나도 Proxy 객체가 사라지지 않고 남아있다. 왜 Hibernate는 Proxy를 없애거나 null로 바꾸지 않을까?

새 객체로 교체하면 안 되는 이유

User u = someService.getUser();
em.close();  // detach

// Hibernate가 Proxy를 새 객체로 바꿔버린다면?
u.posts != u.posts  // 객체 참조가 달라짐!
  • JPA가 보장하는 엔티티 동일성이 깨진다
  • 컬렉션 안의 엔티티 전부가 바뀌면서 객체 그래프가 무너진다
  • 서비스 레이어에서 객체 비교가 이상해진다
  • Hibernate가 관리하는 객체와 애플리케이션이 참조하는 객체가 달라진다

null로 바꾸면 안 되는 이유

user.getPosts()null?

매핑상 List<Post>인데 null이 되면 NPE가 폭발한다. 개발자 입장에서 왜 null이지? 혼란이 생긴다. JPA와 Hibernate는 객체 구조를 유지한다는 철칙이 있다.

자동 merge()를 하면 안 되는 이유

트랜잭션이 끝날 때마다 모든 준영속 엔티티를 자동으로 merge()해버리면 사용하지도 않는 엔티티들이 전부 merge() 되고, 연관 객체까지 cascade merge로 대량 SQL이 실행된다.

결론: Proxy를 그대로 유지하는 것이 가장 안전하고 비용이 적으며, 애플리케이션 객체 구조를 보존하는 유일한 방법이다. Hibernate는 Proxy 객체는 남겨두되 세션이 없어서 로딩은 못하는 상태로 두고, 그 시점에 접근하면 LazyInitializationException을 던진다.

merge() — 준영속 → 영속 전환

// 준영속 엔티티를 영속 상태로 복귀
User detachedUser = ...;  // 준영속 상태
detachedUser.setName("Updated");

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

User managedUser = em.merge(detachedUser);
// 내부 동작:
// 1. detachedUser의 ID로 1차 캐시 확인
// 2. 없으면 DB에서 조회
// 3. 조회한 영속 엔티티에 detachedUser의 값을 복사
// 4. 영속 엔티티 반환

// 중요! merge()는 detachedUser를 영속으로 만드는 게 아님
// 새로운 영속 엔티티(managedUser)를 반환함
System.out.println(em.contains(detachedUser));  // false
System.out.println(em.contains(managedUser));   // true

em.getTransaction().commit();
em.close();

💡 : merge()는 준영속 엔티티 자체를 영속으로 바꾸는 게 아니다. 내부적으로 DB에서 엔티티를 다시 조회한 뒤 값을 복사해서 새 영속 엔티티를 반환한다. merge() 이후에는 반환된 객체를 써야 한다.


삭제 예약 (Removed)

@Test
public void removedState() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    User user = em.find(User.class, 1L);
    System.out.println(em.contains(user));  // true

    em.remove(user);
    // 아직 영속성 컨텍스트에는 존재 (삭제 예약 상태)
    System.out.println(em<.contains(user));  // true

    em.getTransaction().commit();
    // ← DELETE SQL 실행

    System.out.println(em.contains(user));  // false (커밋 후 제거됨)
    em.close();
}

remove()를 "삭제 예약"이라고 부르는가

1. flush 순서 보장

Hibernate는 flush 시 실행 순서를 보장해야 한다.

INSERT → UPDATE → DELETE

remove() 직후 바로 준영속이 되어버리면 flush 전에 엔티티가 사라져서 어떤 변경이 먼저 반영되어야 하는지 순서가 꼬인다. flush 직전까지 영속 상태로 유지해야 이 순서를 제대로 관리할 수 있다.

2. 같은 트랜잭션 내 연관관계 정리 필요

User u = em.find(User.class, 1L);
em.remove(u);
order.setUser(null);  // 연관관계 정리

u가 바로 준영속이 되어버리면 연관관계 정리 같은 후속 작업을 JPA가 정확히 추적하기 어렵다.

3. 커밋 전까지 DB에 반영되지 않음

em.remove(u)
    ↓
아직 DB에서 삭제되지 않음
    ↓
flush() 또는 commit() 될 때 DELETE 실행

이 사이에 개발자가 계속 u 객체를 사용할 수 있다. 바로 준영속으로 만들어버리면 Lazy 로딩 실패, setter 호출 시 변경 감지 안 됨 등 혼란이 생긴다.


DB 삭제 vs JVM 객체 삭제

remove()를 호출하고 commit이 완료되면 DB 레코드는 삭제된다. 하지만 JVM의 Java 객체는 여전히 남아있다.

User user = em.find(User.class, 1L);
em.remove(user);
em.getTransaction().commit();

// DB에서는 삭제됨
// JVM에서는? user 변수가 여전히 객체를 가리키고 있음
System.out.println(user.getId());   // 1 (여전히 값 있음)
System.out.println(user.getName()); // "Alice" (여전히 값 있음)

이유는 간단하다. JPA는 DB와 Java 객체의 동기화를 돕는 도구지, JVM 메모리를 통제할 권한이 없다. 변수에 담긴 객체를 강제로 null로 만들거나 GC를 강제 호출할 수 없다.

Java 객체가 진짜로 사라지는 시점은 해당 객체를 참조하는 변수가 하나도 없어질 때 GC가 수거하는 시점이다.

비영속 + GC 완료 → 메모리에서 완전히 사라진 상태

상태 전환 요약

상태1차 캐시변경 감지Lazy 로딩DB와 관계
비영속XXX무관
영속OOO동기화 대상
준영속XXX (예외 발생)분리됨
삭제 예약O (임시)OO삭제 예약

마치며

엔티티 생명주기를 이해하면 JPA의 동작이 훨씬 예측 가능해진다.

  • persist()는 즉시 SQL을 실행하지 않는다. 커밋 시점에 실행된다.
  • find(), JPQL 조회 결과는 자동으로 영속 상태가 된다. 그래서 setter만 호출해도 UPDATE가 날아간다.
  • 준영속 상태에서 Proxy가 사라지지 않는 건 Hibernate가 객체 동일성을 보장하기 위한 설계 결정이다.
  • remove()는 삭제 예약이다. flush/commit 전까지 영속 상태를 유지하는 것은 연관관계 정리와 SQL 실행 순서 보장을 위해서다.

다음 편에서는 JPQL의 내부 동작 원리와 최적화 전략, QueryDSL과 Native Query를 다룬다.

0개의 댓글