이벤트를 다루는 글이나 발표에는 거의 항상 "느슨한 결합"이라는 말이 따라붙는다.
근데 나는 이 말이 계속 어색했다.
"주문 서비스는 알림 서비스를 몰라도 된다"는 설명을 처음 들었을 때, 결합 자체가 사라진다는 뜻으로 이해했다. 그런데 이상했다. 알림은 결국 주문이 완료됐다는 사실을 듣고 반응하는 거다. 주문에 종속된 무언가를 듣고 움직인다는 건데, 그러면서 결합이 없어진다니.
이 위화감 하나 붙잡고 계속 끙끙 대고 있었다. 코드를 직접 비교해보고, 반론을 세워보고, 결국엔 컴퓨터 역사까지 거슬러 올라갔다. 이 글은 단일 서버의 인메모리 이벤트(Spring @EventListener 같은 것)부터 분산 환경의 메시지 브로커(Kafka 같은 것)까지, 그 과정에서 확인한 것들을 정리한 기록이다.
미리 밝혀둘 게 하나 있다. 뒤에 나오는 하드웨어 인터럽트 역사 부분은 내가 직접 검증한 내용이 아니다. 논문과 문헌을 찾아 읽고 재구성한 거고, 원전을 전부 정독한 것도 아니다. "이렇게 이해했다"는 기록으로 읽어주면 좋겠다.
1. 뭘 알고 싶었나
이벤트(event)는 사건이라는 뜻이다.
사건이 뭔지부터 짚어야 했다. 단순하게 보면 이전과 이후가 갈리는 경계다.
주문이 처리 중이다 → 상태(state)
주문이 완료됐다 → 사건(event)
상태는 지금 어떤지를 말한다. 사건은 뭔가 바뀌었다는 사실을 말한다. 둘 사이엔 결정적인 차이가 하나 있다. 사건은 되돌릴 수 없다는 것.
주문이 완료됐다는 사실은 영원히 사실이다. 나중에 취소되더라도 그건 "취소됐다"는 새 사건이 생긴 거지, 완료 사건 자체가 지워지는 게 아니다.
이게 왜 중요한가 하면, 선언하는 것과 시키는 것은 완전히 다른 행위이기 때문이다.
sendEmail(user) → 명령. 상대의 존재를 전제한다.
UserSignedUp(user) → 선언. 아무도 안 들어도 성립한다.
명령은 받는 쪽이 있어야 성립한다. 선언은 듣는 쪽이 없어도 사실이다. 이벤트 이름이 항상 과거형인 이유가 여기 있다. OrderCompleted, PaymentFailed — 전부 이미 벌어진 일이고, 누가 듣고 뭘 할지는 선언하는 쪽이 알 바 아니다.
여기까지는 납득이 됐다. 문제는 다음이었다.
선언이 누군가에게 도달해서 반응을 일으킨다면, 그 반응은 결국 원래 사건에 종속된 것 아닌가. 알림 서비스는 "주문 완료"라는 사실 없이는 애초에 실행될 이유가 없다. 그런데도 결합이 사라진다고 말할 수 있나.
이벤트가 없애는 게 정확히 뭘까. 결합 자체는 아닌 것 같은데.
2. 직접 뜯어보면서 확인한 것들
코드로 나란히 놓아보기
말로는 답이 안 나와서 코드로 비교했다.
# 직접 호출
def complete_order(order_id):
notification_service.send(order_id) # 알림 서비스를 직접 안다
point_service.accumulate(order_id) # 포인트 서비스를 직접 안다
delivery_service.request(order_id) # 배송 서비스를 직접 안다
# 이벤트
def complete_order(order_id):
event_bus.publish("order.completed", order_id)
# 끝. 누가 듣는지 모른다.
이벤트 방식에서 complete_order는 사실만 선언한다. 누가, 몇 명이 듣고, 뭘 하는지 전혀 모른다.
발신자는 사실을 선언하고, 수신자는 각자 반응한다. 둘은 서로를 모른다.
그래도 이건 결합 아닌가
여기서 다시 막혔다. 분명히 결합은 남아 있다. "주문 완료"라는 사실에 대해서는 발행자도 구독자도 똑같이 알아야 한다. 결합이 사라진 게 아니라 위치가 옮겨간 것이다.
| 직접 호출 | 이벤트 | |
|---|---|---|
| 결합 대상 | 구체적인 서비스 코드 | 주문 완료라는 사실 |
| 알림이 사라지면 | 주문 코드 수정 | 구독 해제, 주문 무관 |
| 새 구독자 추가 | 주문 코드 수정 | 새 서비스가 알아서 붙음 |
| 알림이 실패하면 | 포인트, 배송도 멈춤 | 주문은 계속 동작 |
"주문이 완료됐다"는 사실은 잘 안 바뀐다. 알림 서비스의 구현은 자주 바뀐다. 안정적인 것에 결합하고 불안정한 건 떼어놓는다는 것까지는 이해했다.
근데 표를 만들고 나서도 뭔가 덜 풀린 느낌이었다. 결합의 위치만 옮겨간 거면, 그게 왜 이렇게까지 강조할 일인가 싶었다.
다시 들여다보니 위치 말고 하나가 더 바뀌어 있었다. 결정의 주체다.
직접 호출에서는 주문 서비스가 "완료되면 알림을 보낸다"는 결정을 내린다. 이 결정이 주문 코드 안에 박혀 있다. 이벤트에서는 알림 서비스가 "나는 주문 완료를 구독하겠다"는 결정을 스스로 내린다. 주문 서비스는 그 결정을 모른다.
책임의 소속이 옮겨간 거였다.1 위치가 아니라 소속. 이 차이를 알아채고 나서야 표가 말이 되기 시작했다.
그럼 그냥 잘 관리하면 되지 않나
결합이 남아 있다면, 구독자가 한둘일 때는 그냥 직접 호출로 관리해도 되는 거 아닌가 싶었다. 실제로 케이스가 하나라면 맞는 말이다. 주문 완료 → 알림 발송 딱 하나라면 직접 호출이 더 단순하고 추적도 쉽다.
문제는 현실에 하나짜리 케이스가 없다는 거다.
주문 완료 시:
알림 발송, 포인트 적립, 재고 차감,
배송 요청, 마케팅 통계, 사기 탐지
직접 호출이면 주문 서비스가 이 목록 전부를 알아야 한다. 여기서 자주 나오는 반론이 세 가지였다.
- 책임이 번진다 —> 마케팅팀이 통계 항목을 바꾸면 주문 코드를 열어야 한다.
- 하나가 터지면 다 같이 터진다 —> 알림 서버가 죽으면 그 아래 포인트, 배송 코드가 실행 안 된다.
- 새 기능이 생길 때마다 주문 코드를 열어야 한다 —> 새 팀이 붙을 때마다 주문 서비스를 고쳐야 한다.
셋 다 맞는 말이지만 설명하라고하면 뭔가 막막하다. 그래서 난 그대로 넘어가고 싶지 않았다. 하나씩 직접 확인해보기로 했다.
3. 세 가지 반론을 직접 확인해보자
- 책임이 번진다 —> 마케팅팀이 통계 항목을 바꾸면 주문 코드를 열어야 한다.
책임이 번진다는 말, 코드로 비교하면 어떨까
이 주장이 나한테는 좀 추상적으로 들렸다. 결합도가 낮으면 좋다는 건 알겠다..(난 객체지향을 정말 사랑한다)
하지만 실제로 유지보수가 얼마나 편해지는지 궁금했다. 그래서 "알림에 결제 수단도 넣어달라"는 요구가 들어왔다고 치고, 두 방식의 변경 지점을 나란히 비교했다.
# 직접 호출 — 수정 후
def complete_order(order_id):
payment_method = get_payment_method(order_id) # 추가
notification_service.send(order_id, payment_method) # 파라미터 추가
point_service.accumulate(order_id)
# 이벤트 — 수정 후
def complete_order(order_id):
payment_method = get_payment_method(order_id) # 여기도 추가
event_bus.publish("order.completed", order_id, payment_method) # 여기도 추가
어라. 둘 다 complete_order를 고쳐야 했다. 결제 수단이라는 새 정보는 주문 서비스만 갖고 있으니까, 이벤트든 직접 호출이든 그 정보를 여기서 꺼내 넘겨야 한다. 이건 결합이 아니라 데이터가 원래 어디 있느냐의 문제였다. 데이터가 있는 곳을 건드리는 건 어느 방식을 쓰든 피할 수 없다.
그래서 이번엔 반대 시나리오로 확인했다. "사기 탐지팀이 새로 생겨서, 주문 완료 시점에 로그만 하나 남기고 싶다"고 하면.
# 직접 호출 — 새 팀이 추가되면
def complete_order(order_id):
notification_service.send(order_id)
point_service.accumulate(order_id)
fraud_service.log(order_id) # 주문 코드에 한 줄 추가해야 함
# 이벤트 — 새 팀이 추가되면
# complete_order는 그대로. fraud 서비스 쪽에 파일 하나 추가.
@subscribe("order.completed")
def on_order_completed(order_id):
fraud_service.log(order_id)
여기선 차이가 분명했다. 직접 호출은 complete_order를 열어야 하고, 이벤트는 안 열어도 된다.
두 실험을 합쳐 보니 답이 좀 더 명확해졌다. "책임이 번진다"는 주장은 절반만 맞았다.
코드 양 자체는 다르지 않았다. 둘 다 어딘가엔 새 코드가 들어가야 했다. 달라진 건 어디에 들어가느냐였다. 실험 1(결제 수단)은 어느 방식이든 complete_order 안에 들어갔다. 실험 2(사기 탐지)는 직접 호출은 complete_order 안에, 이벤트는 그 바깥(사기 탐지팀 파일)에 들어갔다.
새로운 구독자가 붙는 경우엔 그 코드가 주문 서비스 밖으로 빠져나갈 수 있어서 이벤트가 유리했다. 새로운 데이터가 필요한 경우엔 그 데이터를 주문 서비스만 갖고 있으니, 코드가 complete_order 안에 남을 수밖에 없었다.
결합이 아예 없어지는 게 아니라, 코드는 늘 생기고 그게 주문 코드 안이냐 밖이냐만 나뉘어 있었다.
- 하나가 터지면 다 같이 터진다 —> 알림 서버가 죽으면 그 아래 포인트, 배송 코드가 실행 안 된다.
같이 터진다는 게 정확히 뭘 말하는 걸까
두 번째 반론도 다시 뜯어봐야 했다. "하나가 터지면 다 같이 터진다"는데, 터진다는 게 느려지는 건지 진짜 예외가 나는 건지부터 헷갈렸다. 느려지는 거라면 비동기로 처리하면 그만 아닌가 싶었다.
둘을 나눠서 확인했다.
지연 문제. 동기 호출이면 주문 서비스가 알림 서비스의 응답을 기다려야 한다. 알림 서버가 5초 걸리면 주문도 5초 멎는다. 이건 비동기로 호출하면 실제로 풀린다. 알림을 던지기만 하고 기다리지 않으면 된다.
예외 전파 문제. 근데 비동기로 던진다고 알림 실패가 완전히 남의 일이 되는 건 아니었다. 알림이 실패했을 때 로그를 남기거나 재시도하려면, 그 처리 코드가 어딘가에는 있어야 한다. 그 코드가 주문 서비스 안에 있는 한, 주문 서비스는 여전히 "알림이 실패할 수 있다"는 사실을 알고 있는 셈이다.
여기서 좀 허탈했다. 비동기 하나면 다 풀릴 줄 알았는데, 비동기가 없애는 건 기다림뿐이었다. "주문 서비스가 알림 서비스를 직접 알고 불러야 한다"는 구조는 그대로 남는다. 게다가 서버가 도중에 죽으면 비동기로 던진 작업도 같이 사라진다. 비동기는 지속성(durability)까지 주지는 않는다.
정리하면 이렇다. 비동기는 지연을 없애고, 이벤트는 구조적 결합을 없앤다. 서로 다른 문제를 푸는 도구였다. 겹치는 부분 때문에 헷갈렸던 거다.
- 새 기능이 생길 때마다 주문 코드를 열어야 한다 —> 새 팀이 붙을 때마다 주문 서비스를 고쳐야 한다.
분산환경이라 그런 거 아닌가
세 번째 반론을 보면서는 이상한 전제를 하나 세우고 있었다. "이건 MSA로 서버가 쪼개졌을 때만 중요한 문제 아닌가"라고 생각했다. 근데 왜 그렇게 생각했는지 스스로 물어보니 딱히 근거가 없었다.
다시 보니 이건 결국 첫 번째 반론과 같은 얘기였다. 서버가 한 대든 여러 대든, 코드를 누가 왜 고쳐야 하느냐의 문제였다. 서버 개수는 이 문제의 크기를 키울 뿐, 문제 자체를 만들어내는 원인은 아니었다.
그 한 줄, 정말 비용이었나
여기서 다시 걸리는 게 있었다. 사기 탐지팀 예시로 돌아가보면, 직접 호출 쪽은 이렇게 바뀌었을 뿐이다.
def complete_order(order_id):
notification_service.send(order_id)
point_service.accumulate(order_id)
fraud_service.log(order_id) # 이 한 줄
한 줄 추가한 게 전부다. 나 혼자 짜는 프로젝트라면, 아니면 한 팀이 이 코드베이스를 전부 소유하고 있다면, 이 한 줄이 무슨 비용이지 싶었다. 파일 열어서 한 줄 쓰고 커밋하면 끝나는 일을 "책임이 번진다"고 부르는 게 좀 과장 같았다.
곰곰이 보니 이 비용은 코드 줄 수에서 나오는 게 아니었다. 누가 이 파일을 건드려도 되는가에서 나오는 거였다.
단일 팀, 단일 배포라면 그 "누가"가 애초에 나 하나다. 사기 탐지 로직도 내가 짜고, 주문 로직도 내가 짜고, 리뷰도 내가 셀프로 하는 셈이다. 경계가 없으니 넘을 것도 없다. 이런 조건에서는 결합이 있어도 아프지 않다.
경계가 생기는 순간은 따로 있었다. 사기 탐지가 다른 팀 소관이 되는 순간, 그 팀이 코드 리뷰를 요청해야 하는 순간, 주문 서비스의 배포 주기와 사기 탐지팀의 배포 주기가 어긋나는 순간. 그때부터 그 한 줄은 더 이상 한 줄이 아니다. PR을 올리고, 다른 팀의 승인을 기다리고, 실수로 주문 로직을 건드릴까봐 조심하고, 배포 일정을 맞춰야 한다.
그러니까 결론을 이렇게 정리해야 했다. 단일 서버, 단일 팀 환경에서는 느슨한 결합의 매력이 분산 환경보다 떨어지는 게 사실이다. 넘을 경계가 없는 곳에서 경계를 없애는 기술은 실감이 나지 않는다. "책임이 번진다"는 주장 자체가 틀린 게 아니라, 그 주장이 힘을 받는 조건이 따로 있었다. 조직이 나뉘고, 배포가 나뉘고, 코드 소유권이 나뉘는 지점. 그 지점은 현실에서 대체로 서버가 나뉘는 지점과 함께 온다. 서비스가 나뉜다는 건 대체로 그 서비스를 소유하는 팀도 나뉜다는 뜻이니까.
이걸 알아채고 나니 방금 전 반론(분산환경이라 그런 거 아닌가)에 왜 그렇게 끌렸는지도 이해가 됐다. 완전히 틀린 감각은 아니었다. 정확히는 "분산이냐 아니냐"가 아니라 "조직적 경계가 있냐 없냐"의 문제였는데, 현실에서 이 둘이 거의 항상 겹쳐서 나타나다 보니 구분이 안 됐던 거다.
4. 단일 서버에서는 사실 뭐가 다른가
여기서 좀 불편한 사실 하나를 인정해야 했다.
단일 서버에서 Spring의 @EventListener 같은 인메모리 이벤트는, 발행되는 순간 등록된 리스너를 그냥 즉시 호출한다. 큐에 쌓이거나 기록이 남지 않는다. 서버가 죽으면 이벤트도 같이 사라진다. 호출을 한 단계 간접적으로 한다는 점만 다를 뿐, 신뢰성 측면에서는 직접 호출과 거의 같다.
그럼 단일 서버에서 이벤트를 쓰는 이유는 뭘까. 구조 정리다. 그게 전부다.
// 직접 호출 — 주문 서비스가 계속 뚱뚱해진다
public class OrderService {
private PaymentService paymentService;
private NotificationService notificationService;
private PointService pointService;
// 기능이 늘수록 여기가 계속 늘어난다
public void completeOrder(Order order) {
paymentService.process(order);
notificationService.send(order);
pointService.accumulate(order);
// 새 기능마다 이 파일을 열어야 한다
}
}
// 이벤트 — 주문 서비스는 그대로 유지된다
public class OrderService {
private ApplicationEventPublisher publisher;
public void completeOrder(Order order) {
publisher.publishEvent(new OrderCompletedEvent(order));
}
}
// 각 팀이 자기 파일에서 알아서 구독한다
@EventListener
public void onOrderCompleted(OrderCompletedEvent event) {
notificationService.send(event.getOrder());
}
새 기능을 추가할 때 직접 호출은 OrderService를 열어야 한다. 이벤트는 새 파일을 만들고 @EventListener만 붙이면 끝이다.
앞에서 확인했던 "줄 수는 비슷하다"는 감각이 여기서 다시 나왔다. 고쳐야 할 지점의 개수는 실제로 비슷하다. 차이는 어느 파일을, 누가, 무엇을 알면서 고치느냐였다. 3장의 첫 번째 실험에서 느꼈던 "어라, 비슷한데?"가 여기서 풀렸다.
솔직히 인정할 부분도 있다. 팀 하나, 코드베이스 하나 규모라면 단일 서버에서 이벤트의 이점은 옅다. IDE로 Find Usages 누르면 누가 뭘 호출하는지 다 보이고, 컴파일러가 깨진 의존성을 바로 잡아주고, 스택 트레이스로 흐름도 추적된다. 반대로 이벤트는 코드 흐름이 흩어져서 디버깅이 더 어려워진다. 이 트레이드오프를 빼놓고 "이벤트는 무조건 좋다"고 말하는 건 틀린 판단이라고 생각한다.
5. 이 아이디어는 원래 어디서 왔을까
여기까지 정리하고 나니 좀 이상한 기분이 들었다. 이 패턴, 어디서 계속 본 것 같았다. "기다리지 말고 알려달라"는 생각이 이벤트에만 있는 게 아니라 더 오래된 곳에서 온 것 같았다. 그래서 원점을 찾아봤다.
1954년, 트레일러 두 대짜리 컴퓨터
1954년 미국 육군은 DYSEAC이라는 컴퓨터를 납품받았다. 트레일러 두 대에 나눠 실려 다녔고, 하나는 12톤 다른 하나는 8톤이었다.2

