본문 바로가기

프로젝트

예약 동시성 제어 과정

0. 개요

이 글은 프로젝트에서 진행했던 예약 동시성 제어 과정에 대한 글이다.

 

문제 상황은 다음과 같다. 한 트레이너는 특정 시간에 하나의 예약만 받을 수 있다. 그러나 여러 회원이 동시에 요청을 보낼 때 2개 이상의 예약이 완료되는 문제가 발생했다. 즉, 동시성 제어가 필요한 상황이다.

 

예약 로직은 다음과 같다.

  1. 트레이너 이메일과 예약 시간을 기준으로 예약 내역을 조회한다. (SELECT)
  2. 조회된 예약이 없으면 새로운 예약을 생성하여 저장한다. (INSERT)

동시성 문제 해결 과정은 다음과 같다.

1. 자바의 synchronized 키워드

예약 로직 메서드에 synchronized 키워드를 붙이는 방법이다.

@Service
@Transactional
@RequiredArgsConstructor
public class ScheduleService {

    private final ScheduleRepository scheduleRepository;
    private final RelationshipRepository relationshipRepository;

    // synchronized 키워드
    public synchronized AddScheduleResponse addSchedule(AddAndDeleteScheduleRequest addAndDeleteScheduleRequest, String memberEmail) {
        Relationship relationship = relationshipRepository.findByMemberEmail(memberEmail)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

        validateDuplicate(relationship.getTrainerEmail(), addAndDeleteScheduleRequest.getDateTime());
        Schedule schedule = Schedule.create(relationship, addAndDeleteScheduleRequest.getDateTime());
        scheduleRepository.save(schedule);
        return new AddScheduleResponse(schedule.getDateTime());
    }

    private void validateDuplicate(String trainerEmail, LocalDateTime dateTime) {
        if (scheduleRepository.findByTrainerEmailAndDateTime(trainerEmail, dateTime).isPresent()) {
            throw new BusinessException(ErrorCode.ALREADY_RESERVATION);
        }
    }

 

그러나 JMeter로 여러 개의 요청을 보냈을 때, 예상과 달리 중복된 예약이 INSERT되었다.

 

문제 원인은 synchronized와 @Transactional을 같이 사용했기 때문이다. @Transactional은 프록시 형태로 동작한다. 따라서 synchronized와 @Transactional을 같이 사용할 경우, 동시성 제어가 완벽히 되지 않을 수 있다. 즉, T1(트랜잭션)의 synchronized 락이 풀리고, 트랜잭션 AOP를 통해 커밋하기 직전에 T2가 DB를 조회할 수 있기 때문이다. 

 

또한 synchronized를 사용하는 방법은 멀티 인스턴스인 분산 환경에서는 동시성 제어를 할 수 없다. 내 프로젝트의 경우, 분산 환경은 아니지만 확장성 있게 분산 환경까지 고려하고자 한다. 따라서 이제 애플리케이션 서비스 계층의 비즈니스 로직이 임계 영역이 아니라, DB가 임계 영역이 된다. 따라서 애플리케이션을 넘어 DB 단에서 동시성 제어를 해주자.

2. REPEATABLE READ 격리 수준 + DB 락

MySQL의 기본 격리 수준은 REPEATABLE READ이다. 이 경우 T1, T2가 동시에 SELECT한 후 동시에 예약을 INSERT하므로 동시성 제어가 되지 않는다. 

읽기 락(SELECT FOR SHARE)

따라서 조회시 읽기 락을 걸어보자(@Lock(value = LockModeType.PESSIMISTIC_READ)). 읽기 락을 걸었으므로, 조회 시 SELECT FOR SHARE 쿼리가 나가는 것을 확인할 수 있다.

 

그러나 이 경우 데드락이 발생했다. 데드락이 발생한 이유는 MySQL의 갭 락인서트 인텐션 락 때문이다. MySQL의 갭 락과 인서트 인텐션 락에 대해서는 다음 글에 좀 더 자세히 설명해두었다. (https://olsohee.tistory.com/222)

  • 테이블에서 레코드를 조회할 때 조건에 맞는 레코드가 없으면(=예약이 없으면), MySQL은 해당 조건의 레코드가 존재할 수 있는 범위에 갭 락을 건다. 그리고 예약을 생성한 후 INSERT할 때 인서트 인텐션 락을 건다. 그리고 갭 락과 인서트 인텐션 락은 호환이 불가하다.
  • 따라서 T1과 T2가 동시에 SELECT할 때 같은 범위에 갭 락을 걸고 있고, 예약을 INSERT할 때 같은 범위에 인서트 인텐션 락을 걸려고 하는데, 이때 상대 트랜잭션이 갭 락을 반납할 때까지 서로 대기하게 되면서 데드락이 발생한다.

InnoDB 로그를 보면 데드락이 발생한 과정을 확인할 수 있다.

  1. T1: 갭 락 획득 (lock mode S)
  2. T2: 갭 락 획득 (lock mode S)
  3. T1: 인서트 인텐션 락 대기 (lock mode X)
  4. T2: 인서트 인텐션 락 대기 (lock mode X)

쓰기 락(SELECT FOR UPDATE)

이번에는 쓰기 락을 걸어보자(@Lock(value = LockModeType.PESSIMISTIC_WRITE)). 쓰기 락을 걸었으므로, 조회 시 SELECT FOR UPDATE 쿼리가 나가는 것을 확인할 수 있다.

 

그러나 이 경우에도 데드락이 발생했다. 역시 갭 락과 인서트 인텐션 락으로 인해 데드락이 발생한 것이고, InnoDB 로그를 보면, 읽기 락을 걸었을 때와 달리 쓰기 락을 걸면서 갭 락을 획득하기 때문에 이때의 갭 락은 lock mode X이다.

  1. T1: 갭 락 획득 (lock mode X)
  2. T2: 갭 락 획득 (lock mode X)
  3. T1: 인서트 인텐션 락 대기 (lock mode X)
  4. T2: 인서트 인텐션 락 대기 (lock mode X)

3. Unique Constraint 제약 조건

예약을 의미하는 Schedule 엔티티에 @UniqueConstraint 어노테이션을 붙여서 date_time과 trainer_email을 기준으로 제약 조건을 거는 방법이다.

@Entity
@Getter
// 유니크 제약조건 설정
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"date_time", "trainer_email"})})
public class Schedule {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "schedule_id")
    private long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "relationship_id")
    private Relationship relationship;

    @Column(nullable = false)
    private LocalDateTime dateTime;

    @Column(nullable = false)
    private String trainerEmail;
    
    ...
}

 

