DB

Spring boot + MongoDB Multi-Document Transactions

신나게개발썰 2022. 7. 22. 17:21
반응형

MongoDB 4.0 이전 버전에선 Single-Document Transactions만을 지원하였습니다.

RDB는 데이터들을 정규화해서 서로 다른 테이블에 저장하는 반면 MongoDB는 정규화를 거치지 않고 하나의 Document에 몽땅 저장 하기 때문에 MongoDB의 지향점을 생각하면 Single-Document Transactions이면 충분했겠지만 세상은 우리의 뜻(?)대로만 살 수 없기에 4.0 버전부터 Multi-Document Transactions이 지원되게 되었습니다. 여담이지만 4.0 이전 버전에서 Multi-Document Transactions을 위해선 2-Phase-Commits과 같은 방법을 개발자가 직접 처리해야 했습니다.

그럼 버전별로 달라진 점을 간략 설명하자면

v4.0

  • Multi-Document Transactions 지원 시작. 단 replica sets 구성이여야 함
  • MongoDB driver 4.0 이상 버전 사용

v4.2

  • Multi-Document Transactions 지원 범위가 sharded clusters까지 확장
  • MongoDB driver 4.2 이상 버전 사용

v4.4

  • Transactions에서 컬렉션과 인덱스 생성 가능. 단 샤드 간 쓰기 Transactions이 아니여야 함
  • MongoDB driver 4.4 이상 버전 사용

그럼 Multi-Document Transactions을 지원하는 MongoDB를 설치하고 Spring Boot에서 정상적으로 동작하는지 확인 해보도록 하겠습니다.

MongoDB

  • docker-compose 활용
  • Multi-Document Transaction을 위한 MongoDB 3대 replica sets 구성
    • 1대 Master, 2대 Sencondary
  • Transactions에서 컬렉션 생성을 위해 4.4 버전 사용

예제 docker-compose은 해당 게시물 예제 Git 프로젝트에 docker-compose 폴더 안에

  • mongo-4.2 MongoDB 4.2.5 버전
  • mongo-4.4 MongoDB 4.4 버전

버전별 테스트를 위해 위 폴더별로 docker-compose yml 파일을 생성해두었습니다. 폴더 구조는 아래를 참고하시면 되겠습니다.

+--- mongo-4.2
|   +--- docker-entrypoint-initdb.d
|   |   +--- rs-init.sh
|   +--- mongo4_2-docker-compose.yml
+--- mongo-4.4
|   +--- docker-entrypoint-initdb.d
|   |   +--- rs-init.sh
|   +--- mongo4_4-docker-compose.yml

docker-compose.yml

version: '3.8'

services:
  mongodb-4.4-1:
    image: mongo:4.4
    container_name: mongodb-4.4-1
    extra_hosts:
      - "host.docker.internal:host-gateway"
    ports:
      - 27017:27017
    volumes:
      - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD", "/docker-entrypoint-initdb.d/rs-init.sh"]
    entrypoint: ["/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs"]
    depends_on:
      - mongodb-4.4-2
      - mongodb-4.4-3
    links:
      - mongodb-4.4-2
      - mongodb-4.4-3
  mongodb-4.4-2:
    image: mongo:4.4
    container_name: mongodb-4.4-2
    extra_hosts:
      - "host.docker.internal:host-gateway"
    ports:
      - 27018:27017
    entrypoint: ["/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs"]
  mongodb-4.4-3:
    image: mongo:4.4
    container_name: mongodb-4.4-3
    extra_hosts:
      - "host.docker.internal:host-gateway"
    ports:
      - 27019:27017
    entrypoint: ["/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs"]

rs-init.sh

#!/bin/bash

mongo <<EOF
var config = {
    "_id": "rs",
    "version": 1,
    "members": [
        {
            "_id": 1,
            "host": "host.docker.internal:27017",
            "priority": 3
        }
        , {
            "_id": 2,
            "host": "host.docker.internal:27018",
            "priority": 2
        }
        , {
            "_id": 3,
            "host": "host.docker.internal:27019",
            "priority": 1
        }
    ]
};
rs.initiate(config, { force: true });
rs.status();
EOF

