사전 이벤트 주도
패턴

이벤트 주도

gabury1고친 사람 github-actions[bot]

이벤트 주도는 서비스끼리 서로를 불러 일을 시키지 않고, 일어난 일을 알려서 다음 일이 이어지게 짓는 방식입니다. 주문 서비스는 주문이 들어왔다는 소식만 내놓습니다. 결제와 배송은 그 소식을 듣고 각자 움직입니다. 한 프로그램 안에서 입력을 기다렸다 반응하는 짜임도 같은 이름으로 부릅니다. 이 문서는 서비스 사이의 설계를 다룹니다.

쉽고 빠른 이해

이벤트 주도는 「이것을 해라」 대신 「이런 일이 있었다」를 알려서 서비스들을 잇습니다. 주문 서비스가 「주문됨」 한 건을 내놓으면 결제·재고·메일 서비스가 그것을 받아 각자 할 일을 합니다.

직접 부르면 주문 코드가 뒤에 올 일을 전부 알아야 합니다. 뒤처리가 하나 늘 때마다 주문 코드를 고쳐야 합니다. 부른 서비스가 멈추면 주문도 같이 멈춥니다.

  1. 일이 벌어진 서비스가 그 소식을 한 건으로 만들어 가운데 통로에 내놓습니다
  2. 통로는 그 소식을 기다리던 서비스들에게 나눠 줍니다
  3. 받은 서비스는 저마다 제 일을 합니다. 필요하면 새 소식을 또 내놓습니다

대가는 흐름이 코드에서 안 보인다는 것입니다. 같은 소식이 두 번 오거나 순서가 바뀌어 올 수 있어서 받는 쪽이 그것을 견디게 짜야 합니다. 서비스마다 가진 데이터가 잠깐씩 어긋나는 때도 생깁니다.

상세

이 절은 쇼핑몰에 주문 하나가 들어온 뒤를 따라갑니다. 먼저 직접 부르는 방식과 알리는 방식을 견줍니다. 다음으로 이벤트에 무엇을 싣는지, 흐름을 누가 쥐는지를 봅니다. 이어서 보내는 쪽과 받는 쪽이 떠안는 일을 짚습니다. 언제 고를지로 마칩니다.

공항의 탑승 안내 방송을 떠올려 봅시다. 방송은 「312편 탑승을 시작합니다」를 한 번 내보냅니다. 방송하는 사람은 그 비행기에 누가 타는지 모릅니다. 312편 표를 가진 사람만 일어나 탑승구로 갑니다.

시스템 전체를 어떤 모양으로 지을지 정하는 결정을 소프트웨어 아키텍처라고 합니다. 이벤트 주도는 그 결정의 한 갈래입니다. 서비스 사이를 방송처럼 잇기로 정하는 것입니다.

직접 부르기와 알리기

주문이 들어오면 결제를 하고, 재고를 잡고, 확인 메일을 보내야 한다고 해 봅시다. 가장 쉬운 방법은 주문 서비스가 세 서비스를 차례로 부르는 것입니다.

Java
// 주문 서비스 안 — 직접 부르기
payment.charge(order);   // 결제 호출
stock.reserve(order);    // 재고 호출
mail.send(order);        // 메일 호출

주문 코드가 세 서비스의 이름과 부르는 법을 다 압니다. 포인트 적립을 붙이려면 주문 코드에 한 줄을 더하고 주문 서비스를 다시 배포해야 합니다. 메일 서비스가 멈춰 있으면 그 줄에서 주문 처리까지 멈춥니다.

이벤트 주도에서는 주문 서비스가 부르지 않고 알립니다. 이벤트는 일어난 일 하나를 데이터 한 건으로 적은 것입니다. 이미 벌어진 일이라서 이름을 과거형으로 짓습니다.

