순수한 J2SE 환경에서 JPA를 사용하면 사용자가 직접 엔터티 매니저를 생성하고 트랜잭션을 관리해야 한다. 하지만 스프링이나 J2EE 컨테이너 환경에서는 컨테이너가 제공하는 전략을 따라야 한다.

스프링 컨테이너는 트랜잭션 시작 시 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료하는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 그리고 같은 트랜잭션 안에서는 같은 영속성 컨텍스트에 접근할 수 있다.

스프링 프레임워크 사용 시 보통 서비스 계층 메서드를 @Transactional 애너테이션으로 지정하여 트랜잭션을 시작할 것을 지시한다. 이 애너테이션으로 지정된 메서드의 실행 전후로 스프링 AOP가 개입하여 트랜잭션의 시작과 종료를 담당한다. 트랜잭션 커밋 시 JPA는 우선 영속성 컨텍스트를 플러시하여 데이터베이스에 변경점을 반영한다. 이때 예외가 발생할 시 트랜잭션은 롤백된다.
예제 13.1. 트랜잭션 범위의 영속성 컨텍스트 예제
@Controller
@RequiredArgsConstructor
public class HelloController {
private final HelloService helloService;
public void hello() {
// 반환된 Member 엔터티는 준영속 상태이다.
Member member = helloService.logic();
}
}
@Service
@RequiredArgsConstructor
public class HelloService {
private final EntityManager em;
private final Repository1 repository1;
private final Repository2 repository2;
// 트랜잭션 시작
@Transactional
public Member logic() {
repository1.hello();
// member는 영속 상태이다.
Member member = repository2.findMember(1L);
return member;
}
// 트랜잭션 종료
}
@Repository
@RequiredArgsConstructor
public class Repository1 {
private final EntityManager em;
public void hello() {
em.flush(); // A. 영속성 컨텍스트 접근
return;
}
}
@Repository
@RequiredArgsConstructor
public class Repository2 {
private final EntityManager em;
public Member findMember(Long id) {
return em.find(Member.class, id); // B. 영속성 컨텍스트 접근
}
}
jakarta.persistence.PersistenceContext 애너테이션을 사용하거나 생성자 주입을 통해 스프링 컨테이너로부터 엔터티 매니저 빈을 주입받을 수 있다.
HelloSerivce.logic() 메서드를 @Transactional 애너테이션으로 지정했으므로 메서드 호출 시 트랜잭션이 우선 시작된다.repository2.findMember() 메서드를 통해 조회한 Member 엔터티는 트랜잭션 범위 안에 있으므로 영속성 컨텍스트의 관리를 받으며 영속 상태에 있다.Member 엔터티는 준영속 상태이다.
트랜잭션 범위의 영속성 컨텍스트 전략은 다양한 위치에서 엔터티 매니저 빈을 주입받아 사용하더라도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다. 예제에서도 Repository1, Repository2가 각각 엔터티 매니저 빈을 주입받았지만 같은 서비스 메서드에서 두 클래스의 메서드가 호출될 때 엔터티 매니저는 같은 영속성 컨텍스트를 사용했다.

