이전 글에서 DynamoDB를 조작할 수 있는 다양한 API를 알아봤습니다. 애플리케이션을 실행하거나 테스트하기 위해 docker로 DynamoDB를 띄우는 것이 번거로우니, 이번에는 Gradle을 활용해 실행 및 테스팅 환경 셋팅을 자동화 하겠습니다. https://github.com/myeongjae-kim/guestbook/tree/complete-first-chapter 이 브랜치는 1부를 끝낸 상태의 스냅샷입니다. 여기서부터 시작합니다.

목차

docker-compose

DynamoDB를 실행하기 위해 다음과 같은 커맨드를 입력했었습니다.

#!/usr/bin/env bash

docker run -p 8000:8000 amazon/dynamodb-local -jar DynamoDBLocal.jar -inMemory -sharedDb

docker-compose를 사용하면 옵션들 저장한 yml파일을 통해서 DynamoDB를 실행할 수 있습니다. docker-compose는 Docker Desktop에 기본으로 탑재되어있고, 1부에서 clone했던 저장소에 이미 docker-compose.yml이 작성되어 있습니다.

version: "3.7"
services:
  dynamodb:
    container_name: dynamodb
    image: amazon/dynamodb-local:latest
    ports:
      - 8000:8000
    restart: always
command: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-inMemory"]

docker-compose로 DynamoDB 실행, 종료하기

guestbook-api 디렉토리에서 docker-compose up을 하면 DynamoDB를 실행합니다. Ctrl+C로 종료할 수 있습니다.

백그라운드로 실행하기 위해서 -d 옵션을 추가합니다. 종료하려면 docker-compose down을 입력합니다.

Gradle로 docker-compose 조작하기

Gradle에 Exec 타입의 task를 추가해서 docker-compose를 조작할 수 있습니다. guestbook-api/guestbook-api-comments/build.gradle의 하단에 다음과 같이 추가합니다.

task dockerComposeUp(type: Exec) {
    group = 'dynamodb'
    commandLine "docker-compose", "up", "-d"
}

task dockerComposeDown(type: Exec) {
    group = 'dynamodb'
    commandLine "docker-compose", "down"
}

./gradlew :guestbook-api-comments:dockerComposeUp./gradlew :guestbook-api-comments:dockerComposeDown으로 docker-compose를 실행, 종료할 수 있습니다.

IDE에도 task가 추가됩니다.

Gradle task on IntelliJ

dependsOn, finalizedBy로 DynamoDB 실행, 종료하기

DynamoDB를 gradle로 조작할 수 있게 되었으니, 다른 task에 의존성을 부여해서 자동으로 DynamoDB를 실행하거나 종료할 수 있습니다. testintegrationTest를 수행하기 전에 자동으로 DynamoDB를 실행하고 테스트들이 끝나면 DynamoDB를 종료하도록 해보겠습니다.

guestbook-api/guestbook-api-comments/build.gradle에 다음과 같이 추가합니다.

test.dependsOn dockerComposeUp
test.finalizedBy dockerComposeDown

integrationTest.dependsOn dockerComposeUp
integrationTest.finalizedBy dockerComposeDown

Gradle의 check task는 test(유닛 테스트)를 실행하고 테스트가 모두 성공하면 integrationTest를 진행하도록 설정되어 있습니다.

testintegrationTest에 같은 의존성이 있으므로 check를 실행하기 전에 DynamoDB를 실행하고 check가 끝나면 DynamoDB를 종료하는 것을 볼 수 있습니다.

bootRun에도 같은 의존성을 추가합니다.

bootRun.dependsOn dockerComposeUp
bootRun.finalizedBy dockerComposeDown

bootRun을 수행중일 때만 DynamoDB를 실행하는 것을 볼 수 있습니다.

로컬에서는 이렇게 환경을 구성한다고 해도, 빌드 파이프라인에서는 미리 docker를 띄워놓고 테스트를 진행하게 될 수도 있습니다. 저는 시스템 환경변수에 GUESTBOOK_REMOTE를 정의하지 않을 때만 의존성을 추가하도록 했습니다.

if (System.env['GUESTBOOK_REMOTE'] == null) {
    test.dependsOn dockerComposeUp
    test.finalizedBy dockerComposeDown

    integrationTest.dependsOn dockerComposeUp
    integrationTest.finalizedBy dockerComposeDown

    bootRun.dependsOn dockerComposeUp
    bootRun.finalizedBy dockerComposeDown
}

Gradle로 테이블 생성/삭제하기

bootRun을 하면 DynamoDB를 자동으로 실행하지만 아직 테이블 생성은 수동으로 해야합니다. 테이블 생성 코드를 애플리케이션에 넣기보단 마찬가지로 Gradle을 통해서 테이블을을 생성/삭제할 수 있으면 좋을 것 같습니다. 저는 curl로 테이블을 생성/삭제하는 코드를 이용해서 task를 작성하겠습니다(curl 대신 aws cli로 해도 됩니다). 이 코드들은 guestbook-api-comments/src/main/resources/scripts 디렉토리의 create-comment-table.sh, delete-comment-table.sh에 작성되어 있습니다.

