C#·C++ 모던

C# 12 람다 기본값으로 게임 이벤트 필터를 더 작게 설계하기

kr-gamedev 2026. 7. 7. 09:09

게임 코드에서 이벤트 필터는 생각보다 자주 커진다. 퀘스트 조건, 전투 로그, 업적, 튜토리얼 트리거, 서버 검증 로직까지 거의 모든 곳에서 “이 이벤트가 내가 원하는 조건인가?”를 반복해서 묻는다. 문제는 조건이 늘어날수록 메서드 오버로드와 작은 헬퍼 함수가 우후죽순 생긴다는 점이다.

C# 12의 람다 기본 매개변수는 이런 코드를 조금 더 작고 읽기 쉽게 만든다. 기존에는 람다 안에서 기본값을 직접 처리하거나 별도 메서드를 만들어야 했다. 이제는 일반 메서드처럼 람다 매개변수에도 기본값을 둘 수 있다.

특히 게임 서버나 툴 코드처럼 최신 .NET 런타임을 쓰는 영역에서 유용하다. Unity 본 프로젝트가 아직 해당 문법을 바로 쓰지 못하더라도, 밸런스 검증기, 로그 분석기, 시뮬레이션 툴, 서버 공용 라이브러리에서는 충분히 실전 가치가 있다.

1. 이벤트 조건을 함수로 넘기기

먼저 단순한 전투 이벤트를 보자.

public readonly record struct DamageEvent(
    int AttackerId,
    int TargetId,
    int Amount,
    string DamageType,
    bool IsCritical);

이벤트 목록에서 특정 조건만 뽑고 싶다면 보통 Func<DamageEvent, bool>을 넘긴다.

static IEnumerable<DamageEvent> FindEvents(
    IEnumerable<DamageEvent> events,
    Func<DamageEvent, bool> predicate)
{
    foreach (var e in events)
    {
        if (predicate(e))
            yield return e;
    }
}

여기까지는 평범하다. 문제는 조건이 조금씩 달라질 때다. 최소 피해량, 치명타 여부, 속성 타입 같은 조건을 계속 조합하다 보면 작은 메서드가 늘어난다.

2. C# 12 람다 기본값으로 조건 압축하기

C# 12에서는 람다 매개변수에도 기본값을 줄 수 있다.

var isHeavyHit = (
    DamageEvent e,
    int minAmount = 100,
    bool criticalOnly = false) =>
{
    if (e.Amount < minAmount)
        return false;

    if (criticalOnly && !e.IsCritical)
        return false;

    return true;
};

이제 같은 조건식을 여러 강도로 재사용할 수 있다.

var normalHeavyHits = battleLog.Where(e => isHeavyHit(e));
var bossBurstHits = battleLog.Where(e => isHeavyHit(e, 250, true));

핵심은 “조건의 기본 정책”이 람다 선언부에 드러난다는 점이다. 100이라는 기준값과 criticalOnly = false라는 의도가 호출부 바깥으로 흩어지지 않는다.

3. 퀘스트 조건에도 잘 맞는다

퀘스트 시스템에서는 “고블린 10마리 처치”, “화염 속성으로 3회 처치”, “보스에게 치명타 1회” 같은 조건이 반복된다. 이때 조건 생성 함수를 작게 만들 수 있다.

public readonly record struct KillEvent(
    int PlayerId,
    string MonsterKey,
    string WeaponElement,
    bool IsBoss,
    bool IsCritical);

var killQuest =
    (string monsterKey, int requiredCount = 1) =>
    {
        var count = 0;

        return e =>
        {
            if (e.MonsterKey != monsterKey)
                return false;

            count++;
            return count >= requiredCount;
        };
    };

var slimeQuest = killQuest("slime", 10);
var bossQuest = killQuest("ancient_dragon");

여기서 한 가지 주의할 점이 있다. 기본값을 가진 람다는 Func<>Action<> 같은 표준 대리자에 바로 대입할 수 없다. 표준 대리자에는 선택적 매개변수가 없어서 변환이 실패하기 때문이다. 그래서 컴파일러가 만들어 주는 자연 대리자 타입을 쓰도록 var로 받거나, 선택적 매개변수를 가진 전용 delegate를 직접 선언해야 한다. 대신 var로 받을 때는 매개변수 타입을 추론할 수 없으므로 string monsterKey, int requiredCount = 1처럼 타입을 명시해야 한다.

기본값 덕분에 “1회만 만족하면 되는 조건”은 호출부가 짧아진다. 반대로 반복 횟수가 중요한 퀘스트만 숫자를 넘기면 된다.

다만 위 예제처럼 람다가 내부 상태를 캡처하면, 객체 생명주기를 조심해야 한다. 플레이어별 퀘스트 인스턴스를 따로 만들지 않고 전역으로 공유하면 카운트가 섞인다. 문법이 간결해져도 상태 소유권은 여전히 설계의 문제다.

4. SOLID 관점에서 보기

이 문법의 장점은 단순히 짧아지는 것이 아니다. 조건을 Func<T, bool> 형태로 넘기면 사용하는 쪽은 구체 클래스를 몰라도 된다. 이는 의존성 역전 원칙에 잘 맞는다.

public sealed class EventCounter<TEvent>
{
    private readonly Func<TEvent, bool> _filter;

    public EventCounter(Func<TEvent, bool> filter)
    {
        _filter = filter;
    }

    public int Count(IEnumerable<TEvent> events)
    {
        var count = 0;

        foreach (var e in events)
        {
            if (_filter(e))
                count++;
        }

        return count;
    }
}

EventCounter는 피해 이벤트인지, 킬 이벤트인지, 업적 이벤트인지 모른다. 단지 필터 함수만 호출한다. 새 조건이 생겨도 EventCounter는 수정하지 않는다. 개방 폐쇄 원칙에도 잘 맞는다.

람다 기본값은 이 구조에서 필터 생성 코드를 더 읽기 쉽게 만드는 역할을 한다. 거대한 상속 구조를 만들지 않고도 작은 정책 객체처럼 쓸 수 있다.

5. 남용하면 안 되는 지점

람다 기본값은 간단한 정책에 적합하다. 조건이 길어지고, 로그가 필요하고, 디버그 표시가 붙고, 에디터에서 노출되어야 한다면 별도 클래스로 빼는 편이 낫다.

public interface IGameEventFilter<in TEvent>
{
    bool Match(TEvent e);
}

팀 단위 개발에서는 명시적인 타입이 검색과 테스트에 더 유리할 때가 많다. 람다는 작고 지역적인 정책에 쓰고, 오래 살아남는 게임 규칙은 이름 있는 타입으로 승격시키는 기준을 두면 된다.

결론

C# 12 람다 기본 매개변수는 게임 이벤트 필터, 퀘스트 조건, 로그 분석기처럼 작은 정책 함수가 많이 나오는 코드에 잘 맞는다. 기본 정책을 선언부에 드러낼 수 있고, 호출부는 필요한 차이만 넘기면 된다.

다만 이 문법은 설계를 대체하지 않는다. 상태를 캡처하는 람다는 생명주기를 조심해야 하고, 복잡한 규칙은 인터페이스나 클래스로 분리하는 편이 낫다. 작게 끝나는 조건은 람다로, 오래 유지될 규칙은 타입으로 남기는 균형이 실전에서 가장 안전하다.