업데이트 도중 새 총알을 목록에 넣어도 될까요?
씬의 오브젝트 목록 [플레이어, 적 A, 적 B]를 순회하는 도중 플레이어가 총알을 발사한다고 생각해 봅시다. 같은 목록에 즉시 총알을 추가하면 벡터의 저장 공간이 바뀌어 순회 중인 iterator가 무효가 될 수 있습니다. 적 A를 처리하다가 목록에서 바로 삭제해도 다음 오브젝트를 어떻게 순회할지 문제가 생깁니다.
이번에는 “객체를 만들거나 지우고 싶다”는 요청을 큐에 담고, 목록 순회가 끝난 공통 시점에 반영하겠습니다. 이 과정에서 객체가 메모리에 존재하는 시점, 씬 목록에 들어가는 시점, 실제로 삭제되는 시점을 구별하는 것이 핵심입니다.
GameObject 이벤트 시스템 설계와 구현
위 개발 영상과 이미지는 객체를 다루는 장면을 보여 줍니다. 화면에서 물체가 나타나거나 사라지는 결과를 아래의 요청·반영 시점과 연결해서 읽어 봅시다. 현재 흐름은 Game과 Scene의 렌더 명령 기록이 끝난 뒤 EndOfFrame에서 큐를 처리합니다. 큐는 이 처리 시점을 모으는 장치이며 객체의 모든 수명 문제를 자동으로 해결하는 장치는 아닙니다.
1. 요청을 저장할 곳과 반영할 곳을 나눕니다
flowchart TD
I[object::Instantiate] --> N[객체 생성 · 초기 값 설정]
N --> Q[SceneManager 소유 EventQueue]
X[object::Destroy 요청] --> Q
R[Game과 Editor 렌더 명령 기록] --> F[Application::EndOfFrame]
F --> S[SceneManager::EndOfFrame]
S --> Q
Q --> H[타입별 핸들러]
H --> L[Scene · Layer 목록 변경]Mermaid
복사
구성 요소 | 실제 역할 |
object::Instantiate | 객체를 만들고 SceneManager에 생성 이벤트 전달 |
SceneManager | 이벤트 큐 소유, 생성·삭제 핸들러 등록 |
EventQueue | 이벤트 포인터를 FIFO로 보관하고 Process에서 처리·삭제 |
EventDispatcher | 단일 이벤트의 타입을 검사해 즉시 콜백 실행 |
Scene / Layer | 객체 목록에 추가하거나 제거·삭제 |
현재 엔진에서는 SceneManager가 EventQueue를 소유합니다. 생성 요청은 GameObjectCreatedEvent에 객체와 대상 Scene을 담아 이 큐로 보냅니다. 처리 시점에는 SceneManager의 생성 핸들러가 실제 Scene/Layer 목록에 객체를 넣습니다. 생성 코드는 목록의 세부 구현을 직접 바꾸지 않고 요청을 보내지만, 대상 Scene을 알아야 하는 관계까지 사라진 것은 아닙니다.
2. 생성 요청과 실제 씬 등록은 시점이 다르다
총알 객체는 지금 만들고, 씬에는 나중에 등록합니다
Instantiate라는 이름만 보면 모든 등록이 끝난 것처럼 생각하기 쉽습니다. 아래에서는 new와 PushEvent가 어디에 놓였는지 봅니다. 코드 위에는 template <typename T>가 선언되어 있습니다.
static T* Instantiate(eLayerType type, Vector3 position)
{
T* gameObject = new T();
gameObject->SetLayerType(type);
Transform* tr = gameObject->template GetComponent<Transform>();
tr->SetPosition(position);
Scene* activeScene = SceneManager::GetActiveScene();
SceneManager::PushEvent(new ya::GameObjectCreatedEvent(gameObject, activeScene));
return gameObject;
}
C++
복사
플레이어가 이 함수를 호출하면 총알 메모리는 즉시 생기고 위치도 설정됩니다. 큐에는 (총알 포인터, 현재 Scene 포인터)를 가진 생성 이벤트가 남습니다. 반환된 포인터로 총알의 속도 같은 초기 데이터를 설정할 수 있지만, 해당 Scene의 목록을 검색해서 바로 찾을 수 있다고 가정하면 안 됩니다. 목록 추가는 프레임 끝의 생성 핸들러가 실행된 뒤입니다.
new T()는 즉시 실행됩니다. 반환된 포인터로 초기 값을 설정할 수 있지만, 씬의 오브젝트 목록에 들어가는 것은 나중입니다. 또한 SceneManager::GetActiveScene()으로 대상 Scene을 얻으므로 SceneManager에 대한 의존성이 사라지는 것은 아닙니다. 분리한 것은 객체 생성 시점과 씬 목록 변경 시점입니다.
// 기존 템플릿 바로 위에는 template <typename T>가 선언되어 있습니다.
// 호출 형식: 레이어가 먼저, 위치가 두 번째입니다.
auto* obj = ya::object::Instantiate<ya::GameObject>(
layerType, ya::math::Vector3(0.0f, 0.0f, 0.0f));
// layerType은 호출자가 선택한 유효한 eLayerType 값입니다.
C++
복사
3. 핸들러 등록과 프레임 끝 처리
큐에 든 사건을 어떤 동작으로 바꿀지 등록합니다
이 초기화는 매번 객체를 만들 때 실행하는 것이 아닙니다. SceneManager를 준비할 때 이벤트 종류와 처리 함수를 연결합니다. 생성 이벤트는 GameObjectCreated로, 삭제 이벤트는 GameObjectDestroyed로 넘깁니다.
void SceneManager::InitializeEventHandlers()
{
// 이벤트 핸들러 등록
mEventQueue.RegisterHandler<GameObjectCreatedEvent>([](GameObjectCreatedEvent& e) -> bool
{
SceneManager::GameObjectCreated(e.GetGameObject(), e.GetScene());
return true;
});
mEventQueue.RegisterHandler<GameObjectDestroyedEvent>([](GameObjectDestroyedEvent& e) -> bool
{
SceneManager::GameObjectDestroyed(e.GetGameObject(), e.GetScene());
return true;
});
// 기본 핸들러 등록
mEventQueue.SetCallback([](Event& e)
{
std::cout << "[Application] Unhandled Event: " << e.ToString() << std::endl;
});
}
C++
복사
RegisterHandler는 타입별 콜백 하나를 맵에 저장합니다. 같은 타입을 다시 등록하면 이전 콜백을 교체하므로, 여러 시스템에 동시에 알리는 방송형 EventBus와는 다릅니다. 기본 콜백은 처리되지 않은 이벤트를 관찰할 경로입니다. 이것만으로 오디오·파티클 시스템에 자동 알림이 연결되지는 않습니다.
반환값 true는 이 이벤트를 처리했다는 뜻입니다. 핸들러가 없거나 처리 결과가 false이고 기본 콜백이 있으면 기본 콜백을 호출합니다. unordered_map의 조회는 평균 상수 시간이며 최악의 경우까지 항상 O(1)인 것은 아닙니다.
void SceneManager::EndOfFrame()
{
mActiveScene->EndOfFrame();
mDontDestroyOnLoad->EndOfFrame();
mEventQueue.Process();
}
C++
복사
이 시점에는 활성 Scene과 DontDestroyOnLoad의 EndOfFrame을 먼저 호출한 뒤 큐를 처리합니다. Update와 Draw에서 사용한 목록을 변경하는 작업을 이 경계 뒤로 모은 것입니다. 따라서 총알을 만든 프레임의 앞선 순회에는 총알이 없고, 등록 완료 후의 다음 순회에서 총알이 포함되는 흐름으로 이해할 수 있습니다.
FIFO로 요청을 꺼내고 이벤트 봉투를 정리합니다
큐는 먼저 들어온 요청을 먼저 꺼냅니다. event는 총알 자체가 아니라 “총알을 등록해 달라”는 요청 객체입니다. 아래 마지막 delete가 무엇을 삭제하는지 구별하며 읽습니다.
void Process()
{
while (!mQueue.empty())
{
Event* event = mQueue.front();
mQueue.pop();
EventDispatcher dispatcher(*event);
// 등록된 핸들러 실행
auto handler = mHandlers.find(event->GetEventType());
if (handler != mHandlers.end())
{
event->Handled = handler->second(*event);
}
// 기본 핸들러 실행 (처리되지 않은 경우)
if (!event->Handled && mCallback)
{
mCallback(*event);
}
delete event;
event = nullptr;
}
}
C++
복사
예를 들어 생성 이벤트 하나가 들어 있으면 front에서 그 이벤트를 꺼내고, GetEventType으로 생성 핸들러를 찾고, 씬에 총알을 등록한 뒤 이벤트만 delete합니다. 등록된 총알은 씬에 남습니다. 큐에서 pop했다고 총알이 사라지는 것은 아닙니다.
현재 Process()는 큐가 빌 때까지 순회하므로 핸들러가 추가한 이벤트도 같은 호출에서 처리될 수 있습니다. 연쇄 이벤트를 무한히 만들면 프레임이 끝나지 않습니다. 다음 프레임으로 넘기려면 큐 교환이나 처리 시작 시점의 개수 제한 같은 별도 정책이 필요합니다.
코드에 생성된 지역 EventDispatcher는 이 Process 경로에서 사용되지 않습니다. 타입별 맵의 콜백을 직접 호출합니다. EventQueue가 항상 EventDispatcher를 통과하는 3단계 구조라고 설명하면 실제 코드와 달라집니다.
4. 삭제 요청에서 소유권까지
static void Destroy(GameObject* gameObject)
{
if (gameObject != nullptr)
gameObject->death();
Scene* activeScene = SceneManager::GetActiveScene();
SceneManager::PushEvent(new ya::GameObjectDestroyedEvent(gameObject, activeScene));
}
C++
복사
death()는 상태를 변경하고, 큐의 삭제 핸들러가 Scene → Layer로 제거를 전달합니다. Layer::EraseGameObject에서 실제 GameObject를 delete합니다. 그 후 Queue가 delete하는 것은 GameObjectDestroyedEvent 객체 자체입니다.
프레임 끝으로 미뤄도 다음 조건은 별도로 지켜야 합니다.
•
생성·삭제 이벤트에 저장한 Scene과 GameObject 포인터가 처리 시점까지 유효해야 합니다.
•
같은 GameObject에 삭제 요청을 여러 번 넣으면 중복 삭제 위험이 있습니다. 현재 코드에는 중복 요청 방지 장치가 없습니다.
•
현재 Destroy(nullptr)도 이벤트를 큐에 넣습니다. 호출자는 유효한 객체만 전달해야 하며, null 요청 차단은 개선할 항목입니다.
•
GetActiveScene()과 실제 객체가 속한 Scene이 다를 수 있습니다. 다중 Scene·DontDestroyOnLoad에서는 소유 Scene을 명확히 전달해야 합니다.
•
현재 큐는 raw pointer와 std::queue를 사용하며 스레드 안전 큐가 아닙니다. 작업 스레드에서 동시에 Push/Process하면 안 됩니다.
•
종료 때 남은 이벤트 해제, 핸들러 예외 발생 시 해제, 씬 전환 중 보류 요청 처리는 추가 설계가 필요합니다.
이 문서는 현재 동작을 설명하며 위 문제를 엔진 코드에서 수정한 것은 아닙니다. 향후에는 이벤트 소유권을 unique_ptr로 표현하고, 객체 ID·세대 번호나 삭제 예약 플래그로 중복·만료 요청을 검증할 수 있습니다. 큐가 찼을 때 오래된 생성·삭제 이벤트를 무조건 버리는 방식은 객체 소유권을 깨뜨릴 수 있으므로 사용하지 않습니다. 풀을 도입할 때도 Queue의 delete와 풀 반환 정책을 함께 바꿔야 합니다.
5. DX12에서 CPU 이벤트와 GPU 완료는 별개다
CPU가 Draw를 모두 기록한 뒤 GameObject를 정리하는 시점과, GPU가 텍스처·descriptor 사용을 끝낸 시점은 다릅니다. DX12 리소스는 최종 제출 Fence를 기준으로 지연 해제해야 합니다. 이벤트 큐를 프레임 끝에 처리한다고 GPU 리소스를 즉시 재사용해도 되는 것은 아닙니다.
6. 한 프레임의 생성과 삭제를 종이에 따라가 봅시다
새 객체를 만든 직후 같은 객체에 Destroy를 한 번 요청했다고 가정하면 큐에는 생성, 삭제 순으로 이벤트가 들어갑니다. 같은 Scene이 살아 있고 다른 중복 요청이 없다면 Process는 먼저 목록에 넣고 다음에 제거합니다. 두 이벤트 봉투도 각각 해제합니다. 여기서 생성 직후 반환된 포인터를 다음 프레임에도 사용하려 한다면 이미 삭제된 객체를 가리킬 수 있습니다.
이번에는 삭제를 두 번 요청하거나 큐 처리 전에 Scene을 교체하는 경우를 그려 봅니다. 큐를 사용했다는 사실만으로 중복·만료 포인터가 유효해지지 않는다는 점이 드러납니다. 객체 ID와 세대 번호, 삭제 예약 표시, 이벤트의 unique_ptr 소유권은 이 문제를 확장할 때 적용할 수 있는 설계입니다. 현재 강의의 첫 목표는 목록 변경을 안전한 처리 시점으로 모으고, 어느 코드가 무엇을 소유하는지 설명할 수 있게 되는 것입니다.


