소유권이란 무엇인가

소유권이란 무엇인가

Rust는 메모리를 관리하는 방식이 조금 특별해요. 프로그램이 돌아가는 동안 필요 없어진 메모리를 뒤에서 치워주는 가비지 컬렉터를 쓰는 언어도 있고, 그 대신 프로그래머가 직접 할당하고 해제해야 하는 언어도 있어요. Rust는 셋째 길로, '소유권(ownership)'이라는 규칙 체계로 메모리를 관리하는데 그 규칙은 컴파일러가 전부 검사해요. 규칙을 하나라도 어기면 프로그램이 아예 컴파일되지 않으니까요. 그리고 이 모든 검사가 실행 중이 아니라 컴파일 타임에 일어나서, 소유권 기능 하나 때문에 프로그램이 느려지지는 않아요.

처음에는 낯설고 적응할 시간이 필요해요. 하지만 소유권 규칙에 익숙해질수록 안전하고 효율적인 코드를 자연스럽게 짜게 되어 있어요. 이 장에서는 흔히 쓰이는 자료 구조인 문자열을 예로 들며 소유권을 하나씩 익혀갈 텐데, 그 전에 스택과 힙의 차이부터 잡고 가지 않으면 나중에 설명이 어려워져요.

출처: The Rust Programming Language — What Is Ownership?

스택과 힙

다른 언어에서는 스택과 힙을 거의 신경 쓸 일이 없어요. 하지만 시스템 프로그래밍 언어인 Rust에서는 값이 스택에 있느냐 힙에 있느냐가 언어의 동작과 여러분의 결정을 좌우해요. 둘 다 여러분의 코드가 실행 중에 쓸 수 있는 메모리 영역인데, 구조가 달라요.

스택은 값을 넣은 순서대로 저장하고 반대로 꺼내요. 마지막에 들어온 것이 먼저 나가는 LIFO(Last In, First Out) 방식이죠. 접시를 쌓는 것과 비슷해요. 위에 올리고, 필요할 때 위에서 하나 꺼내는 식이에요. 데이터를 넣는 걸 '푸시(push)', 빼는 걸 '팝(pop)'이라고 해요. 스택에 저장되는 데이터는 크기가 컴파일 타임에 정확히 알려져 있어야 해요. 크기를 미리 알 수 없거나 실행 중에 커질 수 있는 데이터는 힙에 둬야 해요.

힙은 좀 덜 정돈돼 있어요. 힙에 데이터를 넣으려면 일정 공간을 요청하고, 메모리 할당자가 그만한 빈자리를 찾아 '사용 중'으로 표시한 뒤 그 위치의 주소, 즉 포인터를 돌려줘요. 이 과정을 '힙에 할당한다(allocate)'고 해요. 포인터는 크기가 정해져 있으니 스택에 저장하고, 실제 데이터가 필요할 때 포인터를 따라가면 돼요.

스택에 푸시하는 건 힙에 할당하는 것보다 빨라요. 할당자는 새 데이터를 놓을 자리를 찾을 필요 없이 항상 스택 맨 위를 쓰면 되니까요. 반면 힙 할당은 넉넉한 자리를 찾고 다음 할당을 위한 정리를 해야 해서 일이 더 많아요. 데이터 접근도 힙이 느린 편이에요. 포인터를 따라가야 하니까요. 현대 프로세서는 메모리를 덜 건너뛸수록 빨라지거든요.

함수를 호출하면 함수에 넘기는 값(힙 데이터의 포인터를 포함해요)과 함수의 지역 변수들이 스택에 푸시되고, 함수가 끝나면 팝 되어 빠져나와요. 힙의 어느 데이터를 어느 코드가 쓰는지 추적하고, 힙의 중복 데이터를 줄이고, 쓰지 않는 힙 데이터를 정리해 공간이 바닥나지 않게 하는 문제들이 바로 소유권이 다루는 일이에요. 소유권을 이해하고 나면 스택과 힙을 자주 생각할 필요가 없어지지만, 소유권의 주된 목적이 힙 데이터 관리라는 걸 알아두면 왜 그렇게 동작하는지 설명이 됩니다.

소유권 규칙

소유권의 뼈대는 세 규칙으로 정리돼요. 예시를 보면서 계속 이 규칙에 비춰보면 좋아요.

  • Rust의 모든 값은 '소유자(owner)'를 가진다.
  • 소유자는 한 번에 하나만 있을 수 있다.
  • 소유자가 스코프를 벗어나면 값은 버려진다(drop).

변수의 스코프

스코프는 프로그램에서 어떤 항목이 유효한 범위를 뜻해요. 다음과 같은 변수를 보죠.

let s = "hello";

변수 s는 문자열 리터럴을 가리키는데, 이 값은 프로그램 텍스트에 하드코딩되어 있어요. 이 변수는 선언된 시점부터 현재 스코프가 끝날 때까지 유효해요. 요점은 두 가지예요.

  • s가 스코프에 '들어오면' 유효하다.
  • s가 스코프를 '벗어날' 때까지 계속 유효하다.

지금까지 본 건 다른 언어의 변수 유효 범위와 비슷하죠? 여기서 한 발 더 나가기 위해 String 타입을 도입할게요.

String 타입

소유권 규칙을 설명하려면 Chapter 3에서 다룬 것보다 더 복잡한 자료형이 필요해요. 앞에서 본 타입들은 크기가 정해져 있어서 스코프가 끝나면 스택에서 팝 되어 사라지고, 다른 스코프에서 같은 값을 쓰려면 간단히 복사해서 독립된 인스턴스를 만들 수 있어요. 하지만 우리는 힙에 저장되는 데이터를 보면서 Rust가 그 데이터를 언제 정리하는지 볼 거예요. String이 아주 좋은 예시죠.

문자열 리터럴은 프로그램에 하드코딩되어 있어서 편리하지만 모든 상황에 적합하진 않아요. 문자열 리터럴은 불변이고, 코드를 작성할 때 어떤 문자열 값이 올지 모르는 경우도 있어요. 예를 들어 사용자 입력을 받아 저장해야 한다면요? 그런 경우를 위해 Rust는 String 타입을 제공해요. 이 타입은 힙에 할당된 데이터를 관리해서 컴파일 타임에 크기를 모르는 텍스트도 저장할 수 있어요. 문자열 리터럴에서 from 함수로 String을 만들 수 있어요.

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

이중 콜론 :: 연산자는 이 from 함수를 String 타입 아래에 네임스페이스해 줘요. string_from 같은 이름을 쓸 필요가 없게 만드는 거죠. 이런 종류의 문자열은 변경할 수 있어요. 그럼 왜 String은 변경되는데 리터럴은 안 될까요? 두 타입이 메모리를 다루는 방식이 다르기 때문이에요.

메모리와 할당

문자열 리터럴은 컴파일 타임에 내용을 알 수 있어서 최종 실행 파일에 텍스트가 직접 박혀요. 그래서 빠르고 효율적이죠. 하지만 이 특성은 리터럴이 불변이라서 가능한 거예요. 컴파일 타임에 크기를 모르고 실행 중에 바뀔 수 있는 텍스트마다 메모리 덩어리를 실행 파일에 넣을 수는 없으니까요.

String처럼 변경 가능하고 늘어날 수 있는 텍스트를 지원하려면, 컴파일 타임에 크기를 모르는 힙 메모리를 할당해 내용을 담아야 해요. 다시 말해 두 가지가 필요해요.

  • 런타임에 메모리 할당자에게 메모리를 요청해야 한다.
  • String 사용이 끝나면 이 메모리를 할당자에게 돌려주는 방법이 필요하다.

첫 번째는 우리가 해요. String::from을 호출하면 그 구현이 필요한 메모리를 요청하죠. 이건 거의 모든 언어에서 공통이에요. 문제는 두 번째예요. 가비지 컬렉터(GC)가 있는 언어에서는 GC가 안 쓰는 메모리를 알아서 치워주니 우리가 신경 쓸 필요가 없어요. GC가 없는 대부분의 언어에서는 메모리가 더 이상 안 쓰이는 때를 우리가 직접 찾아서 명시적으로 해제하는 코드를 불러야 해요. 이걸 정확히 하기는 역사적으로 어려운 문제였죠. 잊으면 메모리를 낭비하고, 너무 일찍 하면 유효하지 않은 변수가 생기고, 두 번 하면 그것도 버그예요. allocate 하나에는 free가 정확히 하나가 매칭되어야 해요.

Rust는 다른 길을 가요. 메모리는 그 값을 소유한 변수가 스코프를 벗어나면 자동으로 돌아와요. 변수가 스코프를 벗어날 때 Rust가 특별한 함수를 호출하는데, 그 이름이 drop이에요. String의 작성자가 메모리를 돌려주는 코드를 바로 이 drop에 넣어두는 거죠. Rust는 닫는 중괄호에서 drop을 자동으로 호출해요. (C++에서 RAII 패턴을 써봤다면 drop 함수가 익숙할 거예요.)

이 패턴은 Rust 코드를 쓰는 방식을 크게 바꿔요. 지금은 단순해 보이지만, 힙에 할당한 데이터를 여러 변수가 쓰고 싶을 때처럼 복잡한 상황에서는 예상 밖의 동작이 생길 수 있어요. 이제 그런 상황을 몇 가지 볼게요.

변수와 데이터: 이동(Move)

여러 변수가 같은 데이터를 다른 방식으로 다룰 수 있어요. 정수를 예로 보죠.

let x = 5;
let y = x;

"x에 값 5를 바인딩하고, x의 값을 복사해서 y에 바인딩한다"로 읽히죠. 이제 xy 둘 다 5를 갖게 돼요. 정수는 크기가 정해진 단순한 값이라 두 5가 스택에 푸시되거든요.

이제 String 버전을 볼게요.

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

이것도 비슷해 보여서 같은 방식으로 동작할 거라 생각하기 쉬워요. 즉 두 번째 줄이 s1의 값을 복사해서 s2에 바인딩할 거라고요. 하지만 실제로는 다르게 동작해요.

String은 세 부분으로 이뤄져 있어요. 문자열 내용을 담은 힙 메모리를 가리키는 포인터, 길이(length), 그리고 용량(capacity)이죠. 이 묶음은 스택에 저장되요. 반면 오른쪽에는 내용을 담은 힙 메모리가 있어요.

s1s2에 할당하면 String의 데이터가 복사된다는 건 스택에 있는 포인터·길이·용량을 복사한다는 뜻이에요. 포인터가 가리키는 힙 데이터는 복사하지 않아요. 만약 Rust가 힙 데이터까지 복사한다면, 힙 데이터가 커질 때 s2 = s1 연산이 런타임 성능을 크게 떨어뜨릴 거예요.

이전에 변수가 스코프를 벗어나면 Rust가 자동으로 drop을 호출해 힙 메모리를 정리한다고 했어요. 그런데 두 데이터 포인터가 같은 위치를 가리킨다면, s2s1 둘 다 스코프를 벗어날 때 같은 메모리를 두 번 해제하려고 할 거예요. 이걸 '이중 해제(double free)' 오류라고 하고, 메모리 손상을 일으켜 보안 취약점으로 이어질 수 있는 버그예요.

메모리 안전을 보장하기 위해 Rust는 let s2 = s1; 줄 이후로 s1을 더 이상 유효하지 않다고 봐요. 그래서 s1이 스코프를 벗어날 때 해제할 게 없는 거죠. s2를 만든 다음에 s1을 쓰려 하면 오류가 나요.

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

println!("{s1}, world!");

Rust가 무효화된 참조를 쓰지 못하게 막기 때문이에요. 다른 언어에서 '얕은 복사(shallow copy)'와 '깊은 복사(deep copy)'를 들어봤다면, 포인터·길이·용량만 복사하고 데이터는 복사하지 않는 이 개념이 얕은 복사처럼 들릴 거예요. 하지만 Rust는 첫 번째 변수도 무효화시키기 때문에 얕은 복사가 아니라 '이동(move)'이라고 불러요. 이 예시에서는 s1s2로 '이동했다'고 합니다.

여기 암묵적인 설계 선택이 하나 담겨 있어요. Rust는 데이터의 '깊은' 복사를 절대 자동으로 만들지 않아요. 따라서 어떤 '자동' 복사도 런타임 성능 면에서 저렴하다고 가정할 수 있어요.

변수와 데이터: Clone과 Copy

만약 힙 데이터까지 깊이 복사하고 싶다면, 위 방식 말고 clone이라는 메서드를 쓰면 돼요.

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

println!("s1 = {s1}, s2 = {s2}");

이건 문제없이 동작하고, 힙 데이터까지 복사되는 결과를 명시적으로 만들어요. clone 호출을 보면 "여기서 임의의 코드가 실행되고, 그 코드가 비쌀 수도 있겠다"는 신호로 받아들이면 돼요.

그런데 아직 설명 안 한 예외가 있어요. 정수를 쓰는 아래 코드는 clone도 없는데 xy로 이동되지 않고 여전히 유효해요.

let x = 5;
let y = x;

println!("x = {x}, y = {y}");

그 이유는 정수처럼 컴파일 타임에 크기가 알려진 타입은 전부 스택에 저장돼서, 실제 값을 복사하는 게 아주 빠르기 때문이에요. y를 만든 뒤에도 x를 유효하게 막을 이유가 없는 거죠. 깊은 복사와 얕은 복사의 차이가 없으니 clone을 호출해도 평소 얕은 복사와 다를 게 없어서 그냥 생략할 수 있어요.

Rust에는 스택에 저장되는 타입에 붙일 수 있는 특별한 표시가 있는데, 그게 Copy 트레이트예요. Copy를 구현한 타입은 이동하지 않고 그냥 복사되므로, 다른 변수에 할당한 뒤에도 여전히 유효해요. Drop 트레이트를 구현한 타입(이나 그 일부)에 Copy를 붙이면 컴파일 오류가 나요. 스코프를 벗어날 때 특별히 할 일이 있어야 하는 타입에 Copy를 붙이면 안 되는 거죠.

어떤 타입이 Copy를 구현할까요? 단순 스칼라 값의 묶음이면 대체로 구현할 수 있고, 할당이나 자원이 필요한 타입은 Copy가 될 수 없어요. 예를 들면 이래요.

  • 모든 정수 타입, 예: u32
  • 불리언 타입 bool(true/false)
  • 모든 부동소수점 타입, 예: f64
  • 문자 타입 char
  • Copy를 구현한 타입만 담은 튜플 — (i32, i32)Copy인데 (i32, String)은 아니에요.

소유권과 함수, 반환 값

값을 함수에 넘기는 방식은 변수에 할당할 때와 같아요. 함수에 변수를 넘기면 할당처럼 이동하거나 복사돼요. 함수에 인자로 s를 넘기고 나서 s를 다시 쓰려 하면 컴파일 타임 오류가 나요. 이런 정적 검사가 우리를 실수에서 지켜줘요.

fn main() {
    let s = String::from("hello");  // s가 스코프에 들어옴

    takes_ownership(s);             // s의 값이 함수로 이동해서
                                    // 이 지점부터 s는 더 이상 유효하지 않음

    let x = 5;                      // x가 스코프에 들어옴

    makes_copy(x);                  // x도 함수로 이동하지만,
                                    // i32는 Copy라 이후에도 x를 쓸 수 있음
} // 여기서 x가 스코프를 벗어나고, 그다음 s가 스코프를 벗어남.
  // 하지만 s의 값은 이동했으므로 특별한 일은 없음

fn takes_ownership(some_string: String) { // some_string이 스코프에 들어옴
    println!("{some_string}");
} // 여기서 some_string이 스코프를 벗어나 drop이 호출되고, 메모리가 해제됨

fn makes_copy(some_integer: i32) { // some_integer가 스코프에 들어옴
    println!("{some_integer}");
} // 여기서 some_integer가 스코프를 벗어남. 특별한 일은 없음

반환 값도 소유권을 옮길 수 있어요. 값의 소유권은 늘 같은 패턴을 따라요. 값을 다른 변수에 할당하면 이동하고, 힙 데이터를 포함한 변수가 스코프를 벗어나면 그 데이터가 다른 변수로 이동하지 않는 한 drop으로 정리돼요.

그런데 모든 함수에서 소유권을 가져갔다가 다시 돌려주는 건 꽤 번거로워요. 함수가 값을 "쓰기만" 하고 소유권을 가져가진 않았으면 좋겠다면요. Rust에는 값을 소유권을 옮기지 않고 쓰게 해주는 기능이 있는데, 그게 바로 '참조(references)'예요. 다음 페이지에서 참조와 대여에 대해 자세히 볼게요.

더 알아보기 (Learn more)