BackEnd/Java

[Java] 바이트코드 조작하기

샤아이인 2022. 5. 20.

 

소스코드의 커버리지를 측정하는, 가령 Jacoco와 같은 tool들이 바이트코드를 이용하여 측정한다.

이러한 소스코드 커버리지는 내 전체 소스코드에서 얼만큼을 테스트로 커버 했는지? 를 알려주는 유용한 도구이다.

 

이번 시간에는 이러한 바이트코드를 직접 조작하는 방법에 대하여 공부하는 글이다!

 

1. Jacoco를 통한 코드 커버리지 측정

우선 간단하게 Jacoco를 사용하는 것부터 확인해보자.

 

테스트 해볼 간단한 코드는 다음과 같다.

public class MeetUp {
    private int maxNumberOfAttendees;
    private int numberOfEnrollment;

    public void addPerson() {
        numberOfEnrollment++;
    }

    public int getEnrollmentNumber() {
        return numberOfEnrollment;
    }

    public boolean isFull() {
        if(maxNumberOfAttendees == 0) {
            return false;
        }
        if(maxNumberOfAttendees > numberOfEnrollment) {
            return false;
        }
        return true;
    }
}

이를 테스트해보는 코드는 다음고 같다.

@Test
public void full_test() {
    // given
    MeetUp meetUp = new MeetUp(3); // 최대 방문 인원

    // when
    meetUp.addPerson();
    meetUp.addPerson();
    meetUp.addPerson();

    // then
    assertThat(meetUp.isFull()).isTrue();
}

이를 jacoco로 코드 커버리지를 확인해보니 다음과 같다.

결과를 확인해보면 다음과 같다.

bytecode 패키지의 코드 커버리지가 76% 정도로 확인되었다. 어디서 나머지 부분을 커버하지 못한것 일까?

확인해보니 isFull() 과 getEnrillmentNumber() 에서 테스트 코드의 커버가 부족했다.

isFull()에서 세부적으로 어떤 부분이 부족한것 일까? 해당 함수 이름을 눌러 들어가보면 다음과 같다.

빨간 부분으로 된 부분이 검증하지 못한 부분이다.

초록 부분은 검증이 완료된 부분이다.

노란색은 일부분만 검증 된 부분이다. 예를 들어 if 문 안에 참인 조건만 검증한 경우, 즉 false인 경우를 검증하지 못한경우 노란색으로 보인다.

이런점까지 확인이 가능하다.

 

이러한 기능은 어떻게 사용하는 것 일까? 답은 바이트 코드 조작에 달려있다.

이에 대하여 좀더 알아보자.

 

2. 모자에서 토끼를 꺼내는 마술

예를 들어 Moja 라는 class가 다음과 같이 있다고 해보자.

public class Moja {

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

위 코드와 같이 ""을 반환하는 함수를 하나 가지고 있다.

 

이를 다음고 같이 Main 클래스에서 출력할 때, "Rabbit"이 출력되도록 하고싶다!

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

 

이를 바이트코드를 조작하여 해결해보자.

 

대표적으로 ByteBuddy 라는 도구가 있다. 이를 사용해보자.

 

Byte Buddy - runtime code generation for the Java virtual machine

 

bytebuddy.net

 

다음과 같이 Main class 안에서 ByteCode를 조작하는 코드를 ByteBuddy를 통해 작성했다.

public class Main {

