PHpullh

C# · 심층 가이드

C# 함수 완전 정리

메서드 선언과 기본값, 가변 인자에서 시작해 람다와 클로저, 메모이제이션, 커링, 파이프라인까지 C#에서 함수를 값으로 다루는 스무 가지 방법을 모았습니다.

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

C#에서 함수라는 말은 최소 네 가지를 가리킵니다. 타입에 속한 메서드, 메서드 안에 선언하는 로컬 함수, 델리게이트 인스턴스, 그리고 람다 식입니다. 겉보기에는 다 같은 호출이지만 Func나 Action 같은 델리게이트로 넘기는 순간 값이 되어 변수에 담기고 다른 함수의 인자가 됩니다. 함수를 일급 값으로 쓰는 코드가 낯설다면, 이 전환 지점을 먼저 눈에 익히는 편이 빠릅니다. 이름 있는 메서드와 이름 없는 람다가 결국 같은 델리게이트 타입에 들어간다는 것만 확인해도 뒷부분이 훨씬 수월해집니다.

기본 함수 선언과 두 값을 더하는 함수로 형태를 잡은 뒤 람다 함수로 넘어가면 같은 로직이 값으로 바뀝니다. 그 상태에서 고차 함수 map과 정렬용 비교 함수를 보면 함수를 인자로 받는 쪽이 어떻게 생겼는지 잡힙니다. 클로저 카운터부터는 함수가 바깥 변수를 붙잡는 이야기로 넘어가고, 메모이제이션과 함수 팩토리는 그 캡처를 캐시와 설정 주입에 각각 활용한 사례입니다. 마지막의 파이프라인 함수는 앞의 compose 함수를 여러 단계로 늘린 형태라 두 예제를 이어서 보면 좋습니다.

기본값 매개변수는 편해 보이지만 값이 호출하는 쪽 코드에 박혀 컴파일된다는 성질이 있습니다. 라이브러리에서 기본값만 바꾸고 다시 배포해도, 호출하는 어셈블리를 다시 컴파일하지 않으면 예전 값이 그대로 쓰입니다. 공개 API라면 기본값 대신 오버로드가 안전합니다. 클로저 카운터 쪽에서는 람다가 값이 아니라 변수 자체를 캡처한다는 점을 기억해야 하고, 특히 for 루프 변수는 반복마다 새로 만들어지지 않아 모든 람다가 같은 변수를 공유합니다.

01기본 함수 선언

두 정수를 더하는 지역 함수 Add를 식 본문으로 선언하고 Add(1, 2) 결과 3을 출력합니다. 짧은 계산을 파일 안에서만 재사용할 때 쓰는 형태입니다. 화살표 뒤 식의 값이 곧 반환값이라 return을 적지 않으며, 이 문법은 메서드와 속성에도 똑같이 적용됩니다.

C# code

// 기본 함수 선언
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(1, 2));
알아두면 좋은 점

지역 함수는 선언 위치와 무관하게 같은 블록 어디서든 호출할 수 있고, 바깥 변수를 캡처하지 않는다면 static을 붙여 클로저 할당을 아예 없앨 수 있습니다.

자주 하는 실수

식 본문 메서드에는 값을 만들어 내는 식만 올 수 있어 여러 문장을 넣을 수 없습니다. 무리하게 삼항 연산자로 접으면 오히려 읽기 어려운 한 줄이 됩니다.

02두 값을 더하는 함수

CreateCounter가 지역 변수 current를 캡처한 람다를 돌려주고, 첫 호출에서 1이 출력됩니다. 호출할 때마다 값이 늘어나는 카운터를 만들 때 이 구조를 씁니다. 전위 증가라서 증가시킨 뒤의 값이 반환되며, 후위 증가로 바꾸면 첫 결과가 0이 됩니다.

C# code

// 두 값을 더하는 함수
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

CreateCounter를 두 번 부르면 각각 자기 current를 가진 독립된 카운터가 만들어져, 한쪽을 호출해도 다른 쪽 값에는 영향이 없습니다.

자주 하는 실수

캡처된 current는 스택이 아니라 힙의 클로저 객체에 있어 함수가 반환된 뒤에도 살아 있습니다. 이 카운터를 오래 붙잡아 두면 그 객체도 회수되지 않습니다.

