본문 바로가기

[Project] 프로젝트 삽질기59 (feat transactional outbox)

어가며

NestJS와 PostgreSQL, TypeORM을 활용하여 프로덕트를 만들고 있습니다. 저번 글에서 Transactional 데코레이터를 활용하면서 이벤트가 발행되지 않았다는 이유로 트랜잭션이 rollback 된다면 어떻게 대처해야 하는가에 대한 문제를 살펴봤습니다. 문제 해결을 위해서는 Transactional outbox 패턴을 적용해야 한다고 생각했습니다. 이번 글에서는 Transactional outbox 패턴이란 무엇이며, 패턴을 적용하면서 어떤 어려움이 있었는지 내용을 공유하고자 합니다.

 

 

 

 

 


 

 

 

 

문제점

저번 글에서 작성한 문제를 다시 가져왔습니다. 만약 아래 로직처럼 데이터 저장 로직은 성공했지만 이벤트 발행에 실패할 경우, 트랜잭션은 어떻게 처리되어야 할까요?

 

@Transactional()
   public async create(): Promise<void> {
     ...
     await repository.save();
     await producer.sendEvent();
 }
}

 

 

Transactional 데코레이터를 활용한다면, 이벤트가 발행되지 않았다는 이유로 트랜잭션이 rollback 될 것입니다. 만약 결제 과정에서 결제 데이터는 제대로 저장됐는데, 이벤트 로직이 제대로 처리되지 않았다는 이유로 결제를 취소해야 하는 상황이 온다면 어떻게 대처해야 할지에 대한 의문이 들었습니다. 

 

저는 이에 대한 답으로 'Transactional outbox 패턴'이라는 것을 적용한다면 이 문제를 해결할 수 있다고 생각했습니다. 그럼 'Transactional outbox 패턴'은 무엇이며, 이 패턴을 서비스에 어떻게 적용하려 했는지, 적용하는 과정에서 어떤 어려움이 있었는지 공유하겠습니다.

 

 

 

 

 

 

 

 


 

 

 

 

 

출처: https://microservices.io/patterns/data/transactional-outbox.html

 

Transactional outbox

Transactional Outbox 패턴이란 비즈니스 엔티티를 업데이트하는 행위와 메시지를 발행하는 행위 사이에 메시지를 저장하는 로직을 추가하는 방법을 뜻합니다. 메시지를 발행하는 시스템은 메시지를 곧바로 발행하기보다는 데이터베이스 트랜잭션을 이용해 메시지를 Outbox 테이블에 저장합니다. 이 과정을 통해 트랜잭션이 제공하는 원자성을 이용해, 발행하지 않아야 하는 메시지가 Outbox 테이블에 들어가거나 발행해야 하는 메시지가 Outbox 테이블에 들어가지 않는 문제를 방지합니다. 이후 Message Relay 과정을 통해 Outbox에 저장된 메시지를 성공할 때까지 발행합니다. 메시지를 수신하는 시스템은 같은 메시지를 여러 번 수신해도 한 번 수신한 것과 같이 처리해, 메시지가 정확히 한 번 처리되도록 합니다. 이 패턴을 Transactional Outbox Pattern이라고 합니다.

 

 

 

 

 

 

 

출처: https://microservices.io/patterns/data/transactional-outbox.html

 

 

 

 

이 패턴의 구성요소는 다음과 같습니다.

 

  • Sender - 메시지를 보내는 서비스
  • Database - 엔티티 및 메시지 outbox를 저장하는 데이터베이스
  • Message outbox - 관계형 데이터베이스인 경우 보낼 메시지를 저장하는 테이블
  • Message relay - outbox에 저장된 메시지를 메시지 브로커로 보내는 서비스

 

 

구성요소를 간단하게 살펴봤다면, 이제는 Transactional Outbox 패턴을 구현하는 방법을 알아보겠습니다. 

 

  1. 발행할 메시지 정보를 저장하는 Outbox 테이블을 만든다.
  2. 비즈니스 엔터티를 업데이트하는 로직과 메시지를 Outbox 테이블에 저장하는 로직을 하나의 트랜잭션으로 묶는다.
  3. Outbox 테이블을 풀링하는 별도의 프로세스를 통해 메시지를 발행한다.

 

Transactional Outbox 패턴을 통해 트랜잭션이 커밋될 때만 메시지를 전송하도록 보장할 수 있습니다. 그리고 메시지를 발행하는 로직을 트랜잭션에서 완전히 분리하여 외부 메시지 브로커에 대한 의존성을 최대한 제거할 수 있습니다. 그럼 Transactional outbox pattern 방식을 적용시켜, 앞서 살펴본 문제를 모두 해결해 보겠습니다.

 

 

 

 

 


 

 

 

 

 

 

