웹 소켓(WebSocket)과 폴링(Polling) 중에서 무엇이 더 좋은가? - 8시간 장애로 배운 설계의 기준

들어가며

유튜브를 보다가 WebSocket과 Polling 방식에 대해서 짧게 설명하는 쇼츠를 보게 되었다. 이런 영상을 볼 때마다 댓글을 함께 보는 재미가 꽤 쏠쏠하다. 그 이유는 사실 굳이 나눌 필요도 없는 주제임에도 불구하고, WebSocket파와 Polling파로 나누려는 사람들이 실제로도 존재하기 때문이다. 이런 모습을 볼 때마다 '왜 저걸 이분법으로 나누지?'라는 생각이 들었고, 과거에 비슷한 상황을 직접 겪었던 경험이 떠올랐다. 이번 글에서는 그 경험을 바탕으로 WebSocket과 Polling을 어떻게 바라보는 것이 맞는지에 대해 이야기해보려고 한다.

1. 개요

약 3년 전, 유저 수 약 6만 명, 최대 동시 접속자 수 약 7천 명 정도 되는 서비스를 개발하고 운영한 적이 있었다. 해당 서비스는 총 3종류의 클라이언트가 서버와 통신을 주고받아야 하는 구조였고, 단순 요청/응답이 아닌 양방향 통신이 필요한 상황이었기 때문에 자연스럽게 WebSocket을 사용하게 되었다.

당시에는 Redis Adapter를 붙이고 서버 인스턴스를 여러 대로 확장하여, 서버 1대당 WebSocket 연결로 인해 발생하는 부하를 분산시키는 구조로 운영하고 있었다. 그러던 중, 대표님과 친분이 있던 IT 대기업 출신이라고 하는 컨설턴트가 합류하였고 그가 데려온 한 명의 개발자와 함께 특정 기능을 개발하게 되었다. 약 2주 후, 해당 컨설턴트와 그가 데려온 개발자가 개발한 기능을 배포한 이후 약 8시간 동안 전체 서비스가 중단되는 장애로 이어지게 되었다.

2. 배경

당시 우리는 사용자의 연결 상태를 모니터링하기 위한 관리자 기능을 개발해야 했다. 관리자 수는 많아야 10명 정도였고, 기능의 요구사항은 명확했다.

  • 전체 사용자 목록 조회
  • 각 사용자의 실시간 접속 상태(ONLINE / OFFLINE) 표시
  • ONLINE 사용자 우선 정렬
  • 실시간 상태 변화에 대한 빠른 반영
  • (기능보류)관리자가 사용자의 접속 상태 제어

여기서 중요한 부분은 실시간이라는 요구사항이었다. 다만 이 기능에서 말하는 실시간은 사용자의 접속 상태가 변경되었을 때 약 1~2초 이내에 관리자 화면에 반영되는 수준이었다. 즉, 완전히 즉각적인 반응이 필요한 상황이라기보다는 사람이 체감했을 때 지연 없이 자연스럽게 반영된다고 느낄 수 있는 정도의 실시간성을 요구하는 기능이었다. 이 요구사항을 기준으로 나는 자연스럽게 WebSocket 기반 접근을 제안했다. 모든 데이터를 계속 가져오는 것이 아니라, 변경된 상태만 전달하는 방식이 더 적합하다고 판단했기 때문이다. 물론, 서버쪽에서 클라이언트로 데이터를 전달하는 것이기에 SSE도 선택 항목에 있었다. 하지만, 관리자에서 사용자의 상태를 직접 제어하는 상황 즉, 클라이언트에서 서버로 요청하는 기능 확장까지 고려해야 했기에 양방향 통신을 위해 웹 WebSocket 기반으로 제안하였다. 하지만 컨설턴트와 해당 개발자는 Polling 방식을 고수했다. 그들이 제시한 논리는 다음과 같았다.

'HTTP는 TCP 요청이라 안정적이고, WebSocket은 연결이 불안정하잖아요.'