mongo <<EOF
use admin; 
db.createUser( { user: "user", pwd: "password", roles: ["root"] });
EOF

Master(mongodb-4.4-1)에서 추가적으로 Shell을 실행하여 replica set 설정 및 관리자 계정을 생성합니다. 정상적으로 replica sets 구성이 되려면 몇 초 정도의 시간이 소요 됩니다.

Spring boot

  • 2.7.1 버전
  • spring-boot-starter-data-mongodb 사용
  • 2.7.1 버전 사용 시 mongodb driver 버전은 4.6.1 사용

https://i.ibb.co/N1rLwn4/mongodb-driver.png

application.yml

mongodb:
  master:
    host: localhost
    port: 27017
    database: board
    auth-database: admin
    username: user
    password: password
  sencondary:
    - host: localhost
      port: 27018
    - host: localhost
      port: 27019

replica set 접속을 위한 접속 정보

Config

MongoDB 접속을 위해 Java Config을 사용하였습니다.

@Configuration
public class MongoDBConfig {
    private final MongoDBProperties mongoDBProperties;

    public MongoDBConfig(MongoDBProperties mongoDBProperties) {
        this.mongoDBProperties = mongoDBProperties;
    }

    @Bean
    public MongoClient mongoClient() {
        Master master = mongoDBProperties.getMaster();

        MongoCredential mongoCredential = MongoCredential.createCredential(master.getUsername()
                , master.getAuthDatabase()
                , master.getPassword().toCharArray());

        List<ServerAddress> serverAddresses = new ArrayList<>();
        serverAddresses.add(new ServerAddress(master.getHost(), master.getPort()));

        List<Sencondary> sencondarys = mongoDBProperties.getSencondary();
        for(Sencondary sencondary : sencondarys) {
            serverAddresses.add(new ServerAddress(sencondary.getHost(), sencondary.getPort()));
        }

        MongoClientSettings mongoClientSettings = MongoClientSettings.builder()
                .credential(mongoCredential)
                .applyToClusterSettings(b -> b.hosts(serverAddresses))
                .build();

        return MongoClients.create(mongoClientSettings);
    }

    @Bean
    public MongoDatabaseFactorySupport<MongoClient> mongoDatabaseFactorySupport(MongoClient mongoClient) {
        return new SimpleMongoClientDatabaseFactory(mongoClient, mongoDBProperties.getMaster().getDatabase());
    }

    @Bean
    public MongoTransactionManager mongoTransactionManager(MongoDatabaseFactorySupport<MongoClient> mongoDatabaseFactorySupport) {
        return new MongoTransactionManager(mongoDatabaseFactorySupport);
    }

    @Bean
    public MongoTemplate mongoTemplate(MongoDatabaseFactorySupport<MongoClient> mongoDatabaseFactorySupport) {
        MongoTemplate mongoTemplate = new MongoTemplate(mongoDatabaseFactorySupport);
        ((MappingMongoConverter) mongoTemplate.getConverter()).setTypeMapper(new DefaultMongoTypeMapper(null));

        return mongoTemplate;
    }
}

Entity

Multi-Document Transaction 테스트를 위한 목적으로 게시물 엔티티를 생성하였습니다.

/**
 * <pre>
 *     MongoDB Multi-Document Transaction 테스트를 위해 생성한 게시물 엔티티
 * </pre>
 */
@Document(collation = "posts")
public class Posts {
    private final String memberId;
    private final String text;
    private final LocalDateTime registrationDate;

    public Posts(String memberId, String text) {
        this.memberId = memberId;
        this.text = text;
        this.registrationDate = LocalDateTime.now();
    }

    public String getMemberId() {
        return memberId;
    }

    public String getText() {
        return text;
    }

    public LocalDateTime getRegistrationDate() {
        return registrationDate;
    }
}

Service

Transaction 테스트를 위해 작성된 간단한 테스트 Service 클래스 입니다.

/**
 * <pre>
 *     MongoDB Multi-Document Transaction 테스트를 위한 구현체
 * </pre>
 */
@Service
public class PostsServiceImpl {
    private final MongoTemplate mongoTemplate;