Transactional Outbox 패턴 적용

Transactional Outbox 패턴을 적용하게 되면 어떤 점들이 바뀌는지 살펴보겠습니다. 

 

 

@Injectable()
export class PaymentBusinessService {

constructor(
	private readonly repository: PaymentRepository, 
	private readonly producer: PaymentEventProducer
) {}
    
@Transactional()
   public async create(): Promise<void> {
     ...
     await repository.save();
     await producer.sendEvent();
 }
}

@Injectable()
export class PaymentEventProducer implements IPublisher {
  constructor(
    @Inject(TransactionManager) private readonly entityManager: EntityManager,
  ) {}

  async sendEvent(message: Message): Promise<void> {
    const outboxRepository = this.entityManager
      .getEntityManager()
      .getRepository(OutboxEntity);

    const outboxEntity = new OutboxEntity();
    outboxEntity.id = uuidv1();
    outboxEntity.subject = message.subject;
    outboxEntity.body = JSON.stringify(message.body);

    await outboxRepository.save(outboxEntity);
  }
}

 

 

 

먼저 Transactional 데코레이터를 활용하겠습니다. repository에 데이터를 저장하고, 이벤트를 발행하는 로직에서는 곧바로 이벤트를 발행하는 것이 아니라 이벤트 발행 내용을 outbox 테이블에 저장합니다. 이렇게 처리하면, 데이터베이스 트랜잭션이 원자성을 보장하기 때문에 결제 정보와 메시지는 모두 저장되거나 모두 저장되지 않을 수 있습니다. 따라서 발행되지 않아야 하는 메시지가 Outbox에 저장되거나, 발행되어야 하는 메시지가 Outbox에 저장되지 않는 일은 발생하지 않습니다. 또한 커밋된 순서대로 메시지를 발행함으로써 메시지의 순서도 보장할 수 있습니다. 

 

 

그럼 Outbox 테이블에 저장된 이벤트를 어떻게 발행하는 것일까요? 메시지 발행 로직에 대해 알아보겠습니다. 

 

 

class MessagePublishingJob { 
constructor(
   private readonly outboxRepository: OutboxRepository, 
  ) {}

    ...

    @Cron('45 * * * * *')
    public async publishMessages(): Promise<void> {
        const messages = await outboxRepository.findAll(10);
        await publisher.publish(messages);
        await outboxRepository.delete(messages);
    }
}

 

 

 

Outbox에 저장된 메시지를 Polling 해, 성공할 때까지 발행합니다. 메시지를 Polling 해서 성공적으로 메시지를 발행 완료한다면 Outbox에 저장된 메시지를 삭제합니다. 이 예시에서는 발행이 완료된 메시지를 데이터베이스에서 제거하고 있지만, softDelete 처리를 위해 기록을 위해 남겨두고 발행 완료로 마킹할 수도 있습니다.

 

 

 

 

Outbox 테이블 구성

Outbox는 보낼 편지함(새로 작성한 이메일을 보내기 전에 그 메시지가 저장되는 곳)이라는 뜻으로, 이벤트 메시지 데이터를 저장하는 테이블을 구성해야 합니다. 저는 Outbox 테이블을 구성하기 위해 아래처럼 구성했습니다.

 

 

 

create table outbox
(
    id                bigint auto_increment primary key,
    created_at        datetime default current_timestamp not null,
    updated_at        datetime default current_timestamp not null,
    deleted_at        datetime,
    topic             varchar(255)                       not null,
    payload           varchar(255)                       not null,
    sended_at         timestamp with timezone             
);

 

 

 

즉 Outbox 테이블을 생성하여 서비스 로직과 함께 보낼 메시지 정보가 트랜잭션 내에서 원자성을 보장받기 때문에 모두 저장되거나 모두 저장되지 않게 됩니다.

 

 

 

 

 

 


 

 

 

 

 

고려할 점

Transactional Outbox Pattern을 도입하는 것은 좋은데, 그래서 이벤트를 어떻게 전달하고, 수신받을 것인지 고민해야 합니다. 만약 outbox 테이블에 있는 이벤트 메시지가 중복 발행된다면 어떻게 될까요? 서버에서는 같은 메시지를 여러 번 발행할 수 있기 때문에, 메시지를 수신받는 쪽에서 수신받은 메시지를 멱등하게 처리할 수 있도록 환경을 구성해야 합니다. 즉, 같은 메시지를 여러 번 처리해도 한 번 처리한 것과 같은 결과물이 나오도록 해야 합니다. 만약 푸시 알림을 보내는 이벤트를 전달하는 것이라면, 멱등하게 처리하지 않는다면 유저에게 중복된 푸시 메시지가 전달될 수 있습니다. 그렇기 때문에 멱등하게 이벤트를 수신받기 위해선 어떻게 처리해야 할지 고민해야 합니다. 

 

 

