Post

[DevBid - 실시간통신] WebSocket으로 실시간 경매 구현하기

[DevBid - 실시간통신] WebSocket으로 실시간 경매 구현하기

📚 실시간 경매 시스템 구축기 시리즈

  1. WebSocket으로 실시간 경매 구현하기 ← 현재 글
  2. WebSocket 메시지 동기화 문제와 Redis 선택
  3. Redis 분산락과 Pub/Sub으로 멀티서버 동시성 제어
  4. Redis 도입 후 마주한 문제와 해결
  5. 멀티서버 환경에서 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. 연결 안정성 강화
    • 자동 재연결 로직 구현
    • 재연결 시 누락된 메시지 복구
  2. 채팅 기능
    • 판매자 ↔ 입찰자 간 1:1 실시간 채팅
    • 경매방 단체 채팅으로 커뮤니티 기능 활성화
  3. 실시간 알림
    • 입찰 취소, 경매 종료 임박 등 주요 이벤트 푸시
  4. Redis Pub/Sub 전환
    • 멀티 서버 환경 대응을 위한 메시지 브로커 도입

남은 과제: 멀티 서버 환경은?

현재 구현은 단일 서버 환경에서 완벽하게 동작하지만 서비스가 성장하며 서버를 스케일 아웃 하면 문제가 발생합니다. 이를 해결하기 위한 고민과 Redis Pub/Sub 도입 과정은 다음 포스팅에서 다루겠습니다.

This post is licensed under CC BY 4.0 by the author.