JAVA

바이트코드 조작

경딩 2025. 3. 24. 16:30

스카우터는 어떻게 xlog 를 측정할까? 궁금증을 살펴보다 바이트 조작이라는 개념을 알게되었다.

어떤 원리로 바이트 조작이 발생하는 것일까? 해당 방식에 대해 알아보자

 

바이트 코드 사용 예시

자바 커버리지 측정도 바이트코드 조작과 면밀한 연관이 있다.

 

코드 커버리지

코드 커버리지는 어떻게 측정할까?

테스트 케이스 실행 시 커버리지로 실행을 할 수 있다.

 

커버리지 실행 후 보고서를 확인할 수 있다.

색깔별로 테스트 실행 시 확인한 부분을 알 수 있다.

 

 

바이트 코드 조작 라이브러리 비교

  • ASM 
    • 바이트 코드 조작시 가장 고전적이고 널리 쓰이는 라이브러리  (비지터 패턴과 어뎁터 패턴을 알아야 잘 쓸 수 있음) 
    • 매우 어렵고 러닝커브가 높으며 자바 바이트코드 구조를 어느정도 알고 있어야 한다.
    • 스프링 프레임워크를 포함하여 많은 곳이서 쓰인다.
  • Javasist
    • ASM 보다는 아니지면 여전히 사용하기가 어렵다고 한다
  • ByteBuddy
    • 비교적 가장  최근에 나온 라이브러리 이다.
    • 자바 바이트 코드에 대해 깊게 몰라도 충분히 사용가능하며, 러닝 커브가 낮다.
    • 배우기 쉽다
    • 코드 작성이 편하지만, 체이닝이 심해서 가독성이 떨어지는 편이다.

ByteBuddy 사용하여 모자에서 없는 토끼 꺼내기

  • 의존성 추가
	implementation 'net.bytebuddy:byte-buddy:1.14.9'
package com.flab.mars.tmp;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.implementation.FixedValue;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;

import static net.bytebuddy.matcher.ElementMatchers.named;

public class Masulsa {
    public static void main(String[] args) {
//        try {
//            // 바이트 코드를 변경하는 작업
//            new ByteBuddy()
//                    .redefine(Moja.class) // Moja 클래스 수정
//                    .method(named("pullOut")) // pullOut 메서드를 수정
//                    .intercept(FixedValue.value("Rabbit! ")) // 고정값 반환
//                    .make()
//                    .saveIn(new File("C:\\Users\\jongwan\\Desktop\\굥이\\mars\\build\\classes\\java\\main\\"));
//
//        } catch (IOException e) {
//            e.printStackTrace();
//        }
         System.out.println(new Moja().pullOut());
    }
}

 

  • ByteBuddy를 이용해서 기존의 Moja 클래스를 재정의(redefine) 하고 있다.
  • method(named("pullOut")) → pullOut() 메서드만을 선택하여 수정.
  • .intercept(FixedValue.value("Rabbit! ")) → pullOut() 메서드가 "Rabbit! "을 반환하도록 변경.
  • .saveIn(new File(...)) → 변경된 클래스를 해당 경로에 저장.

바이트코드가 Rabbit 으로 잘 변경되어 있다. 또한 실행시 Rabbit 를 출력하고 있다.

 

 

javaagent 사용하여 어플리케이션 실행 시에 바이트코드 조작하기

공식 문서를 참고하여 구현한다.

 

package com.flab.mars.tmp;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.implementation.FixedValue;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;

import static net.bytebuddy.matcher.ElementMatchers.named;

public class Masulsa {
    public static void main(String[] args) {
        try {
            // 바이트 코드를 변경하는 작업
            new ByteBuddy()
                    .redefine(Moja.class) // Moja 클래스 수정
                    .method(named("pullOut")) // pullOut 메서드를 수정
                    .intercept(FixedValue.value("Rabbit! ")) // 고정값 반환
                    .make()
                    .saveIn(new File("C:\\Users\\jongwan\\Desktop\\굥이\\mars\\build\\classes\\java\\main\\"));

        } catch (IOException e) {
            e.printStackTrace();
        }
         System.out.println(new Moja().pullOut());
    }
}

 

ByteBuddy 로 클래스 변경 후, JVM 이 새로운 클래스를 로드하지 않는 이유

 

문제점

해당 클래스를 실행하면, JVM 은 Moja 클래스를 처음 실행할 때 메서드 영역(Methode Area) 에 로드합니다.

하지만 ByteBuddy 를 사용해 Moja.class 파일을 변경하더라도, JVM 은 이미 로드된 기존 클래스를 사용하기 때문에 변경된 클래스 파일을 다시 로드하지 않습니다.

 

즉 pullOut() 의 메서드의 동작을 변경하더라고, JVM 이 변경된 클래스를 인식하지 못하기 때문에 기존 메서드가 그대로 실행됩니다.

 

클래스 로딩 순서에 상관없이 바이트코드를 전처리할 수 있는 agent 를 만들어보자

 

javaagent 사용하여 어플리케이션 실행시에 바이트 코드 조작하기

premain() → JVM 시작 전에 실행되는 코드 (일반적인 Java Agent)

package com.flab.mars.tmp;

import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.instrument.Instrumentation;

