1. waiting-room.html
프론트엔드는 과감히 GhatGPT에게 맡겨버리기. 아래 이미지를 넣고, 멋있게 만들어달라고 요청한다. 다만, 로딩바 부분이 동적으로 변할 수 있게 javascript 코드를 추가로 작성해 준다.
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>접속 대기 중</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #2b2b42;
font-family: 'Arial', sans-serif;
color: #fff;
}
.container {
text-align: center;
padding: 30px;
background: #3e3e58;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
border: 2px solid #ff66b2;
}
.container h1 {
font-size: 1.5em;
margin-bottom: 20px;
color: #ff66b2;
}
.container .highlight {
color: #ff66b2;
font-weight: bold;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #5c5c7d;
border-radius: 10px;
overflow: hidden;
margin-bottom: 20px;
}
.progress {
height: 100%;
background-color: #ff66b2;
width: 0; /* 초기 상태 */
border-radius: 10px 0 0 10px;
transition: width 1s ease-in-out;
}
.queue-info {
font-size: 1em;
color: #fff;
margin-bottom: 20px;
}
.warning {
color: #ff4040;
font-size: 0.9em;
margin-bottom: 20px;
}
.logo {
margin-top: 20px;
font-size: 1.2em;
font-weight: bold;
color: #ff66b2;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const progressElement = document.querySelector('.progress');
function updateProgress() {
const queue = '[[${queue}]]';
const userId = '[[${userId}]]';
const queryParam = new URLSearchParams({'queue': queue, 'user-id': userId});
fetch('/api/v1/queue/progress?' + queryParam)
.then(response => response.json())
.then(data => {
if(data.queueFront < 0) {
document.querySelector('#queueFront').innerHTML = 0;
document.querySelector('#updated').innerHTML = new Date();
const newUrl = window.location.origin + window.location.pathname + window.location.search;
window.location.href = newUrl;
return;
}
document.querySelector('#queueFront').innerHTML = data.queueFront;
document.querySelector('#queueBack').innerHTML = data.queueBack;
document.querySelector('#updated').innerHTML = new Date();
const progress = data.progress;
progressElement.style.width = progress + '%';
})
.catch(error => console.error('Error:', error));
}
// 동적으로 로딩바를 업데이트
setInterval(updateProgress, 3000);
});
</script>
</head>
<body>
<div class="container">
<h1><span class="highlight">서비스 접속대기</span> 중입니다.</h1>
<div class="progress-bar">
<div class="progress"></div>
</div>
<div class="queue-info">
고객님 앞에 <span class="highlight" th:text="${queueFront}" id="queueFront">1269</span> 명, 뒤에 <span class="highlight" th:text="${queueBack}" id="queueBack">9</span> 명의 대기자가 있습니다.<br>
현재 접속 사용자가 많아 대기 중입니다.
</div>
<div class="warning">※ 재접속하시면 대기시간이 더 길어질 수 있습니다.</div>
<p id="updated"></p>
<div class="logo">IDOL</div>
</div>
</body>
</html>
2. dto 추가
QueueStatusDto
package me.progfrog.idol.flow.dto;
public record QueueStatusDto(
Long userRank,
Long totalQueueSize,
Double progress
) {
}
QueueStatusResponse
서버가 전달해 준 정보(QueueStatusDto)를 뷰에 사용할 부분들에 맞게 변경시킨다.
package me.progfrog.idol.flow.dto;
public record QueueStatusResponse(
Long queueFront,
Long queueBack,
Double progress
) {
public QueueStatusResponse(QueueStatusDto dto) {
this(
dto.userRank() > 0 ? dto.userRank() - 1 : dto.userRank(),
dto.totalQueueSize() - dto.userRank(),
dto.progress()
);
}
}
3. UserQueueService
/**
* 사용자의 대기 번호 조회
*
* @param queue 대기 큐 이름
* @param userId 사용자 ID
* @return 대기 번호
*/
public Mono<Long> getRank(final String queue, final Long userId) {
return reactiveRedisTemplate.opsForZSet().rank(USER_QUEUE_WAIT_KEY.formatted(queue), userId.toString())
.defaultIfEmpty(-1L)
.map(rank -> rank >= 0 ? rank + 1 : rank);
}
/**
* 전체 인원 가져오기
*
* @param queue 큐 이름
* @return 전체 인원 (대기 큐 + 입장 큐)
*/
public Mono<Long> getTotalQueueSize(final String queue) {
Mono<Long> waitQueueSizeMono = reactiveRedisTemplate.opsForZSet()
.size(USER_QUEUE_WAIT_KEY.formatted(queue))
.defaultIfEmpty(0L);
Mono<Long> allowQueueSizeMono = reactiveRedisTemplate.opsForZSet()
.size(USER_QUEUE_ALLOW_KEY.formatted(queue))
.defaultIfEmpty(0L);
return Mono.zip(waitQueueSizeMono, allowQueueSizeMono)
.map(tuple -> tuple.getT1() + tuple.getT2());
}
/**
* 입장 대기 시에 필요한 데이터를 전달
*
* @param queue 큐 이름
* @param userId 사용자 ID
* @return 사용자의 대기 번호, 전체 인원, 진행률
*/
public Mono<QueueStatusDto> registerWaitingQueueOrGetQueueStatus(final String queue, final Long userId) {
var unixTimestamp = Instant.now().getEpochSecond();
Mono<Long> userRankMono = reactiveRedisTemplate.opsForZSet().add(USER_QUEUE_WAIT_KEY.formatted(queue), userId.toString(), unixTimestamp)
.filter(isAdded -> isAdded)
.switchIfEmpty(Mono.error(ErrorCode.QUEUE_ALREADY_REGISTERED_USER.build()))
.flatMap(isAdded -> reactiveRedisTemplate.opsForZSet()
.rank(USER_QUEUE_WAIT_KEY.formatted(queue), userId.toString()))
.map(rank -> rank >= 0 ? rank + 1 : rank);
userRankMono = userRankMono.onErrorResume(throwable -> getRank(queue, userId));
Mono<Long> totalQueueSizeMono = getTotalQueueSize(queue);
return Mono.zip(userRankMono, totalQueueSizeMono)
.flatMap(tuple -> {
Long userRank = tuple.getT1();
Long totalQueueSize = tuple.getT2();
double progress = calculateProgress(userRank);
log.info("registerWaitingQueueOrGetQueueStatus() - rank: {}, totalQueueSize: {}, progress: {}", userRank, totalQueueSize, progress);
return Mono.just(new QueueStatusDto(userRank, totalQueueSize, progress));
});
}
/**
* 입장 대기 시에 필요한 데이터를 전달
* 단, 큐에 등록하는 로직 없음
*
* @param queue 큐 이름
* @param userId 사용자 ID
* @return 사용자의 대기 번호, 전체 인원, 진행률
*/
public Mono<QueueStatusDto> getQueueStatus(final String queue, final Long userId) {
Mono<Long> userRankMono = getRank(queue, userId);
Mono<Long> totalQueueSizeMono = getTotalQueueSize(queue);
return Mono.zip(userRankMono, totalQueueSizeMono)
.flatMap(tuple -> {
Long userRank = tuple.getT1();
Long totalQueueSize = tuple.getT2();
double progress = calculateProgress(userRank);
log.info("getQueueStatus() - rank: {}, totalQueueSize: {}, progress: {}", userRank, totalQueueSize, progress);
return Mono.just(new QueueStatusDto(userRank, totalQueueSize, progress));
});
}
/**
* 진행률 계산하기
*
* @param userRank 사용자의 대기 번호
* @return 진행률(0 ~ 100)
*/
private double calculateProgress(Long userRank) {
if (userRank <= 0) {
return 100.0;
}
return 100.0 / userRank.doubleValue();
}
@Scheduled(initialDelay = 5000, fixedDelay = 3000)
public void scheduleAllowUser() {
if (!scheduling) {
log.info("passed scheduling");
return;
}
log.info("called scheduling...");
// 대기 큐가 여러 개 있는 상황을 고려해서, 사용자 입장을 허용하는 코드 작성
var maxAllowUserCount = 3L;
reactiveRedisTemplate.scan(
ScanOptions
.scanOptions()
.match(USER_QUEUE_WAIT_KEY_FOR_SCAN)
.build())
.map(key -> key.split(":")[2])
.flatMap(queue -> allowUser(queue, maxAllowUserCount)
.map(isAllowed -> Tuples.of(queue, isAllowed)))
.doOnNext(tuple -> log.info("Tried %d and allowed %d members of %s queue".formatted(maxAllowUserCount,
tuple.getT2(),
tuple.getT1())))
.subscribe();
}
4. Controller 수정 및 추가
설명 | HTTP 메서드와 URL |
입장 대기 시 필요한 정보 내려주기 | GET /api/v1/queue/progress |
대기용 웹 페이지 | GET /waiting-room |
UserQueueController
javascript에서 호출할 API 추가
/**
* 입장 대기 시 필요한 정보 내려주기
*
* @param queue 대기 큐 이름
* @param userId 사용자 ID
* @return 사용자 앞/뒤 인원 및 진행률이 담긴 dto
*/
@GetMapping("/progress")
public Mono<QueueStatusResponse> getProgress(@RequestParam(name = "queue", defaultValue = "default") String queue,
@RequestParam(name = "user-id") Long userId) {
return userQueueService.getQueueStatus(queue, userId)
.map(QueueStatusResponse::new);
}
WaitingRoomController
package me.progfrog.idol.flow.controller;
import lombok.RequiredArgsConstructor;
import me.progfrog.idol.flow.dto.QueueStatusResponse;
import me.progfrog.idol.flow.service.UserQueueService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.reactive.result.view.Rendering;
import reactor.core.publisher.Mono;
@Controller
@RequiredArgsConstructor
public class WaitingRoomController {
private final UserQueueService userQueueService;
/**
* @param queue 입장 큐 이름
* @param userId 사용자 ID
* @return 사용자가 대기할 웹 페이지
*/
@GetMapping("/waiting-room")
Mono<Rendering> getWaitingRoomPage(@RequestParam(name = "queue", defaultValue = "default") String queue,
@RequestParam(name = "user-id") Long userId) {
String redirectUrl = "http://www.naver.com";
return userQueueService.isAllowed(queue, userId)
.filter(isAllowed -> isAllowed)
.flatMap(isAllowed -> Mono.just(Rendering.redirectTo(redirectUrl).build()))
.switchIfEmpty(userQueueService.registerWaitingQueueOrGetQueueStatus(queue, userId)
.map(dto -> {
QueueStatusResponse res = new QueueStatusResponse(dto);
return Rendering.view("waiting-room")
.modelAttribute("queue", queue)
.modelAttribute("userId", userId)
.modelAttribute("queueFront", res.queueFront())
.modelAttribute("queueBack", res.queueBack())
.modelAttribute("progress", res.progress())
.build();
}));
}
}
5. 동작 확인
반응형