영속성 컨텍스트의 개념과 역사
영속성 컨텍스트(Persistence Context)는 애플리케이션과 데이터베이스 사이에서 엔티티의 생명주기를 관리하고, 다양한 최적화 기능을 제공하는 JPA의 핵심 개념이다. 이 개념은 2001년 Gavin King이 Hibernate를 개발할 때 Session이라는 이름으로 처음 도입되었다. Hibernate의 Session은 데이터베이스 연결을 추상화하고, 엔티티 객체의 상태를 추적하며, 트랜잭션 안에서 일관된 데이터 뷰를 제공했다. 이후 2006년 JPA 1.0이 Hibernate의 개념을 표준화하면서 영속성 컨텍스트와 EntityManager라는 이름으로 정리되었다.
영속성 컨텍스트는 객체지향 애플리케이션에서 데이터베이스 작업의 복잡성을 줄이기 위해 등장했다. 개발자는 매번 데이터베이스에 직접 쿼리하는 대신, 영속성 컨텍스트라는 중간 계층을 통해 객체 중심으로 비즈니스 로직을 작성할 수 있다. 이 계층은 캐싱, 변경 추적, 지연 쓰기 같은 최적화를 자동으로 처리한다. 영속성 컨텍스트는 Martin Fowler가 정의한 Unit of Work 패턴과 Identity Map 패턴을 구현한 것으로, 비즈니스 트랜잭션 동안 변경된 객체를 추적했다가 트랜잭션 종료 시 한꺼번에 데이터베이스에 반영한다. 또한 동일한 식별자를 가진 엔티티에는 항상 같은 객체 인스턴스를 반환해 일관성을 보장한다.
영속성 컨텍스트의 주요 기능
1차 캐시 (First-Level Cache)
영속성 컨텍스트 내부에는 1차 캐시라고 불리는 Map 구조의 저장소가 있으며, 엔티티의 식별자(@Id)를 키로, 엔티티 인스턴스를 값으로 저장하여 동일한 트랜잭션 내에서 같은 엔티티를 반복 조회할 때 데이터베이스에 접근하지 않고 캐시에서 즉시 반환할 수 있게 한다. find() 메서드가 호출되면 먼저 1차 캐시를 조회하고, 캐시에 없는 경우에만 데이터베이스 쿼리를 실행한 후 결과를 1차 캐시에 저장하는데, 이 방식은 동일 트랜잭션 내에서 같은 데이터를 여러 번 조회하는 경우 데이터베이스 부하를 크게 줄일 수 있다.
1차 캐시는 트랜잭션 범위로 동작하므로 트랜잭션이 시작되면 생성되고 트랜잭션이 종료되면 함께 소멸하며, 서로 다른 트랜잭션은 각자의 1차 캐시를 가지므로 트랜잭션 간 데이터 격리가 자연스럽게 이루어진다. 하지만 요청마다 새로운 1차 캐시가 생성되므로 애플리케이션 전체의 성능을 획기적으로 향상시키지는 못하며, 캐시의 진정한 가치는 성능보다는 동일성 보장과 변경 감지를 위한 기반 메커니즘에 있다.
동일성 보장 (Identity Guarantee)
영속성 컨텍스트는 같은 트랜잭션 내에서 동일한 식별자로 조회한 엔티티는 항상 같은 객체 인스턴스를 반환하도록 보장하며, 이는 Identity Map 패턴의 구현으로서 em.find(User.class, 1L)을 여러 번 호출해도 매번 새로운 객체가 생성되지 않고 1차 캐시에 저장된 동일한 인스턴스가 반환된다. 따라서 a == b 비교가 true를 반환하며, 이 특성 덕분에 애플리케이션 수준에서 REPEATABLE READ 트랜잭션 격리 수준을 제공할 수 있다.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
User user1 = em.find(User.class, 1L); // DB 조회, 1차 캐시에 저장
User user2 = em.find(User.class, 1L); // 1차 캐시에서 반환
System.out.println(user1 == user2); // true - 동일 인스턴스
쓰기 지연 (Write-Behind)
쓰기 지연은 엔티티 매니저가 트랜잭션을 커밋하기 직전까지 INSERT, UPDATE, DELETE 쿼리를 쓰기 지연 SQL 저장소에 모아두었다가 flush 시점에 한꺼번에 데이터베이스로 전송하는 기능으로, 이 방식은 여러 개의 쿼리를 개별적으로 전송하는 것보다 네트워크 왕복 횟수를 줄여 성능을 향상시킨다. persist()를 호출하면 즉시 INSERT 쿼리가 실행되는 것이 아니라 쓰기 지연 SQL 저장소에 쿼리가 등록되고, 트랜잭션 커밋이나 명시적 flush() 호출 시점에 실제 쿼리가 전송된다.
쓰기 지연은 JDBC 배치 처리와 결합하면 더욱 효과적인데, hibernate.jdbc.batch_size를 설정하면 같은 종류의 쿼리를 지정된 개수만큼 모아 하나의 배치로 전송할 수 있어 대량 데이터 처리 시 성능이 크게 향상된다.
변경 감지 (Dirty Checking)
변경 감지는 영속 상태의 엔티티가 수정되었을 때 개발자가 명시적으로 UPDATE 문을 호출하지 않아도 flush 시점에 자동으로 변경 사항을 감지하여 UPDATE 쿼리를 생성하는 기능으로, 엔티티가 영속화될 때 저장한 스냅샷과 현재 상태를 비교하여 변경된 필드를 찾아낸다. 이 기능 덕분에 개발자는 객체의 필드 값만 변경하면 되고, 어떤 필드가 변경되었는지 추적하거나 UPDATE 쿼리를 작성하는 부담에서 해방된다.
em.getTransaction().begin();
User user = em.find(User.class, 1L); // 영속 상태, 스냅샷 저장
user.setName("변경된이름"); // 메모리상 변경만 발생
em.getTransaction().commit(); // flush → 스냅샷 비교 → UPDATE 자동 생성
영속성 컨텍스트의 생명주기와 범위
트랜잭션 범위 영속성 컨텍스트
Spring Framework에서는 기본적으로 트랜잭션 범위 영속성 컨텍스트 전략을 사용한다. 즉, 트랜잭션이 시작될 때 영속성 컨텍스트가 생성되고 트랜잭션이 끝날 때 함께 종료된다. @Transactional 어노테이션이 붙은 메서드에 진입하면 트랜잭션과 영속성 컨텍스트가 함께 시작된다. 메서드가 종료되면 커밋 또는 롤백과 함께 영속성 컨텍스트도 종료되고, 그 안의 모든 엔티티는 준영속 상태가 된다.
같은 트랜잭션 안에서는 여러 리포지토리나 서비스를 거쳐도 동일한 영속성 컨텍스트가 공유된다. 따라서 어디서 조회한 엔티티든 동일성이 보장되고, 변경 감지도 일관되게 동작한다. 반면 트랜잭션이 종료된 뒤 컨트롤러나 뷰에서 지연 로딩을 시도하면 영속성 컨텍스트가 이미 닫혀 있으므로 LazyInitializationException이 발생한다.
확장된 영속성 컨텍스트와 OSIV
OSIV(Open Session In View)는 영속성 컨텍스트를 뷰 렌더링이 끝날 때까지 열어두는 패턴이다. 원래 Hibernate의 Open Session In View에서 유래했고, JPA에서는 Open EntityManager In View라고도 부른다. Spring Boot에서는 spring.jpa.open-in-view 속성의 기본값이 true이므로 OSIV가 활성화된다. 이때 HTTP 요청이 들어오면 서블릿 필터나 인터셉터에서 영속성 컨텍스트를 만들고, 응답이 끝날 때까지 유지한다.
OSIV에서는 요청이 시작될 때 영속성 컨텍스트를 먼저 생성하지만, 트랜잭션은 바로 시작하지 않는다. 이후 서비스 계층에서 @Transactional이 붙은 메서드에 진입하면 기존 영속성 컨텍스트를 사용해 트랜잭션을 시작한다. 서비스 계층 처리가 끝나면 트랜잭션은 커밋되지만 영속성 컨텍스트는 닫히지 않는다. 그래서 컨트롤러와 뷰에서도 지연 로딩이 가능해진다.
OSIV의 장단점과 대안
OSIV의 장점
OSIV가 활성화되면 프레젠테이션 계층에서도 지연 로딩이 가능하므로, 서비스 계층에서 DTO로 변환하지 않고 엔티티를 직접 반환해도 뷰에서 연관 엔티티에 접근할 수 있어 개발이 편리해진다. 트랜잭션 종료 후에도 영속성 컨텍스트가 유지되므로 LazyInitializationException 걱정 없이 필요한 시점에 연관 데이터를 조회할 수 있다.
OSIV의 단점
OSIV의 가장 큰 문제는 데이터베이스 커넥션을 요청 전체 기간 동안 점유한다는 것으로, API 응답이나 뷰 렌더링에 시간이 오래 걸리면 그 시간 동안 커넥션이 반환되지 않아 커넥션 풀이 고갈될 수 있다. 예를 들어 외부 API 호출이나 복잡한 뷰 렌더링이 포함된 요청이 많아지면 커넥션 풀이 소진되어 다른 요청을 처리할 수 없는 상황이 발생할 수 있다.
또 다른 문제는 영속성 컨텍스트가 요청 전체에 걸쳐 공유되므로 하나의 요청 내에서 여러 트랜잭션이 실행될 경우 이전 트랜잭션에서 수정한 엔티티의 변경 사항이 다음 트랜잭션에 영향을 줄 수 있어 의도하지 않은 데이터 변경이 발생할 가능성이 있다.
OSIV 비활성화 시 대안
spring.jpa.open-in-view=false로 설정해 OSIV를 비활성화하면 트랜잭션이 끝나는 시점에 영속성 컨텍스트도 함께 종료된다. 따라서 서비스 계층에서 필요한 데이터를 미리 모두 로딩해야 한다. 대표적인 방법은 세 가지다. JPQL의 fetch join으로 연관 엔티티를 한 번에 조회할 수 있고, @EntityGraph로 특정 연관관계를 즉시 로딩할 수도 있다. 또는 서비스 계층에서 DTO로 변환해 필요한 데이터만 프레젠테이션 계층으로 전달할 수 있다.
// fetch join으로 연관 엔티티 미리 로딩
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
User findWithOrders(@Param("id") Long id);
// @EntityGraph 사용
@EntityGraph(attributePaths = {"orders", "profile"})
Optional<User> findById(Long id);
결론
영속성 컨텍스트는 JPA의 핵심 개념으로, Hibernate의 Session에서 출발했다. 이 개념은 1차 캐시, 동일성 보장, 쓰기 지연, 변경 감지 같은 기능을 통해 객체와 데이터베이스 사이의 작업을 효율적으로 정리해 준다. Spring에서는 기본적으로 트랜잭션 범위 영속성 컨텍스트를 사용해 트랜잭션과 그 생명주기를 맞춘다. OSIV는 뷰까지 영속성 컨텍스트를 유지해 지연 로딩을 가능하게 하지만, 커넥션 풀 고갈 위험도 함께 가져온다. 그래서 API 서버에서는 보통 OSIV를 끄고, fetch join이나 DTO 변환 같은 방식으로 필요한 데이터를 서비스 계층에서 미리 준비하는 편이 권장된다.