콘텐츠로 이동

Bluetape4k Projects Part 3: bytes가 지나가는 길

작은 로봇 작업자들이 I/O, Okio, JSON, Protobuf, HTTP, gRPC, Tink 블록 사이로 byte stream을 연결하는 일러스트
byte는 조용히 지나갑니다. 잘못 지나가면 로그보다 먼저 고객이 알아차릴 때가 있습니다.

이 글은 bluetape4k-projects 시리즈의 3편입니다. Part 1에서는 저장소의 공통 기반을 봤고, Part 2에서는 core, coroutines, logging, testing 기반을 봤습니다. 이번에는 서비스 경계로 한 걸음 나갑니다. 파일을 읽고, byte를 압축하고, JSON이나 Protobuf로 바꾸고, HTTP나 gRPC로 보내고, 필요한 곳은 암호화하는 구간입니다.

이 영역은 평소에는 티가 잘 안 납니다. 문자열 하나, header 하나, serializer 옵션 하나가 맞아떨어지면 그냥 지나갑니다. 그런데 어긋나면 문제의 모양이 갑자기 다양해집니다. 깨진 한글, 끝나지 않는 stream, 호환되지 않는 직렬화 payload, 누락된 timeout, 복호화되지 않는 column. 코드 리뷰 때는 작아 보였는데 장애가 되면 원인 후보가 한꺼번에 늘어납니다.

Bytes move through I/O utilities, Okio streams, serializers, transports, storage boundaries, and Tink encryption
Part 3의 모듈은 byte가 service boundary를 지날 때 필요한 변환, 전송, 보호 규칙을 나눠 맡습니다.

bluetape4k-io는 압축, ZIP, 파일 helper, binary serializer를 모은 기본 공구함입니다. LZ4, Snappy, Zstd, GZip, Deflate, BZip2, Zip 같은 압축기를 공통 Compressor 계층으로 다루고, JDK/Kryo/Fory 기반 BinarySerializer도 같은 방향으로 묶습니다.

여기서 중요한 것은 “압축 알고리즘이 많다”가 아닙니다. 서비스마다 try/catch, 빈 byte 처리, 손상 입력 처리, serializer 실패 정책을 제각각 만들지 않게 하는 것입니다. 예를 들어 compress()는 null 또는 empty 입력에 emptyByteArray를 반환하고, 손상 입력을 예외 대신 null로 받고 싶다면 compressOrNull() 계열을 씁니다. 작은 규칙이지만, 이 규칙이 서비스마다 달라지면 나중에 원인 파악이 어려워집니다.

import io.bluetape4k.io.compressor.Compressors
import io.bluetape4k.io.serializer.BinarySerializers
data class OrderSnapshot(val id: String): java.io.Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}
val payload = "invoice-payload".toByteArray()
val compressed = Compressors.Zstd.compress(payload)
val restored = Compressors.Zstd.decompress(compressed)
val bytes = BinarySerializers.Fory.serialize(OrderSnapshot("A-100"))
val snapshot: OrderSnapshot? = BinarySerializers.Fory.deserialize(bytes)

ForyBinarySerializer.fast()KryoBinarySerializer.fast() 같은 fast path도 있습니다. 다만 fast path는 마법 버튼이 아닙니다. 고정 스키마, nullable 지원 여부, wire format 호환성 같은 전제가 맞아야 합니다. README의 경고처럼 FastFory는 기본 Fory codec과 wire format이 다르므로 휘발성 cache처럼 경계가 분명한 곳에서 써야 합니다. 빠른 길은 좋지만, 표지판을 안 읽으면 빠르게 길을 잃습니다.

Okio: stream을 조립 가능한 파이프라인으로

섹션 제목: “Okio: stream을 조립 가능한 파이프라인으로”

bluetape4k-okio는 Square Okio 위에 압축, 암호화, Base64, NIO channel, coroutine-friendly I/O를 얹습니다. Okio의 좋은 점은 모델이 작다는 겁니다. 데이터는 Source에서 읽고 Sink로 씁니다. 그 사이에 Buffer, BufferedSource, BufferedSink, ByteString이 있습니다.

이 작은 모델 덕분에 파일, socket, HTTP body, 테스트 fixture를 비슷한 방식으로 다룰 수 있습니다. 압축을 붙이고, 복원을 붙이고, 암호화를 붙이고, channel adapter를 붙이는 식입니다. 손으로 InputStream을 감싸고 다시 Reader를 만들고 다시 byte[]로 돌아오는 불필요한 변환을 줄여줍니다. 그 변환은 동작해도 오류 지점을 늘립니다.

import io.bluetape4k.io.compressor.Compressors
import io.bluetape4k.okio.compress.asCompressSink
import okio.Buffer
val target = Buffer()
target.asCompressSink(Compressors.Streaming.Zstd).use { sink ->
val source = Buffer().writeUtf8("large payload")
sink.write(source, source.size)
}

