프로그래밍 언어/Java

[Java] AtomicLong으로 동시성 문제 해결 (feat.비관적 락을 사용 하지 못한 이유)

반응형

콘서트 예매 사이드 프로젝트를 진행하면서 대기열 관련해서 개발을 진행하면서 비관적 락을 사용해서 대기열 동시성을 풀어 나가려고 했습니다.

 

요구사항 중 멀티 스레드 환경에서 여러 번의 요청이 들어와도 내가 정한 대기열의 인원수(QUEUE_LIMIT)만 ONGOING 상태로 저장되어야 하며, 대기열 등록 시 ONGOING 상태가 QUEUE_LIMIT 보다 크거나 같은 경우 WAIT 상태로 추가되어야 합니다.

(REDIS를 사용하지 않고 대기열 문제를 풀어보고 싶어서 RDB를 사용)

 

대기열 등록 서비스 호출시 DB에서 ONGOING상태인 유저의 COUNT(*)를 가져와서 위에 요구사항 대로 진행하도록 구현을 하였습니다.

대기열 도메인 구현을 마친 후 마지막으로 동시성 테스트 코드를 작성하여 테스트를 돌려봤지만, 통과하지 못했습니다.

 

@DisplayName("동시에 여러명 대기열 등록")
@Test
void test_register() throws InterruptedException{
    //Given
    QueueRequest queueRequest = QueueRequest.builder().userId(1L).build();

    //When
    int numberOfRequests = 100;
    CountDownLatch latch = new CountDownLatch(numberOfRequests);
    ExecutorService executorService = Executors.newFixedThreadPool(numberOfRequests);

    for(int i = 0; i < numberOfRequests; i++){
        Long userId = (long) i;
        executorService.submit(() -> {
            try {
                queueService.register(QueueRequest.builder().userId(userId).build());
            } catch (Exception e) {
                log.error(e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    //Then
    long countOfOngoingStatus = iQueueRepository.getCountOfOngoingStatus();
    assertThat(countOfOngoingStatus).isEqualTo(QUEUE_LIMIT);
}

 

통과하지 못한 원인

비관적 락을 사용해서 동시성을 해결하려고 했지만, 비관적 락은 특정 row에 대해서 lock 처리 즉, SELECT COUNT (*) 쿼리는 비관적 락을 사용할 수 없음. (LOCK은 일반적으로 데이터의 일관성과 무결성을 보장하기 위해 동시에 요청이 들어온 트랜잭션이 특정 데이터 행에 액세스 하거나 수정하는 것을 방지하려는 경우에 적용)

 

해결방법 1 - syncronized

처음에는 multi thread 환경에서 동일한 자원에 대한 동시 접근을 막는 방식인 syncronized 키워드를 대기열 서비스에 작성하여 테스트를 통과했지만, syncronized는 여러 쓰레드에서 해당 자원에 동시에 접근할 경우, 가장 처음 접근한 스레드가 작업을 끝날 때까지, 자원에 lock을 걸어서, 다른 스레드에서의 접근을 완전히 차단하므로 동시성 문제를 가장 쉽게 해결할 수 있지만 요청이 많아질수록 lock이 걸리는 스레드가 많아지고, 병목현상이 발생하기 쉬운 이유로 다른 방법을 고민했습니다.

 

해결방법 2 - volatile

volatile 키워드를 사용하면 변수를 Main Memory에만 저장하게 됩니다. 즉 volatile 키워드를 붙인 자원은 read 나 write 작업이, CPU Cache Memory 가 아닌 Main Memory에서 이루어지기 때문에 ONGOING 카운트를 별도의 전역변수로 잡아서 해결하려고 했습니다.

 

로직상 스케쥴러를 통해서 ONGOING 상태의 토큰 만료시간이 될 경우 DONE상태로 변경하게 될때 ONGOING 카운트의 전역 변수를 DONE상태로 변경한 만큼 빼줘야 했고, 대기열 등록 서비스 호출 시 ONGOING 상태로 들어오는 경우 ONGOING 카운트를 증가 시켜줘야 했기 때문에 적합하지 않았습니다.

 

volatile 은 하나의 thread 만이 write를 하고, 나머지 thread는 read를 한다는 전제 하에 비동기 이슈를 해결할 수 있지만, 여러 스레드에서 Main Memory에 있는 공유자원에 동시에 접근하여 여러 스레드에서 수정이 발생하는 경우, 기존 동시접근 문제처럼 연산이 느린 스레드의 계산값으로 덮어씌워질 수 있기 때문에 volatile 키워드로는 가시성 문제를 해결할 수 있으나 동시접근 문제는 해결 할 수 없었습니다. 

 

해결방법 3 - AtomicLong

AtomicLong을 사용해서 ongoingQueueCount를 관리하는 QueueOption 객체를 만들었고, QueueService에서 @PostConstruct를 사용해서 ongoingQueueCount를 초기화해주었습니다. 이로써 대기열 갱신할 때 불필요하게 DB에서 Count 해오는 로직을 제거하고, ongoingQueueCount를 통해서 기존 로직의 요구사항을 해결할 수 있었습니다.

    @PostConstruct
    public void initializeQueueCount(){
        queueOption.initializeQueueCount(iQueueRepository.getCountOfOngoingStatus());
    }
public class QueueOption {
    public static final long QUEUE_EXPIRED_TIME = 1L;
    public static final int QUEUE_LIMIT = 5;
    private final AtomicLong ongoingQueueCount = new AtomicLong();
}

 


Atomic 변수는 원자성을 보장하는 변수라는 의미로, 기존에 원자성을 보장하였던 syncronized 키워드의 성능 저하 문제를 해결하기 위해 고안된 방법으로 CAS(Compare And Swap) 알고리즘을 통해 동작

 

멀티 스레드 환경, 멀티코어 환경에서 각 CPU는 Main Memory에서 변수 값을 참조하는 것이 아니라, 각 CPU의  CPU Cache Memory 를 참조. 이때 Main Memory에 저장된 값과 CPU Cache Memory에 저장된 값이 다른 경우 CAS 알고리즘을 사용하여 가시성 문제를 해결합니다.

 

CAS 알고리즘이란 현재 쓰레드가 존재하는 CPU의 CacheMemory와 MainMemory에 저장된 값을 비교하여, 일치하는 경우 새로운 값으로 교체하고, 일치하지 않을 경우 기존 교체가 실패되고 이에 대해 계속 재시도를 하는 방식

 

AtomicLong에서의 getAndIncrement() 동작 방식

public final long getAndIncrement() {
    return U.getAndAddLong(this, VALUE, 1L);
}

@IntrinsicCandidate
public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset);
    } while (!weakCompareAndSetLong(o, offset, v, v + delta));
    return v;
}

@IntrinsicCandidate
public final boolean weakCompareAndSetLong(Object o, long offset,
                                           long expected,
                                           long x) {
    return compareAndSetLong(o, offset, expected, x);
}

@IntrinsicCandidate
public final native boolean compareAndSetLong(Object o, long offset,
                                              long expected,
                                              long x);

 

CPU가 MainMemory 의 자원을 CPU Cache Memory로 가져와 연산을 수행하는 동안 다른 스레드에서 연산이 수행되어 MainMemory의 자원이 바뀌었을 경우, 기존 연산을 실패처리하고, 새로 바뀐 MainMomory 값으로 재수 행하는 방식.

 

private volatile long value;

 

AtomicLong 내부에서 선언되어 있는 long value는 volatile 키워드를 사용

volatile의 경우, syncronized 키워드와는 달리 동시 접근 문제(원자성)을 보장하지 못하지만 CAS 알고리즘을 통해 원자성을 보장하도록 만들어졌다. 

반응형