선착순 시스템은 “기능이 동작한다”와 “실무에서 안전하다”가 다릅니다.
특히 동시에 많은 요청이 몰리면 다음 문제들이 자주 발생합니다.
- 대기열에서 뽑힌 사람(POP)이 입장권(admitted) 을 못 받거나(서버 다운/중간 실패)
- 스케줄러가 여러 인스턴스에서 실행되면 중복 발급/누락이 생기거나
- 사용자가 신청 버튼을 연타하면 중복 신청/경쟁 조건이 생기거나
이번 글에서는 이 문제를 해결하기 위해 아래 2가지를 적용했습니다.
✅ 목표
- 입장권 발급(pop + grant)을 원자적으로 처리하기
- 신청(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 명령으로 분리되어 있음
기존 스케줄러 흐름은 대략 다음과 같았습니다.
- ZSET에서 N명 pop
- admitted SET에 유저 추가
- admitted 키 TTL 설정
이게 왜 위험하냐면, (2)~(3) 과정이 여러 명령으로 나뉘어 있어서:
- 서버가 중간에 죽거나
- 네트워크 문제가 생기거나
- 스케줄러가 여러 서버에서 동시에 돌아가면
“pop은 됐는데 admitted가 안 들어감 / TTL만 꼬임” 같은 문제가 생길 수 있습니다.
문제점 2) 신청(apply)에서 admitted 확인 후 마지막에 소비(remove)
기존 신청 흐름은:
- admitted 있는지 확인
- DB 저장
- 마지막에 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 |