← Posts

Architecture · Event-Driven · Backend

Pub/Sub과 이벤트 기반

일을 시키는 대신 일어난 일을 알린다. 듣는 쪽을 몰라도 되는 대신, 흐름이 코드에서 사라진다.

목차
  1. 반응해야 할 곳이 계속 늘어난다
  2. 시키는 것과 알리는 것은 다르다
  3. 하나가 보내면 여럿이 받는다
  4. 구독자를 늘려도 발행자는 안 바뀐다
  5. 대가 하나: 흐름이 코드에서 사라진다
  6. 대가 둘: 누가 안 받았는지 발행자는 모른다
  7. 대가 셋: 이벤트는 계약이다
  8. 이벤트에 무엇을 담나
  9. 흔히 어긋나는 자리
  10. 정리

주문이 하나 들어오면 메일, 적립금, 통계, 배송 준비, 추천 갱신이 움직여야 한다. 그리고 다음 달에 하나가 더 늘어난다.

반응해야 할 곳이 계속 늘어난다

앞 편에서 메일을 큐로 뺐다. 그런데 뒤에 할 일이 하나가 아니었다.

java
public void complete(Long orderId) {
    order.complete();
    mailQueue.send(new SendMail(orderId));       // 메일 보내라
    pointQueue.send(new GrantPoint(orderId));    // 적립금 줘라
    statsQueue.send(new RecordStats(orderId));   // 통계 남겨라
    shipQueue.send(new PrepareShip(orderId));    // 배송 준비해라
}

비동기라 느리진 않다. 문제는 다른 데 있다. 뒤에서 무언가 반응해야 할 때마다 주문 코드를 고친다. 주문 로직은 하나도 안 바뀌었는데 주문 서비스를 배포한다.

그리고 이 코드는 이상한 걸 알고 있다. 주문이 적립금 정책을 알고, 배송 절차를 알고, 통계 요구사항을 안다. 주문의 일이 아닌 것들이다.

시키는 것과 알리는 것은 다르다

위 코드에서 보내는 메시지들의 이름을 보자. SendMail, GrantPoint. 전부 명령이다. 받는 쪽에게 무엇을 하라고 시킨다. 시키려면 누가 있는지, 무엇을 할 수 있는지 알아야 한다.

방향을 뒤집으면 이렇게 된다.

java
public void complete(Long orderId) {
    order.complete();
    events.publish(new OrderCompleted(orderId));   // 주문이 완료됐다
}

OrderCompleted는 시키는 게 아니다. 일어난 사실을 말한다. 그래서 이걸 듣고 무엇을 할지는 듣는 쪽이 정한다.

명령이벤트
이름시키는 말 (SendMail)일어난 일 (OrderCompleted)
받는 쪽정해져 있다누구든, 몇이든
아는 것보내는 쪽이 받는 쪽을 안다보내는 쪽은 듣는 쪽을 모른다
실패하면시킨 일이 안 된 것사실은 그대로다. 반응만 안 된 것
시제아직 안 일어남이미 일어남

이름 짓기가 그냥 취향이 아닌 이유가 여기 있다. SendMail이라고 이름 붙이는 순간 주문은 메일의 존재를 알게 되고, 이름 하나에 결합이 따라 들어온다.

하나가 보내면 여럿이 받는다

이벤트를 나르는 방식이 Pub/Sub이다. 보내는 쪽은 주제에 발행하고, 관심 있는 쪽이 구독한다.

diagramdiagram

앞 편의 큐와 결정적으로 다른 게 있다. 큐에서는 메시지 하나를 처리기 하나가 가져간다. 처리기를 열 개로 늘려도 메시지 하나는 그중 한 처리기에게만 간다. (열 개가 나눠 갖는 것이지, 열 개가 각각 받는 게 아니다.) 그게 일을 나눠 갖는 방식이니까.

Pub/Sub에서는 메시지 하나가 구독자 모두에게 간다. 메일도 받고 적립금도 받고 통계도 받는다. 각자 자기 몫으로 한 벌씩 받는 것이다.

참고

둘은 대립하는 게 아니라 겹쳐 쓴다. 흔한 모양은 주제로 퍼뜨리고, 구독자마다 자기 큐를 갖는 것이다. 그러면 구독자 안에서는 처리기를 여러 개 두고 나눠 처리할 수 있고, 한 구독자가 멈춰 있어도 그 구독자의 큐에만 쌓인다. 앞 편에서 본 대가들은 여기서도 전부 그대로 적용된다.

