두 얼굴의 'static

static. 다른 언어에서도 종종 볼 수 있는 키워드다. 우리말로 번역하면 "정적인"이란 의미로, 소프트웨어 공학에선 "동적인"이라는 dynamic의 반대말로 쓰이곤 한다. 프로그래밍 언어에선 각 언어에 따라 그리고 이 키워드가 쓰이는 장소에 따라 조금씩 의미가 달라진다. 따라서 혼동하지 않도록 주의가 필요한 키워드다.

Rust에도 static이라는 키워드가 존재한다. 여기선 이 키워드 앞에 '가 붙는 'static 수명에 대해 살펴본다. 'static 수명은 2가지 형태로 사용되며, 비슷하지만 다르기 때문에 헷갈리기 쉽다. 제대로 이해해서 소유권에 대한 이해를 한층 높여 보자.

'static 수명의 2가지 의미

Rust를 배우기 시작한 대부분의 사람은 다음과 같은 코드로 'static을 처음 접하게 된다.

let hello: &'static str = "안녕";

"안녕"이라고 하는 문자열의 슬라이스 참조로 변수 hello를 초기화하고 동시에 선언하고 있다. 여기서 'static은 참조의 수명(lifetime)으로, "이 참조는 프로그램이 끝날 때까지 언제나 유효하다"라는 것을 나타낸다. 실제로 위 코드를 컴파일하면, 컴파일된 이진 파일에 "안녕"이라는 문자열 리터럴이 특정 영역에 기록된다.

그리고 두 번째,

fn say<T: 'static>(x: T)

조금 전과 조금 다르다. 그래도 'static이 쓰였으니 조금 전과 비슷하게 매개변수 x에는 "프로그램이 끝날 때까지 유효한" 것만 대입할 수 있을 것 같다. 실제 수명이 'static보다 짧은 참조를 대입해보면

let one = 1;
say(&one) // error: `one` does not live long enough

대입한 값 &one의 참조대상 one이 원하는 만큼 오래 살지 않는다는 오류 메시지가 뜬다. 이렇게 '역시 매개변수 x에는 프로그램이 끝날 때까지 유효한 것만 대입할 수 있네'하고 오해하기 쉽다.

위의 2가지 예에선 'static이라는 똑같은 키워드가 쓰였지만, 사실 쓰인 장소를 다시 잘 보면 둘을 쉽게 구분할 수 있다. 전자는 타입 표기에, 후자는 타입 매개변수의 제약 표기에 쓰였다. 이름도 그에 따라 달라서 전자는 참조의 수명, 후자는 타입 매개변수의 수명제약(lifetime bound)이라고 한다. 전자의 'static 수명은 위에서 설명한 대로 프로그램이 끝날 때까지 유효한 참조라는 의미를 지니고 있다. 단순하고 이해하기 쉽다. 후자의 'static 수명제약은 조금 혼란의 여지가 있는데 아래에서 이에 대해 자세히 알아본다.

온전한 소유

정답을 미리 말하자면 <T: 'static>T'static 수명제약을 붙여, T모든 참조의 수명이 'static인 타입으로 제한한다. 여기서 "모든 참조"란 자기 자신을 포함해 자신 안에 있는 모든 참조를 말한다. 예를 들어 앞의 예제에서 설명한 say 함수의 매개변수에는 다음과 같이 'static 수명의 참조를 인수로 대입할 수 있다.

let hello = "안녕";
say(hello); // ok

참조가 하나뿐이고 그 수명이 'static이기 때문이다.

하지만, 앞서 봤듯이 수명이 'static이 아닌 참조는 대입할 수 없었다.

let one = 1;
say(&one); // error: `one` does not live long enough

해당하는 참조는 하나뿐이지만, 그 수명이 'static이 아니기 때문이다.

"모든 참조"라고 했으니 참조가 아예 없는 경우는 어떨까?

let s: String = "hello".to_string();
say(s); // ok

s는 문자열 슬라이스 참조(&str)가 아닌 문자열(String)을 담고 있다. 'static 수명제약은 "모든 참조의 수명이 'static인 타입"으로 제한하지만, 애초에 String은 참조가 아닌 소유 타입(owned type)1이기 때문에 'static 수명제약이 걸린 타입의 매개변수에도 대입할 수 있다. Vec나 다른 소유 타입의 값들도 마찬가지다.

그럼 참조를 포함하는 소유 타입은?

let hello = "안녕";
let one = 1;
let s = S(hello, &one); // struct S<'a>(&'static str, &'a i32);
say(s); // error: `one` does not live long enough

s 자체는 소유 타입이고 그 안에는 참조가 2개 있다. 첫 번째 참조의 수명은 'static으로 문제가 없지만, 두 번째 참조는 수명은 'static이 아니기 때문에 컴파일에 실패한다. 다시 말하면 위의 s는 온전히 소유하고 있지 않고 뭔가 외부에서 값을 빌리고 있기 때문이다.

