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 설정 이대로 유지 가능하지않은데, 어떡하지
- 뷰에서 필요한 데이터를 미리 불어오기 : 글로벌 페치 전략(LAZY)에서 EAGER로 변경(사용하지않는 엔티티 로딩)
- 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=?
- 해결하기
- join fetch 사용하기 : INNER JOIN으로 한방에 가져오기 - POST가 가진 COMMENT_ID(fk)에 해당하는 Comment 테이블에서 한번에 가져오기(공통된 것만 - INNER JOIN), 1:N에서 중복 데이터가 늘어나니깐 DISTINCT를 사용하면 된다함(여기까지 다시 해보기)
- 해결방법 테스트
- 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);
}