Bluetape4k AWS Part 5: 실전 예제로 적용하기

이 글은 bluetape4k-aws 시리즈의 5편입니다. Part 1에서는
프로젝트 개요와 mental model을 봤고, Part 2에서는
core module과 AWS 서비스 지원 현황을
정리했습니다. Part 3에서는 Spring Boot와 Ktor 통합을,
Part 4에서는 Spring Cloud AWS와의 비교를 살펴봤습니다.
이번 글에서는 실제 예제로 내려갑니다. bluetape4k-aws/examples/에는 Spring Boot 4와 Ktor 3로 작성한
S3, SQS, DynamoDB, Exposed 예제가 있고, bluetape4k-workshop/aws에는 애플리케이션 코드에서 스토리지 백엔드를
어떻게 감출 수 있는지 보여 주는 워크숍 예제가 있습니다.
여기서는 예제를 README 목록처럼 훑지 않습니다. 서비스 팀이 bluetape4k-aws를 도입할 때 어떤 순서로 보면 좋은지,
어디까지 라이브러리에 맡기고 어디부터 애플리케이션이 결정해야 하는지를 살펴봅니다.

먼저 S3부터 보는 이유
섹션 제목: “먼저 S3부터 보는 이유”AWS 예제를 처음 볼 때는 S3부터 보는 편이 좋습니다. upload, download, list, delete, presigned URL이 모두 있고, local emulator 설정도 바로 드러납니다. 작은 object API에서 시작해 client-side encryption, content-type 감지, streaming download, pre-signed URL까지 확장되기 때문에 라이브러리가 줄여 주는 반복 작업이 잘 보입니다.
Spring Boot 예제는 S3Operations를 주입받는 WebFlux controller로 시작합니다. Controller는 S3Client builder,
endpoint override, path-style access, presigner 설정을 직접 알 필요가 없습니다.

