← 전체 글로

AI 사용기

AI 활용기: 내 성능 측정 코드가 스스로 거짓말을 하고 있었다

직접 만드는 게임의 대규모 전투 성능 테스트에서, AI 코드리뷰가 측정 루프 자체의 이중 처리 버그를 잡아냈다. 같은 프레임에 데미지가 두 번 들어가 오브젝트 풀이 오염되는 버그도 같은 세션에서 함께 발견했다. 벤치마크 하네스 자체의 결함과 객체 풀 이중 반납 문제를 다룬 온라인 사례와 겹쳐 읽었다.

실제 발견·수정 일자는 2026-09-19 저장소 커밋 기록 기준. 발행일은 2026-09-20.

이 글의 목적

직접 짠 성능 측정 코드나 이벤트 처리 코드를 'AI가 짜줬으니까' 결과만 보고 믿는 사람에게, 측정 도구 자체에 버그가 있으면 숫자가 그럴듯하게 나와도 틀릴 수 있다는 걸 실제 사례로 보여준다.

핵심 내용

잡몹 166마리를 스폰해 60틱을 돌리는 성능 측정 루프가, 수동으로 _process()를 부른 뒤에 await process_frame까지 함께 넣어 매 틱을 두 번씩 처리했다. 결과 로그는 '생존 1~1.5초, 킬 0'으로 찍혔는데 실제로는 시뮬레이션 시간이 두 배로 흘러간 것이었고, 이중 처리를 없애자 실제 생존 시간은 '약 2.4초, 킬 1'로 나왔다 — 덜 극단적이지만 여전히 밸런스가 위험하다는 진짜 문제가 드러났다. 같은 세션에서 무기 A와 B가 같은 프레임에 저체력 잡몹 하나를 동시에 맞히면 free_slot이 두 번 불려 오브젝트 풀 슬롯이 중복 반납되는 버그도 따로 발견해 가드 한 줄로 막았다. 둘 다 게임 로직이 아니라 '측정하고 반납하는' 보조 코드의 결함이었다.

읽고 나서

성능 측정 루프나 리소스 반납 코드를 새로 짤 때 '이 함수가 같은 대상에 두 번 불려도 안전한가'와 '이 루프가 프레임을 정확히 한 번씩만 처리하는가'를 결과 숫자를 보기 전에 먼저 자문한다. 숫자가 그럴듯하면 검증했다고 착각하기 쉽다.

목차

먼저 답하면

직접 만드는 게임 the-weapons의 대규모 자동전투 필드 기능을 짜면서, 잡몹 166마리를 채우고 60틱을 굴려 프레임 비용을 재는 성능 측정 코드를 짰다. 로그에는 “리베가 1~1.5초 만에 죽고 킬 수는 0”이라고 찍혔는데, 코드리뷰 단계에서 이 숫자 자체가 거짓이었다는 게 드러났다. 측정 루프가 field._process()를 수동으로 부른 뒤 await process_frame까지 같이 기다려서, 엔진이 씬 트리에 붙은 필드를 자동으로 한 번 더 처리하는 바람에 60틱을 돌렸다고 생각한 시간이 실제로는 거의 두 배로 흘러가 있었다.

재현 방법

문제의 루프는 이렇게 생겼었다.

for i in 60:
    field._process(1.0 / 60.0)
    await process_frame

field가 이미 씬 트리에 붙어 있는 노드라서, 엔진은 렌더링 프레임마다 _process()를 자동으로도 호출한다. 여기에 수동 호출을 하나 더 얹고 await process_frame으로 다음 렌더 프레임까지 기다리면, 그 대기 시간 동안 엔진이 알아서 한 번 더 _process()를 돌린다. 결과적으로 “60틱 = 1초”를 재려던 루프가 실제로는 그 두 배에 가까운 시뮬레이션 시간을 진행시켰다. 코드리뷰에서 이 구조가 지적됐고, 실측으로 “이중 디스패치 버전은 생존 1~1.5초·킬 0”과 “단일 디스패치로 고친 버전은 실제로 약 2.4초에 사망·킬 1”이 서로 다르다는 걸 직접 돌려 확인했다. 수정은 await process_frame 한 줄을 지우는 것이었다.

