MSA 환경에서 SAGA 패턴으로 데이터 일관성 관리하기

pitseleh·2025년 5월 27일
post-thumbnail

현재 프로젝트는 MSA 기반으로 작업 중이기 때문에 다음과 같이 구조를 분리했다

configserver : 설정 관리
eurekaserver : 서비스 디스커버리 및 서비스 레지스트리 (각 서비스 인스턴스 등록/조회)
gatewayserver : 라우팅 (요청을 적절한 마이크로서비스로 전달)
authenticationserver : 회원 인증 관련
user : 인증을 제외한 회원 관련 서비스
bank : 금융 관련 서비스
anomaly : 이상거래 탐지
intent : 음성명령 시 의도추론
speaker_verification : 음성명령 시 사용자 인증

따라서 어떠한 이벤트가 발생했을 때 마이크로서비스 간의 데이터 일관성을 관리하는 것이 중요해졌다. 실제로 개발 과정에서 회원가입 시 authenticationserver에서 에러가 발생했는데도 user 서비스에 저장된 데이터가 롤백되지 않는 문제가 있었다. 내 담당 파트에서도 회원이 탈퇴했을 때 계좌 상태를 비활성화 시켜야 하기 때문에 SAGA 패턴을 도입하자는 팀원의 제안에 따라 공부해보기로 했다.

1. SAGA 패턴 도입

🔍 SAGA 패턴이란?

  • 분산 트랜잭션을 로컬 트랜잭션의 시퀀스로 분해
  • 각 트랜잭션은 보상 트랜잭션(Compensating Transaction)을 가짐
  • 실패 시 이미 완료된 트랜잭션들을 보상 트랜잭션으로 되돌림

분산 트랜잭션을 로컬 트랜잭션의 시퀀스로 분해한다는게 무슨 의미일까?

하나의 큰 트랜잭션을 여러 개의 작은 로컬 트랜잭션으로 나눔

로컬 트랜잭션 1: User 서비스
┌─────────────────────────────┐
│ BEGIN TRANSACTION         │
│ - 회원 상태를 '탈퇴'로 변경  │ 
│ COMMIT                    │
└─────────────────────────────┘
           ↓ 이벤트 발행

로컬 트랜잭션 2: Bank 서비스  
┌───────────────────────────┐
│ BEGIN TRANSACTION       │
│ - 계좌 상태를 '비활성화'   │
│ COMMIT                  │
└───────────────────────────┘

즉, 위와 같이 각 서비스가 자신의 DB에서만 수행하는 트랜잭션(로컬 트랜잭션)을 순차적으로 실행한다는 뜻이다.
각 서비스는 자신의 도메인 내에서만 트랜잭션을 관리하고, 실패 시 보상 트랜잭션으로 롤백시키는 점이 중요하다.

SAGA 패턴을 선택한 이유는 서비스 간 느슨한 결합을 유지하기 위해서이다. MSA 환경에서 서비스 간 직접 호출 방식은 하나의 서비스 장애가 전체 시스템에 영향을 미치는 도미노 효과를 발생시킨다.
예를 들어, 회원 탈퇴 시 Bank 서비스가 일시적으로 다운된다면 사용자는 탈퇴 자체를 할 수 없게 될 것이다.
반면 이벤트 기반의 느슨한 결합을 통해 User 서비스는 탈퇴 처리를 완료하고, Bank 서비스는 복구된 후 일관성을 맞추는 방식으로 가용성과 일관성을 모두 확보할 수 있다.

간단하게 요약하면 아래와 같다.

User 서비스 → 이벤트 발행 → 성공 (사용자 탈퇴 완료)
                ↓
            Bank 서비스 (다운됨) → 나중에 복구되면 처리
                ↓
        사용자는 탈퇴 완료, Bank는 나중에 일관성 맞춤

2. 구현 과정

회원 탈퇴 시나리오는 아래와 같다.

  1. User 서비스: 회원 탈퇴 처리
  2. Event 발행
  3. Bank 서비스: 계좌 상태 DEACTIVATE 변경
  4. 실패 시: 보상 트랜잭션 발행

1) Kafka를 수동 커밋 모드로 설정

spring:
  kafka:
    bootstrap-servers: kafka-container:9092
    topic:
      name:
        user-withdraw: user-withdraw-topic
        user-withdraw-fail: user-withdraw-fail-topic
    consumer:
      enable-auto-commit: false  # 자동 커밋 비활성화
    listener:
      ack-mode: manual           # 수동 커밋 모드

