DynamoDB CRUD example with spring boot

6 분 소요


RECOMMEND POSTS BEFORE THIS

0. 들어가면서

이전 글에 이어서 스프링 부트 애플리케이션에서 DynamoDB에 데이터를 읽고 쓰는 예제를 작성한다. 이 글은 간단한 예시이기 때문에 서비스 레이어(service layer)는 생략하고, 컨트롤러 레이어가 레포지토리(repository) 레이어를 직접 의존한다. AWS DynamoDB를 로컬에 준비하는 과정은 이전 글에서 다뤘다.

1. Project structure

프로젝트 구조는 다음과 같다. 소스 코드를 제외하고 확인이 필요한 코드들은 다음과 같다.

  • https
    • API 요청 예시
  • init
    • 로컬 DynamoDB 컨테이너 마이그레이션 스크립트
  • docker-compose YAML
    • DynamoDB 컨테이너 준비 도커 컴포즈 YAML 파일
.
├── HELP.md
├── build.gradle.kts
├── docker-compose.yml
├── gradlew
├── gradlew.bat
├── https
│   ├── CreateTodo.http
│   ├── DeleteTodo.http
│   ├── GetTodo.http
│   ├── GetTodos.http
│   └── UpdateTodo.http
├── init
│   └── create-table.sh
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── action
    │   │       └── in
    │   │           └── blog
    │   │               ├── ActionInBlogApplication.kt
    │   │               ├── config
    │   │               │   └── DynamoDbConfig.kt
    │   │               ├── controller
    │   │               │   ├── TodoController.kt
    │   │               │   ├── request
    │   │               │   │   └── TodoRequest.kt
    │   │               │   └── response
    │   │               │       └── TodoResponse.kt
    │   │               └── repository
    │   │                   ├── TodoRepository.kt
    │   │                   └── entity
    │   │                       └── TodoEntity.kt
    │   └── resources
    │       ├── application.yml
    │       ├── static
    │       └── templates
    └── test
        └── kotlin
            └── action
                └── in
                    └── blog
                        └── ActionInBlogApplicationTests.kt

2. Dependencies

AWS SDK를 사용할 때 버전에 주의해야 한다. 많은 예제들이 SDK V1을 사용하고 있지만, 24년 7월 31일부터 유지 관리 모드로 변경되었다. AWS는 25년 12월에 서포트를 중지하기 때문에 V2로 마이그레이션하는 것을 권장한다.

https://docs.aws.amazon.com/ko_kr/sdk-for-java/v1/developer-guide/examples-dynamodb-items.html


DynamoDB 관련된 기능을 사용할 때 코드에서 가장 두드러지는 차이는 V1 SDK인 경우 애너테이션에 대문자 B를 사용한다. 예를 들면 @DynamoDBTable 같이 대문자 B를 사용한다. DynamoDB, 스프링 부트 예제를 검색하면 가장 먼저 눈에 띄는 Baeldung 예제도 V1을 사용하고 있다.

@DynamoDBTable(tableName = "ProductInfo")
public class ProductInfo {
    private String id;
    private String msrp;
    private String cost;

    @DynamoDBHashKey
    @DynamoDBAutoGeneratedKey
    public String getId() {
        return id;
    }

    @DynamoDBAttribute
    public String getMsrp() {
        return msrp;
    }

    @DynamoDBAttribute
    public String getCost() {
        return cost;
    }

    // standard setters/constructors
}

V2 SDK를 사용하는 경우 소문자 b를 사용한다. 예를 들면 @DynamoDbBean 같이 소문자 b를 사용한다.

@DynamoDbBean
data class TodoEntity(
    @get:DynamoDbPartitionKey @get:DynamoDbAttribute(value = "PK") var pk: String,
    @get:DynamoDbSortKey @get:DynamoDbAttribute(value = "SK") var sk: String,
    @get:DynamoDbAttribute(value = "id") var id: String?,
    @get:DynamoDbAttribute(value = "title") var title: String?,
    @get:DynamoDbAttribute(value = "content") var content: String?,
) {
    constructor() : this("", "", "", "", "")
}

