프록시 JDBC(2)
- 개발
- 2026. 3. 9. 18:27
프록시 JDBC(1)
이전에 저는 파일을 통해 slow쿼리를 계산을 진행하였습니다. 하지만 그러다 보니 실시간으로 메시지를 보여주지 못하였습니다. 처음 의도가 네트워크 상황이라던지 여러 요인들이 존재하기 때
b-programmer.tistory.com
분량과 시간 문제로 다음 편에 작성하게 되었습니다.
이전 장에서는 DelegatingDataSource와 Wrapper를 사용해야 하는 이유를 살펴보았습니다. 우리가 최종적으로 관찰하고 싶은 지점은 execute() 메서드입니다. 이 메서드는 실제로 SQL을 실행하는 역할을 합니다. 하지만 SQL은 execute() 시점에 갑자기 만들어지는 것이 아닙니다. SQL을 실행하려면 그보다 앞서 SQL을 준비하는 과정이 필요합니다. 그리고 그 역할을 담당하는 객체가 바로 PreparedStatement입니다. 즉 우리가 관심 있는 지점은 execute()이지만, execute()를 이해하려면 결국 PreparedStatement까지 올라가야 합니다. 그런데 PreparedStatement는 스스로 생성되지 않고, Connection을 통해 생성됩니다. 또한 Connection 역시 직접 생성되는 객체가 아니라 DataSource를 통해 획득됩니다. 정리하면 JDBC 실행 흐름은 다음과 같습니다.
DataSource → Connection → PreparedStatement → execute()
이 구조 때문에 execute() 호출을 자동으로 관찰하려면, 가장 안쪽 메서드만 따로 다룰 수 있는 것이 아니라 객체가 생성되는 흐름의 시작점부터 개입해야 합니다. 그래서 DataSource부터 Wrapping이 필요했던 것입니다. 다행스럽게도 Spring은 DataSource를 감쌀 수 있는 DelegatingDataSource를 제공합니다. 이를 통해 DataSource 단계에는 비교적 쉽게 개입할 수 있습니다. 다만DelegatingDataSource가 Connection부터 PreparedStatement, execute()까지 자동으로 모두 감싸주는 것은 아닙니다. 즉 DataSource 이후의 흐름에 대해서는 개발자가 직접 Wrapping을 구현해야 합니다. 이번 글에서는 이러한 JDBC 실행 흐름을 따라가며,
Connection과 PreparedStatement를 어떻게 감싸고, 최종적으로 execute() 시점을 어떻게 관찰할 수 있는지 알아보겠습니다.
프록시 객체로 만드는 방법?
프록시 객체를 만드는 방법은 다양합니다.
대표적으로 프록시 패턴을 직접 구현하는 방법, JDK 동적 프록시를 사용하는 방법, 그리고 CGLIB과 같은 라이브러리를 사용하는 방법 등이 있습니다.
이번 글에서는 이 중 JDK 동적 프록시를 이용해 구현해보겠습니다.
그 전에, 왜 JDK 동적 프록시 방식을 선택했는지 먼저 살펴보겠습니다.
| 방법 | 장점 | 단점 |
| JDK 동적 프록시 | 외부 의존성 없음, execute* 시점에서 정확한 SQL 실행 시간 계측 가능, traceId 같은 애플리케이션 정보 연동 쉬움 | 프록시 코드 직접 구현 및 유지보수 필요 |
| Hibernate StatementInspector | JPA 환경에서는 자연스럽게 SQL을 가로챌 수 있음 | JDBC 직접 호출이나 다른 ORM 경로의 SQL은 잡지 못할 수 있음 |
| Spring AOP (Service/Repo 계층) | 적용이 쉽고 코드 스타일 일관성 유지 가능 | 실제 DB execute 시간이 아니라 서비스 로직 시간까지 포함될 수 있음 |
방법은 크게 세 가지가 있습니다. 하지만 제가 필요한 것은 SQL 실행 시간을 측정하여 해당 쿼리가 슬로우 쿼리인지 판단하는 것입니다.
먼저 Hibernate StatementInspector의 경우 JPA 환경에서 실행되는 SQL을 가로챌 수 있다는 장점이 있습니다. 하지만 JDBC를 직접 사용하는 경우나 다른 경로로 실행되는 SQL은 잡지 못할 수 있다는 한계가 있습니다. 또한 Spring AOP를 사용하는 방식은 서비스나 리포지토리 계층에서 메서드 실행 시간을 측정하게 됩니다. 이 경우 실제 SQL 실행 시간뿐만 아니라 서비스 로직 수행 시간까지 함께 포함될 수 있습니다. 따라서 제가 원하는 방식은 서비스 로직의 시간이 포함되지 않고, SQL이 실제로 실행되는 시점의 시간만 측정할 수 있는 방법이어야 합니다.
이를 위해서는 JDBC에서 SQL이 실제로 실행되는 지점인 execute() 호출 시점을 기준으로 시간을 측정하는 방식이 가장 적절하다고 판단했습니다.
가장 먼저 DataSource를 Wrapper 클래스로 만들어야 합니다.
이렇게 접근한 이유는 execute() 호출 시점을 관찰하려면, 객체가 생성되는 흐름의 시작점인 DataSource부터 개입해야 한다고 판단했기 때문입니다.

