PHpullh

RUST · 심층 가이드

Rust 함수 완전 정리

기본값 매개변수도 오버로딩도 가변 인자도 없는 언어에서 클로저와 제네릭만으로 같은 수준의 표현력을 만들어내는 방법을, 20개 예제로 차근차근 정리했습니다.

주제 20개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

Rust의 함수 시그니처에는 타입 추론이 없습니다. 인자와 반환 타입을 전부 적어야 하고, 오버로딩도 기본값 인자도 가변 인자도 지원하지 않습니다. 대신 클로저가 그 자리를 메웁니다. 클로저는 캡처 방식에 따라 Fn, FnMut, FnOnce 중 하나로 분류되고, 이 구분이 곧 몇 번 호출할 수 있는가와 캡처한 값을 건드릴 수 있는가를 결정합니다. 다른 언어에서 그냥 람다였던 것이 여기서는 세 가지 계약으로 갈립니다.

기본값 매개변수와 가변 인자 합계는 없는 기능을 Option과 슬라이스로 대체하는 관용구를 보여줍니다. 그다음 람다 함수에서 클로저 카운터로 넘어가면 캡처가 값 복사인지 빌림인지, move가 언제 필요한지가 드러납니다. 이 감각이 붙어야 함수 팩토리와 커링, compose 함수에서 클로저를 반환값으로 쓸 수 있고, 고차 함수 map과 reducer 함수는 그 결과를 이터레이터에 얹는 연습입니다.

메모이제이션이 대표적인 함정입니다. 캐시를 캡처한 클로저는 FnMut가 되는데, 재귀 호출을 하려면 자기 자신을 다시 빌려야 해서 빌림 검사기가 막습니다. 클로저 안에서 클로저를 재귀시키는 대신, 캐시를 &mut HashMap 인자로 받는 평범한 fn으로 분리하는 편이 훨씬 간단합니다. 클로저를 반환할 때도 impl Fn이나 Box<dyn Fn>이 필요하고, 캡처한 값의 수명이 함수 밖까지 살아야 하므로 move가 사실상 필수입니다.

01기본 함수 선언

add는 i32 두 개를 받아 a + b를 돌려주고, main이 1과 2를 넘겨 3을 찍습니다. 함수 시그니처와 반환 타입을 익히는 출발점이 되는 형태입니다. 본문의 마지막 식에 세미콜론을 붙이는 순간 반환값이 ()가 되어 타입 불일치로 컴파일이 막히는 점이 초심자가 가장 자주 걸리는 지점입니다.

Rust code

// 기본 함수 선언
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(1, 2));
}
알아두면 좋은 점

함수 본문의 마지막 식이 곧 반환값이라 return을 적지 않아도 되고, 중간에서 빠져나갈 때만 return을 쓰는 것이 관례입니다.

자주 하는 실수

마지막 식 a + b에 세미콜론을 붙이면 반환값이 ()가 되어, 선언한 -> i32와 어긋난다는 타입 오류가 납니다.

02두 값을 더하는 함수

make_counter가 내부 변수 current를 품은 클로저를 돌려주고, 호출할 때마다 값을 1씩 올려 첫 호출에서 1을 출력합니다. 전역 변수 없이 호출 횟수를 세야 할 때 이 구조를 씁니다. 반환 타입이 impl FnMut이라 상태를 바꾸는 클로저를 담을 수 있고, 받는 쪽 바인딩에는 mut이 반드시 붙어야 합니다.

Rust code

// 두 값을 더하는 함수
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

impl FnMut() -> i32로 반환하면 클로저가 품은 상태가 익명 구조체의 필드로 들어가, 전역 변수 없이 호출 횟수를 셀 수 있습니다.

자주 하는 실수

받는 쪽을 let next로 쓰면 FnMut 호출이 자기 자신을 가변으로 빌려야 하므로 컴파일되지 않고, let mut next여야 합니다.

03기본값 매개변수

기본값 문법은 나오지 않고, 두 인자를 모두 받아 5를 계산하는 함수가 정의되어 있습니다. 인자가 항상 주어지는 계산이라면 이대로가 가장 단순합니다. 러스트에는 매개변수 기본값이 없어서, 생략을 허용하려면 Option을 받거나 인자 수가 다른 함수를 따로 두거나 빌더 구조체를 만드는 쪽으로 갑니다.

