들어가며

지난 글에서는 SharedWorker로 탭마다 열리던 WebSocket 커넥션을 하나로 합쳤습니다. 연결 수가 탭 수 × 팀원 수에서 사용자당 하나로 줄었습니다.

그런데 연결을 합치고 나니 다른 게 눈에 걸렸습니다.

박명수_미동도_하지않는_안면근육
연결은 하나가 됐는데, 그 하나 위로 흐르는 트래픽은 그대로였습니다... 😒

차트가 10개 떠 있으면 1초마다 10번을 물어봤고, 채널이 늘면 그만큼 더 물어봤습니다. 연결 개수 문제를 풀고 보니, 이번엔 연결 위에서 오가는 대화 방식을 바꿔야 하는 불편한 숙제가 따라왔습니다.

이 글은 클라이언트가 계속 물어보는 폴링 방식에서, 서버가 알아서 보내주는 구조(pub/sub)로 대화 방식을 바꾼 이야기입니다.

이전 관련 글

이 글은 SharedWorker 적용기의 후속편입니다. 탭마다 열리던 WebSocket 커넥션을 SharedWorker 하나로 합친 이야기를 먼저 읽으면 맥락이 더 잘 잡힙니다.


문제의 원인 파악하기

기존 방식부터 짚고 가겠습니다.

대시보드의 각 차트는 자기 데이터를 받으려고 주기적으로 서버에 요청을 보냈습니다. WebSocket 위에 올라가 있었지만 동작은 사실상 폴링이었습니다. “데이터 주세요” → “여기 요청한 데이터요”를 1초마다 반복하는 구조였죠.

차트 A ─(1초마다 요청)─▶ 서버 ─(모니터링 서버 조회)─▶ 응답
차트 B ─(1초마다 요청)─▶ 서버 ─(모니터링 서버 조회)─▶ 응답
차트 C ─(1초마다 요청)─▶ 서버 ─(모니터링 서버 조회)─▶ 응답

여기서 모니터링 서버는 우리 서버가 실시간 데이터를 가져오는 원천 서버를 말합니다. 모니터링 서버에는 또, n개의 서버 모듈 즉 채널이라는 단위로 칭하는 서버들이 연결되어있어, n개의 리소스 요청을 받은 서버가 다시 이 모니터링 서버를 들여다봐 실제 값을 가져오는 구조죠.

여기엔 낭비가 세 군데 있었습니다.

  1. 첫째, 데이터가 안 바뀌어도 물어봅니다.

    • 폴링은 변화 여부와 상관없이 정해진 주기로 요청하기 때문에, 예를 들어 특정 서버의 DISK 모니터링이라고 가정한다면, 자주 변하지 않는 디스크 용량을 1초마다 의미없이 가져오는 것입니다.
  2. 둘째, 같은 데이터를 여러 번 조회합니다.

    • 하나의 대시보드 내에서 동일한 리소스를 UI로 렌더링하는 차트 A와 B가 같은 채널의 트래픽을 보고 있어도 각자 따로 요청을 보냈고, 서버는 모니터링 서버를 같은 리소스 요청으로 두 번 조회했습니다.
  3. 셋째, 서버에서 채널 데이터를 한 번에 모아서 통째로 내려줍니다.

    • 채널 하나만 바뀌어도 전체를 다시 만들어 보내야 했습니다.
    • 보여줘야 하는 채널 데이터가 늘어날수록 메시지 크기도 함께 커졌습니다.

이 문제로 실제 고객사에서는 JEUS 라는 웹서버를 사용하고 있었는데, JEUS 웹서버는 받아오는 메시지가 설정된 사이즈를 초과하면 WebSocket 세션을 종료합니다. 이를 해결 하기 위해 메시지를 쪼개야 하는 공수도 필요하였습니다.

Reference

한마디로 누가, 무엇을, 얼마나 자주 필요로 하는지를 클라이언트가 쥐고 있고 서버는 시키는 대로만 움직였습니다. 이 구조에선 화면이 늘수록 모니터링 서버 호출이 선형으로 따라 늘었습니다.


해결책 생각해보기

방향은 분명했습니다.

폴링 방식을 제거하고, 서버가 능동적으로 밀어주는 구조로 가자