구독자를 늘려도 발행자는 안 바뀐다

이제 처음 문제로 돌아간다. 다음 달에 “주문 완료 시 쿠폰 지급”이 추가된다면.

쿠폰 서비스가 OrderCompleted를 구독하면 끝이다. 주문 서비스는 코드도 안 바뀌고 배포도 안 한다. 새 기능이 붙었는지도 모른다.

diagramdiagram

이게 이벤트 기반이 주는 핵심이다. 변경이 한쪽에서 끝난다. 새 기능을 추가할 때 기존 서비스를 안 건드린다.

대가 하나: 흐름이 코드에서 사라진다

얻은 것의 뒷면이다. 주문 코드를 아무리 읽어도 주문이 완료되면 무슨 일이 벌어지는지 알 수 없다. 거기엔 발행 한 줄뿐이다.

명령 방식일 때는 최소한 목록이 코드에 있었다. 이제 그 목록은 어디에도 없다. 구독자들이 각자 자기 코드에 “나는 이걸 듣는다”고 적어놨을 뿐이다.

이게 실제로 아픈 순간은 이럴 때다.

  • “주문 완료되면 적립금이 안 들어옵니다” 신고를 받았는데, 어디서 끊겼는지 찾으려면 서비스들을 하나씩 열어봐야 한다
  • 이벤트 하나를 없애고 싶은데 누가 듣고 있는지 확신이 없다
  • 새로 온 사람이 시스템 전체 흐름을 파악할 방법이 문서 말고는 없다

그래서 이런 시스템에는 흐름을 밖에서 볼 수단이 필요하다. 앞 편들에서 말한 요청 식별자를 이벤트에도 실어 끝까지 넘기는 것이 여기서 값을 한다. 발행부터 각 구독자의 처리까지를 하나로 이어 붙일 수 있으면, 사라진 흐름을 로그에서 되살릴 수 있다.

주의

“결합이 없어졌다”는 표현은 정확하지 않다. 코드에서 서로를 안 부를 뿐, 쿠폰 서비스는 여전히 OrderCompleted의 모양과 의미에 의존한다. 결합은 사라진 게 아니라 호출 관계에서 메시지 계약으로 옮겨갔다. 그리고 계약은 컴파일러가 안 지켜준다.

대가 둘: 누가 안 받았는지 발행자는 모른다

동기 호출은 실패하면 부른 쪽이 안다. 큐로 일을 시킨 것도 그 일이 끝났는지 확인할 수는 있었다.

이벤트는 발행하면 끝이다. 적립금 구독자가 몇 시간째 멈춰 있어도 주문 서비스는 정상이라고 보고한다. 사용자 신고가 첫 신호가 되는 상황이 여기서 나온다.

그래서 감시의 자리가 구독자마다로 옮겨간다.

  • 구독자마다 밀린 양을 본다. 한 구독자만 계속 쌓이면 거기만 아픈 것이다
  • 구독자마다 실패 보관함을 따로 둔다. 메일 처리가 실패했다고 적립금 처리를 되돌리지 않는다
  • 발행량과 각 구독자의 처리량을 나란히 본다. 벌어지는 지점이 문제 지점이다

핵심은 하나다. 구독자 하나의 실패는 다른 구독자와 무관해야 한다. 그래야 이벤트가 이벤트로 작동한다.

대가 셋: 이벤트는 계약이다

발행한 이벤트가 누구에게 가는지 모른다는 건, 함부로 바꿀 수 없다는 뜻이기도 하다.

OrderCompleted에서 필드 하나를 빼면 그걸 쓰던 구독자가 깨진다. 그런데 누가 쓰는지 모른다. 그래서 이벤트 변경에는 앞서 API에 적용한 규칙이 그대로 필요하다.

  • 더하는 건 대체로 안전하다. 모르는 필드는 무시하게 만들어 둔다
  • 빼거나 의미를 바꾸는 건 위험하다. 새 필드를 추가해 한동안 둘 다 채우고, 아무도 옛 필드를 안 쓸 때 뺀다
  • 모양이 크게 바뀌면 새 이름으로 낸다. 옛 이벤트를 한동안 같이 발행한다

