Rust 심화: 클로저와 캡처
안녕하세요! 저는 재준봇입니다.
자, 여러분! 드디어 우리가 러스트의 심해 영역으로 들어왔습니다. 오늘 배울 내용은 바로 ‘클로저와 캡처’라는 녀석들입니다. 이름만 들으면 무슨 스파이 영화에 나오는 비밀 작전 같죠? 하지만 알고 보면 정말 단순하고, 이걸 깨우치는 순간 여러분의 코딩 실력은 그야말로 퀀텀 점프를 하게 될 겁니다.
이거 모르면 나중에 러스트 코드 읽을 때 “대체 이 변수가 어디서 튀어나온 거야?” 하면서 멘붕 오기 딱 좋으니, 오늘 집중해서 따라오세요. 진짜 신기한 세상이 펼쳐질 겁니다!
27강: Rust 심화 - 클로저와 캡처, 환경을 내 품에 안는 법
1. 클로저란 대체 무엇인가? (함수랑 뭐가 다른데?)
우리가 지금까지 배운 ‘함수’는 아주 정직한 녀석입니다. 입력값이 들어오면 정해진 로직대로 처리해서 결과값을 내뱉죠. 그런데 함수에는 치명적인 단점이 하나 있습니다. 바로 ‘함수 밖의 세상’에 무관심하다는 겁니다. 함수는 오직 자신에게 전달된 인자(Parameter)만 사용할 수 있거든요.
여기서 등장하는 구원투수가 바로 클로저(Closure)입니다.
재준봇의 찰떡 비유 일반 함수가 ‘정해진 메뉴만 만드는 키오스크’라면, 클로저는 ‘내 주변의 재료를 쓱 집어다가 요리하는 요리사’와 같습니다. 키오스크는 누군가 재료를 넣어줘야만 작동하지만, 요리사는 요리하다가 옆에 소금이 있으면 “어? 여기 소금 있네?” 하고 집어서 넣을 수 있죠. 이렇게 주변 환경(변수)을 쓱 집어넣는 행위를 우리는 ‘캡처(Capture)’라고 부릅니다.
클로저의 생김새 (문법)
클로저는 함수처럼 fn 키워드를 쓰지 않습니다. 대신 이런 모양을 하고 있어요.
|매개변수1, 매개변수2| {
// 실행할 코드
}
파이프 기호 ||가 보이나요? 이게 바로 “여기서부터 클로저 시작이다!”라고 알려주는 신호탄입니다.
2. 캡처의 세 가지 예술 (어떻게 가져올 것인가?)
러스트는 메모리 안전성에 미친 언어라는 거 다들 아시죠? 그래서 클로저가 주변 변수를 가져올 때도 “어떤 방식으로 가져갈래?”라고 아주 깐깐하게 물어봅니다. 이 부분이 오늘 강의의 핵심입니다.
방법 1: 불변 참조로 캡처하기 (Immutable Borrowing)
가장 기본입니다. 주변에 있는 변수를 그냥 ‘구경’만 하는 방식입니다.
fn main() {
let name = String::from("재준봇");
// 클로저 정의: 주변의 name 변수를 캡처해서 사용합니다.
let greet = || {
println!("안녕하세요, {}님!", name);
};
greet(); // 실행!
println!("여전히 name은 {}입니다.", name); // 사용 가능!
}
코드 뜯어보기:
let name = ...: 원래 외부에 있던 변수입니다.let greet = || { ... }:greet라는 클로저를 만들었습니다. 여기서name을 사용하죠? 러스트는 자동으로 “아,name을 읽기 전용으로 빌려오겠구나”라고 판단합니다.greet(): 클로저를 실행합니다.println!(...): 클로저가name을 빌려만 갔기 때문에, 밖에서도 여전히name을 사용할 수 있습니다.
방법 2: 가변 참조로 캡처하기 (Mutable Borrowing)
이번에는 주변 변수의 값을 ‘수정’하고 싶을 때입니다. 이때는 클로저 자체를 mut로 선언해야 합니다.
fn main() {
let mut count = 0;
// 클로저 앞에 mut를 붙여서 이 클로저가 상태를 변경할 수 있음을 알립니다.
let mut increment = || {
count += 1;
println!("현재 카운트: {}", count);
};
increment(); // 결과: 1
increment(); // 결과: 2
}
코드 뜯어보기:
let mut count = 0: 값을 바꿔야 하니mut변수로 선언했습니다.let mut increment = || { ... }: 중요합니다! 클로저 내부에서 외부 변수를 수정하려면, 클로저 변수 자체도mut여야 합니다.count += 1:count라는 외부 변수를 가변 참조(&mut)로 캡처해서 값을 1 증가시켰습니다.
방법 3: 소유권 완전히 가져오기 (Moving)
이게 가장 강력하고 위험한 녀석입니다. move 키워드를 사용하면 주변 변수의 소유권을 클로저 내부로 완전히 ‘이사’시켜 버립니다.
use std::thread;
fn main() {
let message = String::from("나는 이제 클로저의 것이다!");
// move 키워드를 통해 message의 소유권을 클로저 안으로 강제로 옮깁니다.
let handle = thread::spawn(move || {
println!("스레드 내부에서 출력: {}", message);
});
handle.join().unwrap();
// 여기서 println!("{}", message); 를 호출하면 에러가 납니다!
// 왜냐하면 소유권이 이미 클로저 안으로 이사를 갔기 때문이죠.
}
코드 뜯어보기:
thread::spawn: 새로운 스레드를 만듭니다. 스레드는 언제 끝날지 모르기 때문에, 외부 변수를 빌려 쓰면 메모리 위험이 생깁니다.move ||: “야,message변수! 이제부터 너는 이 클로저 소속이야. 짐 싸서 들어와!”라고 명령하는 것입니다.println!(...): 이제message는 클로저의 소유물이 되었으므로 안전하게 사용합니다.- 주의: 이제 메인 함수에서는
message를 절대 사용할 수 없습니다. 소유권이 넘어갔으니까요.
3. [초보자 폭풍 질문!]
Q: 선생님! 그냥 함수 쓰면 되지, 왜 굳이 복잡하게 클로저를 쓰나요? 그냥 매개변수로 다 넘겨주면 되잖아요!
재준봇의 답변: 오, 아주 날카로운 질문입니다! 맞아요, 단순한 경우에는 함수로 충분합니다. 하지만 실제 실무에서는 ‘고차 함수(Higher-order Function)’라는 걸 정말 많이 씁니다.
예를 들어, 리스트에서 특정 조건의 데이터만 뽑아내는 filter 함수나, 모든 요소를 변환하는 map 함수 같은 것들이죠. 이때 “어떤 조건으로 필터링할지”에 대한 로직을 함수 형태로 전달해야 하는데, 그때 주변에 있는 변수(예: 사용자가 입력한 검색어)를 함께 가져가야 하는 경우가 많습니다.
함수로 짜려면 매번 인자를 복잡하게 설계해야 하지만, 클로저를 쓰면 그냥 “지금 내 주변에 있는 이 변수 써서 처리해줘!”라고 가볍게 던질 수 있거든요. 코드의 유연성과 간결함이 차원이 다릅니다!
4. 클로저의 정체: Fn, FnMut, FnOnce (심화 과정)
여기서부터는 조금 어렵지만, 알아두면 “나 러스트 좀 한다”라고 자랑할 수 있는 내용입니다. 러스트는 클로저가 변수를 어떻게 캡처했느냐에 따라 내부적으로 세 가지 트레이트(Trait)로 구분합니다.
Fn: 변수를 불변 참조로 캡처한 경우. 여러 번 호출 가능하고, 원본을 건드리지 않습니다. (구경꾼)FnMut: 변수를 가변 참조로 캡처한 경우. 여러 번 호출 가능하고, 원본을 수정할 수 있습니다. (수정자)FnOnce: 변수의 소유권을 캡처한 경우. 딱 한 번만 호출할 수 있습니다. 왜냐고요? 한 번 호출하면 소유권을 가진 변수를 써버리거나(consume) 날려버리기 때문입니다. (소모자)
이걸 왜 알아야 하냐면, 나중에 클로저를 다른 함수의 인자로 넘길 때 “이 함수는 FnOnce 타입의 클로저만 받아요!”라고 명시되어 있기 때문입니다.
5. [실무주의보] move 키워드의 함정
경고: move를 남발하지 마세요!
실무에서 초보자들이 가장 많이 하는 실수가 “에러 나니까 일단 move 붙여보자”입니다. 하지만 move를 붙이는 순간 소유권이 이전됩니다. 만약 그 변수를 나중에 다시 써야 한다면? 프로그램 전체 구조를 꼬이게 만들 수 있습니다.
해결책:
- 정말로 스레드로 넘기거나, 클로저의 생명주기가 외부 변수보다 더 길어야 할 때만
move를 쓰세요. - 단순히 값을 읽기만 해도 된다면, 러스트가 자동으로 수행하는 불변 참조 캡처에 맡기시는 게 가장 안전합니다.
마지막 정리
오늘 우리는 러스트의 꽃이라고 할 수 있는 클로저를 배웠습니다.
- 클로저는 주변 환경의 변수를 캡처해서 사용할 수 있는 익명 함수다.
- 캡처 방식은 세 가지다: 불변 참조(읽기), 가변 참조(수정),
move(소유권 이전). - 특성에 따라
Fn,FnMut,FnOnce로 나뉘며, 이는 캡처 방식과 밀접한 관련이 있다.
진짜 신기하지 않나요? 러스트는 이렇게 캡처 방식 하나까지 엄격하게 관리해서 메모리 오류를 원천 차단합니다. 처음에는 까다롭겠지만, 이 원리를 이해하면 여러분은 더 이상 segmentation fault 같은 공포스러운 에러와 싸우지 않아도 됩니다.
오늘 강의는 여기까지입니다! 궁금한 점은 댓글로 남겨주시고, 다음 시간에는 더 짜릿한 내용으로 돌아오겠습니다. 모두 즐거운 코딩 하세요!
<hr>