하지만 DataSource를 직접 Wrapping하려고 살펴보니 한 가지 문제가 있었습니다.
DataSource는 단순히 getConnection() 하나만 가진 객체가 아니라, 여러 메서드를 함께 가지고 있는 인터페이스였습니다.
즉, DataSource를 직접 구현하는 방식으로 Wrapper를 만들 경우, 실제로 필요한 기능은 많지 않더라도 인터페이스에 선언된 수많은 메서드를 함께 구현해야 하는 번거로움이 발생했습니다. 결국 DataSource를 Wrapping한다는 것은 단순히 getConnection() 하나만 다루는 일이 아니라, 기존 DataSource가 가지고 있는 동작 전체를 유지하면서 필요한 지점만 확장해야 하는 작업이었습니다.
다행스럽게도 Spring에서는 DataSource를 wrappring해놓은 클래스를 제공하고 있었습니다.
그렇다면 DataSource를 제외한 나머지를 어떻게 하면 wrapping 할 수 있을지 살펴보겠습니다.
wrapping 방법
Proxy.newProxyInstance()
새로운 프록시객체로 만드는 방법입니다. 어떤 정보가 필요한지 체크 해봅시다.
newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
메서드 시그니처를 살펴보면 세 가지 파라미터가 필요합니다.
코드를 확인해보니 자바의 리플렉션(Reflection) 기술을 이용해 런타임에 프록시 객체를 생성하는 방식이라는 것을 알 수 있습니다.
ClassLoader
프록시 객체를 생성할 때 사용할 클래스 로더입니다.
보통 프록시를 생성할 인터페이스의 클래스 로더를 그대로 사용합니다.
그렇다면 클래스 로더를 가져오는 이유가 무엇일까요?
자바에서는 모든 클래스가 ClassLoader를 통해 JVM에 로딩됩니다.
JDK 동적 프록시는 런타임에 새로운 프록시 클래스를 생성하기 때문에, 이 클래스 역시 JVM에 로딩되어야 합니다.
따라서 JVM은 새로 생성된 프록시 클래스를 어떤 ClassLoader를 통해 로딩할지 알아야 합니다.
그래서 Proxy.newProxyInstance() 메서드는 ClassLoader를 파라미터로 요구합니다.
Connection.class.getClassLoader(),
Interfaces
프록시 객체가 구현해야 할 인터페이스 목록입니다.
JDK 동적 프록시는 인터페이스 기반으로 동작하기 때문에, 반드시 구현할 인터페이스를 전달해야 합니다.
ClassLoader는 프록시 클래스를 JVM에 로딩하기 위한 정보이고, interfaces는 프록시 객체가 구현해야 할 인터페이스 목록입니다. 따라서 전달된 인터페이스는 해당 ClassLoader에서 접근 가능한 타입이어야 하며, 보통은 인터페이스를 로딩한 ClassLoader를 그대로 사용하는 방식이 가장 안전합니다.
new Class[]{Connection.class},
InvocationHandler
프록시 객체의 메서드 호출을 실제로 처리하는 객체입니다.
프록시 객체의 메서드가 호출되면, 실제 메서드가 바로 실행되는 것이 아니라 이 InvocationHandler의 invoke() 메서드가 먼저 실행됩니다.

