본문 바로가기

Server Programming/Spring Boot Backend Programming

7장-2. 일대다 연관관계인 게시물과 첨부파일 (+ 매핑테이블, @Transactional, @EntityGraph)

반응형

연관관계 다대일 연관관계 일대다 연관관계
기준 다른 엔티티 객체의 참조로 FK가 속하는 쪽 기준 PK를 가진 쪽 기준
어노테이션 @ManyToOne @OneToMany
로딩 방식 기본값 fetchType.EAGER fetchType.LAZY
특징   'N+1' 문제 발생 주의
간편한 하위 엔티티 관리

 

요구사항

  • 일대다 연관관계
    • @OneToMany를 이용해 일대다 연관관계 처리
    • 영속성 전이를 이용해, 일대다 관계에 있는 엔티티 객체 삭제 처리
  • 참조 방식
    • 단방향 : @JoinColumn을 이용해 참조관계 형성
    • 양방향
      • 매핑 테이블 이용해 양방향 참조관계 형성 
      • mappedBy 속성을 이용해 양방향 참조관계 형성

일대다 연관관계 - 매핑 테이블 생성하는 양방향 참조관계

@ManyToOne : 댓글의 관점에서 게시물을 바라보는 관계

: 다른 엔티티 객체의 참조로 FK가 속하는 쪽 기준

 

@OneToMany : 게시물의 관점에서 첨부파일을 바라보는 관계

: PK를 가진 쪽 기준

  • 상위 엔티티에서 하위 엔티티 관리
  • 상위 엔티티 기준의 리포지토리로, 하위 엔티티 변경시 상위 엔티티에도 반영되어야 한다.
  • 상위 엔티티 객체의 상태 변경시 하위 엔티티의 상태도 변경 -> 영속성 전이
  • 상위 엔티티 1 : 하위 엔티티 N의 경우 'N+1' 문제 발생 주의

 

 

1. 하위 엔티티 클래스 작성

-@ManyToOne 어노테이션 지정해 하위 엔티티인 BoardImage 엔티티 클래스 작성

-첨부파일의 순번에 맞게 정렬하는 메서드

-영속성 전이를 위한 메서드 작성

  • 엔티티 명 : BoardImage
  • PK : uuid
  • 상위 엔티티 : Board
  • 상속하는 인터페이스 : Comparabe<BoardImage>
  • 오버라이딩 메서드 : compareTo()
    -상위 엔티티에서 하위엔티티를 순번에 맞게 정렬하기 위함
  • Setter 메서드 : changeBoard()
    -상위 엔티티 삭제시 하위 엔티티 객체의 참조 변경을 위함

 

package org.zerock.b01.domain;

import io.swagger.annotations.Api;
import lombok.*;

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.ManyToOne;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class BoardImage implements Comparable<BoardImage>{
    @Id
    private String uuid;
    private String fileName;
    private int ord;
    
    @ManyToOne
    private Board board;

    @Override
    public int compareTo(BoardImage boardImage) {
        return this.ord- boardImage.ord;
    }
    
    public void changeBoard(Board board){
        this.board=board;
    }
}

 

2. 상위 엔티티에 어노테이션 적용

-양방향 참조관계 형성을 위해, @OneToMany 어노테이션 적용해 일대다 연관관계 형성

 

변경 전, Board 엔티티

package org.zerock.b01.domain;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Board extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    @Column(length = 500, nullable = false)
    private String title;
    @Column(length = 2000, nullable = false)
    private String content;
    @Column(length = 50, nullable = false)
    private String writer;

    public void change(String title, String content){
        this.title=title;
        this.content=content;
    }
}

 

변경 후, Board 엔티티

-중복된 이미지를 추가하지 않도록 설정

package org.zerock.b01.domain;

import lombok.*;

import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//연관관계에 있는 필드는 제외
@ToString(exclude = "imageSet")
public class Board extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    @Column(length = 500, nullable = false)
    private String title;
    @Column(length = 2000, nullable = false)
    private String content;
    @Column(length = 50, nullable = false)
    private String writer;
    
    //하위 엔티티 필드 생성
    //: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
    @OneToMany
    @Builder.Default
    private Set<BoardImage> imageSet=new HashSet<>();

    public void change(String title, String content){
        this.title=title;
        this.content=content;
    }
}

 

3. 상위 엔티티와 하위 엔티티의 양방향 참조 관계 형성

-양방향 참조 관계는 DB의 테이블에서는 표현할 수 없어서, JPA는 매핑 테이블을 자동으로 생성한다.

-각 엔티티에 해당하는 테이블을 독립적으로 생성하고, 양방향 참조 관계를 위한 중간에 매핑해주는 테이블이 생성

 

  • 엔티티에 해당하는 테이블 : board, board_image_set
  • @OneToMany로 인한 양방향 참조 관계를 위한 매핑 테이블 : board_image_set

