Programing Language/JAVA

JAVA 바이트 코드 조작

칼쵸쵸 2024. 4. 16. 13:59

출처 : 

https://www.inflearn.com/course/the-java-code-manipulation

바이트 코드 조작의 사용예시 (코드 커버리지)

코드 커버리지는 테스트 코드가 소스 코드의 어느 부분을 실행(확인)했는지를 백분율로 나타낸 것입니다. JaCoCo는 Java 코드 커버리지를 측정하는 도구 중 하나입니다.

JaCoCo 사용하기

JaCoCo 설정: Maven 프로젝트의 pom.xml에 JaCoCo 플러그인을 추가합니다.

JaCoCo 사용법.

https://www.eclemma.org/jacoco/trunk/doc/index.html
http://www.semdesigns.com/Company/Publications/TestCoverage.pdf

pom.xml에 플러그인 추가

  <build>
    <plugins>
      <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.4</version>
        <executions>
          <execution>
            <goals>
              <goal>prepare-agent</goal>
            </goals>
          </execution>
          <execution>
            <id>report</id>
            <phase>prepare-package</phase>
            <goals>
              <goal>report</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build> 

빌드 실행

Maven을 사용하여 프로젝트를 빌드하고, 코드 커버리지 리포트를 생성합니다.
mvn clean verify
excution을 추가해서 커버리지 이하의 비율은 실패처리하게 할수도 있습니다.

          <execution>
            <id>jacoco-check</id>
            <goals>
              <goal>check</goal>
            </goals>
            <configuration>
              <rules>
                <rule>
                  <element>PACKAGE</element>
                  <limits>
                    <limit>
                      <counter>LINE</counter>
                      <value>COVEREDRATIO</value>
                      <minimum>0.50</minimum>
                    </limit>
                  </limits>
                </rule>
              </rules>
            </configuration>
          </execution>

코드 커버리지의 동작원리는 논문이 있는데
아주 간단히 간추리자면
바이트코드를 읽어서 코드 커버리지를 챙겨야 되는 부분들을 개수를 새어놓고
코드가 실행이 될때 그중에 몇개를 지나갔는지 카운팅을 해서 비교한다.
바이트 코드를 조작해서 어디를 지나가고 어디는 안지나가고를 확인하는 복잡한 툴
이런걸 만드는 개념을 설명합니다.

바이트 코드 기본 구현

예시 기본 구조

모자에서 토끼 꺼내기 시뮬레이션

아래의 Java 코드는 마술사가 모자에서 토끼를 꺼내는 마술을 시뮬레이션합니다.

Moja.java: 토끼를 꺼내는 행위를 나타내는 클래스

public class Moja {
    public String pullOut() {
        return "";
    }
}

Masulsa.java: 마술 실행

public class Masulsa {
public static void main(String[] args) {
System.out.println(new Moja().pullOut());
}
}

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

  • ASM: https://asm.ow2.io/ - visitor 패턴 ,adaptor 패턴을 통해서 구현해야됨 , 두 디자인 패턴을 잘 알아도 쓰기가 어려움, 자바 바이트코드 구조를 잘 알아야됨
  • Javassist: https://www.javassist.org/ - 그나마 쓰기 쉽지만 이것도 어려움
  • ByteBuddy: https://bytebuddy.net/#/ - 쉬움(이정도 api면 쉽게 해볼만함)

ByteBuddy 소개

내부적으로는 asm을 사용하며 훨씬 쉽게 사용 가능

다른 바이트 코드 조작 툴과 성능 비교

 

ByteBuddy 사용 방법


의존성 추가

    <dependency>
      <groupId>net.bytebuddy</groupId>
      <artifactId>byte-buddy</artifactId>
      <version>1.10.11</version>
    </dependency>

 

그냥 돌리면 당연히 아무것도 출력이 안된다

public class Masulsa {
    public static void main( String[] args ) throws IOException {
        System.out.println(new Moja().pullOut());
    }
}

 

바이트 코드 수정

public class Masulsa {
    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/user/IdeaProjects/javaTest/target/classes/"));
    }
}

- ByteBuddy로 특정 클래스를 Redifine한다고 선언
- 어떤 메서드를 수정한다고 선언
- 가로채서 return 값을 "Rabbit!!" 으로 바꿔버리기
- 그리고 class 파일을 덮어써버리면 원본상태의 Moja 클래스파일이 아래와 같이 변한다.

