본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

Socket 주인은 하나, 듣는 쪽은 여럿

~11 min · frontend-architecture, single-owner, events, react

Level 0Open Gate
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"자기 socket 을 여는 컴포넌트 하나하나가 터지길 기다리는 작은 누수야. 연결은 한 곳이 소유하고, 나머지는 듣기만 해."

스트림은 앱 레벨이야, 컴포넌트 레벨이 아니라

Keep 엔 live 가격을 원하는 workspace 가 여럿 있어. 포트폴리오 view, market pulse, 종목 하나를 파고드는 Focus 까지. React 에서 제일 끌리는 패턴은 컴포넌트마다 필요한 WebSocket 을 알아서 여는 거지. Keep 는 그걸 금지했어. 앱 레벨 hook 하나(useMarketStream)가 유일한 WebSocket 들을 소유해. workspace 들은 자기 연결을 여는 대신 broadcast 이벤트(keep-market-update)를 듣기만 해. 주인은 하나, 듣는 쪽은 여럿.

컴포넌트별 socket 이 만드는 난장판

컴포넌트마다 socket 을 열게 두면 문제를 묶음으로 사들이는 거야. 컴포넌트 셋이 같은 provider hub 에 연결 셋을 걸어서 부하가 세 배가 돼. mount 와 unmount 라이프사이클도 제각각이라, 정리를 빼먹고 unmount 하면 socket 이 새고 view 가 다시 mount 되면 재연결 폭풍이 몰아쳐. 심지어 컴포넌트끼리 현재 가격을 두고 딴소리를 할 수도 있어. 각자의 socket 이 tick 을 받은 순간이 미묘하게 다르니까. 그리고 pause 는 악몽이 돼. 연결 하나를 멈추는 게 아니라 모든 컴포넌트의 socket 을 찾아다니며 꺼야 하거든. 컴포넌트별 연결은 깔끔한 관심사 하나를 뒤엉킨 N 개로 바꿔 놔.

공유하는 외부 자원의 주인은 정확히 하나여야 해. 연결, 구독, 타이머, 스트림은 소비자마다 하나씩이 아니라 단일 라이프사이클을 원해. 자원엔 주인 하나를 정해 주고 그 출력을 broadcast 해. 소비자는 데이터를 구독하는 거지 연결을 여는 게 아니야. 그러면 N 개의 라이프사이클이 하나로 접히고, '전부 멈춰' 가 동작 하나로 끝나.

bridge 그리고 overlay

그 아래 흐름도 모양이 깔끔해. broadcast 된 tick 은 먼저 thread-safe 한 process-local price bridge, 그러니까 최신 값들을 담는 작은 in-memory 저장소를 갱신해. 그다음 포트폴리오, watchlist, Focus, pulse, export 같은 REST projection 들이 렌더링할 때 그 최신 값을 durable 데이터 위에 overlay 해서 내보내. 그래서 live 와 durable 은 UI 에서 다투는 두 출처가 아니야. live 는 읽는 순간 안정된 바닥 위에 얹히는 얇고 신선한 막일 뿐이지. socket 주인이 bridge 를 최신으로 유지하고, 나머지 전부는 bridge 를 durable 위에 겹친 합성본을 읽는 거야.

'이 view 만 잠깐' 이라며 workspace 안에 socket 을 다시 들이지 마. 시작은 늘 지름길이야. 컴포넌트 하나가 데이터를 조금 더 빨리 받고 싶어서 자기 연결을 열지. 그 순간 주인이 둘이 되고 single-owner 보장은 통째로 사라져. 부하는 두 배, 라이프사이클은 쪼개지고, pause 는 하나를 빼먹어. view 가 스트림이 필요하면 앱 레벨 주인의 방송을 들어. 직접 여는 건 절대 안 돼.
single DB writer 와 같은 '주인 하나' 본능이야. 백엔드에선 프로세스 하나가 모든 쓰기를 소유하고, 프론트엔드에선 hook 하나가 모든 socket 을 소유해. 둘 다 공유 자원에 주인을 여럿 두기를 거부해서 concurrency 버그 한 부류를 통째로 없앤 거야. 같은 패턴이 스택 위아래에서 운을 맞추지. 모두가 공유하는 건 한곳에 모으고, 결과만 broadcast 해라.

Code

hook 하나가 socket 을 소유하고, workspace 는 듣는다·typescript
// 앱 레벨: socket 이 열리는 유일한 자리.
function useMarketStream() {
  useEffect(() => {
    const socket = openSocket(`/ws/stocks?live=1`);
    socket.onMessage = (tick) => {
      priceBridge.update(tick);                       // 최신값 저장소
      window.dispatchEvent(                            // listener 들에게 broadcast
        new CustomEvent("keep-market-update", { detail: tick }));
    };
    return () => socket.close();                       // 정리할 라이프사이클도 하나
  }, []);
}

// workspace 는 듣기만 해. 자기 socket 은 절대 안 열어.
function useLivePrice(ticker: string) {
  const [v, setV] = useState(durableClose(ticker));
  useEffect(() => {
    const on = (e: CustomEvent) => { if (e.detail.ticker === ticker) setV(e.detail.value); };
    window.addEventListener("keep-market-update", on as EventListener);
    return () => window.removeEventListener("keep-market-update", on as EventListener);
  }, [ticker]);
  return v;   // durable close 위에 live bridge 값이 얹힌 결과
}

External links

Exercise

같은 live feed 를 저마다 원하는 컴포넌트 세 개를 상상해 봐. 먼저 컴포넌트별 버전을 스케치해. 각자 socket 을 여는 버전 말이야. 그게 만들어 내는 문제를 전부 적어 봐. 부하, 누수, 값 불일치, pause 의 복잡함까지. 그다음 broadcast 하는 주인 하나에 컴포넌트들이 듣기만 하는 구조로 리팩터해 봐. 두 설계에서 '전부 pause' 의 비용이 어떻게 다른지 설명해 봐. 동작 하나냐, N 개냐.
Hint
컴포넌트별 버전이 제일 요란하게 무너지는 지점은 정리와 pause 야. 컴포넌트 하나를 unmount 하면 그 socket 이 정말 닫혀? 앱을 pause 하면 모든 컴포넌트의 연결을 빠짐없이 잡았어? 주인이 하나면 둘 다 시시한 문제가 돼. close() 한 번, stop 한 번. 애초에 관리할 연결이 하나뿐이었으니까.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.