@OneToMany를 이용한 게시물과 첨부파일 처리

강상은·2023년 12월 4일

@OneToMany 적용

개요

게시물과 댓글, 게시물과 첨부파일의 관계를 테이블 구조로 보면 완전히 같은 구조지만
이를 JPA에서는 게시글 중심으로 해석하는지, 첨부파일을 중심으로 해석하는지에 따라 다른 결과가 나올 수 있습니다.

@OneToMany는 기본적으로 상위 엔티티(게시물)와 여러 개의 하위 엔티티들(첨부파일)의 구조로 이루어집니다.
@ManyToOne과 결정적으로 다른 점은 @ManyToOne은 다른 엔티티 객체의 참조로 FK를 가지는 쪽에서 하는 방식이고, @OneToMany는 PK를 가진 쪽에서 사용한다는 점입니다.

@OneToMany를 사용하는 구조는 다음과 같은 특징을 가집니다.

  • 상위 엔티티에서 하위 엔티티들을 관리한다
  • JPA의 Repository를 상위 엔티티 기준으로 생성한다. 하위 엔티티에 대한 Repository의 생성이 잘못된 것은 아니지만 하위 엔티티들의 변경은 상위 엔티티에도 반영되어야 한다.
  • 상위 엔티티 상태가 변경되면 하위 엔티티들의 상태들도 같이 처리해야 한다.

상위 엔티티 하나와 하위 엔티티 여러 개를 처리하는 경우 'N+1' 문제가 발생할 수 있으므로 주의해야 한다.

=> 'n+1' 문제는 상위 엔티티의 수를 n이라고 할 때, 총 n+1개의 쿼리가 실행된다는 것을 의미
성능에 부담을 줄 수 있고, 특히 데이터가 많은 경우에는 성능 저하를 가져올 수 있음
이 문제를 해결하기 위한 방법으로는 FetchType.EAGER를 사용하여 즉시 로딩을 활성화하는 것이 있음. 하지만 FetchType.EAGER를 계속 사용하면 모든 관련 엔티티가 항상 로딩되므로 성능 이슈가 발생할 수 있다. 대안으로는 FetchType.LAZY를 사용하여 필요한 경우에만 로딩

BoardImage 클래스의 생성

첨부파일을 의미하는 BoardImage 엔티티 클래스를 domain 패키지에 선언하고
@ManyToOne 연관 관계를 적용

BoardImage는 첨부파일의 고유한 uuid값과, 파일의 이름, 순번(ord)을 지정하고,
@ManyToOne으로 Board 객체를 지정

  • 엔티티 클래스 작성 시에 연관 관계를 적용할 때는 항상 @ToString()에 exclude를 이용하는 점을 주의합니다.
연관 관계는 상호 참조로 이어질 수 있고, 이로 인해 무한 루프가 발생할 수 있다. 예를 들어, 두 개의 엔티티가 서로를 참조하는 양방향 관계가 있는 경우
Lombok의 @ToString에서 exclude 속성은 특정 필드를 제외하도록 지정 이를 사용하지 않으면 toString() 메서드가 연관된 객체를 계속 출력하려고 시도하면서 스택 오버플로우나 무한 루프로 이어질 수 있다

BoardImage는 특이하게도 Comparable 인터페이스를 적용하는데 이는 @OneToMany 처리에서 순번에 맞게 정렬하기 위함입니다.
https://velog.io/@house1021/Comparable-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4%EB%9E%80 참조

BoardImage에는 changeBoard()를 이용해서 Board 객체를 나중에 지정할 수 있게 하는데 이것은 나중에 Board 엔티티 삭제 시에 BoardImage 객체의 참조도 변경하기 위해서 사용합니다.

Board 클래스에 @OneToMany 적용

Board와 BoardImage에 대한 양방향(bidirection) 참조하는 방식으로 구성
Board 클래스에 연관관계를 부여하면 다음과 같은 코드를 작성할 수 있습니다.

테이블 생성 확인과 mappedBy

@OneToMany는 기본적으로 각 엔티티에 해당하는 테이블을 독립적으로 생성하고 중간에 매핑해주는 테이블이 생성됩니다.
이를 확인하기 위해 기존의 데이터베이스에서 board 테이블과 reply 테이블을 삭제해 두도록 합니다.

BoardImage가 추가된 상태에서 프로젝트를 실행하면 Board와 BoardImage 엔티티에 해당하는 테이블 외에 추가적인 테이블이 생성되는 것을 확인할 수 있습니다.




ERD로 표현해서 살펴보면 board와 board_image 테이블 중간에 board_image_set이라는 테이블이 @OneToMany를 처리하기 위해서 생성된 것을 확인할 수 있습니다. ->나는 안보이는데..?

mappedBy를 이용한 구조 변경

