코드 커버리지는 어떻게 측정할까?
JaCoCo(Java Code Coverage)
Java 애플리케이션에서 테스트 커버리지를 측정하는 오픈 소스 라이브러리이다.
주로 단위 테스트, 통합 테스트 등을 실행할 때 각 코드가 얼마나 실행되었는지(커버리지)를 확인하기 위해 사용된다.
JaCoCo는 Maven, Gradle 같은 빌드 도구와 쉽게 통합할 수 있어, 테스트를 실행할 때 자동으로 코드 커버리지 리포트를 생성한다. 이 리포트는 HTML, XML, CSV 등의 다양한 포맷으로 제공되며, 이를 통해 코드가 충분히 테스트되고 있는지 파악하고, 테스트를 추가해야 할 부분을 쉽게 찾을 수 있다.
JaCoCo 문서
간단하게 테스트 커버리지 확인하기
// 애플리케이션 코드
public class Moim {
int maxNumberOfAttendees; // 최대 참가자
int numberOfEnrollment; // 현재 신청자
public boolean isEnrollmentFull(){
if(maxNumberOfAttendees == 0) { // 참가 무한대로 가능
return false;
}
if(numberOfEnrollment < maxNumberOfAttendees) {
return false;
}
return true;
}
}
// 테스트 코드
public class MoimTest {
@Test
public void isFull(){
Moim moim = new Moim();
moim.maxNumberOfAttendees = 100;
moim.numberOfEnrollment = 10;
assertFalse(moim.isEnrollmentFull());
}
}
pom.xml
<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>org.example</groupId>
<artifactId>Java</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Java</name>
<url>http://maven.apache.org</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>RELEASE</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</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>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
<version>3.9.1</version>
</plugin>
</plugins>
</build>
</project>
build plugin에 JaCoCo 추가
mvn clean verify
mvn clean verify: Maven 빌드 도구 명령어
clean
프로젝트의 이전 빌드 결과물 제거
target/ 디렉토리와 같은 Maven 빌드 디렉토리를 삭제하여 이전 빌드에서 생성된 파일들이 남아 있지 않도록 한다.
verify
Maven의 빌드 라이프사이클 단계 중 하나로, 프로젝트를 빌드하고, 필요한 테스트 및 품질 검사(예: 테스트 커버리지 검사, 코드 품질 검사 등)를 실행하여 빌드가 정상적으로 완료되었는지 검증한다.
compile, test, package 등 이전 단계를 모두 실행한 후, 추가적인 검증 절차를 수행한다.
JaCoCo 같은 코드 커버리지 도구가 설정되어 있으면 코드 커버리지 검사를 수행
mvn clean verify의 수행 절차
- clean: 이전 빌드 파일 삭제.
- compile: 프로젝트 소스 코드 컴파일.
- test: 단위 테스트 실행.
- package: JAR, WAR 같은 패키지 생성.
- verify: 추가적인 검증(테스트 커버리지 검사, 품질 검사 등).
내가 참고한 maven 윈도우 10 설치 방법
빌드 결과물
빌드 결과물에 보면 index.html이 생긴 걸 확인할 수 있다. (target/site/jacoco/index.html)
→ index.html run
그럼 브라우저에서 테스트 커버리지를 볼 수 있다.
JaCoCo에서 코드 커버리지 리포트를 보면, 색상으로 각 코드 블록이나 분기가 얼마나 테스트되었는지를 시각적으로 나타낸다.
- 노란색: 분기(조건문) 중 일부만 테스트되었음을 의미한다.
예를 들어, if-else 문에서 if 조건만 테스트되고 else 조건은 테스트되지 않은 경우 노란색으로 표시된다.
즉, 분기 조건의 절반만 테스트된 상태이다. - 빨간색: 해당 코드 또는 분기가 전혀 테스트되지 않았음을 의미한다.
이 부분은 테스트에서 한 번도 실행되지 않았으므로, 추가적인 테스트가 필요하다. - 초록색: 해당 코드 블록 또는 분기가 완전히 테스트되었음을 의미한다.
모든 조건과 분기가 테스트되어 정상적으로 통과되었을 때 초록색으로 표시된다.
어떻게 알았을까? 어떻게 이런 툴을 만들 수 있었을까? → 바이트 코드 조작
컴파일
Java 소스 코드를 컴파일하면 Java 바이트 코드(.class)파일이 생성된다.
바이트 코드 조작
JaCoCo는 이 컴파일된 바이트코드를 동적으로 수정한다.
이를 통해 각 코드 블록(메소드, 분기)에 대한 실행 정보를 수집할 수 있도록 한다.
테스트 실행
테스트를 실행하면 수정된 바이트 코드를 포함한 애플리케이션이 테스트를 실행할 때
JaCoCo는 각 코드 블록이 실행될 때마다 카운트한다.
각 분기(if, switch...)가 실행되면, 해당 카운트를 증가시킨다.
커버리지 리포트 생성
테스트가 완료되면, JaCoCo는 수집된 카운트 데이터를 기반으로 커러리지 매트릭을 계산한다.
커버리지 만족 못할시 빌드 실패하도록 설정
pom.xml <execution> Maven 플러그인의 실행을 정의) 부분에 특정 %를 넘기지 못하면 빌드가 실패하도록 설정할 수 있다.
<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.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
현재 나의 설정은 성공 커버리지가 80퍼를 넘기지 못하면 빌드 실패하도록 설정
테스트 코드는 그대로 유지했으니 성공 커버리지는 76퍼 그대로일 것이다. (빌드 실패를 기대)
모자에서 토끼를 꺼내는 마술 (바이트 코드 조작 실습)
public class Moja {
public String pullOut() {
return "";
}
}
public class Masulsa {
public static void main(String[] args) {
System.out.println(new Moja().pullOut());
}
}
// 출력 x
현재 Moja 클래스의 pullOut() 메소드를 보면 그냥 빈 문자열을 반환한다.
이때 Moja 객체를 생성하고 pullOut() 메소드를 호출했을 때 "Rabbit" 문자열이 나오게 하려면 어떻게 해야 할까?
바이트코드 조작 라이브러리
- ASM: https://asm.ow2.io/
- Javassist: https://www.javassist.org/
- ByteBuddy: https://bytebuddy.net/#/
Maven Repository ByteBuddy 링크
https://mvnrepository.com/artifact/net.bytebuddy/byte-buddy
pom.xml ByteBuddy dependency 추가
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.15.3</version>
</dependency>
ByteBuddy API 활용 바이트 코드 조작 예제
import java.io.File;
import java.io.IOException;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.implementation.FixedValue;
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("C:/Study/WhiteShipLecture/Java/target/classes/"));
}
catch (IOException e){
e.printStackTrace();
}
// System.out.println(new Moja().pullOut());
}
}
Moja.class 재정의
method 중에 pullOut 메소드 이름을 가지는 반환값을 "Rabbit" 으로 고정
그리고 소스파일이 컴파일(.class)된 후 저장되는 target/classes 위치에 Moja.class 저장한다.
// src/main/java - 소스 파일
package org.example;
public class Moja {
public String pullOut(){
return "";
}
}
// target/classses - 컴파일 파일
package org.example;
public class Moja {
public Moja() {
}
public String pullOut() {
return "Rabbit!";
}
}
그리고 다시 주석을 풀고 출력하면 "Rabbit"이 나온다.
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("C:/Study/WhiteShipLecture/Java/target/classes/"));
}
catch (IOException e){
e.printStackTrace();
}
System.out.println(new Moja().pullOut());
}
}
참고로 mvn clean 이후 다시 Masulsa 클래스를 run 하면 빈 문자열이 나온다.
이 코드를 동시에 쓸 수 없는 이유는 Masulsa를 실행하면 Masulsa.class, Moja.class로 컴파일 되고,
그 클래스 파일들이 클래스 로더를 통해서 로딩되고 실행되기 때문에 그 이후에 바이트 버디를 통해서 class 파일을 조작해도 이미 로딩된 클래스 정보(메타 스페이스)를 보기 때문에 적용이 안된다.
즉 재정의 한다고 해서 이미 로딩된 메타 정보가 바뀌는 건 아니다.
하지만 재실행(mvn clean x)을 하면 "Rabbit"이 출력된다.
clean 하지 않고 재실행을 하였으니 클래스 로더는 수정된 클래스 파일을 로딩(메타 정보)하기 때문에 "Rabbit"이 문자열이 출력
Moja.class 클래스를 로드하는 시점
new ByteBuddy().redefine(Moja.class) // <- 여기서 클래스 로더가 로딩한다.
javaagent 실습
import java.io.File;
import java.io.IOException;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.pool.TypePool;
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("C:/Study/WhiteShipLecture/Java/target/classes/"));
}
catch (IOException e){
e.printStackTrace();
}
System.out.println(new Moja().pullOut());
}
}
현재 코드는 mvn clean 이후 바로 실행해도 "Rabbit"이 호출된다.
Moja.class를 호출하기 이전에 조작했기 때문이다.
하지만 애플리케이션 내에서 해당 클래스가 아니라 다른 클래스에서 Moja.class를 최초 컴파일하고 로딩 했다면
이미 로딩되었기 때문에 Masulsa.class에서의 Moja.class 바이트 조작은 의미가 없어진다.
즉 해당 방식은 순서에 의존적인 문제가 존재한다.
이런 문제를 JavaAgent를 사용해서 해결해 보겠다.
JavaAgent
Java Agents는 JVM에서 실행되는 애플리케이션의 바이트 코드를 동적으로 수정하거나, 애플리케이션의 실행 전에 특정 작업을 수행할 수 있는 기능을 제공한다.
Java Agent를 사용하면 애플리케이션 코드가 실행되기 전에 클래스의 바이트 코드를 조작할 수 있어, 클래스 로딩 순서에 의존하지 않고 수정할 수 있는 장점이 있다.
이런 JavaAgent의 특징을 Transparent(투명하다)고 표현할 수 있다.
Java Agent가 기존 애플리케이션 코드나 동작 방식에 관여하지 않고, 바이트 코드를 수정하거나 추가적인 기능을 삽입하는 방식으로 동적하기 때문이다.
Javaagent JAR 파일 만들기
https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html
- 붙이는 방식은 시작시 붙이는 방식 premain과 런타임 중에 동적으로 붙이는 방식 agentmain이 있다.
- Instrumentation을 사용한다.
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);
}
}
ByteBuddy 사용
pom.xml
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
<dependency> <!-- byte-buddy -->
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.15.3</version>
</dependency>
</dependencies>
<! -- Manifest Jar Plugin -->
<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>org.example.MasulsaAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
이제 JavaAgent JAR 파일을 만든다.
mvn clean package
생성된 jar 파일 경로를 복사한다.
기존 프로젝트 실행
public class Masulsa {
public static void main(String[] args) {
System.out.println(new Moja().pullOut());
}
}
당연히 빈 문자열이 출력
edit Congiuration → vm option 아래 추가 (JavaAgent JAR파일 경로)
-javaagent:C:\Study\WhiteShipLecture\JavaAgent\target\JavaAgent-1.0-SNAPSHOT.jar
다시 실행하면 Rabbit이 나온다.
코드를 확인하면 소스 코드 파일 / .class 파일 모두 코드가 그대로이다.
public class Moja {
public Moja() {
}
public String pullOut() {
return "";
}
}
어떻게 한걸까?
해당 방식은 파일 자체를 변경하는게 아니다.
JavaAgent는 클래스 로딩 시점에 Instrumentation API( (java.lang.instrument.Instrumentation)를 통해 해당 클래스의 바이트 코드를 수정한다.
기존 클래스 파일 자체가 바뀌는 게 아니지만 JavaAgent에서 변경된 바이트 코드를 로딩하기 때문에
애플리케이션 (소스 코드/ 컴파일 된 .cass) 모두 그대로이다. → Transparent
즉 JavaAgent를 통해서 Application 코드는 전혀 수정하지 않고 메모리 상의 바이트 코드만 조작할 수 있었다.
바이트코드 조작 정리
애플리케이션에서 바이트 코드 조작 기술은 다양한 용도로 사용된다.
이 기술을 통해 소스 코드를 건드리지 않고도 프로그램의 동작을 변경하거나 분석할 수 있다.
바이트 코드 조작의 주요 용도
- 프로그램 분석: 프로그램 실행 중 바이트 코드를 분석하여 성능 이슈를 찾거나 프로그램의 흐름을 추적한다.
- 버그 찾는 툴: 코드에서 발생할 수 있는 버그를 사전에 감지할 수 있는 정적 분석 도구로 바이트 코드 검사를 수행한다
- 코드 복잡도 계산: 바이트 코드에서 메서드 호출, 조건 분기 등을 분석해 코드 복잡도를 측정하는데 사용된다
- 프록시: 특정 메서드 호출 전후에 동작을 추가할 수 있도록 프록시 패턴을 구현하는데 활용되며, 대표적으로 Spring AOP가 있다.
- 특정 API 호출 접근 제한: 바이트 코드를 조작하여 특정 API 호출에 대한 접근을 제어할 수 있다.
- 컴파일러: 스칼라와 같은 언어는 자바와 비슷한 JVM에서 실행되기 때문에 바이트 코드 조작을 통해 해당 언어를 JVM이 이해할 수 있도록 컴파일한다.
- 프로파일러 (예: NewRelic): 메서드 호출, 성능 데이터, 리소스 사용량 등을 모니터링하는 데 바이트 코드 조작을 활용하여 실행 정보를 수집한다.
- 최적화 및 로깅: 코드에 추가적인 로직을 넣어 성능을 최적화하거나, 로깅을 삽입하는 데 사용한다.
바이트 코드 조작 도구
ASM, Javassist, ByteBuddy, CGLIB
Spring의 컴포넌트 스캔과 바이트 코드 분석
- Spring에서는 컴포넌트 스캔을 통해 애플리케이션에서 빈으로 등록할 클래스들을 자동으로 찾아준다.
- ClassPathScanningCandidateComponentProvider는 해당 기능을 수행하는 주요 클래스.
이 클래스는 ClassReader와 Visitor 패턴을 사용해 클래스 파일을 읽어와, 바이트 코드 내에서 애노테이션과 같은 메타데이터를 분석한다. → 프로그램 분석 - SimpleMetadataReader는 클래스 파일을 스캔하며 메타 정보를 추출한다.
이 과정에서 ASM을 사용하여 클래스의 내부 구조를 분석하고 필요한 정보를 가져온다.- 예를 들어, @Component 애노테이션이 달린 클래스를 찾아 빈으로 등록한다.
이 과정에서 ASM이 클래스의 메타데이터를 빠르게 분석하는데 도움을 준다.
- 예를 들어, @Component 애노테이션이 달린 클래스를 찾아 빈으로 등록한다.
참고 자료
인프런 백기선님 더 자바, 코드를 조작하는 다양한 방법
'Computer Sience > Java' 카테고리의 다른 글
[JAVA] 다이나믹 프록시 (5) | 2024.10.16 |
---|---|
[JAVA] Reflection (0) | 2024.10.10 |
[JAVA] JVM 전체 구조 정리 (1) | 2024.10.08 |
[JAVA] 자바, JVM, JDK, JRE (0) | 2024.10.08 |
[JAVA8] 애노테이션의 변화 (0) | 2024.10.07 |