공연 예매 서비스 : 동시성 문제와 해결방안에 관한 분석 보고서 - leonroars/slam GitHub Wiki
현 문서는 공연 예약 서비스를 설계하고 구현하며 마주한 동시성 문제와 이를 해결하는 과정에서 학습한 것을 정리하기 위한 보고서입니다.
해당 보고서를 읽는 분들이 편안하게 이해하실 수 있도록 하기 위해 이와 관련된 현행 설계 및 구현에 관한 간략한 설명을 기술하고자 합니다.
현 프로젝트는 분산 환경에서의 운용을 전제합니다.
현 과제는 분산 환경에서의 동시성 제어 방식과 대규모 트래픽 처리에 필요한 지식 학습 및 경험을 목표로 합니다.
따라서 이번 챕터인 '동시성 제어 방식에 대한 학습 및 적절한 제어 방식 차용' 시에도 이를 항상 고려해야합니다.
예약 대상은 특정 공연 일정의 특정 좌석
입니다.
예를 들어 Oasis의 내한 공연이 3일 간 3회(월, 화, 수) 한국에서 진행된다고 생각해보겠습니다.
이에 대한 실제 예약은 '오아시스 공연' 자체가 아닌 '3일 간의 오아시스 공연 중 특정 일자'에 대한 예약일 것입니다.
이를 반영하기 위해 다음과 같이 설계하였습니다.
ConcertSchedule
테이블의 Row 별 PK 생성 및 할당- 공연 일정 등록 기능 정의 및 구현.
- 각 공연 일정 별 좌석 생성 및 할당. (현 과제 요구사항 상 각 50석)
가예약 상태가 존재합니다.
예약 가능한 좌석에 대하여 예약 시도할 경우 예약 상태가
BOOKED
인 가예약이 생성되어 예약 테이블에 저장되도록 하였습니다.이때 해당 가예약의
expired_at
필드는 생성 시점 기준 "5분 후" 로 설정됩니다.이후 결제가 완료될 경우 해당 예약의 상태를
PAID
로 변경합니다.이때 결제까지 완료되지 않아
BOOKED
상태로 남은 가예약은 스케쥴러에 의해 만료 처리 됩니다.이를 삭제로 구현할 수도 있었으나, 특정 테이블에 대한 빈번한 삽입/삭제가 장애 요인이 될 수 있다고 생각하여 상태 변경을 하는 방향으로 구현하였습니다.
예약 가능 공연 일정 조회
기능 제공을 위해 ConcertSchedule
테이블 내 available_seat_count
필드 정의
이를 구현하는 방법으로는 크게 두 가지가 존재합니다.
- 해당 공연 일정의 특정 좌석에 대한 예약 발생 시(가예약 포함) 필드 내 예약 가능 좌석 수 차감.
- '예약 가능 공연 일정 조회' 기능이 호출될 때마다
COUNT
함수 쿼리 시행.저는 도메인 모델과 JPA entity를 분리하는 과정에서
ConcertSchedule
도메인 모델 생성 시UUID
타입의 무작위에 가까운 식별자를 생성하고 이를 DB에서도 PK로 활용하도록 구현하였습니다.이러한 설계를 고려했을 때 16 Byte 길이를 갖는 UUID에 대한 집계 함수 시행 시 쿼리 실행 효율이 떨어질 것을 고려하여 첫 번째 방법을 선택하여 구현하였습니다.
요구사항 상으로도 '좌석 상태 변경'과 '예약 가능 좌석 수 차감'은 원자적으로 시행되어야 하는 프로세스라고 생각했습니다.
@Transactional
: Facade 내에 Method 수준에 적용
트랜잭션의 Scope가 지나치게 넓을 경우 다음의 상황을 경계해야 함을 학습하였습니다.
- 해당 트랜잭션 Scope 내 Third-party API 호출과 같이 처리 시간이 길어질 수 있고 이런 변인을 서버 내에서 통제할 수 없는 상황이 발생할 경우 Connection Pool Dry-out이 발생할 수 있습니다.
- 파사드 메서드 내에서 어떤 메서드 A와 'A 수행 결과가 Commit 되었음을 전제하고 수행되는 메서드 B'가 순차적으로 호출되도록 구현될 경우, 트랜잭션 전파(Propagation) 에 대한 고민이 필요합니다. 그렇지 않을 경우
READ_UNCOMMITTED
가 발생할 수 있습니다. (바보 같은 구현했다가 덤으로 스프링의 Transaction 처리 방식도 배우게 된 사람)이러한 과정을 고려했을 때도 Domain Service 가 아닌 Facade 레벨에 정의한 이유는 다음과 같습니다.
- 그럼에도 불구하고 Handler Method를 통해 들어온 요청에 대해 비즈니스 로직으로의 단일 진입점을 제공해주어야 하는 파사드의 역할 상, 복수의 domain service 가 원자적으로 수행되어야 하는 상황이 분명 존재한다. (ex. 가예약 시도 시 '좌석 상태 변경', '가예약 생성' 등)
따라서 파사드 클래스 내의 메서드 수준에
@Transactional
을 적용해주었습니다.
특정 공연 일정에 대해 생성되는 좌석은 50석입니다.
공연 좌석 예매의 Usecase를 고려해보면 특정 좌석에 대한 동시 다발적 예약 시도가 발생할 가능성이 아주 높음을 알 수 있습니다.
예약 가능 좌석 목록 조회 시점과 실제 해당 좌석에 대한 예약 시도 사이에 필연적으로 지연 시간이 존재하는데, 바로 이 지연 시간 동안 손이 빠른 어떤 사용자가 이미 선점할 수 있기 때문입니다.
따라서 '좌석'이라는 자원에 대한 선점 시도 요청은 어떤 경우에도 동시에 처리되어서는 안되는 것 입니다.
이를 보장하기 위해선 '좌석 선점', 즉 좌석의 상태 변경 시에 동시성 제어가 필요합니다.
이 부분에서의 동시성 문제는 제 과제 결과물의 요상한(?) 구현에 기인합니다.
어떤 공연 일정의 특정 좌석에 대한 예약(가예약 생성 및 예약 만료에 따른 좌석 선점 상태 변경 포함) 처리 시 이에 따라 위의 필드를 증감시킵니다.
따라서 동시 다발적 예약 시도(각각 다른 좌석)가 존재할 경우, 이러한 좌석 예약 처리가 성공함과 동시에 '예약 가능 좌석 수' 또한 정확하게 차감되어야 하고,
결제 미이행으로 인한 만료된 예약 처리 과정에서 '좌석 상태 변경(UNAVAILABLE
-> AVAILABLE
)' 또한 해당 필드에 대한 정확한 증가 처리가 되어야 합니다.
아휴
제가 선택하여 구현한 과제 시나리오는 '공연 예약 서비스' 입니다.
어떤 좌석에 대한 예약 시행 시 결제는 '이미 충전된 금액을 사용하여' 처리되도록 하였습니다.
이때 '결제'는 언제 수행될까요? 결제는 예약 과정의 가장 마지막에 한 번 수행됩니다.
따라서 '충전과 사용이 동시에 발생하는 상황' 시나리오는 배제할 수 있습니다.
그렇다면 '동시 충전' 혹은 '동시 사용'은 어떨까요?
이러한 일은 네트워크 환경, 사용자의 실수 및 의도로 인한 복수 요청 전송으로부터 충분히 발생가능하기 때문에 동시성 제어가 필요하다고 판단했습니다.
이에 대한 제어는 사실 '기획 수준에서의 정책'에 따라 결정되어 단순히 구현되어 제공될 뿐이므로 제어 방식 결정 주체가 개발자에게 있지 않다고 생각합니다.
다만, 학습 목적과 과제 구현 용이성, 그럼에도 발생 가능한 상황에 대한 고려를 경험해보기 위해 동시 사용 및 동시 충전에 대한 동시성 제어 또한 진행해보기로 하였습니다.
동시 사용과 동시 충전이 '단 한 번만 이루어지도록' 하는 것이 적절하다고 생각하여 해당 방향으로 구현해보기로 하였습니다.
앞서 파악한 동시성 문제 발생 지점 및 제어가 필요한 부분에 대해 몇 가지 동시성 제어 방안을 직접 적용하여 검증해볼 것입니다.
이를 위해 해당 동시성 문제를 부각시킨 후 동시성 문제 발생 상황을 재연하여 어떤 결과가 도출되는 지 확인해볼 필요가 있습니다.
따라서 아래와 같은 동시성 테스트 시나리오를 준비해보았습니다.
@Test
@DisplayName("성공 : 서로 다른 50개 좌석에 대한 동시 예약 시도 -> 모두 성공, 예약 가능 전여 좌석 0.")
void concurrentReservationsForDifferentSeats() throws InterruptedException, ExecutionException {
// ============= [Setup 로직] =============
// 1) 사용자 50명 생성 및 포인트 충전
List<User> localUsers = new ArrayList<>();
for (int i = 1; i <= 50; i++) {
User createdUser = userApplication.registerUser("userName" + i);
concertReservationApplication.chargeUserPoint(createdUser.getId(), 2000);
localUsers.add(createdUser);
}
// 2) 공연 일정 등록
ConcertSchedule registeredConcertSchedule = concertReservationApplication.registerConcertSchedule(
"concert1", dateTime, reservationStartAt, reservationEndAt, SEAT_PRICE
);
// 3) 공연 일정의 좌석 목록 조회
List<Seat> localSeats = concertReservationApplication.getAvailableSeats(registeredConcertSchedule.getId());
// ============= [동시 예약 로직] =============
// given : 50개의 일정한 규칙을 갖는 좌석 생성 및 이에 대한 예약 요청을 수행할 스레드 초기화.
ExecutorService executor = Executors.newFixedThreadPool(50);
List<Callable<Boolean>> tasks = new ArrayList<>();
// 50명 사용자, 50좌석 동시 예약
for (int i = 1; i <= 50; i++) {
final int idx = i; // final 변수를 통해 스레드 간 메모리 가시성 확보.
tasks.add(() -> {
try {
// 가예약 생성
concertReservationApplication.createTemporaryReservation(
registeredConcertSchedule.getId(),
localUsers.get(idx - 1).getId(),
localSeats.get(idx - 1).getId()
);
// 예약 확정
Reservation reservation = concertReservationApplication.confirmReservation(
registeredConcertSchedule.getId(),
localUsers.get(idx - 1).getId(),
localSeats.get(idx - 1).getId()
);
// 각 스레드 별 작업 결과가 !null && "PAID"인 경우 성공으로 간주
return reservation != null && (reservation.getStatus() == ReservationStatus.PAID);
} catch (UnavailableRequestException e) {
return false; // 실패 처리
}
});
}
Collections.shuffle(tasks);
// when
List<Future<Boolean>> futures = executor.invokeAll(tasks);
executor.shutdown();
int successCount = 0;
for (Future<Boolean> f : futures) {
if (f.get()) successCount++;
}
ConcertSchedule updatedConcertSchedule = concertReservationApplication.getConcertSchedule(registeredConcertSchedule.getId());
// then : 스레드 별 실행 결과를 Future<> 를 통해 확인
assertThat(successCount).isEqualTo(50); // 모든 예약이 성공했는지 확인
assertThat(updatedConcertSchedule.getAvailableSeatCount()).isEqualTo(0); // 예약 가능 좌석이 0인지 확인
}
@Test
@DisplayName("실패 : 동일한 좌석에 대한 동시 5건 예약 시도 -> 1명만 성공, 4명 실패 && 예약 가능 좌석 49석.")
void concurrentReservationsForSameSeat() throws InterruptedException, ExecutionException {
// ============= [Setup 로직] =============
// 1) 사용자 5명 생성 및 포인트 충전
List<User> localUsers = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
User createdUser = userApplication.registerUser("userName" + i);
concertReservationApplication.chargeUserPoint(createdUser.getId(), 2000);
localUsers.add(createdUser);
}
// 2) 공연 일정 등록
ConcertSchedule registeredConcertSchedule = concertReservationApplication.registerConcertSchedule(
"concert1", dateTime, reservationStartAt, reservationEndAt, SEAT_PRICE
);
// 3) 공연 일정의 좌석 목록 조회
List<Seat> localSeats = concertReservationApplication.getAvailableSeats(registeredConcertSchedule.getId());
// given : 5명의 사용자가 동일한 좌석에 대한 예약을 동시에 시도.
ExecutorService executor = Executors.newFixedThreadPool(5);
List<Callable<Boolean>> tasks = new ArrayList<>();
// 5명 사용자, 동일 좌석 동시 예약
for(int i = 1; i <= 5; i++){
final int idx = i; // final 변수를 통해 스레드 간 메모리 가시성 확보.
tasks.add(() -> {
try {
// 가예약 생성
concertReservationApplication.createTemporaryReservation(registeredConcertSchedule.getId(), localUsers.get(idx-1).getId(), localSeats.get(0).getId());
// 예약
Reservation reservation = concertReservationApplication.confirmReservation(
registeredConcertSchedule.getId(), localUsers.get(idx-1).getId(), localSeats.get(0).getId());
// 각 스레드 별로 할당된 task(각각 동일 좌석에 대한 예약 수행) 결과가 !null && PAID 인 경우 성공으로 간주 -> true.
return reservation != null && (reservation.getStatus() == ReservationStatus.PAID);
} catch(UnavailableRequestException | ObjectOptimisticLockingFailureException e){
return false; // 실패 처리
}
});
}
// when : 5명의 사용자가 동일한 좌석에 대한 예약을 동시에 시도.
Collections.shuffle(tasks);
List<Future<Boolean>> futures = executor.invokeAll(tasks);
int successCount = 0;
int failCount = 0;
for(Future<Boolean> f : futures){
if(f.get()) {successCount++;}
else{failCount++;}
}
ConcertSchedule updatedConcertSchedule = concertReservationApplication.getConcertSchedule(registeredConcertSchedule.getId());
// then
assertThat(successCount).isEqualTo(1);
assertThat(updatedConcertSchedule.getAvailableSeatCount()).isEqualTo(49);
}
@Test
@DisplayName("성공 : 동시 사용 요청 2건 중 한 건만 성공한다.")
void onlyOneUsageRequestShouldSuccess_WhenThereAreNUsageRequestForSingleUser()
throws InterruptedException, ExecutionException {
// given : 잔액이 0인 사용자가 존재한다.
User user = userApplication.registerUser("userName");
// when : 해당 사용자에 대한 2건의 사용 요청이 동시에 발생한다.
ExecutorService executor = Executors.newFixedThreadPool(2);
List<Callable<Boolean>> tasks = new ArrayList<>();
for(int i = 0; i < 2; i++){
tasks.add(() -> {
try{
concertReservationApplication.useUserPoint(user.getId(), 1000);
return true;
} catch(UnavailableRequestException e){
return false;
}
});
}
Collections.shuffle(tasks);
List<Future<Boolean>> futures = executor.invokeAll(tasks);
AtomicInteger successCount = new AtomicInteger(0);
for(Future<Boolean> f : futures){
if(f.get()) successCount.incrementAndGet();
}
UserPointBalance updatedUserPointBalance = concertReservationApplication.getUserPointBalance(user.getId());
// then : 1건만 성공하고 1건은 실패한다. 이에 따라 해당 사용자의 잔액은 1,000이다.
assertThat(successCount.get()).isEqualTo(1);
assertThat(updatedUserPointBalance.balance().getAmount()).isEqualTo(1000);
}
@Test
@DisplayName("성공 : 동시 충전 요청 2건 중 한 건만 성공한다.")
void onlyOneChargeRequestShouldSuccess_WhenThereAreNChargeRequestForSingleUser()
throws ExecutionException, InterruptedException {
// given : 잔액이 0인 사용자가 존재한다.
User user = userApplication.registerUser("userName");
// when : 해당 사용자에 대한 N건의 충전 요청이 동시에 발생한다.
ExecutorService executor = Executors.newFixedThreadPool(2);
List<Callable<Boolean>> tasks = new ArrayList<>();
for(int i = 0; i < 2; i++){
tasks.add(() -> {
try{
concertReservationApplication.chargeUserPoint(user.getId(), 1000);
return true;
} catch(UnavailableRequestException e){
return false;
}
});
}
Collections.shuffle(tasks);
List<Future<Boolean>> futures = executor.invokeAll(tasks);
AtomicInteger successCount = new AtomicInteger(0);
for(Future<Boolean> f : futures){
if(f.get()) successCount.incrementAndGet();
}
UserPointBalance updatedUserPointBalance = concertReservationApplication.getUserPointBalance(user.getId());
// then : 1건만 성공하고 1건은 실패한다. 이에 따라 해당 사용자의 잔액은 1,000이다.
assertThat(successCount.get()).isEqualTo(1);
assertThat(updatedUserPointBalance.balance().getAmount()).isEqualTo(1000);
}
낙관적 락은 역설적이게도, 어떠한 데이터에 대해서도 상호 배타적인 락을 실시하지 않습니다.
낙관적 락 은 Non-Locking Concurrency Control Mechanism, 즉 복수의 트랜잭션이 실제 데이터에 대한 쓰기/읽기 작업 수행 시, 각 트랜잭션이 상호 배타적으로 수행되도록 하는 조치를 수반하지 않는 동시성 제어 기법입니다.
그렇다면 어떻게 동시성 제어를 하느냐?
바로 '지금 내가 삽입하려는 데이터가 가장 최신의 데이터를 활용하여 처리한 결과가 맞는가?' 에 대한 확인을 하는 것입니다.
그리고 만약 내가 지금 쓰려고 하는 데이터가 가장 최신이 아니라면 이미 그 사이에 다른 트랜잭션이 해당 데이터에 대한 쓰기 작업을 수행하고 커밋까지 완료한 것이므로, 해당 쓰기 작업을 수행하지 않도록 하고 이를 통해 데이터 정합성을 유지하는 것입니다.
이를 어떻게 구현하는 방법은 사실 복잡하지 않습니다.
바로 지금 내가 보고 있는 데이터의 버전을 관리하고 해당 데이터에 대한 변경 트랜잭션 수행 후 저장 혹은 읽기 시에 해당 버전이 가장 최신인지 확인하도록 애플리케이션 레벨에서 강제하는 것으로도 낙관적 락의 기본적인 골자를 구현한 것이기 때문입니다.
따라서 다음과 같은 절차로 데이터 읽기/쓰기가 시행되도록 직접 구현하면 됩니다.
사용자가 별도로 도메인 모델 혹은 Persistence Layer로 전달되는 객체 내부에 '수정 시간' 혹은 '버전'과 같은 정보를 담은 필드를 선언.
데이터 최초 생성 시에 이 필드에 적절히 정보를 채워 저장.
데이터 조회 후 변경 작업 마친 결과물을 다시 저장하기 전, 현재 수정한 데이터의 '버전'과 영속성 저장소에 저장된 데이터의 버전을 비교.
비교 결과 일치하는 경우 '버전'도 함께 갱신 후 저장. 일치하지 않는 경우 저장을 하지 않음.
물론 Spring Data JPA 에서는 사용자가 직접 구현하는 수고를 덜 수 있도록 @Version
어노테이션을 제공합니다.
이 또한 위와 마찬가지의 방식으로 작동합니다.
낙관적 락은 위와 같이 비교적 간단한 메커니즘 기반으로 작동함과 동시에 정상 작동 하기 위해 필요한 변수 및 별도의 장치가 비대하지 않다는 점에서 상당히 가벼워보입니다.
이를 연역적으로 고찰할 경우, 성능 오버헤드에 대한 고민이 깊을 필요가 없다는 결론을 끌어낼 수도 있을 것으로 보입니다.
하지만 이것으로 낙관적 락의 장점을 정의하기에는 충분하지 않습니다.
직접 적용해보고 사용한 후에 결과를 살펴본 후, 이론과 경험을 토대로 낙관적 락의 장점과 한계를 도출해보도록 하겠습니다.
적용 과정은 크게 세 단계로 소개해보도록 하겠습니다.
@Entity
@Table(name = "CONCERTSCHEDULE")
@Getter
public class ConcertScheduleJpaEntity extends BaseJpaEntity{
@Id
@Column(name = "concert_schedule_id")
private String concertScheduleId;
private String concertId;
private int availableSeatsCount;
private LocalDateTime datetime;
private LocalDateTime reservationStartAt;
private LocalDateTime reservationEndAt;
@Version
private long version; // Row 단위 Optimistic Locking을 위한 Version 필드.
...
public ConcertScheduleJpaEntity updateFromDomain(ConcertSchedule domain) {
this.concertScheduleId = domain.getId();
this.concertId = domain.getConcertId();
this.datetime = domain.getDateTime();
this.reservationStartAt = domain.getReservationStartAt();
this.reservationEndAt = domain.getReservationEndAt();
this.availableSeatsCount = domain.getAvailableSeatCount();
return this;
}
}
동시 포인트 사용 / 충전 테스트 : 통과
동일 좌석에 대한 복수 예약 요청 시 한 건만 성공, 나머지 실패 : 통과
서로 다른 50석의 좌석에 대해 50명의 사용자 동시 예약 요청 시 모두 성공 : 실패
앞서 기술하였던 동시성 시나리오를 검증하는 통합 테스트 실시 결과, 오직 '서로 다른 50개의 좌석에 대해 50명의 사용자 동시 예약 요청' 시나리오만 실패하였습니다.
또한 성공하는 예약의 수 마저도 편차가 존재했습니다.
이 부분이 실패했다는 것은 바로 '공연 일정 테이블 내에 존재하는 available_seat_count
갱신` 부분에서의 동시성 제어가 실패했다는 것을 의미합니다.
현재 가예약 생성 시 해당 공연 일정의 '예약 가능 좌석 수'의 차감이 원자적으로 발생하도록 구현되어있는 상태입니다.
이 시나리오에서 낙관적 락에 근거한 동시성 제어가 실패하는 원인으로 크게 두 가지를 지적해볼 수 있습니다.
TOCTOU(Time-of-check, Time-of-use) 문제 발생
반드시 커밋되어야 하는 트랜잭션의 좌절
하나씩 차근차근 알아보도록 하겠습니다.
TOCTOU 문제란, 시스템의 특정 부분을 확인하는 작업과 확인한 결과를 활용하여 어떤 동작을 수행하는 작업 간 정보 격차에 의해 프로세스 수행 결과의 일관성이 지켜지지 않는 Race Condition 성질의 문제입니다.
실패한 시나리오의 해당 문제 발생 지점을 도식화 해보면 다음과 같습니다.
sequenceDiagram
participant T1 as 트랜잭션 1
participant DB as 데이터베이스
participant T2 as 트랜잭션 2
T1->>DB: SELECT SELECT ConcertSchedule.available_seat_count
T2->>DB: SELECT SELECT ConcertSchedule.available_seat_count
note over T1,T2: 두 트랜잭션 모두 잔여 좌석 50, version=0 엔티티를 읽는다.
T1->>DB: 사용 가능 좌석 수 차감 : 50->49
T2->>DB: 사용 가능 좌석 수 차감 : 50->49
note over DB: "충돌 발생" <br/> T2가 갱신을 시도하나, 이미 해당 Entity의 version=1 로 갱신된 상태.
DB-->>T1: 좌석 상태=사용 불가, 남은 좌석=49
DB-->>T2: 트랜잭션 실패 및 롤백
이처럼 읽어올 시점의 데이터 상태(해당 엔티티의 버전)와 저장 시점의 데이터 상태(해당 엔티티의 버전)에 차이가 발생하였고 낙관적 락의 메커니즘에 따라 늦게 도착한 트랜잭션의 커밋이 좌절됨으로써 갱신 분실 이 발생하는 것입니다.
즉, TOCTOU 문제에 의해 갱신 분실이 발생하는 상황인 것입니다.
사실 이는 낙관적 락의 실패라고 단정 짓기 어렵습니다.
커밋 시도 시점에 이미 다른 트랜잭션이 커밋되어 데이터가 갱신된 경우 이를 덮어쓰기 하지 않음으로써 데이터의 정합성 자체는 지켜냈기 때문입니다.
덕분에 모순 감지 와 커밋되지 않은 데이터에 대한 의존 과 같은 트랜잭션 이상현상(Anomaly)를 예방할 수 있었습니다.
즉 낙관적 락은 해당 락의 본질적 목표인 '동시성 제어'에는 성공했다고 할 수 있습니다.
하지만 다시, 낙관적 락이 해당 시나리오에서 '실패'한 이유는 해당 락의 메커니즘이 동시성 문제 발생 상황에서 비즈니스 요구사항이 지켜지도록 하지는 못했기 때문 입니다.
우리의 비즈니스 요구사항 상 가예약 체결 시 정확하게 예약 가능 좌석이 차감되어야 합니다만, 이 부분이 실현되지 못한 것입니다.
이제 이 부분 - 반드시 커밋되어야 하는 트랜잭션의 좌절 에 집중해보겠습니다.
비즈니스 요구사항을 위해선 매 가예약 체결 성공 시마다 정확히 예약 가능 좌석 수는 차감이 되어야합니다.
반드시 트랜잭션으로부터 발생한 변경 내역이 반영이 되어야 하는 상황인 것입니다.
하지만 낙관적 락은 버전 불일치를 감지하자마자 순번 상 나중에 도착한 트랜잭션의 커밋을 반영하지 않고 실패 처리함과 동시에 롤백을 수행합니다.
이를 어떻게 방지할 수 있을까요?
바로 재시도 구현 입니다.
좌절된 커밋이 곧바로 실패 처리 및 롤백 되지 않고 어딘가에 보관되었다가 미리 설정한 특정 시간 이후 커밋 반영을 재시도하여 반영될 수 있도록 구현하는 것입니다.
재시도가 가능하도록 구현하기 위해선 다음과 같은 추가적인 부분을 실현하는 코드 작성이 필요합니다.
좌절된 커밋과 해당 커밋이 재시도 될 시간을 함께 보관하는 장소와 이를 구현하는 코드
해당 장소를 주기적으로 순회하며 재시도 차례가 된 커밋을 가져와 재시도가 이루어질 수 있도록 하는 코드
비록 Spring Framework 진영에서 제공하는 spring-retry
라는 의존성을 주입하고 이로부터 제공되는 인터페이스를 사용하여 이를 보다 편리하게 구현할 수 있지만, 이 또한 유지보수가 필요한 부분이 될 것이 자명할 뿐만 아니라 '어느 정도 시간을 기준으로 재시도 시간 정책을 정해야 하는가'와 같은 새로운 고민거리도 안게 됩니다.
이러한 재시도 방안의 구현이 충분히 안정성을 갖추더라도 분산 환경에서는 여전히 취약점을 드러냅니다.
다수의 서버 인스턴스로부터 낙관적 락 조치가 취해진 DB 데이터에 대한 읽기/쓰기 시 자연스레 버전 충돌에 의한 재시도 발생 빈도가 증가하고 이로 인해 서비스 자체의 응답성이 저하될 가능성이 농후하기 때문입니다.
비록 마지막엔 단점이 잔뜩 적혀있으나, 합리적인 재시도가 보장되도록 추가적인 기능을 구현할 경우 낙관적 락은 성능 상 오버헤드가 다른 락 메커니즘에 비해 크지 않다는 점을 고려했을 때 충분히 경쟁력 있는 상대우위를 갖게 됩니다. 또한 다음과 같이 이러한 재시도 로직이 필요하지 않은 경우엔 장점만 남게 되므로 굉장히 적절한 선택지로서 존재할 수 있게 됩니다.
동시 충전/사용 시나리오
- 앞서 기술했듯, 한 번만 성공하도록 하면 되므로 낙관적 락을 활용하여 처음 커밋된 트랜잭션 이후의 모든 트랜잭션이 실패하도록 하는 것으로도 충분히 목적 달성 가능.
이처럼 낙관적 락을 선택하는 경우엔 '비즈니스 요구사항 상 재시도가 필요한 부분인가?', '그렇다면 재시도가 가능하도록 구현하는 비용을 감안하더라도 성능 상 오버헤드가 적다는 성능 효율 측면에서의 이점을 선택할 필요가 있는가?' 를 잘 고려하여야 합니다.
이와 같은 구현 및 시행 결과, 분석과 결론에 따라 저는 '사용자의 포인트 잔액' 에 대하여 낙관적 락을 적용하기로 하였습니다.
비관적 락 또한 상당히 간결한 메커니즘으로 작동합니다. 다만 낙관적 락과 다른 것은, 실제로 행 혹은 테이블 전체 단위로 '락'을 걺으로써 이에 대한 읽기 및 쓰기 작업을 수행하는 트랜잭션들이 상호배타적으로 수행될 수 있도록 한다는 것입니다.
간결한 메커니즘 만큼 적용법도 상당히 간단합니다.
비관적 락의 대상이 되는 자원을 읽기/쓰기 하는 쿼리가 발생하는 부분에 @Lock(LockModeType.PESSIMISTIC_WRITE)
와 같이 명시적으로 쓰기 작업에 대한 비관적 락 적용을 명시하거나,
@Query(select ... for update)
와 같이 쿼리 차원에서 비관적 락 사용을 명시할 수 있습니다.
비관적 락은 견고한 락 메커니즘을 제공하는만큼 강도 높은 트래픽 발생 상황에서 반드시 고려해야 하는 부분이 있습니다.
비관적 락은 공유 자원을 선점한 트랜잭션의 작업이 종료되고 커밋되는 시점까지 다른 트랜잭션의 접근을 원천 차단합니다.
이때 DB와의 연결을 유지한 상태이기 때문에, 강도 높은 트래픽이 발생하는 경우 커넥션 풀이 말라버리는 현상(Connection Pool Dry-out)이 발생할 가능성이 높아집니다.
또한 비관적 락의 메커니즘 상 교착상태(Deadlock) 발생 주요 조건인 네 가지 - 환형 대기, 상호 배제, 점유와 대기, 비선점 - 조건을 충족시킬 수 있는 상황이 발생할 빈도가 높습니다.
이에 따라 데드락이 발생할 경우 서비스의 가용성 자체가 큰 폭으로 저하되고 이를 관리하기 위한 별도의 작업이 수반됩니다.
저는 가장 먼저 왜 이름이 '분산 락'인지가 궁금했습니다.
사람들에게 '분산 락이 무엇이냐'고 물었을 때 가장 높은 빈도로 돌아오는 대답인 '그냥,,, 레디스 같은 거 써서 하는 건데,, 분산 환경에서 강점이 있고..' 로는 선뜻 해당 이름이 붙은 이유를 이해하기가 어려웠습니다.
조사와 더불어 고민해본 결과 '분산 락'이라는 이름이 붙은 이 락은 '경합을 하는 대상이자 공유자원인 데이터를 제공하는 데이터 소스(ex. 관계형 데이터베이스)가 해당 자원에 대한 권리를 부여하는 것이 아닌, 외부의 어떤 논리/물리 장치를 통해 해당 자원에 대한 읽기/쓰기 권한을 부여하고 관리하는 락 메커니즘'입니다.
바로 이처럼 '공유 자원에 대한 권한 부여 및 관리'를 데이터 제공 출처가 아닌 외부에 존재하는 다른 논리/물리 장치를 통해 제공하기 때문에 분산 락(Distributed Lock) 이라는 이름이 붙은 것으로 이해했습니다.
컴퓨터 공학이라는 분야 내에서 존재하는 다양한 개념들 중 많은 것들이 '필요에 의해 고안된 것'이라는 생각을 종종 합니다.
그렇다면 우리는 왜 잘 작동하는 DB 락을 두고 분산 락이라는 친구를 필요로하게 되었고 결국 만들게 되었을까요?
두 락 메커니즘의 작동방식과 분산 환경에서의 적용 시나리오를 두고 둘을 비교하며 이 질문들에 대한 답을 찾아가보려 합니다.
분산 락에 앞서 기술했던 DB 락은 DBMS(비관적 락) 혹은 애플리케이션 수준(낙관적 락)에서 제공하는 동시성 제어 메커니즘입니다.
분산 락을 두고 DB 락의 특징을 고민해보자면 다음과 같은 대표적인 특성들을 꼽아볼 수 있을 것입니다.
트랜잭션 중심: DB 락은 트랜잭션이 데이터를 읽거나 쓸 때 자동으로 설정됩니다. 트랜잭션이 완료되면 락이 해제됩니다.
락의 종류: 행 락(Row Lock), 테이블 락(Table Lock), 페이지 락(Page Lock) 등 다양한 수준의 락을 제공합니다. 하지만 결국 DB 내의 데이터 포맷에 국한됩니다.
데드락 처리: DBMS는 교착 상태(Deadlock)를 탐지하고 해결하기 위한 메커니즘을 내장하고 있습니다. 교착 상태가 발생하면 일부 트랜잭션을 롤백하여 해결합니다.
생각보다 복잡하지 않은 방식으로 작동하는 동시성 제어 메커니즘이지만 그럼에도 뚜렷한 장점도 갖습니다.
간편한 사용: 별도의 락 관리 로직을 구현할 필요 없이 DBMS가 자동으로 처리합니다.
락 메커니즘 제공 자체로부터 발생하는 성능 오버헤드가 높지 않음
심지어 작동도 잘 합니다. 성능도 나쁘지 않습니다.
오히려 분산 락의 경우 락 제공 주체가 외부에 존재하기 때문에 이와의 통신 비용으로 인해 DB 락에 비해 상대적으로 느린 응답 시간을 필연적으로 갖는다는 점을 고려해보면 성능 상 우위에 있다고 보아도 무리가 아닙니다.
그러나 DB가 존재하는 목적을 다시 한 번 상기해볼 필요가 있습니다.
DB는 기본적으로 물리 저장 장치(제가 처음 공부를 시작하던 때에는 물리 저장 장치 기반으로 배웠습니다)에 근거합니다.
따라서 휘발성인 메모리 기반의 저장 장치와 다르게 보관한 정보를 상대적으로 긴 시간동안 안정적으로 손실 없이 보관할 수 있게 되는 것입니다.
이러한 특성에 근거하여 DB는 오랜 시간을 두고 재사용 가치가 있는 데이터를 저장하는 장소로 존재하고 활용되고 있습니다.
우리가 흔히 영속성 저장소(Persistence Storage) 라고 말하는 것도 통상 이 DB(특히 관계형 데이터베이스)를 지칭하는 것입니다.
이 정도까지 정리해두고 다시 많은 트래픽이 동시성 제어가 필요한 공유 자원에 대한 읽기/쓰기(특히 쓰기) 권한을 획득하고자 DB의 문을 두드리는 상황을 그려보겠습니다.
과연 이렇게 많은 인파를 감당하는 것이 DB의 책임일까, 생각해보면 절대로 그렇지 않습니다.
기본적으로 트래픽을 관리하는 것은 저장소의 역할이 아닌 서비스의 책임이라고 생각합니다.
실제로 동시성 제어의 핵심은 잠시 순서 보장을 떼어놓고 보더라도, 공유 자원에 대한 요청이 순차적으로 처리될 수 있도록 제어하는 것입니다.
그런 관점에서 어차피 해당 공유 자원이 저장된 DB로의 실제 접근이 필요한 요청은 발생한 요청 중 정말 소수일 것입니다.
동시성 제어가 필요해 시행한다는 시나리오에서 100개의 요청이 모두 DB에 닿아야할까에 대한 고민을 해보면 금방 알아챌 수 있습니다.
정리하자면 아래의 두 가지 이유로 "공유 자원에 대한 동시 다발적 요청으로인해 발생하는 부하"는 DB의 책임으로부터 분리되는 것이 바람직하다고 생각합니다.
DB의 책임은 해당 서비스가 유의미한 가치를 창출해낼 수 있도록 재사용 가치가 있는 정보를 안정적으로 저장하는 것입니다.
동시성 제어 실시하는 경우 실질적으로 DB에 접근이 허가되어 접근하게 되는 요청은 발생 요청 수 대비 적은 비율이다. 따라서 나머지가 DB와 접촉하는 것은 불필요하다.
지금까지 살펴본 바론 이론적으로 혹은 직관적으로 이 '부하'를 DB로부터 분리해내는 방법이 있다면 좋겠다는 생각을 할 수 있습니다.
하지만 실제로 비분산 환경이라면 - 즉 서버 인스턴스가 한 대 - 이 부하는 DB의 안정성을 저해할만큼 유의미하지 않습니다.
DB Server 컴퓨터의 사양에 따라 다르지만 일반적으로 시장에 출시된 서비스에 사용되는 경우 다음과 같은 데이터가 존재합니다. 링크
An e-commerce company’s MySQL server handled up to 50,000+ queries per second on a single DELL PE 1750 with 2 single-core CPUs and 2GB of RAM.
그럼에도 불구하고 사람들은 왜 충분하지 못하다고 느끼고 결국 분산 락이라는 다른 개념을 고안해낸 것일까요?
결론부터 말씀드리자면 이젠 하나의 DB로 감당하기 어려운 상황이 되었기 때문이라고 생각합니다.
이미 널리 알려진 바대로 모바일 장치의 대중화 등과 같은 이유로 인해 웹에 참여하여 활동하는 사람의 수가 큰 폭으로 증가하고 이에 따라 기존 산업의 기반 또한 실물에서 웹으로 전환되며 이처럼 기하급수적으로 성장한 사람들의 수요를 감당할 수 있는 시스템을 필요로 하게 되었습니다.
이와 더불어 아무리 단일 DB 서버의 성능을 고사양으로 구비하더라도 지리적 문제(요청이 발생하는 지역이 지리적으로 훨씬 더 넓어졌음)로 인해 대두되는 네트워크 지연 시간을 해결하기 위한 방안으로 자연스레 분산 시스템 설계가 등장하게 되었습니다.
그럼 DB를 물리적으로 여러 곳에 두면 되지 않느냐라는 생각을 해보았습니다. 하지만 DB의 분산은 상당히 높은 난이도를 가집니다.
이를 증명하듯, 데이터베이스의 확장에 관해 CAP Theorom 이라는 이론이 존재합니다.
데이터베이스의 확장은 다음의 세 가지 중 최대 두 가지만 취할 수 있다는 이론입니다.
일관성(Consistency) : 분산된 시스템을 구성하는 클러스터 내 노드들에 저장된 데이터의 일관성을 유지하는 것
가용성(Availability) : 클러스터를 구성하는 노드(ex. DB 하나)가 장애로 인해 작동 불능 상태가 되어도 요청에 대한 응답을 반환할 수 있어야 한다는 것.
분할 내성(Partition Tolerance) : 클러스터를 구성하는 노드 간 통신에 장애가 발생하더라도 전체 클러스터가 요청에 대한 응답을 정상적으로 반환할 수 있어야 한다는 것
흔히 DB를 분산 시스템 형태로 구성하는 대표적인 두 가지 방법이 바로 샤딩 과 Leader-Follower(Master-Slave) 구조로의 설계 를 꼽을 수 있는데요, 그럼에도 불구하고 DB를 분산하는 것은 굉장히 어렵다는 점은 변하지 않습니다.
DB 분할은 어렵고, 그대로 두자니 DB의 책임과는 멀어보이는 '부하에 대한 책임'은 점점 커지는 상황.
그렇기 때문에 공유 자원에 대한 접근 권한 부여 및 관리를 이로부터 분리하여 시스템을 구성하는 설계가 고안되었다고 생각합니다.
DB 보다 확장이 용이하고, 가볍고, 필요 시 다원화하기 용이한 것.
그런 것을 만들어 이를 통해 DB에 대한 접근 권한을 통제함으로써 동시성 제어를 실현하는 것.
바로 이를 구현한 것이 분산 락입니다.
분산 락은 여러 노드나 프로세스가 분산된 환경에서 공유 자원에 대한 접근을 조율하기 위해 사용하는 락 메커니즘입니다.
그렇지만 기존에 없던 락 메커니즘은 아닙니다.
아래에서 다룰 Redisson의 RLock
은 우리가 1주차 때 다루었던 Java의 Reentrant Lock
을 구현하여 제공하는 것이기 때문입니다.
이러한 분산 락은 주로 외부의 분산 제공 및 조율 서비스(예: Redis, ZooKeeper)를 통해 관리됩니다.
주요 특징으론 아래의 성질을 꼽아볼 수 있습니다. 그리고 이 특징들은 분산 환경에서 DB 락보다 분산 락 활용이 더 선호되는 이유기도 합니다.
다중 노드 환경 지원: 여러 서버나 애플리케이션 인스턴스가 동시에 락을 요청하고 관리할 수 있습니다.
고가용성: 분산 락 시스템은 여러 노드에 걸쳐 락 상태를 복제하여, 일부 노드가 장애를 겪더라도 락 관리가 지속됩니다.
확장성: 락 관리가 분산되어 있어, 시스템 확장 시에도 성능 저하 없이 락을 관리할 수 있습니다.
유연한 락 전략: 리엔트런트 락, 타임아웃 설정, 자동 락 해제 등 다양한 락 관리 전략을 지원합니다.
앞서 분산 락의 등장 배경에 관한 고민에서 기술했듯 분산 환경에서 특히 유용한 다음의 장점을 지닙니다.
확장성: 시스템이 확장되어도 락 관리 성능이 유지됩니다.
고가용성: 일부 노드의 장애에도 락 관리가 지속적으로 이루어집니다.
다양한 락 전략: 비즈니스 요구사항에 맞춘 유연한 락 관리가 가능합니다.
트랜잭션 외부의 자원 관리: 데이터베이스 외부의 자원(예: 파일, 캐시, 외부 서비스 등)에 대한 락 관리가 가능합니다. 즉, DB 와는 달리 다양한 포맷의 데이터에 대한 락 제공이 가능합니다. 활용도가 높은 것입니다.
이처럼 분산 환경에서의 활용 측면에서 DB Lock과 비교해 뚜렷한 장점을 가진 분산 락이지만 단점 또한 존재합니다.
복잡성 증가: 분산 락 시스템을 설정하고 관리하는 것이 DB 락에 비해 복잡할 수 있습니다.
추가 인프라 필요: 별도의 분산 락 관리 서비스를 운영해야 하므로 추가적인 인프라가 필요합니다.
일관성 관리: 분산 환경에서 락의 일관성을 유지하기 위해 추가적인 노력이 필요할 수 있습니다.
특징 | DB 락 | 분산 락 |
---|---|---|
운영 환경 | 단일 데이터베이스 인스턴스 내 | 분산 시스템, 여러 노드 |
확장성 | 제한적 | 뛰어남 |
고가용성 | 데이터베이스 장애 시 락 관리 중단 가능 | 다중 노드 복제 및 Fail-over 가 용이하므로 높은 가용성 확보 가능 |
성능 | 디스크 I/O와 네트워크 지연으로 상대적으로 느림 | 인메모리 기반으로 빠름 |
락 관리의 유연성 | 행, 테이블 단위의 기본 락만 제공 | Re-entrant Lock, 타임아웃, 자동 해제 등 다양한 락 전략 지원 |
복잡성 | 간단히 트랜잭션 내에서 자동 관리 | 락 시스템 설정 및 관리가 복잡할 수 있음 |
사용 사례 | 데이터베이스 트랜잭션 내 데이터 일관성 유지 | 분산 캐시, 마이크로서비스 간 자원 접근 조율, 리더 선출 등 |
지금까지 분산 락이 무엇인지, 왜 필요한지, 어떤 상황에서 DB Lock 보다 유리할 수 있는지 살펴보았습니다.
그러니 이제 한 번 직접 적용하며 살펴보도록 하겠습니다.
/**
* Redis Java API 중 하나인 Redisson 연결을 위한 설정 객체.
*/
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort); // redis://blah:blah 형태.
redisson = Redisson.create(config);
return redisson;
}
}
Annotation
/**
* Redisson에서 제공하는 분산 락 활용을 위한 AOP 어노테이션.
*/
@Target(ElementType.METHOD) // 메소드에 적용 가능하도록 설정
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 어노테이션 정보 유지
public @interface RedissonDistributedLock {
/**
* 락의 대상이 되는 자원의 식별자. ex. userId, concertScheduleId 등
*/
String key();
/**
* 락이 유지되는 시간 : 별도의 시간 단위 명시 없을 경우 초 단위로 설정.
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 락 획득을 기다리는 시간 (default - 5s). 즉, 락 획득 실패 시 재시도까지 기다리는 시간을 의미.
*/
long waitTime() default 500L;
/**
* 락 임대 시간 (default - 3s)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 1L;
}
AOP Action
@Aspect
@Order(1)
@Component
@Slf4j
@RequiredArgsConstructor
public class RedissonDistributedLockAop {
private static final String LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
@Around("@annotation(redissonDistributedLock)")
public Object lock(ProceedingJoinPoint pjp, RedissonDistributedLock redissonDistributedLock) throws Throwable {
// 1) 락을 걸 대상의 키를 추출
String paramName = redissonDistributedLock.key();
// 2) 메서드의 파라미터 이름과 값 추출
MethodSignature signature = (MethodSignature) pjp.getSignature();
String[] parameterNames = signature.getParameterNames(); // ex) ["concertId","seatId"]
Object[] args = pjp.getArgs(); // ex) ["C123","S45"]
// 파라미터 이름으로 파라미터 인덱스 찾기
int paramIndex = -1;
for (int i = 0; i < parameterNames.length; i++) {
if (parameterNames[i].equals(paramName)) {
paramIndex = i;
break;
}
}
if (paramIndex == -1) {
throw new IllegalStateException(
"해당 이름을 가진 메서드 파라미터가 존재하지 않습니다. : " + paramName + " _ 발생 메서드 : " + signature.getMethod());
}
// 3) 락을 걸 대상의 키 생성. ex) LOCK:concertId-C123
String lockKey = LOCK_PREFIX + ":" + parameterNames[paramIndex] + "-" + args[paramIndex];
// Redisson의 ReentrantLock 구현체인 RLock 객체 생성
RLock rLock = redissonClient.getLock(lockKey);
log.info("[RedissonLockAspect] 다음 키에 대한 락 획득 시도 중입니다.: {}", lockKey);
boolean currentlyLocked = false; // 현재 락 획득 상태
try {
boolean isLockAvailable = rLock.tryLock(redissonDistributedLock.waitTime(), redissonDistributedLock.leaseTime(), redissonDistributedLock.timeUnit());
if (!isLockAvailable) {
throw new UnavailableRequestException("다음 자원에 대한 락 획득에 실패하였습니다. 이미 해당 자원에 대한 락이 존재합니다.: " + lockKey);
}
rLock.lock(); // 락 실시
currentlyLocked = true; // 현재 락 획득 상태 변경.
log.info("[RedissonLockAspect] 다음 키에 대한 락 획득에 성공하였습니다.: {}", lockKey);
return pjp.proceed(); // 해당 어노테이션이 표기된 메서드 실행. 이때 해당 메서드의 @Transactional 이 적용된다.
} finally {
// 메서드 실행 후 락 해제.
if (currentlyLocked && rLock.isHeldByCurrentThread()) {
rLock.unlock();
log.info("[RedissonLockAspect] 다음 키에 대한 락이 해제되었습니다.: {}", lockKey);
}
}
}
}
/**
* 특정 공연 일정의 특정 좌석에 대한 가예약을 요청합니다.
* @param concertScheduleId
* @param userId
* @param seatId
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@RedissonDistributedLock(key = "seatId")
public Reservation createTemporaryReservation(String concertScheduleId, String userId, String seatId){
// 해당 좌석 할당 처리(AVAILABLE -> UNAVAILABLE) : 만약 해당 좌석 이미 선점 됐을 경우 여기서 실패.
Seat assignedSeat = concertService.assignSeatOfConcertSchedule(concertScheduleId, seatId);
// 가예약 생성 및 반환.
return reservationService.createReservation(userId, concertScheduleId, seatId);
}
동일 좌석에 대한 복수 예약 요청 시 한 건만 성공, 나머지 실패 : 통과
서로 다른 50석의 좌석에 대해 50명의 사용자 동시 예약 요청 시 모두 성공 : 통과
해당 분산 락의 대기 시간과 재시도 시간을 5초 단위부터 2ms 단위 까지 조정하며 시도해본 결과 모두 테스트가 통과하여 놀랐습니다.
이 부분에 대해 테스트가 정말 제대로 된 건지 의심스러웠으나, 조금 더 시간을 갖고 검증해보기로 하였습니다.
현재 적용을 하여 동시성 제어를 성공적으로 해냈으나 아직 해결하지 못한 과제가 있습니다.
우선 앞서 보셨듯 현재 단일 Redis 인스턴스를 활용하여 분산 락을 구현한 상태입니다.
하지만 실제 서비스 제공 시(과제 상 분산 환경을 고려하라고 하였으므로 서비스 제공 또한 당연히 분산 환경에서 이루어질 것을 전제) 단일 Redis 인스턴스가 바로 SPOF(Single Point Of Failure)가 될 가능성이 높습니다.
열심히 잘 분산된 시스템을 만들어보았지만 Redis가 유연하게 확장될 수 있도록 설계 및 다원화된 Redis를 통해 일관적인 분산 락 제공을 통한 동시성 제어가 성공하지 않는다면, 그래서 결국 단일 실패 지점이 되어 서비스에 장애가 발생한다면 아무 소용이 없습니다.
따라서 다음 과제에서는 다원화된 Redis 기반의 RedLock 또한 설날을 틈타 구현해보고 싶습니다.
그렇게 한다면 다원화된 Redis 간 일관성을 유지하는 방안도 강구해야합니다.
또한 커넥션 풀의 크기를 줄여서 강제로 애플리케이션에 대한 부하를 증가시켰을 때도 일관적인 동시성 제어가 가능한지에 대한 검증도 필요하다고 생각합니다.
결국 이와 같은 여정을 통해 저는 당초 분석했던 동시성 제어가 필요한 지점과 이에 대한 동시성 제어 방안을 다음과 같이 정할 수 있게 되었습니다.
동시 충전/사용 시나리오에서의 동시성 제어 : 낙관적 락
동일 좌석에 대한 중복 예약 방지 / 각기 다른 좌석에 대한 동시 예약 시도 시의 동시성 제어 : Redis 를 활용한 Reentrant Lock (Redisson API를 이용)
아직 시행하지 못한 테스트가 분명 남아있다고 생각해 불안한 마음이 듭니다.
하지만 지금은 당초 목표한 바를 달성했다는 점에 아주 조금만 만족하며 보고서를 마무리 하도록 하겠습니다.