콘서트 서비스에서 발생할 수 있는 동시성 상황
- 콘서트 좌석 신청 시 여러 명이 동시에 하나의 좌석을 요청하는 경우
- 임시예약한 좌석을 결제 요청할 경우
- 포인트 충전/사용의 경우
Lock 판별 기준
Optimistic Lock( 낙관적 락 )
@Version를 사용한 낙관적 락을 사용한 테스트입니다.
public class PointJpo {
@Id
private String userId;
@Version
private int entityVersion;
private int point;
}
낙관적 락의 장점으로는 가장 간단하게 적용할 수 있고, 실제 DB락을 사용하지 않아 DB부하가 심하지 않다는 장점이 존재한다 생각합니다.
낙관적 락은 Update할 때 해당 데이터를 조회하여 version이 동일하다면 pass 다르다면 fail처리를 하는 Flow를 가지고 있습니다.
이로써 Transaction이 종료될 때 Update query가 발행되며 검사하는 만큼 Transaction초기에 해당 데이터의 일관성을 판단하고 Exception을 발생시키는 로직보다는 늦게 검증한다는 단점이 존재합니다.
또한 낙관적 락은 동시성을 처리할 때 처음 한번 수정이 이뤄졌다면, 나머지 동시에 요청된 트래픽들은 버전이 다르다면 전부 실패처리 해버려 충돌이 심한 로직의 경우 데이터의 수정에 있어 정확한 데이터를 얻을 수 있을 것이라는 보장이 힘들다 생각합니다.
그리고, 낙관적 락은 버전이 다를 경우 실패 처리해버리기 때문에 Retry를 사용하는 경우가 많은데, 이는 과한 메모리 사용등으로 인해 성능 저하를 일으킬 수 있으므로 적절한 상황을 염두하고 사용해야 합니다.
하여 낙관적 락은 초기 요청을 제외한 나머지 요청들이 실패하고, 충돌이 심하지 않은 곳에 사용하기 적절하다 생각합니다.
Pessimistic Lock( 비관적 락 )
@Lock을 활용한 비관적 락을 사용한 테스트입니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from PointJpo p where p.userId = :id")
Optional<PointJpo> findByIdForLock(String id);
Point를 Charge 할 때 비관적 락을 적용하여 Lock을 걸었고, 배타 락( X-Lock )을 적용하여 데이터의 일관성을 보장하였습니다.
요청한 트래픽들이 대기하다 하나씩 수행되며, DB락을 걸어 DeadLock 발생 위험이 존재합니다.
공유 락 ( S-Lock )을 적용하였을 경우 DeadLock의 발생 빈도가 높아 비관적 락을 적용하여 테스트할 때는 배타 락을 사용하여 테스트하였습니다.
비관적 락은 들어온 요청들에 대해 DeadLock이 발생하지 않으면 모두 수행하는 만큼 충돌이 잦아도 이를 허용할 수 있다 생각합니다.
그리하여 초기 요청을 제외한 나머지 요청은 실패 처리하는 것이 아닌 나머지 요청들도 작업이 이뤄져야 할 경우 사용하는 것이 적절하다 생각합니다.
Distrubuted Lock ( 분산 락 )
Redis를 활용한 분산락입니다.
분산락은 Lettuce를 사용한 심플 락과 Redisson을 사용한 Pub/Sub방식으로 구현 및 테스트를 진행해 보았습니다.
첫 번째로 Lettuce를 사용한 심플 락입니다.
심플락의 경우 RedisTemplate를 사용하여 Redis에 Lock을 저장하고 해당 Lock을 받아 처리하고 UnLock을 하는 방법으로 진행하였습니다.
심플 락은 여러 개의 요청이 들어올 경우 동일한 Lock Key를 사용할 경우 하나의 요청만 Lock을 할당해 주고 나머지는 튕겨내는 방식으로 이루어지는 Lock입니다. 이후 Retry 로직을 통해 Spin Lock으로 변형하여 사용할 수 있습니다.
Point충전 테스트를 Simple Lock을 활용하여 구현한 결과입니다.
10개의 요청을 시도하였으며, 동일한 userId를 사용하여 하나의 요청을 제외하고 나머지는 실패함으로써 한 번만 요청이 실행된 것을 확인할 수 있습니다.
심플 락의 경우 락 획득 후 에러로 인해 락 점유 해제를 하지 않는 다면 락이 계속 점유되어있어 무한 로딩에 빠질 수 있다는 단점이 존재합니다.
이러한 점을 주의하셔서 락은 획득 후 n초 후 락 점유 해제가 되도록 처리 로직을 추가하는 것을 권장합니다.
두 번째로 Redisson을 사용한 Pub/Sub 방식의 분산 락입니다.
Redisson의 경우 Pub/Sub방식을 사용하고 있으며, 내부적으로 retry로직이 포함되어 있습니다.
Lettuce와 다르게 Lock을 시도하는 최대시간, Lock획득 후 점유하는 최대 시간을 손쉽게 설정할 수 있어 예외 상황으로 발생하는 Lock해제 실패로 인한 무한로딩 상황을 방지할 수 있으며, Retry기능으로 인해 동일한 키를 가진 여러 트래픽들을 수용할 수 있습니다.
단점이라고 하면, 지정한 시간 이외 처리될 트래픽들은 유실될 가능성이 크다는 단점을 가지고 있습니다.
위 테스트 결과만 보더라도 10건 중 5건을 처리하고 지정한 Lock점유 시간을 초과하여 트래픽이 유실된 상황입니다.
분산락의 경우 낙관적 락과 비관적 락에 비해 성능이 좋은 편은 아니라 생각하였습니다.
하지만, 지속적으로 트래픽을 점유하지 않아 DB부하를 Redis로 분산하여 관리한다는 점,
그리고 다양하게 Lock을 처리하면서 낙관/비관적 락에 비해 Custom과 락 핸들링이 좀 더 자유로운 점을 보아 다양한 상황에서 사용할 수 있다 생각합니다.
결론
위의 다양한 테스트들에 근거하여 다음 장에서 판단하는 상황별 어떤 락을 사용하는 것이 적합한가에 대해 판단을 하였습니다.
상황별 적용할 Lock
콘서트 좌석 신청 시 여러 명이 동시에 하나의 좌석을 요청하는 경우
적용 락: 낙관적 락
판단 근거:
콘서트 예약 즉 좌석 점유의 경우 한 명만 성공 처리하고 나머지는 실패처리를 하는 것이 정상적인 흐름이라고 판단하였습니다.
즉 비관적 락을 사용하여 동시성을 관리하는 것보다 Seat에 Version을 명시하여 Seat상태를 관리하는 방향으로 낙관적 락을 사용하는 것이 합당하다 생각하여 낙관적 락을 사용하겠습니다.
코드:
public class TemporaryReservationFlowFacade {
@LoggingPoint
@Transactional
public String createTemporaryReservation(
String userId,
String concertId,
String seriesId,
String seatId
) {
Concert concert = this.concertService.loadConcert(concertId);
// 콘서트 시리즈 조회
ConcertSeries concertSeries = this.concertSeriesService.loadConcertSeriesReservationAvailable(seriesId);
// 콘서트 좌석 조회
ConcertSeat concertSeat = this.concertSeatService.loadConcertSeatById(seatId);
// 좌석 예약
this.concertSeatService.reserveSeat(concertSeat.getSeatId());
// 임시 예약 생성
return this.temporaryReservationService.create(
userId,
concert.getConcertId(),
concert.getTitle(),
concertSeries.getSeriesId(),
concertSeat.getSeatId(),
concertSeat.getSeatRow(),
concertSeat.getSeatCol(),
concertSeat.getPrice()
);
}
}
테스트 결과
요청 유저 수: 100명
동시 접속 수: 100명 ( 동시 Thread 생성 수 10 ~ 15 )
원하는 결과: 1명만 좌석 임시예약에 성공하고 나머지는 실패하는 케이스
임시예약한 좌석을 결제 요청할 경우
적용 락: 낙관적 락
판단 근거:
임시 예약의 경우 결제를 하기 위해서는 본인이 신청한 좌석에 한해서 결제가 가능합니다.
즉 동시요청 상황의 경우 본인이 본인이 임시 예약한 정보를 결제요청하는 것이고, 이는 한번 성공하면 이후 요청에 있어 실패 처리를 하면 된다 생각합니다.
이때 TemporaryReservation에 paid라는 속성을 통해 결제 여부를 상태 관리하므로 낙관적 락을 통해 동시성을 처리하였습니다.
코드:
public class PaymentFlowFacade {
@LoggingPoint
@Transactional
public String processTemporaryReservationPayment(
String temporaryReservationId,
String userId
) {
TemporaryReservation temporaryReservation = this.temporaryReservationService.payReservation(temporaryReservationId);
int price = temporaryReservation.getPrice();
// 예약 테이블로 옮김
String reservationId = this.reservationService.create(
userId,
temporaryReservation.getConcertId(),
temporaryReservation.getTitle(),
temporaryReservation.getSeriesId(),
temporaryReservation.getSeatId(),
temporaryReservation.getSeatRow(),
temporaryReservation.getSeatCol(),
price
);
// 결제 처리
String paymentId = this.paymentService.create(reservationId, userId, price);
//포인트 사용
this.pointService.use(userId, price);
this.pointHistoryService.createPointHistory(userId, price, PointHistoryStatus.USE, paymentId);
// 대기열 토큰 만료 처리
String waitingTokenId = this.waitingTokenService.deleteByUserIdAndSeriesId(userId, temporaryReservation.getSeriesId());
this.waitingQueueService.queuesExpiredByToken(waitingTokenId);
return paymentId;
}
}
테스트 결과
요청 유저 수: 1명
동시 접속 수: 10번 ( 한 유저가 비정상 프로그램을 사용하여 10번 동시 요청을 보낸 케이스 테스트 )
원하는 결과: 처음 임시예약을 결제하여 예약하고, 나머지는 취소 처리
포인트 충전/사용
적용 락: 낙관적 락
판단 근거:
낙관적 락의 단점이 될 수 있는 Retry요청의 경우 해당 상황에서는 실패 시 실패 처리 상황이므로 Retry로 인한 메모리 낭비 혹은 지연의 상황을 고려하지 않아도 됩니다.
또한 포인트 충전과 사용의 경우 동시성을 염두해야 하는 경우는 동시에 2번 이상 요청인 하나의 요청만 처리되어야 하는 케이스라 생각하여 낙관적 락으로 처리하였습니다.
코드:
public class PointFlowFacade {
@Transactional
public void chargePoint(String userId, int amount) {
//
this.pointService.charge(userId, amount);
this.pointHistoryService.createPointHistory(
userId,
amount,
PointHistoryStatus.CHARGE
);
}
@Transactional
public void usePoint(
String userId,
int amount,
String paymentId
) {
//
this.pointService.use(userId, amount);
this.pointHistoryService.createPointHistory(
userId,
amount,
PointHistoryStatus.USE,
paymentId
);
}
}
테스트 결과
요청 유저 수: 1명
동시 접속 수: 10번 ( 한 유저가 비정상 프로그램을 사용하여 10번 동시 요청을 보낸 케이스 테스트 )
원하는 결과: 1명만 좌석 임시예약에 성공하고 나머지는 실패하는 케이스
1. 10000포인트 충전 10번 시도 - 낙관적 락
2. 100000포인트 중 10포인트 사용 10번 시도 - 낙관적 락
Repository
'Server' 카테고리의 다른 글
대기열 성능 분석 및 Redis sorted set을 활용한 리펙토링 (0) | 2024.08.02 |
---|---|
[ Caching ] 캐싱 적용 API분석 및 K6를 사용한 성능 테스트 결과 (0) | 2024.08.01 |
[Kafka] 설치하기 (0) | 2024.06.07 |
[스터디] JPA에 대하여 (0) | 2024.05.25 |
[lombok]RequiredArgsConstructord와 Qualifier (0) | 2024.05.03 |