본문 바로가기

[Disruptor] 3. Lock-Free Mechanism: 동시성의 역설, 왜 Lock을 쓰면 느릴까?

@이멀젼씨2026. 1. 26. 21:23

우리는 더 빠른 처리를 위해 멀티 스레드를 사용한다. 작업을 쪼개고, 동시에 실행해서 처리량(Throughput)을 높이려는 전략이다.

 

하지만 아이러니하게도, 안전하게 빠르게 처리하려고 도입한 synchronizedReentrantLock 같은 동기화 장치들이 오히려 시스템의 발목을 잡는 주범이 되곤 한다.

 

스레드를 늘렸는데 성능은 제자리걸음이거나, 오히려 떨어지는 현상.

 

우리는 이를 동시성의 역설이라 부른다.

 

도대체 왜 Lock을 쓰면 느려질까?

 

단순히 "무겁다"라는 추상적인 표현 말고, OS와 하드웨어 관점에서 Lock의 이면을 살펴보자.

 

1. Lock이 비싼 이유: OS의 개입

단도직입적으로 Lock은 비싸다.

 

가장 큰 이유는 락을 획득하고 해제하는 과정에서 운영체제가 개입하기 때문이다.

 

User Mode에서 해결이 안 되어 커널 영역으로의 전환하는 순간 오버헤드는 기하급수적으로 늘어난다.

컨텍스트 스위칭 비용

가장 결정적인 원인이다. 스레드가 락을 획득하려다 실패하면 어떤 일이 벌어질까?

  1. JVM은 이 스레드를 잠재우기 위해 커널에 요청을 보낸다.
  2. 이때 CPU의 실행 모드가 User Mode에서 Kernel Mode로 전환된다.
  3. OS는 현재 스레드의 정보(Register, PC 등)을 저장하고 대기열로 보낸 뒤, 다른 스레드를 깨워 CPU에 올린다.

이 과정에서 수많은 CPU 사이클이 소모된다.

 

단순히 코드를 실행하는 것보다, 누가 CPU를 점유할지 확인하고 할당하는 작업에 더 많은 시간을 쓰게 되어버린다.

캐시 오염

컨텍스트 스위칭의 숨겨진 비용은 메모리 효율 저하다.

 

