[자바 ORM 표준 JPA 프로그래밍] 13장_웹 어플리케이션과 영속성 관리

컨테이너 환경에서 JPA 가 동작하는 내부 동작 방식을 이해하고, 문제점과 해결방안을 정리하자.

스프링 컨테이너의 기본 전략

스프링 컨테이너는 트렌젝션 범위의 영속성 컨텐스트 전략을 기본으로 한다.
즉, 트렌젝션을 시작할 때 영속성 컨텍스트를 생성하고 끝날 때 영속성 컨텍스트를 종료한다.
그리고, 같은 트렌젝션 안에서는 항상 같은 영속성 컨텍스트에 접근힌디.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Controller
class HelloController{

@Autowired HelloService helloService;

public void hello(){
//반환된 member 엔티티는 준영속 상태
Member member = helloService.logic();
}
}

@Service
class HelloService{

// 엔티티 메니저 주입
@PersistenceContext
EntityManager em;

@Autowired Repository1 repository1;
@Autowired Repository2 repository2;

//트랜잭션 시작
@Transactional
public void logic(){
repository1.hello();

//Member 는 영속상태
Member member = repository2.findMember();

return member;
}
//트렌젝션 종료
}

@Repository
class Repository1 {

@PersistenceContext
EntityManager em;

public void hello(){
em.xxx(); //영속성 컨텍스트 접근
}
}

@Repository
class Repository2 {

@PersistenceContext
EntityManager em;

public Member findMember() {
return em.find(Member.class, "id1"); //영속성 컨텍스트 접근
}
}

준영속 상태와 지연 로딩

조회한 엔티티가 서비스와 리포지토리 계층에서는 영속성 컨텍스트에 관리되면서 영속 상태를 유지하지만,
컨트롤러나 뷰 같은 프리젠테이션 계층에서는 준영속 상태가 된다.
따라서, 변경감지와 지연로딩이 동작하지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
public class Order{
@Id @GeneratedValue
private Long id;

@ManyToOne(fetch = FetchType.LAZY) // 지연로딩
private Member member; // 주문 회원
}

class OrderController {
public String view(Long orderId){
Order order = orderService.findOne(orderId);
Member member = order.getMember();
member.getName(); // 지연로딩 시 예외 발생
}
}

변경감지 기능이 프리젠테이션 계층에서 동작하지 않는 것은 문제가 되지 않는다.
변경 감지 기능이 프리젠테이션 계층에서도 동작하면 애플리케이션 계층이 가지는 책임이 모호해지고, 데이터를 어디서 어떻게 변경했는지 프리젠테이션 계층까지 다 찾아야 하므로 유지보수하기 어렵다.
비즈니스 로직은 서비스 계층에서 끝내야한다.

준영속 상태의 지연 로딩을 해결하는 방법은 두 가지이다.

  1. 뷰가 필요한 엔티티를 미리 로딩
  2. OSIV

뷰가 필요한 엔티티를 미리 로딩하는 방법은 어디서 미리 로딩 하느냐에 따라 세가지가 있습니다.

  1. 글로벌 페치 전략 수정
  2. JPQL Fetch Join
  3. 강제 초기화

글로벌 페치 전략 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Order{
@Id @GeneratedValue
private Long id;

@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 전략
private Member member; // 주문 회원
}

//Presentation Logic
class OrderController {
public String view(Long orderId){
Order order = orderService.findOne(orderId);
Member member = order.getMember();
member.getName(); // 이미 로딩된 엔티티
}
}

글로벌 페치 전략에 즉시 로딩 사용시 단점은 두가지가 있습니다.

  1. 사용하지 않는 엔티티를 로딩
    order 를 조회하면서 사용하지 않는 member 도 함께 조회한다.

  2. N+1 문제

1
2
3
4
5
6
7
Order order = em.find(Order.class, 1L);

//실행된 SQL
select o.*, m.*
from Order o
left outer join Member m on o.MEMBER_ID = m.MEMBER_ID
where o.id = 1

여기까지 보면 글로벌 즉시 로딩 전략이 좋아보이지만, 문제는 JPQL 을 사용할 때 발생한다.

1
2
3
4
5
6
7
8
9
List <Order> orders = em.createQuery("select o from Order o", Order.class).getResultList();

//실행된 SQL
select * from Order //JPQL 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
...

JPA 가 JPQL 을 분석해서 SQL 을 생성할 때, 글로벌 패치 전략을 참고하지 않고 오직 JPQL 자체만 사용한다.
따라서, 즉시로딩이든 지연 로딩이등 구분하지 않고 JPQL 쿼리 자체에 충실한 SQL 을 만든다.
이런 N+1 문제는 JPQL Fetch Join 으로 해결할 수 있다.

JPQL Fetch Join

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Fetch Join 사용 전
JPQL : select o from Order o
SQL : select * from Order

// Fetch Join 사용 후
JPQL :
select o
from Order o
join fetch o.member

SQL :
select o.*, m.*
from Order o
join Member m on o.MEMBER_ID = m.MEMBER_ID

Fetch Join 을 사용하면, SQL JOIN 을 사용해서 페치 조인 대상까지 함께 조회해서, N+1 문제가 발생하지 않는다.
하지만, 무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있다.
결국 프리젠테이션 계층이 데이터 접근 계층을 침범하는 것이다.

강제로 초기화

