본문 바로가기

카테고리 없음

분산 환경에서 데이터 일관성 확보하기

0. 개요

프로젝트에서 PT 수업 예약 API를 개발하며 고려한 점들을 정리하고자 한다. PT 수업 예약 요청은 Pt 서버와 Auth 서버를 거치는 분산 환경에서 처리된다. 단일 서버에서 모든 로직을 수행한다면 구현이 간단하지만, 여러 서버를 거치게 되면 트랜잭션의 원자성 및 데이터 일관성을 유지하기 위해 다양한 상황을 고려해야 한다. 이 글은 분산 환경에서 어떤 것들을 고려했는지 그 고려 사항들을 정리한 글이다.

1. 분산 환경에서 로컬 트랜잭션들 간 원자성 보장하기

분산 환경에서의 원자성

분산 환경에서는 여러 개의 로컬 트랜잭션들이 묶여 하나의 논리적인 트랜잭션을 구성한다. 그렇기 때문에 로컬 트랜잭션들이 순차적 또는 병렬로 실행되면서 원자성이 깨지는 문제가 발생할 수 있다. 예를 들어, 마이크로서비스 A에서 트랜잭션 T1을 실행된 후, 마이크로서비스 B에서 T2를 실행된다고 가정하자. 이때 T1이 정상적으로 커밋된 후 T2가 실행되었지만, T2가 롤백된다면 어떻게 될까? 전체 트랜잭션의 원자성을 보장하려면 T2가 롤백됨에 따라 T1도 함께 롤백되어야 한다. 그러나 각 로컬 트랜잭션은 각 서비스에서 독립적으로 동작하기 때문에 T2가 롤백되더라도 T1이 이미 커밋된 상태이기 때문에 원자성이 깨지게 된다.

Saga Pattern

분산 환경에서 트랜잭션의 원자성을 보장하기 위한 방법으로 Saga Pattern이 있다. Saga 패턴은 분산 환경에서 여러 로컬 트랜잭션 간 원자성을 보장하고 결과적으로 데이터 일관성을 보장하기 위한 패턴이다.

 

Saga 패턴은 여러 로컬 트랜잭션 중 하나가 실패하면 이미 성공적으로 완료되어 커밋된 로컬 트랜잭션들을 롤백시키기 위해 보상 트랜잭션을 수행한다. 정확히는 이미 커밋된 트랜잭션이므로 롤백시키는 것은 아니고, 커밋되기 이전으로 되돌리기 위한 보상 트랜잭션을 수행하는 것이다. 이를 통해 전체 데이터의 일관성을 유지할 수 있다. 

 

Saga 패턴에서 데이터 일관성을 관리하는 주체는 DBMS가 아니라 애플리케이션이다. 따라서 보상 트랜잭션이 완료되기 전까지 일시적으로 데이터 정합성이 깨질 수 있으나, 보상 트랜잭션이 완료되면 결과적 정합성이 보장된다. 이를 최종적 일관성(Eventual Consistency)이 보장된다고 한다. 

 

Saga 패턴의 구현 방식은 코레오그래피 방식과 오케스트레이션 방식으로 나뉜다.

코레오그래피 사가(Choreography Saga)

코레오그래피는 중앙제어자 없이 메시지 브로커를 통해 마이크로서비스 간 이벤트를 교환하며 전체 프로세스가 진행되는 방식이다. 즉, 각 로컬 트랜잭션이 다른 마이크로서비스의 로컬 트랜잭션을 트리거하는 이벤트를 발행하게 된다.

장점

  • 구성이 편리하다.
  • 메시지 브로커를 통해 각 서비스들이 느슨하게 결합된다.

단점

  • SAGA 참가자가 많은 경우 트랜잭션 흐름이 복잡해져 파악하기 어렵다.
  • 마이크로 서비스 간 순환 종속성이 발생할 수 있다.

오케스트레이션 사가(Orchestration Saga)

오케스트레이션은 중앙제어자 역할의 오케스트레이터가 각 서비스들에게 트랜잭션과 보상 트랜잭션을 명령하며 진행하는 방식이다.

장점

  • 오케스트레이터에 프로세스의 실행 순서를 중앙에서 관리하기 때문에 현재 진행중인 상태를 추적하기 쉽다.

단점

  • 오케스트레이터에 중앙집중되어 단일 장애점이 될 수 있다.

Saga Pattern 적용

프로젝트 상황은 다음과 같다. 수업 예약 요청이 Pt 서비스로 들어온다. 이때 수업 예약을 위해서는 포인트가 있어야 하며 수업 예약에 성공하면 포인트가 차감되는데, 포인트는 Auth 서비스에서 관리된다. 

 

