TIL - 0821

JPA, Spring

lazy loading

  • 실제 사용할 때까지 연관관계에 있는 객체를 실제로 데이터 찾고(커넥션) 초기화 하지않음 : 메모리를 아낄 수 있고, 커넥션을 하지않으니 효율적(쓸데 없이 테이블 JOIN을 하지않아도 되는 것임)
    • 대신 원 타입을 상속받은 프록시 객체(가짜)가 할당되어있음 : 해당 객체를 처음 사용(최초 1회 - 메모리 상)하려고할 때 그제서야 데이터베이스에서 데이터를 찾고 객체 초기화를 함
    • 컬렉션을 그대로 get받아도 초기화하지않다가 컬렉션.get(idx)를 할 떄 초기화 : 아래 예시를 보면 hibernate에서 만들어낸 컬렉션 이름이 나옴 - 쿼리도 여전히 날리지않음
    • 영속성 컨텍스트가 존재하고 ORM이 대신해줘서 프록시를 끼워맞춰주기때문에 편하게, 중간 계층 이점을 가져가면서 개발 가능함
Post post = postRepository.findById(4L).get();
System.out.println(post.getComments().getClass().getName());

> org.hibernate.collection.internal.PersistentSet
  • 조심해야할 점 : 준영속 상태 - 영속성컨텍스트가 제거되어 데이터베이스에 요청을 보낼 수 없는 상태, 해당 객체는 데이터베이스 조회 후 생성된 엔티티지만 연관 객체는 프록시 객체 상태임 -> 영속성 컨텍스트가 아닐 경우 LazyInitializationException 발생(초기화 할 수 없는 상태)
    • @Transactional 처리를 지워 단일 쿼리로 만든 다음, 실행 시켰을 때 아래와 같이 예외가 발생함 : 준영속 상태에서 프록시에서 실제 데이터(엔티티)로 초기화를 할 수 없음
Caused by: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: jinbro.springdatajpatest.domain.Post.comments, could not initialize proxy - no Session

Eager loading

  • 두번의 쿼리(SELECT 후 JOIN 쿼리)를 날리지않고 쿼리 최적화를 함 : LEFT OUTER JOIN으로 쿼리 처리 - fk와 같은 pk를 가진 쪽 데이터를 찾아옴
    • OUTER JOIN이 사용된 이유 : POST에는 댓글이 달리지않을 수(Null) 있으니깐 - 컬럼의 조건(+ 참조 개체 무결성), nullable 조건이 NOT NULL이라면 INNER JOIN을 사용
    • INNER JOIN : 두 테이블 ON 조건 공통되는 데이터만 찾아옴
    • OUTER JOIN : LEFT, RIGHT 방향에 따라 한 쪽은 모두 찾아오고, 한 쪽은 조건 만족 하는 것만 찾아옴 없으면 NULL로 채움
  • 바로 사용할 것이라면 Eager를 사용하겠지만 굳이 그게 아닌 이상 프록시로
    • 아래 예제는 트랜잭션으로 묶이지 않은 단일 쿼리 - Eager로 즉시 로딩(fetch)를 했기 때문에 준영속 상태여도 상관없이 댓글 내용을 가져올 수 있음 : fetch를 Lazy로 바꿀 경우 LazyInitializationException 발생할 것
/* Post.java */
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Set<Comment> comments = new HashSet<>();


/* Runner.java */
Post post = postRepository.findById(1L); 
System.out.println(post.getCommentContent());


Hibernate: 
    select
        post0_.id as id1_2_0_,
        post0_.created_date as created_2_2_0_,
        post0_.title as title3_2_0_,
        comments1_.fk_comment_post as fk_comme4_1_1_,
        comments1_.id as id1_1_1_,
        comments1_.id as id1_1_2_,
        comments1_.created_date as created_2_1_2_,
        comments1_.content as content3_1_2_,
        comments1_.fk_comment_post as fk_comme4_1_2_ 
    from
        post post0_ 
    left outer join
        comment comments1_ 
            on post0_.id=comments1_.fk_comment_post 
    where
        post0_.id=?

