⏰💡 선착순 강의 Redis 대기열(ZSET) + 입장권 원자화 + 소비(consume) 적용하기 (Lua 1개로 끝)

2026. 2. 26. 22:57·프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트

선착순 시스템은 “기능이 동작한다”와 “실무에서 안전하다”가 다릅니다.

특히 동시에 많은 요청이 몰리면 다음 문제들이 자주 발생합니다.

  • 대기열에서 뽑힌 사람(POP)이 입장권(admitted) 을 못 받거나(서버 다운/중간 실패)
  • 스케줄러가 여러 인스턴스에서 실행되면 중복 발급/누락이 생기거나
  • 사용자가 신청 버튼을 연타하면 중복 신청/경쟁 조건이 생기거나

이번 글에서는 이 문제를 해결하기 위해 아래 2가지를 적용했습니다.

 

✅ 목표

  1. 입장권 발급(pop + grant)을 원자적으로 처리하기
  2. 신청(apply) 시 입장권을 먼저 소비(consume)해서 중복 요청을 막기

1️⃣ 기존 구조와 문제점

기존 대기열/입장권 구조

  • 대기열: Redis ZSET
    • key: queue:lecture:{lectureId}
    • member: userId
    • score: timestamp
  • 입장권(admitted): Redis SET
    • key: admitted:lecture:{lectureId}
    • set 멤버로 userId들을 넣고, TTL(5분)을 설정

문제점 1) 스케줄러 로직이 여러 Redis 명령으로 분리되어 있음

기존 스케줄러 흐름은 대략 다음과 같았습니다.

  1. ZSET에서 N명 pop
  2. admitted SET에 유저 추가
  3. admitted 키 TTL 설정

이게 왜 위험하냐면, (2)~(3) 과정이 여러 명령으로 나뉘어 있어서:

  • 서버가 중간에 죽거나
  • 네트워크 문제가 생기거나
  • 스케줄러가 여러 서버에서 동시에 돌아가면

“pop은 됐는데 admitted가 안 들어감 / TTL만 꼬임” 같은 문제가 생길 수 있습니다.

문제점 2) 신청(apply)에서 admitted 확인 후 마지막에 소비(remove)

기존 신청 흐름은:

  1. admitted 있는지 확인
  2. DB 저장
  3. 마지막에 admitted remove(소비)

이 방식은 신청 버튼 연타나 네트워크 재시도에서

두 요청이 동시에 1)번을 통과할 수 있어 경쟁 조건이 생길 수 있습니다.


2️⃣ 개선 방향(해결 전략)

개선 1) admitted를 SET 하나로 관리하지 않고 “유저별 키”로 바꾸기

기존: admitted:lecture:1 (SET 한 개)

개선: admitted:lecture:1:user:123 (입장권 1장 = key 1개)

장점:

  • TTL이 유저별로 정확하게 관리됨
  • 입장권 확인: EXISTS 한 번
  • 소비(consume): DEL 한 번
  • → 동시 클릭에도 딱 1번만 성공합니다.

개선 2) pop + admitted 발급을 Lua 1개로 “한 방에” 처리하기

Lua 스크립트로 아래를 Redis 내부에서 한 번에 실행합니다.

  • ZRANGE로 앞 N명 가져오기
  • ZREM으로 대기열에서 제거
  • 각 유저에게 SETEX admittedKey ttl로 입장권 발급

이렇게 하면 중간 실패가 줄고, 스케줄러가 여러 번 돌아도 결과가 훨씬 안전합니다.


3️⃣ 적용 코드 (3개 파일)

아래 3개 코드를 적용하면 됩니다.

  • QueueService : Lua + admitted 키 구조 + consume
  • LectureQueueScheduler : pop+grant 호출로 변경
  • EnrollService : 신청 시 consume 먼저 시도하도록 변경

(1) QueueService 전체 코드

✅ 핵심: admitted 키 생성 버그 방지를 위해

admittedKey(lectureId, userId)는 반드시 userId까지 붙여야 합니다.

package studying.blog.service;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Collections;
import java.util.List;

@RequiredArgsConstructor
@Service
public class QueueService {
    private final StringRedisTemplate redisTemplate;

    // =========================
    // Key 생성 규칙
    // =========================
    private String queueKey(Long lectureId) {
        return "queue:lecture:" + lectureId;
    }

    private String member(Long userId) {
        return String.valueOf(userId);
    }

    // ✅ 입장권 1장 = key 1개 (유저별)
    // admitted:lecture:{lectureId}:user:{userId}
    private String admittedKey(Long lectureId, Long userId) {
        return "admitted:lecture:" + lectureId + ":user:" + userId;
    }

    // ✅ Lua에서 사용할 prefix
    // admitted:lecture:{lectureId}:user:
    private String admittedKeyPrefix(Long lectureId) {
        return "admitted:lecture:" + lectureId + ":user:";
    }

    // =========================
    // 대기열 등록/조회
    // =========================
    public Long enqueue(Long lectureId, Long userId) {
        long score = Instant.now().toEpochMilli();
        String m = member(userId);

        // 대기열 등록
        redisTemplate.opsForZSet().add(queueKey(lectureId), m, score);

        // 내 순번 조회 (0부터 시작이므로 +1)
        Long rank = redisTemplate.opsForZSet().rank(queueKey(lectureId), m);
        return rank != null ? rank + 1 : null;
    }

    public Long getPosition(Long lectureId, Long userId) {
        String m = member(userId);
        Long rank = redisTemplate.opsForZSet().rank(queueKey(lectureId), m);
        return rank != null ? rank + 1 : null;
    }

    public Long getTotal(Long lectureId) {
        return redisTemplate.opsForZSet().zCard(queueKey(lectureId));
    }

    // =========================
    // Lua: pop + grant 원자화
    // =========================
    private static final String POP_AND_GRANT_LUA = """
        local queueKey = KEYS[1]
        local count = tonumber(ARGV[1])
        local ttl = tonumber(ARGV[2])
        local admittedPrefix = ARGV[3]

        -- 1) queue에서 앞 count명 가져오기
        local users = redis.call('ZRANGE', queueKey, 0, count - 1)
        if (#users == 0) then
          return users
        end

        -- 2) 가져온 users를 queue에서 제거
        redis.call('ZREM', queueKey, unpack(users))

        -- 3) 각 유저에게 admitted 키 발급 + TTL
        for i = 1, #users do
          local userId = users[i]
          local key = admittedPrefix .. userId
          redis.call('SETEX', key, ttl, '1')
        end

        return users
        """;

    private final DefaultRedisScript<List> popAndGrantScript =
            new DefaultRedisScript<>(POP_AND_GRANT_LUA, List.class);

    /**
     * ✅ 스케줄러가 호출:
     * queue 앞 n명을 꺼내고, admitted 키를 TTL과 함께 발급(원자적)
     */
    public List<String> popAndGrantAdmitted(Long lectureId, int n, int ttlSeconds) {
        @SuppressWarnings("unchecked")
        List<String> granted = (List<String>) redisTemplate.execute(
                popAndGrantScript,
                Collections.singletonList(queueKey(lectureId)),
                String.valueOf(n),
                String.valueOf(ttlSeconds),
                admittedKeyPrefix(lectureId)
        );

        return granted == null ? List.of() : granted;
    }

    // =========================
    // 입장권 확인/소비
    // =========================
    public boolean isAdmitted(Long lectureId, Long userId) {
        Boolean exists = redisTemplate.hasKey(admittedKey(lectureId, userId));
        return Boolean.TRUE.equals(exists);
    }

    /**
     * ✅ consume(소비): DEL 결과가 true면 “입장권이 있었고, 내가 소비했다”
     * - 동시 클릭/중복 요청에도 딱 1번만 true가 됩니다.
     */
    public boolean consumeAdmitted(Long lectureId, Long userId) {
        Boolean deleted = redisTemplate.delete(admittedKey(lectureId, userId));
        return Boolean.TRUE.equals(deleted);
    }
}

(2) LectureQueueScheduler 전체 코드

기존에는 popFront() + markAdmitted()로 분리되어 있었다면,

이제는 popAndGrantAdmitted() 1번 호출로 끝납니다.

package studying.blog.scheduler;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import studying.blog.service.QueueService;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Component
public class LectureQueueScheduler {

    private final QueueService queueService;

    @Scheduled(fixedDelay = 1000)
    public void processQueue() {
        Long lectureId = 1L;   // 테스트 1번 특강 고정
        int batchSize = 50;    // 한 번에 입장권 발급할 인원
        int ttlSeconds = 300;  // 입장권 TTL(5분)

        List<String> grantedUserIds = queueService.popAndGrantAdmitted(lectureId, batchSize, ttlSeconds);

        if (!grantedUserIds.isEmpty()) {
            log.info("입장 처리된 유저 수 : {}", grantedUserIds.size());
        }
    }
}

(3) EnrollService 전체 코드 (consume를 먼저!)

기존에는 “입장권 확인 → DB 저장 → 마지막에 remove”였다면,

이제는 “입장권을 먼저 소비”해서 중복 요청을 원천 차단합니다.

package studying.blog.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import studying.blog.domain.Enrollment;
import studying.blog.domain.Lecture;
import studying.blog.dto.EnrollResult;
import studying.blog.dto.EnrollmentResponse;
import studying.blog.repository.EnrollmentRepository;
import studying.blog.repository.LectureRepository;

@Service
@RequiredArgsConstructor
public class EnrollService {
    private final LectureRepository lectureRepository;
    private final EnrollmentRepository enrollmentRepository;
    private final QueueService queueService;