이벤트를 받아 두었다가 기다리는 쪽에 나눠 주는 중간 프로그램이 메시지 브로커입니다. 줄여서 브로커라고 부릅니다. 가운데에 브로커를 두는 까닭은 알리는 쪽과 받는 쪽이 서로의 주소를 몰라도 되게 하려는 것입니다.

이벤트를 브로커에 내놓는 일을 발행이라고 합니다. 받는 쪽이 「이 이름의 이벤트가 오면 나에게 달라」고 걸어 두는 일은 구독입니다. 이 짝을 묶어 발행-구독이라고 부릅니다.

Java
// 주문 서비스 안 — 알리기(발행)
broker.publish(new OrderPlaced(order.id()));

OrderPlaced 는 「주문됨」이라는 뜻입니다. 주문 서비스가 하는 일은 이 한 건을 브로커에 발행하는 것에서 끝납니다. 누가 받아 가는지는 이 코드에 없습니다.

flowchart TD
    subgraph 직접["직접 부르기"]
        O1["주문 서비스"] --> P1["결제"]
        O1 --> S1["재고"]
        O1 --> M1["메일"]
    end
    subgraph 알림["알리기"]
        O2["주문 서비스"] -->|주문됨| B["메시지 브로커"]
        B --> P2["결제"]
        B --> S2["재고"]
        B --> M2["메일"]
    end
    직접 ~~~ 알림

위쪽에서는 화살표가 주문 서비스에서 셋으로 뻗어 나갑니다. 주문 서비스가 셋을 다 알고 있다는 뜻입니다. 아래쪽에서 주문 서비스가 아는 것은 브로커 하나뿐입니다.

적립 서비스를 붙이려면 아래쪽 그림에서 브로커 밑에 구독자를 하나 더 달면 됩니다. 주문 코드는 손대지 않습니다.

알리면 느슨해지는 세 가지

서로가 서로를 얼마나 알고 기대는지를 결합도라고 합니다. 결합도가 높으면 한쪽을 고칠 때 다른 쪽도 함께 고쳐야 합니다. 이벤트로 알리면 이 결합이 세 방향에서 느슨해집니다.

몰라도 되는 것 직접 부를 때 이벤트로 알릴 때
누가 받는지 부를 상대를 코드에 적는다 브로커 하나만 안다
받는 쪽이 지금 떠 있는지 상대가 멈춰 있으면 호출이 실패한다 브로커가 들고 있다가 나중에 건넨다
받는 쪽이 언제 끝내는지 응답이 올 때까지 기다린다 내놓고 바로 다음 일로 간다

셋째 줄처럼 상대의 처리가 끝나기를 기다리지 않고 제 일을 이어 가는 방식을 비동기라고 합니다. 주문 서비스는 이벤트를 내놓자마자 손님에게 「주문 접수됨」을 돌려줄 수 있습니다. 메일이 몇 초 늦게 나가도 주문 응답은 늦어지지 않습니다.

둘째 줄도 쓸모가 큽니다. 메일 서비스가 배포 중이라 잠깐 내려가 있어도 주문은 계속 받습니다. 그동안 브로커에 쌓인 이벤트는 메일 서비스가 다시 뜬 뒤에 차례로 처리됩니다.

이벤트에 싣는 값

이벤트에 무엇을 싣느냐에 따라 받는 쪽이 할 일이 달라집니다. 흔히 셋으로 가릅니다.

갈래 싣는 것 받는 쪽이 할 일
이벤트 알림 무슨 일이 있었나와 대상 번호 필요한 값은 보낸 쪽에 되물어 가져온다
상태를 실은 이벤트 받는 쪽이 쓸 값 전부 받은 값을 제 저장소에 사본으로 둔다
이벤트 소싱 일어난 일 전부를 순서대로 쌓인 이벤트가 곧 원본 데이터가 된다

첫째 갈래의 이벤트는 소식만 담습니다. 아래 한 건에는 주문 번호밖에 없습니다.

JSON
{ "type": "주문됨", "orderId": 4821 }

