-
[JPA] @OneToOne 관계에서 N + 1 발생하는 이유가 무엇일까?Server/Spring 2022. 6. 4. 00:38728x90반응형
@OneToOne 관계에서 N + 1이 발생하는 이유가 무엇일까?
이번 글에서는
@OneToOne
관계에서N + 1
문제가 발생하는 원인이 무엇인지 해결하기 위해서는 어떤 대안들이 있는지 정리해보겠습니다.OneToOne 관계의 예시를 들기 위해서
File - Thumbnail_Image
로 예시를 들어 정리해보겠습니다.하나의
File
에는Thumbnail_Image
하나만 존재할 수 있는 상황입니다. 그리고File
이 생성된 후에Thumbnail Image
가 생성될 수 있습니다.(File이 없다면 Thumbnail_Image는 존재할 수 없습니다.)위와 같은 조건이 있기 때문에
File - Thumbnail_Image
사이는@OneToOne
관계입니다.@OneToOne 관계는 어떤 테이블에서
외래키를 가질 것인지
를 정해야 합니다.File 테이블이 thumbnail_id 외래키를 가지는 경우
Thumbnail Image 테이블이 file_id 외래키를 가지는 경우
두 가지 경우가 있는데요. 먼저 첫 번째 경우인
File 테이블이 thumbnail_id 외래키를 가지는 상황
에 대해서 알아보겠습니다.File 테이블에서 thumbnail_id 외래키를 가지고 있을 때
File Table
에서thumbnail_id
라는 외래키를 가진다.thumbnail_id
는nullable
한 컬럼이어야 한다.Thumbnail_Image Entity
를 저장하기 위해서는 2번의 쿼리를 실행해야 한다.Thumbnail Entity Save
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
를 가질 수 있는 확장성을 고려했을 때도File
이thumbnail_id
를 가지는 것은 좋지 않다고 생각했습니다.그래서
File 테이블에서 thumbnail_id
외래키를 가지는 것이 아니라thumbnail_image 테이블에서 file_id
를 가지면 한번의Write
작업으로 해결할 수 있기 때문에,File 테이블에서 외래키
를 가지는 방법보다는Thumbnail Image 테이블에서 file_id 외래키
를 가지는 것이 더 좋은 방법이라고 생각했습니다.Thumbnail Image 테이블에서 file_id 외래키를 가지고 있을 때
위와 같은 테이블 구조에서는
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 Entity
는mappedBy
속성을 가진 연관관계의 주인이 아닌 것을 알 수 있습니다. 그리고지연로딩(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; }
File 테이블에는 thumbnail_id 라는 외래키가 없기 때문에 File Entity
입장에서는File에 연결되어 있는 Thumbnail Image
가null
인지 아닌지를 조회해보기 전까지는 알 수 없습니다.그리고
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
형태로 참조하고 있을 것인데요.@OneToMany
는OneToOne
과 다르게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 join
과Batch Size
로 해결할 수 있는지 알아보겠습니다.Batch Size는 @OneToOne의 N + 1 문제를 해결할 수 있을까?
spring: jpa: properties: hibernate.default_batch_fetch_size: 1000
N + 1
을 해결하는 대표적인 방법 중에 하나가Batch Size
입니다. 사용 방법은 위와 같이application.yml
에batch size
설정을 주는 것입니다.Batch Size
는N + 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
반응형'Server > Spring' 카테고리의 다른 글
[Spring] Security WebSecurityConfigurerAdapter Deprecated 해결하기 (0) 2022.06.29 [Spring] Multi-Module에서 Domain 모듈 테스트 실행하는 법 (1) 2022.06.23 [Spring] Spring Security, React를 사용하면서 CORS 허용하는 방법 (0) 2021.12.23 [Spring] AWS EC2에서 Spring Access log, logger log 저장하는 법 (0) 2021.12.09 [Spring] 스프링에서 의존성 주입을 하는 3가지 방법 (0) 2021.11.29