환경

  • Java 11
  • Spring boot (v.2.7.10)
  • MongoDB (v.6.0.5)
  • Docker


트랜잭션(Transaction)이란?

트랜잭션은 데이터베이스 작업의 논리적인 단위로, 단일 작업들의 그룹을 의미한다. 단일 작업은 쉽게 말해 더 이상 나눌 수 없는 최소한의 처리 단위이다. 예를 들어 개발자 A씨는 xx은행을 통해 친구 B씨에게 300불을 송금해야할 일이 생겼다. 그래서 송금을 하던 도중, 300불이 출금되었는데 xx은행에서 오류가 발생해 A씨의 계좌에선 300불이 빠져나갔지만, B씨의 계좌에 300불을 받지 못했다. 이럴 경우엔 우리는 xx은행을 다시는 사용하지 못하게 되는 계기가 되고, 300불은 A씨도 B씨도 아닌 어딘가로 사라진다.

앞 예제에서 A씨의 계좌에서 300불이 출금되는 현상을 하나의 단일 작업로 볼 수 있고, B씨의 게좌에 입금이 되는 것을 또 하나의 단일 작업으로 볼 수 있어, 송금을 트랜잭션으로 묶을 수 있다. 실패를 방지하기 위해 우리는 트랜잭션을 사용한다. 만약 정상적으로 트랜잭션이 완료되었다면, B씨의 계좌에서 300불 입금하면서 오류가 발생했을 것이고, 오류가 발생했으면 롤백(rollback: 트랜잭션 시작 이전의 상태로 돌리는 행위)이 되어 A씨 계좌에서 300불이 출금되지 않았어야 한다.

반대로 오류가 없었다면 트랜잭션 A씨의 계좌에서 300불이 정상적으로 빠져 나간 뒤에는 B씨의 계좌에 300불이 입금되었을 것이다. 올바르게 데이터가 데이터베이스에 반영이 되었을 경우 이를 커밋(commit) 이라고 한다.



트랜잭션의 특성 (ACID)

ACID는 Atomicity(원자성), Consistency(일관성), Isolation(독립성), Durability(지속성)의 약자로, 트랜잭션의 특성을 말한다. 이 네 개의 속성을 모두 만족시켜야 트랜잭션이라고 할 수 있다. 위 예시에서 정상적인 트랜잭션이 진행되었다고 가정하고 ACID가 뭔지 알아보자.

  1. Atomicity(원자성): Transacion 내의 단일 테스크들이 모두 성공을 하거나 모두 실패를 해야 한다.
    • 송금 작업이 원자성의 특성을 가지고 있다. 출금과 입금은 하나의 트랜잭션으로 묶여있고, 출금이 되고 입금까지 다 되어야 성공이 되고 하나의 작업이라도 실패하면 rollback되어야 한다.
  2. Consistency(일관성): 일관성은 트랜잭션이 실행되기 전, 트랜잭션이 실행된 후의 상태가 일관되어야 한다.
    • A씨의 계좌 잔액 + B씨의 계좌 잔액의 합이 트랜잭션이 발생하기 전과 트랜잭션이 발생한 후와 일치해야 일관성 특성을 만족하게 된다.
  3. Isolation(독립성): 여러 개의 트랜잭션이 동시에 실행될 때, 각각의 트랜잭션이 다른 트랜잭션에 영향을 주면 안 된다.
    • 다른 제3자가 송금 작업을 수행할 경우에, A와 B의 계좌에선 영향을 주어서는 안 된다.
  4. Durability(지속성): 트랜잭션이 성공한 이후에, 그 결과가 영구적으로 저장되어야 한다.
    • 송금 작업이 성공적으로 완료되었으면 A씨의 계좌에선 300불이 출금되고 B씨의 계좌에서는 300불이 영구적으로 저장되어야 한다.


Spring Boot에서 MongoDB 트랜잭션 사용하기

@Transactional

Spring Boot에서 위와 같은 예시를 코드로 만들어 @Transactional 어노테이션을 사용해 트랜잭션을 적용시켜보았다.

@Service
@RequiredArgsConstructor
@Transactional
public class TransactionService {
    
    private final BankAccountService bankAccountService;

    public boolean testTransaction(String fromBankAccountNumber, String targetBankAccountNumber, int amount){

        BankAccount fromBankAccount = bankAccountService.findBankAccount(fromBankAccountNumber);
        ...
        ..
        .
        //송금 코드..
    }
}

