Project

다양한(?) 동시성 제어 방법 맛보기

_sparrow 2023. 5. 30. 13:55
반응형

개요

5월쯤이었을까 특정 항공사에서 10000명 정도에 한해 선착순으로 홍콩 무료 항공권 이벤트를 했다. 링크

무료 항공권에 혹해서 이벤트를 참여했는데 뉴스기사에서 10만 명 넘는 사람의 신청자가 몰렸다고 한다.

꽤 많은 사람들이 몰린 이벤트였고 항공사에서 이벤트를 처리하는 방식이 신기했다. 해당 항공사에서 처리한 방식은 클라이언트 요청에 대해 요청비율(RPS)로 제한하는 방식으로 Nginx 같은 웹서버를 통해 요청비율을 넘는 IP주소들은 Queue에 대기시키고 조금씩 그리고 순차적으로 이벤트 페이지에 접속하도록 하는 트래픽 제어하는 방식을 사용한 것 같다.

이는 물론 동시성 제어하는 방식과는 분명히 다른 케이스이지만 많은 사람들이 몰릴 때 어떻게 동시성 제어를 하는 게 좋고 각 특징 그리고 최적의 방법을 나름대로 생각해 보기 위해 테스트를 수행하게 됐다.

 

방법

1. JPA를 활용한 어플리케이션 레벨에서의 Optimistic Locking

2. MySQL을 활용한 RDB에서의 Pessimistic Locking

3. Redis를 활용한 Distributed Locking

3가지 방법을 수행할 것이고 Lock 특징, 장단점 그리고 TPS 수치를 기반으로 설명할 것이다.

 

데이터 구조

정말 단순하다.

 

1. JPA를 활용한 Optimistic Locking

@Version 어노테이션을 사용한 트랜잭션 충돌 체크하는 방식이고 최초 커밋만 인정하는 방식이다.

트랜잭션 커밋시 @Version 필드값을 데이터베이스의 컬럼값과 비교하고 값을 +1을 수행한다. 동일하지 않을 경우 예외발생한다.

즉 트랜잭션을 커밋하기 전까지 트랜잭션이 충돌하는지 알 수 없다. 

 

[ 구현 ]

낙관적 락을 사용하기 위해 version 컬럼 만들기

@Version 어노테이션만 있다면 낙관적 락을 따로 사용하지 않아도 된다.

@Entity
@Table(name = "EVENT")
public class Event {
    @Id
    @Column(name = "event_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "event_name")
    private String name;

    @Column(name = "quantity")
    private int quantity;

    @Column(name = "rest_quantity")
    private int restQuantity;

    @Version
    private int version;
}

 

Service Layer

@Service
public class TicketService {

	...

// 이벤트 신청에 대한 티켓 발급 로직
    @Transactional
    public CreateTicketResponseDto save(CreateTicketRequestDto createTicketRequestDto) {
    // 고객 정보 조회
        Customer customer = customerRepository.findById(createTicketRequestDto.getCustomerId()).orElseThrow(IllegalArgumentException::new);
	// 이벤트 정보 조회
        Event event = eventRepository.findById(createTicketRequestDto.getEventId()).orElseThrow(IllegalArgumentException::new);

	// 티켓 생성
        Ticket aTicket = Ticket.createTicket(customer, event);        
	// 티켓 저장
        Ticket ticket = this.ticketRepository.save(aTicket);

        return CreateTicketResponseDto.of(ticket);
    }
}

 

티켓이 정말로 발급되어도 되는지 검증 로직이 필요하다.

이벤트 티켓 수량이 10개이고 발급 가능한 수량이 0인 경우 발급되어선 안되기 때문이다.

 

Ticket Entity

@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "TICKET")
public class Ticket {

// 영속화 전 수행하는 로직
    @PrePersist
    public void beforeSave() {
      	...
        decreaseRestQuantity();
    }

    private void decreaseRestQuantity() {
        if (event != null) {
        // 이벤트 객체에서 발급 수량이 남았는지 검증하고 수량을 제거할 것이다.
            event.decreaseRestQuantity(1);
        }
    }

   ...
}

Service Layer가 아닌 엔티티에 넣어서 JPA를 활용한 코드를 작성했다.

@PrePersist 어노테이션으로 영속화(persist) 하기 전 발급 가능한 수량이 있는지 확인하고 감소하도록 했다.

또한 발급 수량은 Ticket 객체에서 메시지를 전달해 Event 객체에서 검증 및 처리되도록 했다.

 

즉 Service Layer에서 보이지 않는 로직은 이렇다.

<Ticket 생성>

 - 영속화하기 전 티켓 잔여량이 있는지 체크하고 수량 감소

<Ticket 저장>

 

 

[ 낙관적 락 작동방식 ]

낙관적 락은 조회시점부터 수정 시점까지 보장하기 위한 용도다.

@Transactional annotation에 의해 Service Layer의 비즈니스 로직이 끝나는 순간 영속성 컨텍스트가 flush()를 수행한다.

flush()를 수행하면 쓰기 지연으로 수행되지 않았던 UPDATE SQL문을 수행하게 되는데 데이터베이스 버전값이 현재 버전이 아니면 OptimisticLockException이 발생하게 되고 문제없다면 트랜잭션 커밋을 수행해 데이터를 저장한다.

 

 

테스트

event 수량 10개에 대한 테스트를 JMeter로 수행했다.

테스트 조건은 1초에 50 Thread로 1회 수행하는 테스트를 진행했다.

 

Event 테이블

event_id event_name quantity rest_quantity version
1 항공권 행사 10 3 7

발급 가능한 수량이 10개뿐인데  50 Threads를 사용했는데도 다 발급받지 못했다.

JMeter 클라이언트 요청 성공이 초록색이다.

 

 

왜 이런 일이 발생했을까?

Spring에 2가지 종류의 에러가 발생한다.

 

1. Lock을 얻기 위해 시도하다가 데드락 발생

com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction ... 

 

2. version과 rest_quantity값이 맞지 않아 에러 발생

org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: /* update com.event.fcfssystem.model.Event */ update EVENT set event_name=?, quantity=?, rest_quantity=?, version=? where event_id=? and version=? ..

 

 

[ 에러가 발생한 일어난 이유 ]

1. 요청 클라이언트가 커넥션을 통해 DB에 접근했지만 Lock을 얻을 수 없어 대기하다가 데드락으로 rollback 되어버리는 경우

2. 트랜잭션 커밋하는 순간 조건 컬럼값이 불일치하는 경우

 

 

[ 결론 ]

1. 낙관적 락은 트랜잭션이 대부분 충돌되지 않을 것이라는 판단하에 실행되는데 다수의 트랜잭션이 진행될 때 version값이 이미 변경된 경우 실패하게 된다. 그렇기 때문에 순차적인 선착순 발급이 불가능할 수 있다. 많은 요청에 대해서는 불가능이라고 보는 게 맞다.
이러한 문제를 해결하는 방법으로 다시 락을 얻기 위해 시도하는 방법이 있다. 하지만 다시 다른 트랜잭션과 경합해야 하는 상황에 직면하기 때문에 락을 얻는다는 보장이 없다는 한계점이 있다.

 

끝으로 수량 100개에 대해 1000Threads로 테스트했을 때의 결과다.

평균 50정도의 TPS와 실패를 볼 수 있다. 하지만 조건에 맞게 수량이 딱 100건만 발급된다.

 

 

 

2. MySQL을 활용한 RDB에서의 Pessimistic Locking

비관적 락은 데이터베이스 트랜잭션 락 매커니즘에 의존하는 방법이다.

SQL 쿼리에 SELECT FOR UPDATE 구문을 사용하면서 해당 row Lock을 걸어 다른 트랜잭션이 조회하지 못하도록 한다.

낙관적 락과 다르게 version 컬럼이 필요 없으며 낙관적 락과 다르게 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.

 

[ 구현 ]

Service Layer

@Service
public class TicketService {

	...

// 이벤트 신청에 대한 티켓 발급 로직
    @Transactional(timeout = 5)
    public CreateTicketResponseDto save(CreateTicketRequestDto createTicketRequestDto) {

        Customer customer = customerRepository.findById(createTicketRequestDto.getCustomerId()).orElseThrow(IllegalArgumentException::new);
	// 쓰기락을 사용한 이벤트 정보 조회
        Event event  = eventRepository.findByIdWithLock(createTicketRequestDto.getEventId()).orElseThrow(IllegalArgumentException::new);

        Ticket aTicket = Ticket.createTicket(customer, event);        
        Ticket ticket = this.ticketRepository.save(aTicket);

        return CreateTicketResponseDto.of(ticket);
    }
}

 

EventRepository

public interface EventRepository extends JpaRepository<Event, Long> {

// SELECT FOR UPDATE SQL문과 동일한 역할을 한다.
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select e FROM Event e WHERE e.id = :eventId")
    Optional<Event> findByIdWithLock(@Param("eventId") Long eventId);
}

@Lock 어노테이션으로 비관적 락을 사용하겠다고 명시해 주면 DB에서의 SELECT FOR UPDATE를 수행한다고 보면 된다.

쿼리를 사용하고 싶지 않다면  Querydsl 쿼리빌더의 setLockMode() 메서드를 활용하는 방법도 있다.

 

 

테스트

낙관적 낙관적 락 테스트와 동일하게 event 티켓 수량 10개에 대해 1초에 50 Thread로 1회 수행하는 테스트를 진행했다.

 

Event 테이블

event_id event_name quantity rest_quantity
1 항공권 행사 10 0

 

Ticket 테이블

ticket_id created_at customer_id ...(그외 컬럼)
1 .. 21:03:02.269000 10  
2 .. 21:03:02.326000 3  
3 .. 21:03:02.340000 5  
4 .. 21:03:02.355000 9  
5 .. 21:03:02.367000 7  
6 .. 21:03:02.381000 8  
7 .. 21:03:02.395000 11  
8 .. 21:03:02.407000 2  
9 .. 21:03:02.417000 6  
10 .. 21:03:02.425000 4  

 

JMeter 클라이언트 요청 성공이 초록색이다.

 

 

 

[ 결론과 비관적 락 추가 정보 ]

1. 낙관적 락에 비해 비교적(?) 순차처리가 가능해졌다. 쿼리를 실행하는 순서대로 해당 트랜잭션이 락을 부여받고 다른 트랜잭션은 락을 얻기 위해 대기상태가 된다.

2. 비관적 락은 TIME_OUT을 설정해야 한다. 그렇지 않으면 락을 얻기 위해 트랜잭션이 대기하게 되는데 이는 데드락을 유발할 수 있다.

3. 객체지향 코딩에 부합하지는 않겠지만 엔티티가 아닌 스칼라 타입을 조회할 때도 사용가능하다.

 

끝으로 낙관적 락 테스트와 동일한 수량 100개에 대해 1000Threads로 테스트했을 때의 결과다.

조건에 맞게 수량이 딱 100건만 발급될 뿐만 아니라 낙관적 락에 비해 높은 TPS가 나왔는데 아무래도 락을 먼저 부여받은 트랜잭션에 대해서 로직이 진행되다 보니 충돌이 일어나지 않아 더 좋은 성능을 내는 것 같다.

 

 

 

3. Redis를 활용한 Distributed Locking

분산 락은 여러 서버에서 동시에  접근하는 자원에 대한 액세스를 제어하는 방법으로 분산 락을 사용하면 락을 부여받기 전까지 대기하거나 포기해야 한다.

분산 락은 여러 가지 구현 방법이 있는데 Redis의 라이브러리 Redisson을 활용한 분산락을 구현할 것이다.

Redis를 활용한 분산락 사용 시 클라이언트는 Redis로부터 락을 부여받아야 로직을 실행할 수 있도록해 다른 클라이언트의 접근을 방지한다. 그리고 이는 DB락을 사용하지 않으면서 데이터베이스의 충돌을 방지하고 데이터의 무결성을 유지할 수 있다.

 

 

[ 구현 ]

구현은 Aspect를 만들어서 분산 락을 구현하고자 한다.

 

Aspect를 만든 이유는 2가지가 있다.

1. AOP가 나오게 된 이유와 일치하는데 비즈니스 로직 안에 부가기능을 수행하는 Lock 로직이 존재하면 이해하기 어려워지고 테스트하기 어려워진다. 또한 매번 Lock이 필요한 비즈니스 로직마다 작성해야 해서 중복이 발생한다.

2. Service Layer에 Lock에 대한 코드를 넣게 되면 @Transactional에 의해 트랜잭션이 먼저 시작되고 Lock을 부여받게 되면서 데이터 무결성을 보장받을 수 없기 때문이다. JPA 작동원리와 lock/unlock의 순서 차이로 인해 발생하게 되는데 아래의 코드에서 설명하도록 하겠다.

 

분산 락 annotation

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributedLock {

    /* The name of the resource to lock */
    String value();

    /* wait for lock aquisition */
    long waitTime() default 4L;

    /* automatically unlock */
    long leaseTime() default 3L;

    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

 

분산 락 Aspect

아무런 설정 없이 annotation만 작성한다면 Transcational Aspect -> DistributedLock Aspect가 실행된다.

@DistributedLock이 @Transactional보다 우선적으로 실행되어야 하기 때문에 @Order를 사용했다.

현재 케이스는 해당안되지만 다른 Aspect가 최우선적으로 실행되어야 할 경우가 있을 수 있으니 @Order를 사용하지 않고 다음 pointcut으로 @Transactional이 진행되도록 할 방법을 찾으려고 했으나 아직 못 찾았다.

 

@Aspect
@Component
@Order(value = 1)
public class DistributedLockAspect {

    private static final String PREFIX = "lock";
    private static final String SEPARATOR = "_";
    private final RedissonClient redissonClient;

    public DistributedLockAspect(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @Pointcut("@annotation(com.event.fcfssystem.aop.DistributedLock)")
    private void distributedLock() {
    }

    @Around(value = "distributedLock()")
    public Object lock(ProceedingJoinPoint pjp) throws Throwable {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

		// Redis Base의 특정 이름의 Lock 인스턴스를 부여 받는다. (Lock 부여 요청x)
        RLock lock = redissonClient.getLock(createLockName(signature.getParameterNames(), pjp.getArgs(), distributedLock));

        try {
        	// Lock을 얻기위한 시도를 한다. 락 부여 대기시간과 자동적으로 released되는 시간도 설정했다.
            boolean available = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) {
                return false;
            }

            return pjp.proceed();
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            lock.unlock();
        }
    }

    private String createLockName(String[] parameterNames, Object[] args, DistributedLock distributedLock) {
        ExpressionParser parser = new SpelExpressionParser();
        EvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        Object value = parser.parseExpression(distributedLock.value()).getValue(context, Object.class);

        return PREFIX + SEPARATOR + distributedLock.value() + SEPARATOR + value;
    }
}

 

Service Layer

기존의 어플리케이션 레벨의 Optimistic Lock, DB를 사용한 Pessimistic Lock 이 사용되는 코드를 전부 제거했다.

분산 락을 사용하지 않으면 무조건 동시성에 문제가 발생하는 코드다.

@Service
public class TicketService {

	...

	// 분산 락 어노테이션을 사용했다.
    @DistributedLock(value = "#createTicketRequestDto.getEventId()")
    @Transactional(timeout = 5, rollbackFor = InterruptedException.class)
    public CreateTicketResponseDto save(CreateTicketRequestDto createTicketRequestDto) {
        Customer customer = customerRepository.findById(createTicketRequestDto.getCustomerId()).orElseThrow(IllegalArgumentException::new);
        Event event = eventRepository.findById(createTicketRequestDto.getEventId()).orElseThrow(IllegalArgumentException::new);

        Ticket aTicket = Ticket.createTicket(customer, event);
        Ticket ticket = this.ticketRepository.save(aTicket);

        return CreateTicketResponseDto.of(ticket);
    }
}

 

구현 2번에 Service Layer에 Lock 관련 코드를 넣으면 무결성 보장이 안된다고 했다.

JPA는 flush()가 수행되면서 임시 SQL 저장소에 넣어둔 SQL을 수행하게 되는데 insert를 수행하고 트랜잭션을 커밋하기 전 unlock 되어버리면서 데이터 정합성에 문제가 발생한다.

 

Case 1) Lock이 먼저 해제된 후 트랜잭션 커밋이 수행되는 경우

transcation start -> lock -> 비즈니스 로직 -> unlock -> flush() -> commit()

unlock 하고 메서드가 종료된다. 그 후 데이터베이스 관련 작업이 수행되는데 unlock 되는 순간 다른 클라이언트는 lock을 부여받을 수 있게 된다. 그리고 메서드 내부 로직이 실행되고 동일하게 데이터 베이스 처리까지 수행하게 되면서 수량이 안 맞게 된다.

 

Case 2) Lock을 먼저 발급받고 트랜잭션이 시작되는 경우

lock -> transcation start -> 비즈니스 로직 -> flush() -> commit() -> unlock

lock을 부여받은 클라이언트는 내부 로직 실행뿐만 아니라 데이터 베이스 처리가 완료되기 전까지 lock을 해제하지 않으면서 데이터 정합성을 보장한다.

 

 

테스트

다른 락 테스트와 동일하게 event 티켓 수량 10개에 대해 1초에 50 Thread로 1회 수행하는 테스트를 진행했다.

 

Event 테이블

event_id event_name quantity rest_quantity
1 항공권 행사 10 0

 

Ticket 테이블

ticket_id created_at customer_id ...(그외 컬럼)
1 .. 18:43:05.679000 1  
2 .. 18:43:05.738000 2  
3 .. 18:43:05.831000 3  
4 .. 18:43:05.931000 4  
5 .. 18:43:06.031000 5  
6 .. 18:43:06.137000 6  
7 .. 18:43:06.237000 7  
8 .. 18:43:06.332000 8  
9 .. 18:43:06.444000 9  
10 .. 18:43:06.538000 10  

 

JMeter 클라이언트 요청 성공이 초록색이다.

 

 

[ 에러 ]

의도적으로 lease time을 짧게 설정해서 자동적으로 unlock 되도록 설정해 에러를 발생시켰다.

production의 경우 lock Advice의 finally에 lock 관련 분기처리를 넣고 에러가 난 부분에 대해 로그를 수집해야 할 듯하다. 

 

락이 없는 스레드가 unlock 시도하여 에러 발생

Exception in thread "Thread-2" java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 71cfd14a-b18a-4f17-a6e8-2556353f6c25 thread-id: 56

 

 

[ 결론과 분산 락 추가 정보 ]

1. 요청한 순서대로 DB에 저장하게 되면서 순차처리가 가능해졌다. 

2. 로직 따라 waitTime과 leaseTime 옵션을 설정해야 할 듯하다. waitTime이 너무 짧다면 대기하다가 포기해 버리면서 유저 관점에서는 선착순에 들었는데 대기시간을 너무 짧게 줘서 기회를 박탈당하게 되는 것이고 leaseTime이 너무 짧다면 로직이 진행되다가 락이 해제되고 정상적으로 진행되지 않을 것이다. 하지만 보통의 API라면 길어야 몇 초 내에 응답결과를 만들어내야 하는 게 정상이니 크게 고려를 안 해도 될지도 모르겠다.

 

끝으로 다른 락 테스트와 동일한 수량 100개에 대해 1000Threads로 테스트했을 때의 결과다.

lock을 먼저 부여받고 비즈니스 로직이 수행된 후 데이터가 저장되는 방식이다 보니 TPS가 거의 고정되어 있다.

잔여 수량이 고갈되어 에러가 발생하게될 로직도 비즈니스 로직이 먼저 진행되지 않아 TPS 수치가 일관된다.

MySQL을 활용한 pessimistic Lock보다 TPS가 생각보다 안 나오는데 더 빠른 처리가 가능하도록 할 방법을 생각해 봐야겠다.

그게 아니라면 분산된 환경에서 클라이언트의 요청 순서가 중요한 로직에 대해 사용해야 할 듯하다.

 

 

 

 

마치면서

어플리케이션 기반 락, RDB의 락, Redis를 활용한 락으로 3가지 락에 대해서 알아봤다.

일상의 궁금증에서 시작되어 각 환경에서의 대표적인 락을 테스트를 진행해 봤다.

이 외에도 Name Lock, Spin Lock 등등 수많은 락들이 있는데 다음에 또 알아볼 기회가 있지 않을까 싶다.

 

개요에 작성한 항공사이벤트는 웹서버로 트래픽 제어하고 내부 로직은 분산 락을 사용해서 선착순을 구현했을지도 모르겠다.

또 생각해 보니 클라이언트로부터 패킷이 도달하는 것까지는 고려가 되어있지 않았는데 이런 부분은 어떻게 해결했을지 궁금하다.

 

작성한 코드는 여기에 있다.

반응형