Home /JPA/ N+1 문제
Post
Cancel

/JPA/ N+1 문제



1. JPA N+1 문제


  • N + 1문제란 1번의 쿼리를 날렸을 때
    의도하지 않은 N번의 쿼리가 추가적으로 실행되는 것


EAGER(즉시 로딩)인 경우

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. 이후 JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
  3. 2번 과정으로 N + 1 문제 발생

LAZY(지연 로딩)인 경우

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회는 하지 않음
  3. 하지만, 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하기 때문에 결국 N + 1 문제 발생



2. N+1 문제 발생 이유



2-1. 언제 발생하는가


JPA Repository를 활용해 인터페이스 메소드를 호출할 때(Read 시)


2-2. 누가 발생시키는가


1:N 또는 N:1 관계를 가진 엔티티를 조회할 때 발생


2-3. 어떤 상황에서 발생되는가


  • 연관관계가 설정된 엔티티를 조회할 때 다른 연관관계에 접근하는 경우
  • JPA Fetch 전략이 EAGER 전략으로 데이터를 조회하는 경우
  • JPA Fetch 전략이 LAZY 전략으로 데이터를 가져온 이후에
    연관 관계인 하위 엔티티를 다시 조회하는 경우


2-4. 왜 발생하는가


  • JPA Repository로 find 시
    실행하는 첫 쿼리에서 하위 엔티티까지 한 번에 가져오지 않고,
    하위 엔티티를 사용할 때 추가로 조회하기 때문
  • JPQL은 기본적으로 글로벌 Fetch 전략을 무시하고
    JPQL만 가지고 SQL을 생성하기 때문



3. N+1 문제 해결 방법


  • JPQL의 fetch join을 사용해 DB에서 데이터를 가져올 때
    처음부터 연관된 데이터까지 같이 가져오게 하는 방법을 사용해 해결하는 방법
    • SQL 조인을 사용해 연관된 엔티티를 함께 조회하므로 N+1문제가 발생하지 않음
    • 이경우 1:N 상황에서의 페이징엔 오류가 발생할 수 있기에
      조인을 제거하고 Batch Size를 조절해 해결 가능
  • 하이버네이트 @BatchSize
    • 1:N 상황에서의 페이징엔 오류가 발생할 수 있기에 조인을 제거하고 Batch Size를 조절해 해결 가능
  • 하이버네이트 @Fetch(FetchMode.SUBSELECT)

  • EntityGraph (엔티티 그래프)
  • Querydsl의 projections을 사용해 해결하는 방법

  • 처음부터 Erd설계를 잘 하는 방법
    • 처음부터 Erd설계를 처음부터 잘 짜 놓으면
      이런 문제가 일어날 확률을 줄일 수 있다.



3-1. JPQL의 fetch join


  • fetch join이란, JPQL에서 성능 최적화를 위해 제공하는 기술
  • fetch join은 일반 조인과는 다르게
    SQL 조인 종류가 아닌, JPQL에서 최적화를 위해 제공하는 기술
  • fetch join을 사용하면
    JPQL은 연관된 객체의 모든 정보 하나의 객체로 한번에 불러옴
  • 일반 조인과는 달리, 엔티티의 특정 속성만을 가져올 수 없음


  • fetch join의 단점
    • 패치 조인 대상에는 별칭을 줄 수 없다.
    • 둘 이상의 컬렉션은 패치 조인 할 수 없다.
    • 컬렉션을 패치 조인하면 페이징 API를 사용할 수 없다.


fetch join 사용방법


  • join과 사용법 자체는 동일하다.
    단지 fetch join 명령어로 join을 할 뿐이다.
  • 패치 조인은 실제 질의하는 대상 Entity와
    Fetch join이 걸려있는 Entity를 포함한 컬럼 함께 SELECT한다.

(예시)

1
2
3
4
5
6
// MemberRepository.java
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m from Member m join fetch m.team ") // (1)
    List<Member> findAllMembers();
}

fetch join 결과 확인

1
2
3
// TeamRepository.java
@Query("SELECT distinct t FROM Team t join fetch t.members")
public List<Team> findAllWithMemberUsingFetchJoin();

스크린샷

(사진출처)


SQL 조인을 사용하는 방법


SQL 조인을 사용해 연관된 엔티티를 함께 조회하므로 N+1문제가 발생하지 않음


fetch join을 사용하는 JPQL 예시

1
select m from member m join fetch m.orders

그로인해 실행된 SQL

1
2
SELECT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID

➡️ 이 예제는 일대다 조인을 해서 결과가 늘어나 중복된 결과가 나타날 수 있다. 따라서 JPQL의 DISTICT를 사용해 중복을 제거하는 것이 좋다.



3-2. 하이버네이트 @BatchSize


하이버네이트 @BatchSize를 사용하면 연관된 엔티티를 조회할 때
지정한 size만큼의 SQL의 IN절을 사용해서 조회하게 된다.

만약 조회한 회원이 10명일 경우
size = 5 로 지정하면 2번의 SQL이 실행된다.


(예시)
10건의 데이터를 조회해야할 때
BatchSize(size = 5)으로 설정한 경우

1
2
3
4
5
6
7
8
@Entity
public class Member {
	