위에서 언급한 Baeldung 포스트에서 사용한 com.github.derjust.spring-data-dynamodb 의존성은 거의 5년째 유지 보수 되고 있지 않으니 주의하길 바란다.

https://github.com/derjust/spring-data-dynamodb


이 글에서 사용한 의존성은 다음과 같다.

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    // AWS dependencies
    implementation(platform("software.amazon.awssdk:bom:2.28.11"))
    implementation("software.amazon.awssdk:dynamodb-enhanced")
}

dynamodb-enhanced 의존성은 기본 DynamoDB SDK의 고수준(high level) API다. 객체 자동 매핑, 쿼리, 아이템 생성, 변경 등을 좀 더 편리한 방법으로 작성할 수 있다. 해당 의존성에 포함된 컴포넌트들은 보통 클래스 이름에 -Enhanced-가 붙는다.

3. application YAML

활성화 된 프로파일에 따라 연결할 DynamoDB 테이블을 구분한다. “local” 프로파일인 경우 로컬 호스트에 준비된 DynamoDB 컨테이너에 연결한다. 환경 변수는 파이프라인이나 배포 환경에 따라 적절히 주입한다. 테이블 이름도 배포 환경마다 다를 수 있기 때문에 적절히 주입한다.

spring:
  profiles:
    active: local
amazon:
  dynamodb:
    table-name: ActionInBlog_20241001

4. DynamoDbConfig class

DynamoDB와 통신하는 클라이언트 객체를 생성한다. 프로파일에 따라 선택적으로 엔드포인트 URL을 설정한다. AWS 클라우드 환경에선 지역(region)에 따라 엔드포인트 컴포넌트를 사용해 접근할 수 있기 때문에 지역 정보만 설정해도 충분하다. 지역 별 엔드포인트 주소는 AWS 공식 문서에 정리되어 있다.

  1. 지역 정보를 설정한다.
  2. 프로파일이 “local”인 경우 로컬 호스트 DynamoDB 컨테이너에 연결한다.
  3. DynamoDbClient 객체를 내부에 설정한 DynamoDbEnhancedClient 객체를 스프링 빈(bean)으로 함께 등록한다.
package action.`in`.blog.config

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.dynamodb.DynamoDbClient
import java.net.URI

@Configuration
class DynamoDbConfig(
    @Value("\${spring.profiles.active}") private val profiles: String,
) {
    @Bean
    fun dynamoDbClient(): DynamoDbClient {
        val builder = DynamoDbClient.builder().region(Region.AP_NORTHEAST_1) // 1
        if (profiles == "local") { // 2
            builder
                .endpointOverride(URI.create("http://localhost:8000"))
                .credentialsProvider(
                    StaticCredentialsProvider.create(
                        AwsBasicCredentials.create("dummy", "dummy"),
                    ),
                )
        }
        return builder.build()
    }

    @Bean
    fun dynamoDbEnhancedClient(dynamoDbClient: DynamoDbClient): DynamoDbEnhancedClient = // 3
        DynamoDbEnhancedClient
            .builder()
            .dynamoDbClient(dynamoDbClient)
            .build()
}

5. TodoEntity class

엔티티(entity) 클래스를 살펴보자. 애너테이션들은 모두 dynamodb-enhanced 의존성의 API이다. 애너테이션을 사용해 DynamoDB에 저장된 아이템 속성(attribute)을 클래스 객체에 매핑한다. 애너테이션들은 필드 위에 명시하는 것 같지만, Java 코드에선 게터(getter) 위에 명시하는 것과 동일하다. 다음 같은 사항을 명심하길 바란다.

  • 게터 위에 애너테이션을 사용해야 한다.
  • 불변 객체(immutable) 객체인 경우 데이터를 클래스에 매핑하지 못한다. 코틀린의 경우 var 키워드를 사용한다.
  • 기본 생성자가 필요하다.
package action.`in`.blog.repository.entity

import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey

@DynamoDbBean
data class TodoEntity(
    @get:DynamoDbPartitionKey @get:DynamoDbAttribute(value = "PK") var pk: String,
    @get:DynamoDbSortKey @get:DynamoDbAttribute(value = "SK") var sk: String,
    @get:DynamoDbAttribute(value = "id") var id: String?,
    @get:DynamoDbAttribute(value = "title") var title: String?,
    @get:DynamoDbAttribute(value = "content") var content: String?,
) {
    constructor() : this("", "", "", "", "")
}

