Real-Time Updates (ops board everyone sees instantly)

System Design

Understand it step by step (한국어로 이해 → 영어로 말하기)

수백 명의 운영(ops) 인원이 같은 작업 보드를 보고 있다. 누군가 작업을 완료하면 다른 모든 사람 화면에 1~2초 안에 반영돼야 한다. 풀어야 할 문제는 세 가지: 서버가 클라이언트에게 어떻게 밀어줄까(WebSocket vs SSE vs 폴링), 서버가 여러 대일 때 어떻게 전부에게 퍼뜨릴까(Redis Pub/Sub), 그리고 잠깐 끊겼다 돌아온 사람이 놓친 변경을 어떻게 따라잡을까(resume cursor). 진실의 원천은 항상 DB이고, 실시간 채널은 알림일 뿐이라는 게 설계 전체를 관통하는 원칙이다.
  1. 1

    기능 요구사항: (1) 누가 작업 상태를 바꾸면(완료/할당/취소) 보드를 보는 모든 사람에게 실시간 반영. (2) 잠깐 끊겼다 돌아와도 놓친 변경을 따라잡는다. (3) 진실의 원천은 항상 DB — 실시간 채널은 '바뀌었다'는 알림일 뿐이다. 비기능: 반영 지연은 1~2초면 충분하다(채팅이 아니다). 이벤트를 잃어버려서 보드가 틀린 상태로 남으면 안 되고, 순서가 뒤집혀 화면이 과거로 돌아가도 안 된다.

    ⚖️ Trade-off: 밀리초 지연을 요구하지 않는다고 먼저 못 박는다. 목표를 1~2초로 낮추는 순간 설계가 훨씬 단순해진다.

    🔧 도구:push vs pullDB as source of truth

    🗣 영어로 말해

    When someone completes a task, everyone watching the board sees it within a second or two.

    checking microphone…

  2. 2

    규모 추정: 사내 도구라 작다. 동시 접속 약 500명, 바쁠 때 작업 변경 초당 10건. 이벤트 하나가 500명에게 퍼지면 fanout은 10 x 500 = 초당 5,000 메시지 — 한 대 서버로도 거뜬하다. 이벤트 하나 ~1KB면 대역폭도 5MB/s 수준. 유휴 연결 500개는 서버 한 대가 수만 개를 들 수 있으니 문제가 아니다. 진짜 설계 포인트는 숫자가 아니라, 이중화 때문에 서버가 2대 이상이라 '변경을 받은 서버'와 '클라이언트가 붙은 서버'가 다르다는 사실이다.

    🔧 도구:~500 concurrent connections10 events/sfanout 5,000 msg/s

    🗣 영어로 말해

    Five hundred viewers and ten updates a second is small; the real problem is multiple servers.

    checking microphone…

  3. 3

    API와 데이터 모델: 쓰기는 평범한 REST — POST /tasks/{id}/complete. 구독은 GET /board/{id}/events 로 SSE 스트림을 연다. 재접속 때는 Last-Event-ID 헤더에 마지막으로 받은 이벤트 번호를 실어 보낸다. 데이터 모델은 tasks 테이블 + task_events 테이블(event_id BIGSERIAL, task_id, type, payload, created_at). event_id가 단조 증가하는 커서라서 "1042번 이후 전부 줘"라는 질문이 인덱스 한 번으로 풀린다.

    🔧 도구:PostgresSSELast-Event-IDcursor pagination

    POST /tasks/{id}/complete            // normal REST write
    GET  /board/{id}/events              // SSE stream
      Last-Event-ID: 1042                // resume cursor on reconnect
    
    CREATE TABLE task_events (
      event_id  BIGSERIAL PRIMARY KEY,   -- monotonic cursor
      task_id   BIGINT NOT NULL,
      type      TEXT   NOT NULL,         -- COMPLETED / ASSIGNED / ...
      payload   JSONB  NOT NULL,
      created_at TIMESTAMPTZ DEFAULT now()
    );
    🗣 영어로 말해

    Writes are plain REST; clients subscribe to an event stream and resume with a last-event id.

    checking microphone…

  4. 4

    상위 설계: 클라이언트 → 로드밸런서 → API 서버 여러 대(stateless). 쓰기 경로: POST가 한 트랜잭션으로 tasks 업데이트 + task_events 한 줄 insert, 커밋 후 Redis Pub/Sub 채널에 publish. 실시간 경로: 모든 API 서버가 그 채널을 구독하고 있다가, 자기에게 붙어 있는 SSE 연결들로 흘려보낸다. 클라이언트는 접속 시 REST로 보드 전체를 한 번 받고, 그 뒤로는 이벤트만 받아 화면을 갱신한다.

    ✅ Fix: 서버가 3대면 변경을 처리한 서버 한 대만 그 사실을 안다. Redis Pub/Sub이 서버 사이의 방송 역할을 해서, 어느 서버에 붙은 클라이언트든 같은 이벤트를 받는다.

    🔧 도구:Redis pub/subPostgresstateless API serversSSE

    🗣 영어로 말해

    A write commits to Postgres, publishes to Redis, and every server pushes to its own connections.

    checking microphone…

  5. 5

    딥다이브 1 — 전송 수단 3개 비교. ① WebSocket: 양방향이라 가장 강력하지만 별도 프로토콜이다 — LB/프록시 설정, 하트비트, 재접속 로직을 직접 떠안는다. 이 보드는 클라이언트→서버 방향이 그냥 REST면 되니 양방향이 필요 없다. ② SSE: 서버→클라이언트 단방향 푸시. 그냥 HTTP라 기존 인프라를 그대로 타고, 브라우저 EventSource가 자동 재접속 + Last-Event-ID 재전송을 공짜로 준다. ③ 폴링: 5초마다 GET — 가장 단순하다. 결론: "푸시는 필요하고 양방향은 필요 없다" → SSE를 메인으로, 폴링을 폴백으로 둔다.

    ⚖️ Trade-off: SSE는 단방향이고 HTTP/1.1에선 도메인당 연결 6개 제한이 있다(HTTP/2로 해소). 나중에 양방향이 정말 필요해지면 그때 WebSocket으로 올린다.

    ✅ Fix: 선택 기준은 한 문장이다 — 클라이언트가 서버로 실시간으로 보낼 게 있는가? 없으면 WebSocket은 과하다. 채팅·협업 편집이면 WebSocket, 알림·보드면 SSE.

    🔧 도구:WebSocketSSE / EventSourcelong pollingHTTP/2

    🗣 영어로 말해

    I pick SSE: I only need server-to-client push, and reconnect comes free with EventSource.

    checking microphone…

  6. 6

    딥다이브 2 — 끊김과 따라잡기. Redis Pub/Sub은 fire-and-forget이라 구독하지 않던 순간의 메시지는 그냥 사라진다. 와이파이가 30초 끊긴 사람은 그 사이 이벤트를 영영 못 받는다 → 그래서 '전달'과 '보관'을 분리한다. 보관은 Postgres의 task_events. 재접속하면 클라이언트가 Last-Event-ID: 1042를 보내고, 서버는 event_id > 1042인 행들을 순서대로 먼저 보낸 뒤 실시간 스트림에 합류시킨다. 너무 오래(예: 1시간) 끊겼거나 로그가 이미 정리됐으면 따라잡기를 포기하고 보드 전체를 다시 fetch한다.

    ⚖️ Trade-off: 이벤트 로그는 며칠치만 남기고 지운다. 커서가 그보다 오래됐으면 전체 re-fetch로 떨어진다. Kafka를 쓰면 보관+커서를 한 번에 얻지만 이 규모엔 운영 비용이 과하다.

    ✅ Fix: Pub/Sub은 배달 보장이 없으니 그걸 고치려 들지 않는다. 전달은 Redis, 보관은 Postgres(event_id 커서), 마지막 수단은 항상 전체 re-fetch — 세 겹이라 어떤 끊김에도 보드가 결국 맞아진다.

    🔧 도구:Redis pub/sub (at-most-once)Postgres event logresume cursorKafka (대안 — 이 규모엔 과함)

    -- catch-up on reconnect
    SELECT * FROM task_events
    WHERE event_id > :lastEventId
    ORDER BY event_id
    LIMIT 500;
    -- cursor older than retention? -> full board re-fetch
    🗣 영어로 말해

    On reconnect I replay missed events from the database by cursor, or just re-fetch the whole board.

    checking microphone…

  7. 7

    딥다이브 3 — 순서와 화면의 진실. 서버 여러 대 + 네트워크라 이벤트가 뒤집혀 도착할 수 있다(완료가 할당보다 먼저). 클라이언트는 작업별로 자기가 마지막으로 적용한 event_id보다 작거나 같은 이벤트를 버린다 — 화면이 과거로 돌아가는 걸 막는다. 더 단순한 수: 이벤트에 diff 대신 그 작업의 전체 최신 상태를 실어 '덮어쓰기'만 하게 한다. 낙관적 UI는 내 클릭을 즉시 회색 처리로 보여주되, 서버 이벤트가 오면 그게 진실이다 — 409로 거절되면(다른 사람이 먼저 완료) 원래대로 되돌리고 알려준다.

    ⚖️ Trade-off: 이벤트마다 전체 상태를 실으면 메시지가 커지지만, 클라이언트의 병합 로직이 사라져 버그가 준다. 작업 한 건 크기에선 싸게 먹히는 교환이다.

    🔧 도구:monotonic event_idlast-write-wins per taskoptimistic UI409 Conflict

    🗣 영어로 말해

    Clients drop stale events by id, and my own click is optimistic until the server confirms.

    checking microphone…

  8. 8

    트레이드오프 + 모니터링: 솔직한 옵션부터 — 동시 500명에 1~2초 지연 허용이면 5초 폴링도 정답이다. GET /board?since=<cursor>를 5초마다 치면 100 QPS, 인덱스 한 번 타는 쿼리라 DB가 하품한다. 연결 관리, 재접속, fanout이 통째로 사라진다. SSE를 고른 건 1초 미만 반영과 모바일의 불필요한 폴링 절약을 위해서고, 그 대가로 '연결'이라는 상태를 떠안았다. 모니터링: 서버별 동시 연결 수, publish→클라이언트 수신 지연 p99, 재접속률, catch-up 쿼리 빈도, 폴링 폴백으로 떨어진 클라이언트 비율.

    ⚖️ Trade-off: 실시간 푸시는 연결이라는 상태를 사는 일이다. 그 상태가 주는 가치(1초 미만 반영)가 비용보다 클 때만 산다 — 아니면 폴링이 어른의 선택이다.

    🔧 도구:concurrent connections gaugeend-to-end latency p99reconnect ratepolling fallback %

    🗣 영어로 말해

    Honestly, five-second polling would also work here; I chose SSE for sub-second latency.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

🎙 이 카드 AI랑 음성 대화 · 기록 저장