BOOK 3 - 자바 ORM 표준 JPA 프로그래밍(12)
웹 어플리케이션과 영속성 관리
- 스프링 환경에서 JPA를 사용하면 컨테이너가 트랜잭션과 영속성 컨텍스트를 관리해주므로 어플리케이션을 손쉽게 개발할 수 있다.
- 컨테이너 환경에서 동작하는 JPA의 내부 동작 방식을 이해해야 개발시 발생할 수 있는 다양한 문제를 해결할 수 있다.
트랜잭션 범위의 영속성 컨텍스트
스프링 컨테이너의 기본 전략
- 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
- 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다는 의미이다.
- 즉, 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고, 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.
- 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.

-
보통 비즈니스 로직을 시작하는 Service Layer에서
@Transactional어노테이션을 선언해서 트랜잭션을 시작한다.
- 스프링 트랜잭션 AOP는 대상 메소드를 호출하기 직전에 트랜잭션을 시작하고, 대상 메소드가 정상 종료되면 트랜잭션을 커밋하면서 종료한다.
- 트랜잭션을 커밋하면 JPA는 영속성 컨텍스트를 flush해서 변경 내용을 데이터베이스에 반영한 후 데이터베이스 트랜잭션을 커밋한다.
- 메소드 실행 중 예외가 발생하면 트랜잭션을 롤백하고 종료하는데, 이 때는 플러시를 호출하지 않는다.
트랜잭션 범위의 영속성 컨텍스트 전략의 특징
-
트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.
- 다양한 위치에서 엔티티 매니저를 주입받아 사용해도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다.

- 서로 다른
Repository1과Repository2에서 각각 엔티티 매니저를 주입 받아 쿼리 메소드를 구현해도, 같은 트랜잭션 범위에서 각 메소드들을 호출했다면 같은 영속성 컨텍스트를 사용한다.
@Service class HelloService { @Autowired Repository1 repository1; @Autowired Repository2 repository2; @Transactional public void logic() { repository1.hello(); Member member = repository2.findMember(); ... } } @Repository class Repository1 { @PersistenceContext EntityManager em; public void hello() { ... } } @Repository class Repository2 { @PersistenceContext EntityManager em; public Member findMember() { return em.find(Member.class, "id1"); } } -
트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.
- 여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다.
- 즉, 스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당한다.
- 따라서 멀티스레드 상황에 안전하다.

