JDBC SQL 구문에 클라이언트 정보 남기기 프로그래밍

DB 서버가 한 대 있고, 해당 서버에 접속해서 쿼리를 날리는 Java 웹 애플리케이션이 여러 개라고 해보자.
커머스 업체라고 했을 때 사용자 화면 애플리케이션, 상품 정보 관리 애플리케이션, 정산 애플리케이션 등이 별도로 존재하며 어떤 특정 DB에 모두 동시에 접속하는 경우가 발생한다는 것은 쉽게 예상할 수 있을 것이다.

DB 서버의 로그를 보니 Slow Query가 남아 있는데, 도대체 이 Slow Query를 호출한 애플리케이션이 무엇인지 어떻게 하면 빠르게 판단할 수 있을까?

제일 쉬운 방법은 SQL 구문에 주석으로 각 클라이언트(애플리케이션)의 정보를 남기는 것이다.
SQL은 /* 이런 저런 내용 */ 형태로 주석을 남기는 것을 지원한다.

Java 6/JDBC 4 Spec에서 바로 이러한 Client Info를 남길 수 있는 기법을 지원한다(Connection#setClientInfo). 나는 현재 MySQL만 사용하고 있지만 다른 많은 DB들도 이를 구현하고 있으리라 예상한다.

더 아래에서는 Hibernate 사용시 Interceptor를 통해 더 쉽게 처리하는 방법도 간단히 소개한다.

JDBC 4 Client Info
MySQL의 경우 5.1.10 버전부터 JDBC 4의 Client Info 스펙을 구현하고 있다. (단, MySQL Replication Driver는 아직도(2014년 3월) 이를 구현하지 못하고 있다.)

이 스펙을 구현한다고 해서 모든 JDBC 드라이버가 SQL의 주석으로 Client 정보를 남기는 것은 아니다. 하지만 MySQL은 JDBC4CommentClientInfoProvider를 Client Info 처리의 기본 구현체로 지정하고 있으면 이것이 바로 주석으로 정보를 남기는 역할을 한다. 이 구현체는 clientInfoProvider JDBC URL 파라미터를 통해 원하는 것으로 바꾸는 것이 가능하다.

일단은 아주 생짜 JDBC로 어떻게 작동하는지 코드로 보여주면 다음과 같다.
// 예외처리는 모두 무시했음. 모범적인 코드 예가 아니므로 실전에서 따라하지 말 것.
Class.forName("com.mysql.jdbc.Driver");

Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&user=root&password=root");

InetAddress localhost = InetAddress.getLocalHost();

connection.setClientInfo("ApplicationName", "JDBC4 Test Application");
connection.setClientInfo("ClientHostname", localhost.getHostName());
connection.setClientInfo("ClientAddress", localhost.getHostAddress());


Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("select 1");


위 코드를 실행하면 MySQL의 쿼리 로그에 다음과 같이 찍히게 된다. 즉, DBA가 Slow Query를 발견했다면 즉시 어느 애플리케이션의 어느 서버에서 온 쿼리인지 인지할 수 있다.
/* ApplicationName=JDBC4 Test Application, ClientHostname=kwon37xi-dev, ClientAddress=127.0.1.1 */ select 1


하지만 현대 애플리케이션에서 Connection을 매번 직접 생성하여 값을 설정하는 일은 하지 않는다. 특히 Spring을 사용한다면 Connection 객체는 사실상 구경도 할 필요가 없고, 할 수는 있더라도 안하는게 더 좋다.

BoneCP 커넥션 풀BoneCPConfig.java 설정에서 setClientInfo 메소드를 통해 미리 클라이언트 정보를 Properties 객체로 넣어둘 수 있는 기능을 지원하고 있다. 따라서 커넥션 풀 설정시 한 번만 해주면 모든 Connection에 대해 Client Info가 전달된다.

Hibernate Interceptor
Hibernate를 사용하는 경우(JPA를 통해서든 직접이든) Interceptor 인터페이스 중에서 onPrepareStatement 메소드 구현을 통해서 위와 같은 일을 하는게 가능하다.
이 경우 좀 더 유리한데, 왜냐하면 이 인터셉터 구현체는 모든 쿼리 요청마다 매번 호출되기 때문이다. 따라서 모든 쿼리 호출시 호출 당시의 정보(접속 URL, 호출한 클래스와 메소드 정보) 등도 함께 넣는게 가능하다.

onPrepareStatement는 특별히 키/값 쌍을 넣거나 하는 것이 아니라 DB에 전송하기 전의 SQL 구문을 직접 편집하는 것이므로 SQL 주석을 개발자가 직접 만들어 넣어주면 된다.

실제 구현은 EmptyInterceptor를 상속하여 다른 것은 무시하고 onPrepareStatement만 구현하는 것이 좋다.

JPA 사용시 hibernate.ejb.interceptor로 Interceptor 구현 클래스의 FQCN을 지정해주면 된다.

워낙 쉬워 코드 예를 들지는 않겠지만 한가지 팁을 주면, Spring MVC 사용시 Spring MVC Interceptor를 통해 현재 URL정보와 컨트롤러 클래스/메소드 정보를 수집해두고 이를 ThreadLocal을 통해서 Hibernate Interceptor에 전달하여 SQL 구문에 URL과 컨트롤러 클래스/메소드 정보까지 집어넣는 것이 가능해진다. SQL 구문을 보고나서 오류 발생 URL을 즉시 알 수 있어서 오류 대응 능력이 조금 높아질 것이다. 단, SQL 구문이 길어짐으로써 생기는 다른 단점들은 감안해야 한다.

그리고 마지막으로, 사용자가 직접 입력하거나 변조할 수 있는 정보를 Client Info로 절대로 넣지말라. 굳이 넣어야 겠다면 이 또한 SQL Injection의 하나로 공격 대상이 될 수 있으므로 철저히 검증해서 넣도록 주의해야 한다.