03기본값 매개변수

선언부에 기본값은 없고, 인자 두 개를 모두 넘겨 5를 출력하는 호출만 있습니다. 선택적 매개변수를 붙이려면 기본값이 컴파일 타임 상수여야 합니다. 이 값은 호출하는 쪽 코드에 박혀 들어가므로, 라이브러리에서 기본값만 고치고 배포하면 예전에 컴파일된 호출자는 옛 값을 계속 씁니다.

C# code

// 기본값 매개변수
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(3, 2));
알아두면 좋은 점

선택적 매개변수가 여러 개라면 호출부에서 Add(a: 1, b: 2)처럼 이름 있는 인자를 써서 원하는 것만 골라 넘길 수 있습니다.

자주 하는 실수

기본값을 가진 매개변수 뒤에는 일반 매개변수를 둘 수 없고, 오버로드와 섞이면 어느 쪽이 선택되는지 헷갈려 의도하지 않은 메서드가 불릴 수 있습니다.

04가변 인자 합계

가변 인자 대신 인자 없는 Func를 돌려주는 카운터가 들어 있고, 출력은 1입니다. 개수가 정해지지 않은 값을 받으려면 params 배열을 선언하는 쪽으로 가야 합니다. params는 호출할 때마다 새 배열을 할당하므로, 자주 불리는 경로에서는 그 비용을 감안해야 합니다.

C# code

// 가변 인자 합계
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

params 매개변수에는 배열을 그대로 넘길 수도 있어, 이미 배열을 갖고 있다면 원소를 풀어 적지 않고 통째로 전달하는 편이 낫습니다.

자주 하는 실수

인자 없이 부르면 매개변수는 null이 아니라 빈 배열이므로 null 검사만으로는 아무것도 걸러지지 않습니다. 개수가 0인 경우를 따로 처리해야 합니다.

05객체 또는 구조체 반환

반환되는 것은 객체가 아니라 정수 하나이며, 화면에는 7이 찍힙니다. 여러 값을 한꺼번에 돌려주고 싶다면 값 튜플이나 record가 흔한 선택입니다. 구조체를 반환하면 값이 복사되므로, 필드가 많은 구조체를 자주 주고받을 때는 복사 비용이 눈에 띄게 커집니다.

C# code

// 객체 또는 구조체 반환
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(5, 2));
알아두면 좋은 점

이름 있는 튜플로 (int Sum, int Count)처럼 돌려주면 호출부에서 result.Sum으로 읽을 수 있어 별도 타입을 선언하지 않아도 됩니다.

자주 하는 실수

구조체를 반환하거나 인자로 넘길 때마다 값 전체가 복사되므로, 큰 구조체를 반복문 안에서 주고받으면 클래스보다 오히려 느려집니다.

06람다 함수

람다 () => ++current가 CreateCounter 안의 변수를 붙잡은 채 밖으로 나오고, 호출 결과 1이 출력됩니다. 짧은 동작을 값처럼 넘길 때 쓰는 문법입니다. 바깥 변수를 캡처하는 람다는 컴파일러가 만든 객체를 매번 할당하지만, 아무것도 캡처하지 않는 람다는 캐시되어 재사용됩니다.

C# code

// 람다 함수
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

람다에 매개변수 타입을 적지 않아도 되는 것은 대입되는 델리게이트 타입에서 추론하기 때문이라, 타입이 정해지지 않은 자리에서는 직접 명시해야 합니다.

자주 하는 실수

람다를 이벤트에 +=로 등록하면 같은 참조를 다시 만들 수 없어 -=로 해제할 수 없습니다. 해제할 생각이라면 변수에 담아 두고 그 변수를 넘겨야 합니다.

07클로저 카운터

카운터가 아니라 상태 없는 덧셈 함수가 들어 있고, 결과는 9입니다. Add는 바깥 변수를 하나도 캡처하지 않으므로 static 지역 함수로 선언할 수 있습니다. 그렇게 하면 실수로 캡처하는 코드를 컴파일러가 막아 주고 클로저 객체 할당도 생기지 않습니다.

C# code

// 클로저 카운터
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(7, 2));
알아두면 좋은 점

static 지역 함수는 바깥 지역 변수와 this에 접근하지 못하므로, 의도치 않은 상태 의존을 컴파일 단계에서 막아 줍니다.

자주 하는 실수

지역 함수가 바깥 변수를 하나라도 캡처하면 그 변수가 클로저 객체로 옮겨져, 캡처하지 않는 다른 경로의 코드까지 함께 영향을 받습니다.

08재귀 팩토리얼

재귀는 없고 클로저 카운터를 만들어 1을 출력합니다. 팩토리얼처럼 자기 자신을 부르는 코드는 지역 함수로 쓰면 그대로 동작합니다. 반면 Func 변수에 담은 람다는 선언과 동시에 자기 이름을 참조할 수 없어, 변수를 먼저 선언하고 나중에 대입하는 두 단계를 거쳐야 합니다.

C# code

// 재귀 팩토리얼
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

C#에는 꼬리 재귀 최적화가 보장되지 않으므로, 깊이가 입력 크기에 비례하는 재귀는 반복문이나 명시적 스택으로 바꾸는 편이 안전합니다.

자주 하는 실수

재귀에서 종료 조건을 빠뜨리면 StackOverflowException이 나는데, 이 예외는 try/catch로 잡히지 않고 프로세스가 그대로 죽습니다.

09고차 함수 map

고차 함수는 등장하지 않고 Add(9, 2)의 결과 11만 출력합니다. 이 Add는 Func 형태에 맞으므로 Select 같은 메서드에 이름만 적어 넘길 수 있습니다. 다만 메서드 그룹을 델리게이트로 바꾸는 순간 델리게이트 객체가 만들어진다는 점은 기억해 둘 만합니다.

C# code

// 고차 함수 map
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(9, 2));
알아두면 좋은 점

인자가 두 개인 메서드는 Func<int, int, int>에 이름만 적어 넘길 수 있으며, 이때 매개변수 순서와 타입이 정확히 맞아야 변환이 성립합니다.

자주 하는 실수

반복문 안에서 바깥 변수를 캡처하는 람다를 매번 새로 적으면 그때마다 델리게이트 객체가 만들어집니다. 캡처가 없는 람다는 캐시되므로 캡처 여부가 할당을 좌우합니다.

10조건 함수 predicate

조건 판정 대신 호출할 때마다 1씩 커지는 카운터를 돌려주는 코드입니다. 판정 함수는 보통 Func나 Predicate로 표현합니다. 두 타입은 매개변수와 반환 타입이 같아도 서로 다른 델리게이트라 직접 대입되지 않고, 람다를 다시 감싸야 변환됩니다.

C# code

// 조건 함수 predicate
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

Predicate<T>는 List<T>.Find나 RemoveAll이 요구하고 LINQ는 Func<T, bool>을 받으므로, 같은 판정을 양쪽에 쓰려면 각각 변환해야 합니다.

자주 하는 실수

판정 함수 안에서 값을 바꾸거나 로그를 남기면 Where가 몇 번 열거되느냐에 따라 그 동작 횟수가 달라져, 개수 세기와 목록 만들기의 결과가 어긋납니다.

11정렬용 비교 함수

정렬용 비교자는 없고 두 수를 더해 13을 출력합니다. 비교 함수는 음수, 0, 양수 중 하나를 돌려주기만 하면 되는데, 그래서 두 값을 빼는 구현이 자주 쓰입니다. 값의 범위가 넓으면 뺄셈이 오버플로해 부호가 뒤집히므로 CompareTo를 쓰는 편이 안전합니다.

C# code

// 정렬용 비교 함수
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(11, 2));
알아두면 좋은 점

Comparer<T>.Create에 람다를 넘기면 클래스를 만들지 않고도 비교자를 얻을 수 있어, Array.Sort나 SortedSet에 바로 끼워 넣을 수 있습니다.

자주 하는 실수

비교 함수의 일관성이 깨지면 Array.Sort가 InvalidOperationException을 던지거나, 예외 없이 순서만 뒤죽박죽이 된 결과를 돌려줍니다.

12문자열 파서

문자열을 다루는 부분은 없고 정수 카운터만 만들어 1을 출력합니다. 입력을 숫자로 바꾸는 자리에는 int.Parse 대신 int.TryParse가 어울립니다. 형식이 어긋난 값이 들어와도 예외를 만들지 않고 false로 알려 주므로, 사용자 입력 처리에서 비용과 흐름이 모두 단순해집니다.

