동시성 문제
먼저 동시성 문제가 무엇인지 간단하게 짚고 넘어가보자.
- 동시성 문제란?
여러 작업들이 **동시에 하나의 공통 자원을 점유**하려고 시도할 때 발생하는 문제
그럼 이런 동시성 문제가 발생하면 무엇이 문제인가?
- 내가 원하는 결과와 다른 결과가 발생할 수 있다.
- ex) 데이터를 수정하였으나 조회했을 때 내 예상과 다른 값이 반환
- 동시성 문제로 인해 데이터 무결성이 깨지고, 이로 인해 정합성을 저해시킬 수 있다.
- 데이터의 무결성?
데이터 값이 정확한 상태
- 데이터의 정합성
어떤 데이터들의 값이 서로 일치하는 상태
데이터의 무결성이 훼손 되는 경우
주문정보 테이블에서 고객번호가 모두 -1으로 입력되어 있고, 고객정보 테이블에도 -1의 값을 갖는 고객이 존재한다. (데이터의 값이 일치한다.)
그러나 고객번호는 반드시 1 이상의 값을 가져야 한다. (데이터의 값이 정확하지 않다.)
이런 상황에는 데이터 정합성은 이상이 없으나, 데이터 무결성은 훼손되었다고 볼 수 있다.
데이터의 정합성이 훼손 되는 경우
위 예시에서 주문정보 테이블의 고객번호를 -1 → 2로 변경했지만, 고객정보 테이블에는 고객번호가 변경되지 않았을 때, (데이터의 값이 서로 일치하지 않는다.) 데이터 정합성이 훼손되었다고 볼 수 있다.
실생활에서 예시를 들면 '술을 마시고 운전을 했지만 음주운전을 하지 않았다'가 정합성이 지켜지지 않은 것이다.
그럼 이제 동시성 문제를 해결하기 위한 각 Lock 기법에 대해서 알아보자.
5가지 Lock 비교 분석
1. DB 낙관적 락 (Optimistic Lock)
- 구현의 복잡도: 중간
- 데이터를 읽을 때는 락을 걸지 않고, 데이터를 변경할 때 충돌을 검사하는 방식
- 애플리케이션단에서 락을 처리
- JPA의 경우 @Version 을 통해 쉽게 구현 가능
- 성능: 높음
- 락을 잡지 않고 작업을 수행하기 때문에, 락 경합이 적고 성능이 높음.
- 충돌이 빈번하게 발생하는 경우 재시도 비용이 증가할 수 있어 주의해야함.
- 효율성: 중간
- 충돌이 적은 환경에서 매우 효율적.
- 잦은 충돌이 일어나는경우 롤백처리에 대한 비용이 많이 들어 오히려 성능에서 손해를 볼 수 있음.
- 롤백 처리를 구현하는게 복잡할 수 있음.
2. DB 비관적 락 (Pessimistic Lock)
- 구현의 복잡도: 중간
- 데이터에 대한 동시 액세스를 허용하지 않고, 데이터를 읽거나 변경할 때 즉시 DB에 락을 거는 방식.
- 쿼리에 … for update 을 붙여주거나 JPA의 경우 named 쿼리에@Lock(LockModeType.PESSIMISTIC_WRITE) 붙여주면 됨
- 성능: 낮음
- 모든 작업에서 락을 잡기 때문에 경합이 발생할 수 있으며, 이는 성능 저하로 이어진다.
- 효율성: 높음
- 충돌이 빈번한 환경에서 안정적으로 동작하며, 데이터 일관성을 보장할 수 있다.
3. Redis Simple Lock
- 구현의 복잡도: 낮음
- Redis의 SETNX 명령어를 사용하여 간단한 락을 구현할 수 있음. 락을 잡으려는 시도는 SETNX로 시도하며, 성공하면 락을 잡은 것이고 실패하면 이미 다른 클라이언트가 락을 잡고 있는 상태.
- 성능: 중간
- Redis의 빠른 성능 덕분에 락 작업도 빠르지만, 락 해제 시 적절한 만료 시간 설정이 필요함.
- 효율성: 중간
- 간단한 구현과 빠른 성능 덕분에 적절한 사용 사례에서 효율적이지만, 네트워크 지연이나 Redis 서버 장애 시 문제가 발생할 수 있음.
4. Redis Spin Lock
- 구현의 복잡도: 중간
- Redis Simple Lock과 유사하지만, 락을 잡을 때까지 반복적으로 시도함.
- 명령어를 반복적으로 실행하면서 락을 잡으려고 시도하는 방식.
- 성능: 낮음
- 반복적인 시도로 인해 성능이 저하될 수 있으며, 특히 락을 잡지 못하는 경우 성능이 크게 저하됨.
- 효율성: 낮음
- 스핀 락은 락을 잡을 때까지 CPU를 소모하므로, 시스템 자원을 비효율적으로 사용할 수 있음.
5. Redis Pub/Sub을 이용한 락
- 구현의 복잡도: 높음
- Redis의 Pub/Sub 기능을 이용하여 락을 구현하는 방법.
- 락을 잡을 때 특정 채널에 메시지를 발행하고, 락을 기다리는 다른 클라이언트는 이 채널을 구독하여 락이 해제될 때까지 대기함.
- 성능: 높음
- Pub/Sub 메커니즘 덕분에 락 대기 중에는 불필요한 자원 소모가 줄어들지만, 구현의 복잡도가 높다.
- 효율성: 높음
- 대기 중인 클라이언트가 락 해제 이벤트를 효율적으로 받을 수 있으므로, 리소스 사용 측면에서 매우 효율적임.
결론
- 낙관적 락은 충돌이 적은 환경에서 높은 성능을 발휘하지만, 충돌이 빈번한 환경에서는 효율성이 떨어짐.
- 비관적 락은 안정적인 데이터 일관성을 제공하지만, 동시성이 낮아져 성능이 떨어질 수 있음.
- Redis Simple Lock은 간단하게 구현할 수 있지만, 네트워크 지연이나 Redis 장애 시 문제를 초래할 수 있음.
- Redis Spin Lock은 구현이 비교적 간단하지만, 성능과 효율성이 낮음.
- Redis Pub/Sub을 이용한 락은 높은 효율성을 제공하지만, 구현의 복잡도가 높음.
그럼 직접 성능을 비교분석해보자!
성능 비교
상황은 다 똑같은 상황( 좌석 예약)으로 하겠다.
- 좌석 예약 method
// ConcertService
....
@Transactional
public ConcertReservationInfo reserveSeat(ReservationCommand.Create command) {
// 1 이미 예약이 있는지 확인
boolean checkedReservation = concertRepository.checkAlreadyReserved(command.concertId(), command.concertDateId(),
command.seatNumber());
concertValidator.checkAlreadyReserved(checkedReservation, command.concertDateId(), command.seatNumber());
// 2. concertDate 정보 조회
Optional<ConcertDate> dateForReservation = concertRepository.getDateForReservation(command.concertDateId(),
command.concertId());
ConcertDate concertDate = concertValidator.checkExistConcertDate(dateForReservation, command.concertDateId());
// 3. seat 상태 변경
Optional<Seat> seatForReservation = concertRepository.getSeatForReservation(command.concertDateId(),
command.seatNumber());
Seat seat = concertValidator.checkExistSeat(seatForReservation, "예약 가능한 좌석이 존재하지 않습니다.");
seat.occupy();
concertRepository.saveSeat(seat);
// 4. 예약 테이블 저장
ConcertReservationInfo reservationInfo = ConcertReservationInfo.toReservationDomain(command, seat, concertDate);
return concertValidator.checkSavedReservation(concertRepository.saveReservation(reservationInfo), "예약에 실패하였습니다");
}
1. 낙관적 락
낙관적 락을 다음과 같이 걸었다.
// ConcertService
....
@Transactional
public ConcertReservationInfo reserveSeat(ReservationCommand.Create command) {
...
// 3. seat 상태 변경
Optional<Seat> seatForReservation = concertRepository.getSeatForReservation(command.concertDateId(),
command.seatNumber());
Seat seat = concertValidator.checkExistSeat(seatForReservation, "예약 가능한 좌석이 존재하지 않습니다.");
seat.occupy();
concertRepository.saveSeat(seat);
...
}
// SeatEntity
@Entity
@Getter
@Builder(toBuilder = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(name = "seat")
public class SeatEntity extends BaseTimeEntity {
...
@Version
private int version; //낙관적 락 적용
...
- testCode
@Test
@DisplayName("낙관적 락을 이용하여 3000명의 유저가 동시에 예약 신청을 하면 한 명만 예약에 성공하고, 나머지는 예외를 반환한다.")
void reserveSeatWithOptimisticLock() throws InterruptedException {
//given
concertRepository.deleteAllReservation();
int numThreads = 3000;
int expectSuccessCnt = 1;
int expectFailCnt = 2999;
for (int i = 0; i < 3000; i++) testDataHandler.settingUser(BigDecimal.ZERO);
List<User> users = userRepository.getUsers();
Queue<Long> userIds = new ConcurrentLinkedDeque<>();
for (User user : users) {
userIds.add(user.getUserId());
}
CountDownLatch latch = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
//when
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
try {
ReservationCommand.Create command = new ReservationCommand.Create(1L, 1L,
49, userIds.poll());
reservationFacade.reserveSeat(command);
System.out.println("-----------------------success");
successCount.getAndIncrement();
} catch (RuntimeException e) {
System.out.println("-----------------------fail");
failCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
List<ConcertReservationInfo> result = concertRepository.getAllReservation();
// then
assertSoftly(softly -> {
softly.assertThat(result).hasSize(1);
softly.assertThat(result.get(0).getStatus()).isEqualTo(ReservationStatus.TEMPORARY_RESERVED);
softly.assertThat(successCount.get()).isEqualTo(expectSuccessCnt);
softly.assertThat(failCount.get()).isEqualTo(expectFailCnt);
});
}
- 결과
대략 2.8~3.1 초 정도 나오는 것 같다.
2. 비관적 락
다음과 같이 비관적 락을 걸었다.
// ConcertService
....
@Transactional
public ConcertReservationInfo reserveSeat(ReservationCommand.Create command) {
...
// 3. seat 상태 변경
Optional<Seat> seatForReservation = concertRepository.getSeatForReservation(command.concertDateId(),
command.seatNumber()); //이부분
...
}
// ConcertRepositoryImpl
@Override
public Optional<Seat> getSeatForReservation(Long concertDateId, int seatNumber) {
Optional<SeatEntity> seatEntity = seatJpaRepository
.findSeatWithPessimisticLock(concertDateId, seatNumber);
if (seatEntity.isPresent()) {
return seatEntity.map(SeatEntity::toDomain);
}
return Optional.empty();
}
// SeatJpaRepository
//비관적 락 사용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM SeatEntity s WHERE s.concertDateInfo.concertDateId = :concertDateId AND s.seatNumber = :seatNumber")
Optional<SeatEntity> findSeatWithPessimisticLock(@Param("concertDateId") Long concertDateId,
@Param("seatNumber") int seatNumber);
- TestCode
// ReservationIntegrationTest
@Test
@DisplayName("비관적 락을 이용하여 3000명의 유저가 동시에 예약 신청을 하면 한 명만 예약에 성공하고, 나머지는 예외를 반환한다.")
void reserveSeatWithPessimisticLock() throws InterruptedException {
//given
concertRepository.deleteAllReservation();
int numThreads = 3000;
int expectSuccessCnt = 1;
int expectFailCnt = 2999;
for (int i = 0; i < 3000; i++) testDataHandler.settingUser(BigDecimal.ZERO);
List<User> users = userRepository.getUsers();
Queue<Long> userIds = new ConcurrentLinkedDeque<>();
for (User user : users) {
userIds.add(user.getUserId());
}
CountDownLatch latch = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
//when
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
try {
ReservationCommand.Create command = new ReservationCommand.Create(1L, 1L,
49, userIds.poll());
reservationFacade.reserveSeat(command);
System.out.println("-----------------------success");
successCount.getAndIncrement();
} catch (RuntimeException e) {
System.out.println("-----------------------fail");
failCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
List<ConcertReservationInfo> result = concertRepository.getAllReservation();
// then
assertSoftly(softly -> {
softly.assertThat(result).hasSize(1);
softly.assertThat(result.get(0).getStatus()).isEqualTo(ReservationStatus.TEMPORARY_RESERVED);
softly.assertThat(successCount.get()).isEqualTo(expectSuccessCnt);
softly.assertThat(failCount.get()).isEqualTo(expectFailCnt);
});
}
- 결과
대략 3.5~3.7 초 사이로 나온다.
3. Redis simple Lock
- Redis SImple Lock 구현
// ReservationFacade
@Component
@RequiredArgsConstructor
public class ReservationFacade {
private final RedisSimpleLockService redisSimpleLockService;
...
public ConcertReservationInfo reserveSeat(ReservationCommand.Create command) {
// redis simple lock 구현
if (redisSimpleLockService.acquireLock("reserveSeat", 4000)) {
try {
return concertService.reserveSeat(command);
} finally {
redisSimpleLockService.releaseLock("reserveSeat");
}
} else {
throw new RuntimeException();
}
}
...
// RedisSimpleLockService
@Service
public class RedisSimpleLockService {
private static final String LOCK_KEY_PREFIX = "lock:";
@Autowired
private StringRedisTemplate redisTemplate;
public boolean acquireLock(String lockKey, long expireTime) {
Boolean success = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY_PREFIX+lockKey, "locked", expireTime, TimeUnit.MILLISECONDS);
return success != null && success;
}
public void releaseLock(String lockKey) {
redisTemplate.delete(lockKey);
}
}
- testCode
// ReservationIntegrationTest
@Test
@DisplayName("Redis Simple 락을 이용하여 3000명의 유저가 동시에 예약 신청을 하면 한 명만 예약에 성공하고, 나머지는 예외를 반환한다.")
void reserveSeatWithRedisSimpleLock() throws InterruptedException {
//given
concertRepository.deleteAllReservation();
int numThreads = 3000;
int expectSuccessCnt = 1;
int expectFailCnt = 2999;
for (int i = 0; i < 3000; i++) testDataHandler.settingUser(BigDecimal.ZERO);
List<User> users = userRepository.getUsers();
Queue<Long> userIds = new ConcurrentLinkedDeque<>();
for (User user : users) {
userIds.add(user.getUserId());
}
CountDownLatch latch = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
//when
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
try {
ReservationCommand.Create command = new ReservationCommand.Create(1L, 1L,
49, userIds.poll());
reservationFacade.reserveSeat(command);
System.out.println("-----------------------success");
successCount.getAndIncrement();
} catch (RuntimeException e) {
System.out.println("-----------------------fail");
failCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
List<ConcertReservationInfo> result = concertRepository.getAllReservation();
// then
assertSoftly(softly -> {
softly.assertThat(result).hasSize(1);
softly.assertThat(result.get(0).getStatus()).isEqualTo(ReservationStatus.TEMPORARY_RESERVED);
softly.assertThat(successCount.get()).isEqualTo(expectSuccessCnt);
softly.assertThat(failCount.get()).isEqualTo(expectFailCnt);
});
}
- 결과
대략 2.8~2.9 초로 나온다.
4. Redis spin Lock
- Redis Spin Lock 구현
// ReservationFacade
@Component
@RequiredArgsConstructor
public class ReservationFacade {
private final RedisSimpleLockService redisSimpleLockService;
...
public ConcertReservationInfo reserveSeat(ReservationCommand.Create command) {
// redis spin lock 구현
try {
if (redisSpinLockService.acquireLock("mySpinLock", 3000, 3, 100)) {
try {
return concertService.reserveSeat(command);
} finally {
redisSpinLockService.releaseLock("mySpinLock");
}
} else {
// 락 획득 실패 처리
throw new RuntimeException();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException();
}
}
...
// RedisSpinLockService
@Service
public class RedisSpinLockService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_KEY_PREFIX = "lock:";
public boolean acquireLock(String lockKey, long expireTime, int retryAttempts, long retryDelay) throws InterruptedException {
for (int i = 0; i < retryAttempts; i++) {
Boolean success = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY_PREFIX+lockKey, "locked", expireTime, TimeUnit.MILLISECONDS);
if (success != null && success) {
return true;
}
Thread.sleep(retryDelay);
}
return false;
}
public void releaseLock(String lockKey) {
redisTemplate.delete(lockKey);
}
}
- testCode
// ReservationIntegrationTest
@Test
@DisplayName("Redis Spin 락을 이용하여 3000명의 유저가 동시에 예약 신청을 하면 한 명만 예약에 성공하고, 나머지는 예외를 반환한다.")
void reserveSeatWithRedisSpinLock() throws InterruptedException {
//given
concertRepository.deleteAllReservation();
int numThreads = 3000;
int expectSuccessCnt = 1;
int expectFailCnt = 2999;
for (int i = 0; i < 3000; i++) testDataHandler.settingUser(BigDecimal.ZERO);
List<User> users = userRepository.getUsers();
Queue<Long> userIds = new ConcurrentLinkedDeque<>();
for (User user : users) {
userIds.add(user.getUserId());
}
CountDownLatch latch = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
//when
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
try {
ReservationCommand.Create command = new ReservationCommand.Create(1L, 1L,
49, userIds.poll());
reservationFacade.reserveSeat(command);
System.out.println("-----------------------success");
successCount.getAndIncrement();
} catch (RuntimeException e) {
System.out.println("-----------------------fail");
failCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
List<ConcertReservationInfo> result = concertRepository.getAllReservation();
// then
assertSoftly(softly -> {
softly.assertThat(result).hasSize(1);
softly.assertThat(result.get(0).getStatus()).isEqualTo(ReservationStatus.TEMPORARY_RESERVED);
softly.assertThat(successCount.get()).isEqualTo(expectSuccessCnt);
softly.assertThat(failCount.get()).isEqualTo(expectFailCnt);
});
}
- 결과
대략 3.5~3.7 초로 나온다.
5. Redis pus/sub 을 이용한 Lock
구현하다가 잘 안돼서 포기했다…
어쨌든 제일 성능이 좋다고 한다!!
콘서트 예약 서비스 내 동시성 문제
현재 나는 콘서트 예약 서비스 과제를 진행 중이다. 이 과제 시니라오에서 동시성 문제가 발생할 수 있는 지점들은 어떤 부분이 있을까?
- 좌석 예약
- 좌석 예약의 경우 동시에 많은 트래픽이 몰릴 가능성이 크다.
- 하나의 좌석을 차지하려고 동시에 많은 요청이 들어올 수 있기 때문에 동시성 문제가 발생할 수 있다.
- 유저 포인트 차감(결제)
- 돈과 같은 민감한 정보의 경우 정합성이 매우 중요하다.
- 유저가 결제를 다닥(순간 더블클릭)할 때 동시성 이슈가 발생할 수 있다.
- 한 유저가 동시에 다른 예약을 진행한다면 포인트 차감에 동시성 이슈가 발생할 수 있다.
- 유저 포인트 충전
- 돈과 같은 민감한 정보의 경우 정합성이 매우 중요하다.
- 유저가 포인트 충전을 동시에 진행한다면 동시성 이슈가 발생할 수 있다.
- 대기열 진입
- 콘서트를 예약하기 위해 많은 사람들이 한번에 대기열에 들어갈 것이다.
- 여기서 정해진 활성화 수만큼만 토큰이 활성화가 되어야 하기 때문에 동시성 이슈가 발생할 수 있다.
적용
그런데 사실 이렇게 내가 직접 분산락을 구현할 필요가 없다!
이미 redis를 이용한 다양한 클라이언트(Jedis, Lettuce, Redisson 등) 들이 Lock 을 쉽게 구현할 수 있도록 제공해주고 있기 때문이다!
특히 Redisson 클라이언트의 경우 Pub/Sub 방식을 이용하여 Lock을 획득하게 되어 있다.
또한 Mysql의 경우 named Lock을 통해서 분산락을 구현해볼 수 있다.
그럼 여기서 선택의 기로에 놓이게 된다.
- 분산락을 적용할 것인가?
- 만약 분산락을 적용한다면 redis를 활용할 것인가 아닌가??
그럼 분산락이 뭔지부터 정확하게 이해하고 넘어갈 필요가 있을 것 같다.
- 분산락이란?
분산락이란 **여러 서버가(프로세스)** 공유 데이터를 제어하기 위한 기술을 말한다.
락을 획득한 프로세스 혹은 스레드만이 공유 자원 혹은 Critical Section 에 접근할 수 있도록 설정.
분산락의 장점은 서버 분산 환경에서도 프로세스들의 원자적 연산이 가능하다.
분산락을 구현하기 위해 크게 Mysql의 네임드락과 Redis의 분산락을 고려해볼 수 있다.
- 네임드락을 이용한 분산락
- 장점
- 추가적인 리소스가 필요하지 않음
- 애플리키에션 레벨에서 제어 가능
- 단점
- 락에 대한 정보가 테이블에 따로 저장되어 무거워질 있음
- DB 에 락으로 인한 커넥션 대기가 발생하기때문에 성능상 단점이 발생함
- 장점
- 레디스 분산락
- 장점
- 락에 대한 정보는 휘발성임
- 메모리에서 락을 획득하고 해제하기때문에 가벼움
- 단점
- 추가적인 인프라 구축 필요
- 만약 레디스에 문제가 생긴다면 바로 DB에 부하가 심해짐
- 장점
어떤 기술이든 trade-off 가 존재한다. 나는 이런저런걸 다 따져봤을 때 redis 의 분산락을 활용하기로 결정했다. 우선 redis는 캐싱서버로도 많이 사용되기 때문에 인프라로 구축되어 있을 가능성이 크고, 한번에 많은 요청이 들어와서 db에 커넥션 풀 갯수는 한정적이기 때문에 db의 부하를 줄여주기 위해서라도 redis를 한번 써보는 게 좋다는 생각이 들었다.
그럼 어디에 어떻게 적용해 볼까??
- 좌석 예약
예약 비즈니스 로직은 다음과 같다.
1 이미 예약이 있는지 확인 -> 2. concertDate 정보 조회 -> 3. 좌석 점유 -> 4. 예약 내역 저장
여기서 락을 사용한다면 2가지 방법이 있을 것 같다.
- 전체 로직에 분산락을 건다.
- 3. 좌석 점유에서 낙관적 락을 사용한다.
전체 로직에 분산락을 걸면, 비즈니스 로직이 길기 때문에 그만큼 락을 잡아두는 시간이 늘어난다. 내가 생각 했을 때는 락을 잡아두는 범위는 작으면 작을수록 좋을 것이다. 따라서 여기서는 좌석 점유에 낙관적 락을 적용하기로 했다.
// SeatEntity
@Entity
@Getter
@Builder(toBuilder = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(name = "seat")
public class SeatEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long seatId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "concert_date_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private ConcertDateEntity concertDateInfo;
private int seatNumber;
private BigDecimal price;
@Enumerated(EnumType.STRING)
private SeatStatus status; // available, unavailable
@Enumerated(EnumType.STRING)
private TicketClass ticketClass; // S > A > B > C
@CreatedDate
private LocalDateTime createdAt;
@Version
private int version; //낙관적 락 적용
public static SeatEntity toEntity(Seat seat) {
return SeatEntity.builder()
.seatId(seat.getSeatId() != null ? seat.getSeatId(): null)
.concertDateInfo(ConcertDateEntity.toEntity(seat.getConcertDateInfo()))
.seatNumber(seat.getSeatNumber())
.price(seat.getPrice())
.status(seat.getStatus())
.ticketClass(seat.getTicketClass())
.createdAt(seat.getCreatedAt())
.build();
}
public Seat toDomain() {
return Seat.builder()
.seatId(seatId)
.concertDateInfo(concertDateInfo.toDomain())
.seatNumber(seatNumber)
.price(price)
.status(status)
.ticketClass(ticketClass)
.createdAt(createdAt)
.build();
}
}
- 결제
결제 비즈니스 로직은 다음과 같다.
1. 예약 완료 -> 2. 결제 내역 생성 -> 3. 잔액 차감 -> 4. 토큰 만료
3. 잔액 차감의 경우, 하나의 유저가 다른 예약을 동시에 요청 한다면 둘 다 포인트가 차감이 되어야 한다. 비관적 락을 사용해서 해결할 수도 있지만, db 인덱스 자체에 락을 걸기 때문에 성능적으로 떨어진다. 따라서 이 부분에 구현도 쉬우면서 성능도 좋은 redis 분산락을 적용하기로 했다.
// PaymentFacade
@Component
@RequiredArgsConstructor
public class PaymentFacade {
private final PaymentService paymentService;
private final UserService userService;
private final ConcertService concertService;
private final WaitingQueueService waitingQueueService;
/**
* 결제 요청하는 유즈케이스를 실행한다.
*
* @param command reservationId, userId 정보
* @return PaymentResponse 결제 결과를 반환한다.
*/
@Transactional
@DistributedLock(key = "'payLock'.concat(':').concat(#command.userId())")
public Payment pay(PaymentCommand.Create command) {
// 1. 예약 완료
ConcertReservationInfo completeReservation = concertService.completeReservation(command);
// 2. 결제 내역 생성
Payment payment = paymentService.createPayment(completeReservation);
// 3. 잔액 차감
User user = userService.usePoint(completeReservation.getUserId(),
completeReservation.getSeatPrice());
// 4. 토큰 만료
waitingQueueService.forceExpireToken(command.userId());
return Payment.builder()
.paymentId(payment.getPaymentId())
.paymentPrice(payment.getPaymentPrice())
.status(payment.getStatus())
.balance(user.getBalance())
.paidAt(payment.getPaidAt())
.build();
}
}
- 대기열 진입
대기열 진입의 경우에도 동시성 이슈가 발생할 수 있기 때문에 분산락을 적용했다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class WaitingQueueService {
private final JwtUtils jwtUtils;
private final WaitingQueueRepository waitingQueueRepository;
private final WaitingQueueValidator waitingQueueValidator;
...
/**
* 대기열에 진입을 요청하면 대기열 정보를 반환한다.
*
* @param (user,token) user, token 정보
* @return WaitingQueueResponse 대기열 정보를 반환한다.
*/
@Transactional
@DistributedLock(key = "'waitingQueueLock'")
public WaitingQueue enterQueue(User user, String token) {
// 현재 활성 유저 수 확인
long activeTokenCnt = waitingQueueRepository.getActiveCnt();
// 이미 유저의 활성화 된 토큰이 있다면 expired 시킴
expiredIfExist(user.getUserId(), activeTokenCnt);
// 활성화 시킬 수 있는 수 계산
long availableActiveTokenCnt = WaitingQueue.availableActiveToken(activeTokenCnt);
// 토큰 정보 저장
WaitingQueue queue = availableActiveTokenCnt > 0 ? WaitingQueue.toActiveDomain(user, token)
: WaitingQueue.toWaitingDomain(user, token);
Optional<WaitingQueue> waitingToken = waitingQueueRepository.saveQueue(queue);
WaitingQueue waitingTokenInfo = waitingQueueValidator.checkSavedQueue(waitingToken);
// 만약 활성화 된 토큰이 아니라면 대기열 정보 생성
if (waitingTokenInfo.getStatus() == WaitingQueue.WaitingQueueStatus.WAIT) {
long waitingCnt = waitingQueueRepository.getWaitingCnt();
waitingTokenInfo.addWaitingInfo(waitingCnt, Duration.ofMinutes(waitingCnt).toSeconds());
}
return waitingTokenInfo;
}
...
}
- 포인트 충전
포인트 충전의 경우에도 위에서 포인트 차감에 분산락을 적용했기 때문에 똑같이 분산락을 적용할 것이다.
@Component
@RequiredArgsConstructor
public class UserFacade {
private final UserService userService;
...
/**
* 잔액을 충전하는 요청하는 유즈케이스를 실행한다.
*
* @param command userId, balance 정보
* @return UserResponse 유저의 잔액 정보를 반환한다.
*/
@DistributedLock(key = "'userLock'.concat(':').concat(#command.userId())")
public User chargeBalance(UserCommand.Create command) {
return userService.chargeBalance(command);
}
}
포인트 충전이 동시에 들어와도 모두 충전이 되는걸 볼 수 있었다.
만약 유저의 실수로 따닥 충전이 된다면 어떻게 되야 할까???
이럴경우 서버 앞단에 API gateway 같은 것을 둔 뒤, client에서 헤더 중 하나에 멱등키를 포함해서 보내면 이게 중복된 요청인지 거를 수 있다고 한다.(다음에 시간이 되면 한번 해보기!!)
여기에 대한 내용은 https://docs.tosspayments.com/blog/what-is-idempotency 여기를 참조해보면 좋을 것 같다.
참조
'세션 > 항해플러스' 카테고리의 다른 글
캐시 및 Redis를 통한 성능 개선 (5) | 2024.09.05 |
---|---|
대기열 서비스 구현(+Redis) (3) | 2024.09.02 |
항해 플러스 백엔드 챕터 2 후기 (0) | 2024.07.20 |
WIL 3주차 회고 (0) | 2024.07.06 |
WIL 2주차 회고 (1) | 2024.06.30 |