요청 흐름은 다음과 같다.

  • 예약 가능 유무 확인(Pt) → 포인트 검증 및 차감(Auth) → 예약(Pt)

위와 같이 수업 예약에 대한 하나의 트랜잭션이 분산 환경에서는 여러 로컬 트랜잭션으로 나뉘게 된다. 그렇기 때문에 포인트가 차감되었는데, 롤백 또는 서비스 장애 등의 이유로 수업 예약에 실패하는 경우와 같이 로컬 트랜잭션 간의 원자성이 보장되지 않을 수 있다.

 

로컬 트랜잭션 간 원자성을 보장하기 위해 Saga 패턴을 적용해보자. Saga를 적용한 부분은 다음과 같다. 

  • 포인트를 차감한 후 예약에 실패할 경우, 차감한 포인트를 복구시킨다.

우선 Saga 구현 방식으로 코레오그래피와 오케스트레이션 방식 중 오케스트레이션 방식을 적용했다. 이유는 수업 예약 요청을 받는 Pt 서비스가 존재하고, 이 Pt 서비스가 락을 통해 동시성 제어를 하며, Auth 서비스에게 포인트 검증 및 차감 요청을 보낸 후 수업 예약을 하기 때문이다. 즉, Pt 서비스가 중앙에서 제어하는 오케스트레이터 역할을 하게 된다. 

 

이때 Pt 서비스가 Auth 서비스로 포인트 검증 및 차감 요청을 보낼 때 HTTP 동기 방식을 통해 보낸다. 그 이유는 포인트 검증 및 차감의 결과를 Pt 서비스가 알아야 이후 예약을 실패 처리할지 예약을 시도할지 정해지기 때문이다.  

2. Transactional Messaging 

Pt 서비스는 보상 트랜잭션을 트리거하기 위한 이벤트를 발행한다. 이와 같이 비동기 메시징 시스템을 활용한 분산 환경에서는 비즈니스 로직이 실행됨에 따라 이를 표현하는 이벤트도 온전히 발행되어야 한다. 보통 애플리케이션 로직상 트랜잭션이 완료되기 전에 이벤트 메시지를 발행하는데, 이때 메시지가 발행되었으나 특정 이유로 예외가 발생해 롤백될 수 있고, 또는 메시지 발행에 실패했으나 커밋될 수 있다. 즉, DB 커밋과 메시지 발행이 원자적으로 수행되지 않는 문제가 발생할 수 있다. 

 

이와 같이 서비스 로직의 실행과 그 이후의 이벤트 발행을 원자적으로 함께 실행하는 것을 트랜잭셔널 메시징(Transactional Messaging)이라고 한다. 트랜잭션이 커밋되면 메시지도 정상 발행되어야 하고, 트랜잭션이 롤백되면 메시지는 발행되면 안된다.

Transactional Outbox Pattern

위와 같은 문제를 해결하기 위한 방법으로 DB를 업데이트하는 트랜잭션의 일부로 데이터베이스에 메시지를 저장하는 방법이 있다. 그런 다음 별도의 프로세스가 저장된 이벤트를 읽어 메시지 브로커에 전송하는 것이다. 즉, 메시지 발행을 바로 하지 않고 DBMS에 메시지를 INSERT하는 방식으로 구현하여, 하나의 트랜잭션으로 묶는 방식이다. 그리고 별도의 프로세스가 DBMS에 저장된 메시지를 읽어 메시지 브로커에 전송하는 것이다. 이 방법이 Transactional Outbox Pattern이다.

 

애플리케이션은 데이터베이스의 outbox 테이블에 메시지를 저장한다. 그리고 별도의 프로세스가 outbox 테이블에서 데이터를 읽고 해당 데이터를 사용해 작업을 수행한다. 실패 시 완료될 때까지 재시도한다. 따라서 Outbox Pattern은 메시지 발행에 시차가 생길 수 있지만 최종적 일관성(Eventual Consistency)을 보장하며 적어도 한 번 이상(at-least once) 메시지가 전송됨을 보장한다.

3. Consumer Dead Letter Queue

Auth 서비스는 메시지를 컨슘하여 포인트를 복구시키는 보상 트랜잭션을 수행한다. 그런데 이때 포인트 복구 중 서버가 꺼지거나 예외가 발생해 롤백되는 등 메시지를 정상적으로 컨슘하지 못하게 되면 어떻게 될까?

 

