bluetape4k-assertions와 JUnit 5 동시성 안정성 테스트

동시성 버그는 테스트 횟수가 적어서만 생기지 않습니다. 테스트가 실제 코드와 다른 실행 모델을 사용하거나, 값의 의미를 잘못 비교하거나, 실패와 취소를 정상 종료로 처리할 때도 생깁니다. 따라서 테스트 유틸리티를 선택할 때는 “몇 번 실행할까?”보다 먼저 두 가지를 정해야 합니다.
- 참조 동일성(identity)이 필요한가, 값 동등성(structural equality)으로 충분한가?
- 이 코드는 일반 platform thread, virtual thread, 아니면 suspend 함수와 coroutine
Job에서 실행되는가?
bluetape4k-assertions는 첫 번째 질문에 대한 검증 문장을 고정하고, bluetape4k-junit5의 테스터는 두 번째 질문의
실행 모델을 반복해서 검증합니다. 이 글은 두 모듈을 함께 소개하되, stress test를 성능 benchmark로
오해하지 않도록 각 도구의 계약과 실패 경계를 나눠 설명합니다.
시작하기: 테스트 의존성
섹션 제목: “시작하기: 테스트 의존성”dependencies { testImplementation("io.github.bluetape4k:bluetape4k-assertions:$bluetape4kVersion") testImplementation("io.github.bluetape4k:bluetape4k-junit5:$bluetape4kVersion")}bluetape4k-junit5는 bluetape4k-assertions를 전이 의존성(transitive dependency)으로 포함하므로 JUnit 5 테스트 기반을 함께
사용하는 경우에는 두 번째 의존성만으로 assertion 모듈을 사용할 수 있습니다. assertion만 필요한 모듈은
첫 번째 의존성을 직접 선언하면 됩니다.
1. bluetape4k-assertions: 값의 의미를 검증 문장으로 고정하기
섹션 제목: “1. bluetape4k-assertions: 값의 의미를 검증 문장으로 고정하기”shouldBe와 shouldBeEqualTo는 서로 바꿔 쓰지 않는다
섹션 제목: “shouldBe와 shouldBeEqualTo는 서로 바꿔 쓰지 않는다”가장 중요한 구분은 이름이 비슷한 두 assertion입니다.
| assertion | 비교 의미 | 사용할 때 |
|---|---|---|
shouldBe | 참조 동일성(===) | 반드시 같은 객체 인스턴스여야 하는가를 검증할 때 |
shouldBeEqualTo | 구조적/값 동등성(==) | data class, 문자열, 숫자처럼 값이 같으면 되는 경우 |
data class Token(val value: String)
val expected = Token("ready")val actual = Token("ready")
actual shouldBeEqualTo expected // 값이 같으면 통과// actual shouldBe expected // 서로 다른 인스턴스이므로 실패이 구분을 놓치면 동시성 테스트의 계약이 흐려집니다. 공유 캐시가 같은 인스턴스를 반환해야 하는지, 새로 만든 결과가 같은 내용이면 되는지를 assertion만으로 분명하게 표현할 수 없기 때문입니다.
Java 스타일 호출보다 읽기 쉬운 infix assertion
섹션 제목: “Java 스타일 호출보다 읽기 쉬운 infix assertion”bluetape4k-assertions의 핵심 assertion은 infix fun으로 제공됩니다. Java/JUnit의
assertEquals(expected, actual)처럼 인자 순서를 기억해야 하는 호출보다 actual shouldBeEqualTo expected가
검증 대상을 먼저 제시합니다. 왼쪽의 대상, 가운데의 판정 규칙, 오른쪽의 기대값이 왼쪽에서 오른쪽으로
읽히므로 테스트 의도를 빠르게 파악할 수 있습니다. 단순히 코드 길이를 줄이는 문법이 아니라
검증 문장을 만드는 방식입니다.
// Java/JUnit 스타일: expected가 먼저 오므로 인자 순서를 확인해야 한다assertEquals("clinic-1", response.id)assertEquals(200, response.status)assertTrue(response.tags.containsAll(listOf("booking", "stable")))
// Kotlin + bluetape4k-assertions: 대상 -> 규칙 -> 기대값 순서로 읽는다response.id shouldBeEqualTo "clinic-1"response.status shouldBeEqualTo 200response.tags shouldContainAll listOf("booking", "stable")response.id.shouldBeEqualTo("clinic-1")도 동작하지만, 두 값의 관계를 표현하는 이항 assertion은
infix 표기를 사용하면 테스트가 문장처럼 읽힙니다. shouldNotBeNull()처럼 인자를 받지 않는 검증이나
shouldBeNear(expected, tolerance)처럼 여러 인자가 필요한 함수는 일반 호출이 더 자연스럽습니다.
API 응답을 한 줄씩 읽는 검증
섹션 제목: “API 응답을 한 줄씩 읽는 검증”실제 응답을 검증할 때는 assertion을 값의 나열이 아니라 응답 계약으로 읽어야 합니다.
val response = loadResponse()val requiredTags = listOf("booking", "stable")
response.status shouldBeEqualTo 200response.body shouldStartWith "{"response.body shouldContain "\"status\":\"ready\""response.tags shouldContainAll requiredTagsresponse.tags shouldNotContain "error"이 코드는 status의 정확한 값, JSON body의 형태, 필수 tag 집합, 금지된 tag를 각각 나눠 검증합니다.
특히 response.tags shouldContainAll requiredTags는 containsAll의 반환값을 다시 assertTrue로 감싸는
코드보다 “응답 tag가 이 목록을 모두 포함해야 한다”는 요구사항을 그대로 드러냅니다.
실패 타입과 메시지도 분리해서 읽기
섹션 제목: “실패 타입과 메시지도 분리해서 읽기”실패 타입은 assertFailsWith로 고정하고, 반환된 예외의 메시지는 다시 infix assertion으로 검증합니다.
val error = assertFailsWith<IllegalArgumentException> { repository.find("missing")}
error.message shouldContain "missing"이렇게 쓰면 “어떤 예외인가”와 “메시지에 어떤 단서가 있어야 하는가”가 서로 섞이지 않습니다. suspend
블록의 메시지·cause 검증은 coInvoking DSL을 그대로 사용합니다.
API를 종류별로 선택하기
섹션 제목: “API를 종류별로 선택하기”bluetape4k-assertions는 하나의 거대한 assertion보다 테스트에서 반복되는 판단을 종류별로 나눠 제공합니다.
| 종류 | 대표 기능 | 확인할 계약 |
|---|---|---|
| 기본/널 | shouldBe, shouldBeEqualTo, shouldBeNull, shouldNotBeNull | identity, 값 동등성, null 이후 smart cast |
| 수치 | 크기 비교, 범위, 양수/음수, signed/unsigned, shouldBeNear | 허용 오차와 경계값 |
| 컬렉션·배열·맵 | empty/non-empty, contains all/none, 크기, primitive/object array 비교 | 원소 포함, 순서, 중복, deep equality |
| 문자열 | starts/ends/contains, 대소문자 무시 비교 | 문자열 경계와 normalization |
| 날짜·시간 | java.time 타입의 after/before/on-or-after/on-or-before | 시간 순서와 포함 여부 |
| reflection | shouldBeInstanceOf<T>, shouldNotBeInstanceOf<T> | 런타임 타입 계약 |
| 예외 | invoking, coInvoking, 메시지·cause matcher | 동기/정지 함수의 실패 종류와 내용 |
| 집계 | assertSoftly | 여러 assertion을 한 번에 보고하기 |
| Flow/Turbine | 순서 있는 결과, 순서 무시 결과 집합, failure/error, Turbine item | 스트림 순서·중복·취소 계약 |
BigDecimal 비교처럼 표현상의 차이(scale)를 값의 차이로 취급하지 않는 helper도 있습니다. 중요한 것은 함수 이름을 외우는 것이 아니라, 테스트가 검증하려는 의미에 맞는 함수를 고르는 것입니다.
예외 assertion은 취소를 삼키지 않는다
섹션 제목: “예외 assertion은 취소를 삼키지 않는다”동기 예외 타입은 assertFailsWith로, 메시지와 cause는 invoking 또는 coInvoking으로 검증합니다.
val error = assertFailsWith<IllegalArgumentException> { repository.find("missing")}error.message shouldContain "missing"
coInvoking { client.fetch() } .withCause(IOException::class)coInvoking은 예상한 예외가 아닌 CancellationException을 assertion 실패로 바꾸지 않고 다시
던집니다. 테스트 runner가 취소된 작업을 계속 기다리거나 취소를 비즈니스 예외로 오인하지 않도록 하는
경계입니다. shouldNotThrow, withMessage, withMessageMatching, withCause를 조합해 실패 계약을
구체화할 수도 있습니다.
여러 실패와 Flow 결과
섹션 제목: “여러 실패와 Flow 결과”assertSoftly는 각 검증을 등록한 뒤 한 번에 MultipleFailuresError로 보고합니다.
assertSoftly { add { response.status shouldBeEqualTo 200 } add { response.body.shouldNotBeNull() } add { response.headers shouldContainAll expectedHeaders }}assertSoftly를 호출할 때마다 별도 scope 인스턴스가 생성됩니다. 같은 assertion collector를 여러 테스트
스레드가 공유하지 않으면 병렬 실행에서도 실패 목록이 섞이지 않습니다. 저장소 README가 설명하는
virtual-thread-safe 성질도 이 사용 경계에서 이해해야 합니다.
Flow에서는 필요한 비교 방식을 선택합니다.
assertResult는 방출 순서를 그대로 비교합니다.assertResultSet은 순서를 무시하지만 중복 개수는 보존합니다.assertFailure,assertError는 실패 신호를 검증합니다.- Turbine을 함께 사용할 때는
awaitItemAndAssert,awaitItemMatching,awaitErrorOfType을 사용할 수 있습니다.
Flow assertion도 CancellationException을 무시하지 않습니다. 순서를 무시하는 assertion이 필요하더라도
취소 전파라는 실행 계약까지 함께 검증해야 합니다.
2. bluetape4k-junit5: 실행 모델을 바꿔가며 반복하기
섹션 제목: “2. bluetape4k-junit5: 실행 모델을 바꿔가며 반복하기”이 모듈의 stress tester는 throughput이나 latency를 측정하는 benchmark 도구가 아닙니다. 같은 테스트 블록을 여러 worker와 round로 반복해, 공유 상태·실패 전파·취소·정리 계약이 여러 실행에서 계속 유지되는지를 확인하는 도구입니다.
세 테스터는 공통 fluent 설정을 제공하지만, workers와 rounds를 해석하는 방식은 실행 모델별로 다릅니다.
| 설정 | 현재 계약 |
|---|---|
workers | 1..2000; MultithreadingTester/SuspendedJobTester는 worker 실행자 수, StructuredTaskScopeTester는 동시에 실행 중인 작업 수의 상한 |
rounds | 1..1_000_000; MultithreadingTester는 worker당 라운드, 나머지는 등록 블록당 라운드 |
| 블록 미등록 | run() 시 IllegalStateException |
| 자원 정리 | executor, dispatcher, structured scope를 실행이 끝날 때 닫음 |
| 실행량 | MultithreadingTester는 workers * rounds, 나머지는 등록 블록 수 * rounds |
MultithreadingTester: 고정 platform thread pool
섹션 제목: “MultithreadingTester: 고정 platform thread pool”일반 동기 API, 캐시, memoizer, lock, atomic 연산처럼 platform thread 간 경쟁을 확인하려면
MultithreadingTester를 먼저 선택합니다.
val counter = AtomicInteger()
MultithreadingTester() .workers(8) .rounds(100) .add { counter.incrementAndGet() } .run()
counter.get() shouldBeEqualTo 8 * 100고정 크기 platform-thread pool을 만들고, 등록한 블록을 workers * rounds회 round-robin 방식으로 실행합니다. 한
블록에서 Throwable이 발생하면 thread-safe한 MultiException collector에 넣고, executor를 종료한 뒤 실패를
다시 던집니다. 예외가 하나면 원래 예외를, 둘 이상이면 수집된 MultiException을 던지는
방식이라 실패의 원인을 잃지 않습니다.
workers가 등록한 runnable 수보다 작으면 실행을 시작하지 않는 precondition도 있습니다. 여러 블록을
등록할 때는 이 조건을 먼저 확인해야 합니다.
StructuredTaskScopeTester: Java 21/25 virtual thread
섹션 제목: “StructuredTaskScopeTester: Java 21/25 virtual thread”자주 보이는 StructuredScopedTaskTester라는 표기는 현재 클래스 이름이 아닙니다. 소스의 정확한
이름은 StructuredTaskScopeTester입니다.
이 테스터는 기본 virtual-thread factory와 StructuredTaskScope를 사용합니다. virtual thread에서는 고정
worker 수로 전체 실행량을 표현할 필요가 없습니다. 이 예제는 등록한 블록을 800번 실행하도록
rounds(8 * 100)으로 적습니다. workers는 필요할 때 동시에 실행 중인 작업 수를 제한하는 내부
Semaphore 상한으로 사용할 수 있고, 기본 상한으로 충분하면 생략해도 됩니다.
import kotlin.time.Duration.Companion.seconds
StructuredTaskScopeTester() .rounds(8 * 100) .withTimeout(5.seconds) .add { processRequest() } .run()timeout을 설정하면 fork loop 전에 deadline을 계산하고, 기한 안에 joinUntil을 완료하지 못할 때
TimeoutException을 던집니다. scope가 join된 뒤에는 throwIfFailed로 작업 실패를 전파하고 scope와
thread 자원을 정리합니다. virtual thread 전용 경로, ScopedValue 전파, 구조화된 실패 전파와 timeout을
검증할 때 이 테스터를 사용해야 합니다. 다른 실행 모델을 검증하는 테스트를 단지 더 빠르게 만들기 위한
대체재는 아닙니다.
withFactory로 사용자 지정 ThreadFactory를 주입할 수 있으므로, 이름이나 생성 정책이 중요한 테스트도 같은
계약 안에서 확인할 수 있습니다.
SuspendedJobTester: suspend 함수와 coroutine Job
섹션 제목: “SuspendedJobTester: suspend 함수와 coroutine Job”호출 대상이 suspend 함수라면 coroutine 경계를 그대로 보존해 테스트해야 합니다.
runSuspendTest { val results = ConcurrentLinkedQueue<Int>()
SuspendedJobTester() .workers(16) .rounds(100) .add { delay(10) results.add(1) } .run()
results.size shouldBeEqualTo 100}고정 크기 thread-pool dispatcher에서 worker Job을 실행하고, atomic index로 실행 단위를 나눠 배분합니다.
각 블록에서 발생한 예외는 MultiException에 모읍니다. CancellationException은 별도로 다시 던져 coroutine
취소를 정상 결과로 처리하지 않습니다. 모든 Job이 join되고 dispatcher가 닫힌 뒤에 수집한 실패를 보고합니다.
bluetape4k-projects core 모듈에서 가져온 실전 시나리오
섹션 제목: “bluetape4k-projects core 모듈에서 가져온 실전 시나리오”가상 서비스 예제보다 실제 소스와 테스트에 기반한 예제가 정확합니다. bluetape4k-projects의
bluetape4k/core 모듈은 이미 이 테스터로 동시성 유틸리티를 검증합니다. 아래 코드는
AtomicIntRoundrobinTest와
LockSupportTest를
따르므로 실행량과 최종 assertion을 실제 계약에 맞췄습니다.
1. platform thread에서 atomic round-robin counter 검증하기
섹션 제목: “1. platform thread에서 atomic round-robin counter 검증하기”AtomicIntRoundrobin은
카운터를 원자적으로 증가시키고 maximum에 도달하면 처음으로 돌아갑니다. core 테스트는 사용 가능한 processor 수를
순환 범위로 정한 뒤 platform thread 간 경쟁 상황을 재현합니다.
val atomic = AtomicIntRoundrobin(Runtimex.availableProcessors)
MultithreadingTester() .workers(Runtimex.availableProcessors * 2) .rounds(4) .add { atomic.next() } .run()
atomic.get() shouldBeEqualTo 0테스터는 availableProcessors * 2 * 4회 실행합니다. 전체 증가 횟수가 순환 범위의 배수이므로 counter는 0으로
돌아와야 합니다. atomic.get() shouldBeEqualTo 0은 assertEquals(0, atomic.get())보다 대상 -> 규칙 -> 기대값
순서가 분명해 읽기 쉽습니다.
2. virtual thread에서도 같은 실행량 유지하기
섹션 제목: “2. virtual thread에서도 같은 실행량 유지하기”같은 core 클래스를 Java 21+ virtual thread에서 검증합니다. 소스 테스트는 workers를 생략하고 전체 실행 횟수를
rounds에 직접 적습니다.
val atomic = AtomicIntRoundrobin(Runtimex.availableProcessors)
StructuredTaskScopeTester() .rounds(4 * Runtimex.availableProcessors * 2) .add { atomic.next() } .run()
atomic.get() shouldBeEqualTo 0여기서 rounds(4 * availableProcessors * 2)가 전체 실행 횟수입니다. virtual thread 자체가 실행 모델이므로
workers는 필요하지 않습니다. 동시에 실행 중인 작업 수를 제한하는 semaphore 상한이 테스트 대상일 때만
workers를 추가합니다. 이는 benchmark를 위한 임의의 배수가 아니라 실제 core 테스트와 같은 실행량 계산입니다.
3. 하나의 read/write lock 계약을 세 실행 모델에서 검증하기
섹션 제목: “3. 하나의 read/write lock 계약을 세 실행 모델에서 검증하기”LockSupportTest는
ReentrantReadWriteLock을 사용해 platform thread·virtual thread·coroutine Job에서 동일한 invariant를
검증합니다. write section이 16회 실행되면 counter는 16이어야 합니다.
val lock = ReentrantReadWriteLock()var counter = 0
MultithreadingTester() .workers(16) .rounds(2) .add { lock.read { Thread.sleep(10) val current = counter log.trace { "current=$current" } } } .add { lock.write { Thread.sleep(20) counter++ } } .run()
counter shouldBeEqualTo 16소스 테스트는 lock 본문과 마지막 infix assertion을 그대로 두고 tester 설정만 바꿉니다.
| 실행 모델 | 소스 설정 | 실행량 의미 |
|---|---|---|
| Platform thread | workers(16).rounds(2) | 두 블록을 번갈아 실행해 총 32회, write 블록은 16회 |
| Virtual thread | rounds(16) | 두 블록 × 16 rounds, 고정 worker pool 없이 실행 |
| Coroutine Job | workers(16).rounds(16) | 두 suspend 블록 × 16 rounds, workers는 동시 실행 수만 제한 |
핵심은 모든 tester에 같은 workers 값을 적용하는 것이 아닙니다. 실행 모델만 바꾸고 lock invariant는 그대로
검증해야 합니다.
4. CountDownLatch timeout을 명시적인 실패 계약으로 만들기
섹션 제목: “4. CountDownLatch timeout을 명시적인 실패 계약으로 만들기”같은 core 테스트 모음은 느린 작업을 flaky test로 오인하지 않고 withLatch의 timeout 경로를 검증합니다.
assertFailsWith<TimeoutException> { withLatch(1, 100.milliseconds) { Thread.sleep(200) countDown() }}assertFailsWith는 실패 타입을 고정하고, 바깥 테스트는 withLatch의 deadline 동작을 명시합니다. 이 패턴은
처리량 측정이 아니라 문서화된 timeout 경로를 검증할 때 사용해야 합니다.
3. 실행 모델 선택표
섹션 제목: “3. 실행 모델 선택표”세 테스터를 모두 사용하는 것이 목표는 아닙니다. 테스트 대상과 동일한 실행 모델의 도구를 선택해야 합니다.
| 테스트 대상 | 선택 | 이유 |
|---|---|---|
| 일반 동기 함수, thread-safe cache, lock/atomic 조합 | MultithreadingTester | 고정 platform thread 경쟁을 직접 재현 |
| Java 21/25 virtual thread 경로, structured scope, timeout | StructuredTaskScopeTester | virtual thread와 scope lifecycle을 그대로 실행 |
| suspend 함수, dispatcher 전환, cancellation | SuspendedJobTester | coroutine Job과 취소 전파를 보존 |
| 처리량·지연시간 숫자 비교 | 별도 benchmark 도구 | tester의 목적은 안정성 계약 반복이지 성능 측정이 아님 |
예를 들어 suspend fun을 blocking wrapper로 감싸 MultithreadingTester에서 실행하면 테스트가 통과할 수는
있습니다. 하지만 그 결과로는 coroutine cancellation, dispatcher 종료, structured child 관계를 증명하지
않습니다. 실행 모델이 바뀌면 테스트가 증명하는 사실도 바뀝니다.
4. 실패를 읽는 순서
섹션 제목: “4. 실패를 읽는 순서”동시성 테스트가 실패했을 때 round 수를 무작정 늘리지 말고 다음 순서로 원인을 좁혀야 합니다.
- assertion의 의미를 확인합니다. 동일한 인스턴스를 요구했는지, 값만 같으면 되는지부터 봅니다.
- 실패가
CancellationException인지 비즈니스 예외인지 구분합니다. 취소가 collector에 잘못 수집됐는지 확인합니다. MultiException에 모인 원래 예외를 각각 읽습니다. 첫 stack trace만 보고 race라고 단정하지 않습니다.- tester의 실행 모델이 실제 API와 일치하는지 확인합니다. platform thread 테스트로 virtual thread나 coroutine 계약을 대신하지 않습니다.
- 마지막으로
workers와rounds를 조절합니다. 반복 수를 늘리는 것은 원인을 찾은 뒤 재현 확률을 높이는 단계입니다.
이 순서를 지키면 stress test는 “가끔 실패하는 랜덤 테스트”가 아니라, 어떤 계약이 깨졌는지 알려주는 작은 재현기가 됩니다.
참고 링크
섹션 제목: “참고 링크”bluetape4k-assertionsREADME- 기본 assertion 구현
- 예외 assertion과 cancellation
- Softly assertion
- Flow assertion
bluetape4k-junit5READMEMultithreadingTesterStructuredTaskScopeTesterSuspendedJobTesterMultiExceptionAtomicIntRoundrobinAtomicIntRoundrobinTestLockSupportLockSupportTest- Part 2의 기존 개요 글
- 소스 issue #1493 · 구현 PR #1505
마무리
섹션 제목: “마무리”bluetape4k-assertions는 테스트 문장의 의미를 통일하고, bluetape4k-junit5 테스터는 실제 실행 모델의
경쟁과 lifecycle을 반복해서 드러냅니다. 둘을 함께 사용하면 “값을 어떻게 비교했는가”와 “어떤 실행 모델에서
어떻게 실행했는가”를 같은 테스트 안에서 분리해 읽을 수 있습니다.
안정성 테스트의 목표는 rounds 값을 키우는 것이 아닙니다. 실패와 취소를 보존하고, 자원을 정리하며, 올바른
실행 모델에서 같은 계약을 반복해서 검증하는 것입니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.