콘텐츠로 이동

bluetape4k-dependencies 1.3.0 활용기 Part 2: 서비스 조합하기

작은 로봇 작업자들이 projects, exposed, aws, image, text, leader, graph, javers 모듈 블록과 dependency BOM 보드를 점검하는 3D 작업대 일러스트
BOM은 시작점입니다. 실제 서비스에서는 DB, AWS, leader election, tokenizer, image processing을 어떤 조합으로 가져갈지가 더 중요합니다.

bluetape4k-dependencies를 가져오면 버전 관리는 한결 단순해집니다. 하지만 그다음 질문이 남습니다.

내 서비스에는 어떤 모듈을 넣어야 하지?

이 질문은 생각보다 실전적입니다. Exposed만 쓰는 서비스도 있고, AWS와 같이 묶이는 서비스도 있습니다. 일정 작업을 한 인스턴스에서만 실행해야 하면 Leader가 필요하고, 사용자 입력을 분석해야 하면 Text가 따라옵니다. 이미지 파일을 받는 서비스라면 Image까지 들어옵니다.

dependencies 1.3.0을 가져온 뒤에는 실제 서비스에서 어떤 모듈을 함께 고를지가 더 중요합니다. 여기서는 사용하는 쪽의 선택만 봅니다. 핵심은 “버전을 어디에 적느냐”가 아니라 “어떤 기능 경계에 어떤 모듈을 붙이느냐”입니다.

Gradle에서는 BOM을 platform으로 가져오고, bluetape4k 모듈에는 버전을 적지 않습니다.

dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k:bluetape4k-spring-boot-core")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-spring-boot-jdbc")
implementation("io.github.bluetape4k.aws:bluetape4k-aws-spring-boot")
}

여기서 핵심은 1.3.0을 세 번 적지 않는다는 점입니다. projects, exposed, aws가 각자 다른 릴리즈 번호를 갖고 있어도, 애플리케이션은 BOM 하나를 기준으로 같은 조합을 가져옵니다.

application
-> bluetape4k-dependencies 1.3.0
-> projects 1.11.0
-> exposed 1.11.0
-> aws 0.4.0
-> image 0.3.0
-> text 0.2.1
-> leader 0.4.0

이 그림에서 개발자가 직접 고르는 것은 오른쪽 버전 숫자가 아니라 왼쪽 기능 경계입니다. DB를 쓰는가, AWS를 쓰는가, 분산 작업을 한 번만 실행해야 하는가, 입력 텍스트를 다뤄야 하는가. 그 질문에 따라 모듈을 고르면 됩니다.

첫 번째 예제는 Spring Boot 기반 worker입니다.

상황을 이렇게 잡아보겠습니다.

  • DB에 쌓인 작업을 읽는다.
  • S3에 결과 파일을 쓴다.
  • 같은 작업이 여러 인스턴스에서 동시에 실행되면 안 된다.
  • 작업 이름이나 입력 텍스트를 tokenizer로 전처리한다.
  • write-behind cache를 쓴다면 health에서 상태를 확인하고 싶다.

이 경우 의존성은 대략 이렇게 시작할 수 있습니다.

dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k:bluetape4k-spring-boot-core")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-spring-boot-jdbc")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-cache")
implementation("io.github.bluetape4k.aws:bluetape4k-aws-spring-boot")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-spring-boot")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-redis-lettuce")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-micrometer")
implementation("io.github.bluetape4k.text:tokenizer-korean")
implementation("io.github.bluetape4k.text:text-search")
}

코드의 모양은 이런 식이 됩니다. 실제 API 이름은 프로젝트에 맞게 달라질 수 있으니, 아래 코드는 조합을 설명하기 위한 의사코드입니다.

@Scheduled(fixedDelayString = "PT30S")
fun runPendingJobs() {
leader.runIfLeader("invoice-worker") {
transaction {
val jobs = jobRepository.findPending(limit = 100)
jobs.forEach { job ->
val tokens = koreanTokenizer.tokenize(job.title)
val result = processor.render(job, tokens)
s3Client.putObject(job.resultKey, result)
jobRepository.markDone(job.id)
}
}
}
}

