Rust 기초: Arc로 스레드 간 데이터 공유하기

Rust의 소유권 시스템은 메모리 안전성을 보장해주지만, 여러 스레드에서 같은 데이터를 공유하려고 하면 컴파일러가 허용하지 않습니다. “한 번에 하나의 소유자만 존재할 수 있다”는 규칙 때문입니다. 소유권을 이동하면 다른 스레드에서 사용할 수 없고, 참조를 전달하려고 하면 라이프타임 문제로 컴파일 오류가 발생합니다.

실제로는 여러 스레드가 같은 설정 값을 읽거나, 공유 데이터를 참조해야 하는 상황이 많습니다. 예를 들어 웹 서버에서 모든 워커 스레드가 동일한 설정 파일을 읽어야 하거나, 여러 스레드가 같은 캐시 데이터를 조회해야 할 수 있습니다. 하지만 소유권 규칙 때문에 이런 패턴을 구현하기가 쉽지 않습니다.

Arc(Atomic Reference Counting)는 이 문제를 해결하는 스마트 포인터입니다. 여러 소유자가 같은 데이터를 안전하게 공유할 수 있도록 해줍니다. 이 글에서는 Arc가 무엇이고, 어떻게 사용하는지, 그리고 실전에서 어떻게 활용하는지 살펴보겠습니다. 소유권과 빌림의 기본 개념은 소유권과 빌림 글에서 다루고 있으니 참고하시기 바랍니다.

Arc가 필요한 이유

멀티스레드 프로그래밍을 하다 보면 자연스럽게 여러 스레드가 같은 데이터를 읽어야 하는 상황이 발생합니다. 하지만 Rust의 소유권 시스템은 이를 간단하게 허용하지 않습니다. 스레드는 각자 독립적인 스택을 가지고 있고, 스레드의 수명을 컴파일 시점에 정확히 알 수 없기 때문에 일반적인 참조를 스레드로 전달할 수 없습니다.

다음 코드를 보겠습니다.

use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4, 5];

    let handle = thread::spawn(|| {
        println!("합계: {}", data.iter().sum::<i32>());
    });

    handle.join().unwrap();
}

이 코드는 컴파일되지 않습니다. 컴파일러는 “closure may outlive the current function”이라는 오류를 발생시킵니다. 스레드가 메인 스레드보다 오래 살 수 있기 때문에, data가 drop된 후에도 스레드가 이를 참조할 가능성이 있습니다. Rust는 이런 댕글링 참조를 컴파일 시점에 방지합니다.

move 키워드를 사용해서 소유권을 스레드로 이동할 수도 있지만, 그러면 메인 스레드나 다른 스레드에서는 data를 사용할 수 없습니다. 소유권은 오직 하나의 소유자만 가질 수 있기 때문입니다. 여러 스레드가 동시에 같은 데이터를 읽어야 하는 상황에서는 이 방법도 적합하지 않습니다. 이것이 바로 Arc가 필요한 이유입니다.

Rc와 Arc의 차이점

Arc를 이해하려면 먼저 Rc(Reference Counted)를 알아야 합니다. Rc는 단일 스레드 환경에서 여러 소유자가 같은 데이터를 공유할 수 있게 해주는 스마트 포인터입니다. Rc는 참조 카운트를 내부적으로 관리하면서, 데이터를 가리키는 참조가 몇 개인지 추적합니다. 마지막 참조가 drop될 때 비로소 데이터가 메모리에서 해제됩니다.

하지만 Rc는 스레드 안전하지 않습니다. 참조 카운트를 일반 정수로 관리하기 때문에, 여러 스레드에서 동시에 카운트를 증가시키거나 감소시키면 데이터 경쟁이 발생할 수 있습니다. 실제로 Rc를 스레드 간에 공유하려고 하면 컴파일 오류가 발생합니다.

use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new(vec![1, 2, 3]);
    let data_clone = Rc::clone(&data);

    let handle = thread::spawn(move || {
        println!("{:?}", data_clone);
    });

    handle.join().unwrap();
}