처음엔 단순하게 생각했습니다. 데이터 바뀔 때마다 서버가 그냥 보내주면 되잖아? 그런데 막상 그러려면 두 가지를 풀어야 했습니다.

  1. 하나는 누구에게 보낼지 서버가 알아야 한다는 점입니다.

    • Raw WebSocket은 그냥 양방향 파이프일 뿐, 이 클라이언트는 트래픽 차트를 보고 있다 같은 구독 개념이 없습니다. 서버는 어떤 차트에서 요청한 데이터인지, 어떤 경로로 메시지를 보내야 하는지를 몰랐습니다. 리소스 요청 타입별 패턴 매칭 로직을 직접 만들어야 했습니다.
  2. 다른 하나는 같은 데이터를 보는 클라이언트끼리 묶는 일입니다.

    • 차트 A와 B가 같은 걸 본다면 모니터링 서버는 한 번만 조회하고 결과를 둘에게 나눠주고 싶었습니다.

위의 문제를 풀기에 직접 구현하기에는 매우 많은 시간과 공수가 들 것이라 생각하였습니다. 따라서 프로젝트 릴리즈 일정이 다가오고 있었기에, Raw WebSocket 위에 보편적으로 많이 쓰이는 STOMP의 Pub/Sub 모델을 사용하기로 결정했습니다. STOMP는 WebSocket 같은 양방향 스트림 위에서 도는 가벼운 텍스트 기반 메시징 프로토콜인데, destination(STOMP가 “무엇을 구독할지”를 가리키는 주소) 단위의 구독(SUBSCRIBE)과 발행(MESSAGE)을 기본으로 줍니다. 우리가 손으로 만들 생각을 했던 구독 라우팅을 STOMP에서 그대로 제공하고 있었습니다.


STOMP Pub/Sub 적용해보기

전체 구조를 먼저 그려보면 이렇습니다. 모든 탭은 MessagePort로 SharedWorker에 구독을 요청하고, Worker가 destination당 구독을 하나로 유지하며, 서버는 그 구독에 채널별 메시지를 밀어 줍니다.

멀티탭 STOMP 구조 다이어그램
멀티탭 STOMP 구조. 모든 탭은 MessagePort로 SharedWorker에 구독을 요청하고, Worker는 destination당 구독 1개만 유지합니다. 백그라운드 탭은 구독을 끊고, 서버는 RECEIPT로 구독 성공을 확인한 뒤 채널별 MESSAGE만 내려줍니다.

destination으로 “무엇을 볼지” 선언하기

전환의 핵심은, 차트가 더 이상 “데이터 주세요”라고 요청하지 않는 데 있습니다. 대신 “이 destination 구독할게요”라고 한 번만 선언합니다.

// /{chartType}/{resourceType} 을 구독
const { chartData, isLoading } = useChartSubscription(
  buildSubscription({ chartType, resourceType, targets }),
)

destination은 /{차트종류}/{리소스종류} 규칙으로 만듭니다. buildSubscription은 차트 종류(chartType)·리소스 종류(resourceType)와, 그 안에서 특정 채널만 걸러 보고 싶을 때 쓰는 대상 목록(targets)을 받아 구독 주소를 만들어 주는 헬퍼입니다.

이제 차트는 1초마다 무엇을 물어볼지가 아니라 무엇에 관심 있는지만 말합니다. 나머지는 서버와 SharedWorker가 알아서 합니다.

같은 데이터를 보는 차트는 함께 구독하기

여기서 SharedWorker가 다시 한번 일을 합니다. 모든 구독 요청은 Worker를 거치는데, Worker는 어떤 탭이 어떤 destination을 보고 있는지 관리합니다. 여기서 탭은 SharedWorker에 연결된 MessagePort 단위로 식별합니다.

같은 탭에서 동일한 destination을 여러 차트가 요청하더라도 서버에는 한 번만 구독을 전파하도록 멱등 처리를 추가했습니다.

case 'SUBSCRIBE': {
  const key = subscriptionKey(탭ID, 구독주소, 채널목록);
 
  if (activeSubscriptions.has(key)) break;
 
  const 구독ID = createSubscriptionId();
 
  activeSubscriptions.set(key, 구독ID);
  registerSubscription(탭ID, 구독ID, 구독주소, 채널목록);
  sendSubscribe(구독ID, 구독주소, 채널목록);
 
  break;
}

덕분에 같은 탭 내에서 차트 10개가 같은 destination을 봐도 서버로 나가는 구독은 하나입니다. 서버는 해당 destination에 대해 1초에 한 번만 모니터링 서버를 조회하고, 그 결과를 동일한 데이터를 필요로 하는 차트들이 함께 사용합니다.

