2021. 10. 12. 11:42ㆍJAVA/Spring
이전글
https://yonghwankim-dev.tistory.com/141
SpringBoot #5 다양한 연관관계 처리 #3 단방향 처리2
이전글 https://yonghwankim-dev.tistory.com/140 SpringBoot #5 다양한 연관관계 처리 #2 단방향 처리1 이전글 https://yonghwankim-dev.tistory.com/139 SpringBoot #5 다양한 연관관계 처리 #1 연관관계 처리..
yonghwankim-dev.tistory.com
본 글은 스타트 스프링 도서의 내용을 복습하기 위해 작성된 글입니다.
개요
- 객체 간 연관관계 설정
- 단방향, 양방향 관계의 이해
- JPQL을 이용한 @Query 처리와 Fetch JOIN(스프링 부트 2.0.0)
JPA에서 엔티티 클래스의 양방향 참조는 관계 있는 객체들의 참조를 양쪽에서 가지고 있는 것입니다. 단방향에서 비해서 설정은 수월하지만 데이터의 관리 측면에서는 조금 더 신경써야 합니다.
예를 들어 게시물과 댓글의 관계는 전형적인 '일대다', '다대일'입니다. 이번 예제에서는 양방향으로 처리하는 형태로 게시물과 댓글의 관계를 작성해봅니다.
FreeBoard 엔티티 클래스 생성 (org.zerock.domain.FreeBoard)
@Getter
@Setter
@ToString
@Entity
@Table(name="tbl_freeboards")
@EqualsAndHashCode(of="bno")
public class FreeBoard {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String writer;
private String content;
@CreationTimestamp
private Timestamp regdate;
@UpdateTimestamp
private Timestamp updatedate;
}
FreeBoardReply 엔티티 클래스 생성 (org.zerock.domain.FreeBoardReply)
@Getter
@Setter
@ToString
@Entity
@Table(name="tbl_free_replies")
@EqualsAndHashCode(of="rno")
public class FreeBoardReply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
private String reply;
private String replyer;
@CreationTimestamp
private Timestamp replydate;
@UpdateTimestamp
private Timestamp updatedate;
}
1. 연관관계의 설정
게시물과 댓글의 관게는 '일대다', '다대일'의 관계이고 양방향으로 설정할 것이기 때문에 FreeBoard 엔티티 클래스와 FreeBoardReply 엔티티 클래스는 필드멤버로 서로를 참조합니다.
FreeBoard 엔티티 클래스 수정
public class FreeBoard {
... 생략
@OneToMany
private List<FreeBoardReply> replies;
}
FreeBoardReply 엔티티 클래스 수정
public class FreeBoardReply {
... 생략
@ManyToOne
private FreeBoard board;
}
위 상태에서 프로젝트를 실행하면 예상과 달리 3개의 테이블이 생성됩니다. tbl_freeboards, tbl_free_replies, tbl_freeboards_replies 테이블이 생성됩니다. 양쪽 중간에 지정하지 않은 tbl_freeboards_replies 테이블이 생성된 이유는 @OneToMany에 있습니다. @OneToMany 관계를 저장하려면 중간에 '다(Many)'에 해당하는 정보를 보관하기 위해서 JPA의 구현체는 별도의 테이블을 생성합니다. 이를 해결하기 위해서는 @OneToMany 속성에 mappedBy 속성을 설정해주어야 합니다.
1.1 mappedBy 속성
데이터베이스 상에서 관계를 맺는 방법이 PK, FK만을 사용해서 지정되지만, JPA에서는 양쪽이 모두 참조를 사용하는 겨우에는 어떤 쪽이 PK가 되고, 어떤 쪽이 FK가 되는지를 명시해줄 필요가 있습니다.
JPA에서는 관계를 설정할때 PK 쪽이 mappedBy라는 속성을 사용해서 자신이 다른 객체에게 '매여있다'는 것을 명시하게 됩니다.
FreeBoard 수정
public class FreeBoard {
... 생략
@OneToMany(mappedBy = "board")
private List<FreeBoardReply> replies;
}
mappedBy="board"의 의미는 FreeBoardReply 엔티티 클래스에 FreeBoard 타입의 board 객체명에 매여 있다는 의미입니다.
FreeBoard 엔티티 클래스를 수정하고 프로젝트를 실행합니다.
위의 테스트 결과에서 tbl_free_replies 테이블을 보면 board_bno 이름의 칼럼이 추가된 것을 확인할 수 있습니다. 이 칼럼은 tbl_freeboards 테이블의 bno PK에 대한 FK입니다.
1.2 양방향 설정과 toString()
Lombok 라이브러리를 사용하고 양방향 참조를 하는 경우에는 양쪽에서 toString()을 실행하기 때문에, 무한히 toString()을 반복 실행하는 문제가 발생합니다. 이를 해결하기 위해서는 반드시 한쪽은 toString()에서 참조하는 객체를 출력하지 않도록 수정해야 합니다.
FreeBoard 엔티티 클래스에 replies 참조를 toString()에서 제외
org.zerock.domain.FreeBoard
@Getter
@Setter
@ToString(exclude = "replies")
@Entity
@Table(name="tbl_freeboards")
@EqualsAndHashCode(of="bno")
public class FreeBoard {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String writer;
private String content;
@CreationTimestamp
private Timestamp regdate;
@UpdateTimestamp
private Timestamp updatedate;
@OneToMany(mappedBy = "board")
private List<FreeBoardReply> replies;
}
FreeBoardReply 엔티티 클래스에 board 참조를 toString()에서 제외
org.zerock.domain.FreeBoardReply
@Getter
@Setter
@ToString(exclude = "board")
@Entity
@Table(name="tbl_free_replies")
@EqualsAndHashCode(of="rno")
public class FreeBoardReply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
private String reply;
private String replyer;
@CreationTimestamp
private Timestamp replydate;
@UpdateTimestamp
private Timestamp updatedate;
@ManyToOne
private FreeBoard board;
}
2. Repository 설정
이전 포스팅에서 자료와 첨부파일은 동일한 라이프 사이클을 가져서 첨부파일(PDSFile)에 대한 Repository가 필요없었습니다. 하지만 지금 게시물과 댓글의 관계를 생각해보면 게시물의 작성과 댓글의 작성은 관계없이 별도로 이루어집니다. 댓글의 수정이나 삭제 역시 게시물 자체에는 영향을 주지 않으므로, 별도의 Repository로 작성하는 것이 좋습니다.
org.zerock.persistence.FreeBoardRepository
public interface FreeBoardRepository extends CrudRepository<FreeBoard, Long>{
}
org.zerock.persistence.FreeBoardReplyRepository
public interface FreeBoardReplyRepository extends CrudRepository<FreeBoardReply, Long>{
}
3. 테스트 코드
테스트 관련 폴더에 FreeBoardTests 테스트 클래스 생성
3.1 게시물 등록과 댓글 추가
댓글이 존재하기 위해서는 먼저 게시물이 존재해야 합니다. 따라서 게시물을 등록하는 테스트 코드를 생성합니다.
@SpringBootTest
@Log
@Commit
class FreeBoardTests {
@Autowired
FreeBoardRepository boardRepo;
@Autowired
FreeBoardReplyRepository replyRepo;
// 게시물 샘플 데이터 생성
@Test
public void insertDummy() {
IntStream.range(1, 200).forEach(i->{
FreeBoard board = new FreeBoard();
board.setTitle("Free Board..." +i);
board.setContent("Free Content..."+i);
board.setWriter("user"+i%10);
boardRepo.save(board);
});
}
}
게시물을 추가했으니 이번에는 게시물에 댓글을 추가하도록 합니다. 게시물에 댓글을 추가하는 방식은 2가지 방식을 사용할 수 있습니다.
- 단방향에서 처리하듯이 FreeBoardReply를 생성하고, FreeBoard 자체는 새로 만들어서 bno 속성만을 지정하여 처리하는 방식
- 양방향이므로 FreeBoard 객체를 얻어온 후 FreeBoardReply를 댓글 리스트에 추가한 후에 FreeBoard 자체를 저장하는 방식
이전 포스팅에서는 단방향에서 처리하였으므로 이번에는 양방향으로 처리하는 방식을 사용하도록 합니다.
org.zerock.FreeBoardTests 일부
// 양방향으로 FreeBoard 객체 얻어온 후 FreeBoardReply를
// 댓글 리스트에 추가한 후에 FreeBoard 자체를 저장하는 방식
@Test
public void insertReply2Way() {
Optional<FreeBoard> result = boardRepo.findById(199L);
result.ifPresent(board ->{
List<FreeBoardReply> replies = board.getReplies();
FreeBoardReply reply = new FreeBoardReply();
reply.setReply("REPLY...");
reply.setReplyer("replyer00");
reply.setBoard(board);
replies.add(reply);
board.setReplies(replies);
boardRepo.save(board);
});
}
위 테스트 코드를 실행하면 아래와 같은 결과를 얻습니다.
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: org.zerock.domain.FreeBoard.replies, could not initialize proxy - no Session
at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:606)
...
에러의 원인은 replies에 add()를 하려면 기존에 어떤 댓글이 존재하는지 확인해야 하는데, 데이터베이스와 연결된 이후에 'select'를 한번 실행해 FreeBoard 객체를 가져와 버렸기 때문에, 추가적으로 다시 연결이 필요한 'insert'를 실행할 수 없게 된 것입니다.
이를 해결하기 위해서는 1) 게시물이 저장될 때 댓글이 같이 저장되도록 cascading 처리가 되어야 하고, 2) 댓글 쪽에도 변경이 있기 때문에 트랜잭션을 처리해주어야 합니다.
insertReply2Way()에 @Transactional 처리와 cascade 속성을 지정하면 댓글이 추가될 것입니다.
@Transactional
@Test
public void insertReply2Way() {
... 생략
}
public class FreeBoard {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String writer;
private String content;
@CreationTimestamp
private Timestamp regdate;
@UpdateTimestamp
private Timestamp updatedate;
@OneToMany(mappedBy = "board",
cascade = CascadeType.ALL
)
private List<FreeBoardReply> replies;
}
4. 게시물의 페이징 처리와 @Query
양방향 처리는 단방향의 제한적인 접근에 비해 운용의 폭이 넓은 것은 사실입니다. 다만, 양방향으로 원하는 데이터들을 얻을 수 있다고 하더라도, 최종적으로 실행되는 SQL의 성능에 나쁜 영향을 주는지를 항상 체크해 주어야 합니다.
게시물과 댓글의 관계에서 자주 사용하는 것은 페이징 처리이므로, 다음과 같은 상황을 작성합니다.
- 쿼리 메소드를 이용하는 경우의 '게시물+댓글의수'
- @Query를 이용하는 경우의 '게시물+댓글의수'
4.1 쿼리 메소드를 이용하는 경우
일반적으로 게시물은 게시물 번호의 역순으로 페이징 처리가 되므로 쿼리 메소드를 다음과 같이 작성할 수 있습니다.
org.zerock.persistence.FreeBoardRepository
public interface FreeBoardRepository extends CrudRepository<FreeBoard, Long>{
// 특정 게시물번호보다 큰 게시물 탐색
public List<FreeBoard> findByBnoGreaterThan(Long bno, Pageable page);
}
org.zerock.FreeBoardTests
// 200여개의 게시물 데이터를 역순으로 출력하는 테스트
@Test
public void testList1() {
Pageable page = PageRequest.of(0, 10, Sort.Direction.DESC,"bno");
boardRepo.findByBnoGreaterThan(0L, page).forEach(board->{
log.info(board.getBno() + ": " + board.getTitle());
});
}
위 테스트 결과를 보시면 게시물의 번호가 199부터 시작하는 것을 볼 수 있습니다.
4.2 지연 로딩(lazy loading)
다음 테스트는 게시물의 '제목' 옆에 댓글의 수를 표시하는 테스트입니다.
org.zerock.FreeBoardTests 일부
// testList1 테스트의 내용과 동일하나 게시물 제목 옆에 댓글수를 출력하는 테스트
@Test
public void testList2() {
Pageable page = PageRequest.of(0, 10, Sort.Direction.DESC,"bno");
boardRepo.findByBnoGreaterThan(0L, page).forEach(board->{
log.info(board.getBno() + ": " + board.getTitle() +":" +board.getReplies().size());
});
}
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: org.zerock.domain.FreeBoard.replies, could not initialize proxy - no Session
at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:606)
...
테스트의 결과는 에러가 발생한 것을 볼 수 있습니다. JPA는 연관관계가 있는 엔티티를 조회할 때 기본적으로 '지연 로딩(lazy loading)'이라는 방식을 이용합니다. 지연 로딩은 말 그대로 '게으른'이라는 의미로, 정보가 필요하기 전까지는 최대한 테이블에 접근하지 않는 방식을 의미합니다.
지연 로딩을 사용하는 가장 큰 이유는 성능 때문입니다. 하나의 엔티티가 여러 엔티티들과 종속적인 관계를 맺고 있다면, SQL에서는 조인을 이용하는데, 조인이 복잡해질수록 성능이 떨어지게 됩니다. 따라서 JPA에서는 연관관계의 Collection 타입을 처리할 때 '지연 로딩'을 기본으로 사용합니다.
지연 로딩의 반대 개념은 '즉시 로딩(eager loading)'입니다. 즉시 로딩은 일반적으로 조인을 이용해서 필요한 모든 정보를 처리하게 됩니다.
즉시로딩 적용방법 : @OneToMany(fetch = FetchType.EAGER)
org.zerock.domain.FreeBoard
@Getter
@Setter
@ToString(exclude = "replies")
@Entity
@Table(name="tbl_freeboards")
@EqualsAndHashCode(of="bno")
public class FreeBoard {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String writer;
private String content;
@CreationTimestamp
private Timestamp regdate;
@UpdateTimestamp
private Timestamp updatedate;
@OneToMany(mappedBy = "board",
cascade = CascadeType.ALL,
fetch = FetchType.EAGER) // 즉시 로딩 수행
// fetch = FetchType.LAZY) // 지연 로딩 수행
private List<FreeBoardReply> replies;
}
위 테스트 결과를 보면 10번의 select문을 호출하고 10번의 출력이 정상적으로 나오긴 한다. 실행되는 SQL을 보면 우선은 페이지의 목록을 처리하는 SQL이 실행되고, 이후에 각 게시물에 대해서 'select...'가 이루어지는 것을 볼 수 있습니다. 따라서 실제로 결과가 나오기 위해서 1번의 목록을 추출하는 SQL과 10번의 조회용 SQL이 실행되기 때문에 권장하는 방법은 아닙니다.
지연 로딩을 이용하면서 댓글을 같이 가져오고 싶다면 @Transactional을 이용해서 처리해주어야 합니다.
org.zerock.domain.FreeBoard
public class FreeBoard {
... 생략
@OneToMany(mappedBy = "board",
cascade = CascadeType.ALL,
// fetch = FetchType.EAGER) // 즉시 로딩 수행
fetch = FetchType.LAZY) // 지연 로딩 수행
private List<FreeBoardReply> replies;
}
org.zerock.FreeBoardTests
// testList1 테스트의 내용과 동일하나 게시물 제목 옆에 댓글수를 출력하는 테스트
@Transactional
@Test
public void testList2() {
Pageable page = PageRequest.of(0, 10, Sort.Direction.DESC,"bno");
boardRepo.findByBnoGreaterThan(0L, page).forEach(board->{
log.info(board.getBno() + ": " + board.getTitle() +":" +board.getReplies().size());
});
}
위 테스트 결과를 보면 지연로딩을 했음에도 불구하고 각 게시물마다 select 구문을 통해서 댓글을 가져오는 SQL이 실행되는 것을 볼 수 있습니다.
4.3 @Query와 Fetch Join을 이용한 처리
지연로딩의 문제를 해결하는 가장 좋은 방법은 @Query를 이용해서 조인 처리를 하는 것입니다.
org.zerock.persistence.FreeBoardRepository
public interface FreeBoardRepository extends CrudRepository<FreeBoard, Long>{
// 게시물 번호, 제목, 댓글 개수를 출력합니다.
@Query("SELECT b.bno, b.title, count(r) "
+ " FROM FreeBoard b LEFT OUTER JOIN b.replies r "
+ " WHERE b.bno > 0 GROUP BY b ")
public List<Object[]> getPage(Pageable page);
}
org.zerock.FreeBoardTests 일부
// 게시물 번호, 제목, 댓글 수를 번호를 기준으로 역순으로 출력하는 테스트
// 기존 지연로딩의 문제점인 SQL의 과다 조회로 인한 문제점을 @Query와 Fetch Join을 이용하여 해결
@Test
public void testList3() {
Pageable page = PageRequest.of(0, 10, Sort.Direction.DESC,"bno");
boardRepo.getPage(page).forEach(arr -> log.info(Arrays.toString(arr)));
}
위 테스트 결과를 보면 페이징 처리 및 조인 처리를 하나의 select 구문에서 수행하고 결과를 출력한 것을 볼 수 있습니다.
5. 게시물 조회와 인덱스
게시물을 조회하는 경우에 가장 중요한 고밍느 '지연 로딩을 이용할 것인가?', '즉시 로딩을 이용할 것인가?'입니다. 지연로딩은 필요할 때까지 댓글 관련 데이터를 로딩하지 않기 때문에 성능면에서 장점을 가지고 있지만, 한번에 게시물과 댓글의 내용을 같이 보여주는 상황이라면, SQL이 한번에 처리되지 않기 때문에 여러 번 데이터베이스를 호출하는 문제가 있습니다.
이에 대한 가장 무난한 해결책은 지연 로딩을 그대로 쓰고, 댓글 쪽에서는 필요한 순간에 데이터가 좀 더 빨리 나올 수 있도록 신경 쓰는 방식입니다.
5.1 인덱스 처리
@Query를 이용해서 원하는 데이터만 처리할 수 있지만, 조금 더 신경을 쓰려면 인덱스에 대한 고민을 같이하면 좋습니다.
댓글 목록의 경우는 특정한 게시물 번호에 영향을 받기 때문에 게시물 번호에 대한 인덱스를 생성해 두면 데이터가 많을 때 성능의 향상을 기대할 수 있습니다.
org.zerock.domain.FreeBoardReply
@Getter
@Setter
@ToString(exclude = "board")
@Entity
@Table(name="tbl_free_replies", indexes = {@Index(unique = false, columnList = "board_bno")})
@EqualsAndHashCode(of="rno")
public class FreeBoardReply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
private String reply;
private String replyer;
@CreationTimestamp
private Timestamp replydate;
@UpdateTimestamp
private Timestamp updatedate;
@ManyToOne
private FreeBoard board;
}
@Table에는 인덱스를 설계할 때 @Index 애노테이션과 같이 사용해서 테이블 생성 시에 인덱스가 설계되도록 지정할 수 있습니다.
인덱스 적용전 테스트 결과
인덱스 적용후 테스트 결과
testList3() 테스트의 인덱스 적용전, 적용후 테스트 결과를 보면 인덱스를 적용후 수행속도가 더 빨라진것을 볼 수 있습니다.
References
스타트 스프링 부트, 구멍가게코딩단 지음
'JAVA > Spring' 카테고리의 다른 글
SpringBoot #6 SpringBoot+React 기반 간단한 게시판 생성하기 #2 페이징, 검색 처리 (0) | 2021.10.14 |
---|---|
SpringBoot #6 SpringBoot+React 기반 간단한 게시판 생성하기 #1 SpringBoot 프로젝트 기본 구조 생성 (0) | 2021.10.14 |
SpringBoot #5 다양한 연관관계 처리 #3 단방향 처리2 (0) | 2021.10.11 |
SpringBoot #5 다양한 연관관계 처리 #2 단방향 처리1 (0) | 2021.10.11 |
SpringBoot #5 다양한 연관관계 처리 #1 연관관계 처리 순서와 사전 설계 (0) | 2021.10.11 |