9osari.log
← 목록으로

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

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

  1. WebSocket으로 실시간 경매 구현하기 ← 현재 글
  2. WebSocket 메시지 동기화 문제와 Redis 선택
  3. Redis 분산락과 Pub/Sub으로 멀티서버 동시성 제어
  4. Redis 도입 후 마주한 문제와 해결
  5. 멀티서버 환경에서 Pub/Sub와 분산락 검증

왜 WebSocket이 필요했나?

경매라는 도메인은 실시간성이 핵심입니다.

사용자 A가 10,000원에 입찰하면, 사용자 B는 페이지 새로고침 없이 즉시 이 사실을 알아야 합니다. 하지만 기존 HTTP 방식에서는 사용자가 직접 새로고침을 해야만 최신 입찰 정보를 확인할 수 있었고, 이는 실시간 경매의 본질을 해친다고 생각합니다.

따라서 실시간 통신 기술을 도입하여 사용자가 새로고침 없이도 입찰 현황을 즉시 받아볼 수 있도록 개선하기로 결정했습니다.

기존 HTTP 방식

// 기존 입찰 로직 - HTTP only
@PostMapping("/auctions/bid/{auctionId}")
public String bid(...) {
    // 입찰 처리
    auctionApplicationService.placeBid(auctionId, authUser.getId(), bidAmount);
    // 리다이렉트 (페이지 새로고침)
    return "redirect:/auction/" + auctionId + "/detail";
}

문제 상황 시나리오

사용자A : 화면에 현재가 10000 표시
사용자B : 화면에 현재가 10000 표시
사용자B : 11000 입찰 → DB 저장 성공
사용자A는 모름
사용자 A: 11,000원 입찰 → 낙관적 락 실패 (같은 금액)
사용자 A: 페이지 새로고침 → "현재가: 11,000원" 확인
사용자 A: 12,000원 입찰

HTTP 방식의 한계

클라이언트-: 리퀘스트 →서버 서버 : 리스폰스 -> 클라이언트

결과


실시간 통신 방식 비교

Polling

예) 10초마다 환율 갱신

Long Polling

Polling 단점(지속 요청)을 개선한 방식, 요청을 보내고 서버가 변경이 발생할 때까지 응답을 보류.

동작방식
1. 클라이언트 -> 서버 : 변경있으면 알려줘
2. 서버: 변경 없으면 대기
3. 변경 발생 시 즉시 응답
4. 클라이언트는 응답 받자마자 다시 Long Polling 요청

Sever-Sent Event (SSE)

예) 알림, 로그 스트리밍

WebSocket

HTTP 연결을 업그레이드 하여 양방향 지속 연결을 만드는 방식

장점

단점

예) 주식, 경매, 채팅, 게임 등 여러 사용자에게 동시에 브로드캐스트 필요한 경우

WebSocket을 선택한 이유

경매는 단순히 ‘값이 변경되는 것’만 실시간이면 되는 게 아니라, 입찰 행위 자체도 실시간으로 전달되어야 합니다. SSE는 서버 → 클라이언트 단방향 이므로 한계가 있습니다.

단순 입찰가, 입찰자 실시간 변경을 생각하면 SSE로 구현하는게 맞지만 1:1 채팅이나 알림 기능 등 확장성을 고려해 WebSocket을 구현하기로 결정했습니다.

[현재]
- 입찰 상황 실시간 수신 (서버 → 클라이언트)

[확장 계획]
- 판매자 ↔ 입찰자 1:1 채팅
- 경매방 단체 채팅 (입찰자들끼리)
- 실시간 알림 (입찰 취소, 경매 종료 임박 등)

WebSocket 구현 과정

기술 스택: WebSocket + STOMP + SockJS

SockJS는 상황에 따라 3가지 전송 타입을 사용

WebSocket 동작 원리

- 클라이언트 —HTTP GET ws://localhost:3000/ws —> 서버 (업그레이드 요청)
- 클라이언트 ← 101 Switching — 서버 (프로토콜 변경 승인)
- 클라이언트 ←—- WebSocket —→ 서버 (양방향 통신 시작)

의존성 추가

implementation("org.springframework.boot:spring-boot-starter-websocket")

메시지 브로커 구성

@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 붙음
    }
}

메시지 라우팅 구조

[클라이언트] --메시지 전송--> /app/auctions/123/bid

                               [컨트롤러 처리]

[클라이언트] <--브로드캐스트-- /topic/auctions/123
  (구독 중인 모든 사용자에게 전달)

테스트 연결 확인

//웹소켓 메세지 핸들러 (테스트)
@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() + ")";
}

클라이언트 코드

<!-- 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 프로토콜 로그

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

@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 발행을 분리한 이유

이벤트 핸들러

@EventListener
public void handleBidPlacedEvent(BidPlacedEvent event) {
		BidEventDto dto = BidEventDto.from(event);

		// 해당 경매 구독자들에게 브로드캐스트
		messagingTemplate.convertAndSend(
			"/topic/auctions/" + event.getAuctionId(),
			dto
	 );
}

입찰이 발생하는 경우 모든 클라이언트에게 전송할 데이터들을 이벤트 DTO로 변환 후 전송

클라이언트 실시간 업데이트


// 웹소켓 연결 및 구독
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 도입 과정은 다음 포스팅에서 다루겠습니다.

···
← PREV [DevBid - JPA] 낙관적 락 적용과 데드락 NEXT → [DevBid - 실시간통신] 멀티 서버 환경에서 Redis Pub/Sub이 필요한 이유