이 예제에서 Leader는 장식이 아닙니다. worker가 세 대 떠 있는데 세 대가 같은 row를 집어가면, 그날의 배포는 기술 블로그가 아니라 반성문 소재가 됩니다. Leader를 쓰면 “이 작업은 지금 누가 대표로 실행하는가”를 코드 경계에 드러낼 수 있습니다.

Exposed cache를 같이 쓴다면 health도 같이 봅니다.

{
"status": "OUT_OF_SERVICE",
"components": {
"exposedCache": {
"status": "OUT_OF_SERVICE",
"details": {
"mode": "WRITE_BEHIND",
"queueDepth": 128,
"flushJobRunning": false,
"lastFlushError": "RedisConnectionFailureException"
}
}
}
}

write-behind cache는 실패를 늦게 알수록 위험합니다. 겉으로는 요청이 성공한 것처럼 지나가는데, 뒤에서 flush가 멈추면 데이터 일관성이 깨질 수 있습니다. 아무 일도 없는 것처럼 보이는 상태가 오히려 더 위험할 때가 있습니다. health에 이 정보가 나오면 운영자는 cache 장애를 애플리케이션 증상으로만 추리하지 않아도 됩니다.

두 번째 예제는 Ktor API입니다.

Ktor API는 worker와 확인할 경계가 다릅니다.

  • HTTP API가 사용자 요청을 받는다.
  • R2DBC나 JDBC로 데이터를 읽고 쓴다.
  • AWS CloudWatch에 metric과 log를 보낸다.
  • 관리 API나 scheduled task에서 leader election이 필요하다.
  • 입력 텍스트를 tokenizer나 blockword 검사에 통과시킨다.

의존성은 다음처럼 잡을 수 있습니다.

dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k:bluetape4k-ktor-core")
implementation("io.github.bluetape4k:bluetape4k-ktor-observability")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-ktor")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-r2dbc")
implementation("io.github.bluetape4k.aws:bluetape4k-aws-ktor")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-ktor")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-redis-lettuce")
implementation("io.github.bluetape4k.text:tokenizer-korean")
implementation("io.github.bluetape4k.text:text-search")
}

Ktor 쪽에서 특히 조심할 부분은 lifecycle입니다. AWS client를 만들고 닫는 책임이 애플리케이션 곳곳에 흩어지면, 테스트에서는 티가 덜 나도 운영에서는 connection pool이나 background thread가 남을 수 있습니다.

의사코드로 보면 원하는 구조는 이쪽입니다.

fun Application.module() {
install(BluetapeAwsCloudWatch) {
namespace = "billing-api"
serviceName = "billing"
}
install(BluetapeAwsCloudWatchLogs) {
logGroupName = "/bluetape4k/billing-api"
logStreamName = environment.config.property("deployment.instanceId").getString()
}
routing {
post("/documents/search") {
val request = call.receive<SearchRequest>()
request.query.requireLength(max = 2_000)
val tokens = koreanTokenizer.tokenize(request.query)
val result = documentService.search(tokens)
call.respond(result)
}
}
}

여기서 중요한 점은 CloudWatch를 “log를 한 줄 더 보내는 코드”로 보지 않는 것입니다. metric namespace, log group, stream 이름, client lifecycle이 애플리케이션 경계 안에 들어옵니다. 이 부분을 plugin으로 모으면 장애가 났을 때 “어떤 인스턴스가 어떤 이름으로 metric을 냈는지”를 따라가기 쉬워집니다.

이미지 파일을 받는 API라면 image 모듈이 들어옵니다.

dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k.image:bluetape4k-images")
implementation("io.github.bluetape4k.image:bluetape4k-images-ocr")
implementation("io.github.bluetape4k.image:bluetape4k-images-vips-api")
implementation("io.github.bluetape4k.image:bluetape4k-images-spring-boot")
}

이미지 쪽에서 흔한 실수는 파일을 너무 빨리 ByteArray로 바꾸는 것입니다.

// 작은 샘플에서는 편하다. 큰 파일에서는 위험하다.
val bytes = multipartFile.bytes
val text = ocr.read(bytes)

업로드 크기가 작을 때는 아무 일도 일어나지 않습니다. 그래서 더 위험합니다. 샘플 파일 몇 개로 테스트하면 멀쩡한데, 운영에서 80MB짜리 TIFF가 들어오는 순간 메모리 사용량이 갑자기 튑니다.

