[jpa] 웹 어플리케이션과 영속성 관리

트랜잭션 범위의 영속성 컨텍스트

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

스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
이 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고, 트랜잭션이 끝날 때 영속성 컨텍스트를 종료하는 방법이다.

트랜잭션 범위의 영속성 컨텍스트

스프링 트랜잭션 AOP는 @Transactional 어노테이션이 붙은 메서드가 호출될 때 트랜잭션을 시작한다.
메서드가 성공적으로 수행되면 해당 트랜잭션을 커밋하고, 예외가 발생한다면 트랜잭션을 롤백한다.
이 시점에 영속성 컨텍스트에 추가적인 작업을 호출한다.

  • 트랜잭션을 커밋하면(메서드가 성공적으로 수행되면) 영속성 컨텍스트를 플러시해서 변경내용을 반영한 후 데이터베이스 트랜잭션을 커밋한다.
  • 예외가 발생하면 플러시를 호출하지 않고 데이터베이스 트랜잭션을 롤백한다.
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
@Controller
class HelloController{
@Autowired
private HelloService helloService;

@PostMapping("/{teamId}/members")
public String addMember(@PathVariable Integer teamId, @RequestBody MemberDTO memberDTO){
Member member = helloService.logic(teamId, memberDTO); // 준영속 상태
// ...

return "/member/add_result";
}
}

@Service
class HelloService{
@Autowired
private MemberRepository memberRepository;

@Transactional
public void logic(Integer teamId, MemberDTO memberDTO){
Member member = memberRepository.addMember(memberDTO.toEntity());
Team team = teamRepository.findTeam(teamId);
team.setMemberCnt(team.getMemberCnt()+1);

return member;
}
}

@Repository
class MemberRepository{
@PersistenceContext
EntityManager em;

public Member addMember(Member member){
return em.persist(member);
}
}

@Repository
class TeamRepository{
@PersistenceContext
EntityManager em;

public Team findTeam(Integer id){
return em.find(Team.class, id);
}
}
  • logic 메서드가 실행될 때 트랜잭션이 시작된다.
  • logic 메서드가 종료되면 member, team에 대한 변경 내용이 데이터베이스에 플러시되고 트랜잭션이 커밋된다.
  • 예외가 발생하면 변경 내용이 데이터베이스에 플러시되지 않고, 시작한 트랜잭션은 롤백된다.

트랜잭션과 영속성 컨텍스트의 생명주기가 같으므로, 트랜잭션이 끝남과 동시에 영속성 컨텍스트도 종료된다.
즉, HelloController에서 logic의 결과로 받은 Member 엔티티는 준영속 상태이다.

참고로 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.
위의 상황에서 MemberRepositoryTeamRepository는 서로 다른 엔티티 매니저를 주입받았지만 같은 영속성 컨텍스트를 사용한다.
이와 반대로 같은 엔티티 매니저를 사용해도 트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다(잘 안그려진다 상황이…)

준영속 상태와 지연로딩

위에서 언급했듯이 트랜잭션과 영속성 컨텍스트의 생명주기가 같기 때문에, 트랜잭션이 끝난 뒤의 엔티티는 준영속 상태가 된다.
즉 위의 상황에서 logic 메서드가 끝남과 동시에 영속성 컨텍스트도 종료되었기 때문에 결과로 반환된 Member 엔티티는 준영속 상태가 되는 것이다.
그리고 당연하게도, 준영속 상태인 엔티티에 지연로딩을 수행하게 되면 오류가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Controller
class HelloController{
@Autowired
private HelloService helloService;

@PostMapping("/{teamId}/members")
public String addMember(
@PathVariable Integer teamId,
@RequestBody MemberDTO memberDTO,
ModelMap modelMap){

Member member = helloService.logic(teamId, memberDTO);
// ...

modelMap.add("member", member);
modelMap.add("teamName", member.getTeam().getName()); // lazy loading! but an exception occured

return "/member/add_result";
}
}

위처럼 지연로딩을 하게 되면 하이버네이트 기준으로 org.hibernate.LazyInitializationException이 발생한다.
이는 영속성 컨텍스트에 들어있지 않은 준영속 상태의 엔티티에 지연로딩을 시도했기 때문에 발생하는 것이다.
트랜잭션이 끝나면서 영속성 컨텍스트도 같이 종료되었기 때문에 반환된 엔티티는 자연스럽게 준영속 상태가 되었고, 이런 현상이 발생한 것이다.