반대로 여러 스레드에서 동시에 요청이 와서 같은 엔터티 매니저를 사용하더라도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다. 스프링 컨테이너는 스레드마다 상이한 트랜잭션을 할당하며, 쓰레드 로컬에 있는 각각의 스레드마다 서로 다른 엔터티 매니저 객체를 사용할 수 있다. 이는 프로토타입 생명주기를 갖는 빈의 동작 방식과 비슷하지만 엔터티 매니저 빈은 싱글톤 빈이며 프록시 객체로 래핑되기 때문에 가능한 것이다. 엔터티 매니저의 프록시 객체가 주입되어 있다가 @Transactional 메서드가 실행되거나 데이터베이스 연결이 필요할 때 엔터티 매니저 팩토리로부터 실제 객체를 생성받는다.
이렇듯 스프링이나 J2EE 컨테이너는 트랜잭션과 복잡한 멀티 스레드 상황을 컨테이너가 처리해주기 때문에 사용자가 싱글 스레드 애플리케이션처럼 단순하게 개발할 수 있고 결과적으로 비즈니스 로직 개발에 집중할 수 있다.
스프링이나 J2EE 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 그러므로 보통 트랜잭션의 스코프는 서비스 계층과 리포지토리 계층 간이며 조회한 엔터티는 두 계층에서 영속 상태를 유지한다. 컨트롤러나 뷰 같은 표현 계층에서 엔터티는 준영속 상태가 된다.
먼저 다음과 같이 연관 엔터티에 대해 지연 로딩 전략을 사용하는 주문 엔터티를 정의한다.
예제 13.2. 주문 엔터티 코드
@Entity
@Table(name = "orders")
@NoArgsConstructor
@AllArgsConstructor
@Getter @Setter
public class Order {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
다음과 같이 컨트롤러 계층(표현 계층)에서 지연 로딩 시 엔터티가 준영속 상태이기 때문에 LazyInitializationException 예외가 발생한다.
예제 13.3. 컨트롤러 로직
@Controller
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
public String view(Long orderId) {
Order order = orderService.findOrder(orderId);
Member member = order.getMember();
member.getName(); // 지연 로딩 시 예외 발생
return "view";
}
}
변경 감지 기능은 영속성 컨텍스트가 열려 있는 동안 영속 상태인 엔터티에 대해서만 동작한다. 그러므로 서비스 계층까지만 동작하고 표현 계층에서는 동작하지 않는 것이 일반적이다. 만약 영속성 컨텍스트가 표현 계층이 종료될 때까지 열려 있다면 컨트롤러 로직까지 데이터의 변경 사항을 추적해야 하므로 유지보수성이 떨어지고 불필요하게 트랜잭션의 범위가 길어져 애플리케이션의 성능 또한 떨어지게 될 것이다.
연관 엔터티를 지연 로딩으로 설정하여 프록시 객체로 조회하면 연관 객체 사용 시 초기화를 시도한다. 그런데 이때 엔터티가 준영속 상태에 있을 경우 하이버네이트를 구현체로 사용할 시 org.hibernate.LazyInitializationException 예외가 발생한다. 이 문제를 해결하기 위한 방안은 크게 두 가지가 있다.
뷰가 필요한 엔터티를 미리 로딩해 두는 방법은 로딩 시점에 따라 세 가지 방법으로 구분된다.
가장 간단한 방법은 글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경하는 것이다.
예제 13.4. 글로벌 페치 전략 - 즉시 로딩 설정
@Entity
@Table(name = "orders")
@NoArgsConstructor
@AllArgsConstructor
@Getter @Setter
public class Order {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
private Member member;
}
예제 13.5. 프레젠테이션 로직
public String view(Long orderId) {
Order order = orderService.findOrder(orderId);
Member member = order.getMember();
member.getName();
return "view";
}
엔터티에 있는 fetch 타입을 변경하면 애플리케이션 전역에서 엔터티 로딩 시마다 해당 전략을 사용하므로 글로벌 페치 전략이라고 한다. 이를 FetchType.EAGER로 변경하면 영속성 컨텍스트가 닫히기 전 연관 엔터티가 조회되므로 예외가 발생하지 않는다.
JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 활용한다. 그러므로 로딩 전략을 구분하지 않고 JPQL 쿼리 자체에 충실한 SQL을 생성한다. 따라서 SELECT o FROM Order o와 같은 JPQL 작성 시 우선 SELECT * FROM orders와 같은 SQL을 생성하고, 즉시 로딩 전략에 따라 개별 엔터티별로 SELECT * FROM member WHERE id=?와 같은 SQL을 생성하게 된다. 이러한 문제는 JPQL 페치 조인으로 해결할 수 있다.
글로벌 페치 전략 수정 시 애플리케이션 전역에 영향을 주므로 비효율적이다. 페치 조인을 사용하면 영향 범위를 줄일 수 있다.
JpaRepository 메서드에 엔터티 그래프 사용
public interface OrderRepository extends JpaRepository<Order, Long> {
@Override
@EntityGraph(attributePaths = {"member"})
Optional<Order> findById(Long id);
}
예제 13.6. 페치 조인 사용 전
JPQL: SELECT o FROM Order o
SQL: SELECT * FROM orders
예제 13.7. 페치 조인 사용 후
JPQL:
SELECT o
FROM Order o
JOIN FETCH o.member
SQL:
SELECT o.*, m.*
FROM orders o
JOIN member m ON o.member_id=m.member_id
무분별한 페치 조인 사용은 뷰에 맞춘 리포지토리 메서드의 증가로 인해 사실상 데이터 접근 계층이 표현 계층에 의존하게 되는 문제가 발생할 수 있다. 따라서 무분별하게 페치 조인을 사용한 메서드, 사용하지 않은 메서드를 구분하기보다는 페치 조인을 사용한 메서드만을 구현하고 성능을 약간은 희생하는 방식을 따르는 것이 권장된다. 하지만 이 또한 성능이 얼마나 희생되는지에 따라서 결정해야 할 문제이다.
강제로 초기화하기는 영속성 컨텍스트가 닫히기 전 표현 계층이 필요로 하는 엔터티를 강제로 초기화해서 반환하는 방법이다.
예제 13.8. 프록시 강제 초기화
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public Order findOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElse(null);
order.getMember().getName();
return order;
}
}
글로벌 페치 전략을 지연 로딩으로 설정 시 order.getMember()처럼 연관 엔터티를 단순히 조회만 해서는 프록시 객체가 반환될 뿐 초기화는 수행되지 않는다. order.getMember().getName()처럼 실제 값을 사용해야만 초기화가 수행된다. 또한 org.hibernate.Hibernate.initialize(order.getMember())와 같이 하이버네이트가 지원하는 initialize() 메서드를 사용해서도 프록시 객체를 강제로 초기화할 수 있다.
JPA 표준에는 프록시 초기화 메서드가 없다. 대신 프록시 초기화 여부를 다음과 같이 확인할 수는 있다.
JPA 프록시 초기화 여부 확인
PersistenceUnitUtil persistenceUnitUtil =
em.getEntityManagerFactory().getPersistenceUnitUtil();
boolean isLoaded = persistenceUnitUtil.isLoaded(order.getMember());
하지만 예제처럼 프록시 초기화 역할을 서비스 계층이 담당하면 서비스 계층이 뷰에 의존하게 된다. 이럴 때는 서비스 계층에서 표현 계층을 위한 프록시 초기화 역할을 분리해야 하는데, FACADE 계층이 이러한 역할을 담당한다.