일대다 연관관계 - 매핑 테이블 생성하지 않는 양방향 참조관계

mappedBy를 이용해 양방향 참조관계 형성

mappedBy : 연관 관계의 주인을 명시하는 나타내는 개념으로, 하위엔티티인 첨부파일의 관점에서 게시물을 참조하는 형태

 

 

1. mappedBy 속성으로 연관관계의 주인을 명시하도록 변경

 

변경 전, Board

package org.zerock.b01.domain;

import lombok.*;

import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//연관관계에 있는 필드는 제외
@ToString(exclude = "imageSet")
public class Board extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    @Column(length = 500, nullable = false)
    private String title;
    @Column(length = 2000, nullable = false)
    private String content;
    @Column(length = 50, nullable = false)
    private String writer;

    //하위 엔티티 필드 생성
    //: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
    @OneToMany
    @Builder.Default
    private Set<BoardImage> imageSet=new HashSet<>();

    public void change(String title, String content){
        this.title=title;
        this.content=content;
    }
}

 

변경 후, Board

-mappedBy 속성으로, 상위 엔티티에서 하위 엔티티의 필드에 연관관계 주인 명시

-연관관계의 주인은 변경이 많은 방향이 단방향에서 기준이며, 양방향의 경우 상위 엔티티가 연관관계의 주인이다.

package org.zerock.b01.domain;

import lombok.*;

import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//연관관계에 있는 필드는 제외
@ToString(exclude = "imageSet")
public class Board extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    @Column(length = 500, nullable = false)
    private String title;
    @Column(length = 2000, nullable = false)
    private String content;
    @Column(length = 50, nullable = false)
    private String writer;

    //하위 엔티티 필드 생성
    //: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
    //mappedBy 속성으로 연관관계의 주인 명시함으로써, 매핑테이블 생성하지 않고 양방향 참조관계 설정
    @OneToMany(mappedBy = "board")
    @Builder.Default
    private Set<BoardImage> imageSet=new HashSet<>();

    public void change(String title, String content){
        this.title=title;
        this.content=content;
    }
}

 

 


영속성 전이

상위 엔티티 객체의 상태 변경시 하위 엔티티의 상태도 변경하도록 엔티티의 상태를 관리

-Board와 BoardImage의 참조관계에서 게시물이 삭제되었을 때 게시물에 해당하는 첨부파일도 함께 삭제

-외래키 제약조건 (cascade / CONSTRAINT)

 

JPA에서 제공하는 cascade 속성

cascade 속성값 설명
PERSIST
REMOVE
상위 엔티티 영속 처리시 하위 엔티티도 영속 처리
MERGE
REFRESH
DETACH
상위 엔티티 상태 변경시 하위 엔티티도 상태 변경
ALL 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용

 

하위 엔티티의 리포지토리 생성 여부

  • 상위 엔티티가 하위 엔티티 객체를 관리하는 경우 : 하위 엔티티의 리포지토리를 생성하지 않는다.
    • 상위 엔티티 저장시 하위 엔티티도 함께 저장하는 게시물과 첨부파일 관계
  • 상위 엔티티가 하위 엔티티 객체를 관리하지 않는 경우 : 하위 엔티티의 리포지토리를 생성한다.

 

 

 

1. 영속성 전이 구현

-상위 엔티티에서 하위 엔티티 객체들을 관리하도록 변경

-상위 엔티티와 하위 엔티티의 상태를 동일하게 유지하는 것이 좋다.

-@OneToMany 속성 : mappedBy, cascade, fetch, orphanRemoval

 

(1) 하위 엔티티와 상위 엔티티의 관계 설정 : 연관관계 기준, 영속성 전이 단계, fetch 타입

//하위 엔티티 필드 생성
//: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
//mappedBy 속성으로 연관관계의 주인 명시함으로써, 매핑테이블 생성하지 않고 양방향 참조관계 설정
//cascade 속성으로 영속성 전이 단계 설정
//fetch 속성으로 언제 fetch할지 설정
@OneToMany(mappedBy = "board", cascade = {CascadeType.ALL}, fetch = FetchType.LAZY)
@Builder.Default
private Set<BoardImage> imageSet=new HashSet<>();

 

(2) 상위 엔티티 저장시 하위 엔티티도 함께 저장하는 addImage() 메서드 작성

public void addImage(String uuid, String fileName){
    BoardImage boardImage=BoardImage.builder()
            .uuid(uuid)
            .fileName(fileName)
            .board(this)
            //순서는 이미지가 추가될 때마다, HashSet의 크기가 늘어나므로 size가 순서를 결정
            .ord(imageSet.size())
            .build();
    //Hashset에 BoardImage 객체 추가
    imageSet.add(boardImage);
}

 

