본문 바로가기
기술/Spring-Boot

[SpringBoot] FetchJoin 없이 N+1 문제 제거하기

by Zabee52 2022. 1. 10.

성능 개선

 

22. 1. 24. 추가) 폐기 안 된 방법입니다.

상황에 따라 못 짠(스칼라 서브쿼리가 많은) 한 방 쿼리보다 빠르다. 일단 SQL 짜는 능력이 부족한 지금은 이 방법을 적용하는 것이 나아 보인다. 헛고생이라고 생각했던 일도 보다 나은 경험을 위한 준비였나보다.

 

22. 1. 20. 추가) 폐기된 방법입니다.

결국 여러 번 조회하는 것보다 한방쿼리로 구성하는게 더 빠르기 때문 ..... 그래도 노력한게 아까워서 글은 안 지움.....

특정한 조건에서는(여러 번 조회하는게 더 나을 경우?) 좋을 때가 있지 않을까 하는 기대감을 가지면서 글은 안 지우고 남긴다.

 

 

 

 

 

 

 

 

성능 개선을 위해 불철주야까지는 아니지만 아무튼 열심히 코드를 보는 중인데, 한 가지 고민이 생겼다.

 

ERD는 다음과 같다.

대충 좋아요 테이블은 분리되어 있다는 뜻

public class Dict{
    // ...

    @OneToMany(mappedBy = "dict", cascade = CascadeType.ALL)
    private final List<DictLike> dictLikeList = new ArrayList<>();
}

우리 사이트는 용어 사전 목록을 불러올 때, 해당 페이지 내에서 좋아요를 누른 내용이 있다면 그것을 표시를 해준다.

문제는 이거다. 리스트 속에서 내가 이 글을 좋아요 하고 있는 상태인지 어떻게 확인을 하는가.

 

단순하게 하자면 간단하다. 용어의 ID를 참조하는 좋아요 목록을 호출, 사용자와 동일한지 비교하면 된다. 한 페이지당 10개의 목록을 호출한다면 좋아요 목록을 10번 호출해서.

근데 이건 너무 비효율적이다. 그저 사전을 불러올 때 한 번의 쿼리로 해결하고 싶은데, 흠.. 어떻게 안 되나..?

 

일단 속도 문제는 크게 없다. 테이블에 대한 인덱싱이 되어 있기 때문이다. 문제는 내가 불편하다. 한 번의 호출로 딱 불러오고 싶다.

 

그래서 여러가지 방법을 고민해봤다.

 

1. dictLike 리스트를 미리 불러와서 비교

List<DictLike> dictLikeList = dictLikeRepository.findAllByDictIn(dictList);
for(Dict dict : dictList){
   ... 여기에서 dictLikeList를 for문으로 또 돌리며 비교..
}

하수나 하는 짓이다. dict가 포함되어 있지 않은 dictLikeList와 비교하게 된다. 이렇게 할 경우 시간복잡도가 경이로울 정도로 상승하게 된다. 바로 탈락.

 

2. 그냥 fetchJoin 해버릴까..

안 된다. fetchJoin은 레코드 수가 많지 않거나 사용자에게 모든 내용을 어차피 보여줘야 하는(댓글이라든지) 영역에 적용해야 한다고 생각하기 때문에, 한꺼번에 큰 데이터 뭉치를 넘겨주는 것은 낭비다. 탈락.

 

 

이렇게 생각하다가 나름 생각을 해봤다.

그러면 차라리 맵을 써볼까?

 

맵은 데이터를 넣는건 리스트에 비해 비교적 느리지만 미세한 차이이다. 맵에 많은 데이터가 들어갈 걱정을 하는 것도, 사실상 리스트로 하더라도 어차피 그만큼 for문이 돌아야 하기 때문에 큰 고려사항도 아니다. 게다가 탐색의 시간복잡도가 O(n)인 리스트와 달리 맵은 값을 가져오는데 걸리는 시간복잡도는 O(1)이다. 나름 이론상 괜찮아보인다.

 

그래서 이걸 기반으로 순서를 나름 짜봤다.

// 1. dict 목록을 저장한다.
// 2. dictLike 테이블에서 dict 목록을 IN 연산한 값을 가져온다.
// 3. 이걸 HashMap 에 규칙성 있는 키 값으로 저장한다. 조회할 때 시간복잡도가 O(1)
// 4. 이 키 값이 존재하는지 확인하는 식으로 비교한다.

말하자면, 키 값을 "dictId : userId" 형식으로 보관하는 것이다. 밸류는 뭐가 들어가든 중요하지 않다. 키 값이 존재하기만 한다면 좋아요 상태라는 것을 파악할 수 있는 구조가 완성되는 것이다.

이론상 완벽해. 가본다.

 

HashMap<String, Boolean> dictLikeList = getLikeList(dictList);

        for (Dict dict : dictList) {
            dictResponseDtoList.add(DictResponseDto.builder()
                    .dictId(dict.getDictId())
                    .title(dict.getDictName())
                    .summary(dict.getSummary())
                    .meaning(dict.getContent())
                    .firstWriter(dict.getFirstAuthor().getNickname())
                    .createdAt(dict.getCreatedAt())
                    .isLike(user != null && dictLikeList.get(dict.getDictId() + ":" + user.getId()) != null)
                    .likeCount(dict.getDictLikeList().size())
                    .build());
        }

 

private HashMap<String, Boolean> getLikeList(List<Dict> dictList) {
        QDictLike qDictLike = QDictLike.dictLike;
        List<Tuple> dictLikeListTuple = queryFactory.select(qDictLike.dict.dictId, qDictLike.user.id)
                .from(qDictLike)
                .where(qDictLike.dict.in(dictList))
                .fetch();

        HashMap<String, Boolean> dictLikeList = new HashMap<>();
        for (Tuple tuple : dictLikeListTuple) {
            // 키값을 "DictId":"UserId" 형식으로
            String genString = tuple.get(0, Long.class) + ":" + tuple.get(1, Long.class);
            dictLikeList.put(genString, true);
        }
        return dictLikeList;
    }

 

구동해보니 작동 잘 한다. 실제 성능도 테스트해봤다.

List 방식
HashMap 방식

 

구동할 때 N+1 문제도 해결했을 뿐만 아니라, 성능도 확연히 개선했다.

저번에 피드백을 받고나서 ERD를 건드리거나 새로운 기술을 도입해보는 방법은 할 수 있는 만큼 개선을 해보고 적용해보자 마음먹고나니 뭔가 새로운 방법이 열리는 느낌이다. 노력해야지.

 

물론 쿼리로 한 번에 받아오는게 제일 빠르긴 하다. 이건 좋아요 여부같은걸 체크할 때 리스트 대신 사용하는 방법이 되겠다.

댓글