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

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • langgraph 페이지로 이동
    • 사람용 CLI와 AI 에이전트용 CLI는 설계가 다르다
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code 메모리: CLAUDE.md와 .claude/rules를 규칙으로 쓰는 법
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
    • OpenClaw는 context와 memory를 어떻게 관리하나 — 나만의 에이전트를 구성하는 법
    • OpenClaw vs Hermes Agent — 갈아탈까 고민하며 정리한 비교
    • 스킬 문서를 신경망처럼 학습시킨다 — Microsoft SkillOpt 분석
  • ai 페이지로 이동
    • agent 페이지로 이동
    • [초안] AI 제품 백엔드 안정성 — 지연·비용·권한·관측·도구 실패·폴백/재시도/사람 에스컬레이션
    • [초안] LLM 평가 프레임워크: 골든셋, 회귀 테스트, LLM-as-a-judge, 사람 피드백 루프
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] Event Sourcing과 CQRS — 상태가 아니라 변화를 저장한다는 발상
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리
    • [초안] F&B 주문/매장/픽업 상태머신 설계
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 대규모 회원과 메가 프로모션을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • [초안] 운영 데이터 정합성 장애 대응 — 결제 취소 누락과 중복 적재 런북
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS는 어떻게 안전한가 — TLS, 인증서, 그리고 termination
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
    • [초안] Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • mlops 페이지로 이동
    • 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
  • 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 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

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

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • 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·11 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라는 실행 환경 의존성을 제거했기 때문이다. 테스트 인프라를 고칠 때는 "무엇을 더 켤까"보다 "지금 테스트가 실제로 필요로 하는 능력이 무엇인가"를 먼저 묻는 편이 더 안전하다.

같이 보면 좋은 글

  • 시니어 Java 백엔드를 위한 테스트 전략
  • 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같이 보면 좋은 글

이런 글도

  • [초안] Spring 스케줄러 다중 인스턴스 안전성 — @Scheduled가 N번 도는 문제와 해결
    > 학습 목표: 단일 서버에서 잘 돌던 @Scheduled 작업이 인스턴스를 2대 이상으로 늘리는 순간 왜 중복 실행되는지 이해하고, ShedLock·분산 락·리더 선출·외부 스케줄러 같은 선택지를 trade-off와 함께 고를 수 있게 한다. > > 한 줄 결론: @Scheduled는 JVM 하나 안에서만 동작하므로 인스턴스 간 조율 장치가 전혀 없다....
    ☕ java
    java
    2026.06.16
  • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    커머스 백엔드에서 메뉴, 프로모션, 매장 운영 정책 같은 "거의 안 바뀌지만 모든 요청이 읽는" 데이터는 거의 예외 없이 메모리 캐시로 들어간다. 트래픽이 큰 시간대에 이 캐시를 어떻게 갱신할지가 곧 시스템의 안정성을 결정한다. 갱신 순간에 락을 잘못 잡으면 모든 조회 스레드가 멈추고, 락을 너무 느슨하게 풀면 절반은 옛 데이터, 절반은 새 데이터를 보는...
    ☕ java
    java
    2026.05.08
  • [초안] Spring 트랜잭션 전파, 커머스 주문/결제에서 실전으로 이해하기
    외식 프랜차이즈처럼 매장/배달/예약/결제가 한 트랜잭션 흐름에서 함께 움직이는 커머스 도메인에서는 "이 메서드 하나가 어떤 트랜잭션 안에서 도는가"가 곧 데이터 정합성의 경계가 된다. 주문 저장은 성공했는데 결제 호출은 실패했다, 또는 결제는 통과했는데 알림 발송이 트랜잭션을 같이 끌고 들어가서 전체 롤백되어 사용자 입장에서 "결제는 됐는데 주문은 없는"...
    ☕ java
    java
    2026.05.07
  • [초안] Filter, Interceptor, AOP: Spring 요청 처리 파이프라인에서의 관심사 분리
    Spring 기반 백엔드에서 "요청이 들어와서 컨트롤러에 도달하기 전까지 뭔가 하고 싶다"는 요구는 끊임없이 생긴다. 로깅, 인증, 요청 ID 주입, 요청/응답 바디 감사(audit), 성능 측정, 예외 변환, 트랜잭션 경계 제어, 특정 어노테이션이 붙은 메서드에만 권한 체크 적용 — 이 모든 게 사실상 같은 질문의 변주다. "이 횡단 관심사를 어느 계층에...
    ☕ java
    java
    2026.04.21

댓글 (0)