Rust 기초: 원자적 타입과 메모리 오더링

Rust 기초: 원자적 타입과 메모리 오더링

여러 스레드에서 카운터 하나를 안전하게 올리고 싶을 때 가장 먼저 떠오르는 게 Mutex<i32>인데요. 그런데 막상 짜놓고 보면 “고작 정수 하나 더하자고 잠금을 잡았다 풀었다 한다고?” 싶어집니다. 실제로 이 패턴은 Sync 트레이트 글에서도 잠깐 언급했던 원자적 타입을 쓰면 훨씬 가볍게 해결할 수 있어요.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

fn main() {
    let counter = AtomicUsize::new(0);

    thread::scope(|s| {
        for _ in 0..5 {
            s.spawn(|| {
                for _ in 0..1000 {
                    counter.fetch_add(1, Ordering::Relaxed);
                }
            });
        }
    });

    println!("최종 카운트: {}", counter.load(Ordering::Relaxed));
}

MutexArc도 안 보이는데 5,000이 정확히 출력됩니다. 그런데 이 코드를 처음 보면 두 가지가 좀 거슬리는데요. &counter로 변경이 되는 게 어떻게 가능한지, 그리고 저 Ordering::Relaxed는 도대체 뭐길래 매번 적어줘야 하는지 말이죠. 이 글에서 원자적 타입이 어떤 도구이고, 메모리 오더링은 왜 존재하며 언제 무엇을 골라야 하는지 차근차근 풀어보겠습니다.

원자적 타입이란?

원자적(atomic) 연산은 “중간에 끼어들 수 없는 하나의 연산”을 뜻합니다. 일반적으로 counter += 1은 메모리에서 값을 읽고, 1을 더하고, 다시 쓰는 세 단계로 나뉘는데요. 두 스레드가 이 세 단계를 번갈아 실행하면 결과가 꼬일 수 있습니다. 원자적 연산은 하드웨어 수준에서 이 세 단계를 통째로 묶어 다른 코어가 끼어들지 못하게 보장해요.

Rust는 std::sync::atomic 모듈에서 이런 타입들을 제공합니다. 정수형은 AtomicI8/AtomicI16/AtomicI32/AtomicI64/AtomicIsize와 부호 없는 AtomicU8/AtomicU16/AtomicU32/AtomicU64/AtomicUsize가 있고, 불리언은 AtomicBool, 포인터는 AtomicPtr<T>가 있어요. 카운터처럼 음수가 필요 없는 경우엔 AtomicUsize를 가장 많이 씁니다.

Mutex<T>와 비교하면 차이가 분명한데요. Mutex는 운영체제 잠금에 의존해서 어떤 타입이든 보호할 수 있지만, 잠금 획득과 해제에 시스템 호출이 들어갈 수 있어 비용이 큽니다. 원자적 타입은 CPU의 단일 명령어(예: x86의 LOCK XADD)로 처리되어 훨씬 빠르지만, 정수와 불리언 같은 작은 값에만 쓸 수 있어요.

또 하나 눈에 띄는 점은 원자적 타입의 모든 변경 메서드가 &self를 받는다는 겁니다.

impl AtomicUsize {
    pub fn store(&self, val: usize, order: Ordering);
    pub fn fetch_add(&self, val: usize, order: Ordering) -> usize;
    // ...
}

원래 Rust에선 값을 바꾸려면 &mut self가 필요한데, 원자적 타입은 불변 참조만으로도 값을 바꿀 수 있어요. 이것이 가능한 건 내부적으로 내부 가변성을 제공하면서 동시에 하드웨어가 동기화를 보장하기 때문이고, 그래서 Arc로 감싸지 않고도 thread::scope 안에서 참조로 공유할 수 있는 거예요.

기본 사용법

AtomicUsize의 가장 자주 쓰는 메서드 네 개부터 보겠습니다. 값을 읽는 load, 값을 쓰는 store, 값을 더하면서 이전 값을 돌려주는 fetch_add, 값을 빼면서 이전 값을 돌려주는 fetch_sub예요.

