새소식

ETC

N+1 문제와 동기 처리의 환장 콜라보

  • -

 

환장 듀오 N+1 문제와 동기 처리 콜라보 해결기

 

시작합니다

스따뜨

 

저희 챗봇 서비스에는 마이페이지에 유저가 한 질문수를 보여줘야 하는 요구사항이 있습니다

 

근데 그때그때 쿼리를 날려주기보다는 그냥 반정규화로 질문 수를 보여주는 칼럼을 디비에 만들고 매일 배치 처리로 해당 칼럼을 갱신해 주기로 했습니다

 

근데 지난 시간에

 

반년 만에 다시 이해한 N+1 문제

N+1 문제 해결 후기두 엔티티 간에 영속성 전이가 제대로 이루어지려면?현재 진행하고 있는 개인 프로젝트에서 다음과 같이 엔티티들의 연관관계가 설정되어 있다. Member -- (OneToMany) -- History -- (On

dockerel.tistory.com

 

N+1 문제들을 발견 한 이후로 성능상 심각하다는 걸 깨닫고 성능 측정을 해봤습니다

 

 

결과는 처참했습니다

멤버 10명, 질문 100개, 답변 100개에 대해 쿼리 개수가 쌩으로 거의 10000개 가까이 날아가고 있죠

 

처리 시간도 2688ms로 아주 처참한 성능을 보이고 있습니다

 

사실 뭐 아주 긴 건 아닌데 그냥 뭐 그렇습니다

데이터가 더 많아지면 더 처참해질지도 모르겠네요

 

그래서 이전 게시글에서 알아봤던 대로 1:N에는 패치 조인 적용, 그리고 1:1 관계에는 추후 1:N으로 발전될 가능성이 없다 생각하여 FK를 변경해줬습니다

 

그리고 여기서 핵심 포인트

 

모든 처리가

@Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
private void validateQuestionCounts() {
    List<Member> members = memberRepository.findAll();

    for (Member member : members) {
        int actualCount = memberRepository.countQuestionsByMemberId(member.getId());
        if (member.getQuestionCount() != actualCount) {
            member.updateQuestionCount(actualCount);
            memberRepository.save(member);
        }
    }
}

 

동기 방식으로 순차적으로 진행되고 있었습니다

 

비록 새벽 시간대에 진행되지만 이러면 메인 스레드를 오랫동안 점유할 뿐만 아니라 전체적으로 작업이 지연되어 데이터가 많아지면 굉장히 비효율적인 작업이 될 것 같습니다

 

그래서 CompletableFuture를 사용하여 병렬 비동기 처리를 적용해보았습니다

 

CompletableFuture는 Java 8부터 도입된 복잡한 비동기 작업을 선언적으로 구성할 수 있도록 돕는 도구로, CompletableFuture 를 이용하면, 비동기 작업의 실행, 비동기 작업 간의 연결, 예외 처리, 결과 집계를 쉽게 할 수 있습니다

 

자 그럼 일단

@Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
public void validateQuestionCounts() {
    List<Member> members = memberService.findAllMembers();

    List<CompletableFuture<Void>> futures =
            members.stream()
                    .map(memberService::validateQuestionCount)
                    .toList();
    CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
}

 

CompletableFuture로 병렬 처리를 도입하고

@Async
@Transactional
public CompletableFuture<Void> validateQuestionCount(Member member) {
    int actualCount = memberRepository.countQuestionsByMemberId(member.getId());
    if (member.getQuestionCount() != actualCount) {
        member.updateQuestionCount(actualCount);
        memberRepository.save(member);
    }
    return CompletableFuture.completedFuture(null);
}

 

@Async로 비동기를 적용해주었습니다

 

이때 꼭 해줘야 하는게 있죠?

@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean(name = "validationExecutor")
    public Executor burstExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(0);     // 평소에는 적은 수의 스레드 유지
        executor.setMaxPoolSize(40);     // 트래픽 급증 시 빠르게 확장
        executor.setQueueCapacity(5);    // 대기열을 작게 유지하여 빠르게 스레드 확장
        executor.setThreadNamePrefix("Burst-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());  // 과부하 시 호출 스레드에서 실행
        executor.initialize();
        return executor;
    }
}

 

@EnableAsync를 해줬고요, 그리고 스레드 할당 전략을 만들어줬습니다

 

현재 저 작업은 하루에 한번 발생하는 일이기 때문에 평소에는 0개의 스레드를 유지하다가 조정 작업이 시작될 시 빠르게 확장할 수 있도록 대기열을 작게 유지하였습니다

 

이걸

@Async(value = "validationExecutor")
@Transactional
public CompletableFuture<Void> validateQuestionCount(Member member) {
    int actualCount = memberRepository.countQuestionsByMemberId(member.getId());
    if (member.getQuestionCount() != actualCount) {
        member.updateQuestionCount(actualCount);
        memberRepository.save(member);
    }
    return CompletableFuture.completedFuture(null);
}

 

@Async에 달아주면 적용 끝입니다

 

자 그럼 성능 측정해봐야죠?

 

실행 쿼리수가 굉장히 많이 줄어든 모습을 볼 수 있습니다

N+1 문제를 잘 정리해서 실행된 쿼리가 확 줄어든 모습이구요

 

총 실행 시간도 257ms로 약 90% 정도 줄어들었습니다

 

이렇게 N+1 문제 해결로 실행 쿼리 수를 줄이고 비동기 병렬 처리로 실행시간을 줄여봤습니다

 

이예~~~~~~~

 

마치겠습니다

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.