  @org.hibernate.annotation.BatchSize(size = 5) 
  @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
  private List<Order> orders = new ArrayList<Order>();
  ...
}


  • 즉시로딩
    • 조회 시점에서 10건의 데이터를 모두 조회 하므로 아래의 SQL가 2번 실행된다.
  • 지연로딩
    • 지연로딘된 엔티티를 최초 사용하는 시점에 아래 SQL을 실행해서 5건의 데이터를 미리 로딩해 두게된다.
    • 그리고 6번째 데이터를 사용하면 아래의 SQL를 추가로 실행하게 된다.
1
2
3
4
SELECT * FROM  ORDERS
WHERE MEMBER_ID IN (
    ?, ?, ?, ?, ?
  )


hibernate.default_batch_fetch_size 속성을 사용하면
애플리케이션 전체에 기본으로 @BatchSize를 적용할 수 있다.

1
<property name ="hibernate.default_batch_fetch_size" value="5" />



3-3. 하이버네이트 @Fetch(FetchMode.SUBSELECT)


하이버네이트 fetch 어노테이션에 FetchMode를 SUBSELECT로 사용하면
연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N+1문제를 해결할 수 있다.

(예)

1
2
3
4
5
6
7
@Entity
public Class Member {
	
  @org.hibernate.annotation.Fetch(FetchMode.SUBSELECT)
  @OneToMany(mappdeBy = "member", fetch = FetchType.EAGER)
  private List<Order> orders = new ArrayList<Order>();
  }



3-4. Querydsl의 projections을 사용해 해결하는 방법


프로젝션(Projection)은 select 절에서 어떤 컬럼들을 조회할지 대상을 지정하는 것을 말한다.
프로젝션 대상이 하나일 경우는 타입이 명확하기 때문에 해당 Generic Type이 해당 컬럼 타입에 맞게 지정된다.


(프로젝트에서 적용한 사례)

원래 서비스단의 공지사항 전체조회 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NoticeService {

	// 공지사항 전체조회
	public Page<NoticeResponseDto> getAllNotice(Pageable pageable) {

		Page<Notice> noticeList = noticeRepository.getNoticeList(pageable);

		List<NoticeResponseDto> noticeAllList = new ArrayList<>();

		for (Notice notice : noticeList) {
			noticeAllList.add(
					NoticeResponseDto.builder()
							.id(notice.getId())
							.title(notice.getTitle())
							.noticeContent(notice.getNoticeContent())
							.noticeImgUrl(notice.getNoticeImgUrl())
							.createdAt(notice.getCreatedAt())
							.modifiedAt(notice.getModifiedAt())
							.build()
			);
		}
		return new PageImpl<>(noticeAllList, pageable, noticeList.getTotalElements());
		return noticeRepository.getAllNotices(pageable);
	}
}


⬇ N+1 문제 해결️


1
2
3
4
5
6
7
8
9
10
11
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NoticeService {

	// 공지사항 전체조회
	public Page<NoticeResponseDto> getAllNotice(Pageable pageable) {
		return noticeRepository.getAllNotices(pageable);
	}
}
1
2
3
4
5
6
7
public interface NoticeRepositoryCustom {

	NoticeResponseDto getDetailNotice(Long noticeId);
	Page<NoticeResponseDto> getAllNotices(Pageable pageable);
	
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RequiredArgsConstructor
public class NoticeRepositoryImpl implements NoticeRepositoryCustom {

	private final JPAQueryFactory jpaQueryFactory;

	/*
	 *
	 *  공지사항 전체 조회
	 * */
	@Override
	public Page<NoticeResponseDto> getAllNotices(Pageable pageable) {
		List<NoticeResponseDto> result = jpaQueryFactory.from(notice)
				.select(Projections.constructor(NoticeResponseDto.class,
						notice.id,
						notice.title,
						notice.noticeContent,
						notice.noticeImgUrl,
						notice.createdAt,
						notice.modifiedAt
				))
				.limit(pageable.getPageSize())
				.offset(pageable.getOffset())
				.fetch();

		return new PageImpl<>(result, pageable, result.size());
	}
}

서비스단의 코드를 확 줄이고,
쿼리프로젝션을 사용해 한번 조회시 3번의 쿼리가 오는것을
한번만 실행되게 개선했다.



실무에서 N+1문제로 DB가 죽어버리는 문제 방지 방법


  • 우선 연관관계에 대한 설정이 필요하다면
    FetchType을 성능 최적화를 하기 어려운 즉시 로딩(EAGER)을 사용하는 게 아니라
    지연 로딩 (LAZY) 모드로 사용을 하고
    성능 최적화가 필요한 부분에서는 Fetch 조인을 사용하는 방법
  • 또한 기본적으로 Batch Size의 값을 1000 이하로 설정
    (대부분의 DB에서 IN절의 최대 개수 값 : 1000)


  • 꼭 연관관계 설정이 필요 없다면
    N+1 문제로 인하여 DB가 죽어버리는 불상사를 막기 위해
    연관관계를 끊어버리고 사용하는 것도 방법




(참고)



공부한 내용을 여러글과 책 읽은 내용을 바탕으로 정리하고 있습니다.
좋은 글로 저의 공부에 도움을 주시는 분들께 감사드립니다.

This post is licensed under CC BY 4.0 by the author.