1.  연관관계를 결정하는 방법

데이터베이스에서는 PK와 FK를 이용해서 엔티티 간의 관계를 표현.
  ⚡️  데이터베이스의 테이블을 설계하는 경우 PK를 가진 테이블을 먼저 설계하고, 이를 FK로 사용하는 테이블을 설계하는 방식이 일반적

반면에 객체지향을 이용하는 JPA에서는 key가 아니라 객체를 참조
  ⚡️  예를 들어 '회원'이 여러 개의 '아이템'을 가지고 있다고 가정하면 '회원 객체가 아이템들을 참조할 것인지, 아이템이 회원을 참조하게 될 것인지' 판단을 해야 함.

  ⚡️  만일 reply 테이블에서 board 테이블을 참조하는 bno키를 엔티티 객체에서 구현하는 경우 Long bno 라고 설정을 하면 JPA에서는 board의 프라이머리키를 참조하는 FK라고 인지 못하고, 일반 컬럼 bno 라고 판단

 

JPA의 연관 관계의 판단 기준을 결정할 때 기준
  • 연관 관계의 기준은 항상 변화가 많은 쪽을 기준으로 결정
  • ERD의 FK를 기준으로 결정

 



1)  변화가 많은 쪽을 기준

  🚀  판단 기준은 '조금 더 자주 변화가 있는 쪽'을 선택하는 것이 편리
          ➡️ 예를 들어 '회원'과 '게시물'의 관계를 보면 회원들의 활동을 통해서 여러 개의 게시물이 만들어지므로 연관 관계의 핵심은 '게시물'로 판단하는 것이 편리

ERD 상에서 FK를 기준


  ✓  간혹 연관 관계의 주어를 결정하기 어려울 때는 엔티티 관계 다이어그램 ERD를 그려서 확인하는 것이 좋음
  ✓  ERD에서 JPA의 연관 관계를 읽을 때는 FK를 판단해서 읽는 것이 하나의 해결책이 될 수 있음



2)  단방향과 양방향

  🚀  객체지향이 관계형 데이터베이스와 다른 점 중의 하나는 객체가 다른 객체를 참조하는 방식의 차이가 있음
  🚀  데이터베이스에는 특정한 PK를 다른 테이블에서 FK로 참조해서 사용할 수 있지만, 객체지향에서는 'A가 B의 참조를 가질 수 있고, B가 A의 참조를 가질수'있다는 점이 다름
  🚀  JPA는 참조를 결정할 때 다양한 방식이 존재할 수 있음
       ➡️  A가 B를 참조하거나 B가 A를 참조라는 방식으로 한쪽만 참조를 유지하는 방식을 단방향 unidirection

       ➡️  양쪽 모두를 참조하는 방식을 양방향 bidirection

    ⚡️  양방향 : 양쪽 객체 모두 서로 참조를 유지하기 때문에 모든 관리를 양쪽 객체에 동일하게 적용해야만 하는 불편함이 있지만 JPA에서 필요한 데이터를 탐색하는 작업에서는 편리함을 제공
    ⚡️  단방향 : 구현이 단순하고 에러 발생의 여지를 많이 줄일 수 있지만, 데이터베이스 상에서 조인처리와 같이 다른 엔티티 객체의 내용을 사용하는 데 어렵다는 단점이 있음

  📍  기본적으로 단방향으로 참조 관계를 유지하면서 다른 엔티티를 사용해야 할 때는 JPQL을 통한 조인처리를 이용하도록 작업

 


2.  다대일 연관 관계의 구현

다대일 연관 관계는 필요한 엔티티 클래스에 @ManyToOne을 이용해서 연관관계를 작성

 

domain 패키지에 댓글의 엔티티 클래스를 추가
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class Reply extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long rno;

    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;

    private String replyText;
    private String replyWriter;
}

 

  ✓  Reply 클래스에는 Board 타입의 객체 참조를 하는 변수를 이용해서 참조하는데 이때 @ManyToOne을 이용해서 '다대일' 관계로 구성됨을 설명

 

  📍  연관 관계를 구성할 때에는 다음과 같은 점들을 주의해서 작성

  • @ToString을 할 때 참조하는 객체를 사용하지 않도록 반드시 exclude 속성값을 지정
  • @ManyToOne과 같이 연관 관계를 나타낼 때는 반드시 fetch 속성을 LAZY로 지정


  ✓  프로젝트를 실행하면 Reply 관련 테이블이 자동으로 생성되는 것을 확인
  ✓  생성된 테이블에서는 게시물 Board과 관련된 속성으로 'board_bno'라는 컬럼이 생성되고, 자동으로 FK가 생성


