Skip to content

Commit 436a65f

Browse files
authored
[Feat]: 오프라인 알림 및 정합성 검사 수정
주요 변경사항 실시간 알림 및 사용자 접속 상태 감지 :WebSocket과 Redis 연동하여 사용자의 온/오프라인을 감지하고, 온라인 사용자에게만 실시간 알림을 전송 비동기 입찰 처리: 기존의 동기 방식 입찰 처리를 Redis 메시지 큐를 이용한 비동기 방식으로 변경 -> 사용자 요청에 대한 응답 시간을 단축하고 시스템 처리량을 향상 UserPresenceService: WebSocket 세션과 Redis를 이용해 사용자의 실시간 온/오프라인 상태를 추적하고 관리 NotificationProcessor: UserPresenceService를 사용하여 사용자의 온라인 상태를 확인하고, 온라인 상태일 경우에만 WebSocket을 통해 실시간 알림을 전송하도록 수정 HeartbeatController: 클라이언트가 주기적으로 호출하여 WebSocket 연결을 유지하고, UserPresenceService를 통해 온라인 상태를 갱신하는 /heartbeat 엔드포인트를 구현 WebSocketEventListener: WebSocket 연결 및 해제 이벤트를 감지하여 UserPresenceService의 온라인/오프라인 상태를 자동으로 업데이트하는 리스너 UserPresenceCleanupScheduler: 애플리케이션 시작 시 모든 온라인 상태를 초기화하고, 주기적으로 온라인 사용자 수를 로깅하는 스케줄러 RedisUtil: UserPresenceService에서 사용자 온라인 상태 관리에 필요한 Redis Set 관련 메서드 및 유틸리티 메서드를 추가 BidService: createBid 메서드를 수정하여, 입찰 요청을 동기 처리하는 대신 Redis 메시지 큐에 추가하고 '요청 접수됨(202)' 상태를 즉시 응답하도록 변경 BidConsumerService: Redis의 bid_queue에서 입찰 메시지를 비동기적으로 처리하는 서비스. 분산 락을 사용하여 입찰 처리의 동시성을 제어 BidMessageDto: 비동기 입찰 처리를 위해 Redis 메시지 큐에서 사용될 DTO DistributedLockAop: @order(1)을 추가하여 분산 락 AOP가 트랜잭션 AOP보다 먼저 실행되도록 우선순위를 설정 ProductRepository: N+1 문제를 방지하기 위해 bids 컬렉션을 즉시 로딩하는 findByIdWithBids 메서드를 추가(FETCH JOIN 사용) TestInitData: 테스트 데이터 생성 시, 새로운 비동기 입찰 방식인 BidConsumerService.processBid를 사용하도록 수정 BidDistributedLockPerformanceTest: 비동기 입찰 시스템을 테스트하도록 대폭 수정. API 성공여부와 최종 DB 데이터 정합성을 분리하여 검증
2 parents efecb7d + b862e86 commit 436a65f

File tree

15 files changed

+1001
-90
lines changed

15 files changed

