[시스템 사고] FailOver 과정에서 데이터 유실

배현서·2024년 10월 18일

시스템 사고

목록 보기
1/10

대규모 아키텍쳐 내용을 학습하고 면접 준비를 하면서 느낀 점을 정리하려고 한다.

오늘은 FailOver 과정에서 데이터 유실에 관한 내용이다.

먼저 왜 FailOver와 리더-팔로워 노드 구조가 필요한지를 작성하고 싶다.

Failover와 리더-팔로워 노드 구조의 필요성

1. 고가용성(High Availability)의 중요성

현대의 데이터 중심 애플리케이션에서 시스템의 지속적인 가용성은 필수적이다.(물론 은행 같이 일관성이 더 중요한 서비스도 있다) 사용자들은 24/7 서비스를 기대하며, 시스템 다운타임은 비즈니스에 심각한 영향을 미칠 수 있다.

2. 단일 실패 지점(Single Point of Failure) 제거

단일 데이터베이스 서버에 의존하는 시스템은 해당 서버에 문제가 발생했을 때 전체 시스템이 중단될 위험이 있다. 이러한 단일 실패 지점을 제거하는 것이 중요하다.(이 과정에서 추가되는 성능적 고민의 연속이 오늘 포스트의 작성 이유이다.)

3. 리더-팔로워 노드 구조의 역할

  • 부하 분산: 읽기 작업을 팔로워 노드로 분산시켜 전체 시스템의 성능을 향상시킨다.
  • 데이터 안정성: 여러 노드에 데이터를 복제함으로써 데이터 손실 위험을 줄인다.
  • 지리적 분산: 다른 지역에 팔로워 노드를 배치하여 지역적 장애에 대비할 수 있다.

4. Failover의 중요성

  • 무중단 서비스: 주 서버(리더 노드)에 문제가 발생해도 시스템이 계속 작동할 수 있게 한다.
  • 자동 복구: 문제 발생 시 자동으로 다른 노드가 리더 역할을 맡아 시스템을 복구한다.
  • 데이터 일관성 유지: Failover 과정에서 데이터 일관성을 유지하는 것이 중요하다.(이 포스트는 이 부분에 초점이 맞춰져 있다.)

시나리오

DB리더 노드에 문제가 발생하여 팔로워 노드 중 하나가 새로운 리더로 승격되는 failover 과정에서 미묘한 문제가 발생할 수 있다.

예를 들어, 다음과 같은 시나리오를 생각해 보자

  1. 리더 노드가 장애를 겪기 시작합니다.
  2. 시스템이 이를 감지하고 failover 프로세스를 시작합니다.
  3. 이 짧은 시간 동안 클라이언트가 트랜잭션을 요청합니다.
  4. 새로운 리더 노드가 선출되어 활성화됩니다.

이 과정에서 주요 질문은 다음과 같다

"failover 도중에 발생한 트랜잭션은 어떻게 처리되어야 할까요? 이 데이터는 어떻게 될까요?"

이 질문에 대한 해결책을 제시하기 이전에 리더-팔로워 노드간 데이터 동기화 전략에 대해 알면 좋을 것 같다.


리더-팔로워 노드 간 데이터 동기화 전략

1. 동기식 복제 (Synchronous Replication)

설명

  • 리더 노드가 트랜잭션을 커밋하기 전에 모든 (또는 지정된) 팔로워 노드의 확인을 기다림
  • 가장 강력한 일관성을 제공하지만 가용성이 낮아질 수 있음

일관성-가용성 분석

  • 일관성 (Consistency): 매우 높음
  • 가용성 (Availability): 낮음 (팔로워 노드 응답 대기로 인한 지연 발생)

사용 사례

  • 금융 거래, 중요한 데이터 처리 등 데이터 손실이 절대 허용되지 않는 경우

2. 정족수 기반 동기화 (Quorum-based Synchronization)

설명

  • 지정된 수(정족수)의 팔로워 노드로부터 확인을 받으면 커밋 진행
  • 동기식과 비동기식의 중간 지점, 구성 가능한 일관성 레벨 제공

일관성-가용성 분석

  • 일관성: 중간~높음 (정족수 설정에 따라 조절 가능)
  • 가용성: 중간 (동기식보다 높지만 비동기식보다는 낮음)

사용 사례

  • 일관성과 가용성 사이의 균형이 필요한 대규모 분산 시스템

3. 비동기식 복제 (Asynchronous Replication)

설명

  • 리더 노드가 트랜잭션을 즉시 커밋하고, 팔로워 노드에 비동기적으로 변경사항 전파
  • 가장 높은 가용성을 제공하지만 일관성이 일시적으로 손상될 수 있음

일관성-가용성 분석

  • 일관성: 낮음 (일시적인 불일치 발생 가능)
  • 가용성: 매우 높음 (리더 노드의 응답 속도가 빠름)

사용 사례

  • 소셜 미디어 포스팅, 로그 데이터 등 일시적인 불일치가 허용되는 시스템

4. 세미 동기식 복제 (Semi-Synchronous Replication)

설명

  • 하나의 팔로워 노드가 확인을 보낼 때까지만 대기하고 커밋
  • 동기식과 비동기식의 절충안

일관성-가용성 분석

  • 일관성: 중간 (최소 하나의 팔로워와 동기화 보장)
  • 가용성: 중간~높음 (단일 팔로워 대기로 인한 약간의 지연)