폴링과 Pub/Sub 구조 비교 다이어그램
폴링(왼쪽)에서는 차트마다 1초씩 따로 요청해 모니터링 서버를 3회/sec 조회했지만, Pub/Sub(오른쪽)에서는 같은 destination 구독을 SharedWorker가 하나로 합쳐 모니터링 서버 조회가 1회/sec로 줄었습니다.

모니터링 서버 호출이 화면 개수에 비례하던 게, destination 개수에 비례하는 쪽으로 바뀐 것입니다.

실제 A 고객사 환경에서는 하나의 대시보드에 동일 destination을 바라보는 차트가 4~5개 정도 배치되어 있었습니다.

기존에는 차트마다 1초마다 요청을 보내면서 destination 하나당 초당 4~5회 수준의 모니터링 서버 조회가 발생했지만, Pub/Sub 전환 후에는 destination당 초당 1회 조회로 줄었습니다.

즉 동일한 데이터를 보는 화면 기준으로 모니터링 서버 호출량을 최대 75~80% 가까이 절감할 수 있었습니다.


채널별로 쪼개서 보내기

마지막으로 모니터링 대상 채널 모듈 서버에 대한 리소스 요청의 일괄 수집을 개별 수집으로 바꿨습니다. 예전엔 서버에서 모든 채널 데이터를 한 번에 수집하여 매우 큰 덩어리로 모아 내려줬다면, 이제는 채널별로 독립된 STOMP MESSAGE frame으로 보냅니다. 서버에서도 자동적으로 개별 수집하게 되었습니다.

개선 전 message 크기
개선 전 메시지 크기
개선 후 message 크기
개선 후 메시지 크기

위 사진을 대조해보면 메시지 크기가 확실하게 줄어든 것을 확인할 수 있었고, 개선 전 메시지가 매우 비대했었다는 것도 알게 되었습니다.

메시지 크기 개선으로 고객사에서 발생하였던 WebSocket이 끊어지는 현상은 해결할 수 있었습니다. 🙌

채널별로 메시지를 분리 전송한 결과, 메시지 크기는 기존 1.5KB8KB 수준에서 300B1KB 수준으로 감소했습니다.

실측 기준 평균 약 82%의 메시지 크기 감소 효과를 확인할 수 있었으며, 최대 크기 기준으로는 90% 이상 감소한 케이스도 있었습니다.

그 결과 고객사 JEUS 환경에서 발생하던 WebSocket 세션 종료 문제도 함께 해결할 수 있었습니다.


안 보는 화면은 구독을 끊는다

pub/sub 구조로 개선하며 자연스럽게 떠오른 질문이 있었습니다.

사용자가 활성화하지 않고 있는 탭은 아예 구독을 끊어도 되지 않을까?

다만 동시에 의문도 들었습니다.

차트 10개를 구독 중인 탭이 백그라운드로 갔다 돌아올 때마다 UNSUBSCRIBE 10회 + SUBSCRIBE 10회를 왕복하는데, 차라리 그냥 받아서 무시하는 게 더 싸지 않을까?

결론부터 말하면 끊는 쪽을 택했고, 판단 근거는 세 가지였습니다.

  1. 빈도의 차원이 다릅니다. 탭 전환은 분 단위로 가끔이지만, 데이터 push는 초 단위로 계속입니다.
    • 비활성 시간이 수십 초만 돼도, 한 번의 재구독 왕복보다 그 사이 쌓이는 push가 훨씬 크다고 판단하였습니다.
  2. frame 무게가 다릅니다. SUBSCRIBE/UNSUBSCRIBE는 destination과 id만 담긴 경량 헤더인 반면,
    • MESSAGE frame은 실제 차트 JSON을 담고 있어 한 frame당 크기 차이가 큽니다.
  3. 서버 측 연쇄 절감이 있습니다. 구독을 끊으면 해당 destination에 구독자가 남지 않을 때
    • 서버가 모니터링 서버 조회 자체를 멈출 수 있습니다. 단순히 클라이언트 트래픽을 줄이는 게 아니라, 모니터링 서버까지 부하가 전파되지 않는 것이 결정적이었습니다.

