본문으로 건너뛰기

서비스 사이를 잇는 법, 이벤트 버스와 워크플로우

· 약 7분

서비스를 비동기로 잇는 두 도구(NATS 이벤트 버스, Temporal 워크플로우)를 살펴보고, 메시지 보관 기간을 잘못 잡아 데인 사고를 정리합니다.

지난 편까지 서비스를 나누고, 거기에 로그인을 붙였습니다. 이번에는 나뉜 서비스들이 서로 어떻게 이야기를 주고받는지를 다뤄 보겠습니다.

직접 부르면 함께 넘어집니다

서비스 A가 일을 끝낸 뒤 B에게 알려야 한다고 해봅시다. 가장 단순한 방법은 A가 B를 직접 호출하는 것입니다. 하지만 직접 호출에는 함정이 있습니다.

전화에 비유하면 이렇습니다. A가 B에게 전화를 걸면, B가 받을 때까지 A는 수화기를 들고 기다려야 합니다. B가 바쁘거나 잠시 죽어 있으면, A도 함께 멈춥니다. 한 서비스의 장애가 전화선을 타고 옆으로 번지는 것이지요.

게다가 "결제가 끝났다"는 사실 하나에 관심 있는 서비스가 여럿일 수 있습니다. 알림도 보내야 하고, 통계도 쌓아야 하고, 영수증도 발급해야 합니다. 그때마다 A가 세 곳에 전화를 돌리는 것은 번거롭고, 한 곳이라도 안 받으면 A가 멈춥니다.

사실은 게시판에 붙입니다 (이벤트 버스, NATS)

그래서 직접 전화 대신 게시판을 두었습니다. A는 "결제가 끝났다"를 게시판에 붙이기만 합니다. 누가 보는지는 신경 쓰지 않습니다. 관심 있는 서비스가 알아서 와서 읽습니다.

서비스를 직접 호출하면 함께 멈추는 방식과, 이벤트 게시판으로 발행해 필요한 서비스가 골라 읽는 방식의 차이

이 게시판 역할을 하는 것이 이벤트 버스(NATS)입니다. 핵심은 발행하는 쪽은 수신자를 모른다는 점입니다.

  • A는 게시판에 사실을 붙이고 자기 일을 계속합니다. B가 죽어 있어도 A는 멈추지 않습니다.
  • 알림·통계·영수증 서비스가 각자 필요한 글만 골라 읽습니다.
  • 수신자가 새로 늘어도 A의 코드는 바뀌지 않습니다. 게시판만 보면 되니까요.

서로 모르기 때문에 서로 묶이지 않습니다. 이것이 비동기로 잇는 첫 번째 도구입니다.

여러 단계는 절차로 묶습니다 (워크플로우, Temporal)

이벤트만으로 충분하지 않은 경우가 있습니다. 여러 단계를 거치는데, 중간에 실패하면 앞 단계를 되돌려야 하는 작업입니다.

예를 들어 "주문 → 결제 → 재고 차감 → 배송 등록"을 생각해 봅시다. 결제는 됐는데 재고 차감에서 실패하면, 결제를 되돌려(환불) 줘야 합니다. 이 되돌리기(보상)를 손으로 짜다 보면, 꼭 한 단계를 빠뜨립니다. "어디까지 진행됐더라"를 코드 곳곳에서 추적해야 하기 때문입니다.

워크플로우 엔진(Temporal)은 이 절차를 하나의 흐름으로 묶어 줍니다. 택배 송장에 비유할 수 있습니다. 송장에는 단계가 적혀 있고, 어디까지 왔는지가 항상 기록됩니다. 중간에 멈춰도 송장을 보면 다음에 무엇을 할지, 되돌린다면 어디까지 되돌릴지 알 수 있습니다.

  • 각 단계의 진행 상태를 엔진이 기억합니다. 서버가 재시작해도 멈춘 지점부터 이어집니다.
  • 실패 시 되돌리는 단계(보상)를 흐름 안에 명시적으로 둡니다.
  • "어디까지 됐더라"를 코드가 아니라 엔진이 추적합니다.

정리하면, 알리기만 하면 되는 일은 이벤트로, 여러 단계를 책임지고 끝내야 하는 일은 워크플로우로 나눠 맡겼습니다.

크게 데인 사고, 우체통을 매일 비웠다

이벤트 버스에는 한 가지 설정이 있습니다. 게시판에 붙은 글을 얼마 동안 보관할지(보관 기간, retention)입니다. 모든 글을 영원히 둘 수는 없으니, 일정 기간이 지나면 지웁니다.

한 분석용 스트림에서 이 보관 기간을 하루로 잡아 두었습니다. 그런데 그 글을 모아 가는 집계 작업은 하루보다 더 긴 주기로 돌고 있었습니다.

보관 기간 1일이 집계 작업 주기보다 짧아, 가�져가기 전에 메시지가 삭제되고 오류 로그도 남지 않은 사고

결과는 이랬습니다. 집계 작업이 글을 가져가기도 전에, 보관 기간이 지나 글이 먼저 지워졌습니다. 데이터가 조용히 비기 시작했습니다.

비유로 보면

우체통에 편지가 도착했는데, 수취인이 가져가기 전에 매일 우체통을 비워 버린 셈입니다. 편지를 보낸 쪽도, 받을 쪽도 잘못이 없는데 중간 설정 하나 때문에 편지가 사라졌습니다.

가장 고약한 점은 어디에도 오류가 찍히지 않았다는 것입니다. 발행은 성공했고, 보관 기간 만료에 따른 삭제도 정상 동작이었습니다. 시스템 입장에서는 모든 것이 "정상"이었습니다. 지난 편의 쿠키 사고와 똑같은 교훈입니다. 오류 없이 조용히 어긋나는 동작이 가장 오래 사람을 붙잡습니다.

해결은 두 가지였습니다.

  • 보관 기간을 넉넉히(여러 날) 통일했습니다. 가져가는 쪽 주기보다 충분히 길게 둔 것입니다.
  • 이 값을 각자 임의로 정하지 못하게, 공통 상수 하나로 고정했습니다. 누가 새 스트림을 만들어도 같은 보관 기간을 쓰도록 코드 차원에서 강제했습니다.
from shared.events.constants import STREAM_MAX_AGE  # 한곳에서 관리

config = StreamConfig(
name="MY_STREAM",
subjects=["my-service.>"],
max_age=STREAM_MAX_AGE.total_seconds(), # 임의의 1일/7일 금지
)

설정값을 사람의 판단에 맡기면 누군가는 다르게 잡습니다. 중요한 기본값은 코드로 고정하는 편이 안전했습니다.

마무리

  • 서비스를 직접 호출하면 한쪽 장애가 옆으로 번집니다. 사실은 이벤트로 발행하면, 발행자와 수신자가 서로 몰라 묶이지 않습니다.
  • 여러 단계 + 되돌리기가 필요한 작업은 워크플로우로 묶습니다. 진행 상태와 보상 절차를 엔진이 대신 기억합니다.
  • 메시지 보관 기간을 가져가는 쪽 주기보다 짧게 잡으면, 수취 전에 사라집니다. 그것도 오류 없이 조용히.
  • 사람이 매번 정하는 설정값은 어긋나기 마련입니다. 중요한 기본값은 공통 상수로 고정했습니다.

다음 편에서는 이 서비스들을 고객사 안에서 직접 돌려야 했던 온프레미스(on-premise) 환경 이야기를 해보겠습니다. 인터넷이 막힌 곳에 어떻게 설치했는지를 적어 볼 생각입니다.