표현 계층과 서비스 계층 사이에 외관(facade) 계층을 두어 서비스 계층의 복잡성을 줄이고 표현 계층을 위해 프록시 초기화 등의 로직을 수행할 수 있다. 외관 패턴(facade pattern)은 다른 계층의 복잡성을 줄여주는 역할을 담당한다.
특히 프록시 초기화 등의 작업은 영속성 컨텍스트가 열려 있어야 하므로 외관 계층에서 트랜잭션을 시작해야 한다.
예제 13.9. FACADE 계층 추가
@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderFacade {
private final OrderService orderService;
public Order findOrder(Long id) {
Order order = orderService.findOrder(id);
// 표현 계층이 필요로 하는 프록시 객체를 강제로 초기화한다.
order.getMember().getName();
return order;
}
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public Order findOrder(Long orderId) {
return orderRepository.findById(orderId).orElse(null);
}
}
FACADE 계층 테스트
@Test
void findAndInitialize() {
Order order = orderFacade.findOrder(1L);
assertNotNull(order);
assertNotNull(order.getMember());
}
외관 계층 사용으로 서비스 계층과 표현 계층 간 논리적 의존 관계를 제거했다. 이제 서비스 계층은 프록시 초기화 같이 다른 계층에 필요한 로직 대신 자신이 수행해야 할 비즈니스 로직에만 집중할 수 있다. 대신 이러한 부차적인 로직은 외관 계층이 위임받는다. 이 패턴의 단점은 계층이 하나 추가되는 것, 코드의 양이 증가하는 것, 그리고 역할을 위임하는 코드가 상당히 많아지는 것 등이 있다.
준영속 상태일 때 글로벌 페치 전략이 지연 로딩인 연관 엔터티를 다루는 것은 오류를 맞닥뜨리기 쉽다. 뷰 개발 시 엔터티의 필드 초기화 여부를 신경쓰는 것 자체가 성가신 일이기 때문에 LazyInitializationException 예외가 자연스레 발생할 수밖에 없다.
또한 물리적으로 애플리케이션 로직과 뷰가 분리되어 있더라도 논리적으로는 엔터티 필드의 초기화라는 작업으로 인해 논리적으로는 의존한다는 문제점이 있다. 외관 계층을 추가해 이러한 문제를 어느 정도 해소할 수 있지만 이것은 완전한 해결책은 아니다.
결국 모든 문제는 엔터티가 표현 계층에서 준영속 상태이기 때문에 발생한다. 표현 계층 클래스를 @Transactional 애너테이션으로 지정한다고 해결되는 것은 아니다. 결국 표현 계층에 연관 필드를 초기화하는 코드가 추가될 것이기 때문이다. 권장되는 방식은 아니지만, 이때 영속성 컨텍스트의 생명주기(트랜잭션의 범위)를 뷰까지 확장하는 OSIV 기술이 사용될 수 있다.
OSIV(Open Session In View)는 영속성 컨텍스트의 생명주기를 뷰까지 연장한다는 의미이다. 그렇게 하면 뷰에서도 영속성 컨텍스트가 지원하는 지연 로딩 기능을 사용할 수 있다.