배송 서비스가 배송지를 알아야 한다면 주문 서비스에 다시 물어봐야 합니다. 되묻는 호출이 생기므로 느슨해졌던 결합이 일부 돌아옵니다.

둘째 갈래는 받는 쪽이 쓸 값을 처음부터 실어 보냅니다. 상태를 실은 이벤트를 이벤트 운반 상태 전달이라고도 부릅니다.

JSON
{ "type": "주문됨", "orderId": 4821,
  "address": "서울시 ...", "total": 32000 }

이번에는 배송 서비스가 되물을 일이 없습니다. 받은 배송지를 제 저장소에 적어 두고 씁니다. 대가는 이벤트가 커진다는 것과, 그 사본이 원본보다 늦게 맞춰진다는 것입니다.

셋째 갈래는 이벤트를 알림으로 쓰고 버리지 않습니다. 현재 값 대신 일어난 일을 전부 쌓아 둡니다. 지금 값은 그 기록을 처음부터 다시 읽어 만듭니다.

주문 4821 에 「주문됨」「결제됨」「배송됨」 세 건이 쌓여 있다고 해 봅시다. 처음부터 차례로 읽으면 이 주문의 지금 상태는 「배송됨」입니다. 상태 값 하나를 고쳐 쓰는 대신 기록 세 건이 남습니다.

흐름을 쥐는 곳

주문 하나가 결제와 배송까지 이어지는 일을 서비스 여럿이 나눠 하면, 그 순서를 누가 아는지 정해야 합니다. 이벤트 주도에서 흔한 답은 아무도 전체를 쥐지 않는 쪽입니다.

sequenceDiagram
    participant 주문 as 주문 서비스
    participant 브로커
    participant 결제 as 결제 서비스
    participant 배송 as 배송 서비스
    주문->>브로커: 주문됨
    브로커->>결제: 주문됨
    결제->>브로커: 결제됨
    브로커->>배송: 결제됨
    Note over 배송: 배송을 잡는다

결제 서비스는 「주문됨」을 듣고 움직입니다. 배송 서비스는 「결제됨」을 듣고 움직입니다. 각 서비스는 제가 무엇을 듣고 무엇을 내놓는지만 압니다.

이렇게 서비스마다 들은 이벤트에 반응해서 흐름이 이어지는 방식을 코레오그래피라고 부릅니다. 대가는 흐름 전체가 어디에도 적혀 있지 않다는 것입니다. 결제 뒤에 무엇이 오는지 알려면 「결제됨」을 구독한 서비스를 다 찾아봐야 합니다.

반대쪽은 한 서비스가 순서를 쥐고 「결제해라」와 「배송 잡아라」를 차례로 보내는 방식입니다. 이것을 오케스트레이션이라고 합니다. 흐름은 한곳에서 보입니다. 대신 순서를 쥔 서비스가 모든 단계를 알아야 해서 결합이 그쪽으로 모입니다.

오케스트레이션에서도 서비스 사이를 브로커로 이을 수 있습니다. 그래도 오가는 것은 받을 서비스가 정해진 「이것을 해라」입니다. 보내는 쪽이 받는 쪽을 알고 있다는 점에서 이 부분은 알리기보다 직접 부르기에 가깝습니다.

중간에 결제가 실패하면 앞서 만든 주문을 되돌려야 합니다. 여러 서비스에 걸친 작업을 단계로 나누고, 실패하면 앞 단계를 취소하는 요청을 따로 보내는 방식이 사가입니다. 사가는 코레오그래피로도 오케스트레이션으로도 짤 수 있습니다.

보내는 쪽이 떠안는 일

주문 서비스는 주문을 데이터베이스에 저장합니다. 그다음 이벤트를 브로커에 보냅니다. 서로 다른 두 곳에 한 번씩 씁니다.

저장을 마치고 보내기 직전에 서비스가 죽을 수 있습니다. 그러면 주문은 남았어도 아무도 그 사실을 모릅니다.

