고민해 보기

JPA One To Many 그리고 N+1

NEWDODORIPYO 2022. 7. 18. 20:30

JPA를 하면서 연관관계에 대해 공부하던 중 접하게 된 One To Many 

장점과 단점이 있었지만 솔직히 장점보다는 단점이 더 많은 방법처럼 느껴졌습니다.

 

우선 장점으로는 관리의 주체가 도메인  스러워지고 Pk위주의 설계인점 이 있었고 

단점으로는 단방향 참조이기에 페이징 ,  N+1 등 다양한 문제점들이 있었습니다.  

 

😉그럼 좀더 깊~~~ 숙하게 One To Many를 알아보겠습니다.

 

😒맵핑 테이블?

우선 One To Many 는 기본적으로 중간에 맵핑 테이블을 생성합니다 하지만 이런 방식이 불편하게 느껴지기에 저는 강제적으로 맵핑 테이블이 생성되지 못하도록 처리해서 코드를 작성했습니다.

 

@Entity
@Table(name = "t_board")
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@ToString
public class Board extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer bno;

    @Column(length = 200, nullable = false)
    private String title;

    private String content;

    private String writer;

    public void changeTitle(String title){
        this.title = title;
    }

    public void changeContent(String content){
        this.content = content;
    }
	@OneToMany(fetch = FetchType.LAZY)
    @JoinColumn(name = "board")
    @Builder.Default //자료구조를 반환할때 null값을 체크안해주게 만들어주기 
    private Set<BoardImage> boardImages = new HashSet<>();
		
		//이미지를 추가
		public void addImage(BoardImage boardImage){
				
				//처음에 들어갈때 size가 0번으로 들어간다 
        boardImage.fixOrd(boardImages.size());
        boardImages.add(boardImage);

    }
}

@OneToMany(fetch = FetchType.LAZY) , @JoinColumn(name = "board") 이 2가지를 설정을 안 해주고 OneToMany를 사용하면 맵핑 테이블이 만들어진다 그걸 사용하지 않으려면 이렇게 추가적인 설정을 해주어야 한다.

 

😱❗❗ 에러 발생!!!  object references an unsaved transient instance”  

테스트를 진행하려던 중 “object references an unsaved transient instance”  가 발생했다.

진행하던 테스트 코드는 이렇다. 

@Test
    public void testInsertWithImage() {

        for (int i = 0; i < 20; i++) {

            Board board = Board.builder()
                    .title("fileTest.." +i)
                    .content("fileTest")
                    .writer("user" + (i % 10))
                    .build();
					//게시물 하나당 이미지 2개씩 추가 
            for (int j = 0; j < 2; j++) {
                BoardImage boardImage = BoardImage.builder()
                        .uuid(UUID.randomUUID().toString())
                        .fileName(i+"aaa.jpg")
                        .img(true)
                        .build();
                board.addImage(boardImage); //관리의 주체가 board기 때문에 board에 추가해주어야한다
            }//image

            repository.save(board);
        }

    }
  • 이 테스트를 할 때 Board <Entity>에 boardImages에 “cascade = CascadeType.ALL” 설정이 없다면 “object references an unsaved transient instance” 이 에러가 발생한다.
  • 이 에러는 @OneToMany나 @ManyToMany인 상황에서 흔히 만나는 에러이다. 부모 객체에서 자식 객체를 바인딩하여 한번에 저장하려는데 자식 객체가 아직 데이터 베이스에 저장되지 않았기 때문에 발생한다.
  • 해결 방안 = 부모 객체에 cascade 옵션을 추가하면 된다.
  • cascade = CascadeType.ALL을 하면 되는데 이거만 해서는 안 먹힐 때가 있다.. 그럴 때는 자식 객체도 똑같이 cascade 옵션을 추가해주면 해결!
  • CascadeType에는 PERSIST, MERGE, REMOVE, REFRESH, DETACH, ALL이 있는데 자세한 사항은 따로 찾아보자

Entity를 수정한 모습 

package org.zerock.b2.entity;

import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;


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

@Entity
@Table(name = "t_board")
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@ToString
public class Board extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) //Auto_Increment 기능
    private Integer bno;


    //컬럼의 사이즈 지정해주고 not null 지정
    @Column(length = 200, nullable = false)
    private String title;

    private String content;

    private String writer;



    public void changeTitle(String title){
        this.title = title;
    }
    public void changeContent(String content){
        this.content = content;
    }

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "board")
    @Builder.Default //자료구조를 반환할때 null값을 체크안해주게 만들어주기
    private Set<BoardImage> boardImages = new HashSet<>();

    //이미지를 추가
    public void addImage(BoardImage boardImage){

        //처음에 들어갈때 size가 0번으로 들어간다
        boardImage.fixOrd(boardImages.size());
        boardImages.add(boardImage);

    }

}

위에서 설명한 것처럼 cascade = CascadeType.ALL”를 추가해 주었다. 

그리고 새롭게 orphanRemoval = true 추가해 주었는데 이걸 추가해준 이유는 Board 즉 <Entity>가 삭제된다면 이미지도 삭제되어야 하기는 데 orphanRemoval = true 이 코드가 없으면 부모 entity 가 없는데 자식 entity 가 있는 일이 생길 수 있다. 

 

직접 만난 N+1....... 아후....🤯

우선 N+1을 만나게 된 테스트 코드는 이러하다.

@Transactional
@Test
public void testPageImage(){

    //1페이지를 가져오고 Sort 로 역순으로 가져온다
    Pageable pageable = PageRequest.of(0,10 , Sort.by("bno").descending());

    //findAll 로 Board 를 가져온다 , 검색조건이 없는 상태로 목록을 가져온다
    Page<Board> result = repository.findAll(pageable);

    //게시물을 로그로 찍어보자
    result.getContent().forEach(board -> {
log.info(board);
    });
}

 

결과 

이 테스트에서 중요한 점

  • 연관관계가 복잡할 때 사용하는 여러 방법 fetchJoin 등등 사용할 때 가장 중요한 것은 Limit 가 걸리는지 확인을 해야 한다 테스트가 성공해도 Limit 가 안 걸리면 데이터가 100만 건이면 100만 개를 다 불러온다는 이야기이다 Limit 가 없으면 성공하나 마나 DB가 죽는다.
  • 쿼리를 보면 from으로 t_bimage을 select를 하는 걸 볼 수 있는데 게시물마다 이미지 테이블을 전부 찍는 것이다
    • 이러면 쿼리가 목록 가져오는 쿼리가 실행되고 카운트 쿼리가 실행되고 게시물 하나당 이미지 쿼리가 실행된다 이런 문제가 N+1이다 이러면 시스템은 바로 죽는다…

 

 

One To Many에서 만난  N+1 절망적이었습니다.

이걸 해결하기 위에 EntityGraph 나 BatchSize 등 다양한 방법을 사용해서 해결하긴 했지만 만족스럽지는 못 했다.

강제적으로 해결하는 방식이어서 좋은 방법이라고는 말하지 못할 것 같다. 

One To Many는 사용해보니 현재 진행 중인 프로젝트에는 적합한 방법은 아닌 것 같다. 

하지만 이 코드들을 작성하면서 배운 점이 많아 그 점이 장점처럼 느껴지는 것 같습니다.

One To Many  가 아닌 ElementCollection 방법으로 다시 만들어 봐야겠습니다.

'고민해 보기' 카테고리의 다른 글

Map 이 아닌 객체를 사용하는 이유  (0) 2022.08.22