1)  ReplyRepository 생성과 테스트

  🚀  Reply는 Board와 별도로 CRUD가 일어날 수 있기 때문에 별도의 Repository를 작성해서 관리하도록 구성

repository 패키지에 ReplyRepository를 선언
public interface ReplyRepository extends JpaRepository<Reply, Long> {
}

 

 

A.  테스트를 통한 insert 확인


  👾  test 폴더에는 ReplyRepositoryTest 클래스를 작성해서 작성된 ReplyRepository의 테스트 코드를 작성

  👾  insertTest() 에서 주의할 부분은 Board 객체를 생성하는 부분. JPA에서는 @Id를 이용해서 엔티티 객체들을 구분하므로 실제 존재하는 @Id 값을 가지도록 구성하는 것이 중요

@SpringBootTest
@Log4j2
class ReplyRepositoryTest {
    @Autowired
    private ReplyRepository replyRepository;

    @Test
    public void testInsert() {
        // 실제 DB에 있는 bno
        Long bno = 50L;
        Board board = Board.builder().bno(bno).build();
    
        Reply reply = Reply.builder()
            .board(board)
            .replyText("댓글")
            .replyWriter("작성자 1")
            .build();

        replyRepository.save(reply);
    }

 

  ✓  InsertTest()를 실행하면 SQL 문이 실행되는 것과 데이터베이스의 reply 테이블도 확인

 


B.  특정 게시물의 댓글 조회와 인덱스


  👾  댓글이 사용되는 방식은 주로 게시물 번호를 통해서 사용되는 경우가 많음
         ➡️  게시물의 댓글의 수나 해당 게시물의 댓글 목록 등
  👾  쿼리 조건으로 자주 사용되는 컬럼에는 인덱스를 생성해 두는 것이 좋은데 @Table 어노테이션에 추가적인 설정을 이용해서 인덱스를 지정할 수 있음

 

Reply 클래스에는 @Table 어노테이션을 추가해서 다음과 같이 구성
@Entity
@Table(name = "Reply", indexes = {@Index(name = "idx_reply_board_bno", columnList = "board_bno")})
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class Reply extends BaseEntity {

 

특정한 게시글의 댓글들은 페이징 처리를 할 수 있도록 Pageable 기능을 ReplyRepository에 @Query를 이용해서 작성
public interface ReplyRepository extends JpaRepository<Reply, Long> {
    @Query("SELECT r FROM Reply r WHERE r.board.bno = :bno")
    Page<Reply> listByBoard(@Param("bno") Long bno, Pageable pageable);
}

 


C.  테스트 코드와 fetch 속성


  👾  테스트 코드를 통해서 listOfBoard()의 동작을 확인

@Test
public void listOfBoardTest() {
    // 실제 DB에 있는 bno
    Long bno = 50L;
    Pageable pageable = PageRequest.of(0, 10, Sort.by("rno").descending());
    Page<Reply> replies = replyRepository.listByBoard(bno, pageable);
    replies.getContent().forEach(log::info);
}

 

 

  👾  Reply 객체를 출력할 때 조심해야 하는 부분은 @ToString(). JPA는 기본적으로 필요한 엔티티를 최소한의 자원으로 쓰는 방식을 선택.

    ➡️  예를 들어 Reply를 출력했할 때 Board를 같이 출력하도록 한다면 reply 테이블에서 쿼리를 실행하고 board테이블에서 추가적인 쿼리를 실행하게 됨
  👾  Reply 클래스의 @ToString()에서 exclude를 제거해 보면 이런 동작을 확인할 수 있음

@ToString
public class Reply extends BaseEntity {

 

  ✓  이 상태에서 BoardRepliesTest() 실행하면 에러가 발생

 

💫  에러 메시지가 발생하는 상황을 생각해보면 reply 테이블에서 쿼리를 실행했지만, Board 객체를 같이 출력해야 하므로 다시 board 테이블에 쿼리를 추가로 실행해야만 하는 상황. 그래서 다시 한번 데이터베이스를 연결해야만 하는데 현재 테스트 코드는 한 번만 쿼리를 실행할 수 있기 때문에 발생하는 에러. no Session이라는 의미가 데이터베이스와 추가적인 연결이 필요해서 발생하는 문제


  👾  강제로 이를 실행하고 싶으면 테스트 코드에 @Transactional을 추가하면 가능

  👾  @Transactional을 추가한 후 테스트 코드를 실행하면 다음과 같이 reply 테이블에 쿼리가 실행되고 board 테이블에 추가 쿼리가 실행된 것을 볼 수 있음

 

 

  👾  @ManyToOne에 fetch 속성 값이 FetchType.LAZY로 지정되어 있음. LAZY 속성값은 '지연 로딩' 이라고 표현하는데 지연 로딩은 기본적으로 필요한 순간까지 데이터베이스와 연결하지 않는 방식으로 동작
  👾  FetchType.EAGER. EAGER는 '즉시 로딩'이라는 단어로 표현하는데 해당 엔티티를 로딩할 때 같이 로딩하는 방식. EAGER은 성능에 영향을 줄 수 있으므로 우선은 LAZY 값을 기본으로 사용하고 필요에 따라 EAGER를 고려하는 것이 좋음

 


D.  댓글 조회 / 수정 / 삭제

 

  👾  JPA에서 엔티티 간의 관계를 한쪽에서만 참조하는 '단방향'방식으로 구현하는 경우 장점은 관리하기 편함
          ➡️ 양방향의 경우 양쪽 객체 모두를 변경해 주어야 하기 때문에 구현할 때도 주의해야 하지만 트렌잭션을 신경 써야만 함
  👾  현재 구현하는 코드처럼 단방향으로 구현되는 경우 Board 객체는 Reply에 대해서 모르는 상태. 즉, Reply의 CRUD와 무관하기 때문에 단순하게 구현할 수 있음

 


2)  게시물 목록과 Projection

  🚀  게시물과 댓글의 관계처럼 엔티티가 조금씩 확장되면 가장 문제가 되는 부분은 목록 화면.

        ✓  기존의 목록 화면에서는 Board 객체를 BoardDTO로 변환시켜서 내용을 출력하면 충분했지만 댓글이 추가되면 상황이 달라짐
        ✓  목록 화면에서는 특정한 게시물에 속한 댓글의 숫자를 같이 출력해 주어야 하기 때문에 기존의 코드를 사용할 수가 없고 다시 추가 개발이 필요

 

dto 패키지에 BoardListReplyCountDTO 클래스를 작성
@Data
public class BoardListReplyCountDTO {
    private Long bno;
    private String title;
    private String writer;
    private LocalDateTime regDate;

    private Long replyCount;
}

 

목록 처리는 Querydsl을 이용하는 구조이므로 search의 BoardSearch에 추가
public interface BoardSearch {
    Page<Board> search1(Pageable pageable);
    Page<Board> searchAll(String[] types, String keyword, Pageable pageable);
    Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable);
}

 


A.  LEFT (OUTER) JOIN 처리