HTTP와 WebSocket 모두 TCP 기반이며 전송 자체는 동일하게 안정적이다. 다만 WebSocket은 연결을 유지해야 하기 때문에 운영과 구현이 더 까다로울 수가 있다. 즉, 기술 그 자체가 문제라기 보다는 구현 방식에 따라 불안정할 수도 있고 그렇지 않을 수도 있다. 기능 확장성, 데이터 크기, 쿼리 비용 등을 기준으로 설득을 시도했지만 협의는 잘 되지 않았다. 결국 대표님이 나를 따로 불러 '메인 서비스가 아니라 관리자 기능인 만큼 그들에게 기회를 주고 지켜보는 것이 어때요?'라고 물었다. 나는 그 말에 동의하고 다른 기능을 개발하기로 했다.

'뭐, 말이 아닌 결과로 증명하겠지'

3. 그리고 발생한 장애

스프린트 종료 전날(아니, 00시가 넘었으니 당일), 새벽 1시에 컨설턴트에게서 코드 푸시 권한을 달라는 연락이 왔다. 다음 날 코드 리뷰 후 진행하겠다고 했지만 대표 승인을 받았다는 이유로 즉시 권한을 요청했다. 마침 대표님한테 연락이 왔고 사실 확인 후 권한을 부여했다. 아침에 눈을 떠서 휴대폰을 확인해보니 BitBucket 메일과 슬랙이 쌓여있었다. 내가 잠든 사이 새벽 2시에 코드 리뷰나 승인 과정 없이 수차례 운영 배포까지 완료된 상태임을 알 수 있었다. 그리고 오전 11시 반, 관리자 6명이 해당 기능으로 모니터링을 하기 위해 관리자 페이지에 접속했다. 이것저것 만져보다가 잘 되는 것을 확인하고 관리자 페이지를 열어놓을 채로 점심식사를 하러 갔다. 그리고 약 30분 후 관리자 서버가 터졌다. 뿐만 아니라, 다른 서비스들도 요청/응답이 지연되고 있었다.

  • CPU 100%
  • 메모리 100%
  • Sentry에는 타임아웃 로그 폭증

점심시간 이후 고객의 CS 문의가 쌓이기 시작했고, 환불처리 요청이 급증했다. 대표님은 나에게 서버 긴급 점검 공지를 띄운 후 원인 분석을 요청하였고, 하던 작업을 모두 중단하고 서버 쪽부터 어떤 상황인지 살펴보기 시작했다. 그 결과, 상당히 심각한 상황임을 알 수 있었다.

4. 왜 서버가 터졌을까?

이번 장애의 원인을 한 줄로 정리하면 Polling을 사용해서가 아니다. 정확히 말하면, Polling을 잘못된 방식으로 구현했기 때문이었다. 당시 요구사항을 다시 보면, 모든 사용자에 대해 접속 여부와 상태를 지속적으로 확인해야 하는 구조였다. 즉, 단순 조회가 아니라 전체 데이터를 기반으로 상태를 계속 갱신해야 하는 기능이었다. 문제는 이 요구사항을 처리하는 방식이었다. 실제 구현은 다음과 같이 동작하고 있었다.

  • 관리자 페이지에서는 응답 여부와 상관 없이 1초마다 전체 사용자 목록을 조회하는 API를 호출한다.
  • 서버에서는 요청이 들어올 때마다 데이터베이스에서 모든 사용자 정보를 조회한 뒤 Redis에서 각 사용자의 연결 상태를 확인하고 이를 기반으로 ONLINE/OFFLINE 상태를 계산하여 정렬한 후 응답을 반환한다.

서버 입장에서 보면 상당히 위험한 구조이다. 뿐만 아니라 프론트 쪽 소스코드를 확인해보니 useEffect도 잘못 동작하고 있었다. 응답을 받은 후 1초 뒤에 다시 요청해야 함에도 불구하고, 요청을 기준으로 1초마다 재요청하도록 되어 있었기 때문이다. 정리해보면, 당장 해결해야 할 가장 큰 문제는 두 가지였다.