task createCommentTable (type: Exec) {
    group = 'dynamodb'
    commandLine "bash", "-c", "sleep 3; bash src/main/resources/scripts/create-comment-table.sh"
}

task deleteCommentTable (type: Exec) {
    group = 'dynamodb'
    commandLine "bash", "src/main/resources/scripts/delete-comment-table.sh"
}

createCommentTable.dependsOn dockerComposeUp
deleteCommentTable.finalizedBy dockerComposeDown

if (System.env['GUESTBOOK_REMOTE'] == null) {
    bootRun.dependsOn createCommentTable
    bootRun.finalizedBy deleteCommentTable

    test.dependsOn createCommentTable
    test.finalizedBy deleteCommentTable

    integrationTest.dependsOn createCommentTable
    integrationTest.finalizedBy deleteCommentTable
}

createCommentTable을 실행하기 전에 dockerComposeUp을 실행하고, deleteCommentTable을 실행한 뒤 dockerComposeDown을 실행하도록 했습니다. if문 내부에서도 dockerComposeUp을 createCommentTable로, dockerComposeDown을 deleteCommentTable로 바꿨습니다.

createCommentTable task를 보면 3초동안 sleep한 뒤 테이블을 생성합니다. dockerComposeUp task가 끝난 직후에 createCommentTable task를 수행하는데, 딜레이 없이 바로 요청을 보내면 실패하는 경우가 종종 발생해서 영원과도 같은 3초를 기다린 뒤 테이블 생성 요청을 전송합니다.

어차피 inMemory로 실행하니까 테이블 삭제 안하고 DB를 꺼도 똑같지 않나요? 예 그렇지만.. 2줄씩 맞춰주면 예쁘잖아요.

… 다양성 속에 통일성이 들어 있을 때, 인간은 쾌감을 느낀다. 그 다양성의 통일의 대표적인 예가 바로 ‘대칭’ 아닌가

- ⟨놀이와 예술 그리고 상상력⟩, 진중권

리팩토링

테스트에서 테이블 생성/삭제 코드 제거

1부에서 CommentRepositoryTest에 자랑스럽게 기술부채를 쌓은 곳이 있습니다. 개별 유닛테스트 앞/뒤로 테이블을 생성/삭제하는데, 이제 Gradle을 통해서 자동으로 테이블을 생성/삭제하므로 해당 코드를 없앨 수 있습니다.

아래의 코드를 CommentRepositoryTest에서 삭제합니다.

// TODO: It is weired to create and delete table for each test. Refactor it somehow...
@BeforeEach
void createTable() {
    CreateTableRequest createTableRequest = dynamoDbMapper.generateCreateTableRequest(Comment.class)
            .withProvisionedThroughput(new ProvisionedThroughput(1L, 1L));

    createTableRequest.getGlobalSecondaryIndexes().forEach(
            idx -> idx
                    .withProvisionedThroughput(new ProvisionedThroughput(1L, 1L))
                    .withProjection(new Projection().withProjectionType("ALL"))
    );
    TableUtils.createTableIfNotExists(amazonDynamoDb, createTableRequest);
}

@AfterEach
void deleteTable() {
    DeleteTableRequest deleteTableRequest = dynamoDbMapper.generateDeleteTableRequest(Comment.class);
    TableUtils.deleteTableIfExists(amazonDynamoDb, deleteTableRequest);
}

그리고 테스트를 수행하면 실패하는 테스트가 발생합니다.

@Test
void findComments_ByMentionIdAndOrderByCreatedAtDescDeletedFalse_FoundCommentsInDesignatedOrder() {
    // given
    int size = 10;
    IntStream.range(0, size).forEach(i -> commentRepository.save(Comment.builder()
            .mentionId(1)
            .name("name " + i)
            .content("content " + i).build()));

    // when
    List<Comment> foundComment = commentRepository
            .findAllByMentionIdOrderByCreatedAtAsc(1);

    // then
    then(foundComment.size()).isEqualTo(size);
    IntStream.range(1, size).forEach(i -> {
        Comment prev = foundComment.get(i - 1);
        Comment next = foundComment.get(i);
        then(prev.getCreatedAt().isBefore(next.getCreatedAt())).isTrue();
    });
}

같은 기록(Mention) id를 가진 댓글 10개를 DB에 저장하고, 저장한 댓글을 시간 순서에 맞게 제대로 불러오는지 테스트합니다. JUnit5는 10개의 댓글을 기대했는데 12개가 나왔다고 보고했습니다.

Expecting:
 <12>
to be equal to:
 <10>
but was not.

이전까지는 개별 테스트의 독립성을 매 테스트마다 테이블을 새로 만들면서 보장했지만, 이제는 테이블을 한 번 생성하고 여러 테스트가 같은 테이블을 사용하므로 다른 방법을 사용해야 합니다. 저는 매 테스트마다 양수의 기록ID를 무작위로 생성하게 해서 테스트들이 서로 영향을 주지 않도록 하려고 합니다.