Dead Letter Queue를 설정하면, 컨슘에 실패한 메시지가 DLQ로 전달된다. 그리고 이 DLQ를 구독한 서버가 다시 해당 메시지를 기존 토픽으로 발행하면, Auth 서비스가 해당 메시지를 재컨슘할 수 있다.

4. 에러의 종류에 따른 후속 처리

Pt 서비스는 Auth 서비스에게 포인트 검증 및 차감 요청을 HTTP 동기 방식으로 보낸다. 이때 발생할 수 있는 에러는 다음과 같이 정상적인 에러와 비정상적인 에러로 나눌 수 있다.

  • 정상적인 에러(ex, 포인트 부족으로 인해 차감 실패)
  • 비정상적인 에러(ex, 네트워크 문제로 인한 타임아웃)

따라서 두 경우를 구분했다. 포인트 부족으로 인해 차감에 실패하는 경우, false를 반환한다. 따라서 Pt 서비스는 false를 반환받은 경우, 포인트가 부족하다는 예외 응답을 반환한다.

 

그리고 비정상적인 에러도 다음과 같이 두 경우로 나눠서 생각해야 한다.

  • 포인트 차감 후 타임아웃 오류가 발생한 경우
  • 포인트 차감 전 타임아웃 오류가 발생한 경우

포인트가 차감되었다면 예외 응답만 반환할 것이 아니라, 포인트도 복구시켜야 한다. 반면, 포인트가 차감되지 않았다면 예외 응답만 반환하면 된다.

 

따라서 타임아웃 에러가 발생했을 때 Pt 서비스는 보상 트랜잭션을 트리거하기 위한 이벤트를 발행하고 이를 Auth 서비스가 컨슘한다. Auth 서비스는 해당 예약 요청에 대해 포인트가 차감되었는지 확인한 후, 차감되었다면 포인트를 복구시키는 보상 트랜잭션을 수행한다.

 

이때 각 예약 요청마다 uuid를 생성한 후 이벤트 발행 시 이를 함께 담아서 발행한다. 그리고 Auth 서비스는 포인트를 차감했다면 해당 uuid 값을 redis에 저장해둔다. 그리고 이후 보상 트랜잭션이 필요할 때 redis에 해당 uuid 값이 있는지 확인한 후 있다면 포인트가 차감된 것이므로 포인트를 복구하는 보상 트랜잭션을 수행한다.

DBMS와 레디스 연산의 원자성 보장

포인트를 차감하면 레디스에 uuid 값을 저장한다. 그런데 다음과 같은 경우 두 연산의 원자성이 보장되지 않는다.

  • 레디스에 uuid 값을 저장했지만 포인트를 차감한 트랜잭션이 롤백된 경우, 결과적으로 포인트가 차감되지 않았지만 레디스에 uuid 값이 저장된다.

따라서 DBMS와 레디스 연산의 원자성을 보장해야 한다. 기본적으로 RedisTemplate은 관리되는 스프링 트랜잭션에 참여하지 않는다. 즉, @Transactional 내부에서 RedisTemplate을 사용하여 레디스에 커맨드를 수행할 경우, 레디스 내에서 MULTI, EXEC을 통한 트랜잭션은 이뤄지지 않는다. 따라서 @Transactional을 사용할 때 RedisTemplate이 레디스 트랜잭션을 사용하도록 하려면, 각 RedisTemplate에 대해 명시적으로 setEnableTransactionSupport(true)를 설정하여 트랜잭션 지원을 활성화해야 한다. 

 

따라서 setEnableTransactionSupport(true)을 설정하고 @Transactional을 사용하여 RedisTemplate이 DBMS 트랜잭션에 참여하도록 했다. 참고로 이때 쓰기 연산은 레디스 서버에 바로 전달되지 않고 트랜잭션이 커밋되어 EXEC가 호출될 때 전달된다. (자세한 내용은 다음 글을 참고하자.)

 

결과적으로 @Transactional AOP로 인해 트랜잭션이 커밋될 때, 레디스 서버에 EXEC가 호출되며 쓰기 연산이 수행된다. 따라서 트랜잭션 롤백 시 레디스에 uuid 값이 쓰여지지 않고 커밋 시에만 레디스에 uuid 값이 쓰여지며, DBMS와 레디스 연산의 원자성이 보장된다.

원자성 보장 X