@RestController@RequestMapping("/s3/documents")class S3DocumentController( private val s3: S3Operations, private val encryptedS3Provider: ObjectProvider<S3ClientSideEncryptionOperations>,) { @PutMapping(consumes = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) suspend fun upload( @RequestParam bucket: String, @RequestParam key: String, @RequestBody bytes: ByteArray, @RequestHeader(HttpHeaders.CONTENT_TYPE, required = false) contentType: String?, ): S3DocumentUploadResponse { val response = s3.upload(bucket = bucket, key = key, bytes = bytes, contentType = contentType) return S3DocumentUploadResponse(bucket = bucket, key = key, eTag = response.eTag()) }}이 코드에서 애플리케이션이 직접 보는 것은 S3Operations입니다. upload 결과를 response DTO로 바꾸고, 도메인 API를
노출하는 일만 controller에 남습니다. 같은 controller에는 encrypted route도 있습니다. tenant 값을 metadata와
encryption context로 넘기면, 실제 envelope encryption은 S3ClientSideEncryptionOperations가 처리합니다.
설정에서는 LocalStack 또는 Floci 같은 emulator를 쓸 때 필요한 차이가 바로 드러납니다.
bluetape4k: aws: s3: region: ap-northeast-2 endpoint-override: http://localhost:4566 path-style-access-enabled: true presign: duration: PT15M client-side-encryption: enabled: true key-id: alias/example-s3실제 AWS에 붙일 때는 endpoint-override를 빼고 AWS SDK credential chain을 쓰면 됩니다. 예제 테스트는 bucket을
만든 뒤 upload, download, list, presigned GET/PUT, delete, deterministic test KMS 기반 client-side encryption
helper를 검증합니다. 단순한 문서 예제가 아니라 “이 설정으로 실제 호출이 돈다”는 확인 지점까지 들어 있는 셈입니다.
실제 흐름으로 보면 이 예제는 네 단계로 읽으면 됩니다.
- 먼저
PUT /s3/documents로 object를 올립니다. Controller는 request body를ByteArray로 받고,Content-Typeheader를 그대로S3Operations.upload()에 넘깁니다. - 이어서
GET /s3/documents로 같은 key를 읽습니다. 이때 controller는 S3 response stream을 직접 다루지 않고,downloadBytes()결과를ResponseEntity<ByteArray>로 돌려줍니다. GET /s3/documents/objects로 prefix 목록을 확인합니다. 운영 코드에서는 이 지점이 “파일 목록 API”나 “batch 대상 찾기”로 이어집니다.- 마지막으로
presigned-get,presigned-putroute를 봅니다. 파일 자체는 S3에 두고, 애플리케이션은 제한 시간 URL만 발급하는 구조를 만들 때 필요한 흐름입니다.
curl -X PUT \ "http://localhost:8080/s3/documents?bucket=demo-bucket&key=docs/hello.txt" \ -H "Content-Type: text/plain" \ --data-binary "hello bluetape4k-aws"
curl \ "http://localhost:8080/s3/documents?bucket=demo-bucket&key=docs/hello.txt"
curl \ "http://localhost:8080/s3/documents/presigned-get?bucket=demo-bucket&key=docs/hello.txt"여기서 핵심은 curl 명령 자체가 아닙니다. Controller에 남은 코드를 보면 S3 SDK의 세부 설정이 업무 API로
새어 나오지 않습니다. Bucket과 key는 여전히 애플리케이션이 결정하지만, presigner 생성, request builder 조립,
LocalStack endpoint 설정, path-style access 같은 반복 작업은 helper와 auto-configuration 쪽으로 내려갑니다.
암호화 route도 같은 관점으로 읽으면 됩니다. PUT /s3/documents/encrypted는 tenant 값을 metadata와 encryption
context에 같이 넣습니다. 운영 서비스에서는 tenant, document type, retention policy처럼 나중에 audit이나 access
control에서 다시 확인해야 하는 값을 여기에 실을 수 있습니다.
val response = encryptedS3().uploadEncrypted( bucket = bucket, key = key, bytes = bytes, metadata = mapOf("tenant" to tenant), encryptionContext = mapOf("tenant" to tenant), contentType = contentType,)물론 모든 서비스가 client-side encryption을 바로 켜야 한다는 뜻은 아닙니다. 이 예제가 보여 주는 것은 선택지를
어디에 둘 것인가입니다. 일반 upload/download API는 그대로 두고, 암호화가 필요한 route에서는
S3ClientSideEncryptionOperations를 명시적으로 사용합니다. 이렇게 하면 “기본 S3 저장”과 “tenant context를 가진
암호화 저장”이 controller 수준에서 분리되고, 실제 envelope encryption 구현은 라이브러리에 맡길 수 있습니다.
Ktor에서는 같은 S3를 다르게 연결한다
섹션 제목: “Ktor에서는 같은 S3를 다르게 연결한다”Ktor 예제는 Spring Boot 예제와 같은 모양을 흉내 내지 않습니다. Ktor에서는 S3KtorClient를 route에 연결하고,
route가 suspend 흐름으로 S3 REST client를 호출합니다.
fun Route.s3DocumentRoutes( s3: S3KtorClient, bucket: String,) { put("/s3/objects/{key...}") { val key = call.s3KeyParameter() val bytes = call.receive<ByteArray>() val response = s3.putObject( bucket = bucket, key = key, bytes = bytes, contentType = call.request.headers["Content-Type"], ) call.respondText("""{"key":"$key","eTag":"${response.eTag}"}""") }}Ktor 예제에서 눈여겨볼 부분은 tailcard path parameter로 slash가 포함된 object key를 보존한다는 점입니다.
/s3/objects/{key...} route는 docs/hello.txt 같은 key를 깨뜨리지 않습니다. 또한 getObjectStream,
putConfigObject, getConfigObject, presignGetObject, presignPutObject 같은 route로 Ktor 서비스가
Spring bean 없이도 같은 S3 작업을 coroutine-first 코드로 유지하는 방식을 확인합니다.
LocalStack 형태의 endpoint를 쓸 때는 path-style 주소를 명시합니다.
val s3 = s3KtorClientOf( region = "ap-northeast-2", endpointOverride = Url("http://localhost:4566"), addressingStyle = S3KtorAddressingStyle.Path,)Part 3에서 정리했듯이 adapter의 모양이 같을 필요는 없습니다. Spring Boot는 auto-configuration과 bean injection이 자연스럽고, Ktor는 route와 plugin lifecycle이 자연스럽습니다. 같은 AWS 문제라도 각 프레임워크에 맞는 방식으로 풀어야 합니다.
SQS, DynamoDB, Exposed는 어떤 순서로 보면 좋은가
섹션 제목: “SQS, DynamoDB, Exposed는 어떤 순서로 보면 좋은가”S3 다음에는 SQS를 보는 편이 좋습니다. SQS는 단순히 sendMessage를 감싸는 문제에서 끝나지 않습니다. receive
loop, visibility timeout, delete 시점, retry, typed payload 변환, manual acknowledgement가 함께 따라옵니다.
Spring Boot SQS 예제에서는 이 경계가 @SqsListener로 드러납니다.

