Rust · 심화
트레이트와 동시성으로 깊어지는 Rust
수명 심화 - 구조체 속 참조와 'static 오해
참조를 담는 구조체, 생략 규칙 세 가지, 여러 수명 매개변수, 'static 경계의 진짜 뜻, 소유 타입으로 바꿔 수명 문제 피하기
개발자KR · 원고 갱신
이 장에서 배우는 것
기본서에서 수명(lifetime)은 함수 시그니처에 붙는 표기로 처음 만났다. 이 장은 한 걸음 더 나아가 참조를 구조체 필드에 담을 때 수명이 어떻게 달라지는지, 컴파일러가 수명을 대신 채워 주는 생략 규칙이 어디까지 통하는지, 그리고 'static 이라는 표기가 왜 자주 오해되는지를 다룬다. 마지막에는 수명 표기를 늘리는 대신 소유 타입으로 바꾸는 편이 나은 경우를 가려 본다. 예제는 창고의 주문 텍스트와 품목 카탈로그를 읽어 들이는 코드다.
- 참조를 담는 구조체에 수명 매개변수를 선언하고, 그 의미를 "원본이 살아 있어야 하는 구간"으로 설명할 수 있다.
- 수명 생략 규칙 세 가지를 시그니처에 직접 적용해 보고, 생략이 실패하는 경우를 구분한다.
- 수명 매개변수를 둘 이상 두어 서로 다른 원본에서 온 참조를 따로 추적한다.
T: 'static경계가 "값이 프로그램 끝까지 산다"가 아니라 "값이 빌린 것을 갖고 있지 않다"는 뜻임을 설명한다.- 참조 구조체를 소유 구조체로 바꿔야 하는 시점을 판단한다.
문제 상황
창고 프로그램은 두 가지 텍스트를 읽는다. 하나는 품목 카탈로그(A100 = 볼트 같은 줄), 다른 하나는 주문 한 건(A100:3 같은 줄)이다. 줄마다 String 을 새로 만들어 담으면 코드는 쉽지만, 주문이 수천 줄이면 같은 글자를 계속 복사한다. 그래서 원본 텍스트 안의 조각을 가리키는 &str 을 구조체에 그대로 담고 싶어진다.
이때 곧바로 세 가지 질문이 생긴다. 구조체 정의에 수명을 어떻게 적는가, 메서드가 돌려주는 참조는 누구에게 묶이는가, 주문 텍스트가 사라진 뒤에도 카탈로그에서 꺼낸 품명을 쓸 수 있는가. 컴파일러가 거부하는 이유를 읽지 못하면 'static 을 붙이거나 clone 을 남발하는 쪽으로 흐르기 쉽다. 이 장은 그 판단 기준을 정리한다.
참조를 담는 구조체
수명 매개변수를 선언한다
구조체 필드가 참조이면 그 참조가 얼마나 오래 유효한지 타입에 드러나야 한다. 수명 매개변수는 제네릭 타입 매개변수와 같은 자리에 선언한다.
#[derive(Debug, Clone, Copy)]
struct Line<'a> {
sku: &'a str,
qty: u32,
}
Line<'a> 는 "어떤 원본 위에 놓인 조각 하나"라는 뜻이다. 'a 는 그 원본이 살아 있는 구간 이내를 가리키며, Line 값은 이 구간 밖으로 나갈 수 없다. 필드를 &str 로만 적고 수명을 생략하면 컴파일러는 error[E0106]: missing lifetime specifier 로 이름 있는 수명이 필요하다고 알려 준다. 구조체 정의에서는 생략 규칙이 적용되지 않기 때문이다.
아래 그림은 이 관계를 메모리 관점에서 본 것이다. Line 은 글자를 복사하지 않고 원본 버퍼 안의 위치와 길이만 가진다.
구현 블록의 수명
수명 매개변수가 있는 구조체에 메서드를 붙일 때는 impl 뒤에도 같은 수명을 선언한다. 카탈로그 구조체로 보자.
struct Catalog<'a> {
entries: Vec<(&'a str, &'a str)>,
}
impl<'a> Catalog<'a> {
fn name_of(&self, sku: &str) -> Option<&'a str> {
// ...
}
}
impl<'a> 는 "임의의 'a 에 대해 구현한다"는 선언이고, Catalog<'a> 는 그 'a 를 사용한다. 반환 타입의 'a 는 카탈로그가 빌린 원본 텍스트에 묶인다는 뜻이다. 아래에서 이것을 생략한 경우와 비교한다.
생략 규칙 세 가지
함수 시그니처에서 수명을 적지 않아도 되는 이유는 컴파일러가 다음 세 규칙을 순서대로 적용하기 때문이다. 규칙을 적용하고도 반환 타입의 수명이 정해지지 않으면 오류가 난다.
| 규칙 | 대상 | 동작 | 예 |
|---|---|---|---|
| 1 | 입력 위치의 참조 | 참조마다 서로 다른 수명을 부여한다 | fn f(a: &str, b: &str) 는 '1, '2 |
| 2 | 입력 수명이 하나뿐일 때 | 그 수명을 모든 출력 참조에 부여한다 | fn parse_line(text: &str) -> Option<Line<'_>> |
| 3 | &self 나 &mut self 가 있을 때 | self 의 수명을 모든 출력 참조에 부여한다 | fn longest_name(&self) -> &str |
규칙 2를 쓰는 함수가 이 장의 주문 해석 함수다. 입력 참조가 하나이므로 반환하는 Line 은 그 입력에 묶인다.
fn parse_line(text: &str) -> Option<Line<'_>> {
let (sku, qty) = text.split_once(':')?;
let qty = qty.trim().parse::<u32>().ok()?;
Some(Line { sku: sku.trim(), qty })
}
명시적으로 쓰면 fn parse_line<'t>(text: &'t str) -> Option<Line<'t>> 와 같다. Line<'_> 의 '_ 는 "이름은 붙이지 않지만 여기에 수명이 있다"는 표시로, 구조체가 수명 매개변수를 가진다는 사실을 시그니처에서 숨기지 않게 해 준다.
반대로 입력 참조가 둘이고 self 도 없으면 규칙이 모자란다. fn pick(a: &str, b: &str) -> &str 는 반환값이 a 와 b 중 어느 쪽에 묶이는지 컴파일러가 알 수 없어 E0106 으로 거부된다. 이 경우에는 직접 적는다.
규칙 3의 함정
규칙 3은 편하지만 때로 너무 좁은 약속을 만든다. 메서드가 돌려주는 참조가 실제로는 self 가 아니라 구조체가 빌린 원본에서 왔는데도, 생략하면 &self 의 수명으로 묶여 버린다. 뒤의 "자주 틀리는 것"에서 이 차이가 오류로 나타나는 모습을 본다. 카탈로그의 name_of 가 Option<&'a str> 로 적힌 것도 그 때문이다.
여러 수명 매개변수
서로 다른 원본에서 온 참조를 한 구조체에 담으면 수명 하나로 묶는 것이 부정확해진다. 품명은 카탈로그 텍스트에서, SKU 는 주문 텍스트에서 오므로 둘을 따로 표기한다.
struct Resolved<'c, 'o> {
name: &'c str,
sku: &'o str,
qty: u32,
}
fn resolve<'c, 'o>(catalog: &Catalog<'c>, line: &Line<'o>) -> Option<Resolved<'c, 'o>> {
let name = catalog.name_of(line.sku)?;
Some(Resolved { name, sku: line.sku, qty: line.qty })
}
여기서 catalog 매개변수 바깥쪽 참조에는 이름을 붙이지 않았다. 결과가 catalog 변수를 빌리는 기간과는 무관하고, 오직 카탈로그 텍스트('c)와 주문 텍스트('o)에만 묶이기 때문이다. 수명을 둘로 나누면 주문 텍스트가 먼저 해제되어도 카탈로그에서 꺼낸 품명은 계속 쓸 수 있다. 다음 그림이 그 구간 관계다.
같은 원리로 known_names 는 주문 줄 목록을 받아 품명만 모아 돌려준다. 반환 타입이 Vec<&'c str> 이고 입력 줄의 수명은 '_ 로 남겨 두었으므로, 결과는 주문 텍스트와 무관해진다. 수명을 하나로 합쳐 'a 로 적어도 컴파일되는 경우가 많지만(공변성 덕분에 더 짧은 쪽으로 줄여 맞춘다), 그러면 시그니처가 "결과는 두 원본 중 짧은 쪽에 묶인다"고 약속하게 되어 호출하는 쪽의 자유가 줄어든다.
'static 경계의 진짜 뜻
'static 은 두 가지 자리에 나타나며 의미가 다르다.
| 자리 | 읽는 법 | 예 | 만족하는 값 |
|---|---|---|---|
| 참조의 수명 | 프로그램 전체 동안 유효한 데이터를 가리킨다 | &'static str | 문자열 리터럴, 누출시킨 데이터 |
| 타입 경계 | 값이 짧은 수명의 참조를 포함하지 않는다 | T: 'static | String, i32, Vec<OwnedLine>, 리터럴 참조 |
흔한 오해는 T: 'static 을 "T 값이 프로그램이 끝날 때까지 산다"로 읽는 것이다. 실제 뜻은 "T 안에 'static 보다 짧은 참조가 들어 있지 않다"이다. 그래서 String::from("A100") 은 만들자마자 버려져도 'static 경계를 만족한다. 빌린 것이 없기 때문이다. 반면 지역 변수를 가리키는 &String 은 'static 이 아니다.
이 경계는 값이 호출한 쪽의 스코프를 벗어나 움직여야 할 때 요구된다. 다음 장에서 다루는 스레드 생성 함수가 대표적이다. 새 스레드가 언제까지 돌지 알 수 없으므로 넘기는 값은 빌린 것이 없어야 한다. 그 때문에 이 장에서 'static 의 뜻을 미리 바로잡아 두는 것이 중요하다. Line<'a> 는 원본에 묶여 있으니 경계를 만족하지 못하고, OwnedLine 은 만족한다.
&'static 참조를 억지로 만들려고 Box::leak 을 쓰는 것은 대개 해법이 아니다. 데이터를 해제하지 않고 남겨 두어 메모리를 계속 쓰게 되며, 구조를 바로잡는 문제를 가리기만 한다.
소유 타입으로 바꿔 수명 문제 피하기
참조 구조체는 복사를 줄여 주지만 원본이 사라지면 쓸 수 없다. 주문을 읽은 뒤 큐에 쌓아 두었다가 나중에 처리하는 것처럼 값이 원본보다 오래 살아야 하면 소유 타입으로 바꾼다.
#[derive(Debug)]
struct OwnedLine {
sku: String,
qty: u32,
}
impl From<Line<'_>> for OwnedLine {
fn from(line: Line<'_>) -> Self {
OwnedLine { sku: line.sku.to_string(), qty: line.qty }
}
}
선택 기준은 단순하다. 원본 텍스트를 읽고 곧바로 처리가 끝나는 구간(해석, 검증, 집계)에서는 참조 구조체를 쓰고, 값이 함수 경계나 스레드, 저장소를 넘어 이동하는 지점에서 한 번 변환한다. 변환 비용은 그 한 번뿐이다. 수명 매개변수가 두세 개를 넘어 시그니처가 읽기 어려워진다면 구조가 소유 쪽으로 기울었다는 신호로 볼 수 있다.
완성 코드
아래 프로그램은 위의 조각을 모두 합친다. 카탈로그와 주문 텍스트를 읽어 해석하고, 주문 텍스트가 사라진 뒤에도 품명 목록을 쓰며, 마지막에 소유 타입으로 바꾼 주문을 'static 경계 함수에 넘긴다. src/main.rs 한 파일이다.
use std::fmt::Debug;
#[derive(Debug, Clone, Copy)]
struct Line<'a> {
sku: &'a str,
qty: u32,
}
fn parse_line(text: &str) -> Option<Line<'_>> {
let (sku, qty) = text.split_once(':')?;
let qty = qty.trim().parse::<u32>().ok()?;
Some(Line { sku: sku.trim(), qty })
}
fn parse_order(text: &str) -> Vec<Line<'_>> {
text.lines().filter_map(parse_line).collect()
}
struct Catalog<'a> {
entries: Vec<(&'a str, &'a str)>,
}
impl<'a> Catalog<'a> {
fn parse(text: &'a str) -> Catalog<'a> {
let entries = text
.lines()
.filter_map(|l| l.split_once('='))
.map(|(sku, name)| (sku.trim(), name.trim()))
.collect();
Catalog { entries }
}
fn name_of(&self, sku: &str) -> Option<&'a str> {
self.entries.iter().find(|(s, _)| *s == sku).map(|(_, n)| *n)
}
fn longest_name(&self) -> &str {
self.entries
.iter()
.map(|(_, n)| *n)
.max_by_key(|n| n.chars().count())
.unwrap_or("")
}
}
struct Resolved<'c, 'o> {
name: &'c str,
sku: &'o str,
qty: u32,
}
fn resolve<'c, 'o>(catalog: &Catalog<'c>, line: &Line<'o>) -> Option<Resolved<'c, 'o>> {
let name = catalog.name_of(line.sku)?;
Some(Resolved { name, sku: line.sku, qty: line.qty })
}
fn known_names<'c>(catalog: &Catalog<'c>, lines: &[Line<'_>]) -> Vec<&'c str> {
lines.iter().filter_map(|l| catalog.name_of(l.sku)).collect()
}
#[derive(Debug)]
struct OwnedLine {
sku: String,
qty: u32,
}
impl From<Line<'_>> for OwnedLine {
fn from(line: Line<'_>) -> Self {
OwnedLine { sku: line.sku.to_string(), qty: line.qty }
}
}
fn load_pending() -> Vec<OwnedLine> {
let text = String::from("A100:2\nB200:4");
parse_order(&text).into_iter().map(OwnedLine::from).collect()
}
fn describe<T: Debug + 'static>(value: T) -> String {
format!("{value:?}")
}
fn main() {
let catalog_text = String::from("A100 = 볼트\nB200 = 와셔 세트\nC300 = 육각 너트 M8");
let catalog = Catalog::parse(&catalog_text);
println!("가장 긴 품명: {}", catalog.longest_name());
let names;
{
let order_text = String::from("A100:3\nC300: 5\nZ999:1\nbad line\nB200:x");
let lines = parse_order(&order_text);
println!("해석한 줄 수: {}", lines.len());
for line in &lines {
match resolve(&catalog, line) {
Some(r) => println!("{} ({}) x {}", r.sku, r.name, r.qty),
None => println!("{}: 카탈로그에 없음", line.sku),
}
}
names = known_names(&catalog, &lines);
}
println!("주문 텍스트가 사라진 뒤: {:?}", names);
println!("{}", describe(String::from("A100")));
println!("{}", describe("literal"));
let pending = load_pending();
for item in &pending {
println!("{} -> {}", item.sku, item.qty);
}
println!("{}", describe(pending));
}
줄별 해설
Line 과 parse_line. Line<'a> 는 Copy 이므로 값으로 넘겨도 원본 소유권과 무관하다. parse_line 은 split_once 로 콜론 앞뒤를 나누고, ? 로 콜론이 없는 줄(bad line)을 None 으로 돌려보낸다. 숫자 해석이 실패하는 B200:x 도 .ok()? 에서 None 이 된다. trim 은 같은 원본의 더 작은 조각을 돌려주므로 수명이 그대로 유지된다.
parse_order. lines() 가 만드는 조각들은 모두 입력 텍스트의 일부이고, filter_map(parse_line) 에서 해석에 실패한 줄이 걸러진다. 반환 타입 Vec<Line<'_>> 는 규칙 2로 입력 text 에 묶인다.
Catalog. parse 는 연관 함수이므로 self 가 없다. 인자에 'a 를 직접 적어 Catalog<'a> 가 그 텍스트를 빌린다고 알린다. name_of 는 &self 와 sku 두 참조를 받지만 반환은 'a 로 명시했다. 생략하면 규칙 3으로 &self 에 묶인다. longest_name 은 반대로 규칙 3에 맡겼다. 결과를 잠깐 출력하는 용도라 충분하기 때문이다.
resolve 와 known_names. resolve 는 수명 두 개로 품명과 SKU 의 출처를 구분한다. known_names 는 반환을 Vec<&'c str> 로 한정하여 결과가 줄 목록의 수명과 무관함을 시그니처에 못박는다.
OwnedLine 과 load_pending. From<Line<'_>> 구현에서 to_string 으로 글자를 복사한다. load_pending 은 지역 String 을 해석한 뒤 소유 값으로 바꾸어 돌려주므로, 지역 변수가 사라져도 문제가 없다. Line 을 그대로 돌려주려 했다면 컴파일되지 않는다.
describe. T: Debug + 'static 경계 함수다. String, 문자열 리터럴, Vec<OwnedLine> 은 모두 통과한다.
main. let names; 로 선언만 해 두고 안쪽 블록에서 값을 넣는다. 블록이 끝나면 order_text 와 lines 가 해제되지만, names 는 catalog_text 에만 묶여 있으므로 이후 출력이 유효하다. 카탈로그 텍스트와 catalog 변수는 main 끝까지 살아 있다.
실행 결과
$ cargo run
가장 긴 품명: 육각 너트 M8
해석한 줄 수: 3
A100 (볼트) x 3
C300 (육각 너트 M8) x 5
Z999: 카탈로그에 없음
주문 텍스트가 사라진 뒤: ["볼트", "육각 너트 M8"]
"A100"
"literal"
A100 -> 2
B200 -> 4
[OwnedLine { sku: "A100", qty: 2 }, OwnedLine { sku: "B200", qty: 4 }]
실무에서 자주 틀리는 것
구조체 필드의 수명을 빼먹는다
struct Line {
sku: &str,
qty: u32,
}
컴파일러는 error[E0106]: missing lifetime specifier 로 sku 필드에 이름 있는 수명이 필요하다고 알려 준다. 구조체 정의에는 생략 규칙이 없다. 수명 매개변수를 선언하거나 필드를 String 으로 바꾼다.
struct Line<'a> {
sku: &'a str,
qty: u32,
}
지역 값을 가리키는 참조 구조체를 돌려준다
fn load_bad() -> Vec<Line<'static>> {
let text = String::from("A100:2");
parse_order(&text)
}
error[E0515]: cannot return value referencing local variable `text` 가 나온다. 반환값이 함수가 끝나면 사라질 text 를 빌리고 있다는 뜻이다. 수명 표기를 바꿔서는 풀리지 않으며, 소유 타입으로 변환해 돌려준다.
fn load_ok() -> Vec<OwnedLine> {
let text = String::from("A100:2");
parse_order(&text).into_iter().map(OwnedLine::from).collect()
}
T: 'static 을 오래 사는 값으로 읽는다
let local = String::from("A100");
println!("{}", describe(&local));
error[E0597]: `local` does not live long enough 이고, 요지는 인자가 'static 으로 빌려져야 한다는 것이다. &String 은 지역 변수를 빌린 참조이므로 경계를 만족하지 못한다. 이때 Box::leak 으로 억지로 맞추기보다 소유 값을 넘긴다.
let local = String::from("A100");
println!("{}", describe(local.clone()));
규칙 3 때문에 반환 참조가 self 에 묶인다
impl<'a> Catalog<'a> {
fn name_of(&self, sku: &str) -> Option<&str> {
self.entries.iter().find(|(s, _)| *s == sku).map(|(_, n)| *n)
}
}
let name = catalog.name_of("A100");
drop(catalog);
println!("{:?}", name);
반환 타입이 규칙 3으로 &self 에 묶이므로 error[E0505]: cannot move out of `catalog` because it is borrowed 가 난다. 데이터는 catalog_text 에 있는데도 시그니처가 그 사실을 숨긴 것이다. 반환 타입을 Option<&'a str> 로 고치면 catalog 변수를 먼저 정리해도 품명을 쓸 수 있다.
한눈에 보기
| 상황 | 선택 | 이유 |
|---|---|---|
| 원본을 읽는 짧은 구간(해석, 집계) | Line<'a> 같은 참조 구조체 | 복사 없이 원본 조각을 그대로 쓴다 |
| 값이 원본보다 오래 살거나 함수 밖으로 이동 | OwnedLine 같은 소유 구조체 | 수명 표기가 필요 없고 'static 경계도 만족한다 |
| 출처가 다른 참조를 한 구조체에 담음 | 수명 매개변수 여러 개 | 각 원본의 해제 시점을 따로 추적한다 |
| 메서드 결과가 self 가 아닌 원본에서 옴 | 반환 타입에 'a 명시 | 규칙 3의 과도한 제약을 피한다 |
| 시그니처 | 적용 규칙 | 결과 |
|---|---|---|
fn f(s: &str) -> &str | 2 | 컴파일된다 |
fn f(&self, s: &str) -> &str | 3 | 출력이 self 에 묶인다 |
fn f(a: &str, b: &str) -> &str | 해당 없음 | E0106 |
struct S { x: &str } | 해당 없음 | E0106 |
연습 문제
- 다음 세 시그니처 중 수명을 직접 적지 않아도 컴파일되는 것을 고르고, 컴파일되지 않는 것은 이유를 설명하라. (가)
fn tail(text: &str, skip: usize) -> &str(나)fn merge(a: &str, b: &str) -> &str(다) 구조체Catalog의 메서드fn first(&self, prefix: &str) -> Option<&str> - 배송 정보를 담는 구조체
Shipment가 운송사 이름(&str, 카탈로그와 별개의 텍스트에서 옴)과 메모(&str, 주문 텍스트에서 옴)를 가진다. 두 필드의 출처를 구분하는 정의를 쓰고, 운송사 이름만 돌려주는 함수를 메모 텍스트와 무관한 수명으로 작성하라. - 다음 타입 중
T: 'static을 만족하는 것을 모두 고르라.String, 지역String을 가리키는&String, 문자열 리터럴&str,Line<'a>(지역 텍스트에서 해석),Vec<OwnedLine> - 주문 텍스트를 받아
Vec<OwnedLine>으로 돌려주는 함수load(text: &str)를 이 장의parse_order와OwnedLine을 사용해 작성하라. 반환값이text보다 오래 살아도 되는 이유도 한 문장으로 적어라.
정답과 해설
- (가)와 (다)는 컴파일된다. (가)는 입력 참조가
text하나뿐이므로 규칙 2로 반환이text에 묶인다.skip은 참조가 아니라 수명과 무관하다. (다)는 규칙 3으로 반환이&self에 묶인다. (나)는 입력 참조가 둘이고self가 없어 반환 수명을 정할 수 없으므로E0106이 난다.fn merge<'a>(a: &'a str, b: &'a str) -> &'a str처럼 적어야 한다. - 정의는 다음과 같다.
반환 타입이struct Shipment<'c, 'o> { carrier: &'c str, memo: &'o str, } fn carrier_of<'c>(s: &Shipment<'c, '_>) -> &'c str { s.carrier }'c이므로 메모 텍스트나Shipment값 자체가 먼저 사라져도 운송사 이름은 쓸 수 있다. String, 문자열 리터럴&str,Vec<OwnedLine>이다. 이들은 짧은 수명의 참조를 포함하지 않는다. 지역String을 가리키는&String과 지역 텍스트에서 해석한Line<'a>는 지역 변수를 빌리고 있으므로 만족하지 못한다. 리터럴이 통과하는 것은 프로그램 바이너리에 들어 있어&'static str이기 때문이다.- 구현은 다음과 같다.
fn load(text: &str) -> Vec<OwnedLine> { parse_order(text).into_iter().map(OwnedLine::from).collect() }OwnedLine의sku가String으로 글자를 복사해 갖고 있어 원본text를 빌리지 않기 때문이다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.