준영속 상태와 지연 로딩
- 트랜잭션 범위의 영속성 컨텍스트 전략 사용시 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다.
- Presentation Layer에서는 준영속 상태가 되어 변경 감지와 지연 로딩이 동작하지 않는다.
- 따라서 컨트롤러에서
order.getMember().getName()같이 지연 로딩을 설정한 엔티티의 값을 사용하고자 하면 예외가 발생한다.- 준영속 상태에서 지연 로딩을 시도했기 때문에
LazyInitializationException예외가 발생한다.
- 준영속 상태에서 지연 로딩을 시도했기 때문에
- 준영속 상태의 지연 로딩 문제를 해결하는 방법은 크게 2가지가 있다.
- 뷰가 필요한 엔티티를 미리 로딩해두는 방법
- 글로벌 페치 전략 수정
- JPQL 페치 조인
- 강제로 초기화
- OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법
- 뷰가 필요한 엔티티를 미리 로딩해두는 방법
글로벌 페치 전략 수정
- 글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경하면 된다.
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch=FetchType.EAGER)
private Member member;
...
}
- 엔티티에 있는 fetch 타입을 변경하면 어플리케이션 전체에서 이 엔티티를 로딩할 때마다 해당 전략을 사용한다.
- 즉,
Order엔티티 조회시 연관된Member엔티티도 항상 함께 조회된다.
글로벌 페치 전략에 즉시 로딩 사용시 단점
- 사용하지 않는 엔티티를 로딩한다.
- N+1 문제가 발생한다.
사용하지 않는 엔티티를 로딩한다.
- 화면 A에서
order와member둘 다 필요해서 글로벌 페치 전략을 즉시 로딩으로 설정했다. - 화면 B에서는
order엔티티만 있으면 충분하지만, 글로벌 페치 전략으로 인해 사용하지 않는member엔티티까지 함께 조회하게 된다.
N+1 문제가 발생한다.
em.find()메소드로 엔티티를 조회할 때 페치 전략이 즉시 로딩인 경우 데이터베이스에 JOIN 쿼리를 사용해서 한번에 연관된 엔티티까지 조회한다.
Order order = em.find(Order.class, 1L);
Order.member를 즉시 로딩으로 설정했을 때, 위 코드 실행시 수행되는 SQL은 다음과 같다.
SELECT o.*, m.*
FROM ORDERS o
LEFT OUTER JOIN MEMBER m ON o.MEMBER_ID = m.MEMBER_ID
WHERE o.ID = 1
- JPQL로 엔티티를 조회할 때는 JPA가 JPQL을 SQL로 변환할 때 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용한다.
- 즉, 즉시 로딩이든 지연 로딩이든 구분하지 않고, JPQL 쿼리 자체에 충실하게 SQL을 생성한다.
List<Order> orders = em.createQuery("select o from Order o", Order.class).getResultList();
- 위 코드를 실행했을 때 수행되는 SQL은 다음과 같다.
SELECT * FROM ORDERS // JPQL로 실행된 SQL
SELECT * FROM MEMBER WHERE ID = ? // EAGER로 실행된 SQL
SELECT * FROM MEMBER WHERE ID = ? // EAGER로 실행된 SQL
SELECT * FROM MEMBER WHERE ID = ? // EAGER로 실행된 SQL
...
select o from Order oJPQL을 분석해서SELECT * FROM ORDERSSQL을 생성하여 수행한다.- 데이터베이스에서 결과를 받아
Order엔티티 인스턴스들을 생성한다. Order.member의 글로벌 페치 전략이EAGER이므로Order를 로딩하는 즉시 연관된Member도 로딩해야 한다.- 연관된
Member를 영속성 컨텍스트에서 찾는다. - 만약 영속성 컨텍스트에 없으면
SELECT * FROM MEMBER WHERE ID = ?SQL을 조회한Order엔티티 수만큼 실행한다.
- 이처럼 처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 것을 N+1 문제라 한다.
- SQL이 상당히 많이 호출되므로 조회 성능에 좋지 않다.
- 이런 N+1 문제는 JPQL 페치 조인으로 해결할 수 있다.
JPQL 페치 조인
- 글로벌 페치 전략을 즉시 로딩으로 설정하면 어플리케이션 전체에 영향을 주므로 너무 비효율적이다.
select o from Order o join fetch o.member
SELECT o.*, m.*
FROM ORDERS o
JOIN MEMBER m ON o.MEMBER_ID = m.MEMBER_ID
- 페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 함께 조회한다.
- N+1 문제가 발생하지 않는다.
JPQL 페치 조인의 단점
- 페치 조인을 무분별하게 사용하면 화면에 맞춘 Repository 메소드가 증가할 수 있다.
- 예를 들어 화면 A는
order와member엔티티 둘 다 필요하고, 화면 B는order엔티티만 필요할 때 Repository에 다음 2가지 메소드를 만들게 된다.- 화면 A를 위해
order와 연관된member를 페치 조인으로 조회하는repository.findOrderWithMember()메소드 - 화면 B를 위해
order만 조회하는repository.findOrder()메소드
- 화면 A를 위해
- 이렇게 메소드를 각각 만들면 View와 Repository 간에 논리적인 의존관계가 발생한다.
현실적인 대안은 findByOrder() 메소드만 만들고 여기서 페치 조인으로 order와 member를 함께 로딩하는 것이다.
화면 B에서도 불필요한 member 엔티티까지 조회하여 로딩 시간이 약간 증가하겠지만 JOIN 쿼리가 성능에 미치는 영향은 미비하기 때문에 적절한 타협점을 찾는 것이 합리적이다.
강제로 초기화
- 영속성 컨텍스트가 살아있을 때 Presentation Layer가 필요한 엔티티를 강제로 초기화해서 반환하는 방법이다.
- 하이버네이트에서는
initialize()메소드를 사용해서 프록시를 강제로 초기화할 수 있다. - 프록시를 초기화하는 역할을 서비스 계층이 담당하면 뷰가 필요한 엔티티에 따라 서비스 계층의 로직이 변경된다.
- 프레젠테이션 계층이 서비스 계층을 침범하는 상황으로 적절하지 않다.
FACADE 계층 추가
-
Presentation Layer와 Service Layer 사이에 FACADE 계층을 하나 더 두는 방법이다.
-
뷰를 위한 프록시 초기화를 이 FACADE에서 담당하도록 한다.
-
서비스 계층과 프레젠테이션 계층 사이에 논리적인 의존성을 분리할 수 있다.
-
-
프록시를 초기화하려면 영속성 컨텍스트가 필요하므로 FACADE에서 트랜잭션을 시작해야 한다.
FACADE 계층의 역할과 특징
- 프레젠테이션 계층과 도메인 모델 계층간의 논리적 의존성을 분리해준다.
- 서비스 계층을 호출해서 비즈니스 로직을 실행한다.
- 프레젠테이션 계층에서 필요한 프록시 객체를 초기화한다.
class OrderFacade {
@Autowired
OrderService orderService;
public Order findOrder(Long id) {
Order order = orderService.findOrder(id);
order.getMember().getName(); // 강제 초기화
return order;
}
}
OSIV (Open Session In View)
- OSIV는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.
- 영속성 컨텍스트가 살아있으면 엔티티는 영속 상태로 유지되어 뷰에서도 지연 로딩을 사용할 수 있게 된다.
과거 OSIV : 요청 당 트랜잭션
- 요청 당 트랜잭션 방법은 클라이언트의 요청이 들어오자마자 Servlet Filter나 Spring Interceptor에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션도 끝내는 것이다.

