파이널 프로젝트에 저희 팀으 선정한 주제는 "실시간 경매를 활용한 리셀"프로젝트입니다
실시간 경매 프로젝트를 진행하면서 제가 맡게된 파트는 웹소캣을 활용한 실시간 경매방과 자동 상태 변경을 구현하는 것이 였습니다.
다른 서버에 존재하는 유저들이 딜레이없이 입찰가를 확인하며 입찰로그를 알아야한다는것이
저희가 일상적으로 사용하는 SNS 카톡과 유사하다고 생각했고 실시간 통신을 중점으로 두고 프로젝트를 진행했습니다
실시간으로 통신하는 방식에는 HTTP를 활용한
폴링 , 롱폴링 스트림 방식과 websocket을 활용한 tcp 방식이 있습니다
폴링 과 롱폴링 그리고 스트림방식은 실시간으로 소통를 하는데 많은 무리가 있습니다
폴링 : 클라이언트가 일정 주기로 서버에 “새로운 데이터가 있나요?” 라고 반복요청을 하는 방식
- 불필요한 네트워크 트레픽 발생
- 실시간 데이터 변경 불확실
- 서버 리소스 과부화
이러한 요소로 실시간 경매에는 적합지 않으며
롱폴링 : 서버가 새로운 데이터가 생길 때까지 클라이언트의 요청을 대기 시키는 방식
- 연결을 지속하기 위해 HTTP 요청-응답 사이클을 계속 반복
- 서버와 클라이언트 모두 커넥션 유지 비용 발생
- 대규모 유저 접속시 서버 과부화
경매 시스템 같이 수많은 사용자가 짧은 시간안에 입찰하고 빠르게 반응해야 하는 상황에선 적합하지 않습니다
스트리밍(SSE) : 서버가 클라이언트에게 일방향 적으로 실시간 데이터를 푸시하는 방식
- 단방향 통신만 지원 (클라 > 서버 로는 별도 요청이 필요)
- 복잡한 양방향 통신 구현 어려움
- 브라우저 지원 범위 작음
그래서 WebSocket을 선택했다
웹소캣은 이러한 문제를 모두 해결하는 최적의 선택이 였습니다
- 양방향 통신 지원
- 한번 연결으로 지속적 통신 가능(네트워크 트레픽 최소화)
- 레이턴시가 낮아 실시간성 우수
- 경매방마다 유저들을 그룹핑하고 특정 방안에서만 입찰 정보를 주고 받는것도 쉽게 구현가능
따라서 경매라는 특성상 빠른 반응성, 안정적 연결, 양방향 통신이 모두 중요했기에 웹소캣 방식을 선택했습니다
< 아키택처 다이어그램 >