영속성 컨테스트가 살아있을 때 프리젠테이션 계층이 필요한 엔티티를 강제로 초기화해서 반환하는 방법이다.

1
2
3
4
5
6
7
8
9
class OrderService{

@Transactional
public Order findOrder(id){
Order order = oderRepository.findOrder(id);
order.getMember().getName(); // 프록시 객체를 강제로 초기화
return order;
}
}

글로벌 페치 전략을 지연로딩으로 설정하면, 연관된 엔티리를 실제 엔티티가 아닌 프록시 객체로 조회한다.
프록시 객체는 실제 사용하는 시점에 초기화된다.
order.getMember() 까지만 호출하면 단순히 프록시 객체만 반환한다. 아직 초기화 하지 않았다.
member.getName() 처럼 실제 값을 사용할 때 초기화된다.
프록시 초기화 하는 역할을 서비스 계층이 담당하면, 뷰가 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야한다.

비즈니스 로직을 담당하는 서비스 계층에서 프리젠테이션 계층을 위한 프록시 초기화 역할을 하는 FACADE 계층이 그 역할을 담당해줄 수 있다.

FACADE 계층 추가

프리젠테이션 계층과 도메인 모델 계층간의 논리적 의존성을 분리한다.
프리젠테이션 계층에서 필요한 프록시 객체를 초기화한다.
서비스 계층을 호출해서 비즈니스 로직을 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class OrderFacade{
@Autowired OrderService orderService;

public Order findOrder(id){
Order order = orderService.findOrder(id);

// 프리젠테이션 계층이 필요한 프록시 객체를 강제 초기화
order.getMember().getName();
return order;
}
}

class OrderService{
public Order findOrder(id){
return orderRepository.findOrder(id);
}
}

OSIV (Open Session in View)

영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.

과거 OSIV : 요청 당 트렌젝션

OSIV 의 핵심은 뷰에서도 지연로딩이 가능하도록 하는 것이다.
가장 단순한 구현은 클라이언트의 요청이 들어오자마자 서플릿 필터나 스프링 인터셉터에서 트렌젝션을 시작하고 요청을 끝날 때 트렌젝션도 끝내는 것이다.
이것을 요청 당 트렌젝션 방식의 OSIV 라고 한다.
문제는, 프레센테이션 계층이 엔티티를 변경할 수 있다는 것이다.

1
2
3
4
5
6
7
class MemberControlelr{
public String viewMember(Long id){
Member member = memberService.getMember(id);
member.setName("XXX");
model.addAttribute("member", member);
}
}

개발자의 의도는 단순히 뷰에 노출할 때만 고객이름을 XXX 로 변경하고 싶은 것이지, 데이터베이스에 있는 고객 이름까지 변경하고자 하는 것이 아니다.
하지만 요청당 트렌젝션 방식은 뷰 렌더링 이후에 트렌젝션을 커밋한다.
커밋을 하면 영속성 컨텍스트를 플러쉬해서, 영속성 컨텍스트의 변경 감지 기능이 동작하여 변경된 엔티티를 데이터베이스에 반영해버린다.

따라서, 프레젠테이션 계층에서 엔티티를 수정하지 못하게 해야한다.
다음과 같은 방법이 있다.

  1. 엔티티를 읽기 전용 인터페이스로 제공
  2. 엔티티 레핑
  3. DTO 만 반환

엔티티를 읽기 전용 인터페이스로 제공

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface MemberView{
public String getName();
}

@Entity
class Member implements MemberView{
...
}

class MemberService {
public MemberView getMember(id){
return memberRepository.findById(id);
}
}

엔티티 레핑

엔티티의 읽기 전용 메소드만 가지고 있는 엔티티를 감싼 객체를 만들고, 이것을 프리젠테이션 계층에 반환하는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
class MemberWrapper{
private Member member;

public MemberWrapper(member){
this.member = member;
}

// 읽기 전용 메소드만 제공
public String getName(){
member.getName;
}
}

DTO 만 반환

1
2
3
4
5
6
7
8
9
10
class MemberDTO {
private STring name;

//GETTER, SETTER
}

...
MemberDTO memberDTO = new MemberDTO();
memberDTO.setName(member.getName());
return memberDTO;

최근에는 비즈니스 계층에서만 트렌젝션을 유지하는 방식의 OSIV 를 사용한다.
스프링 프레임워크가 제공하는 OSIV 방식이다.

스프링 OSIV : 비즈니스 계층 트렌젝션

  1. 클라이언트의 요청이 들어오면 영속성 컨텍스트를 생성한다. 이 때, 트렌젝션을 시작하지 않는다.
  2. 서비스 계층에서 트렌젝션을 시작하면 앞에서 생성해둔 영속성 켄텍스트에 트렌젝션을 시작한다.
  3. 비즈니스 로직을 실행하고 서비스 계층이 끝나면 트렌젝셩르 커밋하면서 영속성 컨텍스트를 플러쉬한다. 이때, 트렌젝션만 종료하고 영속성 컨텍스트를 살려둔다.
  4. 클라이언트의 요청이 끝날 때 영속성 컨텍스트를 종료한다.

영속성 컨텍스트는 트렌젝션 범위 안에서 엔티티를 조회하고 수정할 수 있다.
영속성 컨텍스트는 트렌젝션 범위 밖에서 엔티티를 조회만 할 수 있다.


자바 ORM 표준 프로그래밍 <김영한>

Comments