JDK 21로 올린 뒤 테스트 로그 맨 위에 Mockito is currently self-attaching... 경고가 찍히기 시작했거나, CI에서만 Could not initialize inline Byte Buddy mock maker로 테스트가 통째로 실패한 적 있으시죠. 결론부터 말하면 일반 JVM 테스트는 Mockito 5 기본값(inline 목 메이커)을 그대로 쓰고, 빌드 설정에서 테스트 JVM에 -javaagent:mockito-core.jar를 명시하면 대부분 끝나요. 목 메이커를 바꿔야 하는 경우는 GraalVM 네이티브 이미지, 안드로이드 기기 테스트, 인터페이스만 목으로 만드는 경우 정도예요.
아래에서 상황별 판단표, 로그 문구로 찾는 확인 순서, 그리고 직접 측정한 목 생성 비용·호출 기록 메모리 숫자를 차례로 정리했어요.
Mockito 목 메이커 세 가지, 무엇이 다른가요
Mockito에서 목 객체를 실제로 만드는 부품을 목 메이커(MockMaker)라고 해요. 지금 고를 수 있는 것은 셋이에요. 설정 이름과 가능한 범위는 Mockito 공식 Javadoc의 39·50·53절에 나와 있어요.
| 목 메이커 | 켜는 방법 | 목으로 만들 수 있는 것 | 못 하는 것 |
|---|---|---|---|
| inline (5.0.0부터 기본) | 아무것도 안 해도 됨 | 일반·final 클래스와 메서드, enum, static 메서드, 생성자 | native 메서드, java.*의 패키지 전용 메서드, final 타입에 serializable()·extraInterfaces() |
| subclass | mockito-subclass 아티팩트 또는 목 단위 지정 | 인터페이스, final이 아닌 클래스·메서드 | final 클래스·메서드, static, 생성자 |
| proxy | 클래스패스에 mockito-extensions/org.mockito.plugins.MockMaker 파일, 내용 mock-maker-proxy | 인터페이스만 | 클래스 전부 |
실무에서 중요한 차이는 두 가지예요. inline은 JVM 에이전트(Instrumentation)가 있어야만 동작해서 JDK의 에이전트 정책 변화에 먼저 영향을 받아요. subclass 목에서 final 메서드는 가로채지 못해 오류 없이 진짜 코드가 그대로 돌아요.
Mockito 5.0.0 릴리스 노트는 기본값을 바꾼 이유로 JDK 17 이후 모듈 경계 문제를 들고, subclass가 여전히 맞는 경우로 GraalVM 네이티브 이미지를 꼽아요.
상황별로 어떤 목 메이커를 고르나 — 판단표
| 상황 | 고를 것 | 근거와 주의점 |
|---|---|---|
| 일반 JVM 단위 테스트, JDK 11~20 | inline (기본값) | 셀프 어태치로 동작하지만 미리 -javaagent를 넣어 두면 좋아요 |
| 일반 JVM 단위 테스트, JDK 21 이상 | inline + -javaagent 명시 | 공식 문서 0.3절이 이 설정을 안내해요 |
| JDK 8 프로젝트 | Mockito 4.x 유지 | 5.0.0부터 최소 자바 11 |
| GraalVM 네이티브 이미지에서 테스트 | subclass | inline은 네이티브 이미지에서 안 됨 (릴리스 노트) |
| 안드로이드 기기·에뮬레이터 계측 테스트 | mockito-android | 5.23.0부터 final·코틀린 클래스 지원, 기기 API 28 이상 |
| 인터페이스만 목, 코드 생성을 피하고 싶음 | proxy | JDK Proxy만 써서 준비 비용이 작음 |
| 특정 목 하나만 inline에서 문제 | @Mock(mockMaker = MockMakers.SUBCLASS) | 4.8.0부터 목 단위 지정 |
| 직렬화가 필요한 final 타입 | 직접 만든 가짜 객체 | inline은 serializable()과 함께 못 씀 |
안드로이드는 5.23.0 Javadoc 0.1절대로 androidTest/AndroidManifest.xml에 android:extractNativeLibs="true"도 넣어야 해요. 로컬 JVM 단위 테스트(test 소스셋)는 그냥 mockito-core를 써요.
JDK 21 이상에서 -javaagent 설정하는 법
inline 목 메이커는 시작할 때 Instrumentation 객체가 필요해요. JVM을 -javaagent로 띄웠으면 그걸 쓰고, 아니면 실행 중인 자기 JVM에 에이전트를 붙여요(셀프 어태치). JEP 451은 JDK 21부터 이렇게 실행 중에 붙는 에이전트에 경고를 찍고, 앞으로 나올 릴리스에서 기본으로 막겠다고 밝혔어요. 시작할 때 -javaagent로 올린 에이전트는 어느 릴리스에서도 경고 대상이 아니에요.
Gradle(Kotlin DSL)은 에이전트용 설정을 하나 만들어 jar 경로만 넘기면 돼요. 버전은 프로젝트에 맞게 바꾸세요.
// build.gradle.kts
val mockitoAgent = configurations.create("mockitoAgent")
dependencies {
testImplementation("org.mockito:mockito-core:5.23.0")
mockitoAgent("org.mockito:mockito-core:5.23.0") { isTransitive = false }
}
tasks.test {
jvmArgs("-javaagent:${mockitoAgent.asPath}")
}
Maven은 maven-dependency-plugin의 properties 목표가 의존성 jar 경로를 ${org.mockito:mockito-core:jar} 속성으로 만들어 줘요. 그 속성을 surefire의 argLine에 넘겨요.
<properties>
<!-- JaCoCo 같은 플러그인이 argLine을 안 채우면 비워서라도 선언 -->
<argLine></argLine>
</properties>
<!-- build/plugins 안 -->
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions><execution><goals><goal>properties</goal></goals></execution></executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
설정이 먹었는지는 두 가지로 확인하세요.
- 테스트 안에서 JVM 인자 확인:
ManagementFactory.getRuntimeMXBean().getInputArguments()에-javaagent항목이 있는지 단언하는 테스트를 두면, 설정이 빠진 모듈이나 IDE 실행이 바로 드러나요. - 미래 JDK 흉내: CI에
-XX:-EnableDynamicAgentLoading을 넣고 돌려 보세요. OpenJDK 24.0.2에서 확인한 결과 에이전트 없이 이 옵션만 주면 첫 목 생성에서Could not initialize inline Byte Buddy mock maker로 실패하고,-javaagent를 함께 주면 정상 동작해요. 이 조합이 통과하면 동적 로딩이 막혀도 깨지지 않아요.
-XX:+EnableDynamicAgentLoading으로 경고만 끄는 방법도 있지만, Mockito 문서는 이 옵션이 표준이 아니어서 앞으로의 JDK에서 막힐 수 있다고 적어요. 임시방편으로만 쓰세요.
운영에서 자주 나는 문제와 확인 순서
1. self-attaching 경고와 Dynamic loading of agents 경고가 같이 찍혀요. 테스트 JVM에 에이전트가 없어요. IDE에서만 뜬다면 IDE가 빌드 도구 대신 자체 실행기로 테스트를 돌리는 설정일 수 있고, 멀티 모듈이면 tasks.test 설정이 하위 모듈에 적용되지 않았을 수 있어요.
2. Could not initialize inline Byte Buddy mock maker로 모든 목 생성이 실패해요. "your JDK does not supply a working agent attachment mechanism"이 붙어 있으면 셀프 어태치가 막힌 환경(JRE 이미지, 동적 로딩을 막은 CI 러너)이에요. -javaagent로 시작 시 올리는 게 가장 확실해요.
3. MissingMethodInvocationException이 나요. when()은 직전의 목 호출을 꺼내 쓰므로 괄호 안이 목 호출이 아니면 이 예외가 나요. 확인 순서는 ① 괄호 안 객체가 정말 목인지(new로 만든 진짜 객체가 아닌지) ② 그 메서드가 final인데 subclass 목 메이커를 쓰고 있지 않은지 ③ static 메서드를 mockStatic 없이 when()에 넣지 않았는지예요.
4. static mocking is already registered in the current thread가 나요. 같은 스레드에서 같은 클래스의 MockedStatic이 아직 열려 있다는 뜻이에요. 앞선 테스트가 mockStatic()을 열고 닫지 않았을 가능성이 커요. 공식 문서 48·49절대로 static·생성자 목은 try-with-resources 범위 안에서만 쓰세요. 반대로 static 목을 걸었는데 효과가 없다면, 그 코드가 CompletableFuture나 스레드 풀 같은 다른 스레드에서 돌고 있지 않은지 보세요. static 목은 연 스레드에서만 동작해요.
5. 테스트가 뒤로 갈수록 느려지거나 OutOfMemoryError가 나요. 목은 verify를 위해 모든 호출을 기록해서, 수십만 번 부르는 목이 있으면 기록이 쌓여요. 공식 문서 47절은 inline 목에서 드물게 메모리 누수가 생길 수 있다며 Mockito.framework().clearInlineMocks()를 제공해요. 목을 static 필드에 담아 두지 않았는지도 보세요.
숫자로 보는 비용 — 목 생성 시간 직접 재기
"inline은 느리다"는 말이 실제로 얼마인지 Apple M1, OpenJDK 24.0.2, Mockito 5.23.0, ByteBuddy 1.18.15 환경에서 재 봤어요. 절대값은 기계마다 다르니 비율과 자릿수를 보세요. 먼저 새 JVM에서 첫 번째 목을 만드는 데 걸린 시간이에요. 목 메이커 초기화(에이전트 준비, 바이트코드 생성기 로딩)가 여기에 다 들어가요.
다음은 준비가 끝난 뒤 목을 하나 더 만드는 비용이에요. 같은 클래스로 목 2만 개를 만들어 평균을 내고, 이를 5번 반복한 중앙값이에요.
처음 보는 클래스를 목으로 만들 때는 이야기가 달라요. 메서드 10개짜리 클래스 200개를 차례로 목으로 만들었더니 클래스 하나당 inline 약 16ms, subclass 약 23ms였어요(측정마다 13~41ms로 흔들렸어요). 이 숫자로 계산해 보면 판단이 쉬워져요.
- 같은 클래스 목을 많이 만드는 경우: 테스트 3,000개가 목을 5개씩 만들면 1만 5,000개예요. inline 38µs × 15,000 = 0.57초, subclass 12µs × 15,000 = 0.18초예요. 차이가 0.4초 정도라 속도 때문에 목 메이커를 바꿀 이유는 없어요.
- 처음 보는 클래스가 많은 경우: 목으로 만드는 서로 다른 클래스가 300개면 300 × 16ms = 약 4.8초가 JVM마다 한 번 들어요.
- JVM을 자주 새로 띄우는 경우: Gradle
forkEvery = 1처럼 테스트 클래스마다 JVM을 새로 띄우면 초기화 비용이 곱해져요. 테스트 클래스 200개면 첫 목 비용만 200 × 0.15초 = 30초이고, 클래스 변환 비용도 매번 다시 들어요. 테스트가 느리다면 목 메이커보다 포크 설정을 먼저 보세요.
mockStatic()을 열고 스텁을 걸고 닫는 한 사이클은 약 110µs였어요. static 목의 진짜 비용은 속도가 아니라 스레드 범위와 닫기 누락이에요.
숫자로 보는 한계 — 호출 기록 메모리와 진짜 객체 부담
목 하나에 스텁을 걸고 같은 메서드를 반복 호출한 뒤 GC 후 힙 사용량을 비교했어요.
| 호출 횟수 | 기본 목 | stubOnly() 목 |
|---|---|---|
| 10만 번 | 약 44.5 MB 증가 | 거의 0 |
| 100만 번 | 약 443 MB 증가 | 거의 0 |
호출 한 번에 약 465바이트(44.5MB ÷ 10만)가 기록으로 남는 셈이에요. 테스트 JVM 힙을 512MB로 잡았다면 한 목을 100만 번 부르는 테스트 하나로 거의 다 써요. MockSettings Javadoc은 stubOnly() 목이 호출을 기록하지 않아 메모리를 아끼는 대신 verify를 할 수 없다고 설명해요. 반복 호출이 많은 의존성은 mock(Foo.class, withSettings().stubOnly())로 만들거나, 아예 간단한 가짜 구현 클래스를 쓰는 편이 나아요.
inline 방식에는 잘 안 알려진 비용이 하나 더 있어요. 어떤 클래스를 inline으로 한 번 목으로 만들면 그 클래스의 메서드 본문에 "이 객체가 목인가"를 묻는 코드가 들어가는데, 같은 JVM의 진짜 객체도 그 코드를 거쳐요. 진짜 객체의 아주 짧은 메서드를 5천만 번 부르는 반복문으로 재 봤어요.
호출 하나가 약 2.5ns에서 33ns로 늘었고, clearInlineMocks()로 목 상태를 비워도 26ns로 남았어요. 목 상태를 지워도 끼워 넣은 검사 코드는 JVM에 남는다는 뜻이에요. 1,000만 번 호출해도 0.3초 정도라 단위 테스트에는 문제가 없지만, 같은 JVM에서 성능 측정 테스트를 돌리면 앞선 테스트의 목 생성 여부에 따라 숫자가 흔들려요. 성능 테스트는 별도 JVM으로 분리하세요.
도입·업그레이드 전에 따질 것
Mockito 5나 JDK 21 이상으로 올리기 전에 확인할 항목이에요.
- 자바 버전: JDK 8이면 Mockito 4.x에 머물러요.
- 실제로 들어오는 버전: Spring Boot 같은 프레임워크가 Mockito 버전을 정해 주기도 해요.
./gradlew dependencies나mvn dependency:tree로 실제 버전을 확인하고,-javaagentjar도 같은 버전인지 보세요. mockito-inline아티팩트: 5.0.0부터 필요 없고, 공식 문서 0.2절은 앞으로 없어질 수 있다고 적어요.mockito-core만 남기세요.- 엄격한 스텁 검사: JUnit 5의
MockitoExtension은 쓰지 않은 스텁을UnnecessaryStubbingException으로 실패시켜요. 스텁을 정리하고, 꼭 필요한 곳만lenient()로 풀어요. - 목으로 만들지 말아야 할 타입: 공식 문서 1절도
List는 목 대신 진짜 인스턴스를 쓰라고 해요. 값 객체·컬렉션·record는 진짜 객체가 정확하고, 목으로 만들면 위험한 내 클래스는 51절의@DoNotMock으로 막을 수 있어요.
자주 묻는 질문
Mockito 5에서 mockito-inline 의존성을 따로 넣어야 하나요?
아니요. 5.0.0부터 inline 목 메이커가 mockito-core의 기본값이에요. 공식 문서는 mockito-inline 아티팩트가 앞으로 없어질 수 있다고 적고 있으니, mockito-core 하나만 두는 게 깔끔해요.
self-attaching 경고를 그냥 둬도 테스트는 통과하는데 괜찮나요?
지금은 동작하지만 JEP 451이 앞으로 실행 중 에이전트 로딩을 기본으로 막겠다고 밝혔어요. 막히는 JDK로 올리는 순간 모든 inline 목 생성이 실패해요. 빌드에 -javaagent를 넣고 CI에서 -XX:-EnableDynamicAgentLoading으로 미리 확인해 두세요.
static 목을 걸었는데 비동기 코드에서는 진짜 메서드가 불려요.
mockStatic()은 연 스레드에서만 동작해요. 스레드 풀이나 CompletableFuture로 넘어간 코드는 진짜 static 메서드를 불러요. static 호출을 작은 인터페이스로 감싸 주입받게 바꾸면 스레드와 상관없이 목으로 바꿀 수 있어요.
목에 verify를 안 쓰는데 메모리를 줄일 방법이 있나요?
withSettings().stubOnly()로 만든 목은 호출을 기록하지 않아요. 측정에서 100만 번 호출 시 기본 목은 약 443MB를 썼고 stubOnly 목은 거의 늘지 않았어요. 대신 그 목에는 verify를 쓸 수 없어요.
정리
- 일반 JVM 테스트는 Mockito 5 기본값인 inline을 그대로 쓰고, JDK 21 이상이면 Gradle
jvmArgs나 MavenargLine으로-javaagent를 명시해요. GraalVM 네이티브 이미지는 subclass, 안드로이드 기기 테스트는mockito-android, 인터페이스 전용이면 proxy를 골라요. - 문제가 나면 로그 문구로 찾아요. 경고·초기화 실패는 에이전트,
MissingMethodInvocationException은when()안의 대상,already registered는 닫지 않은 static 목, 메모리 증가는 호출 기록이 원인인 경우가 많아요. - 목 생성 비용보다 JVM 포크 횟수가 크고, 수십만 번 불리는 목에는
stubOnly()를, 성능 테스트는 별도 JVM을 써요.