[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (1) - 동시성 이슈

들어가기 전에 우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위해 바닥부터 만들어보면서 느낀점과 배운것들을 정리하기 위

tmd8633.tistory.com

 

[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (2) - Redis

[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (1) - 동시성 이슈들어가기 전에 우아한테크톡의 선착순 이벤트 서버 생존기를 보고 아이디어를 얻었고, 그 과정을 처음부터 부딪히면서 배우기 위

tmd8633.tistory.com

이전 글을 읽어보시기 바랍니다. 이전 글에서 이어집니다.

 

 

개요

이전 글에서 선착순 이벤트 쿠폰 발급에 대해서 공부했습니다. 이는 오늘 같이 공부해볼 내용을 하기 위해서 선행학습에 불과합니다. 파트별로 공부하기 위해서 구현해보았던 것이죠. 오늘은 이벤트 주문에 대해서 공부해볼겁니다.

 

큐넷

큐넷 시험신청이나 수강신청을 할 때 이런 대기열을 보셨을겁니다. 이걸 만들어볼겁니다. 이전 글에서 Redis를 활용하여 대기열을 만들었었는데 그걸 이용하면 금방 구현해볼 수 있을 것같습니다.

 

 

이전과 다른 점

이전 선착순 이벤트 쿠폰을 발행하는 로직은 줄을 세우고 순차적으로 원하는 양만큼만 발급해주면 끝났습니다. 그런데 이번에는 줄을 세우고 줄을 선 유저에게 자신이 몇번째인지 알려주고 이벤트 페이지에 접속하기전까지 갱신해주어야합니다. 이 점을 생각하면서 전체적인 구조를 그려보겠습니다.

 

 

구조

유저가 /event 에 접속 요청을 하면 Filter에서 인증된 토큰이 있는지 검사합니다. 없으면 Redis에서 토큰을 생성해 대기열에 넣고, /waiting 으로 redirect 해서 현재 대기열을 표시해줍니다.

/waiting 에서 /api/v1/polling 요청으로 현재 대기열이 몇명인지, 인증되었는지, 내 대기순번은 몇번인지를 1초마다 polling 해줍니다.

유저의 토큰이 인증되었으면 /event 로 보내줍니다.

 

이렇게 구성해보았습니다. 이제 구현해보겠습니다.

 

 

 

Page

@Controller
public class EventController {

    @GetMapping("/")
    public String mainPage() {
        return "main";
    }

    @GetMapping("/event")
    public String orderPage() {
        return "event";
    }

    @GetMapping("/waiting")
    public String waitingPage() {
        return "waiting";
    }

}

 

main.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>메인페이지임</title>
</head>
<body>
    <h1>메인페이지임</h1>
    <a href="/event">이벤트 참여</a>
</body>
</html>

메인페이지에서 이벤트를 참여할 수 있게 만들었습니다.

event.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>이벤트 페이지</title>
</head>
<body>
    <h1>이벤트 페이지임</h1>
</body>
</html>

이벤트페이지입니다. 인증된 토큰이 있어야지 접속할 수 있습니다.

 

waiting.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>대기중</title>
    <style>
        body {
            width: 100%;
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .container {
            width: 300px;
            height: 400px;
            background-color: antiquewhite;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        h1,
        h3 {
            text-align: center;
        }
    </style>
    <script src="/js/waiting.js"></script>
</head>
<body>
<div class="container">
    <div id="card">
        <h3>대기번호</h3>
        <h1 id="rank">0</h1>
        <span>뒤에 <span id="waitingCount">0</span>명이 있습니다.</span>
    </div>
</div>
</body>
</html>

대기열페이지입니다. 내 대기번호와 그 뒤로 몇명의 대기자기 있는지 확인할 수 있습니다.

 

waiting.js

const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');

if (token == null) {
    alert('토큰 없음');
    window.location.href = '/';
}

window.addEventListener('load', () => {

    const rank = document.querySelector('#rank');
    const remain = document.querySelector('#waitingCount');

    const poling = () => setInterval(() => fetchGet('/api/v1/poling', callback), 1000);
    poling();


    const callback = (json) => {
        let jRank = json.rank;
        let jTotal = json.total;
        let jAuthenticated = json.authenticated;

        if (jAuthenticated) {
            location.href = `/event?token=${token}`;
            return;
        }

        if (jRank == null) {
            alert('토큰 정보가 없습니다.');
            location.href = '/';
            return;
        }
        rank.innerHTML = jRank + '';
        remain.innerHTML = (Number(jTotal) - Number(jRank)) + '';
    }
});

function fetchGet(url, callback) {
    fetch(`${url}?token=${token}`)
        .then(response => response.json())
        .then(json => callback(json))
}

 

setInterval로 1000ms 마다 서버에서 대기번호, 전체대기열사이즈, 인증여부를 받아옵니다.

인증이 되었다면 인증된 토큰과 함께 /event 페이지로 이동합니다.

 

 

Dto

public record ResponseToken(
    boolean authenticated,	// 인증여부
    Long total, 		// 전체 대기열 수
    Long rank) {		// 대기번호

}

 

Redis

@Service
@RequiredArgsConstructor
public class TokenRedisService {

    private final TokenRedisRepository tokenRedisRepository;

    private static final String WAITING_KEY = "event:waiting";
    private static final String AUTHENTICATED_KEY = "event:authenticated";

    @PostConstruct
    void initDeleteAll() {
        tokenRedisRepository.delete(WAITING_KEY);
        tokenRedisRepository.delete(AUTHENTICATED_KEY);
    }

    public String register() {
        String token = UUID.randomUUID().toString();
        tokenRedisRepository.addIfAbsent(WAITING_KEY, token);
        return token;
    }

    public boolean isAuthenticated(String token) {
       return tokenRedisRepository.exists(AUTHENTICATED_KEY, token);
    }
    public boolean isWaiting(String token) {
        return tokenRedisRepository.exists(WAITING_KEY, token);
    }

    public ResponseToken getRank(String token) {
        Long total = tokenRedisRepository.getSize(WAITING_KEY);
        Long rank = tokenRedisRepository.getRank(WAITING_KEY, token);
        boolean authenticated = tokenRedisRepository.exists(AUTHENTICATED_KEY, token);
        return new ResponseToken(authenticated, total, rank);
    }

    public void authenticate(long poolSize) {
        for (long i = 0; i < poolSize; i++) {
            String token = tokenRedisRepository.popMinIfExists(WAITING_KEY);
            if (token == null) return;

            tokenRedisRepository.addIfAbsent(AUTHENTICATED_KEY, token);
        }
    }

}

 

@Repository
@RequiredArgsConstructor
public class TokenRedisRepository {

    private final StringRedisTemplate redisTemplate;

    public Long getSize(String key) {
        return redisTemplate.opsForZSet().size(key);
    }

    public void delete(String key) {
        redisTemplate.delete(key);
    }

    public String popMinIfExists(String key) {
        if (getSize(key) < 1) return null;
        var tuple = redisTemplate.opsForZSet().popMin(key);
        return (tuple == null) ? null : tuple.getValue();
    }

    public Boolean addIfAbsent(String key, String token) {
        return redisTemplate.opsForZSet().addIfAbsent(
            key,
            token,
            System.currentTimeMillis()
        );
    }

    public void removeToken(String key, String token) {
        redisTemplate.opsForZSet().remove(key, token);
    }

    public boolean exists(String key, String token) {
        return redisTemplate.opsForZSet().score(key, token) != null;
    }

    public Long getRank(String key, String token) {
        Long rank = redisTemplate.opsForZSet().rank(key, token);
        return rank != null ? rank + 1 : null;
    }

}

 

 

 

Filter

@Configuration
@RequiredArgsConstructor
public class FilterConfig implements WebMvcConfigurer {

    private final EventTokenFilter eventTokenFilter;

    @Bean
    FilterRegistrationBean<EventTokenFilter> filterRegistrationBean() {
        FilterRegistrationBean<EventTokenFilter> filter = new FilterRegistrationBean<>(eventTokenFilter);
        filter.setOrder(1);
        filter.addUrlPatterns("/event/*");
        return filter;
    }

}
@Component
@RequiredArgsConstructor
public class EventTokenFilter extends OncePerRequestFilter {

    private final TokenRedisService tokenRedisService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = request.getParameter("token");

        if (token == null) {
            token = tokenRedisService.register();
            response.sendRedirect("/waiting?token=" + token);
            return;
        }

        boolean isAuthenticated = tokenRedisService.isAuthenticated(token);

        if (!isAuthenticated) {
            response.sendRedirect("/waiting?token=" + token);
            return;
        }

        filterChain.doFilter(request, response);
    }

}

 

/event 요청의 Filter입니다.

  1. token이 없으면 토큰을 생성해서 대기열에 넣고 /waiting 으로 redirect 합니다.
  2. token이 있지만 인증된 토큰이 아니라면 /waiting 으로 redirect 합니다.

 

 

Polling

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class EventRestController {

    private final TokenRedisService tokenRedisService;

    @GetMapping("/poling")
    public ResponseEntity<ResponseToken> rank(String token) {
        ResponseToken rToken = tokenRedisService.getRank(token);
        return ResponseEntity.ok(rToken);
    }

}

 

 

 

Schedule

@Configuration
@RequiredArgsConstructor
public class EventScheduler {

    private final TokenRedisService tokenRedisService;
    
    private static final long POOL_SIZE = 100;

    @Scheduled(fixedDelay = 1000)
    public void eventScheduler() {
        tokenRedisService.authenticate(POOL_SIZE);
    }
}

 

1초마다 대기열에서 100명의 유저를 인증해줍니다.

 

 

 

결과

JMeter로 대기열에 사람을 채우면서 대기열에 진입해보겠습니다.

 

 

/waiting
/waiting
/event 페이지

 

정상 동작합니다. 영상으로 남기고싶었는데 귀찮아서 사진으로 그냥 찍었습니다. 궁금하면 만들어서 직접 해보세요

 

 

 

 

추가

여기까지만 하려는데 조금 아쉽습니다.

 

첫번째로 위의 코드는 고정적인 유저수를 1초간격으로 허용해주는데 내가 원하는 사이즈만큼만 허용시키고싶은 생각이 들더라구요 전체 허용시킬 size가 1000이고, 1초마다 100명의 유저를 인증해준다고했을때 전체 size 1000이 다 차면 더이상 허용시키지 않고 인증된 유저가 페이지를 벗어나면 그 수만큼 인증시키고싶었습니다.

 

두번째는 대기열에서 유저가 빠져나가면 대기열 토큰을 지워 불필요한 데이터가 담기지 않게 하고싶었습니다.

 

이 두 작업을 더해보겠습니다.

 

먼저 대기열에서 토큰을 지워주는 /api/v1/leave와 인증된토큰을 지워주는 /api/v1/exit를 만들어보겠습니다.

 

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class EventRestController {

    private final TokenRedisService tokenRedisService;

    @GetMapping("/poling")
    public ResponseEntity<ResponseToken> rank(String token) {
        ResponseToken rToken = tokenRedisService.getRank(token);
        return ResponseEntity.ok(rToken);
    }

    @PostMapping("/exit")
    public ResponseEntity<String> exit(String token) {
        tokenRedisService.exit(token);
        return ResponseEntity.ok("exit");
    }

    @PostMapping("/leave")
    public ResponseEntity<String> leave(String token) {
        tokenRedisService.leave(token);
        return ResponseEntity.ok("leave");
    }
}

 

TokenRedisService

    public void leave(String token) {
        tokenRedisRepository.removeToken(WAITING_KEY, token);
    }

    public void exit(String token) {
        tokenRedisRepository.removeToken(AUTHENTICATED_KEY, token);
    }

 

 

1. 대기열 토큰 제거

window.addEventListener('beforeunload', () => {
    navigator.sendBeacon(`/api/v1/leave?token=${token}`);
})

waiting.js 에서 unload 되기전에 /api/v1/leave 에 토큰을 보내 대기열에서 제거해주도록 했습니다.

 

 

 

2. 인증토큰 제거

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>이벤트 페이지</title>
</head>
<body>
    <h1>이벤트 페이지임</h1>
    <button>완료</button>

    <script>
        const button = document.querySelector('button');

        const param = new URLSearchParams(window.location.search);
        const token = param.get('token');

        button.addEventListener('click', () => {

            fetch(`/api/v1/exit?token=${token}`, {method : 'POST'})
                .then(res => {
                    window.location.href = '/';
                })
        })

    </script>
</body>
</html>

이벤트 페이지에서 모든 작업이 완료되었다면 /api/v1/exit 요청으로 인증된 토큰을 제거해줍니다.

 

 

TokenRedisService

    public void authenticateWithStaticPool(long poolSize) {
        for (long i = 0; i < poolSize; i++) {
            String token = tokenRedisRepository.popMinIfExists(WAITING_KEY);
            if (token == null) return;

            tokenRedisRepository.addIfAbsent(AUTHENTICATED_KEY, token);
        }
    }

    public void authenticateWithMaxPool(long maxPoolSize, long poolSize) {
        Long authenticatedSize = tokenRedisRepository.getSize(AUTHENTICATED_KEY);
        long remainAuthenticatedSize = Math.min(maxPoolSize - authenticatedSize, poolSize);
        System.out.println("현재 인증된 토큰 수 : " + authenticatedSize + ", 인증 가능한 수 : " + remainAuthenticatedSize);
        authenticateWithStaticPool(remainAuthenticatedSize);
    }

 

기존의 authenticate 를 위와 같이 변경해줍니다. authenticateWithStaticPool 은 기존의 authenticate이고,

authenticateWithMaxPool 은 최대 인증토큰 개수까지만 인증시키는 메소드입니다.

 

@Configuration
@RequiredArgsConstructor
public class EventScheduler {

    private final TokenRedisService tokenRedisService;

    private static final long POOL_SIZE = 100;
    private static final long MAX_POOL_SIZE = 1000;

    @Scheduled(fixedDelay = 1000)
    public void eventScheduler() {
        tokenRedisService.authenticateWithMaxPool(MAX_POOL_SIZE, POOL_SIZE);
    }
}

이렇게 하고 실행해봅시다.

 

 

결과

100 개씩 인증하다가 최대 사이즈가 다 차면 인증토큰을 더이상 발행하지 않습니다.

이때 1001번 대기자는 계속 polling 하면서 대기하다가 앞에서 인증토큰이 exit되면

1001번 대기자 이벤트페이지 접속

들어오는 걸 확인할 수 있습니다.

1001번째 대기자 polling되었을때의 Schedule

 

아직 해결못한 내용

이렇게 했을 때 아직 해결못한것이 있습니다. 인증된 토큰을 어떻게 지우냐 입니다. 위의 코드는 인증된 토큰을 지우려면 이벤트페이지에서 어떤 작업을 모두 완료했을때 뿐입니다.

만약 중간에 페이지를 닫거나 새로운 페이지로 이동한다면 어떻게 토큰을 지워야할까요?

JS에서 beforeunload 로 토큰을 지우도록 하면 다음 문제는 /event 페이지에서 /event/** 로 이동할때 발생합니다. 페이지가 변경되면 인증된 토큰이 사라지거든요.

이 문제에 대해서는 해결하면 글써보도록하겠습니다.

 

+ Recent posts