따라서 DDL에 제약 조건이 추가되고, MySQL 내부적으로는 유니크 키가 생성된다.

 

이를 통해 DB에 중복 데이터가 삽입되면, 애플리케이션 로직으로 DataIntegrityViolationException 예외가 던져진다. 따라서 애플리케이션 로직에서 해당 예외를 잡아서, 비즈니스 예외로 변환하고 적절한 예외 응답을 내리도록 구현했다.

@Service
@Transactional
@RequiredArgsConstructor
public class ScheduleService {

    private final ScheduleRepository scheduleRepository;
    private final RelationshipRepository relationshipRepository;

    public AddScheduleResponse addSchedule(AddAndDeleteScheduleRequest addAndDeleteScheduleRequest, String memberEmail) {
        Relationship relationship = relationshipRepository.findByMemberEmail(memberEmail)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));
        try {
            Schedule schedule = Schedule.create(relationship, addAndDeleteScheduleRequest.getDateTime());
            scheduleRepository.save(schedule);
            return new AddScheduleResponse(schedule.getDateTime());
        } catch (DataIntegrityViolationException e) { // 충돌 발생 시
            throw new BusinessException(ErrorCode.ALREADY_RESERVATION);
        }
    }
}

 

결과적으로 이 방법을 적용하여 동시성 제어에 성공했다. JMeter로 여러 개의 요청을 보냈을 때, 다음과 같이 1개의 예약만 완료되었다.

 

그리고 DB에도 한 개의 예약만 INSERT되었다.

 

이때 EXPLAIN 명령어로 실행된 쿼리의 실행 계획을 보면, 제약 조건으로 인해 생성된 유니크 키가 사용된 것을 알 수 있다. 즉, 유니크 키를 통해 조회하기 때문에 빠른 조회가 가능하다.

4. Redis 분산 락

자바의 synchronized 키워드는 다중 서버 환경에서 동시성 제어가 불가하지만, 분산 락은 다중 서버가 동일한 락을 바라보며 락 획득을 시도하므로, 다중 서버 환경에서도 동시성 제어가 가능하다. 

 

분산 락을 구현하기 위해 락에 대한 정보를 어딘가에 보관하고 있어야 하고, 분산 환경의 여러 서버는 이 공통된 어딘가를 바라보며, 자신이 임계 영역에 접근이 가능한지 확인한다. 즉, synchronized를 통해 락을 대기하고, 락을 획득하면 임계 영역에 접근할 수 있는 것처럼, 이를 애플리케이션 밖 어딘가를 통해 락을 구현하는 것이라고 이해하면 된다.

 

'어딘가'로 활용되는 기술은 MySQL의 네임드 락, Redis, Zookeeper 등이 있다. 그리고 Redis를 통한 분산 락에는 Lettuce 클라이언트를 통한 SETNX 명령어를 통해 스핀 락 방식으로 구현하는 방법과, Reddison 클라이언트를 통한 pub/sub 방식으로 구현하는 방법이 있다.

 

스핀 락은 비효율적이게 Redis에 부하를 가하기 때문에, Lettuce를 통한 pub/sub 방식으로 구현했다. 그런데 이 경우에도 여전히 동시성 제어에 실패하는데, 이유는 락과 @Transactional을 함께 사용하기 때문이다.

락과 @Transactional을 함께 사용했을 때 문제

