6 분 소요

개요

결제 관련 업무 중 환불 관련 기능을 만들고 있었다. 어드민에서 환불 요청이 들어오면, 별도의 환불 프로세서 모듈이 그 요청을 받아 실제 환불을 처리하도록 흐름을 분리하는 일이었다. 두 모듈 사이에 토픽을 하나 두고, 어드민은 환불 요청을 메시지로 발행하고 프로세서는 그 메시지를 소비해 환불을 처리한다. 구조 자체는 단순했다.

문제는 “그 메시지에 무엇을 담을 것인가” 였다.

처음에는 별 고민이 없었다. 메시지만 보고도 환불 프로세서가 곧장 일을 시작할 수 있도록, 결제 id, 환불 금액, 환불 사유를 비롯해 처리에 필요할 만한 정보를 전부 페이로드에 담아주려고 했다. 컨슈머가 일을 잘 하도록 도와주는 게 메시지를 설계하는 사람의 역할이라고 생각했기 때문이다.

그런데 막상 필드를 하나씩 작성하다 보니, “이게 맞나?” 라는 생각이 들기 시작했다.

처음엔 비용 문제로 봤다

처음에 fat한 메시지로 설계하는게 낫다고 생각한 이유는 다음과 같다.

“환불 결제 대상 id 하나만 보내면, 컨슈머가 결국 그 id로 DB 조회를 한 번 더 해야 하는 거 아닌가? 그러면 비효율적이지 않나?”

그래서 fat한 메시지가 더 낫다고 생각했다. 어차피 메시지에 다 들어 있으면 조회를 안 해도 되니까. 한 번의 DB I/O를 아끼는 셈이라고 봤다.

그런데 환불 프로세서의 일을 좀 더 자세히 들여다보니, 이 사고가 의미 없다고 생각을 다시 하게 되었다.

환불 처리는 결국 결제 상태를 변경하고, 환불 내역을 저장하고, 정합성을 맞추기 위한 여러 DB 작업을 동반한다. 즉 메시지에 모든 정보가 다 들어 있다고 해도 DB에 가지 않는 시나리오는 처음부터 존재하지 않았다. “조회 한 번을 아낀다” 는 명분은, 어차피 N번 들어가야 하며 돈이 걸려 속도보다는 정합성이 중요한 작업 앞에서 의미가 없었다.

즉 thin이냐 fat이냐는 DB 비용으로 가를 수 있는 문제가 아니다.

독립성을 기준으로 삼아봤다

비용 기준이 사라지자 다른 기준이 필요했다. 고민하다 두 가지 정도로 정리했다.

  1. 컨슈머가 그 메시지를 처리할 때 메시지 외부에 있는 다른 시스템을 추가로 호출해야 하는가
  2. 그 시점의 사실을 나중에 replay 해서 재현할 가능성이 있는가

둘 중 하나라도 해당된다면 fat이 맞고, 둘 다 아니라면 thin이어도 충분하다는 식의 기준이었다.

이 기준은 기존의 DB 비용(속도) 기준보다는 메시지의 본질에 가까운 질문이라고 생각했다. 그런데 이 기준을 들고 환불 메시지를 다시 봤을 때, 또 어딘가 어긋난다는 느낌이 들었다.

환불 컨슈머가 결제 id로 들여다보는 곳은 결제 DB다. 그런데 이 DB가 과연 어드민(프로듀서)의 자원이라고 부를 수 있는가? 어드민은 환불을 요청하는 쪽일 뿐, 결제 데이터의 소유자는 아니다. 결제 DB는 결제 도메인 안에서 어드민과 환불 프로세서가 함께 바라보는 공용 저장소에 더 가깝다. 컨슈머가 그 DB를 다시 본다고 해서, 그게 프로듀서에게 무언가를 부탁하는 의존인지는 애매했다.

여기서 “독립적인 처리” 라는 기준이 한 번 더 갈라진다는 걸 알게 됐다. 컨슈머가 진짜 다른 시스템 (외부 PG, 외부 회원 서비스, 다른 도메인의 API 같은 것) 에 손을 뻗어야 하는 경우와, 같은 도메인 안에서 데이터를 함께 보고 있을 뿐인 경우는 외형이 비슷해 보여도 무게가 다르다. 전자는 시스템 경계를 넘는 진짜 결합이지만, 후자는 그저 같은 데이터를 다른 모듈이 들여다보는 일에 가깝다.

환불 케이스는 후자에 가까웠다. 그러니 독립성 기준만 가지고는 thin이어야 할지 fat이어야 할지를 깔끔하게 판단할 수 없었다. 비용 기준이 비껴갔던 것처럼, 독립성 기준도 이 케이스에선 결정타가 되지 못했다.

그러면서 또 다른 생각이 떠올랐다.

“그런데 메시지에 무엇을 담을지 결정하려면, 결국 메시지가 무엇인지 먼저 정해야 하지 않나?”

메시지가 뭔지부터 다시 물었다

돌아보니 나는 줄곧 같은 사고를 하고 있었다.

“이 메시지를 받아서 컨슈머는 무엇을 해야 하지? 그러려면 무엇이 필요하지?”

이 사고는 자연스럽지만, 동시에 이상한 사고이기도 했다. 메시지를 만드는 쪽인 어드민이 메시지를 받는 쪽인 환불 프로세서가 어떤 일을 어떻게 해야 하는지를 미리 상상하고 있다는 뜻이기 때문이다.

이벤트 기반으로 가는 이유가 뭐였는지를 다시 떠올려봤다. 결국은 결합도를 낮추기 위함이다. 그런데 메시지를 만들 때마다 컨슈머의 행동을 머릿속에 그리고 있다면, 그 결합은 단지 코드가 아니라 사고 안에 그대로 살아 있는 셈이다. 컨슈머가 바뀌면 메시지도 바뀌어야 하고, 새 컨슈머가 붙으면 메시지에 또 무언가를 보태야 한다.

이 자리에서 메시지가 무엇을 담아야 하는지보다, 이벤트란 무엇인가 부터 다시 정의해야겠다는 생각이 들었다.

이벤트는 사실이다

여러 생각을 하다 내가 내린 이벤트의 정의는 이거였다.

이벤트는 그 시점에 발생한 사실이다.

이렇게 정의를 내린 이유는 단순하다. 이벤트 기반 아키텍처가 결합도를 낮추기 위한 도구라면, 이벤트의 정의도 그 목적에 부합해야 한다. 그러려면 이벤트는 받는 쪽을 의식하지 않은 무언가여야 한다. 받는 쪽이 무엇을 할지 모른 채로도 충분히 의미가 있는, 그 자체로 닫힌 기록.

“발생한 사실” 이라는 표현은 이 조건을 자연스럽게 만족시킨다. 사실은 누가 어떻게 사용하든 사실이고, 컨슈머가 메일을 보내든 통계를 집계하든 그것은 사실의 변형이 아니라 사실에 대한 반응이다.

이 관점에서 명령(Command)과 이벤트(Event)는 분명하게 갈린다.

  • ProcessRefund 는 명령이다. 미래형이고, 누군가에게 무엇을 하라고 지시한다.
  • RefundRequested 는 이벤트다. 과거형이고, 이미 일어난 사실을 기록한다.

이름 짓는 컨벤션을 이야기하는 것이 아니다. 메시지를 만드는 사람이 “받는 쪽이 어떻게 행동해야 하는가” 를 머릿속에 두고 있느냐, 그렇지 않느냐의 차이다. 이벤트는 그 행동을 알지 않아야 한다.

그래서 thin인가 fat인가

이벤트에 대한 정의를 새로 잡았다고 해서, thin/fat 문제까지 사라지는 건 아니었다.

같은 환불 사실도 “환불 #123이 요청되었다” 라고 기록할 수 있고, “환불 #123이 결제 #456에 대해 5,000원, 단순변심 사유로, 사용자 A에 의해 요청되었다” 라고 기록할 수도 있다. 둘 다 사실을 기록한 이벤트라는 정의에서는 어긋나지 않는다.

다만 이전과 달라진 건 질문의 모양이다.

이전엔 “컨슈머가 무엇을 필요로 하는가” 를 물었다면, 이제는 “이 사실을 얼마나 풍부하게 기록할 것인가” 를 묻게 된다. 후자의 질문은 컨슈머의 행동이 아니라 사실의 입자 크기에 대한 질문이다. 이 시점의 상태를 그 자체로 보존해야 하는가, 아니면 식별자만 남겨두고 필요할 때 다시 조회하게 둘 것인가.

결국 이번 환불 메시지는 결제 id 하나만 담는 thin으로 결론을 냈다. 다음 두 가지 조건을 확인하고 나서야 안전하다고 판단할 수 있었다.