첫 번째는 요청 하나의 비용이 지나치게 크다는 점이었다. 매 요청마다 6만 명 이상의 사용자 데이터를 페이지네이션 없이 조회하고, 그 위에 Redis 조회와 정렬 작업까지 수행하고 있었다. 즉, 요청 하나가 단순한 조회가 아니라, 꽤 무거운 연산을 포함하고 있는 구조였다.

두 번째는 요청이 제어되지 않는다는 점이다. 관리자 페이지에서는 1초마다 API를 호출하고 있었는데, 여기서 중요한 것은 앞서 언급했듯이 이전 요청이 끝났는지 여부를 확인하지 않는다는 점이다. 실제 응답 시간은 약 1.2초에서 길게는 3.2초까지 걸렸다. 이 말은 곧, 어떤 경우에는 이전 요청이 아직 처리 중인 상태에서 다음 요청이 들어온다는 의미이다. 이 상황을 서버 관점에서 보면 다음과 같이 이해할 수 있다.

  • 요청 1이 들어와서 처리 중이다.
  • 1초가 지나면 요청 2가 들어온다. (요청 1은 아직 끝나지 않았을 수도 있다)
  • 다시 1초가 지나면 요청 3이 들어온다.
  • 이런 식으로 요청이 계속 누적된다.

즉, 서버 입장에서는 요청을 처리하는 속도보다 쌓이는 속도가 더 빠른 상태에 놓이게 된다. 이렇게 되면 서버 내부에서는 다음과 같은 일이 발생한다.

  • 처리 중인 요청이 계속 증가한다.
  • CPU는 계속 연산을 수행하게 된다.
  • 메모리에는 처리 대기 중인 요청들이 쌓인다.
  • 결국 리소스 한계에 도달하면서 응답 지연 → 타임아웃 → 프로세스 종료로 이어진다.

이 상태를 흔히 Polling Storm(폴링 폭주)라고 부른다. 특히 요청 비용이 높은 상황에서 짧은 주기의 Polling이 결합되면, 비교적 적은 사용자 수로도 쉽게 발생할 수 있는 문제이다. 결국 이 장애의 본질은 단순하다. Polling을 썼기 때문이 아니라, 비용이 큰 작업을 제어 없이 반복 호출하는 구조였기 때문이다. 이 구조에서는 WebSocket이든 Polling이든 어떤 방식을 사용했더라도 비슷한 문제가 발생할 가능성이 높다. 다만 Polling 방식에서는 이러한 문제가 더 빠르게 드러났을 뿐이다.

좋다. 이 부분도 단순 개념 설명에서 끝나지 말고, 왜 이런 차이가 생기는지 → 서버 관점에서 어떻게 다르게 보이는지까지 풀어주는 게 훨씬 좋다.

5. WebSocket과 Polling

이 시점에서 WebSocket과 Polling을 단순히 '어느 게 더 좋다'라는 기준으로 비교하는 것은 크게 의미가 없다. 두 방식은 같은 문제를 해결하기 위한 방법이 아니라, 애초에 접근 방식 자체가 다른 통신 구조이기 때문이다.

먼저 WebSocket(WebSocket)은 연결을 한 번 맺은 이후 그 연결을 계속 유지하는 방식이다. 클라이언트와 서버는 하나의 연결을 공유한 상태에서 데이터를 주고받게 되고, 서버는 이 연결이 누구의 연결인지 식별하고 관리해야 한다. 자연스럽게 이 구조는 상태를 유지하는(Stateful) 형태를 가지게 된다. 즉, 서버는 단순히 요청을 처리하는 역할을 넘어서 연결 자체를 관리하는 책임까지 함께 가지게 된다.

반면 Polling은 완전히 다른 방식으로 동작한다. 클라이언트는 일정 주기로 서버에 요청을 보내고, 서버는 그 요청에 대한 응답을 반환한 뒤 연결을 종료한다. 다음 요청은 이전 요청과 완전히 독립적으로 처리된다. 서버 입장에서는 각 요청이 서로 연결되어 있지 않기 때문에 특정 클라이언트의 상태를 장기적으로 기억할 필요가 없다. 이 구조는 자연스럽게 Stateless 형태를 가지게 된다.

