JAVA

바이트코드 조작

경딩 2025. 3. 24. 16:30

스카우터는 어떻게 우리 코드를 '감시'할까?

"스카우터(Scouter)는 어떻게 애플리케이션의 XLog를 실시간으로 측정할까?"

 

APM 툴은 어떻게 내 코드를 건드리지 않고도 실행시간을 측정하고 성능을 모니터링할까?  코드 커버리지 도구는 어떻게 테스트가 어느 부분을 실행했는지 알아낼까?

 

답은 바이트코드 조작(Bytecode Manipulation)에 있다.  이는 자바 생태계의 가능 흥미로운 기능 중 하나로, 실제 코드를 수정하지 않고도 프로그램의 동작을 바꿀 수 있는 마법같은 기술이다.

 

https://github.com/hek316/java-meta-playground


바이트 코드 조작이란?

자바 코드는 컴파일되면 .class 파일이 된다. 이 파일안에는  JVM 이 이해할 수 있는 바이트코드가 들어있다.

바이트코드 조작은 이 중간 단계에서 코드를 변경하는 기술이다.

컴파일된 코드를 실행 직전에 몰래 바꿔치기 하는 것이다.

 

실생활 예시로 이해하기

카페에서 커피를 주문했다고 생각해보자. 바리스타(JVM)가 커피를 만들어 주는데, 중간에 누군가(바이트코드 조작 도구)가 몰래 설탕을 추가한다. 당신은 설탕을 요청하지 않았지만, 받은 커피는 달콤하다. 바로 이런 식으로 작동한다


실무에서 마주치는 바이트코드  조작

 

1.코드 커버리지 측정

테스트를 실행할 때 어떤 부분이 실행되었는지 추적하는 것은 바이트코드 조작의 대표적인 사례이다.

package hek;

public class Moim {

    int maxNumberOfAttendees;

    int numberOfEnrollment;

    public boolean isErollmentFull() {
        if(maxNumberOfAttendees == 0){
            return false;
        }
        if(numberOfEnrollment < maxNumberOfAttendees){
            return false;
        }
        return true;
    }
}
package hek;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class MoimTest {

    @Test
    public void isFull() {
        Moim moim = new Moim();
        moim.maxNumberOfAttendees = 100;
        moim.numberOfEnrollment = 10;
        Assertions.assertFalse(moim.isErollmentFull());
    }


}

 

커버리지 실행 후 보고서를 확인

커버리지 툴은 각 라인과 분기점에 "카운트 코드" 를 삽입하여 실행 결로를 추적한다.

녹색으로 표시되는 부분이 바로 이런 방식으로 감지된 실행 경로이다.

 

2.APM 도구의 성능 모니터링

스카우터, 핀포인트 같은 APM도구들은 메서드 실행 시간을 측정하기 위해서 메서드 시작과 끝에 시간 측정 코드를 자동으로 삽입한다.

이 모든 것이 원본 코드 수정 없이 이루어진다.

 

3.프레임워크의 마법

스프링의 AOP, 하이버네이트의 지연로딩등도 내부적으로 바이트코드 조작을 활용한다. 우리가 @Transactional 어노테이션만 붙여도 트랜잭션 처리가 되는 것도 이런 기술 덕분이다.

 

 


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

  • ASM - 베테랑 장인의 도구 
    • 특징: 가장 고전적이고 널리 사용되는 라이브러리
    • 장점: 성능이 뛰어나고 세밀한 제어 가능
    • 단점: 매우 어려움, 비지터 패턴과 어댑터 패턴 이해 필요
    • 적합한 경우: 고성능이 중요한 프레임워크 개발
  • Javasist - 중간 지점
    • 특징: ASM과 ByteBuddy의 중간 위치
    • 장점: ASM보다는 쉬움
    • 단점: 여전히 러닝 커브가 높음
  • ByteBuddy - 현대적 선택
    • 특징: 가장 최근에 나온 현대적 라이브러리
    • 장점: 가장 쉽고 직관적, 러닝 커브가 낮음
    • 단점: 메서드 체이닝이 길어져 가독성이 떨어질 수 있음
    • 적합한 경우: 빠른 프로토타이핑, 학습 목적

