[Unity] FixedUpdate에 대한 고찰

자스비·2026년 8월 12일

Unity

목록 보기
3/3

Unity를 쓰면 "물리 처리는 FixedUpdate에 넣어라"는 말을 많이 듣습니다.
그런데 왜 그래야 하는지, FixedUpdate가 정확히 언제 몇 번 불리는지는 뭉뚱그려 알고 넘어가기 쉽습니다.

결국 Unity의 Transform 세계와 물리 엔진의 세계는 별개이고, FixedUpdate는 그 둘을 잇는 창구다. 라는 점을 기억하고 아래 내용을 봐주시면 감사하겠습니다.

FixedUpdate는 한 프레임에 0번일 수도, 여러 번일 수도 있다.

Unity는 FixedUpdate를 "프레임마다 한 번"이 아니라 누산기(accumulator) 방식으로 실행합니다.

// 엔진 내부, 의사코드
accumulator += Mathf.Min(realDeltaTime, Time.maximumDeltaTime);

while (accumulator >= Time.fixedDeltaTime)
{
    FixedUpdate();                        // 내 스크립트
    PhysicsSimulate(Time.fixedDeltaTime); // 엔진 물리 적분
    //
    // 여기서 OnCollisionXXX, OnTriggerXXX 발생
    //
    accumulator -= Time.fixedDeltaTime;
}

Update();
LateUpdate();
Render();

while 루프를 통해 밀린 시간이 있으면 따라잡을 때까지 반복합니다.

fixedDeltaTime의 기본값은 0.02초, 즉 50Hz입니다. fps별로 실제 호출 횟수를 계산하면 아래와 같습니다.

프레임률프레임 시간프레임 당 FixedUpdate1초간 Update1초간 FixedUpdate
200fps5ms0, 0, 0, 1 반복200회50회
60fps16.7ms대부분 1, 가끔 060회50회
25fps40ms항상 225회50회

Update 횟수는 기기마다 제각각이지만 FixedUpdate 횟수는 어느 기기에서든 50으로 고정됩니다.

물리는 별도 쓰레드가 아니다.

여기서 헷갈리기 쉬운 부분은 FixedUpdate는 프레임과 무관하게 돈다는 말이 비동기로 돈다는 뜻이 아닙니다.

실행 순서는 확실히 정해져 있습니다. 같은 메인 쓰레드에서 FixedUpdate(n번) -> Update -> LateUpdate 순으로 순차 실행됩니다. 분리된 것은 실행 시간이 아니라 실행 빈도입니다. 별도의 쓰레드가 아닌 별도의 클럭(시계)으로 돌아간다고 생각하는게 맞습니다.

왜 이렇게 만들었을까?

물리 적분은 속도 += 가속도 * dt 같은 누적 계산이라 dt가 달라지면 결과 궤적이 달라집니다.
만약 물리를 프레임마다 한 번씩 가변 dt로 돌린다면

  • 144fps 게이밍 pc -> 점프 높이 3.02m
  • 30fps 노트북 -> 점프 높이 2.87m

같은 조작인데 컴퓨터 성능에 따라 결과가 달라지게 됩니다.
그러나 dt를 항상 0.02로 고정하면 어떤 기기에서든 계산이 똑같이 재현됩니다. 이것이 목적입니다.


한 프레임의 실제 구조

Unity 6는 프레임 구조를 API로 열어놨습니다.
PlayerLoop.GetCurrentPlayerLoop()를 재귀적으로 출력하면 엔진이 한 프레임에 실제로 실행하는 것들이 전부 나옵니다.

using System.Text;
using UnityEngine;
using UnityEngine.LowLevel;

public class PlayerLoopProbe : MonoBehaviour
{
    [ContextMenu("현재 프레임 루프 구조 출력")]
    private void Dump()
    {
        var sb = new StringBuilder();
        Append(sb, PlayerLoop.GetCurrentPlayerLoop(), 0);
        Debug.Log(sb.ToString());
    }

    private static void Append(StringBuilder sb, PlayerLoopSystem system, int depth)
    {
        if (system.type != null)
            sb.Append(' ', (depth - 1) * 2).AppendLine(system.type.Name);

        if (system.subSystemList == null) return;

        foreach (var sub in system.subSystemList)
            Append(sb, sub, depth + 1);
    }
}

출력을 요약하면 아래와 같습니다.

Initialization
EarlyUpdate
FixedUpdate                          <- 이 블록 전체가 0~N회 반복
  ScriptRunBehaviourFixedUpdate      <- 내 FixedUpdate
  PhysicsFixedUpdate                 <- 물리 시뮬레이션
  ScriptRunDelayedFixedFrameRate     <- WaitForFixedUpdate 코루틴
PreUpdate
  PhysicsUpdate
  NewInputUpdate                     <- Input System
Update
  ScriptRunBehaviourUpdate           <- 내 Update
  ScriptRunDelayedDynamicFrameRate   <- yield null / WaitForSeconds
