Mission-critical한 상황에서 Kafka 활용하기

Kafka는 Mission-crticial한 유즈케이스를 위해 zero message loss, guaranteed ordering을 보장한다.
하지만 애플리케이션 관점에서 이것은 유효하지 않다.
전송과 순서에 있어 무결성을 달성하려면 퍼블리셔와 컨슈머 각각 필요한 처리들이 존재한다.

  1. DB & Queue의 트랜잭션 보장 불가
  2. Pattern: Transactional outbox
    1. Polling publisher
    2. Transaction log tailing
  3. 애그리거트 기반 토픽 분할
    1. 컴퓨터는 실패한다
    2. 샤딩을 한다
    3. 시간을 신뢰할 수 없다
    4. 버전은 신뢰할 수 있다

1. DB & Queue의 트랜잭션 보장 불가

컴퓨터는 어떤 Instructment에서든 갑자기 실패 할 수 있다.
따라서 서로 같은 트랜잭션으로 묶여 있지 않은 DB와 Kafka는 원자성을 보장할 수 없다.
예를 들어 아래의 경우 유저 생성은 성공해도, Kafka 전송은 실패 할 수 있는 이유는 다양하다.

- Begin Transaction
  user_repo.save(User)
- Commit Transaction
kafka_publisher.send(topic="user", value=UserCreated)

이렇듯 DB와 Kafka 같이 서로 신뢰 불가능한 환경에 놓여 있는 경우 TCP에서 지혜를 얻을 수 있다.
신뢰성을 만들기 위해 재시도 가능한 환경을 구성하는 것이다.

2. Pattern: Transactional outbox

Transaction Outbox 패턴은 큐 전송 신뢰성과, 순서 보장을 동시에 만족하는 패턴이다.
핵심은 큐에 전송할 메시지를 DB 의 트랜잭션 내부에서 같이 저장하는 것이다.
RDBMS의 경우 Outbox 테이블을 만들 수 있고,
NOSQL의 경우 message/event attribute를 두어 함께 저장한다.
구현법은 Polling publisher과 Transaction log tailing이 있다.
둘 다 간략하게 살펴보자.

2.1 Polling publisher

- Begin Transaction
  user = User()
  outbox = Outbox(
      topic="user",
      payalod=UserCreated,
  )
  user_repo.save(User)
  outbox_repo.save(outbox)
- Commit Transaction
- Begin Transaction
  user = User()
  outbox = Outbox(
      topic="user",
      payalod=UserCreated,
  )
  user_repo.save(User)
  outbox_repo.save(outbox)
- Commit Transaction

이처럼 Outbox 테이블에 로우가 남아있으면 카푸카에 전송이 성공할 때까지 재시도를 할 수 있게 된다.
그리고 Outbox에 적힌 순서대로 카푸카에 전송하기 때문에 순서도 보장할 수 있다.
다만 이 구현에서 처리량 높이기 위해 Batch 수를 늘린다면, 메시지의 순서를 보장할 수 없게 된다.
뒤에서 자세히 설명하겠지만, 한 애그리거트의 내부에서 발생하는 이벤트만 순서를 보장 할 수 있다.
이점을 이해하게 되면 순서를 보장하면서도 동시성을 올릴 아이디어를 얻게 된다.

2.2 Transaction log tailing

마치 DB의 레플리카가 된 것처럼 Transaction Log를 받아 Kafka에 메시지를 전송하는 방법이다.
실용적으론 Debezium 같은 CDC를 사용한 구현으로 봐도 무방해 보인다.
Debezium 같은 Kafka Connector는 Kafka Topic에 트래킹한 Transaction Log를 저장한다.
그래서 모종의 실패 혹은 신버전 배포등의 이유로 CDC를 재실행해도 끊긴 지점부터 메시지를 전송할 수 있다.

Transaction log traling은 효율적인 면에서 Polling publisher 보다 우월하다.
특히 기본적으로 샤딩 되어 있는 NoSQL 같은 경우에도 잘 어울린다.
다만 CDC를 도입하면서 인프라와 개발 환경이 복잡성이 올라갈 수 있다.
또한 CDC는 날데이터에 가까우므로 토픽을 이벤트에 맞게 분산시키는 등 재변환이 필요하다.
이런 부분은 차후에 Ksql이 일반화되면 좀 더 쉽게 처리 할 수 있을것으로 보인다.

3. 애그리거트 기반 토픽 분할으로 순서 보장

애그리거트 기반으로 토픽을 분할하는 것이 좋다고 생각한다.
토픽의 특성중 하나로는 파티션 키를 기반으로 순서를 보장할 수 있다는 것이다.
그러나 전체 서비스에서 발생하는 이벤트에 대해 순서를 보장할 수 있는건 아니다.
엄밀히 말해서 애그리거트의 한 로우 안에서만 발생하는 이벤트들만 순서를 보장할 수 있다.
그렇게 생각하는 이유는 다음과 같다.

3.1 컴퓨터는 실패한다

다시 한 번 말하지만 컴퓨터는 실패한다.
예를 들어 깃허브에 가입하고 레포를 만들었다고 가정하자.
이것이 회원 가입 이벤트 -> 레포 생성 이벤트 순서로 Kafka에 도달됨을 보장하진 못한다.
인증 서버와 레포 생성 서버가 분리 되어 있을 경우, 인증 서버의 네트워크 등의 이유로 회원 가입 이벤트는 전송 실패 할 수 있기 때문이다.
이것이 서비스 전체에서 발생하는 이벤트의 순서를 보장 할 수 없는 이유 중 하나다.

3.2 샤딩

NoSQL은 물론 RDBMS의 경우도 처리량을 높이기 위해 샤딩 할 수 있다.
그러면 Polling publisher의 경우 Outbox 테이블이 샤딩된다는 뜻이고,
Transaction log tailing의 경우는 여러 CDC가 붙을 수 있다는 뜻이다.
앞서 말한 실패 등의 이유와 각기 처리 속도가 다를 수 있으므로 순서 보장이 불가능해진다.
이것은 애그리거트 모델 범위에서도 순서를 보장 할 수 없는 이유 중 하나다

시간을 신뢰할 수 없다

이벤트를 발생 시간 기준으로 정렬해도 그것이 정확한 순서임을 보장 할 수 없다.
모든 컴퓨터의 시간이 동일하지 않기 때문이다.
어떤 컴퓨터는 다른 컴퓨터 보다 항상 10ms 더 느린 시간값을 가지고 있을 수 있다.
또한 오차를 동기화하는 작업이 발생하면 동일한 컴퓨터 조차 시간값이 과거로 회귀할 수 있다.
시간은 유용한 도구지만 완전히 정확한 순서 보장이 요구되는 경우에는 사용 할 수 없다.
이것 또한 서비스 전체에서 발생하는 이벤트의 순서를 보장 할 수 없는 이유가 된다.
모든 이벤트를 시간으로 정렬해도 시간 값에 오차가 있을 수 있기 때문이다.

3.4 버전은 신뢰할 수 있다.

버전은 애그리거트를 영속화 할 때마다 증가하는 필드다.
보통 Optimsitic Lock 용도로 활용된다.
DB에 영속화된 순서이기 때문에 시간 값과는 다르게 신뢰할 수 있다.
이것이 애그리거트 한 로우에서 발생하는 이벤트의 순서를 보장할 수 있는 이유다.