트랜잭션은 여러 변경을 전부 반영하거나 전부 취소하는 묶음입니다. 데이터베이스와 브로커는 따로 도는 시스템이라 이 둘을 한 트랜잭션에 넣기 어렵습니다.

흔히 쓰는 해법은 보낼 이벤트를 먼저 같은 데이터베이스에 적는 것입니다. 주문을 저장하는 트랜잭션 안에서 보낼 이벤트도 별도 테이블에 한 줄 적습니다. 그러면 둘은 함께 남거나 함께 사라집니다. 브로커로 보내는 일은 따로 도는 중계 프로세스가 맡습니다.

sequenceDiagram
    participant 주문 as 주문 서비스
    participant 저장소 as 데이터베이스
    participant 중계 as 중계 프로세스
    participant 브로커
    주문->>저장소: 주문과 보낼 이벤트를 한 트랜잭션으로 쓴다
    중계->>저장소: 아직 안 보낸 이벤트를 읽는다
    중계->>브로커: 이벤트를 보낸다
    중계->>저장소: 보냈다고 표시한다

중계 프로세스는 그 테이블을 읽어 브로커로 보냅니다. 보낸 줄에는 표시를 남깁니다. 이 방식을 트랜잭셔널 아웃박스라고 합니다.

중계 프로세스가 보낸 직후, 표시하기 전에 죽으면 같은 이벤트를 한 번 더 보냅니다. 그래서 이 방식은 이벤트를 잃지 않는 대신 두 번 보낼 수 있습니다. 그 뒷감당은 받는 쪽이 합니다.

받는 쪽이 떠안는 일

받는 쪽에는 네 가지가 찾아옵니다. 중복, 순서 뒤바뀜, 늦은 사본, 끝내 처리가 안 되는 이벤트입니다.

먼저 중복입니다. 받는 쪽은 이벤트 처리를 마치면 브로커에 「처리했다」는 확인을 돌려줍니다. 브로커는 이 확인이 오지 않으면 같은 이벤트를 받는 쪽에 다시 건넵니다.

확인이 오는 길에 사라지면 처리를 마친 이벤트도 다시 건너옵니다. 잃는 것보다 두 번 건네는 쪽을 고르는 셈입니다. 이렇게 한 번 이상은 반드시 건넨다는 약속을 최소 한 번 전달이라고 합니다.

그래서 받는 쪽은 같은 이벤트를 두 번 처리해도 결과가 한 번과 같게 짭니다. 이 성질이 멱등성입니다. 흔한 방법은 이벤트마다 붙은 고유 번호를 기억해 두고, 본 번호면 건너뛰는 것입니다.

Java
if (seen.has(e.id())) return; // 이미 봄
points.add(e.user(), 100);    // 적립
seen.add(e.id());

적립과 번호 기록은 한 트랜잭션에 넣습니다. 따로 쓰면 둘 중 하나만 남는 때가 생겨 다시 두 번 적립될 수 있습니다.

다음은 순서입니다. 브로커는 이벤트를 큐 여러 개에 나눠 담기도 합니다. 큐는 넣은 차례대로 꺼내 가는 대기열입니다. 큐를 여럿 두면 여러 곳에서 동시에 꺼내 가서 더 많이 나를 수 있습니다.

큐가 다르면 어느 쪽 이벤트가 먼저 처리될지 정해지지 않습니다. 한 큐를 받는 쪽 여러 대가 나눠 꺼낼 때도 같습니다. 그러면 「주문됨」보다 「주문 취소됨」이 먼저 처리될 수 있습니다.

순서가 결과를 바꾸는 이벤트라면 같은 주문의 이벤트는 늘 같은 큐에 담습니다. 그 큐는 받는 쪽 한 대가 넣은 차례대로 꺼내 처리합니다. 그래서 한 주문의 이벤트끼리는 앞뒤가 바뀌지 않습니다.