C# code

// 문자열 파서
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

int.TryParse는 실패해도 예외를 만들지 않아, 잘못된 값이 섞인 대량 데이터를 훑을 때 int.Parse와 처리 속도 차이가 크게 벌어집니다.

자주 하는 실수

TryParse는 문화권에 따라 소수점과 천 단위 구분자 해석이 달라, 서버 로캘이 바뀌면 어제까지 잘 읽히던 문자열이 갑자기 실패할 수 있습니다.

13함수 팩토리

팩토리 대신 고정된 덧셈 결과 15를 출력하는 코드입니다. 함수를 만들어 돌려주려면 반환 타입을 델리게이트로 잡아야 합니다. 지역 함수 자체는 값이 아니라서 그대로 반환하지 못하고, Func에 대입하는 순간 델리게이트로 변환된다는 점이 람다와의 차이입니다.

C# code

// 함수 팩토리
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(13, 2));
알아두면 좋은 점

지역 함수는 델리게이트로 변환되는 순간 델리게이트 객체가 만들어지므로, 밖으로 내보내지 않고 그 자리에서만 부를 것이라면 변환 없이 직접 호출하는 편이 가볍습니다.

자주 하는 실수

팩토리가 만든 함수가 팩토리의 매개변수를 캡처하고 있다면, 그 함수를 오래 보관할 때 인자로 받은 큰 객체까지 함께 메모리에 남습니다.

14메모이제이션

결과를 저장해 두는 캐시는 없고 매 호출마다 계산하는 Add가 17을 출력합니다. 메모이제이션은 Dictionary를 캡처한 람다로 흔히 구현합니다. 이때 캐시는 위 코드의 current처럼 클로저에 붙어 계속 살아 있으므로, 크기를 제한하지 않으면 메모리가 계속 늘어납니다.

C# code

// 메모이제이션
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

캐시 사전을 지역 변수로 두고 람다가 캡처하게 하면 외부에서 손댈 수 없는 비공개 캐시가 되지만, 그만큼 비우거나 들여다볼 방법도 사라집니다.

자주 하는 실수

메모이제이션의 키로 부동소수나 가변 객체를 쓰면 같은 값인데도 다른 키가 되어, 캐시는 계속 늘기만 하고 적중률은 오르지 않습니다.

15커링

커링 형태가 아니라 두 인자를 한 번에 받는 Add가 그대로 호출돼 17이 나옵니다. 커링으로 바꾸면 첫 인자를 받아 둘째 인자를 받는 함수를 돌려주는 모양이 됩니다. 호출 횟수만큼 델리게이트가 생기므로, 성능이 중요한 자리에서는 그냥 두 인자를 받는 쪽이 낫습니다.

C# code

// 커링
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(15, 2));
알아두면 좋은 점

커링은 인자를 미리 고정한 함수를 넘겨야 할 때 쓸모가 있지만, C#에서는 이름 있는 인자와 기본값만으로도 대부분의 필요가 해결됩니다.

자주 하는 실수

반복문 안에서 매번 커링하면 호출 횟수만큼 안쪽 델리게이트가 새로 만들어집니다. 고정된 인자가 같다면 커링 결과를 루프 밖에서 한 번만 만들어야 합니다.

16compose 함수

합성 대신 카운터를 만들어 한 번 호출하고 1을 출력합니다. CreateCounter를 두 번 부르면 서로 독립된 클로저가 만들어져 숫자가 섞이지 않습니다. 이런 상태 있는 함수를 합성하면 호출 순서에 따라 결과가 달라지므로, 합성 대상은 상태 없는 함수로 두는 편이 예측하기 쉽습니다.

C# code

// compose 함수
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

합성 함수를 만들 때는 어느 쪽을 먼저 적용하는지 이름이나 인자 순서로 못 박아 두어야, 사용하는 쪽에서 순서를 반대로 이해하는 일이 생기지 않습니다.

자주 하는 실수

상태를 가진 함수를 합성하면 같은 합성 함수를 두 번 불렀을 때 결과가 달라집니다. 테스트가 실행 순서에 따라 통과와 실패를 오가는 대표적인 원인입니다.

17제네릭 함수

