이 글은 2021년 11월에 작성되었으며 블로그를 이전하며 옮기게 되었습니다.
제 글에 문제가 있다면 댓글로 알려주시면 감사하겠습니다! 🙇♂️
저는 JPA를 이용하며 프로젝트를 하며 N+1 문제를 만나보진 않았습니다.
하지만 JPA를 사용하면 자주 만나게 되는 것이 N+1 문제입니다.
JPA를 공부 할 때에 예시 상황을 만들고 공부를 했었지만
공부한 지 시간이 좀 지났기 때문에 프로젝트 중 N+1 문제가 생기더라도
당황하지 않고 빠르게 해결하기 위해, 다시 공부하기 위해 이 글을 작성합니다.
이동욱 님의이동욱 님의 글을 참고하여
Java8 -> Java11,
SpringBoot 1.5.X -> 2.5.X,
Junit4 -> Junit5로 버전을 업그레이드하여
코드를 작성하였고, 이 글을 작성하였습니다
참고하였습니다 🙇♂️
모든 코드는 Github에 있습니다.
먼저 클래스 다이어그램과 ERD입니다.
클래스 다이어그램, ERD
위와 같은 구조에서 Academy를 호출하여 그 안에 속한 Subject를 사용한다고 가정해보겠습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class AcademyService {
private final AcademyRepository academyRepository;
@Transactional(readOnly = true)
public List<String> findAllSubjectNames(){
return extractSubjectNames(academyRepository.findAll());
}
/**
* Lazy Load를 수행하기 위해 메소드를 별도로 생성
*/
private List<String> extractSubjectNames(List<Academy> academies){
log.info(">>>>>>>>[모든 과목을 추출한다]<<<<<<<<<");
log.info("Academy Size : {}", academies.size());
return academies.stream()
.map(a -> a.getSubjects().get(0).getName())
.collect(Collectors.toList());
}
}
여기서 서비스의 findAllSubjectNames를 호출하면 어떤 일이 발생하는지 테스트 코드를 작성하여 쿼리가 어떻게 생성되는지 확인해보겠습니다.
@Commit
@SpringBootTest
class AcademyServiceTest {
@Autowired private AcademyRepository academyRepository;
@Autowired private TeacherRepository teacherRepository;
@Autowired private AcademyService academyService;
@BeforeEach
public void setup() {
List<Academy> academies = new ArrayList<>();
Teacher teacher = teacherRepository.save(new Teacher("선생님"));
for(int i=0;i<10;i++){
Academy academy = Academy.builder()
.name("강남스쿨"+i)
.build();
academy.addSubject(Subject.builder().name("자바웹개발" + i).teacher(teacher).build());
academy.addSubject(Subject.builder().name("파이썬자동화" + i).teacher(teacher).build()); // Subject를 추가
academies.add(academy);
}
academyRepository.saveAll(academies);
}
@Test
public void Academy여러개를_조회시_Subject가_N1_쿼리가발생한다() throws Exception {
//given
List<String> subjectNames = academyService.findAllSubjectNames();
//then
assertEquals(10, subjectNames.size());
}
}
위의 테스트코드를 실행 보면
DB에는 이렇게 저장되고
전체 조회하는 쿼리 1개와
각각의 Academy가 본인들의 subject를 조회하는 쿼리 10개가 발생한 것을 확인할 수 있습니다.
이렇게 하위 엔티티들을 첫 쿼리 실행 시 한 번에 가져오지 않고, LAZY로딩(지연 로딩)으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제, 1개의 쿼리로 인해 N개의 쿼리가 더 나가는 상황을 N+1 문제라고 합니다.
(
1+N이 더 맞는 것 같은데 왜 N+1인지 모르겠네요😝
)
현재 상황에서는 Academy가 10개로 (첫 조회 쿼리 1개) + (Academy의 subject 10개 조회 쿼리 10개) = 11밖에 발생하지 않았지만, 만약 Academy 조회 결과가 10만 개라면 한 번의 서비스 로직을 실행하는데 DB 조회 쿼리가 10만 번이 발생하는 상황입니다.
그래서 이렇게 연관관계가 맺어진 Entity를 한 번에 가져오기 위한 몇 가지 방법들이 있습니다.
1. Join Fetch
첫 번째 방법은 join fetch
를 사용하는 방법입니다.
/**
* 1. join fetch를 통한 조회
*/
@Query("select a from Academy a join fetch a.subjects")
List<Academy> findAllJoinFetch();
조회 시 바로 가져오고 싶은 Entity 필드를 지정(join fetch a.subjects
)하는 것입니다.
이렇게 바꾼 후 테스트 코드를 실행하면
이렇게 하나의 쿼리로 모두 조회할 수 있습니다.
만약 Subject
의 하위 Entity
까지 한 번에 가져와야 할 때에도
아래와 같은 방법으로 쉽게 해결할 수 있습니다.
/**
* 5. Academy+Subject+Teacher를 join fetch로 조회
*/
@Query("select distinct a from Academy a join fetch a.subjects s join fetch s.teacher")
List<Academy> findAllWithTeacher();
a.subjects를 s로 alias 하여 s의 teacher를 join fetch 하면 한 번에 가져올 수 있습니다.
단, 이 방법은 불필요한 쿼리문이 추가되는 단점이 있습니다.
이 필드는 Eager
조회, 저 필드는 Lazy
조회를 해야 한다까지
쿼리에서 표현하는 것은 불필요하다고 생각하실 분들이 계실 수 있습니다.
그런 분들은 2번 방법을 사용해보시면 좋을 것 같습니다.
2. @EntityGraph
두 번째 방법은 @EntityGraph
를 사용하는 방법입니다.
/**
* 2. @EntityGraph
*/
@EntityGraph(attributePaths = "subjects")
@Query("select a from Academy a")
List<Academy> findAllEntityGraph();
@EntityGraph
의 attributePaths
에 쿼리 수행 시
바로 가져올 필드명을 지정하면 LAZY(지연 로딩)가
아닌 Eager(즉시 로딩)
조회로 가져오게 됩니다.
위처럼 원본 쿼리의 손상 없이 EAGER/LAZY
필드를 정의하고 사용할 수 있게 되었습니다.
추가로 Teacher
까지 한 번에 가져오는 쿼리도 아래와 같이 표현할 수 있습니다.
/**
* 6. Academy+Subject+Teacher를 @EntityGraph 로 조회
*/
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select DISTINCT a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();
🚨 사용 시 주의사항
Join Fetch
SELECT academy0_.id AS id1_0_0_,
subjects1_.id AS id1_1_1_,
academy0_.name AS name2_0_0_,
subjects1_.academy_id AS academy_3_1_1_,
subjects1_.name AS name2_1_1_,
subjects1_.teacher_id AS teacher_4_1_1_,
subjects1_.academy_id AS academy_3_1_0__,
subjects1_.id AS id1_1_0__
FROM academy academy0_
INNER JOIN subject subjects1_
ON academy0_.id = subjects1_.academy_id
@EntityGraph
SELECT academy0_.id AS id1_0_0_,
subjects1_.id AS id1_1_1_,
academy0_.name AS name2_0_0_,
subjects1_.academy_id AS academy_3_1_1_,
subjects1_.name AS name2_1_1_,
subjects1_.teacher_id AS teacher_4_1_1_,
subjects1_.academy_id AS academy_3_1_0__,
subjects1_.id AS id1_1_0__
FROM academy academy0_
LEFT OUTER JOIN subject subjects1_
ON academy0_.id = subjects1_.academy_id
JoinFetch
는 Inner Join, @EntityGraph
는 Outer Join이라는 차이점이 있습니다.
공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수만큼 Academy가 중복 발생하게 됩니다.
확인을 위해 테스트 코드를 작성해보면 아래와 같습니다.
@BeforeEach
public void setup() {
List<Academy> academies = new ArrayList<>();
Teacher teacher = teacherRepository.save(new Teacher("선생님"));
for(int i=0;i<10;i++){
Academy academy = Academy.builder()
.name("강남스쿨"+i)
.build();
academy.addSubject(Subject.builder().name("자바웹개발" + i).teacher(teacher).build());
academy.addSubject(Subject.builder().name("파이썬자동화" + i).teacher(teacher).build()); // Subject를 추가
academies.add(academy);
}
academyRepository.saveAll(academies);
System.out.println("====================save all====================");
}
@Test
public void Academy여러개를_joinFetch로_가져온다() throws Exception {
//given
List<Academy> academies = academyRepository.findAllJoinFetch();
List<String> subjectNames = academyService.findAllSubjectNamesByJoinFetch();
//then
assertEquals(20, academies.size()); // 20개가 조회!?
assertEquals(20, subjectNames.size()); // 20개가 조회!?
// JoinFetch는 InnerJoin, Entity Graph는 Outer Join
// 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수 만큼 Academy가 중복발생하게 됩니다.
// 그래서 20개가 조회되는 것 입니다.
}
해결 방안
두 가지 방법이 있습니다.
- 먼저 첫 번째 방법은 필드의 타입을
Set
으로 선언하는 것입니다.
(Set
은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않는 점을 이용합니다.)
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name="academy_id")
private Set<Subject> subjects = new LinkedHashSet<>();
(Set은 순서가 보장되지 않기에 LinkedHashSet을 사용하여 순서를 보장합니다.)
- 두 번째 방법은
DISTINCT
를 사용하여 중복을 제거하는 것입니다.
(Set
보다는List
가 적합하다고 판단될 때)
이 방법은 쿼리에 적용하는 방법이라join fetch
,@EnityGraph
모두 동일하게 사용됩니다.
/**
* DISTINCT + join fetch
*/
@Query("select DISTINCT a from Academy a join fetch a.subjects s join fetch s.teacher")
List<Academy> findAllWithTeacher();
/**
* DISTINCT + @EntityGraph
*/
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select DISTINCT a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();
두 가지 방법 중 상황에 맞게 사용하시면 될 것 같습니다.
@NamedEntityGraphs
N+1 문제 해결을 얘기할 때 @NamedEntityGraphs
가 예시로 많이 등장하곤 하는데
@NamedEntityGraphs
의 경우 Entity
에 관련해서 모든 설정 코드를 추가해야 하는데,
이동욱 님 생각엔 Entity
가 해야 하는 책임에 포함되지 않는다고 생각하신다고 합니다.
A 로직에서는 Fetch전략을 어떻게 가져가야 한다는 것은 해당 로직의 책임이지, Entity의 책임이 아니다.
Entity에선 실제 도메인에 관련된 코드만 작성하고,
상황에 따라 유동적인 Fetch 전략을 가져가는 것은 전적으로 서비스/레파지토리에서 결정해야 하는 일.
마무리
이렇게 한번 더 공부 함으로써 N+1 문제에 대해 더 확실히 알게 되고
프로젝트 중 N+1 문제가 생기더라도 당황하지 않고 해결할 수 있을 것 같습니다
글 읽어주셔서 감사합니다 😊
'JPA' 카테고리의 다른 글
JPA 값타입 (0) | 2022.03.25 |
---|---|
JPA 프록시와 연관관계 관리 (0) | 2022.03.25 |
JPA 상속관계 매핑과 고급매핑 (0) | 2022.03.25 |
JPA 다양한 연관관계 매핑 (0) | 2022.03.25 |
JPA 연관관계 매핑과 연관관계 주인 (0) | 2022.03.25 |
댓글