오늘 소개해볼 주제는 웹소켓입니다.
가상 화폐 거래소 프로젝트 진행중 기존의 HTTP 방식으로는 원활한 실시간 시세 반영이 쉽지 않다고 느껴 도입을 하였던 기술입니다.
1. WebSocket이란?
WebSocket은 단일 TCP 연결을 통해 서버와 클라이언트 간의 전이중(Full-Duplex) 통신을 지원하는 프로토콜입니다. 일반적인 웹 서비스가 요청이 있을 때만 응답하는 '수동적'인 구조라면, 웹소켓은 한 번 연결되면 양방향으로 자유롭게 데이터를 주고받는 '능동적'인 전용선을 까는 것과 같습니다.
그렇다면 왜 기존 HTTP 요청 방식이 무리가 있다고 느꼈을까?
2. 기존 HTTP 요청 vs WebSocket
우리가 흔히 쓰는 HTTP 통신과 웹소켓은 주도권과 효율성 면에서 큰 차이가 있습니다.

- HTTP (Request-Response): 클라이언트가 반드시 먼저 요청을 보내야 서버가 답할 수 있는 단방향 구조입니다. 데이터가 변했는지 확인하려면 계속 물어봐야 하는 Polling 방식이 강제되며, 매번 무거운 헤더(Header) 정보를 주고받아야 합니다.
- WebSocket: 처음 연결 시에는 HTTP의 80(또는 443) 포트를 통해 Handshake를 수행하며, 이때 Upgrade: websocket 헤더를 통해 프로토콜을 전환합니다. 이후부터는 TCP 위에서 웹소켓 고유의 프레임 단위로 통신하게 됩니다. 이후에는 가벼운 메시지 프레임으로 통신합니다. 서버가 클라이언트의 요청 없이도 "가격 변했다"라고 먼저 데이터를 밀어줄 수 있는 Server Push가 가능합니다.
3. 우리 프로젝트에 왜 WebSocket이 필요한가?
제가 진행 했던 가상 화폐 거래소 프로젝트의 핵심은 실시간성입니다.
- 지연 시간(Latency) 최소화: 코인 시장은 초 단위로 시세가 급변합니다. 사용자가 새로고침을 누르지 않아도 즉각적으로 차트와 호가가 변해야 합니다.
- 서버 리소스 효율화: 수천 명의 사용자가 0.1초마다 HTTP 요청을 보낸다면 서버는 금방 마비될 것입니다. 웹소켓은 연결을 유지하며 필요한 데이터만 쏙 골라 보내므로 부하가 훨씬 적습니다.
이러한 특징을 가진 웹소켓은 저희 프로젝트에서 선택이 아닌 필수적인 요소였고, 이제부터는 이러한 웹소켓의 설정 방법에 대해서 이야기해보자면,
저희 프로젝트는 Spring Boot에서 진행하였으므로 이에 맞는 환경으로 이를 기반으로 설명하겠습니다.
4. Spring Boot 설정하기
① build.gradle 의존성 추가
먼저 스프링에서 웹소켓 기능을 사용하기 위해 아래 의존성을 추가합니다.
dependencies {
// WebSocket 및 STOMP 지원
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}
② WebSocket Config 설정 (서버 측)
WebSocketMessageBrokerConfigurer를 상속받아 경로와 브로커를 설정합니다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 클라이언트가 서버에 처음 접속할 때 사용할 엔드포인트 주소
registry.addEndpoint("/ws-heartbit")
.setAllowedOrigins("*") // 테스트를 위해 모든 도메인 허용
.withSockJS(); // 웹소켓을 지원하지 않는 브라우저를 위한 대체 옵션
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 클라이언트가 메시지를 보낼 때 사용할 프리픽스 (Server로 향하는 주소)
registry.setApplicationDestinationPrefixes("/app");
// 클라이언트가 특정 토픽을 구독할 때 사용할 프리픽스 (Client로 향하는 주소)
registry.enableSimpleBroker("/topic");
}
}
③ 클라이언트 접속 주소 설정
클라이언트(React나 Vanilla JS 등)에서 서버에 연결할 때 입력하는 주소 형식은 다음과 같습니다.
- 연결 주소: ws://localhost:8080/ws-heartbit (SockJS 사용 시 http 프로토콜 기반)
- 구독 주소(Subscribe): /topic/ticker/{categoryId} (예: /topic/ticker/1)
- 발행 주소(Send): /app/chat(클라이언트가 서버로 무언가 보낼 때)