- 영속성 컨텍스트가 처음부터 끝까지 살아있으므로 조회한 엔티티도 영속 상태를 유지한다.
- 뷰에서도 지연 로딩을 할 수 있으므로 엔티티를 미리 초기화할 필요가 없다.
- FACADE 계층 없이도 뷰에 독립적인 서비스 계층을 유지할 수 있다.
요청 당 트랜잭션 방식의 OSIV 문제점
- 요청 당 트랜잭션 방식의 문제점이 Controller나 View 같은 Presentation Layer에서 엔티티를 변경할 수 있다는 것이다.
- 예를 들어, 회원의 이름을 보안상의 이유로
XXX로 변경해서 화면에 보여줘야 한다고 해보자.- 이 때는 화면에 보이는 값만 바꾸고 싶은것이지 데이터베이스 값을 바꾸고 싶은 것이 아니다.
class MemberController {
...
public String viewMember(Long id) {
Member member = memberService.getMember(id);
member.setName("XXX");
...
}
}
- 위처럼
Member엔티티의name값을 변경해서 뷰에 넘겨주게 되면 해당 요청이 끝날 때 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다.- 이 때 영속성 컨텍스트가 해당 엔티티의 변경을 감지해서 변경된 엔티티를 데이터베이스에 반영해버린다.
- 프레젠테이션 계층에서 데이터를 잠시 변경했다고 실제 데이터베이스까지 변경 내용이 반영되면 어플리케이션을 유지보수하기 상당히 힘들다.
- 프레젠테이션 계층에서 엔티티를 수정하지 못하게 막아야 한다.
- 엔티티를 읽기 전용 인터페이스로 제공
- Entity Wrapping
- DTO만 반환
엔티티를 읽기 전용 인터페이스로 제공
- 엔티티를 직접 노출하는 대신에 읽기 전용 메소드만 제공하는 인터페이스를 프레젠테이션 계층에 제공하는 방법이다.
interface MemberView {
public String getName();
}
@Entity
class Member implements MemberView {
...
}
class MemberService {
public MemberView getMember(id) {
return memberRepository.findById(id);
}
}
- 실제 엔티티가 읽기 전용 메소드만 있는 인터페이스를 구현하도록 하고, 서비스 계층에서 해당 인터페이스를 반환하도록 한다.
- 프레젠테이션 계층은 읽기 전용 메소드만 있는 인터페이스를 사용하므로 엔티티를 수정할 수 없다.
Entity Wrapping
- 엔티티의 읽기 전용 메소드만 가지고 있는 Wrapper 객체를 만들고, 이것을 프레젠테이션 계층에 반환하는 방법이다.
class MemberWrapper {
private Member member;
public MemberWrapper(Member member) {
this.member = member;
}
public String getName() {
return member.getName();
}
}
DTO만 반환
- 프레젠테이션 계층에 엔티티 대신에 단순히 데이터만 전달하는 객체인 DTO를 생성해서 반환하는 방법이다.
class MemberDTO {
private String name;
// Getter, Setter
...
}
...
MemberDTO memberDTO = new MemberDTO();
memberDTO.setName(member.getName());
return memberDTO;
- 엔티티와 거의 비슷한 DTO를 만들고 엔티티의 값을 여기에 채워서 반환한다.
지금까지 설명한 방법들 모두 코드량이 상당히 증가하는 단점이 있다.
이런 문제점으로 인해 최근에는 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용한다.
스프링 OSIV : 비즈니스 계층 트랜잭션
- 요청 당 트랜잭션 방식의 OSIV는 프레젠테이션 계층에서 데이터를 변경할 수 있다는 문제가 있다.
- 스프링 프레임워크가 제공하는 OSIV는 비즈니스 계층에서 트랜잭션을 사용하는 OSIV이다.