다른 방법은 이벤트에 순번을 싣는 것입니다. 받는 쪽은 이미 처리한 순번보다 작은 이벤트가 오면 뒤처진 것으로 보고 걸러 냅니다.

세 번째는 늦음입니다. 받는 쪽이 사본을 두면 원본이 바뀐 뒤 이벤트가 닿기 전까지 두 값이 다릅니다. 주문 직후 배송 화면을 열면 방금 한 주문이 아직 안 보일 수 있습니다.

시간이 지나면 같아지는 이 상태를 결과적 일관성이라고 합니다. 이벤트 주도를 고르면 이 몇 초를 업무 규칙으로 받아들여야 합니다.

마지막은 처리할 때마다 실패하는 이벤트입니다. 계속 다시 시도하면 뒤의 이벤트까지 막힙니다. 그래서 몇 번 실패한 이벤트는 따로 모아 두는 큐로 옮기고 사람이 나중에 열어 봅니다. 이 큐를 데드 레터 큐라고 부릅니다.

코드에서 흐름이 사라지는 비용

직접 부르던 때는 주문 코드를 읽으면 뒤에 무엇이 오는지 보였습니다. 이벤트로 바꾸면 그 줄이 사라집니다. 「주문됨」을 누가 듣는지는 구독 설정을 뒤져야 압니다.

장애를 쫓을 때도 같습니다. 요청 하나가 이벤트를 타고 서비스 여럿을 지나므로, 한 서비스의 로그만 봐서는 어디서 멈췄는지 모릅니다. 그래서 처음 요청에 추적 번호를 붙입니다. 그 요청에서 나온 이벤트마다 같은 번호를 실어 보냅니다. 이 번호를 상관 ID(correlation identifier, 상관 식별자)라고 부릅니다.

이벤트의 모양도 서비스 사이의 약속이 됩니다. 필드 이름 하나를 바꾸면 그 이벤트를 받던 서비스가 깨집니다. 보내는 쪽은 누가 받는지 모르니 누구에게 미리 알릴지도 모릅니다.

그래서 이벤트는 필드를 더하기만 하고, 지우거나 이름을 바꾸지 않는 식으로 고쳐 나갑니다. 이벤트에 어떤 필드가 어떤 형으로 들어가는지 적은 정의를 스키마라고 합니다. 여러 서비스가 같은 스키마를 보도록 한곳에 모아 두기도 합니다.

알리는 쪽이 받는 쪽을 모른다는 것이 이 방식의 이득이었습니다. 이 절의 비용도 전부 같은 데서 나옵니다.

고를 만한 상황과 미룰 상황

이 결정이 이득이 되는 것은 한 일에 반응할 곳이 여럿이고 앞으로 더 늘 때입니다. 주문 하나에 결제·재고·메일·적립·통계가 붙는 경우입니다. 다음에 무엇이 또 붙을지도 모릅니다. 뒤처리가 몇 초 늦어도 괜찮을 때도 잘 맞습니다.

부르는 쪽이 결과를 바로 받아야 하면 맞지 않습니다. 카드 승인이 났는지를 화면에 곧바로 보여 줘야 한다면 결제 서비스를 직접 부르는 편이 단순합니다. 받는 쪽이 하나로 정해져 있고 늘어날 일도 없으면 가운데에 브로커를 둘 이유가 없습니다.

한 시스템 안에서도 두 방식은 섞입니다. 결과가 바로 필요한 호출은 직접 부르고, 뒤처리는 이벤트로 알리는 식입니다.

서비스를 나눠야만 쓸 수 있는 것도 아닙니다. 한 번에 배포하는 애플리케이션 안에서도 모듈끼리 이벤트로 알리게 짤 수 있습니다. 모듈러 모놀리스가 모듈 사이를 이렇게 잇습니다. 이때는 브로커 대신 프로세스 안에서 이벤트를 나눠 주는 이벤트 버스를 씁니다.