원본
변경된 클래스 파일

 

public class Masulsa {
    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/user/IdeaProjects/javaTest/target/classes/"));
        System.out.println(new Moja().pullOut());
    }
}

Moja.pullOut"Rabbit!!" 을 출력하게 된다.

한번에 처리되지 않은 이유

그러나 한번에 모두 돌리면 마술이 먹히지 않는다.

이미 모자 클래스바이트 코드로 변화한 후 읽어 버렸기 때문에 바이트 코드를 변환후는 실행이 안된다.

 

마술사는 마술하기 전에 막 준비해서 하지 않는다.

근데 이짓을 매번 해야된다면 쓸모가 없다.

코드를 돌리고 바이트 코드를 변환시키서 다시 돌리는지 않고 사용할수 있어야 한다.

JavaAgent를 이용한 바이트코드 조작

클래스 로더로 수정하기

클래스 로더를 바꿔서 로딩순서를 변경

우선 클래스 로더를 먼저 등장시켜서 ByteBuddy에게 줘보자

public class Masulsa {
    public static void main( String[] args ) throws IOException {
        ClassLoader classLoader = Masulsa.class.getClassLoader();
        TypePool typePool = TypePool.Default.of(classLoader);


        new ByteBuddy().redefine(Moja.class)
                .method(named("pullOut")).intercept(FixedValue.value("Rabbit!!"))
                .make().saveIn(new File("/Users/user/IdeaProjects/javaTest/target/classes/"));
        System.out.println(new Moja().pullOut());
    }
}

 

클래스 로더로 읽어오는 클래스파일과 해당 클래스 로더를 ByteBuddy에게 넣어준다.

public class Masulsa {
    public static void main( String[] args ) throws IOException {
        ClassLoader classLoader = Masulsa.class.getClassLoader();
        TypePool typePool = TypePool.Default.of(classLoader);

        new ByteBuddy().redefine(
                // 클래스파일로 읽어오는 모자 클래스
                typePool.describe("org.example.Moja").resolve(), 
                // 클래스 로더
                ClassFileLocator.ForClassLoader.of(classLoader))
                .method(named("pullOut")).intercept(FixedValue.value("Rabbit!!"))
                .make().saveIn(new File("/Users/user/IdeaProjects/javaTest/target/classes/"));
        System.out.println(new Moja().pullOut());
    }
}

이렇게 하면 system.out.println 하기전에 모자 클래스를 읽을 필요가 없어진다.
클래스로더를 수정해서 바꾼다.
이러면 나오긴하는데 이것도 쓸모가 없다.

클래스로링 순서를 바꾸는것은 범용성이 없다.

JavaAgent로 동적으로 조작하기

JavaAgent는 JVM이 클래스를 로드할 때 바이트코드를 동적으로 조작할 수 있게 해줍니다.

바이트코드 조작은 ASM, Javassist, ByteBuddy 등의 라이브러리를 통해 수행할 수 있습니다.

새로운 프로젝트 생성

새로운 프로젝트(MasulsaAgent)를 만듭니다.

 

main 클래스는 지워버립니다.

premain 클래스 생성

premain 클래스를 만들어 줍니다.

public class MasulsaAgent {
    public static void premain(String argentArgs, Instrumentation inst){

    }
}

https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html

위의 스펙은 자바 공식문서에서 확인 가능

위에서 Instrumentation 인터페이스가 코드를 조작하는 인터페이스이고 해당 내용을 asm , byteBuddy등으로 채워 넣는것

inst 구현

ByteBuddy의 AgentBuilder를 불러와서 inst에 구현하기

public class MasulsaAgent {
    public static void premain(String argentArgs, Instrumentation inst){
        new AgentBuilder.Default()
                .type(ElementMatchers.any())
                .transform(new AgentBuilder.Transformer() {
                    @Override
                    public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassLoader classLoader, JavaModule javaModule, ProtectionDomain protectionDomain) {
                        return null;
                    }
                }).installOn(inst);
    }
}

동적으로 클래스 파일을 바꾸고 inst에 넣어준다는뜻

나머지는 사실 이전의 내용과 같다. (람다로 바꾸면 더 복잡해집니다.)

기존과 같이 메서드 바꿔치기

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

위와 마찬가지로 메서드 중에 pullOut이라는 요소를 찾아서
return Value를 "Rabbit!!" 으로 변경해준다.

JavaAgent JAR 설정하기

Jar 파일안에다가 특정한 값들을 넣어줘야됨

메이븐으로 넣을때 Manifest를 조작할수 있는 jar 플러그인으로 만들어 줘야 된다.

https://maven.apache.org/plugins/maven-jar-plugin/examples/manifest-customization.html

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.3.0</version>
        <configuration>
          <archive>
            <index>true</index>
            <manifest>
              <addClasspath>true</addClasspath>
            </manifest>
            <manifestEntries>
              <mode>development</mode>
              <url>${project.url}</url>
              <key>value</key>
            </manifestEntries>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>

ManifestEntries 안에 https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html 스펙을 보면

Manifest Attributes

The following manifest attributes are defined for an agent JAR file:

Premain-Class

When an agent is specified at JVM launch time this attribute specifies the agent class. That is, the class containing the premain method. When an agent is specified at JVM launch time this attribute is required. If the attribute is not present the JVM will abort. Note: this is a class name, not a file name or path.

Agent-Class

If an implementation supports a mechanism to start agents sometime after the VM has started then this attribute specifies the agent class. That is, the class containing the agentmain method. This attribute is required, if it is not present the agent will not be started. Note: this is a class name, not a file name or path.

Boot-Class-Path

A list of paths to be searched by the bootstrap class loader. Paths represent directories or libraries (commonly referred to as JAR or zip libraries on many platforms). These paths are searched by the bootstrap class loader after the platform specific mechanisms of locating a class have failed. Paths are searched in the order listed. Paths in the list are separated by one or more spaces. A path takes the syntax of the path component of a hierarchical URI. The path is absolute if it begins with a slash character ('/'), otherwise it is relative. A relative path is resolved against the absolute path of the agent JAR file. Malformed and non-existent paths are ignored. When an agent is started sometime after the VM has started then paths that do not represent a JAR file are ignored. This attribute is optional.

Can-Redefine-Classes

Boolean (true or false, case irrelevant). Is the ability to redefine classes needed by this agent. Values other than true are considered false. This attribute is optional, the default is false.

Can-Retransform-Classes

Boolean (true or false, case irrelevant). Is the ability to retransform classes needed by this agent. Values other than true are considered false. This attribute is optional, the default is false.

Can-Set-Native-Method-Prefix

Boolean (true or false, case irrelevant). Is the ability to set native method prefix needed by this agent. Values other than true are considered false. This attribute is optional, the default is false.

An agent JAR file may have both the Premain-Class and Agent-Class attributes present in the manifest. When the agent is started on the command-line using the -javaagent option then the Premain-Class attribute specifies the name of the agent class and the Agent-Class attribute is ignored. Similarly, if the agent is started sometime after the VM has started, then the Agent-Class attribute specifies the name of the agent class (the value of Premain-Class attribute is ignored).

이런 값들을 넣어줘야 된다고 써있다. 현재는 Premain-Class 모드를 사용하고 있다.

  • Premain은 자바 실행할때 옵션으로 넣어주기
  • Agent는 이미 돌고 있는데 거기다가 넣어주기

build 옵션 조정

Can-Redefine-Classes , Can-Retransform-Classes 도 사용해야 하므로 ( 클래스 재정의하기, 클래스 바꾸기) true로 넣어준다.

 <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.3.0</version>
        <configuration>
          <archive>
            <index>true</index>
            <manifest>
              <addClasspath>true</addClasspath>
            </manifest>
            <manifestEntries>
              <mode>development</mode>
              <url>${project.url}</url>
              <key>value</key>
              <Premain-Class>org.example.MasulsaAgent</Premain-Class>
              <Can-Redefine-Classes>true</Can-Redefine-Classes>
              <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
          </archive>
        </configuration>
      </plugin>

패키징

패키징 하고 사용하기

1. 우선 패키징한다.

mvn clean Package

 

2. 그리고 가서 확인해보면 jar가 만들어져 있다.

3. zip으로 바꿔주고 열어보면 설정한게 들어간것을 확인할수 있다.

4. zip으로 바꿔서 다시 빌드해줘야 한다.

다시 빌드해서 경로를 복사해준다.

바이트 코드 조작 vm 변경

원래 프로젝트는 이제 모두 지우고 moja에서 꺼내기를 하고 있다.

아무것도 안나온다.

 

public class Masulsa {
public static void main( String[] args ) throws IOException {
System.out.println(new Moja().pullOut());
}
}

 

vm 옵션으로 전에 만든 MasulsaAgent를 넣어준다.

 

-javaagent:/Users/user/.m2/repository/org/example/MasulsaAgent/1.0-SNAPSHOT/MasulsaAgent-1.0-SNAPSHOT.jar
  • Agent는 여러개 넣어도 된다,
  • 이렇게 넣고 실행하면 토끼가 나온다.

바이트 코드 확인

  • 확인을 해보면 현재 사용하는 바이트 코드는 바뀌지 않는다.

  • 파일시스템에 있는 클래스파일을 변경하는것이 아니라(기존에는 클래스파일을 바꿔버림)
  • 클래스 로딩할때 JavaAgent가 동작해서 변경된 바이트코드가 메모리 내부에 들어가서 읽게 만들어버린다.
  • 이 방식이 기존 코드를 보다 덜 건드리면서 뭔가를 했다.
  • 실행할때 javaAgent옵션을 줬을뿐 클래스 파일은 그대로다.
  • 조금더 TransParents 하다

바이트코드 조작 활용 사례

  • 코드에서 버그 찾는 툴
    정적 분석 도구: 코드를 실행하지 않고 분석하여 버그, 코드 스멜, 안전하지 않은 코드 패턴 등을 식별합니다. 예를 들어, Java에는 Checkstyle, PMD, FindBugs와 같은 도구가 있습니다.
  • 동적 분석 도구
    코드를 실행하여 버그를 찾습니다. 예를 들어, Java의 JUnit과 같은 단위 테스트 프레임워크를 사용하여 코드의 특정 부분이 예상대로 작동하는지 확인할 수 있습니다.
  • 코드 복잡도 계산
    복잡도 측정: 코드의 복잡성을 측정하여 유지 관리의 어려움을 예측합니다. Cyclomatic Complexity와 같은 메트릭이 이에 해당합니다.
  • 클래스 파일 생성
  • 프록시 생성: 객체의 접근을 제어하거나 기능을 확장하기 위해 프록시 객체를 동적으로 생성합니다. Java에서는 Reflection API 또는 Proxy 클래스를 사용할 수 있습니다.
  • API 호출 접근 제한: 특정 조건에서만 API 호출을 허용하도록 접근을 제한합니다. 이는 보안을 강화하고 불필요한 리소스 사용을 줄이는 데 도움이 됩니다.
  • 컴파일러: 스칼라와 같은 언어의 컴파일러는 소스 코드를 바이트코드로 변환하는 과정에서 다양한 최적화와 분석을 수행합니다.
  • 코드 변경을 위한 도구
  • 프로파일러: 성능 분석 도구로, 코드의 실행 시간과 메모리 사용량을 측정합니다. New Relic과 같은 도구가 이에 해당합니다.
  • 최적화: 코드의 실행 효율성을 높이기 위해 알고리즘 개선, 메모리 관리 최적화 등을 수행합니다.
  • 로깅: 시스템의 동작 상태나 오류 정보를 기록하여 문제 해결과 성능 모니터링에 도움을 줍니다.

 

  • 스프링의 컴포넌트 스캔 방법
    스프링은 asm 라이브러리를 사용하여 컴포넌트 스캔을 수행합니다. ClassPathScanningCandidateComponentProvider를 통해 클래스패스에서 빈으로 등록할 후보 클래스를 찾고, SimpleMetadataReader를 사용하여 클래스의 메타데이터를 읽어옵니다. 이 과정에서 ClassReader와 Visitor 패턴을 사용하여 클래스 파일의 바이트코드를 분석합니다.

 

  • 바이트코드 조작 라이브러리
    ASM: 자바 바이트코드를 직접 조작할 수 있는 강력한 라이브러리입니다. 성능 최적화와 고급 분석 기능을 제공합니다.
    Javassist: 코드 수준에서 바이트코드를 조작하기 위한 라이브러리로, 상대적으로 쉬운 API를 제공합니다.
    ByteBuddy: 동적 클래스 생성과 바이트코드 조작을 위한 현대적인 라이브러리로, 사용하기 쉬운 API와 높은 성능을 자랑합니다.
    CGlib: 리플렉션을 사용하지 않고 런타임에 동적 프록시를 생성할 수 있는 라이브러리입니다.