2023-05-28 14:31:20.006 ERROR 1921 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.InvalidDataAccessApiUsageException: Command failed with error 20 (IllegalOperation): 'Transaction numbers are only allowed on a replica set member or mongos' on server localhost:27017. The full response is {"ok": 0.0, "errmsg": "Transaction numbers are only allowed on a replica set member or mongos", "code": 20, "codeName": "IllegalOperation"}; nested exception is com.mongodb.MongoCommandException: Command failed with error 20 (IllegalOperation): 'Transaction numbers are only allowed on a replica set member or mongos' on server localhost:27017. The full response is {"ok": 0.0, "errmsg": "Transaction numbers are only allowed on a replica set member or mongos", "code": 20, "codeName": "IllegalOperation"}] with root cause

MongoDB 공식 문서를 살펴보면, For Transactions on MongoDB 4.2 deployments (replica sets and sharded clusters), clients must use MongoDB drivers updated for MongoDB 4.2.라고 나와있다.


MongoDB에서 레플리카 셋(replica set)과 샤드 클러스터(shard cluster)

레플리카 셋을 직역하자면 '복제 집합'이다. MongoDB에서 레플리카 셋은 동일한 데이터를 관리하는 인스턴스 그룹이라고 생각하면 된다. 레플리카 셋은 중복성을 제공하고 가용성을 높인다.

레플리카 셋은 하나의 primary 서버와 하나 이상의 secondary 서버로 구성한다. Primary 서버에서는 모든 Write operation를 담당한다. 즉 데이터가 생성되고, 업데이트되고 삭제되는 부분을 담당한다.Secondary 서버에서는 데이터 복사본을 유지 관리 담당한다. Primary 서버로부터 동일한 데이터를 저장하고 변경된 사항을 비동기로 복제한다. Transacion 관점에서 봤을 경우, 롤백이 일어날 때, Primary서버와 Secondary서버 간의 데이터 복제를 통해 롤백이 되어 데이터 일관성을 유지한다.

샤딩은 데이터를 여러 시스템에 분산시키는 방법이다. MongoDB에서 샤딩은 데이터 세트 또는 처리량이 많은 애플리케이션이 있을 경우 사용한다. 샤드 클러스터는 하나 이상의 mongos(router역할), 두 개 이상의 샤드, 그리고 Config server로 샤드 클러스터를 구성한다.

  • Shard: 샤드는 샤딩된 데이터로 구성되어 있다. 각 샤드는 레플리카 셋으로 배포할 수 있다.
  • Mongos: mongos는 router 역할을 하고 클라이언트의 요청을 받는 샤드 클러스터의 인터페이스 역할을 한다.
  • Config server: 구성 서버에는 메타 데이터와 클러스터에 대한 설정을 담고 있다.

결정적인 요소는 데이터의 크기, 처리량, 성능 요구 사항, 확장 가능성 등과 같은 요구 사항을 고려하여 선택해야 한다. 일반적으로 작은 규모의 애플리케이션에서는 레플리카 셋을 사용하고, 대규모 및 고성능 환경에서는 샤드 클러스터를 사용하는 경향이 있다.


레플리카 셋은 어떻게 이루어 질까?

레플리카 셋에서의 node들은 위한 투표권을 가지게 되는데, 만약 primary에서 서버 장애가 생겼을 시 투표권으로 secondary 멤버 중 한 노드가 primary가 된다. 레플리카 셋은 최대 50개까지 노드를 가질 수 있으나 투표권을 가질수 있는 노드는 7개까지만 구성이 가능하다. 선거를 통해 primary로 선정된 노드보다 최신 데이터를 가지고 있는 secondary가 존재할 시, 최신 데이터를 가진 secondary node는 선정된 primary 데이터를 롤백한다. 그래서 MongoDB를 사용할 때, 롤백을 해서 데이터가 손실되는 경우가 발생할 수 있다. 롤백을 해서 데이터가 손실되는 경우엔 WriteConcern옵션을 조정해서 Rollback을 방지해야한다.

왜 MongoDB에서는 레플리카 셋이 필요할까?

트랜잭션은 논리적 세션의 개념으로 만들어졌기 때문에, 레플리카 셋 환경에서만 가능한 oplog와 같은 기술이 필요해 했다고 Mongodb 직원이 대답해줬다. (oplog : Operation Log의 약자로, 레플리카 셋의 데이터 동기화를 위해서 내부에서 발생하는 로그를 기록한 것)

위에서 트랜잭션 예시와 MongoDB에서의 트랜잭션을 위한 레플리카 셋에 대해서 설명해봤는데, 이번엔 간단한 트랜잭션 테스트를 해보자. Docker에서 MongoDB 래플리카 셋 환경을 구축한 뒤, 자바에서 테스트를 진행할 예정이다.

Docker에서 레플리카 셋 설정하는 방법

1. key파일 생성 및 키파일 권한 변경

    openssl rand -base64 756 > mongodb-keyfile 
    chmod 400 mongodb-keyfile