멱등 처리를 위해서는 여러 가지 방법을 사용할 수 있습니다. 첫 번째는 메시지의 고유 식별자를 이용해 이미 처리한 메시지는 다시 처리하지 않도록 할 수 있습니다. 두 번째는 메시지를 수신할 때마다 상품의 현재 상태를 확인함으로써 여러 번 처리해도 같은 결과가 나오도록 할 수도 있습니다.

 

 

 

 

 

 


 

 

 

 

 

 

한계점

Outbox 패턴을 팀에 적용하면서 문제 해결을 하는 것은 좋은데 Transactional Outbox 패턴이 우리 팀에 필요한 패턴일까 고민이 들었습니다. 이 패턴이 필요할 만큼 팀 내에서 이벤트 발행 로직 때문에 트랜잭션이 실패해서 문제가 발생한 적이 있는가 생각해 봤습니다. 그래서 먼저 두 가지를 고려해 보기로 했습니다. 

 

 

  1. 트랜잭션 성공하고 이벤트 발행이 실패하는 경우가 얼마나 발생하는가?
  2. 이벤트 발행이 실패했을 때, 이벤트 발행이 실패했다는 것을 확인할 수 있다면 이벤트를 수동으로 처리하는 것이 훨씬 비용이 적게 드는 것이 아닐까?

 

 

만약 두 가지를 고려했을 때, Transactional Outbox 패턴이 정말 필요하다면 팀에 적용해야겠다고 생각했습니다. 

 

 

 

1. 트랜잭션 성공하고 이벤트 발행이 실패하는 경우가 얼마나 발생하는가?

육아크루 서비스를 개발하는 과정에서 AWS SQS, SNS를 활용해서 이벤트를 발행합니다. 서비스를 운영하는 과정에서 이벤트 발행 실패는 단 한 번도 발생하지 않았습니다.

 

 

 

Amazon Messaging (SQS, SNS) Service Level Agreement

This Amazon Messaging Service Level Agreement (“SLA”) is a policy governing the use of the Included Services (listed below) and applies separately to each account using the Included Services. In the event of a conflict between the terms of this SLA and

aws.amazon.com

 

 

심지어 AWS는 서비스 수준 협약(SLA)을 통해 고객에게 서비스의 가용성과 신뢰성에 대한 일정 수준의 보장을 제공하고 있었습니다. SNS 및 SQS의 SLA는 대부분의 경우 99.9% 이상의 가용성을 제공하고 있었는데, 99.9%는 대략 365일 중 8시간 미만의 다운타임이 발생한다는 것을 의미했습니다. 차라리 8시간 미만의 다운타임이 발생할 때, 이벤트 발행이 실패한다면 실패한 이벤트를 수동으로 발행하는 것이 효율적이지 않을까 생각할 수 있었습니다. 

 

 

 

 

 

 

 

2. 실패한 이벤트를 수동으로 다시 발행하는 것이 비용이 적게 드는 것이 아닐까?

그럼 이벤트 발행이 실패했다는 것을 어떻게 알 수 있을까 생각했습니다. 이벤트 발행에 실패한다면 Sentry를 통해 어떤 이벤트 발행이 언제, 어떻게 실패했는지를 자세히 기록하면 된다고 생각했습니다. 그리고 이벤트 발행에 실패했다는 것을 빠르게 모니터링할 수 있다면, 수동으로 이벤트를 다시 발행할 수 있지 않을까 생각했습니다. 

 

 

이 두 가지를 고려하면서, Transactional Outbox 패턴은 훌륭한 문제 해결 방식이지만, 당장 우리 팀에 적용하기에는 오버 엔지니어링을 하는 것이라 판단했습니다. 그렇지만 Transactional 데코레이터를 활용하기 때문에 트랜잭션이 성공하더라도 이벤트 발행이 실패하면 트랜잭션이 실패하기 때문에 Transactional Outbox 패턴을 사용하지 않는다면 이벤트 발행이 실패했기 때문에 트랜잭션도 함께 롤백되는 문제를 겪을 수밖에 없었습니다.

 

 

 

 

 

 