첫째, 환불 처리에서 컨슈머가 의존하는 결제 정보 (결제 금액, 결제자, 결제 시각 …)는 한 번 기록되면 변하지 않는 필드들이다. 즉 발행 시점이든 처리 시점이든 이 값들은 같다. 둘째, 환불 내역은 결제 row를 수정하는 방식이 아니라 별도 row로 append되는 구조라, 환불 history는 시점 추적이 가능하다. DB 전체가 append-only는 아니지만, 컨슈머의 의존 대상이 immutable한 부분으로 제한되어 있다는 점 이 thin 선택을 정당화하는 근거였다.

뒤집어 말하면, 이 조건이 깨지는 도메인에선 thin으로 가기 어렵다. 예를 들어 요청 시점의 환불 사유 자체가 사후에 수정 가능한 시스템이라면, 컨슈머가 처리 시점에 DB를 봤을 때 _발행 시점의 사실_이 아닌 _수정된 사실_을 보게 된다. 부분 환불이 짧은 간격으로 동시 발행되는 도메인이라면, 첫 번째 메시지가 처리되기도 전에 두 번째 요청이 들어와 “남은 환불 가능 금액”이 발행 시점과 어긋날 수 있다. 외부 PG의 비동기 상태 변경, 컨슈머 장애로 인한 수 시간 뒤 재처리 같은 시나리오에서도 같은 문제가 생긴다. 이런 케이스에선 fat이거나, 발행 시점 상태를 복원할 수 있는 별도의 audit/event-sourcing 인프라가 필요해진다.

이렇게 정리하고 나니, thin/fat 결정은 “이 도메인에서 컨슈머가 의존하는 상태 집합이 발행 시점과 처리 시점 사이에 변할 수 있는가” 라는 한 줄로 좁혀졌다. 변할 수 있다면 fat, 변하지 않거나 변해도 최신 상태가 처리에 더 적합하다면 thin. 이번 환불 케이스는 후자였고, 그래서 thin이 컨벤션을 따르는 동시에 도메인적으로도 정합적인 선택이 됐다.

결합도 다시 보기

정의를 잡고 나서 또 하나 분명해진 게 있다. 결합도라는 단어를 더 세분해서 보게 됐다는 점이다.

풀린 결합 은 행동의 결합이다. 컨슈머가 메시지를 받아 메일을 보내든, 통계를 쌓든, 슬랙 알림을 띄우든 프로듀서는 알 필요도 없고 바꿀 일도 없다. 새 컨슈머가 하나 붙어도 프로듀서 코드는 한 줄도 건드리지 않는다.

남은 결합 도 분명히 있다. 같은 DB 테이블을 바라본다는 것. 컨슈머가 결제 id로 DB를 조회한다면, 결국 그 테이블의 스키마와 의미에 의존하고 있는 셈이다. 이벤트 기반으로 간다고 해서 이 결합이 사라지지는 않는다.

그리고 결합이라고 부르기 어려운 것 이 있다. 메시지의 발행 순서다. 환불이 요청되어야 환불 처리가 시작된다는 것은 시스템 설계의 문제가 아니라 그냥 사실의 순서다. 후행 사건은 선행 사건이 일어난 다음에 발생한다는 것은 어떤 아키텍처를 쓰든 변하지 않는다. 이걸 결합이라고 부르면 세상의 모든 인과관계가 결합이 된다.

이렇게 나누어 놓고 보니, 이벤트 기반이 결합을 없애는 것이 아니라 결합의 위치를 옮기는 일 에 가깝다는 게 분명해진다. 행동의 결합을 떼어 내고, 데이터 형태에 대한 결합은 남겨 두고, 인과의 순서는 처음부터 결합이 아닌 것으로 인정하는 일.

마치며

처음에는 환불 메시지에 어떤 필드를 넣을지를 두고 시작한 고민이었다. 그런데 이 작은 결정 하나가 비용 기준 → 독립성 기준 → 정의 자체 → 결합도의 분류까지 사고를 한참 끌고 갔다.

도착한 자리는 결제 id만 담는 thin 메시지라는, 어쩌면 처음부터 답이었을지도 모르는 결론이었다. 그런데 그 자리에 도달하기까지 무엇을 거쳤는지가 결국 이번에 더 남는 게 됐다.

메시지를 만들 때 던지는 질문이 바뀌었다. 이전엔 “컨슈머가 받아서 무엇을 하려면 어떤 정보가 필요한가” 라고 물었다면, 이제는 “이 시점에 무엇이 사실이 되었는가” 라고 묻는다. 후자의 질문은 받는 쪽을 신경 쓰지 않는 질문이고, 그래서 결합도를 낮추는 사고와 결이 맞는다.

댓글남기기