1. 최적화, 그 이상의 기록이 필요한 이유
JPA와 벌크 연산을 활용하여 성능 문제를 해결하는 방법은 이미 앞서 다루었다. 그러나 단순히 코드를 수정했다고 해서 최적화가 끝났다고 단정하는 것은 위험하다. 진정한 엔지니어라면 "이 개선이 실제로 얼마나 효과가 있었는가?"라는 질문에 수치로 답할 수 있어야 한다.
성능 향상을 판단하기 위해서는 구체적인 데이터가 필요하다. 특히 대량의 데이터를 처리하는 배치(Batch) 작업에서는 다음과 같은 지표가 핵심이 된다.
- 총 실행 시간: 작업의 시작부터 종료까지 얼마나 소요되었는가?
- 실행된 SQL 쿼리 수: 의도한 대로 벌크 연산이 수행되어 쿼리 수가 줄었는가?
- 쿼리 유형별 통계: SELECT, INSERT, DELETE 중 어떤 쿼리가 병목을 일으키는가?
이러한 지표를 기록하고 분석하면 성능 병목 구간을 조기에 인지하고 불필요한 리소스 낭비를 방지할 수 있다.
2. 로그 기반 모니터링이 필요한 배경
일반적으로 메트릭 수집에는 Prometheus와 Grafana 조합을 자주 사용한다. 하지만 배치 작업에서는 한계가 존재한다. Prometheus는 일정 간격마다 데이터를 수집(Pull)하는 구조이기 때문에, 수 밀리초에서 수 초 내에 끝나는 벌크 작업의 경우 수집 주기 사이에서 데이터가 누락될 위험이 크다. 따라서 찰나에 종료되는 배치 작업일수록, 실행 시점에 즉시 기록하는 로그 기반 방식이 훨씬 정교한 모니터링을 제공한다.
3. 배치 작업 모니터링 시스템 구현
이미 구현했던 QueryCountInspector를 확장하여 배치 작업에 특화된 모니터링 시스템을 구축해 본다. 이 시스템은 ThreadLocal을 활용하여 스레드별로 독립적인 측정 환경을 제공한다.
3.1. BatchContext: 지표 저장소
배치 작업의 이름, 시작 시간, 쿼리 통계를 관리하는 객체이다.
@Slf4j
@Getter
public class BatchContext {
private final BatchName batchName;
private final LocalDateTime startTime;
private final Map<QueryType, Integer> queryCountByType;
public BatchContext(BatchName batchName) {
this.batchName = batchName;
this.startTime = LocalDateTime.now();
this.queryCountByType = new HashMap<>();
}
public void incrementQueryCount(String sql) {
QueryType queryType = QueryType.from(sql);
queryCountByType.merge(queryType, 1, Integer::sum);
}
public void log() {
long executionTime = ChronoUnit.MILLIS.between(startTime, LocalDateTime.now());
StringBuilder logMessage = new StringBuilder("\n");
logMessage.append("========================================\n");
logMessage.append("# Batch Query Count Report\n");
logMessage.append("- Batch Name: ").append(batchName).append("\n");
logMessage.append("- Batch ExecutionTime(ms): ").append(executionTime).append("\n");
logMessage.append("- Query Statistics:\n");
queryCountByType.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> logMessage.append(String.format(" - %-7s: %d\n", entry.getKey(), entry.getValue())));
logMessage.append("========================================\n");
log.info(logMessage.toString());
}
}
3.2. BatchContextHolder: 스레드 격리 관리
각 배치 스레드가 자신의 BatchContext에만 접근할 수 있도록 ThreadLocal을 사용한다.
public class BatchContextHolder {
private static final ThreadLocal<BatchContext> CONTEXT = new ThreadLocal<>();
public static void initContext(BatchContext context) {
CONTEXT.remove();
CONTEXT.set(context);
}
public static BatchContext getContext() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove(); // 메모리 누수 방지 필수
}
}
3.3. QueryCountInspector: 쿼리 가로채기
Hibernate가 실행하는 SQL을 감지하여 현재 컨텍스트에 기록한다.
public class QueryCountInspector implements StatementInspector {
@Override
public String inspect(String sql) {
// HTTP 요청 모니터링 병행 가능
RequestContext requestContext = RequestContextHolder.getContext();
if (requestContext != null) requestContext.incrementQueryCount(sql);
// 배치 모니터링
BatchContext batchContext = BatchContextHolder.getContext();
if (batchContext != null) batchContext.incrementQueryCount(sql);
return sql;
}
}
4. 실전 적용 및 테스트 결과
배치 애플리케이션 로직 전후로 컨텍스트 초기화와 로그 출력을 배치한다. 이는 Spring MVC의 Interceptor 역할을 배치 환경에서 수동으로 구현한 것과 유사하다.
배치 실행 로그 예시
구현된 모니터링 시스템을 통해 출력된 로그는 다음과 같다. 개선 전과 후의 쿼리 개수 차이를 명확하게 확인할 수 있다.
========================================
# Batch Query Count Report
- Batch Name: BULK_INSERT
- Batch ExecutionTime: 1390
- Query Statistics:
- INSERT : 1440
========================================
...
========================================
# Batch Query Count Report
- Batch Name: IMPROVED_BULK_INSERT
- Batch ExecutionTime: 120
- Query Statistics:
- INSERT : 1
========================================
'Spring > JPA' 카테고리의 다른 글
| [Basic-2] 영속성 컨텍스트 (Persistence Context) (0) | 2026.01.06 |
|---|---|
| [Basic-1] JPA 시작 (0) | 2026.01.05 |
| [Optimization-6] JPA: 벌크 연산과 성능 최적화 (0) | 2025.12.27 |
| [Optimization-5] JPA: 벌크 연산의 이해와 ID 생성 전략의 상관관계 (0) | 2025.12.26 |
| [Optimization-4] JPA: N+1 모니터링 시스템 구축하기 (0) | 2025.12.26 |