  👾  BoardSearchImpl에서 searchWithReplyCount() 구현에는 단방향 참조가 가지는 단점이 보이는데, 바로 필요한 정보가 하나의 엔티티를 통해서 접근할 수 없다는 점
  👾  이 문제를 해결하기 위해서 가장 쉽게 사용할 수 있는 방법은 JPQL을 이용해서 'left (outer) join'이나 'inner join'과 같은 조인 join 을 이용하는 것
  👾  게시물과 댓글의 경우 한쪽에서만 데이터가 존재하는 상황이 발생할 수 있음. 예를 들어 특정 게시물은 댓글이 없는 경우가 발생하므로 outer join 을 통해서 처리

 

코드의 일부분으로 조인 처리만을 구성
@Override
public Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable) {
    QBoard board = QBoard.board;
    QReply reply = QReply.reply;

    JPQLQuery<Board> query = from(board);
    query.leftJoin(reply).on(reply.board.eq(board));

    query.groupBy(board);
    
    return null;
}

 

  ✓  JPQLQuery의 leftJoin()을 이용할 때는 on()을 이용해서 조인 조건을 지정
  ✓  조인 처리 후에 게시물당 처리가 필요하므로 groupBy()를 적용

 


B.  Projections.bean()


  👾  JPA에서는 Projection(프로젝션)이라고 해서 JPQL의 결과를 바로 DTO로 처리하는 기능을 제공. Querydsl도 마찬가지로 이러한 기능을 제공
  👾  목록 화면에서 필요한 쿼리의 결과를 Projections.bean()이라는 것을 이용해서 한번에 DTO로 처리할 수 있는데, 이를 이용하려면 JPQLQuery 객체의 select()를 이용

 

BoardSearchImpl 클래스의 searchWithReplyCount() 내부는 다음과 같이 구현

 

  ·  최종적으로 검색 조건과 applyPagination() 까지 적용