@Componentclass OrderMessageListener( private val store: ReceivedOrderStore,) { @SqsListener(queue = "\${example.aws.sqs.listener-queue:orders}", maxMessages = 1, waitTimeSeconds = 1) fun handle(message: String) { store.record(message) }
@SqsListener(id = "typed-order-listener", queue = "\${example.aws.sqs.typed-listener-queue:typed-orders}") suspend fun handleTyped(order: OrderPayload, acknowledgement: SqsAcknowledgement) { store.record(order) acknowledgement.acknowledge() }}여기서 애플리케이션 코드는 “무엇을 처리할지”에 집중합니다. listener container는 message polling, handler 호출, acknowledgement, 실패한 메시지를 삭제하지 않는 동작, retry/backoff 설정을 맡습니다. 예제에는 SNS → SQS fanout, DLQ redrive policy, interceptor event 기록도 포함되어 있어 운영에서 필요한 hook을 어디에 붙일지 확인합니다.
DynamoDB 예제는 repository 추상화의 출발점으로 보면 됩니다.
@Repositoryclass OrderRepository( enhancedClient: DynamoDbEnhancedAsyncClient, tableNameResolver: DynamoDbTableNameResolver,) : AbstractCoroutinesDynamoDbRepository<Order, String>( enhancedClient = enhancedClient, tableNameResolver = tableNameResolver, entityClass = Order::class.java,) { override val tableName: String = "orders" override fun keyFromId(id: String): Key = Key.builder().partitionValue(id).build() override fun keyFromItem(item: Order): Key = Key.builder().partitionValue(item.id).build()}이 예제는 DynamoDB를 ORM처럼 숨기자는 뜻이 아닙니다. table name, partition key, enhanced client mapping은
명시적으로 둡니다. 대신 save, findById, delete, scan, query처럼 반복되는 coroutine repository 흐름을
공통 base class에 맡깁니다.
Exposed 예제는 AWS와 database registry가 만나는 지점입니다. AWS 설정이나 secret을 읽어 database connection을
구성해야 하는 서비스에서는 aws-exposed와 프레임워크 adapter가 같이 필요합니다. Part 5에서는 깊게 파고들기보다,
S3/SQS/DynamoDB 예제와 같은 구조로 “설정 → helper → framework wiring → 테스트”를 확인하는 용도로 보면 됩니다.
StorageService 워크숍: 서비스 코드가 안정되는 지점
섹션 제목: “StorageService 워크숍: 서비스 코드가 안정되는 지점”bluetape4k-workshop/aws/storage-abstraction은 실제 서비스 팀이 따라 하기 좋은 예제입니다. 여기서는
애플리케이션이 S3를 직접 알지 않습니다. StorageService 하나만 보고, Spring profile로 백엔드를 바꿉니다.

