티스토리 뷰

728x90
반응형

By unsplash

 

 

Transactional OutBox Pattern 이란?


MSA 환경에서 데이터베이스의 상태가 변경되면 해당 트랜잭션과 함께 이벤트를 발행해야 하는 경우가 종종 발생하곤 합니다. 예를 들어 사용자가 회원가입을 완료한 경우 쿠폰을 발급하거나 이메일을 발송해야 하는 경우입니다. 

위와 같은 이벤트를 발행하는 행위는 데이터베이스의 상태 변경과 원자적으로 실행되어야 합니다. 만약 데이터베이스에 회원가입 정보를 커밋한 뒤에 이벤트를 발행하면 이벤트가 어떠한 이유로 인해 오류가 발생하여 제대로 발행이 되지 않았다면? 데이터의 일관성이 깨질 우려가 있습니다. 간단한 코드로 조금 더 이해를 해보겠습니다.

 

🤔 코드로 살펴보기

 

아래 코드는  사용자의 회원가입 요청이 들어왔을 경우 한 트랜잭션 안에서 이벤트를 발송하고 있습니다. 그리고 이벤트 리스너에서는 @TransactionalEventListener 어노테이션을 사용하여 회원가입 트랜잭션이 commit 되고 나서 이벤트가 발행되게 됩니다. 그렇다면 아래 코드에서 무엇인 문제가 될까요? 이벤트 리스너에서 이벤트를 받아 다른 애플리케이션에게 Rest Api로 호출하는 경우 네트워크에 문제가 발생하면 데이터베이스에는 데이터가 저장되지만 다른 애플리케이션에는 해당 데이터를 받을 수 없어 일관성이 깨지게 됩니다.

여기서 그럼 네트워크 문제가 발생했을 경우 Retry를 하면되지 않을까?라는 생각을 가질 수 있습니다. 하지만 Retry는 근본적인 해결책이 될 수 없습니다. 만약 Retry를 5번 했다고 했을 경우 다른 애플리케이션이 다운된 경우라면 N번을 요청해도 결과는 같을 것입니다.

@Service
@Transactional
@RequiredArgsConstructor
public class CreateMemberService implements CreateMemberUsecase {

    private final CreateMemberPort createMemberPort;
    private final ValidationMemberPort validationPort;
    private final ApplicationEventPublisher publisher;

    @Override
    public MemberCreateResponse create(MemberCreateRequest request) {
        validationPort.validateMemberId(request.id());
        validationPort.validateMemberEmail(request.email());

        var memberEmail = new MemberEmail(request.email());
        var member = Member.create(request.id(), request.password(), request.name(), request.nickName(), memberEmail);
        var entity = createMemberPort.createMember(member);

        // 이벤트를 발송시킨다.
        publisher.publishEvent(new MemberCreatedEvent(member.getId()));

        return new MemberCreateResponse(entity.converterPKToString());
    }
}

public record MemberCreatedEvent(String memberId) {

}

@Component
public class MemberEventListener {

    @TransactionalEventListener(classes = {MemberCreatedEvent.class})
    public void handle(MemberCreatedEvent event) {
        // event를 받아 다른 애플리케이션에게 REST API를 호출한다.
    }
}

 

Transactional OutBox Pattern 살펴보기


위와 같은 문제를 해결하기 위해서는 Transactional OutBox Pattern을 사용할 수 있는데, 데이터베이스의 상태가 변경될  때마다 OutBox Table에 해당 이벤트도 함께 저장하는 것입니다. 그리고 별도의 프로세스가 OutBox Table을 주기적으로 읽어 저장된 이벤트를 이벤트 브로커에게 전달하는 방식입니다.

별도의 프로세스는 OutBox Table에 저장된 데이터를 가져와 작업을 수행하고 실패시 완료될 때까지 다시 시도할 수 있습니다. 따라서 이 방법은 적어도 한번 이상(at-least once) 메시지가 성공적으로 전송되었는지 보장할 수 있습니다.

 

Message Relay


Message Relay라는 프로세스가 있는데, 이 친구의 역할은 OutBox Table에 대한 변경 감지를 확인하는 친구입니다. 

OutBox Table은 큐의 역할을 수행하며, Message Relay는 비동기적으로 데이터를 읽어 들입니다. 그리고 읽어 들인 데이터를 Message Broker에게 전달하게 됩니다. Message Relay를 구현하는 방법에는 Polling Publisher, Transaction log tailing, Kafka Connect 방법이 있다고 합니다.

 

Polling Publisher

 

  • RDB를 사용하는 애플리케이션에서 OutBox table을 주기적으로 읽는 것입니다. 주기적으로 읽어 데이터를 처리하고 처리가 완료된 데이터는 table에서 삭제처리 합니다.
  • 데이터베이스를 주기적으로 읽어야하기 때문에 데이터베이스에 부담이 가며, 폴링 주기를 잘 고려해야 합니다.
  • 또한 Polling Publisher만을 위한 인스턴스를 따로 구성해야 할 필요가 있습니다. 만약 따로 구성하지 않고 scale-out 되어 인스턴스가 N개 있는 경우라면 ShedLock을 사용할 필요가 있습니다.

Transaction Log Tailing

 

  • 애플리케이션에 의해 커밋된 정보들은 데이터베이스의 로그 항목으로 남는데, 트랜잭션 로그 마이너로 트랜잭션 로그를 읽어 변경분을 하나씩 메시지로 만들고, 메시지 브로커에 전달하는 방법입니다.
  • 구현 난이도가 높아 Debezium, LinkedIn Databus와 같은 툴을 사용하는 경우가 많습니다.

Kafka Connect

 

 

주의점


 

OutBox Pattern은 적어도 한번(At-least Once) 이상은 메시지가 보내질 것이라고 확신할 수 있습니다. 그래서 Consumer에서는 중복된 메시지를 받을 수 있습니다. 그렇기 때문에 Consumer 입장에서는 멱등성이 보장되도록 애플리케이션을 구성해야 하며, 고유한 메시지 식별자를 함께 보내 이를 달성할 수 있다고 합니다.

 

 

장단점


장점

 

  • Two-Phase Commit(2PC) 방식을 사용하지 않기 때문에 성능 저하가 발생하지 않는다고 합니다. 이와 관련해서 구글링을 해보면 좋은 자료들을 찾아볼 수 있습니다.
  • 데이터베이스 커밋과 메시지 전송 모두 보장할 수 있습니다.
  • 애플리케이션이 전송한 순서대로 메시지 브로커로 전송됩니다. (전송한 순서는 결국 커밋 시점이므로)

단점

 

  • 멱등성을 보장해야 합니다.

 

 

후기


지금까지 OutBox Pattern에 대해서 알아보았고 이후에는 Message Relay 방법으로 Polling Publisher와 Kafka Connct를 각각 구현해 보고 조금 더 깊게 다루도록 하겠습니다. 

 

 

Polling Publisher 방법으로 구현한 트랜잭셔널 아웃박스 패턴 살펴보기

 

 

 

 

참고

 

 

 

 

 

 

728x90
반응형