선택 위치 캐싱 시스템
1. 시스템 요구 사항
Grid 기반 건축 시스템에서는 마우스 이동에 따라 선택 위치가 매 프레임 갱신된다.
PlacementManager의 Update 루프에서 지속적으로 'GetSelectedMapPosition → Grid 변환 → Selection 계산 → Preview 갱신' 이 수행되기 때문에, 동일한 위치에 마우스가 머물러 있음에도 불구하고 불필요한 연산이 반복될 수 있다.
이 문제는 단순한 성능 낭비를 넘어서, 프리뷰 시스템이나 선택 계산 로직이 불필요하게 계속 실행되면서 시스템 전체의 효율을 떨어뜨릴 수 있다.
특히 Grid 기반 시스템에서는 좌표 계산, 충돌 검사, SelectionStrategy 실행이 반복되기 때문에 작은 최적화가 전체 체감 성능에 영향을 줄 수 있다.
따라서 건축 시스템에서는 이전 프레임과 동일한 위치라면 업데이트를 수행하지 않는다는 최소한의 변경 감지 로직이 필요하다.
또한 이 로직은 특정 시스템에 종속되지 않고, 어떤 위치 기반 처리에서도 재사용 가능해야 한다.
LastDetectedPosition은 이러한 요구를 해결하기 위해 설계된 경량 상태 객체이며, 다음과 같은 역할을 수행한다.
마지막으로 처리된 Grid 좌표를 저장한다.
새로운 좌표가 이전 좌표와 동일한지 비교한다.
좌표가 변경된 경우에만 업데이트를 허용한다.
아직 값이 설정되지 않은 경우 예외를 통해 오류를 명확히 드러낸다.
즉, 이 클래스는 입력 기반 연산의 불필요한 반복을 차단하는 상태 캐싱 유틸리티다.
2. 흐름도
현재 마우스 위치 → Grid 좌표 변환
↓
TryUpdatingPosition 호출
↓
이전 좌표와 비교
├─ 동일 → false 반환 → 업데이트 생략
└─ 다름 → 좌표 갱신 → true 반환
↓
true일 경우만 Selection / Preview 수행
이 흐름의 핵심은 단순하다.
위치가 바뀌었을 때만 다음 로직을 실행한다.
하지만 이 단순한 조건이 Update 루프 전체의 실행 횟수를 줄이는 중요한 역할을 한다.
3. 구현
3.1. 마지막 위치를 저장하는 변수
public Vector3Int? lastPosition;이 변수는 마지막으로 감지한 Grid 좌표를 저장한다.
여기서 중요한 점은 타입이 Vector3Int가 아니라 Vector3Int?라는 것이다.
Vector3Int는 Unity에서 정수 기반 3차원 좌표를 표현하는 구조체이며, Grid 좌표처럼 셀 단위 위치를 다룰 때 적합하다.
월드 좌표처럼 소수점이 필요한 위치 표현이 아니라, 몇 번째 셀인”를 나타내는 값이므로 Vector3Int를 사용하는 것이 맞다.
그런데 이 코드에서는 여기에 ?를 붙여 Nullable 형태로 선언하였다.
C#에서 값 형식 구조체는 기본적으로 null을 가질 수 없는데, ?를 붙이면 값이 있을 수도 있고 없을 수도 있는 상태를 표현할 수 있다.
이렇게 한 이유는 아주 명확하다.
이 클래스는 아직 한 번도 위치를 기록하지 않은 상태와 이미 위치가 기록된 상태를 구분해야 하기 때문이다.
만약 단순히 Vector3Int만 썼다면 초기값은 (0, 0, 0)이 되는데, 이 값이 실제 첫 번째 셀인지, 아니면 아직 값이 없는 초기 상태인지 코드만 보고는 구분할 수 없다.
반면 Nullable을 사용하면, 값이 없으면 아직 초기화 되지 않은 상태, 값이 있으면 실제로 감지된 위치가 존재하는 상태를 명확하게 나눌 수 있다.
이 선택의 장점은 상태 표현이 매우 정확하다는 점이다.
단점은 값을 사용할 때마다 HasValue나 Value를 확인해야 해서 코드가 조금 더 길어진다는 점이다.
하지만 이 클래스는 초기 상태와 실제 좌표 상태를 구분하는 것이 가장 중요하기 때문에, 이 불편을 감수할 가치가 있다.
3.2. 현재 저장된 위치 반환
public Vector3Int GetPosition()
{
if (lastPosition.HasValue) return lastPosition.Value;
throw new Exception("LastDetectedPositon 위치가 한 번도 업데이트되지 않았습니다. " +
"LastDetectedPosition 스크립트 인스턴스에서 TryUpdatingPositon을 호출하고 있는지 확인하십시오.");
}이 함수는 현재 저장된 마지막 위치를 반환하는 메서드이다.
겉보기에는 단순 getter처럼 보이지만, 실제로는 안전하게 값을 꺼내기 위한 검증 메서드에 가깝다.
첫 줄의 조건문을 보면 lastPosition.HasValue를 검사하고 있다.
HasValue는 Nullable 타입이 실제 값을 가지고 있는지 확인하는 C# 기능이다.
이 기능을 통해, 이 줄은 위치가 한 번이라도 기록된 적이 있는가를 묻는 것이다.
값이 있으면 lastPosition.Value를 반환한다.
여기서 Value는 Nullable 내부에 들어 있는 실제 Vector3Int 값을 꺼내는 속성이다.
중요한 점은, 이 코드가 단순히 return lastPosition.Value;를 하지 않았다는 것이다.
왜냐하면 값이 없는 상태에서 Value를 바로 접근하면 런타임 예외가 발생하기 때문이다.
그래서 먼저 HasValue를 확인한 뒤에만 Value를 꺼내고 있다.
값이 없는 경우에는 Exception을 던진다.
이 부분이 코드 해석에서 중요하다.
이 함수는 값을 못 찾았을 때 조용히 (0,0,0)을 반환하거나, null 비슷한 값을 흉내 내지 않는다.
대신 이 클래스가 잘못 사용되고 있다는 것을 즉시 드러낸다.
즉, 이 함수의 의도는 위치가 반드시 기록된 이후에만 이 메서드를 호출해야 하는 사용 규칙을 강제하는 데 있다.
이런 방식은 방어적 프로그래밍에 가깝다.
장점은 잘못된 흐름을 빠르게 발견할 수 있다는 점이고, 단점은 예외가 발생하면 프로그램 흐름이 끊길 수 있다는 점이다.
하지만 이 클래스는 내부 유틸리티 성격이 강하고, 잘못된 호출은 개발 단계에서 바로 발견하는 것이 훨씬 중요하기 때문에 예외를 던지는 방식이 오히려 적절하다.
이 함수는 단순히 값을 꺼내는 함수가 아니라, 이 객체가 올바른 순서로 사용되고 있는지 검사하는 보호 장치 역할도 한다.
3.3. 위치 변경 감지
public bool TryUpdatingPositon(Vector3Int tempPos)
{
if (lastPosition.HasValue && lastPosition == tempPos) return false;
lastPosition = tempPos;
return true;
}이 함수는 이 클래스의 핵심 로직이다.
단순히 값을 저장하는 setter가 아니라, 값이 실제로 바뀌었는지를 검사한 뒤, 바뀐 경우에만 상태를 갱신하고 호출부에 결과를 알려준다.
먼저 매개변수 tempPos는 새로 들어온 Grid 좌표이며, 현재 프레임에서 계산된 선택 위치라고 보면 된다.
첫 번째 줄의 조건문은 두 가지를 동시에 확인한다.
if (lastPosition.HasValue && lastPosition == tempPos) return false;첫 번째 조건 lastPosition.HasValue는 이전 값이 존재하는지 확인한다.
이 값이 없다면 비교 자체가 의미 없기 때문에, 이전 값이 있는 경우에만 두 번째 비교를 수행한다.
두 번째 조건 lastPosition == tempPos는 기존 위치와 새 위치가 같은지를 검사한다.
여기서 Vector3Int는 구조체이기 때문에 값 비교가 가능하다. 즉 x, y, z 값이 모두 같으면 같은 좌표로 판단된다.
이 조건이 참이면 false를 반환한다.
이 의미는 매우 중요하다.
이 함수가 false를 반환한다는 것은 새로운 위치가 들어왔지만, 이전 값과 동일하므로 굳이 다음 로직을 실행할 필요가 없다는 뜻이다.
이 함수의 반환값은 단순 성공/실패가 아니라, 'true면 위치가 실제로 바뀌었음, false면 위치가 그대로' 라는 의미를 가진다.
그 다음 줄에서는 실제로 상태를 갱신한다.
lastPosition = tempPos;
return true;이 부분은 이전 값이 없었거나, 새 값이 이전 값과 다를 때만 실행된다.
이 함수는 변경 감지와 상태 갱신을 하나의 메서드 안에서 같이 처리한다.
이 구조가 좋은 이유는 호출부를 단순하게 만들기 때문이다.
보통 이런 로직을 호출부에서 직접 처리하면, '이전 값 존재 여부 확인, 이전 값과 새 값 비교, 같으면 리턴, 다르면 저장' 이 과정을 매번 반복해야 한다.
하지만 이 클래스는 그 로직을 내부에 캡슐화했기 때문에, 호출하는 쪽에서는 단순히
if (lastDetectedPosition.TryUpdatingPositon(newPos))
{
// 다음 로직 실행
}처럼 사용할 수 있다.
즉 이 함수는 단순한 저장 메서드가 아니라, 다음 연산을 실행해도 되는가를 판단하는 게이트 역할을 한다.
이게 이 클래스가 작은 크기인데도 유용한 이유다.
3.4. 상태 초기화
public void Reset() => lastPosition = null;이 함수는 저장된 마지막 위치를 초기 상태로 되돌리는 역할을 한다.
코드는 한 줄이지만 의미는 분명하다.
이 클래스는 이전 위치와 현재 위치를 비교하는 구조이기 때문에, 새로운 상태로 진입하거나 건축 모드가 바뀌었을 때 이전 값을 그대로 유지하면 잘못된 비교가 발생할 수 있다.
예를 들어 이전 상태에서 마지막 위치가 (5, 0, 3)으로 저장되어 있었는데, 새로운 상태에서도 그 값이 그대로 남아 있으면 첫 번째 입력이 실제로 새로운 위치임에도 불구하고 이미 처리한 위치처럼 오인될 수 있다.
그래서 상태 전환 시점에는 반드시 이 값을 null로 돌려야 한다.
여기서 다시 null을 쓰는 이유는 이 클래스의 초기 상태를 '값 없음' 으로 정의했기 때문이다.
이 메서드는 C#의 표현식 본문 메서드 문법을 사용했다.
이 문법은 함수 본문이 하나의 단순 표현식일 때 코드를 간결하게 줄여준다.
장점은 짧고 의도가 명확하다는 점이고, 단점은 함수 내용이 조금만 복잡해져도 오히려 읽기 어려워진다는 점이다.
현재 Reset은 정말 한 줄 동작만 가지므로 이 문법이 적절하다.
4. 개발 의도
LastDetectedPosition의 핵심 설계 의도는 불필요한 연산을 최소화하기 위한 최소 단위 상태 캐싱이다.
이 클래스는 매우 작지만, 역할은 명확하다.
Update 루프에서 동일한 입력이 반복될 때 선택 계산, 프리뷰 갱신, 충돌 검사 같은 비용이 큰 연산을 차단한다.
특히 건축 시스템에서는 '마우스 이동 → Grid 계산 → Selection → Validation → Preview' 이 전체 파이프라인이 매 프레임 실행되기 때문에, 입력 변화 감지는 필수적인 최적화 포인트다.
또한 이 클래스는 특정 시스템에 종속되지 않고, 어떤 위치 기반 로직에서도 사용할 수 있도록 독립적으로 설계되었다.
결과적으로 LastDetectedPosition은 단순한 변수 래퍼가 아니라, Update 기반 시스템에서 연산 실행 조건을 제어하는 상태 캐싱 계층이며, 작지만 시스템 전체 성능과 구조 안정성에 영향을 주는 중요한 유틸리티다.
