🌱 들어가기 전
이번 포스팅에서는 개발중인 서비스의 예약 시스템을 만들며 발견한 동시성 문제를 해결하는 과정을 다룹니다.
- 동시성 제어를 구현한 처음에는 바로 예상한 결과가 나오지 않았었습니다. 그 원인은 트랜잭션 상태 및 커밋 시점과 관련이 있었습니다. 이 내용은 중요한 트러블 슈팅이라 생각하여, 따로 포스팅하였습니다.
https://deeper-dev.tistory.com/18
[프로젝트 - 트러블 슈팅] 동시성 제어의 트랜잭션 이슈 및 테스트 환경 이슈 해결
🌱 들어가기 전이번 포스팅에서는 동시성 제어 구현 및 테스트 코드 작성 과정에서 발생한, 아래의 세가지 문제를 분석하고 해결하는 과정을 다룹니다.1. profile분리 상황에 발생한 error2. ec2 metad
deeper-dev.tistory.com
🌱 서론
개발중인 서비스의 의자 예약 기능에서 중복 예약 문제를 발견했다.
예약 시스템에서 가장 중요한 부분은 동시성 제어라고 생각한다. 중복 예약이 되어버리면, 사용자에게 곤란한 상황이 생길뿐만 아니라 서비스에서도 문제가 발생하기 때문이다.
따라서 이 문제를 적극적으로 해결하고, 테스트 코드로 중복 예약에 관한 코드 품질을 보장하도록 하겠다.
💭 의자 중복예약 시나리오
중복 예약이 발생하는 시나리오를 통해, 의자 예약 시스템 로직을 살펴보겠습니다.
여러 사용자가 동일한 의자에 시간이 겹치게 예약하려하면 무슨일이 발생할까요?
아래 이미지를 보면서 설명하겠습니다.
chairId = 예약하려는 의자 id
startSchedule = 예약 시작 시간
endSchedule = 예약 끝 시간
*user1과 user2의 예약하려는 날짜가 동일하고, 시간은 05:00 ~ 07:30 이 중복됩니다.
- DB 트랜잭션 격리 수준이 가장 높은 수준, 즉 serializable로 설정되어 있지 않다고 하겠습니다. 이 상황에서 user1과 user2가 동시에 동일한 의자에 겹치는 예약 일정으로 예약을 요청합니다. user1은 트랜잭션 1이라고 하고, user2는 트랜잭션 2라고 하겠습니다.
하나의 의자는 한 명만 이용할 수 있습니다. - 트랜잭션 1은 요청한 예약 시간과 겹치는 예약이 DB에 존재하는지 검사합니다. 겹치는 예약 내역이 없기때문에 정상적으로 다음 로직으로 넘어갑니다.
- 트랜잭션 2도 요청한 예약 시간과 겹치는 예약이 DB에 존재하는지 검사합니다. 겹치는 예약 내역이 없기때문에 정상적으로 다음 로직으로 넘어갑니다.
- 트랜잭션 2가 먼저 의자 예약을 합니다.
- 그 직후 트랜잭션 1도 의자 예약을 실행합니다. DB의 ACID 속성에서 Isolation은 각 트랜잭션은 다른 트랜잭션과 무관하게 작업을 완료해야만 한다는 뜻이기에, 트랜잭션 2가 변경한 데이터는 commit되기 전에 트랜잭션 1에게 보이지 않습니다. 따라서 예약 시간이 겹치더라도 트랜잭션 1도 예약을 완료합니다. 결과적으로 한 의자에 예약 시간이 겹치는 이중 예약이 발생했습니다.
- 트랜잭션 1이 변경 사항을 성공적으로 DB에 반영합니다.
- 트랜잭션 2가 변경 사항을 성공적으로 DB에 반영합니다.
이 중복 예약 문제를 해결하기 위해서는 락(Lock)을 활용해야 합니다.
🌱 AS-IS
💭 좌석 예약 기능의 서비스 코드
좌석 예약 기능의 Service 클래스 코드입니다.
해당 함수는 예약하려는 의자id(시나리오의 chairId)와 예약 일정(시나리오의 startSchedule, endSchedule) 정보를 담은 요청을 받습니다.
@Transactional
public long chairReservation(String userEmail, ChairUtilizationRequest chairUtilizationRequest)
throws JsonProcessingException {
StoreChair storeChair =
storeChairService.findByIdAndState(chairUtilizationRequest.getStoreChairId());
// 겹치는 예약 예외처리
if (existsReservationAtDateTime(
storeChair,
chairUtilizationRequest.getStartSchedule(),
chairUtilizationRequest.getEndSchedule())) {
throw new BaseException(ResponseCode.RESERVATION_ALREADY_EXIST);
}
Store store = storeService.findByIdAndState(storeChair.getStoreSpace().getStore().getId());
User user = userService.findByEmailAndState(userEmail);
Reservation reservation =
Reservation.builder()
.store(store)
.reservedStoreChair(storeChair)
.reservedStoreSpace(null)
.user(user)
.startSchedule(chairUtilizationRequest.getStartSchedule())
.endSchedule(chairUtilizationRequest.getEndSchedule())
.build();
long savedId = reservationService.save(reservation).getId();
// custom utilization content 관련
inputChairCustomUtilizationContent(user, reservation, chairUtilizationRequest);
return savedId;
}
현재 해당 기능에 동시성 처리가 되어있지 않습니다. 따라서 동시에 여러 유저에게 겹치는 내용으로 예약 요청이 들어올 경우, 모두 정상적으로 완료되는 중복 예약 문제가 발생할 것 입니다.
정말 문제가 발생하는지 동시성 처리 테스트 코드를 통해 확인해보겠습니다.
우선 동시성처리를 한 경우, 의자 중복 예약 시나리오는 다음과 같습니다.
💭 동시성 처리를 한 경우, 의자 중복예약 시나리오
* 시작시점에는 예약 요청내용과 겹치는 내역이 DB에 없다고 가정하겠습니다.
- user1과 user2가 동시에 동일한 의자에 겹치는 예약 일정으로 예약을 요청합니다. user1은 트랜잭션 1이라고 하고, user2는 트랜잭션 2라고 하겠습니다.
하나의 의자는 한 명만 이용할 수 있습니다. - 트랜잭션 1이 '예약하려는 일정과 겹치는 건이 있는지 확인' 로직을 먼저 수행하고, 겹치는 예약 내역이 없기때문에 정상적으로 다음 로직으로 넘어갑니다.
- 트랜잭션 1이 의자를 예약합니다.
- 트랜잭션 1이 변경사항을 성공적으로 DB에 반영합니다.
- 이후, 트랜잭션 2가 '예약하려는 일정과 겹치는 건이 있는지 확인' 로직을 수행합니다. 직전에 트랜잭션 1의 commit으로 DB에 겹치는 예약 내역이 있기때문에, 다음 로직으로 넘어가지 않습니다.
- 트랜잭션 2에 예외가 발생합니다.
💭 좌석 예약 동시성 처리 테스트 코드
시나리오대로 동시에 두 명의 user가 동일한 의자에 예약 일정이 겹치게 요청하는 상황을 가정합니다.
동시성 처리를 잘 했다면 Lock을 선점한 user만 정상적으로 예약이 되고, 이후 요청은 예약 내용이 겹치기 때문에 예외처리로 빠집니다.
즉, 정상 처리되는 예약은 1개입니다.
@Test
void testChairReservationConcurrency() throws InterruptedException {
// Given
int numberOfThreads = 2;
ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
String userEmail = "yes@naver.com";
long storeChairId = 1500031;
LocalDateTime startSchedule1 = LocalDateTime.of(2024, 10, 29, 3, 0, 0);
LocalDateTime endSchedule1 = LocalDateTime.of(2024, 10, 29, 7, 30, 0);
LocalDateTime startSchedule2 = LocalDateTime.of(2024, 10, 29, 5, 0, 0);
LocalDateTime endSchedule2 = LocalDateTime.of(2024, 10, 29, 9, 0, 0);
long fieldId = 1;
List<String> content = Arrays.asList("스터디");
CustomUtilizationContentRequest customUtilizationContentRequest =
new CustomUtilizationContentRequest(fieldId, content);
ChairUtilizationRequest utilizationRequest1 =
new ChairUtilizationRequest(
storeChairId,
startSchedule1,
endSchedule1,
Arrays.asList(customUtilizationContentRequest));
ChairUtilizationRequest utilizationRequest2 =
new ChairUtilizationRequest(
storeChairId,
startSchedule2,
endSchedule2,
Arrays.asList(customUtilizationContentRequest));
// When
List<Long> ids = new ArrayList<>();
service.execute(
() -> {
long id = -1;
try {
id =
userReservationService.chairReservation(
userEmail, utilizationRequest1);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
} catch (BaseException baseException) {
log.error(ResponseCode.RESERVATION_ALREADY_EXIST.getMessage());
}
if (id != -1) {
synchronized (this) {
ids.add(id);
}
}
latch.countDown();
});
service.execute(
() -> {
long id = -1;
try {
id =
userReservationService.chairReservation(
userEmail, utilizationRequest2);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
} catch (BaseException baseException) {
log.error(ResponseCode.RESERVATION_ALREADY_EXIST.getMessage());
}
if (id != -1) {
synchronized (this) {
ids.add(id);
}
}
latch.countDown();
});
latch.await();
// Then
Assertions.assertThat(ids.size()).isEqualTo(1);
}
💭 AS-IS 테스트 결과
기존 코드에서 위 동시성 처리 테스트 코드를 돌린 결과입니다.
예약하려는 일정이 겹쳐도 모든 요청이 정상적으로 처리되어, 결과적으로 두 개의 요청 모두 정상적으로 DB에 들어갔습니다.
즉, 동시성 처리가 되어있지 않아 중복예약이 되는 문제가 있음을 확인했습니다.
🌱 Lock 계획
락을 통해 동시성을 제어할 때는 락의 범위를 최소화하는 것이 중요합니다. 락의 범위가 길어지면 대기중인 DB 커넥션이 많아지므로 커넥션 풀 고갈로 이어질 수 있다.
💭 Lock 종류
DB Lock으로 공유락, 배타락, 테이블이나 레코드, DB 객체 락, 을 거는 등 여러가지 Lock 종류가 있습니다.
이 시나리오는 DB에 공유자원이 없는 상태에서 발생하기 때문에, 사용자가 지정한 문자열에 대해 락을 획득하고 반납하는
따라서, 코드단에서 락을 걸어야합니다.
현재 서비스는 분산환경이 아니기 때문에, redis를 이용한 분산락보다 네임드 락을 사용하여 문제를 해결하겠습니다.
네임드 락은 임의로 락의 이름을 설정하고, 해당 락을 사용하여 동시성을 처리하는 방식입니다.
🌱 TO-BE
JPA를 사용할 때 애플리케이션 코드 단에서 다음과 같이 네임드 락을 구현할 수 있습니다.
💭 네임드 락 구현
public interface LockRepository extends JpaRepository<Reservation, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
네임드 락은 비관적 락과 달리 트랜잭션 종료 시 자동으로 해제되지 않기 때문에, release_lock을 통해 수동으로 해제해야합니다.
네임드 락을 사용할 때는 네임드 락 설정 부분과 비즈니스 로직의 트랜잭션을 분리해야 합니다.
💭 비즈니스 로직
public long chairReservation(String userEmail, ChairUtilizationRequest chairUtilizationRequest)
throws JsonProcessingException {
StoreChair storeChair =
storeChairService.findByIdAndState(chairUtilizationRequest.getStoreChairId());
Store store = storeService.findByIdAndState(storeChair.getStoreSpace().getStore().getId());
User user = userService.findByEmailAndState(userEmail);
Reservation reservation =
Reservation.builder()
.store(store)
.reservedStoreChair(storeChair)
.reservedStoreSpace(null)
.user(user)
.startSchedule(chairUtilizationRequest.getStartSchedule())
.endSchedule(chairUtilizationRequest.getEndSchedule())
.build();
// 동시성처리: 의자 예약 일정 겹치는지 검사 && 일정 겹치지 않으면 예약 DB에 저장
long savedId =
namedLockUserReservationFacade.chairReservation(
storeChair,
chairUtilizationRequest.getStartSchedule(),
chairUtilizationRequest.getEndSchedule(),
reservation);
if (savedId == -1) {
throw new BaseException(ResponseCode.RESERVATION_ALREADY_EXIST);
}
inputChairCustomUtilizationContent(user, reservation, chairUtilizationRequest);
return savedId;
}
💭 네임드 락 설정 로직
public class NamedLockUserReservationFacade {
private final LockRepository lockRepository;
private final ReservationService reservationService;
@Transactional
public long chairReservation(
StoreChair storeChair,
LocalDateTime startDateTime,
LocalDateTime endDateTime,
Reservation reservation) {
long chairId = -1;
try {
lockRepository.getLock(storeChair.getId().toString());
chairId =
reservationService.checkExistsReservationDateTimeAndSave(
storeChair, startDateTime, endDateTime, reservation);
if (chairId == -1) {
throw new BaseException(ResponseCode.RESERVATION_ALREADY_EXIST);
}
} finally {
lockRepository.releaseLock(storeChair.getId().toString());
return chairId;
}
}
}
💭 비즈니스 로직과 네임드 락 설정 로직 분리 이유
두 로직이 하나의 트랜잭션으로 묶인다면 어떤 문제가 발생할까요?
스레드 A, B에서 동시에 좌석 예약을 요청했다고 가정해봅시다.
스레드 A에서 먼저 네임드 Lock을 설정하고 좌석 예약을 실행한 후에 네임드 Lock을 해제합니다.
여기서 해당 좌석 예약 로직을 실행한 트랜잭션이 커밋되는 시점이 중요합니다.
트랜잭션 분리가 이루어지지 않았다면, 네임드 Lock 해제 후 트랜잭션이 커밋될 것입니다.
이때 스레드 B 입장에서는, 네임드 Lock에 의해서 블로킹 상태로 대기하고 있다가 Lock이 해제되는 순간 좌석 예약 로직을 실행합니다.
여기서 스레드 A의 좌석 예약이 아직 커밋되지 않은 상태에서 스레드 B가 DB의 좌석 예약 히스토리를 조회할 것이므로 동시성 문제에서 발생했던 중복 예약이 똑같이 발생할 것입니다.
따라서, 트랜잭션을 분리하여 비즈니스 로직이 커밋된 후에 네임드 Lock을 해제하도록 해야합니다.
💭 TO-BE 동시성 테스트 결과
네임드 락을 사용한 동시성 제어로, 중복 예약 문제를 해결했다.
'프로젝트' 카테고리의 다른 글
[프로젝트-Redis] 다중 서버 환경에서 락 동기화 문제 해결을 위한 Redisson 분산 락 도입 (1) | 2024.10.19 |
---|---|
[프로젝트 - 트러블 슈팅] 동시성 제어의 트랜잭션 이슈 및 테스트 환경 이슈 해결 (1) | 2024.09.18 |
[프로젝트] JWT 재발급 자동화를 통해, 반복 작업을 줄이고 네트워크 I/O 감소시키기 (0) | 2024.04.14 |
[프로젝트] 대량 트래픽에 대한 조회 성능 개선 (쿼리 최적화 및 캐시) (0) | 2024.03.17 |