컴파일러는 “Rc<Vec<i32>> cannot be sent between threads safely”라는 오류를 발생시킵니다. Rc는 Send 트레이트를 구현하지 않기 때문에 스레드 경계를 넘을 수 없습니다.

Arc는 이 문제를 해결합니다. Arc는 Atomic Reference Counted의 약자로, 원자적(atomic) 연산을 사용하여 참조 카운트를 관리합니다. 원자적 연산은 CPU 레벨에서 여러 스레드가 동시에 접근해도 안전하도록 보장됩니다. 따라서 Arc는 스레드 간에 안전하게 공유될 수 있습니다.

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3]);
    let data_clone = Arc::clone(&data);

    let handle = thread::spawn(move || {
        println!("{:?}", data_clone);
    });

    handle.join().unwrap();
}
결과
[1, 2, 3]

Rc를 Arc로 바꾸기만 하면 코드가 정상적으로 컴파일되고 실행됩니다. 원자적 연산은 일반 정수 연산보다 약간 느리지만, 그 대가로 스레드 안전성을 얻을 수 있습니다. 단일 스레드 환경에서는 Rc를, 멀티 스레드 환경에서는 Arc를 사용하면 됩니다.

Arc 기본 사용법

Arc는 Arc::new()로 생성합니다. Arc로 감싼 값은 힙 메모리에 저장되고, Arc는 그 값에 대한 포인터와 참조 카운트를 관리합니다. Arc를 복제하면 데이터 자체가 복사되는 것이 아니라, 같은 힙 메모리를 가리키는 새로운 Arc가 만들어지고 참조 카운트가 증가합니다.

use std::sync::Arc;

fn main() {
    let message = Arc::new(String::from("안녕하세요"));

    println!("참조 카운트: {}", Arc::strong_count(&message));

    {
        let message2 = Arc::clone(&message);
        let message3 = Arc::clone(&message);

        println!("참조 카운트: {}", Arc::strong_count(&message));
        println!("메시지: {}", message2);
    }

    println!("참조 카운트: {}", Arc::strong_count(&message));
}
결과
참조 카운트: 1
참조 카운트: 3
메시지: 안녕하세요
참조 카운트: 1

처음 message를 생성했을 때 참조 카운트는 1입니다. 내부 스코프에서 message2message3를 복제하면 참조 카운트가 3으로 증가합니다. 세 개의 Arc가 모두 같은 String 데이터를 가리키고 있습니다. 내부 스코프를 벗어나면 message2message3가 drop되면서 참조 카운트가 다시 1로 감소합니다.

Arc::clone(&arc)arc.clone()과 동일합니다. 다만 Arc::clone()을 명시적으로 사용하면 이것이 깊은 복사가 아니라 참조 카운트만 증가시키는 저렴한 연산임을 코드를 읽는 사람에게 명확히 알릴 수 있습니다.

마지막 Arc가 스코프를 벗어나면 참조 카운트가 0이 되고, 그때 비로소 힙 메모리가 자동으로 해제됩니다. 개발자가 직접 메모리를 관리할 필요가 없습니다.

스레드 간 Arc 공유하기

Arc의 진가는 멀티스레드 환경에서 발휘됩니다. 여러 스레드가 같은 데이터를 읽어야 할 때, 각 스레드에 Arc의 복제본을 전달하면 됩니다. 메인 스레드에서 Arc를 복제한 후, 각 복제본을 move 클로저로 스레드에 이동시키는 패턴을 사용합니다.

use std::sync::Arc;
use std::thread;