Rust code

// 기본값 매개변수
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(3, 2));
}
알아두면 좋은 점

러스트에는 매개변수 기본값이 없어, 생략을 허용하려면 Option을 받아 unwrap_or로 채우거나 빌더 구조체를 두게 됩니다.

자주 하는 실수

기본값 대신 같은 이름의 함수를 인자 수만 바꿔 여러 개 두려 하면, 러스트는 오버로딩을 지원하지 않아 이름 중복 오류가 납니다.

04가변 인자 합계

가변 인자와는 관계없이, 카운터 클로저를 만들어 한 번 호출한 뒤 1을 출력합니다. 값을 발급하는 생성기를 함수 하나로 감싸 두고 싶을 때 이 모양이 나옵니다. move가 없으면 current를 빌리기만 하게 되어 함수가 끝나는 순간 대상이 사라지고, 컴파일러가 반환을 거부합니다.

Rust code

// 가변 인자 합계
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

move가 붙어 있어 current의 소유권이 클로저 안으로 들어가고, 그래서 함수가 반환된 뒤에도 상태가 살아남습니다.

자주 하는 실수

move를 빼면 클로저가 지역 변수를 빌리기만 해서, 함수가 끝나는 순간 대상이 사라진다는 이유로 컴파일러가 반환을 거부합니다.

05객체 또는 구조체 반환

구조체를 돌려주지는 않고 정수 덧셈 결과 7을 반환하는 함수입니다. 반환값이 단순한 스칼라라면 굳이 타입을 새로 만들 필요가 없습니다. i32는 Copy라 인자로 넘겨도 원본이 그대로 남기 때문에, 소유권 이동이나 참조를 신경 쓸 일이 이 함수에서는 아예 생기지 않습니다.

Rust code

// 객체 또는 구조체 반환
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(5, 2));
}
알아두면 좋은 점

i32는 Copy라 인자로 넘겨도 원본이 그대로 남으므로, 이 함수에서는 소유권 이동이나 참조를 신경 쓸 일이 아예 없습니다.

자주 하는 실수

같은 감각으로 String을 값으로 넘기면 소유권이 이동해 호출부에서 원본을 다시 쓸 수 없게 되므로, 읽기만 한다면 &str로 받아야 합니다.

06람다 함수

람다라는 제목 아래 실제로는 클로저를 반환하는 함수와 그 호출이 놓여 있습니다. 만들어 낸 함수 값을 변수에 담아 나중에 부르는 흐름을 여기서 볼 수 있습니다. FnMut은 호출 시 자기 자신을 가변으로 빌리므로, let next가 아니라 let mut next여야 호출이 통과합니다.

Rust code

// 람다 함수
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

만들어 낸 클로저를 변수에 담아 두면 일반 함수처럼 next()로 부를 수 있고, 여러 번 불러도 같은 상태를 이어 갑니다.

자주 하는 실수

이 클로저를 Fn을 요구하는 자리에 넘기면 상태를 바꾼다는 이유로 거부되므로, 받는 쪽 경계가 FnMut인지 먼저 확인해야 합니다.

07클로저 카운터

클로저 카운터를 기대할 자리에 캡처가 전혀 없는 일반 함수 add가 들어 있고 결과는 9입니다. 상태가 필요 없는 계산은 이렇게 이름 붙은 함수로 두는 편이 읽기 쉽습니다. 이런 함수 항목은 Fn 계열 트레이트를 모두 만족해서, 클로저를 받는 API에 그대로 넘길 수 있습니다.

Rust code

// 클로저 카운터
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(7, 2));
}
알아두면 좋은 점

이름 있는 함수 항목은 Fn 계열 트레이트를 모두 만족하므로, 클로저를 받는 API에 add를 그대로 넘길 수 있습니다.

자주 하는 실수

함수 이름 뒤에 괄호를 빼고 적으면 호출이 아니라 함수 값이 되므로, 결과를 기대한 자리에 넘기면 타입이 맞지 않는다는 오류로 드러납니다.

08재귀 팩토리얼

