콘텐츠로 이동

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

작은 로봇 작업자들이 Spring Boot, Ktor, S3, SQS, DynamoDB, StorageService, LocalStack, Floci 블록을 조립해 AWS 예제 실험실을 만드는 3D 일러스트
실전 예제의 목적은 API를 많이 보여 주는 것이 아니라, 애플리케이션 코드가 어디까지 단순해지는지 확인하는 것입니다.

이 글은 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를 도입할 때 어떤 순서로 보면 좋은지, 어디까지 라이브러리에 맡기고 어디부터 애플리케이션이 결정해야 하는지를 살펴봅니다.

Adoption flow from example application through Spring Boot or Ktor path, bluetape4k AWS helpers, local verification, and AWS services
실전 예제에는 프레임워크 코드, helper layer, local emulator 검증 경계가 함께 들어 있습니다.

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 설정을 직접 알 필요가 없습니다.

Spring Boot S3 example flow from HTTP client through S3DocumentController, S3Operations, optional encryption routes, KmsOperations, and S3 endpoint
Spring Boot S3 예제에서는 일반 object API와 선택적인 client-side encryption route가 같은 controller 안에서 분리됩니다.
@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를 검증합니다. 단순한 문서 예제가 아니라 “이 설정으로 실제 호출이 돈다”는 확인 지점까지 들어 있는 셈입니다.

실제 흐름으로 보면 이 예제는 네 단계로 읽으면 됩니다.

  1. 먼저 PUT /s3/documents로 object를 올립니다. Controller는 request body를 ByteArray로 받고, Content-Type header를 그대로 S3Operations.upload()에 넘깁니다.
  2. 이어서 GET /s3/documents로 같은 key를 읽습니다. 이때 controller는 S3 response stream을 직접 다루지 않고, downloadBytes() 결과를 ResponseEntity<ByteArray>로 돌려줍니다.
  3. GET /s3/documents/objects로 prefix 목록을 확인합니다. 운영 코드에서는 이 지점이 “파일 목록 API”나 “batch 대상 찾기”로 이어집니다.
  4. 마지막으로 presigned-get, presigned-put route를 봅니다. 파일 자체는 S3에 두고, 애플리케이션은 제한 시간 URL만 발급하는 구조를 만들 때 필요한 흐름입니다.
Terminal window
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/encryptedtenant 값을 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로 드러납니다.

Spring Boot SQS and SNS example flow from HTTP client through WebFlux API, SQS and SNS operations, fanout, LocalStack, SQS listener container, and ReceivedOrderStore
SQS/SNS 예제는 REST 발행, SNS fanout, listener 처리, manual acknowledgement, retry, DLQ 설정을 하나의 실행 가능한 흐름으로 묶습니다.
@Component
class 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 추상화의 출발점으로 보면 됩니다.

@Repository
class 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로 백엔드를 바꿉니다.

StorageService profile switch from application code to local, S3, and S3 presigned backend implementations
StorageService를 기준으로 애플리케이션 코드는 그대로 두고, 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구현체용도
localLocalStorageServiceDocker 없이 빠르게 개발/테스트한다.
s3S3StorageServiceS3Client로 S3-compatible 백엔드에 저장한다.
s3-presignedS3PresignedStorageService저장은 S3에 하고, getUrl()은 pre-signed GET URL을 반환한다.

이 예제는 bluetape4k-aws-spring-boot를 의존성으로 두지만, 모든 것을 auto-configuration에 맡기지는 않습니다. StorageService라는 애플리케이션 경계를 먼저 정하고, S3Config가 profile별 S3ClientS3Presigner 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 없이 돌고, s3s3-presigned profile은 Floci 기반 S3-compatible endpoint를 사용합니다. 서비스 코드는 하나인데 백엔드 검증은 profile별로 나뉩니다. 실제 서비스에서도 처음에는 local로 빠르게 시작하고, S3 저장이 필요해지면 profile과 infrastructure bean만 바꿀 수 있습니다.

이 워크숍을 실제 서비스에 옮긴다고 생각하면 순서는 더 분명해집니다. 먼저 domain service는 StorageService만 의존합니다.

@Service
class 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에서 확인하면 됩니다.

Terminal window
./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 WritableResource
val 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 URLexamples/aws-spring-boot-s3-examplesS3Operations, LocalStack/Floci 설정, encryption route
Ktor에서 S3 REST client 사용examples/aws-ktor-s3-examplesS3KtorClient, slash가 포함된 key route, streaming download
SQS listener와 retry/ackexamples/aws-spring-boot-sqs-examples@SqsListener, typed payload, manual ack, interceptor event
DynamoDB coroutine repositoryexamples/aws-spring-boot-dynamodb-examplesenhanced async client, table name resolver, repository base class
서비스 코드와 스토리지 백엔드 분리bluetape4k-workshop/aws/storage-abstractionStorageService, local/s3/s3-presigned profile 전환
Spring Cloud AWS 방식 비교bluetape4k-workshop/aws/s3-spring-cloudS3Template, ResourceLoader, Spring 중심 사용감

Part 5의 결론은 단순합니다. bluetape4k-aws를 도입할 때는 “어떤 AWS API를 호출할 수 있나”보다 “애플리케이션 코드가 어느 경계까지 단순해지는가”를 먼저 봐야 합니다. S3 controller, Ktor route, SQS listener, DynamoDB repository, StorageService profile 전환은 결국 같은 질문으로 이어집니다.

반복되는 AWS 연결 코드는 helper와 adapter에 맡기고, 서비스 코드는 도메인 계약을 유지할 수 있는가. Part 5의 예제들은 이 질문에 답하기 위한 출발점입니다.

댓글

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