조회수는 방문자가 가장 먼저 신뢰하는 숫자입니다. 이 숫자는 영상이 30초를 투자할 가치가 있는지, 아니면 30분이나 투자할 가치가 있는지를 알려줍니다. TopVideoHub에서 그 숫자는 매우 빠르게 움직입니다. 트렌딩 클립은 10분 만에 조회수 40,000회를 기록할 수 있습니다. 만약 페이지의 카운터가 멈춰버린다면, 마치 빈 방에 있는 듯한 공허함이 느껴집니다. 사용자들은 떠나버립니다.
그 숫자를 브라우저에 전달하는 작업은 사소해 보일 수 있습니다. 하지만 그렇지 않습니다. 대부분의 팀이 가장 먼저 시도하는 해결책은 폴링(polling)입니다. 구현하기 쉽고 스테이징 환경에서는 잘 작동합니다. 하지만 스테이징 환경은 실제 상황을 반영하지 못합니다.
폴링이 스스로를 향한 DDoS가 될 때
TopVideoHub 팀은 간단한 JavaScript 폴러(poller)를 구축했습니다. 5초마다 최신 조회수를 가져오는 방식이었습니다. 브라우저 3개를 띄워놓은 테스트 환경에서는 완벽해 보였습니다. 하지만 운영 환경에 적용하자 플랫폼이 무너졌습니다.
5초마다 새로고침하는 8,000명의 동시 접속자는 초당 1,600개의 요청을 생성했습니다. 모든 요청이 데이터베이스를 파고들었습니다. 복제 지연(replication lag)이 급증했고, 읽기 복제본(read replicas)은 비명을 질렀습니다. 캐시 계층은 우회되었습니다. 팀은 영상을 서비스하는 것이 아니라, 스스로 초래한 부하를 서비스하고 있었습니다.
폴링은 문제가 생기기 전까지는 무해합니다. 트래픽이 적은 대시보드나 관리자 패널에서는 괜찮습니다. 하지만 바이럴 영상 페이지에서는 시한폭탄과 같습니다. 팀에는 서버에서 브라우저로 이어지는 지속적인 파이프가 필요했지만, 풀 듀플렉스(full-duplex) 프로토콜의 복잡성까지 필요하지는 않았습니다.
SSE가 단방향 파이프에 적합한 이유
SSE(Server-Sent Events)는 바로 이런 형태의 문제를 위해 만들어졌습니다. 서버는 데이터를 가지고 있고, 브라우저는 듣기만 하면 되는 경우입니다.
WebSockets와 달리 SSE는 일반 HTTP 위에서 동작합니다. 이는 생각보다 훨씬 중요합니다. 새로운 프록시 규칙, 업그레이드 헤더, 또는 로드 밸런서의 복잡한 설정이 필요하지 않습니다. 서버가 HTTP/1.1 또는 HTTP/2를 지원한다면 SSE는 바로 작동합니다. 스트림이 단순한 텍스트이기 때문에 디버깅도 매우 간편합니다. curl로 엔드포인트를 호출하여 숫자가 실시간으로 올라가는 것을 지켜볼 수 있는데, 이는 바이너리 소켓 프레임이 왜 잘못되었는지 추측하는 것보다 훨씬 낫습니다.
브라우저가 번거로운 작업들을 알아서 처리해 줍니다. 연결이 끊어지면 SSE는 Last-Event-ID 헤더와 함께 자동으로 재연결을 시도하므로, 서버는 어디서부터 재개해야 할지 알 수 있습니다. JavaScript API도 매우 간단합니다. EventSource를 생성하고 onmessage 핸들러를 연결하면 끝입니다.
진짜 아키텍처는 캐싱이다
실시간 카운터에서 저지르는 가장 큰 아키텍처적 실수는 모든 브라우저 연결을 데이터베이스 쿼리의 이유로 취급하는 것입니다. 8,000명이 같은 영상을 보고 있다면, 2초마다 8,000개의 쿼리를 실행하는 것은 미친 짓입니다. 데이터베이스는 바이럴 트래픽을 견뎌내지 못할 것입니다.
TopVideoHub는 PHP의 인메모리 opcode 및 사용자 캐시인 APCu를 사용하여 이 문제를 해결했습니다. 흐름은 간단합니다. 백그라운드 프로세스(또는 타이머에 맞춰 호출되는 가벼운 엔드포인트)가 2초마다 한 번씩 현재 조회수를 APCu에 기록합니다. 수천 개의 브라우저가 열어두고 있는 SSE 엔드포인트는 오직 APCu에서만 데이터를 읽습니다.
그 결과, 시청자가 얼마나 많든 상관없이 데이터베이스는 2초에 한 번만 호출됩니다. 캐시가 완충 장치(shock absorber) 역할을 하게 된 것입니다. APCu는 생소한 기술이 아닙니다. PHP와 함께 제공되며, 공유 메모리에 상주하여 그 어떤 네트워크 왕복보다 빠르게 읽을 수 있습니다. 빈번하게 변하지만 즉각적일 필요는 없는 단일 숫자를 처리하기에 가장 적합한 도구입니다.
APCu를 사용하지 않는다면 Redis나 Memcached도 좋습니다. 원칙은 동일합니다. 데이터베이스로부터 핫 리드(hot read) 경로를 분리하십시오.
PHP, LiteSpeed, Cloudflare가 스트리밍하도록 만들기
PHP는 작업을 마치고 종료되기를 원합니다. 웹 서버는 출력을 버퍼링하여 깔끔한 응답을 전달하고 싶어 합니다. 하지만 SSE는 그 반대가 필요합니다. 연결을 유지하면서 데이터가 도착하는 대로 바이트를 즉시 흘려보내야(flush) 합니다. 주의를 기울이지 않으면 "스트림"이 30초 후에 하나의 덩어리로 한꺼번에 도착하게 되어, SSE를 사용하는 목적 자체가 무색해집니다.
TopVideoHub가 파이프가 막히지 않게 유지한 방법은 다음과 같습니다.
출력 버퍼링을 제거하세요. SSE 스크립트 시작 부분에서 PHP가 활성화했을 수 있는 모든 버퍼링 계층을 비활성화합니다. 버퍼가 활성화되어 있다면 ob_end_flush()를 호출하고, 헤더를 보낸 후에는 ob_implicit_flush(true)를 사용하여 암시적 플러싱을 끕니다.
프록시가 개입하지 않도록 설정하세요. X-Accel-Buffering: no 헤더를 전송합니다. Nginx와 LiteSpeed는 이 헤더를 인식합니다. 이는 응답이 버퍼링되거나 캐시 가능한 덩어리로 압축되지 않아야 함을 의미합니다.
짧은 수명을 설정하세요. 각 SSE 연결은 PHP 워커(worker) 하나를 점유합니다. TopVideoHub는 스트림 수명을 55초로 제한합니다. 타이머가 종료되면 서버는 마지막 코멘트를 보내 스트림을 닫고, 브라우저는 자동으로 재연결합니다. 이 재연결은 새로운 워커에서 이루어지므로, 특정 프로세스가 영원히 점유하는 것을 방지할 수 있습니다.
연결 유지를 위한 핑(Ping). 20초마다 : ping과 같은 주석 라인을 보내세요. SSE에서 주석은 브라우저의 메시지 핸들러에 의해 무시되지만, TCP 연결을 활성 상태로 유지해 줍니다. 로드 밸런서와 CDN은 종종 30초 또는 60초 동안 아무런 데이터가 없는 연결을 끊어버립니다. 간단한 줄바꿈 하나가 이러한 연결 끊김을 방지해 줍니다.
사용자의 탭을 존중하세요. 방문자가 탭을 최소화하거나 숨기면 연결을 종료하세요. 브라우저에서 visibilitychange 이벤트를 감지하고 eventSource.close()를 호출하세요. 서버 또한 클라이언트의 연결 해제를 감지하고 루프를 종료해야 합니다. PHP에서는 루프 내부에서 connection_aborted()를 통해 확인할 수 있습니다. 10분 전에 떠난 사람들을 위해 유령 연결이 워커(worker)를 낭비하게 두지 마세요.
명확한 한계: PHP 워커(Workers)
PHP의 SSE는 한계가 명확합니다. 열려 있는 모든 SSE 연결은 하나의 PHP 워커를 소비합니다. 워커 풀에 100개의 워커가 있다면, 스트림도 100개뿐입니다. 그게 전부입니다. Apache 또는 PHP-FPM 프로세스 모델 내에서는 비동기적인 우회 방법이 없습니다. pm.max_children을 조정할 수는 있지만, 실제 경계는 메모리와 CPU가 결정합니다.
일반적인 페이지 로드, API 호출, 에셋 생성에도 워커를 사용하고 있다면 이 한계는 금방 찾아옵니다. 워커 포화 상태를 주의 깊게 모니터링하세요. 모든 워커가 20분 동안 스트림에 묶여 SSE 엔드포인트에 대기열이 생기기 시작하면, 사이트 전체가 느려집니다.
수용 가능한 범위를 넘어서면, 전환하세요. 보통 Go로 넘어가는 것이 일반적이지만, Rust, Node.js 또는 Erlang도 같은 역할을 할 수 있습니다. Go의 고루틴(goroutine)이 핵심입니다. 고루틴은 몇 킬로바이트의 비용만 발생합니다. 평범한 하드웨어에서도 큰 어려움 없이 수만 개의 스트림을 유지할 수 있습니다. 캐시에서 읽고 소켓에 쓰는 핵심 로직은 동일하지만, 런타임이 무거운 프로세스에서 가벼운 스레드로 전환됩니다.
하지만 처음부터 그럴 필요는 없습니다. PHP로도 놀라울 정도로 멀리 갈 수 있습니다. 먼저 제품의 가치를 검증하세요. 메트릭 페이지에서 데이터베이스 과부하가 아닌 워커 고갈이 나타난다면, 현재 스택의 한계를 넘어선 것입니다. 이는 좋은 신호입니다.
요약
라이브 카운터는 단순히 기술의 문제가 아닙니다. 사용자로부터 데이터베이스를 보호하는 것이 핵심입니다. SSE는 보기보다 간단하므로 SSE로 시작하세요. 연결 수가 쿼리 수로 이어지지 않도록 스트림과 데이터베이스 사이에 공격적으로 캐시를 적용하세요. 워커 한계를 철저히 모니터링하세요. 그리고 단순하게 시작하세요. PHP는 한계에 부딪히기 전까지는 충분하며, 그때가 되면 왜 코드를 다시 작성해야 하는지 정확히 알게 될 것입니다.
다음 프로젝트를 위한 가이드:
- 데이터가 서버에서 브라우저로 한 방향으로만 흐를 때는 SSE를 사용하세요.
- 데이터베이스 앞에 캐시 계층을 두세요. 몇 초마다 한 번의 쿼리를 수행하는 것이 수천 번의 쿼리보다 훨씬 낫습니다.
- SSE 연결 시간을 1분 미만으로 제한하고 브라우저가 재연결하도록 하세요.
- 탭이 숨겨지면 스트림을 닫으세요. 유휴 연결을 위해 실제 워커를 낭비하지 마세요.
- PHP 워커 사용량을 모니터링하세요. 한계에 도달하면 스트리밍 계층을 Go로 포팅하세요.
