Bluetape4k Projects Part 2: 공통 기반의 안쪽

이 글은 bluetape4k-projects 시리즈의 2편입니다. Part 1에서는
이 저장소가 bluetape4k 생태계의 공통 기반이라는 점을 봤습니다. 이번에는 그 기반 안에서도 가장 먼저
손이 가는 모듈을 봅니다. core, coroutines, logging, assertions, junit5, testcontainers입니다.
솔직히 처음 보면 심심합니다. 사용자에게 바로 보여줄 API 서버도 아니고, 대시보드도 아니고, “이걸
넣으면 기능 하나 완성” 같은 느낌도 아닙니다. 하지만 서비스를 몇 개 만들다 보면 계속 등장합니다.
입력값을 확인하고, coroutine 결과를 합치고, 로그에 requestId를 남기고, 같은 경로를 테스트에서 다시
검증하는 일 말입니다.
이 일을 각 서비스가 알아서 만들기 시작하면 금방 어수선해집니다. 검증 함수 이름은 조금씩 달라지고, 로그 문맥은 어떤 coroutine 경계에서 사라지고, 테스트 fixture는 클래스마다 다른 방식으로 컨테이너를 띄웁니다. 처음에는 사소해 보이지만, 어느 순간 각 서비스가 자기만의 작은 framework를 갖기 시작합니다. 이름만 framework가 아닐 뿐, 유지보수 비용은 꽤 정직하게 청구됩니다.

