반응형

바이트 코드를 조작하기

자바에서는 바이트 코드를 조작할 수 있다. 대표적인 예로 코드 커버리지를 측정하는 jacoco 라이브러리가 있다.
정확히는 모르지는 바이트 코드를 조작하여 코드 커버리지를 측정한다고 한다.

활용 예

  • 코드 커버리지 jacoco 라이브러리
  • 스프링 컴포넌트 스캔이 애노테이션들을 찾는 과정
  • 등등 활용 범위가 굉장히 많다고 함.

바이트 코드를 조작한다는 것이 무엇일까?


간단한 예제 코드를 살펴 보겠다. 바이트 코드를 조작하여 텅빈 모자에서 토끼를 꺼내는 예제이다.
분명 Moja.java에는 빈 문자열이 들어있다. 하지만 소스는 건들지 않고 바이트 코드만 조작해서 토끼를 꺼낼 수 있다.

어떻게 코드를 조작할까?


byteBuddy라는 라이브러리를 사용하면 조작할 수 있고 코드는 아래와 같이 작성하면 된다.
간단히 설명하자면, Moja.class인 바이트 코드를 읽어 pullOut() 메소드를 가로채서 "Rabbit!!"으로 바이트 코드만 변경하는 것이다.

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

import java.io.File;
import java.io.IOException;

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

public class Masula {

    public static void main(String[] args) throws IOException {
        new ByteBuddy().redefine(Moja.class)
                .method(named("pullOut")).intercept(FixedValue.value("Rabbit!!"))
                .make().saveIn(new File("/Users/monkeydugi/IdeaProjects/classloader-sample/target/classes"));

//        System.out.println(new Moja().pullOut());
    }
}

이렇게 조작 후 바이트 코드를 보면 아래와 같이 변경이 된다!!!

이제 이걸 읽어오는 것이다. 그럼 소스 코드는 분명 빈 문자열이지만 바이트 코드는 토끼가 있기 때문에 결과는 토끼가 나오게 된다.

근데 두 코드를 같이 실행하면 안될까?


굉장히 불편하다. 먼저 조작을 하고 이후에 사용을 해야한다. 이유는 간단한다.
만약 두 코드를 같이 실행 한다면, 초기의 바이트 코드는 빈 문자열이고 byteBuddy는 Moja.class를 읽어 바이트 코드를 조작하게 된다.
여기까지는 문제가 없어 보인다. 하지만 new Moja().pullOut()도 동일하게 클래스가 로딩되는 시점에 조작되기 전의 바이트 코드를 읽게 된다.
이미 메소드 영역에 클래스가 로딩되는 시점에 바이트 코드가 들어갔기 때문이다. 그렇기 때문에 같이 사용하면 토끼를 꺼낼 수 없게 되는 것이다.

그렇다면 어떻게 번거로운 방법을 개선할 수 없을까?


javaAgent를 사용하면 개선할 수 있다.

  • javaAgent란 : JVM에서 동작하는 Java 애플리케이션으로 JVM의 다양한 이벤트를 전달 받거나 특정 API를 이용해 바이트 코드 등을
    제어할 수 있다고 한다.
  • javaAgent 특징 : 런타임 시 바이트 코드 조작 가능, JVM의 실행 지점인 main 메서드를 가로챌 수 있다.

javaAgent를 이용해서 public static void premain(String agentArgs, Instrumentation inst) {...}을 가진
프로젝트에서 해당 프로젝트를 jar로 maven을 이용해 패키징 한다. 이 jar를 사용할 곳에서 실행 시
javaagent옵션으로 해당 jar를 읽어 들이면 main 메서드 실행 전에 premain을 실행하게 되는 것이다.
즉, 가로챈 것이다. 아래 코드를 간단히 살펴 보자.

package org.example;

import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.dynamic.DynamicType;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.matcher.ElementMatchers;
import net.bytebuddy.utility.JavaModule;

import java.lang.instrument.Instrumentation;

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

public class MasulaAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        new AgentBuilder.Default()
            .type(ElementMatchers.any())
            .transform(new AgentBuilder.Transformer() {
                public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassLoader classLoader, JavaModule javaModule) {
                    return builder.method(named("pullOut")).intercept(FixedValue.value("Rabbit!"));
                }
            }).installOn(inst);
    }
}

named("pullOut")과 FixedValue.value("Rabbit!")를 보면 pullOut의 메서드의 내용을 Rabbit으로 변경 한다는 의미이다.
하지만 실제 사용하는 측의 바이트 코드는 변경이 되지 않는다. 클래스를 로딩하는 시점에 premain이 적용되기 때문이다.
즉, 사용 측의 어플리케이션이 로딩할 때 premain이 실행되고, 메모리에 저장을 하게 된다.
이렇게 변경을 하여 jar로 패키징을 하였기 때문에 이를 사용하는 프로젝트에서는 바이트 코드와 소스 코드는 Rabbit이 없지만 Rabbit을
꺼낼 수 있게 되는 것이다.

반응형
복사했습니다!