fn main() {
    let numbers = Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
    let mut handles = vec![];

    for i in 0..3 {
        let numbers_clone = Arc::clone(&numbers);

        let handle = thread::spawn(move || {
            let sum: i32 = numbers_clone.iter().sum();
            println!("스레드 {}: 합계 = {}", i, sum);
        });

        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("메인: 참조 카운트 = {}", Arc::strong_count(&numbers));
}
결과
스레드 0: 합계 = 55
스레드 1: 합계 = 55
스레드 2: 합계 = 55
메인: 참조 카운트 = 1

세 개의 스레드가 동시에 같은 벡터 데이터에 접근하여 합계를 계산합니다. 각 스레드는 numbers의 Arc 복제본을 소유하고 있으므로, 스레드가 실행되는 동안 데이터가 유효함이 보장됩니다. 모든 스레드가 작업을 마치고 join()으로 종료를 기다린 후에는, 메인 스레드의 numbers만 남아 있어 참조 카운트가 1이 됩니다.

이 패턴은 매우 안전합니다. Rust의 타입 시스템이 컴파일 시점에 데이터 경쟁을 방지해주기 때문에, 런타임에 예상치 못한 버그가 발생할 가능성이 거의 없습니다. 스레드 수가 많아지더라도 같은 패턴을 사용하면 됩니다.

Arc와 Mutex를 함께 사용하기

Arc 단독으로는 불변 참조만 제공합니다. 여러 스레드에서 데이터를 읽는 것은 가능하지만, 수정하는 것은 불가능합니다. Arc가 제공하는 것은 공유 소유권이지 내부 가변성이 아니기 때문입니다. 여러 스레드에서 데이터를 수정하려면 Mutex(상호 배제)를 함께 사용해야 합니다.

Arc<Mutex<T>> 패턴은 Rust에서 공유 가변 상태를 관리하는 표준적인 방법입니다. Arc는 여러 스레드 간에 Mutex를 공유하고, Mutex는 한 번에 하나의 스레드만 데이터에 접근할 수 있도록 보장합니다. lock() 메서드를 호출하면 MutexGuard가 반환되는데, 이를 통해 내부 데이터에 가변 접근할 수 있습니다. MutexGuard는 스코프를 벗어날 때 자동으로 락을 해제하므로, 락을 해제하는 것을 잊어버릴 걱정이 없습니다.

간단한 카운터 예제를 보겠습니다.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter_clone = Arc::clone(&counter);

        let handle = thread::spawn(move || {
            let mut num = counter_clone.lock().unwrap();
            *num += 1;
        });

        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("최종 카운트: {}", *counter.lock().unwrap());
}
결과
최종 카운트: 10

열 개의 스레드가 각각 카운터를 1씩 증가시킵니다. lock()을 호출하면 다른 스레드가 락을 해제할 때까지 대기하고, 락을 획득한 후에 값을 수정합니다. MutexGuard가 스코프를 벗어나면 자동으로 락이 해제되어 다른 스레드가 접근할 수 있게 됩니다. 최종 결과는 정확히 10이 됩니다.

조금 더 실용적인 예제로 작업 큐를 만들어보겠습니다.

use std::sync::{Arc, Mutex};
use std::thread;
use std::collections::VecDeque;

