개요

앞의 세 편(복잡도, iterator와 해시, 탐색과 그래프)은 자료구조를 하나씩 봤다. 이번 편은 방향이 다르다.

몬스터를 100마리 생성했더니 FPS가 급락한다. 무엇부터 의심하겠는가?

자료구조를 고르는 문제가 아니라 순서를 말할 수 있느냐를 보는 질문이다. 그리고 이 순서는 지난주에 유니티 프로젝트에서 이미 밟아본 것이기도 하다. 14편16편에서 좀비를 잔뜩 띄워놓고 가설을 세우고 재고 기각하기를 이틀 반복했는데, 그때는 순서를 의식하지 않고 손에 잡히는 대로 했다.

그래서 이번 편은 앞의 세 편과 반대다. 앞에서는 알고 있다고 생각한 게 계속 어긋났는데, 여기서는 대체로 답이 나왔고 대신 빠진 칸을 채우는 쪽이었다.

  • 큰 흐름은 맞았던 것 — 재현 → 측정 → 가설 → 좁히기. 여기는 그대로 나왔다
  • 순서에 없던 칸 — 조건 고정과 재측정. 손으로는 했는데 절차로 안 세워뒀다
  • 증상의 모양을 잘못 짝지은 것 — “점점 느려짐”의 원인으로 무한 루프를 꺼냈다

마지막에 AI가 준 코드를 검증하는 이야기가 하나 붙는데, 이건 앞 편들에서 정리한 소유권이 그대로 걸리는 자리다.

무엇부터 의심할 것인가, 그리고 그걸 어떻게 확인할 것인가? 이번 편은 이 두 줄이 전부다.

( 진행 방식은 앞 편들과 같다. 직접 컴파일해서 돌려보지는 않았고, 아래 코드는 전부 개념 예시다. 유니티 쪽 수치는 지난주에 실제로 잰 값이며 출처는 각 편에 링크해뒀다 )


1. 순서를 먼저 고정한다

내가 원래 갖고 있던 흐름은 이랬다.

실제 증상 재현
-> profiler로 비교
-> 성능을 많이 차지하는 부분 확인
-> 가설을 하나씩 세워 원인을 좁힘

큰 틀은 맞았다. 여기에 두 칸을 더 넣었다.

증상 재현
-> 재현 조건 고정            <- 추가
-> 병목 범주 분류 (CPU / GPU / 메모리 / 렌더링)
-> profiler로 측정
-> hotspot 특정
-> 가설 수립
-> 한 변수만 바꾸는 통제 실험
-> 수정
-> 재측정                    <- 추가

재현 조건 고정14편에서 제일 먼저 했던 일이다. 조건이 흔들리면 나중에 나온 숫자가 뭐 때문에 바뀐 건지 알 수 없다. 그때는 이게 절차의 일부라는 생각 없이 그냥 필요해서 했다.

재측정은 더 명확하다. 고쳤으니 빨라졌겠지로 끝내면 안 된다. 같은 조건에서 다시 재서 실제로 달라졌는지 봐야 한다.

원칙은 하나다.

측정 없이 기술을 먼저 적용하지 않는다.

16편이 이 문장의 값을 치른 편이었다. 오브젝트 풀링을 넣으려고 이틀을 재다가 넣을 근거가 없다는 결론이 나왔는데, 안 쟀으면 이틀을 들여 만들고 나서 “왜 안 빨라지지”를 하고 있었을 것이다.


2. 언제 느린가 — 스파이크와 지속 증가를 가른다

몬스터 100마리로 FPS가 떨어졌다고 할 때, 제일 먼저 갈라야 하는 축이 이거였다.

증상 의심할 것
생성되는 순간만 튄다 일회성 비용
살아 있는 동안 계속 느리다 매 프레임 반복 비용

생성 순간만 튀는 경우 — 할당, 초기화, 컴포넌트 등록, 물리/충돌체 등록, 동기 리소스 로딩.

계속 느린 경우 — AI 판단, 이동, 경로 탐색, 물리/충돌, 애니메이션 갱신, 반복되는 게임 로직.

같은 “느리다”인데 의심할 목록이 완전히 다르다. 이걸 안 가르고 프로파일러를 열면 뭘 봐야 할지 모르는 채로 숫자만 보게 된다.


3. CPU인가 GPU인가

다음 갈림길이다. 프레임 시간이 늘었을 때 어느 쪽에서 늘었는지를 본다.

GPU 프레임 시간은 그대로인데 CPU 쪽만 늘었다면, 몬스터 수에 비례해 늘어나는 CPU 작업을 찾는다.

CPU 후보 GPU 후보
AI 판단 draw call
경로 탐색 지오메트리
물리 / 충돌 그림자
애니메이션 갱신 overdraw
할당 / 초기화 셰이더 / 픽셀 비용
비싼 탐색  

14편에서 결국 걸린 게 물리 쪽이었고, 그것도 마리 수가 아니라 밀집도가 변수였다. 몇 마리가 있느냐보다 얼마나 붙어 있느냐가 충돌 비용을 정한다는 걸 그때 화면을 보고 알았다.

이번에 개념으로 이어붙인 건 그 이유였다. 새 충돌체를 물리 세계에 등록할 때 엔진은 객체 안쪽만 초기화하는 게 아니라 공간 자료구조나 충돌 관계를 갱신해야 한다. 그래서 등록이 공짜가 아니고, 100개를 한 프레임에 등록하면 그 비용이 한곳에 몰린다.


4. 함수 이름만 보고 병목을 정하지 않는다

이런 코드가 있다고 하자.

void Monster::Initialize()
{
    hp = 100;
    target = FindNearestPlayer();
}

hp = 100은 O(1)이다. 문제는 아래 줄인데, FindNearestPlayer()가 플레이어 P명을 선형 탐색한다면 호출 하나가 O(P)다. 몬스터 M마리가 각각 부르면 전체는 O(M × P) 성격이 된다.

이름만 보면 “가장 가까운 플레이어 찾기”라 별거 아닌 것 같은데, 안에서 뭘 하느냐에 따라 전체 비용의 차수가 바뀐다. 그래서 이렇게 잡았다.

이름으로 의심은 해도, 확정은 프로파일러와 실제 구현으로 한다.

16편이 정확히 이걸로 이틀을 썼다. 재기 전에 예상을 적어뒀는데 실측이 예상의 10분의 1이었고, 틀린 이유가 둘이었다. 하나는 프로파일러의 그 칸이 비용 전부를 보여주는 게 아니었던 것, 다른 하나는 스폰 설정을 안 보고 예상한 것이었다.

그때 적어둔 문장이 여기에 그대로 붙는다.

예상은 맞히려고 적는 게 아니라 틀린 지점을 발견하려고 적는다.


5. 한 프레임에 몰린 걸 여러 프레임으로 나누기

스파이크가 확인됐을 때 쓸 수 있는 방법 하나.

100개 * 0.1ms = 한 프레임에 10ms

10개씩 * 10프레임 = 프레임당 약 1ms

총 작업량은 거의 같다. 줄어드는 건 한 프레임이 감당하는 양이다. 프레임 예산이라는 관점에서는 이게 전부인 경우가 많다.

앞 편에서 정리한 incremental rehashing이 자료구조 내부에서 하는 것과 같은 거래다. 그리고 첫 편의 상각 O(1)도 결국 가끔 비싼 걸 어떻게 볼 것인가의 이야기였다. 평균으로 보면 괜찮고, 프레임으로 보면 안 괜찮다.

다만 이건 공짜가 아니다. 모든 몬스터가 같은 순간에 등장해야 하는 기획이면 나눌 수 없다. 성능 기법이 기획 요구와 부딪히는 자리다.


6. 시간이 지날수록 느려진다면

이건 2번과 또 다른 증상이다. 처음엔 괜찮은데 몇 분 지나면 느려지는 경우.

여기서 나는 후보로 무한 루프를 꺼냈는데, 증상과 안 맞았다. 메인 스레드에서 진짜 무한 루프가 돌면 점점 느려지는 게 아니라 그 자리에서 멈춘다. 증상의 모양이 다르다.

점점 느려진다면 시간에 따라 쌓이는 상태를 먼저 본다.

  • memory leak(메모리 누수)
  • object retention(객체 유지) — 죽었어야 할 객체가 참조 때문에 안 죽음
  • container growth — 컨테이너가 계속 자람
  • event subscription retention — 구독 해제를 안 해서 객체가 살아남음
  • 매 프레임 갱신 대상 수 자체가 증가
  • allocation churn(반복 할당/폐기)

메모리 그래프로 두 가지를 가른다

관찰 우선 의심
메모리 사용량이 계속 우상향 누수 또는 객체 누적
메모리는 일정한데 GC가 자주 돌고 프레임이 튄다 allocation churn

아래쪽이 헷갈리는 자리다. 총량이 안 늘어나니 누수는 아닌데, 임시 객체를 계속 만들고 버리는 탓에 수거가 자주 일어나고 그때마다 프레임이 튄다.

이벤트 구독 얘기는 < RAII와 스마트 포인터 >에서 이미 나왔었다. 거기선 수명 문제로 봤는데, 여기서는 시간이 지날수록 느려지는 증상의 원인 후보로 다시 나온다.

그리고 하나 갈라둘 것. 재할당은 그 자체로 누수가 아니다. 더 큰 저장 공간을 잡고 기존 것을 정상 해제하는 동작이라 비용은 들어도 새는 건 없다. 첫 편에서 본 vector 재할당이 여기 해당한다. 비싼 것과 새는 것은 다른 문제고 고치는 방법도 다르다.


7. AI가 준 코드를 어떻게 검증할 것인가

std::vector<Enemy*> enemies;

for (auto enemy : enemies)
{
    if (enemy->IsDead())
        delete enemy;
}

AI가 짜준 코드라고 치고 문제를 찾아보라는 질문이었다. 앞 편들에서 정리한 게 그대로 걸렸다.

dangling pointer

delete enemy를 해도 vector 안의 포인터 값은 그대로 남는다. 다음 반복이나 다음 프레임에 enemy->IsDead()를 부르면 이미 수명이 끝난 객체를 건드리는 것이고, Undefined Behavior다.

지우려면 delete컨테이너에서 제거가 같이 가야 한다.

소유권이 타입에 없다

Enemy*라는 타입만으로는 누가 이걸 지울 책임이 있는지 알 수 없다. 다른 시스템이 소유자인데 여기서도 지우면 double delete가 된다.

이건 < 상속과 다형성 > 7번에서 std::vector<Monster*>를 놓고 이미 봤던 그림이다. 그때 정리한 게 이거였다.

Monster*라는 타입 자체에 소유권 정보가 없다. 벡터는 주소값을 담아둘 뿐이라 소멸될 때 원소를 delete하지 않는다.

vector<Enemy*>가 파괴될 때 정리되는 건 포인터 값을 담아둔 공간이고, 그 포인터가 가리키던 Enemy는 그대로 남는다. 컨테이너가 단독 소유자라면 타입에 그렇게 적는 쪽이 명확하다.

std::vector<std::unique_ptr<Enemy>> enemies;

소멸자

delete enemyEnemy의 소멸자를 부른다. 멤버들이 RAII 타입이면 거기서 정리된다. 다만 Enemy가 raw 자원을 직접 들고 있으면서 정리하지 않는 설계라면 별도로 샌다 — < RAII와 스마트 포인터 >의 이야기가 여기까지 온다.

검증 순서

절차로 정리하면 이렇게 된다.

1. 소유권과 수명 — 누가 지우는가, 지운 뒤 남는 핸들은 없는가
2. 정상 동작과 경계 조건
3. 복잡도 — 호출 하나가 아니라 호출 횟수까지
4. 필요하면 스레드 안전성
5. 테스트와 프로파일러로 확인

1번이 맨 위인 게 이번 주 내내 본 것들의 결론이었다. 컴파일러가 통과시키는 선과 실제로 안전한 선이 다른 자리가 대부분 소유권과 수명이다.


정리

원래 갖고 있던 것

  • 재현 → 측정 → 가설 → 좁히기의 큰 흐름 — 여기는 맞았다
  • 프로파일러로 병목을 확인해야 한다 — 지난주에 실제로 해봤던 것
  • vector<Enemy*>가 소유권을 표현하지 않는다 — 앞 편들에서 정리한 게 바로 걸렸다

순서에 새로 넣은 것

  • 재현 조건 고정 — 조건이 흔들리면 나온 숫자를 해석할 수 없다
  • 재측정 — 고쳤으니 빨라졌겠지로 끝내지 않는다

틀리게 잡고 있던 것

  • 무한 루프는 “점점 느려짐”의 대표 원인이 아니다. 그건 멈추는 증상이다
  • 재할당은 그 자체로 메모리 누수가 아니다. 비싼 것과 새는 것은 다르다

이번에 새로 얹은 것

  • 스파이크와 지속 증가를 먼저 가른다. 의심 목록이 완전히 달라진다
  • 충돌체 등록은 객체 내부 초기화가 아니라 물리 세계 쪽 갱신을 부른다
  • 메모리 총량이 일정한데 프레임이 튀면 allocation churn을 의심한다
  • AI 코드 검증은 소유권과 수명이 1번이다

지난주에 유니티에서 이 절차를 손으로 밟았는데, 그때는 순서가 있는 줄도 몰랐다. 조건을 못 박은 것도, 예상을 먼저 적은 것도, 가설을 하나씩 기각한 것도 그때그때 필요해서 한 거였다. 그걸 이번에 말로 꺼낼 수 있는 형태로 다시 세웠다.

순서가 거꾸로긴 하다. 그런데 어제 편을 쓰면서도 같은 걸 느꼈는데, 먼저 해봤던 게 이해를 훨씬 빠르게 만든다. 밀집도가 변수라는 걸 화면 보고 알아낸 경험이 있으니까, 충돌체 등록 비용 설명이 처음 듣는 얘기로 안 들렸다.

지금 부족한 건 오히려 다른 쪽이다. Stack과 Queue를 독립 질문으로 짧게 설명하는 것, O(N log N)과 O(N²)의 대표 예시를 바로 꺼내는 것, 그래프를 인접 리스트와 인접 행렬 중 뭘로 표현할지 — 이건 오늘 확인 안 했다. 여기부터 채우고 모의 면접으로 넘어갈 생각이다.

참고 자료

소감

오늘 네 편 중에 제일 쓰기 편했다. 앞의 세 편은 알고 있다고 생각한 게 계속 어긋나는 쪽이었는데, 이번 편은 지난주에 이미 해본 걸 순서로 옮기는 일이었다. 그때는 결론이 “안 해도 된다” 하나뿐이라 이게 뭔가 싶었는데, 그 이틀이 오늘 절차 전체의 근거가 됐다.