import static net.bytebuddy.matcher.ElementMatchers.named;

public class MasulsaAgent {

    // jvm 시작시 실행되는 agent 진입점
    public static void premain(String agentArgs, Instrumentation inst) {
        // AgentBuilder 를 사용해 클래스변환을 정의
        new AgentBuilder.Default()
                // 모든 클래스를 대상으로 바이트코드 변환을 적용
                .type(ElementMatchers.any())
                // 메서드 변환을 정의하는 부분
                .transform((builder, typeDescription, classLoader, module, protectionDomain) ->
                        // pullOut 메서드를 찾아서
                        builder.method(named("pullOut"))
                                // 해당 메서드가 호출되면 "Rabbit!" 을 반환하도록 반환
                                .intercept(FixedValue.value("Rabbit!"))
                 // 변환된 바이트코드를 Instrumentation 에 설치하여 적용       
                ).installOn(inst);


    }
}

 

  • Javaagent JAR 파일 만들기
  • 붙이는 방식은 시작시 붙이는 premain 과 런타임 중에 동적으로 붙이는 방식 agentmain 이 있다.
  • Instrumentation 을 사용한다.

jar manifest 변경하기

 

Javaagent 붙여서 사용하기

클래스로더가 클래스를 읽어올 때 javaagent 를 거쳐서 변경된 바이트코드를 읽어들여 사용한다.

premain() 함수 작성하기

// jar 파일의 manifest 속성 변경
jar {
	manifest.attributes(
			// Premain-Class: 에이전트가 시작될 때 호출되는 클래스 지정
			'Premain-Class' : 'me.flash.MasulsaAgent', // 'me.flash.MasulsaAgent' 클래스를 에이전트 시작 클래스(진입점)로 설정
			// Can-Redefine-Classes: 클래스 재정의 여부 설정
			'Can-Redefine-Classes' : true, // 클래스 재정의 기능을 활성화
			// Can-Retransform-Classes: 로드된 클래스 재변환 여부 설정
			'Can-Retransform-Classes' : true // 이미 로드된 클래스를 재변환할 수 있도록 설정
	)
}

화살표 표시를 눌러 jar 파일을 패키징하면 lib 파일에 jar 가 생성된것을 볼 수 있다.

MANIFEST 파일에 정의한 파일이 들어있는 것을 확인 할 수 있다.

Premain-Class (Java Agent용):

  • Java Agent의 경우, 에이전트를 실행할 때 사용할 진입점 클래스를 설정하는 속성입니다.
  • 이 클래스는 JVM 시작 시 또는 프로그램 중에 바이트코드를 변환하는 역할을 합니다.

vm 옵션에서 javaagent 를 추가해준다.

-javaagent:PATH\build\libs\mars-0.0.1-SNAPSHOT-plain.jar

 

실행결과 조작된 바이트 코드로 출력되는 것을 확인할 수 있다.

 

 

바이트코드 수정 및 Java Agent 설명

Java Agent 를 사용하여 바이트코드를 수정하는 방식은 기존 파일 시스템에서 클래스를 수정하는 것이 아니라, 클래스를 메모리에 로딩할 때 수정된 바이트코드를 동적으로 적용하는 방식이다. 이를 통해 기존의 코드를 수정하지 않으면서도 클래스의 동작을 변경할 수 있다. 이러한 방법을 비침투적(non-intrusive) 방식이라고 부른다.

 

실제 바이트 코드를 살펴보면 바이트 코드는 변하지 않았다. 하지만 메모리 내부에서는 수정되어 있다.

읽어 올떄 바뀌었다.

기존 파일 시스템에 있는 파일을 수정하는것이 아닌 클래스 로딩할때  agent 를 거쳐서 변경된 바이트 코드를 읽어와 메모리에 로딩된다.

즉 클래스로더가 클래스를 읽어올때 javaagent 를 거쳐서 변경된 바이트코드를 읽어들여 사용한다..

 

예시: Java Agent가 바이트코드를 변경하는 과정

  • 1. 클래스 로딩
    • JVM 이 클래스를 로딩할 때, Java Agent 가 로딩되는 클래스의 바이트코드를 가로챈다.
  • 2. 바이트코드 변경
    • Java Agent 는 바이트코드를 변환하여 메모리에 로딩할 클래스를 수정한다.
    • 예를 들어, pullOut 메서드의 반환값을 "Rabbit"  으로 변경하는 것처럼 메서드 로직을 동적으로 수정할 수 있다.
  • 3. 메모리에 로드
    • 수정된 바이트코드는 메모리 상에 로딩되어 JVM 에 의해 실행된다.
    • 실제 클래스 파일은 변경되지 않지만, 실행되는 코드는 변환된 코드가 적용된다.

 

참고 자료 :

<더 자바, 코드를 조작하는 다양한 방법>

'JAVA' 카테고리의 다른 글

Java 예외 처리, 제대로 알고 쓰자  (1) 2025.03.26
클래스 로더란?  (0) 2025.03.24
JVM, JDK, JRE 의 차이, JVM의 동작방식  (0) 2025.03.23
네트워크 - 프로그램1  (1) 2025.01.29
자바 - IO 기본 (buffer)  (1) 2025.01.28