특히 모니터링 대시보드 특성상 여러 탭을 열어두고 한 탭만 집중해서 보는 패턴이 지배적이었기 때문에, 비활성 시간이 활성 시간보다 훨씬 긴 사용 패턴에서는 끊는 쪽의 이득이 명확했습니다.

탭 비활성화 시 UNSUBSCRIBE를 보내는 기준은 브라우저의 visibilitychange 이벤트와 document.visibilityState 속성으로, 아래와 같이 구현하였습니다.

useEffect(() => {
  const handleVisibilityChange = () => {
    const nowVisible = document.visibilityState === "visible"
 
    const port = portRef.current
 
    // 차트 구독정보를 순회
    subscriptionsRef.current.forEach((sub) => {
      const { destination, targets } = sub
 
      const message = nowVisible
        ? { type: "SUBSCRIBE", data: { destination, targets } }
        : { type: "UNSUBSCRIBE", data: { destination, targets } }
 
      if (port) {
        port.postMessage(message)
      }
    })
  }
 
  document.addEventListener("visibilitychange", handleVisibilityChange)
 
  return () => document.removeEventListener("visibilitychange", handleVisibilityChange)
}, [])

폴링 방식으로 구현되어있었을 때는 탭이 보이지 않거나, 숨어있는 상태에서도 서버로 리소스 요청을 1초마다 보냈었지만, 이제 활성화가 되어있지 않는 탭에서는 리소스 요청을 하지 않게 되었습니다.


트레이드오프와 한계

결과만 보면 깔끔하지만, push로 옮기면서 떠안은 비용도 분명했습니다. 솔직히 폴링 방식이 그리울 때가 있었습니다.

  1. 폴링 방식은 멍청할 만큼 단순합니다.

    • 요청을 보내고 응답 오면 끝, 안 오면 다시 보내면 그만입니다. 하지만 pub/sub은 달랐습니다. 지금 누가 무엇을 구독 중인지 를 어딘가가 계속 들고 있어야 합니다. 우리는 그 상태를 SharedWorker에 모았는데

    구독을 식별하는 키 → 구독 ID, 서버에 실제로 건 구독 목록, 구독 ID → 탭 고유 ID

    위에 언급했던 케이스들을 처리하기 위한 Map이 줄줄이 생겼습니다

그만큼 Worker의 코드가 초기에 비해 매우 복잡해졌습니다.

  1. 구독 성공을 보장하는 것도 일이었습니다.
    • 폴링은 매 요청이 독립적이라 한 번 실패해도 다음 번에 자연히 만회되지만, 구독은 한 번 약속을 맺는 동작이어서, 그 SUBSCRIBE 하나가 유실되면 그 화면은 영영 데이터를 못 받습니다. 그래서 SUBSCRIBE를 보낼 때 receipt 헤더를 함께 실어 보내고, 서버가 RECEIPT 프레임으로 응답하면 구독 성공으로 간주했습니다. 일정 시간 안에 RECEIPT를 받지 못하면 구독이 유실된 것으로 보고 최대 3번 재시도하게 했습니다. 신뢰성을 위해 들어간 코드가 꽤 됐습니다.

한마디로 이번 전환은 단순함을 포기하고 모니터링 서버 호출량과 트래픽을 줄이는 방향을 택한 선택이었다고 생각합니다.


회고

모니터링 서버 호출이 화면 개수가 아니라 destination 개수에 비례하게 바뀌었습니다. 채널별 개별 송신으로 불필요한 전체 갱신도 사라지게 되었습니다. 숫자로 보면 분명한 개선이었습니다.

대신 SharedWorker가 떠안은 상태와 예외 처리가 그만큼 늘었습니다. 폴링이었다면 끊기면 다시 요청 한 줄로 끝났을 일을, 재연결과 재구독과 재시도 세 갈래로 나눠 구현해야 했으니까요. 트래픽이 그리 많지 않은 화면이었다면 이 복잡도가 과했을지도 모릅니다. 이 부분은 지금도 조금 애매하게 느껴집니다.

그래도 연결을 줄인 지난 작업과 이번 pub/sub 전환이 합쳐지면서, 탭 수 × 차트 수 × 폴링 빈도 로 불어나던 부하를 destination 수 × 1회/sec로 눌러앉힐 수 있었습니다.

클라이언트가 서버를 1초마다 괴롭히던 구조에서 서버가 필요한 만큼만 말해주는 구조로 넘어온 경험이었습니다.

이상으로 글을 마치겠습니다.