제 글에 문제가 있다면 댓글로 알려주시면 감사하겠습니다 ! 🙇♂️
저는 JPA를 이용하며 프로젝트를 하며 N+1 문제를 만나보진 않았습니다.
하지만 JPA를 사용하면 자주 만나게 되는 것이 N+1 문제입니다.
JPA를 공부 할 때에 예시 상황을 만들고 공부를 했었지만
공부한지 시간이 좀 지났기 때문에 프로젝트 중 N+1 문제가 생기더라도
당황하지 않고 빠르게 해결하기 위해, 다시 공부하기 위해 이 글을 작성합니다.
이동욱님의 글을 참고하여
Java8 -> Java11,
SpringBoot 1.5.X -> 2.5.X,
Junit4 -> Junit5로 버전을 업그레이드하여
코드를 작성하였고, 이 글을 작성하였습니다
참고하였습니다 🙇♂️
모든 코드는 Github에 있습니다.
먼저 클래스 다이어그램과 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를 한번에 가져오기 위한 몇가지 방법들이 있습니다.
첫번 째 방법은 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번 방법을 사용해보시면 좋을 것 같습니다.
두번 째 방법은 @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();
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
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();
두가지 방법 중 상황에 맞게 사용하시면 될 것 같습니다.
N+1 문제 해결을 얘기할때 @NamedEntityGraphs
가 예시로 많이 등장하곤하는데
@NamedEntityGraphs
의 경우 Entity
에 관련해서 모든 설정 코드를 추가해야하는데,
이동욱님 생각엔 Entity
가 해야하는 책임에 포함되지 않는다고 생각하신다고 합니다.
A 로직에서는 Fetch전략을 어떻게 가져가야 한다는 것은 해당 로직의 책임이지, Entity의 책임이 아니다.
Entity에선 실제 도메인에 관련 된 코드만 작성하고,
상황에 따라 유동적인 Fetch 전략을 가져가는 것은 전적으로 서비스/레파지토리에서 결정해야하는 일.
이렇게 한번 더 공부 함으로써 N+1 문제에 대해 더 확실히 알게되고
프로젝트 중 N+1 문제가 생기더라도 당황하지 않고 해결할 수 있을 것 같습니다
글 읽어주셔서 감사합니다 😊