ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 동시성 제어에 관하여
    개발 2024. 9. 10. 17:29

    개요

    우아한 테크 캠프에서 팀 프로젝트를 진행하면서 주문 시스템을 최적화 하기 위해서 많은 고민을 했다. 그 중 재고의 정합성을 유지하면서 속도를 빠르게 하고자 하였는데, 기존 비관적 락을 사용하면 같은 메뉴에 대한 요청이 계속 대기하면서 성능 저하가 발생했습니다. 그래서 원자적 연산을 적용해 DB의 락을 제거하고 레디스로 재고 관리를 위임하면서 2-3배의 성능 개선을 이뤄냈다.

    이 과정에서 원자적 동시성 제어에 대해서 궁금증이 생겼고 더 공부해보고 싶다는 생각이 들어 한빛미디어의 '동시성 프로그래밍'을 읽었다.

    한빛 미디어 <동시성 프로그래밍>

    동기 처리

    동기 처리를 해야하는 이유는 여러 프로세스가 공유 자원을 동시에 접근하면서 발생하는 예상하지 못한 상황, 즉 경쟁 상태를 발생시키지 않고 경쟁 상태가 발생하는 코드 영역, 임계 영역을 보호하기 위함이다.

    간단히 내가 팀 프로젝트를 진행하면서 발생한 경쟁 상태에 대해 먼저 설명해보겠다.

     

    sequenceDiagram participant 고객A participant 고객B participant 웹서버 participant 데이터베이스 Note over 데이터베이스: 초기 재고: 10개 par 고객A의 주문 고객A->>웹서버: 상품 5개 주문 웹서버->>데이터베이스: 재고 확인 (10개 남음) 웹서버->>데이터베이스: 재고 감소 (5개) 데이터베이스-->>웹서버: 주문 성공 웹서버-->>고객A: 주문 완료 and 고객B의 주문 고객B->>웹서버: 상품 8개 주문 웹서버->>데이터베이스: 재고 확인 (10개 남음) 웹서버->>데이터베이스: 재고 감소 (8개) 데이터베이스-->>웹서버: 주문 성공 웹서버-->>고객B: 주문 완료 end Note over 데이터베이스: 최종 재고: -3개 (오류 상태)

     

    다음과 같은 상황처럼 한 개의 메뉴에 대해서 동시에 요청이 오면 재고를 불러오고 그 값에 대해서 수정하기 때문에 재고가 예상치 못한 값으로 저장된다.

     

    그렇기 때문에 재고에 대한 수정은 베타적으로 수행되어야 재고의 정합성을 맞출 수 있다는 것을 알 수 있다.

     

    그러면 어떠한 방법으로 동기 처리를 진행할 수 있을까? 방법으로는 원자적 연산, 뮤텍스, 세마포어 등이 있다.

     

    그 중 원자적 연산(Atomic Operation) 에 대해서 알아보자.

     

    원자적 연산

    원자적 연산은 이름 그대로 나눠질 수 없는 연산, 단일 연산을 의미한다. 일반적으로 더하기나 빼기의 연산들은 값을 불러오고 그 값에 대해서 연산을 진행하고 다시 저장하는 방식으로 여러 번 메모리를 접근하는 방식으로 구현되어 있다.

     

    sequenceDiagram participant 스레드A participant 메모리 participant 스레드B Note over 메모리: 초기값: 10 par 스레드A의 연산 스레드A->>메모리: 값 읽기 (10) 스레드A->>스레드A: 10 + 1 계산 스레드A->>메모리: 11 저장 and 스레드B의 연산 스레드B->>메모리: 값 읽기 (10) 스레드B->>스레드B: 10 + 1 계산 스레드B->>메모리: 11 저장 end Note over 메모리: 최종값: 11 (예상: 12)

     

    그렇기 때문에 위와 같은 예상치 못한 상황이 발생할 가능성이 있다. 그렇기 때문에 원자적 연산을 활용해 베타적으로 메모리에 접근하여 연산을 하는 방법이 생겨났다.

     

    원자적 연산을 구현하는 방법은 Compare And Set(CAS), Test And Set(TAS), Load-Link/Store-Conditional 등 여러 가지가 있다. 

     

    CAS(Compare And Set)

    CAS는 값을 연산 전 값과 현재 메모리의 값을 비교해 같을 경우에만 연산이 진행되도록 하는 방법이다. 아래와 같은 의사 코드로 구현할 수 있다.

    # CAS 의사 코드
    function CAS(address: *int, expected: int, new_value: int) -> bool:
        atomic {
            if *address == expected:
                *address = new_value
                return true
            else:
                return false
        }
    
    function atomic_increment(address: *int) -> int:
        while true:
            old_value = *address
            new_value = old_value + 1
            if CAS(address, old_value, new_value):
                return new_value

     

    TAS(Test And Set)

    TAS는 직접적으로 원자적 연산을 지원하지는 않지만 TAS를 활용해 원자적 연산을 구현할 수 있다. TAS는 해당 값이 True이면 True를 반환하고 False이면 True로 변환하고 False를 반환한다.

    # TAS 의사코드
    function TestAndSet(address: *bool) -> bool:
        atomic {
            old_value = *address
            *address = true
            return old_value
        }
        
        
    function atomic_operation():
        while TestAndSet(&lock):
            // 스핀
        
        // 임계 영역 시작
        // 여기서 원하는 연산 수행
        shared_value = some_calculation(shared_value)
        // 임계 영역 끝
        
        lock = false  // 락 해제

    보통 스핀락을 구현할 때 사용한다.

     

    Load-Link/Store-Conditional

    LL(Load-Link)는 메모리에서 값을 읽을 때 베타적으로 수행할 수 있도록 지정하는 명령이다. SC(Store-Conditional)을 통해 다른 CPU 가 LL 로 지정한 메모리로의 쓰기가 발생하지 않았을 때만 값을 저장하고 실패할 경우 다시 읽기와 쓰기를 수행한다. 이론적으로 CAS 보다 성능이 좋고 CAS에서 발생할 수 있는 ABA문제를 방지할 수 있는 장점이 있지만, x86 아키텍쳐에서는 지원하지 않아 하드웨어에 종속되는 단점을 가지고 있기 때문에 CAS를 더 많이 사용한다.

     

     

    원자적 연산을 통한 동시성 제어의 이점

    원자적 연산을 통한 동시성 제어의 이점은 기존 원시적 락 방식에 비해 짧은 락 지속 시간(보통 단위 연산 명령 실행 기간)과 작은 범위의 장점을 가지고 있어 큰 장점을 가지고 있다. 

     

    다음은 레디스를 활용한 원자적 연산 방식과 DB 락을 활용한 방식의 차이를 보여주는 시퀀스 다이어그램이다.

    sequenceDiagram participant 고객A participant 고객B participant 웹서버 participant Redis participant DB Note over DB: 초기 재고: 10개 rect rgb(200, 230, 200) Note right of 고객A: Redis 활용 원자적 연산 par 고객A의 주문 고객A->>웹서버: 상품 5개 주문 웹서버->>Redis: DECRBY stock 5 Redis-->>웹서버: 새 재고 값 (5) 웹서버->>DB: 주문 정보 저장 (비동기) 웹서버-->>고객A: 주문 완료 and 고객B의 주문 고객B->>웹서버: 상품 8개 주문 웹서버->>Redis: DECRBY stock 8 Redis-->>웹서버: 새 재고 값 (-3) 웹서버-->>고객B: 재고 부족 end end Note over Redis: 최종 재고: 5개 (정확한 값) Note over DB: 최종 재고: 5개 (정확하지만 처리 지연) rect rgb(230, 200, 200) Note right of 고객A: 기존 DB 락 방식 고객A->>웹서버: 상품 5개 주문 웹서버->>DB: 락 획득 시도 DB-->>웹서버: 락 획득 웹서버->>DB: 재고 확인 및 갱신 DB-->>웹서버: 갱신 완료 웹서버->>DB: 락 해제 웹서버-->>고객A: 주문 완료 Note over 고객B: 대기... 고객B->>웹서버: 상품 8개 주문 웹서버->>DB: 락 획득 시도 (대기) DB-->>웹서버: 락 획득 웹서버->>DB: 재고 확인 및 갱신 DB-->>웹서버: 재고 부족 웹서버->>DB: 락 해제 웹서버-->>고객B: 재고 부족 end Note over DB: 최종 재고: 5개 (실시간 처리)

    맺음말

    이 책을 읽고 추상적으로 알고 있었던 내용을 조금 구체적으로 알 수 있었던 것 같다. 아직 책을 다 읽지는 않아서 끝까지 읽어보고 내용을 더 수정해봐야겠다.

    댓글

Designed by Tistory.