README에서 반복해서 강조하는 규칙도 실전적입니다. 리소스를 소유한 source/sink는 use {}로 닫고, 큰 payload에는 one-shot보다 streaming adapter를 우선합니다. coroutine 코드에서는 blocking stream을 대충 감싸기보다 suspended source/sink를 씁니다. 특히 압축/암호화 sink는 close()에서 footer나 ciphertext를 확정하는 경우가 있으니, 닫지 않으면 “거의 다 썼는데 못 읽는 파일”이 됩니다. 닫는 규칙까지가 쓰기 규칙입니다.

JSON과 binary format: 한 계약, 여러 구현체

섹션 제목: “JSON과 binary format: 한 계약, 여러 구현체”

JSON 계열은 bluetape4k-jsonJsonSerializer 계약을 중심으로 봐야 합니다. 구현체는 Jackson 2, Jackson 3, Fastjson2가 맡습니다. Jackson 모듈은 Kotlin-ready mapper 기본값, JsonNode helper, 비동기 JSON parsing, UUID Base62, field masking, Tink 기반 field encryption까지 제공합니다. Fastjson2는 JSON 문자열뿐 아니라 JSONB를 다룹니다.

Avro와 Protobuf는 별도 성격이 있습니다. Avro는 generic/specific/reflect serializer와 schema evolution, 압축 codec 선택을 다룹니다. Protobuf는 gRPC에 묶이지 않는 순수 Protobuf helper입니다. Instant, Duration, LocalDateTime, JavaMoney 변환, Any pack/unpack, allowlist 기반 ProtobufSerializer가 들어 있습니다.

계열기반 라이브러리bluetape4k가 더하는 것
Binary I/OJDK, Kryo, Apache Fory, LZ4/Snappy/Zstd공통 serializer 계약, 압축 조합, 신뢰 경계와 안전한 기본값
Streaming I/OOkio, Java NIOSource/Sink adapter, streaming compression, cipher, suspended I/O
JSONJackson 2/3, Fastjson2Kotlin-ready mapper, JsonSerializer, async parsing, JSONB, field masking/encryption
Schema formatAvro, Protobufschema evolution helper, well-known type 변환, allowlist serializer
HTTP/RPCApache HC5, OkHttp3, Feign, Retrofit2, gRPCcoroutine integration, DSL builder, transport 선택지, 테스트용 in-process 경계
SecurityGoogle TinkAEAD/DAEAD wrapper, associated data, key rotation, JSON/Okio/data 경계와의 연결

이 표를 볼 때 “무엇이 제일 빠른가”보다 “내 경계가 무엇을 약속해야 하는가”를 먼저 보면 좋습니다. public API 응답이면 사람이 읽는 JSON이 중요할 수 있고, 내부 cache면 JSONB나 binary serializer가 더 편할 수 있습니다. 서비스 간 계약이면 Protobuf/gRPC가 자연스럽고, 장기 보관이면 Avro의 schema evolution이 더 중요할 수 있습니다.

HTTP와 RPC: client도 기반이 필요하다

섹션 제목: “HTTP와 RPC: client도 기반이 필요하다”

HTTP client 코드는 처음에는 단순합니다. URL 만들고, header 넣고, 호출합니다. 문제는 운영 옵션이 붙는 순간입니다. connection pool, keep-alive, timeout, retry, cache, async, coroutine bridge, virtual thread, transport 선택이 들어오면 “그냥 호출”은 금방 사라집니다.

bluetape4k-http는 Apache HttpComponents 5, OkHttp3, Vert.x HttpClient, Ktor Client 선택지를 다룹니다. HC5 classic/async, caching client, virtual-thread dispatcher, coroutine async client 같은 구성이 README에 정리되어 있습니다. bluetape4k-feign은 Feign builder DSL과 suspend 함수 기반 호출, transport 선택, Resilience4j 통합을 제공합니다. bluetape4k-retrofit2는 Retrofit builder DSL, Result 패턴, coroutine 지원, CallFactory 선택을 묶습니다.

gRPC, Netty, Vert.x는 조금 더 아래쪽 경계입니다. bluetape4k-grpc는 server/client 추상화와 in-process 테스트 경계를 제공하고, bluetape4k-nettyByteBuf 확장, smart number encoding, reference-counted helper를 둡니다. bluetape4k-vertx는 Vert.x core, coroutine, SQL client, Resilience4j 통합을 한 모듈로 정리했습니다.

이 계열의 목표는 client framework를 하나로 통일하는 것이 아닙니다. 오히려 반대에 가깝습니다. 서비스가 이미 HC5, OkHttp3, Feign, Retrofit2, gRPC, Vert.x 중 하나를 선택했다면, 그 선택을 Kotlin/coroutine/test 환경에서 덜 거칠게 쓰게 하는 쪽입니다.