서비스를 따로 배포하는 마이크로서비스에서는 이벤트 주도가 서비스 사이를 잇는 주요 수단 중 하나입니다. 직접 부르면 뒤쪽 서비스 하나가 멈출 때 앞쪽 서비스도 응답을 기다리며 같이 멈춥니다. 이벤트로 이으면 앞쪽은 내놓고 제 일을 끝내므로 그 기다림이 없습니다.

한 프로그램 안의 이벤트 주도

같은 이름이 한 프로그램 안의 짜임을 가리킬 때도 있습니다. 화면 프로그램은 버튼이 눌리기를 기다렸다가 그때 미리 걸어 둔 함수를 부릅니다. 미리 걸어 두는 이 함수를 콜백이라고 합니다.

기다림은 반복문 하나가 맡습니다. 들어온 입력을 하나씩 꺼내 알맞은 콜백에 넘기고 다시 기다립니다. 이 반복문을 이벤트 루프라고 부릅니다. 이렇게 짜는 방식은 이벤트 주도 프로그래밍이라고 합니다.

두 쓰임은 「부르지 말고 알린다」는 생각을 나눠 씁니다. 한쪽은 한 프로세스 안의 함수 사이를 잇고, 다른 쪽은 네트워크 건너 서비스 사이를 잇습니다. 앞에서 본 중복·순서·늦은 사본 문제는 대개 네트워크를 건너는 쪽의 몫입니다.

관련 항목

이벤트 주도가 속하는 상위 분류

소프트웨어 아키텍처 · 아키텍처 양식 · 분산 시스템 · 엔터프라이즈 통합 패턴

이벤트 주도를 이루는 구성 요소

이벤트 · 메시지 브로커 · 발행-구독 · 토픽 · 프로듀서 · 컨슈머 · 이벤트 버스 · 이벤트 스트림

이벤트를 나르는 통로를 구현한 제품

Apache Kafka · RabbitMQ · Amazon SNS · Amazon SQS · Amazon EventBridge · NATS

이벤트에 싣는 값으로 가른 하위 종류

이벤트 알림 · 이벤트 운반 상태 전달 · 이벤트 소싱 · 통합 이벤트

여러 서비스에 걸친 흐름을 이어 가는 방식

코레오그래피 · 오케스트레이션 · 사가 · 보상 트랜잭션 · CQRS

이벤트 주도가 바꾸는 설계 성질

결합도 · 비동기 · 결과적 일관성 · 가용성 · 확장성

받는 쪽이 지켜야 하는 전달 성질

멱등성 · 최소 한 번 전달 · 정확히 한 번 전달 · 메시지 순서 보장

이벤트를 주고받다 터지는 문제

중복 전달 · 순서 뒤바뀜 · 메시지 유실 · 이중 쓰기 · 독 메시지

터지는 문제를 받아 내는 장치

트랜잭셔널 아웃박스 · 데드 레터 큐 · 재시도 · 백프레셔

흩어진 흐름을 들여다보는 수단

상관 ID · 분산 추적 · 트레이스 · 관측 가능성

이벤트 모양을 약속으로 지키는 수단

스키마 · 스키마 레지스트리 · 스키마 진화 · 계약 테스트

이벤트를 찾아내는 설계 방법

도메인 주도 설계 · 이벤트 스토밍 · 경계 컨텍스트 · 도메인 이벤트

이벤트 주도와 맞세워지는 연결 방식

직접 호출 · 요청-응답 · RPC · REST · gRPC · 폴링

이벤트 주도를 채택하는 구성 방식

마이크로서비스 · 모듈러 모놀리스 · 서버리스 · 서비스 지향 아키텍처

한 프로그램 안에서 같은 이름을 쓰는 짜임

이벤트 주도 프로그래밍 · 이벤트 루프 · 콜백 · 옵저버 패턴 · 리스너

다른 이름: event-driven · event-driven architecture · 이벤트 주도 아키텍처 · 이벤트 기반 아키텍처 · 이벤트 드리븐