use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let counter = AtomicUsize::new(0);

    counter.store(10, Ordering::Relaxed);
    println!("현재 값: {}", counter.load(Ordering::Relaxed)); // 10

    let prev = counter.fetch_add(5, Ordering::Relaxed);
    println!("이전 값: {}, 현재 값: {}", prev, counter.load(Ordering::Relaxed));
    // 이전 값: 10, 현재 값: 15

    let prev = counter.fetch_sub(3, Ordering::Relaxed);
    println!("이전 값: {}, 현재 값: {}", prev, counter.load(Ordering::Relaxed));
    // 이전 값: 15, 현재 값: 12
}

fetch_add가 이전 값을 돌려주는 게 특히 유용한데요. 고유 ID를 발급하는 카운터를 만들 때 “방금 내가 받은 ID는 몇 번이지?”를 별도의 잠금 없이 알 수 있거든요.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

fn main() {
    let next_id = AtomicUsize::new(1);

    thread::scope(|s| {
        for _ in 0..3 {
            s.spawn(|| {
                let my_id = next_id.fetch_add(1, Ordering::Relaxed);
                println!("내 ID: {}", my_id);
            });
        }
    });
}

각 스레드가 받는 my_id는 절대 겹치지 않습니다. fetch_add는 “값을 읽고 더하고 쓰는” 동작을 원자적으로 처리하니까요.

좀 더 강력한 메서드도 있는데요. compare_exchange는 “현재 값이 X이면 Y로 바꿔라”라는 조건부 갱신입니다. 잠금 없이 안전한 알고리즘을 짤 때 핵심이 되는 연산이에요.

use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let value = AtomicUsize::new(100);

    // 현재 값이 100이면 200으로 바꾼다
    let result = value.compare_exchange(
        100,                 // 기대 값
        200,                 // 새 값
        Ordering::SeqCst,    // 성공 시 오더링
        Ordering::SeqCst,    // 실패 시 오더링
    );
    println!("{:?}", result); // Ok(100) — 기대 값과 같았으므로 성공, 이전 값 반환

    // 이번엔 100이 아니니 실패
    let result = value.compare_exchange(100, 300, Ordering::SeqCst, Ordering::SeqCst);
    println!("{:?}", result); // Err(200) — 현재 값은 200이므로 실패, 현재 값 반환

    println!("최종: {}", value.load(Ordering::SeqCst)); // 200
}

compare_exchange는 “다른 스레드가 사이에 끼어들지 않았을 때만 갱신”이 필요한 상황에서 빛을 발합니다. 예를 들어 한 번만 초기화하는 패턴(if !initialized { initialize(); initialized = true; })을 안전하게 구현할 수 있어요.

메모리 오더링이 필요한 이유

여기까지는 Ordering::Relaxed만 써도 멀쩡히 동작했는데요. 왜 오더링이라는 게 따로 필요한지 짚고 넘어가야 합니다. 현대 컴파일러와 CPU는 성능을 위해 명령어 순서를 자유롭게 바꿉니다. 단일 스레드 입장에선 결과가 똑같으니까 문제가 없어요.

// 우리가 쓴 코드
data = 42;
ready = true;

// CPU가 실제로 실행하는 순서일 수도 있음
ready = true;
data = 42;

단일 스레드에서야 둘 다 같은 결과지만, 다른 스레드가 ready를 보고 “데이터가 준비됐다”라고 판단해서 data를 읽으려 하면 큰일이 납니다. readytrue인데 data는 아직 0일 수 있거든요.

메모리 오더링은 컴파일러와 CPU에게 “이 연산 주변에선 이 정도의 순서는 지켜라”라고 알려주는 힌트입니다. 강도가 다른 다섯 가지 오더링이 있어요.

오더링의미
Relaxed원자성만 보장. 다른 메모리 연산과의 순서는 보장하지 않음
Acquire이 연산 이후의 읽기/쓰기가 이 연산보다 먼저 일어나지 않음 (load에만 사용)
Release이 연산 이전의 읽기/쓰기가 이 연산보다 나중에 일어나지 않음 (store에만 사용)
AcqRel읽고 쓰는 연산(fetch_add, compare_exchange)에 Acquire와 Release를 동시에 적용
SeqCst모든 스레드가 동일한 순서로 관찰. 가장 강한 보장이지만 가장 비쌈

