선착순 이벤트 시스템이란?
댕댕어디가에는 펫 페스티벌 티켓을 선착순으로 받을 수 있는 이벤트가 존재합니다. 빠른 저장과 부하를 위해 이벤트 당시에는 티켓을 발급한 유저의 정보를 Redis에 저장한 후, MySQL에 영속화하는 과정을 거칩니다.
이전 글에서는 이러한 선착순 이벤트 시스템 구현 과정을 글로 정리하여 남겼으며 부하테스트를 진행하여 목표 시간내 실행되는 것을 확인하였습니다. 그런데 테스트 중간 중간에 원하는 발급량보다 더 많이 발급하는 것을 확인하였고, 이번엔 이를 해결하기 위해 글로 남기고자 합니다.
https://gani-dev.tistory.com/154
1. 동시성 문제 발견
여러번의 부하 테스트 도중, 300개보다 더 많이 티켓을 발급한 경우를 확인할 수 있었습니다.
2. 동시성 문제 발생 원인
▸ 동시성이란 ?
동시성(Concurrency)이란 여러 작업이 동시에 실행되는 것처럼 보이는 개념입니다. 하지만, 실제로 동시에 실행되는 것이 아니며, 여러 작업이 시간을 분할하여 실행되어 결과적으로 동시에 처리되는 것처럼 동작하는 것을 말합니다.
동시성 문제는 동일한 자원(data)에 대해 여러 스레드가 동시에 접근하면서 발생하는 것을 말합니다. 이번 동시성 문제는 멀티스레딩 환경에서 발생한 것으로, Java는 기본적으로 멀티스레딩을 지원하며, Spring Boot에서는 요청이 들어올 때마다 Tomcat의 스레드 풀에서 새로운 스레드를 할당하여 요청을 처리합니다.
그렇기에 여러 스레드가 동시에 작업을 나눠 진행했을 경우 아래 그림과 같이 원하는 티켓보다 더 많은 티켓을 발급할 수 있게 됩니다.
테스트 코드
@Test
void testConcurrentEventTicketGeneration() throws InterruptedException {
int threadCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(50);
CountDownLatch latch = new CountDownLatch(threadCount);
redisTemplateObject.delete(KEY);
activeEventParticipantLimit.put(KEY, 300);
for (int i = 0; i < threadCount; i++) {
final int userId = i;
executorService.execute(() -> {
try {
ticketService.generateEventTicket(userId, KEY);
} catch (Exception ignored) {
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
activeEventParticipantLimit.remove(KEY);
HashOperations<String, String, String> hashOperations = redisTemplateObject.opsForHash();
long size = hashOperations.size(KEY);
System.out.println("size = " + size);
assertEquals(300, size, "Redis key size는 300이어야 합니다.");
}
테스트 결과
300보다 3개 많은 303개를 발급받은 것을 확인할 수 있습니다.
3.동시성 문제 해결방안
현재 상황에서 고려할 수 있는 동시성 문제 해결 방안입니다.
- synchronized 키워드
- ReentrantLock 클래스
- 분산락
다른 락 알리즘, Atomic 이용, DB 락(낙관적락,비관적락) 등 다른 락들은 현재 상황과 맞지 않아 제외했습니다.
synchronized 키워드와 ReentrantLock 클래스
synchronized 키워드
Java에서 기본적으로 제공하는 내장 동기화 방법으로, 한 번에 하나의 스레드만 특정 메서드 또는 블록을 실행할 수 있도록 보장합니다.
public synchronized void generateEventTicket(Integer userId,String key) {
if(!activeEventParticipantLimit.containsKey(key)) {
throw new EventTicketLimitException();
}
HashOperations<String, String, String> hashOperations = redisTemplateObject.opsForHash();
if(hashOperations.size(key) < activeEventParticipantLimit.get(key)) {
hashOperations.put(key, userId.toString(), LocalDateTime.now().toString());
}else {
activeEventParticipantLimit.remove(key);
throw new EventTicketLimitException();
}
}
장점
- 구현이 간단하고 읽기 쉽습니다.
- JVM에서 자동으로 락을 관리합니다.
- 동기화된 블록을 벗어나면 자동으로 락이 해제됩니다.
단점
- 공정성(Fairness) 보장 X → 먼저 요청한 스레드가 반드시 먼저 실행된다는 보장이 없습니다.
- synchronized는 대기 중인 스레드에 대한 타임아웃을 지원하지 않습니다.
- 성능 저하 → 모든 스레드가 하나의 락을 기다려야 하므로 병렬 처리 성능이 떨어질 수 있습니다.
테스트
정확히 300개의 티켓을 발급한 것을 확인할 수 있었습니다.
ReentrantLock 클래스
synchronized 키워드와 유사한 기능을 제공하지만, 더 유연한 동기화 메커니즘을 제공하는 명시적 락(explicit lock) 클래스입니다.
private final ReentrantLock lock = new ReentrantLock();
public void generateEventTicket(Integer userId,String key) {
if(!activeEventParticipantLimit.containsKey(key)) {
throw new EventTicketLimitException();
}
lock.lock();
try{
HashOperations<String, String, String> hashOperations = redisTemplateObject.opsForHash();
if(hashOperations.size(key) < activeEventParticipantLimit.get(key)) {
hashOperations.put(key, userId.toString(), LocalDateTime.now().toString());
}else {
activeEventParticipantLimit.remove(key);
throw new EventTicketLimitException();
}
} finally {
lock.unlock();
}
}
장점
- 명시적 락 해제 가능 → unlock()을 직접 호출해야 하므로 관리가 쉽습니다.
- 공정성(Fairness) 지원 → 먼저 요청한 스레드가 먼저 락을 획득하도록 보장 가능합니다.
- 비차단 락(tryLock()) 지원 → 락을 획득하지 못했을 때, 스레드가 대기하지 않고 바로 다른 작업을 수행할 수 있습니다.
- 인터럽트 가능 락(lockInterruptibly()) 지원 → 대기 중인 스레드를 인터럽트 가능합니다.
단점
- 직접 unlock()을 호출해야 함 → 실수로 락을 해제하지 않으면 데드락 발생할 수 있습니다.
- synchronized보다 무겁다 → JVM 내부 최적화가 적용된 synchronized보다 약간 성능이 떨어질 수 있습니다.
- 코드 복잡도 증가 → try-finally 블록을 항상 써야 하므로 코드가 길어질 수 있습니다.
테스트 결과
정확히 300개의 티켓을 발급한 것을 확인할 수 있었습니다.
synchronized 키워드와 ReentrantLock 클래스의 문제점
두 방법은 메모리 기반 락을 관리하기 때문에 다른 락에 비해 속도가 빠르다는 장점이 있지만, 그만큼 싱글 서버에서만 가능하다는 단점이 있습니다. 그렇기에 분산 환경을 구성하고 있는 저희 서버에서는 적합한 방안이 아니었습니다.
4. 분산락
분산 락은 여러 서버가 공유 데이터를 제어하기 위한 기술입니다.
락을 획득한 프로세스 혹은 스레드만 자원에 접근할 수 있으며, 분산 환경에서도 프로세스들의 원자적인 연산이 가능하다는 장점을 갖고 있습니다.
5. 분산락 구현 방법
- Zookeeper : 분산 서버 관리 시스템으로, 분산 서비스 내 설정을 공유해 준다. 그러나 추가적인 인프라 구성, 러닝 커브가 높은 단점이 있다.
- MySQL : User Level Lock으로 분산락을 직접 구현할 수 있지만, 락을 자동 반납할 수 없어 명시적으로 락을 해제해야 하며, DB에 부하가 우려, 락 획득 시도를 위해 스핀락으로 구현해야하는데 이 또한 WAS에 부하가 우려되는 단점이 있다.
- Redis : 인메모리 DB로, 속도가 빠르고 싱슬 스레드로 동시성 문제가 적다. 하지만 메모리 기반이기 때문에 장애 발생 시, 데이터 손실 또는 락 관련하여 문제가 발생할 수 있다는 단점이 있다.
각 장단점을 따졌을 때, 이미 Redis를 사용하고 있다는 점과 속도가 빠르고 간단하게 락을 구현할 수 있다는 점에서 Redis를 활용한 분산락을 구현하기로 결정하였습니다.
6. Redis 분산락 구현 방법
Redis를 활용하여 임계 영역에 접근할 때 Lock을 설정하고 작업이 끝난 시점에 Lock을 해제하는 식으로 구현할 수 있습니다.
아래 2가지 방법이 존재합니다.
- Lettuce 의 Sprin Lck : 지속적으로 Redis 서버에 락 요청을 보내서 Lock 사용 가능 여부 확인
- Redisson 의 RedLock 알고리즘 : RedLock 알고리즘을 활용하여 Lock 획득,해제를 pub/sub 방법으로 사용 가능 여부 확인
Lettuce를 활용한 분산락 방법은 락 획득을 희망하는 스레드들이 지속적으로 Redis에 획득 요청을 보내기 때문에 부하가 심합니다.
또한 직접 retry, timeout과 같은 추가 기능을 구현해줘야 하는 단점이 있습니다.
반대로 Redisson을 활용한 분산락 방법은 pub/sub 구조로 락 사용 가능 여부를 확인하기 때문에 부하가 덜하며 추가 기능과 관련된 인터페이스를 지원하기 때문에, 덜 번거롭습니다. 그래서 Redisson을 활용하여 분산락을 구현하기로 결정했습니다.
7. Redis에서의 분산락
구현하기 전에, Redis에서는 락을 어떻게 획득하는지, Redis 분산락 알고리즘인 RedLock은 무엇인지 알아보겠습니다.
단일 Redis 인스턴스에서의 락
단일 Redis 인스턴스에서는어떻게 락을 획득하고 해제하는지 알아보겠습니다.
Redis에서 아래와 같이 SET 명령어를 이용해 락을 획득합니다.
SET resource_name_key my_random_value NX PX 30000
- NX 옵션: 키가 존재하지 않은 경우에만 값을 설정합니다, 이미 존재하는 경우 값을 설정할 수 없습니다.
- PX 30000: 30,000 밀리초(30초) 동안 키의 만료 시간을 설정합니다.
아래와 같이 DEL 명령어로 락을 해제합니다.
DEL resource_name_key
위와 같이 무턱대고 키 삭제 명령어를 사용하면, 자신이 소유하지 않은 락을 해제할 수 있습니다.
- 예를 들어, 클라이언트가 락을 획득한 후 시간이 오래 걸려 락의 유효 시간이 초과되어 키가 만료될 수 있습니다. 그 후 클라이언트가 락을 해제하려 할 때, 이미 다른 클라이언트가 락을 획득한 경우에도 실수로 해당 락을 해제하는 일이 발생할 수 있습니다.
따라서 아래와 같이 Lua 스크립트를 활용하여, 자신이 가진 락인지 먼저 확인한 후에 해제할 수 있도록 합니다.
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1]) // -> DEL resource_name_key
else
return 0
end
- del 명령어를 통해 키를 삭제합니다.
- 이때 다른 클라이언트가 획득한 락을 해제하지 못하도록, value를 비교한 후 동일한 경우에만 삭제할 수 있게 합니다.
이렇게 구현된 단일 Redis 인스턴스는 단일 장애 지점이 될 수 있기 때문에, 클러스터를 구성하기도 합니다. 클러스터를 구성해도 과연 아무 문제가 없을까요?
Redis 복제는 비동기로 진행되기 때문에 상황에 따라 경쟁 상태가 발생할 수도 있습니다.
- 클라이언트 A가 잠금을 획득합니다.
- key에 대한 복제가 다른 인스턴스에 복제되기 전에 마스터 노드가 다운됩니다.
- 다른 인스턴스가 마스터 노드로 승격됩니다.
- 클라이언트 B는 A가 잠금을 보유하는 동안 잠금을 획득합니다.
이러한 문제를 보완하기 위해 Redis에서는 분산락 알고리즘으로 RedLock 알고리즘을 고안했습니다.
분산 Redis 인스턴스에서의 락 - RedLock 알고리즘
Redisson에서는 RedLock 알고리즘으로 분산 환경에서 안전하게 락을 획득하는 방법을 제공합니다. Redis의 인스턴스가 N개일 때, 다수결 이상의 인스턴스에서 락을 획득했을 경우에만 락을 획득한 것으로 판단합니다.
락 획득 과정
- 현재 시간을 밀리초 단위로 기록합니다.
- 각 Redis 인스턴스에서 락을 순차적으로 시도합니다. 이때 동일한 키와 랜덤 값을 사용하여 락을 시도합니다. 락을 설정할 때 SET 명령을 사용하며, 락의 자동 만료 시간을 고려해 매우 짧은 타임아웃(예: 5-50 밀리초)을 설정하여 락을 획득하려고 합니다. 만약 어떤 인스턴스가 응답하지 않으면, 즉시 다음 인스턴스로 넘어가도록 합니다.
- 락을 획득한 시간과 실제 락 획득까지의 시간을 비교하여, 전체 락 획득 시간이 설정된 락 유효 시간 내에 완료되었는지 확인합니다. 유효 시간 내에 대다수의 인스턴스(최소 3개)에서 락을 획득했다면 락을 성공적으로 획득했다고 간주합니다.
- 락을 획득했다면, 유효 시간을 초기 유효 시간에서 락 획득에 소요된 시간만큼 뺀 값으로 설정합니다.
- 만약 락을 획득하지 못했거나 유효 시간이 음수일 경우, 이미 획득한 락을 전부 해제합니다.
RedLock 한계
애플리케이션 중단, 네트워크 지연으로 인해 락 과정에 문제가 발생할 수 있습니다.
애플리케이션 중단으로 인한 문제
예를 들어 아래와 같이 락을 획득하고 데이터를 쓰는 과정이 있다고 생각해 봅시다.
public void writeData(String filename, String data) throws IOException {
RLock rLock = lockService.acquireLock(filename);
try {
// 락 획득
boolean availableLock = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!availableLock) {
return false;
}
Path filePath = Paths.get(filename);
String fileContents = new String(Files.readAllBytes(filePath));
// 내용 업데이트
String updatedContents = updateContents(fileContents, data);
// 파일에 업데이트된 내용 저장
Files.write(filePath, updatedContents.getBytes());
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {}", key);
}
}
}
위 코드에서는 아래 다이어그램 과정을 거치게 되면 데이터 동시성 문제가 발생합니다.
- 클라이언트 A가 마스터에서 잠금을 획득합니다.
- 클라이언트 A에서 Stop-the-World GC로 인한 어플리케이션 코드 중지가 발생하고 그 사이에 잠금이 만료됩니다.
- 클라이언트 B가 분산락을 획득하고 파일에 데이터를 씁니다.
- 클라이언트 A가 GC가 끝난 후 파일에 데이터를 쓰면서 동시성 문제가 발생합니다.
일반적으로 GC는 매우 빠르게 수행되지만, 드물게 잠금이 만료될 정도로 지속될 수 있습니다.
위 과정을 통해 Redis로 락을 거는 방법, 분산 환경에서 락을 획득하는 방법과 그에 따른 문제점을 알 수 있었습니다. 이번에는 애플리케이션 중지로 인한 동시성 문제에 대해서만 언급했지만, 네트워크 지연으로 인해 락이 만료되었음에도 DB 쓰기 작업이 이루어져 동시성 문제가 발생하는 경우와 같이 네트워크 지연이나 다른 이유들로도 문제가 발생할 수 있음을 깨달았습니다.
8. Redisson을 활용한 분산락 구현
이제 이론적으로 학습한 내용을 바탕으로, Redisson을 활용하여 분산락을 구현해 보겠습니다.
분산락을 구현하기 위해 여러 기술 블로그를 참고하여 몇가지를 고려하였습니다.
- 분산락 처리 로직과 비즈니스 로직은 분리될 것
- 락 이름과 waitTime, leaseTime을 커스텀하게 지정할 수 있게 한다.
위 규칙을 고려하기 위해 AOP를 활용하였습니다.
Flow
AOP를 적용한 락 획득 과정은 다음과 같습니다.
- 락을 걸고자하는 메서드에 AOP를 적용합니다.
- AOP 선언 시, 획득하고자 하는 락 이름을 파라미터로 전달합니다.
- 메서드 실행 전 AOP를 통해 락을 획득합니다.
- 메서드 실행 완료 후 락을 반납합니다.
DistributedLock 어노테이션
분산락 어노테이션으로, 락의 이름, 락의 시간 단위, 락을 기다리는 시간, 락 임대 시간을 갖습니다.
/**
* 분산락 어노테이션
* - 분산락 처리 과정과 비즈니스 로직을 분리할 수 있도록 AOP로 구현.
* - waitTim,leaseTime 을 통해 락을 획득하는 시간과 락을 유지하는 시간을 설정할 수 있다.
* - 락 이름을 커스텀할 수 있다.
* - 추가 요구사항에 대해 공통적으로 처리할 수 있다.
*
* key : 락 이름
* timeUnit : 락의 시간 단위
* waitTime : 락을 기다리는 시간 (default - 5s)
* leaseTime : 락 임대 시간 (default - 3s) 락을 획득한 후 leaseTime 이 지나면 락을 해제한다
*
* */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 5L;
long leaseTime() default 3L;
}
DistributedLock Aop
@DistributedLock 어노테이션이 붙은 메서드가 실행되면 lock 메서드가 먼저 실행됩니다.
획득하고자 하는 key를 만들고, 해당 키에 대한 락을 획득합니다. tryLock을 통해 락을 획득하며, 시간 내 획득했을 시 true를 반환하고 호출한 메서드를 실행합니다. 만약 시간 내 획득하지 못했을 시 false를 반환하고 호출한 메서드를 실행하지 않습니다. 호출된 메서드 실행이 완료되면 락을 해제합니다.
- CustomSpringELParser.getDynamicValue 를 이용하여 어노테이션에서 지정한 key 값(메서드 매개변수)를 동적으로 사용하여 Redis 락의 Key 값을 생성합니다.
- 데이터 정합이 중요한 과정이었다면 트랜잭션과 관련된 과정도 추가되었어야 했으나, 현재 상황에는 필요 없기 때문에 생략했습니다.
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
@Around("@annotation(com.daengdaeng_eodiga.project.Global.annotation.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean availableLock = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!availableLock) {
return false;
}
return joinPoint.proceed();
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {}", key);
}
}
}
}
DistributedLock 어노테이션 적용
락을 획득하고자 하는 메서드에 @DistributedLock 어노테이션을 사용함으로써, 메서드의 파라미터인 key로 락을 획득합니다.
@DistributedLock(key = "#key")
public void generateEventTicket(Integer userId,String key) {
if(!activeEventParticipantLimit.containsKey(key)) {
throw new EventTicketLimitException();
}
HashOperations<String, String, String> hashOperations = redisTemplateObject.opsForHash();
if(hashOperations.size(key) < activeEventParticipantLimit.get(key)) {
hashOperations.put(key, userId.toString(), LocalDateTime.now().toString());
}else {
activeEventParticipantLimit.remove(key);
throw new EventTicketLimitException();
}
}
테스트
단일 서버에서 100번 테스트 해본 결과 성공적으로 300개만 티켓을 발급한 것을 확인할 수 있었습니다.
분산 서버에서 동시에 각자 10번 테스트 해본 결과 성공적으로 300개씩 티켓을 발급한 걸 확인할 수 있었습니다.
(10개의 키를 이용하여 각 테스트마다, 동일한 키에 대해 두 서버가 요청했을 경우 300개만 티켓을 발급하는지 확인하였습니다.)
9. 느낀점
이번 과정을 통해 멀티 스레딩 환경에서 발생할 수 있는 동시성 문제와 이를 해결하기 위한 다양한 접근 방식의 장단점을 이해하게 되었습니다. 최적의 해결책이라고 생각한 방법에도 분명한 한계가 있음을 깨닫게 되었고, 프로그래밍 세계에서 '당연함'과 '완벽함'은 존재하지 않음을 실감했습니다. 이를 통해 기술 선택 시 명확한 근거와 논리적 사고의 중요성을 다시 한 번 깊이 깨닫게 되었습니다.
'댕댕어디가 프로젝트 > 트러블슈팅' 카테고리의 다른 글
[댕댕어디가] 땅따먹기 주인 조회 API 속도 개선 (0) | 2025.02.06 |
---|---|
[댕댕어디가] 선착순 이벤트 시스템 구현기 (0) | 2025.01.31 |