2) User 서비스 - 이벤트 발행

@Service
public class UserService {
    private final KafkaTemplate<String, String> kafkaTemplate;
    private final ObjectMapper objectMapper;

    public void deactivateUserStatus(Integer userId) {
        log.info("회원 탈퇴 요청 - userId: {}", userId);
        
        // 1. 로컬 트랜잭션: User 상태 변경
        User userEntity = getUserEntity(userId);
        userEntity.updateUserStatus(UserStatus.DEACTIVATE);
        log.info("회원 탈퇴 성공 - userId: {}", userId);

        // 2. 다른 서비스에 이벤트 발행
        UserWithdrawRollbackResponse request = UserWithdrawRollbackResponse.builder()
                .userId(userId)
                .status(UserStatus.DEACTIVATE)
                .build();
        
        try {
            kafkaTemplate.send("user-withdraw-topic", objectMapper.writeValueAsString(request));
            log.info("SAGA 패턴 트랜잭션 전송 성공 아이디 : {}", request.getUserId());
        } catch (Exception e) {
            log.error("SAGA 패턴 트랜잭션 전송 에러");
        }
    }

    // 보상 트랜잭션: 회원 상태 롤백
    public void rollbackUserWithdraw(Integer userId) {
        log.info("회원 탈퇴 롤백 요청 - userId : {}", userId);
        User userEntity = getUserEntity(userId);
        userEntity.updateUserStatus(UserStatus.ACTIVATE);
        log.info("회원 탈퇴 롤백 성공 - userId : {}", userId);
    }
}

회원 탈퇴 처리 및 이벤트를 발행한다.

3) Bank 서비스 - 이벤트 수신 및 처리

@Service
@Slf4j
@RequiredArgsConstructor
public class UserWithdrawConsumer {

    private final ObjectMapper objectMapper;
    private final UserWithdrawService userWithdrawService;
    private final KafkaProducerService kafkaProducerService;

    @KafkaListener(topics = "${spring.kafka.topic.name.user-withdraw}", groupId = "user-withdraw-group")
    public void consume(String message, Acknowledgment ack) {
        try {
            UserWithdrawDto dto = objectMapper.readValue(message, UserWithdrawDto.class);
            userWithdrawService.updateAccountStatus(dto);
            ack.acknowledge();     // 성공했을 때만 커밋
            log.info("user-withdraw-topic consume 완료");
        } catch (Exception e) {
            kafkaProducerService.sendUserWithdrawRollback(message);
            log.error("Kafka consume 에러 발생: {}", e.getMessage());
        }
    }
}

수동 커밋을 통해 안전하게 트랜잭션을 처리한다.

@Service
@Slf4j
@RequiredArgsConstructor
public class UserWithdrawService {

    private final AccountRepository accountRepository;

    @Transactional
    public void updateAccountStatus(UserWithdrawDto request) {
        List<Account> accounts = accountRepository.findAllByUserId(request.getUserId());
        for (Account account : accounts) {
            account.updateStatus(AccountStatus.DEACTIVATE);
        }
    }
}

계좌 상태도 비활성화로 변경한다.

4) 보상 트랜잭션 처리

@Service
@RequiredArgsConstructor
public class KafkaProducerService {
    private final KafkaTemplate<String, String> kafkaTemplate;
    private final ObjectMapper objectMapper;

    @Value("${spring.kafka.topic.name.user-withdraw-fail}")
    private String userWithdrawFailTopic;

    public void sendUserWithdrawRollback(String message) {
        try {
            log.info("KafkaProducerService 실행 - UserWithdrawRollback");
            
            // 원본 메시지에서 userId 추출
            UserWithdrawDto dto = objectMapper.readValue(message, UserWithdrawDto.class);
            
            // 보상 트랜잭션 이벤트 발행
            kafkaTemplate.send(userWithdrawFailTopic, 
                             objectMapper.writeValueAsString(dto.getUserId()));
                             
        } catch (JsonProcessingException e) {
            log.error("회원 탈퇴 SAGA 보상 메시지 전송 실패: {}", e.getMessage());
        }
    }
}

Bank 서비스에서 User 서비스로 보상 이벤트를 발행한다.

🔍 수동 커밋을 진행한 이유?