    public PostsServiceImpl(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }

    /**
     * <pre>
     *     테스트 목적을 위한 게시물 저장 메서드
     * </pre>
     * @param posts
     * @param isTransactionsTest 예외 발생 시 롤백이 수행되는지 확인 하기 위한 변수
     */
    @Transactional(rollbackFor = Exception.class)
    public void savePosts(Posts posts, boolean isTransactionsTest) {
        this.mongoTemplate.insert(Objects.requireNonNull(posts), "posts");
        this.mongoTemplate.insert(Objects.requireNonNull(posts), "posts_transactions");

        if(isTransactionsTest) {
            throw new IllegalStateException("트랜잭션 테스트를 위한 임시 예외 발생");
        }
    }
}

두 번째 매개변수인 isTransactionsTest 를 보시면 해당 변수의 값이 true인 경우 임의의 예외를 발생 시킵니다. 예외가 발생되면 성공적으로 Rollback이 수행되어야 합니다.

Test

정상 Insert 케이스

먼저 정상적으로 Insert가 되는지 확인해보겠습니다. 테스트 코드는 아래를 참고하시면 되겠습니다.

@Test
void mongodb_게시물_정상_케이스() {
    Posts posts = new Posts("success_id", "mongodb Insert!");

    this.postsServiceImpl.savePosts(posts, false);
}

테스트 메서드를 실행 시키면 데이터가 정상적으로 Insert 된 것을 확인 할 수 있습니다.

rs:PRIMARY> db.posts.find()
{ "_id" : ObjectId("62da3b0b11f3b1463dfc85d6"), "memberId" : "success_id", "text" : "mongodb Insert!", "registrationDate" : ISODate("2022-07-22T05:52:11.629Z") }
rs:PRIMARY> db.posts_transactions.find()
{ "_id" : ObjectId("62da3b0b11f3b1463dfc85d7"), "memberId" : "success_id", "text" : "mongodb Insert!", "registrationDate" : ISODate("2022-07-22T05:52:11.629Z") }

Rollback 케이스

그럼 이번엔 강제로 예외를 발생시키면 Rollback이 되는지 확인해보겠습니다. 테스트 코드는 아래를 참고하시면 되겠습니다.

@Test
void mongodb_게시물_임의_예외_발생_롤백_케이스() {
    Posts posts = new Posts("fail_id", "mongodb Rollback!");

    this.postsServiceImpl.savePosts(posts, true);
}

만약 Rollback이 수행 되지 않았으면 member_id 필드에 fail_id라는 값이 존재해야 합니다.

rs:PRIMARY> db.posts.find()
{ "_id" : ObjectId("62da3b0b11f3b1463dfc85d6"), "memberId" : "success_id", "text" : "mongodb Insert!", "registrationDate" : ISODate("2022-07-22T05:52:11.629Z") }
rs:PRIMARY> db.posts_transactions.find()
{ "_id" : ObjectId("62da3b0b11f3b1463dfc85d7"), "memberId" : "success_id", "text" : "mongodb Insert!", "registrationDate" : ISODate("2022-07-22T05:52:11.629Z") }

조회를 해보면 fail_id 값이 없는걸로 봐서 정상적으로 Rollback이 수행 되었음 알 수 있습니다.

그 외

MongoDB v4.2에서 테스트를 수행하려고 하면 아래와 같은 에러가 발생합니다.

Write operation error on server host.docker.internal:27017. Write error: WriteError{code=263, message='Cannot create namespace board.posts in multi-document transaction.', details={}}.; nested exception is com.mongodb.MongoWriteException: Write operation error on server host.docker.internal:27017. Write error: WriteError{code=263, message='Cannot create namespace board.posts in multi-document transaction.', details={}}.

Transactions에서 컬렉션 생성이 v4.4에서 부터 지원하기 때문에 v4.4 이하 버전인 v4.2에서는 위와 같은 에러가 발생합니다. 그러므로 v4.2에서는 사용하려는 컬렉션을 필히 생성해두어야 합니다.

마치며

본 작성글의 예제 Git은 GitHub - sungwookkim/mongodb-multi-transactions에서 확인하실 수 있습니다.

반응형