재귀는 등장하지 않고, 호출마다 증가하는 값을 내주는 클로저를 만들어 첫 값 1을 얻습니다. 반복 상태를 바깥에 노출하지 않고 감춰야 할 때 이 방식이 유효합니다. impl FnMut은 정확히 하나의 구체 타입만 가리키므로, 조건에 따라 서로 다른 클로저를 반환하려 하면 타입이 갈려 컴파일되지 않습니다.

Rust code

// 재귀 팩토리얼
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

make_counter를 부를 때마다 완전히 독립된 current가 새로 생기므로, 카운터를 여러 개 만들어도 값이 서로 섞이지 않습니다.

자주 하는 실수

impl FnMut은 정확히 하나의 구체 타입만 가리키므로, 조건에 따라 다른 클로저를 반환하도록 고치면 타입이 갈려 컴파일되지 않습니다.

09고차 함수 map

map을 쓰는 대신 두 수를 더해 11을 내놓는 함수 호출만 있습니다. 컬렉션 변환이 아니라 단일 계산이라면 고차 함수를 끌어올 이유가 없습니다. 덧셈 오버플로는 디버그 빌드에서 패닉하고 릴리스에서는 감싸 도는 값이 되므로, 경계를 다뤄야 한다면 checked_add로 Option을 받는 편이 정확합니다.

Rust code

// 고차 함수 map
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(9, 2));
}
알아두면 좋은 점

덧셈이 넘칠 수 있는 자리에서는 checked_add가 Option을, saturating_add가 한계값을 돌려주므로 상황에 맞게 고를 수 있습니다.

자주 하는 실수

정수 오버플로는 디버그 빌드에서만 패닉하고 릴리스에서는 감싸 도므로, 테스트에서 잡히던 문제가 배포본에서는 잘못된 값으로 조용히 지나갑니다.

10조건 함수 predicate

조건 판정 함수 대신 상태를 지닌 클로저가 만들어져 1을 출력합니다. 발급 번호나 재시도 횟수처럼 호출 순서를 기억해야 하는 값에 어울립니다. make_counter를 부를 때마다 완전히 독립된 current가 새로 생기므로, 여러 카운터를 동시에 두어도 서로 값이 섞이지 않습니다.

Rust code

// 조건 함수 predicate
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

발급 번호처럼 호출 순서를 기억해야 하는 값은 클로저 안에 상태를 가둬 두면, 바깥에서 실수로 건드릴 방법 자체가 없어집니다.

자주 하는 실수

이 카운터를 여러 스레드에서 함께 쓰려면 원자 타입이나 Mutex가 필요하며, 그냥 복제해 나눠 주면 스레드마다 따로 세게 됩니다.

11정렬용 비교 함수

정렬 비교자는 없고, 인자 두 개를 더해 13을 반환하는 함수가 전부입니다. 두 값을 받아 하나를 내놓는 가장 기본적인 계약을 확인하기에 적당합니다. 비교 함수로 쓰려면 반환 타입이 정수가 아니라 Ordering이어야 하며, sort_by는 이 세 갈래 값을 보고 순서를 정합니다.

Rust code

// 정렬용 비교 함수
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(11, 2));
}
알아두면 좋은 점

정렬 비교자는 정수가 아니라 Ordering을 돌려주어야 하며, a.cmp(&b)가 그 세 갈래 값을 그대로 만들어 줍니다.

자주 하는 실수

C 스타일로 차이를 빼서 비교 결과로 쓰면 큰 값에서 오버플로가 날 뿐 아니라, sort_by가 요구하는 반환 타입과도 맞지 않습니다.

12문자열 파서

문자열 파싱과는 무관하게, 카운터 클로저를 생성해 한 번 호출하는 코드입니다. 입력을 순서대로 훑으며 위치를 세는 처리에서 이런 상태 보관이 쓸모 있습니다. 클로저가 가진 상태는 컴파일러가 만든 익명 구조체의 필드로 들어가며, 여기서는 i32 하나뿐이라 크기도 그만큼입니다.

Rust code

// 문자열 파서
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

클로저의 상태는 컴파일러가 만든 익명 구조체의 필드로 들어가므로, 여기서는 i32 하나 크기만 차지하고 별도의 힙 할당도 생기지 않습니다.

