Rust · 심화
트레이트와 동시성으로 깊어지는 Rust
반복자 심화 - 어댑터 조합과 직접 구현
Iterator 트레이트 구현, map·filter·fold·zip·chain·flat_map, 지연 평가, collect 의 대상 타입 추론, iter·iter_mut·into_iter 차이
개발자KR · 원고 갱신
이 장에서 배우는 것
기본서에서는 for 문과 iter(), 간단한 map·collect 호출을 써 봤다. 이 장은 그 호출들이 어떻게 동작하는지 따져 본다. 반복자(iterator)는 next 메서드 하나로 정의된 트레이트이고, map이나 filter 같은 어댑터(adapter)는 모두 그 위에 쌓인 구조다. 이 원리를 이해하면 반복문을 직접 쓰는 대신 어댑터를 조합할 수 있고, 필요하면 직접 반복자를 만들 수도 있다.
예제는 작은 창고의 재고 프로그램이다. 품목 목록에서 총액을 계산하고, 입고 수량을 반영하고, 주문 여러 건을 품목별 수요로 합친다.
Iterator트레이트를 직접 구현해 SKU 코드를 만들어 내는 반복자를 작성한다.map,filter,fold,zip,chain,flat_map의 역할을 구분하고 조합한다.- 지연 평가(lazy evaluation)가 어떤 순서로 코드를 실행하는지 설명한다.
collect가 결과 타입을 어떻게 정하는지 알고,Vec·HashMap·Result로 모으는 코드를 쓴다.iter,iter_mut,into_iter의 차이를 소유권 관점에서 구분한다.
문제 상황
창고 프로그램에 품목이 다섯 개뿐일 때는 for 문과 let mut total = 0 같은 가변 변수만으로 충분하다. 요구가 늘면 문제가 생긴다. 재고 총액, 부족 품목 목록, 입고 반영, 주문의 품목별 합계를 각각 구하다 보면 거의 같은 모양의 반복문이 계속 생긴다. 반복문마다 가변 변수와 빈 Vec이 따라오고, 어느 변수가 어느 반복문에서 바뀌는지 읽기 어려워진다.
반복자 어댑터는 "무엇을 걸러 내고, 무엇으로 바꾸고, 어떻게 합칠지"를 단계별로 적게 해 준다. 다만 다음 질문에 답할 수 있어야 안전하게 쓸 수 있다.
- 어댑터를 줄줄이 이었는데 왜 아무 일도 일어나지 않는가?
collect()는 무엇으로 모으는지 어떻게 아는가?- 같은
Vec인데iter()와into_iter()를 바꾸면 왜 컴파일 오류가 나는가? - 우리 구조체를
for문이나 어댑터에 쓰려면 무엇을 구현해야 하는가?
참고로 이 장의 코드에는 |i| i.qty < 10 같은 짧은 함수 식이 나온다. 클로저는 다음 장에서 정식으로 다루므로, 여기서는 "인자를 받아 값을 돌려주는 이름 없는 함수"로 읽으면 충분하다.
Iterator 트레이트와 지연 평가
next 하나가 전부다
표준 라이브러리의 Iterator는 사실상 다음 모양이다.
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// map, filter, fold 등은 next 를 이용한 기본 구현
}
Item은 꺼내는 값의 타입이다. 연관 타입(associated type)은 제네릭 심화에서 따로 다루며, 여기서는 "next가 돌려주는 값의 타입을 구현하는 쪽이 정한다"는 정도만 알면 된다. next는 값이 남아 있으면 Some(값), 끝나면 None을 돌려준다. &mut self를 받는 이유는 호출할 때마다 반복자가 자기 상태(어디까지 줬는지)를 바꾸기 때문이다.
이 메서드 하나만 구현하면 map, filter, zip, collect 등 수십 개의 메서드가 기본 구현으로 따라온다. 완성 코드의 SkuSeq가 그 예다. 접두 문자와 끝 번호를 받아 A-001, A-002 같은 코드를 차례로 만든다. 구조체에 현재 위치(next 필드)를 두고, next 메서드가 그 위치를 한 칸씩 옮긴다.
어댑터는 아무것도 실행하지 않는다
어댑터는 반복자를 받아 새 반복자를 돌려주는 메서드다. map은 값을 바꾸는 반복자를, filter는 조건을 통과한 값만 내보내는 반복자를 만든다. 이 단계에서는 값을 하나도 꺼내지 않는다. 구조체 하나가 다른 구조체를 감싼 형태를 만들 뿐이다. 실제 계산은 collect, sum, fold, for 문처럼 값을 끝까지 또는 필요한 만큼 끌어내는 소비자(consumer)가 next를 부를 때 일어난다. 이것이 지연 평가다.
소비자가 가장 바깥 반복자의 next를 부르면, 그 반복자는 안쪽 반복자의 next를 부르고, 이 요청이 소스까지 전달된다. 값은 그 반대 방향으로 흘러온다. 값이 하나씩 끝까지 내려가므로 중간 결과를 담는 Vec이 필요 없다. 필요한 만큼만 꺼내므로 take(1) 뒤에 오는 값은 계산되지도 않는다.
완성 코드의 첫 부분이 이 순서를 눈으로 확인하는 실험이다. map 안에서 값을 출력하게 해 두고, 파이프라인을 만든 직후와 collect를 부른 뒤를 비교한다. 파이프라인을 만든 시점에는 map 출력이 없고, take(1)이 첫 결과를 받은 뒤에는 나머지 값에 대한 map 호출이 없다.
어댑터 여섯 가지
자주 쓰는 어댑터와 소비자를 재고 예제의 용도와 함께 정리하면 다음과 같다.
| 이름 | 종류 | 하는 일 | 재고 예제에서의 쓰임 |
|---|---|---|---|
map | 어댑터 | 각 값을 다른 값으로 바꾼다 | 품목에서 SKU 문자열만 뽑는다 |
filter | 어댑터 | 조건이 참인 값만 통과시킨다 | 수량이 기준보다 적은 품목을 고른다 |
zip | 어댑터 | 두 반복자를 짝지어 튜플로 만든다 | 품목과 입고 수량을 같은 순서로 묶는다 |
chain | 어댑터 | 한 반복자 뒤에 다른 반복자를 잇는다 | A 구역 코드 뒤에 B 구역 코드를 붙인다 |
flat_map | 어댑터 | 각 값에서 반복자를 만들고 이어 붙인다 | 주문 여러 건의 주문 줄을 한 줄로 펼친다 |
fold | 소비자 | 초깃값에 값을 하나씩 누적한다 | 수량 × 단가를 합쳐 총액을 구한다 |
zip은 둘 중 짧은 쪽이 끝나면 멈춘다. 길이가 다른 두 목록을 짝지을 때 남는 값이 조용히 버려지므로 길이가 같다는 가정이 있다면 따로 확인해야 한다. flat_map은 map 뒤에 한 겹을 펼치는 동작이 붙은 것으로 이해하면 된다. 각 주문이 줄 목록을 갖고 있을 때 map만 쓰면 "줄 목록의 반복자"가 나오지만, flat_map을 쓰면 줄 하나하나가 직접 나온다.
표준 라이브러리의 Iterator 문서에 나머지 메서드가 전부 정리되어 있으니, 필요한 것을 찾아 읽어 보면 된다.
직접 구현한 반복자도 어댑터를 그대로 쓴다
SkuSeq는 next만 구현했지만 곧바로 chain, map, collect를 쓸 수 있다. 완성 코드에서는 SkuSeq::new('A', 2).chain(SkuSeq::new('B', 2))로 두 구역의 코드를 이어 붙인다. 반복자를 반환하는 함수는 impl Iterator<Item = &Item>처럼 구체적인 타입을 숨겨서 쓸 수 있다. 앞 장에서 다룬 Box<dyn Iterator>도 같은 용도로 쓸 수 있지만, 분기에 따라 서로 다른 반복자를 돌려줘야 하는 경우가 아니라면 impl 쪽이 힙 할당과 동적 호출이 없어 간단하다.
collect 가 대상 타입을 정하는 방법
collect는 반복자의 값을 모아 컬렉션을 만든다. 시그니처는 대략 fn collect<B: FromIterator<Self::Item>>(self) -> B다. 반환 타입 B를 collect가 정하는 것이 아니라 호출하는 쪽이 정해야 한다. 같은 반복자가 Vec, HashMap, String, Result 어느 것으로도 모일 수 있기 때문이다. 타입을 알려 주는 방법은 세 가지다.
- 변수에 타입을 적는다:
let low: Vec<&str> = ….collect(); - 터보피시(turbofish)로 적는다:
….collect::<Vec<_>>(). 원소 타입은_로 두면 추론된다. - 함수 반환 타입에서 정해지게 한다:
fn parse_qtys(…) -> Result<Vec<u32>, ParseIntError> { … .collect() }
완성 코드에는 세 가지가 모두 나온다. 특히 parse_qtys가 중요하다. 반복자의 원소가 Result<u32, E>일 때 Result<Vec<u32>, E>로 모을 수 있다. 모두 Ok이면 값들이 담긴 Ok(Vec)이 되고, 하나라도 Err이면 처음 만난 오류에서 멈추고 그 Err를 돌려준다. 입력 문자열 여러 개를 파싱해 하나라도 틀리면 전체를 실패로 처리하는 일이 흔한데, 이 방식은 반복문과 조기 반환을 쓰지 않고도 같은 동작을 얻는다. Option도 같은 규칙으로 Option<Vec<T>>로 모을 수 있다.
(키, 값) 튜플을 내보내는 반복자는 HashMap으로 모을 수 있다. 완성 코드에서 품목의 SKU와 단가를 짝지어 HashMap<&str, u32>를 만든다. 같은 키가 여러 번 나오면 뒤의 값이 앞의 값을 덮어쓴다는 점은 기억해 둔다.
iter, iter_mut, into_iter 의 차이
같은 Vec<T>에서도 반복자를 꺼내는 세 가지 방법은 꺼내는 값의 타입이 다르다. 이는 곧 소유권을 어떻게 다루는지의 차이다.
| 호출 | 꺼내는 값 | 원본 이후 |
|---|---|---|
v.iter(), for x in &v | &T | 그대로 사용 가능 |
v.iter_mut(), for x in &mut v | &mut T | 반복이 끝나면 사용 가능 |
v.into_iter(), for x in v | T | 이동되어 사용 불가 |
완성 코드에서는 세 가지가 모두 나온다. 총액 계산은 읽기만 하므로 iter()를 쓴다. 입고 반영은 수량을 바꿔야 하므로 iter_mut()을 쓰고, 마지막에 SKU 문자열만 다른 곳으로 넘기는 부분은 String의 소유권을 가져가야 하므로 into_iter()를 쓴다. 소유권을 가져가고 나면 원본 벡터는 쓸 수 없다.
어댑터 안의 짧은 함수에서 자주 헷갈리는 것이 참조의 겹침이다. iter()가 &u32를 내보내면 filter의 인자는 그 참조에 대한 참조인 &&u32가 된다. filter가 값을 소비하지 않고 빌려서 조건만 보기 때문이다. 이 부분은 뒤의 "자주 틀리는 것"에서 다시 본다.
완성 코드
cargo new로 만든 프로젝트의 src/main.rs를 아래 내용으로 바꾼다.
use std::collections::HashMap;
use std::num::ParseIntError;
struct Item {
sku: String,
qty: u32,
price: u32,
}
struct Order {
id: u32,
lines: Vec<(&'static str, u32)>,
}
struct SkuSeq {
prefix: char,
next: u32,
end: u32,
}
impl SkuSeq {
fn new(prefix: char, end: u32) -> Self {
SkuSeq { prefix, next: 1, end }
}
}
impl Iterator for SkuSeq {
type Item = String;
fn next(&mut self) -> Option<String> {
if self.next > self.end {
return None;
}
let sku = format!("{}-{:03}", self.prefix, self.next);
self.next += 1;
Some(sku)
}
}
struct Warehouse {
items: Vec<Item>,
}
impl Warehouse {
fn iter(&self) -> std::slice::Iter<'_, Item> {
self.items.iter()
}
fn low_stock(&self, limit: u32) -> impl Iterator<Item = &Item> {
self.items.iter().filter(move |i| i.qty < limit)
}
}
fn item(sku: &str, qty: u32, price: u32) -> Item {
Item { sku: sku.to_string(), qty, price }
}
fn parse_qtys(raw: &[&str]) -> Result<Vec<u32>, ParseIntError> {
raw.iter().map(|s| s.parse::<u32>()).collect()
}
fn main() {
println!("== 지연 평가 ==");
let qtys = [2u32, 3, 4];
let pipeline = qtys
.iter()
.map(|q| {
println!(" map {q}");
q * 2
})
.filter(|v| *v > 5);
println!("파이프라인 생성 완료");
let first: Vec<u32> = pipeline.take(1).collect();
println!("첫 결과: {first:?}");
let mut w = Warehouse {
items: vec![
item("A-001", 120, 50),
item("A-002", 8, 300),
item("B-001", 0, 1200),
item("B-002", 35, 450),
item("C-001", 4, 800),
],
};
println!("== 재고 평가 ==");
let total_value = w.iter().fold(0u32, |acc, i| acc + i.qty * i.price);
println!("재고 총액: {total_value}원");
let low: Vec<&str> = w.low_stock(10).map(|i| i.sku.as_str()).collect();
println!("부족 품목: {low:?}");
println!("== 입고 ==");
let incoming = [30u32, 0, 50, 0, 20];
for (item, add) in w.items.iter_mut().zip(incoming) {
item.qty += add;
}
let line: Vec<String> = w.iter().map(|i| format!("{}={}", i.sku, i.qty)).collect();
println!("{}", line.join(", "));
let total_qty: u32 = w.iter().map(|i| i.qty).sum();
println!("입고 후 총 수량: {total_qty}");
println!("== SKU 후보 ==");
let labels = SkuSeq::new('A', 2).chain(SkuSeq::new('B', 2)).collect::<Vec<_>>();
println!("{labels:?}");
println!("== 주문 ==");
let orders = vec![
Order { id: 1, lines: vec![("A-001", 10), ("B-002", 5)] },
Order { id: 2, lines: vec![("A-001", 20), ("C-001", 3)] },
Order { id: 3, lines: vec![("B-002", 7)] },
];
let ids: Vec<u32> = orders.iter().map(|o| o.id).collect();
println!("처리 주문: {ids:?}");
let price_of: HashMap<&str, u32> = w.iter().map(|i| (i.sku.as_str(), i.price)).collect();
let mut demand: HashMap<&str, u32> = HashMap::new();
for (sku, n) in orders.iter().flat_map(|o| o.lines.iter()) {
*demand.entry(*sku).or_insert(0) += *n;
}
let mut rows: Vec<(&str, u32)> = demand.into_iter().collect();
rows.sort();
for (sku, n) in rows {
let amount = price_of[sku] * n;
println!("{sku}: {n}개, {amount}원");
}
println!("== 검증 ==");
println!("수량 파싱: {:?}", parse_qtys(&["10", "20", "5"]));
match parse_qtys(&["10", "x", "5"]) {
Ok(v) => println!("수량 파싱: {v:?}"),
Err(e) => println!("수량 파싱 실패: {e}"),
}
println!("== 이관 ==");
let names: Vec<String> = w.items.into_iter().map(|i| i.sku).collect();
println!("이관 SKU: {}", names.join(" "));
}
줄별 해설
타입 정의와 SkuSeq
Item, Order, Warehouse는 단순한 구조체다. Order의 lines는 (SKU, 수량) 튜플의 벡터인데, 예제에서는 문자열 리터럴만 쓰므로 &'static str로 두었다. 수명은 뒤에서 따로 다루므로 지금은 "프로그램 전체 기간 유효한 문자열"로 읽으면 된다.
SkuSeq::next는 self.next가 end를 넘으면 None을 돌려 반복을 끝내고, 아니면 코드를 만든 뒤 위치를 한 칸 옮긴다. {:03}은 숫자를 0으로 채워 세 자리로 맞추는 서식이다.
Warehouse 의 메서드
iter는 안쪽 벡터의 iter()를 그대로 노출한다. low_stock은 filter가 붙은 반복자를 impl Iterator로 돌려준다. 짧은 함수 앞의 move는 인자 limit을 함수 안으로 가져가는 표시다. low_stock이 끝나도 반복자는 살아 있어야 하므로 필요하다. 이 부분은 다음 장에서 자세히 다룬다.
main: 지연 평가 실험
pipeline을 만드는 코드는 map과 filter를 이었을 뿐이므로 출력이 없다. "파이프라인 생성 완료"가 먼저 나오는 이유다. take(1).collect()에서 요청이 시작된다. 2는 map을 거쳐 4가 되지만 filter에서 걸러지고, 3은 6이 되어 통과한다. take(1)은 하나를 받았으므로 더 요청하지 않아 4에는 map이 실행되지 않는다.
main: 재고 평가와 입고
fold는 초깃값 0u32에 품목마다 수량 × 단가를 더한다. low는 변수의 타입 표기로 collect의 대상이 정해지고, 이 벡터는 w의 문자열을 빌려 가지고 있다. 이 변수를 마지막으로 쓴 뒤라서, 아래의 w.items.iter_mut()가 가변 빌림을 얻어도 충돌하지 않는다.
입고 반영은 iter_mut()과 zip이다. 품목과 입고 수량이 같은 순서로 짝지어지고, item은 &mut Item이라 item.qty += add로 바로 고칠 수 있다. 이 item은 앞의 함수 item과 이름이 같지만 지역 변수라서 이 반복문 안에서만 가려진다. incoming은 배열을 값으로 zip에 넘긴다.
main: SKU, 주문, 검증
labels는 터보피시로 Vec<_>를 지정했다. 주문 처리에서는 flat_map이 주문 세 건의 줄을 한 줄로 이어 준다. 각 원소는 &(&str, u32)인데 for 패턴이 이를 분해해, sku는 &&str, n은 &u32가 된다. *sku, *n으로 참조를 벗겨 HashMap의 entry에 넘긴다. HashMap은 순회 순서가 정해져 있지 않으므로 into_iter().collect()로 벡터를 만든 뒤 sort해서 출력해야 결과가 매번 같다.
검증에서는 올바른 입력과 잘못된 입력을 모두 parse_qtys에 넣는다. 두 번째 입력에서는 "x"에서 파싱이 실패해 Err가 나오고, 이후 값은 처리되지 않는다. 마지막으로 into_iter()가 w.items의 소유권을 가져가 SKU 문자열만 새 벡터로 옮긴다. 이후에는 w.items를 쓸 수 없다.
실행 결과
$ cargo run
== 지연 평가 ==
파이프라인 생성 완료
map 2
map 3
첫 결과: [6]
== 재고 평가 ==
재고 총액: 27350원
부족 품목: ["A-002", "B-001", "C-001"]
== 입고 ==
A-001=150, A-002=8, B-001=50, B-002=35, C-001=24
입고 후 총 수량: 267
== SKU 후보 ==
["A-001", "A-002", "B-001", "B-002"]
== 주문 ==
처리 주문: [1, 2, 3]
A-001: 30개, 1500원
B-002: 12개, 5400원
C-001: 3개, 2400원
== 검증 ==
수량 파싱: Ok([10, 20, 5])
수량 파싱 실패: invalid digit found in string
== 이관 ==
이관 SKU: A-001 A-002 B-001 B-002 C-001
실무에서 자주 틀리는 것
어댑터만 만들고 소비하지 않는다
입고 수량을 모든 품목에 1씩 더하려고 이렇게 썼다고 하자.
w.items.iter_mut().map(|i| i.qty += 1);
컴파일은 되지만 경고가 나온다. 요지는 "unused Map that must be used — iterators are lazy and do nothing unless consumed"다. map은 반복자를 만들 뿐이라서 수량은 하나도 바뀌지 않는다. 부수 효과가 목적이면 for 문이나 for_each로 소비한다.
for i in w.items.iter_mut() {
i.qty += 1;
}
collect 의 타입을 알려 주지 않는다
let skus = w.iter().map(|i| i.sku.clone()).collect();
오류의 요지는 "type annotations needed"이며, 컴파일러가 모을 대상 컬렉션을 정할 수 없다는 뜻이다. 변수 타입이나 터보피시로 알려 준다.
let skus: Vec<String> = w.iter().map(|i| i.sku.clone()).collect();
into_iter 로 소비한 뒤 원본을 쓴다
let v = vec![String::from("A-001"), String::from("B-001")];
for s in v {
println!("{s}");
}
println!("{}", v.len());
오류는 "borrow of moved value: v"이고, for s in v가 into_iter()를 호출해 v를 이동시켰다는 안내가 따라온다. 읽기만 한다면 참조로 돌린다.
for s in &v {
println!("{s}");
}
println!("{}", v.len());
filter 인자의 이중 참조를 놓친다
let qtys = [4u32, 15, 0, 9];
let n = qtys.iter().filter(|x| *x < 10).count();
오류는 "mismatched types: expected &u32, found integer"다. x는 &&u32라서 *x를 해도 아직 &u32이므로 정수와 비교할 수 없다. 패턴으로 참조를 벗기거나 copied()로 값을 복사해 둔다.
let n = qtys.iter().filter(|&&x| x < 10).count();
let m = qtys.iter().copied().filter(|x| *x < 10).count();
한눈에 보기
| 주제 | 핵심 | 주의할 점 |
|---|---|---|
| Iterator 구현 | type Item과 next만 정의하면 어댑터가 따라온다 | 끝은 반드시 None으로 알린다 |
| 지연 평가 | 소비자가 next를 부를 때 값이 하나씩 흐른다 | 소비하지 않은 어댑터는 아무 일도 하지 않는다 |
| zip, chain, flat_map | 짝짓기, 이어 붙이기, 펼치기 | zip은 짧은 쪽에서 멈춘다 |
| fold, sum | 값을 하나로 누적한다 | 누적 타입의 크기를 초깃값으로 정한다 |
| collect | 호출하는 쪽이 대상 타입을 정한다 | Result로 모으면 첫 오류에서 멈춘다 |
| iter / iter_mut / into_iter | 빌림 / 수정 빌림 / 소유권 이동 | filter의 인자는 한 겹 더 참조다 |
연습 문제
let qtys = [4u32, 15, 0, 9];에서 10 미만인 값의 개수를 구하는 식을 어댑터와 소비자로 한 줄에 쓰고, 결과를 적어라.SkuSeq::new('A', 6).step_by(2).collect::<Vec<_>>()의 결과를 예상하라.step_by(2)는 처음 값을 포함해 두 칸씩 건너뛰며 꺼낸다.let raw = vec![Some(1u32), None, Some(3)];를Option<Vec<u32>>로 모으는 코드를 쓰고 결과를 적어라.raw가[Some(1), Some(3)]이면 결과는 어떻게 되는가?- 3부터 1까지 내려가며 값을 내보내는 반복자
Countdown을 구현하고,collect한 결과를 적어라. 시작 값은 필드로 받는다.
정답과 해설
1번. 한 줄은 다음과 같고 결과는 3이다. 4, 0, 9가 10보다 작다.
let n = qtys.iter().filter(|&&q| q < 10).count();
iter()의 원소가 &u32이고 filter가 한 겹 더 참조로 넘기므로 패턴 &&q로 벗겼다. copied()를 먼저 붙여도 된다.
2번. ["A-001", "A-003", "A-005"]다. 처음 값부터 시작해 두 칸씩 건너뛰므로 1, 3, 5번이 나온다. step_by도 어댑터라서 SkuSeq에 따로 구현하지 않아도 쓸 수 있다.
3번. 대상 타입을 변수에 적는다.
let all: Option<Vec<u32>> = raw.into_iter().collect();
첫 입력은 중간에 None이 있으므로 None이다. [Some(1), Some(3)]이면 Some([1, 3])이다. Result와 같은 규칙이며, 하나라도 없으면 전체가 없는 값이 된다.
4번. 한 가지 구현은 다음과 같다.
struct Countdown {
n: u32,
}
impl Iterator for Countdown {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.n == 0 {
return None;
}
let v = self.n;
self.n -= 1;
Some(v)
}
}
// Countdown { n: 3 }.collect::<Vec<_>>() 의 결과는 [3, 2, 1]
현재 값을 먼저 꺼내 두고 상태를 줄인 뒤 꺼낸 값을 돌려주는 순서가 핵심이다. 0이 되면 None을 돌려 반복을 끝낸다. 상태를 먼저 줄이고 값을 돌려주면 한 칸씩 어긋나므로 순서를 확인해야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.