여기서 실수하기 쉬운 게 하나 더 있다. 이벤트에 내부 구현이 새어 나오는 것이다. DB 엔티티를 그대로 직렬화해 발행하면, 컬럼 하나 바꿀 때마다 남의 서비스가 깨진다. 이벤트는 바깥에 보여줄 사실만 담은 별도의 모양이어야 한다.

이벤트에 무엇을 담나

두 갈래가 있고 둘 다 쓴다.

얇게 담는다. 무슨 일이 일어났는지와 식별자만 넣는다. 필요한 정보는 구독자가 다시 물어본다.

json
{ "type": "OrderCompleted", "orderId": 1024, "occurredAt": "..." }
  • 이벤트가 작고, 발행자가 무엇을 담을지 고민하지 않아도 된다
  • 대신 구독자마다 되물어야 해서 동기 호출이 다시 생긴다. 앞 편의 문제로 돌아갈 수 있다
  • 되묻는 시점의 상태가 이벤트 시점과 다를 수 있다. 완료 이벤트를 처리하는데 그 사이 취소된 주문을 읽는 식이다

두껍게 담는다. 구독자가 대개 필요로 하는 값을 같이 넣는다.

json
{ "type": "OrderCompleted", "orderId": 1024, "memberId": 7,
  "totalAmount": 32000, "occurredAt": "..." }
  • 되묻지 않아도 되고, 그 시점의 사실이 그대로 박혀 있다
  • 대신 이벤트가 커지고, 담긴 필드가 곧 계약이라 바꾸기 어려워진다
  • 구독자가 그 값을 자기 쪽에 복사해 두기 시작하면 같은 데이터가 여러 곳에 생긴다

기준은 그 값이 시점에 묶인 사실인가다. 주문 시점의 금액처럼 나중에 물어보면 달라질 수 있는 값은 담는 쪽이 낫다. 회원 이름처럼 언제 물어도 최신이 맞는 값은 되묻는 게 낫다.

흔히 어긋나는 자리

이벤트 기반에서 실무가 무너지는 방식은 대체로 셋 중 하나다.

이벤트로 명령한다. OrderCompleted라고 이름 붙였는데 안에 “메일을 보내야 함” 같은 지시가 들어 있거나, 구독자가 하나뿐이고 그 구독자가 반드시 처리해야만 업무가 성립한다. 그러면 이름만 이벤트고 실제로는 명령이다. 반드시 되어야 하는 일이면 시키는 게 맞다. 그래야 실패했을 때 책임 소재가 분명하다.

이벤트로 답을 기다린다. 발행해 놓고 결과가 돌아오길 기다리는 구조를 만들면, 동기 호출의 단점에 흐름이 안 보이는 단점까지 더한 것이 된다. 지금 답이 필요하면 그냥 부르는 게 낫다.

사실이 아닌 걸 발행한다. 아직 커밋 안 된 상태로 발행하고 뒤에서 롤백하면, 구독자들은 일어나지 않은 일에 반응한다. 앞 편에서 본 대로 발행할 사실을 같은 트랜잭션에 기록해 두고 커밋된 뒤에 내보내야 한다.

판단이 헷갈릴 때 물어볼 문장 하나. “듣는 쪽이 아무도 없어도 발행하는 게 말이 되나?” 그렇다면 이벤트다. 아무도 안 들으면 아무 일도 안 일어나는데 그래도 괜찮아야 한다. 아니라면 그건 명령이고, 시키는 대상을 분명히 하는 게 낫다.

정리

이벤트란시키는 말이 아니라 일어난 사실. 이름부터 과거형
Pub/Sub하나가 발행하면 구독자 모두가 한 벌씩 받는다
큐와 차이큐는 하나가 가져가 처리, 주제는 모두에게 전달
얻는 것구독자를 늘려도 발행자는 안 바뀐다
잃는 것흐름이 코드에서 사라진다. 식별자를 실어 로그로 되살린다
결합은사라진 게 아니라 메시지 계약으로 옮겨갔다
감시구독자마다 밀린 양·실패 보관함을 따로 본다
판단 기준아무도 안 들어도 발행하는 게 말이 되면 이벤트, 아니면 명령

여기까지 서비스를 나누고, 부르고, 알리는 방법을 봤다. 그런데 계속 미뤄둔 게 하나 있다. 한 트랜잭션으로 못 묶는다는 것. 다음 글에서 나눈 시스템이 정합성을 어떻게 잃고, 그 대신 무엇을 얻는지 본다.

관련 글