Redis를 통한 분산 락이든 앞서 살펴본 synchronized를 사용하든 @Transactional 어노테이션과 함께 사용했을 때 동시성 제어가 불가하다. 이유는 락을 해제하고 커밋하기 직전에, 다른 트랜잭션이 락을 획득하여 임계영역에 접근하기 때문이다. (이때 임계영역은 "예약 내역을 SELECT한 후, INSERT하는 로직이다.)

 

이는 트랜잭션 격리 수준을 바꾸더라도 문제가 된다. JPA는 쓰기 지연으로 예약을 INSERT하더라도, 이 SQL이 바로 DB에 반영되는 것이 아니라 커밋 직전에 플러시 시점에 반영되기 때문이다. 즉, JPA는 REPEATABLE READ 격리 수준을 보장한다.

 

해결 방안은 다음과 같다.

  • 분산 락을 먼저 획득하고, 이후에 트랜잭션을 시작/종료하기
  • @Transactional을 제거하기

나는 @Transactional을 제거하는 방법을 선택했다. 서비스 계층에 @Transactional을 붙이지 않더라도, JPA 메서드를 호출할 때 @Transactional이 붙어있다. 그리고 서비스 계층에 @Transactional을 붙이지 않았을 때의 문제는 다음과 같다. (다음 글에 더 자세히 설명되어 있다. https://olsohee.tistory.com/220)

  • 서비스 계층에서 리포지토리의 JPA 메서드를 호출할 때마다, 각각의 트랜잭션이 실행되고 따라서 각각 영속성 컨텍스트를 갖는다. 따라서 영속성 컨텍스트를 통한 1차 캐시 이점을 누릴 수 없다.
    • 그러나 비즈니스 로직 상 동일 엔티티를 여러 번 조회하는 로직이 없다. 따라서 1차 캐시를 통한 이점을 누릴 만한 로직이 없다.
  • 서비스 계층에 영속성 컨텍스트가 있지 않으므로 서비스 계층에서 지연 로딩이 불가하다. 
    • 비즈니스 로직 상 지연 로딩이 필요하지 않다.
  • 리포지토리 메서드가 호출될 때마다 트랜잭션을 열고 닫으므로, 원자성이 보장되어야 하는 작업에서 각기 다른 트랜잭션이 수행될 수 있다.
    • 그러나 락을 통해 논리적인 원자성을 보장할 수 있다. 즉, 예약이 있는지 SELECT하고, 새로운 예약을 INSERT하는 것이 각기 다른 트랜잭션, 각기 다른 영속성 컨텍스트에서 실행되지만, 락을 통해 논리적으로 두 작업을 하나의 작업처럼 원자성을 보장한다. 그 사이에 다른 트랜잭션이 수행되지 않는다.
  • 서비스 계층에서 로직을 수행하다가 DB 장애 발생시, 지금까지 진행하던 것이 롤백되지 않는다.
    • 그러나 예약 내역을 조회한 후, 삽입하는 로직이기 때문에 조회 후 DB 장애가 발생했을 경우, 단순 조회만 했으므로 롤백할 필요가 없다. (만약, 예약 엔티티 값을 변경하거나 새로운 예약 엔티티를 삽입하는 등의 로직이 있었을 경우, 중간에 DB 장애 발생시 롤백이 되어야 한다.

따라서 서비스 계층에 @Transactional 어노테이션이 불필요하다고 판단되어 제거했다. 따라서 Redis 락을 통해 동시성 제어에 성공한다.

@Service
@RequiredArgsConstructor
public class ScheduleService {

    private final ScheduleRepository scheduleRepository;
    private final RelationshipRepository relationshipRepository;
    private final RedissonClient redissonClient;

    public AddScheduleResponse addSchedule(AddAndDeleteScheduleRequest addAndDeleteScheduleRequest, String memberEmail) throws JsonProcessingException {
        Relationship relationship = relationshipRepository.findByMemberEmail(memberEmail)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));
        RLock lock = redissonClient.getLock(relationship.getTrainerEmail());

        try {
            boolean isLocked = lock.tryLock(3, 30, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new RuntimeException("Redis 락 획득 실패");
            } else { // 락 획득 후 임계영역 접근
                Schedule schedule = Schedule.create(relationship, addAndDeleteScheduleRequest.getDateTime());
                scheduleRepository.save(schedule);
                return new AddScheduleResponse(schedule.getDateTime());
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock(); // 락 해제
        }
    }
}

5. 결론

동시성 제어에 성공한 방법은 유니크 제약 조건과 분산 락이다. 두 방법의 차이는 다음과 같다.

  • 유니크 제약 조건: 충돌이 잦지 않을 것이라고 가정하여, 락을 사용하지 않고 임계영역에 접근한 후 충돌이 발생하면 처리하는 낙관적 접근법
  • 분산 락: 충돌이 잦을 것이라고 가정하여, 임계영역에 접근하기 전에 항상 락을 획득하는 비관적 접근법

나는 동시 요청이 많다는 가정 하에 비관적 접근법인 분산 락을 적용했다.