ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JPA] @OneToOne 관계에서 N + 1 발생하는 이유가 무엇일까?
    Server/Spring 2022. 6. 4. 00:38
    728x90
    반응형

    @OneToOne 관계에서 N + 1이 발생하는 이유가 무엇일까?

    이번 글에서는 @OneToOne 관계에서 N + 1 문제가 발생하는 원인이 무엇인지 해결하기 위해서는 어떤 대안들이 있는지 정리해보겠습니다.

     

    OneToOne 관계의 예시를 들기 위해서 File - Thumbnail_Image로 예시를 들어 정리해보겠습니다.

    하나의 File에는 Thumbnail_Image 하나만 존재할 수 있는 상황입니다. 그리고 File이 생성된 후에 Thumbnail Image가 생성될 수 있습니다.(File이 없다면 Thumbnail_Image는 존재할 수 없습니다.)

     

    위와 같은 조건이 있기 때문에 File - Thumbnail_Image 사이는 @OneToOne 관계입니다.

    @OneToOne 관계는 어떤 테이블에서 외래키를 가질 것인지를 정해야 합니다.

    1. File 테이블이 thumbnail_id 외래키를 가지는 경우
    2. Thumbnail Image 테이블이 file_id 외래키를 가지는 경우

     

    두 가지 경우가 있는데요. 먼저 첫 번째 경우인 File 테이블이 thumbnail_id 외래키를 가지는 상황에 대해서 알아보겠습니다.



    File 테이블에서 thumbnail_id 외래키를 가지고 있을 때

    스크린샷 2022-02-19 오후 9 35 34

    1. File Table에서 thumbnail_id 라는 외래키를 가진다.
    2. thumbnail_idnullable한 컬럼이어야 한다.
    3. Thumbnail_Image Entity를 저장하기 위해서는 2번의 쿼리를 실행해야 한다.
      1. Thumbnail Entity Save
      2. File 테이블에 thumbnail_id FK Update

     

    File 테이블에서 thumbnail_id 외래키를 가지기 위해서는 위의 3가지 조건을 가지게 되는데요. 여기서 File-Thumbnail Image 사이에 3번의 경우가 정확히 어떤 것을 의미하는지 코드의 예시를 보겠습니다.

    @Entity
    public class File {
    
       @Id 
       @GeneratedValue(strategy = GenerationType.IDENTITY)
       private Long Id;
    
       @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
       private ThumbnailImage thumbnailImage;
    
       public void setThumbnailImage(ThumbnaulImage thumbnailImage) {
           this.thumbnailImage = thumbnailImage;
           thumbnailImage.setFile(this);
       }
    }
    @Entity
    public class ThumbnailImage {
    
       @Id 
       @GeneratedValue(strategy = GenerationType.IDENTITY)
       private Long Id;
    
       @OneToOne(mappedBy = "thumbnailImage", fetch = FetchType.LAZY)
       private File file;
    }
    @RequiredArgsConstructor
    @Service
    public class ThumbnailImageService {
    
        private final FileRepository fileRepository;
        private final ThumbnailRepository thumbnailRepository;
    
        @Transactional
        public void saveThumbnail(final Long fileId, final ThumbnailImageRequestDto thumbnailImageRequestDto) {
            File file = fileRepository.findById(fileId).orElseThrow(EntityNotFoundException::new);
            file.setThumbnailImage(thumbnailImageRequestDto.toEntity());
        }
    }
    Hibernate: 
        select
            file0_.id as id1_0_0_,
            file0_.file_size as file_siz2_0_0_,
            file0_.filename as filename3_0_0_,
            file0_.thumbnail_image_id as thumbnai4_0_0_ 
        from
            file file0_ 
        where
            file0_.id=?
    Hibernate: 
        insert 
        into
            thumbnail_image
            (thumbnail_image_name, thumbnail_image_size) 
        values
            (?, ?)
    Hibernate: 
        update
            file 
        set
            file_size=?,
            filename=?,
            thumbnail_image_id=? 
        where
            id=?

    위의 saveThumbnail 메소드를 보면 Thumbnail Image를 먼저 저장을 합니다. 그리고 저장한 후에 생긴 thumbnail_id(PK) 값을 File 테이블에 존재하는 thumbnail_id에 업데이트 쿼리를 한번 더 실행을 해주어야 합니다. File과 Thumbnail Image를 연결하는 작업이 필요합니다.

     

    즉, Write 작업이 2번 필요한데요. 이 때 저는 Write 작업이 2번 일어나서 성능이 걱정된다기 보다는 Thumbnail Image를 저장하는데 2번의 Write 작업이 필요하다는 것이 깔끔하지 않다고 생각했습니다.

     

    뿐만 아니라 데이터베이스 관점에서도 File이 여러 개의 Thumbnail Image를 가질 수 있는 확장성을 고려했을 때도 Filethumbnail_id를 가지는 것은 좋지 않다고 생각했습니다.

     

    그래서 File 테이블에서 thumbnail_id 외래키를 가지는 것이 아니라 thumbnail_image 테이블에서 file_id를 가지면 한번의 Write 작업으로 해결할 수 있기 때문에, File 테이블에서 외래키를 가지는 방법보다는 Thumbnail Image 테이블에서 file_id 외래키를 가지는 것이 더 좋은 방법이라고 생각했습니다.



    Thumbnail Image 테이블에서 file_id 외래키를 가지고 있을 때

    스크린샷 2022-02-13 오전 11 45 51

    위와 같은 테이블 구조에서는 Thumbnail Image를 저장할 때 1번의 Write 작업으로 가능하다는 장점이 있습니다. 하지만 위와 같이 설계하면 JPA를 사용했을 때 예상치 못했던 이슈가 발생했는데요. 이슈의 내용은 아래와 같습니다.



    File Entity를 조회할 때

    현재 File 테이블에는 5개의 데이터가 존재하고 Thumbnail 테이블은 데이터가 존재할 수도 있고 존재하지 않을 수도 있는 상황입니다.

    @Entity
    public class File {
    
       @Id 
       @GeneratedValue(strategy = GenerationType.IDENTITY)
       private Long Id;
    
       @OneToOne(mappedBy = "file", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
       private ThumbnailImage thumbnailImage;
    
       public void setThumbnailImage(ThumbnaulImage thumbnailImage) {
           this.thumbnailImage = thumbnailImage;
           thumbnailImage.setFile(this);
       }
    }
    • File, Thumbnail 관계는 LAZY 로딩

     

    @Entity
    public class ThumbnailImage {
    
       @Id 
       @GeneratedValue(strategy = GenerationType.IDENTITY)
       private Long Id;
    
       @OneToOne(fetch = FetchType.LAZY)
       @JoinColumn(name = "file_id")
       private File file;
    }
    • Thumbnail, File 관계는 LAZY 로딩

     

    @RequiredArgsConstructor
    @Service
    public class FileService {
    
        private final FileRepository fileRepository;
    
        public List<FileResponseDto> getFiles() {
            return fileRepository.findAll().stream()
                    .map(FileResponseDto::from)
                    .collect(Collectors.toList());
        }
    }

    이 때 위와 같이 File을 전체 조회하는 findAll()을 통해서 조회하고 있습니다.(File 5개를 조회하는 것입니다.)

    이 때 저는 File Entity를 전체 조회하는 쿼리 한번만 실행될 것이라 예측했습니다.(아마 이 글을 보시는 분들도 그렇게 예측하시지 않을까 싶습니다.)

     

    Hibernate: 
        select
            file0_.id as id1_0_,
            file0_.file_size as file_siz2_0_,
            file0_.filename as filename3_0_ 
        from
            file file0_
    Hibernate: 
        select
            thumbnaili0_.id as id1_1_0_,
            thumbnaili0_.file_id as file_id4_1_0_,
            thumbnaili0_.thumbnail_image_name as thumbnai2_1_0_,
            thumbnaili0_.thumbnail_image_size as thumbnai3_1_0_ 
        from
            thumbnail_image thumbnaili0_ 
        where
            thumbnaili0_.file_id=?
    Hibernate: 
        select
            thumbnaili0_.id as id1_1_0_,
            thumbnaili0_.file_id as file_id4_1_0_,
            thumbnaili0_.thumbnail_image_name as thumbnai2_1_0_,
            thumbnaili0_.thumbnail_image_size as thumbnai3_1_0_ 
        from
            thumbnail_image thumbnaili0_ 
        where
            thumbnaili0_.file_id=?
    Hibernate: 
        select
            thumbnaili0_.id as id1_1_0_,
            thumbnaili0_.file_id as file_id4_1_0_,
            thumbnaili0_.thumbnail_image_name as thumbnai2_1_0_,
            thumbnaili0_.thumbnail_image_size as thumbnai3_1_0_ 
        from
            thumbnail_image thumbnaili0_ 
        where
            thumbnaili0_.file_id=?
    Hibernate: 
        select
            thumbnaili0_.id as id1_1_0_,
            thumbnaili0_.file_id as file_id4_1_0_,
            thumbnaili0_.thumbnail_image_name as thumbnai2_1_0_,
            thumbnaili0_.thumbnail_image_size as thumbnai3_1_0_ 
        from
            thumbnail_image thumbnaili0_ 
        where
            thumbnaili0_.file_id=?
    Hibernate: 
        select
            thumbnaili0_.id as id1_1_0_,
            thumbnaili0_.file_id as file_id4_1_0_,
            thumbnaili0_.thumbnail_image_name as thumbnai2_1_0_,
            thumbnaili0_.thumbnail_image_size as thumbnai3_1_0_ 
        from
            thumbnail_image thumbnaili0_ 
        where
            thumbnaili0_.file_id=?

    하지만 실행된 쿼리를 보았을 때는 예상과 달랐습니다. File 테이블 조회했을 때의 결과 row의 수 만큼 Thumbnail Image Entity를 조회하는 쿼리가 실행된 것을 볼 수 있습니다. 즉, 위에서 5개의 File 데이터가 있다고 했으니 Thumbnail Image를 조회하는 쿼리 5번이 더 실행되어 총 6번의 쿼리가 실행된 것을 볼 수 있습니다.(File 1번, Thumbnail 5번, 1 + N 쿼리)

     

    저는 여기서 왜 이러한 현상이 발생하는 것인지?가 궁금했습니다. 이러한 현상이 발생하는 이유를 알아보기 위해서 이번에는 Entity 관점에서 알아보겠습니다.



    File Entity

    @Entity
    public class File {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String filename;
    
        private String fileSize;
    
        @OneToOne(mappedBy = "file", fetch = FetchType.LAZY)
        private ThumbnailImage thumbnailImage;
    }

    이 부분 부터가 중요합니다!

     

    현재 Thumbnail 테이블에서 외래키를 가지기 때문에 File EntitymappedBy 속성을 가진 연관관계의 주인이 아닌 것을 알 수 있습니다. 그리고 지연로딩(LAZY)이 적용되어 있습니다. 테이블 관점에서 보면 File 테이블(외래키가 없는 테이블)에서 Thumbnail Image(외래키가 있는 테이블)을 조회할 수 있다는 특징이 있습니다.

     

    하지만 객체 관점에서는 File -> Thumbnail을 참조하기 위해서는 File -> Thumbnail Image를 참조하는 관계가 필요합니다. 즉, 양방향 매핑이 필요한데요. 그런데 위와 같이 @OneToOne 관계에서 양방향 매핑이 되어 연관관계 주인이 아닌 곳에서 조회했을 때 발생하는 문제가 있습니다. 그 부분을 아래에서 자세히 알아보겠습니다.

     

    양방향 매핑을 한 이유는 비즈니스에 따라 다를 수 있겠지만 일반적으로는 File을 통해서 Thumbnail Image를 조회하는 경우가 대부분이고, Thumbnail Image를 통해서 File을 조회하는 경우는 거의 없을 것이기 때문입니다.



    Thumbnail Image Entity

    @Entity
    public class ThumbnailImage {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String thumbnailImageName;
    
        private String thumbnailImageSize;
    
        @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
        private File file;
    
    }

    ThumbnailImage Entity는 외래키를 가지고 있는 테이블과 연결되어 있는 엔티티이기 때문에 객체의 관점에서도 연관관계의 주인이 됩니다.



    @OneToOne 관계에서는 연관관계 주인이 아닌 곳에서 조회할 때 N + 1 문제가 발생한다.

    저는 N + 1 문제를 어떻게 해결해야 하는지? 보다는 왜 연관관계 주인이 아닌 곳에서 조회할 때 조회하지 않은 엔티티가 조회되어 N + 1 문제가 발생하는지?가 궁금했습니다.

     

    연관관계 주인이 아닌 File Entity

    @Entity
    public class File {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String filename;
    
        private String fileSize;
    
        @OneToOne(mappedBy = "file", fetch = FetchType.LAZY)
        private ThumbnailImage thumbnailImage;
    }

    스크린샷 2022-02-13 오전 11 45 51

    File 테이블에는 thumbnail_id 라는 외래키가 없기 때문에 File Entity 입장에서는 File에 연결되어 있는 Thumbnail Imagenull 인지 아닌지를 조회해보기 전까지는 알 수 없습니다.

     

    그리고 LAZY 로딩이어서 프록시 객체를 사용할 것처럼 보이지만, 실제로는 Proxy 객체를 사용하지 않고 있습니다. 그 이유는 Proxy 객체를 만들기 위해서는 Thumbnail Image 객체가 null인지 값이 있는지를 알아야 하는데, File Entity 객체 관점으로는 알 수 없기 때문입니다.

     

    그래서 Thumbnail Image를 조회하는 쿼리들이 실행되는 것입니다. 이렇게 쿼리들을 실제로 조회를 하면 영속성 컨텍스트에 엔티티들이 올라오기 때문에 프록시 객체를 사용할 이유가 없어져서 LAZY 로딩으로 설정하여도 즉시 로딩처럼 동작하는 것입니다.

    지연 로딩을 설정하여도 즉시 로딩으로 동작하는 이유는 JPA의 구현체인 Hibernate 에서 프록시 기능의 한계로 지연 로딩을 지원하지 못하기 때문에 발생한다. bytecode instrumentation을 사용하면 해결할 수 있다.



    Reference: JPA ORM 프로그래밍

     

    좀 더 자세한 설명은 여기 에서도 확인할 수 있는데요.

    The reason for this is that owner entity MUST know whether association property should contain a proxy object or NULL and it can't determine that by looking at its base table's columns due to one-to-one normally being mapped via shared PK, so it has to be eagerly fetched anyway making proxy pointless.

    위의 링크를 보면 위와 같이 설명하고 있습니다. 즉, 연관관계 주인이 아닌 테이블에서는 프록시로 만들 객체가 null 인지 아닌지 알 수 없기 때문에 조회하는 쿼리가 실행되는 것입니다.



     

    @OneToMany 에서 Lazy Loading이 적용되는 이유가 무엇일까?

    이제 @OneToOne 관계에서 연관관계 주인이 아닌 쪽에서 조회를 하면 참조하고 있는 객체가 null 인지 아닌지 알 수 없기 때문에 프록시를 사용할 수 없기 때문에 N + 1 문제가 발생하는 것은 이해했는데요.

     

    그러면 @OneToMany 관계에서도 연관관계 주인이 아니기 때문에 똑같이 Proxy가 적용되지 않아야 맞는거 아닐까? 라는 생각을 했습니다.

    @Entity
    public class User {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String nickname;
    
        private String part;
    
        @OneToMany(mappedBy = "user")
        private List<Post> posts = new ArrayList<>();
    
    }

    @OneToMany 경우라면 위와 같이 List 형태로 참조하고 있을 것인데요. @OneToManyOneToOne과 다르게 Lazy Loading이 적용이 됩니다. 적용이 되는 이유는 무엇일까요?

     

    위에서 말했던 링크 에서 답이 나와 있는데요.

    many-to-one associations (and one-to-many, obviously) do not suffer from this issue. Owner entity can easily check its own FK (and in case of one-to-many, empty collection proxy is created initially and populated on demand), so the association can be lazy.

    요약하자면, @OneToMany 관계는 빈 컬렉션이 초기화될 때(new ArrayList<>() 할 때) Proxy가 생긴다. 입니다. 다시 말하면 posts 자체는 null이 아니고 size 자체가 0일 수 있는 것이기 때문에 @OneToMany 관계는 @OneToOne과 다르게 Lazy Loading이 가능했던 것입니다.

     

    그러면 이번에는 다시 @OneToOne 관계로 돌아와서 연관관계 주인인 Thumbnail Image Entity에서 조회를 해보겠습니다.



    Thumbnail Image에서는 지연로딩이 적용이 될까?

    @Entity
    public class ThumbnailImage {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String thumbnailImageName;
    
        private String thumbnailImageSize;
    
        @OneToOne(fetch = FetchType.LAZY)
        private File file;
    }

    Thumbnail Image Entity를 보면 연관 관계의 주인입니다. 즉, Thumbnail Image 테이블에서 file_id 외래키를 가지고 있기 때문에 Thumbnail Image 객체 입장에서 굳이 File Entity를 조회해보지 않아도 File Entity가 존재하는지 안하는지를 알 수 있습니다.

     

    그렇기에 프록시 객체도 만들 수 있어서 Thumbnail Image를 통해서 File을 조회했을 때 지연로딩이 적용될 수 있는 것입니다.



    N + 1 문제를 해결해보기

    N + 1 문제를 해결하려면 fetct join, entity graph, batch size 같은 것들을 사용하면 됩니다. 이 중에서 fetch joinBatch Size로 해결할 수 있는지 알아보겠습니다.



    Batch Size는 @OneToOne의 N + 1 문제를 해결할 수 있을까?

    spring:
      jpa:
        properties:
          hibernate.default_batch_fetch_size: 1000

    N + 1을 해결하는 대표적인 방법 중에 하나가 Batch Size 입니다. 사용 방법은 위와 같이 application.ymlbatch size 설정을 주는 것입니다. Batch SizeN + 1 쿼리 처럼 쿼리를 나눠서 실행하지 않고 IN 절을 통해서 쿼리를 실행하는 것입니다.

     

    그래서 저는 위의 설정을 한 후에 File을 조회하면 N + 1 문제가 발생하지 않고 IN 절을 통해서 한번의 쿼리가 실행될 것이라고 예상했습니다. 하지만 여전히 N + 1 문제가 발생했습니다. 즉, 결과가 달라지지 않았는데요.

     

    결과가 달리지지 않는 이유는 @OneToOne에서는 지연 로딩이 적용되지 않고, 해당 객체가 null 인지 아닌지를 알아야 하기에 조회해야 하는 문제 때문에 N + 1 문제는 Batch Size로는 해결할 수 없었습니다.



    fetch join을 사용해서 N + 1 문제를 해결해보기

    그래서 이번에는 N + 1 문제를 해결하는 가장 대표적인 fetch join을 사용해보겠습니다.

    SELECT f FROM File f join fetch f.thumbnailImage

    위와 같은 JPQL을 사용하여 fetch join을 사용했을 때 어떤 쿼리들이 실행되는지 알아보겠습니다.

     

    Hibernate: 
        select
            file0_.id as id1_0_0_,
            thumbnaili1_.id as id1_2_1_,
            file0_.file_size as file_siz2_0_0_,
            file0_.filename as filename3_0_0_,
            thumbnaili1_.file_id as file_id4_2_1_,
            thumbnaili1_.thumbnail_image_name as thumbnai2_2_1_,
            thumbnaili1_.thumbnail_image_size as thumbnai3_2_1_ 
        from
            file file0_ 
        inner join
            thumbnail_image thumbnaili1_ 
                on file0_.id=thumbnaili1_.file_id

    이번에는 N + 1 쿼리가 발생하지 않고 JOIN을 통해서 1번의 쿼리로 조회할 수 있습니다. 하지만 N + 1 문제를 해결하는 fetch join을 사용하면 저는 File Entity만 조회하고 싶은데 Thumbnail Image Entity 까지 같이 조회하게 되어 이것도 마냥 해법은 아니라는 생각도 들었습니다.



    정리하기

    저는 DB 테이블 관점에서 Entity 설계를 해야 좀 더 적절하다고 생각이 드는데요. File 테이블에서 thumbnail_id 외래키를 가지면 File 테이블에서 nullable 컬럼을 가져하기에 비즈니스 로직에서 검증 로직이 추가되어야 해서 좋지 않다고 생각했습니다.

     

    뿐만 아니라 확장성을 고려했을 때 File이 여러 개의 Thumbnail Image를 가질 수 있게 된다고 고려했을 때를 생각했을 때도 Thumbnail Image에서 file_id로 가지는 것이 더 적절하다고 생각했습니다.

     

    하지만어떤 것이 무조건 좋다 라기 보다는 상황에 따라 다를 수 있다 라고 생각합니다. 만약에 File만 조회하는 경우는 거의 없고, File, Thumbnail Image가 같이 필요할 때가 많다면 fetch join으로 사용해서 계속 File-Thumbnail Image를 같이 조회해도 엄청 큰 부하는 준다고 생각하지 않기 때문입니다.

     

    그런데 만약 대부분의 곳에서 File 조회만 필요하고 일부분에서 File-Thumbnail을 같이 조회하는 것이 필요하고, JOIN을 하는 것 자체가 부담이라면 처음 말했던 방식처럼 File Entity에서 thumbnail_id 외래키를 가지도록 설계하여 Thumbnail Image Entity를 저장할 때 Write 작업을 2번하도록 하는 것도 하나의 방법이라 생각합니다.



    Reference

    반응형

    댓글

Designed by Tistory.