spring-data-JPA

  • 엔티티매니져를 컨테이너에서 관리함

영속성 컨텍스트 기본 생존범위 - lazy loading은 처리 후 변경하는 계층까지

  • 트랜잭션과 영속성 컨텍스트 범위가 같음 == 트랜잭션 시작시 영속성 컨텍스트 생성 -> 트랜잭션이 종료되면 해당 영속성 컨텍스트가 제거됨(Lazy 상태에서 단일 쿼리일 때 LazyInitializationException 발생하는 이유)
    • 컨텍스트 : 코드가 실행되는 환경 - 어디까지 실행되었는지에 대한 정보와 메모리 정보
    • 같은 트랜잭션이면 같은 영속성 컨텍스트를 사용함 : 쓰레드마다 영속성 컨텍스트가 다름 - 같은 엔티티매니져를 사용해도 각각 다른 컨텍스트를 사용함(캐시 등에 사용되는 메모리가 다르다는 뜻)
    • 보통 서비스 단에 @Transactional로 트랜잭션을 선언해두는데, 서비스 단이 종료되고 컨트롤러 단으로 가면 준영속 상태 : 변경 레이어가 아니기때문에(레이어드 아키텍쳐를 사용하는 이유)

lazy loding 설정 이대로 유지 가능하지않은데, 어떡하지

  1. 뷰에서 필요한 데이터를 미리 불어오기 : 글로벌 페치 전략(LAZY)에서 EAGER로 변경(사용하지않는 엔티티 로딩)
  2. OSIV(Open Session In View)를 사용해서 영속성 컨텍스트 생명을 뷰 레이어에서 사용할 수 있게 살려둠(엔티티 영속 상태 유지)

연관관계 조회 시 생기는 N+1 문제 해결하기

  • 1개의 쿼리로 찾은 결과가 N개일 때 N개 만큼 연관된 객체에 모두 찾기를 함 - JPQL로 만들어져(쿼리 재사용) SQL을 그대로 POST_ID만 바꿔가면서 조회함
    • 아래와 같은 상황이 발생해서(JPQL을 재사용 - 객체 모델 그대로 사용) N+1 문제가 발생함
SELECT * FROM COMMENT WHERE POST_ID=? <-- ?만 POST의 ID값으로 바꿔서 쿼리를 날리게됨
  • N+1 확인하기
    • 찾은 만큼 정확히 4번 실행함 : N+1 확인
/* 모든 포스트를 찾고 - 4개 */
Hibernate: 
    select
        post0_.id as id1_2_,
        post0_.created_date as created_2_2_,
        post0_.title as title3_2_ 
    from
        post post0_
        

/* 아래 쿼리 4번 실행 */
Hibernate: 
    select
        comments0_.fk_comment_post as fk_comme4_1_0_,
        comments0_.id as id1_1_0_,
        comments0_.id as id1_1_1_,
        comments0_.created_date as created_2_1_1_,
        comments0_.content as content3_1_1_,
        comments0_.fk_comment_post as fk_comme4_1_1_ 
    from
        comment comments0_ 
    where
        comments0_.fk_comment_post=?
  • 해결하기
    1. join fetch 사용하기 : INNER JOIN으로 한방에 가져오기 - POST가 가진 COMMENT_ID(fk)에 해당하는 Comment 테이블에서 한번에 가져오기(공통된 것만 - INNER JOIN), 1:N에서 중복 데이터가 늘어나니깐 DISTINCT를 사용하면 된다함(여기까지 다시 해보기)
  • 해결방법 테스트
    1. join fetch는 INNER JOIN으로 변환되기때문에 디비에서 실행시켜봄
springdata=# select p.*, c.* from post as p inner join comment as c on p.id=c.fk_comment_post;
 id | content |      created_date       | title  | id |   content    | fk_comments_post |      created_date       | fk_comment_post
