회사에서 나는 매일 재고 입출고 및 기타상황에 따라 발생되는 입출고 데이터와 정산데이터를 통해 생성 비교되는 배치인 일수불데이터 배치와 매달 첫 영업일 마감작업을 위한 정합성 배치를 사용하며 운영업무를 진행해왔다.
마감작업은 매달 첫 영업일 오후 특정시간대까지 마감데이터를 생성하여 타부서에 넘기고 그걸 토대로 협력사에게 대금이 지급되는 예민한 업무인데 마감데이터를 검증하기위한 검증 배치가 너무 오래걸리고 가끔은 일수불들을 다시 생성해야하는 최악의 경우도 발생되어 곤욕을 치루기도 했다.. 그래서 적어도 당일에 배치를 통한 시간 병목은 없애자라는 생각으로 우선 배치를 개선해보기로했고 아래 두 배치를
일수불(일별 수입불출) 집계 배치와 정합성 검증 배치에서
두 가지 성능 문제를 발견하고 개선했다.
회사에서 나는 매일 재고 입출고 및 기타 상황에 따라 발생되는 입출고 데이터와 정산 데이터를 통해 생성·비교되는 배치인 일수불(일별 수입불출) 데이터 배치와, 매달 첫 영업일 마감 작업을 위한 정합성 검증 배치를 운영해왔다.
마감 작업은 매달 첫 영업일 오후 특정 시간대까지 마감 데이터를 생성해 타부서에 넘기고, 그걸 토대로 협력사에 대금이 지급되는 예민한 업무다. 그런데 마감 데이터를 검증하기 위한 검증 배치가 너무 오래 걸리고, 가끔은 일수불을 다시 생성해야 하는 최악의 상황도 발생해 곤욕을 치르기도 했다.
적어도 배치 자체로 인한 시간 병목은 없애자는 생각으로 두 배치를 개선해보기로 했다.
집계된 VO N건을 저장할 때, 건마다 SKU 정보를 개별로 DB 조회하고 있었다.
// insert() 내부 — VO 1건 처리할 때마다 실행
Optional<Sku> skuOpt = skuMRepository.findById(statsDailyCollectPayVO.getAttrPrdCd());
루프에서 스레드풀(4개)에 태스크를 제출하는 구조였기 때문에,
결과적으로 SELECT * FROM sku WHERE id = ? 쿼리가 N번 날아갔다.
for (StatsDailyCollectPayVO vo : resultList) {
executor.submit(new StatsDailyCollectPayRunnable(service, vo));
// ↑ 각 태스크 내부에서 DB 조회 1번씩
}
여기서 놓치기 쉬운 함정이 있다.
스레드를 4개로 병렬 처리해도 총 쿼리 수는 줄어들지 않는다.
스레드는 실행을 병렬화할 뿐, 쿼리 횟수 자체를 줄이지 않기 때문이다.
| 데이터 건수 | 쿼리 수 | 비고 |
|---|---|---|
| 1,000건 | 1,000번 | 스레드 4개여도 쿼리 수는 동일 |
| 10,000건 | 10,000번 | |
| 203,000건 | 203,000번 | 데이터 증가에 비례해서 늘어남 |
데이터가 늘수록 배치 시간이 선형으로 증가하는 구조였다.
두 번째 문제도 숨어 있었다.
MD 영업그룹 코드를 캐싱하는 mdSaleGrpMap이 각 Runnable마다 새 Map으로 초기화되고 있었다.
// StatsDailyCollectPayRunnable 생성자
public StatsDailyCollectPayRunnable(StatsDailyCollectPayService service, StatsDailyCollectPayVO target) {
this.service = service;
this.target = target;
this.mdSaleGrpMap = new ConcurrentHashMap<>(); // 스레드마다 빈 Map으로 시작
}
같은 MD ID라도 다른 스레드가 이미 조회했다는 걸 모르고 외부 API를 또 호출했다.
캐시가 스레드 간에 공유되지 않으니 사실상 캐시 효과가 없었던 것이다.
1) SKU 조회 — 루프 전에 IN 쿼리로 한 번에 벌크 조회
// 루프 전: 전체 attrPrdCd를 Set으로 모은 뒤 IN 쿼리 1번
Set<Long> attrPrdCdSet = resultList.stream()
.map(StatsDailyCollectPayVO::getAttrPrdCd)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, Sku> skuMap = skuRepository.findByIdIn(attrPrdCdSet)
.stream()
.collect(Collectors.toMap(Sku::getId, s -> s));
// 루프: DB 조회 없이 Map.get()으로 즉시 반환
for (StatsDailyCollectPayVO vo : resultList) {
executor.submit(new StatsDailyCollectPayOptimizedRunnable(service, vo, mdSaleGrpMap, skuMap));
}
// 변경 전
Optional<Sku> skuOpt = skuMRepository.findById(attrPrdCd); // DB 호출
// 변경 후
Sku sku = skuMap.get(attrPrdCd); // Map 조회 (DB 호출 없음)
쿼리 N번 → 1번으로 줄었고, 처리 시간이 데이터 증가에 관계없이 거의 일정해졌다.
2) mdSaleGrpMap — 스레드 간 공유 + computeIfAbsent로 최초 1회만 호출
// Processor에서 하나의 Map을 생성해 모든 Runnable에 전달
Map<String, String> mdSaleGrpMap = new ConcurrentHashMap<>();
for (StatsDailyCollectPayVO vo : resultList) {
executor.submit(new StatsDailyCollectPayOptimizedRunnable(service, vo, mdSaleGrpMap, skuMap));
}
// 변경 후: 같은 key는 최초 1회만 외부 API 호출
String saleGrpCd = mdSaleGrpMap.computeIfAbsent(mdId, k -> {
CmmMdidMDTO cmm = externalBegoniaCmmMdidMFacade.getByMdid(k);
return (cmm != null) ? cmm.getSaleGrpCd() : null;
});
| 항목 | 기존 | 개선 후 |
|---|---|---|
| SKU DB 쿼리 | N번 (건마다) | 1번 (IN 쿼리) |
| 외부 API 캐시 | 스레드별 독립 Map | 전체 공유 Map |
정합성 검증은 외부 시스템(SMTC) 조회와 DB 조회를 순차 실행하고 있었다.
// 두 호출 사이에 의존성이 전혀 없는데 순서대로 실행
List<RcvpayCheckDTO> smtcList =
externalBegoniaPrhFacade.getPrhDtRcvpayCheckList(targetDateStr); // 외부 API 호출
List<StatsDailyCollectPay> auroraList =
statsDailyCollectPayRepository.findAllByPrdMoveDt(targetDate); // DB 조회
두 호출의 결과가 서로에게 필요하지 않음에도 직렬로 실행되어 응답 시간이 단순 합산되었다.
또한 검증 결과 로그를 DB에 저장할 때 save()를 3번 개별 호출했다.
systemLogRepository.save(makeSystemLog("smtcOnly", ...)); // 트랜잭션 1번
systemLogRepository.save(makeSystemLog("auroraOnly", ...)); // 트랜잭션 2번
systemLogRepository.save(makeSystemLog("notEqual", ...)); // 트랜잭션 3번
1) 두 조회를 CompletableFuture로 병렬 실행
CompletableFuture<List<RcvpayCheckDTO>> smtcFuture = CompletableFuture.supplyAsync(
() -> externalBegoniaPrhFacade.getPrhDtRcvpayCheckList(targetDateStr));
CompletableFuture<List<StatsDailyCollectPay>> auroraFuture = CompletableFuture.supplyAsync(
() -> statsDailyCollectPayRepository.findAllByPrdMoveDt(targetDate));
// 둘 다 완료될 때까지 대기
List<RcvpayCheckDTO> smtcList = smtcFuture.join();
List<StatsDailyCollectPay> auroraList = auroraFuture.join();
Before: 외부 API(500ms) → DB 조회(300ms) = 800ms
After: 외부 API(500ms) ┐ 동시 실행
DB 조회(300ms) ┘ = 500ms (더 느린 쪽이 기준)
2) 개별 save() → saveAll() 1번
systemLogRepository.saveAll(Arrays.asList(
makeSystemLog("smtcOnlyCollectPayCheckService", smtcOnly.toString(), targetDateStr),
makeSystemLog("auroraOnlyCollectPayCheckService", auroraOnly.toString(), targetDateStr),
makeSystemLog("notEqualCollectPayCheckService", notEqual.toString(), targetDateStr)
));
트랜잭션 3번 → 1번, DB 왕복 3번 → 1번으로 줄었다.
| 항목 | 기존 | 개선 후 |
|---|---|---|
| 처리 건수 | 203,000건 | 203,000건 |
| 소요 시간 | 47분 | 17분 |
| 단축 시간 | — | 30분 (64% 감소) |
이번 개선에서 얻은 핵심 패턴은 하나다.
반복문 안에서 I/O가 일어나고 있는가?
20만 건이면 루프 안의 1ms짜리 작업 하나가 200초(3분 20초)다.
findById() 하나가 아무리 빠르더라도 N번 불리면 누적된다.
반복문을 볼 때 "이 안에 있는 코드 중 루프 밖으로 꺼낼 수 있는 게 있나?" 를
먼저 확인하는 습관이 배치 성능 개선의 출발점이다.
| 개선 항목 | Before | After |
|---|---|---|
| SKU 조회 (N건) | SELECT N번 | SELECT IN 1번 |
| MD 영업그룹 캐시 | 스레드별 독립 Map | 전체 공유 Map |
| 정합성 조회 | 순차 실행 | CompletableFuture 병렬 실행 |
| 로그 저장 | save() × 3 | saveAll() × 1 |
| 총 소요 시간 | 47분 | 17분 |