즉, 99.9% 이상의 가용성을 제공하더라도, 절대로 장애가 발생하지 않는다는 뜻은 아니었습니다. 잠깐의 다운타임이 발생해서 이벤트 발행이 실패한다면 트랜잭션도 롤백될 수 있기 때문에 트랜잭션이 롤백된다면 비즈니스에 영향을 줄 수 있었습니다. 그럼 Transactional 데코레이터를 사용하는 것이 좋은 트랜잭션 관리 방법이 맞는 것일까? 다시 뒤돌아 생각했습니다. 

 

 

 

 

 

 


 

 

 

 

 

 

타협안

원점으로 돌아가, TypeORM에서 트랜잭션을 효율적으로 관리하기 위해 어떤 것을 선택해야 할까 고민했습니다. 이때 다시 고려한 해결책이 바로 Typeorm의 Transaction 메서드였습니다.

 

 

await this.connection.transaction<Users>(async (em: EntityManager) => {
    // ...
  });

 

 

 

transaction 메서드를 활용한다면, queryRunner를 활용해서 수동으로 트랜잭션을 관리하는 방법보다 반복해서 작성하는 코드를 제거할 수 있다는 장점이 있습니다. 그리고 await queryRunner.release() 코드를 작성하지 않아도 트랜잭션을 관리할 수 있기 때문에 코드를 작성하지 않음으로써 서버에 장애를 발생시킬 일은 없습니다. 그리고 이벤트 발행이 실패하더라도 트랜잭션은 실패하지 않도록 구성할 수도 있었습니다.  

 

 

이 긴 여정을 통해 팀에서는 Typeorm을 활용할 땐 transactional 데코레이터를 사용하기보다는 transaction 메서드를 활용하는 방식으로 트랜잭션을 관리하도록 정책을 구성할 수 있었습니다.  

 

 

 

 

 


 

 

 

 

 

 

 

 

 

마치며

서비스의 발전을 위해 개발자로서 좋은 기술을 습득하고 적용하는 것이 중요하다고 생각합니다. 하지만 팀의 현재 상황을 고려하여 문제를 해결하기 위해서는 적정한 기술을 활용하는 것이 대단히 중요하다는 것을 다시금 깨닫습니다. 오버 엔지니어링을 지양할 수 있는 개발자가 되기 위해 계속해서 정진하겠습니다. 긴 글 읽어주셔서 대단히 감사합니다. 

 

 

 

 

 

 


 

 

 

출처

 

Transactional Outbox Pattern 알아보기

Transactional Outbox Pattern이벤트로 기반으로 분리된 분산환경에서 우리는 특정 DB 상태를 변경하는 트랜잭션과 함께 이벤트를 발행해야할 때가 있습니다. 가령 웹 서비스에 회원 탈퇴 요청이 왔을

velog.io

 

분산 시스템에서 메시지 안전하게 다루기

Transactional Outbox Pattern을 이용한 결과적 일관성 확보 by 강남언니 블로그

blog.gangnamunni.com

 

분산 시스템에서 메시지 데이터 정합성 유지하기 - Transactional Outbox Pattern

분산 시스템에서 각각의 서비스를 분리할수록 트랜잭션을 보장하기 어려워지는 문제점이 있다. 하지만 데이터 일관성(Consistency)의 중요도는 누구라도 공감하는 부분일 것이다. 이번 포스트에서

velog.io

 

트랜잭셔널 아웃박스 패턴의 실제 구현 사례 (29CM)

이 글에서는 실무 관점에서의 Apache Kafka 활용 에서 잠깐 소개했던 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern) 을 실제로 구현하여 활용하고 있는 29CM 의 사례를 소개하고자 한다.

medium.com

 

Outbox 패턴을 이용한 메일 발송 구현해보기

Github Repo https://github.com/beaniejoy/resetpw-outbox-scheduler Overview Eventual Consistency & Strong Consistency At Least Once? Outbox 패턴 코드 구현 📌 1. Overview 프로젝트 개발 중에 비밀번호 초기화 관련한 메일 발송 기

beaniejoy.tistory.com

 

MSA에서 메시징 트랜잭션 처리하기 | Popit

비동기 메시지를 사용하여 상호 간에 통신하는 방식을 메시징 Messaging[1] 이라고 부른다. 마이크로서비스 환경에서 비동기 처리 시 보통 카프카 Kafka 나 래빗엠큐 RabbitMQ 같은 메시지 브로커 Message

www.popit.kr

 

Transactional Outbox 패턴을 이용한 메시지 발행의 신뢰성 보장

부제: 메시지 발행의 신뢰성을 보장하기 위해선 어떻게 해야할까? Transactional Outbox 패턴이란 비즈니스 엔터티를 업데이트하는 행위와 메시지를 발행하는 행위 사이에 메시지를 저장하는 로직을

yeongunheo.tistory.com