PreLateUpdate
  ScriptRunBehaviourLateUpdate       <- 내 LateUpdate
PostLateUpdate
  FinishFrameRendering
  PlayerSendFrameComplete            <- 프레임 끝

여기서 ScriptRunBehaviourFixedUpdate(내 FixedUpdate)의 다음 줄이 PhysicsFixedUpdate입니다.
뒤에서 다시 다루겠지만, 이 인접성이 물리는 FixedUpdate 안에서 다뤄야 하는 가장 직접적인 근거입니다.


Maximum Allowed Timestep

FixedUpdate가 무거우면 이런 일이 일어날 수 있습니다.
프레임이 느려짐 -> 다음 프레임에 FixedUpdate 더 많이 호출 -> 프레임이 더 느려짐 -> 더 많이 호출 -> ...(반복)
이걸 막는게 Edit > Project Settings > Time의 Maximum Allowed Timestep입니다. 위에서 확인했던 누적 시간(accumulator)을 이 값에서 잘라버립니다.

한 프레임 최대 호출 수 = maximumDeltaTime ÷ fixedDeltaTime = 0.3333 ÷ 0.02 ≈ 16회

그럼 초당 50회가 깨지는 것 아닌가?

맞습니다. 깨집니다. 다만 여기서 발생하는 게 어떤 종류의 오류인지 구분하는 것이 중요합니다.
시간이 잘려나가도 실행되는 각 스텝의 dt는 여전히 정확히 0.02입니다. 스텝을 크게 늘려서 따라잡는 게 아니라 스텝 자체를 버립니다.
만약 스텝을 늘렸다면 벽을 관통하는 터널링 현상, 스프링 발산 등의 물리 문제가 전부 일어나게 됩니다. Unity는 그걸 안하기 때문에 물리 계산 자체는 언제나 정상입니다.

대신 게임 시간이 실제 시간보다 뒤처집니다. 버려진 시간만큼 Time.time이 실제 시계보다 느려지고, 결과적으로 슬로우모션이 됩니다.
실제로 문제가 되는 것들은 아래와 같습니다.

  • 오디오 싱크 - 오디오 자체는 DSP 클럭으로 실제 시간대로 흐릅니다. 게임만 느려지면 리듬 게임 노트가 음악과 어긋납니다.
  • 멀티플레이 - 서버 시간과 클라이언트 시간이 벌어집니다.
  • 제한시간 - Time.time 기준 60초 타이머가 실제로는 63초(예시)가 됩니다.

그럼에도 이 설계가 옳은 이유

클램프가 없다고 상상해보면 명확합니다. 디버깅 중 브레이크 포인트에 10초 멈췄다가 재개하면 누산기에 10초가 쌓여 있으니 500번 연속 실행됩니다. 화면은 얼어붙고 캐릭터는 순간이동합니다. 알트탭에서도 같은 일이 벌어집니다.

선택지는 둘 뿐입니다.
1. 시간을 버린다 (Unity) -> 게임이 잠깐 느려진다
2. 전부 따라잡는다 -> 게임이 멈춘다
프레임이 감당 못하는 상황에서 실시간성을 포기하고 반응성을 지키는 쪽을 선택한 것입니다.


입력을 FixedUpdate에서 읽으면 안되는 이유

Input.GetKeyDown 같은 플래그는 프레임 단위로 세팅되고 리셋됩니다. 물리 루프와 주기가 다릅니다.

// 절대 이렇게 하지 말 것
void FixedUpdate()
{
    if (Input.GetKeyDown(KeyCode.Space))
        _rb.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
}
  • 해당 프레임에서 FixedUpdate가 2번 돌면 -> 플래그가 두 번 다 true -> 점프력 2배
  • 해당 프레임에서 0번 돌면 -> 다음 프레임에 플래그가 리셋됨 -> 점프 입력 씹힘
    이런 경우, 무작위로 발생하고 재현이 어려워서 가끔 점프가 안되는 유령 버그가 됩니다.
    올바른 패턴은 Update에서 받아서 FixedUpdate에서 소비하는 것입니다.
private bool _jumpQueued;

void Update()
{
    // 입력 수집은 프레임 단위로
    if (Input.GetKeyDown(KeyCode.Space)) _jumpQueued = true;
}

void FixedUpdate()
{
    if (!_jumpQueued) return;

    _rb.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
    _jumpQueued = false;   // 소비한 쪽에서 내린다
}

플래그를 Update에서 내리면 소비되기 전에 지워질 수 있기에 조심해야 합니다.


AddForce를 Update에서 부르면 안되는 이유

Rigidbody는 힘을 누산기에 모아뒀다가 물리 스텝에서 한 번에 적용하고 비웁니다.
그러나 Update에서 AddForce를 부르면 물리 스텝 사이에 몇 번 불렸느냐가 그대로 결과에 곱해집니다.