(3) 상위 엔티티 삭제시 하위 엔티티도 함께 삭제하는 clearImages() 메서드 작성

public void clearImages(){
    //이미지들을 모두 null처리 후 HashSet 지우기
    imageSet.forEach(boardImage -> boardImage.changeBoard(null));
    this.imageSet.clear();
}

 

(4) 상위 엔티티에서 하위 엔티티 관리하는 테스트 코드 작성

-BoardRepositoryTests에 첨부파일을 등록하는 메서드 작성

@Test
public void testInsertWithImage(){

    Board board=Board.builder()
            .title("Image Insert Test")
            .content("첨부파일 테스트")
            .writer("tester")
            .build();

    for(int i=0;i<3;i++){
        board.addImage(UUID.randomUUID().toString(), "file"+i+".jpg");
    }
    boardRepository.save(board);
}

 

board insert-> board_image insert->  board_image insert->  board_image insert

: fetchType이 LAZY이기 때문에 DB 테이블의 접근은 필요할때까지 가장 마지막까지 미룬다.

Hibernate: 
    insert 
    into
        board
        (moddate, regdate, content, title, writer) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        board_image
        (board_bno, file_name, ord, uuid) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        board_image
        (board_bno, file_name, ord, uuid) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        board_image
        (board_bno, file_name, ord, uuid) 
    values
        (?, ?, ?, ?)

Lazy 로딩과 @EntityGraph

지연로딩의 경우, 일대다 연관관계에서 게시물 조회시 에러 발생

: Board 객체와 BoardImage 객체를 생성할 때 두 번의 select 발생하므로

 

성능저하 관계없이 문제 해결하는 방법

  • fetchType.EAGER로 변경
  • @Transational : 필요할 때마다 메소드 내에서 쿼리를 여러번 실행

 

@EntityGraph

지연 로딩 사용시 여러번의 성능 저하를 막기 위해 DB 접근을  최소화하기 위해
필요한 데이터들을 한번의 조인으로 select가 이루어지도록 한다.

 

(1) 상위 엔티티의 리포지토리인 BoardRepository에 게시물번호로 이미지를 찾는 메서드 작성

-@EntityGraph : attributePaths 속성으로 필요한 필드 설정

-JPQL을 이용해 findByIdWithImages() 메서드 작성 

  • 메서드 명 : findByIdWithImages
  • 파라미터 : Long bno
  • 리턴타입 : Optional<Board>
@EntityGraph(attributePaths = {"imageSet"})
@Query("select b from Board b where b.bno=:bno")
Optional<Board> findByIdWithImages(Long bno);

 

 

(2) 테스트 코드 작성

@Test
public void testReadWithImages(){
    //존재하는 글번호
    Optional<Board> result = boardRepository.findByIdWithImages(1L);
    
    Board board=result.orElseThrow();
    
    log.info(board);
    
    log.info("---");
    
    //HashSet에서 BoardImage를 하나씩 꺼낸다.
    for(BoardImage boardImage: board.getImageSet()){
        log.info(boardImage);
    }
    
}

-> board 테이블과 board_image 테이블이 조인된 상태로 select

Hibernate: 
    select
        board0_.bno as bno1_0_0_,
        imageset1_.uuid as uuid1_1_1_,
        board0_.moddate as moddate2_0_0_,
        board0_.regdate as regdate3_0_0_,
        board0_.content as content4_0_0_,
        board0_.title as title5_0_0_,
        board0_.writer as writer6_0_0_,
        imageset1_.board_bno as board_bn4_1_1_,
        imageset1_.file_name as file_nam2_1_1_,
        imageset1_.ord as ord3_1_1_,
        imageset1_.board_bno as board_bn4_1_0__,
        imageset1_.uuid as uuid1_1_0__ 
    from
        board board0_ 
    left outer join
        board_image imageset1_ 
            on board0_.bno=imageset1_.board_bno 
    where
        board0_.bno=?

-> 외부조인으로 게시물의 이미지가 존재하지 않더라도 null로 출력


일대다 연관관계에서의 하위 엔티티 수정 및 삭제

하위 엔티티 객체 하나의 수정이 아니라, 모든 하위 엔티티가 삭제되고 새롭게 추가된다.

즉, addImage()와 clearImages() 두 메서드만을 이용해 수정 처리

 

 

(1) 상위 엔티티의 리포지토리에서 첨부파일을 수정하는 테스트 코드 작성

