[Java] 바이트코드 조작
코드 커버리지 측정
코드 커버리지는 테스트 코드가 프로덕션 코드를 얼마나 실행했는지를 백분율로 나타내는 지표이다.
즉, 테스트 코드가 실제로 프로덕션 코드를 얼마나 검증하고 있는지를 나타낸다.
코드 커버리지를 통해 현재 작성된 테스트 코드의 수가 충분한 것인지 논의할 수 있다.
그럼 코드 커버리지 툴을 사용해서 코드 커버리지를 측정해보자.
대표적으로 JaCoCo가 있다. 여기선 JaCoCo를 통해 측정해보도록 하자.
Jacoco
먼저 JaCoCo 플러그인을 추가하고 task를 설정해보자.
plugins {
id 'jacoco'
}
jacoco {
toolVersion = '0.8.5'
}
JaCoCo Gradle 플러그인에는 다음과 같은 task가 있다.
- jacocoTestReport: 바이너리 커버리지 결과를 사람이 읽기 좋은 형태의 리포트로 저장한다. html 파일로 생성해 사람이 쉽게 눈으로 확인할 수도 있고 xml, csv 같은 형태로도 리포트를 생성할 수 있다.
- jacocoTestCoverageVerification: 내가 원하는 커버리지 기준을 만족하는지 확인해주는 task이다. 예를 들어, 브랜치 커버리지를 최소 90% 이상으로 유지하고 싶다면 여기에 설정한다. test의 task 처럼 Gradle build의 success/fail로 결과를 보여준다.
jacocoTestReport {
reports {
html.enabled true
xml.enabled false
csv.enabled false
// html.destination file("$buildDir/jacocoHtml")
// xml.destination file("$buildDir/jacoco.xml")
}
}
jacocoTestCoverageVerification {
violationRules {
rule {
element = 'CLASS'
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.90
}
}
}
}
이제 코드를 작성해볼 차례다. 그 전에 추가해야할 것이 하나 더 있다.
여기선 테스트 코드를 JUnit5로 작성했기 때문에 테스트 시 JUnit이 함께 동작할 수 있도록 다음을 추가해준다. 다음 설정은 test의 task에서는 JUnit을 사용한다고 Gradle에게 알려주는 것이다. 이를 설정해주지 않으면 jacocoTestReport task와 jacocoTestCoverageVerification task를 스킵하게 되어 테스트가 돌지 않는다.
test {
useJUnitPlatform()
}
이제 테스트할 코드를 임의로 작성해보자.
public class Loading {
private int currentPercent;
private int maxPercent;
public Loading(int currentPercent, int maxPercent) {
this.currentPercent = currentPercent;
this.maxPercent = maxPercent;
}
public boolean isFull() {
if (maxPercent == 0) {
return false;
}
if (currentPercent < maxPercent) {
return false;
}
return true;
}
}
그 다음 위 코드를 테스트할 테스트 코드를 작성해보자.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class LoadingTest {
@Test
public void isNotFull() {
Loading loading = new Loading(0, 10);
Assertions.assertFalse(loading.isFull());
}
}
작성이 완료됐으면, jacocoTestReport와 jacocoTestCoverageVerification을 실행하도록 다음과 같이 빌드를 수행해보자.
./gradlew test jacocoTestReport jacocoTestCoverageVerification
만약 task 실행 상황을 보고 싶다면 --console verbose 옵션을 추가하여 다음과 같이 작성한다.
./gradlew --console verbose test jacocoTestReport jacocoTestCoverageVerification
여기서 빌드가 실패했다면 jacocoTestCoverageVerification을 다시 살펴보자.
커버리지가 최소 기준인 minimum=0.90, 즉 90%를 못넘었기 때문에 실패한 것이다.
여기서 minimum을 지우고 다시 실행시켜보자.
실행 후 build/reports/jacoco/test/html 경로로 들어가보면 index.html 파일이 있을 것이다.
이 파일을 다음과 같이 실행시켜보자.
그러면 다음과 같이 각 커버리지 항목마다 총 개수와 놓친 개수를 표시해준다.
이렇게 코드 커버리지를 측정하는 원리가 무엇일까?
이는 바이트 조작과 관련이 있는데, 그 동작 원리를 한 번 알아보자.
바이트 코드 조작
public class Member {
public String getName() {
return "";
}
}
public class App {
public static void main(String[] args) {
System.out.println(new Member().getName());
}
}
위 코드는 원래 실행결과라면 아무것도 출력이 안될 것이다. (정확히 말하면 개행만 될 것이다)
그런데 이런 출력 결과가 나오게 할 수 있다.
MyName
이제부터 바이트코드 조작을 통해 이를 알아보자.
먼저 바이트코드 조작을 위해 라이브러리를 설치하자.
바이트코드 관련 라이브러리는 여러가지가 있다. (ASM, Javassist, ByteBuddy 등)
우리는 이 중에서 ByteBuddy를 사용해볼 것이다.
먼저 다음을 build.gradle에 추가하자 (maven은 다음을 참고하면 된다 [mvnrepository])
dependencies {
implementation 'net.bytebuddy:byte-buddy:1.11.22'
}
그리고 main 메서드에 다음과 같이 적고 실행시켜보자.
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 App {
public static void main(String[] args) throws IOException {
new ByteBuddy().redefine(Member.class)
.method(named("getName")).intercept(FixedValue.value("MyName"))
.make().saveIn(new File(new File("").getAbsolutePath() + "/build/classes/java/main/"));
}
}
실행을 완료했다면 다시 원래 코드를 실행시켜보자.
public class App {
public static void main(String[] args) {
System.out.println(new Member().getName());
}
}
그럼 MyName이 출력되는 것을 확인할 수 있다.
맞다, 위에서 그런 출력결과가 나왔던 이유는 바로 바이트코드에 있다
저 위에서 경로로 지정한 build 패키지는 클래스 파일들이 있는 곳이다. 한 번 들어가서 살펴보자.
들어가서 보면 다음과 같이 "MyName"이 들어있는 것을 확인할 수 있다.
원래는 바이트코드 형태이지만 ide에서 자동으로 decompile하여 보여주는 것이다.
public class Member {
public Member() {
}
public String getName() {
return "MyName";
}
}
이렇게 바이트코드 조작을 수행하면 여러 동작을 수행할 수 있다.
JavaAgent
그럼 이제는 원래 코드를 재실행하는 것이 아니라, print만 했는데 MyName이 나오도록 구현해보자.
먼저 새 프로젝트를 생성하여 Agent 클래스를 하나 만들어 다음과 같이 premain(String agentArgs, Instrumentation inst) 형식으로 메서드를 생성하고 코드를 작성해보자.
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 AppAgent {
public static void premain(String agentArgs, Instrumentation inst) {
new AgentBuilder.Default()
.type(ElementMatchers.any())
.transform((builder, typeDescription, classLoader, javaModule) ->
builder.method(named("getName")).intercept(FixedValue.value("MyName")))
.installOn(inst);
}
}
위 코드는 전과 같이 getName 메서드를 찾아 고정 값을 MyName으로 바꾸는 수행을 inst에 적용하는 로직이다.
작성을 다 했으면 다음을 build.gradle에 추가해준다.
targetCompatibility = 11
sourceCompatibility = 11
jar {
manifest {
attributes(
"mode": "development",
"Premain-Class": "AppAgent",
"Can-Redefine-Classes": true,
"Can-Retransform-Classes": true
)
}
}
위 내용은 jar을 생성할 때 manifest를 조작하여 값을 추가하도록 한다.
여기서 Premain-Class값에 방금 작성한 클래스명을 적어주었다. (만약 패키지 안에 있다면 패키지 명도 같이 적어준다 ex -> org.example.AppAgent)
그리고 다음을 실행시켜 빌드를 하면 ./build/libs 에 jar파일이 생성됐을 것이다.
./gradlew clean build
그럼 이제 원래 프로젝트로 돌아가서 main 메서드가 있는 Configurations에 들어가서 agent를 설정해보자.
위와 같이 Edit으로 들어가게 되면 다음과 같이 창이 뜨는데, 여기서 오른쪽 부분에 Modify options를 클릭하여 VM options를 추가해준다.
그럼 작성할 수 있는 칸이 생기는데 여기에 javaagent를 다음과 같이 추가해 주어야 한다.
-javaagent:/Users/seowonho/GradleAgent/build/libs/GradleAgent-1.0-SNAPSHOT.jar
나와 경로 명과 jar파일명이 다를 수 있으므로, 주의해서 작성하도록 한다. (파일 경로 복사를 이용하면 편하다)
그렇게 해서 App 클래스로 돌아가 print만 해보자.
public class App {
public static void main(String[] args) throws IOException {
System.out.println(new Member().getName());
}
}
분명 바이트코드를 조작하지 않았음에도 MyName이 나오는 결과를 확인할 수 있다.
물론, 프로젝트 내 Member 클래스 파일도 return "MyName"; 으로 변경되지 않았다.
javaagent는 위에서 클래스 파일이 변경된 것과 달리, JVM의 클래스 로더가 클래스를 로딩할 때 javaagent를 거쳐서 변경된 바이트코드를 읽어들인다. 때문에 프로젝트 내 클래스 파일이 변경되지 않은 것이다.
마무리
바이트 코드 조작에 대해 알아보았다. 바이트 코드 조작을 통해 많은 일을 할 수 있다.
그리고 여러 라이브러리 및 프레임워크 동작 원리에 대해서도 이해하는 데 좀 도움이 될 것이다.
위 내용은 다음 강의를 참고하여 작성했습니다
https://www.inflearn.com/course/the-java-code-manipulation/dashboard
'Language > Java' 카테고리의 다른 글
[Java] 람다 표현식 소개 (0) | 2023.03.31 |
---|---|
[Java] 동작 파라미터화 코드 전달하기 (0) | 2023.03.28 |
[Java] JVM (0) | 2023.03.20 |
[Java] "" vs new String("") (2) | 2023.03.19 |
[Java] 아무 생각 없이 생성했는데 동일한 객체? (0) | 2023.03.09 |
댓글
이 글 공유하기
다른 글
-
[Java] 람다 표현식 소개
[Java] 람다 표현식 소개
2023.03.31 -
[Java] 동작 파라미터화 코드 전달하기
[Java] 동작 파라미터화 코드 전달하기
2023.03.28 -
[Java] JVM
[Java] JVM
2023.03.20 -
[Java] "" vs new String("")
[Java] "" vs new String("")
2023.03.19