하지만 생각해보면, 영속성 컨텍스트가 트랜잭션과 동시에 종료되고 프레젠테이션 계층까지 전파되지 않는것은 좋은 선택(?)이다.
만약 영속성 컨텍스트를 프레젠테이션 계층까지 열어두었다면 프레젠테이션 계층에서도 변경 감지가 동작하게 되어 위험하고,
각 계층이 가지는 역할자체도 모호해지기 때문이다.

하지만 위에서 봤다시피, 지연로딩이 동작하지 않는다는 점은 꽤나 골치아픈 일이다.
결국 위의 상황을 해결하고 싶으면 아래의 2가지 방법을 사용해야 한다.

뷰에 필요한 엔티티를 미리 로딩해두는 방법

말 그대로 영속성 컨텍스트가 살아있을 때 뷰에 필요한 엔티티들을 미리 다 로딩하거나 초기화해서 반환하는 방법이다.
아래의 3가지 방법이 있다.

글로벌 페치 전략 수정

fetchType을 EAGER로 바꾸는 방법이다.

1
2
3
4
5
class Member{
@ManyToOne(fetch=FetchType.EAGER)
@JoinColumn(name = "team_id")
private Team team;
}

Member 조회 시 항상 Team을 같이 로딩해서 가지게 되므로, 준영속 상태가 되어도 지연로딩 문제가 발생하지 않는다.
이미 로딩해서 가지고 있기 때문이다.

하지만 이 방식은 아래와 같은 문제를 가진다.

  • 사용하지 않는 엔티티를 로딩한다
    뷰에서 Team이 필요하지 않은 경우도 있을것이다. 하지만 항상 Team을 같이 조회해야 한다.

  • N+1 문제가 발생한다

    1
    2
    String sql = "SELECT m FROM Member m";
    List<Member> members = em.createQuery(sql, Member.class).getResultList();

    실행되는 SQL은 아래와 같다.

    1
    2
    3
    4
    5
    6
    SELECT * FROM Member;
    SELECT * FROM Team WHERE id = ?;
    SELECT * FROM Team WHERE id = ?;
    SELECT * FROM Team WHERE id = ?;
    SELECT * FROM Team WHERE id = ?;
    ....

    JPQL을 실행할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL만 참고하여 충실히 SQL을 만들기 때문에 발생한 현상이다.
    위와 같이 처음 조회한 수만큼 다시 SQL을 사용해서 조회하는 것을 N+1 문제라고 한다.

JPQL FetchJoin

봤다시피 fetchType을 EAGER로 바꾸는건 너무 비효율적이다.
fetchType을 LAZY로 설정하고 필요할 때만 같이 조회해오도록 하는것이 좋을 것이고, JPQL에서는 FetchJoin이라는 기능으로 이를 지원한다.
사용법은 간단하다.

1
2
String sql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(sql, Member.class).getResultList();

간단히 조인 명령어 마지막에 FETCH 만 넣어주면 된다.
이렇게 하면 해당 대상까지 조인으로 함꼐 조회해온 뒤 엔티티에 바인딩해준다.

이 방식이 현실적인 대안이긴 하지만, 화면에 맞춘 리파지토리 메서드가 증가할 수 있다는 단점이 있다.
즉, 아래와 같은 메서드들이 생길 수 있다는 것이다.

  • Member만 조회해오는 repository.findMember 메서드
  • Member와 연관된 Team 까지 조회해오는 repository.findMemberWithTeam 메서드

이런식으로 계속 메서드가 추가되다보면 레파지토리와 뷰 간의 논리적인 의존관계가 발생하게 된다.
이런 상황에서는 최적화를 조금 포기하고 논리적 의존관계를 최소화하는 방법을 선택하던지(findMember와 findmemberWithTeam 통합),
최적화를 선택하고 논리적 의존관계를 가지고 가던지… 선택해야 한다.

사실상 성능에 미치는 영향이 미비하므로 뷰와 레파지토리의 의존관계가 급격하게 증가하는 것보다는 최적화를 포기하는것이 조금 나은 것 같다.

강제로 초기화

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

1
2
3
4
Member member = em.find(Member.class, 1);
member.getTeam().getName(); // 프록시는 실제 사용하는 시점에 초기화된다

return member;

강제로 초기화했으므로 준영속 상태에서도 사용할 수 있게된다.