----+---------+-------------------------+--------+----+--------------+------------------+-------------------------+-----------------
  2 |         | 2018-08-21 17:50:02.707 | ㅂㅇ |  3 | 안녕       |                  | 2018-08-21 17:50:02.707 |               2
  5 |         | 2018-08-21 17:52:00.331 | ㅎㅇ |  6 | 안녕       |                  | 2018-08-21 17:52:00.331 |               5
  2 |         | 2018-08-21 17:50:02.707 | ㅂㅇ |  7 | 안녕안녕 |                  | 2018-08-21 18:18:26.002 |               2

프레젠테이션계층에서도 lazy loading 하기

  • OSIV : 뷰에서도 지연로딩이 가능하도록 - 스프링부트에서는 기본 설정으로 open-in-view 설정이 되어있음(원래는 필터를 생성하고 적용시켜줘야하나 스프링부트는 기본 설정..)
    • 가장 우려되는 지점이자 조심해야할 것 : 뷰 레이어에서 데이터에 대한 변경을 할 수 없게끔 막아야함 - 뷰 랜더링 후 데이터베이스 커밋하기때문에 변경이 그대로 반영됨, 장애 발생 시 원래 변경점이 아닌 곳까지 뒤져봐야하는 상황이 옴
    • 이전에는 위와 같은 문제로 DTO로 반환하기 : 수정못하도록 - 콜렉션을 그대로 담아서 주거나 객체를 그대로 담아서 주면(아직까지는 프록시) 실제 사용할 때 로딩됨
    • 아래는 실제로 lazy loading을 컨트롤러에서 할 수 있는지 테스트 해본 결과 : OSIV가 설정되어있어서 영속성컨텍스트가 더 늘어남
    • 테스트하고나니 스프링 OSIV는 트랜잭션을 비즈니스 계층에서 다끝내고(영속성 컨텍스트 flush -> commit) 변경할 수 없게 만든 다음 읽기 전용 SQL : DTO는 메모리 상에서도 변경하지말라는 이유로 그냥…냅두자…. 어차피 DTO의 의미가 비즈니스 계층에서 처리한 뒤 상태 프리징 시키고 (계층 이동용 - 사실상 아래코드는 이동도 안되긴한다…. 이왕 배운 내용 테스트 위해서 구현했으니 확인 잘했다는 뜻에서 살려두기)이니깐…. 또한 프레젠테이션 계층에서 서비스에 요청을 해서 새로운 트랜잭션 처리릃 버리면 변경될 수 있으니 막는 의미로 :)
    • 결론적으로 비즈니스 계층이 아니고 프레젠테이션 계층에서도 지연로딩이 된다! : 스프링부트는 더 쉽다
/* 스프링부트 기본 설정 */
spring.jpa.open-in-view=true # Register OpenEntityManagerInViewInterceptor. Binds a JPA EntityManager to the thread for the entire processing of the request.


/* 실제 테스트 - 변경할 수 없도록 DTO에 담아서 보내줌 */
16:56:01.352 [DEBUG] [http-nio-auto-1-exec-1] [jinbro.osivtest.domain.Post] - comments type : org.hibernate.collection.internal.PersistentSet
16:56:01.353 [DEBUG] [http-nio-auto-1-exec-1] [jinbro.osivtest.domain.dto.PostDto] - comments type : org.hibernate.collection.internal.PersistentSet


@Controller
@RequestMapping("/posts")
public class PostController {
    
    @GetMapping("/{id}")
    public String findById(@PathVariable Long id, Model model) {
        PostDto post = postService.get(id).toDto();
        model.addAttribute("post", post);
        post.getCommentContent(); // 여기서 로딩을 해도 LazyInitializationException이 발생하지않음
        return "/post/show";
    }
}

spring-mvc

  • 개발하다 다시 짚어본 서비스 계층을 만드는 이유 : 인프라 로직을 둘 수 있으니깐 - 트랜잭션, repository에 엔티티를 찾아오라 시켰는데 없을 경우에 대한 처리를 할 수 있기도 하고
/* PostService.class */
public Post get(Long id) {
    return postRepository.findById(id).orElseThrow(EntityNotFoundException::new);
}


/* CommentService.class */
@Transactional
public Comment create(CommentDto commentDto, Long postId) {
    Post post = postService.get(postId);
    Comment comment = commentDto.toComment(post);
    return commentRepository.save(comment);
}