실습: 모자에서 토끼 꺼내기 마술

  • ByteBuddy를 사용해서 실제로 "마술"을 부려보자
  • 의존성 추가
	implementation 'net.bytebuddy:byte-buddy:1.14.9'
package hek;

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("/Users/hwang-eungyeong/IdeaProjects/java-meta-playground/target/classes/"));
        } catch (IOException e) {
            e.printStackTrace();
        }



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

 

첫번째 함정: 클래스 로딩 타이밍

위 코드를 실행하면 처음에는 원래 결과(바이트코드 조작 전) 가 나오고, 다시 실행해야 변경된 결과가 나온다.

왜 일까?

 

 JVM 클래스 로딩 시점과 재정의 순서

JVM 은 한번 로딩된 클래스는 다시 로딩하지 않습니다.

JVM은 클래스를 처음 사용할 때 단 한번만 로드하고, 그 이후에는 메모리에 상주시킨다.

즉, new ByteBuddy().redefine(Moja.class) 을 호출하는 순간  이미 Moja.class가 JVM에 로드되어 버렸기 때문에,

이후에, 바이트 코드를 변경해서 반영되지않고, JVM은 이미 로딩된 클래스를 사용합니다.

 

그래서 그 이후에는 바뀌값이 나오나요?

 

이후 실행은 JVM 재시작 후 실행된것일 경우

바이트코드가 변경된 Moja.class파일은 디스크에 저장만 되어 있습니다.

JVM을 재시작하면 이 덮어쓴 .class 파일이 로드되고, 그때부터 "Rabbit!"이 출력됩니다.


 

 

 

 

 

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

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

 