다음과 같이 setEnableTransactionSupport(true)을 설정하지 않은 경우를 먼저 살펴보자.

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setPassword("1234");
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate() {
        StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
        stringRedisTemplate.setConnectionFactory(redisConnectionFactory());;
        stringRedisTemplate.setKeySerializer(new StringRedisSerializer());
        stringRedisTemplate.setValueSerializer(new StringRedisSerializer());
//        stringRedisTemplate.setEnableTransactionSupport(true); // 트랜잭션 설정
        return stringRedisTemplate;
    }
}

 

Auth 서버는 FeignClient를 통해 Pt 서버로부터 포인트 검증 및 차감 요청을 받는다. 그리고 서비스 계층의 메서드인 deductPoints() 메서드가 실행된다.

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final RedisTemplate<String, String> redisTemplate;

    @Transactional
    public boolean deductPoints(DeductAndCompensatePoints deductAndCompensatePoints) {
        User user = userRepository.findByEmail(deductAndCompensatePoints.getEmail())
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_USER));
        if (user.getPoint() < 3000) {
            return false;
        }
        
        // 포인트 차감
        user.deductPoints(); 
        
        // 포인트 차감했다는 의미로 레디스에 uuid 저장
        redisTemplate.opsForSet().add("deductPoints", deductAndCompensatePoints.getUuid());
        
        // 예외 발생으로 트랜잭션 롤백(포인트 차감되지 않음)
        throw new RuntimeException();
    }

    public void compensatePoints(DeductAndCompensatePoints dto) {
        if (redisTemplate.opsForSet().isMember("deductPoints", dto.getUuid())) {
            log.info("레디스에 uuid 저장된 상태, 따라서 보상 트랜잭션으로 포인트 복구시키기");
            User user = userRepository.findByEmail(dto.getEmail())
                    .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_USER));
            user.compensatePoints();
        } else {
            log.info("레디스에 uuid 저장되지 않은 상태");
        }
    }
}

 

deductPoints() 메서드는 포인트를 검증한 후 차감한다(user.deductPoints()). 그리고 레디스에 uuid 값을 저장한다. 그 이후에 RuntimeException이 발생하면서 DBMS 트랜잭션은 롤백된다. 하지만 레디스에는 uuid 값이 정상적으로 쓰여진다. (원자성 보장 X)

 

Auth 서버에서 RuntimeException이 발생하면 Pt 서버는 카프카에 보상 트랜잭션을 트리거하는 이벤트를 발행한다. Auth 서버는 이벤트를 컨슘하고 compensatePoints() 메서드에서 uuid 값이 레디스에 저장되었는지 확인 후 저장되었다면 포인트를 복구하는 보상트랜잭션을 수행한다.

참고로 compensatePoints() 메서드에는 @Transactional을 붙이면 안된다. @Transactional을 붙이면 redisTemplate.opsForSet().isMember() 연산이 null을 반환하면서 오류가 발생하기 때문이다. 레디스 트랜잭션에서 읽기 연산을 할 경우, 읽기 커맨드가 바로 레디스 서버로 전달되기는 하지만 레디스 내 큐에 저장되고 바로 실행되지는 않는다. 따라서 읽기 연산의 결과는 null이다. 자세한 내용은 다음 글을 참고하자.

 

위 코드를 실행해보면, 다음과 같은 로그가 남는다.

 

즉, 레디스에 uuid 값이 저장된 상태이다. 즉, DBMS 롤백으로 포인트가 차감되지 않았지만 레디스에는 uuid가 저장되었고, 이후 이벤트가 발행되었을 때 레디스에 uuid 값이 저장되어있으므로 포인트가 차감되었다고 판단하여 포인트를 복구시키게 된다. 결과적으로 DB에 사용자 포인트가 6000으로 업데이트된다(6000 = 3000(최초의 포인트 값) + 3000(포인트가 복구되며 추가된 값)).

원자성 보장 O

이번에는 setEnableTransactionSupport(true)을 설정하여 DBMS와 레디스 연산의 원자성을 보장한 경우를 살펴보자.

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setPassword("1234");
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate() {
        StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
        stringRedisTemplate.setConnectionFactory(redisConnectionFactory());;
        stringRedisTemplate.setKeySerializer(new StringRedisSerializer());
        stringRedisTemplate.setValueSerializer(new StringRedisSerializer());
        stringRedisTemplate.setEnableTransactionSupport(true); // 트랜잭션 설정
        return stringRedisTemplate;
    }
}

 

코드를 실행해보면 이번에는 레디스에 uuid 값이 저장되지 않은 것을 확인할 수 있다.

 

즉, DBMS 트랜잭션이 롤백되며 레디스의 쓰기 연산도 실행되지 않은 것이다. 따라서 DBMS와 레디스 연산의 원자성이 보장되었다.


Reference