[DevBid - 실시간통신] WebSocket으로 실시간 경매 구현하기
📚 실시간 경매 시스템 구축기 시리즈
- WebSocket으로 실시간 경매 구현하기 ← 현재 글
- WebSocket 메시지 동기화 문제와 Redis 선택
- Redis 분산락과 Pub/Sub으로 멀티서버 동시성 제어
- Redis 도입 후 마주한 문제와 해결
- 멀티서버 환경에서 Pub/Sub와 분산락 검증
왜 WebSocket이 필요했나?
경매라는 도메인은 실시간성이 핵심입니다.
사용자 A가 10,000원에 입찰하면, 사용자 B는 페이지 새로고침 없이 즉시 이 사실을 알아야 합니다. 하지만 기존 HTTP 방식에서는 사용자가 직접 새로고침을 해야만 최신 입찰 정보를 확인할 수 있었고, 이는 실시간 경매의 본질을 해친다고 생각합니다.
따라서 실시간 통신 기술을 도입하여 사용자가 새로고침 없이도 입찰 현황을 즉시 받아볼 수 있도록 개선하기로 결정했습니다.
기존 HTTP 방식
1
2
3
4
5
6
7
8
// 기존 입찰 로직 - HTTP only
@PostMapping("/auctions/bid/{auctionId}")
public String bid(...) {
// 입찰 처리
auctionApplicationService.placeBid(auctionId, authUser.getId(), bidAmount);
// 리다이렉트 (페이지 새로고침)
return "redirect:/auction/" + auctionId + "/detail";
}
문제 상황 시나리오
1
2
3
4
5
6
7
사용자A : 화면에 현재가 10000 표시
사용자B : 화면에 현재가 10000 표시
사용자B : 11000 입찰 → DB 저장 성공
사용자A는 모름
사용자 A: 11,000원 입찰 → 낙관적 락 실패 (같은 금액)
사용자 A: 페이지 새로고침 → "현재가: 11,000원" 확인
사용자 A: 12,000원 입찰
HTTP 방식의 한계
클라이언트-: 리퀘스트 →서버 서버 : 리스폰스 -> 클라이언트
- 클라이언트가 계속 요청해야 함 (불필요한 네트워크 트래픽)
- 실시간성 떨어짐 및 서버 부하
- 클라이언트만 요청 가능
- 요청, 응답 헤더를 압축하지않고 보냄 → 오버헤드 발생
결과
- 불필요한 입찰 시도 증가
- DB 부하 증가 (반복적인 SELECT)
- UX (실시간 경매 느낌 없음)
실시간 통신 방식 비교
Polling
- 주기적으로 서버에 요청 ⇒ 바뀐거 있어? 있어? 있어?
- 구현이 간단(GET 요청 반복) 하지만 불필요한 트래픽, 서버 부하 문제
- 실시간성이 크게 중요하지 않은 경우
- 데이터 변경 빈도가 매우 낮은 경우
예) 10초마다 환율 갱신
Long Polling
Polling 단점(지속 요청)을 개선한 방식, 요청을 보내고 서버가 변경이 발생할 때까지 응답을 보류.
1
2
3
4
5
6
동작방식
1. 클라이언트 -> 서버 : 변경있으면 알려줘
2. 서버: 변경 없으면 대기
3. 변경 발생 시 즉시 응답
4. 클라이언트는 응답 받자마자 다시 Long Polling 요청
- Polling 보다 효율적
- 변경 시에만 응답 (실시간에 가까움)
- 단점: 서버 스레드 점유
- 트래픽이 많지 않은 작업에 적합
Sever-Sent Event (SSE)
- 서버 → 클라이언트 단방향 스트리밍
- 통로를 유지하며 이벤트가 발생하면 그 통로로 이벤트 푸시
- Spring 어노테이션 지원으로 구현이 간단함
예) 알림, 로그 스트리밍
WebSocket
HTTP 연결을 업그레이드 하여 양방향 지속 연결을 만드는 방식
장점
- 진짜 실시간 양방향
- 연결 유지 (HTTP 헤더 오버헤드 없음)
- STOMP 같은 프로토콜로 채팅/브로드캐스트 쉽게 구현
- 서버가 클라이언트에게 즉시 push 가능
- 서버 부담 낮음 (연결만 유지하기 때문에)
단점
- 서버 상태 관리 필요
- 멀티 서버 확장 시 Redis Pub/Sub 등 브로커 필요
- Ping/Pong 관리 필요
예) 주식, 경매, 채팅, 게임 등 여러 사용자에게 동시에 브로드캐스트 필요한 경우
WebSocket을 선택한 이유
경매는 단순히 ‘값이 변경되는 것’만 실시간이면 되는 게 아니라, 입찰 행위 자체도 실시간으로 전달되어야 합니다. SSE는 서버 → 클라이언트 단방향 이므로 한계가 있습니다.
단순 입찰가, 입찰자 실시간 변경을 생각하면 SSE로 구현하는게 맞지만 1:1 채팅이나 알림 기능 등 확장성을 고려해 WebSocket을 구현하기로 결정했습니다.
1
2
3
4
5
6
7
8
[현재]
- 입찰 상황 실시간 수신 (서버 → 클라이언트)
[확장 계획]
- 판매자 ↔ 입찰자 1:1 채팅
- 경매방 단체 채팅 (입찰자들끼리)
- 실시간 알림 (입찰 취소, 경매 종료 임박 등)
WebSocket 구현 과정
기술 스택: WebSocket + STOMP + SockJS
- WebSocket - 양방향 실시간 통신
- STOMP - 메시징 프로토콜 (Spring 지원 우수)
- SockJS - 브라우저 호환성 보장
SockJS는 상황에 따라 3가지 전송 타입을 사용
- WebSocket - 가장 우선적으로 시도
- HTTP Streaming - WebSocket 실패 시 대안
- HTTP Long Polling - 최종 폴백 방식
WebSocket 동작 원리
1
2
3
- 클라이언트 —HTTP GET ws://localhost:3000/ws —> 서버 (업그레이드 요청)
- 클라이언트 ← 101 Switching — 서버 (프로토콜 변경 승인)
- 클라이언트 ←—- WebSocket —→ 서버 (양방향 통신 시작)
의존성 추가
1
implementation("org.springframework.boot:spring-boot-starter-websocket")
메시지 브로커 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@EnableWebSocketMessageBroker //WebSocket + STOMP 프로토콜 사용
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws") // ws://localhost:3000/ws HTTP 업그레이드 요청
.setAllowedOriginPatterns("*") //CORS 허용
.withSockJS(); //브라우저 호환성을 위해
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
//메세지
//백그라운드에서 SimpleBroker를 생성하고:
//@SendTo("/topic/...") ← 이게 작동하도록 지원
//SimpMessagingTemplate ← 빈이 생성되어 주입 가능하게 함
config.enableSimpleBroker("/topic", "/queue"); //SimpleBroker는 인메모리로 구독자 관리
//topic: 1:N 브로드캐스트 (모든 구독자에게)
//queue: 1:1 개인 메세지
config.setApplicationDestinationPrefixes("/app");
//클라이언트가 서버로 메세지 보낼 떄 앞에 /app 붙음
}
}
메시지 라우팅 구조
1
2
3
4
5
6
7
[클라이언트] --메시지 전송--> /app/auctions/123/bid
↓
[컨트롤러 처리]
↓
[클라이언트] <--브로드캐스트-- /topic/auctions/123
(구독 중인 모든 사용자에게 전달)
테스트 연결 확인
1
2
3
4
5
6
7
8
//웹소켓 메세지 핸들러 (테스트)
@MessageMapping("/test/{message}") // WebSocketConfig의 "/app" prefix 사용
@SendTo("/topic/test") //리턴 값을 특정 경로 구독자들에게 자동 전송 해 모든 /topic/test 구독자가 메시지 받음 (SimpleBroker)
public String handleTestMessage(@DestinationVariable String message ) {
log.info("웹소켓 메세지 수신: {}", message);
return "서버 응답: " + message + " (시각; " + LocalDateTime.now() + ")";
}
- 테스트 페이지를 먼저 만들어 WebSocket을 구현했습니다.
클라이언트 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- SockJS + STOMP 라이브러리 -->
<script src="<https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js>"></script>
<script src="<https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js>"></script>
<script>
let stompClient = null;
// WebSocket 연결
function connect() {
const socket = new SockJS('/ws'); // WebSocketConfig의 엔드포인트
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('연결 성공: ' + frame);
// 상태 업데이트
document.getElementById('status').textContent = '연결됨';
document.getElementById('status').className = 'connected';
document.getElementById('connectBtn').disabled = true;
document.getElementById('disconnectBtn').disabled = false;
// /topic/test 구독 시작
stompClient.subscribe('/topic/test', function(message) {
showMessage(message.body);
});
showMessage('[시스템] WebSocket 연결 성공');
}, function(error) {
console.error('연결 실패:', error);
showMessage('[에러] 연결 실패: ' + error);
});
}
</script>
테스트 결과
크롬 일반탭과 시크릿 탭으로 동시 접속 테스트
- [시크릿탭]→ 메세지 전송 → [서버] 처리 → [일반탭] 메세지 수신
- 시크릿탭에서 메시지를 전송하면 서버에서 정상 처리되었고, 독립된 세션인 일반 탭에서도 실시간으로 메시지를 수신하는 것을 확인했습니다.
STOMP 프로토콜 로그
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Opening Web Socket...
Web Socket Opened... <- WebSocket 연결 성공
>>> CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000 <-클라이언트가 STOMP 프로토콜로 연결 요청
<<< CONNECTED
version:1.1
heart-beat:0,0
user-name:1313 <- 서버가 연결 수락 (사용자명: 1313)
>>> SUBSCRIBE
id:sub-0
destination:/topic/test <- /topic/test 토픽 구독 시작
<<< MESSAGE <- 메시지 수신 성공!
destination:/topic/test
content-type:text/plain;charset=UTF-8
서버 응답: 웹소켓 테스트 입니다. (시각; 2025-11-17T10:15:47.633949800)
실제 입찰 로직에 WebSocket 적용
테스트 성공 후, 실제 경매 입찰 로직에 WebSocket을 연결했습니다.
HTTP + WebSocket
1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/auctions/bid/{auctionId}")
public String bid(...) {
//1. 입찰 처리 (트랜잭션)
BidPlacedEvent event = auctionApplicationService.placeBid(auctionId, authUser.getId(), bidAmount);
//2. WebSocket 이벤트 발행 (트랜잭션 외부)
auctionApplicationService.publishBidEvent(event);
return "redirect:/auctions/" + auctionId + "/success";
}
입찰 로직과 WebSocket 발행을 분리한 이유
- 트랜잭션 안정성:
WebSocket전송 실패가 입찰 롤백을 유발하지 않음 - 이메일, SMS 등 다른 알림 채널 추가 용이 → 확장성
- Application Layer는 “입찰 완료” 사실만 알리고 누가 받는지, 어떻게 처리하는지 몰라도 되니 약한결합으로 이벤트 발행 (결합도 ▼, 확장성 ▲)
이벤트 핸들러
1
2
3
4
5
6
7
8
9
10
11
@EventListener
public void handleBidPlacedEvent(BidPlacedEvent event) {
BidEventDto dto = BidEventDto.from(event);
// 해당 경매 구독자들에게 브로드캐스트
messagingTemplate.convertAndSend(
"/topic/auctions/" + event.getAuctionId(),
dto
);
}
입찰이 발생하는 경우 모든 클라이언트에게 전송할 데이터들을 이벤트 DTO로 변환 후 전송
클라이언트 실시간 업데이트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 웹소켓 연결 및 구독
let stompClient = null;
function connectWebSocket() {
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
console.log('WebSocket 연결 성공');
// 현재 경매의 토픽 구독
const auctionId = [[${auction.id}]];
stompClient.subscribe('/topic/auctions/' + auctionId, function (message) {
const bidEvent = JSON.parse(message.body);
updateAuctionInfo(bidEvent); // 화면 업데이트
});
});
}
// 입찰 정보 실시간 업데이트
function updateAuctionInfo(bidEvent) {
// 현재가 업데이트
document.querySelector('.price').textContent =
bidEvent.currentPrice.toLocaleString('ko-KR') + '원';
// 최고 입찰자 업데이트
document.getElementById('winnerName').textContent = bidEvent.bidderName;
// 입찰 횟수 업데이트
document.getElementById('bidCount').textContent = bidEvent.bidCount;
}
// 페이지 로드 시 연결
window.addEventListener('DOMContentLoaded', connectWebSocket);
테스트
- 일반 탭과 시크릿 탭을 포함한 다중 클라이언트 환경에서 실시간 입찰 데이터가 문제 없이 전파되는 것을 확인했습니다.
개선효과
- 실시간 입찰 현황 반영 (새로고침 없이 즉시 업데이트)
- 낙관적 락 충돌 감소 (사용자가 최신 정보를 항상 확인하여 불필요한 입찰 시도 방지)
- 사용자 경험 향상 (실시간 경매의 긴장감과 몰입도 증가)
향후 확장 계획
WebSocket 기반이 구축되었으니, 다음과 같은 기능들을 순차적으로 추가할 예정입니다.
- 연결 안정성 강화
- 자동 재연결 로직 구현
- 재연결 시 누락된 메시지 복구
- 채팅 기능
- 판매자 ↔ 입찰자 간 1:1 실시간 채팅
- 경매방 단체 채팅으로 커뮤니티 기능 활성화
- 실시간 알림
- 입찰 취소, 경매 종료 임박 등 주요 이벤트 푸시
- Redis Pub/Sub 전환
- 멀티 서버 환경 대응을 위한 메시지 브로커 도입
남은 과제: 멀티 서버 환경은?
현재 구현은 단일 서버 환경에서 완벽하게 동작하지만 서비스가 성장하며 서버를 스케일 아웃 하면 문제가 발생합니다. 이를 해결하기 위한 고민과 Redis Pub/Sub 도입 과정은 다음 포스팅에서 다루겠습니다.