//특정 게시물의 첨부파일을 다른 파일들로 수정
@Transactional
@Commit
@Test
public void testModifyImages(){
    Optional<Board> result = boardRepository.findByIdWithImages(1L);
    Board board=result.orElseThrow();

    //기존의 하위엔티티 모두 삭제
    board.clearImages();

    //새로운 하위엔티티 추가
    for(int i=0;i<2;i++){
        board.addImage(UUID.randomUUID().toString(), "updatefile"+i+".jpg");
    }
    boardRepository.save(board);
}

-> cascade 속성이 ALL인, 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용되는 상태에서는 하위 엔티티만 단독으로 삭제 불가능

->삭제는 되지 않았고, board_bno가 null인 잘못된 로우가 추가된다.

 

(2) 상위 엔티티인 Board 클래스의 @OneToMany의 orphanRemoval 속성 변경

-cascade 속성 상관없이 하위 엔티티를 삭제할 수 있는 속성을 true 설정

//하위 엔티티 필드 생성
//: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
//mappedBy 속성으로 연관관계의 주인 명시함으로써, 매핑테이블 생성하지 않고 양방향 참조관계 설정
//cascade 속성으로 영속성 전이 단계 설정
//fetch 속성으로 언제 fetch할지 설정
@OneToMany(mappedBy = "board", cascade = {CascadeType.ALL}, fetch = FetchType.LAZY, orphanRemoval = true)
@Builder.Default
private Set<BoardImage> imageSet=new HashSet<>();

 

 


다대일 연관 관계에서 상위 엔티티 삭제

-연관관계의 주인인 상위 엔티티를 삭제하기 위해서는 주인 엔티티를 참조하는 엔티티가 없어야 삭제가 가능하다.

-하지만, 사용자의 데이터를 함부로 삭제하는 것은 주의해야한다.

-하위 엔티티 먼저 삭제 후, 상위 엔티티가 삭제된다.

 

(1) 상위 엔티티인 특정 게시물을 참조하는 엔티티인 Reply를 모두 삭제하는 쿼리 메서드 작성

더보기

쿼리 메서드 작성

-키워드와 칼럼을 결합하는 쿼리

-'findBy', 'get'으로 시작해 칼럼명과 키워드를 결합한다.

Page<Board> findByTitleContainingOrderByBnoDesc(String keyword, Pageable pageable);

 

void deleteByBoard_Bno(Long bno);

 

 

(2) BoardRepositoryTests에 해당 쿼리 메서드를 수행하기 위해서 ReplyRepository 의존성 주입

-@Transactional, @Commit : 다른 테이블에도 접근을 해야하고, 삭제가 실제로 이루어져야 하므로 

-하위 엔티티인 Reply 엔티티 삭제 후 상위 엔티티인 Board 엔티티 삭제

//주인 엔티티를 지우기 위해 참조하는 엔티티 모두 삭제
@Test
@Transactional
@Commit
public void testRemoveAll(){
    Long bno=1L;
    replyRepository.deleteByBoard_Bno(bno);
    boardRepository.deleteById(bno);
}

 

(3) 상위 엔티티 삭제 동작 순서

  1. 상위 엔티티를 참조하는 Reply가 존재하는 경우 Reply 삭제
  2. 하위 엔티티인 BoardImage 삭제
  3. 상위 엔티티인 Board 삭제

 

Hibernate: 
    select
        reply0_.rno as rno1_2_,
        reply0_.moddate as moddate2_2_,
        reply0_.regdate as regdate3_2_,
        reply0_.board_bno as board_bn6_2_,
        reply0_.reply_text as reply_te4_2_,
        reply0_.replyer as replyer5_2_ 
    from
        reply reply0_ 
    left outer join
        board board1_ 
            on reply0_.board_bno=board1_.bno 
    where
        board1_.bno=?
Hibernate: 
    select
        board0_.bno as bno1_0_0_,
        board0_.moddate as moddate2_0_0_,
        board0_.regdate as regdate3_0_0_,
        board0_.content as content4_0_0_,
        board0_.title as title5_0_0_,
        board0_.writer as writer6_0_0_ 
    from
        board board0_ 
    where
        board0_.bno=?
Hibernate: 
    select
        imageset0_.board_bno as board_bn4_1_0_,
        imageset0_.uuid as uuid1_1_0_,
        imageset0_.uuid as uuid1_1_1_,
        imageset0_.board_bno as board_bn4_1_1_,
        imageset0_.file_name as file_nam2_1_1_,
        imageset0_.ord as ord3_1_1_ 
    from
        board_image imageset0_ 
    where
        imageset0_.board_bno=?

Hibernate: 
    delete 
    from
        board_image 
    where
        uuid=?
Hibernate: 
    delete 
    from
        board_image 
    where
        uuid=?
Hibernate: 
    delete 
    from
        board 
    where
        bno=?
반응형