윤개발

[#1 이슈] 시트 조회시 다수의 쿼리 발생(N+1 문제) 본문

프로젝트/Gathering-bot

[#1 이슈] 시트 조회시 다수의 쿼리 발생(N+1 문제)

DEV_SJ 2021. 4. 6. 18:08

Gathering-bot 서비스를 개발하면서 성능에서 가장 크게 고민했던 부분에 대한 포스팅입니다.

1. 초기 설계

개발 전 Gathering-bot 서비스 데이터베이스 설계는 다음과 같습니다.

다른 테이블(사람, 배치등)과 일부 컬럼(생성한 사람, 응답시간등)은 주제와 연관이 없어 표기하지 않았습니다

  • 시트는 1개 이상의 취합 항목을 가지고 있습니다.
    • 취합 항목은 시트_id 를 FK로 가진다.
  • 시트는 1개 이상의 응답을 가지고 있습니다.
    • 응답은 시트_id 를 FK로 가진다.
  • 응답은 1개 이상의 응답 항목을 가지고 있으며 시트의 취합항목에 대한 응답입니다.
    • 응답 항목은 응답_id를 FK로 가지며 취합항목_id를 FK로 가진다.

2. 성능 이슈 고민

초기 데이터베이스 설계와 함께 JPA의 지연로딩을 사용하여 하나의 시트를 ID로 조회하면 다음과 같은 쿼리가 나갑니다.

Hibernate: 
    select
        sheet0_.sheet_id as sheet_id1_2_0_ 
    from
        sheet sheet0_ 
    where
        sheet0_.sheet_id=?

시트 API는 시트 ID로 조회시 다음 항목들을 보여줘야합니다.

  1. 시트 정보
  2. 해당시트가 취합할 항목
  3. 해당시트에 대한 응답
  4. 응답에 대한 응답항목 전체

즉 sheet, sheetItem, response, responseItem 4가지를 모두 조회하여야 하며 코드는 다음과 같습니다.

//시트 찾기
Sheet findSheet = sheetRepository.findById(1L).orElseThrow(() -> new EntityNotFoundException());

//시트 항목
List<SheetItem> findSheetItemList = findSheet.getSheetItemList();

//응답
List<Response> findResponseList = findSheet.getResponseList();
for (Response response : findResponseList){
    //응답 항목
    List<ResponseItem> findResponseItemList = response.getResponseItemList();
}

해당 테스트 코드를 실행하여도 지연로딩이므로 실제 사용하지 않는다면 쿼리가 나가지 않습니다.

하지만 현재 API는 4가지를 모두 반환하므로 지연로딩일뿐 실제로는 비즈니스 로직상에서 모두 로딩됩니다.

실제 사용을 위해 각 객체들을 print 하면 아래와 같이 무수히 많은 쿼리가 나갑니다.

Hibernate: 
    select
        sheet0_.sheet_id as sheet_id1_2_0_ 
    from
        sheet sheet0_ 
    where
        sheet0_.sheet_id=?
Hibernate: 
    select
        sheetiteml0_.sheet_id as sheet_id3_3_0_,
        sheetiteml0_.sheet_item_id as sheet_it1_3_0_,
        sheetiteml0_.sheet_item_id as sheet_it1_3_1_,
        sheetiteml0_.content as content2_3_1_,
        sheetiteml0_.sheet_id as sheet_id3_3_1_ 
    from
        sheet_item sheetiteml0_ 
    where
        sheetiteml0_.sheet_id=?
Hibernate: 
    select
        responseli0_.sheet_id as sheet_id3_0_0_,
        responseli0_.response_id as response1_0_0_,
        responseli0_.response_id as response1_0_1_,
        responseli0_.content as content2_0_1_,
        responseli0_.sheet_id as sheet_id3_0_1_ 
    from
        response responseli0_ 
    where
        responseli0_.sheet_id=?
Hibernate: 
    select
        responseit0_.response_id as response3_1_0_,
        responseit0_.response_item_id as response1_1_0_,
        responseit0_.response_item_id as response1_1_1_,
        responseit0_.content as content2_1_1_,
        responseit0_.response_id as response3_1_1_ 
    from
        response_item responseit0_ 
    where
        responseit0_.response_id=?

Hibernate: 
    select
        responseit0_.response_id as response3_1_0_,
        responseit0_.response_item_id as response1_1_0_,
        responseit0_.response_item_id as response1_1_1_,
        responseit0_.content as content2_1_1_,
        responseit0_.response_id as response3_1_1_ 
    from
        response_item responseit0_ 
    where
        responseit0_.response_id=?

Hibernate: 
    select
        responseit0_.response_id as response3_1_0_,
        responseit0_.response_item_id as response1_1_0_,
        responseit0_.response_item_id as response1_1_1_,
        responseit0_.content as content2_1_1_,
        responseit0_.response_id as response3_1_1_ 
    from
        response_item responseit0_ 
    where
        responseit0_.response_id=?

 

비즈니스 로직상 모두 로딩하므로 지연로딩이 아닌 즉시로딩이여도 결과는 위와 같습니다.

하나의 시트 조회시에 이렇게 N+1문제가 발생하는 어플리케이션은 성능 상에 반드시 문제가 있습니다.

N+1 문제에 주로 사용하는 Join Fetch, @EntityGraph 전략

n+1문제에서 문제에서 자주 사용하는 전략입니다. 저는 EntityGraph 전략을 사용하여 해결해보도록 하겠습니다.

@EntityGraph(attributePaths={"sheetItemList", "responseList", "responseList.responseItemList"})
Optional<Sheet> findById(Long id);

시트 조회시 sheet_item, reponse, response_item을 같이 불러오는 코드입니다.

아래는 실제 사용된 쿼리입니다.

Hibernate: 
    select
        sheet0_.sheet_id as sheet_id1_2_0_,
        responseli1_.sheet_id as sheet_id3_0_1_,
        responseli1_.response_id as response1_0_1_,
        responseli1_.response_id as response1_0_2_,
        responseli1_.content as content2_0_2_,
        responseli1_.sheet_id as sheet_id3_0_2_,
        responseit2_.response_id as response3_1_3_,
        responseit2_.response_item_id as response1_1_3_,
        responseit2_.response_item_id as response1_1_4_,
        responseit2_.content as content2_1_4_,
        responseit2_.response_id as response3_1_4_,
        sheetiteml3_.sheet_id as sheet_id3_3_5_,
        sheetiteml3_.sheet_item_id as sheet_it1_3_5_,
        sheetiteml3_.sheet_item_id as sheet_it1_3_6_,
        sheetiteml3_.content as content2_3_6_,
        sheetiteml3_.sheet_id as sheet_id3_3_6_ 
    from
        sheet sheet0_ 
    left outer join
        response responseli1_ 
            on sheet0_.sheet_id=responseli1_.sheet_id 
    left outer join
        response_item responseit2_ 
            on responseli1_.response_id=responseit2_.response_id 
    left outer join
        sheet_item sheetiteml3_ 
            on sheet0_.sheet_id=sheetiteml3_.sheet_id 
    where
        sheet0_.sheet_id=?

 

대형 서비스에서는 이보다 많은 조인이 발생하여 우스운 정도이긴 합니다.

하지만 저는 조금 더 근본적인 문제를 해결하고 싶었습니다.

"서비스 특성상 매번 한번에 불러와야하는데 항상 조인을 해서 가져와야할까?"

이에 대한 고민으로 역정규화와 캐시가 떠올랐습니다.

  • 캐시 : 캐시를 이용하여도 최초 1번은 조인이 필요하며, 다른 시트에서는 다시 조인이 발생
  • 역정규화 : 응답 항목들을 응답 하나의 컬럼에 다 넣어서 구분자로 관리하는 방법

작은 서비스 조금이라도 더 좋은 성능을 내도록 고민하여 개발하는 것이 맞다고 생각했습니다.


3. 역정규화

1정규형(원자성)을 위반하는 다음 방법도 역정규화라고 부르는지는 모르겠습니다. (편법일수도..)

위에서 말한 대로 답변 목록들의 리스트를 구분자를 이용해서 하나의 컬럼에 넣었습니다.

// 예시
취합항목1&&&&취합항목2&&&&취합항목3&&&&

응답항목1&&&&응답항목2&&&&응답항목3&&&&

테이블이 훨씬 간단해졌지만 프론트단에서 렌더링할때 &&&&를 구분자로 정보를 보여주는 로직이 추가되어야 합니다.

구분자는 잘 사용하지 않는 &&&& 를 사용하였고 답변에 &&&&가 포함되지 않도록 프론트, 백엔드 단에서 예외처리를 해주었습니다.

 

그러면 바뀐 쿼리 성능을 알아보기 위해 테스트를 진행해보겠습니다.


4. 성능 비교 테스트

아래의 상황을 가정하여 실제 쿼리를 성능 비교 테스트를 진행해 보았습니다.

  1. 10명의 인원 & 5개의 취합 항목
  2. 100명의 인원 & 10개의 취합 항목
  3. 1000명의 인원 & 20개의 취합 항목

테스트 결과 단위가 ns 단위라 미비하지만 데이터가 많을경우에는 확연한 차이를 보이고 있습니다.

5. 왜 NoSQL을 사용하지 않았나

사실 지금까지 고민한 내용은 NoSQL을 이용하면 손쉽게 해결할 수 있었습니다.

서비스의 특성상 답변의 수정은 최대 2회(첫 응답 , 수정한 응답, 필요시 더 가능) 정도이며 대부분은 응답의 조회 기능입니다.

각 인원의 응답이 다른 인원의 응답에는 영향을 주지 않기 때문에 꼭 RDB를 사용할 필요가 없었습니다.

아래와 같이 Key-Value 형식으로 저장하면 빠른 조회가 가능했습니다.

시트

{
	sheet_id: 1,
	sheet_title: "시트 제목",
	sheet_item: { // 배열로도 사용 가능!
		1: "취합 항목1", 
		2: "취합 항목2", 
		3: "취합 항목3"
	} 
{

응답

{
	response_id: 1,
	sheet_id: 1,
	response_item: {
		1: "응답1", 
		2: "응답2", 
		3: "응답3"
	} 
{

JPA를 사용하였기 때문에 MySQL → MongoDB 변경에도 큰 어려움은 없었을 것 같습니다.

실제 서비스 진행중이였다 하더라도 "&&&&" 구분자를 이용하여 마이그레이션이 가능 했습니다.

 

하지만 개발 당시에는 Person, Group 등 대부분이 이미 개발된 상태였고 프로젝트를 사내시스템에 추가하는 것이 최종 목적이였습니다. 사내시스템은 관계형 데이터베이스를 사용하였고 하나의 서비스 때문에 추가적으로 db를 생성하는 것 제한될 것이란 생각이 들어 RDB로 해결해보고자 고민하였습니다.


6. 회고

프로젝트를 진행하며 제가 관계형 데이터베이스(MySQL)을 사용한 것처럼 개발자가 익숙한 기술을 사용하는 것도 중요하지만 여러 기술의 장단점을 파악하고 적절한 기술을 사용하는 능력 또한 굉장히 중요합니다.

비슷한 기능들의 장단점을 정리하고 깊이 있게는 아니더라도 필요시에 해당 기술이 떠올라 "사용해보자" 라는 생각을 가지고 그 기술을 사용하지 못한다면 어떻게 해결할 수 있을지 고민해보는 것또한 굉장히 이로운것 같습니다.

Comments