현재 진행중인 특가 항공권 프로젝트에서는 해당 항공권이 판매 시작 시간이 되었을 때, 많은 트래픽이 몰릴 것으로 예상하였고, 이 때문에 항공권의 남은 좌석 수에 대한 동시성 처리가 필요했습니다. 이를 해결하기 위해 다양한 방법을 테스트 해보았습니다.
테스트 환경
테스트는 동시성 문제를 일으키기 위해 어느정도의 부하가 필요했기 때문에 부하 테스트 툴인 k6 를 사용하였습니다.
스크립트
현재 프로젝트에서는 로그인한 사용자만 예매가 가능했기 때문에, 우선 로그인을 진행한 후 예매를 진행하는 방식으로 스크립트를 구성하였습니다.
약 400 RPS(로그인 + 예매) 를 고정적으로 유지하기 위해 constant-arrival-rate executor 및 이를 유지하기 위한 vuser 의 수를 200 보다 큰 값인 400 정도로 설정하였습니다.
또한, 예매하고자 하는 항공권의 남은 좌석 수는 300 지정하였습니다.
import http from "k6/http";
import { sleep, check } from "k6";
import { Counter } from "k6/metrics";
export const options = {
scenarios: {
constant_rate_test: {
executor: "constant-arrival-rate",
rate: 400,
timeUnit: "1s",
duration: "10s",
preAllocatedVUs: 400,
maxVUs: 400,
},
},
};
// 응답 코드 별 갯수를 세기 위한 값
let status_201 = new Counter("status_201");
let status_409 = new Counter("status_409");
let status_503 = new Counter("status_503");
let status_500 = new Counter("status_500");
export default function () {
const headers = { "Content-Type": "application/json" };
// 1에서 300 사이의 랜덤한 숫자 생성
const randomNumber = Math.floor(Math.random() * 300) + 1;
// 로그인 아이디, 비밀번호 생성
const loginId = `loginId${randomNumber}`;
const password = `password${randomNumber}!`;
// 1. 로그인 요청
const loginRes = http.post(
"http://localhost:8080/login",
JSON.stringify({
loginId: loginId,
password: password,
}),
{
headers: headers,
}
);
// 로그인 요청 성공 체크
check(loginRes, {
"login succeeded": (r) => r.status === 200,
});
// 2. 쿠키를 통해 세션 값이 주어지지만, 이는 k6 에서 자동으로 처리하므로
// 별도의 설정 없이 이후의 요청에서 사용됨
// 단, 제대로 받았는지 검증을 위해 체크를 추가함
const cookies = loginRes.cookies;
const jsessionid = cookies["JSESSIONID"];
check(loginRes, {
"JSESSIONID cookie received": (r) => jsessionid !== undefined,
});
// 3. 항공권 예매 요청
const res = http.post(
"http://localhost:8080/booking/flightsInfo/272845",
JSON.stringify({
headCount: 1,
}),
{
headers: headers,
}
);
// 요청에 대한 응답 코드 카운트
if (res.status == 201) {
status_201.add(1);
} else if (res.status == 409) {
status_409.add(1);
} else if (res.status == 500) {
status_500.add(1);
} else if (res.status == 503) {
status_503.add(1);
}
sleep(1); // 대기 시간 추가
}
Synchronized 키워드
가장 먼저 생각한 것은 자바에서 기본적으로 제공하는 Synchronized 키워드 였고, 해당 방식으로 해결이 되는지를 먼저 테스트 해보았습니다.
적용 코드
재고 감소 로직
public void decreaseSeats(Long flightsInfoOptionId, int seatCount) throws CheckedException {
synchronized (this) {
if(flightsInfoOptionRepository.decreaseSeatsInAvailable(flightsInfoOptionId, seatCount) < 0){
throw new InsufficientSeatsException(flightsInfoOptionId);
}
}
}
Repository - 좌석수 감소 쿼리
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Modifying
@Query("UPDATE FlightsInfoOption fio SET fio.availableSeats = fio.availableSeats - :seatCount "
+ "WHERE fio.id = :id AND fio.availableSeats >= :seatCount")
int decreaseSeatsInAvailable(@Param("id") Long flightsInfoOptionId, @Param("seatCount") int seatCount);
위 로직에서는 단순히 동기화 키워드를 필요한 부분에만 적용하고 있습니다. 또한, 현재 Repository 에서는 @Transactional(propagation = REQUIRES_NEW) 을 통해 부모 트랜잭션이 존재하여도 해당 트랜잭션이 별개로 작동하여 commit 될 수 있도록 하였습니다.
현재 decreaseSeats 메서드는 호출하는 부모 메서드에서는 Transactional 이 적용된 상태입니다. 이 상황에서 Repository 의 메서드가 부모 트랜잭션을 사용하게 된다면, synchronized 가 걸려 있더라도 synchronized 를 탈출하는 시점 이후, 여러 작업이 수행된 후 commit 이 되기 때문에 결국에는 해당 쿼리가 동시에 실행되어 문제가 발생할 수 있음을 의미하게 됩니다.
테스트 결과
현재 테스트의 목적이 부하 측정을 위한 테스트가 아니므로, 주요하게 본 결과는 요청에 대한 응답 코드였습니다.
현재 예매가 성공했을 때 201 코드를 반환하도록 하였는데, 현재 201 코드가 497 건 반환된 것으로 보아 총 497 건이 예매되었다고 볼 수 있습니다. 다음과 같이 실제 DB 에서 조회하여도 예매 테이블과 티켓 테이블 모두 497 건이 저장된 것을 볼 수 있습니다.
기존의 남은 좌석이 300 석이었으므로 이는 동시성 처리가 제대로 되지 않았음을 의미합니다.
원인 분석
기존에 제가 생각하지 못했던 부분은 DB 에서의 동시성 처리와 애플리케이션 코드에서의 동시성 처리가 별개라는 것입니다. 현재 로직에서는 synchronized 키워드를 통해 한 순간에 하나의 스레드만 repository 를 통해서 쿼리 메서드를 실행하고 있습니다. 하지만, 하나의 스레드만 쿼리 메서드를 실행하고, 결과를 얻는다고 해서 DB 에서 동시에 하나의 트랜잭션에서만 좌석수 감소를 할 수 있다는 의미는 아닙니다.
위 그림을 보면, 스레드는 배타적으로 실행되지만, 트랜잭션에서의 수정은 배타적으로 실행되지 않을 수 있습니다. 이는 JPA Transactional 사용시 트랜잭션 commit 시점이 메서드 종료 시점과 일치하지 않아 발생하는 현상입니다.
낙관적 락
낙관적 락은 버전 정보를 사용하여 어플리케이션 레벨에서 충돌을 발견하고 재처리하는 방식입니다. 일반적으로, 충돌이 많이 발생하는 상황에서는 트랜잭션 롤백이 많아져 적절하지 않지만 좀 더 명확하게 확인하기 위해 테스트를 진행해 보았습니다.
적용 코드
재고 감소 로직
public void decreaseSeats(Long flightsInfoOptionId, int seatCount) {
FlightsInfoOption flightsInfoOption = flightsInfoOptionRepository.findByIdWithLock(flightsInfoOptionId)
.orElseThrow(() -> new ResourceNotFoundException("flightsInfoOption"));
if(flightsInfoOption.getAvailableSeats() >= seatCount) {
flightsInfoOption.decreaseSeats(seatCount);
} else {
throw new InsufficientSeatsException(flightsInfoOptionId);
}
}
리포지토리 - 남은 좌석수 조회 쿼리
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT fio FROM FlightsInfoOption fio WHERE fio.id = :id")
Optional<FlightsInfoOption> findByIdWithLock(Long id);
낙관적 락에서는 데이터를 읽는 시점의 버전과 데이터를 업데이트 하는 시점의 버전을 확인하기 때문에, 데이터를 읽는 쿼리 메서드에 @Lock 을 선언해 주어야 합니다.
따라서 기존에는 한 번의 쿼리에서 현재 좌석수 - 1 을 적용했지만, 현재는 데이터를 읽는 쿼리와 수정하는 쿼리가 별도로 작동하도록 재고 감소 로직을 수정하였습니다.
테스트 결과
테스트 결과를 보면, 총 300 건 중 13 건만이 예매에 성공했고, 나머지는 모두 실패한 것을 볼 수 있습니다. 또한, http_req_duration 과 http_req_waiting 을 보면 서버 측에서 응답까지의 시간이 오래걸린 것을 볼 수 있습니다.
현재 retry 처리를 따로 해주지 않았고, 약 200 RPS 로 충돌이 많이 일어날만한 상황이기 때문에 예매 성공이 적은 것은 납득할 만한 결과입니다. 하지만, 서버 측에서 응답까지의 시간은 왜 이렇게 오래 걸린것일까요?
낙관적 락과 롤백
위에서 언급했던 것 처럼, 낙관적 락을 사용하면 충돌이 많은 상황에서는 비교적 적은 요청만 성공하게 됩니다. 적은 성공이라는 것은 많은 실패를 의미하고, 많은 실패를 했다는 의미는 그 만큼 트랜잭션의 롤백이 많아졌다는 의미입니다.
JPA 에서 트랜잭션 롤백은 2가지 의미를 갖습니다. 먼저, 쓰기 지연 SQL 저장소의 쿼리를 모두 비워야 함을 의미하고, DB 로 flush 된 쿼리가 존재할 수도 있기 때문에 DB 트랜잭션 자체를 롤백해야 합니다.
즉, 이러한 비용이 큰 작업을 많이 진행했기 때문에 어느 정도 서버의 응답 시간이 늦어진 것으로 볼 수 있으며 또한 트랜잭션이 롤백된 이후에 클라이언트로 응답이 나가기 때문에 더욱 응답 시간이 늦어진 것입니다.
Retry 를 한다면?
만약, Retry 를 하면 어떤 결과가 나올까요? Retry 로 인해 성공하는 건수가 많아진다면 그로인해 트랜잭션의 롤백이 적어지고 서버의 응답 시간도 늘지 않을까요?
위 코드에서는 Spring 의 Retry 를 사용하여 재시도 로직을 구현했습니다. 너무 짧은/많은 재시도로 인해 큰 부하가 일어나지 않게 최대 횟수는 5번으로, 재시도 간격은 50ms 로 지정하였습니다.
또한, 트랜잭션 전파 속성을 REQUIRES_NEW 로 지정하여 새로운 트랜잭션에서 시도하도록 변경하였습니다. 만약 REQUIRES_NEW 속성을 사용하지 않는다면 다음과 같은 문제가 발생할 수 있습니다.
1. 상위 메서드의 트랜잭션에서 데이터 변경 2. 하위 메서드에서 충돌 발생 및 롤백 / 1 번의 변경 사항 초기화 - 1회 시도 3. 하위 메서드에서 성공 - 2회 시도
위와 같은 상황에서는 요청이 성공했음에도 상위 메서드의 변경 사항이 적용되지 않아 무결성에 문제가 발생할 수 있습니다. 위 문제를 해결하기 위해 REQUIRES_NEW 속성을 사용한다면 트랜잭션이 추가적으로 계속 생성되어 추가적인 오버헤드가 발생할 것 같지만, 일단 테스트 해보았습니다.
테스트 결과
재시도를 하기 전과 크게 다르지 않은 결과를 보여주고 있습니다. 결국 충돌이 많은 상황에서는 재시도를 어느 정도 시도해도 성공할 확률이 적은 것으로 보이며, 오히려 트랜잭션의 추가적인 생성 및 롤백이 생겨 문제가 발생할 수 있을 것 같습니다.
비관적 락
비관적 락은 DB 의 락을 직접 사용하는 방식으로 수정하고자 하는 ROW 의 공유락 / 배타락을 직접 획득하여 동시성을 제어하는 방식입니다. 현재 방식에서는 남은 좌석 수를 읽고, 수정 후 커밋하는 시점까지 다른 트랜잭션에서 해당 데이터를 읽고 쓰지 못하게 해야 함으로 배타락을 적용하였습니다.
적용 코드
재고 감소 로직
public void decreaseSeats(Long flightsInfoOptionId, int seatCount) {
FlightsInfoOption flightsInfoOption = flightsInfoOptionRepository.findByIdWithLock(flightsInfoOptionId)
.orElseThrow(() -> new ResourceNotFoundException("flightsInfoOption"));
if(flightsInfoOption.getAvailableSeats() >= seatCount) {
flightsInfoOption.decreaseSeats(seatCount);
} else {
throw new InsufficientSeatsException(flightsInfoOptionId);
}
}
리포지토리 - 남은 좌석수 조회 쿼리
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "15000")})
@Query("SELECT fio FROM FlightsInfoOption fio WHERE fio.id = :id")
Optional<FlightsInfoOption> findByIdWithLock(Long id);
낙관적 락 코드에서 변경된 부분은 데이터를 조회 메서드에 Lock 옵션을 PESSIMISTIC_WRITE 로 변경한 부분입니다. 또한, 비관적 락은 데이터베이스의 락을 직접 사용하기 때문에 락 획득 시간에 따라 발생할 수 있는 문제들을 고민해 보았고, 이를 해결하고자 락 타임아웃과 트랜잭션 타임아웃을 지정하였습니다.
락 타임아웃
위 코드에서는 락 타임아웃을 지정하여 한 트랜잭션에서 문제 발생시 이후 트랜잭션들이 지나치게 오래 기다리게 되는 상황이 발생하지 않도록 하였습니다. 현재 예매 가능한 좌석 수가 300 건이고, 하나의 트랜잭션이 평균 50 ms 소요되므로, 트랜잭션의 범위가 락의 범위와 같은 상황에서는 300 * 50 ms 인 15000 ms 가 적절하다고 생각했습니다. (데드락의 발생 가능성을 낮춘다는 효과도 있지만 현재 코드에서는 하나의 트랜잭션에서 하나의 락만 소유하게 되므로 데드락 발생 가능성이 없어 주된 목적은 위와 같습니다.)
트랜잭션 타임아웃
위의 락 타임아웃으로 특정 트랜잭션에서 문제가 발생하였을 때, 이후 트랜잭션들이 오래 대기하는 상황은 막을 수 있었지만 해당 트랜잭션 자체가 특정 락을 오래 점유하는 현상은 막을 수 없었습니다. 즉, 특정 트랜잭션에서 오랜 시간동안 락을 점유하고 있는 경우에는 이후 트랜잭션이 지속적으로 실패하게 되므로 이를 막기위해 트랜잭션 자체의 타임아웃을 지정하였습니다.
이 때, 트랜잭션의 소요 시간은 락 획득 이전과 락 획득 이후로 나뉘게 되므로, 락 획득 이전의 시간을 고려해야 했기 때문에 락 타임아웃 에 평균 소요 시간인 50 ms 를 추가하여 지정하였습니다.
테스트 결과
낙관적 락
비관적 락
낙관적 락과 비관적 락 테스트 결과를 비교해보면, 먼저 http_req_duration 과 http_req_waiting 시간이 1/4 수준으로 감소한 것을 볼 수 있으며, status_201 도 기존 13건 에서 300건으로 현재 남은 좌석수를 모두 성공적으로 소진한 결과를 보여주었습니다.
비관적 락은 락을 즉시 획득하지 못할 경우 예외를 발생시키는 낙관적 락과 달리 추가적인 요청이 없어도 데이터베이스 레벨에서 락을 관리하여 지정한 타임아웃 시간동안 락을 얻기 위해 대기하기 때문에 좀 더 짧은 처리 시간을 보여준 것으로 보이며, 충돌을 감지하고 처리하는 방식이 아닌 직접 락을 얻어 충돌을 막기 때문에 더 많은 성공을 한 것으로 보입니다.
문제점
비관적 락을 사용했을 때의 문제점은 다음과 같습니다.
첫 번째 문제는 락을 유지하는 범위(시간)입니다. 기존에 해결하고자 했던 문제는 남은 좌석에 대한 감소에 대한 처리였기 때문에, 감소하는 로직에서만 락을 유지하면 됩니다. 하지만 비관적 락에서는 해당 트랜잭션 커밋이나 롤백 시점까지 락을 해제하지 않기 때문에 하나의 트랜잭션에서 여러 처리가 이루어지는 현재 상황에서는 굳이 필요하지 않은 시점까지 락을 유지시키게 됩니다.
두 번째 문제는 락이 되는 대상 입니다. 비관적 락을 사용하게 된다면 현재 사용중인 MySQL 에서는 해당 레코드에 대한 락을 획득하기 때문에, 동시성 처리가 필요하지 않은 다른 요청(항공권 조회 등) 에 대해서도 락이 해제되기를 기다려야 하기 때문에 성능 저하의 원인이 됩니다. 해당 락을 획득하는 목적 자체가 남은 좌석 수의 감소시키기 위한 것이기 때문에 다른 요청에서 영향을 받지 않으려면 락의 대상을 해당 항공편의 남은 좌석수로 한정지으면 됩니다.
세 번째 문제는 분산 DB 환경일 경우 입니다. DB 가 분산된 환경에 존재하는 경우, 비관적 락을 사용해야 한다면 여러 DB 에 모두 락을 걸어주고, 해제해 주어야 합니다. 하지만 이러한 방법은 각 DB 의 락을 관리해야 하므로 현실적으로 어렵습니다.
위의 두 문제를 모두 해결할 수 있는 방법으로는 MySQL 의 네임드 락과 Redis 의 분산락 등이 존재합니다. 두 가지 방법 모두 트랜잭션의 범위와는 별도로 특정 범위에서만 락을 획득하고, 유지할 수 있으며 특정 문자열을 락의 대상으로 지정하여 사용할 수 있습니다. 또한, 기존의 운영중인 DB 가 여러 개인 경우 락에 관한 관리를 하나의 DB 서버에서 관리하게 함으로서 각 DB 의 락의 상태를 따로 관리하지 않아도 사용할 수 있습니다.
네임드 락
적용 코드
재고 감소 로직
기존 낙관적 락, 비관적 락과 달리 남은 좌석 수를 읽어오고 수정하는 것이 아닌, 한 번의 쿼리에서 처리하도록 변경하였습니다.
public void decreaseSeats(Long flightsInfoOptionId, int seatCount) throws CheckedException {
if (!lockManager.executeWithLock("flightsSeatLock:" + flightsInfoOptionId, 10, () ->
flightsInfoOptionRepository.decreaseSeatsInAvailable(flightsInfoOptionId, seatCount) > 0
)) {
throw new InsufficientSeatsException(flightsInfoOptionId);
}
}
lockManager
락을 사용하는 코드와 해당 락 범위에서 실행되는 코드를 분리하기 위해 템플릿 콜백 패턴을 사용하였고, 예외 처리는 간단하게 해주었습니다.
@Component
@RequiredArgsConstructor
public class NamedLockManager {
private final JdbcTemplate jdbcTemplate;
public <T> T executeWithLock(String lockKey, long waitTime, LockCallback<T> callback) throws
LockAcquisitionFailException {
boolean lockAcquired = false;
try {
lockAcquired = Boolean.TRUE.equals(jdbcTemplate.queryForObject(
"SELECT GET_LOCK(?, ?)", Boolean.class, lockKey, waitTime
));
if (lockAcquired) {
return callback.doWithLock();
} else {
throw new LockAcquisitionFailException(lockKey);
}
} catch (Exception e) {
throw new LockAcquisitionFailException(lockKey);
} finally {
if (lockAcquired) {
jdbcTemplate.queryForObject("SELECT RELEASE_LOCK(?)", Boolean.class, lockKey);
}
}
}
@FunctionalInterface
public interface LockCallback<T> {
T doWithLock();
}
}
리포지토리
@Modifying
@Query("UPDATE FlightsInfoOption fio SET fio.availableSeats = fio.availableSeats - :seatCount "
+ "WHERE fio.id = :id AND fio.availableSeats >= :seatCount")
int decreaseSeatsInAvailable(@Param("id") Long flightsInfoOptionId, @Param("seatCount") int seatCount);
테스트 결과
기존 비관적 락을 사용했을 때의 응답 시간인 350 ms 에 비해 303 ms 로 약 50 ms 가량 감소된 결과를 보여주었지만, 크게 응답 속도가 향상하지는 않았습니다.
락의 범위를 트랜잭션 전체에서 남은 좌석 수를 감소시키는 로직으로 줄였지만, 감소시키는 로직 이외의 코드에서 오랜 시간이 소요되지 않아 서버의 응답 시간이 50 ms 정도로 미미하게 감소한 것으로 예상됩니다.
Redis 분산락
Redis 의 분산락을 쉽게 사용할 수 있는 대표적인 라이브러리는 Lettuce 와 Redisson 이 존재합니다. Lettuce 는 스핀락을 기반으로 구현되어 있으며, Redisson 은 pub-sub 구조를 기반으로 구현되어 있습니다.
Lettuce - 스핀락 적용 코드
재고 감소 로직
기존 낙관적 락, 비관적 락과 달리 남은 좌석 수를 읽어오고 수정하는 것이 아닌, 한 번의 쿼리에서 처리하도록 변경하였습니다.
public void decreaseSeats(Long flightsInfoOptionId, int seatCount) throws CheckedException {
if (!lockManager.executeWithLock("flightsSeatLock:" + flightsInfoOptionId, 15, 1, TimeUnit.SECONDS, () ->
flightsInfoOptionRepository.decreaseSeatsInAvailable(flightsInfoOptionId, seatCount) > 0
)) {
throw new InsufficientSeatsException(flightsInfoOptionId);
}
}
lockManager
Lettuce 라이브러리에서 지원하는 RedisClient 를 사용하였습니다. 해당 라이브러리에서는 Setnx(Set if not Exist) 를 기반으로 락을 획득하기 때문에 내부 구현에서는 해당 라이브러리에서 지원하는 메서드를 활용하여 구현하였습니다.
@Component
@RequiredArgsConstructor
public class LettuceLockManager {
private final RedisClient redisClient;
public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, LockCallback<T> callback) throws LockAcquisitionFailException {
String lockValue = UUID.randomUUID().toString();
long waitEndTime = System.nanoTime() + timeUnit.toNanos(waitTime);
boolean lockAcquired = false;
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisCommands<String, String> commands = connection.sync();
while (System.nanoTime() < waitEndTime) {
// 이미 lock 에 대한 key-value 가 존재하는 경우에는 획득 불가
String result = commands.set(lockKey, lockValue, SetArgs.Builder.nx().px(timeUnit.toMillis(leaseTime)));
if ("OK".equals(result)) {
lockAcquired = true;
break;
}
Thread.sleep(20);
}
if (lockAcquired) {
return callback.doWithLock();
} else {
throw new LockAcquisitionFailException(lockKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionFailException(lockKey);
} finally {
if (lockAcquired) {
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisCommands<String, String> commands = connection.sync();
String currentValue = commands.get(lockKey);
if (lockValue.equals(currentValue)) {
commands.del(lockKey);
}
}
}
}
}
@FunctionalInterface
public interface LockCallback<T> {
T doWithLock();
}
}
리포지토리
@Modifying
@Query("UPDATE FlightsInfoOption fio SET fio.availableSeats = fio.availableSeats - :seatCount "
+ "WHERE fio.id = :id AND fio.availableSeats >= :seatCount")
int decreaseSeatsInAvailable(@Param("id") Long flightsInfoOptionId, @Param("seatCount") int seatCount);
테스트 결과
http_req_duration, http_req_waiting 결과에서 약 1 s 의 시간이 걸려 기존 네임드 락이나 비관적 락을 사용한 코드에 비해 3배 정도의 시간이 소요되었습니다.
이는 스핀락의 특성으로 각 스레드에서 busy waiting 상태로 짧은 지연 시간을 두고 대기중인 스레드가 모두 실행 상태로 락 획득을 시도하기 때문입니다. 이에 반해, 비관적 락이나 네임드 락의 경우 요청한 스레드에서 락 획득에 대한 결과를 받기 전까지는 대기 상태로 전환되기 때문에 컨텍스트 스위칭 비용이 발생하지 않아 스핀락보다 짧은 응답 시간이 걸리게 됩니다.
Redisson - pub-sub 기반 락 적용 코드
재고 감소 로직
public void decreaseSeats(Long flightsInfoOptionId, int seatCount) throws CheckedException {
if (!lockManager.executeWithLock("flightsSeatLock:" + flightsInfoOptionId, 15, 1, TimeUnit.SECONDS, () ->
flightsInfoOptionRepository.decreaseSeatsInAvailable(flightsInfoOptionId, seatCount) > 0
)) {
throw new InsufficientSeatsException(flightsInfoOptionId);
}
}
lockManager
Redisson 라이브러리에서 지원하는 RLock 을 사용하여 구현하였습니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisLockManager {
private final RedissonClient redissonClient;
public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, LockCallback<T> callback) throws LockAcquisitionFailException {
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(waitTime, leaseTime, timeUnit)) {
return callback.doWithLock();
} else {
throw new LockAcquisitionFailException(lockKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionFailException(lockKey);
} finally {
if (lock.isLocked()) {
lock.unlock();
}
}
}
@FunctionalInterface
public interface LockCallback<T> {
T doWithLock();
}
}
리포지토리
@Modifying
@Query("UPDATE FlightsInfoOption fio SET fio.availableSeats = fio.availableSeats - :seatCount "
+ "WHERE fio.id = :id AND fio.availableSeats >= :seatCount")
int decreaseSeatsInAvailable(@Param("id") Long flightsInfoOptionId, @Param("seatCount") int seatCount);
테스트 결과
http_req_duration 을 보면, 기존 네임드 락(300 ms)이나 비관적 락(350 ms)에 비해 상대적으로 느린 378 ms 의 서버측 응답 시간을 확인할 수 있습니다.
이는 pub-sub 기반의 락을 지원하기 위해 추가적인 비용(구독자에게 알림, 구독자 등록 등) 이 들기 때문으로 예상됩니다.
선택한 방법
MySQL 의 네임드 락을 사용하기로 결정하였습니다.
위에서 테스트한 낙관적 락과 스핀락의 경우에는 서버의 응답 시간이 지나치게 느렸고, 비관적 락은 레코드 락으로 인해 다른 요청에서의 지연이 우려되었습니다. 또한 pub-sub 기반의 Redis 분산락은 많은 트래픽이 존재할 때, Redis 의 특성상 scale-out 이 더 용이하다는 장점이 있었지만 현재 상황에서는 그 만큼의 트래픽이 기대되지 않고, 실제 테스트 결과 네임드 락이 더 짧은 서버 응답 시간을 가졌기 때문에 네임드 락을 선택하였습니다.