본문으로 건너뛰기

시간의 경계와 이벤트 기반 처리 - 디자인 패턴, 시스템 수준 편 ep.06

“완료했습니다”라고 말하지만, 사실은 큐에 넣었을 뿐이다.


이 경계는 무엇을 청구하는가

앞 편은 호출이 돌아오기를 기다리는 이야기였습니다. 이번 편은 기다리지 않기로 했을 때의 이야기입니다.

주문이 들어왔습니다. 해야 할 일이 여섯 가지입니다. 재고 차감, 결제 승인, 주문 저장, 확인 메일 발송, 배송사 접수, 추천 모델 업데이트를 해야 합니다.

전부 응답 전에 처리하면 사용자는 8초를 기다립니다. 그중 하나라도 실패하면 전체가 실패합니다. 추천 모델 업데이트를 기다리다가 주문을 날려버립니다.

그래서 지금 반드시 끝나야 하는 것과 나중에 해도 되는 것 사이에 선을 긋습니다.

이 선을 긋는 순간 청구되는 것들이 있습니다.

  • 진실성: 응답을 먼저 돌려주면, 그 응답의 내용이 아직 사실이 아닐 수 있습니다.
  • 순서: 나중에 처리되는 일들의 순서가 보장되지 않습니다.
  • 중복: 메시지가 두 번 전달될 수 있습니다.
  • 가시성: 나중에 실패한 일을 사용자가 알 방법이 없습니다.

시간의 경계가 답하는 질문은 하나입니다. 지금 처리할 것인가, 나중으로 미룰 것인가.


이 경계에 붙어 있는 이름들

응답 시점과 완료 시점

동기 처리에서는 두 시점이 같습니다. 응답이 돌아왔다는 것은 일이 끝났다는 뜻입니다.

비동기 처리에서는 갈라집니다. 응답은 “접수했다”는 뜻이고, 완료는 나중입니다.

// 동기 — 응답 = 완료
async function placeOrder(input: OrderInput) {
  await deductStock(input)
  await chargePayment(input)
  const order = await saveOrder(input)
  await sendConfirmationEmail(order)
  await notifyShippingPartner(order)
  await updateRecommendationModel(order)
  return order   // 여기 도달했다면 여섯 가지가 모두 끝났습니다
}
// 비동기 — 응답 ≠ 완료
async function placeOrder(input: OrderInput) {
  // 지금 끝나야 하는 것
  await deductStock(input)
  await chargePayment(input)
  const order = await saveOrder(input)

  // 나중에 해도 되는 것 — 큐에 넣고 즉시 반환
  await queue.publish('order.placed', { orderId: order.id })

  return order   // 메일도 배송 접수도 아직 안 됐습니다
}

두 번째 코드의 return은 사실과 다를 수 있습니다. 사용자가 “주문이 완료되었습니다” 화면을 보는 순간, 확인 메일은 아직 발송되지 않았고 배송사도 모릅니다.

어디까지가 “완료”인지를 정의하는 것이 이 경계의 첫 작업입니다.

동기 처리와 비동기 처리의 시간축 비교 다이어그램. 상단에는 세 가지 색을 정의하는 범례가 있습니다. 주황은 '지금 끝나야 하는 것', 초록은 '나중에 해도 되는 것', 연한 분홍은 '응답 이후 · 아직 안 끝난 구간'입니다. 동기 처리 행에서는 요청 시점부터 여섯 개의 작업이 순차적으로 이어지고 마지막 작업이 끝난 뒤에야 응답이 나갑니다. 앞의 세 작업(재고 차감·결제 승인·주문 저장)은 주황이고 뒤의 세 작업(확인 메일·배송사 접수·추천 모델)은 초록이어서, 나중에 해도 되는 것까지 응답 안에 들어 있다는 사실이 색으로 드러납니다. 전체 소요 시간은 8초로 표시되며, 응답 시점과 완료 시점이 같은 지점에 있습니다. 하단은 비동기 처리로, 요청 시점부터 세 개의 필수 작업만 순차 처리된 뒤 큐 발행과 함께 응답이 나갑니다. 응답까지는 1.2초입니다. 나머지 세 초록 작업은 응답 이후 아래 줄에서 진행되며, 동기 행과 같은 폭으로 그려져 같은 작업임이 드러납니다. 실제 완료 시점은 응답 시점보다 훨씬 뒤에 있습니다. 두 시점 사이의 구간에는 '사용자는 완료되었다고 알고 있지만 아직 끝나지 않은 구간'이라는 라벨이 붙어 있습니다. 하단에는 이 구간에서 실패가 일어나면 사용자가 알 방법이 없다는 경고가 표시됩니다.

