대규모 아키텍쳐 내용을 학습하고 면접 준비를 하면서 느낀 점을 정리하려고 한다.
오늘은 FailOver 과정에서 데이터 유실에 관한 내용이다.
먼저 왜 FailOver와 리더-팔로워 노드 구조가 필요한지를 작성하고 싶다.
현대의 데이터 중심 애플리케이션에서 시스템의 지속적인 가용성은 필수적이다.(물론 은행 같이 일관성이 더 중요한 서비스도 있다) 사용자들은 24/7 서비스를 기대하며, 시스템 다운타임은 비즈니스에 심각한 영향을 미칠 수 있다.
단일 데이터베이스 서버에 의존하는 시스템은 해당 서버에 문제가 발생했을 때 전체 시스템이 중단될 위험이 있다. 이러한 단일 실패 지점을 제거하는 것이 중요하다.(이 과정에서 추가되는 성능적 고민의 연속이 오늘 포스트의 작성 이유이다.)
DB리더 노드에 문제가 발생하여 팔로워 노드 중 하나가 새로운 리더로 승격되는 failover 과정에서 미묘한 문제가 발생할 수 있다.
예를 들어, 다음과 같은 시나리오를 생각해 보자
이 질문에 대한 해결책을 제시하기 이전에 리더-팔로워 노드간 데이터 동기화 전략에 대해 알면 좋을 것 같다.
내가 생각하는 솔루션과 이걸 도입했을때 개선할 점들을 작성해보았다.
public class TransactionExecutor {
public TransactionResult executeTransaction(Transaction transaction) {
LogEntry logEntry = createLogEntry(transaction);
writeToWAL(logEntry);
TransactionResult result = applyTransaction(transaction);
if (result.isSuccess()) {
markLogEntryAsCompleted(logEntry);
}
return result;
}
private LogEntry createLogEntry(Transaction transaction) {
// 로그 엔트리 생성 로직
return new LogEntry(transaction);
}
private void writeToWAL(LogEntry logEntry) {
// WAL에 로그 엔트리 쓰기 로직
}
private TransactionResult applyTransaction(Transaction transaction) {
// 실제 트랜잭션 적용 로직
return new TransactionResult(/* 결과 */);
}
private void markLogEntryAsCompleted(LogEntry logEntry) {
// 로그 엔트리를 완료 상태로 표시하는 로직
}
}
WAL 쓰기 작업 (writeToWAL 메서드):
동기적 실행:
로그 엔트리 생성 및 관리:
동시성 제어:
리더 노드는 최소 하나의 팔로워 노드로부터 확인을 받은 후 클라이언트에 응답한다.
import java.util.List;
import java.util.concurrent.TimeUnit;
public class TransactionCommitter {
public boolean commitTransaction(Transaction transaction) {
writeToLeader(transaction);
List<Acknowledgement> acknowledgements = waitForAcknowledgement(5);
return acknowledgements.size() >= 1;
}
private void writeToLeader(Transaction transaction) {
// 리더 노드에 트랜잭션 쓰기 로직
}
private List<Acknowledgement> waitForAcknowledgement(int timeoutSeconds) {
// 팔로워 노드로부터의 확인을 기다리는 로직
// 여기서는 간단히 구현했지만, 실제로는 비동기 처리가 필요할 수 있다.
try {
return leaderNode.getAcknowledgements(TimeUnit.SECONDS.toMillis(timeoutSeconds));
} catch (TimeoutException e) {
return Collections.emptyList();
}
}
}
writeToLeader 메소드: 리더 노드로의 쓰기 작업이 네트워크 지연이나 리더 노드의 부하에 따라 병목이 될 수 있다.
waitForAcknowledgement 메소드: 타임아웃 동안 대기하는 것이 전체 트랜잭션 처리 시간을 늘릴 수 있습니다. 특히 네트워크 지연이 큰 경우 문제가 될 수 있습니다.
이러한 병목점들은 비동기 처리, 타임아웃 최적화, 네트워크 최적화 등을 통해 개선할 수 있다.
각 트랜잭션에 유니크한 ID를 부여하여 순서와 중복을 관리한다.
import java.time.Instant;
import java.util.concurrent.atomic.AtomicLong;
public class GlobalTransactionIdGenerator {
private static final AtomicLong sequence = new AtomicLong(0);
public String generateGlobalTransactionId() {
long timestamp = Instant.now().toEpochMilli();
String nodeId = getNodeId();
long nextSequence = sequence.getAndIncrement();
return String.format("%d-%s-%d", timestamp, nodeId, nextSequence);
}
private String getNodeId() {
// 노드 ID 반환 로직
return "NODE_001";
}
}
System.currentTimeMillis() 호출이 많을 경우 성능 저하 가능
높은 동시성 환경에서 AtomicLong의 경합 발생 가능
개선 방안
시간 호출 횟수를 줄이기 위해 배치로 ID 생성
분산 환경에서는 Snowflake 알고리즘 등 고려
Failover 중 발생한 트랜잭션을 Redis에 임시 저장한다.
import redis.clients.jedis.Jedis;
public class RedisTransactionStore {
private final Jedis redisClient;
public RedisTransactionStore(String host, int port) {
this.redisClient = new Jedis(host, port);
}
public void storeTransactionInRedis(Transaction transaction) {
redisClient.setex(transaction.getId(), 3600, transaction.toJson());
}
}
나중에 lettuce나 redisson도 추가 예정
새로운 리더 노드가 선출되면 누락된 트랜잭션을 복구한다.
import java.util.List;
public class FailoverHandler {
private final RedisTransactionStore redisStore;
private final TransactionProcessor processor;
public FailoverHandler(RedisTransactionStore redisStore, TransactionProcessor processor) {
this.redisStore = redisStore;
this.processor = processor;
}
public void handleFailover() {
String newLeader = electNewLeader();
List<Transaction> missedTransactions = getMissedTransactionsFromRedis();
for (Transaction transaction : missedTransactions) {
processor.applyTransaction(transaction);
}
syncWithFollowers();
}
private String electNewLeader() {
// 새 리더 선출 로직
return "NEW_LEADER_NODE";
}
private List<Transaction> getMissedTransactionsFromRedis() {
// Redis에서 누락된 트랜잭션 조회 로직
return List.of();
}
private void syncWithFollowers() {
// 팔로워들과 동기화 로직
}
}
각 레코드에 버전 번호를 부여하여 동시성 문제를 해결한다.
UPDATE users
SET balance = new_balance, version = version + 1
WHERE id = user_id AND version = current_version;
여러 트랜잭션을 묶어서 일괄 처리한다.
def batch_process_transactions(transactions):
with database.transaction():
for transaction in transactions:
apply_transaction(transaction)
주요 지표를 실시간으로 모니터링하고 문제 발생 시 알림을 보낸다.
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
public class BatchTransactionProcessor {
private final Connection connection;
private final TransactionProcessor processor;
public BatchTransactionProcessor(Connection connection, TransactionProcessor processor) {
this.connection = connection;
this.processor = processor;
}
public void batchProcessTransactions(List<Transaction> transactions) throws SQLException {
connection.setAutoCommit(false);
try {
for (Transaction transaction : transactions) {
processor.applyTransaction(transaction);
}
connection.commit();
} catch (SQLException e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(true);
}
}
}
트랜잭션의 상태를 사용자에게 명확히 알린다.
public class UserNotifier {
public void notifyUser(Transaction transaction) {
switch (transaction.getStatus()) {
case PENDING:
sendNotification("거래가 처리 중입니다. 잠시 후 다시 확인해 주세요.");
break;
case COMPLETED:
sendNotification("거래가 성공적으로 완료되었습니다.");
break;
default:
sendNotification("거래 상태를 확인할 수 없습니다.");
}
}
private void sendNotification(String message) {
// 실제 알림 전송 로직 (예: 푸시 알림, 이메일 등)
System.out.println("Notification: " + message);
}
}
이런 글을 쓰다보니 너무 추상적인 것 같아서 다음에는 좀 세부적인 내용을 쓰려고 한다!
장인호 화이팅!!