웹소캣 설정
package org.example.auctionmaerketrealtime.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {
// client 가 server 와 웹소캣을 연결할 엔드포인트 지정
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("*")
.withSockJS();
}
// 메세지 브로커 동작 설정
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// client 가 서버로 요청하는 엔드포인트는 아래에서 지정한 /sub 으로 시작해야 한다
config.setApplicationDestinationPrefixes("/pub"); // client > server 요청 들어올 prefix 경로
// client 는 아래에서 지정한 prefix 를 시작으로 경로를 구독 해야한다
config.enableSimpleBroker("/topic", "queue"); // client 가 구독(sub)할 prefix 경로
}
}
- 웹소캣은 이전방식처럼 포스트맨이나 인텔리제이에서 바로 실행을 할수 없기에 html같은 웹 브라우저에서 테스트를 진행한다
ws-stomp : 클라이언트가 최초연결을 할때 필요한 진입점 (소캣연결 시 모든 클라이언트 무조건 사용)
/pub : 클라이언트가 서버로 메시지를 보낼 때 쓰는 경로 (입찰 등 클라 > 서버로 전송시 사용)
/topic , queue : 클라이언트가 서버에서 메시지를 받을 때 구독하는 경로 (입찰 결과를 받거나 실시간 알림 받을때 사용)
소캣으로 들오는 경로를 설정 했다면 이젠 클라이언트가 전송한 텍스트 요청을 처리할 핸들러를 작성해보겠습니다
BidMessageListener
다른 서버에서 입장하거나 입찰한 값을 같은 방에 있는 다른 서버에게도 전달할 수 있게 작성하였다
public class BidMessageListener implements MessageListener {
private final ObjectMapper objectMapper;
public static final Map<String, Set<WebSocketSession>> auctionSessions = new ConcurrentHashMap<>();
@Override
public void onMessage(Message message, byte[] patten) {
try {
String channel = new String(message.getChannel());
String auctionId = channel.split(":")[2];
String body = new String(message.getBody());
log.info("[Redis PubSub] auctionId: {}, body: {}",auctionId, body);
BidMessage bidMessage = objectMapper.readValue(body, BidMessage.class);
Set<WebSocketSession> sessions = auctionSessions.getOrDefault(auctionId, Set.of());
for (WebSocketSession session : sessions) {
if (session.isOpen()) {
session.sendMessage(new TextMessage(
bidMessage.getUsername() + "님이 " + bidMessage.getAmount() + "원 입찰!"
));
}
}
log.info("입찰 브로드캐스트 완료 (경매ID: {})",auctionId);
} catch (Exception e) {
log.error("Redis 메세지 처리 실패",e);
}
}
- String auctionId = channel.split(":")[2] : 레디스에 저장된 auction:top:”123” 에서 123즉 경매 아이디를 추출
- 열려 있는 세션에 실시간으로 메시지 (입찰알림) 전송
AuctionRedisHandler
- 유저가 입찰한 금액을 레디스에 최고가로 저장하는 로직
- 유저의 이름과 입찰가, 만료시간을 경매종료시간까지 유지하게 설정
@Service
@RequiredArgsConstructor
public class AuctionRedisHandler {
private final RedisTemplate<String, String> redisTemplate;
public void saveTopBid(Long auctionId, String username, Long amount, LocalDateTime auctionEndTime) {
String key = "auction:top:" + auctionId;
redisTemplate.opsForHash().put(key, "amount", String.valueOf(amount));
redisTemplate.opsForHash().put(key, "username", username);
Duration ttl = Duration.between(LocalDateTime.now(), auctionEndTime);
if (ttl.isNegative() || ttl.isZero()) {
ttl = Duration.ofMinutes(5);
}
redisTemplate.expire(key, ttl);
System.out.println("redis 저장 키: " + key);
System.out.println("username: " + username + " amount: " + amount);
}
}
AuctionWebSocketHandler
- AuctionRedisHandler 가 레디스에 입찰가를 저장하는 로직이라면
- AuctionWebSocketHandler 는 입찰한 금액을 각 서버에 브로드캐스트 하는 역활을 하는 로직이다
@Slf4j
@Component
@RequiredArgsConstructor
public class AuctionWebSocketHandler extends TextWebSocketHandler {
private final RedisTemplate<String, Object> redisPubSubTemplate;
@Override
public void afterConnectionEstablished(WebSocketSession session) {
String auctionId = extractAuctionId(session);
BidMessageListener.auctionSessions
.computeIfAbsent(auctionId, k -> ConcurrentHashMap.newKeySet())
.add(session);
System.out.println("[접속] " + session.getId() + "님이 " + auctionId + "경매방에 입장했습니다");
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
String payload = message.getPayload();
String auctionId = extractAuctionId(session);
if (!payload.matches("\\\\d+")) {
try {
session.sendMessage(new TextMessage("잘못된 입찰가입니다. 숫자만 입력하세요"));
} catch (IOException e) {
log.warn("메세지 전송 실패", e);
}
return;
}
try {
BidMessage bidMessage = new BidMessage();
bidMessage.setAuctionId(Long.parseLong(auctionId));
bidMessage.setAmount(Long.parseLong(payload));
bidMessage.setUsername("익명");
redisPubSubTemplate.convertAndSend("auction:bid", bidMessage);
log.info("[Redis 입찰 발행] {}원 - 경매: {}", bidMessage.getAmount(), auctionId);
} catch (Exception e) {
log.error("입찰 처리 중 예외 발생", e);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String auctionId = extractAuctionId(session);
Set<WebSocketSession> sessions = BidMessageListener.auctionSessions.get(auctionId);
if (sessions != null) {
sessions.remove(session);
System.out.println("[퇴장] " + session.getId() + "님이 " + auctionId + "경매방에서 나갔습니다");
if (sessions.isEmpty()) {
BidMessageListener.auctionSessions.remove(auctionId);
}
}
}
private String extractAuctionId(WebSocketSession session) {
URI uri = session.getUri();
if (uri == null) {
return "unknown";
}
String[] segments = uri.getPath().split("/");
return segments.length > 0 ? segments[segments.length - 1] : "unknown";
}
- WebSocket 세션 연결/해제, 메세지 처리, Redis 발행을 처리하는 로직
- private final RedisTemplate<String, Object> redisPubSubTemplate Redis Pub/Sub 메세지를 발행하기 위한 템플릿 [convertAndSend() 시 사용]
- 접속 시 호출하는 afterConnectionEstablished , 연결 해지 시 호출하는 afterConnectionClosed
- WebSocket에 연결된 세션에서 경매 ID를 추출하는 extractAuctionId
Pub/Sub기능을 사용하기 위해서는 Spring에도 설정을 해야한다
public class RedisPubSubConfig {
private final BidMessageListener bidMessageListener;
// Redis pub/sub 메시지 리스너 등록
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(bidMessageListener, new PatternTopic("auction:bid:*"));
return container;
}
@Bean
public RedisTemplate<String, Object> redisPubSubTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
- RedisMessageListenerContainer 는 Pub/Sub에서 메세지를 구독 하기 위해서 가장 핵심인 로직이다
- 해당 컨테이너가 Redis 서버와 연결해주고 특정 패턴(채널)을 구독한다
위 로직들로 각 서버에서는 클라이언트가 입찰한 입찰금을 레디스에 저장하고 각 서버로 브로드캐스트 할 수 있다
브로드캐스트 설정을 마쳤다면 이젠 경매방을 만들어보다
아키텍처 다이어그램에서 경매방은 메인에서 판매자가 경매를 생성하면 웹소캣은 해당 경매방을 만들고 유저가 참여 요청을 보내면 링크를 반환한다
public class AuctionRoomController {
private final AuctionService auctionService;
// 경매방 생성
@PostMapping("/start")
public ResponseEntity<WebSocketAuctionCreateResponse> startAuctionRoom(
@RequestBody WebSocketAuctionCreateRequest request) {
auctionService.createAuction(request);
String roomUrl = "<http://localhost:8081/auction.html?auctionId=>" + request.getAuctionId();
log.info("경매방 생성완료 : {}", roomUrl);
WebSocketAuctionCreateResponse response = new WebSocketAuctionCreateResponse(roomUrl);
return ResponseEntity.ok(response);
}
// 유저별 입장 html 생성
@PostMapping("/join")
public ResponseEntity<WebSocketAuctionCreateResponse> joinAuctionRoom(@RequestBody WebSocketAuctionCreateRequest request) {
Long auctionId = request.getAuctionId();
Long consumerId = request.getConsumerId();
String nickName = request.getNickName();
String roomUrl = "<http://localhost:8081/auction.html?auctionId=>" + auctionId +"&consumerId="+ consumerId +"&nickname=" + nickName;
log.info("참여용 경매 링크 생성: {}", roomUrl);
return ResponseEntity.ok(new WebSocketAuctionCreateResponse(roomUrl));
}
}
- 해당 로직은 메인에 요청을 받아 방을 만들고 유저의 링크를 제공한다.
- 그렇다면 메인은 어떻게 요청을 보낼까?
이 요청은 webclient를 사용해서 진행했다
WebClient란?
RestTemplate를 대체하는 비동기 HTTP 클라이언트로 서버간 HTTP요청을 보낼따 사용하는 도구이다
주요 특징으로는
| 비동기 / 논블로킹 | 요청을 보내고 응답을 기다리지 않고 바로 다른 일 처리 가능 |
| RestTemplate 대체 | RestTemplate은 동기 방식, WebClient는 비동기 방식 |
| 단순한 HTTP 요청 | GET, POST, PUT, DELETE 등 모든 HTTP 요청 가능 |
| JSON 직렬화/역직렬화 지원 | 객체를 JSON으로 변환하거나, JSON 응답을 Java 객체로 변환 가능 |
| Reactor 기반 | Mono, Flux를 사용해서 리액티브 프로그래밍 가능 (필수는 아님) |
Main
@Component
@RequiredArgsConstructor
public class WebSocketClient {
private final WebClient webClient;
@Value("${WEBSOCKET.SERVER.URL}")
private String websocketUrl;
@PostConstruct
public void init() {
System.out.println(" WebSocket URL: " + websocketUrl);
}
public String createAuctionRoom(WebSocketAuctionCreateRequest request) {
return webClient.post()
.uri(websocketUrl + "/internal/auction/start")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(WebSocketAuctionCreateResponse.class)
.map(WebSocketAuctionCreateResponse::getWebsocketUrl)
.block();
}
public String joinAuctionRoom(WebSocketAuctionJoinRequest request) {
return webClient.post()
.uri(websocketUrl + "/internal/auction/join")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(AuctionJoinResponse.class)
.map(AuctionJoinResponse::getWebsocketUrl)
.block();
}
}
1. webClient.post()
- HTTP POST 요청을 시작한다는 뜻
- GET, POST, PUT, DELETE 중 하나를 선택해야 하는데, 여기서는 POST
2. uri(websocketUrl + "/internal/auction/start")
- 요청을 보낼 목표 URL을 지정
- 예를 들면:
<http://localhost:8081/internal/auction/start>
- websocketUrl은 @Value로 읽어온 서버 주소 (localhost:8081)
- 그 뒤에 /internal/auction/start를 붙여서 최종 요청 주소
3. contentType(MediaType.APPLICATION_JSON)
- 요청의 Content-Type 헤더를 설정
- 즉, "나는 JSON 형식으로 데이터를 보낼 거야" 라고 서버한테 알려주는 것
Content-Type: application/json
이걸 HTTP 헤더에 추가해주는 역할
4. bodyValue(request)
- *요청 본문(body)**에 실제로 보낼 데이터를 넣는 거야
→ WebClient가 이 객체를 JSON으로 변환해서 HTTP Body에 담아 보낸다
5. retrieve()
- 요청을 보내고, 서버의 응답을 준비한다는 뜻
- 서버로부터 응답이 오면 그걸 읽을 준비를 하는 단계
6. bodyToMono(WebSocketAuctionCreateResponse.class)
- 응답 본문(Response Body)을 WebSocketAuctionCreateResponse 클래스 객체로 변환한다는 뜻
- Mono는 "미래에 하나의 값이 올 거야" 를 의미하는 리액티브 타입
즉, 서버 응답(JSON)을 → WebSocketAuctionCreateResponse로 변환해서 → Mono로 감싼다
7. map(WebSocketAuctionCreateResponse::getWebsocketUrl)
- 변환된 WebSocketAuctionCreateResponse 객체가 있으면,
- 그 안에서 getWebsocketUrl() 값만 꺼낸다는 뜻.
즉, 객체 전체가 아니라 우리가 진짜 필요한 것 (websocketUrl)만 뽑아낸다
8. block()
- Mono의 결과가 나올 때까지 현재 스레드를 멈추고 기다리는 것
- 즉, 지금까지 비동기 흐름이였지만 block()을 쓰면서 최종적으로 동기 처리로 바꿔버려린다
→ 결과가 나올 때까지 기다리고, 그 결과를 String으로 반환
🎯 전체 흐름 요약하면
1. POST 요청을 보낸다
2. JSON 본문(request) 넣는다
3. 응답을 WebSocketAuctionCreateResponse로 받는다
4. 응답 객체에서 websocketUrl만 뽑는다
5. 결과가 나올 때까지 기다린다 (block)
6. 최종적으로 String 타입 URL을 반환한다
@Transactional
public AuctionSaveResponse createAuction(AuthUser authUser, AuctionSaveRequest request){
//유저 예외처리
User user = userRepository.findById(authUser.getId())
.orElseThrow(()->new UserNotFoundException());
//입력한 상품이 없는 경우 예외처리
Product product = productRepository.findById(request.getProductId())
.orElseThrow(()->new AuctionException(AuctionErrorCode.PRODUCT_NOT_FOUND));
//물품을 올린 사용자가 아닌 경우 불가
if(!Objects.equals(authUser.getId(), product.getUser().getId())){
throw new AuctionException(AuctionErrorCode.NOT_AUCTION_OWNER);
}
//경매 내용 저장(최소 가격과 경매 진행 시간)
Auction auction = new Auction(
product,
request.getMinPrice(),
request.getStartTime(),
request.getProgressTime()
);
Auction saveAuction = auctionRepository.save(auction);
webSocketClient.createAuctionRoom(
new WebSocketAuctionCreateRequest(
saveAuction.getId(),
saveAuction.getProduct().getProductName(),
saveAuction.getMinPrice(),
saveAuction.getStartTime(),
saveAuction.getEndTime()
)
);
Duration ttl = Duration.between(LocalDateTime.now(), saveAuction.getEndTime());
//저장한 경매 출력
return new AuctionSaveResponse(
saveAuction.getId(),
saveAuction.getProduct().getId(),
saveAuction.getProduct().getUser().getId(),
saveAuction.getProduct().getProductName(),
saveAuction.getProduct().getCategory(),
saveAuction.getMinPrice(),
saveAuction.getStartTime(),
saveAuction.getEndTime(),
saveAuction.getStatus(),
"<http://localhost:8081/auction.html?auctionId=>" + saveAuction.getId()
);
}
- 서비스단에서 생성에 필요한 값을 dto를 통해 웹소캣 서버로 전달 후 AuctionRoomController에서 만들어진 링크를 받아서 유저에게 반환한다
이렇게 클라이언트가 링크를 받아 경매에 참여하여 입찰을 하면 웹소캣서버의 BidService를 통해 입찰가를 확인하고 Redis와 DB에 저장한다
WebSocket
@Slf4j
@Service
@RequiredArgsConstructor
public class BidService {
private final BidRepository bidRepository;
private final AuctionRepository auctionRepository;
private final AuctionRedisHandler auctionRedisHandler;
private final RedisTemplate<String, Object> redisTemplate;
@Transactional
public BidMessage placeBid(
Long auctionId,
BidMessage bidMessage) {
log.info("Place bid for auctionId {}, username {}, amount {}", auctionId, bidMessage.getUsername(), bidMessage.getAmount());
Auction auction = auctionRepository.findById(auctionId)
.orElseThrow(() -> new RuntimeException("경매를 찾을 수 없습니다"));
LocalDateTime now = LocalDateTime.now();
if (auction.getStartTime().isAfter(now)) {
log.warn("경매 시작전: 입찰 거부");
throw new RuntimeException("경매가 아직 시작하지 않았습니다");
}
if (auction.getEndTime().isBefore(now)) {
log.warn("경매 종료됨 : 입찰거부");
throw new RuntimeException("경매가 이미 종료되었습니다");
}
Long currentTopPrice = auction.getTopPrice();
Long newBidAmount = bidMessage.getAmount();
log.info("📌 현재 최고가: {}, 들어온 입찰가: {}", currentTopPrice, newBidAmount);
if (bidMessage.getAmount() <= auction.getTopPrice()) {
log.warn("❌ 입찰가가 최고가 이하입니다. 저장하지 않음");
throw new RuntimeException("현재 최고가보다 낮아 입찰할 수 없습니다");
}
// 입찰 저장
Bid bid = Bid.builder()
.consumerId(bidMessage.getConsumerId())
.username(bidMessage.getUsername())
.amount(bidMessage.getAmount())
.auction(auction)
.createdAt(LocalDateTime.now())
.build();
bidRepository.save(bid);
auction.setTopPrice(newBidAmount);
auctionRepository.save(auction);
auctionRedisHandler.saveTopBid(auctionId, bidMessage.getUsername(), bidMessage.getAmount(), auction.getEndTime());
log.info("✅ 입찰 저장 완료! DB에 반영");
redisTemplate.convertAndSend("auction:bid:" + auctionId, bidMessage);
log.info("서버간 입찰가 전송 완료");
return bidMessage;
}
}
🎯 마지막으로
이번시간엔 웹소캣을 통해 실시간 경매를 만들어 보았다 다음에는 레디스를 활용해 스케줄링 서버를 만들어보겠다
'내일배움캠프' 카테고리의 다른 글
| [트러블 슈팅] Transaction Commit 전 서버에서 경매방이 생성되는 문제 (0) | 2025.04.30 |
|---|---|
| Redis를 활용한 스케줄링 서버 구현 (0) | 2025.04.30 |