jOOQ의 ExecuteListener를 활용하면 애플리케이션 레벨에서 슬로우 쿼리를 실시간 슬로우 쿼리 감지할 수 있다
ExecuteListener
ExecuteListener는 jOOQ가 제공하는 라이프사이클 훅으로, SQL 실행의 각 단계에서 커스텀 로직을 실행할 수 있게 해준다
- executeStart(): 쿼리 실행 직전
- executeEnd(): 쿼리 실행 완료 후
- fetchStart(), fetchEnd(): 결과 패칭 전후
- exception(): 예외 발생 시
PerformanceListener 구현
package org.sight.jooqstart.config;
import lombok.extern.slf4j.Slf4j;
import org.jooq.ExecuteContext;
import org.jooq.ExecuteListener;
import org.jooq.Query;
import org.jooq.tools.StopWatch;
import java.time.Duration;
@Slf4j
public class PerformanceListener implements ExecuteListener {
private StopWatch watch;
private static final Duration SLOW_QUERY_LIMIT = Duration.ofSeconds(3);
@Override
public void executeStart(ExecuteContext ctx) {
watch = new StopWatch();
}
@Override
public void executeEnd(ExecuteContext ctx) {
final long queryTimeNano = watch.split();
if (queryTimeNano > SLOW_QUERY_LIMIT.toNanos()) {
Query query = ctx.query();
Duration executeTime = Duration.ofNanos(queryTimeNano);
log.warn(
String.format(
"""
### Slow SQL 탐지 >>
경고: jOOQ로 실행된 쿼리 중 %d초 이상 실행된 쿼리가 있습니다.
실행시간: %s초
실행쿼리: %s
""",
SLOW_QUERY_LIMIT.toSeconds(),
millisToSeconds(executeTime),
query
)
);
}
}
private String millisToSeconds(Duration duration) {
return String.format("%.1f", duration.toMillis() / 1000.0);
}
}
- StopWatch를 인스턴스 변수로 선언하여 쿼리별 실행 시간 측정
- executeStart()에서 타이머 시작
- executeEnd()에서 시간 측정 후 임계값 초과 시 경고 로그 출력
- 나노초 단위로 정밀하게 측정 후 초 단위로 변환
jOOQ Configuration 등록
Spring Boot 3.x에서는 DefaultConfigurationCustomizer를 통해 jOOQ 설정을 커스터마이징 한다
package org.sight.jooqstart.config;
import org.jooq.conf.ExecuteWithoutWhere;
import org.springframework.boot.autoconfigure.jooq.DefaultConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JooqConfig {
@Bean
public DefaultConfigurationCustomizer jooqDefaultConfigurationCustomizer() {
return c -> {
// PerformanceListener 등록
c.set(PerformanceListener::new);
// 추가 안전 설정
c.settings()
.withExecuteDeleteWithoutWhere(ExecuteWithoutWhere.THROW) // WHERE 없는 DELETE 방지
.withExecuteUpdateWithoutWhere(ExecuteWithoutWhere.THROW) // WHERE 없는 UPDATE 방지
.withRenderSchema(false); // 스키마명 렌더링 비활성화
};
}
}
- ExecuteWithoutWhere.THROW: WHERE 절 없는 DELETE/UPDATE 실행 시 예외 발생
- withRenderSchema(false): 쿼리에 스키마명을 포함하지 않음
테스트
package org.sight.jooqstart;
import org.jooq.DSLContext;
import org.jooq.impl.DSL;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.jooq.impl.DSL.dual;
@SpringBootTest
public class JooqSlowQueryTest {
@Autowired
DSLContext dslContext;
@Test
@DisplayName("SLOW 쿼리 탐지 테스트")
void 슬로우쿼리_탐지_테스트() {
// 4초 대기하는 슬로우 쿼리 실행
dslContext.select(DSL.field("SLEEP(4)"))
.from(dual())
.execute();
}
}
실행 결과
### Slow SQL 탐지 >> 경고: jOOQ로 실행된 쿼리 중 3초 이상 실행된 쿼리가 있습니다. 실행시간: 4.1초 실행쿼리: select SLEEP(4) from ( select 'X' as `DUMMY` ) as `DUAL`
환경별 임계값 설정
프로덕션과 개발 환경에서 다른 임계값을 사용
@Slf4j
public class PerformanceListener implements ExecuteListener {
private final Duration slowQueryLimit;
private StopWatch watch;
public PerformanceListener(Duration slowQueryLimit) {
this.slowQueryLimit = slowQueryLimit;
}
// 나머지 코드는 동일...
}
@Configuration
public class JooqConfig {
@Value("${jooq.slow-query-limit-seconds:3}")
private long slowQueryLimitSeconds;
@Bean
public DefaultConfigurationCustomizer jooqDefaultConfigurationCustomizer() {
return c -> {
Duration limit = Duration.ofSeconds(slowQueryLimitSeconds);
c.set(() -> new PerformanceListener(limit));
// ...
};
}
}
모니터링 시스템 연동
실무에서는 로그만으로 부족할 수 있다. Micrometer를 통해 메트릭으로 수집할 수 있다
@Override
public void executeEnd(ExecuteContext ctx) {
final long queryTimeNano = watch.split();
Duration executeTime = Duration.ofNanos(queryTimeNano);
// 메트릭 기록
meterRegistry.timer("jooq.query.execution")
.record(executeTime);
// 슬로우 쿼리 감지
if (queryTimeNano > slowQueryLimit.toNanos()) {
meterRegistry.counter("jooq.slow.query").increment();
// 로깅...
}
}
쿼리 파라미터 포함
디버깅을 위해 바인딩된 파라미터 값도 함께 로깅
log.warn(
String.format(
"""
### Slow SQL 탐지 >>
실행시간: %s초
실행쿼리: %s
바인딩 값: %s
""",
millisToSeconds(executeTime),
ctx.query(),
ctx.query().getBindValues() // 바인딩된 값
)
);
주의 사항
- 스레드 안정성: 현재 구현은 각 쿼리 실행마다 새로운 PerformanceListener 인스턴스를 생성하므로 (PerformanceListener::new) 스레드 안전하다
- 성능 오버헤드: StopWatch와 로깅의 오버헤드는 매우 적지만, 초고성능이 요구되는 환경에서는 샘플링 방식 고려가 필요할 수 있다
- 로그 레벨: WARN 레벨로 설정했으므로 프로덕션 환경에서도 누락 없이 감지된다
ExecuteListener를 활용하면 별도의 APM(Application Performance Monitoring) 도구 없이도 애플리케이션 레벨에서 슬로우 쿼리를 효과적으로 모니터링할 수 있다. 특히 jOOQ를 사용하는 환경에서는 쿼리 실행 시점에 정확히 개입할 수 있어 유용하다
- 개발 단계에서 성능 문제를 조기 발견
- 프로덕션 환경에서 실시간 슬로우 쿼리 감지
- 배치 작업이나 대량 데이터 처리 시 병목 지점 파악