개요
선착순 예약 사이트에서 대기 순번을 확인해본 적이 있다면 이상한 점을 눈치챘을 수 있다. 화면은 몇 초마다 저절로 갱신되는데, 개발자 도구를 열어 보면 그때마다 새 HTTP 요청이 나간다. 대기 순번처럼 실시간성이 중요해 보이는 상황이라면 연결 하나를 열어 두고 서버가 알려주는 WebSocket이 정답 같은데, 왜 굳이 요청을 반복해서 보낼까.
이 질문에 제대로 답하려면 먼저 HTTP의 근본 제약부터 짚어야 한다. 브라우저나 앱이 서버에 HTTP로 요청을 보내면 서버가 답하고 그걸로 끝난다. 이 통신의 규칙은 명확하다. 클라이언트가 먼저 물어봐야 서버가 답한다. 반대는 성립하지 않는다. 서버 쪽에 새 소식이 생겨도, 서버가 스스로 클라이언트를 찾아가 알려줄 방법이 기본적으로는 없다.
우편함을 떠올리면 이해가 쉽다. 답장을 기다리는 사람은 우체통을 계속 열어봐야 새 편지가 왔는지 안다. 우체부가 먼저 문을 두드려 알려주지 않는다. 채팅 메시지, 주문 상태 변경, 실시간 알림처럼 서버 쪽에서 먼저 소식을 던져줘야 하는 상황에서는 이 구조가 그대로 문제가 된다.
그래서 실제 서비스들은 이 한계를 근본적으로 없애는 대신 여러 단계로 흉내를 낸다. 계속 다시 물어보는 방식에서 시작해, 연결을 붙들어 두는 방식을 거쳐, 연결 하나를 열어 놓고 데이터를 흘려보내는 방식까지 점점 발전한다.
네 가지 방식을 한 줄로 정리하면 이렇다.
- 숏 폴링 (Short Polling): 클라이언트가 일정 간격으로 계속 다시 물어본다
- 롱 폴링 (Long Polling): 서버가 변화가 생길 때까지 응답을 미루고 붙들고 있는다
- SSE (Server-Sent Events): 연결 하나를 열어 두고 서버가 그 위로 계속 데이터를 흘려보낸다
- WebSocket: 연결 하나를 열어 두고 양쪽이 아무 때나 데이터를 주고받는다
이 글은 네 방식을 순서대로 훑은 뒤, 다시 대기열 질문으로 돌아온다. 접속자 규모라는 축을 하나 얹으면 "실시간성이 중요하니 WebSocket"이라는 첫 직관이 왜 뒤집히는지, iOS 클라이언트로 좁혀보면 무엇이 더 걸리는지를 순서대로 짚는다.
1. 숏 폴링 (Short Polling)
앱이 "지금 바뀐 거 있어요?"를 몇 초에서 몇십 초 간격으로 계속 물어보는 방식이다. 매번 새 HTTP 요청을 보내고, 서버는 그 순간의 상태를 그대로 응답한다. 바뀐 게 있든 없든 일단 물어보고 본다.
가장 큰 장점은 구현이 단순하다는 점이다. 특별한 프로토콜이나 서버 설정 없이 흔히 쓰는 HTTP 요청을 타이머로 반복하기만 하면 끝난다. 클라이언트 쪽 타이머 하나, 서버 쪽 조회 API 하나로 충분하다.
문제는 두 가지다. 하나는 지연이다. 실제 변화가 생긴 시점과 클라이언트가 다음 요청을 보내는 시점 사이에는 항상 간격이 있고, 그 사이에는 변화가 생겨도 클라이언트가 모른다. 요청 간격을 줄이면 지연은 줄지만 그만큼 요청 수가 늘어난다. 다른 하나는 낭비다. 대부분의 요청은 "바뀐 거 없음"이라는 응답만 받고 끝난다. 서버는 별 의미 없는 조회를 계속 처리해야 하고, 클라이언트도 배터리와 네트워크를 그만큼 쓴다.
그래서 숏 폴링은 상태가 자주 바뀌지 않고, 몇 초에서 몇십 초 정도의 지연이 크게 문제되지 않는 상황에 어울린다. 재고 수량 확인이나 배치 작업 진행률 조회 같은 게 예다.
요청과 "없음" 응답이 반복되다가, 실제로 변화가 생긴 다음 요청에서야 데이터가 온다. 그 사이 구간은 전부 낭비된 왕복이다.
그중에서도 대기열 순번 조회가 딱 이 낭비를 감당할 만한 사례다. 순번이 매초 바뀌는 것도 아니고, 1초 안팎의 지연은 사용자 경험에 크게 영향을 주지 않는다. 다만 접속자가 수십만 명 규모로 커지면 구현이 단순하다는 장점만으로는 부족하다. 두 가지를 더 신경 써야 한다.
하나는 요청 한 건의 무게다. 일반적인 API 요청은 DB 조회와 비즈니스 로직을 태우지만, 대기열 순번 조회는 Redis 같은 인메모리 저장소에서 정수 하나를 읽어오는 수준으로 끝낼 수 있다. 요청 한 건의 비용이 훨씬 작으니, 같은 서버 용량으로 버틸 수 있는 요청 수의 여유폭이 크다.
다른 하나는 폴링 주기다. 접속자 전원이 같은 주기로 요청을 반복하면 그 여유는 금방 사라진다. 실무에서는 두 가지를 함께 쓴다. 하나는 적응형 폴링이다. 대기 순번이 많이 남아 있을 때는 폴링 주기를 길게 잡고, 순번이 가까워질수록 짧게 당긴다. 이 주기를 클라이언트가 임의로 정하지 않고 서버 응답에 다음 폴링까지 기다릴 시간을 실어 보내는 방식도 많이 쓰인다. HTTP 표준에 있는 Retry-After 헤더가 이 발상과 같은 계열이다. 서버가 부하 상황을 보고 다음 요청 시점을 지정하면, 클라이언트 수만 개가 각자 판단으로 주기를 정할 때보다 훨씬 예측 가능한 트래픽 곡선이 나온다. 다른 하나는 지터다. 수십만 클라이언트가 같은 주기를 그대로 따르면 서버가 지정한 시각이라도 요청이 특정 순간에 몰린다. 클라이언트마다 폴링 시점에 무작위 오차를 조금씩 섞으면 요청이 시간 축에 고르게 퍼진다.
2. 롱 폴링 (Long Polling)
숏 폴링의 낭비를 줄이려는 시도가 롱 폴링이다. 클라이언트가 요청을 보내는 것까지는 같지만, 서버가 그 요청에 바로 응답하지 않는다. 서버는 요청을 손에 쥔 채로 기다리다가, 실제로 상태가 바뀌는 순간에야 응답을 돌려보낸다. 클라이언트는 응답을 받자마자 곧바로 다음 요청을 다시 보낸다.
바깥에서 보면 서버가 먼저 소식을 알려준 것처럼 보인다. 지연 없이 변화를 전달한다는 점에서 서버가 먼저 밀어주는 것과 비슷한 효과를 내는데, 표준 용어는 아니지만 이렇게 흉내 낸 방식을 흔히 의사 push라고 부른다.
대신 비용을 다른 쪽으로 옮겼을 뿐이다. 서버는 응답하지 않은 요청을 계속 붙들고 있어야 하는데, 동시 접속자가 많아지면 그만큼 많은 연결과 대기 상태(스레드 또는 이벤트 루프의 핸들러)를 유지해야 한다. 타임아웃도 신경 써야 한다. 아무 변화 없이 너무 오래 붙들고 있으면 중간에서 요청을 중계하는 장비(로드밸런서나 프록시)가 연결을 끊어버릴 수 있어서, 보통 30초에서 1분 정도의 타임아웃을 두고 그 안에 변화가 없으면 빈 응답을 보낸 뒤 클라이언트가 다시 요청하게 만든다.
숏 폴링과 비교하면 왕복 횟수 자체가 줄어든다. "없음" 응답이 사라지고, 연결 하나가 변화가 생길 때까지 계속 살아 있다가 딱 필요한 순간에 데이터를 전달한다. 대신 그 연결을 붙들고 있는 동안의 서버 자원은 고스란히 비용으로 남는다.
3. SSE (Server-Sent Events)
폴링은 클라이언트가 요청을 계속 새로 만든다는 점에서 벗어나지 못한다. SSE는 발상을 바꾼다. 연결을 한 번만 열어 두고, 그 연결을 닫지 않은 채로 서버가 필요할 때마다 데이터를 조금씩 흘려보낸다. 클라이언트는 다시 요청하지 않고, 열린 연결로 들어오는 데이터를 계속 받기만 한다.
방향은 한쪽뿐이다. 서버에서 클라이언트로만 데이터가 흐른다. 클라이언트가 서버에 뭔가 보내야 하면 그건 평범한 HTTP 요청을 따로 보내야 한다. 그래서 SSE는 알림, 뉴스 피드, 진행 상황 표시처럼 서버가 일방적으로 소식을 내려주는 상황에 맞는다.
동작 자체는 특별하지 않다. 서버가 응답의 Content-Type을 text/event-stream으로 두고, 응답을 끝내지 않은 채로 정해진 형식의 텍스트 줄을 계속 써 내려보내면 된다. 한 가지 편한 점은 끊겼을 때의 처리다. 브라우저의 기본 클라이언트(EventSource)는 연결이 끊기면 알아서 다시 연결하고, 마지막으로 받은 지점의 식별자를 서버에 알려줘서 빠뜨린 데이터부터 이어받게 해 준다. 재연결을 직접 짜지 않아도 되는 셈이다.
한 번 연결을 맺은 뒤에는 클라이언트가 다시 요청하지 않고, 서버가 준비되는 대로 이벤트를 하나씩 내려보낸다.
특별한 프로토콜 없이 그냥 HTTP 위에서 도는 것도 장점이다. 중간의 중계 장비 입장에서는 그냥 조금 긴 HTTP 응답이라, 별도 설정 없이도 대체로 잘 통과한다. 다만 실무에서 자주 걸리는 함정이 하나 있다. 브라우저는 한 사이트에 동시에 열 수 있는 연결 수를 제한하는데(옛 HTTP/1.1 기준으로 대체로 여섯 개), 열린 SSE 연결이 그 자리를 하나씩 차지한다. 그래서 같은 사이트를 여러 탭에 띄우면 연결이 금방 동날 수 있다. HTTP/2 이상에서는 이 제약이 크게 완화된다. 자세한 형식은 WHATWG HTML 표준의 Server-Sent Events에 정리돼 있다.
4. WebSocket
SSE로도 안 되는 게 있다. 서버와 클라이언트가 양쪽 다 아무 때나 서로에게 데이터를 보내야 하는 경우다. 채팅, 협업 편집, 실시간 위치 공유처럼 주고받음이 양방향인 상황이 그렇다. WebSocket은 이걸 위해 연결 하나를 양쪽이 자유롭게 쓰는 통로로 만든다.
시작은 평범한 HTTP 요청이다. 클라이언트가 "이 연결을 WebSocket으로 바꾸자"는 뜻을 담은 요청(Upgrade: websocket 헤더)을 보내고, 서버가 이를 받아들이면 101 Switching Protocols로 답한다. 이때 클라이언트가 함께 보낸 임의의 키(Sec-WebSocket-Key)를 서버가 정해진 규칙으로 변환해 되돌려주고, 클라이언트는 그 값을 확인해 상대가 WebSocket을 제대로 이해하는 서버가 맞는지 검증한다. 이 순간부터 그 연결은 더 이상 요청-응답을 주고받는 HTTP가 아니라, 양쪽이 아무 때나 메시지를 밀어 넣을 수 있는 양방향 통로가 된다. 이 전환 과정을 핸드셰이크(handshake)라고 부른다.
핸드셰이크 위쪽까지는 HTTP지만, 아래쪽 양방향 구간부터는 데이터를 프레임(frame)이라는 단위로 주고받는다. 하나의 연결로 여러 메시지를 흘려보내니 "여기까지가 한 메시지"라는 경계를 정하는 규칙이 필요한데, 이건 TCP 위에서 메시지 경계를 만드는 일반적인 문제와 같다. 그 방식은 TCP 프레이밍 글에서 다뤘다. 여기에 규칙이 하나 더 붙는다. 클라이언트에서 서버로 보내는 프레임은 반드시 임의의 값으로 뒤섞어서(마스킹) 보내야 한다. 반대로 서버에서 클라이언트로 오는 프레임에는 적용하지 않는다. 악의적인 스크립트가 중간의 프록시나 캐시에 원하는 바이트열을 심어 캐시를 오염시키는 것을 막기 위한 안전장치다.
연결을 오래 열어 두다 보니 생기는 문제도 있다. 중간에 연결이 조용히 죽어도 양쪽이 한동안 모를 수 있다. 그래서 서로 살아 있는지 확인하는 신호를 주기적으로 주고받는데, 보내는 쪽이 짧은 확인 신호(ping)를 보내면 받는 쪽이 응답(pong)하는 식이다. 정해진 시간 안에 응답이 없으면 연결이 끊긴 것으로 보고 정리한다. 프로토콜 자체의 규격은 RFC 6455에 정의돼 있다.
비용은 그만큼 분명하다. 연결을 계속 살려 둬야 하니 서버는 접속자 수만큼 연결을 유지해야 하고, 끊겼을 때 다시 잇는 처리도 (SSE와 달리) 대체로 직접 관리해야 한다. 중간의 프록시나 로드밸런서도 이 오래 사는 연결을 제대로 통과시키도록 설정해 줘야 한다.
접속자 규모가 커지면 이 비용의 성격이 달라진다. 접속자가 50만 명이면 서버는 50만 개의 연결 객체를 메모리에 물고 있어야 하고, 그 앞단 로드밸런서도 접속자 수만큼의 동시 연결을 버텨야 한다. 로드밸런서의 최대 동시 연결 수는 유한하다. 이 한도에 먼저 닿으면 뒤쪽 API 서버를 아무리 늘려도 신규 접속을 받을 수 없다. 병목이 API 계층이 아니라 그 앞의 연결 유지 계층으로 옮겨가는 셈이다. 이 비용은 아무 데이터도 오가지 않는 순간에도 그대로 청구된다.
더 큰 문제는 평온한 상황이 아니라 장애 상황에서 드러난다. 네트워크가 잠깐 흔들려 50만 개의 소켓이 동시에 끊기면, 클라이언트는 거의 동시에 재연결을 시도한다. 서버 입장에서는 정상 유저 트래픽이 만들어낸 디도스와 다르지 않다. 이런 동시 몰림 현상은 표준적으로 thundering herd(떼 지어 몰려드는 소 떼) 문제라고 부른다.
같은 수의 재접속 요청이라도 한 시점에 몰리는지, 시간 축에 퍼지는지에 따라 서버가 느끼는 순간 부하는 완전히 달라진다. 재연결 시도에 무작위 지연(지터)을 섞어주면 끊긴 순간 한 점에 몰리던 요청을 아래쪽 패턴처럼 시간 축 위로 퍼뜨릴 수 있다. 폴링은 애초에 상태를 들고 있지 않으니 이런 재접속 몰림 자체가 없다. 요청 하나가 실패하면 다음 요청에서 다시 시도하면 되고, 그 실패가 다른 클라이언트의 요청에 영향을 주지 않는다.
5. 방식별 선택 기준
네 방식은 더 좋고 나쁨의 순서가 아니다. 상황이 요구하는 방향과 빈도, 그리고 감당할 수 있는 비용이 무엇을 고를지 정한다.
숏 폴링
- 데이터 방향: 요청할 때만
- 연결: 매번 새로 맺음
- 낭비되는 왕복: 많음 (대부분 "없음")
- 끊겼을 때: 원래 반복이라 무관
- 인프라 궁합: 가장 좋음 (그냥 HTTP)
- 어울리는 곳: 가끔 바뀌는 상태 조회
롱 폴링
- 데이터 방향: 서버가 미뤘다 응답
- 연결: 요청 하나를 붙들고 있음
- 낭비되는 왕복: 적음
- 끊겼을 때: 클라이언트가 다시 요청
- 인프라 궁합: 대체로 무난 (타임아웃 주의)
- 어울리는 곳: 지연에 민감한 저빈도 알림
SSE
- 데이터 방향: 서버 → 클라 한쪽
- 연결: 하나를 계속 열어 둠
- 낭비되는 왕복: 없음
- 끊겼을 때: 브라우저가 자동 재연결
- 인프라 궁합: 좋음 (HTTP 위)
- 어울리는 곳: 알림, 피드, 진행률 표시
WebSocket
- 데이터 방향: 양쪽
- 연결: 하나를 계속 열어 둠
- 낭비되는 왕복: 없음
- 끊겼을 때: 대체로 직접 처리
- 인프라 궁합: 프록시·로드밸런서 설정 필요
- 어울리는 곳: 채팅, 협업 편집, 실시간 위치 공유
고르는 순서는 대체로 단순하다. 서버가 먼저 알려줄 필요가 없으면 폴링으로 충분하고, 그중에서도 지연이 중요하면 롱 폴링을 얹는다. 서버에서 클라이언트로 흐르기만 하면 되면 SSE가 가볍고, 양쪽이 서로 밀어 넣어야 비로소 WebSocket이 필요하다.
여기에 접속자 규모라는 축을 하나 더 얹으면 저울이 움직인다. 대기열처럼 상태가 한쪽으로만 흐르고 폴링 요청 자체가 가벼우면, 접속자가 늘수록 앞서 본 WebSocket의 연결 유지 비용과 재접속 폭주 쪽이 먼저 무너진다. 반대로 접속자가 적거나 양방향 통신이 필요하면 이 계산은 성립하지 않는다.
6. iOS 클라이언트 관점
지금까지는 서버 쪽 비용 구조였다. 클라이언트, 특히 iOS를 붙여보면 몇 가지가 더 걸린다.
폴링과 커넥션 재사용
"폴링은 매번 새로 연결한다"는 말은 전송 계층에서는 정확하지 않다. URLSession은 기본적으로 HTTP/1.1의 커넥션 재사용이나 HTTP/2 멀티플렉싱으로 같은 호스트로의 TCP 연결을 그대로 물고 있다가 다음 요청에 재사용한다. 폴링에서 무상태인 건 애플리케이션 계층의 의미다. 서버가 요청 사이에 세션 상태를 들고 있지 않는다는 뜻이지, 매 요청마다 TCP와 TLS 핸드셰이크를 새로 하는 게 아니다. 이 구분을 놓치면 폴링의 비용을 실제보다 부풀려 잡게 된다.
폴링 주기와 배터리
셀룰러 모뎀은 요청이 끝난 뒤에도 한동안 고전력 상태를 유지하다가 저전력 상태로 내려간다(무선 자원 제어, RRC 상태 전이의 tail 구간). 그 사이에 또 요청이 들어오면 재상승 비용은 없지만, 요청 간격이 이 tail보다 계속 길면 모뎀이 매번 전력 상태를 다시 끌어올려야 한다. 폴링 주기를 서버 부하만 기준으로 짧게 잡으면 배터리 쪽에서 손해를 볼 수 있다는 뜻이다. 적응형 폴링이 서버 부하뿐 아니라 배터리 관점에서도 필요한 이유다.
앱 생명주기와 재연결
iOS는 앱이 백그라운드로 가면 네트워크 요청과 타이머를 오래 유지해 주지 않는다. 이건 오히려 폴링에 유리하게 작용한다. 포그라운드로 돌아왔을 때 한 번만 조회해서 최신 순번을 받아오면 되기 때문이다. 소켓이었다면 사정이 다르다. 백그라운드 진입과 동시에 연결이 끊기고, 포그라운드 복귀 시 결국 재연결 로직을 다시 짜야 한다. 연결을 계속 살려 둔다는 전제 자체가 모바일 OS 위에서는 그대로 성립하지 않는다.
정리
- 실시간의 근본 문제는 HTTP가 서버 주도 통신을 못 한다는 데 있다. 네 방식은 이 한계를 서로 다른 정도로 흉내 낼 뿐이다.
- 더 실시간에 가깝다고 늘 더 나은 선택은 아니다. 상태가 가끔만 바뀌면 폴링이 가장 싸고 튼튼하다.
- 연결을 오래 붙들수록 비용이 커진다. SSE와 WebSocket은 낭비가 적은 대신 연결을 살려 두는 자원과 재연결·인프라 설정을 떠안으니, 그 비용을 감당할 이유가 분명할 때 쓴다.
- 접속자 규모가 커지면 유불리가 뒤집힌다. 대기열처럼 상태가 한쪽으로만 흐르고 폴링 요청 자체가 가벼우면, 접속자가 늘수록 연결 유지 비용 쪽이 먼저 무너진다.
- 모바일 클라이언트로 좁혀보면 전송 계층의 커넥션 재사용, 배터리, 앱 생명주기까지 더해져 폴링이 유리한 쪽으로 계산이 한 번 더 기운다.
용어
- Polling: 클라이언트가 주기적으로 서버에 다시 물어보는 방식.
- Long Polling: 서버가 변화가 생길 때까지 응답을 미뤄, 서버가 먼저 밀어준 것 같은 효과를 내는 방식.
- SSE (Server-Sent Events): 열린 HTTP 연결로 서버가 클라이언트에게 한쪽으로 데이터를 흘려보내는 방식.
- WebSocket: HTTP 연결을 승격해 양쪽이 프레임을 자유롭게 주고받는 연결.
- Upgrade / 핸드셰이크: HTTP 연결을 WebSocket으로 전환하는 과정.
- Heartbeat (ping/pong): 연결이 살아 있는지 주기적으로 확인하는 신호.
- Push: 서버가 먼저 클라이언트에게 데이터를 보내는 것.
- Thundering Herd: 다수의 클라이언트가 같은 시점에 동시에 재시도해 서버에 순간 부하가 몰리는 현상.
- Jitter: 동시 요청이 몰리지 않도록 각 클라이언트의 요청 시점에 무작위 오차를 섞는 기법.
- Retry-After: 서버가 응답에 실어 클라이언트에게 다음 재시도 시점을 알려주는 HTTP 헤더.
- RRC (Radio Resource Control): 셀룰러 모뎀이 통신 여부에 따라 전력 상태를 전환하는 무선 자원 제어 규격.