fn main() {
    let queue = Arc::new(Mutex::new(VecDeque::new()));
    let mut handles = vec![];

    for i in 0..3 {
        let queue_clone = Arc::clone(&queue);

        let handle = thread::spawn(move || {
            let mut q = queue_clone.lock().unwrap();
            q.push_back(format!("작업-{}", i));
            println!("추가됨: 작업-{}", i);
        });

        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    let q = queue.lock().unwrap();
    println!("큐 내용: {:?}", *q);
}
결과
추가됨: 작업-0
추가됨: 작업-1
추가됨: 작업-2
 내용: ["작업-0", "작업-1", "작업-2"]

여러 스레드가 동시에 작업을 큐에 추가합니다. Mutex 덕분에 각 스레드는 순차적으로 큐에 접근하여 데이터 경쟁 없이 안전하게 작업을 추가할 수 있습니다. 실제 프로그램에서는 이 패턴을 확장하여 생산자-소비자 패턴을 구현할 수 있습니다.

주의할 점은 락을 너무 오래 잡고 있으면 다른 스레드가 대기하게 되어 성능이 저하될 수 있다는 것입니다. 또한 여러 Mutex를 잘못된 순서로 획득하면 데드락이 발생할 수 있으니 주의해야 합니다.

Arc의 성능 특성

Arc는 편리하지만 공짜가 아닙니다. 원자적 연산은 일반 정수 연산보다 느립니다. CPU가 여러 코어 간에 메모리 상태를 동기화해야 하기 때문입니다. 따라서 Rc보다 약간 느리지만, 그 대가로 스레드 안전성을 얻을 수 있습니다.

Arc를 복제하는 것은 큰 데이터를 복사하는 것보다 훨씬 저렴합니다. 참조 카운트만 원자적으로 증가시키면 되기 때문입니다. 하지만 완전히 공짜는 아닙니다. 복제할 때마다 원자적 증가 연산이 발생하고, drop될 때마다 원자적 감소 연산이 발생합니다. 또한 Arc 자체가 참조 카운트를 저장하는 메모리 오버헤드를 가지고 있습니다.

Arc를 사용하지 말아야 할 때도 있습니다. 단일 스레드 환경에서는 Rc를 사용하는 것이 더 효율적입니다. 소유권을 완전히 이전할 수 있다면 채널(channel)을 사용하는 것이 더 나을 수 있습니다. 채널은 메시지 패싱 방식으로 스레드 간 통신을 할 수 있게 해주는데, 공유 상태보다 더 안전하고 명확한 경우가 많습니다.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let data = vec![1, 2, 3, 4, 5];
        tx.send(data).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("받은 데이터: {:?}", received);
}
결과
받은 데이터: [1, 2, 3, 4, 5]

채널을 사용하면 소유권이 스레드 간에 이동하므로, 여러 스레드가 동시에 같은 데이터를 읽을 필요가 없는 경우에는 이 방식이 더 적합합니다. Arc는 여러 스레드가 동시에 같은 데이터를 읽어야 할 때 사용하세요.

성능이 정말 중요한 상황이라면 벤치마크를 통해 측정하는 것이 좋습니다. 대부분의 경우 Arc의 오버헤드는 무시할 만한 수준이지만, 극단적으로 성능이 중요한 코드에서는 다른 대안을 고려해볼 필요가 있습니다.

마치며

Arc는 원자적 참조 카운팅을 통해 여러 스레드 간 안전한 데이터 공유를 가능하게 해주는 스마트 포인터입니다. Arc 단독으로는 불변 데이터를 여러 스레드에서 읽을 때 사용하고, Arc와 Mutex를 함께 사용하면 가변 데이터를 여러 스레드에서 공유하고 수정할 수 있습니다.

작은 성능 비용이 있지만, 그 대가로 컴파일 타임에 안전성을 보장받고 편리하게 멀티스레드 프로그래밍을 할 수 있습니다. Rust의 타입 시스템 덕분에 데이터 경쟁이나 댕글링 포인터 같은 버그를 컴파일 시점에 방지할 수 있습니다. 이것이 Rust가 안전한 동시성을 제공한다고 말하는 이유입니다.

Arc의 개념이 처음에는 복잡해 보일 수 있지만, 몇 번 사용해보면 Rust에서 멀티스레드 프로그래밍을 할 때 없어서는 안 될 도구라는 것을 알게 될 것입니다. 소유권 시스템과 함께 사용하면 안전하고 효율적인 동시성 코드를 작성할 수 있습니다.

소유권과 빌림의 기본 개념은 소유권과 빌림 글을 참고하시기 바랍니다. 공유 소유권이 필요 없고 단순히 힙에 데이터를 저장하고 싶다면 Box 글을 참고하시기 바랍니다. Arc를 통해 내부 값의 메서드를 바로 호출할 수 있는 원리가 궁금하다면 Deref 트레이트와 역참조 강제를 참고하시기 바랍니다. 다음 단계로는 채널을 이용한 메시지 패싱이나 async/await를 사용한 비동기 프로그래밍 등 다른 동시성 패턴도 학습해보시기를 권장합니다.

This work is licensed under CC BY 4.0 CC BY

Discord