@Override
public Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable) {
    QBoard board = QBoard.board;
    QReply reply = QReply.reply;

    JPQLQuery<Board> query = from(board);
    query.leftJoin(reply).on(reply.board.eq(board));

    query.groupBy(board);

    if ((types != null && types.length > 0) && keyword != null) { // 검색 조건과 키워드가 있다면
        BooleanBuilder booleanBuilder = new BooleanBuilder(); // (
        for (String type : types) {
            switch (type) {
                case "t":
                    booleanBuilder.or(board.title.contains(keyword));
                    break;
                case "c":
                    booleanBuilder.or(board.content.contains(keyword));
                    break;
                case "w":
                    booleanBuilder.or(board.writer.contains(keyword));
                    break;
             }
         } // end for
         query.where(booleanBuilder);
    }// end if

    // bno > 0
    query.where(board.bno.gt(0L));

    JPQLQuery<BoardListReplyCountDTO> dtojpqlQuery = query.select(Projections.bean(BoardListReplyCountDTO.class,
        board.bno, board.title, board.writer, board.regDate, reply.count().as("replyCount")));

    this.getQuerydsl().applyPagination(pageable, dtojpqlQuery);
    List<BoardListReplyCountDTO> dtoList = dtojpqlQuery.fetch();
    long count = dtojpqlQuery.fetchCount();
    return new PageImpl<>(dtoList, pageable, count);
}

 

테스트 코드는 BoardRepositoryTests를 이용
@Test
public void searchReplyCountTest() {
    Pageable pageable = PageRequest.of(1, 10, Sort.by("bno").descending());
    Page<BoardListReplyCountDTO> boardPage = boardRepository.searchWithReplyCount(new String[]{"t", "c", "w"}, "1", pageable);

    // total pages
    log.info(boardPage.getTotalPages());

    // page size
    log.info(boardPage.getSize());

    // pageNumber
    log.info(boardPage.getNumber());

    // prev next
    log.info(boardPage.hasPrevious() + ": " + boardPage.hasNext());

    boardPage.getContent().forEach(board -> log.info(board));
}

 

 


3)  게시물 목록 화면 처리

  🚀  BoardRepository와 BoardSearch(Impl)을 이용해서 BoardListReplyCountDTO 처리가 완료되었다면 이를 반영해서 댓글의 수가 반영되는 화면을 구성

  🚀  우선 데이터를 가져오는 타입이 BoardDTO가 아닌 BoardListReplyCountDTO가 되었으므로 BoardService와 BoardServiceImpl에는 listWithReplyCount() 메서드를 추가하고 구현

 PageResponseDTO<BoardListReplyCountDTO> listWithReplyCount(PageRequestDTO pageRequestDTO);
@Override
public PageResponseDTO<BoardListReplyCountDTO> listWithReplyCount(PageRequestDTO pageRequestDTO) {
    String[] types = pageRequestDTO.getTypes();
    String keyword = pageRequestDTO.getKeyword();
    Pageable pageable = pageRequestDTO.getPageable("bno");

    Page<BoardListReplyCountDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable);

    return PageResponseDTO.<BoardListReplyCountDTO>withAll()
        .pageRequestDTO(pageRequestDTO)
        .dtoList(result.getContent())
        .total((int)result.getTotalElements())
        .build();
}

 

  🚀  BoardController에서는 호출하는 메서드를 변경. 기존에 BoardService의 list()를 호출하는 대신에 listWithReplyCount()를 호출하도록 수정

@GetMapping("/list")
public void list(PageRequestDTO pageRequestDTO, Model model) {
    //PageResponseDTO<BoardDTO> responseDTO = boardService.list(pageRequestDTO);
    PageResponseDTO<BoardListReplyCountDTO> responseDTO = boardService.listWithReplyCount(pageRequestDTO);
    log.info(responseDTO);

    model.addAttribute("responseDTO", responseDTO);
}

 

  🚀  마지막으로 화면을 처리하는 list.html에는 replyCount라는 속성을 출력하도록 수정

<tr th:each="dto, status:${responseDTO.dtoList}">
    <th scope="col">[[${no - status.index}]]</th>
    <td><a th:href="|@{/board/read(bno =${dto.bno})}&${link}|"> [[${dto.title}]] </a>
        <span class="badge progress-bar-success" style="background-color: #0a53be">[[${dto.replyCount}]]</span>
    </td>
    <td>[[${dto.writer}]]</td>
    <td>[[${#temporals.format(dto.regDate, 'yyyy-MM-dd')}]]</td>
</tr>

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts