Skip to main content

Command Palette

Search for a command to run...

[Rust] 소유권 이해하기

Updated
6 min read
[Rust] 소유권 이해하기

스택 & 힙

소유권의 주 목표는 힙 데이터의 관리이다.

스택과 힙은 모두 프로그램이 런타임에 이용하게 될 메모리 영역이지만, 차이점이 있다.

스택

스택은 후입선출(last in first out)방식으로 값을 처리한다. 입출구가 하나인 통처럼 맨 꼭대기에서 무언가를 집어넣고 꺼내는 것은 가능하지만 중간과 아래에 있는 것은 바로 건드릴 수 없다. 스택에 저장될 데이터는 모두 명확하고 크기가 정해져 있어야 한다. 컴파일 타임에 크기를 알 수 없거나 크기가 변할 수 있는 데이터는 힙에 저장되어야 한다.

함수를 호출하면 호출한 함수에 넘겨준 값과 해당 함수의 로컬 변수들이 스택에 들어가고, 이 데이터들은 함수가 종료될 때 팝된다.

데이터에 힙을 넣을 때 절차는 다음과 같다.

  1. 저장할 공간이 있는지 운영체제에 물어보고

  2. 메모리 할당자가 힙 영역 내에서 빈 지점을 찾는다.

  3. 아까 발견한 빈 지점을 사용 중이라고 표시하고

  4. 그 지점을 가리키는 포인터를 반환한다.

이 전체 과정을 힙 공간 할당이라고 한다.

레스토랑에 비유하자면

  1. 레스토랑에 방문하는 인원수를 수용할 좌석이 있는지 묻는다.

  2. 직원은 빈 테이블을 찾아서

  3. 테이블이 사용 중임으로 상태를 바꾸고(비유가 이상하긴 한데 다른 손님이 들어왔을 때 여전히 빈 테이블이라고 하지는 않을테니까...)

  4. 손님에게 테이블 위치를 안내할 것이다.

라고 할 수 있다.

힙 영역은 포인터가 가리키는 곳을 찾아가는 과정 때문에 느려진다. 힙에 있는 데이터들은 붙어 있는 것이 아니라 이곳 저곳에 떨어져 있는데 이렇게 되면 프로세서가 돌아다니면서 데이터들을 처리해야하기 때문에 느리다.


소유권 규칙

소유권 규칙은 세 가지가 있다.

  1. 각각의 값은 소유자(Owner)가 정해져 있다.

  2. 한 값의 소유자는 동시에 여럿 존재할 수 없다.

  3. 소유자가 스코프 밖으로 벗어나면 값은 버려진다(dropped).

변수의 스코프

스코프란 프로그램 내에서 아이템이 유효한 범위를 말한다. 예제로 보자면

{ // s가 선언되기 전이다. 아직 유효하지 않다
    let s = "Hello"; // s가 선언되어 여기서부터 유효하다
    // s로 어떠한 작업을 한다.
} // 스코프가 종료되어 s가 유효하지 않다.

위 에제에서

  1. s가 스코프 내에 나타나면 유효하고

  2. 유효기간은 스코프 밖으로 벗어나기 전이다(벗어나면 유효하지 않다)


String

바로 위의 문자열 리터럴은 불변(immutable)하기 때문에 사용자에게 문자열을 입력받아 사용하거나 하는 것은 불가능하다. 그렇기 때문에 러스트는 또 다른 문자열 타입인 String 타입을 제공하는데 소유권 규칙을 이해하기 좋은 타입이다. String 타입은

let s = String::from("Hello");

와 같이 생성한다. ::String 타입에 있는 특정한 from함수라는 것을 지정할 수 있게 해주는 네임스페이스 연산자다.

대충 예상이 가겠지만 String은 가변(mutable)하다.

let s = String::from("Hello");
s.push_str(", world!"); //문자열에 리터럴 추가

를 하게되면 sHello, world!가 된다.

그런데 문자열 리터럴과 String이 따로 존재할 이유는 무엇이며, 왜 하나는 불변이고 하나는 가변인가?


메모리와 할당

문자열 리터럴은 컴파일 타임에 내용을 알 수 있고 크기가 고정적이다. 아까 스택과 힙의 설명에 기반하자면 스택에 더 적합해보이는 친구이다.

String은 가변적인 값으로 크기가 변할 수 있다. 힙에 메모리를 할당해서 관리해야 하는 친구다.

어째거나 힙에 메모리를 할당해야 하는 String

  • 실행 중 메모리 할당자로부터 메모리를 요청하고

  • 사용을 마치면 메모리를 해제해야 한다.

타 언어들과 비교하자만 가비지 컬렉터가 있으면 사용하지 않는 메모리를 자동으로 해제해주므로 신경쓸 필요가 없고, C같은 경우 free()로 해제해줘야 했다.

