들어가며
공연 프로젝트 티켓 오픈 10분 전, 사용자들은 이미 새로고침 버튼을 누르며 대기하고 있었습니다. 오픈 시간이 되자 10만 명이 동시에 좌석 선택 페이지로 몰려들었고, 화면은 멈췄습니다. 사용자들은 로딩 스피너만 보다가 타임아웃 에러를 만났고, 몇 번의 새로고침 끝에 결국 "품절" 메시지만 보게 되었습니다.
이 문제를 해결하기 위해 WebSocket 기반 대기룸 시스템을 구현한 과정을 글로 작성해보았습니다.
화면이 멈춘 이유
막연하게 트래픽이 많아서라고 생각할 수 있지만, 문제를 정확히 파악하는 것이 해결의 시작이라고 생각했습니다.
병목 지점
백엔드 API 과부하
좌석 선택 페이지는 Seats.io 라이브러리를 사용했습니다. 이 라이브러리를 초기화하려면 먼저 자체 백엔드 API를 호출해서 필요한 정보를 가져와야 했습니다.
// Step 1: 백엔드에서 Seats.io 초기화 정보 조회
const response = await fetch("/api/concert/settings");
const { workspaceKey, pricing, eventKey } = await response.json();
// Step 2: 받은 정보로 Seats.io 라이브러리 초기화
<SeatsioSeatingChart
workspaceKey={workspaceKey}
event={eventKey}
pricing={pricing}
region="eu"
/>;10만 명이 동시에 페이지에 접속하면, 10만 건의 API 요청이 한 번에 몰립니다. 서버는 이 트래픽을 감당하지 못했습니다.
평상시: 100 req/s → 응답 100ms
피크타임: 10,000 req/s → 응답 10s 또는 타임아웃화면 멈춤
API 응답이 느려지거나 실패하면서, 프론트엔드는 Seats.io 컴포넌트를 초기화할 수 없었습니다. 사용자는 로딩 스피너만 보게 되고, "기다려야 하는지, 에러가 난 건지" 알 수 없는 상태가 되었습니다.
핵심 문제
분석 결과, 문제는 동시성이었습니다. 10만 건의 요청을 처리하는 것 자체는 가능하지만, 같은 순간에 몰리는 게 문제였습니다. 트래픽을 시간적으로 분산시킬 방법이 필요하다고 생각했습니다.
대기룸 외에 다른 방법은 없었을까
처음 대기룸을 생각했을 때 솔직히 의문이었습니다. "사용자를 기다리게 만드는 게 과연 좋은 경험일까?" 그래서 다른 방법들을 먼저 검토했습니다.
검토한 대안들
1. 캐싱
"좌석 데이터를 캐싱하면 DB 부하를 줄일 수 있지 않을까?"
- 장점: DB 부하 감소, 응답 속도 개선
- 단점: 좌석 상태는 실시간으로 변경됨, 첫 요청 시점의 병목은 해결되지 않음
좌석 상태는 계속 바껴 누군가 선택 중이거나 예매가 완료되면 즉시 반영되어야 합니다. 캐시 무효화 로직이 복잡해질뿐더러, 결국 첫 요청이 몰리는 순간의 병목은 해결되지 않는다고 판단했습니다.
2. 서버 증설
"서버를 늘려서 트래픽을 수용하면 되지 않을까?"
- 장점: 모든 트래픽을 받을 수 있음
- 단점: 일시적 피크를 위해 상시 인프라 비용 증가, DB 커넥션 한계는 여전히 존재
일시적인 트래픽 때문에 항상 큰 인프라를 유지하는 건 비효율적이라고 생각했습니다.
3. API Rate Limiting
"API 호출 수를 제한하면 서버를 보호할 수 있지 않을까?"
- 장점: 서버 보호 가능
- 단점: 제한당한 사용자는 429 에러를 만남, 계속 재시도하면서 오히려 트래픽 증가
제한에 걸린 사용자는 에러 화면을 보고, "왜 안 될까?" 하며 계속 재시도하게 됩니다. 오히려 더 많은 트래픽이 발생할 수 있다고 생각했습니다.
4. 낙관적 UI
"일단 좌석을 보여주고 백그라운드에서 데이터를 가져오면 어떨까?"
- 장점: 빠른 첫 화면
- 단점: 실제로 선택 불가능한 좌석을 보여주면 신뢰 하락, 서버 부하는 미해결
좌석을 골랐는데 실제로는 예매할 수 없다면? 사용자의 기대를 만들었다가 배신하는 꼴이라고 생각했습니다. 그리고 근본적인 서버 부하 문제는 해결되지 않습니다.
대기룸을 선택한 이유
모든 대안의 공통된 약점은 사용자가 상황을 모른다는 것이었습니다. 로딩만 계속되면 기다려야 하는지, 에러인지 알 수 없습니다. 429 에러가 나면 왜 안 되는지, 다시 시도해야 하는지 불명확합니다. 낙관적 UI는 기대를 만들었다가 배신당하는 경험을 주게 됩니다.
대기룸이란
대기룸은 사용자를 가상의 대기 공간에 먼저 입장시키고, 순서대로 실제 서비스에 접근하게 하는 시스템입니다. 마치 콘서트장에 입장하기 전 줄을 서는 것과 같습니다.
사용자 → 대기룸 입장 → 대기열 등록 → 순서 대기 → 입장 허용 → 서비스 이용대기룸의 장점
- 트래픽 제어: 서버가 감당 가능한 수준으로 사용자를 순차 입장시킴
- 명확한 정보 제공: 대기 인원, 예상 시간을 실시간으로 안내
- 공정성 보장: 먼저 온 사람이 먼저 입장하는 규칙
대기룸의 단점
- 사용자는 기다려야 함: 즉시 서비스를 이용할 수 없음
- 구현 복잡도: 추가적인 시스템 구축 필요
- 인프라 비용: 대기룸 서버와 WebSocket 관리 필요
그럼에도 선택한 이유
단점에도 불구하고 대기룸을 선택한 이유는 투명성과 공정성 때문이었습니다.
사용자에게 현재 상황을 명확히 알려주는 것:
"현재 대기: 1,243명"
"예상 시간: 약 3분"
"순서가 되면 자동 입장"사용자는 자신의 상황을 정확히 이해하고, 기다릴지 포기할지 선택할 수 있게 됩니다. 무작정 기다리는 것보다, 순서를 알고 기다리는 것이 훨씬 나은 경험이라고 생각했습니다.
그리고 선착순 예매에서 가장 중요한 공정성을 보장합니다. 네트워크가 빠르거나 봇을 돌린 사람이 아니라, 정직하게 줄을 선 사람에게 기회를 주는 시스템이라고 생각했습니다.
마지막으로 핵심은 트래픽의 시간적 분산입니다. 10만 명을 한 번에 받는 게 아니라, 서버가 감당 가능한 수준으로 나눠서 입장시키는 것입니다. 요청을 순차적으로 보내는 게 아니라, 입장을 순차적으로 시키는 것이 핵심이었습니다.
백엔드 팀과의 협업
대기룸을 만들기로 결정한 후, 백엔드 팀과 함께 시스템 설계를 시작했습니다. 가장 먼저 한 일은 책임 분리를 명확히 하는 것이었습니다.
대기 순서 관리
처음에는 이런 생각도 했습니다. "프론트엔드에서 타임스탬프를 기록하고 순서대로 API를 호출하면 되지 않을까?"
// 이런 식으로?
const timestamp = Date.now();
localStorage.setItem("joinTime", timestamp);하지만 백엔드 팀과 논의한 결과, 이건 위험하다는 결론을 내렸습니다. 클라이언트는 신뢰할 수 없습니다. 악의적인 사용자가 타임스탬프를 조작하거나, 여러 개의 탭을 열어서 순서를 무시할 수 있기 때문입니다.
결론: 대기 순서는 반드시 서버가 관리해야 한다고 합의했습니다.
페이지 접근 제어
다음 질문은 페이지 접근 제어였습니다. "사용자가 대기룸을 거치지 않고 직접 좌석 페이지 URL로 접근하면 어떻게 될까?"
https://example.com/seats/concert123대기룸을 우회할 수 있다면, 대기룸의 의미가 없어진다고 생각했습니다. 백엔드 팀과 토큰 기반 인증 방식으로 합의했습니다.
1. 사용자가 대기룸에 입장 → 서버가 대기열에 등록
2. 순서가 되면 → 서버가 일회용 토큰 발급
3. 토큰으로만 좌석 API 접근 가능
4. 토큰 없이 접근 시 → 403 에러, 대기룸으로 리다이렉트프론트엔드는 페이지 진입 시 토큰을 확인하고, 백엔드는 API 요청 시 토큰을 검증하는 이중 방어 구조를 만들기로 했습니다.
실시간 정보 전달 방식
대기 정보를 어떻게 업데이트할 것인가도 중요한 고민이었습니다. 백엔드 팀과 두 가지 옵션을 논의했습니다.
1. HTTP Polling의 한계
HTTP 폴링은 클라이언트가 일정 주기로 서버에 요청을 보내 상태를 확인하는 방식입니다.
// 1초마다 서버에 요청
setInterval(() => {
fetch("/api/queue/status").then(updateUI);
}, 1000);이 방식의 문제점:
- 네트워크 과부하: 10만 명이 1초마다 요청하면 초당 10만 번의 HTTP 요청 발생
- 응답 지연: 요청-응답 간 지연으로 실시간 상태 반영 어려움
- 불필요한 데이터 전송: 변경사항이 없어도 계속 요청
부하를 줄이려고 대기룸을 만드는데, 오히려 부하를 만드는 모순이라고 생각했습니다.
2. WebSocket의 장점
WebSocket은 양방향 통신을 가능하게 하며, 한 번 연결 후 지속적인 통신이 가능합니다.
const ws = new WebSocket("wss://api/queue");
ws.onmessage = (event) => {
updateUI(JSON.parse(event.data));
};이 방식의 장점:
- 실시간 상태 업데이트: 대기 순서, 남은 인원 등을 즉시 반영
- 데이터 효율성: 초기 연결 이후 추가 요청 없이 서버가 변경사항만 푸시
- 비용 효율성: 지속적인 연결로 불필요한 리소스 사용 최소화
서버는 대기 순서가 바뀔 때, 입장 가능할 때만 메시지를 보내면 되었습니다. 백엔드 팀과 합의한 구조는 다음과 같았습니다. 서버는 Redis Queue로 대기열을 관리하고 변경사항을 WebSocket으로 푸시하며, 프론트엔드는 WebSocket 연결을 유지하고 받은 데이터로 UI를 업데이트하는 것이었습니다.
타이머 관리
남은 시간을 어떻게 보여줄지도 논의했습니다. 백엔드 팀과 논의한 결과, 아래와 같은 결론에 도달했습니다.
서버는 "예상 대기 시간"만 알려주고, 카운트다운은 클라이언트가 관리하기로 했습니다.
// 서버가 보내는 메시지
{ type: 'QUEUE_UPDATE', estimatedWaitTime: 180 } // 180초
// 클라이언트가 1초마다 감소
const [seconds, setSeconds] = useState(180);
useEffect(() => {
const timer = setInterval(() => {
setSeconds(prev => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, []);이렇게 하면 서버는 변경사항이 있을 때만 메시지를 보내면 되고, 클라이언트는 부드러운 카운트다운을 보여줄 수 있었습니다. 매 초마다 서버에 요청을 보낼 필요가 없어졌습니다.
명확해진 역할
백엔드의 책임
- 대기열 관리 (Redis Queue)
- 순서 결정 및 토큰 발급
- 입장 타이밍 제어
- 부하 모니터링
프론트엔드의 책임
- WebSocket 연결 유지 및 재연결
- 실시간 UI 업데이트
- 토큰 검증 및 페이지 접근 제어
- 사용자에게 명확한 정보 전달
이렇게 역할을 나누니 각자 자신의 영역에 집중할 수 있었고, 문제가 생겼을 때도 책임 소재가 명확했습니다.
핵심 구현
백엔드 팀과 책임을 명확히 나눈 후, 프론트엔드 구현을 시작했습니다.
WebSocket 연결과 상태 관리
WebSocket 로직을 재사용 가능한 훅으로 분리했습니다.
export const useWebSocket = (url: string) => {
const [waitingCount, setWaitingCount] = useState(0);
const [estimateTime, setEstimateTime] = useState(0);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => setIsConnected(true);
ws.onmessage = (event) => {
const { type, payload } = JSON.parse(event.data);
if (type === "QUEUE_UPDATE") {
setWaitingCount(payload.count);
setEstimateTime(payload.seconds);
}
};
ws.onclose = (event) => {
setIsConnected(false);
// 비정상 종료 시 재연결
if (event.code !== 1000) {
setTimeout(() => window.location.reload(), 2000);
}
};
return () => ws.close();
}, [url]);
return { waitingCount, estimateTime, isConnected };
};핵심만 남긴 코드입니다. WebSocket 연결, 메시지 수신, 상태 업데이트가 전부입니다. 커스텀 훅으로 분리해 재사용성과 유지보수성을 높였습니다.
토큰 검증
export default function SeatsPage({ concertId }: Props) {
const router = useRouter();
useEffect(() => {
const token = sessionStorage.getItem("waitingToken");
if (!token) {
router.push(`/waiting/${concertId}`);
return;
}
// API 호출 시 토큰 포함
fetch("/api/seats", {
headers: { "X-Waiting-Token": token },
});
}, []);
return <SeatMap />;
}토큰이 없으면 대기룸으로, 있으면 좌석을 표시하도록 했습니다. 간단하지만 효과적인 접근 제어라고 생각했습니다.
메모리 릭과 성능 최적화
WebSocket을 구현하면서 가장 조심해야 했던 부분이 메모리 관리였습니다.
메모리 릭 위험 요인
1. 이벤트 리스너 누적
컴포넌트가 리렌더링될 때마다 새로운 이벤트 리스너가 추가되면, 이전 리스너들이 메모리에 계속 쌓이게 됩니다.
// ❌ 실제 메모리 릭 발생 케이스
useEffect(() => {
const ws = new WebSocket(url);
// 클로저가 이전 messages를 계속 참조
ws.onmessage = (event) => {
const allMessages = [...messages, event.data]; // 이전 messages 참조
setMessages(allMessages);
};
// cleanup 없음!
}, [messages]); // 의존성 배열에 messages 포함!2. 연결 누수
컴포넌트가 언마운트될 때 WebSocket 연결을 닫지 않으면, 연결 객체가 메모리에 남아있게 됩니다.
3. 대규모 메시지 관리
메시지를 계속 상태에 쌓기만 하면, 시간이 지날수록 메모리 사용량이 증가합니다.
해결 방안
1. 클린업 함수로 리스너 제거
useEffect의 클린업 함수에서 이벤트 리스너를 제거하고 WebSocket 연결을 종료하도록 했습니다.
useEffect(() => {
const ws = new WebSocket(url);
const handleMessage = (event: MessageEvent) => {
setMessages((prev) => [...prev, event.data]);
};
ws.addEventListener("message", handleMessage);
return () => {
ws.removeEventListener("message", handleMessage);
ws.close();
};
}, [url]);이렇게 하면 컴포넌트가 언마운트되거나 url이 변경될 때 기존 리스너와 연결을 정리할 수 있습니다.
2. 메시지 크기 제한
수신 메시지 크기를 제한하여 메모리 낭비를 방지했습니다.
const MAX_MESSAGE_SIZE = 10240; // 10KB 제한
const handleIncomingMessage = (message: string) => {
if (message.length > MAX_MESSAGE_SIZE) {
console.warn("메시지가 너무 큽니다.");
return;
}
const parsedData = JSON.parse(message);
updateState(parsedData);
};3. 상태 업데이트 최적화
불필요한 상태 업데이트를 줄이기 위해 React.memo와 메시지 개수 제한을 적용했습니다.
import { create } from "zustand";
const useQueueStore = create((set) => ({
messages: [],
addMessage: (msg) =>
set((state) => {
const newMessages = [...state.messages, msg];
// 최대 메시지 50개만 유지
if (newMessages.length > 50) {
newMessages.shift();
}
return { messages: newMessages };
}),
}));
const MessageList = React.memo(() => {
const messages = useQueueStore((state) => state.messages);
return (
<ul>
{messages.map((msg, idx) => (
<li key={idx}>{msg}</li>
))}
</ul>
);
});이렇게 하면 메시지가 50개를 초과하지 않고, 컴포넌트도 불필요하게 리렌더링되지 않습니다.
예상치 못한 문제
배포 후, 예상치 못한 문제가 생겼습니다. 사용자들이 대기 중 습관적으로 새로고침을 누르는 것이었습니다. "혹시 더 빨리 들어가지 않을까?" 하는 심리였습니다.
문제는 새로고침하면 WebSocket 연결이 끊기고, 새 연결을 시도하면서 대기 순서를 잃을 수 있다는 것이었습니다. 백엔드 팀과 긴급 회의를 했고, 해결책을 찾았습니다.
Connection ID를 브라우저에 저장하기
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.connectionId) {
localStorage.setItem("connectionId", data.connectionId);
}
};
// 재연결 시
const savedId = localStorage.getItem("connectionId");
if (savedId) {
ws.send(
JSON.stringify({
type: "RECONNECT",
connectionId: savedId,
})
);
}서버는 Connection ID로 사용자를 식별하고, 같은 ID로 재연결하면 기존 대기 순서를 유지해주도록 했습니다. 이후 사용자 불만이 크게 줄었습니다.
회고
더 나은 사용자 경험
1. 대기 중 심심함 해소
현재 대기룸은 단순히 숫자만 보여줍니다. 하지만 대기 시간을 더 의미 있게 만들 수 있었을 것 같습니다.
<WaitingRoom>
<QueueStatus count={waitingCount} time={estimateTime} />
{/* 대기 중 유용한 정보 제공 */}
<ContentWhileWaiting>
<ConcertInfo /> {/* 공연 상세 정보 */}
<SeatGuide /> {/* 좌석 선택 팁 */}
<Reviews /> {/* 다른 사용자 후기 */}
</ContentWhileWaiting>
</WaitingRoom>기다리는 시간을 "낭비"가 아니라 "준비 시간"으로 만들 수 있었을 것 같습니다. 사용자가 좌석을 고르는 데 도움이 되는 정보를 미리 보여주거나, 공연에 대한 기대감을 높이는 콘텐츠를 제공할 수 있었을 것 같습니다.
2. 더 정확한 예상 시간 표시 현재는 서버에서 받은 예상 시간을 그대로 표시합니다. 하지만 프론트엔드에서도 더 나은 표현을 할 수 있었을 것 같습니다.
// 기존: 딱딱한 숫자
<p>예상 시간: 180초</p>;
// 개선: 사용자 친화적 표현
const formatWaitTime = (seconds: number) => {
if (seconds < 60) return "곧 입장 가능합니다";
if (seconds < 180) return "약 2-3분 후 입장 예정";
const minutes = Math.ceil(seconds / 60);
return `약 ${minutes}분 후 입장 예정`;
};
<p>{formatWaitTime(estimateTime)}</p>;백엔드 팀과의 협업
당시에는 "대기룸 만들자"는 제안만 했지만, 지금이라면 더 구체적인 질문들을 했을 것 같습니다.
"대기 정보 업데이트 주기는 어떻게 할까요?"
- 매 초마다 업데이트하면 사용자는 실시간으로 느끼지만, 서버 부하는?
- 5초마다 업데이트하면 부하는 줄지만, 사용자가 답답해하지 않을까?
"입장 토큰의 유효 시간은?"
- 너무 짧으면 네트워크 지연으로 입장 못하는 사용자 발생
- 너무 길면 보안 위험
"WebSocket 연결이 끊겼을 때 재연결 정책은?"
- 몇 초 안에 재연결하면 순서 유지?
- 몇 번까지 재시도 허용?
이런 구체적인 질문들을 통해 프론트엔드와 백엔드가 함께 더 나은 정책을 만들 수 있었을 것 같습니다.
배운 것
완벽한 해결책은 없다
대기룸은 사용자를 기다리게 만든다는 명확한 단점이 있습니다. 하지만 다른 대안들(서버 증설, 캐싱, Rate Limiting)은 근본적인 문제를 해결하지 못하거나, 사용자에게 더 나쁜 경험을 줄 수 있었습니다.
중요한 건 "완벽한 선택"이 아니라 어떤 단점을 감수할 것인가를 명확히 하는 것이라고 생각합니다. 대기룸의 단점(기다림)은 명확하지만, 그 대신 투명성과 공정성을 얻을 수 있었습니다.
책임 분리는 협업의 시작
프론트엔드와 백엔드의 책임을 명확히 나눈 것이 가장 잘한 부분이라고 생각했습니다.
- 백엔드: 대기열 관리, 토큰 발급
- 프론트엔드: UI 표시, WebSocket 연결 유지
각자 영역에 집중할 수 있었고, 문제가 생겼을 때도 "이건 서버 문제인가, 클라이언트 문제인가?"를 빠르게 판단할 수 있었습니다. 애매한 경계가 없으니 회의 시간도 줄어들었습니다.
사용자를 먼저 생각하기
처음 WebSocket을 도입할 때, 실시간 양방향 통신이라는 기술 자체가 흥미로웠고, 이걸 프로젝트에 적용해본다는 것도 기대됐습니다. 하지만 지금 돌아보면, WebSocket을 선택한 진짜 이유는 사용자에게 지금 무슨 일이 일어나고 있는지 실시간으로 알려주고 싶었기 때문이라고 생각합니다. 대기 인원이 줄어들 때마다 화면에 바로 반영되고, "내 차례가 얼마나 남았는지" 명확히 보여주는 것이 중요했습니다. 만약 HTTP Polling으로도 충분히 좋은 경험을 줄 수 있었다면, 더 간단한 HTTP Polling을 선택했을 것 같습니다.
하지만 10만 명이 동시에 1초마다 요청을 보내는 상황을 상상하니, 이건 아니라는 생각이 들었습니다. 오히려 서버에 더 큰 부담을 주게 되니까요. 결국 기술 선택의 기준은 이 기술이 사용자에게 어떤 가치를 주는가?라고 생각합니다. 멋있어 보이는 기술이 아니라, 지금 상황에서 사용자에게 가장 나은 경험을 줄 수 있는 기술을 선택하는 것. 이번 프로젝트를 통해 이 점을 다시 한번 배웠습니다.
마치며
10만 명이라는 숫자를 처음 들었을 때, 솔직히 겁이 났습니다. 하지만 지금 생각해보면 그 숫자 하나하나가 "이 공연을 꼭 보고 싶어"하는 사람들의 간절함이었던 것 같습니다. 대기룸을 만들면서 가장 크게 배운 것은, 기술적으로 완벽한 시스템을 만드는 것보다 사용자가 지금 무슨 일이 일어나고 있는지 이해할 수 있게 해주는 것이 더 중요하다는 점이었습니다. 로딩 스피너만 돌아가는 것보다, "1,243명 대기 중"이라고 보여주는 게 훨씬 나은 경험이라는 사실을 알게 되었습니다.
이번 개발을 통해 완벽한 해결책은 없다는 것도 배웠습니다. 대기룸도 사용자를 기다리게 만든다는 단점이 있었지만, 투명성과 공정성이라는 더 큰 가치를 줄 수 있었습니다. 그리고 백엔드 팀과 책임을 명확히 나누면서 협업이 얼마나 효율적으로 이루어질 수 있는지도 경험했습니다.