이전 글(https://emgc.tistory.com/160)에서 다루었듯 CPU는 데이터를 L1/L2 캐시에 올려두고 빠르게 처리한다.

 

하지만 스레드가 교체되면 기존 스레드에 의해 로딩된 캐시 데이터는 모두 무효화되고, 새로 들어온 스레드는 데이터를 다시 메인 메모리에서 조회해서 캐시를 갱신해주어야 한다.

 

메모리 가시성 확보

어느 스레드가 변경한 값은 다른 스레드에서도 제대로 읽혀야한다.

 

이를 위해선 CPU나 컴파일러가 명령어 실행 순서를 마음대로 바꾸는 것을 막고, 특정 시점의 메모리 접근 순서를 강제로 유지하도록 하는 동기화 기능, 즉 메모리 베리어 기능을 사용하여 이를 보장해야 한다.

 

이 과정에서 CPU가 데이터를 빠르게 처리하기 위해 사용하는 최적화 기능, 캐싱이나 명령어 재배치 등을 제한하므로 비용이 추가적으로 발생하게 된다.


결과적으로 빠른 속도를 위해 멀티 스레드를 도입했지만 역설적이게도 Lock으로 인한 비용 때문에 오히려 전체 퍼포먼스가 하락하는 결과를 초래한다.

 

2. Lock없이 제어하기

우리가 궁금한 것은 아래와 같다.

 

Disruptor는 어떻게 lock없이 병렬 처리를 가능케 하였는가?

 

답은 CAS이다.

 

CAS는 Compare-And-Swap 연산으로 Lock-Free 환경을 구축하였다.

 

CAS란 무엇인가?

CAS는 비교하고 교환한다는 단순한 논리를 가진다.

 

락을 걸고 들어가는 게 아니라 "내가 기억하는 값이 맞다면 업데이트해줘, 아니면 다시 시도할게"라는 낙관적 접근 방식이다.

 

- 메모리 주소에 존재하는 값과 시스템에서 예상하는 값을 비교

- 메모리 주소의 현재값과 시스템 예상값이 같으면  메모리 주소의 현재값을 새값으로 업데이트

- 메모리 주소의 현재값이 시스템 예상값과 다르면 예상값을 메모리 주소의 현재값으로 업데이트한 뒤 다시 함수 실행

boolean CAS(메모리_주소, 예상값, 새값) {
    if (메모리_주소의_현재값 == 예상값) {
        메모리_주소의_현재값 = 새값;
        return true;  // 성공
    } else {
    	예상값 = 메모리_주소의_현재값
        return false;  // 실패
    }
}

 

예를 들어보자.

 

메모리에 1이라는 값이 저장되어있고 스레드A와 스레드B는 메모리에 저장되어있는 값에 1을 더해야한다.

 

스레드1과 스레드2가 동시에 접근하였지만 스레드1이 근소한 차이로 먼저 접근했다고 가정하자.

 

스레드1은 메모리 주소에 1이 저장되어있다고 예상하고 있고 실제로도 1이 저장되어있다.

 

따라서 메모리에 저장되어있는 값인 1에 1을 더한 2라는 값을 다시 메모리에 저장한다.

 

스레드2 또한 메모리에서 값을 조회했더니 예상한 값은 1이었지만 2가 되어버렸다.

 

그러면 스레드2는 Retry를 통해 다시 값을 읽어들이고 최신값 2를 읽고 1을 더해 3이라는 값을 메모리에 저장하게 된다.

 

아무리 둘 다 동시에 접근하는 상황에서도 하드웨어 버스 레벨에서는 순서가 매겨지기때문에 스레드1이 아주 미세하게 먼저 CAS명령을 내리게 된다면 스레드2의 명령은 그 이후에 처리되므로 문제가 없다.

왜 CAS는 빠른가?

개발자가 작성한 코드는 위와 같지만, 실제 실행될 때는 OS 커널을 거치지 않는다.

 

CPU가 제공하는 단 하나의 원자적 명령어로 실행된다. (CMPXCHG)

 

Disruptor는 이 원리를 이용해 여러 Producer가 동시에 들어와도 락 없이 안전하게 Sequence를 증가시킨다.

(자바의 AtomicLong과 유사한 원리)

 

3. CAS가 해결하지 못한 명령어 재배치

CAS는 동시성 문제를 해결했지만 명령어의 순서 문제를 해결하지 못한다.

 

CPU의 명령어 최적화

우리가 작성한 코드는 순서대로 실행될 것 같지만 실제 하드웨어는 그렇지 않다.

 

CPU와 컴파일러는 성능 최적화를 위해 서로 연관성이 없다고 판단되면 명령어의 실행 순서를 뒤바꾼다.

(이를 Instruction Reordering이라 부른다.)

 

싱글 스레드에서는 결과값만 같다면 순서가 바뀌어도 전혀 문제가 없다.

 

하지만 멀티 스레드, 특히 Disruptor처럼 데이터를 쓰고, 다 썼다고 알리는 구조에서는 치명적인 재앙이 발생할 수 있다.

 

event.value = "Hello World"; // 1. 데이터 쓰기
sequence.set(current + 1);   // 2. 시퀀스 업데이트 (커밋)

 

만약 CPU가 최적화를 위해 2번을 1번보다 먼저 실행해버린다면?

  1. sequence가 먼저 증가한다. (Consumer: "오! 새 데이터가 왔네?")
  2. Consumer가 event를 읽으러 간다.
  3. 하지만 event.value는 아직 메모리에 기록되지 않았다. (Null 혹은 쓰레기 값)
  4. Consumer는 엉뚱한 데이터를 처리한다.
  5. 뒤늦게 Producer가 "Hello World"를 쓴다.

데이터 정합성이 완전히 깨져버리는 상황이 발생한다.

 

4. Memory Barrier

데이터 정합성을 지키기 위해 필요한 것이 바로 메모리 장벽이다.

Volatile의 진짜 의미

우리가 흔히 사용하는 volatile 키워드는 단순히 메인 메모리에서 읽어라라는 의미로만 알려져 있다.

 

하지만 더 중요한 하드웨어적 의미가 숨어있다.

 

바로 "이 선(Barrier)을 기준으로 위쪽 명령어와 아래쪽 명령어를 절대 섞지 마라"라는 제약을 거는 것이다.

 

Disruptor는 이 원리를 기가 막히게 활용한다.

 

Sequence 필드는 내부적으로 volatile (혹은 이에 준하는 Unsafe 연산)로 선언되어 있다.

 

Producer가 sequence를 업데이트하는 순간(Store Barrier), 그 이전에 실행된 Event에 데이터를 채우는 작업들이 모두 강제로 메모리에 반영된다.

 

반대로 Consumer가 sequence를 읽는 순간(Load Barrier), 그 이후에 읽을 Event 데이터들은 반드시 sequence 업데이트 이후의 최신 값임을 보장받게 된다.

 

이해를 돕기 위해 LMAX 거래소에서 "비트코인 매수 주문"이 들어온 상황을 가정해 보자.

 

Producer는 RingBuffer의 다음 슬롯에 주문 정보를 채워 넣고, Consumer에게 "처리해"라고 알려야 한다.

Producer의 입장 (Store Barrier)

Producer는 할당받은 RingBuffer 슬롯에 데이터를 채운다.

 

// 1. 데이터 채우기 (일반적인 메모리 쓰기)
event.setSymbol("BTC");
event.setPrice(50000);
event.setQuantity(1.5);

// 2. 시퀀스 업데이트 (Volatile 쓰기 -> Store Barrier 작동!)
sequence.set(nextSequence);

 

sequence.set()이 실행되는 순간, 그 위에서 실행된 setSymbol, setPrice, setQuantity 등의 작업들이 모두 메모리에 강제로 기록(Flush)된다.

 

만약 이 장벽이 없다면, CPU 최적화에 의해 sequence가 먼저 업데이트될 수도 있다.

 

그러면 Consumer는 sequence가 올라간 것을 보고 데이터를 읽으러 왔는데, 정작 가격 정보가 아직 메모리에 안 써져서 0원으로 읽히는 대참사가 발생할 수 있다.

Consumer의 입장 (Load Barrier)

Consumer는 계속해서 sequence를 감시하고 있다.

 

// 1. 시퀀스 읽기 (Volatile 읽기 -> Load Barrier 작동!)
long availableSequence = sequence.get(); 

// 2. 데이터 읽기
if (availableSequence >= nextSequence) {
    Event event = ringBuffer.get(nextSequence);
    process(event); // BTC, 50000, 1.5 정보 처리
}

 

Consumer가 sequence.get()을 호출하여 최신 값을 읽는 순간, Load Barrier가 작동한다.

 

이는 CPU에게 "지금 읽은 시퀀스 값보다 오래된 캐시 데이터는 다 버리고, 반드시 메모리에서 새로 가져와" 라고 명령하는 것과 같다.

 

덕분에 Consumer는 Producer가 방금 전 Store Barrier를 통해 밀어 넣은 최신 BTC, 50000 등의 데이터를 정확하게 볼 수 있게 된다.

 

Disruptor는 이 장벽을 통해 락 없이도 완벽한 가시성과 순서를 보장한다.

 

5. Disruptor의 Barrier 구현 코드

그렇다면 Disruptor는 이 복잡한 하드웨어 배리어를 어떻게 Java 코드로 구현했을까?

 

최신 Disruptor는 Unsafe 혹은 Java 9+의 VarHandle을 사용하여 저수준의 배리어를 직접 제어한다.

 

public class Sequence {
    // 1. 값의 실제 저장소 (패딩 처리로 False Sharing 방지)
    protected volatile long value;

    // 2. VarHandle: 메모리에 직접 접근하는 핸들
    private static final VarHandle VALUE_HANDLE;
    static {
        try {
            VALUE_HANDLE = MethodHandles.lookup()
                .findVarHandle(Sequence.class, "value", long.class);
        } catch (Exception e) { throw new RuntimeException(e); }
    }

    /**
     * Producer: Store Barrier (Release Semantics)
     * "이 명령 이전의 모든 쓰기 작업을 메모리에 반영하라"
     */
    public void set(final long value) {
        VALUE_HANDLE.setRelease(this, value);
    }

    /**
     * Consumer: Load Barrier (Acquire Semantics)
     * "이 명령 이후의 모든 읽기 작업은 메모리에서 새로 가져와라"
     */
    public long get() {
        return (long) VALUE_HANDLE.getAcquire(this);
    }
}

 

Disruptor는 극한의 성능을 위해 volatile조차 잘 사용하지 않는 경우가 있다.

 

volatile 쓰기는 가장 강력한 배리어를 치기 때문에 비용이 꽤 든다.

 

하지만 Disruptor는 굳이 당장 모든 코어를 깨울 필요가 없는 경우, putOrderedLong (LazySet)이라는 기술을 사용한다.

 

이는 "즉시 다른 코어에 보일 필요는 없는데, 순서만 지켜줘"라는 조금 더 저렴한 명령이다.

 

이런 디테일한 하드웨어 제어 기술들이 모여 Disruptor의 괴물 같은 성능을 만들어낸다.

 

정리하자면 Disruptor는 두 가지 무기로 Lock-Free 아키텍처를 완성했다.

  1. CAS (Compare-And-Swap): 원자적 명령어로 여러 스레드의 동시 쓰기를 제어한다.
  2. Memory Barrier (메모리 장벽): 명령어 재배치를 막아 데이터의 순서와 가시성을 보장한다.

이 덕분에 LMAX는 Context Switching 없이도 하드웨어 레벨에서 가장 빠르고 안전하게 데이터를 주고받을 수 있게 되었다.

 

 

 

 

결국 Disruptor가 락을 버린 이유는 명확하다. 운영체제라는 무거운 중재자를 거치지 않고, 하드웨어와 직접 소통하기 위해서다.

 

CAS를 통해 원자성을 확보하고 Memory Barrier를 통해 순서를 강제함으로써 Disruptor는 자바라는 고수준 언어 위에서 C++에 버금가는 하드웨어 제어권을 획득했다.

 

가장 빠른 동기화는 락을 잘 쓰는 것이 아니라, '락이 필요 없는 구조'를 만드는 것임을 Disruptor는 증명하고 있다.

이멀젼씨
@이멀젼씨 :: 이멀젼씨

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차