Skip to content

Commit 872e594

Browse files
authored
Merge pull request #315 from letter333/main
[WEEK8] Redis Sorted Set 기반 대기열 시스템 구축
2 parents 7137e69 + a1d4647 commit 872e594

77 files changed

Lines changed: 4264 additions & 43 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ out/
4141

4242
### Study ###
4343
docs/study/
44+
io/
45+
check.md
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.loopers.application.event;
2+
3+
public record ProductViewedEvent(
4+
Long productId
5+
) {
6+
}

apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.loopers.application.event.OrderCompletedEvent;
66
import com.loopers.application.event.ProductLikedEvent;
77
import com.loopers.application.event.ProductUnlikedEvent;
8+
import com.loopers.application.event.ProductViewedEvent;
89
import com.loopers.domain.outbox.OutboxEventService;
910
import lombok.RequiredArgsConstructor;
1011
import org.springframework.context.event.EventListener;
@@ -51,6 +52,16 @@ public void handleProductUnliked(ProductUnlikedEvent event) {
5152
);
5253
}
5354

55+
@EventListener
56+
public void handleProductViewed(ProductViewedEvent event) {
57+
outboxEventService.recordEvent(
58+
OutboxAggregateType.PRODUCT,
59+
String.valueOf(event.productId()),
60+
OutboxEventType.PRODUCT_VIEWED,
61+
toJson(Map.of("productId", event.productId()))
62+
);
63+
}
64+
5465
@EventListener
5566
public void handleOrderCompleted(OrderCompletedEvent event) {
5667
outboxEventService.recordEvent(

apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventType.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ private OutboxEventType() {}
77
public static final String PRODUCT_LIKED = "PRODUCT_LIKED";
88
public static final String PRODUCT_UNLIKED = "PRODUCT_UNLIKED";
99
public static final String ORDER_COMPLETED = "ORDER_COMPLETED";
10+
public static final String PRODUCT_VIEWED = "PRODUCT_VIEWED";
1011
}

apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@
1818
import com.loopers.domain.product.ProductImage;
1919
import com.loopers.domain.product.ProductOption;
2020
import com.loopers.domain.product.ProductService;
21+
import com.loopers.domain.queue.QueueEventType;
22+
import com.loopers.domain.queue.QueueService;
23+
import com.loopers.domain.queue.QueueTokenService;
2124
import com.loopers.support.auth.AdminValidator;
2225
import com.loopers.support.error.CoreException;
2326
import com.loopers.support.error.ErrorType;
24-
import lombok.RequiredArgsConstructor;
27+
import org.springframework.beans.factory.annotation.Autowired;
2528
import org.springframework.context.ApplicationEventPublisher;
29+
import org.springframework.context.annotation.Lazy;
2630
import org.springframework.orm.ObjectOptimisticLockingFailureException;
2731
import org.springframework.retry.annotation.Backoff;
2832
import org.springframework.retry.annotation.Retryable;
@@ -36,26 +40,70 @@
3640
import java.util.stream.Collectors;
3741

3842
@Component
39-
@RequiredArgsConstructor
4043
public class OrderFacade {
4144

4245
private final OrderService orderService;
4346
private final MemberService memberService;
4447
private final AddressService addressService;
4548
private final ProductService productService;
4649
private final MemberCouponService memberCouponService;
50+
private final QueueService queueService;
51+
private final QueueTokenService queueTokenService;
4752
private final AdminValidator adminValidator;
4853
private final ApplicationEventPublisher applicationEventPublisher;
4954

55+
@Lazy @Autowired
56+
private OrderFacade self;
57+
58+
public OrderFacade(
59+
OrderService orderService,
60+
MemberService memberService,
61+
AddressService addressService,
62+
ProductService productService,
63+
MemberCouponService memberCouponService,
64+
QueueService queueService,
65+
QueueTokenService queueTokenService,
66+
AdminValidator adminValidator,
67+
ApplicationEventPublisher applicationEventPublisher
68+
) {
69+
this.orderService = orderService;
70+
this.memberService = memberService;
71+
this.addressService = addressService;
72+
this.productService = productService;
73+
this.memberCouponService = memberCouponService;
74+
this.queueService = queueService;
75+
this.queueTokenService = queueTokenService;
76+
this.adminValidator = adminValidator;
77+
this.applicationEventPublisher = applicationEventPublisher;
78+
}
79+
80+
public OrderDetailInfo createOrder(String loginId, String password, String queueToken, OrderCommand.Create command) {
81+
Member member = memberService.authenticate(loginId, password);
82+
83+
boolean queueActive = queueService.isQueueActive(QueueEventType.ORDER);
84+
if (queueActive) {
85+
if (queueToken == null || queueToken.isBlank()) {
86+
throw new CoreException(ErrorType.FORBIDDEN, "대기열 진입이 필요합니다.");
87+
}
88+
long tokenTtl = queueTokenService.validateAndConsume(QueueEventType.ORDER, member.getId(), queueToken);
89+
try {
90+
return self.executeCreateOrder(member, command);
91+
} catch (Exception e) {
92+
queueTokenService.restoreToken(QueueEventType.ORDER, member.getId(), queueToken, tokenTtl);
93+
throw e;
94+
}
95+
}
96+
97+
return self.executeCreateOrder(member, command);
98+
}
99+
50100
@Retryable(
51101
retryFor = ObjectOptimisticLockingFailureException.class,
52102
maxAttempts = 3,
53103
backoff = @Backoff(delay = 50, multiplier = 2)
54104
)
55105
@Transactional
56-
public OrderDetailInfo createOrder(String loginId, String password, OrderCommand.Create command) {
57-
Member member = memberService.authenticate(loginId, password);
58-
106+
public OrderDetailInfo executeCreateOrder(Member member, OrderCommand.Create command) {
59107
Address address = findAddressForMember(member.getId(), command.addressId());
60108

61109
Order order = new Order(

apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEvictListener.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.loopers.application.product;
22

3+
import com.loopers.domain.ranking.ProductRankingRepository;
34
import lombok.RequiredArgsConstructor;
45
import org.springframework.stereotype.Component;
56
import org.springframework.transaction.event.TransactionPhase;
@@ -11,10 +12,14 @@ public class ProductCacheEvictListener {
1112

1213
private final ProductDetailCacheRepository productDetailCacheRepository;
1314
private final ProductListCacheRepository productListCacheRepository;
15+
private final ProductRankingRepository productRankingRepository;
1416

1517
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
1618
public void handleCacheEvict(ProductCacheEvictEvent event) {
17-
event.productIds().forEach(productDetailCacheRepository::evict);
19+
event.productIds().forEach(productId -> {
20+
productDetailCacheRepository.evict(productId);
21+
productRankingRepository.removeProduct(productId);
22+
});
1823
if (event.evictList()) {
1924
productListCacheRepository.evictAll();
2025
}

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.loopers.domain.brand.BrandService;
66
import com.loopers.domain.category.CategoryService;
77
import com.loopers.domain.product.Product;
8+
import com.loopers.application.event.ProductViewedEvent;
89
import com.loopers.domain.product.ProductService;
910
import com.loopers.domain.product.ProductSortType;
1011
import com.loopers.support.auth.AdminValidator;
@@ -31,8 +32,9 @@ public class ProductFacade {
3132
private final ProductListCacheRepository productListCacheRepository;
3233
private final ApplicationEventPublisher applicationEventPublisher;
3334

34-
@Transactional(readOnly = true)
35+
@Transactional
3536
public ProductDetailInfo getProduct(Long productId) {
37+
applicationEventPublisher.publishEvent(new ProductViewedEvent(productId));
3638
return productDetailCacheRepository.get(productId)
3739
.orElseGet(() -> {
3840
Product product = productService.getActiveProduct(productId);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.loopers.application.queue;
2+
3+
import com.loopers.domain.queue.QueueEventType;
4+
import com.loopers.domain.queue.QueueService;
5+
import com.loopers.domain.queue.QueueTokenService;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
14+
@Component
15+
@Slf4j
16+
public class QueueAdmissionScheduler {
17+
18+
private final QueueService queueService;
19+
private final QueueTokenService queueTokenService;
20+
private final int batchSize;
21+
22+
public QueueAdmissionScheduler(
23+
QueueService queueService,
24+
QueueTokenService queueTokenService,
25+
@Value("${queue.admission.batch-size:50}") int batchSize
26+
) {
27+
this.queueService = queueService;
28+
this.queueTokenService = queueTokenService;
29+
this.batchSize = batchSize;
30+
}
31+
32+
@Scheduled(fixedDelayString = "${queue.admission.interval-ms:10000}")
33+
public void processAdmission() {
34+
List<Long> userIds = queueService.dequeueBatch(QueueEventType.ORDER, batchSize);
35+
36+
if (userIds.isEmpty()) {
37+
return;
38+
}
39+
40+
try {
41+
queueTokenService.issueTokensBatch(QueueEventType.ORDER, userIds);
42+
log.info("대기열 입장 처리: {}명 토큰 일괄 발급", userIds.size());
43+
return;
44+
} catch (Exception e) {
45+
log.warn("토큰 일괄 발급 실패, 개별 발급으로 전환", e);
46+
}
47+
48+
int issued = 0;
49+
List<Long> failedUserIds = new ArrayList<>();
50+
for (Long userId : userIds) {
51+
try {
52+
queueTokenService.issueToken(QueueEventType.ORDER, userId);
53+
issued++;
54+
} catch (Exception e) {
55+
log.error("토큰 발급 실패 - userId: {}", userId, e);
56+
failedUserIds.add(userId);
57+
}
58+
}
59+
60+
if (!failedUserIds.isEmpty()) {
61+
try {
62+
queueService.requeueAll(QueueEventType.ORDER, failedUserIds);
63+
log.warn("토큰 발급 실패 {}명 대기열 재삽입 완료", failedUserIds.size());
64+
} catch (Exception e) {
65+
log.error("대기열 재삽입 실패 - userIds: {}", failedUserIds, e);
66+
}
67+
}
68+
69+
log.info("대기열 입장 처리: {}/{}명 토큰 발급", issued, userIds.size());
70+
}
71+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.loopers.application.queue;
2+
3+
import com.loopers.domain.member.Member;
4+
import com.loopers.domain.member.MemberService;
5+
import com.loopers.domain.queue.QueueEventType;
6+
import com.loopers.domain.queue.QueueInfo;
7+
import com.loopers.domain.queue.QueuePositionInfo;
8+
import com.loopers.domain.queue.QueuePositionStatus;
9+
import com.loopers.domain.queue.QueueService;
10+
import com.loopers.domain.queue.QueueTokenService;
11+
import com.loopers.support.auth.AdminValidator;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.stereotype.Component;
14+
15+
@Component
16+
@RequiredArgsConstructor
17+
public class QueueFacade {
18+
19+
private final MemberService memberService;
20+
private final QueueService queueService;
21+
private final QueueTokenService queueTokenService;
22+
private final AdminValidator adminValidator;
23+
24+
public QueueInfo enter(String loginId, String password) {
25+
Member member = memberService.authenticate(loginId, password);
26+
return queueService.enter(QueueEventType.ORDER, member.getId());
27+
}
28+
29+
public QueuePositionInfo getPosition(String loginId, String password) {
30+
Member member = memberService.authenticate(loginId, password);
31+
Long userId = member.getId();
32+
33+
String token = queueTokenService.getToken(QueueEventType.ORDER, userId);
34+
if (token != null) {
35+
long totalWaiting = queueService.getTotalWaiting(QueueEventType.ORDER);
36+
return new QueuePositionInfo(QueuePositionStatus.ADMITTED, 0, totalWaiting, 0, 0, token);
37+
}
38+
39+
QueueInfo queueInfo = queueService.getQueueInfo(QueueEventType.ORDER, userId);
40+
long estimatedWait = queueService.calculateEstimatedWaitSeconds(queueInfo.position());
41+
long pollInterval = queueService.suggestPollIntervalMs(queueInfo.position());
42+
return new QueuePositionInfo(QueuePositionStatus.WAITING, queueInfo.position(), queueInfo.totalWaiting(), estimatedWait, pollInterval, null);
43+
}
44+
45+
public void activateQueue(String ldap) {
46+
adminValidator.validate(ldap);
47+
queueService.activateQueue(QueueEventType.ORDER);
48+
}
49+
50+
public void deactivateQueue(String ldap) {
51+
adminValidator.validate(ldap);
52+
queueService.deactivateQueue(QueueEventType.ORDER);
53+
}
54+
55+
public boolean isQueueActive(String ldap) {
56+
adminValidator.validate(ldap);
57+
return queueService.isQueueActive(QueueEventType.ORDER);
58+
}
59+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.loopers.application.ranking;
2+
3+
public record RankingDetailInfo(
4+
Long rank,
5+
Double score,
6+
String date
7+
) {
8+
}

0 commit comments

Comments
 (0)