Skip to content

[ Refactor ] - AI 리포트 배치 대상 소득분석 조회 N+1 → 벌크 조회 개선 - #152

Merged
hanjyoon01 merged 9 commits into
developfrom
feature/#150
Aug 14, 2026
Merged

[ Refactor ] - AI 리포트 배치 대상 소득분석 조회 N+1 → 벌크 조회 개선#152
hanjyoon01 merged 9 commits into
developfrom
feature/#150

Conversation

@hanjyoon01

@hanjyoon01 hanjyoon01 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

관련 이슈

작업 사항

  • work-service 소득분석 조회(SETTLEMENT/JOB/WORK_LOG/WORK_LOG_PLATFORM_INCOME)를 사용자 단건 조회에서 벌크(IN절) 조회로 개선
    • SettlementMapper/JobMapper/WorkLogMapper/WorkLogPlatformIncomeMapper에 벌크 조회 메서드 추가, 각 InMemory*Mapper 페이크도 함께 구현
    • IncomeAnalysisService.aggregateMonth를 DB 조회부/순수 집계부로 분리, getMonthlyIncomeAnalysisBulk 추가 - 단건 경로와 buildSummary()로 조립 로직 공유
    • buildEarnedDepositComparisons/buildFatigueSummariesfindWorkLogsInMonth를 각각 중복 호출하던 부분 제거
    • IncomeAnalysisQueryClient(common)에 default 메서드로 벌크 조회 계약 추가 - 다른 도메인(diagnosis-service/ai-service)의 기존 구현체·테스트는 변경 없이 그대로 컴파일되도록 함
  • SettlementMapper가 실제 DB에서 언더스코어 컬럼(actual_amount, job_id 등)을 매핑하지 못하던 버그 발견 및 수정 (resultMap 명시)
  • 벌크 vs 단건 유닛 테스트 추가 (결과 일치, 사용자 간 데이터 미혼입 검증)
  • 실제 MySQL 기반 성능 비교 테스트 추가 (RUN_INCOME_ANALYSIS_BULK_PERF_TEST=true)

실제 비교 결과 (사용자 1000명)
스크린샷 2026-08-14 110614

참고 사항

  • SettlementMapper 버그(중요): SELECT * + resultType만 쓰고 명시적 resultMap이 없어서, mapUnderscoreToCamelCase 설정이 없는 이 프로젝트에서는 actual_amount/job_id/period_start 등 언더스코어 컬럼이 전부 null로 매핑되고 있었습니다. InMemorySettlementMapper 기반 유닛 테스트는 실제 DB를 거치지 않아 이 문제를 못 잡았고, 이번에 처음 실제 MySQL로 돌려본 성능 테스트에서 NPE로 드러났습니다. 이 PR에서 resultMap을 추가해 고쳤지만, 운영 환경에서 실제로 소득분석 결과에 영향이 있었는지는 별도 확인이 필요합니다. SETTLEMENT를 함께 쓰는 diagnosis-service 쪽도 확인이 필요합니다.

추가 참고 사항‼️ -> 구현 완료

  • ai-service 연동은 이번 PR 범위 밖입니다.

  • MonthlyAiReportOrchestrationService(ai-service)가 여전히 사용자 루프 안에서 단건 메서드를 호출하는 한, 실제 AI 리포트 배치의 쿼리 수는 줄어들지 않습니다. 벌크 메서드는 이번에 준비만 해뒀고, 배치가 그걸 쓰도록 바꾸는 작업은 별도 이슈로 진행 필요합니다. 참고로 필요한 변경은 대략 이렇습니다:

    1. runBatch()에서 사용자 루프 밖으로 incomeAnalysisQueryClient.getMonthlyIncomeAnalysisBulk(userIds, targetYearMonth) 호출을 빼내서, 전체 사용자분을 미리 Map<Long, MonthlyIncomeAnalysisSummary>로 한 번에 받아둡니다.
    2. processUserReport(userId, targetYearMonth)가 내부에서 직접 getMonthlyIncomeAnalysis(userId, ...)를 호출하던 부분을 지우고, 1번에서 받은 Map에서 꺼낸 값을 파라미터로 받도록 시그니처를 바꿉니다 (processUserReport(userId, targetYearMonth, income)).
    3. 기존엔 사용자 1명 처리 실패가 try/catch로 격리돼 있었는데, 벌크 호출을 루프 밖으로 빼면 그 벌크 호출 자체의 실패는 더 이상 사용자 단위로 격리되지 않습니다. 벌크 호출을 별도로 try/catch해서 실패 시 빈 Map으로 진행하고 로그를 남기는 처리가 필요합니다.
    4. monthlyExpenseQueryClient(account-service, 소비집계)는 이 PR에 포함 안 됐으므로 위 작업을 해도 배치 쿼리 수는 절반만 줄어듭니다. 완전히 해결하려면 account-service 쪽도 같은 방식으로 벌크화하고 배치가 그것도 같이 쓰도록 바꿔야 합니다.

    work-service의 getMonthlyIncomeAnalysisBulk는 사용자 6000명이든 몇 명이든 쿼리 6개로 고정되니, 위 작업만 하면 배치 쪽 쿼리 수도 동일하게 고정됩니다.

JaeYoon added 8 commits August 14, 2026 10:16
- SettlementMapper 인터페이스에 IN절 기반 벌크 조회 메서드 추가 (findByUserIdInAndDepositDateRange)
- XML에 <foreach>로 다건 userId 바인딩 구현
- InMemorySettlementMapper 테스트 페이크에 동일 동작 구현

