프로젝트/APM prototype 개발

개발 진행 : Springboot DataSource 동적 Proxy 테스트

블랑v 2024. 2. 6. 20:11

https://csg1353.tistory.com/179

 

개발 진행 : 메서드 변조(MethodVisitor, ClassVisitor), 로깅

PrepareStatement Search transformer를 통해 prepareStatement를 조회해보자. public class MyClassTransformer implements ClassFileTransformer { //이 인터페이스의 구현체(transform)은 JVM이 존재하는 클래스를 로드할 때마다 호

csg1353.tistory.com

 

이전 문제의 한계를 극복하고, 실 sql 쿼리 요청에 대해 성공적으로 로그를 출력(최종적으로는 데이터 파싱) 해야 한다.

이를 위해 몇가지 방법을 찾아보았다.

제시안

1. Java Agent를 통한 드라이버 래핑

  • 독립성: 높음. 드라이버 래핑은 JDBC API에만 의존하며, 특정 SQL이나 데이터베이스에 의존하지 않는다.
  • 개발 용이성: 중간. 이 방법은 JDBC 드라이버와 연결 관리에 대해 학습 곡선이 있지만, 한번 설정하고 나면 다양한 데이터베이스와 JDBC 드라이버에 적용할 수 있다.
  • 유지보수: 중간. 드라이버의 변화나 JDBC API의 업데이트에 대응해야 하지만 드라이버에 크게 의존하지 않는 일반적인 로직으로 구성될 수 있다.

2. 바이트코드 조작

  • 독립성: 중간. 바이트코드 조작은 JDBC API에 의존하지만, 특정 JDBC 구현체의 내부 구조를 이해해야 하고 드라이버마다 적용 방법이 달라질 수 있다.
  • 개발 용이성: 낮음. ASM 코드 난이도가 상대적으로 높고, 런타임 오류를 발생시킬 수 있다. 
    물론 현재 단계에서 고민을 많이 했다. 분명 이 부분이 가능하긴 할 것 같아서..
  • 유지보수: 낮음. 드라이버가 업데이트되거나 내부 구현이 변경될 때마다 새롭게 조정해야 할 가능성이 높다.
    이는 별로 좋지 않은 이슈이다.

3. DataSource 프록시 파싱

  • 독립성: 높음. DataSource 프록시는 JDBC API에 의존하며, 특정 SQL이나 데이터베이스 구현에 종속되지 않는다.
  • 개발 용이성: 높음. 프록시는 비교적 이해하기 쉬운 패턴이며, Java 표준 인터페이스를 사용하기 때문에 구현 난이도가 상대적으로 낮다.
  • 유지보수: 높음. 단순히 프록시 객체를 만들어서 Connection 단계에서 먼저 가로채기를 실행하여 파싱할 뿐이므로 그렇게 유지보수의 난이도가 높지 않을 것이다.

 

일단은 동적 프록시를 생성해보기로 결정했다.

 

(동적 프록시 좋은 정리글)

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EB%88%84%EA%B5%AC%EB%82%98-%EC%89%BD%EA%B2%8C-%EB%B0%B0%EC%9A%B0%EB%8A%94-Dynamic-Proxy-%EB%8B%A4%EB%A3%A8%EA%B8%B0

 

☕ 누구나 쉽게 배우는 Dynamic Proxy 다루기

Java Dynamic Proxy 자바 프로그래밍의 디자인 패터중 하나인 프록시 패턴은 초기화 지연, 접근 제어, 로깅, 캐싱 등, 기존 대상 원본 객체를 수정 없이 추가 동작 기능들을 가미하고 싶을 때 사용하는

inpa.tistory.com

(DataSource의 좋은 정리글)

https://velog.io/@byeongju/DataSource-cbd8ln4x

 

velog

 

velog.io

 

 

실제 코드

 

순환 참조의 이슈를 해결하고 설정한 코드. SpringBoot 기준으로는 문제 없이 구동될 것이다.

 

1. DataSourceConfig

 

import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;

@Configuration
public class DataSourceConfig {

    private final DataSourceProperties properties;

    public DataSourceConfig(DataSourceProperties properties) {
        this.properties = properties;
    }

    @Bean
    @Primary
    public DataSource dataSource() {
        // DataSourceProperties를 사용하여 실제 DataSource 구성 (순환 참조 이슈)
        DataSource realDataSource = DataSourceBuilder.create()
                .driverClassName(properties.getDriverClassName())
                .url(properties.getUrl())
                .username(properties.getUsername())
                .password(properties.getPassword())
                .build();

        // 프록시 DataSource 생성 및 반환
        return DataSourceProxyHandler.createProxy(realDataSource);
    }
}

 

2. DataSourceProxyHandler

 


import javax.sql.DataSource;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;

/**
 * javax.sql.DataSource 인터페이스를 기반
 * DataSource의 getConnection() 메서드 호출을 가로채서,
 * 반환되는 Connection 객체에 로깅 등의 추가 작업을 수행할 수 있도록 하는 InvocationHandler 구현체
 */
public class DataSourceProxyHandler implements InvocationHandler {
    private final DataSource targetDataSource;

    public DataSourceProxyHandler(DataSource targetDataSource) {
        this.targetDataSource = targetDataSource;
    }

    /**
     * proxy 인스턴스, 호출된 메서드의 Method 인스턴스, 메서드 전달 인수
     1. getConnection 메서드가 호출될 때, 실제 DataSource 객체의 해당 메서드를 호출하여 Connection 객체를 가져온다.
     2. 이 Connection 객체에 대한 새로운 프록시를 생성하여 반환한다.
     3. 프록시는 Connection 인터페이스의 모든 메서드 호출을 가로채고, prepareStatement 같은 메서드가 호출될 때 SQL 쿼리를 로깅.
     */
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if ("getConnection".equals(method.getName())) {
            final Connection connection = (Connection) method.invoke(targetDataSource, args);
            // Connection에 대한 프록시를 반환
            return Proxy.newProxyInstance(
                    Connection.class.getClassLoader(),
                    new Class<?>[]{Connection.class},
                    (proxyConn, methodConn, argsConn) -> {
                        //prepareStatement 메서드 호출 시 로깅
                        if ("prepareStatement".equals(methodConn.getName())) {
                            System.out.println("SQL proxy 로그: " + argsConn[0]);
                        }
                        return methodConn.invoke(connection, argsConn);
                    });
        }
        return method.invoke(targetDataSource, args);
    }

    //DataSource 인스턴스를 받아서, 이를 대리하는 프록시 DataSource 인스턴스를 생성
    public static DataSource createProxy(DataSource realDataSource) {
        return (DataSource) Proxy.newProxyInstance(
                DataSource.class.getClassLoader(),
                new Class<?>[]{DataSource.class},
                new DataSourceProxyHandler(realDataSource)
        );
    }
}

 