Kafka 커밋과 DB 트랜잭션을 분리하기 위해서이다. spring: kafka: consumer: enable-auto-commit: true 설정에 의한 자동 커밋 모드에서는 아래와 같은 문제가 발생할 수 있다.

시나리오: Bank 서비스에서 계좌 비활성화 처리 중 DB 에러 발생

1. Kafka 메시지 수신 
2. Kafka 자동 커밋  (메시지 처리 완료로 표시)
3. DB 트랜잭션 시작
4. 계좌 상태 변경 중 에러 발생 
5. DB 롤백 
6. 하지만 Kafka는 이미 커밋됨 

결과: 메시지는 처리된 것으로 표시되어 재처리 불가능
     → 데이터 불일치 발생

하지만 수동 커밋 모드에서는 DB 트랜잭션 수행이 성공했을 때만 명시적으로 커밋하고, 실패 시 커밋하지 않기 때문에 Kafka가 메시지를 재전송할 수 있다. 따라서 계좌 상태를 DEACTIVATE로 변경하는 작업이 실패한 경우

@Transactional에 의해 DB 자동 롤백 → 예외가 Consumer로 전파 → Consumer에서 ack.acknowledge() 호출 안됨 → Kafka 메시지 미커밋 상태 유지

와 같이 DB 트랜잭션과 Kafka 커밋 순서를 보장할 수 있게 된다.
실패 시 보상 트랜잭션을 발행할 때도 마찬가지다.

public void consume(String message, Acknowledgment ack) {
    try {
        // 비즈니스 로직 실행
        userWithdrawService.updateAccountStatus(dto);
        ack.acknowledge(); // 성공 시에만 커밋
        
    } catch (Exception e) {
        // 1. DB는 이미 롤백됨 (@Transactional)
        // 2. Kafka 메시지는 커밋 안됨 (재처리 가능)
        // 3. User 서비스에 보상 트랜잭션 요청
        kafkaProducerService.sendUserWithdrawRollback(message);
        
        // 4. 로그 남기고 메서드 종료 (ack 호출 안함)
        log.error("계좌 비활성화 실패, 보상 트랜잭션 발행: {}", e.getMessage());
    }
}

3. 완성된 SAGA 패턴 플로우

정상 처리 시나리오:
┌──────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   User Service  │   │  Kafka Broker  │    │  Bank Service  │
└──────────────────┘    └─────────────────┘    └─────────────────┘
         │                       │                       │
    1. 회원 탈퇴 요청              │                       │
         │ ─────────────────────→  │                       │
    2. 상태 변경 (DEACTIVATE)     │                       │
         │                       │                       │
    3. 이벤트 발행                │                       │
         │ ─────────────────────→ │                        │
         │                  4. 메시지 전송                │
         │                       │ ─────────────────────→ │
         │                       │                  5. 계좌 비활성화
         │                       │                       │
         │                       │                  6. ack.acknowledge()
         │                       │ ←───────────────────── │
         │                      7. 커밋 완료              │
         │                       │                       │

실패 및 보상 트랜잭션 시나리오:
         │                       │                       │
         │                       │                  ❌ DB 에러 발생
         │                       │                       │
         │                       │             보상 이벤트 발행
         │                  8. user-withdraw-fail        │
          │ ←───────────────────── │ ←───────────────────── │
    9. 롤백 처리 (ACTIVATE)       │                       │
         │                       │                  (ack 호출 안함)
         │                       │              → 메시지 재처리 가능

처음에 헷갈렸던 부분은 User 탈퇴 → Bank 실패 → User 롤백 → 사용자에게 "다시 회원탈퇴를 해주세요"와 같은 메시지를 반환해야 하는건가? 라고 생각했는데, 계좌 상태 변경이 실패해도 사용자에게는 회원탈퇴 성공을 응답하고 관리자가 확인할 수 있도록 실패 로그를 남기는게 SAGA 패턴에 적절한 방식이라고 한다.
생각해보면 애초에 계좌 상태 변경과 같은 후속 처리 실패로 인해 핵심 비즈니스 플로우인 회원 탈퇴 자체를 실패시키는 것은 사용자 경험 측면에서 부적절하다. 회원 탈퇴는 사용자의 핵심 요구사항이고, 계좌 비활성화는 부수적인 정리 작업에 해당하기 때문.....
SAGA 패턴은 이번에 처음 접해본 개념이라 구현하면서 상당히 헷갈렸는데 어느 정도 정리된 기분이다.

0개의 댓글