러스트에서는 이러한 메모리 해제가 변수가 자신이 소속된 스코프를 벗어나는 순간 메모리가 자동으로 해제되는 방식으로 진행된다.

{
    let s = String::from("hello");
} // 스코프 종료

위 코드에서 s가 스코프를 벗어날 때(닫힌 중괄호 } 가 나타나는 지점) 러스트는 drop이라는 함수를 자동으로 호출한다.

아직은 별 게 아닌 것 같지만 이러한 패턴 때문에 복잡한 상황이 생기기도 한다.


이동(Move)

let s1 = String::from("hello");
let s2 = s1;

코드의 첫 줄에서는

와 같은 일이 일어난다.

그런데 s2s1를 대입할 때 스택에 있는 데이터만 복사되지 포인터가 가리키는 힙 영역의 데이터는 복사되지 않는다.

그래서 위와 같은 메모리 구조를 가지게 된다.

앞에서 변수가 스코프 밖으로 벗어날 때 러스트에서 drop을 호출하여 변수가 사용하는 힙 메모리를 제거한다고 했는데, 포인터 두 개가 같은 곳을 가리키면 어떻게 될까?

s1, s2가 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제(double free) 에러가 발생하게 될 것이다.

이 문제를 해결하기 위해 러스트는 let s2 = s1; 라인 뒤로 s1이 유효하지 않다고 판단한다. 그래서

let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);

는 유효하지 않은 참조자 사용을 감지했다며 컴파일 에러를 일으킬 것이다.

다른 언어의 얕은 복사(shallow copy), 깊은 복사(deep copy) 개념을 떠올려본다면 아까처럼 힙 데이터를 복사하지 않는

이러한 상황을 얕은 복사

힙 메모리까지 복사되는

이러한 상황을 깊은 복사라고 할텐데

러스트에서는 얕은 복사 개념이 존재하지 않는다. 두 포인터가 같은 곳을 가리키는 게 아니라 이제 s1은 유효하지 않아질 것이기 때문이다. s1에서 s2이동한 것과 같은 상황이 되는데 진짜로 이동(move)라고 한다.

더해서 러스트는 깊은 복사를 자동으로 수행하는 일이 없다. 힙 메모리까지 복사하는 것은 비효율적인 작업이 될 수 있는데(특히 데이터가 크다면) 러스트가 자동으로 수행하는 복사는 이러한 경우가 없다. 물론 수동으로는 가능하다.


클론(Clone)

이거다. 힙 데이터까지 복사(깊은 복사)하고 싶으면 clone을 쓰면 된다.

let s1 = String::from("Hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);

깊은 복사이기 때문에 애초에 포인터는 다른 곳을 가리킨다.(데이터 내용이 같은거지 위치는 다르다) 그래서 s1은 유효한 것.


복사(Copy)

그런데

let x = 1;
let y = x;
println("x = {}, y = {}", x, y);

를 실행해보면 x가 여전히 유효한데 애초에 이런 컴파일 타임에 크기를 알 수 있는 고정 크기 값들은 스택에 저장되기 때문이다.

소유권 개념은 힙 메모리 관리에 적용되는 개념이므로 여기에는 적용되지 않는다.

이런 복사가 가능한 타입은

  • 일반적인 스칼라 타입

  • 복사 가능한 타입으로 구성된 튜플 이 가능하다.


소유권과 함수

함수로 값을 전달하는 것도 비슷한 매커니즘으로 작동한다. 함수에 변수를 전달하면 대입과 마찬가지로 이동이나 복사가 일어나기 때문.

fn main() {
    let s = String::from("Hello"); //여기서부터 s가 스코프 안으로

    takes_ownership(s): //s의 값이 함수로 이동
    // 여기서부턴 s가 유효하지 않다.
    // s를 사용하려고 하면 컴파일 에러가 나타날 것.

    let x = 5; // x가 스코프 안으로

    makes_copy(x); // x가 함수로 Copy
}

fn takes_ownership(some_string: String) { //  some_string이 스코프 안으로
    println!("{}", some_string);
} // some_string이 스코프 밖으로 벗어남.
// 메모리 해제

fn makes_copy(some_integer: i32) { // some_integer가 스코프 안으로
    println!("{}", some_integer);
} // 닫는 중괄호가 나타났지만 어차피 스택 써서 아무런 일이 발생하지 않았다...

원리는 다르지 않다. Copy의 경우 신경 쓸 게 없지만 문자열을 함수에 집어넣어 호출한 이후 다시 s를 사용하려고 하면 컴파일 에러가 나타난다.


반환 값과 스코프

값을 넣는 과정뿐 아니라 반환하는 과정에서도 소유권이 이동한다.

fn main() {
    let s1 = gives_ownership(); // gives_ownership이 반환 값을 s1으로
    let s2 = String::from("Hello"); // s2가 스코프 안으로

    let s3 = takes_and_gives_back(s2); // s2가 함수로 값을 이동
    // 함수도 값을 s3으로 이동
} // s3이 스코프 밖으로 벗어나면서 drop
// s1도 스코프 밖으로 벗어나고 버려진다

fn gives_ownership() -> String {
    // gives_ownership은 자신의 반환 값을 자신의 호출자 함수로
    // 이동시키게 된다
    let some_string = String::from("yours"); //some_string이 스코프 안으로

    some_string // some_string이 반환되고 호출자 함수로 이동
}

fn takes_and_gives_back(a_string: String) -> String {
    // a_string이 스코프 안으로
    a_string // a_string이 반환되고 호출자 함수로 이동
}

상황이 달라도 소유권 규칙은 동일하다.

그런데 함수에 값을 넘겨줬어도 그 값을 여전히 쓰고 싶을 수 있다. 그래서 값을 사용할 수 있게 하되 소유권은 가져가지 말라고 하고 싶다면 어떻게 하는가?

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len()은 String의 길이를 반환합니다

    (s, length)
}