Tink: 암호화는 직접 발명하지 않는다

섹션 제목: “Tink: 암호화는 직접 발명하지 않는다”

암호화는 “직접 잘 만들면 된다”라는 문장이 가장 위험한 영역입니다. 그래서 bluetape4k-tink는 Google Tink를 감싸는 얇은 Kotlin wrapper로 둡니다. AEAD, Deterministic AEAD, MAC, digest, 통합 TinkEncryptor, Base64 string/byte API, key rotation, Redis 기반 keyset store를 제공합니다.

실무에서 자주 갈리는 기준은 AEAD와 Deterministic AEAD입니다. 일반 데이터 보호에는 매번 다른 ciphertext가 나오는 AEAD가 기본입니다. DB column 검색처럼 equality가 필요할 때만 AES-SIV 계열의 deterministic encryption을 씁니다. 같은 평문이 같은 암호문이 되기 때문에 패턴 노출을 감수해야 합니다. 검색이 편해지는 대신, 같은 값이라는 사실을 암호문이 드러냅니다.

또 하나는 associated data입니다. tenant, user, table, column 같은 context를 ciphertext와 묶으면 같은 key를 쓰더라도 다른 context로 옮겨 복호화하는 일을 막을 수 있습니다. 이건 Exposed/data 모듈의 저장 경계, Okio의 payload envelope, Jackson field encryption과 자연스럽게 이어집니다.

import io.bluetape4k.tink.aead.TinkAeads
val ad = "tenant-a:orders".toByteArray()
val encrypted = TinkAeads.AES256_GCM.encrypt("secret payload", ad)
val decrypted = TinkAeads.AES256_GCM.decrypt(encrypted, ad)

Part 3의 모듈은 한 번에 다 넣는 묶음이 아닙니다. 지금 서비스가 만지는 경계에서 시작하면 됩니다.

지금 보이는 문제먼저 볼 모듈이유
payload를 압축하거나 binary cache에 넣어야 함bluetape4k-iocompressor와 binary serializer 실패 정책을 맞출 수 있습니다
파일/socket/large payload를 streaming으로 다룸bluetape4k-okioSource/Sink 조합으로 압축, 암호화, coroutine I/O를 붙일 수 있습니다
JSON mapper 설정이 서비스마다 다름bluetape4k-json, bluetape4k-jackson2/3Kotlin-ready mapper와 공통 serializer 계약을 공유합니다
내부 저장/큐 payload에 schema가 필요함bluetape4k-avro, bluetape4k-protobufschema evolution, well-known type 변환, allowlist serializer를 제공합니다
HTTP client 설정이 흩어짐bluetape4k-http, bluetape4k-feign, bluetape4k-retrofit2timeout, pool, coroutine, transport 선택을 한곳에서 다룹니다
gRPC나 low-level byte buffer 경계가 있음bluetape4k-grpc, bluetape4k-netty, bluetape4k-vertxRPC/test/in-process 경계와 event-loop 기반 helper를 제공합니다
저장 데이터나 JSON field를 보호해야 함bluetape4k-tinkAEAD/DAEAD, associated data, key rotation을 직접 암호화 코드 없이 씁니다

기준은 단순합니다. byte가 파일로 남는지, 네트워크를 타는지, cache에 들어가는지, 사람이 읽어야 하는지, 검색되어야 하는지를 먼저 정합니다. 그다음 모듈을 고르면 됩니다.

Part 3의 모듈은 byte가 서비스 경계를 지나는 길을 정리합니다. 압축할지, streaming할지, JSON으로 둘지, Protobuf로 묶을지, HTTP로 보낼지, 암호화할지 결정하는 영역입니다.

이 결정을 서비스마다 새로 만들면 작은 차이가 계속 쌓입니다. 어떤 서비스는 timeout이 다르고, 어떤 서비스는 serializer 실패를 null로 삼키고, 어떤 서비스는 암호화 context를 빼먹습니다. 처음에는 개성처럼 보이지만, 운영에서는 대개 리스크가 됩니다.

공통 I/O 기반을 쓰면 모든 문제가 사라지지는 않습니다. 대신 문제를 비슷한 모양으로 만들 수 있습니다. 비슷한 모양의 문제는 찾기 쉽고, 고치기 쉽고, 다음 서비스로 전파하기 쉽습니다. 엔지니어링에서는 이 정도만으로도 꽤 큰 이득입니다. 규칙도 짧습니다. “같은 경계는 같은 방식으로.”

다음 글에서는 data와 infrastructure 모듈을 봅니다. byte가 무사히 도착한 뒤에도, database와 Redis와 Kafka는 각자 성격이 꽤 뚜렷합니다.

댓글

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