core: 사소한 규칙일수록 흩어지면 귀찮다
섹션 제목: “core: 사소한 규칙일수록 흩어지면 귀찮다”bluetape4k-core는 거창한 모듈이 아닙니다. 그래서 더 자주 쓰입니다. require* 검증 함수, codec,
UTF-8 안전 문자열 처리, range, collection, wildcard, hash, java.time 확장처럼 “없으면 직접 만들게
되는” 조각을 모아둡니다.
가장 먼저 체감되는 것은 검증 함수입니다. 호출자가 넘긴 값을 확인할 때 requireNotBlank, requireGe,
requireContains 같은 함수가 있으면 예외 타입과 메시지 스타일을 맞출 수 있습니다. Kotlin contract
덕분에 검증 뒤 타입도 자연스럽게 좁혀집니다.
import io.bluetape4k.support.requireContainsimport io.bluetape4k.support.requireNotBlank
fun createUser(email: String?) { val validEmail = email .requireNotBlank("email") .requireContains("@", "email")
// validEmail은 non-null String으로 다룰 수 있습니다.}이런 코드는 한 번만 보면 “그냥 직접 쓰면 되지 않나?” 싶습니다. 맞습니다. 한 번이면 직접 써도 됩니다.
문제는 한 번이 아니라는 겁니다. API 입력, 설정값, fixture 이름, image tag, cache key를 검증할 때마다
조금씩 다른 if와 throw가 늘어납니다. core는 그 작은 차이를 줄입니다. 작은 차이가 줄어야 나중에
정말 중요한 차이를 찾을 수 있습니다.
coroutines: async는 쉽고, 정리는 어렵다
섹션 제목: “coroutines: async는 쉽고, 정리는 어렵다”async { ... }를 쓰는 것은 어렵지 않습니다. 어려운 부분은 그다음입니다. 여러 Deferred 중 먼저 끝난
값을 쓸지, 실패한 작업이 있으면 나머지를 취소할지, Flow 처리 순서를 유지할지, coroutine context를
어디까지 넘길지 결정해야 합니다.
bluetape4k-coroutines는 이 애매한 지점을 다룹니다. DeferredValue는 생성하자마자 시작되는 비동기
값을 await()와 blocking value 양쪽에서 읽어야 할 때 씁니다. awaitAnyAndCancelOthers()는 먼저
끝난 작업을 채택하고 나머지를 정리해야 할 때 유용합니다. Flow 쪽에는 chunked, windowed,
mapParallel, takeUntil, race, replay 같은 연산자가 있습니다.
import io.bluetape4k.coroutines.deferredValueOfimport io.bluetape4k.coroutines.mapimport kotlinx.coroutines.delay
val source = deferredValueOf { delay(100) 21}
val doubled = source.map { it * 2 }이 helper들이 필요한 이유는 “코루틴을 더 멋지게 쓰자”가 아닙니다. 실패와 취소를 서비스마다 다르게
해석하지 않으려는 쪽에 가깝습니다. await를 어디서 하고, 실패한 작업을 언제 정리하고, Flow 순서를
어디까지 보장할지 같은 결정이 흩어지면 비동기 코드는 금방 원인 추적이 어려워집니다. workshop의
kotlin/coroutines와 spring-boot/webflux-coroutines 예제는 학습용 코드에서 시작해 실제 HTTP 요청
경로로 이어지는 모양을 보여줍니다.
logging: 로그는 장애 때 읽는 글이다
섹션 제목: “logging: 로그는 장애 때 읽는 글이다”로그는 평소에는 배경처럼 지나갑니다. 장애가 나면 갑자기 본문이 됩니다. 그때 requestId나 orderId가
빠져 있으면 운영자는 원인을 따라가는 데 시간을 씁니다. 재미는 있을 수 있지만 대개 야근입니다.
bluetape4k-logging은 SLF4J를 Kotlin 코드에서 덜 거칠게 쓰게 해줍니다. KLogging, KotlinLogging,
lambda 기반 lazy logging, MDC helper, coroutine MDC 전파, channel 기반 비동기 logging을 묶습니다.
핵심은 단순합니다. 꺼져 있는 log level을 위해 문자열을 미리 만들지 않고, coroutine 경계를 지나도 traceId,
spanId, orderId 같은 문맥을 붙잡아 둡니다.
import io.bluetape4k.logging.KLoggingimport io.bluetape4k.logging.coroutines.withCoroutineLoggingContextimport io.bluetape4k.logging.debug
class OrderService { companion object: KLogging()
suspend fun process(orderId: String) = withCoroutineLoggingContext("orderId" to orderId) { log.debug { "Processing order: $orderId" } }}이 정도 helper는 작아 보이지만, 로그가 남는 방식은 서비스 전체의 운영 습관이 됩니다. 습관이 흩어지면 장애 때만 티가 납니다. 그리고 장애 때 티 나는 문제는 보통 제일 비쌉니다. 로그는 평소에 읽는 글이 아니라, 일이 터졌을 때 반드시 읽혀야 하는 글입니다.
tests: 테스트 fixture도 금방 중복된다
섹션 제목: “tests: 테스트 fixture도 금방 중복된다”테스트 코드는 운영 코드보다 덜 보일 뿐, 중복은 더 심할 때가 많습니다. 임시 디렉터리를 만들고, system property를 바꿨다가 복원하고, stdout을 잡고, coroutine 조건을 기다리고, Redis나 PostgreSQL 컨테이너를 띄웁니다. 한두 번은 괜찮습니다. 서른 번쯤 되면 테스트 코드가 운영 코드만큼 손이 갑니다.
bluetape4k-assertions는 이 층에서 가장 자주 보이는 문장을 담당합니다. Kluent의 아이디어를 차용해
Kotlin 테스트에서 영어 문장처럼 actual shouldBeEqualTo expected, items shouldContainAll expected,
name.shouldNotBeNull() 같은 assertion을 읽을 수 있게 만든 DSL입니다. 중요한 것은 멋진 문법이 아니라,
테스트마다 assertEquals, assertTrue, null check, collection check를 제각각 섞지 않게 만드는 쪽입니다.
import io.bluetape4k.assertions.shouldBeEqualToimport io.bluetape4k.assertions.shouldContainAllimport io.bluetape4k.assertions.shouldNotBeNull
found.shouldNotBeNull()found.name shouldBeEqualTo "TestItem"names shouldContainAll listOf("alpha", "beta")bluetape4k-junit5는 Stopwatch, TempFolder, Output Capture, Random/Faker, System Property 복원,
coroutine 대기, stress tester를 묶습니다. bluetape4k-testcontainers는 Redis, Kafka, PostgreSQL,
MongoDB, Neo4j, WireMock, LocalStack 계열 서버를 wrapper와 singleton launcher로 묶어둡니다.
여기서 봐야 할 것은 “컨테이너를 띄울 수 있다”가 아닙니다. 테스트마다 다른 방식으로 컨테이너를 띄우지 않게 막는 쪽입니다. singleton launcher와 system property export가 있으면 Spring Boot test, HTTP mock, data integration test가 같은 연결 규칙을 공유합니다. fixture가 조용해져야 테스트가 실제 실패 이유를 말하기 시작합니다.
import io.bluetape4k.junit5.coroutines.runSuspendTestimport io.bluetape4k.testcontainers.storage.RedisServer
class CacheTest { private val redis = RedisServer.Launcher.redis
@Test fun `cache entry expires`() = runSuspendTest { val redisUrl = redis.url // exercise coroutine path against shared fixture }}동시성 테스트도 마찬가지입니다. raw thread를 매번 직접 만들기보다 MultithreadingTester,
StructuredTaskScopeTester, SuspendedJobTester 같은 helper를 쓰면 반복 횟수, worker 수, 정리 방식을
한곳에서 맞출 수 있습니다.
| helper | 검증하고 싶은 실행 모델 | 주로 잡아내는 냄새 |
|---|---|---|
MultithreadingTester | 고정 platform thread pool | 공유 mutable state, cache/memoizer, lock, atomic 연산이 일반 thread 경쟁에서도 안전한가 |
StructuredTaskScopeTester | Java 21/25 virtual thread와 StructuredTaskScope | virtual thread 경로에서 실패 전파, timeout, scope close, ScopedValue 전파가 흐트러지지 않는가 |
SuspendedJobTester | coroutine Job과 suspend block | suspend 함수가 dispatcher 위에서 동시에 실행될 때 cancellation, 예외 수집, 공유 상태가 버티는가 |
셋을 다 쓰자는 뜻은 아닙니다. 동기 API라면 MultithreadingTester로 충분한 경우가 많고, virtual thread
전용 경로라면 StructuredTaskScopeTester가 더 정확합니다. suspend API는 SuspendedJobTester로 봐야
합니다. coroutine 코드를 platform thread stress test에 억지로 끼워 넣으면 테스트가 통과해도 별로
안심이 되지 않습니다. 잘못된 도구로 때린 테스트는 실패할 때만 시끄럽고, 통과할 때는 묘하게 조용합니다.
테스트 코드가 덜 창의적이어야 운영 코드가 더 정직해집니다.
그래서 언제 가져오나
섹션 제목: “그래서 언제 가져오나”| 모듈 | 이런 냄새가 날 때 | 같이 보면 좋은 예제 |
|---|---|---|
bluetape4k-core | if (x.isBlank()) throw ...가 여기저기 생김 | core README |
bluetape4k-coroutines | Deferred, Flow, cancellation 처리를 서비스마다 다시 씀 | kotlin/coroutines workshop |
bluetape4k-logging | requestId가 coroutine 경계에서 가끔 사라짐 | logging README |
bluetape4k-assertions | assertEquals, assertTrue, null/collection check가 테스트마다 다른 문체로 섞임 | assertions README |
bluetape4k-junit5 | 임시 파일, 출력 캡처, coroutine 대기 코드가 테스트마다 복붙됨 | junit5 README |
bluetape4k-testcontainers | Redis, Kafka, DB fixture를 클래스마다 다른 방식으로 띄움 | testcontainers README |
| WebFlux coroutine 예제 | HTTP request path에서 coroutine controller/handler 모양을 보고 싶음 | webflux-coroutines workshop |
가져오는 순서는 대단한 설계 문제가 아닙니다. 같은 코드가 두세 번 보이면 core를 봅니다. 비동기 결과
조합과 취소가 중복되면 coroutines를 봅니다. 로그 문맥이 중요한 서비스면 logging을 붙입니다. 테스트
문장이 들쭉날쭉해지면 assertions를 먼저 붙입니다. 그리고 그 경로를 fixture와 외부 의존성까지 포함해
증명해야 하는 순간 junit5와 testcontainers가 들어옵니다. 필요한 순간에 필요한 조각만 집는 것이 이
저장소를 쓰는 가장 편한 방법입니다.
참고 링크
섹션 제목: “참고 링크”- Core README: bluetape4k/core/README.md
- Coroutines README: bluetape4k/coroutines/README.md
- Logging README: bluetape4k/logging/README.md
- Assertions README: testing/assertions/README.ko.md
- JUnit 5 README: testing/junit5/README.md
- Testcontainers README: testing/testcontainers/README.md
- Workshop: kotlin/coroutines
- Workshop: spring-boot/webflux-coroutines
마무리
섹션 제목: “마무리”Part 2의 모듈들은 바로 쓸 수 있는 제품 기능으로 보이지 않을 겁니다. 하지만 서비스 요청이 들어와 입력을 검증하고, coroutine 경로를 지나고, 로그 문맥을 남기고, 같은 경로를 테스트로 증명할 때 계속 등장합니다.
작은 기반이 정리되어 있으면 application layer는 자기 일에 집중합니다. 정리되어 있지 않으면 controller, service, test가 각자 작은 framework를 중복 개발합니다. 이름만 framework가 아닐 뿐, 만들기도 까다롭고 유지하는 데도 리소스가 듭니다. 그래서 이런 기반 모듈은 눈에 잘 띄지는 않아도, 없을 때 훨씬 시끄럽습니다.
다음 글에서는 I/O, serialization, HTTP, encryption을 다룹니다. byte와 wire format이 등장하면, 작은 실수도 갑자기 네트워크를 타고 멀리 갑니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.