이 두 구조의 차이는 단순히 구현 방식의 차이에서 끝나지 않고, 리소스를 사용하는 방식 자체를 완전히 다르게 만든다. WebSocket의 경우, 요청이 없더라도 연결이 유지되는 동안은 계속해서 리소스를 점유하게 된다. 각 연결마다 메모리와 파일 디스크립터를 사용하게 되고, 연결 수가 많아질수록 이 비용은 점점 커진다. 또한 로드밸런서(L4/L7)나 서버의 최대 연결 수 제한(Max Connection)에 도달할 가능성도 존재한다. 즉, WebSocket은 연결 자체가 비용이라고 볼 수 있다.

반면 Polling은 연결을 유지하지 않는다. 대신 요청이 발생할 때마다 새로운 연결을 생성하고 처리한 뒤 종료한다. 이 구조에서는 연결 자체의 비용은 낮지만 요청이 발생할 때마다 처리 비용이 발생한다. 만약 요청 주기가 짧거나, 요청 하나당 처리 비용이 크다면 서버에 상당한 부하가 발생할 수 있다. 결국 두 방식의 차이는 다음과 같이 정리할 수 있다.

  • WebSocket은 연결을 유지하는 대신, 요청이 발생할 때의 비용은 낮다.
  • Polling은 연결을 유지하지 않는 대신, 요청이 발생할 때마다 비용이 발생한다.

이 관계를 조금 더 단순하게 표현하면 다음과 같은 trade-off로 볼 수 있다.

  • WebSocket: 연결 비용 ↑, 요청 비용 ↓
  • Polling: 연결 비용 ↓, 요청 비용 ↑

즉, 이 둘 중 하나가 항상 더 좋다고 말할 수 있는 구조가 아니라는 것이다. 어떤 방식이 더 적합한지는 결국 요청의 성격, 데이터의 크기, 실시간성 요구사항, 트래픽 패턴 등에 따라 달라진다.

6. 필자의 해결 방식

문제가 발생한 이후, 기존에 배포되어 있던 기능은 즉시 롤백하고 구조를 다시 설계했다. 핵심은 단순히 'Polling 방식에서 WebSocket으로 변경한다'가 아니라, 데이터를 어떻게 전달할 것인지에 대한 방식 자체를 바꾸는 것이었다. 기존 구조는 관리자 페이지에서 일정 주기로 전체 데이터를 계속 요청하는 방식이었다. 즉, 변화가 없어도 동일한 데이터를 반복해서 가져오는 구조였다. 이 방식은 데이터 양이 많아질수록 불필요한 비용이 계속 증가하게 된다.

그래서 접근 방식을 완전히 반대로 가져갔다. '주기적으로 데이터를 가져온다'가 아니라 '변경이 생겼을 때만 전달한다'는 방향으로 설계를 바꿨다. 즉, 주체를 클라이언트에서 서버로 위임한 것이다. 당연하게도 클라이언트는 데이터가 어떻게 바뀌었는지 모르기 때문이다. 구조는 다음과 같이 구성했다.

  1. 관리자 페이지에서는 최초 진입 시 WebSocket 연결을 먼저 수행한 뒤, 사용자 목록 조회 API를 통해 필요한 데이터만 한 번 가져온다. 이때 전체 조회가 아닌 페이지네이션을 적용하여 필요한 범위의 데이터만 조회하도록 했다.
  2. API 서버에서는 Redis에 저장되어 있는 사용자 연결 정보를 기준으로, 필요한 사용자 목록에 대해 DB 조회를 수행한 뒤 응답을 반환한다. 즉, 전체 조회가 아니라 필요한 만큼만 조회하는 구조로 변경했다.
  3. 이후에는 WebSocket 서버를 통해 상태 변화만 처리하도록 했다. 사용자의 접속 상태가 변경되면 Redis Pub/Sub을 통해 이벤트를 전파하고 변경된 사용자 정보만 다시 조회하여 관리자 페이지로 전달하도록 구성했다.