메시지 큐와 Pub/Sub

나중에 할 일을 어디에 적어 두느냐가 다음 문제입니다.

큐(queue) 는 일감 목록입니다. 한 메시지는 한 소비자가 처리합니다. “이메일 발송”이라는 일감을 워커 세 대가 나눠 처리할 수는 있어도 한 메일이 세 번 발송되지는 않습니다.

Pub/Sub은 방송입니다. 한 메시지를 여러 구독자가 각자 받습니다. “주문이 생성됨”이라는 사실을 메일 서비스도, 배송 서비스도, 분석 서비스들이 각자 받아서 각자의 일을 합니다.

앞선 시리즈에서 다룬 Observer 패턴이 프로세스 경계를 넘으면 Pub/Sub이 됩니다. 구조는 같고, 넘는 경계가 다릅니다. 그리고 그 경계 때문에 새 문제가 생깁니다. 메모리 안의 Observer는 호출이 반드시 도착하지만, 네트워크 너머의 구독자는 그렇지 않습니다.

at-least-once와 멱등성

메시지 전달 보장은 세 종류로 이야기됩니다.

보장의미현실
at-most-once최대 한 번유실될 수 있습니다
at-least-once최소 한 번중복될 수 있습니다
exactly-once정확히 한 번엄밀한 의미로는 어렵습니다

실무에서 쓰는 것은 대개 at-least-once입니다. 유실보다는 중복이 낫기 때문입니다. 그리고 중복을 감당하는 방법이 멱등성(idempotency) 입니다.

멱등하다는 것은 같은 작업을 여러 번 해도 결과가 한 번 한 것과 같다는 뜻입니다.

// 멱등하지 않음 — 두 번 처리하면 재고가 2개 줄어듭니다
async function handleOrderPlaced(event: OrderPlacedEvent) {
  await db.product.update({
    where: { id: event.productId },
    data: { stock: { decrement: event.quantity } },
  })
}
// 멱등함 — 처리한 이벤트 ID를 기록합니다
async function handleOrderPlaced(event: OrderPlacedEvent) {
  await db.$transaction(async (tx) => {
    // 이미 처리했으면 아무것도 하지 않습니다
    const existing = await tx.processedEvent.findUnique({
      where: { eventId: event.id },
    })
    if (existing) return

    await tx.product.update({
      where: { id: event.productId },
      data: { stock: { decrement: event.quantity } },
    })

    await tx.processedEvent.create({ data: { eventId: event.id } })
  })
}

같은 원리가 HTTP API에는 **멱등키(idempotency key) **로 적용됩니다.

// 클라이언트가 요청마다 고유 키를 생성해 보냅니다
await fetch('/api/payments', {
  method: 'POST',
  headers: { 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ amount, orderId }),
})

서버는 같은 키의 요청이 다시 오면, 처리하지 않고 첫 요청의 결과를 그대로 돌려줍니다. ep.05 네트워크 경계에서 남겨둔 “알 수 없음” 문제가 여기서 풀립니다. 응답을 못 받았을 때 같은 키로 재시도하면, 결제가 되었으면 그 결과를, 안 되었으면 새로 처리한 결과를 받습니다. 두 번 결제될 일이 없습니다.

Outbox 패턴

비동기 처리에서 자주 빠지는 함정이 있습니다.

// 위험한 코드
async function placeOrder(input: OrderInput) {
  const order = await db.order.create({ data: input })   // ①
  await queue.publish('order.placed', { orderId: order.id })  // ②
  return order
}

①과 ② 사이에 프로세스가 죽으면, 주문은 저장되었는데 아무도 모릅니다. 순서를 바꿔도 마찬가지입니다. 이벤트는 나갔는데 주문이 저장 안 될 수 있습니다.

데이터베이스 트랜잭션과 메시지 발행은 하나의 트랜잭션으로 묶이지 않습니다. 서로 다른 시스템이기 때문입니다.

문제의 근본적인 원인은 성질이 다른 두 동작이 한 함수에 섞여 있다는 데 있습니다. 데이터베이스 쓰기는 롤백하면 없던 일이 됩니다. 큐로 나간 메시지는 주워 담을 수 없습니다. 소비자가 벌써 읽고 메일을 보냈을 수도 있습니다.

Outbox 패턴은 되돌릴 수 없는 행동을 되돌릴 수 있는 기록으로 바꿔치기합니다. 메시지를 브로커에 직접 보내는 대신, 같은 데이터베이스의 테이블에 저장합니다.

async function placeOrder(input: OrderInput) {
  return db.$transaction(async (tx) => {
    const order = await tx.order.create({ data: input })

    // 같은 트랜잭션 안 — 둘 다 저장되거나 둘 다 롤백됩니다
    await tx.outbox.create({
      data: {
        topic: 'order.placed',
        payload: { orderId: order.id },
        status: 'pending',
      },
    })

    return order
  })
}

그리고 별도의 프로세스가 outbox 테이블을 읽어 브로커로 옮깁니다.

// 릴레이 — 주기적으로 실행됩니다
async function relayOutbox() {
  const pending = await db.outbox.findMany({
    where: { status: 'pending' },
    take: 100,
    orderBy: { createdAt: 'asc' },
  })

  for (const msg of pending) {
    await queue.publish(msg.topic, msg.payload)
    await db.outbox.update({
      where: { id: msg.id },
      data: { status: 'sent', sentAt: new Date() },
    })
  }
}

어느 시점에 죽느냐에 따라 남는 상태가 다릅니다.

죽은 시점Outbox 없이Outbox를 쓰면
주문 저장 전아무 일도 일어나지 않습니다아무 일도 일어나지 않습니다
주문 저장 후, 발행 전주문은 있는데 이벤트가 없습니다이 상태가 생길 수 없습니다
커밋 전-주문과 outbox가 함께 롤백됩니다
커밋 후, 릴레이가 집기 전-pending으로 남아 있다가 나중에 발행됩니다
발행 후, sent 표시 전-같은 메시지가 다시 나갑니다

Outbox 없이 죽으면 유실입니다. Outbox 구조에서 죽으면 지연 아니면 중복입니다. 유실은 고칠 수 없습니다. 사라졌다는 사실을 아무도 모르기 때문입니다. 지연은 릴레이가 살아나면 해소되고, 중복은 앞 절의 멱등 소비자가 흡수합니다.

그래서 at-least-once이고, 그래서 소비자가 멱등해야 합니다. Outbox는 멱등성을 전제로 성립합니다. 멱등하지 않은 소비자에 Outbox를 붙이면 중복 차감이 일어납니다. 앞 절의 processedEvent 테이블이 받는 쪽의 짝입니다. 보내는 쪽이 Outbox, 받는 쪽이 Inbox 패턴입니다.