자주 하는 실수

입력 위치를 세는 카운터를 클로저에 감춰 두면 어디까지 읽었는지 밖에서 볼 수 없어, 오류 메시지에 위치를 남기기 어려워집니다.

13함수 팩토리

함수를 찍어 내는 팩토리는 아니고, 고정된 덧셈 함수가 15를 계산합니다. 동작이 하나로 정해져 있으면 이렇게 직접 정의하는 쪽이 군더더기가 없습니다. 러스트는 이름이 같은 함수를 인자 타입만 바꿔 여러 개 두는 오버로딩을 허용하지 않으므로, 다형성이 필요하면 제네릭이나 트레이트로 풀어야 합니다.

Rust code

// 함수 팩토리
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(13, 2));
}
알아두면 좋은 점

동작이 하나로 정해져 있다면 이름 있는 함수가 가장 단순하고, 캡처가 없어 어디서 불러도 같은 결과를 내므로 테스트하기도 쉽습니다.

자주 하는 실수

러스트는 오버로딩이 없어 여러 타입을 받게 하려면 제네릭이나 트레이트로 풀어야 하며, 이름만 바꿔 함수를 늘리면 호출부가 금방 지저분해집니다.

14메모이제이션

메모이제이션 캐시는 보이지 않고, 값을 하나씩 늘려 주는 클로저만 반환됩니다. 이전 호출 결과에 의존하는 계산을 함수 안에 가둬 두는 데 알맞습니다. 결과를 실제로 캐싱하려면 HashMap을 캡처해야 하는데, 그 순간 클로저는 여전히 FnMut이지만 맵 조회 비용과 키 해싱 비용이 새로 붙습니다.

Rust code

// 메모이제이션
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

결과를 실제로 캐싱하려면 HashMap을 클로저가 캡처하면 되고, 그때도 클로저는 여전히 FnMut으로 다룰 수 있습니다.

자주 하는 실수

캐시를 붙이면 해싱과 조회 비용이 새로 생기므로, 원래 계산이 덧셈처럼 값싼 경우에는 캐싱을 얹는 쪽이 오히려 느려집니다.

15커링

커링 형태가 아니라 두 인자를 한꺼번에 받는 함수가 17을 만들어 냅니다. 인자를 나눠 받을 이유가 없다면 이렇게 한 번에 처리하는 편이 명확합니다. 인자를 쪼개려면 첫 인자를 캡처한 클로저를 돌려줘야 하고, 그 반환 타입은 이름이 없어 impl Fn 같은 표기가 필요해집니다.

Rust code

// 커링
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(15, 2));
}
알아두면 좋은 점

인자를 나눠 받으려면 첫 인자를 캡처한 클로저를 돌려주면 되고, 반환 타입 자리에는 이름이 없으므로 impl Fn(i32) -> i32를 적습니다.

자주 하는 실수

커링을 흉내 내려고 단계마다 Box<dyn Fn>을 만들면 인자 하나마다 힙 할당과 간접 호출이 붙어, 얻는 것보다 비용이 큽니다.

16compose 함수

합성 함수 없이, 카운터를 만들어 주는 함수와 한 번의 호출이 코드의 전부입니다. 함수가 함수를 돌려준다는 감각을 잡는 데 이 예제가 도움이 됩니다. impl FnMut은 힙 할당도 동적 분기도 없는 정적 타입이라, 같은 일을 Box<dyn FnMut>으로 하면 할당 한 번과 간접 호출 비용이 더 붙습니다.

Rust code

// compose 함수
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

impl FnMut은 정적으로 결정되는 타입이라 힙 할당도 동적 분기도 없고, 호출이 그대로 인라인될 여지가 남습니다.

자주 하는 실수

같은 일을 Box<dyn FnMut>으로 하면 할당 한 번과 가상 테이블을 거치는 호출이 더해지므로, 여러 타입을 담아야 할 때만 선택해야 합니다.

17제네릭 함수

