본문 바로가기

개발/면접

분산환경에서 트랜잭션 관리하는 방법

주로 아래 4개의 관리 방법을 사용함

2PC는 성능 이슈로 다른 선택지를 사용하거나

정말 강한 일관성이 필요한 것 아닌 경우에는 잘 안쓰는 듯?

언제나 그렇듯이 정답은 없고 상황에 맞게 잘 사용하는 것이 중요한 것 같다

 

2PC, Saga, CDC, 그리고 트랜잭셔널 아웃박스

- 강한 일관성이 중요하다면 2PC 또는 Transactional Outbox

- 성능과 가용성을 우선시한다면 Saga Pattern 또는 CDC

- 운영의 단순성을 중요시한다면 Transactional Outbox

패턴 일관성 가용성 실시간성 운영 복잡성
2PC 강함 낮음 높음 높음
Saga 약함 높음 중간 중간
CDC 최종 일관성 높음 높음 높음
Transactional Outbox 강함 높음 중간 낮음

 

 

 

 

 

1. 2PC (2-Phase Commit Protocol)
2PC는 트랜잭션 관리자가 분산 데이터베이스 간 트랜잭션의 일관성을 보장하는 전통적인 방법입니다. 데이터베이스 노드는 다음 두 단계를 통해 트랜잭션에 참여합니다.

 

1. Prepare (준비): 트랜잭션 관리자가 각 노드에 트랜잭션 준비 요청을 보냅니다.

2. Commit or Rollback (커밋/롤백): 준비 상태를 확인한 뒤, 커밋 명령을 내리거나 실패 시 롤백 명령을 내립니다.

 

스프링에서는 `JTA (Java Transaction API)`와 `Atomikos` 라이브러리를 사용하여 2PC를 구현할 수 있습니다.

 

요약하면

 

관리자: 너네 커밋 가능?

DB1: ㅇㅋ

DB2: ㅇㅋ

관리자: 커밋

 

관리자: 너네 커밋 가능?

DB1: ㅇㅋ

DB2: ㄴㄴ

관리자: 롤백

 

-----------------------

#### 의존성
com.atomikos.transactions-jta.5.0.9
org.springframework.boot.spring-boot-starter-jta-atomikos


#### DataSource 설정
분산 트랜잭션이 일어날 두 개의 데이터베이스를 설정합니다. 예를 들어, **A 데이터베이스**는 결제 정보 저장, **B 데이터베이스**는 사용자 잔액 업데이트를 처리합니다.

@Configuration
public class DataSourceConfig {
    // 데이터베이스 A 설정
    @Bean
    public DataSource dataSourceOne() {
        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName("db1");
        dataSource.setXaDataSourceClassName("cohttp://m.mysql.cj.jdbc.MysqlXADataSource");
        Properties prop = new Properties();
        prop.setProperty("user", "db1_user");
        prop.setProperty("password", "db1_pass");
        prop.setProperty("url", "jdbc:mysql://localhost:3306/db1?serverTimezone=UTC");
        dataSource.setXaProperties(prop);
        return dataSource;
    }

    // 데이터베이스 B 설정
    @Bean
    public DataSource dataSourceTwo() {
        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName("db2");
        dataSource.setXaDataSourceClassName("cohttp://m.mysql.cj.jdbc.MysqlXADataSource");
        Properties prop = new Properties();
        prop.setProperty("user", "db2_user");
        prop.setProperty("password", "db2_pass");
        prop.setProperty("url", "jdbc:mysql://localhost:3306/db2?serverTimezone=UTC");
        dataSource.setXaProperties(prop);
        return dataSource;
    }
}

#### Transaction Manager 설정
@Configuration
public class TransactionConfig {
    @Bean
    public PlatformTransactionManager transactionManager() {
        return new JtaTransactionManager();
    }
}

#### Service 코드
서비스에서 각 데이터베이스가 참여하는 분산 트랜잭션을 실행합니다.
@Service
public class PaymentService {

    @Autowired
    @Qualifier("dataSourceOne")
    private JdbcTemplate jdbcTemplateOne;

    @Autowired
    @Qualifier("dataSourceTwo")
    private JdbcTemplate jdbcTemplateTwo;

    @Transactional
    public void performPayment(String userId, double amount) {
        // A 데이터베이스: 결제 기록 추가
        jdbcTemplateOne.update("INSERT INTO payments (user_id, amount) VALUES (?, ?)", userId, amount);

        // B 데이터베이스: 사용자 잔액 차감
        jdbcTemplateTwo.update("UPDATE accounts SET balance = balance - ? WHERE user_id = ?", amount, userId);

        // 오류 발생 시 모든 작업 롤백
    }
}

 

 

2PC 장단점
장점