참조가 더 많은 경우에도 똑같은 원리가 적용된다. 참조를 아무리 많이 포함하고 있어도 모두 'static 수명이면 문제없이 say 함수에 대입할 수 있지만, 단 하나라도 'static 수명이 아닌 참조가 있다면 대입할 수 없게 된다. 이렇게 'static 수명제약을 이용하면 'static 수명이 아닌 참조를 거를 수 있다.

정리하면 어느 타입 매개변수 T'static 수명제약을 가한다는 말은, T를 빌린(borrowed) 것을 가지고 있지 않은(가지고 있더라고 수명이 'static인), 온전히 소유(fully owned)할 수 있는 타입으로 제한하는 것이라고 말할 수 있다. 또한 VecString 같은 소유 타입은 프로그램 도중 언제든지 메모리에서 해제될 수 있기 때문에, 'static 수명이 의미하는 "프로그램이 끝날 때까지 항상 유효한 참조"라는 것은 'static 수명제약이 의미하는 바와 다르다는 것을 알 수 있다.

그럼 이 온전히 소유한다는 의미가 어떨 때 유용하게 쓰일까?

'static 수명제약의 쓰임새

표준라이브러리에는 새로운 스레드를 생성하는 spawn이라는 함수가 있다.

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static,

첫 번째 제약(F: FnOnce() -> T)을 보면 유추할 수 있겠지만, F는 생성할 스레드의 로직을 담는 클로저이고 T는 그 반환 타입이다. 그리고 그 아래에 타입 매개변수 FT에는 각각 'static 수명제약이 가해져 있다. 그렇다는 건 FT를 온전히 소유할 수 있는 타입으로 제한한다는 것이고, 다른 말로 부모 스레드와 자식 스레드가 서로 주고받는 데이터 타입을 온전한 소유 타입으로 제한한다고 해석할 수 있다. 즉, 온전히 소유할 수 있는 데이터만을 서로 주고받을 수 있게 된다. 따라서 기본적으로 각 스레드는 다른 스레드의 데이터에 대한 참조를 가질 수 없고2, 데이터들은 각각의 스레드별로 서로 독립되어 그 안에서만 사용된다. (스레드 사이에 공유할 필요가 있는 데이터는 다른 안전한 방법으로 공유할 수 있다.)

실제 다음과 같은 코드는 컴파일되지 않는다.

let one = 1;
thread::spawn(|| {
    say(&one); // error: `one` does not live long enough
});

스레드를 생성한 부모 스레드와 그렇게 생성된 자식 스레드중에 누가 먼저 일을 끝내고 종료될지 컴파일러는 알 수가 없다. 따라서 참조하고 있는 값이 사용 도중에 메모리 해제되어 무효한 값이 되어버릴 수 있는 참조, 즉 댕글링포인터(dangling pointer)가 될 가능성이 있는 참조는 애초에 다른 스레드에 넘길 수 없도록 안전장치가 마련되어 있다. 이렇게 Rust의 소유권 시스템은 'static 수명제약이라는 안전장치로 다른 스레드에서 쓰일 데이터에 대해서도 메모리 안전성을 보장한다. (그것도 컴파일 타임에!)

만약 'static 수명으로 착각해 프로그램이 끝날 때까지 유효한 데이터만 다른 스레드에 넘겨줄 수 있다고 잘못 이해하고 있다면, 불필요한 메모리를 계속 점유하는 건 아니냐고 메모리 누수(memory leak)를 걱정할 수도 있다. 하지만, 이미 알다시피 'static 수명제약의 타입은 소유 타입의 값도 대입할 수 있고, 이는 언제든지 필요하지 않을 땐 메모리에서 해제될 수 있다.

일반적인 수명제약

앞서 <T: 'static>T'static 수명제약이 붙여 모든 참조의 수명이 'static인 타입으로 제한한다고 했다. 사실 컴파일러의 입장에서 좀 더 엄밀하게 보면 모든 참조의 수명이 'static 이상인 타입으로 제한하는 것이다. 원래 'static 수명이 프로그램 전체 실행기간이라는 범위를 나타내니까 수명의 길이가 'static 이상이라는 말은 그냥 수명이 'static이라는 말하고 같다. 하지만 이렇게 이해하면 수명제약을 보다 일반화해서 이해하기 쉽다. 즉, 'static이 아닌 일반적인 어떤 수명 'a에 대한 수명제약을 아래와 같다.

<T: 'a>에서 T모든 참조의 수명의 길이가 'a 이상인 타입으로 제한된다.

그래서 이 경우 T의 매개변수에 수명이 'a보다 짧은 참조가 있는 값은 대입할 수 없다.


  1. String 값은 내부적으로 힙에 할당된 문자열을 가리키는 포인터를 가지고 있지만 외부적(논리적)으로는 소유하고 있다고 표현된다. 이는 PhantomData 로 구현된다.
  2. crossbeam의 Scoped Thread를 이용하면 수명이 'static이 아닌 참조를 다른 스레드에 넘길 수 있다.