-
Spring Boot + WebSocket으로 실시간 조회 인원 구현하기 (Part 1 - 개념 편)프로젝트, 트러블슈팅 2026. 3. 5. 11:12

실시간 기능을 구현할 때 가장 먼저 떠오르는 게 WebSocket이다.
그런데 막상 Spring Boot에서 WebSocket을 쓰려고 찾아보면 STOMP, SimpMessagingTemplate, MessageBroker 같은 낯선 개념들이 쏟아진다. 단순히 "실시간으로 숫자 하나 보여주면 되는 거 아닌가?"라고 생각했는데 생각보다 알아야 할 게 많다.
이 글은 WebSocket을 처음 접하는 사람도 이해할 수 있도록 개념부터 차근차근 정리한다. 코드 분석과 실제 동작 흐름은 Part 2에서 다룬다.
1. HTTP의 한계 - 왜 WebSocket이 필요한가?
일반적인 HTTP 통신은 이렇게 동작한다.
[클라이언트] [서버] | | | GET /api/products/123 | |─────────────────────────> | | | (처리) | 응답: 200 OK | |<───────────────────────── | | (연결 종료) |클라이언트가 요청해야만 서버가 응답할 수 있다. 그리고 응답이 오면 연결이 끊긴다.
"실시간 조회 인원"을 HTTP로 구현하려면 어떻게 해야 할까?
1초마다 반복: 클라이언트 → 서버: GET /api/products/123/viewers 서버 → 클라이언트: {"count": 5}이게 폴링(Polling) 방식이다. 사용자가 100명이면 초당 100번 요청이 들어온다. 비효율적이고 서버 부담이 크다.
WebSocket은 이 문제를 근본적으로 해결한다.
[클라이언트] [서버] | | | WebSocket 연결 요청 | |============================>| | (연결 유지!) | | | | 메시지 전송 | |─────────────────────────> | | | | 메시지 전송 (서버가 먼저!) |<───────────────────────── | | | | (연결 계속 유지) |한 번 연결하면 연결을 유지하면서 양방향으로 자유롭게 주고받을 수 있다. 서버가 먼저 데이터를 보내는 것도 가능하다.
2. SSE vs WebSocket - 뭘 선택해야 하나?
실시간 통신 방법이 WebSocket만 있는 건 아니다. 이 프로젝트에서 이미 할인 알림 기능에 SSE를 사용했다. 그런데 왜 조회 인원은 WebSocket으로 만들었을까?
구분 SSE WebSocket 통신 방향 서버 → 클라이언트 단방향 서버 ↔ 클라이언트 양방향 프로토콜 HTTP ws:// (별도 프로토콜) 구현 복잡도 간단 복잡 적합한 용도 알림, 뉴스피드, 가격 변동 알림 채팅, 게임, 실시간 협업 할인 알림은 서버가 일방적으로 "가격 떨어졌어요!"를 보내면 끝이다. 클라이언트가 서버에 뭔가를 보낼 필요가 없다. 그래서 SSE로 충분했다.
반면 조회 인원은 다르다.
클라이언트 → 서버: "나 들어왔어요" (단순 수신만으로는 불가능) 클라이언트 → 서버: "나 나갔어요" 서버 → 클라이언트: "현재 5명이에요"클라이언트가 서버에게 입장/퇴장을 알려줘야 한다. 즉 클라이언트 → 서버 방향의 통신이 필요하기 때문에 WebSocket을 선택했다.
3. WebSocket 동작 원리
WebSocket은 HTTP를 통해 연결을 시작하고, 이후 WebSocket 프로토콜로 업그레이드한다. 이 과정을 핸드셰이크(Handshake) 라고 한다.
1단계: HTTP 업그레이드 요청 클라이언트 → 서버: GET /ws HTTP/1.1 Upgrade: websocket Connection: Upgrade 2단계: 서버 승인 서버 → 클라이언트: HTTP/1.1 101 Switching Protocols Upgrade: websocket 3단계: WebSocket 연결 완료 클라이언트 ↔ 서버 (이제부터 ws:// 프로토콜로 자유롭게 통신)HTTP 101 응답이 "OK, WebSocket으로 전환할게요"라는 의미다. 이후로는 HTTP가 아닌 WebSocket 프레임으로 데이터를 주고받는다.
4. STOMP 프로토콜 - 왜 필요한가?
순수 WebSocket만 쓰면 어떤 문제가 생기나?
WebSocket은 그냥 날것의 문자열을 주고받는 통신 채널이다. 형식이 없다.
// 프론트엔드에서 순수 WebSocket을 쓰면 socket.send("나 들어왔어"); // 이게 뭔 의미인지 서버가 파싱해야 함 socket.onmessage = (event) => { // event.data가 뭔지 직접 해석해야 함 // "1명", "enter:123", JSON? 뭘 받을지 모름 console.log(event.data); };이렇게 되면 문제가 많다.
- 메시지가 어떤 상품에 대한 건지 알 수 없다
- 어떤 행동(입장/퇴장/조회)인지 알 수 없다
- 서버에서 직접 파싱 로직을 만들어야 한다
- 여러 상품 페이지를 동시에 다루면 더 복잡해진다
STOMP가 해결하는 방법
STOMP(Simple Text Oriented Messaging Protocol) 는 WebSocket 위에서 동작하는 메시징 규칙이다.
┌─────────────────────────┐ │ STOMP (메시징 규칙) │ ← 목적지, 형식, 라우팅 규칙 ├─────────────────────────┤ │ WebSocket (연결 유지) │ ← 실시간 양방향 연결 ├─────────────────────────┤ │ TCP/IP (네트워크) │ ← 물리적 데이터 전송 └─────────────────────────┘STOMP를 쓰면 메시지에 목적지(destination) 가 생긴다.
// STOMP를 쓰면 이렇게 명확해진다 client.send('/app/products/123/enter', {}, {}); // 123번 상품에 입장 client.subscribe('/topic/products/123/viewers', (message) => { const data = JSON.parse(message.body); console.log("현재 인원:", data.viewerCount); });메시지가 어디서 온 것인지, 어디로 가야 하는지가 명확하다. Spring이 이 목적지를 보고 자동으로 적절한 메서드를 호출해준다.
STOMP 프레임 구조
STOMP는 메시지를 프레임(Frame) 단위로 주고받는다. 실제로 전송되는 데이터를 보면 이렇게 생겼다.
연결 (CONNECT)
CONNECT accept-version:1.0,1.1,2.0 host:서버주소 ^@구독 (SUBSCRIBE)
SUBSCRIBE id:sub-0 destination:/topic/products/123/viewers ^@전송 (SEND)
SEND destination:/app/products/123/enter content-type:application/json {} ^@수신 (MESSAGE)
MESSAGE destination:/topic/products/123/viewers message-id:007 {"productId":123,"viewerCount":5} ^@각 프레임은 명령어, 헤더, 본문 으로 구성되고 ^@(null 문자)로 끝난다. Spring은 이 프레임을 자동으로 파싱해서 처리해준다.
5. 브로드캐스트(Broadcast)란?
이 기능에서 가장 중요한 개념이다.
브로드캐스트는 한 명의 행동을 구독 중인 여러 명에게 동시에 전달하는 것이다.
일반 HTTP 응답과 비교하면 이렇다.
[일반 HTTP - 1:1] 서버 ─────────────────> 사용자 A (요청한 사람에게만) 사용자 B는 모름 사용자 C는 모름 [브로드캐스트 - 1:N] ─────────────> 사용자 A (구독 중) 서버 ──────────────────> 사용자 B (구독 중) ─────────────> 사용자 C (구독 중) 구독한 모든 사람에게 동시에 전달!실생활로 비유하면 이렇다.
방식 실생활 비유 HTTP 응답 문자 메시지 (1:1) 브로드캐스트 TV 방송, 유튜브 라이브 알림 (1:N) 브로드캐스트 단체 카카오톡 채팅방 공지 이 프로젝트에서는 이렇게 동작한다.
사용자 B가 상품 123번 페이지에 입장 ↓ 서버: Redis에서 인원 수 계산 → 2명 ↓ 브로드캐스트 (같은 상품 구독자 전원에게!) ↓ 사용자 A 화면: "1명" → "2명" 자동 업데이트 ✅ 사용자 B 화면: "2명" 즉시 표시 ✅Spring에서 브로드캐스트하는 코드는 딱 한 줄이다.
messagingTemplate.convertAndSend( "/topic/products/123/viewers", // 이 주소 구독자 전원에게 message // 이 메시지를 전달 );"구독(Subscribe)"이랑 항상 함께 이해해야 한다.
유튜브로 비유하면, 구독 버튼 클릭이 subscribe()이고, 유튜버가 영상 올릴 때 구독자 전체에게 알림 보내는 게 convertAndSend()다.
6. 핵심 개념 정리: Destination
STOMP에서 Destination은 메시지의 목적지다. /app과 /topic 두 가지가 있고 역할이 완전히 다르다.
/app - 클라이언트 → 서버 요청
클라이언트가 send()로 보낼 때 사용 예시: /app/products/123/enter ← 123번 상품에 입장했다고 알림 /app/products/123/leave ← 123번 상품에서 퇴장했다고 알림서버에서는 @MessageMapping이 이 메시지를 받아서 처리한다.
/topic - 서버 → 여러 클라이언트 브로드캐스트
서버가 convertAndSend()로 보낼 때 사용 예시: /topic/products/123/viewers ← 123번 상품의 현재 조회 인원클라이언트는 subscribe()로 이 주소를 구독해놓으면 서버가 데이터를 보낼 때마다 자동으로 받는다.
정리
구분 /app /topic 방향 클라이언트 → 서버 서버 → 클라이언트들 용도 요청/명령 브로드캐스트 수신자 1:1 1:N 클라이언트 동작 send() subscribe() 서버 처리 @MessageMapping convertAndSend() Spring에서의 메시지 라우팅 흐름
클라이언트: /app/products/123/enter 전송 ↓ Spring: "/app" 제거 → "/products/123/enter" ↓ @MessageMapping("/products/{productId}/enter") 매칭 ↓ enterProduct() 실행
7. 세션(Session) 관리와 Redis를 쓰는 이유
세션이란?
사용자가 WebSocket으로 연결하면 Spring이 자동으로 고유한 sessionId를 부여한다. 이게 WebSocket 세션이다.
사용자 A 접속 → sessionId: "abc-123" 사용자 B 접속 → sessionId: "def-456" 사용자 C 접속 → sessionId: "ghi-789"이 sessionId로 "지금 이 상품을 보고 있는 사람이 누구누구인지"를 관리한다.
왜 Redis Set을 선택했나?
세션을 어디에 저장할지 선택지가 있다.
옵션 1: JVM 메모리 (HashMap)
// 간단하지만... Map<Long, Set<String>> viewerMap = new ConcurrentHashMap<>();- 서버가 한 대라면 동작함
- 서버가 두 대 이상으로 늘어나면(스케일 아웃) 각 서버가 서로 다른 정보를 가지게 됨
옵션 2: Redis Set (선택)
# 모든 서버가 공유하는 외부 저장소 SADD product:viewers:123 "abc-123" # 세션 추가 SADD product:viewers:123 "def-456" # 세션 추가 SCARD product:viewers:123 # 2 (인원 수 조회)Redis Set 자료구조를 선택한 이유는 두 가지다.
첫째, 중복을 자동으로 방지한다. 같은 sessionId를 두 번 추가해도 하나만 남는다. 같은 사람이 두 번 카운트되는 걸 막을 수 있다.
둘째, 인원 수 조회가 O(1) 으로 빠르다. SCARD 명령어 하나로 바로 count를 알 수 있다.
TTL 30분 설정
Redis에 저장할 때 TTL(Time To Live)을 30분으로 설정한다.
redisTemplate.expire(key, 30, TimeUnit.MINUTES);비정상 종료 처리가 완벽하지 않을 수 있다. 그래서 최후의 보루로 30분 뒤에는 자동으로 데이터가 삭제되도록 해서 메모리 누수를 방지한다.
8. 비정상 종료 처리
문제: 사용자가 페이지를 정상적으로 떠나지 않으면?
정상적인 경우라면 이렇다.
사용자가 뒤로가기 버튼 클릭 → 프론트엔드: /app/products/123/leave 전송 → 서버: Redis에서 세션 제거 → 브로드캐스트: 인원 수 1 감소그런데 비정상적인 경우가 있다.
브라우저 강제 종료 (X 버튼) 와이파이 갑자기 끊김 컴퓨터 절전 모드 배터리 방전이럴 때는 /app/products/123/leave가 절대 호출되지 않는다. 그러면 그 사람은 영원히 조회 인원에 포함된 채 남아있게 된다.
해결 방법 1: 세션 속성에 productId 저장
입장할 때 WebSocket 세션에 어떤 상품을 보고 있는지 기록해둔다.
// 입장할 때 headerAccessor.getSessionAttributes().put("productId", productId);WebSocket 세션은 연결이 끊길 때까지 살아있다. 그래서 비정상 종료가 돼도 이 정보는 남아있다.
해결 방법 2: SessionDisconnectEvent 감지
WebSocket 연결이 끊기면 정상이든 비정상이든 Spring이 자동으로 SessionDisconnectEvent를 발생시킨다. WebSocketEventListener가 이 이벤트를 감지해서 뒤처리를 한다.
브라우저 강제 종료 ↓ Spring: SessionDisconnectEvent 자동 발생 ↓ WebSocketEventListener.handleDisconnect() ↓ 세션에서 productId 꺼냄 → 123 ↓ Redis에서 해당 세션 제거 ↓ 브로드캐스트: 인원 수 감소정상 종료 vs 비정상 종료 비교
[정상 종료] 1. leaveProduct() 호출 → Redis 제거, 세션에서 productId 제거 2. disconnect() 호출 3. SessionDisconnectEvent 발생 → productId = null → if문 실행 안 됨 (이미 처리됨) [비정상 종료] 1. leaveProduct() 호출 안 됨! → Redis에 세션 남아있음, 세션에 productId 남아있음 2. SessionDisconnectEvent 발생 → productId = 123 → if문 실행 → Redis 제거 ✅ → 브로드캐스트 ✅두 경우 모두 최종적으로 Redis가 정리된다. 정상 종료는 leaveProduct()가, 비정상 종료는 SessionDisconnectEvent가 처리한다.
마무리
Part 1에서 다룬 개념을 정리하면 이렇다.
개념 역할
개념 역할 WebSocket 연결 유지, 양방향 실시간 통신 STOMP 메시지 형식과 목적지 표준화 브로드캐스트 구독자 전원에게 동시 전달 /app 클라이언트 → 서버 요청 경로 /topic 서버 → 구독자들 전달 경로 Redis Set 중복 없는 세션 관리, 서버 간 공유 SessionDisconnectEvent 비정상 종료 감지 및 뒤처리 Part 2에서는 실제 코드를 하나씩 분석하고, 전체 동작 흐름과 트러블슈팅을 다룬다.
'프로젝트, 트러블슈팅' 카테고리의 다른 글
Spring Boot + WebSocket으로 실시간 조회 인원 구현하기 (Part 2 - 구현 편) (0) 2026.03.05 Spring Boot + OpenAI API로 의류 소재 설명 자동 생성하기 (0) 2026.03.05 실시간 할인 알림 구현기 — Spring SSE + Scheduler (0) 2026.03.04 도움돼요 토글, 비관적 락 대신 DB 원자적 UPDATE를 선택한 이유 (0) 2026.03.04 Redis 캐시에 빈 배열이 영구 저장되는 버그 트러블슈팅 (0) 2026.03.04