FetchType
연관된 객체를 언제 가져올 것인지를 정한다. 즉시(Eager)와 지연(Lazy)가 있다.
FetchType.EAGER : @XXToOne에서 FetchType의 기본 값이다.
FetchType.LAZY : @XXToMany에서 FetchType의 기본 값이다.
즉시 로딩
@ManyToOne(fetch =FetchType.EAGER) //@ManyToOne은 기본 fetch가 Eager이므로 () 생략가능
private Product product;
FetchType.EAGER
연관관계에 있는 모든 개체를 join하여 가져온다.
JPQL : 즉시 가져오므로 연관관계의 객체 수가 N개일때 N+1 조회(select)
(EntityManager.find()는 최적화 되므로 N+1 문제 발생 X)
@Import(ObjectMapper.class)
@DataJpaTest
public class BoardRepositoryTest extends DummyEntity {
@Autowired
private UserRepository userRepository;
@Autowired
private EntityManager em;
@Autowired
private ObjectMapper om;
@BeforeEach
public void setUp() {
em.createNativeQuery("ALTER TABLE user_tb ALTER COLUMN id RESTART WITH 1").executeUpdate();
User ssar = userRepository.save(newUser("ssar"));
User cos = userRepository.save(newUser("cos"));
em.clear(); //영속성 컨텍스트 비우기(비우지 않으면 캐싱값에서 가져와서 DB에 쿼리 안나간다.)
}
@Test
@DisplayName("eager 조회 쿼리 - board에서 eager 붙이기")
public void findById_user_eager_test() {
// given
int id = 1;
// when
boardRepository.findById(1); //eager : Left outer join 쿼리 나감, default가 eager, 관련된 것 다 가져온다.
// then
}
}
결과
board의 id 조회시에 user 테이블과 left outer join한 후 조회한다.
지연 로딩
@ManyToOne(fetch =FetchType.LAZY)
private Product product;
FetchType.Lazy
실제 사용시점에 조회한다.
JPQL : 연관관계의 객체의 필드 값이 필요하지 않다면 조회 쿼리는 1번(필요할때 select함)
연관관계 객체의 필드 값이 필요하다면 가져와야하므로 N+1번 조회
테스트 코드
@Import(ObjectMapper.class)
@DataJpaTest
public class BoardRepositoryTest extends DummyEntity {
@Autowired
private UserRepository userRepository;
@Autowired
private EntityManager em;
@Autowired
private ObjectMapper om;
@BeforeEach
public void setUp() {
em.createNativeQuery("ALTER TABLE user_tb ALTER COLUMN id RESTART WITH 1").executeUpdate();
User ssar = userRepository.save(newUser("ssar"));
User cos = userRepository.save(newUser("cos"));
em.clear(); //영속성 컨텍스트 비우기(비우지 않으면 캐싱값에서 가져와서 DB에 쿼리 안나간다.)
}
@Test
@DisplayName("lazy 조회 쿼리 - board에서 lazy 붙이기")
public void findById_user_lazy_test() {
// given
int id = 1;
// when
boardRepository.findById(1); //join되지 않고 board만 select 쿼리
// then
}
}
결과
board의 id 조회시 board만 가져온다.
참조 객체인 user는 프록시 객체(가짜 객체)이며 외래키(user의 id)값만 존재한다.
✳️ findAll() 함수 수행할 경우, board 테이블 조회 1번 + 연관관계에 있는 유저 객체 갯수 N만큼 조회(캐싱 후에는 조회 X)
프록시 객체
em.find(Product.class, "product1"); //PC에 Entity 없으면 DB 조회
em.getReference(Product.class, "product2"); //Entity 실제 사용 전까지 DB 조회 미룸, 프록시 객체 반환
✅ board 조회시 연관관계에 있는 user는 조회하지 않으므로 실제 객체가 아닌 프록시 객체를 만들어 집어넣는다.
✅ 프록시 객체는 실제 객체를 상속받아 생성되므로 실제 객체와 모습이 같다.
✅ 프록시 객체는 실제 객체에 대한 참조를 가지고 있으므로, 프록시 객체의 메서드 호출시 실제 객체의 메서드를 호출한다.
프록시 객체의 초기화
em.getReference(Product.class, "product2"); //프록시 객체 반환
product2.getPrice(); //프록시 초기화 수행
프록시 객체가 참조하는 실제 Entity가 PC에 생성되지 않았을때, (target=null)
PC에 실제 Entity 생성을 요청하고 생성된 실제 Entity를 프록시 객체의 참조 변수에 할당하는 과정
참조 객체의 외래키가 아닌 속성이 필요할 경우
1. PC에 실제 참조하는 Entity가 존재하면 그 Entity로 속성을 조회(select)한다.
2. 만약 프록시 객체가 참조하는 실제 Entity가 PC에 존재하지 않을때, (프록시 객체가 초기화되지 않았을때)
1. PC에 실제 Entity 생성을 요청한다. (프록시 초기화 요청)
2. PC는 DB를 조회하여 실제 Entity를 생성한다.
3. Proxy 객체의 참조 변수(target)에 실제 Entity에 대한 참조를 저장한다.
4.프록시 객체는 실제 Entity의 메서드를 호출하여 속성을 얻는다. ( target.get속성() )
✅ 만약 PC에 실제 객체가 이미 존재한다면 프록시 객체가 아닌 실제 객체를 반환한다.
✅ 즉시 로딩에서는 바로 실제 객체를 가져오므로 초기화 과정이 없다.
✅ 프록시 객체는 처음 사용할때 한번만 초기화된다.
✅ 준영속 상태의 프록시를 초기화하면 PC에 존재하지 않으므로 오류가 발생한다.
Q. 만약 프록시 객체 초기화 후 PC를 모두 없앴다면 (em.clear()) 프록시는 다시 초기화 되어야할까?
지연 로딩 : 참조 객체의 외래키 아닌 속성 참조
@Test
@DisplayName("lazy 조회 쿼리 - board에서 lazy 붙이기")
public void findById_user_lazy_loading_test() {
// given
int id = 1;
// when
Optional<Board> boardOP = boardRepository.findById(1); //board만 select
if (boardOP.isPresent()) {
System.out.println("테스트 : board 객체를 꺼낸다.");
Board boardPS = boardOP.get(); //user 객체 없는 상황
System.out.println("테스트 : board 객체의 User의 id를 불러본다 : FK로 들고 있는 값이기 때문에 Lazy 로딩안됨");
boardPS.getUser().getId(); // user 객체 id값은 있다. (외래키는 들고 있음)
System.out.println("테스트 : board 객체의 User의 username를 불러본다 : Lazy 로딩 발동");
boardPS.getUser().getUsername(); //select user 쿼리 나감
}
// then
}
}
결과
board를 조회한 후 user 프록시 객체가 초기화되고 조회(select)한다. (조회 쿼리 2번 수행)
지연 로딩 설정한 Entity를 JSON으로 직렬화 시 오류
@Test
@DisplayName("프록시 객체 직렬화 에러 발생")
public void findById_user_lazy_loading_message_converter_fail_test() throws JsonProcessingException {
// given
int id = 1;
// when
Optional<Board> boardOP = boardRepository.findById(1);
if (boardOP.isPresent()) {
System.out.println("테스트 : board 객체를 꺼낸다.");
Board boardPS = boardOP.get(); //프록시 객체 가져오기
boardPS.getUser().getId();
boardPS.getUser().getUsername(); //프록시 초기화 수행
boardPS.getUser().getPassword();
boardPS.getUser().getEmail();
System.out.println("테스트 : MessageConverter를 발동시킨다.");
String responseBody = om.writeValueAsString(boardPS); //에러 발생 (프록시 객체 직렬화 X)
System.out.println("테스트 : " + responseBody);
}
// then
}
스프링 부트는 Entity를 반환하면 JSON으로 직렬화한다.
이때 지연로딩으로 인한 Message Converter 오류가 발생할 수 있다.
=> 실제 객체가 아닌 Proxy 객체이므로 DB를 조회해서 값을 가져오기까지 시간이 걸린다.
결국 MessageConverter 발동보다 늦게 값을 가져오고, 오류가 발생한다.
또한 미리 Lazy 로딩을 하여 DB 조회를 끝내더라도, 실제 Entity는 프록시 객체를 통해 접근하므로
프록시 객체를 직렬화할 수 없어 오류가 발생한다.
해결 방법 1 : DTO 생성
DTO 코드
public class BoardDTO {
private int id;
private String title;
private String content;
private UserDTO user;
public BoardDTO(Board board) {
this.id = board.getId();
this.title = board.getTitle();
this.content = board.getContent();
this.user = new UserDTO(board.getUser());
}
public class UserDTO {
private Integer id;
private String username;
private String email;
// DTO의 생성자로 Lazy Loading을 한다.
public UserDTO(User user) {
this.id = user.getId();
this.username = user.getUsername();
this.email = user.getEmail();
}
// getter - setter (테스트에서는 lombok 사용못함)
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
//lombok의 getter와 setter가 적용이 안되어 직접 생성
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public UserDTO getUser() {
return user;
}
public void setUser(UserDTO user) {
this.user = user;
}
}
테스트 코드
@DisplayName("프록시 객체 직렬화 에러 발생 해결 : DTO 사용")
public void findById_user_lazy_loading_message_converter_dto_test() throws JsonProcessingException {
// given
int id = 1;
// when
Optional<Board> boardOP = boardRepository.findById(1);
if (boardOP.isPresent()) {
System.out.println("테스트 : board 객체를 꺼낸다.");
Board boardPS = boardOP.get();
System.out.println("테스트 : DTO를 만들면서 Lazy 로딩을 한다.");
BoardDTO boardDTO = new BoardDTO(boardPS);
System.out.println("테스트 : MessageConverter를 발동시킨다.");
String responseBody = om.writeValueAsString(boardDTO);
System.out.println("테스트 : " + responseBody);
}
// then
}
DTO를 이용하면 DTO 생성시 필요한 값만 가져오므로 프록시 객체의 초기화 과정에서 DB 조회하는 것보다 빠르다.
=> 오류 발생 X
해결방법2 : Fetch Join사용
쿼리
public interface BoardRepository extends JpaRepository<Board, Integer> {
@Query("select b from Board b join b.user where b.id = :id") //join
Optional<Board> mFindByIdJoinUser(@Param("id") int id);
@Query("select b from Board b join fetch b.user where b.id = :id") //join fetch, 연관된 객체의 필드 가져옴
Optional<Board> mFindByIdJoinFetchUser(@Param("id") int id);
}
테스트코드
@Test
public void findById_user_lazy_loading_message_converter_join_fetch_test() throws JsonProcessingException {
// given
int id = 1;
// when
// Optional<Board> boardOP = boardRepository.mFindByIdJoinUser(1); //join, 직렬화 불가
Optional<Board> boardOP = boardRepository.mFindByIdJoinFetchUser(1); //join fetch, 직렬화 가능(연관된 객체 필드 가져옴)
if (boardOP.isPresent()) {
Board boardPS = boardOP.get();
String responseBody = om.writeValueAsString(boardPS);
System.out.println("테스트 : " + responseBody);
}
// then
}
✅ join은 join 후 연관된 객체 필드를 가져오지 않지만, fetch join은 join 후 연관된 객체 필드를 가져온다.
⬇️ Fetch Join은 아래에 더 자세한 설명 있다.
실무에서는 지연 로딩 사용
📌 예상할 수 없는 SQL문 발생 : 즉시 로딩 사용시 연관관계에 있는 모든 값을 가져오므로 문제가 발생한다.
📌 N+1 문제 발생
JPQL 쿼리는 즉시 로딩시 영속성 컨텍스트를 고려하지 않고 항상 DB 먼저 조회
지연로딩에서는 외래키가 아닌 속성을 조회할 경우 N+1문제 발생
✅ JPQL의 select * from Product 쿼리 수행시 Product와 연관관계에 있는 테이블 N개의 쿼리를 수행하는 문제가 발생한다.
batch fetch size 설정 : in query
결과
Hibernate:
select
board0_.id as id1_0_,
board0_.content as content2_0_,
board0_.title as title3_0_,
board0_.user_id as user_id4_0_
from
board_tb board0_
Hibernate:
select
user0_.id as id1_2_0_,
user0_.email as email2_2_0_,
user0_.password as password3_2_0_,
user0_.username as username4_2_0_
from
user_tb user0_
where
user0_.id in (
?, ?
)
default_batch_fetch_size 옵션을 설정하면 지연 로딩이든, eager 로딩이던 size 설정한만큼 in query로 발동된다.
위 설정을 통해 지연 로딩시 적은 쿼리를 보내어 성능이 향상된다.
Fetch vs Join Fetch
repository.java
public interface BoardRepository extends JpaRepository<Board, Integer> {
@Query("select b from Board b join b.user where b.id = :id") //join
Optional<Board> mFindByIdJoinUser(@Param("id") int id);
@Query("select b from Board b join fetch b.user where b.id = :id") //join fetch
Optional<Board> mFindByIdJoinFetchUser(@Param("id") int id);
}
테스트 코드
@Test
public void findById_user_lazy_loading_message_converter_join_fetch_test() throws JsonProcessingException {
// given
int id = 1;
// when
Optional<Board> boardOP = boardRepository.mFindByIdJoinUser(1); //join, 직렬화 불가
// Optional<Board> boardOP = boardRepository.mFindByIdJoinFetchUser(1); //join fetch, 직렬화 가능
if (boardOP.isPresent()) {
Board boardPS = boardOP.get();
String responseBody = om.writeValueAsString(boardPS);
System.out.println("테스트 : " + responseBody);
}
// then
}
join 결과
LAZY더라도 inner join 수행, 참조 객체 가져오지 않아 직렬화에서 에러 발생
select 문을 보면 Inner join 후 user 객체의 값은 가져오지 않음을 볼 수 있다.
fetch를 적지 않으면 기본적으로 join 수행된다.
join fetch 결과
✅ join fetch는 inner join 후 연관관계 객체까지 1번만 조회하므로 N+1문제가 발생하지 않는다.
FetchType.EAGER(N+1번) != join fetch(1번)
✅ fetch join은 FetchType 전략을 무시한다.
Inner join 후 user 객체의 값도 가져온다.
직렬화가 가능하다.
게시글 상세 조회시 join fetch (연관관계 여러개)
게시글, 유저 Entity 존재
Reply.java
@ToString
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity //JPA 객체 매핑
@Table(name="reply_tb") // 테이블 매핑
public class Reply {
@Id //기본키 매핑
@GeneratedValue(strategy = GenerationType.IDENTITY) //id auto increment
private Integer id;
@ManyToOne(fetch = FetchType.LAZY) //연관관계 매핑 : Reply: User = N:1
private User user;
@ManyToOne(fetch = FetchType.LAZY)
private Board board;
@Column(nullable = false) //Field와 Column 매핑
private String comment;
}
replyRepository.java
public interface ReplyRepository extends JpaRepository<Reply, Integer> {
@Query("select r from Reply r join fetch r.user")
List<Reply> mFindByBoardId(@Param("boardId") int boardId, Pageable pageable);
List<Reply> findByBoardId(@Param("boardId") int boardId); //기본적으로 join 수행
}
boardRepository.java
public interface BoardRepository extends JpaRepository<Board, Integer> {
@Query("select b from Board b join b.user where b.id = :id") //join
Optional<Board> mFindByIdJoinUser(@Param("id") int id);
@Query("select b from Board b join fetch b.user where b.id = :id") //join fetch, 연관된 객체의 필드 가져옴
Optional<Board> mFindByIdJoinFetchUser(@Param("id") int id);
}
setup.java
@DataJpaTest
public class ReplyRepositoryTest extends DummyEntity {
@Autowired
private BoardRepository boardRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private ReplyRepository replyRepository;
@Autowired
private EntityManager em;
// 더미 데이터를 잘 기억해야 된다. 시나리오를 짜보자
@BeforeEach
public void setUp() {
em.createNativeQuery("ALTER TABLE user_tb ALTER COLUMN id RESTART WITH 1").executeUpdate();
em.createNativeQuery("ALTER TABLE board_tb ALTER COLUMN id RESTART WITH 1").executeUpdate();
User ssar = userRepository.save(newUser("ssar"));
User cos = userRepository.save(newUser("cos"));
User love = userRepository.save(newUser("love"));
Board board1 = boardRepository.save(newBoard("자바 재밌다", ssar));
replyRepository.save(newReply("자바 공부 그만하고 휴가가자", cos, board1));
replyRepository.save(newReply("댓글 씹는거야?", cos, board1));
replyRepository.save(newReply("미안해 자기야 공부하다가 바빳어", ssar, board1));
replyRepository.save(newReply("ㅡㅡ;", love, board1));
replyRepository.save(newReply("ㅡㅡ;", love, board1));
replyRepository.save(newReply("ㅡㅡ;", love, board1));
replyRepository.save(newReply("ㅡㅡ;", love, board1));
em.clear(); //영속성 컨텍스트 제거하여 쿼리 날라가는지 확인
}
}
1. select 3번 하기
// ManyToOne을 모두 Lazy 전략 변경한다.
@Test
public void board_detail_test1() {
// given
int boardId = 1;
// when
// 1. 게시글과 작성자 정보를 찾는다. (board1, ssar) - 영속화 되어 있음
Board boardPS = boardRepository.mFindByIdJoinFetchUser(boardId).orElseThrow( //join fetch
() -> new RuntimeException("게시글을 찾을 수 없어요")
);
// 2. 댓글을 불러 온다. (reply1)
List<Reply> replyList = replyRepository.findByBoardId(boardId);
// 3. reply1에 연관되어 있는 board1 정보는 찾지 않아도 된다. (board1은 위에서 이미 조회되었음)
// 4. reply1에 연관되어 있는 cos 정보는 찾아야 한다. in query로 찾으면 된다. lazy loading 한다.
replyList.forEach(reply -> {
reply.getUser().getUsername(); //select 쿼리 나감(외래키가 아닌 속성 참조)
});
// then
}
결과
select
board0_.id as id1_0_0_,
user1_.id as id1_2_1_,
board0_.content as content2_0_0_,
board0_.title as title3_0_0_,
board0_.user_id as user_id4_0_0_,
user1_.email as email2_2_1_,
user1_.password as password3_2_1_,
user1_.username as username4_2_1_
from
board_tb board0_
inner join
user_tb user1_
on board0_.user_id=user1_.id
where
board0_.id=?
Hibernate:
select
reply0_.id as id1_1_,
reply0_.board_id as board_id3_1_,
reply0_.comment as comment2_1_,
reply0_.user_id as user_id4_1_
from
reply_tb reply0_
left outer join
board_tb board1_
on reply0_.board_id=board1_.id
where
board1_.id=?
Hibernate:
select
user0_.id as id1_2_0_,
user0_.email as email2_2_0_,
user0_.password as password3_2_0_,
user0_.username as username4_2_0_
from
user_tb user0_
where
user0_.id in (
?, ?
)
2. fetch join으로 2번 조회 (추천) - 다중 fetch ⭐️
@Test
public void board_detail_test2() {
// given
int boardId = 1;
// when
// 1. 게시글과 작성자 정보를 찾는다. (board1, ssar) - 영속화 됨, Board는 User와 연관관계 가짐
Board boardPS = boardRepository.mFindByIdJoinFetchUser(boardId).orElseThrow( //join fetch
() -> new RuntimeException("게시글을 찾을 수 없어요")
);
// 2. 댓글을 불러 온다. (reply1, user들) - 영속화 됨, 찾은 Board로 Reply 찾기
PageRequest pageRequest = PageRequest.of(0, 4);
List<Reply> replyList = replyRepository.mFindByBoardId(boardId, pageRequest); //join fetch
// then
}
결과
Hibernate:
select
board0_.id as id1_0_0_,
user1_.id as id1_2_1_,
board0_.content as content2_0_0_,
board0_.title as title3_0_0_,
board0_.user_id as user_id4_0_0_,
user1_.email as email2_2_1_,
user1_.password as password3_2_1_,
user1_.username as username4_2_1_
from
board_tb board0_
inner join
user_tb user1_
on board0_.user_id=user1_.id
where
board0_.id=?
Hibernate:
select
reply0_.id as id1_1_0_,
user1_.id as id1_2_1_,
reply0_.board_id as board_id3_1_0_,
reply0_.comment as comment2_1_0_,
reply0_.user_id as user_id4_1_0_,
user1_.email as email2_2_1_,
user1_.password as password3_2_1_,
user1_.username as username4_2_1_
from
reply_tb reply0_
inner join
user_tb user1_
on reply0_.user_id=user1_.id limit ?
3. 양방향 매핑하여 한방 쿼리로 가져오기 (추천X)
select
board0_.id as id1_0_0_,
user1_.id as id1_2_1_,
replys2_.id as id1_1_2_,
user3_.id as id1_2_3_,
board0_.content as content2_0_0_,
board0_.title as title3_0_0_,
board0_.user_id as user_id4_0_0_,
user1_.email as email2_2_1_,
user1_.password as password3_2_1_,
user1_.username as username4_2_1_,
replys2_.board_id as board_id3_1_2_,
replys2_.comment as comment2_1_2_,
replys2_.user_id as user_id4_1_2_,
replys2_.board_id as board_id3_1_0__,
replys2_.id as id1_1_0__,
user3_.email as email2_2_3_,
user3_.password as password3_2_3_,
user3_.username as username4_2_3_
from
board_tb board0_
inner join
user_tb user1_
on board0_.user_id=user1_.id
inner join
reply_tb replys2_
on board0_.id=replys2_.board_id
inner join
user_tb user3_
on replys2_.user_id=user3_.id
4. native query 한방 쿼리로 가져오기 (추천X)
쿼리 재사용성이 없다.
통계쿼리 짤때 추천
@Transactional, flush
@Test
public void update_test() {
// given
int id = 1;
String title = "title1 update";
String content = "content1 update";
// when
Optional<Board> boardOP = boardRepository.findById(id);
if (boardOP.isPresent()) {
Board boardPS = boardOP.get();
boardPS.update(title, content); //값 update
}
em.flush(); //flush 해야 쿼리 날라감, 아니면 @Transational로 인해 rollback됨(쿼리 안날라감)
// then
}
flush() 코드를 작성하지 않으면 @DataJpaTest의 @Transaction으로 인해 rollback되어 쿼리가 날라가지 않는다.
'Spring > Spring 개발 상식' 카테고리의 다른 글
JDBC, MyBatis, JPA (0) | 2023.07.12 |
---|---|
@Builder와 @Getter, @Setter (1) | 2023.07.10 |
DTO 생성 방법 (0) | 2023.07.10 |
DAO vs DTO vs VO (2) | 2023.07.10 |
Spring Security Test (0) | 2023.07.08 |