프로젝트/APM prototype 개발

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

블랑v 2024. 4. 3. 22:38

PrepareStatement Search

transformer를 통해 prepareStatement를 조회해보자.

public class MyClassTransformer implements ClassFileTransformer {

    //이 인터페이스의 구현체(transform)은 JVM이 존재하는 클래스를 로드할 때마다 호출되며, 이 시점에서 바이트코드를 조사하고 변경할 수 있다.
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) {

        if (className.contains("PreparedStatement") || className.contains("preparedStatement") ) {
            System.out.println("Detected PreparedStatement implementation: " + className);
            // Apply bytecode manipulation logic here
        }

        return classfileBuffer;
    }

 

 

 

다음과 같은 경로 중 ProxyPreparedStatement를 바이트코드 조작하는 것이 낫다는 결론을 지었다.

이 클래스는 PreparedStatement의 프록시 구현으로, 실제 SQL 쿼리 실행 메서드(execute, executeQuery, executeUpdate 등)를 오버라이딩한다. HikariCP 커넥션 풀 라이브러리에 의해 사용되며, SQL 쿼리 실행 시점을 가로채는 데 이상적인 지점을 제공한다.

 

이 중 execute, executeQuery, executeUpdate 메서드 에 바이트코드를 삽입하여 SQL 쿼리 실행 전후에 트랜잭션 데이터를 캡처하고 로깅 또는 외부 시스템으로 전송하는 로직을 추가하는 것이 이상적이다.

 

 

 

코드 적용(Agent)

package org.agent.util.asm.testcode;

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

/**
 * 테스트용 ClassVisitor 구현체
 * psmt 변조
 */
public class PreparedStatementModifyVisitor extends ClassVisitor {
    private String currentClassName;

    public PreparedStatementModifyVisitor(ClassVisitor cv) {
        // 부모 클래스의 생성자에 ASM API 버전과 ClassVisitor 인스턴스(ClassWriter)를 전달
        super(Opcodes.ASM9, cv);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        currentClassName = name.replace('/', ',');
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);

        // execute, executeQuery, executeUpdate 메서드 확인
        if ("execute".equals(name) || "executeQuery".equals(name) || "executeUpdate".equals(name)) {
            System.out.println("check method : " + name);
            
            return new AddLogVisitor(mv, name, currentClassName);
        }

        return mv; // 기본 MethodVisitor 반환
    }

    //메서드 최상단에 로그 생성
    private static class AddLogVisitor extends MethodVisitor {
        private final String methodName;
        private final String className;

        public AddLogVisitor(MethodVisitor mv, String methodName, String className) {
            super(Opcodes.ASM9, mv);
            this.methodName = methodName;
            this.className = className;
        }

        @Override
        public void visitCode() {
            super.visitCode();

            /*
            System.out.println("Method start: ")
            - Opcodes.GETSTATIC : 정적 필드 접근 / "out"은 접근하려는 필드 / "Ljava/io/PrintStream;"은 필드의 타입
            - mv.visitLdcInsn("Method start: "); - 상수 풀에서 문자열 상수를 로드하는 바이트코드 생성
            - Opcodes.INVOKEVIRTUAL : 가상 메서드 호출 / "java/io/PrintStream" : 메서드가 속한 클래스의 내부 이름 / println : 메서드 / 서술자
             */

            mv.visitFieldInsn(Opcodes.GETSTATIC,"java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitLdcInsn("Method start : " + methodName + " \t Class name : " + className);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,"java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
    }

}

 

바이트코드가 삽입된 .class 파일(파싱)

 

check method : executeQuery
check method : executeUpdate
2024-02-06T16:58:26.822+09:00  INFO 19760 --- [nio-8080-exec-5] org.agent.util.asm.AsmCodeFactory        : done print
Method start : executeUpdate 	 Class name : com,zaxxer,hikari,pool,HikariProxyPreparedStatement
Method start : executeUpdate 	 Class name : com,zaxxer,hikari,pool,ProxyPreparedStatement
2024-02-06T16:58:31.428+09:00  WARN 19760 --- [nio-8080-exec-1] c.d.j.restapi.service.SqlServiceImpl     : SQL insert, nowTime = 2024-02-06T16:58:31.428271200
Hibernate: insert into sql_dummy (etc,send_time) values (?,?)
Method start : executeUpdate 	 Class name : com,zaxxer,hikari,pool,HikariProxyPreparedStatement
Method start : executeUpdate 	 Class name : com,zaxxer,hikari,pool,ProxyPreparedStatement
2024-02-06T16:58:36.434+09:00  WARN 19760 --- [nio-8080-exec-4] c.d.j.restapi.service.SqlServiceImpl     : SQL insert, nowTime = 2024-02-06T16:58:36.434740600
Hibernate: insert into sql_dummy (etc,send_time) values (?,?)
Method start : executeUpdate 	 Class name : com,zaxxer,hikari,pool,HikariProxyPreparedStatement
Method start : executeUpdate 	 Class name : com,zaxxer,hikari,pool,ProxyPreparedStatement
2024-02-06T16:58:41.123+09:00  INFO 19760 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2024-02-06T16:58:41.126+09:00  INFO 19760 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 -

 

로그 확인시 executeUpdate 메서드가 ProxyPreparedStatement HikariProxyPreparedStatement에서 각각 호출되고 있음을 알 수 있다.

 

이것의 이유는 다음과 같다.

 

- 프록시 체이닝

HikariProxyPreparedStatement는 ProxyPreparedStatement를 상속받아 구현된다고 한다.

그렇기에 executeUpdate가 호출되면, 먼저 HikariProxyPreparedStatement의 오버라이드된 메서드가 호출되고, 이 메서드 내부에서 super.executeUpdate() 형식으로 부모 클래스인 ProxyPreparedStatement의 executeUpdate 메서드를 명시적으로 호출된다.

 

- 데코레이터 패턴

HikariCP는 데코레이터 패턴을 사용하여 JDBC 커넥션과 관련된 클래스들을 래핑할 수 있다. 이 패턴은 각각의 프록시 클래스가 동작을 수행한 후 다음 프록시 또는 실제 대상 객체로 작업을 전달하는 방식으로 작동다.

 

한계

하지만 이는 SQL 쿼리문을 직접 볼 수는 없다.

표준 라이브러리에서 실쿼리인 executeQuery()를 확인할 수 있는 방법이 없기 때문이다. 이를 위해 다른 방법을 고민해야 한다.

 

 

이슈

 

생성자 super 선언 : 

super.visit(version, access, name, signature, superName, interfaces); .. 등

super를 호출하지 않는다면, ASM이 내부적으로 수행해야 하는 작업들이 실행되지 않아 방문자 패턴(visitor pattern)이 제대로 동작하지 않을 수 있다.

 

이는 visitor가 방문자 패턴이자, 체이닝 방식으로 동작하기 때문인데, 한 방문자(visitor)가 작업을 수행한 후 다음 방문자로 작업을 넘겨주는 방식으로 설계되어 있다.

super를 호출하지 않으면, 체인 중 다음 방문자로의 작업 전달이 누락되어 문제가 발생할 수 있다.