그렇다면 여기에서 진행이 되어지는 것이 무엇일까요?
InvocationHandler는 프록시 객체에서 발생하는 모든 메서드 호출을 가로채고, 그 호출에 대해 추가적인 로직을 수행할 수 있는 핵심 지점입니다.
예시를 몇개 들어봅시다.
Object result = invoke(conn, method, args);
if (result instanceof PreparedStatement ps) {
return wrapPreparedStatement(ps, (String) args[0]);
}
이 코드는 메소드에 아무것도 실행하지 않고 원 상태를 실행을 시켜줬습니다.
만약, result가 PrepredStatement인 경우 PrepraredStatmet를 wrapping한 결과로 넘어갑니다.
여기서 또 의문이 들었습니다. 이럴거면 원문에서 PrepredStatement가 리턴되어지는것으로 바꾸면 되지 않을까요?
아쉽게도 불가능합니다. 그 이유는 Connection -> PrepredStatement를 리턴해주는 메소드들을 살펴봅시다.
PreparedStatement prepareStatement(String sql)
throws SQLException;
PreparedStatement prepareStatement(String sql, int resultSetType,
int resultSetConcurrency)
throws SQLException;
PeparedStatement prepareStatement(String sql, int resultSetType,
int resultSetConcurrency, int resultSetHoldability)
throws SQLException;
PreparedStatement prepareStatement(String sql, int autoGeneratedKeys)
throws SQLException;
PreparedStatement prepareStatement(String sql, int columnIndexes[])
throws SQLException;
하나같이 전부 sql문이 필요합니다. 하지만 api를 실행하기 전까지는 어떤 sql이 실행이 되어졌는지는 모릅니다.
그렇다면 방법은 하나뿐입니다. 이것또한 프록시로 만드는 방법입니다.
다시 코드를 보면
Object result = invoke(conn, method, args);
invoke가 되어지는 부분이 존재합니다. 보시면 conn, method, args정보가 들어가있습니다.
우리가 주목해야 하는 부분은 바로 args입니다. 그러니까 이렇게 처리하게 되면 2가지 장점이 있습니다.
첫 번째는 객체 도중에 추가 기능을 추가하는 것이 있고, 두 번째는 매개변수를 개발자가 몰라도 넣을 수 있다는 점입니다.
이를 이용하게 하면
if (result instanceof PreparedStatement ps) {
return wrapPreparedStatement(ps, (String) args[0]);
}
요렇게 사용할 수 있습니다. 만약 결과가 PreparedStatemet라면 result값이랑 매개변수 첫번째를 넘겨줍니다.
이제 두번째 예시입니다.
return (PreparedStatement) Proxy.newProxyInstance(
PreparedStatement.class.getClassLoader(),
new Class[]{PreparedStatement.class},
(proxy, method, args) -> {
long startedNanos = System.nanoTime();
try {
return invoke(target, method, args);
} finally {
monitor.collect(sql, toElapsedMillis(startedNanos));
}
}
);
코드가 살짝 다른데요.
우리가 주목을 해야 하는 부분은 바로
long startedNanos = System.nanoTime();
try {
return invoke(target, method, args);
} finally {
monitor.collect(sql, toElapsedMillis(startedNanos));
}
이것이 바로 wrapper로만 사용하지 않고 프록시까지 사용하는 방법입니다. invoke위를 보시면 startedNanos가 존재하는 것을 확인 할 수 있습니다. 그리고 나서 invoke가 실행이 된다면? 물론 약간의 네트워크 시간이 있을 수 는 있겠지만, 그것이 확인하는데 문제가 될정도는 아닐겁니다. 만약, 문제가 된다면 직접 db 로그를 확인 하는 방법이 더 효율적이라고 생각합니다.
그리고 나서 객체가 생성이 완료가 된 이후에 시간 측정을 완료한다면, slow쿼리를 측정할 수 있습니다.
이는 프록시 객체로 만들어서 추가 기능을 만드는 방법입니다.
두 가지 예시를 살펴봤는데요. 어째서 Connection도 wrapping을 하는지 이해를 했습니다.
결론
어제에 이어서 코드 분석을 진행하였습니다. 어째서 jdk 동적 프록시를 사용하는지 알아보았습니다.
이제와서 고백하는데 프록시 JDBC라는 말은 없습니다. 정확히 말하면 Datsource를 프록시화한거지만요..
사실 이 기능은 라이브러리를 사용하면 조금더 편한다고는 하는데 정확하게 이 코드에 대해 설명하기 위해서는 라이브러리를 사용하지 않는것이 좋다고 판단하였습니다.
아무튼 이렇게 정리를 해보니 눈에 보이는것같습니다. 리플렉션 기술은 무분별하게 사용하면 안된다고 합니다.
시간이 된다면 jdk 동적 프록시 vs cglib와 레플렉션에 대해 깊게 학습하는 시간을 가져봅시다.
'개발' 카테고리의 다른 글
| Actuator (0) | 2026.03.11 |
|---|---|
| OpenAI는 어떻게 PostgreSQL을 스케일했을까 (0) | 2026.03.10 |
| 프록시 JDBC(1) (0) | 2026.03.08 |
| 분명... slow쿼리를 모니터링하려고 했는데... 어째서... (0) | 2026.03.06 |
| 데이터베이스의 종류 (0) | 2026.03.05 |