interface StorageService { suspend fun upload(key: String, content: ByteArray, contentType: String): String suspend fun download(key: String): ByteArray suspend fun getUrl(key: String): String suspend fun delete(key: String)}Profile은 세 가지입니다.
| Profile | 구현체 | 용도 |
|---|---|---|
local | LocalStorageService | Docker 없이 빠르게 개발/테스트한다. |
s3 | S3StorageService | S3Client로 S3-compatible 백엔드에 저장한다. |
s3-presigned | S3PresignedStorageService | 저장은 S3에 하고, getUrl()은 pre-signed GET URL을 반환한다. |
이 예제는 bluetape4k-aws-spring-boot를 의존성으로 두지만, 모든 것을 auto-configuration에 맡기지는 않습니다.
StorageService라는 애플리케이션 경계를 먼저 정하고, S3Config가 profile별 S3Client와 S3Presigner bean을
직접 구성합니다. 이 구조에서는 서비스 도메인의 “파일을 저장한다”는 계약은 안정적으로 유지하고,
로컬 파일 시스템, S3, pre-signed URL은 infrastructure 구현으로 밀어낼 수 있습니다.
S3Config는 테스트와 로컬 실행에서 FlociServer.Launcher.floci를 사용합니다.
@Configuration(proxyBeanMethods = false)@Profile("s3 | s3-presigned")class S3Config { companion object { val floci: FlociServer = FlociServer.Launcher.floci }
@Bean fun s3Client(): S3Client = S3Client.builder() .endpointOverride(floci.awsEndpoint) .region(Region.of(floci.regionName)) .credentialsProvider(staticCredentialsProviderOf(floci.awsAccessKey, floci.awsSecretKey)) .build()}S3Client는 blocking client이므로 구현체는 withContext(Dispatchers.IO)로 감쌉니다. 이 부분도 예제에서 봐야 할
지점입니다. “S3를 쓴다”에서 끝나지 않고, Kotlin coroutine 서비스에서 blocking I/O를 어디로 격리할지까지 다룹니다.
override suspend fun upload(key: String, content: ByteArray, contentType: String): String = withContext(Dispatchers.IO) { ensureBucketExists() s3Client.putObject( { req -> req.bucket(bucketName).key(key).contentType(contentType) }, RequestBody.fromBytes(content) ) "https://s3.amazonaws.com/$bucketName/$key" }테스트도 profile별로 분리됩니다. local profile은 Docker 없이 돌고, s3와 s3-presigned profile은 Floci 기반
S3-compatible endpoint를 사용합니다. 서비스 코드는 하나인데 백엔드 검증은 profile별로 나뉩니다. 실제 서비스에서도
처음에는 local로 빠르게 시작하고, S3 저장이 필요해지면 profile과 infrastructure bean만 바꿀 수 있습니다.
이 워크숍을 실제 서비스에 옮긴다고 생각하면 순서는 더 분명해집니다. 먼저 domain service는 StorageService만
의존합니다.
@Serviceclass DocumentService( private val storage: StorageService,) { suspend fun saveDocument(documentId: String, bytes: ByteArray): String = storage.upload( key = "documents/$documentId.bin", content = bytes, contentType = "application/octet-stream", )}이 코드는 local, s3, s3-presigned 중 어느 profile이 켜졌는지 모릅니다. 그래서 controller나 service test는
대부분 local profile로 빠르게 돌릴 수 있습니다. S3 호환성은 별도 integration test에서 확인하면 됩니다.
./gradlew :aws-storage-abstraction:test
./gradlew :aws-storage-abstraction:bootRun \ --args='--spring.profiles.active=local'
./gradlew :aws-storage-abstraction:bootRun \ --args='--spring.profiles.active=s3-presigned'Profile별로 바뀌는 것은 StorageService 구현체입니다. local은 {basePath}/{key}에 파일을 쓰고 file:// URL을
돌려줍니다. s3는 object를 S3-compatible 백엔드에 저장하고 일반 S3 URL 형태를 반환합니다. s3-presigned는
저장은 동일하게 S3에 하되, getUrl()에서 S3Presigner로 제한 시간 GET URL을 만듭니다.
override suspend fun getUrl(key: String): String = withContext(Dispatchers.IO) { val presignRequest = GetObjectPresignRequest.builder() .signatureDuration(Duration.ofMinutes(presignDurationMinutes)) .getObjectRequest { req -> req.bucket(bucketName).key(key) } .build()
s3Presigner.presignGetObject(presignRequest).url().toString() }이 차이는 작아 보이지만 운영에서는 꽤 큽니다. 내부 관리자 화면은 download()로 바로 bytes를 읽어도 되고, 외부
사용자에게는 getUrl()로 pre-signed URL만 내려줄 수 있습니다. 서비스 코드는 StorageService 계약을 유지하고,
배포 환경이나 보안 요구에 따라 profile과 구현체를 바꾸면 됩니다.
또 하나 볼 부분은 테스트 전략입니다. 이 예제는 모든 테스트를 실제 AWS에 붙이지 않습니다. local profile은
파일 시스템으로 빠르게 검증하고, S3 profile은 Floci/LocalStack-compatible endpoint로 bucket 생성과 object I/O를
검증합니다. 실제 AWS 계정은 release 전 smoke test나 별도 환경 검증에 남겨 둘 수 있습니다. 매번 클라우드 비용과
credential 설정에 묶이지 않으면서도, S3 API 호환성은 놓치지 않는 구조입니다.
Spring Cloud AWS 예제는 어디에 쓰나
섹션 제목: “Spring Cloud AWS 예제는 어디에 쓰나”bluetape4k-workshop/aws/s3-spring-cloud도 같이 보면 좋습니다. 이 예제에서는 Spring Cloud AWS의 S3Template과
Spring ResourceLoader로 S3를 사용하는 방식을 확인합니다.
s3Template.store("my-bucket", "hello.txt", "Hello, S3!")
val resource = resourceLoader.getResource("s3://my-bucket/hello.txt") as WritableResourceval content = resource.inputStream.bufferedReader().readText()이 예제의 목적은 Part 4를 반복하는 것이 아닙니다. Spring Cloud AWS가 Spring Resource abstraction과 template로
어떤 사용감을 제공하는지 실제 코드로 확인하는 데 있습니다. Spring Boot 애플리케이션 안에서 Spring 방식이 충분히
자연스럽다면 이 흐름이 좋은 선택입니다. 반대로 Ktor, coroutine helper, explicit SDK dependency, StorageService
같은 애플리케이션 경계가 먼저라면 bluetape4k-aws 예제를 보는 편이 더 빠릅니다.
어떤 예제부터 실행하면 좋은가
섹션 제목: “어떤 예제부터 실행하면 좋은가”처음부터 모든 예제를 돌릴 필요는 없습니다. 지금 풀려는 문제에 맞춰 순서를 정하면 됩니다.
| 목적 | 먼저 볼 예제 | 확인할 것 |
|---|---|---|
| S3 upload/download/presigned URL | examples/aws-spring-boot-s3-examples | S3Operations, LocalStack/Floci 설정, encryption route |
| Ktor에서 S3 REST client 사용 | examples/aws-ktor-s3-examples | S3KtorClient, slash가 포함된 key route, streaming download |
| SQS listener와 retry/ack | examples/aws-spring-boot-sqs-examples | @SqsListener, typed payload, manual ack, interceptor event |
| DynamoDB coroutine repository | examples/aws-spring-boot-dynamodb-examples | enhanced async client, table name resolver, repository base class |
| 서비스 코드와 스토리지 백엔드 분리 | bluetape4k-workshop/aws/storage-abstraction | StorageService, local/s3/s3-presigned profile 전환 |
| Spring Cloud AWS 방식 비교 | bluetape4k-workshop/aws/s3-spring-cloud | S3Template, ResourceLoader, Spring 중심 사용감 |
Part 5의 결론은 단순합니다. bluetape4k-aws를 도입할 때는 “어떤 AWS API를 호출할 수 있나”보다 “애플리케이션
코드가 어느 경계까지 단순해지는가”를 먼저 봐야 합니다. S3 controller, Ktor route, SQS listener, DynamoDB
repository, StorageService profile 전환은 결국 같은 질문으로 이어집니다.
반복되는 AWS 연결 코드는 helper와 adapter에 맡기고, 서비스 코드는 도메인 계약을 유지할 수 있는가. Part 5의 예제들은 이 질문에 답하기 위한 출발점입니다.
Source links
섹션 제목: “Source links”- Spring Boot S3 example README: README.ko.md
- Spring Boot S3 controller: S3DocumentController.kt
- Ktor S3 example README: README.ko.md
- Ktor S3 routes: S3KtorServerExamples.kt
- Spring Boot SQS example README: README.ko.md
- SQS listener example: ReceivedOrderStore.kt
- Spring Boot DynamoDB example README: README.ko.md
- DynamoDB repository example: OrderRepository.kt
- Storage abstraction workshop: README.ko.md
- StorageService contract: StorageService.kt
- S3 profile configuration: S3Config.kt
- Spring Cloud AWS S3 workshop: README.ko.md
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.