+1001
-90
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.backend.domain.bid.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Data;
5+
import lombok.NoArgsConstructor;
6+
7+
@Data
8+
@NoArgsConstructor
9+
@AllArgsConstructor
10+
public class BidMessageDto {
11+
private Long productId;
12+
private Long bidderId;
13+
private Long price;
14+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package com.backend.domain.bid.service;
2+
3+
import com.backend.domain.bid.dto.BidMessageDto;
4+
import com.backend.domain.bid.dto.BidResponseDto;
5+
import com.backend.domain.bid.entity.Bid;
6+
import com.backend.domain.bid.enums.BidStatus;
7+
import com.backend.domain.bid.repository.BidRepository;
8+
import com.backend.domain.member.entity.Member;
9+
import com.backend.domain.member.repository.MemberRepository;
10+
import com.backend.domain.notification.service.BidNotificationService;
11+
import com.backend.domain.product.entity.Product;
12+
import com.backend.domain.product.enums.AuctionStatus;
13+
import com.backend.domain.product.event.helper.ProductChangeTracker;
14+
import com.backend.domain.product.repository.jpa.ProductRepository;
15+
import com.backend.global.exception.ServiceException;
16+
import com.backend.global.lock.DistributedLock;
17+
import com.backend.global.websocket.service.WebSocketService;
18+
import com.fasterxml.jackson.core.JsonProcessingException;
19+
import com.fasterxml.jackson.databind.ObjectMapper;
20+
import lombok.RequiredArgsConstructor;
21+
import lombok.extern.slf4j.Slf4j;
22+
import org.springframework.context.ApplicationEventPublisher;
23+
import org.springframework.data.redis.core.RedisTemplate;
24+
import org.springframework.scheduling.annotation.Scheduled;
25+
import org.springframework.stereotype.Service;
26+
import org.springframework.transaction.annotation.Transactional;
27+
28+
import java.time.LocalDateTime;
29+
import java.util.List;
30+
31+
@Slf4j
32+
@Service
33+
@RequiredArgsConstructor
34+
public class BidConsumerService {
35+
36+
private final RedisTemplate<String, String> redisTemplate;
37+
private final ObjectMapper objectMapper;
38+
private final ProductRepository productRepository;
39+
private final MemberRepository memberRepository;
40+
private final BidRepository bidRepository;
41+
private final WebSocketService webSocketService;
42+
private final BidNotificationService bidNotificationService;
43+
private final ApplicationEventPublisher eventPublisher;
44+
45+
@Scheduled(fixedDelay = 100) // 0.1초마다 실행
46+
public void consumeBidQueue() {
47+
String messageJson = redisTemplate.opsForList().leftPop("bid_queue");
48+
49+
if (messageJson != null) {
50+
try {
51+
BidMessageDto messageDto = objectMapper.readValue(messageJson, BidMessageDto.class);
52+
processBid(messageDto);
53+
} catch (JsonProcessingException e) {
54+
log.error("입찰 메시지 역직렬화 실패: {}", messageJson, e);
55+
} catch (Exception e) {
56+
log.error("입찰 처리 중 예외 발생: {}", e.getMessage(), e);
57+
// 예외 발생 시, 해당 메시지를 별도의 Dead Letter Queue로 보내거나 로깅 후 폐기하는 정책 필요
58+
}
59+
}
60+
}
61+
62+
@DistributedLock(key = "'product:' + #messageDto.productId", waitTime = 10, leaseTime = 10)
63+
public void processBid(BidMessageDto messageDto) {
64+
Long productId = messageDto.getProductId();
65+
Long bidderId = messageDto.getBidderId();
66+
Long price = messageDto.getPrice();
67+
68+
// Product/Member 조회
69+
Product product = productRepository.findByIdWithBids(productId)
70+
.orElseThrow(() -> ServiceException.notFound("존재하지 않는 상품입니다."));
71+
Member member = memberRepository.findById(bidderId)
72+
.orElseThrow(() -> ServiceException.notFound("존재하지 않는 사용자입니다."));
73+
74+
// 유효성 검증
75+
validateBid(product, member, price);
76+
77+
Long previousHighestPrice = bidRepository.findHighestBidPrice(productId).orElse(null);
78+
79+
// 이전 최고 입찰자 확인 (입찰 밀림 알림용)
80+
Bid previousHighestBid = null;
81+
if (previousHighestPrice != null) {
82+
List<Bid> recentBids = bidRepository.findNBids(productId, 10);
83+
previousHighestBid = recentBids.stream()
84+
.filter(bid -> bid.getBidPrice().equals(previousHighestPrice))
85+
.findFirst()
86+
.orElse(null);
87+
}
88+
89+
// 입찰 생성 및 저장
90+
Bid savedBid = saveBid(product, member, price);
91+
92+
// 상품 업데이트
93+
updateProduct(product, savedBid, price);
94+
95+
// 응답 생성
96+
BidResponseDto bidResponse = createBidResponse(savedBid);
97+
98+
// 실시간 브로드캐스트
99+
webSocketService.broadcastBidUpdate(productId, bidResponse);
100+
101+
// 입찰 성공 알림 (현재 입찰자에게)
102+
bidNotificationService.notifyBidSuccess(bidderId, product, price);
103+
104+
// 입찰 밀림 알림 (이전 최고 입찰자에게)
105+
if (previousHighestBid != null && !previousHighestBid.getMember().getId().equals(bidderId)) {
106+
bidNotificationService.notifyBidOutbid(
107+
previousHighestBid.getMember().getId(),
108+
product,
109+
previousHighestBid.getBidPrice(),
110+
price
111+
);
112+
}
113+
log.info("입찰 성공: 상품 ID {}, 입찰자 ID {}, 입찰가 {}", productId, bidderId, price);
114+
}
115+
116+
private Bid saveBid(Product product, Member member, Long bidPrice) {
117+
Bid bid = Bid.builder()
118+
.bidPrice(bidPrice)
119+
.status(BidStatus.BIDDING)
120+
.product(product)
121+
.member(member)
122+
.build();
123+
return bidRepository.save(bid);
124+
}
125+
126+
private void validateBid(Product product, Member member, Long bidPrice) {
127+
// 경매 상태 확인
128+
validateAuctionStatus(product);
129+
130+
// 경매 시간 확인
131+
validateAuctionTime(product);
132+
133+
// 본인 상품 입찰 방지
134+
validateNotSelfBid(product, member);
135+
136+
// 입찰 금액 유효성 검증
137+
validateBidPrice(bidPrice, product);
138+
}
139+
140+
private void validateAuctionStatus(Product product) {
141+
if (!AuctionStatus.BIDDING.getDisplayName().equals(product.getStatus())) {
142+
throw ServiceException.badRequest("현재 입찰할 수 없는 상품입니다.");
143+
}
144+
}
145+
146+
private void validateAuctionTime(Product product) {
147+
LocalDateTime now = LocalDateTime.now();
148+
if (product.getStartTime() != null && now.isBefore(product.getStartTime())) {
149+
throw ServiceException.badRequest("경매가 아직 시작되지 않았습니다.");
150+
}
151+
if (product.getEndTime() != null && now.isAfter(product.getEndTime())) {
152+
throw ServiceException.badRequest("경매가 이미 종료되었습니다.");
153+
}
154+
}
155+
156+
private void validateNotSelfBid(Product product, Member member) {
157+
Member seller = product.getSeller();
158+
if (seller != null && seller.getId().equals(member.getId())) {
159+
throw ServiceException.badRequest("본인이 등록한 상품에는 입찰할 수 없습니다.");
160+
}
161+
}
162+
163+
private void validateBidPrice(Long bidPrice, Product product) {
164+
// 입찰 금액 기본 검증
165+
if (bidPrice == null || bidPrice <= 0) {
166+
throw ServiceException.badRequest("입찰 금액은 0보다 커야 합니다.");
167+
}
168+
169+
// 현재 최고가보다 높은지 확인
170+
Long currentHighestPrice = bidRepository.findHighestBidPrice(product.getId()).orElse(product.getInitialPrice());
171+
if (bidPrice <= currentHighestPrice) {
172+
throw ServiceException.badRequest("입찰 금액이 현재 최고가인 " + currentHighestPrice + "원 보다 높아야 합니다.");
173+
}
174+
175+
// 최소 입찰단위 100원
176+
if (bidPrice % 100 != 0) {
177+
throw ServiceException.badRequest("입찰 금액은 100원 단위로 입력해주세요.");
178+
}
179+
}
180+
181+
private void updateProduct(Product product, Bid savedBid, Long newPrice) {
182+
ProductChangeTracker tracker = ProductChangeTracker.of(product);
183+
184+
product.addBid(savedBid);
185+
product.setCurrentPrice(newPrice);
186+
187+
productRepository.save(product); // 변경사항을 명시적으로 저장
188+
189+
tracker.publishChanges(eventPublisher, product);
190+
}
191+
192+
private BidResponseDto createBidResponse(Bid bid) {
193+
return new BidResponseDto(
194+
bid.getId(),
195+
bid.getProduct().getId(),
196+
bid.getMember().getId(),
197+
bid.getBidPrice(),
198+
bid.getStatus(),
199+
bid.getCreateDate()
200+
);
201+
}
202+
}

src/main/java/com/backend/domain/bid/service/BidService.java

Lines changed: 17 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.backend.domain.bid.dto.*;
44
import com.backend.domain.bid.entity.Bid;
5-
import com.backend.domain.bid.enums.BidStatus;
65
import com.backend.domain.bid.repository.BidRepository;
76
import com.backend.domain.member.entity.Member;
87
import com.backend.domain.member.repository.MemberRepository;
@@ -12,14 +11,16 @@
1211
import com.backend.domain.product.event.helper.ProductChangeTracker;
1312
import com.backend.domain.product.repository.jpa.ProductRepository;
1413
import com.backend.global.exception.ServiceException;
15-
import com.backend.global.lock.DistributedLock;
1614
import com.backend.global.response.RsData;
1715
import com.backend.global.websocket.service.WebSocketService;
16+
import com.fasterxml.jackson.core.JsonProcessingException;
17+
import com.fasterxml.jackson.databind.ObjectMapper;
1818
import lombok.RequiredArgsConstructor;
1919
import org.springframework.context.ApplicationEventPublisher;
2020
import org.springframework.data.domain.Page;
2121
import org.springframework.data.domain.PageRequest;
2222
import org.springframework.data.domain.Pageable;
23+
import org.springframework.data.redis.core.RedisTemplate;
2324
import org.springframework.stereotype.Service;
2425
import org.springframework.transaction.annotation.Transactional;
2526

@@ -39,72 +40,28 @@ public class BidService {
3940
private final WebSocketService webSocketService;
4041
private final BidNotificationService bidNotificationService;
4142
private final ApplicationEventPublisher eventPublisher;
43+
private final RedisTemplate<String, String> redisTemplate;
44+
private final ObjectMapper objectMapper;
4245

4346
// ======================================= create methods ======================================= //
44-
@DistributedLock(key = "'product:' + #productId", waitTime = 10, leaseTime = 5)
4547
public RsData<BidResponseDto> createBid(Long productId, Long bidderId, BidRequestDto request) {
46-
return createBidInternal(productId, bidderId, request);
47-
}
48+
try {
49+
// 1. 입찰 요청 DTO 생성
50+
BidMessageDto messageDto = new BidMessageDto(productId, bidderId, request.price());
4851

49-
@Transactional
50-
public RsData<BidResponseDto> createBidInternal(Long productId, Long bidderId, BidRequestDto request) {
51-
// Product/Member 조회
52-
Product product = productRepository.findById(productId)
53-
.orElseThrow(() -> ServiceException.notFound("존재하지 않는 상품입니다."));
54-
Member member = memberRepository.findById(bidderId)
55-
.orElseThrow(() -> ServiceException.notFound("존재하지 않는 사용자입니다."));
56-
57-
// 유효성 검증
58-
validateBid(product, member, request.price());
59-
60-
Long previousHighestPrice = bidRepository.findHighestBidPrice(productId).orElse(null);
61-
62-
// 이전 최고 입찰자 확인 (입찰 밀림 알림용)
63-
Bid previousHighestBid = null;
64-
if (previousHighestPrice != null) {
65-
List<Bid> recentBids = bidRepository.findNBids(productId, 10);
66-
previousHighestBid = recentBids.stream()
67-
.filter(bid -> bid.getBidPrice().equals(previousHighestPrice))
68-
.findFirst()
69-
.orElse(null);
70-
}
52+
// 2. DTO를 JSON 문자열로 직렬화
53+
String messageJson = objectMapper.writeValueAsString(messageDto);
7154

72-
// 입찰 생성 및 저장
73-
Bid savedBid = saveBid(product, member, request.price());
55+
// 3. Redis List에 입찰 요청 추가
56+
redisTemplate.opsForList().rightPush("bid_queue", messageJson);
7457

75-
// 상품 업데이트
76-
updateProduct(product, savedBid, request.price());
58+
// 4. 사용자에게 "요청 접수됨" 응답
59+
return RsData.of("202", "입찰 요청이 성공적으로 접수되었습니다.", null);
7760

78-
// 응답 생성
79-
BidResponseDto bidResponse = createBidResponse(savedBid);
80-
81-
// 실시간 브로드캐스트
82-
webSocketService.broadcastBidUpdate(productId, bidResponse);
83-
84-
// 입찰 성공 알림 (현재 입찰자에게)
85-
bidNotificationService.notifyBidSuccess(bidderId, product, request.price());
86-
87-
// 입찰 밀림 알림 (이전 최고 입찰자에게)
88-
if (previousHighestBid != null && !previousHighestBid.getMember().getId().equals(bidderId)) {
89-
bidNotificationService.notifyBidOutbid(
90-
previousHighestBid.getMember().getId(),
91-
product,
92-
previousHighestBid.getBidPrice(),
93-
request.price()
94-
);
61+
} catch (JsonProcessingException e) {
62+
// 로깅 추가
63+
return RsData.of("500", "입찰 요청을 처리하는 중 오류가 발생했습니다.", null);
9564
}
96-
97-
return RsData.created("입찰이 완료되었습니다.", bidResponse);
98-
}
99-
100-
private Bid saveBid(Product product, Member member, Long bidPrice) {
101-
Bid bid = Bid.builder()
102-
.bidPrice(bidPrice)
103-
.status(BidStatus.BIDDING)
104-
.product(product)
105-
.member(member)
106-
.build();
107-
return bidRepository.save(bid);
10865
}
10966

11067
// ======================================= find/get methods ======================================= //

0 commit comments

Comments
 (0)