기존 findByUserIdAndDepositDateRange(단건)은 그대로 유지한다. (타 서비스에서 사용)
- JobMapper 인터페이스에 IN절 기반 벌크 조회 메서드 추가 (findByUserIdIn)
- XML에 <foreach>로 다건 userId 바인딩 구현
- InMemoryJobMapper 테스트 페이크에 동일 동작 구현

기존 findByUserId(단건)는 그대로 유지한다. (타 서비스에서 사용)
- WorkLogMapper 인터페이스에 IN절 기반 벌크 조회 메서드 추가 (findByUserIdInAndDateRange)
- XML에 <foreach>로 다건 userId 바인딩 구현
- InMemoryWorkLogMapper 테스트 페이크에 동일 동작 구현

기존 findByUserIdAndDateRange(단건)는 그대로 유지한다 (타 서비스에서 사용)
- WorkLogPlatformIncomeMapper 인터페이스에 IN절 기반 벌크 조회 메서드 추가 (findConfirmedByUserIdInAndDateRange)
- XML에 <foreach>로 다건 userId 바인딩 구현 (WORK_LOG 조인 조건에 적용)
- InMemoryWorkLogPlatformIncomeMapper 테스트 페이크에 동일 동작 구현

이 엔티티에는 userId 필드가 없어 벌크 결과를 사용자별로 그룹핑하려면
같은 기간에 벌크 조회한 WorkLog(logId → userId)를 호출부에서 함께
이용 - IncomeAnalysisService 벌크 메서드 구현 시 처리 예정.

기존 findConfirmedByUserIdAndDateRange(단건)는 그대로 유지한다.
- getMonthlyIncomeAnalysisBulk 추가 (쿼리 6개로 고정, N+1 해결)
- aggregateMonth를 DB조회부/순수집계부(aggregate)로 분리
- 단건/벌크가 buildSummary()로 조립 로직 공유
- calculateVolatility 순수 함수화
- findWorkLogsInMonth 중복 호출 제거
- WorkLogPlatformIncome(userId 없음)은 WorkLog logId→userId 매핑으로 그룹핑
- StubSettlementMapper에 신규 메서드 구현 추가
- getMonthlyIncomeAnalysisBulk(List<Long> userIds, YearMonth yearMonth)를
  default 메서드로 추가 - 기본 구현은 단건 메서드를 사용자마다 반복 호출하는
  폴백이라 N+1을 그대로 가진다
- LocalIncomeAnalysisQueryClient(work-service)는 이 메서드를 오버라이드해서
  IncomeAnalysisService.getMonthlyIncomeAnalysisBulk로 위임, 실제 벌크 조회 수행

다른 구현체(diagnosis-service/ai-service)가 이 메서드를 오버라이드하지 않으면
폴백(N+1)으로 계속 동작한다 - 실제 성능 이점을 받으려면 각 소비 주체가
명시적으로 오버라이드해야 한다.
- userIds 빈 리스트 → 빈 Map
- 요청한 사용자 전원이 결과에 포함되는지
- 데이터 없는 사용자는 0/빈 값으로 채워지고 null이 아닌지 (단건 경로의 null=조회실패 의미와 다름을 검증)
- 두 사용자의 SETTLEMENT/WORK_LOG/WorkLogPlatformIncome이 서로 섞이지 않는지 (WorkLogPlatformIncome은 userId가 없어 logId 매핑을 거치므로 별도 검증)
- 벌크 결과가 단건 조회(getMonthlyIncomeAnalysis)와 동일한 값을 내는지
getMonthlyIncomeAnalysis, getMonthlyIncomeAnalysisBulk 성능 비교 테스트 구현

- 벌크 경로는 사용자 수와 무관하게 쿼리 6개 이하로 고정되는지, 루프 경로는 사용자 수 이상 쿼리가 나가는지 검증
- work-service build.gradle에 테스트 전용 DB 연동 의존성 추가 (HikariCP, mysql-connector-java, spring-jdbc/spring-test)

실측 결과:
- 사용자 50명: 루프 917ms/쿼리 300회 → 벌크 61ms/쿼리 6회
- 사용자 1000명: 루프 7843ms/쿼리 6000회 → 벌크 192ms/쿼리 6회 (약 40배)
MonthlyAiReportOrchestrationService.runBatch가 사용자마다 개별 호출하던
incomeAnalysisQueryClient.getMonthlyIncomeAnalysis를 벌크 메서드
getMonthlyIncomeAnalysisBulk 한 번 호출로 교체한다.

- runBatch: 사용자 루프 밖에서 벌크 조회 1회 수행, 실패 시 try/catch로
  감싸 빈 Map + 로그로 처리 (사용자 단위 격리가 없는 대신 배치 전체가 막히지 않도록)
- processUserReport: 내부에서 직접 조회하던 코드를 지우고 income을 파라미터로 받도록 시그니처 변경

account-service의 monthlyExpenseQueryClient는 이번 변경에 포함되지 않아
배치 쿼리 수는 절반만 개선된다 - 완전 해결은 account-service 벌크화 이후.
@hanjyoon01
hanjyoon01 merged commit ef21410 into develop Aug 14, 2026
2 checks passed
@hanjyoon01
hanjyoon01 deleted the feature/#150 branch August 14, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 기능 개발

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ Refactor ] - AI 리포트 배치 대상 소득분석 조회 N+1 → 벌크 조회 개선

3 participants