fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • gaps and islands: SQL 창 함수로 이어진 구간 찾기
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • personal-projects 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/java/Java 21 테스트에서 Mockito `m…
java

Java 21 테스트에서 Mockito `mock-maker-subclass`로 JVM attach를 피하기

Spring Boot 3과 Java 21 프로젝트에서 테스트가 갑자기 Mockito 초기화 단계에서 깨졌다. 처음에는 내가 방금 건드린 테스트가 문제라고 생각했는데, 실제 원인은 테스트 코드가 아니라 Mockito의 mock 생성 방식이었다. 이 글은 mock-maker-subclass가 무슨 역할을 하는지, 왜 -javaagent보다 CI에서 다루기 쉬웠...

2026.06.22·8 min read·107 views

Spring Boot 3과 Java 21 프로젝트에서 테스트가 갑자기 Mockito 초기화 단계에서 깨졌다. 처음에는 내가 방금 건드린 테스트가 문제라고 생각했는데, 실제 원인은 테스트 코드가 아니라 Mockito의 mock 생성 방식이었다. 이 글은 mock-maker-subclass가 무슨 역할을 하는지, 왜 -javaagent보다 CI에서 다루기 쉬웠는지 정리한 기록이다.

문제 상황은 단순했다. 새로 추가한 테스트만 돌리면 통과하는데, 전체 mvn verify를 돌리면 여러 테스트가 Mockito MockMaker 초기화에서 실패했다. 실패 지점은 비즈니스 로직이 아니라 Mockito가 mock 객체를 만들기 전에 사용하는 Byte Buddy agent attach 경로였다.

먼저 결론

이번 문제의 핵심은 Mockito 기능 부족이 아니라 필요 이상으로 강한 mock maker를 쓰고 있었다는 점이었다.

  • mock-maker-inline은 final class, final method, static mock까지 다룰 수 있지만 Java agent attach가 필요하다.
  • mock-maker-subclass는 하위 클래스를 만들어 mock을 만들기 때문에 final/sealed/static mock에는 약하지만, agent attach가 필요 없다.
  • 이번 테스트 suite는 final/static mock이 필요 없었으므로 subclass 방식이 더 좁고 안정적인 해결이었다.

테스트 인프라 문제를 볼 때는 "무엇을 더 켤까"보다 "현재 테스트가 실제로 필요로 하는 mock 능력이 무엇인가"를 먼저 확인하는 편이 낫다.

MockMaker는 mock을 어떻게 만들지 결정하는 플러그인이다

Mockito에서 MockMaker는 말 그대로 mock 객체를 만드는 엔진이다. 같은 Mockito.mock(Foo.class) 호출이라도 어떤 MockMaker를 쓰느냐에 따라 방식과 제약이 달라진다.

Mockito 5 기준으로 자주 만나는 선택지는 두 가지다. 둘 다 Byte Buddy와 관련이 있지만, 같은 의미는 아니다.

MockMaker방식장점제약
mock-maker-inlineByte Buddy Agent + Java Instrumentation API로 기존 클래스를 재정의final class, final method, static mock 같은 강한 기능 가능런타임 agent attach 또는 명시적 -javaagent가 필요
mock-maker-subclassByte Buddy로 하위 클래스를 새로 만들어 mock 생성JVM attach가 필요 없어 CI와 샌드박스에서 안정적final/sealed class, final method, static mock에는 부적합

내가 만난 문제는 inline 방식의 장점이 필요하지 않은 테스트에서 inline 방식의 비용만 치르고 있던 상황이었다.

Byte Buddy와 Byte Buddy Agent는 다르다

처음에는 "Mockito가 Byte Buddy를 쓰니까 어차피 agent가 필요한 것 아닌가?"라고 생각하기 쉽다. 여기서 구분해야 할 게 있다.

Byte Buddy는 런타임에 클래스를 만들어내거나 바이트코드를 조작하는 라이브러리다. mock-maker-subclass도 Byte Buddy를 쓴다. 다만 이 경우에는 기존 클래스를 건드리지 않고 FooService를 상속한 새 클래스를 만든다.

반면 Byte Buddy Agent는 이미 로딩된 클래스를 JVM Instrumentation API로 다시 정의하는 쪽에 가깝다. mock-maker-inline이 final method나 static method까지 다룰 수 있는 이유가 여기에 있다. 기존 클래스의 메서드 바이트코드 자체를 바꾸는 방향이므로 JVM에 agent가 붙어야 한다.

그래서 이번 선택은 "Byte Buddy를 안 쓰자"가 아니었다. Byte Buddy는 그대로 쓰되, Byte Buddy Agent attach 경로를 피하자에 가까웠다.

왜 Java 21 환경에서 더 잘 보였나

inline mock maker는 final class나 static method까지 mock할 수 있게 해준다. 대신 JVM에 Java agent를 붙여야 한다. Mockito는 가능하면 런타임에 Byte Buddy agent를 attach하려고 시도한다. 로컬 개발 머신에서는 이것이 별문제 없이 넘어가기도 하지만, 다음 조건이 겹치면 실패한다.

  • JDK/JRE 구성이나 보안 정책 때문에 self-attach가 제한되는 경우
  • CI 컨테이너나 샌드박스가 attach 관련 권한을 제한하는 경우
  • Java 21 이후 동적 agent loading 경고와 정책 변화가 더 눈에 띄는 경우
  • Maven Surefire가 띄운 테스트 JVM에 필요한 agent 옵션이 명시되지 않은 경우

정확히 말하면 Java 21이 모든 Mockito inline 테스트를 막는다는 뜻은 아니다. inline mock maker는 여전히 유효하다. 다만 테스트 JVM이 agent attach를 허용해야 하고, 그 전제가 로컬·CI·샌드박스마다 달라질 수 있다. 이번 실패는 그 전제가 흔들린 사례에 가까웠다.

처음에는 Surefire argLine에 -javaagent를 넣는 방법을 떠올렸다.

xml
<argLine>
  -javaagent:${settings.localRepository}/net/bytebuddy/byte-buddy-agent/.../byte-buddy-agent-....jar
</argLine>

이 방식은 실제로 로컬에서는 동작했다. 하지만 CI 관점에서는 마음에 걸렸다. ${settings.localRepository} 경로가 항상 같은지, 해당 artifact가 테스트 시작 전에 반드시 내려받혀 있는지, Maven mirror나 cache 정책이 바뀌어도 안정적인지까지 같이 책임져야 한다. 테스트 안정화를 위해 테스트 JVM 옵션과 로컬 저장소 경로를 더 강하게 결합하는 셈이다.

이번 경우에는 subclass가 더 좁은 해결이었다

내가 확인한 테스트들은 static mock이나 final class mock을 쓰지 않았다. 대부분 일반 서비스, 어댑터, DTO 주변의 mock/stub이었다. 그렇다면 inline mock maker가 제공하는 강한 기능이 필요하지 않았다.

이때 다음 파일 하나로 Mockito의 mock maker를 바꿀 수 있다.

text
src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker

내용은 한 줄이다.

text
mock-maker-subclass

이 파일은 테스트 classpath에 올라가고, Mockito는 시작 시점에 이 extension file을 읽어 MockMaker를 선택한다. mock-maker-subclass는 대상 타입을 상속한 하위 클래스를 만들고, 그 하위 클래스에 호출을 가로채는 interceptor를 붙인다. 새 클래스를 만들어 쓰는 방식이라 기존 클래스를 재정의하기 위한 runtime attach가 필요 없다.

대신 이 선택은 기능을 줄인다. final class는 상속할 수 없고, final method는 override할 수 없다. static method도 인스턴스 하위 클래스로 가로챌 수 없다. sealed class도 허용된 하위 타입 목록이 고정되어 있으므로 Mockito가 임의로 만든 subclass를 끼워 넣을 수 없다. 그래서 이 설정은 "Mockito 기능을 더 세게 켜는 설정"이 아니라, 오히려 필요한 기능만 남겨 테스트 JVM을 단순하게 만드는 설정에 가깝다.

final 타입은 왜 subclass로 mock할 수 없나

mock-maker-subclass는 이름 그대로 하위 클래스를 만든다. 다음 코드가 있다고 하자.

java
final class TokenVerifier {
 
    boolean verify(String token) {
        return token != null && token.startsWith("Bearer ");
    }
}

subclass 방식의 Mockito가 하고 싶은 일은 개념적으로 이런 클래스 생성이다.

java
class TokenVerifierMockitoMock extends TokenVerifier {
 
    @Override
    boolean verify(String token) {
        return mockHandler.handle("verify", token);
    }
}

하지만 Java에서 final class는 상속할 수 없다. 컴파일러가 막는 제약이고, 런타임에 몰래 하위 클래스를 만들어도 JVM 검증 단계에서 받아들일 수 없다. 그래서 subclass mock maker는 final class를 mock할 수 없다. Mockito source에서도 subclass mock maker는 primitive, final, sealed 타입을 mock 불가능한 타입으로 분류한다.

final method도 같은 이유다.

java
class TokenVerifier {
 
    final boolean verify(String token) {
        return token != null && token.startsWith("Bearer ");
    }
}

이 경우 클래스 자체는 상속 가능하지만 verify는 override할 수 없다. subclass mock은 메서드를 override해서 호출을 가로채야 하는데, final method는 그 진입점이 막혀 있다. 그래서 final method를 stub하거나 verify해야 하는 테스트라면 subclass 방식은 맞지 않는다.

inline mock maker는 이 제약을 다른 방식으로 우회한다. 상속해서 override하는 대신 기존 클래스 바이트코드를 재정의한다. 그래서 final class와 final method mock이 가능해진다. 대신 앞에서 말한 agent attach 비용을 치른다.

sealed 타입은 final과 비슷하지만 이유가 조금 다르다

Java 17부터 정식으로 들어온 sealed type도 subclass 방식과 잘 맞지 않는다. sealed class/interface는 하위 타입을 permits 목록으로 제한한다.

java
sealed interface PaymentResult permits Approved, Rejected {
}
 
final class Approved implements PaymentResult {
}
 
final class Rejected implements PaymentResult {
}

여기서 Mockito가 임의로 PaymentResultMockitoMock 같은 구현체를 만들고 싶어도, PaymentResult의 permits 목록에 그 타입이 없다. sealed의 핵심은 "이 타입 계층은 여기 적힌 하위 타입으로 닫혀 있다"는 계약이다. 테스트 프레임워크가 런타임에 새 하위 타입을 끼워 넣는 순간 그 계약을 깨게 된다.

이런 타입은 보통 mock보다 실제 하위 타입 인스턴스를 쓰는 편이 낫다.

java
PaymentResult result = new Approved();

sealed 계층은 상태 공간을 좁히기 위해 도입하는 경우가 많다. 그 좁힌 상태 공간을 mock으로 다시 열어버리면 테스트가 실제 도메인 모델보다 더 넓은 세계를 검증하게 된다. 그래서 sealed 타입을 자주 mock해야 한다면 mock maker를 바꾸기 전에 테스트 설계를 먼저 의심해볼 만하다.

static mock은 왜 subclass로 해결되지 않나

static method는 인스턴스 dispatch를 타지 않는다. subclass mock maker는 mock 인스턴스를 만들고, 그 인스턴스 메서드 호출을 interceptor로 넘긴다. 그런데 static 호출은 애초에 인스턴스를 거치지 않는다.

java
class TokenClock {
 
    static long now() {
        return System.currentTimeMillis();
    }
}

TokenClock.now() 호출을 바꾸려면 하위 클래스 인스턴스를 만드는 것으로는 부족하다. 이미 정해진 클래스의 static method 호출 자체를 바꿔야 한다. 이 영역은 inline mock maker가 Instrumentation API를 써서 처리하는 쪽이다.

다만 static mock이 필요하다는 건 설계 신호이기도 하다. 시간, 랜덤, 외부 환경 값은 가능하면 인터페이스나 Clock 같은 주입 가능한 객체로 빼는 편이 테스트가 단순해진다.

언제 쓰고 언제 피할까

내가 잡은 기준은 이렇다.

mock-maker-subclass를 써도 되는 경우:

  • 테스트 대상과 협력자가 대부분 interface 또는 non-final class다.
  • static mock, construction mock을 쓰지 않는다.
  • final method를 stub하거나 verify하지 않는다.
  • CI나 샌드박스에서 Mockito inline agent attach가 불안정하다.
  • 테스트 인프라를 JVM 옵션보다 classpath resource로 고정하고 싶다.

피해야 하는 경우:

  • Kotlin처럼 class/method가 기본 final인 코드가 많다.
  • final class를 직접 mock해야 한다.
  • mockStatic, mockConstruction이 테스트 전략의 일부다.
  • 레거시 코드 때문에 static method를 당장 제거하기 어렵다.

즉 선택지는 "inline이 더 최신이고 subclass가 낡았다"가 아니다. 테스트가 요구하는 mock 능력과 실행 환경 제약이 무엇인지에 따라 고르는 문제다.

이 작업에서 같이 배운 것

처음에는 전체 테스트 실패를 새로 작성한 테스트의 문제로 의심했다. 그런데 단일 테스트는 통과했고, 전체 suite에서 Mockito 초기화가 흔들렸다. 이럴 때는 실패한 테스트 클래스보다 더 앞단, 즉 테스트 프레임워크 bootstrap 단계부터 봐야 한다.

내가 쓴 확인 순서는 이랬다.

  • 새 테스트 단독 실행으로 기능 회귀 여부 확인
  • 전체 mvn verify로 suite 실패 재현
  • 실패 stack trace에서 비즈니스 코드가 아니라 MockMaker 초기화가 root인지 확인
  • -javaagent 방식으로 원인 가설 검증
  • CI 경로 결합을 줄이기 위해 mock-maker-subclass로 대체
  • static/final mock 사용 여부를 검색해 기능 축소가 안전한지 확인

이 순서가 중요했다. 바로 mock-maker-subclass를 넣었다면 "왜 이 설정이 안전한가"를 설명하기 어려웠을 것이다. 반대로 -javaagent가 통과한다는 사실을 먼저 확인했기 때문에, 문제의 축이 테스트 로직이 아니라 inline agent attach라는 점을 분리할 수 있었다.

결론

Mockito의 inline mock maker는 강력하다. final class, final method, static mock이 필요한 테스트에서는 거의 필수다. 하지만 그런 기능을 쓰지 않는 프로젝트라면 그 강력함이 CI 불안정성으로 돌아올 수 있다.

이번에는 mock-maker-subclass가 더 나은 선택이었다. 테스트가 요구하는 mock 능력은 유지하면서, Java agent attach라는 실행 환경 의존성을 제거했기 때문이다. 테스트 인프라를 고칠 때는 "무엇을 더 켤까"보다 "지금 테스트가 실제로 필요로 하는 능력이 무엇인가"를 먼저 묻는 편이 더 안전하다.

같이 보면 좋은 글

  • Spring AOP 프록시와 ByteBuddy
on this page
  • 01먼저 결론
  • 02MockMaker는 mock을 어떻게 만들지 결정하는 플러그인이다
  • 03Byte Buddy와 Byte Buddy Agent는 다르다
  • 04왜 Java 21 환경에서 더 잘 보였나
  • 05이번 경우에는 subclass가 더 좁은 해결이었다
  • 06final 타입은 왜 subclass로 mock할 수 없나
  • 07sealed 타입은 final과 비슷하지만 이유가 조금 다르다
  • 08static mock은 왜 subclass로 해결되지 않나
  • 09언제 쓰고 언제 피할까
  • 10이 작업에서 같이 배운 것
  • 11결론
  • 12같이 보면 좋은 글
tags
#study

이런 글도

  • java

    JPA 식별자 전략: Long IDENTITY와 외부용 UUID v7

    2026.10.09
  • java

    Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유

    2026.10.09
  • java

    쿠버네티스에 올렸더니 X-Forwarded-For 가 사라졌다 — forward-headers-strategy 와 RemoteIpValve

    2026.08.04
  • java

    서블릿 async 재디스패치에서 preHandle이 두 번 돌고 응답 객체가 바뀌는 이유

    2026.08.02

댓글 (0)