    public static void main(String[] args) {
        try {
            new ByteBuddy().redefine(Moja.class)
                    .method(named("pullOut"))
                    .intercept(FixedValue.value("Rabbit"))
                    .make()
                    .saveIn(new File("/Users/jiwookim/study/thejava/build/classes/java/main/"));
        } catch (IOException e) {
            e.printStackTrace();
        }

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

Moja().pullOut()을 실행하기 전에 사전에 미리 ByteCode 조작 코드를 실행해야 한다.

클래스로더가 동적으로 먼저 class를 로딩해버리기 때문에 동시에 실행하면 안된다.

 

바이트버디 코드와 System.out.println(new Moja().pullOut()); 는 Main class를 실행하면 Main.class, Moja.class로 컴파일되고 그 클래스 파일들이 로딩되고 실행되기 때문에, 그 이후에 바이트버디를 통해서 class 파일을 조작해도 이미 로딩된 클래스 정보 (메소드 영역에 있는) 를 보고 있기 때문에  적용이 안되는 것 이다.

 

먼저 바이트 코드를 확인해보면 다음과 같이 변경되어있다. 

public class Moja {
    public Moja() {
    }

    public String pullOut() {
        return "Rabbit";
    }
}

하지만 소스코드에 가보면 여전히 소스코드에는 "" 을 출력하고 있다.

 

이제 출력해보자!

public class Main {

    public static void main(String[] args) {
//        try {
//            new ByteBuddy().redefine(Moja.class)
//                    .method(named("pullOut"))
//                    .intercept(FixedValue.value("Rabbit"))
//                    .make()
//                    .saveIn(new File("/Users/jiwookim/study/thejava/build/classes/java/main/"));
//        } catch (IOException e) {
//            e.printStackTrace();
//        }

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

원하던 것처럼 "Rabbit"이 출력되는것을 확인할 수 있다.

하지만 이처럼 코드로 사전에 먼저 바이트코드를 조작하고 난후, 다시 실행하는것이 귀찮다. 이를 해결해보자.

 

jacoco만 해도 우리가 뭔가 사전에 한번 실행을 한후, 다시 test를 실행한적이 없다.

그냥 test를 실행하니까 코드 커버리지까지 한번에 측정되었다.

이를 다음 글에서 알아보자.

 

3. javaagent 실습

우선 javaagent를 사용하지 않는 방식으로 해결해 보자.

이번에는 다음과 같이 코드를 작성해보자.

public class Magic {

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

        try {
            new ByteBuddy().redefine(
                            typePool.describe("bytecode.Moja").resolve(),
                            ClassFileLocator.ForClassLoader.of(classLoader))
                    .method(named("pullOut"))
                    .intercept(FixedValue.value("Rabbit"))
                    .make()
                    .saveIn(new File("/Users/jiwookim/study/thejava/build/classes/"));
        } catch (IOException e) {
            e.printStackTrace();
        }

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

한번의 실행으로 "Rabbit"을 출력할수 있게 되었다.

클래스 로딩 순서에 매우 의존적이기 때문에 다른 코드 부분에서 미리 Moja를 읽었으면 동작하지 않게된다.

 

javaagent를 이용하는 방법도 있는데, 이 방식을 class를 로딩할 때 javaagent를 거쳐 변경된 바이트코드 자체를 읽어오기 때문에 메모리 내부에서는 바뀌어 있다.

 

4. 바이트코드 조작을 활용하는 곳

이러한 기술을 활용하는 대표적인 예시로, Srping 의 component scan기능이 있다.

ClassPathScanningCandidateComponentProvider 이라는것을 활용한다. 내부에 들어가보면 다음과 같은 설명이 있다.

중요한 메서드로 내부에 addCandidateComponentsFromIndex 라는 메서드가 있다.

private Set<BeanDefinition> addCandidateComponentsFromIndex(CandidateComponentsIndex index, String basePackage) {
    Set<BeanDefinition> candidates = new LinkedHashSet<>();
    try {
        // 생략...
        for (String type : types) {
            // 메타 데이터를 읽는다.
            MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(type); 
            // 생략...
        }
    }
    catch (IOException ex) {
        throw new BeanDefinitionStoreException("I/O failure during classpath scanning", ex);
    }
    return candidates;
}

해당 메서드를 통해 meta-date를 getMetadataReader를 통해 읽어온다.

이때 사용하는 구현체로 SimpleMetadataReader 가 있다.

 

SimpleMetadataReader에서 ClassReaderVisitor 사용해서 클래스에 있는 메타 정보를 읽어온다.

바로 ClassReader가 asm으로 구현된 바이트 코드를 조작하는 기술을 사용한다.

 

'BackEnd > Java' 카테고리의 다른 글

[Java] Dynamic Proxy  (0) 2022.06.07
[Java] Reflection  (0) 2022.05.22
[Java] JVM 구조  (0) 2022.05.19
[Java] equals, hashCode 를 같이 구현하는 이유  (0) 2022.05.08
[Java] Java 에서의 Thread, Light Weight Process  (0) 2022.03.30

댓글