Java Reactive Programming 핵심 정리 시리즈 13편 (완결). StepVerifier 완전 정복 — create/expectNext/expectError/verifyComplete 기본기, withVirtualTime 가상 시간 테스트, TestPublisher 수동 이벤트 주입, Context 테스트까지 비유와 시험 함정을 곁들여 시리즈 마지막 편을 장식.
이 글은 Java Reactive Programming 핵심 정리 시리즈의 열세 번째이자 마지막 편입니다. Reactive 파이프라인은 비동기·논블로킹으로 동작하기 때문에 기존 JUnit 방식으로 테스트하기가 까다로워요. subscribe() 호출 후 값이 언제 도착할지 모르는데 어떻게 단언(assertion)을 작성할 수 있을까요? Project Reactor가 이 문제를 위해 StepVerifier를 제공합니다.
1편부터 함께 달려온 여정의 마무리로, 이번 편은 StepVerifier를 통해 우리가 만든 모든 파이프라인을 어떻게 검증하는지 배웁니다. 단순한 값 검증부터 가상 시간(Virtual Time)으로 50초짜리 스트림을 0ms에 테스트하는 방법까지요.
이 시리즈는 Project Reactor 공식 문서와 Reactive Streams 명세를 포함한 공개 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다. 테스트를 직접 실행하려면 reactor-test 의존성이 필요합니다.
Gradle: testImplementation 'io.projectreactor:reactor-test'
왜 StepVerifier가 처음엔 어렵게 느껴질까요
이유는 네 가지예요.
첫째, verify 계열 메서드를 안 부르면 테스트가 항상 통과합니다. 검증 체인을 아무리 길게 써도 마지막에 verify() / verifyComplete() / verifyError() 중 하나를 호출하지 않으면 아무 일도 안 일어나요. 가짜 성공(fake passing test)이 만들어지는 가장 흔한 실수입니다.
둘째, withVirtualTime에 Supplier를 써야 하는 이유가 처음엔 이해되지 않습니다. Publisher 인스턴스를 직접 전달하면 이미 실제 시간 타이머가 시작돼 버려서 가상 시간이 의미 없어져요.
셋째, expectNoEvent를 쓰기 전에 expectSubscription이 필요한 이유를 모릅니다. 구독 자체도 이벤트이기 때문에 expectNoEvent만 쓰면 구독 이벤트에서 실패해요.
넷째, TestPublisher에서 구독 전에 데이터를 발행하면 데이터가 사라집니다. 구독자가 없으면 받아갈 사람이 없으니까요. then() 블록 안에서 발행해야 해요.
비유로 잡으면 명확해요. StepVerifier = 데이터가 흘러갈 때마다 한 단계씩 검문하는 체크포인트. 컨베이어 벨트 위의 상자(데이터)가 하나씩 지나갈 때마다 검문관(StepVerifier)이 "이거 맞아?", "이거 맞아?" 확인하고 마지막에 "벨트 멈췄어?" 최종 확인하는 구조예요.
StepVerifier 기본 구조 — Mono 테스트
StepVerifier의 기본 구조는 단순합니다. create → 검증 체인 → verify* 순서예요.
public class ProductService {
public Mono<String> getProduct(int id) {
return Mono.just("product-" + id);
}
public Mono<String> getUserName(int id) {
return switch (id) {
case 1 -> Mono.just("sam");
case 2 -> Mono.empty();
case 3 -> Mono.error(new RuntimeException("invalid input"));
case 4 -> Mono.error(new IllegalArgumentException("not found"));
default -> Mono.just("unknown");
};
}
}
// 단일 값 테스트
@Test
void testProduct() {
StepVerifier.create(service.getProduct(1))
.expectNext("product-1")
.verifyComplete();
}
// 빈 Mono 테스트
@Test
void testEmpty() {
StepVerifier.create(service.getUserName(2))
.verifyComplete(); // 값 없이 완료
}
// 에러 타입 테스트
@Test
void testErrorType() {
StepVerifier.create(service.getUserName(4))
.expectError(IllegalArgumentException.class)
.verify();
}
// 에러 메시지 테스트
@Test
void testErrorMessage() {
StepVerifier.create(service.getUserName(3))
.expectErrorMessage("invalid input")
.verify();
}
// 에러 상세 검증
@Test
void testErrorDetails() {
StepVerifier.create(service.getUserName(3))
.consumeErrorWith(error -> {
assertEquals(RuntimeException.class, error.getClass());
assertEquals("invalid input", error.getMessage());
})
.verify();
}
여기서 시험 함정이 하나 있어요. verify()는 blocking 호출입니다. 스트림이 완료될 때까지 현재 스레드를 블로킹해요. 프로덕션 코드에서는 절대 쓰면 안 되지만, 테스트에서는 이 방식이 표준이에요. 테스트 스레드를 블로킹해서 결과를 기다리는 게 의도된 동작입니다.
StepVerifier로 Flux 테스트
public Flux<Integer> getProducts() {
return Flux.range(1, 5);
}
// 여러 값 한 번에 검증
@Test
void testAllProducts() {
StepVerifier.create(getProducts())
.expectNext(1, 2, 3, 4, 5)
.verifyComplete();
}
// expectNextCount: 값 내용 확인 없이 개수만
@Test
void testCountOnly() {
StepVerifier.create(Flux.range(1, 50))
.expectNext(1, 2, 3) // 처음 3개는 값 확인
.expectNextCount(47) // 나머지 47개는 개수만 확인
.verifyComplete();
}
// 부분 테스트 후 취소
@Test
void testPartial() {
StepVerifier.create(getProducts(), 1) // 1개만 요청
.expectNext(1)
.cancel() // 구독 취소
.verify();
}
StepVerifier 조건 기반 검증
값이 랜덤하거나 객체가 복잡할 때는 expectNextMatches / assertNext / thenConsumeWhile을 씁니다.
// expectNextMatches: 첫 번째 값이 조건 만족하는지 검증
@Test
void testRandomItems() {
Flux<Integer> randomFlux = Flux.range(1, 50)
.map(i -> ThreadLocalRandom.current().nextInt(1, 101));
StepVerifier.create(randomFlux)
.expectNextMatches(item -> item > 0 && item <= 100) // 첫 값만
.expectNextCount(49)
.verifyComplete();
}
// thenConsumeWhile: 조건 만족하는 동안 모두 소비
@Test
void testConsumeWhile() {
StepVerifier.create(Flux.range(1, 50).map(i -> i * 2))
.thenConsumeWhile(item -> item % 2 == 0) // 모두 짝수여야 함
.verifyComplete();
}
// assertNext: 복잡한 객체 검증 (JUnit assertion 사용)
@Test
void testBooks() {
Flux<Book> books = Flux.range(1, 3)
.map(i -> new Book(i, "author-" + i, "title-" + i));
StepVerifier.create(books)
.assertNext(book -> assertEquals(1, book.getId()))
.assertNext(book -> {
assertEquals(2, book.getId());
assertNotNull(book.getTitle());
})
.assertNext(book -> assertEquals(3, book.getId()))
.verifyComplete();
}
값 검증 메서드 선택 기준:
- 정확한 값 일치 →
expectNext(v) - 조건만 확인 →
expectNextMatches(pred) - JUnit assertion 사용 →
assertNext(assertion) - 조건 만족 동안 모두 소비 →
thenConsumeWhile(pred) - 개수만 확인 →
expectNextCount(n)
withVirtualTime — 시간 기반 StepVerifier 테스트
delayElements나 interval 같이 실제 시간이 걸리는 스트림을 테스트할 때는 withVirtualTime을 씁니다. 50초짜리 스트림을 실제로 50초 기다리지 않고 0ms에 테스트할 수 있어요.
// 실제로 50초 걸리는 스트림
Flux<Integer> slowStream() {
return Flux.range(1, 5)
.delayElements(Duration.ofSeconds(10));
}
// withVirtualTime + thenAwait: 시간을 즉시 진행
@Test
void testWithVirtualTime() {
StepVerifier.withVirtualTime(() -> slowStream()) // Supplier로 전달!
.thenAwait(Duration.ofSeconds(51)) // 51초 즉시 진행
.expectNext(1, 2, 3, 4, 5)
.verifyComplete();
// 실제 소요 시간: 거의 0ms
}
// expectNoEvent와 단계별 시간 진행
@Test
void testNoEventsThenData() {
StepVerifier.withVirtualTime(() -> slowStream())
.expectSubscription() // 구독 이벤트 먼저 소비 (필수!)
.expectNoEvent(Duration.ofSeconds(9)) // 9초 동안 아무것도 없음
.thenAwait(Duration.ofSeconds(1)) // 1초 더 진행 (총 10초)
.expectNext(1)
.thenAwait(Duration.ofSeconds(40)) // 40초 더 진행 (총 50초)
.expectNext(2, 3, 4, 5)
.verifyComplete();
}
여기서 시험 함정이 하나 있어요. withVirtualTime에는 반드시 Supplier 람다 안에서 Publisher를 생성해야 합니다. 외부에서 이미 만든 인스턴스를 전달하면 실제 타이머가 이미 시작된 상태라 가상 시간이 의미 없어요.
// 잘못된 코드: 외부에서 생성한 인스턴스 전달
Flux<Integer> slow = Flux.range(1, 5).delayElements(Duration.ofSeconds(10));
StepVerifier.withVirtualTime(() -> slow) // 이미 실제 시간으로 생성됨!
.thenAwait(Duration.ofSeconds(51))
.verifyComplete(); // 실제 51초 대기 발생!
// 올바른 코드: Supplier 안에서 생성
StepVerifier.withVirtualTime(() ->
Flux.range(1, 5).delayElements(Duration.ofSeconds(10)) // 람다 내부에서 생성
)
.thenAwait(Duration.ofSeconds(51))
.verifyComplete();
Context 테스트 — StepVerifierOptions
Context가 필요한 파이프라인을 테스트할 때는 StepVerifierOptions.withInitialContext를 씁니다.
Mono<String> getWelcomeMessage() {
return Mono.deferContextual(ctx -> {
if (ctx.hasKey("user")) {
return Mono.just("Welcome " + ctx.get("user"));
} else {
return Mono.error(new RuntimeException("unauthenticated"));
}
});
}
// 성공 경로: Context 제공
@Test
void testWithContext() {
Context ctx = Context.of("user", "sam");
StepVerifierOptions options = StepVerifierOptions.create()
.withInitialContext(ctx);
StepVerifier.create(getWelcomeMessage(), options)
.expectNext("Welcome sam")
.verifyComplete();
}
// 실패 경로: Context 없음
@Test
void testWithoutContext() {
StepVerifierOptions options = StepVerifierOptions.create()
.withInitialContext(Context.empty());
StepVerifier.create(getWelcomeMessage(), options)
.expectErrorMessage("unauthenticated")
.verify();
}
TestPublisher — 수동 이벤트 주입
TestPublisher는 테스트에서 수동으로 이벤트를 발행하는 도구입니다. 복잡한 시나리오(지연 발행, 에러 주입)를 테스트할 때 유용해요.
String toUpperCase(String input) {
return input.toUpperCase() + "2";
}
Flux<String> processor(Flux<String> input) {
return input.map(this::toUpperCase);
}
// 올바른 TestPublisher 사용: then() 블록에서 발행
@Test
void testWithTestPublisher() {
TestPublisher<String> publisher = TestPublisher.create();
Flux<String> flux = publisher.flux();
StepVerifier.create(processor(flux))
.then(() -> { // 구독 완료 후 실행!
publisher.next("hi"); // 값 발행
publisher.complete(); // 완료 신호
})
.expectNext("HI2")
.verifyComplete();
}
// 에러 주입 테스트
@Test
void testWithTestPublisherError() {
TestPublisher<String> publisher = TestPublisher.create();
StepVerifier.create(processor(publisher.flux()))
.then(() -> {
publisher.next("hello");
publisher.error(new RuntimeException("테스트 에러"));
})
.expectNext("HELLO2")
.expectErrorMessage("테스트 에러")
.verify();
}
여기서 시험 함정이 하나 있어요. TestPublisher에서 구독 전에 데이터를 발행하면 데이터가 사라집니다. StepVerifier.create(publisher.flux()) 호출 시점에 구독이 설정되기 때문에, 그 이전에 publisher.next()를 호출하면 받아갈 구독자가 없어서 데이터가 증발해요. 반드시 .then() 블록 안에서 발행해야 합니다.
자세한 StepVerifier API는 Project Reactor 공식 문서에서 확인할 수 있습니다.
핵심 압축 노트 — 시험 직전 25개
시리즈 마지막 편인 만큼, 이전 편들의 핵심도 아울러 정리할 수 있게 StepVerifier 중심으로 압축 노트를 마무리합니다.
- StepVerifier = 체크포인트 검문관 — 데이터가 지나갈 때마다 한 단계씩 검증
StepVerifier.create(publisher)또는withVirtualTime(() -> publisher)- 마지막에 반드시
verifyComplete()/verify()/verifyError()호출 - verify 계열 없으면 아무것도 실행 안 됨 = 가짜 성공 (가장 흔한 실수!)
verify()는 blocking — 테스트에서만 허용, 프로덕션 코드 절대 금지verifyComplete()— 완료 검증,verifyError()— 에러 검증expectNext(v)— 정확한 값,expectNextMatches(pred)— 조건 만족assertNext(assertion)— JUnit assertion 사용 가능expectNextCount(n)— 값 확인 없이 개수만thenConsumeWhile(pred)— 조건 만족 동안 모두 소비expectError(Class)— 특정 타입 에러 (클래스 매칭)expectErrorMatches(pred)— 람다로 에러 조건 검증expectErrorMessage(msg)— 에러 메시지 정확 일치withVirtualTime은 시간 기반(interval/delayElements) 테스트용- withVirtualTime에는 Supplier 안에서 Publisher 생성 (외부 인스턴스 전달 금지)
thenAwait(Duration)— 가상 시간 진행expectNoEvent(Duration)앞에 반드시expectSubscription()먼저StepVerifierOptions.withInitialContext(ctx)— Context 테스트TestPublisher.create()— 수동 이벤트 주입- TestPublisher는
.then()블록 안에서 발행 (구독 설정 후) cancel()— 부분 구독 취소 테스트- 부분 구독 후
verifyComplete()→ 무한 대기 주의 →cancel()사용 .as("설명")— 단계별 설명 추가 (실패 시 메시지에 포함)scenarioName("이름")— 테스트 시나리오 이름 설정collectList()후assertNext(list -> ...)패턴으로 전체 리스트 한 번에 검증
시리즈를 마치며
이 글로 Java Reactive Programming 핵심 정리 시리즈가 완결됩니다. 1편에서 Reactive Streams 명세와 Publisher/Subscriber 구조를 잡고, Mono/Flux/연산자/스케줄러/배압/Sinks/Context를 거쳐 마지막에 테스트까지 — 13편 전체가 하나의 파이프라인처럼 이어졌습니다.
핵심은 하나예요. 데이터는 물처럼 흐르고, 우리는 그 흐름의 길을 선언적으로 설계합니다. 처음엔 낯설었던 Lazy Execution, 배압, Context 전파 방향이 이제는 자연스럽게 느껴지신다면 시리즈의 목적은 달성된 거예요.
시리즈 전체 목록
모든 편을 한눈에 볼 수 있게 완결 목록으로 마무리합니다.
- 1편 — Reactive Programming 입문
- 2편 — Mono 완전 정복
- 3편 — Flux 완전 정복
- 4편 — 연산자 (map·flatMap·filter·reduce 등)
- 5편 — Hot & Cold Publishers
- 6편 — Threading & Schedulers
- 7편 — Backpressure (배압)
- 8편 — Publisher 결합 (zip·merge·concat 등)
- 9편 — Batching·Windowing·Grouping
- 10편 — Repeat & Retry
- 11편 — Sinks
- 12편 — Context
- 13편 — 단위 테스트 (StepVerifier) (현재 글, 완결)