1.3.0 라인에서는 Okio 기반 large-file I/O와 OCR 사용 경로를 같이 봐야 합니다. 의사코드로는 이런 식입니다.

fun extractText(path: Path): OcrResult {
return fileSystem.source(path).buffer().use { source ->
imageReader
.read(source)
.resize(maxWidth = 2_000)
.normalizeForOcr()
.runOcr(language = "kor+eng")
}
}

핵심은 OCR option보다 파일이 메모리에 올라오는 지점입니다. OCR 품질을 올리는 것도 중요하지만, 큰 파일 하나 때문에 서버가 메모리 압박이나 timeout에 걸리면 클라이언트 요청에 OCR 결과를 돌려줄 수 없습니다.

모듈을 고를 때는 저장소 이름보다 서비스 경계를 먼저 보면 편합니다.

서비스 경계먼저 볼 모듈같이 확인할 것
Spring Boot 공통 기반bluetape4k-spring-boot-coreSpring Boot major line
Ktor 기반 APIbluetape4k-ktor-core, bluetape4k-ktor-observabilityKtor major line, plugin lifecycle
Exposed JDBCbluetape4k-exposed-spring-boot-jdbc, bluetape4k-exposed-jdbctransaction helper, datasource 설정
Exposed R2DBCbluetape4k-exposed-spring-boot-r2dbc, bluetape4k-exposed-r2dbccoroutine 경계, connection lifecycle
AWS 연동bluetape4k-aws-spring-boot, bluetape4k-aws-ktorclient lifecycle, emulator/runtime
분산 작업 대표 선출bluetape4k-leader-spring-boot, bluetape4k-leader-ktorprovider/storage 선택, metric
한국어/일본어 텍스트tokenizer-korean, tokenizer-japanese, text-search입력 길이 제한, error response
이미지/OCRbluetape4k-images, bluetape4k-images-ocr파일 크기, memory, native runtime

이 표는 “무조건 다 넣으라”는 뜻이 아닙니다. 오히려 반대입니다. 서비스가 책임지는 경계를 먼저 고르고, 그 경계에 필요한 모듈만 가져오면 됩니다. BOM은 그 모듈들이 같은 버전 조합으로 맞물리도록 돕는 장치입니다.

dependencies 1.3.0으로 올렸다면 최소한 다음 정도는 확인해 두는 편이 좋습니다.

Terminal window
./gradlew compileTestKotlin
./gradlew test
./gradlew dependencyInsight --dependency exposed --configuration runtimeClasspath
./gradlew dependencyInsight --dependency ktor --configuration runtimeClasspath
./gradlew dependencyInsight --dependency aws --configuration runtimeClasspath

모든 프로젝트에서 dependencyInsight를 다 볼 필요는 없습니다. 하지만 DB, AWS SDK, Ktor, Spring Boot처럼 서비스 경계에 가까운 dependency가 움직였을 때는 한 번 확인할 가치가 있습니다. 문제가 생겼을 때 “BOM을 올린 뒤 실제로 어떤 runtime classpath가 되었는지”를 바로 볼 수 있기 때문입니다.

BOM을 가져왔다는 것은 버전 숫자 고민을 줄였다는 뜻입니다. 하지만 서비스 설계가 자동으로 끝났다는 뜻은 아닙니다.

dependencies 1.3.0에서는 Exposed, AWS, Leader, Image, Text 쪽 조합이 실제 서비스에서 쓰기 좋게 정리되었습니다. 좋은 출발점은 이 질문입니다.

  • 내 서비스는 어떤 DB 경계를 갖고 있나?
  • AWS client와 log/metric lifecycle은 누가 관리하나?
  • scheduled job은 여러 인스턴스에서 동시에 돌아도 되나?
  • 사용자 입력은 tokenizer나 blockword 검사 전에 어디서 제한하나?
  • 이미지 파일은 어느 순간 메모리에 올라오나?

이 질문에 답하면 필요한 모듈이 꽤 자연스럽게 정해집니다. 버전은 BOM에 맡기고, 사람은 서비스 경계를 보면 됩니다.

처음 bluetape4k-dependencies를 가져오는 방법부터 보고 싶다면 사용 가이드를 먼저 읽으면 됩니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.