일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- varchar
- 페이징
- buildx
- ALB
- cache miss
- url 단축기
- SMTP
- ECR
- transcational outbox
- health check
- AWS Lambda
- 동시성 문제
- redis
- 성능개선
- explain
- Spring
- 분산시스템
- 비동기
- 이메일 비동기
- 포트원
- spring actuator
- 실행계획
- scheduling
- JMeter
- 레디스
- 인프런
- M오더
- Docker
- 결제누락
- 향로
- Today
- Total
jjunhub
선착순 가입 시의 동시성 문제 해결하기 본문
부제 : Redis 분산락을 활용한 Multi Node 환경에서의 동시성 제어
개요
프로젝트에서 선착순으로 정해진 인원수만 가입을 받아야하는 로직을 구현해야하는 임무가 생겼다. 이렇게 선착순 가입과 같이 동시에 하나의 DB 레코드에 접근할 때에는 동시성 문제를 고려해야한다. 이 동시성 문제를 고려하기 전 알아야할 배경 지식들을 먼저 서술한 후에, 여러 시나리오가 실제로 문제를 해결할 수 있는지 다방면에서 검증해보자.
배경 지식 - 동시성 이슈
우선 동시성이란 IBM의 정의에 따르면 다음과 같다.
동시성은 여러 상호작용 사용자나 애플리케이션 프로그램이 동시에 리소스를 공유하는 것을 의미합니다.
본문에서 접근하게 될 리소스는 DB의 Record를 뜻한다.
문제 상황
Group 테이블의 구성은 간략하게 다음과 같다.
Group들 중 id가 23인 그룹은 최대 20명까지만 가입 가능한 그룹이며, 현재 0명이 가입되어 있는 상태이다. 만약 이 상태에서 동시에 45명의 사용자가 가입 요청을 하였을 때, 정상적으로 20명만 추가로 가입을 시켜야한다. 만약 동시성 이슈를 고려하지 않은 채로, 서버 로직을 작성하면 다음과 같은 일이 발생한다.
이렇게 그나마 9명이라도 선착순 가입이 되어서, 이제 11명이 추가로 가입이 가능한 것인가를 또 살펴보면..
![]() |
![]() |
DB의 group_member 목록에는 9명이 잘 들어갔으나, group 테이블에는 4명만이 존재하는 것으로 기록되었다? 이렇게 동시성 이슈가 발생한 것에 대해서 여러가지 시나리오를 통해서 해결해보자.
문제 해결을 위한 시나리오 작성
위에서 언급한 동시성 문제를 해결하기 위해서는 결국 Lock이라는 개념의 도입이 필수적이다.
여러 방법에서 다양한 Lock을 사용하여 문제를 해결할 수 있는지 살펴보자.
해결 방법 1 : Process Level Lock
첫번째 해결 방법은 프로세스 레벨에서 Lock을 도입하는 것이다. Lock을 소유하고 있는 스레드만 DB에 요청을 보낼 수 있도록 제어한다면, 동시성 문제를 해결할 수 있다.
Java에서는 프로세스 레벨에서의 Lock을 아래처럼 간단하게 아래의 코드처럼 사용할 수 있다.
@Service
@RequiredArgsConstructor
public class RegistrationService {
private final RegistrationRepository registrationRepository; // Repository 주입
public synchronized boolean register() {
if (registrationRepository.isEnableToJoin()) { // 자리가 남았는 지 확인
return false; // 가입 마감
}
registrationRepository.save(new Registration()); // DB에 신규 가입 추가
return true;
}
}
하지만 서버 프로세스가 여러 개 뜬다면, 어떻게 될까?
이 구조에서는 서버 프로세스 수만큼 Lock을 동시에 가져갈 수 있는 인원 수가 설정되는 것이다. 따라서 이 경우에는 요청하는 사용자들이 어떤 서버에 진입하느냐에 따라서 Lock의 소유 여부에 따라 가입이 될 수도 있고, 안될 수도 있다. 따라서 서버 프로세스가 2개 이상인 환경, 즉 멀티 노트 환경에서는 프로세스 레벨의 Lock을 사용할 수 없다.
이로부터 우리는 Lock이 프로세스 레벨에서 존재하는 것이 아니라, 다른 공통 요소에 존재해야함을 알 수 있다. 이 방법에 대해서 하나 씩 알아보자.
해결 방법 2 : MySQL DB Lock
다른 공통 요소 중 DB에다가 Lock을 구현하는 방법을 살펴보자. 자세한 내용을 살펴보기에 앞서서 MySQL의 구조에 대해서 살펴보면 다음과 같다.
사용하기에 적합한 MySQL의 Lock은 레코드 수준의 Lock과 애플리케이션 수준의 Named Lock이 있다. 우선 레코드 수준에서의 Lock 중 쓸만한 것을 먼저 살펴보면 아래와 같다.
Exclusive Lock | Optimistic Lock |
SELECT ... FOR UPDATE | UPDATE ... WHERE VERSION = ? |
다른 트랜잭션이 읽거나, 쓰기 불가 | 모두 가능, 종료 시점의 버전으로 오류 검증 |
이 중 Optimistic Lock을 사용하게 된다면 동시 요청이 많은 경우에, 충돌이 반복되어 성능이 급격히 저하될 수 있다. 따라서 Exclusive Lock을 사용하여, 한 번에 하나의 유저만 가입시켜 동시성 문제를 해보자. 이제 40명의 사용자가 동시에 Exclusive Lock을 통해서 접근한다고 가정해보면, MySQL에서는 다음과 같은 과정이 일어날 것이다.
1. Connection Layer
- MySQL과 연결된 사용자 요청을 관리하는 계층이다.
- 모든 트랜잭션에 대해 쓰레드 단위로 요청을 처리한다. (40개의 쓰레드를 사용)
- 이때, 쓰레드는 Thread Pool에서 가져오거나, Pool이 없거나 부족하면 새로 생성한다.
2. SQL Layer(Parser, Optimizer)
- SQL을 파싱하여, 실행 계획을 세우고 Storage Engine Layer로 전달하는 계층이다.
- 모든 트랜잭션(40개)의 Exclusive Lock 요청에 대해 InnoDB의 Lock Manager로 전달한다.
- 자체적으로 Lock과 트랜잭션을 관리하기도 한다.
3. Storage Engine Layer
- 실제 데이터가 저장되는 InnoDB 엔진이 존재하는 계층이다.
- InnoDB 엔진에 존재하는 Lock Manager가 요청받은 Exclusive Lock에 대해서 적용할 수 있는지 검토한다.
- 만약 이미 다른 요청에서 Lock을 점유하고 있다면, 해당 요청에 대해서 InnoDB 단의 Wait Queue에 추가한다. (39개 대기)
- Lock이 해제되면 Wait Queue에서 DB 내부 상황(데드락, 트랜잭션 격리 수준)에 따라, Lock을 획득하여 요청을 처리한다.
- 만약 Wait Queue에 들어간 뒤에 일정 시간동안 처리되지 않는다면, Time Out이 발생하며 해당 요청은 종료된다.
이렇게 Exclusive Lock을 통해 여러 트랜잭션이 동시에 접근하는 것을 방지하여, 동시성을 해결할 수 있다.
다음으로는 Named Lock이다. 우선 SQL 문으로 살펴보면 아래와 같다.
-- Lock 획득
SELECT GET_LOCK('data_processing', 10);
-- 처리할 로직 실행
UPDATE orders SET status = 'processing' WHERE status = 'pending';
-- Lock 해제
SELECT RELEASE_LOCK('data_processing');
위의 경우와 다른 점은 (Lock 획득, 로직 수행, Lock 해제)를 위해서 총 3개의 쿼리가 날라간다는 것이다. 따라서 MySQL 단에서의 로직도 약간 다르게 수행된다. 이번에도 40명의 사용자가 동시에 Named Lock을 요청한다고 가정해보면, 다음과 같은 과정이 일어날 것이다.
1. Connection Layer
- 모든 트랜잭션에 대해 쓰레드 단위로 요청을 처리한다. (40개의 쓰레드 사용)
- 이때, 쓰레드는 Thread Pool에서 가져오거나, Pool이 없거나 부족하면 새로 생성한다.
- 모든 클라이언트의 요청은 SELECT GET_LOCK('lock_name', 10); 일 것이다.
2. SQL Layer(Parser, Optimizer)
- SQL을 파싱하여, 실행 계획을 세우고 Storage Engine Layer로 전달하는 계층이다.
- Named Lock 요청을 InnoDB가 아닌 MySQL 자체의 Lock Manager로 전달한다.
3. Named Lock Manager
- SELECT GET_LOCK('lock_name', 10); 에 대해서 MySQL Lock Table에서 Lock 사용 여부를 판단한다.
- 만약 이미 다른 요청에서 Lock을 점유하고 있다면, 해당 요청에 대해서 MySQL 서버 단의 Wait Queue에 추가한다. (39개 대기)
- Lock이 해제되면 Wait Queue에서 DB 내부 상황(데드락, 트랜잭션 격리 수준)에 따라, Lock을 획득하여 요청을 처리한다.
- 만약 Wait Queue에 들어간 뒤에 일정 시간동안 처리되지 않는다면, Time Out이 발생하며 해당 요청은 종료된다.
위 과정을 거쳐 Named Lock을 획득한 트랜잭션은 비즈니스 로직을 수행한 뒤에, Lock을 Release하는 방식으로 동시성 문제를 해결할 수 있다.
이렇게 Lock을 점유하고, 점유한 요청(쓰레드)만이 해당 로직을 수행할 수 있는 구조를 대략적인 그림으로 보면 다음과 같다.
위의 그림에서 Lock을 관리하는 것의 주체는 사용하는 Lock의 종류에 따라 다르다는 것을 이미 살펴보았다.
- Exclusive Lock : InnoDB Engine
- Named Lock : MySQL Engine
앞선 두 가지 방식의 DB Lock은 동시성 문제를 모두 해결할 수 있다. 하지만 실제 로직을 수행하지 않는 모든 요청에 대해서 DB와 connection을 맺은 채로, 대기하고 있기 때문에 DB의 자원을 심하게 소모할 것으로 예상되어 실제 시도해보지는 않았다.
해결 방법 3 : Redis Distribution Lock
다음 공통 요소 중 Redis를 고려해보자. Redis는 다음과 같은 엔진 구조를 띄고 있다.
사용자들의 요청을 처리하는 부분이 I/O Multiplex로 3개의 스레드를 통해 처리하고 있으며, 이렇게 처리된 요청들에 대해서 싱글 스레드의 Event Loop 방식으로 요청을 1개씩 처리하는 것이다. 따라서 이 모델에서는 여러 명의 사용자들이 동시에 진입하더라도, 반드시 1명에게만 Lock을 제공할 수 있게 된 것이다. 이 특성을 통해 Lock이라는 개념을 Redis가 관리하도록 한다면, Lock을 적절하게 쓰레드에게 제공할 수 있게 된다. 이처럼 분산된 여러 서버(노드)간에 자원 접근을 동기화하고, 데이터 무결성과 동시성 문제를 해결하기에 이를 분산락이라고 표현한다.
아래의 그림은 이를 간략하게 표현한 것이다. ( 실제로 레디스에서 DB에 접근하는 것이 아니다 )
이 구조를 구현하기 위해서는 Redis 클라이언트를 사용해야하는데, 대부분은 Lettuce와 Redission을 사용한다. Lettuce를 사용하면 SET NX + Lua 스크립트 + TTL 관리를 수동으로 진행해야하지만, Redission을 이용한 Lock 소유 코드는 다음과 같이 간단하게 구현할 수 있다. 아래 코드는 2초간 Lock 획득을 시도하고, Lock 획득에 성공하면 해당 Lock은 5초 후에 자동으로 해제되는 코드이다.
public void joinGroup(Long groupId, LocalDate date) {
final String lockName = "group:" + groupId;
final RLock lock = redissonClient.getLock(lockName);
try {
if (!lock.tryLock(2, 5, TimeUnit.SECONDS)) {
return;
}
groupServiceTrans.processJoinGroup(groupId, date);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
결과는 아래와 같다!
정말 끝일까?
해결 방법 4 : ?!
하지만 해결 방법3에서도 아래와 같은 문제 상황이 발생할 수 있고, 각 문제에 대한 대처 방법을 정리해보았다.
- 문제 1 : 싱글 노드 구조의 레디스를 사용하면서 발생하는 SPOF
- Redis Cluster 모드를 도입하여, 하나의 레디스가 모종의 이유로 다운되도 요청을 받을 수 있도록 한다.
- 이 때, 락을 GET할 수 있는 지 여부에 대해서 여러 개의 레디스에 물어서 결정하는 RedLock 알고리즘을 사용한다.
- 문제 2: 많은 선착순 가입 요청 시, 발생하는 TIMEOUT
- 지금은 Lock을 소유 -> Race Condition 부분 진행 -> Lock 릴리즈 구조로 진행 중이다.
- 많은 요청이 하나의 Lock에 대해 요청할 때, 대기해야하는 request들이 Timeout이 발생할 여지가 있다.
- Race Condition 부분을 최소화하여 Timeout을 최소화할 수 있을 것이다.
- 문제 3: 진짜 대규모 수준의 선착순 가입 요청 시, 발생하는 TIMEOUT
- 지금은 Lock을 소유 -> Race Condition 부분 진행 -> Lock 릴리즈 구조로 진행 중이다.
- 하나의 Lock에 대해 대규모 수준으로 요청이 들어올 때에는 이를 비동기로 처리해야할 필요가 존재한다.
- 사용자들의 선착순 가입 요청을 우선적으로 DB에 Timestamp와 함께 모두 적재하고, Timestamp 기준으로 정렬하여 선착순 인원이 채워질 때까지 반복하여 성공 마킹을 해둔다.
- 대신 이 구조로 진행한다면, 사용자는 자신의 요청 직후에 결과를 보는 것이 아니라 일정 시간 이후에 이 결과를 Push 알림과 같은 방식으로 통보 받을 것이다.
결론.
동시성 문제는 간단히 해결할 수 없는 것 같다.
Redis + Red Lock 구조가 Request per Thread 모델에서는 가장 안정적인 것 같지만, 이상적(돈이 많다면)으로는 Event 모델을 도입하고 Event를 줄세울 수 있는 Kafka와 같은 Queue 구조가 존재하는 것이 좋아보인다.
'Architecture' 카테고리의 다른 글
이메일 여러 개 동시 전송하기 - 2편 (0) | 2025.05.12 |
---|---|
URL 단축기 서버 설계하기 - 2편 (0) | 2025.04.19 |
URL 단축기 서버 설계하기 - 1편 (1) | 2025.03.02 |
잃어버린 내 빅맥 - 1편 (3) | 2024.12.13 |