하이버네이트를 사용한다면 initialize 메서드를 사용해요 프록시를 강제로 초기화할 수 있다.

1
org.hibernate.Hibernate.initialize(member.getTeam());

하지만 이것도 결국 생각해보면 프리젠테이션 계층이 은근슬쩍 서비스 계층을 침범하는 상황이다.
프리젠테이션 계층에 필요한 엔티티를 서비스 계층에서 초기화하고 있기 때문이다.

프리젠테이션 계층과 서비스 계층 사이에 FACADE 계층이라는 것을 둬서 이런 논리적 의존관계를 완전히 제거할 수 있다.
FACADE 계층이랄게 특별할 건 없고, 그냥 중간에 계층하나를 더 두고
서비스 계층에서 받은 엔티티를 프리젠테이션 계층에서 필요한 형태로 가공해서 내려다주는 역할을 하는 것이다.
지연로딩 때문에 영속성 컨텍스트가 필요하므로, 트랜잭션은 FACADE 계층부터 시작해야한다.

얼핏보면 이렇게 함으로써 논리적 의존관계가 완전히 제거된 듯 보이지만,
실용적인 관점에서 보면 결국 코드를 훨씬 많이 작성하게 되고, 단순히 서비스 계층 호출을 위임하는 코드가 생길 가능성이 많다.

OSIV

OSIV(Open Session In View)는 영속성 컨텍스트를 뷰 까지 열어준다는 뜻이다.
영속성 컨텍스트가 살아있으면 엔티티는 영속 상태가 유지되므로, 뷰에서도 지연로딩을 사용할 수 있다.
이 기능의 핵심은, 뷰에서도 지연 로딩이 가능하다 이다.

요청 당 트랜잭션(Transaction Per Request)

뷰까지 영속성 컨텍스트를 열어두기 위해 가장 간단한 방법을 사용한다.
요청이 들어오자마자 필터나 인터셉터에서 트랜잭션을 시작하고, 요청이 끝날때 트랜잭션도 끝내는 것이다(트랜잭션과 영속성 컨텍스트의 생명주기가 같기 때문)

이렇게 하면 뷰에서도 지연로딩이 가능하므로 엔티티를 미리 초기화할 필요가 없다. 당연히 FACADE 계층도 필요없어진다.
하지만 트랜잭션이 프레젠테이션 영역까지 열리므로 프레젠테이션 계층이 엔티티를 변경할 수 있는 심각한 문제가 생긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
class HelloController{
@Autowired
private HelloService helloService;

@GetMapping("/member/{memberId}")
public String addMember(@PathVariable Integer memberId, ModelMap modelMap){

Member member = helloService.logic(memberId);
member.setName("XXXX"); // 보안상의 이유로 XXXX로 세팅해서 내림

modelMap.add("member", member);

return "/member/list";
}
}

뷰를 렌터링 한 후 트랜잭션이 커밋될 것이고, 이때 변경 내역이 플러시되면서 회원의 이름이 XXXX로 변경되는 참사가 발생할 것이다.
프레젠테이션 계층은 데이터를 보여주는 계층이다. 이런 행위가 절대 허용되어서는 안된다.
이를 막기 위한 방법들은 아래와 같다.

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    interface MemberView{
    // 보여줄 애들의 getter만 선언
    public String getName();
    }

    @Entity
    class Member implements MemberView{
    // getter 오버라이드
    }

    class MemberService{
    public MemberView getMember(Integer id){ // memberView 반환
    return memberRepository.findById(id);
    }
    }
  • 엔티티 래핑

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class MemberWrapper{
    private Member member;

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

    public String getName(){
    return member.getName();
    }
    }

    class MemberService{
    public MemberWrapper getMember(Integer id){ // memberView 반환
    return new MemberWrapper(memberRepository.findById(id));
    }
    }
  • DTO
    전통적인 방법으로, 단순히 데이터만 전달하는 객체인 DTO를 만들고 보여줄 엔티티의 값을 세팅해서 내려주는 방법이다.
    사실 이 방법은 OSIV의 장점을 못 살리는 방법이다. 강제로 초기화의 조금 다른 방법일 뿐이다.

이러한 문제점들로 인해 요청 당 트랜잭션은 거의 사용하지 않는다.
요즘에는 서비스 레벨까지만 트랜잭션을 내리는 방법을 사용하는데, 이게 스프링 프레임워크에서 제공하는 OSIV가 선택한 방식이다.

스프링 OSIV