Outbox 패턴의 흐름 다이어그램. 좌측 상단에 애플리케이션이 있고, 그 아래로 하나의 데이터베이스 트랜잭션 경계가 점선 박스로 그려져 있습니다. 박스 안에는 주문 테이블 쓰기와 아웃박스 테이블 쓰기가 나란히 배치되어 있으며, 둘 다 저장되거나 둘 다 롤백된다는 라벨이 붙어 있습니다. 우측에는 별도의 릴레이 프로세스가 배치되어, 아웃박스 테이블에서 pending 상태의 메시지를 읽어 메시지 브로커로 발행하고 상태를 sent로 바꾸는 흐름이 화살표로 표시됩니다. 하단에는 릴레이가 발행 후 상태 업데이트 전에 죽는 경우가 별도로 표시되어, 같은 메시지가 다시 발행되므로 소비자가 멱등해야 한다는 설명이 붙어 있습니다. 하단에는 이 패턴을 쓰지 않았을 때의 실패 시나리오가 세 박스로 대비되어, 주문은 저장되었으나 이벤트가 발행되지 않은 상태가 표시됩니다.

실무에서 붙는 조건이 몇 가지 있습니다.

  • 릴레이를 여러 대 띄우면 같은 행을 둘이 집습니다. SELECT ... FOR UPDATE SKIP LOCKED로 서로 다른 행을 잡게 합니다.
  • sent 행을 정리하지 않으면 테이블이 계속 커집니다. 주기적으로 삭제하거나 파티션을 씁니다.
  • 폴링 주기만큼 지연이 생깁니다. 초 단위도 못 견디면 데이터베이스 변경 로그를 직접 읽는 CDC(Change Data Capture) 로 바꿉니다.
  • 모든 이벤트에 붙일 필요는 없습니다. 결제·정산·재고처럼 유실이 곧 손실인 곳에 씁니다. 추천 모델 업데이트처럼 몇 개 빠져도 되는 이벤트에는 과합니다.

프론트엔드의 시간 경계

시간의 경계는 브라우저 안에도 있습니다.

디바운스와 스로틀이 가장 익숙한 형태입니다. 검색어를 입력하고 버튼을 누르지 않고도 검색이 되는 구조에서 무조건 쓰입니다. 검색어를 한 글자 칠 때마다 요청을 보내면 초당 여러 번의 불필요한 요청이 보내집니다.

디바운스는 입력이 멈춘 뒤 300ms까지 요청을 보내지 않고 보류합니다.

// 입력이 멈출 때까지 요청을 미룹니다
const debouncedSearch = useDebouncedCallback((q: string) => {
  search(q)
}, 300)

스로틀은 미루는 것이 아니라 일정 간격 안에서 한 번만 통과시킵니다. 스크롤 위치 추적처럼 멈추지 않고 계속 발생하는 이벤트에 씁니다. 입력이 멈추기를 기다리면 계속해서 움직이는 스크롤의 위치를 잡을 수가 없습니다.

디바운스는 요청 수를 줄일 뿐, 순서를 보장하지 않습니다. “리액”과 “리액트”를 연달아 보내면 “리액”의 응답이 나중에 도착해 화면을 덮어쓸 수 있습니다. 이전에 보낸 건 취소해야 합니다. 서버에서 본 순서 문제가 브라우저에서도 발생합니다.

// 새 요청을 보내기 전에 이전 요청을 취소합니다
const controllerRef = useRef<AbortController>()

async function search(q: string) {
  controllerRef.current?.abort()
  const controller = new AbortController()
  controllerRef.current = controller

  const res = await fetch(`/api/search?q=${q}`, { signal: controller.signal })
  setResults(await res.json())
}

낙관적 업데이트(optimistic update) 는 경계를 더 앞으로 당깁니다. 응답을 기다리지 않고 화면을 먼저 바꾸고, 실패하면 되돌립니다.

// 응답 전에 화면을 바꾸고, 실패하면 되돌립니다
async function toggleLike(postId: string) {
  setLiked(true)
  try {
    await api.like(postId)
  } catch {
    setLiked(false)
    toast('좋아요를 반영하지 못했습니다')
  }
}

다만 이건 되돌릴 수 있는 것에만 쓸 수 있습니다. 좋아요는 잘못 켜졌다 꺼져도 손해가 없습니다. 결제 완료 화면은 그렇지 않습니다.

오프라인 큐는 네트워크가 없을 때 사용자의 행동을 로컬에 쌓아 두고, 연결이 돌아오면 순서대로 보냅니다. Service Worker의 Background Sync가 이 일을 합니다.