사용 사례

  • 데이터 중요도가 높지만 동시에 빠른 응답 시간이 필요한 경우

5. 체인 복제 (Chain Replication)

설명

  • 노드들이 체인 형태로 연결되어 순차적으로 데이터 전파
  • 마지막 노드가 클라이언트에 응답

일관성-가용성 분석

  • 일관성: 높음 (모든 노드가 순차적으로 동기화)
  • 가용성: 중간 (체인의 길이에 따라 지연 발생 가능)

사용 사례

  • 높은 처리량이 필요하면서도 강한 일관성이 요구되는 시스템

Failover 중 데이터 유실 방지를 위한 솔루션

내가 생각하는 솔루션과 이걸 도입했을때 개선할 점들을 작성해보았다.

1. Write-Ahead Logging (WAL) 구현

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) {
        // 로그 엔트리를 완료 상태로 표시하는 로직
    }
}

병목점 :

  1. WAL 쓰기 작업 (writeToWAL 메서드):

    • 디스크 I/O가 병목점이 될 수 있다. WAL은 일반적으로 디스크에 순차적으로 기록되므로, 디스크 속도에 따라 성능이 제한될 수 있다.
    • 개선 방법: WAL을 고성능 SSD에 저장하거나, 여러 개의 WAL 파일을 사용하여 병렬 쓰기를 구현할 수 있다.
  2. 동기적 실행:

    • 현재 구현은 각 단계를 순차적으로 실행하므로, 전체 프로세스가 느려질 수 있다.
    • 개선 방법: WAL 쓰기와 트랜잭션 적용을 비동기적으로 처리하거나, 병렬 처리를 도입할 수 있다.
  3. 로그 엔트리 생성 및 관리:

    • 대량의 트랜잭션 처리 시 로그 엔트리 생성과 관리가 병목이 될 수 있다.
    • 개선 방법: 로그 엔트리를 효율적으로 관리하는 자료구조를 사용하고, 주기적으로 오래된 로그를 정리하는 프로세스를 구현할 수 있다.
  4. 동시성 제어:

    • 다중 스레드 환경에서 WAL 접근에 대한 동시성 제어가 필요할 수 있으며, 이는 성능에 영향을 줄 수 있다.
    • 개선 방법: 효율적인 락킹 메커니즘 사용, 락-프리 알고리즘 도입 등을 고려할 수 있다.

2. 세미 동기식 복제 적용

리더 노드는 최소 하나의 팔로워 노드로부터 확인을 받은 후 클라이언트에 응답한다.

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();
        }
    }
}

병목점

  1. writeToLeader 메소드: 리더 노드로의 쓰기 작업이 네트워크 지연이나 리더 노드의 부하에 따라 병목이 될 수 있다.

  2. waitForAcknowledgement 메소드: 타임아웃 동안 대기하는 것이 전체 트랜잭션 처리 시간을 늘릴 수 있습니다. 특히 네트워크 지연이 큰 경우 문제가 될 수 있습니다.

이러한 병목점들은 비동기 처리, 타임아웃 최적화, 네트워크 최적화 등을 통해 개선할 수 있다.

3. 글로벌 트랜잭션 ID 도입

각 트랜잭션에 유니크한 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 알고리즘 등 고려

4. Redis를 활용한 임시 저장소 구현

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도 추가 예정

5. Failover 감지 및 복구 프로세스

새로운 리더 노드가 선출되면 누락된 트랜잭션을 복구한다.

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() {
        // 팔로워들과 동기화 로직
    }
}

6. 버전 관리를 통한 충돌 해결

각 레코드에 버전 번호를 부여하여 동시성 문제를 해결한다.

UPDATE users 
SET balance = new_balance, version = version + 1
WHERE id = user_id AND version = current_version;

7. 배치 처리를 통한 성능 최적화

여러 트랜잭션을 묶어서 일괄 처리한다.

def batch_process_transactions(transactions):
    with database.transaction():
        for transaction in transactions:
            apply_transaction(transaction)

8. 모니터링 및 알림 시스템 구축

주요 지표를 실시간으로 모니터링하고 문제 발생 시 알림을 보낸다.

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);
        }
    }
}

9. 사용자에게 트랜잭션 상태 통보

트랜잭션의 상태를 사용자에게 명확히 알린다.

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);
    }
}

실제 시나리오에서의 동작

  1. 클라이언트가 트랜잭션을 요청한다.
  2. 시스템은 글로벌 트랜잭션 ID를 생성하고 WAL에 기록한다.
  3. 리더 노드는 트랜잭션을 실행하고, 최소 하나의 팔로워 노드로부터 확인을 받는다.
  4. 동시에 트랜잭션 정보를 Redis에 임시 저장한다.
  5. 클라이언트에게 트랜잭션 접수 확인을 보낸다.
  6. 만약 이 시점에 Failover가 발생하면
    • 새 리더가 선출된다.
    • 새 리더는 Redis와 WAL을 확인하여 누락된 트랜잭션을 식별한다.
    • 누락된 트랜잭션을 버전 관리와 함께 재적용한다.
  7. 모니터링 시스템은 전체 과정을 감시하고, 문제 발생 시 즉시 알린다.
  8. 사용자에게는 최종 트랜잭션 상태를 알린다.

이런 글을 쓰다보니 너무 추상적인 것 같아서 다음에는 좀 세부적인 내용을 쓰려고 한다!

1개의 댓글

comment-user-thumbnail
2024년 11월 21일

장인호 화이팅!!

답글 달기