1. 클라이언트의 메시지 전송 (SEND)
사용자(WebSocket Client)가 서버로 메시지를 보낼 때, 두 가지 주요 경로가 있습니다.
- /app으로 시작하는 메시지: 애플리케이션의 비즈니스 로직을 타야 하는 경우입니다. (예: 채팅)
- /topic으로 시작하는 메시지: 별도의 처리 없이 바로 구독자들에게 브로드캐스팅해야 하는 경우입니다.
2. 메시지 채널과 라우팅 (Message Channels)
클라이언트가 보낸 메시지는 먼저 request channel에 들어옵니다. 여기서 설정된 설정값에 따라 메시지가 분기됩니다.
- SimpAnnotationMethod (MessageHandler):
- /app 경로를 가진 메시지가 도달합니다.
- 우리가 흔히 쓰는 @MessageMapping 어노테이션이 붙은 컨트롤러 메서드가 이 역할을 수행합니다.
- 로직 처리가 끝나면 다시 메시지를 브로커 채널로 보내 공유할 수 있습니다.
- SimpleBroker (MessageHandler):
- /topic 경로를 가진 메시지가 도달합니다.
- In-Memory Message Broker 역할을 하며, 해당 토픽을 구독 중인 다른 클라이언트들에게 메시지를 즉시 전달합니다.
3. 메시지 브로커와 응답 (Broker & Response)
- broker channel: 컨트롤러(SimpAnnotationMethod)에서 처리가 끝난 메시지를 다시 브로커에게 넘겨줄 때 사용하는 통로입니다.
- response channel: 브로커가 최종적으로 구독 중인 클라이언트들에게 메시지를 보내기 위해 사용하는 통로입니다.
4. 클라이언트의 메시지 수신 (MESSAGE)
최종적으로 클라이언트는 response channel을 통해 서버가 보낸 메시지를 수신하게 됩니다.
5. 실시간 시세 변화 데이터 전송
서버에서 매칭 엔진이나 외부 API를 통해 시세 변화를 감지하면, 아래와 같이 웹소켓으로 데이터를 쏴줍니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class TradeMarketBroadcaster {
private final SimpMessagingTemplate messagingTemplate;
private void sendWebSocketData(Long categoryId, TradeResponse response) {
String suffix = "/" + categoryId;
BigDecimal price = response.getTradePrice();
BigDecimal buyQty = totalBuyQtys.getOrDefault(categoryId, BigDecimal.ZERO);
BigDecimal sellQty = totalSellQtys.getOrDefault(categoryId, BigDecimal.ONE);
if (sellQty.compareTo(BigDecimal.ZERO) == 0) sellQty = BigDecimal.ONE;
BigDecimal intensity = buyQty.divide(sellQty, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"));
BigDecimal openPrice = openPrices.getOrDefault(categoryId, price);
BigDecimal dailyHigh = dailyHighs.getOrDefault(categoryId, price);
BigDecimal dailyLow = dailyLows.getOrDefault(categoryId, price);
Map<String, Object> ticker = new HashMap<>();
ticker.put("price", price.toPlainString());
ticker.put("changeAmount", price.subtract(openPrice).toPlainString());
ticker.put("changeRate", changeRates.getOrDefault(categoryId, BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP).toPlainString());
ticker.put("high", dailyHigh.toPlainString());
ticker.put("low", dailyLow.toPlainString());
ticker.put("volume", accVolumes.getOrDefault(categoryId, BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP).toPlainString());
ticker.put("amount", accAmounts.getOrDefault(categoryId, BigDecimal.ZERO).setScale(0, RoundingMode.HALF_UP).toPlainString());
messagingTemplate.convertAndSend("/topic/ticker" + suffix, (Object)ticker);
}
}
단순 가격뿐만 아니라 전일 대비 등락률, 체결 강도 등을 실시간으로 계산하여 전송하며, 실제로 체결이 일어나면 개발자 도구의 웹소켓에서 확인할 수 있듯이 실시간으로 데이터들이 송수신 되는 것을 아래를 통해 확인할 수 있습니다.

웹소켓 도입은 단순한 기능 추가를 넘어, 시스템 아키텍처를 이벤트 기반(Event-Driven)으로 변화시키는 큰 걸음이었습니다. 다음 포스팅에서는 웹소켓을 통해 실시간으로 나가는 이 방대한 데이터들을 더 효율적으로 관리하고, 서버 부하를 줄이기 위해 도입한 Redis 캐싱 전략에 대해 다루어 보겠습니다.
'Project' 카테고리의 다른 글
| 실시간 시세를 초고속으로 처리하는 Redis 캐싱과 Pub/Sub 도입 (0) | 2026.03.16 |
|---|