같은 세션에서 두 번째 버그도 나왔다. 오브젝트 풀에서 잡몹을 관리하는 damage_slot() 함수는 데미지를 주고 체력이 0 이하면 슬롯을 죽음 처리 후 free_slot으로 풀에 반납한다. 그런데 무기 A와 무기 B의 쿨다운이 둘 다 0.0으로 시작하는 첫 프레임 같은 경우, 같은 저체력 타겟에 두 무기가 같은 프레임에 명중하면 damage_slot()이 그 슬롯에 두 번 불린다. 이미 죽어서 반납된 슬롯에 두 번째 호출이 들어가면 같은 슬롯 번호가 free_stack에 중복으로 쌓이고, 다음 두 번의 spawn()이 같은 슬롯을 나눠 받으면서 서로 다른 두 잡몹의 데이터가 한 슬롯에 겹쳐 쓰이는 풀 오염이 실제로 재현됐다. 고친 코드는 함수 맨 앞에 가드 한 줄을 추가한 것뿐이다.

func damage_slot(kind: String, slot: int, amount: float) -> bool:
    if alive[kind][slot] == 0:
        return false
    ...

두 버그 모두 전투 로직 자체가 아니라, 그 로직을 “재는 코드”와 “반납하는 코드”에 있었다. 각 커밋에 테스트를 추가해 회귀를 막았다 — 이중 데미지 가드는 같은 슬롯에 치명타를 두 번 넣어 두 번째 호출이 false를 반환하고 free_stack에 슬롯이 정확히 한 번만 쌓이는지 확인하는 케이스로, 측정 루프는 고친 뒤의 실제 생존 시간·킬 수를 로그에 남기는 방식으로 검증했다.

온라인에서 찾은 비슷한 사례

측정 하네스 자체에 결함이 있어 결과가 그럴듯해 보이지만 틀렸던 경험은 드문 일이 아니다. 개발자 rem_dore는 자신의 벤치마크 하네스를 처음부터 돌렸을 때 “열네 가지 방식으로 틀려 있었다”고 기록했는데, 그중 하나가 이번 사례와 정확히 같은 종류다 — 카운터를 프레임마다 증가시켜야 하는데 실제로는 “읽기(read)“마다 증가시키고 있었고, 마침 테스트에 쓴 데이터가 프레임과 읽기가 1:1로 맞아떨어지는 스트림이라 네 개의 기존 테스트를 전부 통과해버렸다는 것이다. 측정 루프의 버그가 우연히 “그럴듯한 숫자”를 만들어내면 테스트 통과만으로는 잡히지 않는다는 점이 이번 이중 디스패치 사례와 같다.

오브젝트 풀의 이중 반납·이중 처리 문제는 게임 프로그래밍의 고전적인 함정으로 따로 정리돼 있다. Robert Nystrom의 『Game Programming Patterns』 오브젝트 풀 챕터는 풀에서 꺼낸 객체를 “살아있음/죽음” 같은 상태로 구분해 관리해야 하며, 이미 반납된 객체를 실수로 다시 조작하면 풀이 오염된다는 점을 핵심 주의사항으로 짚는다. 이번에 만난 “같은 프레임에 두 공격이 같은 타겟을 맞혀 반납 함수가 두 번 불리는” 상황은 이 챕터가 경고하는 문제의 구체적인 발현형이다.

AI 코드리뷰가 이런 버그를 어디까지 잡아내는지에 대해서는 낙관과 신중론이 함께 있다. O’Reilly Radar의 분석은 AI 코드리뷰가 구조적인 패턴(경쟁 상태, 리소스 누수, 이중 해제류)은 비교적 잘 잡아내지만 “요구사항을 모르는 채 패턴만 보는” 한계가 있어 전체 버그의 절반 정도만 잡아낸다고 지적한다. 이번 두 버그는 둘 다 “패턴만으로 알 수 있는” 범주 — 이미 반납된 자원의 재사용, 의도치 않은 중복 처리 — 에 들어맞아서 코드리뷰 단계에서 걸러졌지만, 밸런스가 실제로 안전한지(잡몹 166마리 밀도에서 2.4초 생존이 괜찮은 수치인지) 같은 판단은 여전히 사람이 로그를 보고 결정해야 했다.

결론

측정 코드와 반납 코드는 “그냥 돕는 코드”라서 전투 로직보다 덜 의심받기 쉽다. 하지만 이번 사례처럼 측정 루프 자체가 틀리면 눈에 보이는 숫자가 오히려 진짜 문제(밸런스가 너무 빡빡하다)를 가려버린다. 온라인의 벤치마크 하네스 사례와 오브젝트 풀 패턴 문서 둘 다 같은 조언으로 수렴한다 — 숫자가 그럴듯하게 나왔다고 측정 코드를 검증했다고 착각하지 말고, “이 루프가 정확히 한 번씩만 도는가”와 “이 함수가 같은 대상에 두 번 불려도 안전한가”를 먼저 확인하라는 것이다.