아래는 spring-orm.jar에 들어있는 클래스들 중 하나이다. 필요한 위치에 따라 선택해서 사용하면 된다.

  • JPA OEIV(OSIV) 서블릿 필터

    org.springframework.org.jpa.support.OpenEntityManagerInViewFilter

  • JPA OEIV(OSIV) 스프링 인터셉터

    org.springframework.org.jpa.support.OpenEntityManagerInViewInterceptor

스프링 OSIV는 아래와 같이 OSIV를 사용하기는 하지만 트랜잭션은 비즈니스 계층에서만 사용한다.

스프링 OSIV

  1. 클라이언트 요청이 들어오면 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 트랜잭션은 시작하지 않는다.
  2. 서비스 계층에서 트랜잭션을 시작할 때(@Transactional) 1번에서 생성한 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
  3. 서비스 게층이 끝나면 영속성 컨텍스트를 플러시하고 트랜잭션을 커밋한다. 그리고 트랜잭션을 끝내지만 영속성 컨텍스트는 종료하지 않는다.
  4. 프레젠테이션까지 영속성 컨텍스트가 유지되므로 지연로딩이 가능하다.
  5. 서블릿 필터나 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이때 플러시는 하지 않는다.

    참고로 여기서 강제로 플러시를 호출해도 예외(javax.persistence.TransactionRequiredException이 발생한다)

다시 간단하게 정리하면,

  • 프레젠테이션 계층에서 트랜잭션이 없으므로 엔티티 변경이 불가능하다
  • 하지만 영속성 컨텍스트는 열려있으므로 지연로딩이 가능하다

이런것이 가능한 이유는, 엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 되기 때문이다. 이를 Nontransaction reads라고 한다.
(트랜잭션 없이 엔티티를 변경하면 javax.persistence.TransactionRequireException이 발생한다)

상당히 많은 부분이 해결되었지만, 여기에도 여전히 주의사항이 존재한다.
바로 프레젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 만났을 경우이다.

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
@Controller
class HelloController{
@Autowired
private HelloService helloService;

@GetMapping("/member/{memberId}")
public String addMember(@PathVariable Integer memberId, ModelMap modelMap){

Member member = helloService.logic(memberId);
member.setName("XXXX"); // 보안상의 이유로 XXXX로 세팅해서 내림

helloService.biz();

modelMap.add("member", member);

return "/member/list";
}
}

@Service
class HelloService{
@Transactional
public void biz(){
// ...
}
}

결과는 member의 이름이 XXXX로 바껴버린다.
기존의 트랜잭션 단위로 영속성 컨텍스트를 열던 것을 요청 단위로 영속성 컨텍스트를 여는 것으로 확장했기 때문에
하나의 요청에서 여러 트랜잭션이 있을 경우 영속성 컨텍스트를 공유하게 되고, 위와 같은 현상이 발생하는 것이다.

나는 OSIV를 이용해 엔티티를 프레젠테이션까지 내리는 행위는 딱히 좋지 않다고 생각한다.
위와 같은 문제점도 있고, 엔티티와 프레젠테이션 사이에 논리적인 의존관계가 생기는 것을 막을 수 없게 될 테니까…
전통적인 방법인 필요한 애들만 DTO로 만들어서 내려주는 방법이 가장 괜찮은 것 같긴한데,
이것또한 서비스 영역과 프레젠테이션 영역에 은근한 의존관계가 생긴다는 말을 반박할수는 없는 것 같다.

OSIV 주의사항

영속성 컨텍스트가 리퀘스트까지 열린다는 것은, 커넥션을 유지하는 시간이 늘어남을 의미한다
이는 트래픽이 많아지면 OSIV를 사용하면 영속성 컨텍스트를 유지하는 영역이 트랜잭션 이상으로(request까지) 넓어지고,
이는 커넥션을 맺는 시간이 길어짐을 의미한다
(게다가 하나의 요청내에서는 db작업 외에 다른 많은 작업들이 수행될 수 있으므로(네트워크 통신 등), 커넥션을 계속 열어두는 행위는 좋지않다)

request 까지 커넥션을 유지하게 됨. 데이터베이스에서 데이터를 가져와야 하기 때문이다

이는 성능상 이슈를 발생시킬 수 있으므로(커넥션 풀 개수 부족), OSIV를 끄고 service에서 DTO로 변환해서 내려주는 방식을 택하는 것이 좋다

위에서 반박할 수 없다고 해놓고…