요청당 트랜잭션(Transaction per request) 방식의 OSIV는 클라이언트의 요청이 서블릿 컨테이너를 통과하자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고, 요청에 대한 처리가 끝날 때 동일한 레벨에서 트랜잭션을 끝낸다.
요청당 트랜잭션 방식의 OSIV는 컨트롤러나 뷰 같은 표현 계층에서 엔터티를 변경할 수 있다는 문제점이 있다. 그래서 최근에는 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용하며 스프링 프레임워크가 제공하는 OSIV가 이러한 방식을 사용한다.
단순히 뷰에 표현하는 방식을 위해 다음과 같이 고객 이름을 변경하는 상황을 생각해 보자.
예제 13.10. OSIV의 문제점 예제
class MemberController {
public String viewMember(Long id) {
Member member = memberService.getMember(id);
member.setName("XXX"); // 보안상의 이유로 고객 이름을 XXX로 변경한다.
model.addAttribute("member", member);
...
}
}
위 코드에서 컨트롤러 로직이 마무리될 때 영속성 컨텍스트의 변경 감지 기능으로 인해 수정된 엔터티의 필드가 데이터베이스에 반영된다. 이는 사용자가 의도한 바는 당연히 아닐 것이다. 이렇게 표현 계층에서 엔터티를 수정하지 못하게 막는 방법은 다음과 같다.
엔터티를 직접 노출하는 대신에 읽기 전용 메서드만 제공하는 인터페이스를 표현 계층에 제공한다.
예제 13.11. 엔터티를 읽기 전용 인터페이스로 제공 예제
public interface MemberView {
public String getName();
}
@Entity
public class Member implements MemberView {
...
}
public class MemberService {
private final MemberRepository memberRepository;
public MemberView getMember(Long id) {
return memberRepository.findById(id).orElse(null);
}
}
엔터티의 읽기 전용 메서드만 갖고 있는 래퍼 클래스를 정의하고 이 클래스의 객체를 표현 계층에 제공한다.
예제 13.12. 엔터티 레핑 예제
public class MemberWrapper {
private Member member;
public MemberWrapper(Member member) {
this.member = member;
}
// 읽기 전용 메서드만 제공
public String getName() {
return member.getName();
}
}
엔터티 대신 데이터 전송 객체(DTO, Data Transfer Object)를 활용하는 것은 가장 전통적인 방법이다. 하지만 이 방법을 사용하면 애초에 OSIV를 사용할 이유가 없으며 엔터티와 거의 유사한 모양의 DTO 클래스를 하나 더 만들어야 한다.
예제 13.13. DTO로 반환
@Getter @Setter
class MemberDTO {
private String name;
}
...
MemberDTO memberDTO = new MemberDTO();
memberDTO.setName(member.getName());
return memberDTO;
스프링 프레임워크의 spring-orm.jar는 다양한 OSIV 클래스를 제공하므로 OSIV를 서블릿 필터에서 적용할지, 스프링 인터셉터에서 적용할지에 따라 원하는 클래스를 선택해서 사용하면 된다.
org.springframework.orm.hibernate5.support.OpenSeesionInViewFilterorg.springframework.orm.hibernate5.support.OpenSeesionInViewInterceptororg.springframework.orm.jpa.support.OpenEntityManagerInViewFilterorg.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor
스프링 프레임워크가 제공하는 OSIV는 비즈니스 계층에서 트랜잭션을 사용하는 OSIV이다. 서비스 계층의 로직이 시작될 때 트랜잭션을 시작하고, 로직이 종료될 때 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다. 이후 트랜잭션만 끝내고 영속성 컨텍스트는 열어둔다. 서블릿 필터나 스프링 인터셉터에 돌아오기 전까지 영속성 컨텍스트는 열려있기 때문에 영속 상태의 엔터티는 수정만 불가할 뿐 영속성 컨텍스트가 지원하는 기능을 활용할 수 있다.
영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야 한다. 만약 트랜잭션 없이 엔터티 변경 후 영속성 컨텍스트를 플러시하면 jakarta.persistence.TransactionRequiredException 예외가 발생한다.
엔터티를 변경하지 않고 단순 조회만 할 때는 트랜잭션이 필요하지 않은데, 이를 트랜잭션 없이 읽기(Nontransactional reads)라 한다. 지연 로딩 역시 조회 기능에 포함되므로 트랜잭션 없이 읽기가 가능하다.
스프링 OSIV를 사용하면 표현 계층에서는 트랜잭션이 종료된 상태이기 때문에 엔터티를 수정할 수 없다. 하지만 트랜잭션 없이 읽기를 통해 지연 로딩은 가능하다. 즉, 비즈니스 계층 바깥에서 엔터티에 변경 사항이 발생했다면 다음과 같은 2가지 이유로 영속성 컨텍스트는 결코 플러시될 수 없어 안전하다.
TransactionRequiredException 예외가 발생한다.스프링 OSIV를 사용할 때 표현 계층에서 서비스 계층의 메서드를 여러 번 호출하면, 다시 변경 감지 메커니즘이 작동하면서 원치 않는 변경이 발생할 수 있다.
예제 13.15. 스프링 OSIV 주의사항
class MemberController {
public String viewMember(Long id) {
Member member = memberService.getMember(id);
member.setName("XXX"); // 보안상의 이유로 고객 이름을 XXX로 변경한다.
memberService.biz(); // 비즈니스 로직
return "view";
}
}
class MemberService {
@Transactional
public void biz() {
// ... 비즈니스 로직 실행
}
}
가장 확실한 해결책은 비즈니스 로직을 먼저 다 호출한 후 엔터티를 변경하는 것이다. 애초에 위 예제와 같은 스타일의 코드는 거의 작성되지 않는다.
스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있기 때문에 이러한 문제가 발생한다. OSIV를 사용하지 않는 일반적인 트랜잭션 범위의 영속성 컨텍스트 전략은 트랜잭션의 생명주기와 영속성 컨텍스트의 생명주기가 같으므로 동일한 문제가 발생할 여지가 없다.
스프링 OSIV의 특징
스프링 OSIV의 단점
OSIV vs. FACADE vs. DTO
OSIV의 대안은 FACADE 계층 또는 그것의 변형 등 다양한 방법이 있다. 하지만 어떤 방법이든 준영속 상태가 되기 전 프록시를 초기화해야 한다. 또 다른 방법은 엔터티를 직접 노출하지 않고 DTO로 서빙하는 방법이 있다. 어떤 방법이든 OSIV를 사용하는 것과 비교하여 코드 양이 늘어난다는 단점이 있다.
OSIV 사용 시 뷰 계층에서 엔터티의 영속 상태를 유지하면서 객체 그래프를 자유롭게 탐색할 수 있다. 하지만 통계 정보 등을 활용한 복잡한 뷰 구성 시에는 처음부터 데이터에 맞는 JPQL을 구성하여 DTO 객체를 모델로 반환하는 것이 더 효율적일 수 있다.
OSIV에서 영속성 컨텍스트의 생명주기는 클라이언트 요청의 생명주기와 같으므로 클라이언트에서 연관 엔터티를 활용할 수 없다. 그러므로 클라이언트가 필요로 하는 데이터를 서버 사이드에서 모두 구성하여 노출해야만 한다.
외부 API의 경우 엔터티를 직접 노출하기보다 DTO를 통한 데이터 서빙이 적합하며, 내부 API의 경우에도 기본적으로 DTO를 통한 데이터 서빙이 적합하나 엔터티를 직접 반환하는 방식도 고려할 수 있다.
다음 예제는 컨트롤러에서 리포지토리를 직접 접근한다.
예제 13.17. 컨트롤러에서 리포지토리 직접 접근
@Controller
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final OrderRepository orderRepository;
public String orderRequest(Order order, Model model) {
Long id = orderService.order(order);
// 리포지토리 직접 접근
Optional<Order> orderResult = orderRepository.findById(id);
model.addAttribute("order", orderResult);
return "order";
}
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public Long order(Order order) {
// 비즈니스 로직
return orderRepository.save(order).getId();
}
}
public interface OrderRepository extends JpaRepository<Order, Long> {
}
OSIV 사용 전에는 표현 계층에서 연관된 엔터티를 사용하기 위해 미리 지연 로딩을 수행해야 했다. 그리고 프록시 초기화는 서비스 계층이나 외관 계층에서 영속성 컨텍스트가 열려 있을 때 수행해야 했다. 하지만 OSIV 사용 시 영속성 컨텍스트가 표현 계층까지 열려 있으므로 단순한 엔터티 조회는 컨트롤러에서 리포지토리를 직접 호출하는 식으로 구현해도 전혀 문제가 없다.