기본 생성자가 없는 경우 런타임 중 다음과 같은 에러가 발생한다.

2024-10-01T22:40:25.753+09:00 ERROR 50554 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalArgumentException: Class 'class action.in.blog.repository.entity.TodoEntity' appears to have no default constructor thus cannot be used with the BeanTableSchema] with root cause

java.lang.NoSuchMethodException: action.in.blog.repository.entity.TodoEntity.<init>()
	at java.base/java.lang.Class.getConstructor0(Class.java:3585) ~[na:na]
	at java.base/java.lang.Class.getConstructor(Class.java:2271) ~[na:na]
	at software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema.newObjectSupplierForClass(BeanTableSchema.java:380) ~[dynamodb-enhanced-2.28.11.jar:na]
  ... 

6. TodoRequest class

컨트롤러 엔드포인트에서 전달 받은 데이터를 엔티티로 매핑하는 과정이 생략되면 이해하기 어려울 것 같다. 관련된 내용을 살펴보자. DynamoDB에서 파티션을 구분할 때 파티션 키(partiton key)를 사용한다. 동일한 비즈니스 의미를 같는 데이터가 동일한 파티션에 저장된다. 정렬 키(sort key)는 파티션에 저장된 데이터를 정렬할 때 사용한다. 예제에선 다음과 같이 파티션 키와 정렬 키를 정의힌다.

  • 파티션 키
    • “TODO” 문자열로 고정한다.
  • 정렬 키
    • 데이터 정렬을 위해 타임스탬프를 사용한다.
    • 데이터 식별을 위해 UUID를 뒤에 연결한다.
package action.`in`.blog.controller.request

import action.`in`.blog.repository.entity.TodoEntity
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*

data class TodoRequest(
    val title: String?,
    val content: String?,
) {
    fun toEntity(): TodoEntity {
        val uuid = UUID.randomUUID()
        val dateTime =
            LocalDateTime
                .now()
                .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
        val id = "$dateTime-$uuid"
        return TodoEntity(
            pk = "TODO",
            sk = "ID#$id",
            id = id,
            this.title,
            this.content,
        )
    }

    fun toEntity(id: String): TodoEntity =
        TodoEntity(
            pk = "TODO",
            sk = "ID#$id",
            id = id,
            this.title,
            this.content,
        )
}

7. TodoRepository class

레포지토리 객체에서 CRUD 메소드를 알아보기 전에 테이블 객체를 생성하는 코드를 살펴보자. DynamoDB 테이블 설계의 베스트 플랙티스(best practice)는 하나의 물리 테이블을 파티션 키, 정렬 키를 사용해 논리적으로 나누는 것이다. 아래 코드가 언뜻 보기엔 테이블을 구분짓는 것처럼 보이지만, 실제로는 application YAML 파일에 설정된 단일 테이블을 사용한다.

dynamodb-enhanced 의존성을 사용하면 아래 테이블 객체처럼 비즈니스 로직에서 데이터를 논리적으로 구분하고 동일한 객체로 쉽게 다루는 것이 가능하다. 또한 기본 SDK만 사용하는 경우 요청을 만들거나 응답을 변경하는 코드가 다소 복잡해지는 데 dynamodb-enhanced 의존성을 사용하면 이 부분도 쉽게 가능하다.

package action.`in`.blog.repository

import action.`in`.blog.repository.entity.TodoEntity
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable
import software.amazon.awssdk.enhanced.dynamodb.Key
import software.amazon.awssdk.enhanced.dynamodb.TableSchema
import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode
import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional
import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest

@Repository
class TodoRepository(
    @Value("\${amazon.dynamodb.table-name}") val tableName: String,
    dynamoDbEnhancedClient: DynamoDbEnhancedClient,
) {
    val todoEntityTable: DynamoDbTable<TodoEntity> by lazy {
        dynamoDbEnhancedClient.table(
            tableName,
            TableSchema.fromBean(TodoEntity::class.java),
        )
    }

    ... 
}

