주로 아래 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) 관리로 데이터베이스 부담 증가
- 트랜잭션 외부의 이벤트 지연 문제 관리가 필요
'개발 > 면접' 카테고리의 다른 글
코딩테스트/기술블로그/메일링 모음 (0) | 2025.03.10 |
---|---|
디자인 패턴 아는척하기 (2) - 현대적인 디자인 패턴 (1) | 2024.12.25 |
디자인 패턴 아는척하기 (1) - 전통적인 디자인 패턴 (1) | 2024.12.25 |
백엔드 개발 면접 질문 모음 (2) | 2024.12.21 |