1. 개요
대기열 이탈과 관련해서는 여러 가지 정책을 취할 수 있다. 여기서는 사용자가 진입 가능 상태가 되어, 타겟 페이지로 리다이렉트를 할 때 서버는 토큰을 생성하여 이를 쿠키 형태로 클라이언트에게 전달한다. 타겟 페이지 요청이 올 때, 서버는 클라이언트에 저장된 쿠키를 확인하여 쿠키가 없거나 기대한 값이 아니면 처음부터 대기하도록 한다.
2. 토큰 생성
UserQueueService
- 토큰은 SHA-256 해시 알고리즘을 사용하여, 입력 문자열을 해시하고 그 결과를 16진수 문자열로 변환하여 반환해서 사용한다.
- queue와 userId를 이용해 문자열을 생성하는 데, 예를 들어 queue가 default이고 userId가 123이면 입력 문자열은 "user-queue-default-123"이 된다.
- 생성된 문자열을 UTF-8 인코딩하여, 바이트 배열로 변환한 후 digest 메서드를 사용해 해시 값을 계산한다.
- SHA-256은 입력 데이터를 256비트(32바이트) 고정 길이의 해시 값으로 변환한다.
- SHA-256 해시 값을 2진수로 표현하면 256개의 숫자가 필요하지면, 16진수 문자열로 변환하면 64자리의 사람이 읽을 수 있는 형태가 된다.
/**
* 토큰 생성
*
* @param queue 큐 이름
* @param userId 사용자 ID
* @return 생성된 토큰 (queue 와 userId 가 같다면 항상 같은 토큰이 나옴)
*/
public Mono<String> generateToken(final String queue, final Long userId) {
String input = String.format("user-queue-%s-%d", queue, userId);
return Mono.fromSupplier(() -> {
try {
return hashToHex(input, "SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("해당 알고리즘을 지원하지 않습니다.", e);
}
});
}
/**
* 해시 값 계산
*
* @param input 입력 문자열
* @param algorithm 알고리즘 ex) SHA-256
* @return 16진수 문자열
* @throws NoSuchAlgorithmException algorithm 을 잘못 설정된 경우
*/
private String hashToHex(String input, String algorithm) throws NoSuchAlgorithmException {
MessageDigest messageDigest = MessageDigest.getInstance(algorithm);
byte[] encodedHash = messageDigest.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(encodedHash);
}
/**
* 16진수 문자열로 변환
*
* @param bytes 해시 값(바이트 배열)
* @return 16진수 문자열
*/
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
}
- 기존에는 입장 큐에 존재하는지 확인해서(isAllowed()) 입장 가능 여부를 판단했다면, 이번에는 넘어온 토큰이 일치하는지 확인해서(isAllowedByToken) 입장 가능 여부를 판단한다.
/**
* 사용자가 입장 가능한 상태인지 조회
*
* @param queue 입장 큐 이름
* @param userId 사용자 ID
* @return 입장 가능 여부
*/
public Mono<Boolean> isAllowed(final String queue, final Long userId) {
return reactiveRedisTemplate.opsForZSet().rank(USER_QUEUE_ALLOW_KEY.formatted(queue), userId.toString())
.defaultIfEmpty(-1L)
.map(rank -> rank >= 0);
}
/**
* 토큰이 일치하는 지 확인
*
* @param queue 입장 큐 이름
* @param userId 사용자 ID
* @param token 전달된 토큰
* @return 입장 가능 여부
*/
public Mono<Boolean> isAllowedByToken(final String queue, final Long userId, final String token) {
log.info("token: {}", token);
return this.generateToken(queue, userId)
.filter(generatedToken -> generatedToken.equalsIgnoreCase(token))
.map(isMatching -> true)
.defaultIfEmpty(false);
}
3. 코드 적용
설명 | HTTP 메서드와 URL |
토큰 생성 후 쿠키 저장 | GET /api/v1/queue/touch |
UserQueueController
- 기존에 isAllowed() 사용하고 있던 부분을 isAllowedByToken()으로 변경
package me.progfrog.idol.flow.controller;
import lombok.RequiredArgsConstructor;
import me.progfrog.idol.flow.dto.AllowUserResponse;
import me.progfrog.idol.flow.dto.AllowedUserResponse;
import me.progfrog.idol.flow.dto.QueueStatusResponse;
import me.progfrog.idol.flow.dto.RegisterUserResponse;
import me.progfrog.idol.flow.service.UserQueueService;
import org.springframework.http.ResponseCookie;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.time.Duration;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/queue")
public class UserQueueController {
private final UserQueueService userQueueService;
/**
* 사용자를 대기 큐에 등록
*
* @param queue 대기 큐 이름
* @param userId 사용자 ID
* @return 대기 번호가 담긴 dto
*/
@PostMapping
public Mono<RegisterUserResponse> registerUser(@RequestParam(name = "queue", defaultValue = "default") String queue,
@RequestParam(name = "user-id") Long userId) {
return userQueueService.registerWaitQueue(queue, userId)
.map(RegisterUserResponse::new);
}
/**
* 사용자를 입장 가능 상태로 전환
*
* @param queue 대기 큐 이름
* @param count 대기 큐에서 가져올 사용자 수
* @return 요청 수와 처리 수가 담긴 dto
*/
@PostMapping("/allow")
public Mono<AllowUserResponse> allowUser(@RequestParam(name = "queue", defaultValue = "default") String queue,
@RequestParam(name = "count") Long count) {
return userQueueService.allowUser(queue, count)
.map(allowedCount -> new AllowUserResponse(count, allowedCount));
}
/**
* 사용자가 입장 가능한 상태인지 조회
*
* @param queue 입장 큐 이름
* @param userId 사용자 ID
* @return 입장 가능 여부가 담긴 dto
*/
@GetMapping("/allowed")
public Mono<AllowedUserResponse> isAllowedUser(@RequestParam(name = "queue", defaultValue = "default") String queue,
@RequestParam(name = "user-id") Long userId,
@RequestParam(name = "token") String token) {
return userQueueService.isAllowedByToken(queue, userId, token)
.map(AllowedUserResponse::new);
}
/**
* 입장 대기 시 필요한 정보 내려주기
*
* @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);
}
/**
* 토큰 생성 후 쿠키 저장
*
* @param queue 큐 이름
* @param userId 사용자 ID
* @param exchange HTTP 요청
* @return 토큰
*/
@GetMapping("/touch")
public Mono<String> touch(@RequestParam(name = "queue", defaultValue = "default") String queue,
@RequestParam(name = "user-id") Long userId,
ServerWebExchange exchange) {
return Mono.defer(() -> userQueueService.generateToken(queue, userId))
.map(token -> {
exchange.getResponse().addCookie(
ResponseCookie.from("user-queue-%s-token".formatted(queue), token)
.maxAge(Duration.ofSeconds(300))
.path("/")
.build()
);
return token;
});
}
}
WaitingRoomController
- 기존에 isAllowed() 사용하고 있던 부분을 isAllowedByToken()으로 변경하기위해 저장된 쿠키를 읽어오는 코드 추가
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 org.springframework.web.server.ServerWebExchange;
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,
@RequestParam(name = "redirect-url") String redirectUrl,
ServerWebExchange exchange) {
var key = "user-queue-%s-token".formatted(queue);
var cookieValue = exchange.getRequest().getCookies().getFirst(key);
var token = (cookieValue == null) ? "" : cookieValue.getValue();
return userQueueService.isAllowedByToken(queue, userId, token)
.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();
}));
}
}
4. javascript 수정
waiting-room.html
- 주기적으로 입장 가능 여부를 묻다가, 입장 가능 상태가 되면 토큰을 생성해서 쿠키에 저장하는 코드를 호출하고 타겟 페이지로 리다이렉트
<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) {
fetch('/api/v1/queue/touch?' + queryParam)
.then(response => {
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;
})
.catch(error => console.error(error));
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>
5. 테스트 코드 추가
UserQueueServiceTest
@Test
@DisplayName("isNotAllowedByToken: 토큰이 맞지않으면 진입 미허용")
void isNotAllowedByToken() {
StepVerifier.create(userQueueService.isAllowedByToken("default", 101L, ""))
.expectNext(false)
.verifyComplete();
}
@Test
@DisplayName("isAllowedByToken: 토큰이 맞으면 진입 허용")
void isAllowedByToken() {
StepVerifier.create(userQueueService.isAllowedByToken("default", 101L, "bf00fd9ec300129861628c5a13e9507bb8b3dc6603f3bc8dd978b709c1146dff"))
.expectNext(true)
.verifyComplete();
}
@Test
@DisplayName("generateToken: 토큰 생성 확인")
void generateToken() {
StepVerifier.create(userQueueService.generateToken("default", 101L))
.expectNext("bf00fd9ec300129861628c5a13e9507bb8b3dc6603f3bc8dd978b709c1146dff")
.verifyComplete();
}
반응형