목차
주문이 하나 들어오면 메일, 적립금, 통계, 배송 준비, 추천 갱신이 움직여야 한다. 그리고 다음 달에 하나가 더 늘어난다.
반응해야 할 곳이 계속 늘어난다
앞 편에서 메일을 큐로 뺐다. 그런데 뒤에 할 일이 하나가 아니었다.
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. 전부 명령이다. 받는 쪽에게 무엇을 하라고 시킨다. 시키려면 누가 있는지, 무엇을 할 수 있는지 알아야 한다.
방향을 뒤집으면 이렇게 된다.
public void complete(Long orderId) {
order.complete();
events.publish(new OrderCompleted(orderId)); // 주문이 완료됐다
}OrderCompleted는 시키는 게 아니다. 일어난 사실을 말한다. 그래서 이걸 듣고 무엇을 할지는 듣는 쪽이 정한다.
| 명령 | 이벤트 | |
|---|---|---|
| 이름 | 시키는 말 (SendMail) | 일어난 일 (OrderCompleted) |
| 받는 쪽 | 정해져 있다 | 누구든, 몇이든 |
| 아는 것 | 보내는 쪽이 받는 쪽을 안다 | 보내는 쪽은 듣는 쪽을 모른다 |
| 실패하면 | 시킨 일이 안 된 것 | 사실은 그대로다. 반응만 안 된 것 |
| 시제 | 아직 안 일어남 | 이미 일어남 |
이름 짓기가 그냥 취향이 아닌 이유가 여기 있다. SendMail이라고 이름 붙이는 순간 주문은 메일의 존재를 알게 되고, 이름 하나에 결합이 따라 들어온다.
하나가 보내면 여럿이 받는다
이벤트를 나르는 방식이 Pub/Sub이다. 보내는 쪽은 주제에 발행하고, 관심 있는 쪽이 구독한다.
앞 편의 큐와 결정적으로 다른 게 있다. 큐에서는 메시지 하나를 처리기 하나가 가져간다. 처리기를 열 개로 늘려도 메시지 하나는 그중 한 처리기에게만 간다. (열 개가 나눠 갖는 것이지, 열 개가 각각 받는 게 아니다.) 그게 일을 나눠 갖는 방식이니까.
Pub/Sub에서는 메시지 하나가 구독자 모두에게 간다. 메일도 받고 적립금도 받고 통계도 받는다. 각자 자기 몫으로 한 벌씩 받는 것이다.
둘은 대립하는 게 아니라 겹쳐 쓴다. 흔한 모양은 주제로 퍼뜨리고, 구독자마다 자기 큐를 갖는 것이다. 그러면 구독자 안에서는 처리기를 여러 개 두고 나눠 처리할 수 있고, 한 구독자가 멈춰 있어도 그 구독자의 큐에만 쌓인다. 앞 편에서 본 대가들은 여기서도 전부 그대로 적용된다.
구독자를 늘려도 발행자는 안 바뀐다
이제 처음 문제로 돌아간다. 다음 달에 “주문 완료 시 쿠폰 지급”이 추가된다면.
쿠폰 서비스가 OrderCompleted를 구독하면 끝이다. 주문 서비스는 코드도 안 바뀌고 배포도 안 한다. 새 기능이 붙었는지도 모른다.
이게 이벤트 기반이 주는 핵심이다. 변경이 한쪽에서 끝난다. 새 기능을 추가할 때 기존 서비스를 안 건드린다.
대가 하나: 흐름이 코드에서 사라진다
얻은 것의 뒷면이다. 주문 코드를 아무리 읽어도 주문이 완료되면 무슨 일이 벌어지는지 알 수 없다. 거기엔 발행 한 줄뿐이다.
명령 방식일 때는 최소한 목록이 코드에 있었다. 이제 그 목록은 어디에도 없다. 구독자들이 각자 자기 코드에 “나는 이걸 듣는다”고 적어놨을 뿐이다.
이게 실제로 아픈 순간은 이럴 때다.
- “주문 완료되면 적립금이 안 들어옵니다” 신고를 받았는데, 어디서 끊겼는지 찾으려면 서비스들을 하나씩 열어봐야 한다
- 이벤트 하나를 없애고 싶은데 누가 듣고 있는지 확신이 없다
- 새로 온 사람이 시스템 전체 흐름을 파악할 방법이 문서 말고는 없다
그래서 이런 시스템에는 흐름을 밖에서 볼 수단이 필요하다. 앞 편들에서 말한 요청 식별자를 이벤트에도 실어 끝까지 넘기는 것이 여기서 값을 한다. 발행부터 각 구독자의 처리까지를 하나로 이어 붙일 수 있으면, 사라진 흐름을 로그에서 되살릴 수 있다.
“결합이 없어졌다”는 표현은 정확하지 않다. 코드에서 서로를 안 부를 뿐, 쿠폰 서비스는 여전히 OrderCompleted의 모양과 의미에 의존한다. 결합은 사라진 게 아니라 호출 관계에서 메시지 계약으로 옮겨갔다. 그리고 계약은 컴파일러가 안 지켜준다.
대가 둘: 누가 안 받았는지 발행자는 모른다
동기 호출은 실패하면 부른 쪽이 안다. 큐로 일을 시킨 것도 그 일이 끝났는지 확인할 수는 있었다.
이벤트는 발행하면 끝이다. 적립금 구독자가 몇 시간째 멈춰 있어도 주문 서비스는 정상이라고 보고한다. 사용자 신고가 첫 신호가 되는 상황이 여기서 나온다.
그래서 감시의 자리가 구독자마다로 옮겨간다.
- 구독자마다 밀린 양을 본다. 한 구독자만 계속 쌓이면 거기만 아픈 것이다
- 구독자마다 실패 보관함을 따로 둔다. 메일 처리가 실패했다고 적립금 처리를 되돌리지 않는다
- 발행량과 각 구독자의 처리량을 나란히 본다. 벌어지는 지점이 문제 지점이다
핵심은 하나다. 구독자 하나의 실패는 다른 구독자와 무관해야 한다. 그래야 이벤트가 이벤트로 작동한다.
대가 셋: 이벤트는 계약이다
발행한 이벤트가 누구에게 가는지 모른다는 건, 함부로 바꿀 수 없다는 뜻이기도 하다.
OrderCompleted에서 필드 하나를 빼면 그걸 쓰던 구독자가 깨진다. 그런데 누가 쓰는지 모른다. 그래서 이벤트 변경에는 앞서 API에 적용한 규칙이 그대로 필요하다.
- 더하는 건 대체로 안전하다. 모르는 필드는 무시하게 만들어 둔다
- 빼거나 의미를 바꾸는 건 위험하다. 새 필드를 추가해 한동안 둘 다 채우고, 아무도 옛 필드를 안 쓸 때 뺀다
- 모양이 크게 바뀌면 새 이름으로 낸다. 옛 이벤트를 한동안 같이 발행한다
여기서 실수하기 쉬운 게 하나 더 있다. 이벤트에 내부 구현이 새어 나오는 것이다. DB 엔티티를 그대로 직렬화해 발행하면, 컬럼 하나 바꿀 때마다 남의 서비스가 깨진다. 이벤트는 바깥에 보여줄 사실만 담은 별도의 모양이어야 한다.
이벤트에 무엇을 담나
두 갈래가 있고 둘 다 쓴다.
얇게 담는다. 무슨 일이 일어났는지와 식별자만 넣는다. 필요한 정보는 구독자가 다시 물어본다.
{ "type": "OrderCompleted", "orderId": 1024, "occurredAt": "..." }- 이벤트가 작고, 발행자가 무엇을 담을지 고민하지 않아도 된다
- 대신 구독자마다 되물어야 해서 동기 호출이 다시 생긴다. 앞 편의 문제로 돌아갈 수 있다
- 되묻는 시점의 상태가 이벤트 시점과 다를 수 있다. 완료 이벤트를 처리하는데 그 사이 취소된 주문을 읽는 식이다
두껍게 담는다. 구독자가 대개 필요로 하는 값을 같이 넣는다.
{ "type": "OrderCompleted", "orderId": 1024, "memberId": 7,
"totalAmount": 32000, "occurredAt": "..." }- 되묻지 않아도 되고, 그 시점의 사실이 그대로 박혀 있다
- 대신 이벤트가 커지고, 담긴 필드가 곧 계약이라 바꾸기 어려워진다
- 구독자가 그 값을 자기 쪽에 복사해 두기 시작하면 같은 데이터가 여러 곳에 생긴다
기준은 그 값이 시점에 묶인 사실인가다. 주문 시점의 금액처럼 나중에 물어보면 달라질 수 있는 값은 담는 쪽이 낫다. 회원 이름처럼 언제 물어도 최신이 맞는 값은 되묻는 게 낫다.
흔히 어긋나는 자리
이벤트 기반에서 실무가 무너지는 방식은 대체로 셋 중 하나다.
이벤트로 명령한다. OrderCompleted라고 이름 붙였는데 안에 “메일을 보내야 함” 같은 지시가 들어 있거나, 구독자가 하나뿐이고 그 구독자가 반드시 처리해야만 업무가 성립한다. 그러면 이름만 이벤트고 실제로는 명령이다. 반드시 되어야 하는 일이면 시키는 게 맞다. 그래야 실패했을 때 책임 소재가 분명하다.
이벤트로 답을 기다린다. 발행해 놓고 결과가 돌아오길 기다리는 구조를 만들면, 동기 호출의 단점에 흐름이 안 보이는 단점까지 더한 것이 된다. 지금 답이 필요하면 그냥 부르는 게 낫다.
사실이 아닌 걸 발행한다. 아직 커밋 안 된 상태로 발행하고 뒤에서 롤백하면, 구독자들은 일어나지 않은 일에 반응한다. 앞 편에서 본 대로 발행할 사실을 같은 트랜잭션에 기록해 두고 커밋된 뒤에 내보내야 한다.
판단이 헷갈릴 때 물어볼 문장 하나. “듣는 쪽이 아무도 없어도 발행하는 게 말이 되나?” 그렇다면 이벤트다. 아무도 안 들으면 아무 일도 안 일어나는데 그래도 괜찮아야 한다. 아니라면 그건 명령이고, 시키는 대상을 분명히 하는 게 낫다.
정리
| 이벤트란 | 시키는 말이 아니라 일어난 사실. 이름부터 과거형 |
| Pub/Sub | 하나가 발행하면 구독자 모두가 한 벌씩 받는다 |
| 큐와 차이 | 큐는 하나가 가져가 처리, 주제는 모두에게 전달 |
| 얻는 것 | 구독자를 늘려도 발행자는 안 바뀐다 |
| 잃는 것 | 흐름이 코드에서 사라진다. 식별자를 실어 로그로 되살린다 |
| 결합은 | 사라진 게 아니라 메시지 계약으로 옮겨갔다 |
| 감시 | 구독자마다 밀린 양·실패 보관함을 따로 본다 |
| 판단 기준 | 아무도 안 들어도 발행하는 게 말이 되면 이벤트, 아니면 명령 |
여기까지 서비스를 나누고, 부르고, 알리는 방법을 봤다. 그런데 계속 미뤄둔 게 하나 있다. 한 트랜잭션으로 못 묶는다는 것. 다음 글에서 나눈 시스템이 정합성을 어떻게 잃고, 그 대신 무엇을 얻는지 본다.