- 클라이언트의 요청이 들어오면 Servlet Filter나 Spring Interceptor에서 영속성 컨텍스트를 생성한다. 단, 이 때 트랜잭션은 시작하지 않는다.
- 서비스 계층에서
@Transactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다. - 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이 때 트랜잭션은 끝내지만 영속성 컨텍스트는 종료하지 않는다.
- 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
- 서블릿 필터나 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이 때
flush()를 호출하지 않고 바로 종료한다.em.close()를 호출하여 영속성 컨텍스트만 종료한다.
트랜잭션 없이 읽기
- 영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야 한다.
- 트랜잭션 없이 엔티티를 변경하고 영속성 컨텍스트를 플러시하면
TransactionRequiredException예외가 발생한다.
- 트랜잭션 없이 엔티티를 변경하고 영속성 컨텍스트를 플러시하면
-
엔티티를 변경하지 않고 단순히 조회만할 때는 트랜잭션이 없어도 되는데, 이것을 트랜잭션 없이 읽기(Nontransactional reads) 라고 한다.
-
즉, 영속성 컨텍스트는 트랜잭션 범위 안에서 엔티티 조회, 수정이 가능하고, 트랜잭션 범위 밖에서는 엔티티 조회만 가능하다.
- 프레젠테이션 계층에서는 트랜잭션이 없지만 Nontransactional reads를 사용해서 지연 로딩을 할 수 있다.
- 프레젠테이션 계층에서 엔티티의 값을 변경하고
em.flush()를 호출해서 강제로 플러시해도 트랜잭션 범위 밖이므로TransactionRequiredException예외가 발생하여 데이터베이스에 변경 사항이 반영되지 않는다.
스프링 OSIV 주의사항
- 프레젠테이션 계층에서 엔티티를 수정해도 수정 내용을 데이터베이스에 반영하지 않지만 한가지 예외 상황이 있다.
- 프레젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다.
class MemberController {
...
public String viewMember(Long id) {
Member member = memberService.getMember(id);
member.setName("XXX");
memberService.biz();
...
}
}
class MemberService {
@Transactional
public void biz() {
...
}
}
- 컨트롤러에서 회원 엔티티를 조회하고 이름을
member.setName("xxx")로 수정했다. biz()메소드를 실행해서 트랜잭션이 있는 비즈니스 로직을 실행했다.- 트랜잭션 AOP가 동작하면서 영속성 컨텍스트에 트랜잭션을 시작한다. 그리고
biz()메소드를 실행한다. biz()메소드가 끝나면 트랜잭션 AOP는 트랜잭션을 커밋하고, 영속성 컨텍스트를 플러시한다.- 이 때 dirty checking이 일어나면서 회원 엔티티의 수정 사항을 데이터베이스에 반영한다.
- 스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 이런 문제가 발생한다.
- 이런 문제를 해결하는 방법은 비즈니스 로직을 모두 호출하고 나서 엔티티를 변경하는 것이다.
- OSIV를 사용하지 않는 트랜잭션 범위의 영속성 컨텍스트 전략은 트랜잭션의 생명주기와 영속성 컨텍스트의 생명주기가 같으므로 이런 문제가 발생하지 않는다.
OSIV를 사용하면 화면을 출력할 때 엔티티를 유지하면서 객체 그래프를 마음껏 탐색할 수 있다.
하지만 복잡한 화면을 구성할 때는 엔티티를 조회하기보다는 처음부터 JPQL로 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적일 수 있다.