제네릭 매개변수는 쓰이지 않고 i32로 고정된 덧셈이 19를 반환합니다. 타입이 하나로 정해진 내부 계산이라면 이대로 두는 편이 컴파일도 빠릅니다. 여러 수 타입을 받도록 제네릭으로 바꾸면 컴파일러가 쓰인 타입마다 별도 코드를 찍어 내므로, 실행 속도는 유지되지만 바이너리가 커집니다.

Rust code

// 제네릭 함수
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(17, 2));
}
알아두면 좋은 점

여러 수 타입을 받으려면 T: Add<Output = T> 같은 트레이트 경계를 적어, 그 타입에 필요한 연산만 요구하도록 만들면 됩니다.

자주 하는 실수

제네릭 함수는 쓰인 타입마다 별도 코드가 생성되므로, 실행 속도는 유지되지만 바이너리 크기와 컴파일 시간이 함께 늘어납니다.

18콜백으로 완료 알림

콜백을 넘겨받는 부분은 없고, 반환된 클로저를 직접 불러 1을 출력합니다. 완료 시점에 값을 하나 꺼내 쓰는 구조를 흉내 내기에는 이 정도로 충분합니다. 이 클로저는 i32만 품고 있어 다른 스레드로 보낼 수는 있지만, 여러 스레드가 동시에 호출하려면 Mutex 같은 동기화가 따로 필요합니다.

Rust code

// 콜백으로 완료 알림
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

이 클로저는 i32만 품고 있어 다른 스레드로 보낼 수 있고, 캡처한 값이 Send인지가 그 판단 기준이 됩니다.

자주 하는 실수

여러 스레드가 같은 클로저를 동시에 호출하려 하면 FnMut이 가변 접근을 요구해 막히므로, Mutex로 감싸 한 번에 하나만 들어가게 해야 합니다.

19reducer 함수

리듀서 대신 두 값을 더하는 평범한 함수가 21을 계산합니다. 누적 규칙 자체를 이름 있는 함수로 떼어 두면 재사용과 테스트가 쉬워집니다. 실제로 이 add는 fold의 인자로 그대로 넘길 수 있는데, 함수 항목이 Fn(i32, i32) -> i32를 만족하기 때문입니다.

Rust code

// reducer 함수
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(19, 2));
}
알아두면 좋은 점

이 add는 Fn(i32, i32) -> i32를 만족하므로 fold의 인자로 그대로 넘길 수 있어, 누적 규칙을 따로 감싸지 않아도 됩니다.

자주 하는 실수

누적 규칙을 호출부마다 클로저로 인라인해 두면 같은 로직이 여러 곳에 복사되어, 규칙이 바뀔 때 고쳐야 할 자리를 빠뜨리기 쉽습니다.

20파이프라인 함수

파이프라인 구성 요소는 없고, 카운터 클로저를 만들어 한 번 호출한 값 1이 출력됩니다. 단계마다 번호를 매기는 처리에서 이런 발급기를 앞단에 두곤 합니다. 반환 타입 자리에 클로저의 진짜 타입을 적을 방법이 없어서 impl 표기가 등장하며, 호출자는 그 타입의 정체를 알지 못한 채 트레이트로만 다룹니다.

Rust code

// 파이프라인 함수
fn make_counter() -> impl FnMut() -> i32 { let mut current = 0; move || { current += 1; current } }
fn main() {
    let mut next = make_counter();
    println!("{}", next());
}
알아두면 좋은 점

반환 타입에 클로저의 진짜 이름을 적을 방법이 없어 impl 표기가 필요하며, 호출자는 그 타입의 정체를 모른 채 트레이트로만 다룹니다.

자주 하는 실수

impl Trait으로 감춘 값은 명시한 트레이트의 능력만 밖으로 드러나므로, 반환값을 Clone하거나 다른 트레이트로 쓰려 하면 그 자리에서 막힙니다.

정리하며

  • 기본값 인자는 Option 매개변수나 별도 생성 함수로 대신합니다
  • 클로저는 Fn / FnMut / FnOnce 중 어디에 해당하는지 먼저 따져 봅니다
  • 클로저를 반환할 때는 move와 impl Fn 또는 Box<dyn Fn>을 함께 씁니다
  • 재귀 메모이제이션은 클로저 대신 캐시를 인자로 받는 fn으로 분리합니다

더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.