제네릭 선언은 없고 int 전용 덧셈이 19를 출력합니다. 타입 매개변수로 일반화하려면 걸림돌이 하나 있는데, 제약이 없는 T에는 + 연산자를 쓸 수 없다는 점입니다. .NET 7의 제네릭 수학 인터페이스를 제약으로 걸면 그때부터 연산자 사용이 허용됩니다.

C# code

// 제네릭 함수
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(17, 2));
알아두면 좋은 점

제네릭 메서드는 인자 타입에서 타입 매개변수를 추론하므로 꺾쇠를 적지 않아도 되지만, 반환 타입만으로는 추론되지 않는다는 제약이 있습니다.

자주 하는 실수

제약 없는 T에 ==를 쓰면 참조 비교로만 동작해 값이 같은 두 구조체를 다르다고 판정합니다. 동등성 판정에는 EqualityComparer<T>.Default를 써야 합니다.

18콜백으로 완료 알림

콜백을 넘기는 코드는 없지만, 여기서 만든 Func가 곧 콜백으로 넘길 수 있는 값입니다. 완료 알림에는 반환값 없는 Action이 더 자주 쓰입니다. 델리게이트는 +=로 여러 개를 묶을 수 있는데, 그렇게 묶은 상태에서 호출하면 마지막 하나의 반환값만 돌아옵니다.

C# code

// 콜백으로 완료 알림
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

완료를 알리는 콜백이 어느 스레드에서 불릴지는 정해져 있지 않습니다. UI를 갱신하는 콜백이라면 디스패처를 거쳐 UI 스레드로 넘겨야 합니다.

자주 하는 실수

등록되지 않은 콜백을 그대로 호출하면 NullReferenceException이 나므로, 선택적 콜백은 callback?.Invoke(...)로 부르거나 빈 동작을 기본값으로 넣어 두어야 합니다.

19reducer 함수

누적 함수는 아니지만 두 인자를 받아 하나를 돌려주는 Add의 모양은 리듀서와 같아서, Aggregate에 그대로 넘길 수 있습니다. 지금 코드는 21만 출력합니다. 리듀서를 넘길 때 첫 인자가 누적값, 둘째가 원소라는 순서를 뒤집으면 뺄셈 같은 연산에서 결과가 달라집니다.

C# code

// reducer 함수
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(19, 2));
알아두면 좋은 점

Aggregate에 넘긴 함수는 원소마다 한 번씩 불리므로, 원소가 많다면 그 안에서 새 객체를 만드는 코드가 곧바로 할당 부담으로 이어집니다.

자주 하는 실수

같은 시드를 여러 계산에 재사용할 때 그 시드가 가변 객체라면, 앞선 계산의 결과가 다음 계산에 그대로 섞여 들어갑니다.

20파이프라인 함수

파이프라인은 없고 클로저 카운터 하나가 1을 출력합니다. 여러 단계를 이어 붙이는 구조에서 이런 상태 있는 함수를 끼워 넣을 때는 주의가 필요합니다. ++는 원자적 연산이 아니어서 여러 스레드가 같은 카운터를 부르면 값이 누락되고, Interlocked.Increment가 필요합니다.

C# code

// 파이프라인 함수
using System;

Func<int> CreateCounter() { var current = 0; return () => ++current; }
var next = CreateCounter();
Console.WriteLine(next());
알아두면 좋은 점

여러 스레드가 공유하는 카운터는 Interlocked.Increment로 올려야 하며, 이 메서드는 증가 후의 값을 돌려주므로 반환값을 그대로 쓸 수 있습니다.

자주 하는 실수

volatile을 붙이면 안전해진다고 오해하기 쉬운데, 읽기와 쓰기의 가시성만 보장할 뿐 증가 연산 자체는 여전히 원자적이지 않습니다.

정리하며

  • 람다를 Func나 Action에 담는 순간 함수가 전달 가능한 값이 됩니다
  • 공개 API의 기본값 매개변수는 호출부에 박히므로 오버로드로 두는 편이 안전합니다
  • 클로저는 값이 아니라 변수를 캡처하며 for 루프 변수는 반복 간에 공유됩니다
  • 메모이제이션은 캡처한 딕셔너리 하나로 만들 수 있지만 키 설계와 수명은 따로 챙깁니다

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