@SpringBootTest(classes = {DynamoDbConfig.class})
class CommentRepositoryTest {
  ...
  private int mentionId;

  @BeforeEach
  void setup() {
      mentionId = (new Random()).nextInt() & Integer.MAX_VALUE;
  }
  ...
}

mentionId 멤버변수를 테스트 클래스에 추가하고 매 테스트마다 무작위로 양의 정수mentionId를 부여합니다. 테스트들에서 1로 적어놨던 기록ID를 mentionId변수로 교체하면 실패 없이 모든 CommentRepositoryTest의 테스트들을 통과합니다. CommentControllerIntTest.java에도 같은 작업을 해줍니다.

CommentRepositoryTest와 CommentControllerIntTest 클래스에서 테이블 생성/삭제 코드를 제거하면 이 클래스들의 멤버 변수인 AmazonDynamoDB와 DynamoDBMapper도 필요가 없어집니다.

    // 이 변수들은 더이상 필요가 없다
    private @Autowired AmazonDynamoDB amazonDynamoDb;
    private @Autowired DynamoDBMapper dynamoDbMapper;

이 변수들을 삭제하면 이전에 재정의했던 DynamoDBMapper Bean을 제거할 수 있습니다.

DynamoDbConfig.java에서 필요없어진 Bean 제거

@Primary가 달려있는 DynamoDBMapper Bean은 사실 spring-data-dynamodb 내부에서 dynamoDB-DynamoDBMapper라는 이름으로 알아서 생성해서 사용합니다. 하지만 CommentRepository만으로는 테이블을 생성하거나 삭제할 수 없기 때문에 @Primary를 붙여 재정의해 우리가 직접 DynamoDBMapper를 조작할 수 있도록 했습니다. 테이블 생성, 삭제를 하지 않아도 되면 DynamoDBMapper를 재정의하지 않아도 될테니 더 좋겠네요.

- spring-data-dynamodb로 쿼리 메서드 사용하기

드디어 아래의 Bean을 DynamoDbConfig.java에서 삭제할 수 있습니다.

public class DynamoDbConfig {
    ...

    @Primary
    @Bean
    public DynamoDBMapper dynamoDbMapper(AmazonDynamoDB amazonDynamoDb) {
        return new DynamoDBMapper(amazonDynamoDb, DynamoDBMapperConfig.DEFAULT);
    }

    ...
}

check task를 실행해 모든 테스트를 통과하는 것을 볼 수 있습니다.

샘플 데이터 추가

API를 모두 완성했습니다! 샘플 데이터를 추가해서 애플리케이션이 실행될 때 이미 댓글을 가지고 있게 만들고 싶지만, SQL DB가 아니기 때문에 resources/data.sql을 사용할 수 없습니다. 저는 profile이 local일 때만 CommandLineRunner를 실행하도록 하고, resources/application.yml에 profile을 local로 설정했습니다.

완성한 애플리케이션 실행

complete-second-chapter 브랜치를 clone해서 애플리케이션을 실행할 수 있습니다.

git clone -b complete-second-chapter --single-branch https://github.com/myeongjae-kim/guestbook.git

# guestbook/guestbook-api
./gradlew :guestbook-api-mentions:bootRun
./gradlew :guestbook-api-comments:bootRun

# guestbook/guestbook-webapp
npm install; npm start;

마무리

2부에 걸쳐서 spring-data-dynamodb 라이브러리로 DynamoDB를 조작하고 테스팅 환경을 구축했습니다.
더 좋은 아이디어가 있다면 꼭 댓글로 남겨주세요! 감사합니다.

추신 1.

그나저나, 왜 DynamoDB를 docker로 띄우나요? 라는 근본적인 질문이 마음속에 있으실 수도 있겠네요. 임베디드 데이터베이스를 쓰면 어떨까요? 관계형 데이터베이스는 h2가 있고, MongoDB는 Embedded MongoDB가 있는데, DynamoDB는 없나요?

비슷한게 있긴 합니다. DynamoDBLocal이 있는데, Baeldung에서 이걸 활용해 DynamoDB의 integration test를 하는 튜토리얼을 올려놓기도 했습니다 그러나…

DynamoDBLocal는 내부적으로 sqlite를 쓰기 때문에 관련 의존성을 추가해야 합니다. 그리고 결정적으로 sqlite때문에 spring-data-dynamodb와 궁합이 별로 좋지 않아서, 적용해보려다가 그만뒀습니다. spring-data-dynamodb 내부에서 사용하는 AmazonDynamoDB클래스의 인스턴스를 DynamoDBLocal에 연결되어 있는 AmazonDynamoDB 인스턴스로 바꿔치기 해야하고, 이 작업을 위해서 테스트 코드뿐만 아니라 main코드에도 분기문이 들어가서 저는 Docker로 DynamoDB를 띄우는 방법을 선택했습니다. 아마존에서 추천하는 방법이기도 합니다. 더 좋은 방법을 알고계시다면 꼭 알려주세요!