JPA log를 켰을때 모습. 실제 전송 내역을 파싱하여 출력하는 것을 확인할 수 있다.

 

견해

해당 방법을 사용한 이유는 javax.sql.datasource 인터페이스를 프록시로 파싱한다면, 어떤 구현체가 오든 프록시 객체가 이를 가로채고 로직을 구현할 수 있기 때문이었다.

==> 원래 기대하던 사실은 WAS의 종속성 없이, 어떠한 구현체든 정보를 몰라도 대다수의 구현체들은 이 datasource 인터페이스를 사용하니 범용적으로 사용할 수 있겠다는 내용이었다.

실제로 그런 것 같기도 하고..

 

하지만 이 방법은 실제 사용하기에 무리가 있다.

 

1. premain 단계 에서 이 코드를 실행할 경우 컨텍스트 과정 이전이기에 Bean 객체에 애초에 저장이 되어 있지 않다. 이 방법은 불가능하다. 즉, Instrument API를 사용하는 내 Agent에서 실행하기 어려운 환경이다.

2. 또한 springboot 기반 코드이며, 다른 WAS에서는 실행된다는 보장이 없다. 애초에 spring에서는 datasource를 xml에서 설정하는데..(물론 Bean에 등록하는 과정은 똑같겠지만 말이다.) tomcat은? zeus는?

3. Agent로 바이트코드를 조작하는 방법이 차라리 더 깔끔하고, 오버헤드 역시 덜 발생한다. (클래스로더 이후 동적으로 바이트코드를 설정하는게 차라리 깔끔할 것이다.)