- 데이터 일관성을 강력히 보장합니다

- 실패 시 자동으로 롤백되어 데이터 손실 없음

단점

- 트랜잭션 참여 노드가 증가할수록 성능 저하
- 하나의 노드라도 실패하면 트랜잭션 전체 실패

---------

2. Saga Pattern
Saga는 장기 실행 트랜잭션을 여러 단계로 나누고, 각 단계가 실패했을 때 보상 작업(롤백 또는 취소)을 수행해 데이터 일관성을 유지합니다. Saga는 두 가지 방식으로 실행됩니다:
1. Choreography: 각 서비스가 독립적으로 이벤트를 발행하고 처리합니다.
2. Orchestration: 특정 서비스(Saga Coordinator)가 트랜잭션 전체를 제어합니다.

 

트랜잭션을 짧게 처리하고 실패시 보상 트랜잭션으로 복구

가장 많이 사용 하는 기술

Spring Statemachine이랑 결합해서 상태관리를 한다고 함

 

#### State Machine 설정
@Configuration
@EnableStateMachine
public class SagaStateMachineConfig extends StateMachineConfigurerAdapter {

    @Override
    public void configure(StateMachineStateConfigurer states) throws Exception {
        states
            .withStates()
            .initial("START")
            .state("PAYMENT_COMPLETED")
            .state("BALANCE_UPDATED")
            .end("COMPLETED")
            .end("FAILED");
    }

    @Override
    public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
        transitions
            .withExternal().source("START").target("PAYMENT_COMPLETED").event("PROCESS_PAYMENT")
            .and()
            .withExternal().source("PAYMENT_COMPLETED").target("BALANCE_UPDATED").event("UPDATE_BALANCE")
            .and()
            .withExternal().source("BALANCE_UPDATED").target("COMPLETED").event("SUCCESS")
            .and()
            .withExternal().source("ANY").target("FAILED").event("FAILURE");
    }
}

#### 서비스 구현
@Service
public class SagaService {

    @Autowired
    private StateMachine stateMachine;

    public void startSaga(String userId, double amount) {
        stateMachine.sendEvent("PROCESS_PAYMENT");

        try {
            processPayment(userId, amount);
            stateMachine.sendEvent("UPDATE_BALANCE");
            updateBalance(userId, amount);
            stateMachine.sendEvent("SUCCESS");
        } catch (Exception e) {
            stateMachine.sendEvent("FAILURE");
            rollbackPayment(userId, amount);
        }
    }

    public void processPayment(String userId, double amount) {
        // 결제 처리 로직
    }

    public void updateBalance(String userId, double amount) {
        // 잔액 업데이트 로직
    }

    public void rollbackPayment(String userId, double amount) {
        // 보상 작업: 결제 취소
    }
}

 

 

Saga 장단점

장점

- 비동기 메시지 처리로 성능과 확장성 높음

단점

- 데이터 복구를 위한 보상 작업을 따로 구현해야 함

- 최종적인 데이터 일관성을 보장하므로 즉각적인 일관성이 요구될 시 부적절

 

--------------------------------------------

 

3. CDC (Change Data Capture)

CDC(Change Data Capture)는 데이터베이스 테이블에서 발생하는 변경 사항(INSERT, UPDATE, DELETE)을 캡처해서 이벤트 스트림으로 전달하는 방식입니다. 이를 통해 데이터베이스 변경과 관련된 작업을 다른 시스템에서 비동기적으로 처리할 수 있습니다. Debezium, Kafka Connect 등 도구를 사용하여 구현하는 것이 일반적입니다.

CDC가 데이터베이스 변경 로그를 읽고 이를 Kafka 이벤트로 발행하는 구조를 설정합니다.

 

데이터 파이프 라인을 만들때도 사용함

#### Kafka & Debezium 설정
Kafka와 Debezium을 설치하고 MySQL과 연동합니다.

1. MySQL binlog 활성화
   MySQL에서 CDC를 사용하려면 `binlog`를 활성화해야 합니다.
   ```bash
   [mysqld]
   server-id=1
   log_bin=mysql-bin
   binlog-format=ROW
   ```
   `my.cnf` 파일에서 위 옵션을 추가한 후 MySQL을 재시작하세요.