DynamoDB의 조회 방법인 스캔(scan), 쿼리(query)나 PK, SK 특성을 사용한 조회 등에 대한 내용은 이번 글에서 다루지 않는다. JVM 애플리케이션에서 SDK를 사용해 어떻게 데이터를 다루는지만 이야기 한다. 데이터를 조회하는 코드는 다음과 같다.

  1. PK, SK를 사용해 쿼리를 만들고 이를 사용해 아이템 리스트를 조회한다.
  2. PK, SK를 사용해 단일 객체를 조회한다.
@Repository
class TodoRepository(
    @Value("\${amazon.dynamodb.table-name}") val tableName: String,
    dynamoDbEnhancedClient: DynamoDbEnhancedClient,
) {

    fun getTodos(): List<TodoEntity> { // 1
        val queryRequest =
            QueryEnhancedRequest
                .builder()
                .queryConditional(
                    QueryConditional.sortBeginsWith(
                        Key
                            .builder()
                            .partitionValue("TODO")
                            .sortValue("ID")
                            .build(),
                    ),
                ).build()
        return todoEntityTable
            .query(queryRequest)
            .items()
            .toList()
    }

    fun getTodo(id: String): TodoEntity = // 2
        todoEntityTable.getItem(
            Key
                .builder()
                .partitionValue("TODO")
                .sortValue("ID#$id")
                .build(),
        )

}

아이템 생성은 다음과 같다. 엔티티 객체를 테이블 객체에 그대로 삽입한다.

@Repository
class TodoRepository(
    @Value("\${amazon.dynamodb.table-name}") val tableName: String,
    dynamoDbEnhancedClient: DynamoDbEnhancedClient,
) {

    fun createTodo(todoEntity: TodoEntity) {
        todoEntityTable.putItem(todoEntity)
    }

}

아이템 변경은 다음과 같다. 엔티티 객체를 테이블 객체에 삽입할 때 변경(update) 요청을 만든다. SCALAR_ONLY 모드인 경우 널(null) 데이터 값이 매칭된 속성은 업데이트 하지 않는다.

@Repository
class TodoRepository(
    @Value("\${amazon.dynamodb.table-name}") val tableName: String,
    dynamoDbEnhancedClient: DynamoDbEnhancedClient,
) {

    fun updateTodo(todoEntity: TodoEntity) {
        val updateRequest =
            UpdateItemEnhancedRequest
                .builder(TodoEntity::class.java)
                .item(todoEntity)
                .ignoreNullsMode(
                    IgnoreNullsMode.SCALAR_ONLY,
                ).build()
        todoEntityTable.updateItem(updateRequest)
    }

}

키(key) 객체를 사용해 데이터를 삭제한다.

@Repository
class TodoRepository(
    @Value("\${amazon.dynamodb.table-name}") val tableName: String,
    dynamoDbEnhancedClient: DynamoDbEnhancedClient,
) {

    fun deleteTodo(
        id: String,
    ) {
        todoEntityTable.deleteItem(
            Key
                .builder()
                .partitionValue("TODO")
                .sortValue("ID#$id")
                .build(),
        )
    }

}

8. Run and test

애플리케이션을 실행 후 HTTP 요청을 통해 정상적으로 동작하는지 확인한다. 먼저 도커 컴포즈를 사용해 DynamoDB 컨테이너를 준비한다. 스크립트는 프로젝트 루트(root)에 준비되어 있다.

$ docker compose up -d
[+] Running 3/3
 ✔ Network action-in-blog_default           Created                                 0.0s 
 ✔ Container dynamodb-local                 Healthy                                 2.7s 
 ✔ Container action-in-blog-setup-dynamo-1  Started   

애플리케이션을 실행 후 https 디렉토리에 있는 .http 파일들을 실행하면서 결과를 확인해본다.

  • CreateTodo.http
    • 데이터 생성
  • DeleteTodo.http
    • 데이터 삭제
  • GetTodo.http
    • 데이터 조회
  • GetTodos.http
    • 데이터 리스트 조회
  • UpdateTodo.http
    • 데이터 업데이트

TEST CODE REPOSITORY

RECOMMEND NEXT POSTS

REFERENCE

댓글남기기