    @Transactional
    public EnrollResult enroll(Long lectureId, Long userId) {

        // ✅ 1) 입장권을 "먼저 소비"
        boolean consumed = queueService.consumeAdmitted(lectureId, userId);
        if (!consumed) {
            throw new IllegalStateException("입장 권한이 없습니다. (입장권이 없거나 만료/이미 사용됨)");
        }

        // ✅ 2) 강의 row 락(동시성 방지)
        Lecture lecture = lectureRepository.findByIdForUpdate(lectureId)
                .orElseThrow(() -> new IllegalArgumentException("Lecture not found :" + lectureId));

        // ✅ 3) 중복 신청 방지
        if (enrollmentRepository.existsByLectureIdAndUserId(lectureId, userId)) {
            return new EnrollResult("ALREADY_ENROLLED", lecture.getId(), lecture.getTitle());
        }

        // ✅ 4) 정원 체크 + 차감
        if (!lecture.hasSeat()) {
            throw new IllegalStateException("정원이 마감되었습니다.");
        }
        lecture.increaseEnrolled();

        // ✅ 5) 신청 기록 저장
        Enrollment enrollment = Enrollment.builder()
                .lecture(lecture)
                .userId(userId)
                .build();

        enrollmentRepository.save(enrollment);

        return new EnrollResult("ENROLLED", lecture.getId(), lecture.getTitle());
    }

    @Transactional(readOnly = true)
    public EnrollResult myEnroll(Long lectureId, Long userId) {
        return enrollmentRepository.findByLectureIdAndUserId(lectureId, userId)
                .map(e -> new EnrollResult("ENROLLED", lectureId, e.getLecture().getTitle()))
                .orElseGet(() -> new EnrollResult("NOT_ENROLLED", lectureId, null));
    }

    @Transactional(readOnly = true)
    public EnrollmentResponse getMyEnrollmentDetail(Long lectureId, Long userId) {
        Enrollment enrollment = enrollmentRepository.findByLectureIdAndUserId(lectureId, userId)
                .orElseThrow(() -> new IllegalArgumentException("신청 내역이 없습니다."));
        return EnrollmentResponse.from(enrollment);
    }
}

4️⃣ 적용 후 체크리스트(꼭 확인)

✅ 1) Redis 키가 제대로 생기는지 확인

터미널에서:

redis-cli
KEYS admitted:lecture:1:user:*
TTL admitted:lecture:1:user:<내유저id>

✅ 2) 신청 버튼 연타 테스트

  • 첫 번째 신청: 성공
  • 두 번째 신청: “입장권 없음/만료/이미 사용됨” 같은 실패 응답
  • → 정상입니다. 중복 요청이 막힌 상태입니다.

✅ 3) TTL 만료 테스트

SUCCESS 받고 5분 지나면 apply가 실패하는 게 정상입니다. (자리 회수 정책)


5️⃣ 마무리 요약

이번 개선으로 얻은 효과:

  • 스케줄러가 대기열에서 사람을 뽑고 입장권을 발급하는 과정이 원자화되어 중간 실패에 강해짐
  • 입장권을 유저별 key로 관리해서 TTL과 조회/소비가 깔끔해짐
  • 신청 시 입장권을 먼저 consume(DEL) 해서 중복 클릭/재시도에 안전해짐

'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글

⏰🚨 “입장권이 있는데도 apply 페이지에서 입장권 없음 오류 발생”  (0) 2026.03.06
⏰🚨 선착순 강의 신청에서 “이미 신청했는데 다시 신청 가능?” 문제 해결  (0) 2026.03.06
⏰🚨 선착순 강의 신청 페이지에서 버튼이 동작하지 않던 문제 해결  (0) 2026.02.23
⏰💡 소셜 로그인 + 선착순 강의 신청 시스템 통합 (JWT 기반 userId 전환)  (1) 2026.02.23
⏰🚨 동시성 테스트 중 500 Internal Server Error 발생 원인과 해결  (1) 2026.02.23
'프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
  • ⏰🚨 “입장권이 있는데도 apply 페이지에서 입장권 없음 오류 발생”
  • ⏰🚨 선착순 강의 신청에서 “이미 신청했는데 다시 신청 가능?” 문제 해결
  • ⏰🚨 선착순 강의 신청 페이지에서 버튼이 동작하지 않던 문제 해결
  • ⏰💡 소셜 로그인 + 선착순 강의 신청 시스템 통합 (JWT 기반 userId 전환)
hak0622
hak0622
개발하면서 “이게 뭐지?”라는 순간마다 궁금한 점을 바탕으로 정리한 개발 블로그입니다.
  • hak0622
    궁금한 개발 이야기 Why?
    hak0622
  • 전체
    오늘
    어제
    • 분류 전체보기 (68)
      • 공부 (36)
        • 1. 자바 ORM 표준 JPA 프로그래밍 - 기본.. (35)
        • 시험 (1)
      • 프로젝트 (32)
        • 스프링 부트 3 백엔드 개발자 되기 Blog + .. (32)
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hak0622
⏰💡 선착순 강의 Redis 대기열(ZSET) + 입장권 원자화 + 소비(consume) 적용하기 (Lua 1개로 끝)
상단으로

티스토리툴바