2. docker-compose.yml 작성

version: "3"

services:
mongodb-01:
    image: mongo
    container_name: mongodb-01
    restart: always
    ports:
    - "27017:27017"
    environment:
    MONGO_INITDB_ROOT_USERNAME: admin
    MONGO_INITDB_ROOT_PASSWORD: **** 
    MONGO_INITDB_DATABASE: test
    MONGO_REPLICA_SET_NAME: my-replica-set
    volumes:
    - ./mongodb-01:/data/db
    - ./mongodb-keyfile:/opt/mongo/mongodb-keyfile:ro
    command: mongod --bind_ip_all --replSet my-replica-set --auth --keyFile /opt/mongo/mongodb-keyfile

mongodb-02:
    image: mongo
    container_name: mongodb-02
    restart: always
    ports:
    - "27018:27017"
    volumes:
    - ./mongodb-02:/data/db
    - ./mongodb-keyfile:/opt/mongo/mongodb-keyfile:ro
    environment:
    MONGO_INITDB_ROOT_USERNAME: admin
    MONGO_INITDB_ROOT_PASSWORD: ****
    MONGO_INITDB_DATABASE: test
    MONGO_REPLICA_SET_NAME: my-replica-set
    command: mongod --bind_ip_all --replSet my-replica-set --auth --keyFile /opt/mongo/mongodb-keyfile

mongodb-03:
    image: mongo
    container_name: mongodb-03
    restart: always
    ports:
    - "27019:27017"
    environment:
    MONGO_INITDB_ROOT_USERNAME: admin
    MONGO_INITDB_ROOT_PASSWORD: ****
    MONGO_INITDB_DATABASE: test
    MONGO_REPLICA_SET_NAME: my-replica-set
    volumes:
    - ./mongodb-03:/data/db
    - ./mongodb-keyfile:/opt/mongo/mongodb-keyfile:ro
    command: mongod --bind_ip_all --replSet my-replica-set --auth --keyFile /opt/mongo/mongodb-keyfile
docker pull mongo
docker-compose up -d // 위에서 작성한 docker-compose 파일로 서버 생성
docker exec -it mongodb-01 mongosh -u admin -p **** //mongodb-01로 shell 접속
rs.initiate(); // 초기화
rs.add("mongodb-02"); // 02 서버 추가
rs.add("mongodb-03"); // 03 서버 추가

Spring boot

1. Spring boot에서 MongoConfig 작성

// import ....
//
//...
@Configuration
public class MongoConfig extends AbstractMongoClientConfiguration {

    @Value("mongodb://localhost:27017")
    private String uri; //연결은 Primary서버로 연결한다

    @Override
    protected String getDatabaseName() {
        return "initdb";
    }

    @Override
    public MongoClient mongoClient() {
        MongoCredential credential = MongoCredential.createCredential("admin", "admin", "****".toCharArray()); // 비밀번호 주의
        MongoClientSettings settings = MongoClientSettings.builder()
                .applyConnectionString(new ConnectionString(uri))
                .credential(credential)
                .applyToSocketSettings(builder ->
                        builder.connectTimeout(60, TimeUnit.SECONDS)
                                .readTimeout(60, TimeUnit.SECONDS))
                .build();
        return MongoClients.create(settings);
    }
 
    @Bean
    MongoTransactionManager TransactionManager(MongoDatabaseFactory factory){
        return new MongoTransactionManager(factory);
    }
}

연결은 primary로 해야 한다. Primary 서버는 레플리카 셋의 write operation을 담당하기 때문에 primary로 설정했다. 만약 primary 서버가 변경이 되면 새로운 primary 서버로 연결해 줘야 한다. 하지만 레플리카 셋이 아닌 샤드 클러스터 환경으로 구축한 뒤 연결할 경우엔, primary가 바뀌어도 spring에서 바꿔주지 않아도 된다.

2. 테스트

  1. A씨에겐 300불, B씨에게도 동일하게 300불을 가지고 있다.
  1. 은행에서 장애가 발생했을 시, A씨의 계좌와 B씨의 계좌에서는 아무런 변화가 일어나지 않았다. 즉, 롤백이 된 모습을 확인할 수 있다.
  1. 은행에서 장애가 발생하지 않았을 땐, A씨에겐 0불 B씨에겐 600불 성공적으로 송금이 된걸 확인할 수 있다.
  • 위 트랜잭션 예시를 코드로 작성해봤다. 송금하는 사람(A씨)와 송금받는사람(B씨)의 은행 계좌의 존재여부를 확인하고, 송금하는 사람의 계좌의 잔고가 보낼 금액보다 많은지 확인한 뒤, 송금하는 사람과 송금받는 사람의 잔액을 갱신해주었다.


jk4g2

참고