처음 보면 머리가 어지러운데요. 실제로 쓸 때는 보통 다음 세 가지 패턴 중 하나입니다. 카운트만 한다면 Relaxed로 충분하고, 데이터를 공개하고 받는 패턴이라면 Release/Acquire 쌍을 쓰며, 헷갈리면 SeqCst를 쓰면 됩니다.

Relaxed: 카운터에 충분한 이유

Relaxed는 원자성만 보장하고 순서는 신경 쓰지 않습니다. 그런데도 도입부의 카운터 예제가 잘 동작한 건, 카운트 자체에는 메모리 순서가 중요하지 않기 때문이에요. 우리가 알고 싶은 건 “5개 스레드가 각각 1,000번 더한 뒤 최종 값”일 뿐이고, 각 fetch_add가 다른 연산보다 먼저인지 나중인지는 무관하거든요.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

static REQUEST_COUNT: AtomicUsize = AtomicUsize::new(0);

fn handle_request() {
    // 어디서든 호출 가능, 순서는 무관
    REQUEST_COUNT.fetch_add(1, Ordering::Relaxed);
}

fn main() {
    thread::scope(|s| {
        for _ in 0..10 {
            s.spawn(|| {
                for _ in 0..100 {
                    handle_request();
                }
            });
        }
    });

    println!("총 요청 수: {}", REQUEST_COUNT.load(Ordering::Relaxed));
}

static으로 선언한 카운터를 어디서든 접근할 수 있고, 최종 합계 1,000만 정확하면 되니 가장 가벼운 Relaxed로 충분합니다. 메트릭 수집, 디버깅용 카운터, 통계 같은 경우가 여기에 해당해요.

Release와 Acquire: 데이터 공개 패턴

본격적인 문제가 시작되는 건 한 스레드가 데이터를 준비한 뒤 다른 스레드가 “이제 읽어도 돼”라는 신호를 보고 데이터를 읽는 패턴인데요. 앞서 본 data = 42; ready = true; 같은 상황이에요. 이때 필요한 게 Release-Acquire 쌍입니다.

use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;

fn main() {
    let data = AtomicU64::new(0);
    let ready = AtomicBool::new(false);

    thread::scope(|s| {
        s.spawn(|| {
            // 데이터를 준비한 뒤 ready를 true로 게시(publish)
            data.store(42, Ordering::Relaxed);
            ready.store(true, Ordering::Release);
        });

        s.spawn(|| {
            // ready가 true가 될 때까지 대기
            while !ready.load(Ordering::Acquire) {
                std::hint::spin_loop();
            }
            // 여기서 data를 읽으면 42가 확정적으로 보임
            println!("받은 데이터: {}", data.load(Ordering::Relaxed));
        });
    });
}

Releasestore한 값을 Acquireload해서 보면, 그 store 이전의 모든 쓰기가 이 load 이후의 모든 읽기보다 먼저 일어났다는 게 보장됩니다. 그래서 두 번째 스레드가 ready == true를 보았을 때 data == 42도 확정적으로 읽을 수 있어요.

흥미로운 건 data 자체에는 Relaxed를 써도 안전하다는 점입니다. ready에 걸린 Release-Acquire가 두 스레드 사이에 일종의 다리를 놓아주거든요. 그 다리 너머의 모든 연산이 순서대로 보이게 되어, data는 단지 원자성만 보장하면 충분해요.

핵심은 ReleaseAcquire가 짝을 이뤄야 한다는 점입니다. store에는 Release, load에는 Acquire를 쓰는 게 관습이에요. 반대로 쓰면 컴파일은 되지만 의미가 어긋나고, 실제로 Ordering::Acquirestore에 넘기면 패닉이 발생합니다.

SeqCst: 가장 안전한 기본값

SeqCst는 Sequential Consistency의 약자로, 모든 스레드가 모든 SeqCst 연산을 같은 순서로 관찰한다는 점을 가장 강하게 보장합니다. 위에서 본 Release/Acquire는 두 연산 사이의 관계를 보장하지만, 여러 변수와 여러 스레드가 얽힌 복잡한 상황에선 직관과 어긋날 수 있는데요. SeqCst는 이런 미묘한 경우까지 다 막아주는 대신 성능 비용이 가장 큽니다.

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;