이 구조에서 가장 중요한 포인트는 데이터 흐름이다. 기존에는 주기적으로 전체 데이터를 pull하는 구조였다면, 변경 이후에는 필요한 시점에 필요한 데이터만 가져오고, 이후에는 변경 사항만 push하는 구조로 바꾸었다.

이렇게 설계한 이유는 다음과 같다. 먼저, 관리자 페이지로 전달해야 하는 데이터 자체가 결코 가벼운 편이 아니었다. 사용자 정보와 상태 정보를 포함하고 있었기 때문에, 이를 매초마다 전체 조회하는 것은 비효율적이었다. 또한 관리자 수가 많지 않다는 점도 중요한 기준이었다. 최대 10명 정도의 사용자를 위해 전체 시스템이 매초마다 대량의 요청을 처리하는 구조는 비용 대비 효율이 좋지 않았다. 이런 경우에는 Polling보다 push 기반 구조가 훨씬 자연스럽고 효율적이라는 판단이 들었다. 마지막으로, 단순 조회뿐만 아니라 향후 기능 확장도 고려해야 했다. 예를 들어 특정 사용자의 세션을 강제로 종료하거나, 상태를 즉시 변경하는 기능이 추가될 가능성이 있었다. 이런 기능들은 Polling 구조에서는 지연이 발생할 수밖에 없다. 반면 WebSocket 기반 구조에서는 이러한 제어가 훨씬 직관적으로 가능하다. 결과적으로 이 구조는 단순히 'Polling에서 WebSocket으로 바꿨다'는 차원이 아니라 데이터를 전달하는 방식 자체를 '전체 조회 → 변경 기반 전달'로 바꾼 것에 의미가 있었다.

7. 적용하지 않은 다른 방식

물론 위에서 선택한 방식 외에도 고려해볼 수 있는 선택지는 몇 가지 더 있었다. 단순히 Polling이냐 WebSocket이냐의 문제가 아니라 데이터를 어떻게 가져오고 어떻게 전달할 것인지에 대한 다양한 접근이 가능했기 때문이다.

먼저 가장 직관적으로 떠올릴 수 있는 방법은, 사용자 정보를 Redis에 캐싱하고 데이터베이스를 거치지 않고 Redis에서만 조회하는 방식이다. 이 경우 조회 성능 자체는 매우 빠르기 때문에 Polling 구조에서도 비교적 안정적으로 동작할 가능성이 있다. 하지만 이 방식에는 명확한 한계가 있었다. 관리자 화면에서는 ONLINE/OFFLINE 여부와 관계없이 전체 사용자 목록을 기반으로 상태를 보여줘야 했기 때문에, 결국 모든 사용자 데이터를 Redis에 올려야 하는 구조가 된다. 사용자 수가 수십만 단위로 증가할 경우 이 데이터 전체를 메모리에 유지하는 것은 부담이 될 수 있다. 또한 데이터 정합성을 유지하기 위한 추가적인 동기화 로직까지 고려해야 하기 때문에 단순히 캐싱만으로 해결하기에는 복잡도가 크게 증가하는 문제가 있었다.

두 번째로 고려했던 방식은 보다 안전하게 설계된 Polling 방식이다. 예를 들어 요청 주기를 고정하지 않고 상황에 따라 조절하거나, jitter를 적용하여 요청 시점을 분산시키는 방식이 있다. 또는 마지막 변경 시점을 기준으로 변경된 데이터만 조회하는 방식(delta Polling)도 충분히 현실적인 대안이 될 수 있다. 이러한 방식은 단순 Polling에 비해 훨씬 안정적이며 잘 설계하면 높은 트래픽 환경에서도 버틸 수 있는 구조가 될 수 있을거라고 생각했다. 특히 요청당 처리 비용이 낮고 데이터 크기가 작다면 Polling 기반 구조가 오히려 더 단순하고 효율적일 수도 있다. 하지만 이 경우에도 한계는 존재했다. 이 기능은 단순 조회가 아니라 상태 변화에 대한 빠른 반영과 실시간 제어가 중요한 요구사항이었다. 예를 들어 특정 사용자의 연결을 강제로 끊거나, 상태를 즉시 반영해야 하는 경우 Polling 구조에서는 최소 Polling 주기만큼의 지연이 발생할 수밖에 없다. 주기를 줄이면 해결할 수 있지만 그만큼 서버 부하가 증가하는 trade-off가 발생한다.

세 번째로는 SSE(Server-Sent Events) 방식도 고려해볼 수 있었다. SSE는 HTTP 연결을 유지한 상태에서 서버가 클라이언트로 데이터를 push하는 방식으로 구현 난이도는 WebSocket보다 낮으면서도 실시간에 가까운 데이터 전달이 가능하다. SSE 역시 WebSocket과 마찬가지로 연결을 유지해야 하기 때문에 연결 수가 많아질 경우 서버 리소스를 지속적으로 점유한다는 부담은 동일하게 존재한다. 다만 SSE는 단방향 통신이라는 구조적 한계 때문에 이후 양방향 제어나 기능 확장을 고려했을 때 적합하지 않다고 판단했다.

마지막으로 메시지 큐 기반 구조도 생각해볼 수 있었다. 예를 들어 Kafka나 Redis Streams 등을 사용하여 상태 변화 이벤트를 처리하고 관리자 서버가 이를 구독하는 방식이다. 이 경우 시스템 전체를 이벤트 기반으로 설계할 수 있다는 장점이 있다. 다만 이 방식은 현재 요구사항에 비해 과도한 설계였다. 관리자 수가 많지 않고 시스템 규모 대비 복잡도와 실제 비용이 지나치게 높아질 수 있기 때문에 현실적인 선택지는 아니라고 판단했다.

결국 여러 선택지를 비교해보았을 때 각각의 방식은 분명 장단점이 존재했다. '좋냐, 나쁘냐'라는 이분법적 사고로 판단하는 것이 아니라 현재 요구사항과 시스템 상황에서 어떤 방식이 가장 적절한지를 판단하려고 했다.

마치며

이번 경험을 통해 느낀 점은 단순했다. 처음에는 'WebSocket이 맞냐, Polling이 맞냐' 같은 선택의 문제라고 생각했지만 실제로는 그보다 훨씬 더 근본적인 문제였다. 당시 장애의 원인은 특정 기술을 선택해서가 아니라, 요구사항에 맞지 않는 구조를 선택했기 때문이었다. Polling 자체가 잘못된 것도 아니었고, WebSocket이 항상 더 나은 선택이었던 것도 아니었다. 문제는 '어떤 방식이 더 적합한가?'를 충분히 고민하지 않은 상태에서 결정을 내렸다는 점에 있었다.

Polling은 분명 장점이 있는 방식이다. 구조가 단순하고 Stateless하게 동작하기 때문에 확장성 측면에서도 유리한 경우가 많다. 하지만 요청 하나의 비용이 크거나 짧은 주기로 반복 호출되는 구조라면 생각보다 쉽게 병목이 발생할 수 있다. 특히 이번 사례처럼 변화가 없어도 계속해서 전체 데이터를 요청하는 구조에서는 그 한계가 더 빠르게 드러난다.

그렇다면, 이런 질문을 던져볼 수 있다.

그건 WebSocket이나 Polling이나 똑같은 거 아닌가?

맞다. 결국 어떤 방식을 선택하든 설계를 잘못하면 같은 결과로 이어진다. WebSocket이라고 해서 항상 안전한 것도 아니고 Polling이라고 해서 항상 효율적인 것도 아니다. 두 방식 모두 각자의 trade-off를 가지고 있고 그 특성을 제대로 이해하지 못하면 어떤 선택을 하더라도 문제가 발생할 수 있다. 그래서 개인적으로는 'WebSocket이 맞다', 'Polling이 정석이다' 같은 이야기를 들을 때마다 조금은 조심스럽게 바라보게 된다. 기술은 정답을 고르는 문제가 아니라 주어진 상황과 요구사항에 맞게 선택하는 문제에 가깝기 때문이다. 결국 중요한 것은 하나라고 생각한다.

기술은 취향으로 선택하는 것이 아니라, 맥락과 요구사항을 기준으로 선택해야 한다.