앞과 같이 엔티티 테이블 사이에 생성되는 테이블을 흔히 '매핑 테이블'이라고 하는데 매핑 테이블을 생성하지 않는 방법으로는

  1. 첫 번째는 단방향으로 @OneToMany를 이용하는 경우 @JoinColumn을 이용하거나
  2. 두 번째로 mappedBy라는 속성을 이용하는 방법이 있다.

이 중 mappedBy의 경우 Board와 BoardImage가 서로 참조를 유지하는 양방향 참조 상황에서 사용하는데 mappedBy는 '어떤 엔티티의 속성으로 매핑되는지'를 의미합니다.

흔히 mappedBy를 '연관 관계의 주인'이라고 해석하기도 합니다. 예를 들어 게시물 관점에서 보면
첨부파일은 완전히 별개의 존재로 하나의 첨부파일이 여러 개의 게시물에서 사용될 수 있는 구조를
가정하고 생성하게 됩니다.
반대로 첨부파일의 관점에서는 하나의 게시물을 참조하는 구조가 필요하므로 mappedBy를 이용해서 
첨부파일쪽의 해석을 적용할 것임을 명시합니다.

mappedBy를 적용하기 전에 기존의 테이블들을 삭제합니다.

  • Board 클래스의 연관관계를 수정합니다.

mappedBy를 적용한 후에 프로젝트를 실행하면 다음과 같은 테이블들이 생성되는데 BoardImage가 연관관계의 핵심이므로 @ManyToOne의 구조처럼 테이블이 생성된 것을 확인할 수 있습니다.

영속성의 전이(cascade)

상위 엔티티(Board)와 하위 엔티티(BoardImage)의 연관 관계를 상위 엔티티에서 관리하는 경우 신경써야 하는 가장 중요한 점 중에 하나는 상위 엔티티 객체의 상태가 변경되었을 때 하위 엔티티 객체들 역시 같이 영향을 받는다는 점입니다.

JPA에서는 '영속성의 전이(cacade)'라는 용어로 이를 표현하는데, 가장 대표적인 영속성의 전이가 바로 지금부터 작성하게 되는 Board와 BoardImage의 저장입니다.

  • 예를 들어 BoardImage 객체가 JPA에 의해서 관리되면 BoardImage를 참조하고 있는 Board 객체도 같이 처리되어야 합니다. 반대로 Board 객체가 변경될 때 BoardImage 객체들 역시 영향을 받을 수 있습니다.

JPA에서는 이러한 경우 연관 관계에 cascade 속성을 부여해서 이를 제어하도록 합니다.

  • PERSIST, REMOVE : 상위 엔티티가 영속 처리될 때 하위 엔티티들도 같이 영속 처리
  • MERGE, REFRESH, DETACH : 상위 엔티티의 상태가 변경될 때 하위 엔티티들도 같이 상태 변경(merge, refresh, detach)
  • ALL : 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용

Board와 BoardImage의 insert 테스트

현재 구조에서 BoardImage는 Board가 저장될 때 같이 저장되어야 하는 엔티티 객체입니다.
이처럼 상위 엔티티가 하위 엔티티 객체들을 관리하는 경우에는 별도의 JPARepository를 생성하지 않고, Board 엔티티에 하위 엔티티 객체들을 관리하는 기능을 추가해서 사용합니다.

Board 객체 자체에서 BoardImage 객체들을 관리하도록 addImage()와 clearImages()를 이용해서 Board 내에서 BoardImage 객체들을 모두 관리하도록 합니다.

  • addImage()는 내부적으로 BoardImage 객체 내부의 Board에 대한 참조를 this를 이용해서 처리합니다(양방향의 경우 참조 관계가 서로 일치하도록 작성해야만 합니다).
.board(this)        //양방향 관계를 유지하기 위해 BoardImage 객체 내부의 board 변수에 현재 Board 엔티티를 참조
  • clearImage()는 첨부파일들을 모두 삭제하므로 BoardImage 객체의 Board 참조를 null로 변경하게 합니다(필수적이지는 않지만 항상 상위 엔티티의 상태와 하위 엔티티의 상태를 맞추는 것이 좋습니다).
 imageSet.forEach(boardImage -> boardImage.changeBoard(null)); // imageSet에 속한 모든 BoardImage의 board 변수를 null로 변경하여 참조를 끊고, imageSet을 비워 양방향 관계를 제거

상위 엔티티인 Board에서 BoardImage들을 관리하므로 테스트 역시 BoardRepository 자체를이용해서 처리할 수 있습니다. BoardRepositoryTests를 이용해서 첨부파일이 있는 게시물을 등록해 보도록 합니다.


게시물 하나에 3개의 첨부파일을 추가하는 경우를 가정한 것
영속성 전이가 일어나기 때문에 다음과 같이 board 테이블에 1번,board_image 테이블에 3번 insert가 일어나게 됩니다.


0개의 댓글