캐싱(Caching)이란?
자주 사용하는 데이터나 연산 결과를 한 번만 계산하고 저장해두었다가 다음에 또 필요할 때 다시 계산하지 않고 빠르게 꺼내 쓰는 기법이다.
간단한 비유
캐싱 안하는 경우 : 매번 요리할 때마다 레시피책을 찾아서 펼쳐보기
캐싱 하는 경우 : 자주 만드는 요리 레시피를 냉장고에 붙여두고 바로 보기
왜 필요할까?
Unity는 매 프레임(초당 60~144번 이상) 빠르게 많은 계산을 해야 하는 실시간 게임 엔진이다. 아래와 같은 예시의 작업들을 매번 수행하면 성능 병목이 발생할 수 있다.
1) GetComponent<T>()로 컴포넌트 찾기
모든 컴포넌트를 순회하며 검색
void Update()
{
GetComponent<Animator>().Play("Run");
}
2) Vector3.Distance()로 매 프레임 거리 계산
두 점 사이 거리 = √((x2-x1)² + (y2-y1)² + (z2-z1)²) 제곱근 계산은 CPU에게 부담스러운 연산이다. Distance는 벡터 차이 계산 및 제곱근 연산을 수행한다.
void Update()
{
float distance = Vector3.Distance(enemy.position, player.position);
// 내부적으로 제곱근 계산이 실행되는데 이게 수학적으로 무거운 연산
}
3) 매번 new 키워드로 객체 생성
void Update()
{
// 매 프레임마다 새 객체 생성
Vector3 newPosition = new Vector3(x, y, z);
}
4) 복잡한 UI계산 : 매 프레임 레이아웃 재계산
void Update()
{
// UI 위치를 매번 다시 계산
Canvas.ForceUpdateCanvases(); // UI 전체 재계산
}
이런 작업들을 한 번만 수행하고 결과를 저장(=캐싱)하면 게임 성능(FPS) 이 훨씬 좋아질 수 있다.
1. GetComponent<T>()
01 문제 코드 예시
GetComponent는 내부적으로 GameObject의 모든 컴포넌트를 검색해서 찾기 때문에 매번 부하가 크다. 그러므로 AI가 많은 게임에서 캐싱되지 않으면 부하가 매우매우 커지게 된다.
void Update()
{
GetComponent<Animator>().SetBool("IsRunning", true);
}
02 캐싱 예시
GetComponent<T>()를 반복하지 않고 자동으로 캐싱되도록 하기
public class PlayerController : MonoBehaviour
{
private Animator _animator;
private Rigidbody _rigidbody;
public float speed = 5f;
void Awake()
{
_animator = this.Get<Animator>();
_rigidbody = this.Get<Rigidbody>();
}
void Update()
{
_animator.SetBool("IsRunning", true);
_rigidbody.velocity = Vector3.forward * speed;
}
}
2. Distance 계산 캐싱 (성능 최적화)
01 문제 코드 예시
Vector3.Distance()는 내부적으로 제곱근 계산을 수행하므로 다수의 AI가 매 프레임 호출하면 병목 현상이 발생시킨다.
public class EnemyAI : MonoBehaviour
{
public Transform player;
public float attackRange = 5f;
void Update()
{
// 매 프레임마다 제곱근 계산 - CPU 부담
float distance = Vector3.Distance(transform.position, player.position);
if (distance < attackRange)
{
Attack();
}
}
}
02 캐싱 예시
일정 시간마다만 계산해서 저장해두기
public class DistanceCache
{
private float _cacheInterval = 0.1f; // 0.1초마다 갱신
private float _lastUpdateTime = 0f;
private float _cachedDistance = 0f;
private Transform _targetA, _targetB;
public DistanceCache(float cacheInterval = 0.1f)
{
_cacheInterval = cacheInterval;
}
// 일정 거리를 일정 주기마다 갱신해서 반환
public float GetDistance(Transform a, Transform b)
{
// 대상이 바뀌었거나 갱신 시간이 지났으면 재계산
if (_targetA != a || _targetB != b || Time.time - _lastUpdateTime >= _cacheInterval)
{
_targetA = a;
_targetB = b;
_cachedDistance = Vector3.Distance(a.position, b.position); // 제곱근 포함
_lastUpdateTime = Time.time;
}
return _cachedDistance;
}
// 거리 비교만 할거면 제곱 거리로 비교하는게 더 빠름
public float GetSqrDistance(Transform a, Transform b)
{
if (_targetA != a || _targetB != b || Time.time - _lastUpdateTime >= _cacheInterval)
{
_targetA = a;
_targetB = b;
_cachedDistance = (a.position - b.position).sqrMagnitude; // 제곱근 생략
_lastUpdateTime = Time.time;
}
return _cachedDistance;
}
}
03 사용법
일정 시간마다 계산하여 성능 부담을 줄일 수 있다.
public class EnemyAI : MonoBehaviour
{
public Transform player;
public float attackRange = 5f;
// 거리 캐시 인스턴스 (0.2초마다 갱신)
private DistanceCache _distanceCache = new DistanceCache(0.2f);
void Update()
{
// 제곱 거리로 비교 (제곱근 계산 생략 = 더 빠름)
float sqrDistance = _distanceCache.GetSqrDistance(transform, player);
float sqrAttackRange = attackRange * attackRange; // 미리 제곱 계산
if (sqrDistance < sqrAttackRange)
{
Attack();
}
}
void Attack()
{
Debug.Log("공격!");
}
}
3. 상태(State)자동 캐싱
상태 기반 AI(FSM)를 구성할 때, 상태 객체들을 매번 new로 만들지 말고 한 번만 만들어서 재사용하면 훨씬 효율적이다.
01 문제 코드 예시
상태 전환마다 new를 사용하게 된다면 new는 GC 대상이다. 그러므로 상태가 자주 바뀌면 메모리 할당/해제가 반복되므로 메모리 누수가 많을 수 있다.
// 상태 전환마다 새 객체 생성
public void ChangeState(StateType newStateType)
{
switch(newStateType)
{
case StateType.Idle:
currentState = new IdleState();
break;
case StateType.Attack:
currentState = new AttackState();
break;
}
}
02 캐싱 예시
public interface IState<T>
{
void Enter(T context);
void Update(T context);
void Exit(T context);
}
public class StateFactory<T>
{
private Dictionary<System.Type, IState<T>> _stateCache = new();
// StateType은 IState<T>를 구현하고 매개변수 없는 생성자가 있어야 한다.
public StateType GetState<StateType>() where StateType : class, IState<T>, new()
{
var stateType = typeof(StateType);
// 캐시에서 찾기
if (_stateCache.TryGetValue(stateType, out var cachedState))
{
return cachedState as StateType;
}
// 없으면 생성 후 캐싱
var newState = new StateType();
_stateCache[stateType] = newState;
return newState;
}
// 캐시 정리
public void ClearCache()
{
_stateCache.Clear();
}
}
03 사용법
public class EnemyStateMachine : MonoBehaviour
{
private StateFactory<EnemyStateMachine> _stateFactory = new();
private IState<EnemyStateMachine> _currentState;
void Start()
{
// 초기 상태 설정
ChangeState<IdleState>();
}
public void ChangeState<T>() where T : class, IState<EnemyStateMachine>, new()
{
_currentState?.Exit(this);
_currentState = _stateFactory.GetState<T>();
_currentState.Enter(this);
}
void Update()
{
_currentState?.Update(this);
}
}
// 상태 클래스 예시
public class IdleState : IState<EnemyStateMachine>
{
public void Enter(EnemyStateMachine context) { }
public void Update(EnemyStateMachine context) { }
public void Exit(EnemyStateMachine context) { }
}
4. StateType이 무엇인가요?
제네릭 타입 매개변수(Generic Type Parameter)이다.
public StateType Get<StateType>() where StateType : IState<T>, new()
01 제네릭이란?
클래스나 메서드가 다양한 타입에서 재사용 가능하도록 하는 문법이다.
public class Box<T>
{
public T Value;
}
// 예시
Box<int> intBox = new Box<int>(); // T = int
Box<string> stringBox = new Box<string>(); // T = string
02 타입 매개변수란?
메서드나 클래스 정의 시에 타입을 직접 명시하지 않고 타입을 나중에 전달받을 수 있도록 만든 변수 같은 존재이다.
public StateType GetState<StateType>() where StateType : IState<T>, new()
// ↑
// 이게 타입 매개변수
StateType은 메서드를 호출할 때 어떤 타입으로 바뀔지 미정이다. 호출 시점에 실제 타입으로 바뀌어 컴파일 된다.
// 호출 시점에 StateType이 IdleState로 결정됨
var idleState = _stateFactory.GetState<IdleState>();
// 호출 시점에 StateType이 AttackState로 결정됨
var attackState = _stateFactory.GetState<AttackState>();
03 제약 조건이 붙은 이유는?
잘못된 타입을 넣는 실수도 막고, 내부에서 안전하게 new로 생성 가능하게 보장한다.
// 이 타입은 반드시 IState<T>를 구현해야하고 기본 생성자기 있어야한다.
where StateType : IState<T>, new()
5. 캐싱 시 주의사항
01 Destroy된 객체 접근
Unity에서 Destory(gameObject)를 호출하면 해당 GameObject에 속한 모든 컴포넌트가 논리적으로 파괴된 상태가 된다. 하지만 캐시된 참조 변수에는 여전히 이전 컴포넌트의 주소가 남아있을 수도 있다.
public class Enemy : MonoBehaviour
{
void Start()
{
// 컴포넌트를 캐싱
var animator = this.Get<Animator>();
}
void SomeMethod()
{
// 나중에 이 적이 파괴되었는데도
Destroy(gameObject);
// 캐시에는 여전히 파괴된 객체 참조가 남아있음
// 다른 곳에서 캐시를 사용하면 null 참조 에러발생할 수 있다.
}
}
해결 방법
이를 해결하기 위해 캐시 접근시 매번 유효성 검사가 필요하다.
public static class SafeComponentCache
{
public static T SafeGet<T>(this Component component) where T : Component
{
// 1차 안전 장치: 호출 컴포넌트가 유효한지 확인
if (component == null)
{
Debug.LogWarning("SafeGet: 호출 컴포넌트가 null입니다.");
return null;
}
var cached = component.Get<T>();
// 2차 안전 장치: 캐시된 컴포넌트가 유효한지 확인
if (cached == null)
{
Debug.LogWarning($"SafeGet: {typeof(T).Name} 컴포넌트를 찾을 수 없습니다.");
return null;
}
return cached;
}
}
02 메모리 누수 방지
캐시를 정적변수로 관리하는 경우 씬이 전환되어도 해당 캐시는 메모리에서 제거되지 않는다. 예를 들어 씬 A에서 GameObject를 캐시에 등록한 후 씬 B로 넘어가면 씬 A에 있던 오브젝트는 이미 파괴되었지만 캐시에는 여전히 해당 참조가 남아있게 된다. 이렇게 되면 이미 존재하지 않는 오브젝트를 계속 참조하게 되어 메모리 누수가 발생한다.
// Static 캐시는 씬이 바뀌어도 계속 남아있음
public static class PersistentCache
{
private static Dictionary<GameObject, SomeData> _cache = new();
public static void AddToCache(GameObject obj, SomeData data)
{
_cache[obj] = data; // 씬 전환 후에도 GameObject 참조가 남음
}
}
해결 방법
Static 캐시를 사용할 경우 씬 전환 시점이나 오브젝트 파괴 시점에 명시적으로 캐시를 정리해주는 로직이 필요하다.
static void ClearStaticCaches()
{
// 씬 전환 전에 static 캐시 초기화
MyComponentCache.ClearAll();
}
03 GC 최소화
캐싱을 하더라도 그 과정에서 Dictionary, List, new 연산 등이 발생하면 GC(가비지 컬렉션) 의 대상이 된다. 특히 Update()나 FixedUpdate()처럼 프레임 단위로 반복 호출되는 함수 안에서 매번 객체를 생성하거나 컬렉션을 초기화하는 방식은 불필요한 메모리 할당을 하며 GC가 자주 발생해 프레임 드롭의 원인이 된다.
그러므로 가능한 초기화는 Awake()또는 Start()등 초기 구간에서 한 번만 수행되도록 하고 이후에는 캐시된 객체를 재사용 한다. 또한 Dictionary를 사용할 경우 초기 예상 크기를 지정하거나 미리 할당하는 것이 좋다.
private Dictionary<int, string> _dict = new Dictionary<int, string>(100);
void Awake()
{
this.Get<Animator>(); // 첫 진입 시 한번만 생성되게
this.Get<Rigidbody>();
}
'유니티 공부 > Unity' 카테고리의 다른 글
Unity - 커스텀 에디터(Custom Editor) +) 전처리기(Preprocessor) (1) | 2025.06.11 |
---|---|
Unity - Unity에서 수학 개념을 이해해보자 2편 - 행렬,동차 좌표계,TRS, 아크 탄젠트 (0) | 2025.06.09 |
Unity - Unity에서 수학 개념을 이해해보자 1편 - 벡터,내적,외적 (0) | 2025.05.23 |
Unity - velocity vs addForce +) Velocity 덮어쓰기 , AddForce 무시 문제 (0) | 2025.05.22 |
Unity - Lighting을 활용한 낮과 밤 시스템 구현 방법 (0) | 2025.05.19 |
댓글