Unity 최적화 관련 글을 보다 보면 이런 이야기를 자주 보게 된다.
Update나 자주 실행되는 함수에서는 LINQ 사용을 지양하라.
처음 들으면 조금 이상하게 느껴질 수 있다.
LINQ와 람다식은 C#에서 굉장히 편리한 기능이다.
코드를 짧고 읽기 쉽게 만들어주고, 복잡한 데이터 처리도 “무엇을 하고 싶은지” 중심으로 표현할 수 있게 해준다.
예를 들어, 여러 적 중에서 살아있는 적만 찾는다고 해보자.
일반적인 방식이라면 모든 적을 순회하면서 직접 조건을 검사할 수 있다.
List<Enemy> aliveEnemies = new List<Enemy>();
foreach (var enemy in enemies)
{
if (enemy.IsAlive)
{
aliveEnemies.Add(enemy);
}
}
LINQ를 사용하면 훨씬 간결하게 표현할 수 있다.
var aliveEnemies = enemies.Where(enemy => enemy.IsAlive);
코드만 보면 LINQ 쪽이 훨씬 읽기 쉽다.
“적들 중에서 살아있는 적만 가져온다”는 의도가 한눈에 들어온다.
그래서 LINQ와 람다식은 생산성과 가독성 측면에서 매우 좋은 도구다.
그런데 왜 Unity에서는 Update에서 LINQ 사용을 조심하라고 할까?
문제는 LINQ 문법 자체가 아니다
먼저 분명히 해야 할 점이 있다.
LINQ와 람다식 자체가 나쁜 것은 아니다.
사용하면 안 되는 기능도 아니다.
문제는 다음과 같은 상황이다.
void Update()
{
var result = enemies.Where(enemy => enemy.Hp > hpThreshold);
}
이 코드가 매 프레임 실행된다면, 내부적으로 매 프레임 관리형 객체가 생성될 수 있다.
이렇게 생성된 객체들은 나중에 GC의 수집 대상이 된다.
즉, Update에서 LINQ를 조심하라는 말은 정확히 말하면 다음에 가깝다.
자주 호출되는 코드에서 불필요한 GC Allocation을 만들지 않도록 조심하라.
LINQ는 내부적으로 열거자, iterator, delegate, closure 객체 등을 만들 수 있다.
특히 람다가 외부 변수를 참조하는 경우 클로저가 발생하면서 추가 할당이 생길 수 있다.
클로저란 무엇인가?
다음 코드를 보자.
int hpThreshold = 100;
var result = enemies.Where(enemy => enemy.Hp > hpThreshold);
여기서 람다식은 hpThreshold라는 외부 변수를 사용하고 있다.
enemy => enemy.Hp > hpThreshold
이때 C# 컴파일러는 hpThreshold 값을 람다 내부에서 계속 사용할 수 있도록 별도의 객체에 보관할 수 있다.
이렇게 람다가 외부 변수를 붙잡고 있는 구조를 클로저라고 한다.
왜 이런 객체가 필요할까?
hpThreshold가 지역 변수라면 원래는 함수가 끝날 때 사라지는 값이다.
하지만 람다는 그 함수가 끝난 뒤에도 실행될 가능성이 있다.
예를 들어 LINQ는 지연 실행을 사용한다.
Where()를 호출했다고 해서 그 순간 모든 순회가 바로 끝나는 것이 아니다. 실제 순회는 나중에 foreach, ToList(), FirstOrDefault() 같은 연산을 할 때 일어날 수 있다.
따라서 컴파일러는 외부 변수를 안전하게 보관하기 위해 별도의 객체를 만든다.
대략적인 구조로 보면 이런 느낌이다.
private sealed class DisplayClass
{
public int hpThreshold;
public bool Predicate(Enemy enemy)
{
return enemy.Hp > hpThreshold;
}
}
그리고 원래 코드는 내부적으로 이런 식에 가까운 형태가 될 수 있다.
var closure = new DisplayClass();
closure.hpThreshold = hpThreshold;
var result = enemies.Where(closure.Predicate);
즉, 외부 변수를 캡처하는 람다는 클로저 객체를 만들 수 있다.
이 코드가 Update에서 매 프레임 실행된다면, 매 프레임 클로저 객체가 생성될 수 있고, 그만큼 GC Allocation이 발생한다.
외부 변수를 캡처하지 않으면 괜찮을까?
그렇다면 외부 변수를 캡처하지 않는 람다는 어떨까?
예를 들어 기준값을 static 필드로 만든다고 해보자.
private static int HpThreshold = 100;
void Update()
{
var result = enemies.Where(enemy => enemy.Hp > HpThreshold);
}
이 경우 람다는 지역 변수를 캡처하지 않는다.
따라서 앞에서 본 것처럼 매번 클로저 객체를 만들 필요는 줄어들 수 있다.
컴파일러는 이런 람다를 캐싱해서 재사용할 수 있다.
대략적으로는 이런 형태가 된다.
private static Func<Enemy, bool> cachedPredicate;
void Update()
{
if (cachedPredicate == null)
{
cachedPredicate = enemy => enemy.Hp > HpThreshold;
}
var result = enemies.Where(cachedPredicate);
}
이렇게 되면 매 프레임 delegate 객체를 새로 만들지 않을 수 있다.
하지만 여기서 끝이 아니다.
클로저가 사라졌다고 해서 LINQ 할당이 완전히 사라지는 것은 아니다.
Where()를 호출하면 일반적으로 Where iterator 객체가 생성된다.
즉, 클로저는 피했지만 LINQ 자체의 iterator 할당은 여전히 남을 수 있다.
LINQ는 지연 실행된다
LINQ를 이해할 때 중요한 특징 중 하나가 지연 실행이다.
var result = enemies.Where(enemy => enemy.IsAlive);
이 코드는 호출되는 순간 즉시 모든 적을 검사해서 결과 리스트를 만드는 것이 아니다.
조건을 담은 “쿼리 객체”를 만들어두고, 실제 순회가 필요할 때 실행한다.
예를 들어 다음처럼 순회할 때 실제 검사가 일어난다.
foreach (var enemy in result)
{
// 여기서 실제로 Where 조건이 적용됨
}
또는 ToList()를 호출하면 그 순간 결과 리스트가 만들어진다.
var aliveEnemies = enemies
.Where(enemy => enemy.IsAlive)
.ToList();
여기서는 Where iterator뿐 아니라 결과를 담기 위한 새로운 List도 생성된다.
즉, Update에서 이런 코드를 매 프레임 실행하면 다음과 같은 할당이 발생할 수 있다.
void Update()
{
var aliveEnemies = enemies
.Where(enemy => enemy.IsAlive)
.ToList();
}
매 프레임 새 리스트가 만들어지고, 내부 배열도 필요에 따라 할당된다.
이런 할당이 누적되면 GC가 더 자주 발생하고, GC가 실행되는 순간 프레임 드랍이 생길 수 있다.
람다식도 같은 문제가 있다
이 문제는 LINQ에만 있는 것이 아니다.
람다식도 외부 변수를 캡처하면 클로저가 생길 수 있다.
예를 들어 이벤트 등록이나 콜백에서도 같은 일이 발생한다.
void Update()
{
int value = currentValue;
button.onClick.AddListener(() =>
{
Debug.Log(value);
});
}
이 코드는 람다가 value를 캡처한다.
따라서 클로저 객체가 만들어질 수 있다.
물론 위 코드는 Update에서 이벤트를 계속 등록한다는 점에서도 문제가 있다.
하지만 핵심은 람다식이 외부 변수를 캡처하면 LINQ가 아니어도 할당이 생길 수 있다는 점이다.
즉, “Update에서 LINQ를 쓰지 말라”는 말의 본질은 다음과 같다.
Update에서 클로저와 반복적인 관리형 할당을 만들지 말라.
foreach와 for는 항상 안전할까?
그렇다고 해서 foreach나 for가 항상 무조건 정답이라는 뜻은 아니다.
다만 Unity에서 List<T>를 직접 순회하는 단순한 for문은 매우 명확하고 예측하기 쉽다.
for (int i = 0; i < enemies.Count; i++)
{
Enemy enemy = enemies[i];
if (enemy.IsAlive)
{
// 처리
}
}
이 방식은 LINQ iterator나 임시 리스트를 만들지 않는다.
Update처럼 매 프레임 호출되는 코드에서는 이런 명시적인 코드가 더 안전하다.
결과 리스트가 필요하다면 매번 새로 만들기보다 기존 리스트를 재사용하는 방식이 좋다.
private readonly List<Enemy> aliveEnemies = new List<Enemy>();
void Update()
{
aliveEnemies.Clear();
for (int i = 0; i < enemies.Count; i++)
{
Enemy enemy = enemies[i];
if (enemy.IsAlive)
{
aliveEnemies.Add(enemy);
}
}
}
이렇게 하면 aliveEnemies 리스트 객체 자체는 재사용된다.
물론 리스트의 capacity가 부족하면 내부 배열이 늘어나면서 할당이 생길 수 있으므로, 필요하다면 미리 capacity를 잡아두는 것도 좋다.
private readonly List<Enemy> aliveEnemies = new List<Enemy>(128);
그러면 LINQ는 언제 써도 될까?
LINQ를 아예 사용하지 말라는 뜻은 아니다.
다음과 같은 곳에서는 LINQ를 사용해도 큰 문제가 되지 않는 경우가 많다.
- 초기화 코드
- 로딩 단계
- 에디터 전용 코드
- 디버깅 코드
- 자주 호출되지 않는 UI 처리
- 성능에 민감하지 않은 툴 코드
- 한 프레임에 수천 번 반복되지 않는 로직
예를 들어 게임 시작 시 데이터 테이블을 정리하거나, 에디터 툴에서 특정 데이터를 필터링하는 용도라면 LINQ의 가독성 장점이 훨씬 클 수 있다.
var bossEnemies = enemyTable
.Where(enemy => enemy.Type == EnemyType.Boss)
.OrderBy(enemy => enemy.Level)
.ToList();
이런 코드는 읽기 쉽고 유지보수하기 좋다.
반대로 다음과 같은 곳에서는 조심해야 한다.
- Update
- FixedUpdate
- LateUpdate
- 매 프레임 호출되는 AI 로직
- 매 프레임 호출되는 UI 갱신
- 다수의 오브젝트가 반복적으로 실행하는 함수
- 모바일 환경에서 자주 호출되는 전투 로직
이런 곳에서는 작은 할당도 누적되면 문제가 될 수 있다.
정리
LINQ와 람다식은 생산성과 가독성을 높여주는 좋은 기능이다.
문법 자체가 나쁜 것은 아니다.
하지만 Unity의 Update처럼 자주 호출되는 함수에서는 내부 할당을 조심해야 한다.
특히 다음 패턴은 주의가 필요하다.
void Update()
{
int hpThreshold = 100;
var result = enemies
.Where(enemy => enemy.Hp > hpThreshold)
.ToList();
}
이 코드에서는 외부 변수를 캡처하는 람다 때문에 클로저가 생길 수 있고, Where iterator와 ToList() 결과 리스트까지 생성될 수 있다.
즉, 매 프레임 GC Allocation이 발생할 수 있다.
반면 다음처럼 명시적으로 작성하면 할당을 더 쉽게 통제할 수 있다.
private readonly List<Enemy> result = new List<Enemy>(128);
void Update()
{
result.Clear();
for (int i = 0; i < enemies.Count; i++)
{
Enemy enemy = enemies[i];
if (enemy.Hp > hpThreshold)
{
result.Add(enemy);
}
}
}
결론적으로 Update에서 LINQ를 지양하라는 말은
“LINQ가 나쁘다”는 뜻이 아니다.
정확히는 다음과 같다.
자주 호출되는 코드에서는 LINQ와 람다식이 만드는 클로저, iterator, 임시 컬렉션 할당을 의식해야 한다.
LINQ는 읽기 좋은 코드를 만들기 위한 훌륭한 도구다.
하지만 Unity 런타임의 핫패스에서는 가독성보다 예측 가능한 메모리 사용이 더 중요할 때가 많다.
따라서 성능에 민감한 구간에서는 LINQ를 습관적으로 쓰기보다, 실제로 GC Allocation이 발생하는지 Profiler로 확인하고 사용하는 것이 좋다.
참고자료
https://velog.io/@luz0415/%EB%9E%8C%EB%8B%A4-%EC%8B%9D%EA%B3%BC-%ED%81%B4%EB%A1%9C%EC%A0%80
[C#] 람다 식과 클로저 (Lambda Expression & Closure)
람다 식 클로저를 알기 위해선 람다 식부터 알아야 한다. 람다 식은 C#에서 익명 함수(Anonymous Fuction)을 표현하는 하나의 방식이다. 람다 식은 기본적으로 => 연산자를 활용해 매개변수와 본문을
velog.io
C# LINQ(메모리 사용 측면에서의 단점)
LINQ란? C#에서 데이터를 쿼리하고 조작할 때 루프나 조건문 등 복잡한 논리리문을 작성해야 할 때 작업을 간소화시켜주는 LINQ(Language-Intergrated Query)라는 기능을 제공한다. LINQ는 다양한 데이터 소
velog.io
'Unity(C#)' 카테고리의 다른 글
| Unity UI OBB 충돌 검사와 Burst를 이용한 최적화 (0) | 2026.07.07 |
|---|---|
| Unity GC를 호출했는데 왜 메모리가 바로 줄어들지 않을까? (0) | 2026.06.19 |
| Unity 비어 있는 Update는 왜 지워야 할까? (0) | 2026.06.11 |
| Unity 공간분할트리 (0) | 2024.05.27 |
| Unity 오브젝트풀 (0) | 2020.05.06 |