당시 컴퓨터가 외부 장치와 통신하는 방법은 하나뿐이었다.
while (장치가 준비 안 됨) {
// 됐어? 됐어? 됐어?
// CPU는 이 루프에 갇혀 아무것도 못 한다
}
폴링(polling)이다. 문제는 당시 I/O 장치가 CPU보다 수천 배 느렸다는 것. 연산 능력 대부분이 대기로 사라졌다.
DYSEAC 설계자들은 방향을 뒤집었다. CPU가 장치에게 물으러 가는 대신, 장치가 준비됐을 때 CPU에게 신호를 보내면 어떨까 생각했다. 그게 하드웨어 인터럽트다. DYSEAC은 프로그램 카운터가 두 개 있었고, I/O 신호가 오면 실행 중이던 카운터를 버리고 다른 카운터로 전환했다.2 CPU는 신호가 올 때까지 다른 일을 할 수 있었다.
기다리지 말고, 일이 일어나면 알려달라는 것. 이게 이벤트의 원점이었다.
인터럽트가 열어버린 문제
인터럽트는 효율을 가져왔다. 동시에 새로운 골칫거리도 열었다.
비슷한 시기 네덜란드의 젊은 프로그래머 에츠허르 다익스트라는 동료들이 X1 컴퓨터에 인터럽트를 넣겠다고 하자 패닉에 빠졌다고 회고했다.
내 프로그램이 재현 불가능한 오류를 일으키는 장면이 머릿속에 그려졌고, 나는 공황 상태에 빠졌다.3
왜 그랬을까. 기존 프로그램은 완전히 예측 가능했다. 같은 코드에 같은 데이터를 넣으면 항상 같은 결과가 나왔다. 인터럽트가 들어오면 이 보장이 깨진다. 인터럽트는 CPU가 어느 연산을 하고 있을 때 와도 이상하지 않다.
Turner와 Rawlings는 1958년 논문에서 이렇게 남겼다.
프로그램 오류가 가변적인 오류를 만들거나 아예 오류가 나오지 않기도 해서, 랜덤한 하드웨어 오작동과 구분이 되지 않았다.4
이 특이한 점을 찾는 데만 수십 시간이 걸렸다고 한다.
다익스트라는 훗날 이걸 이렇게 정리했다. 인터럽트의 진짜 파장은 비동기성이 아니라 비결정론(nondeterminism)의 등장이었고, 그걸 다룰 수학적 기법이 필요해졌다는 것.5
인터럽트는 컴퓨터를 빠르게 만들었지만 동시에 예측 불가능하게 만들었다. 운영체제가 존재하는 이유 중 하나가 이 복잡성을 대신 관리하기 위해서다.
인터럽트를 거부한 사람도 있었다
모두가 인터럽트를 환영한 건 아니었다.
1964년 CDC 6600을 설계한 Seymour Cray는 I/O 인터럽트를 쓰지 않기로 했다. "내가 다른 일을 하는 중에 그 이벤트를 알려주지 마라. 대신 저장해두면 내가 준비됐을 때 물어보겠다"는 취지로 말했다고 전해진다.6 Blaauw와 Brooks는 이 철학을 두고, 폴링으로 동기화하면 시스템과 감독 소프트웨어가 더 단순해지는 건 분명하다고 정리했다.6
CDC 6600은 열 개의 독립 처리 장치가 CPU 대신 폴링을 맡았고, CPU는 순수 연산에만 집중했다. 오늘날에도 고성능 네트워크 카드나 게임 엔진에서 인터럽트 오버헤드를 피하려고 일부러 폴링을 쓰는 경우가 있다.
어느 쪽이 옳다는 얘기가 아니었다. "언제 반응할 것인가"에 대한 설계 철학의 차이였다.
6. 다시 소프트웨어로 — 같은 문제가 스케일만 바꿔 돌아오다
1960년대 들어 인터럽트는 운영체제 안으로 흡수됐다. 개발자는 레지스터 저장이나 프로그램 카운터 전환을 직접 다루지 않아도 됐다. 인터럽트는 이제 이벤트라는 이름을 얻었다.
1970년대엔 GUI가 등장했다. 다음에 뭐가 올지 알 수 없는 환경에서 순차적 프로그래밍은 무너졌고, 이벤트 루프가 탄생했다. DYSEAC의 아이디어가 소프트웨어 레이어로 올라온 셈이다.
1990년대 후반, 인터넷이 커지면서 서버가 여러 대로 늘었다. 1954년의 문제가 더 큰 스케일로 돌아왔다.
def complete_order(order_id):
requests.post("http://notification-server/notify", ...) # 응답 올 때까지 대기
requests.post("http://point-server/accumulate", ...) # 응답 올 때까지 대기
알림 서버가 5초 응답이 없으면 주문 처리가 5초 멈춘다. CPU가 프린터를 기다리던 문제가 서버가 서버를 기다리는 문제로 돌아왔다.
근데 이번엔 "느리다"는 것만 문제가 아니었다. 네트워크는 단일 프로세스 함수 호출과 근본적으로 다르다. 함수 호출이 실패하면 에러가 바로 온다. 됐거나 안 됐거나 둘 중 하나다. 네트워크는 세 가지를 모른다.
1. 요청이 갔는지 모른다 → 패킷 유실
2. 처리됐는지 모른다 → 서버가 받고 죽었을 수도
3. 응답이 왔는지 모른다 → 응답 패킷이 유실됐을 수도
직접 HTTP 호출로 이걸 풀려면 주문 서비스가 이 불확실성을 전부 떠안아야 한다. 재시도 로직을 들고, 재시도 상태를 메모리에 유지하고, 서버가 죽으면 그 상태도 같이 사라진다.
메시지 브로커(Kafka, RabbitMQ 등)는 이걸 다르게 푼다.
주문 서버 ──"order.completed"──▶ 메시지 브로커 ──▶ 알림 서버
──▶ 포인트 서버
주문 서버는 메시지를 브로커에 던지고 끝낸다. 브로커가 디스크에 저장해두기 때문에, 알림 서버가 죽어있어도 주문은 성공한다. 메시지는 브로커에 남아있다가 알림 서버가 살아나면 처리된다. 불확실성이 없어지는 게 아니라 브로커가 대신 떠안는 것이다.
2003년 Eugster 등은 이 구조의 핵심을 세 가지로 정리했다.7 공간 분리(발행자와 구독자가 서로 몰라도 됨), 시간 분리(동시에 살아있지 않아도 됨), 동기화 분리(이벤트 생산 중에 차단되지 않음). 4장에서 확인했던 단일 프로세스 Observer 패턴이 추구하던 것과 완전히 같은 원칙이었다. 규모만 달라졌을 뿐.
이 대목에서 좀 신기했다. 결국 1954년의 그 아이디어 하나가, 형태만 바꿔서 70년 동안 계속 반복되고 있었던 거다.
이벤트와 메시지 큐는 다른가
여기서 자주 헷갈리던 지점 하나를 정리하고 넘어가야겠다. 이벤트는 개념이고, 메시지 큐는 그 개념을 구현하는 인프라 중 하나다.
단일 프로세스의 @EventListener나 Node.js의 EventEmitter는 메모리에서 즉시 호출될 뿐 큐가 아니다. 신뢰성도 없다. Kafka나 RabbitMQ 같은 분산 이벤트 인프라는 디스크에 저장하고 전달을 보장한다. "신뢰성"이라는 말은 여기서만 의미를 가진다.
"이벤트를 썼으니 신뢰성이 보장된다"는 착각이 이 둘을 섞어서 생긴다. 단일 서버의 이벤트는 구조 정리 도구고, 신뢰성 보장은 메시지 큐를 쓸 때만 성립한다.
7. 지금 정리되는 것들
여기까지 확인하고 나서 이해가 달라진 부분을 정리해본다. 처음의 위화감이 어디서 왔는지도 이제 알겠다. "결합이 없어진다"는 말을 문자 그대로 받아들인 게 오해의 시작이었다.
결합은 없어지지 않는다. 위치와 소속이 바뀐다. 이벤트가 없애는 건 결합 자체가 아니라, "구체적인 서비스 코드"라는 불안정한 대상에 대한 결합이다. 대신 "주문 완료"라는 비교적 안정적인 사실에 결합한다. 그리고 그 결합을 유지할지 말지 결정하는 주체가 발행자에서 구독자로 넘어간다.
"유지보수가 쉬워진다"는 새 구독자가 붙을 때만 성립한다. 새 데이터가 필요한 변경은 이벤트든 직접 호출이든 원본 코드를 건드려야 한다. 3장에서 직접 비교해보고 확인한 부분이다. 이벤트가 모든 변경을 공짜로 만들어주는 건 아니었다.
"같이 터진다"는 지연과 예외 전파, 두 가지로 나뉜다. 비동기는 지연만 해결한다. 구조적 결합과 지속성은 발행-구독 구조나 메시지 브로커가 있어야 풀린다.
정리하면 이렇게 보인다.
| 시기 | 형태 | 발신자 | 메시지 |
|---|---|---|---|
| 1954 | 하드웨어 인터럽트 | 장치 → CPU | "나 준비됐어" |
| 1970s | GUI 이벤트 루프 | 사용자 → 프로그램 | "버튼 눌렸어" |
| 1994 | Observer 패턴 | 객체 → 객체 | "상태 바뀌었어" |
| 2000s | 분산 메시지 브로커 | 서버 → 서버 | "주문 완료됐어" |
구조는 매번 같다. 발신자는 사실을 선언하고, 수신자는 각자 반응하고, 둘은 서로를 모른다.
이 확신은 코드로 직접 비교해본 부분에 대해서는 꽤 단단하다. 위 표와 역사 이야기는 조금 다르게 보고 있다. 2차 자료를 읽고 재구성한 거라서, "이렇게 이해하니 패턴이 보이더라" 정도로만 믿고 있다. 원전을 직접 검증하진 못했다.
8. 아직 안 풀린 것들
몇 가지는 여전히 답을 못 냈다.
유지보수가 실제로 얼마나 편해지는지 정량적으로는 모른다. 3장의 비교는 시나리오 두 개를 손으로 짜본 것뿐이라서, 실제 코드베이스를 몇 달 굴려본 것과는 다르다.
단일 서버에서 이벤트를 언제부터 쓰는 게 이득인지도 명확한 기준이 없다. 지금은 감으로 판단하고 있다.
참고 문헌
Footnotes
-
Martin Fowler, "Event Sourcing," martinfowler.com, 2005. 구현 분리와 결정 분리의 개념 차이에 근거. https://martinfowler.com/eaaDev/EventSourcing.html ↩
-
A.L. Leiner and S.N. Alexander, "System Organization of DYSEAC," IRE Transactions on Electronic Computers, vol. EC-3, no. 1, March 1954, pp. 1–10. ↩ ↩2
-
E.W. Dijkstra, "What led to 'Notes on Structured Programming'," EWD manuscript, circa 1979. ↩
-
L.R. Turner and J.H. Rawlings, "Realization of Randomly Timed Computer Input and Output by Means of an Interrupt Feature," IRE Transactions on Electronic Computers, EC-7, 2, June 1958, pp. 141–149. ↩
-
E.W. Dijkstra, "The next fifty years," Communications of the ACM, vol. 44, no. 1, January 2001, pp. 84–88. ↩
-
Gerrit Blaauw and Fred Brooks, Computer Architecture: Concepts and Evolution, Addison-Wesley, 1997. ↩ ↩2
-
Patrick Th. Eugster, Pascal Felber, Rachid Guerraoui, Anne-Marie Kermarrec, "The many faces of publish/subscribe," ACM Computing Surveys, vol. 35, no. 2, June 2003, pp. 114–131. DOI: 10.1145/857076.857078 ↩