package hek;

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) throws InterruptedException {

        try {

            // 여기서 Moja.class를 직접 참조하는 순간,
            // JVM은 Moja 클래스를 즉시 로딩해서 메모리에 올립니다.
            // 이후 ByteBuddy가 Moja의 바이트코드를 변경하더라도,
            // 이미 JVM에 로드된 클래스는 다시 로드되지 않기 때문에 반영되지 않습니다.
            new ByteBuddy().redefine(Moja.class)
                            .method(named("pullOut")).intercept(FixedValue.value("Rabbit!"))
                            .make().saveIn(new File("/Users/hwang-eungyeong/IdeaProjects/java-meta-playground/target/classes/"));
        } catch (IOException e) {
            e.printStackTrace();
        }




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

 

 

문제점

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

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

// 여기서 Moja.class를 직접 참조하는 순간,
// JVM은 Moja 클래스를 즉시 로딩해서 메모리에 올립니다.
// 이후 ByteBuddy가 Moja의 바이트코드를 변경하더라도,
// 이미 JVM에 로드된 클래스는 다시 로드되지 않기 때문에 반영되지 않습니다.
new ByteBuddy().redefine(Moja.class)

 

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


해결 방법 1: TypePool을 활용한 우회

클래스를 직접 참조하지 않고 조작하는 방법이 있다.

 

 

package hek;

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 Masulsa2 {

    public  static  void  main(String[]  args) {

        ClassLoader classLoader = Masulsa2.class.getClassLoader();
        TypePool typePool = TypePool.Default.of(classLoader);
        try {

            new ByteBuddy().redefine(typePool.describe("hek.Moja").resolve() , ClassFileLocator.ForClassLoader.of(classLoader))
                            .method(named("pullOut")).intercept(FixedValue.value("Rabbit!"))
                            .make().saveIn(new File("/Users/hwang-eungyeong/IdeaProjects/java-meta-playground/target/classes/"));
        } catch (IOException e) {
            e.printStackTrace();
        }

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

 

 

TypePool typePool = TypePool.Default.of(classLoader);
typePool.describe("hek.Moja").resolve()

핵심 아이디어: TypePool 을 사용하면 클래스명으로만 참조하여 JVM이 해당 클래스를 즉시 로딩하지 않게 할 수 있다.

하지만 이 방법도 new Moja() 를 호출하는 순간 여전히 위험하다.

 

주의사항: 이 방법도 완벽하지 않다. new Moja()를 호출하는 시점에 이미 JVM이 기본 ClassLoader로 클래스를 로드했다면 여전히 실패할 수 있다.

 

 

결과

바이트코드 조작이 JVM 클래스 로딩 이전에 성공적으로 반영된다.

 

 

요약

내용 설명
TypePool 사용 클래스 직접 참조 없이 바이트코드 조작 가능
ClassFileLocator 실제 .class 파일 위치 지정 가능
기대 효과 JVM이 클래스를 로드하기 전에 바이트코드를 바꿔놓을 수 있음
위험 요소 new Moja() 호출 시점에 이미 JVM이 Moja를 로드했을 경우 효과 없음

 

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


Java Agent: 진짜 마법의 시작

실제 운영 환경에서 바이트코드 조작이 어떻게 작동하는지 알아보자.

Java Agent를 사용하면 클래스 로딩 순서에 상관없이 바이트코드를 전처리 할 수 있다.

 

Agent 구현

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

package hello.masulsaagent;

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 agentArgs, Instrumentation inst) {
        new AgentBuilder.Default()
                .type(ElementMatchers.any())
                .transform((builder, typeDescription, classLoader, javaModule, protectionDomain) ->
                        builder.method(named("pullOut")).intercept(FixedValue.value("Rabbit!"))).installOn(inst);
    }
}

 

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

 

Javaagent 붙여서 사용하기

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

premain() 함수 작성하기

 

  • jar manifest 설정
// jar 파일의 manifest 속성 변경
jar {
    manifest {
        attributes(
                'Premain-Class': 'hello.masulsaagent.MasulsaAgent',
                'Main-Class': 'hello.masulsaagent.MasulsaAgent',
                'Can-Redefine-Classes': 'true',
                'Can-Retransform-Classes': 'true'
        )
    }
}

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

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

 

생성된 MANIFEST.MF 파일:

Manifest-Version: 1.0
Premain-Class: hello.masulsaagent.MasulsaAgent
Main-Class: hello.masulsaagent.MasulsaAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true

Premain-Class (Java Agent용):

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

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

-javaagent:/Path/MasulsaAgent/build/libs/MasulsaAgent-0.0.1-SNAPSHOT.jar

 

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

 

 

 

 

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

읽어 올떄 바뀌었다.

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

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

 

바이트코드 조작의 핵심 원리

Java Agent 의 진짜 마법은 클래스 로딩 시점에 개입한다는 것이다.

  • 클래스 로딩 가로채기 : JVM이 클래스를 로딩할 때 Agent가 바이트코드를 가로챈다.
  • 바이트 코드 변환 : 메모리에 로딩되기 전에 바이트 코들르 수정한다.
  • 투명한 실행: 원본 파일은 그대로 두고 메모리상에서만 변경된 코드가 실행된다.

이것이 바로 비침투적 방식이다. 원본 코드를 전혀 건드리지 않고도 동작을 변경할 수 있다.

 

클래스 로딩 과정 상세 분석

1. JVM 시작 → Java Agent의 premain() 실행
2. 애플리케이션 클래스 로딩 시도
3. Agent가 바이트코드 가로채기
4. 바이트코드 변환 (메서드 내용 변경)
5. 변환된 바이트코드를 JVM 메모리에 로딩
6. 실행 시 변경된 코드가 동작

 

중요한 : 실제 디스크의 .class 파일은 변경되지 않는다. 오직 메모리 상에서만 변경된 바이트 코드가 존재한다.

 

바이트코드 조작이라는 마법을 통해, 우리는 코드를 건드리지 않고도 소프트웨어의 동작을 제어할 수 있는 강력한 도구를 손에 넣게 됩니다.

 

참고 자료 :

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