엔티티 생명주기란
JPA(Java Persistence API)에서 엔티티의 생명주기는 엔티티 객체가 생성되고 소멸할 때까지 거치는 일련의 상태 변화를 의미하며, 이 개념은 2006년 Java EE 5와 함께 발표된 JPA 1.0 명세에서 처음 정의되었고, Hibernate의 Session 개념을 표준화한 영속성 컨텍스트(Persistence Context)를 중심으로 설계되었다. 엔티티가 영속성 컨텍스트에 의해 관리되는지 여부와 데이터베이스와의 동기화 상태에 따라 비영속(Transient), 영속(Managed), 준영속(Detached), 삭제(Removed)의 4가지 상태로 구분되며, 각 상태는 EntityManager의 persist(), merge(), detach(), remove() 메서드를 통해 전이된다.
엔티티의 상태를 올바르게 이해하는 것은 JPA를 효과적으로 사용하고 데이터 일관성을 유지하며 성능을 최적화하는 데 필수적인데, 이는 영속성 컨텍스트가 제공하는 1차 캐시, 변경 감지(Dirty Checking), 쓰기 지연(Write-Behind) 등의 기능이 모두 엔티티의 영속 상태에서만 작동하기 때문이며, 상태를 잘못 이해하면 LazyInitializationException이나 EntityNotFoundException 같은 예외가 발생하거나 의도치 않은 데이터 손실이 발생할 수 있다.
엔티티 생명주기의 역사적 배경
JPA의 엔티티 생명주기 개념은 2001년 Gavin King이 개발한 Hibernate의 Session과 영속 객체(Persistent Object) 개념에서 유래했으며, 당시 EJB 2.x의 Entity Bean이 가진 복잡성과 성능 문제를 해결하기 위해 POJO(Plain Old Java Object) 기반의 간단한 영속화 모델을 제시했다. Hibernate는 객체의 상태를 추적하고 변경 사항을 자동으로 데이터베이스에 반영하는 투명한 영속화(Transparent Persistence) 개념을 도입했으며, 이 아이디어는 2006년 JPA 1.0 표준으로 채택되어 EntityManager와 영속성 컨텍스트라는 이름으로 명세화되었고, 이후 JPA 2.0(2009년), JPA 2.1(2013년), JPA 2.2(2017년)를 거치며 발전해왔다.
영속성 컨텍스트는 Martin Fowler가 정의한 Unit of Work 패턴과 Identity Map 패턴을 구현한 것으로, Unit of Work는 비즈니스 트랜잭션 동안 변경된 객체들을 추적하고 한 번에 데이터베이스에 반영하는 패턴이며, Identity Map은 동일한 데이터베이스 레코드에 대해 하나의 객체 인스턴스만 유지하여 일관성을 보장하는 패턴이다. 이러한 패턴들은 객체-관계 임피던스 불일치(Object-Relational Impedance Mismatch) 문제를 해결하기 위해 고안되었으며, JPA는 이를 영속성 컨텍스트라는 단일 개념으로 통합하여 개발자에게 제공한다.
4가지 상태 상세 분석
비영속 (Transient)
비영속 상태는 엔티티 객체가 new 키워드로 생성되었지만 아직 영속성 컨텍스트에 저장되지 않은 상태로, EntityManager와 아무런 관계가 없는 순수한 자바 객체이며 데이터베이스와도 연결되지 않아 식별자(ID)가 할당되지 않은 경우가 대부분이다. 비영속 상태의 엔티티는 JPA가 전혀 관리하지 않으므로 변경 감지나 1차 캐시 같은 영속성 컨텍스트의 기능을 사용할 수 없고, 트랜잭션이 커밋되어도 데이터베이스에 반영되지 않으며, 가비지 컬렉션의 대상이 되어 언제든지 메모리에서 제거될 수 있다.
비영속 상태에서 주의할 점은 @GeneratedValue 전략을 사용하는 엔티티의 경우 persist() 호출 전까지는 ID가 null이므로 equals()와 hashCode() 메서드 구현 시 ID에만 의존하면 안 되며, 비즈니스 키(Business Key)나 자연 키(Natural Key)를 함께 사용하는 것이 권장된다.
// 비영속 상태 - EntityManager와 무관한 순수 객체
User user = new User();
user.setName("홍길동");
user.setEmail("[email protected]");
// ID는 null이며, 영속성 컨텍스트에 관리되지 않음
영속 (Managed)
영속 상태는 엔티티가 영속성 컨텍스트에 저장되어 EntityManager에 의해 관리되는 상태로, persist() 메서드를 호출하거나 find(), JPQL, Criteria API 등으로 데이터베이스에서 조회하면 자동으로 영속 상태가 되며, 영속성 컨텍스트가 해당 엔티티의 변경 사항을 추적하고 트랜잭션 커밋 시 데이터베이스와 자동으로 동기화한다.
영속 상태의 엔티티는 1차 캐시에 저장되어 같은 트랜잭션 내에서 동일한 식별자로 조회하면 데이터베이스 접근 없이 캐시에서 반환되고, 변경 감지 기능이 활성화되어 엔티티의 필드를 변경하기만 해도 트랜잭션 커밋 시 자동으로 UPDATE 쿼리가 실행되며, 지연 로딩(Lazy Loading)을 통해 연관 엔티티를 필요한 시점에 로딩할 수 있고, 쓰기 지연 저장소에 SQL을 모아두었다가 한 번에 실행하여 데이터베이스 왕복 횟수를 줄인다.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
User user = new User();
user.setName("홍길동");
em.persist(user); // 영속 상태로 전환, 1차 캐시에 저장
User found = em.find(User.class, user.getId()); // 1차 캐시에서 반환, DB 조회 없음
found.setName("김철수"); // 변경 감지 - 별도의 update() 호출 불필요
em.getTransaction().commit(); // INSERT와 UPDATE 쿼리가 한 번에 실행
준영속 (Detached)
준영속 상태는 이전에 영속 상태였던 엔티티가 영속성 컨텍스트에서 분리되어 더 이상 EntityManager의 관리를 받지 않는 상태로, 데이터베이스 식별자(ID)는 가지고 있지만 영속성 컨텍스트의 관리 대상이 아니므로 변경 감지, 지연 로딩, 1차 캐시 같은 기능이 동작하지 않는다. detach() 메서드로 특정 엔티티만 분리하거나, clear()로 영속성 컨텍스트 전체를 초기화하거나, close()로 영속성 컨텍스트를 종료하면 엔티티가 준영속 상태로 전환되며, 트랜잭션이 종료되어 영속성 컨텍스트가 닫히는 경우에도 관리되던 엔티티들이 모두 준영속 상태가 된다.
준영속 상태에서 가장 흔히 발생하는 문제는 LazyInitializationException으로, 지연 로딩으로 설정된 연관 엔티티에 접근하려 할 때 영속성 컨텍스트가 이미 닫혀있으면 프록시를 초기화할 수 없어 예외가 발생하며, 이를 해결하려면 영속 상태일 때 미리 연관 엔티티를 로딩하거나, JPQL의 fetch join을 사용하거나, @EntityGraph를 활용해야 한다.
User user = em.find(User.class, 1L); // 영속 상태
em.detach(user); // 준영속 상태로 전환
user.setName("이영희"); // 변경해도 DB에 반영되지 않음
User merged = em.merge(user); // 준영속 엔티티의 값을 가진 새로운 영속 엔티티 반환
// 주의: user는 여전히 준영속, merged가 영속 상태
삭제 (Removed)
삭제 상태는 엔티티가 영속성 컨텍스트와 데이터베이스에서 삭제되도록 예약된 상태로, remove() 메서드를 호출하면 엔티티가 삭제 상태로 전환되고 트랜잭션이 커밋될 때 실제 DELETE 쿼리가 실행되며, 커밋 전까지는 데이터베이스에서 삭제되지 않고 롤백하면 삭제가 취소될 수 있다. 삭제 상태의 엔티티는 영속성 컨텍스트에서 관리되지만 제거 예정이라는 표시가 되어 있어 find()로 조회해도 반환되지 않으며, 삭제된 엔티티를 다시 영속 상태로 만들려면 persist()를 다시 호출해야 한다.
CASCADE.REMOVE나 orphanRemoval = true 옵션을 사용하면 부모 엔티티를 삭제할 때 연관된 자식 엔티티도 함께 삭제되며, 이는 편리하지만 의도치 않은 대량 삭제를 유발할 수 있으므로 주의해서 사용해야 한다.
영속성 컨텍스트의 핵심 기능
1차 캐시와 동일성 보장
영속성 컨텍스트는 내부에 1차 캐시를 가지고 있어 영속 상태의 엔티티를 Map 형태로 저장하며, 키는 @Id로 지정한 식별자이고 값은 엔티티 인스턴스이다. 같은 트랜잭션 내에서 동일한 식별자로 엔티티를 조회하면 1차 캐시에서 반환하므로 데이터베이스 접근 횟수를 줄이고, 동일한 식별자에 대해 항상 같은 인스턴스를 반환하여 애플리케이션 레벨에서 REPEATABLE READ 수준의 트랜잭션 격리를 제공한다.
User user1 = em.find(User.class, 1L); // DB 조회, 1차 캐시에 저장
User user2 = em.find(User.class, 1L); // 1차 캐시에서 반환, DB 조회 안 함
System.out.println(user1 == user2); // true - 동일성 보장
변경 감지 (Dirty Checking)
변경 감지는 영속 상태의 엔티티가 수정되었을 때 별도의 update() 메서드 호출 없이 자동으로 변경 사항을 감지하여 UPDATE 쿼리를 생성하는 기능으로, 영속성 컨텍스트는 엔티티를 처음 영속 상태로 만들 때 스냅샷을 저장해두고 flush 시점에 현재 엔티티와 스냅샷을 비교하여 변경된 필드를 찾는다. 기본적으로 변경된 필드만이 아닌 모든 필드를 UPDATE하는 쿼리가 생성되는데, 이는 쿼리를 미리 준비하고 재사용할 수 있어 애플리케이션 로딩 시점에 쿼리를 파싱하고 캐싱할 수 있기 때문이며, @DynamicUpdate 어노테이션을 사용하면 변경된 필드만 UPDATE하도록 할 수 있다.
Flush와 Clear
flush는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 작업으로, em.flush()를 직접 호출하거나 트랜잭션 커밋 시 자동으로 호출되며 JPQL 쿼리 실행 전에도 자동으로 호출되어 쿼리 결과의 일관성을 보장한다. flush가 호출되면 변경 감지가 작동하여 수정된 엔티티를 찾고, 수정 쿼리를 쓰기 지연 SQL 저장소에 등록한 다음, 저장소의 쿼리를 데이터베이스에 전송하며, 중요한 점은 flush가 영속성 컨텍스트를 비우는 것이 아니라 변경 내용을 동기화하는 것이므로 flush 후에도 엔티티는 여전히 영속 상태를 유지한다.
clear는 영속성 컨텍스트를 완전히 초기화하여 관리 중인 모든 엔티티를 준영속 상태로 전환하는 작업으로, em.clear()를 호출하면 1차 캐시를 포함한 영속성 컨텍스트의 모든 내용이 삭제되며, 배치 처리 시 메모리 사용량을 관리하거나 테스트 코드에서 격리를 보장하기 위해 사용한다. clear 후에는 이전에 조회했던 엔티티를 다시 사용하려면 재조회해야 하고, 기존 엔티티 객체에 대한 변경사항은 데이터베이스에 반영되지 않는다.
em.getTransaction().begin();
User user = em.find(User.class, 1L);
user.setName("변경됨");
em.flush(); // 변경 내용이 DB에 반영되지만 user는 여전히 영속 상태
em.clear(); // 영속성 컨텍스트 초기화, user는 준영속 상태가 됨
User fresh = em.find(User.class, 1L); // DB에서 새로 조회
System.out.println(user == fresh); // false - 다른 인스턴스
em.getTransaction().commit();
상태 전이 메서드 상세
persist()
persist() 메서드는 비영속 상태의 새로운 엔티티를 영속성 컨텍스트에 저장하여 영속 상태로 만들며, 호출 즉시 1차 캐시에 저장되고 트랜잭션 커밋 시 INSERT 쿼리가 실행된다. persist()는 새로운 엔티티에만 사용해야 하고 이미 데이터베이스에 존재하는 엔티티(ID가 할당된 준영속 엔티티)에 호출하면 EntityExistsException이 발생할 수 있으며, 준영속 상태의 엔티티를 다시 영속 상태로 만들려면 persist() 대신 merge()를 사용해야 한다.
merge()
merge() 메서드는 준영속 상태의 엔티티를 영속 상태로 만드는 메서드로, 동작 방식은 먼저 준영속 엔티티의 식별자로 영속성 컨텍스트에서 엔티티를 조회하고 없으면 데이터베이스에서 조회하며, 조회된 영속 엔티티에 준영속 엔티티의 모든 값을 복사한 다음 병합된 영속 엔티티를 반환한다. 중요한 점은 merge()가 준영속 엔티티 자체를 영속 상태로 변환하는 것이 아니라 준영속 엔티티의 값을 가진 새로운 영속 엔티티를 반환하는 것이므로, merge() 호출 후에는 반환된 엔티티를 사용해야 하고 원본 준영속 엔티티는 여전히 준영속 상태로 남아있다.
detach()와 remove()
detach() 메서드는 특정 엔티티를 영속성 컨텍스트에서 분리하여 준영속 상태로 만들며, 분리된 엔티티는 더 이상 영속성 컨텍스트의 관리를 받지 않아 변경 감지가 작동하지 않고 지연 로딩도 불가능해진다. remove() 메서드는 영속 상태의 엔티티를 삭제 상태로 전환하여 트랜잭션 커밋 시 데이터베이스에서 삭제되도록 예약하며, 삭제 상태의 엔티티는 영속성 컨텍스트에서 제거 예정으로 표시되고 커밋 시 DELETE 쿼리가 실행된다.
실무 적용 가이드
엔티티 생명주기를 실무에서 활용할 때 가장 중요한 원칙은 영속 상태의 엔티티만 수정하고, 변경은 트랜잭션 내에서 수행하며, 트랜잭션이 끝나기 전에 필요한 연관 엔티티를 모두 로딩하는 것이다. 준영속 상태의 엔티티를 수정하고 merge()로 반영하는 패턴은 모든 필드를 UPDATE하므로 의도치 않은 데이터 손실이 발생할 수 있어, 가급적 트랜잭션 내에서 엔티티를 조회하고 수정하는 방식을 권장한다.
배치 처리 시에는 일정 개수의 엔티티를 처리한 후 flush()와 clear()를 호출하여 메모리 사용량을 관리해야 하며, 예를 들어 10,000건의 데이터를 insert할 때 100건마다 flush()와 clear()를 호출하면 영속성 컨텍스트에 최대 100개의 엔티티만 유지되어 OutOfMemoryError를 방지할 수 있다.
결론
JPA 엔티티의 생명주기는 비영속, 영속, 준영속, 삭제의 4가지 상태로 구분되며, 각 상태는 EntityManager의 메서드를 통해 전환되고 영속성 컨텍스트의 관리 여부와 데이터베이스 동기화 상태에 따라 결정된다. 영속성 컨텍스트는 1차 캐시, 변경 감지, 쓰기 지연 등의 기능을 제공하여 개발 생산성을 높이고 성능을 최적화하며, flush와 clear의 동작 시점을 이해하면 메모리 사용량을 효과적으로 관리할 수 있다. 엔티티 상태를 정확히 이해하면 LazyInitializationException 같은 예외를 방지하고 트랜잭션 범위 내에서 엔티티를 안전하게 관리할 수 있으며, 이는 JPA를 효과적으로 사용하기 위한 필수 지식이다.