2. Debezium Kafka Connector 설정
   Kafka Connect에 Debezium MySQL 커넥터를 추가합니다:
   ```json
   {
     "name": "mysql-connector",
     "config": {
       "connector.class": "io.debeziuhttp://m.connector.mysql.MySqlConnector",
       "database.hostname": "localhost",
       "database.port": "3306",
       "database.user": "cdc_user",
       "database.password": "cdc_password",
       "database.server.id": "184054",
       "database.server.name": "mysql_server",
       "include.schema.changes": false,
       "database.history.kafka.bootstrap.servers": "localhost:9092",
       "database.history.kafka.topic": "dbhistory.payment_db",
       "table.whitelist": "payment_db.payments"
     }
   }


#### Kafka Listener 구현
Kafka Listener를 작성하여 수신된 이벤트를 처리합니다.

@Service
public class CDCEventConsumer {

    @KafkaListener(topics = "mysql_server.payment_db.payments", groupId = "cdc-group")
    public void consumePaymentEvent(String message) {
        // Kafka에서 수신한 JSON 이벤트 처리
        System.out.println("Received Event: " + message);

        // JSON 파싱 및 비즈니스 로직 수행
        TransactionEvent event = parseJsonToEvent(message);
        handleEvent(event);
    }

    private TransactionEvent parseJsonToEvent(String json) {
        // Jackson 또는 Gson으로 JSON 파싱
        return new ObjectMapper().readValue(json, TransactionEvent.class);
    }

    private void handleEvent(TransactionEvent event) {
        // 이벤트에 따른 비즈니스 로직
        if ("PAYMENT_CREATED".equals(event.getEventType())) {
            // 다른 DB에 저장 or 업데이트 등
        }
    }
}



CDC 장단점
장점

- 비동기 이벤트 처리

- 실시간 데이터 변경 전송 가능

- 데이터베이스 변경 내용과 이벤트 스트림의 동기화 가능

 

단점

- 최종적 일관성만 보장

- 데이터 변경이 이벤트로 반영되기까지 지연 발생 가능

- 데이터 로깅 및 이벤트 생성으로 인해 운영 비용 증가

 

---------------------

 

 

4. 트랜잭셔널 아웃박스 (Transactional Outbox)
트랜잭셔널 아웃박스는 데이터 변경과 이벤트 생성을 동일 트랜잭션에서 처리하는 패턴입니다. 데이터베이스 내에 아웃박스 테이블(Outbox Table)을 추가하여 트랜잭션의 원자성을 보장합니다. 이후 별도의 프로세스(예: Kafka Connect)가 아웃박스 테이블을 폴링하여 이벤트를 생성합니다.

 

#### 1. 아웃박스 테이블 생성
CREATE TABLE outbox (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_type VARCHAR(255) NOT NULL,
    aggregate_id BIGINT NOT NULL,
    payload JSON NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

- event_type: 이벤트의 유형(예: PAYMENT_CREATED).
- aggregate_id: 관련 레코드의 ID.
- payload: 추가 데이터(JSON 형식).
- created_at: 이벤트가 생성된 시간.

#### 2. 서비스 로직에서 아웃박스 사용
@Service
public class PaymentService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Transactional
    public void performPayment(String userId, double amount) {
        // 결제 데이터 삽입
        jdbcTemplate.update("INSERT INTO payments (user_id, amount) VALUES (?, ?)", userId, amount);

        // 아웃박스 테이블에 이벤트 삽입
        String payload = String.format("{\"userId\":\"%s\",\"amount\":%f}", userId, amount);
        jdbcTemplate.update("INSERT INTO outbox (event_type, aggregate_id, payload) VALUES (?, ?, ?)",
                "PAYMENT_CREATED", userId, payload);
    }
}

#### 3. 아웃박스 테이블을 폴링하여 이벤트 처리
Kafka Connect 또는 별도의 폴링 작업을 통해 이벤트를 Kafka로 전달합니다.

@Service
public class OutboxEventPublisher {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Scheduled(fixedDelay = 1000) // 1초마다 실행
    public void publishOutboxEvents() {
        String query = "SELECT * FROM outbox WHERE published = FALSE";
        List> results = jdbcTemplate.queryForList(query);

        for (Map row : results) {
            String payload = (String) row.get("payload");
            String eventType = (String) row.get("event_type");

            // Kafka에 이벤트 발행 (KafkaTemplate 사용)
            kafkaTemplate.send("payment-events", eventType, payload);

            // 이벤트를 성공적으로 발행한 후 동기화 상태 업데이트
            jdbcTemplate.update("UPDATE outbox SET published = TRUE WHERE id = ?", row.get("id"));
        }
    }
}


Transactional Outbox 장단점

장점

- 원자성 보장: 데이터 변경과 이벤트 생성이 하나의 트랜잭션으로 처리됨

- 데이터 손실 위험이 거의 없음

- Kafka를 활용하여 비동기 처리 가능

 

단점

- 이벤트를 폴링하기 때문에 실시간성이 다소 부족할 수 있음

- 추가 테이블(Outbox Table) 관리로 데이터베이스 부담 증가

- 트랜잭션 외부의 이벤트 지연 문제 관리가 필요