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

Part 1에서 WebSocket, STOMP, 브로드캐스트 같은 개념을 정리했다.
Part 2에서는 실제로 어떻게 구현했는지 코드를 하나씩 분석한다. 이 기능을 만들게 된 배경부터 시작해서 전체 동작 흐름을 다루겠다.
1. 이 기능을 왜 만들었나?
이 프로젝트는 60대 이상 어르신을 위한 패션 쇼핑몰이다.
상품 상세 페이지에 "현재 N명이 보고 있어요" 라는 문구를 실시간으로 표시하는 기능을 만들었다. 단순해 보이지만 이 기능에는 두 가지 핵심 요구사항이 있다.
- 사용자가 페이지에 들어올 때 즉시 숫자가 올라가야 한다
- 사용자가 페이지를 떠날 때 즉시 숫자가 내려가야 한다
새로고침 없이 다른 사람의 화면도 자동으로 업데이트돼야 하기 때문에 WebSocket + STOMP + Redis 조합으로 구현했다.
2. 프로젝트 구조
아키텍처
┌─────────────────────────────────────────────────────────────┐ │ 프론트엔드 (React) │ │ 👁️ 현재 5명이 보고 있어요 ← WebSocket 실시간 업데이트 │ └─────────────────────────────────────────────────────────────┘ ↕️ WebSocket (STOMP) ┌─────────────────────────────────────────────────────────────┐ │ 백엔드 (Spring Boot) │ │ │ │ WebSocketConfig SecurityConfig │ │ (STOMP 엔드포인트 설정) (/ws/** permitAll) │ │ │ │ ProductViewerController ← 입장/퇴장 메시지 수신 │ │ ↕️ │ │ ProductViewerService ← Redis 관리 + 브로드캐스트 │ │ ↕️ │ │ WebSocketEventListener ← 비정상 종료 감지 │ └─────────────────────────────────────────────────────────────┘ ↕️ ┌─────────────────────────────────────────────────────────────┐ │ Redis │ product:viewers:123 = { "session-A", "session-B" }│ └─────────────────────────────────────────────────────────────┘
3. 코드 상세 분석
3.1 ViewerCountMessage.java
서버가 클라이언트에게 보내는 메시지 형식을 정의하는 DTO다.
package com.ongil.backend.domain.product.websocket.dto; public record ViewerCountMessage( Long productId, // 어떤 상품인가? Long viewerCount // 몇 명이 보고 있는가? ) { public static ViewerCountMessage of(Long productId, Long viewerCount) { return new ViewerCountMessage(productId, viewerCount); } }Java 17의 record로 만든 불변 DTO다. 생성자, getter, equals, hashCode, toString을 자동으로 만들어준다.
Spring이 이 객체를 JSON으로 자동 변환해서 클라이언트에게 전송한다.
ViewerCountMessage message = ViewerCountMessage.of(123L, 5L); // → {"productId": 123, "viewerCount": 5}
3.2 WebSocketConfig.java
WebSocket 연결과 STOMP를 설정하는 핵심 설정 클래스다.
@Configuration @EnableWebSocketMessageBroker // STOMP 활성화 public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") // 연결 URL: ws://서버주소/ws .setAllowedOriginPatterns("*") // CORS 허용 (운영에서는 특정 도메인만) .withSockJS(); // WebSocket 미지원 브라우저 대응 } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic"); // 브로드캐스트용 registry.setApplicationDestinationPrefixes("/app"); // 요청용 } }@EnableWebSocketMessageBroker 를 붙이면 Spring이 자동으로 아래 컴포넌트들을 생성한다.
- SimpleBrokerMessageHandler → /topic 구독자 관리 및 브로드캐스트 처리
- SimpMessagingTemplate → 코드에서 직접 메시지 전송할 때 사용
- SimpAnnotationMethodMessageHandler → @MessageMapping 메서드 라우팅
withSockJS() 는 WebSocket을 지원하지 않는 오래된 브라우저를 위한 폴백이다. WebSocket 연결이 실패하면 HTTP 롱폴링으로 자동 대체된다.
3.3 SecurityConfig.java
WebSocket 엔드포인트에 대한 보안 설정이다.
http .authorizeHttpRequests(auth -> auth .requestMatchers("/ws/**").permitAll() // WebSocket 엔드포인트 허용 // ... 나머지 설정 );WebSocket 초기 연결은 HTTP 업그레이드 요청으로 시작한다. 인증 없이 연결할 수 있어야 하기 때문에 /ws/**를 permitAll()로 열어둔다. 조회 인원은 로그인하지 않은 사람도 볼 수 있는 기능이라 인증이 필요 없다.
3.4 ProductViewerController.java
클라이언트가 보내는 입장/퇴장 메시지를 처리하는 컨트롤러다.
@Slf4j @Controller // @RestController가 아님! @RequiredArgsConstructor public class ProductViewerController { private final ProductViewerService productViewerService; @MessageMapping("/products/{productId}/enter") public void enterProduct( @DestinationVariable Long productId, SimpMessageHeaderAccessor headerAccessor ) { String sessionId = headerAccessor.getSessionId(); // 비정상 종료 대비: 세션에 productId 저장 headerAccessor.getSessionAttributes().put("productId", productId); productViewerService.addViewer(productId, sessionId); log.info("상품 입장: productId={}, sessionId={}", productId, sessionId); } @MessageMapping("/products/{productId}/leave") public void leaveProduct( @DestinationVariable Long productId, SimpMessageHeaderAccessor headerAccessor ) { String sessionId = headerAccessor.getSessionId(); // 정상 퇴장 시 세션에서 productId 제거 headerAccessor.getSessionAttributes().remove("productId"); productViewerService.removeViewer(productId, sessionId); log.info("상품 퇴장: productId={}, sessionId={}", productId, sessionId); } }@Controller vs @RestController
@RestController는 @Controller + @ResponseBody다. HTTP 응답 바디에 데이터를 담아서 돌려줄 때 쓴다. WebSocket 컨트롤러는 HTTP 응답이 아니라 STOMP 메시지를 처리하는 것이기 때문에 @ResponseBody가 필요 없다. 그래서 @Controller만 붙인다.
@MessageMapping
HTTP의 @GetMapping, @PostMapping과 같은 역할이다. 클라이언트가 /app/products/123/enter를 보내면 /app이 제거되고 /products/123/enter가 이 어노테이션과 매칭된다.
@DestinationVariable
HTTP의 @PathVariable과 같다. /products/123/enter에서 123을 productId로 추출한다.
SimpMessageHeaderAccessor
WebSocket 메시지의 헤더와 세션 정보에 접근하는 도구다. getSessionId()로 이 연결의 고유 ID를 가져올 수 있고, getSessionAttributes()로 세션에 데이터를 저장하고 꺼낼 수 있다.
// 세션에 저장 headerAccessor.getSessionAttributes().put("productId", productId); // 세션에서 조회 Long productId = (Long) headerAccessor.getSessionAttributes().get("productId");세션에 productId를 저장해두는 이유는 비정상 종료 대비다. 정상 퇴장이 아닌 경우엔 leaveProduct()가 호출되지 않는다. 그래도 세션에 기록이 남아있으면 WebSocketEventListener가 나중에 꺼내서 정리할 수 있다.
3.5 ProductViewerService.java
핵심 비즈니스 로직을 담당한다. Redis로 세션을 관리하고 브로드캐스트를 실행한다.
@Slf4j @Service @RequiredArgsConstructor public class ProductViewerService { private final StringRedisTemplate redisTemplate; private final SimpMessagingTemplate messagingTemplate; private static final String VIEWER_KEY_PREFIX = "product:viewers:"; private static final long VIEWER_TTL_MINUTES = 30; public void addViewer(Long productId, String sessionId) { String key = VIEWER_KEY_PREFIX + productId; // "product:viewers:123" // [1] Redis Set에 세션 추가 redisTemplate.opsForSet().add(key, sessionId); // [2] TTL 갱신 (30분) redisTemplate.expire(key, VIEWER_TTL_MINUTES, TimeUnit.MINUTES); // [3] 브로드캐스트 broadcastViewerCount(productId); } public void removeViewer(Long productId, String sessionId) { String key = VIEWER_KEY_PREFIX + productId; redisTemplate.opsForSet().remove(key, sessionId); broadcastViewerCount(productId); } public Long getViewerCount(Long productId) { String key = VIEWER_KEY_PREFIX + productId; Long count = redisTemplate.opsForSet().size(key); return count != null ? count : 0L; } private void broadcastViewerCount(Long productId) { Long viewerCount = getViewerCount(productId); ViewerCountMessage message = ViewerCountMessage.of(productId, viewerCount); String destination = "/topic/products/" + productId + "/viewers"; messagingTemplate.convertAndSend(destination, message); log.debug("브로드캐스트: productId={}, count={}", productId, viewerCount); } }StringRedisTemplate
Redis를 조작하는 도구다. opsForSet()으로 Redis Set 자료구조를 사용한다.
Redis 명령어로 보면 이렇게 동작한다.
# addViewer SADD product:viewers:123 "session-abc" EXPIRE product:viewers:123 1800 # removeViewer SREM product:viewers:123 "session-abc" # getViewerCount SCARD product:viewers:123 → 2SimpMessagingTemplate
WebSocket 메시지를 전송하는 도구다. convertAndSend()를 호출하면 내부적으로 이런 과정이 일어난다.
1. ViewerCountMessage 객체 → JSON 변환 {"productId": 123, "viewerCount": 2} 2. STOMP MESSAGE 프레임 생성 MESSAGE destination:/topic/products/123/viewers {"productId":123,"viewerCount":2} 3. /topic/products/123/viewers 구독자 조회 → [session-A, session-B] 4. 모든 구독자에게 전송addViewer()에서 TTL을 매번 갱신하는 이유
TTL은 키가 생성될 때 한 번만 설정되는 게 아니다. 누군가 입장할 때마다 30분으로 리셋된다. 사람이 계속 들어오는 상품이라면 데이터가 살아있고, 아무도 안 보는 상품은 마지막 활동 후 30분이 지나면 자동 삭제된다.
3.6 WebSocketEventListener.java
WebSocket 연결/해제 이벤트를 감지해서 비정상 종료를 처리한다.
@Slf4j @Component @RequiredArgsConstructor public class WebSocketEventListener { private final ProductViewerService productViewerService; @EventListener public void handleWebSocketConnectListener(SessionConnectEvent event) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); String sessionId = headerAccessor.getSessionId(); log.debug("WebSocket 연결: sessionId={}", sessionId); } @EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); String sessionId = headerAccessor.getSessionId(); // 세션에서 productId 꺼내기 Long productId = (Long) headerAccessor.getSessionAttributes().get("productId"); if (productId != null) { productViewerService.removeViewer(productId, sessionId); log.debug("비정상 종료 처리: productId={}, sessionId={}", productId, sessionId); } log.debug("WebSocket 해제: sessionId={}", sessionId); } }SessionDisconnectEvent는 WebSocket 연결이 끊기면 정상/비정상 구분 없이 항상 자동으로 발생한다.
정상 퇴장일 때는 이미 leaveProduct()에서 Redis도 정리하고 세션에서 productId도 지웠다. 그래서 SessionDisconnectEvent가 발생해도 productId가 null이라 if문이 실행되지 않는다. 이중 처리가 되지 않는다.
비정상 종료일 때는 leaveProduct()가 호출 안 됐으니 세션에 productId가 남아있다. SessionDisconnectEvent에서 이걸 꺼내서 Redis를 정리한다.
[정상 종료 흐름] leaveProduct() → Redis 제거, 세션에서 productId 제거 disconnect() SessionDisconnectEvent → productId = null → if문 실행 안 됨 [비정상 종료 흐름] 브라우저 강제 종료 (leaveProduct() 호출 안 됨) SessionDisconnectEvent → productId = 123 → Redis 제거 ✅ → 브로드캐스트 ✅
4. 전체 동작 흐름
사용자 A 입장
사용자 A가 상품 123번 페이지 접속 ↓ WebSocket 연결: ws://서버/ws 세션 생성: "session-abc" ↓ SUBSCRIBE destination:/topic/products/123/viewers → 서버: 구독자 명단에 추가 (응답 없음) ↓ SEND destination:/app/products/123/enter → 서버: enterProduct(123) 실행 → 세션에 productId 저장 → Redis: SADD product:viewers:123 "session-abc" → SCARD → 1 → 브로드캐스트: {"productId":123,"viewerCount":1} ↓ 사용자 A 화면: "1명이 보고 있어요"사용자 B 입장 (10초 후)
사용자 B 접속 → 세션: "session-def" → 구독 등록 → 입장 메시지 전송 → Redis: { "session-abc", "session-def" } → count = 2 → 브로드캐스트 구독자 전원에게 전송: session-abc (사용자 A) ← {"viewerCount": 2} session-def (사용자 B) ← {"viewerCount": 2} 사용자 A 화면: "1명" → "2명" 자동 업데이트 사용자 B 화면: "2명" 즉시 표시전체 타임라인
시간 사용자 A 서버 사용자 B ────────────────────────────────────────────────────────────────── 10:00 페이지 접속 WebSocket 연결 ──────> session-A 생성 subscribe ──────> 구독 등록 enter 전송 ──────> Redis: add(A), count=1 broadcast {count: 1} <────── 화면: "1명" 10:05 페이지 접속 <────── WebSocket 연결 <────── subscribe <────── enter 전송 Redis: add(B), count=2 broadcast {count: 2} <────── <──── {count: 2} 화면: "2명" 화면: "2명" 10:10 강제 종료 SessionDisconnectEvent Redis: remove(B), count=1 broadcast {count: 1} <────── 화면: "1명"
마무리
구현을 정리하면 이렇다.
1. 구독 (subscribe) → 받을 준비 2. 입장 알림 (send) → 서버에 나 들어왔다고 알림 3. Redis Set에 추가 → 중복 없이 세션 관리 4. 인원 수 조회 → SCARD로 O(1) 성능 5. 브로드캐스트 → 구독자 전원에게 동시 전송 6. 화면 업데이트 → "N명이 보고 있어요" 7. 비정상 종료 처리 → SessionDisconnectEvent로 뒤처리 8. TTL 30분 → 최후의 보루로 메모리 누수 방지WebSocket + STOMP는 처음 접하면 개념이 많아서 복잡하게 느껴지지만, 핵심은 간단하다. 연결을 유지하면서 서버가 먼저 데이터를 밀어줄 수 있다. 그리고 STOMP가 메시지의 목적지를 명확하게 해줘서 Spring이 자동으로 라우팅해준다.
'프로젝트, 트러블슈팅' 카테고리의 다른 글
Spring Boot + WebSocket으로 실시간 조회 인원 구현하기 (Part 1 - 개념 편) (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