// 오프라인이면 큐에 넣고, 온라인이 되면 처리합니다
async function submitComment(text: string) {
  if (!navigator.onLine) {
    await addToOutbox({ type: 'comment', text, id: crypto.randomUUID() })
    return { queued: true }
  }
  return api.postComment(text)
}

crypto.randomUUID()로 붙인 id는 전송에 실패해 다시 보낼 때 서버가 같은 요청임을 알아보는 멱등키입니다. 큐를 메모리에 두면 새로고침 한 번에 사라지므로, IndexedDB처럼 지속되는 저장소에 둡니다.

안티패턴

전부 비동기로: 사용자가 즉시 결과를 확인해야 하는 작업까지 큐에 넣으면, “처리 중입니다”만 보여주다 끝납니다. 결제 승인 여부는 지금 알려줘야 합니다.

멱등하지 않은 소비자: at-least-once 브로커를 쓰면서 소비자가 멱등하지 않으면, 언젠가 반드시 중복이 생깁니다.

순서에 의존하는 처리: 대부분의 브로커는 전역 순서를 보장하지 않습니다. “주문 생성” 다음에 “주문 취소”가 올 것이라 가정하면 안 됩니다. 이벤트에 버전이나 타임스탬프를 넣고 순서가 뒤집혀도 견디게 만들어야 합니다.

Dead Letter Queue 없음: 계속 실패하는 메시지가 큐 맨 앞에 남아 뒤를 막습니다. 일정 횟수 실패하면 별도 큐로 보내고, 사람이 볼 수 있게 알립니다.


이 경계가 화면에 드러나는 자리

이 경계는 문구로 화면에 나타납니다. 그리고 대부분의 서비스가 여기서 거짓말을 합니다.

정직한 완료 문구

주문 버튼을 누르면 “주문이 완료되었습니다”가 뜹니다. 그런데 실제로 완료된 것은 결제와 저장뿐이고, 배송사 접수는 아직입니다.

이 차이가 항상 문제인 것은 아닙니다. 사용자에게 중요한 것은 “내 주문이 접수되어 되돌릴 수 없는 상태가 되었는가”이고, 그것은 사실입니다. 배송사 접수가 내부 처리라는 점을 사용자가 알 필요는 없습니다.

문제는 나중 작업이 실패했을 때 사용자가 이미 완료 화면을 봤다는 것입니다.

기준을 하나 세울 수 있습니다.

나중 작업이 실패했을 때 사용자가 무언가를 해야 한다면, 그 작업은 완료 문구에 포함되면 안 됩니다.

  • 배송사 접수 실패 → 운영팀이 처리합니다. 사용자는 몰라도 됩니다. “주문 완료”로 충분합니다.
  • 확인 메일 발송 실패 → 사용자가 메일을 기다립니다. “확인 메일을 보내드렸습니다”라고 썼다면 거짓말이 됩니다.

후자는 문구를 바꿔야 합니다. “확인 메일을 곧 보내드립니다” 정도가 정확합니다. 미래형이 정직합니다.

상황부정확한 문구정확한 문구
큐에 넣었을 뿐업로드가 완료되었습니다업로드를 시작했습니다
백그라운드 처리 중변환되었습니다변환 중입니다. 완료되면 알려드립니다
오프라인 큐에 저장저장되었습니다저장 대기 중입니다. 연결되면 전송됩니다
메일 발송 예약메일을 보냈습니다메일을 곧 보내드립니다

진행 상태를 어떻게 보여줄 것인가

나중에 끝나는 일의 결과를 사용자에게 전달하는 방법은 세 가지입니다.

폴링: 화면에서 주기적으로 물어봅니다. 구현이 가장 단순하고, 사용자가 그 화면에 있을 때만 유효합니다. 짧고 확실히 끝나는 작업에 적합합니다.

푸시: WebSocket, SSE, 웹 푸시 등을 통해 서버가 먼저 알립니다. 사용자가 다른 화면에 있어도 도달합니다. 대신 연결 관리와 권한 요청이 필요합니다.