위의 코드처럼 튜플을 이용해 여러 값을 반환받는 것은 가능하지만, 상당히 보기 좋기 않다...그래서 소유권 이동 없이 값을 사용할 수 있도록 참조자(reference)라는 기능을 가지고 있다.

43 views

More from this blog

락프리 데이터 구조와 알고리즘

여기서는 락프리 데이터 구조를 설명한다. 락프리(lock-free) 란 배타락을 이용하지 않고 처리를 수행하는 데이터 구조 및 그에 대한 조작 알고리즘을 총칭한다. 왜 락프리인가? 전통적인 동시성 제어 방법인 뮤텍스나 세마포어는 여러 문제점을 가지고 있다: 성능 저하: 락 경합(lock contention)으로 인한 대기 시간 데드락: 여러 스레드가 서로의 락을 기다리는 상황 우선순위 역전: 낮은 우선순위 스레드가 높은 우선순위 스레드를 ...

Jul 27, 20257 min read126

소프트웨어 트랜잭셔널 메모리

소프트웨어 트랜잭셔널 메모리 동시성 프로그래밍에서 공유 자원에 대한 안전한 접근은 항상 중요한 과제다. 전통적으로 뮤텍스 락과 같은 비관적 락(Negative Lock) 방식을 사용해왔다. 이 방식은 크리티컬 섹션에 진입하기 전에 반드시 락을 획득해야 하며, 락을 얻지 못하면 코드 실행 자체가 블록된다. 하지만 이와는 다른 접근 방식이 있다. 바로 낙관적 락(Optimistic Lock) 방식인데, 이는 "일단 실행하고 나중에 검증하자"는 철학...

Jul 20, 202517 min read263

공평한 배타 제어

공평한 배타 제어 여기서는 공평한 배타 제어에 대해 설명한다. 먼저 컨텐션(contention) 이라는 개념을 이해할 필요가 있다. 컨텐션이란 여러 스레드가 동시에 같은 락을 획득하려고 경쟁하는 상황을 말한다. 컨텐션이 높을수록 스레드들이 락을 기다리는 시간이 길어지고 성능이 저하된다. 이러한 컨텐션 상황은 시스템 아키텍처에 따라 더욱 복잡해질 수 있다. 특히 비균일 메모리 접근(Non-Uniform Memory Access, NUMA) 와 같...

Jul 13, 20259 min read21

KernelSnitch[논문 리뷰]

Paper 1. Intro 이 글은 NDSS 2025에서 발표된 KernelSnitch 논문을 소개이다. 이 연구는 커널의 평범한 데이터 구조체들이 가진 본질적인 특성이 어떻게 심각한 보안 취약점이 되는지를 보여준다. 핵심은 이러하다: "데이터 구조체의 크기에 따른 접근 시간 차이를 이용해 커널의 비밀 정보를 유출할 수 있다" 여기서는 커널 힙 포인터 유출에 집중해서 설명한다. 이 공격이 성공하면 KASLR을 우회하고 더 심각한 커널 익스플로...

Jul 11, 20257 min read131

멀티태스크와 액터 모델

멀티태스크 협조적/비협조적 멀티태스크 선점: 프로세스와의 협조 없이 수행하는 컨택스트 스위칭이라고는 하나, 결국 뺏어오는 게 가능하냐의 문제다. 협조적 멀티태스크(비선점형, cooperative): 각각의 프로세스가 자발적으로 컨택스트 스위칭을 수행하는 멀티태스크 방식. 장점: 멀티태스크 매커니즘을 구현하기 쉽다. 단점: 프로세스가 자발적으로 컨텍스트 스위칭을 해야하는데, 만약 버그가 발생하여 프로세스가 무한 루프에 빠지거나 정지하게 되면 그 ...

Jul 6, 20252 min read25
M

MaxLog

35 posts