티스토리 뷰

JVM 구조에 대한 글에서 살펴보았듯이 

.java 파일을 자바가 컴파일하여, .class 파일로 변환하고

.class 파일을 JVM이 클래스 로더에 의해서 로딩 -> 링크 -> 초기화 

작업을 통하여 메모리(Runtime Data Area)에 저장하게 됩니다.

 

바이트 코드 조작은 개발자의 소스코드(.java)를 직접 수정하지 않고

.class 파일을 수정하여 원하는 기능 구현하는 장점이 있습니다.

그럼 바이트 코드를 조작하여 활용하는 예시를 살펴보겠습니다. 

 

1. 프로그램 분석

  • 코드에서 버그 찾는 툴
  • 코드 복잡도 계산

2. 클래스 파일 생성

  • 프록시 (원래 소스 코드 대신 실행할 코드)
  • 특정 API 호출 접근 제한
  • 스칼라 같은 언어의 컴파일러

3. 그 밖에도 자바 소스 코드 건드리지 않고 코드 변경이 필요한 여러 경우에 사용

  • 프로파일러 (애플리케이션의 성능 분석 툴)
  • 최적화 (바이트 코드를 조작하여 불필요한 코드를 제거)
  • 로깅

4. 스프링이 컴포넌트 스캔을 하는 방법 (asm 라이브러리 사용)

  • asm library를 이용하여 컴포넌트 스캔 및 빈으로 등록할 후보 클래스 정보를 찾는 데 사용합니다.
  • ClassPathScanningCandidateComponentProvider -> SimpleMetadataReader
  • ClassReader와 Visitor 사용해서 클래스에 있는 메타 정보를 읽어 옵니다.

 

바이트 코드 조작 라이브러리에는 다음과 같이 있습니다.

ASM : https://asm.ow2.io/

Javassist : https://www.javassist.org/

ByteBuddy : https://bytebuddy.net/ (사용하기 편하고 API 문서가 잘되어 있어서 추천)

 

라이브러리 중 ByteBuddy를 사용하여 실습을 해보겠습니다.

먼저 프로젝트 생성 후

<!-- https://mvnrepository.com/artifact/net.bytebuddy/byte-buddy -->
<dependency>
   <groupId>net.bytebuddy</groupId>
   <artifactId>byte-buddy</artifactId>
   <version>1.12.8</version>
 </dependency>​

pom.xml에 ByteBuddy Dependency를 추가합니다.

 

package org.example;

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

Moja 클래스에는 String 리턴 값이 있는 pullOut 메소드가 있습니다.

System.out.println(new Moja().pullOut())  이 코드를 사용하면 결과가 "" 이렇게 출력이 되겠지만,

바이트 코드 조작을 하여 결과가 "Rabbit!"으로 출력이 나오게 할 것입니다.

package org.example;

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 Masulsa {
    public static void main(String[] args) {
        try {
            new ByteBuddy().redefine(Moja.class)
                    .method(named("pullOut")).intercept(FixedValue.value("Rabbit!"))
                    .make().saveIn(new File("/Volumes/Samsung T5/project/codeCoverage/target/classes/"));
        } catch (IOException e) {
            e.printStackTrace();
        }

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

Masulsa 클래스에서 try-catch문 안에 

ByteBuddy 라이브러리를 이용하여 Moja 클래스에 있는 pullOut 메소드의 return 값을 "Rabbit!"으로 

바이트 코드를 조작하는 코드가 작성되어 있습니다.

try-catch문 실행 후에 주석 후

아래 주석되어 있는 System.out.println(new Moja().pullOut());  코드의 주석을 풀고서 실행하면

실행결과가 "Rabbit!"으로 출력되는 것을 확인할 수 있습니다.

 

여기서 모두 주석을 풀고서 실행하면 "Rabbit!"이 나오지 않습니다.

그 이유는 Masulsa 클래스가 실행되면 redefine 함수에서 Moja.class 파일을 직접 참조하여 

Moja 클래스 정보가 메모리(Runtime Data Area)의 메소드 영역에 저장되기 때문에

그 이후 바이트 코드가 조작이 되어도 new Moja().pullOut의 코드는

바이트 코드 조작 이전의 메모리에 있는 클래스 정보를 참조하기 때문입니다.

 

이렇게 불편한 실행을 개선한 코드를 살펴보겠습니다.

package org.example;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.pool.TypePool;

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

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

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

        try {
            new ByteBuddy().redefine(
                    typePool.describe("org.example.Moja").resolve(),
                    ClassFileLocator.ForClassLoader.of(classLoader))
                    .method(named("pullOut")).intercept(FixedValue.value("Rabbit!"))
                    .make().saveIn(new File("/Volumes/Samsung T5/project/codeCoverage/target/classes/"));
        } catch (IOException e) {
            e.printStackTrace();
        }

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

위 코드에서 달라진 점은 redefine 함수에서 Moja.class를 직접 참조하지 않고,

문자열(주소)을 참조하고 있습니다.

바이트 코드 조작 이후 new Moja().pullOut()에서 

Moja 클래스를 메모리(Runtime Data Area)의 메소드 영역에 저장하기 때문에

"Rabbit!" 이 출력이 됩니다.

하지만 이 방법은 클래스 로딩 순서에 너무 의존적이라서 

다른 클래스에서 Moja 클래스를 로딩한다면, pullOut 메소드는 전과 같이

"" 이렇게 출력이 됩니다.

 

위와 같은 클래스 로딩 순서에 의한 의존적인 방법이 아닌

Masulsa 클래스는 System.out.println(new Moja().pullOut());

코드만 가지고 있는 상황에서 "Rabbit!"이 출력되게 javaagent 실습을 해보겠습니다.

 

Javaagent

클래스 로더가 클래스를 읽어온 후 javaagent를 거쳐서 조작된 바이트 코드를 

메모리(Runtime Data Area)의 메소드 영역에 저장하게 됩니다.

이때, 클래스 파일은 변경되지 않습니다. 

시작 시 붙이는 방식인 premain과 런타임 중에 동적으로 붙이는 방식인 agentmain이 있습니다.

agent는 java.lang.instrumentation을 사용합니다.

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

 

 프로젝트를 생성해 보겠습니다.

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>me.whiteship</groupId>
  <artifactId>masulsaJavaAgent</artifactId>
  <version>1.0-SNAPSHOT</version>

  <name>masulsaJavaAgent</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>

    <!-- https://mvnrepository.com/artifact/net.bytebuddy/byte-buddy -->
    <dependency>
      <groupId>net.bytebuddy</groupId>
      <artifactId>byte-buddy</artifactId>
      <version>1.12.8</version>
    </dependency>

  </dependencies>

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

pom.xml에 ByteBuddy Dependency를 추가합니다.

javaagent 방식 중 Premain 방식을 이용하려고 합니다.

Premain 방식은 main 메소드를 시작하기 전에 Premain 메소드가 먼저 호출되는 방식입니다.

javaagent는 jar로 파일 안에 manifest 속성을 추가해야 하므로

jar로 만들 때 manifest를 커스텀할 수 있게 plugin을 추가하였습니다.

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

manifest에 추가할 내용은 다음과 같습니다.

Premain-class : premain 메소드를 포함한 클래스 풀 패키지명을 기입합니다.

Can-Redefine-Classes : true 및 false 값을 기입할 수 있습니다.

Can-Retransform-Classes : true 및 false 값을 기입 할 수 있습니다.

Can-Redefine-Classes, Can-Retransform-Classes는 클래스를 바이트 코드로 재정의 및 재변환하기 때문에

둘 다 true로 기입합니다.

 

package me.whiteship;

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 {

    public static void premain(String agentAgrs, Instrumentation inst){
        new AgentBuilder.Default()
                .type(ElementMatchers.any())
                .transform((builder, typeDescription, classLoader, javaModule) ->
                                builder.method(named("pullOut")).intercept(FixedValue.value("Rabbit!")))
                .installOn(inst);
    }
}

premain 메서드를 가지고 있는 MasulsaAgent 생성합니다.

premain 메서드는 pullOut 메서드를 찾아 return 값을 "Rabbit!"으로 바꾸는 작업을 합니다.

 

jar 파일을 만들어 보겠습니다.

 

인텔리제이 상단 메뉴에서 View -> Tool Windows -> Maven 클릭하시고,

위 사진처럼 'M' 버튼 클릭 후 mvn clean package 작성 후 엔터를 칩니다.

 

package org.example;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.pool.TypePool;

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

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

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

이제 이전의 byteBuddy 실습 프로젝트의 Masulsa 클래스에서 byteBuddy 코드를 지우고 위 코드처럼 

System.out.println(new Moja().pullOut()); 만 남기게 합니다.

 

이전의 bytaeBuddy 실습 프로젝트에서 오른쪽 상단에 위 그림처럼 Edit Configurations 클릭합니다.

기본적으로 VM option 기입하는 곳이 없어 추가하였습니다.

VM option에 -javaagent: "jar파일의 경로"

작성 후 OK 버튼을 클릭하시면 됩니다.

이제 인텔리제이 상단 메뉴에서 View → Tool Windows → Maven 클릭 후

maven clean 한 후에 애플리케이션을 실행하면 “Rabbit!”이 출력됩니다.

target 폴더에 Moja.class 파일을 보면 아무것도 변경되지 않은 걸 확인할 수 있습니다.

이처럼 javaagent는 클래스 로더가 클래스를 읽어온 후

javaagent를 거쳐서 조작된 바이트 코드를 메모리(Runtime Data Area)의 메소드 영역에 저장하게 됩니다.

이렇게 기존 코드를 건드리지 않는 방식을 Transparent(비 침투적인)라고 합니다.

 

 

이로써 공부한 내용을 간략히 정리해보았습니다. 

감사합니다.


출처

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