// 프레임률에 따라 결과가 달라진다
void Update()
{
    _rb.AddForce(Vector3.forward * 10f);
}

PC의 성능에 따라 결과가 달라질 수 있습니다.
Time.deltaTime을 곱해도 해결되지 않습니다. 힘 누산기가 비워지는 시점이 프레임이 아니라 물리 스텝이라서, 애초에 곱해야 할 dt가 프레임의 dt가 아니기 때문입니다.
FixedUpdate에 넣으면 호출 1회당 물리 스텝 1회가 보장되므로 이 문제가 원천적으로 사라집니다.


Transform 세계와 물리 세계는 별도다.

여기가 이 글의 결론입니다.

내 스크립트가 사는 곳과 실제 물리 계산이 일어나는 곳은 별도입니다. 두 세계는 항상 일치하지 않습니다.
PhysicsFixedUpdate 단계에서 맞춰질 뿐입니다.

그래서 FixedUpdate에서 하는 일은 "물리 세계에 주문서를 넣는 것"이고, 주문이 처리되는 건 바로 다음 줄의 시뮬레이션 단계입니다.

AutoSyncTransforms

두 세계의 동기화는 양방향인데 비대칭입니다.

  • PhysX -> Transform: 시뮬레이션이 끝나면 항상 자동으로 반영
  • Transform -> PhysX: 최신 Unity 프로젝트에서는 자동으로 안 함

즉, transform.position을 바꿔도 PhysX는 여전히 옛날 위치를 알고 있습니다.

transform.position = newPos;

// PhysX 세계는 아직 갱신 전 -> 옛 위치 기준으로 판정된다
if (Physics.Raycast(transform.position, Vector3.down, out var hit, 1f))
    Debug.Log(hit.collider.name);   // 엉뚱한 결과

해결책은 둘 중 하나입니다.

Physics.SyncTransforms();   // 강제로 밀어넣기 (비쌈)
_rb.MovePosition(newPos);   // 애초에 물리 세계에 명령하기

기본값이 꺼져 있는 이유는 성능입니다. Transform을 건드릴 때마다 PhysX에 밀어넣으면 오브젝트가 많을 때 비용이 큽니다.
대신 개발자가 두 세계의 존재를 알고 있어야 한다는 부담을 지우긴 합니다.


Rigidbody-Interpolate

물리는 50Hz, 화면은 144Hz라고 해봅시다. 그러면 렌더링되는 프레임 중 상당수는 물리가 갱신되지 않은 프레임이라 오브젝트가 같은 자리에 멈춰 있다가 툭 튀는 것처럼 보입니다.
해결은 Rigidbody의 Interpolate 옵션입니다.

설정동작용도
None보정 없음대부분의 소품 (비용 절약)
Interpolate직전 두 물리 상태 사이를 보간. 항상 한 스텝 뒤처짐플레이어·카메라 추적 대상
Extrapolate속도로 미래를 예측. 충돌 시 파고들었다 튐특수한 경우만

보통 플레이어와 카메라가 따라다니는 대상에만 켜고, 나머지는 끕니다. 전부 켜면 그 자체가 비용이기 때문입니다.
그리고 이건 카메라를 LateUpdate에 두는 것과 짝입니다. 물리 캐릭터를 따라가는 카메라는 LateUpdate + 대상 Interpolate 둘 다 있어야 안 떨립니다.


마무리

FixedUpdate에 넣는 기준은 "Rigidbody를 건드리는가" 입니다.

어디에이유
Rigidbody.AddForce / linearVelocityFixedUpdate누산기·결정론
Rigidbody.MovePosition / MoveRotationFixedUpdate다음 스텝까지 쓸어가며 이동
물리 이동용 지면 RaycastFixedUpdate판정과 적용을 같은 시점에
CharacterController.MoveUpdatePhysX 다이내믹스가 아님
transform 직접 이동 (Rigidbody 없음)Update물리와 무관
입력 수집Update프레임 단위 이벤트
카메라 추적LateUpdate대상 이동이 끝난 뒤
UI, 타이머, 애니메이션 파라미터Update

FixedUpdate는 물리 엔진과 대화하는 창구라고 생각하면 정확합니다. 물리 엔진과 할 얘기가 없으면 들어갈 이유가 없습니다.

결국 요약하면,
1. 호출 횟수가 가변이다 -> 그래서 입력을 여기서 읽으면 안된다.
2. 힘은 스텝 단위로 누적, 소비된다. -> 그래서 AddForce를 Update에서 부르면 안된다.
3. 물리 세계와 Transform 세계는 별개다 -> 그래서 transform.position 직접 수정이 위험하다.

로 정리할 수 있겠습니다.

[참고자료]
https://docs.unity3d.com/kr/2022.3/Manual/ExecutionOrder.html
https://www.youtube.com/watch?v=QtmGT-22PqA

profile
게임 개발을 좋아하는 컴공생

0개의 댓글