나중에 확인: 알림 센터나 메일로 남깁니다. 몇 분에서 몇 시간 걸리는 작업에 적합합니다.

작업 시간에 따라 고릅니다.

  • 1초 이내 → 애초에 동기로 처리합니다.
  • 1~10초 → 화면에서 폴링하며 기다립니다.
  • 10초~수 분 → 푸시로 알립니다. 사용자는 다른 일을 해도 됩니다.
  • 수 분 이상 → 알림 센터에 남깁니다. 사용자는 화면을 떠나도 됩니다.

작업이 오래 걸린다면, 사용자를 화면에 붙잡아 두지 않는 것이 배려입니다. 진행률 바를 정교하게 만드는 것보다, 떠나도 된다고 알려주는 편이 낫습니다.

비동기 작업의 소요 시간별 UX 대응 표. 네 개의 열 머리말은 '소요 시간', '대응 방식', '화면 문구', '사용자는'입니다. 행은 소요 시간 순으로 네 개이고, 각 행의 테두리 색이 서로 다릅니다. 첫 행은 '~ 1s / 즉시'로 대응 방식은 '동기 처리', 화면 문구는 "저장되었습니다"이며 '과거형이 정확합니다'라는 설명이 붙고, 사용자는 '기다리지 않습니다', '비동기로 만들 이유가 없습니다'입니다. 둘째 행은 '1 ~ 10s / 짧은 대기'로 대응 방식은 '화면에서 폴링', 문구는 "처리 중입니다"이며 '진행률이 있으면 함께 표시', 사용자는 '화면에 머무릅니다', '떠나면 결과를 놓칩니다'입니다. 셋째 행은 '10s ~ 수 분 / 중간 대기'로 대응 방식은 '푸시 알림', 문구는 "변환 중입니다. 완료되면 알려드립니다"이며 '떠나도 된다는 사실을 명시합니다', 사용자는 '다른 일을 합니다', 'WebSocket · SSE · 웹 푸시'입니다. 넷째 행은 '수 분 이상 / 긴 작업'으로 대응 방식은 '알림 센터 · 메일', 문구는 "작업을 예약했습니다. 결과는 알림으로 보내드립니다"이며 '미래형이 정직합니다', 사용자는 '화면을 떠납니다', '나중에 확인합니다'입니다. 그 아래에는 '큐에 넣었을 뿐인데 완료라고 쓰면 거짓말이 됩니다'라는 제목의 점선 카드가 있고, '부정확'과 '정확' 두 열로 세 쌍이 대조됩니다. '업로드가 완료되었습니다'와 '업로드를 시작했습니다', '메일을 보냈습니다'와 '메일을 곧 보내드립니다', '저장되었습니다 (오프라인)'과 '저장 대기 중 · 연결되면 전송됩니다'입니다. 맨 아래에는 '작업이 오래 걸린다면, 사용자를 화면에 붙잡아 두지 않는 것이 배려입니다'가 적혀 있습니다.

취소 가능성

비동기 처리에는 중간에 취소를 할 수 있는 여유가 있습니다. 큐에 들어갔지만 아직 처리되지 않은 구간입니다.

메일 서비스의 “보내기 취소”가 이 구간을 상품화한 예입니다. 실제로는 발송을 몇 초 지연시키고, 그 사이를 취소 가능 구간으로 노출합니다. 의도적으로 시간 경계를 늘려 사용자에게 되돌릴 기회를 주는 것으로, 즉시 처리하는 것이 언제나 더 나은 UX는 아닙니다.


참고 자료


다음 편 예고

ep.07 - 상태의 경계와 서버 상태 분리

여기까지가 실행의 경계였습니다. 이제 값이 흐릅니다. 같은 필터 조건이 여섯 군데에 저장되어 있는 화면을 본 적이 있다면, 다음 편의 질문이 익숙하실 것입니다. 이 값의 소유자는 누구인가.