영속성 컨텍스트의 개념과 역사
영속성 컨텍스트(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차 캐시를 통한 캐싱, Identity Map 패턴을 통한 동일성 보장, 쓰기 지연을 통한 배치 최적화, 스냅샷 비교를 통한 변경 감지 기능을 제공한다. Spring에서는 기본적으로 트랜잭션 범위 영속성 컨텍스트 전략을 사용하여 트랜잭션과 영속성 컨텍스트의 생명주기를 일치시키며, OSIV 패턴은 뷰까지 영속성 컨텍스트를 유지하여 지연 로딩을 가능하게 하지만 커넥션 풀 고갈 위험이 있으므로 API 서버에서는 비활성화하고 fetch join이나 DTO 변환 방식을 사용하는 것이 권장된다.