fn main() {
    let x = AtomicBool::new(false);
    let y = AtomicBool::new(false);

    thread::scope(|s| {
        s.spawn(|| x.store(true, Ordering::SeqCst));
        s.spawn(|| y.store(true, Ordering::SeqCst));

        s.spawn(|| {
            while !x.load(Ordering::SeqCst) {}
            if y.load(Ordering::SeqCst) {
                println!("관찰자 1: y도 true");
            }
        });

        s.spawn(|| {
            while !y.load(Ordering::SeqCst) {}
            if x.load(Ordering::SeqCst) {
                println!("관찰자 2: x도 true");
            }
        });
    });
}

SeqCst를 쓰면 두 관찰자가 본 순서가 일치한다는 게 보장됩니다. 한 관찰자는 “x 먼저, y 나중”으로 보고 다른 관찰자는 “y 먼저, x 나중”으로 보는 모순적 상황이 생기지 않아요.

실무에선 처음엔 SeqCst로 짜고 프로파일링 결과 병목이 보일 때 Release/AcquireRelaxed로 약화시키는 게 안전한 접근입니다. 잘못 약화시켜서 생기는 버그는 재현이 어렵고 디버깅이 악몽이거든요.

실전: 한 번만 실행되는 초기화

원자적 타입의 활용을 보여주는 대표 패턴이 한 번만 실행되는 초기화입니다. 여러 스레드가 동시에 호출해도 초기화 코드는 딱 한 번만 실행되어야 할 때 compare_exchange로 깔끔하게 해결할 수 있어요.

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;

static INITIALIZED: AtomicBool = AtomicBool::new(false);

fn initialize_once() {
    // false → true로 바꿀 수 있는 스레드는 단 하나
    if INITIALIZED
        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
        .is_ok()
    {
        println!("초기화 실행");
        // 여기에 실제 초기화 로직
    } else {
        println!("이미 초기화됨, 건너뜀");
    }
}

fn main() {
    thread::scope(|s| {
        for _ in 0..5 {
            s.spawn(|| initialize_once());
        }
    });
}

compare_exchange(false, true, ...)는 현재 값이 false일 때만 true로 바꿉니다. 다섯 스레드가 동시에 도달해도 단 하나만 “false였다”는 결과를 받고, 나머지는 “이미 true”라는 에러를 받죠. 결과적으로 초기화 코드는 정확히 한 번만 실행됩니다.

물론 실제 코드에서는 표준 라이브러리의 OnceLock이나 std::sync::Once를 쓰는 게 더 안전하고 표현력도 좋아요. 다만 그 안에서 동작하는 원리가 바로 이 compare_exchange 패턴이라는 걸 알아두면 라이브러리를 더 깊이 이해할 수 있습니다.

마치며

원자적 타입은 잠금 없이 스레드 안전한 연산을 가능하게 해주는 도구입니다. Mutex보다 가볍지만 정수와 불리언 같은 작은 값에만 쓸 수 있다는 제약이 있고요. &self로 값을 바꿀 수 있는 내부 가변성을 제공하면서 하드웨어가 동기화를 보장한다는 점이 핵심이에요.

메모리 오더링은 처음 보면 부담스럽지만 실전 패턴은 셋으로 압축됩니다. 카운트만 한다면 Relaxed로 충분하고, 데이터를 한 스레드가 준비하고 다른 스레드가 받는 게시-구독 패턴이라면 Release/Acquire 쌍을 쓰며, 헷갈리면 SeqCst로 시작해 필요할 때만 약화시키면 됩니다. 잘못 약화시켜서 생기는 버그는 재현이 거의 불가능하니 의심스러우면 더 강한 보장을 선택하세요.

원자적 타입은 락 프리(lock-free) 자료구조의 기반이기도 한데요. compare_exchange를 활용한 락 프리 큐, 스택, 카운터 같은 고급 패턴이 궁금하다면 crossbeam 같은 외부 크레이트의 소스 코드를 따라가 보는 것도 좋은 학습이 됩니다.

더 자세한 내용은 std::sync::atomic - Rust 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord