유니티 입문2026년 02월 28일· 9 min read

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

Start, Update, FixedUpdate가 Player Loop에서 언제 호출되는지와 C#→C++ 바인딩 흐름, 물리 스텝·프레임 스텝 분리 이유, 성능·GC 관점의 사용 기준을 다룬다. 초보도 납득 가능하게 엔진 관점으로 설명한다. 60fps 기준의 실전 팁 포함.

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

게임 만들다 처음 터지는 사고가 있다. 캐릭터를 Rigidbody로 움직이는데 Update에서 AddForce를 줬더니 어떤 PC에서는 미끄러지듯 빨라지고, 다른 PC에서는 덜 움직인다. 반대로 FixedUpdate로 옮겼더니 입력이 한 박자 늦고 카메라가 덜덜 떤다. Start에 초기화 코드를 넣었는데 비활성 오브젝트를 켰을 때 값이 초기화되지 않아 NullReferenceException이 뜬다. 이 문제는 “함수 이름의 차이”가 아니라 Unity 엔진이 프레임 루프와 물리 루프를 분리해 처리하는 구조에서 나온다.

핵심 개념

Start(), Update(), FixedUpdate()는 C#에서 보이는 함수처럼 보이지만, 실제로는 MonoBehaviour 메시지(message)를 엔진이 특정 루프 구간에서 호출해주는 약속이다. 개발자가 직접 호출하지 않아도 되는 이유는, 엔진이 Scene 안의 활성화된 Behaviour 목록을 관리하고 Player Loop 단계마다 “이번 단계에서 호출할 메시지”를 일괄 실행하기 때문이다.

Update는 ‘렌더 프레임’에 맞춰 1프레임에 1번(가능하면) 호출된다. 프레임이 120fps면 초당 120번, 30fps면 초당 30번이다. 입력 처리, UI, 카메라 추적, 애니메이션 파라미터 갱신처럼 “화면에 보이는 반응”을 프레임과 동기화할 때 쓴다.

FixedUpdate는 ‘물리 스텝’에 맞춰 일정한 간격으로 호출된다. 기본값은 Time.fixedDeltaTime = 0.02초(초당 50회)이다. 프레임이 느려지면 한 프레임 안에서 FixedUpdate가 2~5번 연속 호출될 수 있다. Rigidbody, Collider, Joint 같은 PhysX 기반 시뮬레이션을 일정한 시간 간격으로 적분(integration)하기 위해 분리되어 있다.

Start는 “첫 Update 직전”에 1회 호출되는 초기화 지점이다. Awake가 오브젝트 로드/생성 시점에 가깝고, Start는 실제로 게임 루프에 참여할 준비가 끝난 시점에 가깝다. 비활성(GameObject.SetActive(false)) 상태로 시작하면 Start는 호출되지 않고, 활성화되는 순간 첫 Update 전에 호출된다.

용어를 5개로 고정해서 잡는다. 1) Player Loop: 엔진이 매 프레임 실행하는 단계들의 집합. 2) Message: Update 같은 이름 기반 콜백. 3) Frame Step: 렌더 프레임 단위 진행. 4) Physics Step: fixedDeltaTime 단위의 물리 적분. 5) Catch-up: 프레임이 밀렸을 때 물리 스텝을 여러 번 돌려 따라잡는 동작.

BasicLoopPrint.cs
1using UnityEngine;
2
3public class BasicLoopPrint : MonoBehaviour
4{
5    private void Start()
6    {
7        Debug.Log($"Start frame={Time.frameCount} t={Time.time:F3}");
8    }
9
10    private void FixedUpdate()
11    {
12        Debug.Log($"FixedUpdate frame={Time.frameCount} ft={Time.fixedTime:F3}");
13    }
14
15    private void Update()
16    {
17        Debug.Log($"Update frame={Time.frameCount} t={Time.time:F3} dt={Time.deltaTime:F3}");
18    }
19}

이 스크립트를 빈 오브젝트에 붙이고 Play를 누르면 Console에 FixedUpdate 로그가 한 프레임에 여러 번 찍히는 순간이 보인다. Game 뷰에서 Stats를 켜고(우측 상단 Stats 버튼) 에디터 창을 일부러 리사이즈하거나 Scene 뷰를 크게 흔들면 프레임이 떨어지며 catch-up이 터진다. Update는 프레임마다 1줄인데 FixedUpdate는 연속으로 여러 줄이 나온다. 이 차이가 ‘언제 써야 하는지’의 출발점이다.

엔진 관점에서의 내부 동작

C#의 MonoBehaviour는 네이티브(C++) 엔진 오브젝트(대개 C++ 쪽의 Component/Behaviour)에 붙는 관리 래퍼다. Scene에 있는 컴포넌트의 실체 데이터는 네이티브 메모리에 있고, C# 인스턴스는 그 네이티브 오브젝트를 가리키는 핸들(InstanceID 기반)을 들고 있다. 그래서 C#에서 Update를 ‘등록’하는 코드가 없어도, 엔진이 “활성 Behaviour 목록”을 네이티브에서 유지하고 호출 타이밍이 오면 C# 쪽으로 역호출한다.

Player Loop는 대략 Initialize → EarlyUpdate → FixedUpdate → PreUpdate → Update → PreLateUpdate → LateUpdate → PostLateUpdate 같은 단계로 구성된다. FixedUpdate 단계 안에서 물리 시뮬레이션(Physics.Simulate 또는 내부 PhysX step)과 함께 MonoBehaviour.FixedUpdate 메시지 호출이 묶여 있다. Update 단계는 입력, 스크립트 Update, 애니메이션 일부 평가가 섞여 있고, 렌더링 준비는 더 뒤 단계에서 진행된다.

왜 Update와 FixedUpdate를 분리했을까. 물리 적분은 dt가 일정할수록 안정적이다. 프레임 기반 dt(Time.deltaTime)는 0.008~0.05처럼 흔들린다. dt가 흔들리면 힘/속도/충돌이 매 프레임 달라지고, 같은 입력이라도 결과가 달라진다. 엔진은 fixedDeltaTime으로 물리를 고정 스텝으로 돌려 재현성을 확보한다. 대신 프레임이 밀리면 catch-up으로 FixedUpdate를 여러 번 수행해 시간을 따라잡는다.

Start가 ‘첫 Update 직전’인 이유도 엔진 쪽 준비 순서 때문이다. Awake는 오브젝트가 로드될 때(씬 로딩, Instantiate, AddComponent) 직후에 호출되며, 직렬화된 필드가 C# 객체로 주입된 다음이다. 하지만 다른 오브젝트의 Awake/OnEnable 순서에 의존하면 초기화 레이스가 생긴다. Start는 한 프레임에 모아서 호출되기 때문에, 같은 프레임에 활성인 오브젝트들끼리는 Awake/OnEnable이 이미 끝난 상태에서 Start가 진행되어 의존성 문제를 완화한다.

메모리 관점에서 메시지 호출 자체는 GC Alloc을 만들지 않는 쪽으로 설계되어 있다. 문제는 Update에서 매 프레임 문자열 보간($"...") 같은 것을 하면 그 순간 managed heap에 문자열이 생기고 GC Alloc이 튄다. FixedUpdate에서도 똑같이 터지지만, catch-up이 걸리면 같은 프레임에 여러 번 할당되어 더 빨리 GC를 유발한다.

C#→C++ 경계 비용도 있다. Rigidbody.AddForce, Transform.position set 같은 API는 내부적으로 네이티브 호출(바인딩)을 한다. Update에서 매 프레임 수백 개 오브젝트가 바인딩을 왕복하면 CPU가 바쁘다. FixedUpdate에서 Rigidbody를 다루는 이유는 ‘물리 월드 상태가 업데이트되는 구간’과 맞추기 위해서이고, Update에서 Rigidbody를 만지면 물리 스텝 사이의 중간 상태를 건드려 보간/외삽이 꼬일 수 있다.

LoopOrderProbe.cs
1using UnityEngine;
2
3public class LoopOrderProbe : MonoBehaviour
4{
5    private int _fixedCountThisFrame;
6
7    private void OnEnable()
8    {
9        Debug.Log($"OnEnable frame={Time.frameCount}");
10    }
11
12    private void Start()
13    {
14        Debug.Log($"Start frame={Time.frameCount}");
15    }
16
17    private void FixedUpdate()
18    {
19        _fixedCountThisFrame++;
20        Debug.Log($"FixedUpdate #{_fixedCountThisFrame} frame={Time.frameCount} fixedTime={Time.fixedTime:F3}");
21    }
22
23    private void Update()
24    {
25        Debug.Log($"Update frame={Time.frameCount} fixedCountThisFrame={_fixedCountThisFrame}");
26        _fixedCountThisFrame = 0;
27    }
28}

이 코드는 “한 프레임에 FixedUpdate가 몇 번 돌았는지”를 Update에서 숫자로 확인하게 만든다. Console에서 Update 로그에 fixedCountThisFrame이 2, 3으로 찍히면 catch-up이 발생한 것이다. 이때 Update에서 입력을 받아 FixedUpdate에서 힘을 적용하는 구조를 쓰면, 한 프레임에 입력 1번으로 힘이 3번 적용되는 사고가 생긴다. 입력은 Update에서 수집하되, FixedUpdate에서는 ‘누적된 입력 상태’를 사용해야 한다.

FixedVsUpdateMovement.cs
1using UnityEngine;
2
3public class FixedVsUpdateMovement : MonoBehaviour
4{
5    public Rigidbody rb;
6    public float moveForce = 20f;
7
8    private float _moveX;
9
10    private void Reset()
11    {
12        rb = GetComponent<Rigidbody>();
13    }
14
15    private void Update()
16    {
17        _moveX = Input.GetAxisRaw("Horizontal");
18    }
19
20    private void FixedUpdate()
21    {
22        if (rb == null) return;
23        rb.AddForce(new Vector3(_moveX, 0f, 0f) * moveForce, ForceMode.Acceleration);
24    }
25}

이 스크립트는 입력 샘플링을 Update에서 하고, 물리 적용을 FixedUpdate에서 한다. 실행하면 프레임이 흔들려도 이동 감각이 일정하게 유지된다. 반대로 AddForce를 Update로 옮기면, 프레임이 높은 PC에서 더 많이 호출되어 더 멀리 간다. “물리는 fixed step, 입력은 frame step”이라는 분리가 엔진 구조와 맞는다.

실습하기

1단계: 프로젝트 설정(물리 스텝을 눈으로 확인)

Unity Hub → New project → 3D(Core) 선택 → 프로젝트 생성한다. 버전은 2022.3 LTS 또는 2023.2 이상이면 동일하게 따라간다. 프로젝트가 열리면 Edit → Project Settings → Time에서 Fixed Timestep이 0.02로 되어 있는지 확인한다.

Game 뷰 우측 상단 Stats 버튼을 켠다. Play 중에 FPS가 흔들릴 때 FixedUpdate catch-up이 더 잘 보인다. Console은 Window → General → Console로 열고, Collapse는 꺼둔다(연속 로그를 실제 개수대로 보기 위해서).

TimeSettingsPrinter.cs
1using UnityEngine;
2
3public class TimeSettingsPrinter : MonoBehaviour
4{
5    private void Start()
6    {
7        Debug.Log($"fixedDeltaTime={Time.fixedDeltaTime} maximumDeltaTime={Time.maximumDeltaTime}");
8        Debug.Log($"targetFrameRate={Application.targetFrameRate} vSync={QualitySettings.vSyncCount}");
9    }
10
11    private void Update()
12    {
13        if (Input.GetKeyDown(KeyCode.F1))
14        {
15            Time.fixedDeltaTime = 0.01f;
16            Debug.Log("fixedDeltaTime changed to 0.01 (100Hz)");
17        }
18    }
19}

이 코드를 실행하고 F1을 누르면 물리 스텝이 50Hz에서 100Hz로 바뀐다. 같은 장면에서 FixedUpdate 로그가 더 자주 찍히는 걸 확인한다. fixedDeltaTime을 바꾸면 물리 비용이 늘고, catch-up이 더 쉽게 발생한다. 실무에서 “물리가 버벅인다”는 제보가 들어오면 fixedDeltaTime을 무작정 줄이는 선택이 왜 위험한지 체감된다.

2단계: 씬 구성(프레임 이동 vs 물리 이동 차이)

Hierarchy 우클릭 → 3D Object → Plane 생성한다. Plane의 Transform은 Position (0,0,0) 그대로 둔다. Hierarchy 우클릭 → 3D Object → Cube 생성 후 Position (0,0.5,0)로 올린다. Cube에 Rigidbody를 추가한다(Inspector → Add Component → Rigidbody).

Rigidbody Inspector에서 Collision Detection을 Continuous로 바꾼다(빠르게 움직일 때 관통 방지). Interpolate는 Interpolate로 바꾼다. 이 옵션은 물리 스텝과 렌더 프레임 사이를 보간해 카메라/오브젝트 떨림을 줄인다. 옵션을 끄고 켤 때 화면에서 큐브 움직임이 어떻게 달라지는지 비교한다.

MoveInUpdateBad.cs
1using UnityEngine;
2
3public class MoveInUpdateBad : MonoBehaviour
4{
5    public Rigidbody rb;
6    public float speed = 6f;
7
8    private void Reset()
9    {
10        rb = GetComponent<Rigidbody>();
11    }
12
13    private void Update()
14    {
15        if (rb == null) return;
16        float x = Input.GetAxisRaw("Horizontal");
17        Vector3 v = new Vector3(x * speed, rb.linearVelocity.y, 0f);
18        rb.linearVelocity = v;
19    }
20}

이 스크립트를 Cube에 붙이고 Play하면, PC 성능이나 에디터 상태에 따라 이동 감각이 들쭉날쭉해진다. 특히 Scene 뷰를 열어둔 채로 에디터를 무겁게 만들면 Update 호출 간격이 흔들려 linearVelocity 세팅 타이밍이 물리 스텝과 어긋난다. 같은 키 입력인데도 미세하게 떨리거나, 충돌 반응이 불안정해지는 장면이 나온다.

MoveInFixedGood.cs
1using UnityEngine;
2
3public class MoveInFixedGood : MonoBehaviour
4{
5    public Rigidbody rb;
6    public float speed = 6f;
7
8    private float _x;
9
10    private void Reset()
11    {
12        rb = GetComponent<Rigidbody>();
13    }
14
15    private void Update()
16    {
17        _x = Input.GetAxisRaw("Horizontal");
18    }
19
20    private void FixedUpdate()
21    {
22        if (rb == null) return;
23        Vector3 v = new Vector3(_x * speed, rb.linearVelocity.y, 0f);
24        rb.linearVelocity = v;
25    }
26}

같은 씬에서 스크립트를 교체하면, 프레임이 흔들려도 이동이 훨씬 일정해진다. Game 뷰에서 큐브가 바닥과 접촉할 때 미세 진동이 줄어드는 것도 보인다(Interpolate 켠 상태 기준). “입력 샘플링은 Update, 물리 상태 변경은 FixedUpdate”가 엔진의 단계 분리와 맞물리기 때문이다.

3단계: Start 타이밍(비활성 오브젝트와 초기화 레이스)

Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만든다. 이름을 Spawner로 바꾼다. Hierarchy 우클릭 → UI → Canvas 생성한다. Canvas 아래에 UI → Button 생성한다. Button의 On Click() 이벤트에 Spawner 오브젝트를 드래그해 넣고, 스크립트 메서드를 연결할 준비를 한다.

Hierarchy 우클릭 → Create Empty로 Target 오브젝트를 만든다. Inspector에서 Target을 비활성(체크박스 해제)로 둔다. Target에 아래 스크립트를 붙인다. Spawner 스크립트는 Button 클릭으로 Target을 활성화한다. Play 후 버튼을 눌렀을 때 Start가 언제 찍히는지 Console로 확인한다.

StartOnActivate.cs
1using UnityEngine;
2
3public class StartOnActivate : MonoBehaviour
4{
5    private void Awake()
6    {
7        Debug.Log($"Awake name={name} frame={Time.frameCount}");
8    }
9
10    private void OnEnable()
11    {
12        Debug.Log($"OnEnable name={name} frame={Time.frameCount}");
13    }
14
15    private void Start()
16    {
17        Debug.Log($"Start name={name} frame={Time.frameCount}");
18    }
19}

Target이 비활성으로 시작하면 Play 시점에 Awake는 호출되지만(OnEnable/Start는 호출되지 않음), 활성화되는 순간 OnEnable이 먼저 찍히고 같은 프레임의 첫 Update 직전에 Start가 찍힌다. 처음에 나도 이 순서를 착각해서, 비활성 상태로 풀에 넣어둔 오브젝트가 ‘Start에서 캐싱하던 참조’를 못 잡아 NullReferenceException이 났다. 콘솔에는 NullReferenceException: Object reference not set to an instance of an object 라고만 떠서, 3시간 동안 프리팹 참조가 깨진 줄로만 의심했다.

ActivateTargetFromButton.cs
1using UnityEngine;
2
3public class ActivateTargetFromButton : MonoBehaviour
4{
5    public GameObject target;
6
7    public void Activate()
8    {
9        if (target == null)
10        {
11            Debug.LogError("target is null. Assign it in Inspector.");
12            return;
13        }
14        target.SetActive(true);
15    }
16}

Spawner(ActivateTargetFromButton)를 Spawner 오브젝트에 붙이고, Inspector에서 Target 슬롯에 비활성 Target 오브젝트를 드래그한다. Button 선택 → Inspector → Button 컴포넌트 → On Click()의 + 버튼 → Spawner 드래그 → 함수 목록에서 ActivateTargetFromButton.Activate 선택한다. Play 후 버튼 클릭 시 Target의 OnEnable/Start 로그가 찍힌다. Start에 의존하는 초기화는 ‘활성화 시점이 늦어질 수 있다’는 전제가 필요하다.

심화 활용

패턴 1: 입력은 Update에서 수집, 물리는 FixedUpdate에서 소비(상태 버퍼)

입력을 FixedUpdate에서 읽으면, 프레임이 빠를 때 입력 샘플링이 듬성듬성해진다. 반대로 입력을 Update에서 읽고 FixedUpdate에서 바로 힘을 주면, catch-up 시 한 프레임에 힘이 여러 번 적용되는 사고가 생긴다. 해결책은 ‘입력 상태를 버퍼로 저장’하고, FixedUpdate에서 그 상태를 1스텝당 1번만 소비하는 방식이다.

BufferedInputMotor.cs
1using UnityEngine;
2
3public class BufferedInputMotor : MonoBehaviour
4{
5    public Rigidbody rb;
6    public float accel = 30f;
7
8    private Vector2 _move;
9    private bool _jumpPressed;
10
11    private void Reset()
12    {
13        rb = GetComponent<Rigidbody>();
14    }
15
16    private void Update()
17    {
18        _move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
19        if (Input.GetKeyDown(KeyCode.Space)) _jumpPressed = true;
20    }
21
22    private void FixedUpdate()
23    {
24        if (rb == null) return;
25        Vector3 a = new Vector3(_move.x, 0f, _move.y) * accel;
26        rb.AddForce(a, ForceMode.Acceleration);
27
28        if (_jumpPressed)
29        {
30            rb.AddForce(Vector3.up * 5f, ForceMode.VelocityChange);
31            _jumpPressed = false;
32        }
33    }
34}

Space는 ‘한 번 눌림’ 이벤트라서 Update에서 플래그로 저장하고 FixedUpdate에서 1회 처리 후 false로 내린다. 이 구조를 쓰면 catch-up이 걸려도 점프가 3번 나가는 일이 없다. Profiler에서 Physics.Simulate가 밀릴 때도 입력 처리 비용이 물리 반복 횟수에 비례해 폭증하지 않는다.

패턴 2: Update는 시각적 보정, FixedUpdate는 상태 결정(보간/추적)

카메라가 Rigidbody를 따라갈 때 FixedUpdate에서 카메라 Transform을 움직이면 화면이 떨린다. 물리 스텝은 50Hz인데 렌더는 60Hz 이상이라, 카메라가 50Hz로만 갱신되기 때문이다. 해결책은 물리 오브젝트는 FixedUpdate에서 움직이고, 카메라는 Update/LateUpdate에서 Rigidbody의 보간된 위치를 읽거나(Interpolate) 별도 스무딩을 적용한다.

FollowRigidbodyLate.cs
1using UnityEngine;
2
3public class FollowRigidbodyLate : MonoBehaviour
4{
5    public Rigidbody target;
6    public Vector3 offset = new Vector3(0f, 4f, -8f);
7    public float smooth = 12f;
8
9    private void LateUpdate()
10    {
11        if (target == null) return;
12        Vector3 desired = target.position + offset;
13        transform.position = Vector3.Lerp(transform.position, desired, 1f - Mathf.Exp(-smooth * Time.deltaTime));
14        transform.LookAt(target.position);
15    }
16}

LateUpdate를 쓰는 이유는 같은 프레임에서 target이 Update(또는 애니메이션 평가)로 움직인 뒤 최종 위치를 기준으로 카메라가 따라가게 만들기 위해서다. Rigidbody Interpolate가 켜져 있으면 target.position이 렌더 프레임에 맞춰 보간된 값을 제공해 떨림이 더 줄어든다. Interpolate를 끄면 같은 코드인데도 미세한 끊김이 보인다.

내 흑역사 1: Update에서 Rigidbody.MovePosition을 호출해 네트워크 캐릭터를 움직였더니, 클라이언트마다 충돌 결과가 달랐다. 어떤 기기에서는 계단을 타고, 어떤 기기에서는 계단 모서리에 걸렸다. 원인은 물리 월드가 fixed step으로만 확정되는데, frame step에서 위치를 강제로 바꾸며 충돌 해결 순서가 흔들린 것이다. Profiler에서 Physics.ProcessReports가 스파이크 치는 프레임이 있었고, 그 프레임에 catch-up이 겹쳤다.

내 흑역사 2: FixedUpdate에서 Debug.Log와 문자열 보간을 잔뜩 넣고 “물리가 느리다”고 착각했다. Profiler에서 GC.Alloc이 FixedUpdate마다 200B~1KB씩 쌓였고, 5초마다 GC.Collect가 8~15ms 튀면서 프레임 드랍이 났다. 로그를 끄고, 필요한 값은 OnGUI나 UI 텍스트로 표시하도록 바꾸니 물리 스텝은 정상이었다. 느린 건 물리가 아니라 내가 만든 GC였다.

자주 하는 실수

실수 1: Update에서 AddForce/MovePosition을 호출해 프레임 의존 이동이 됨

증상: 같은 키 입력인데 PC마다 이동 속도가 다르거나, 에디터에서 Scene 뷰를 켜면 캐릭터가 갑자기 느려진다. 충돌이 불안정해 벽에 비비면 튕기거나 관통 직전까지 간다.

원인: Update 호출 횟수는 FPS에 비례한다. Update에서 힘을 누적하면 초당 적용 횟수가 달라진다. 물리 월드는 fixed step에서 적분되는데, frame step에서 상태를 바꾸면 물리 스텝 경계가 흐트러진다.

해결: 입력은 Update에서 읽고, Rigidbody 상태 변경은 FixedUpdate에서 한다. 이동량 기반이라면 Time.fixedDeltaTime을 기준으로 계산하거나, ForceMode를 적절히 선택한다(Acceleration/VelocityChange 등).

실수 2: FixedUpdate에서 Input.GetKeyDown을 읽어 점프가 씹힘

증상: Space를 눌렀는데 가끔 점프가 안 된다. 특히 FPS가 높을수록 재현이 더 어렵고, 낮을수록 더 자주 발생한다.

원인: GetKeyDown은 ‘프레임’ 이벤트다. FixedUpdate는 프레임과 독립이라, 어떤 프레임에서는 FixedUpdate가 0번 돌 수도 있고 2번 돌 수도 있다. 그 프레임에 FixedUpdate가 0번이면 눌림 이벤트를 놓친다.

해결: Update에서 GetKeyDown을 읽어 플래그로 저장하고 FixedUpdate에서 소비한다. 점프 같은 단발 이벤트는 버퍼링이 필수다.

실수 3: Start에만 의존해 풀링 오브젝트 초기화가 누락됨

증상: 오브젝트 풀에서 꺼낸 탄환이 가끔 방향이 이상하거나, TrailRenderer가 이전 프레임 잔상을 끌고 나온다. 콘솔에는 NullReferenceException이 간헐적으로 뜬다.

원인: Start는 “활성화 후 첫 Update 직전 1회”라서, 풀에 비활성으로 오래 있던 오브젝트는 Start가 이미 한 번 실행된 상태다. 재사용 시점의 초기화가 Start에서 다시 돌지 않는다.

해결: 재사용 초기화는 OnEnable에서 처리하거나, 풀 매니저가 Spawn 시점에 명시적으로 Init 메서드를 호출한다. Start는 ‘최초 1회’에만 쓰는 전제가 필요하다.

실수 4: FixedUpdate에서 Transform 직접 수정(Transform.position)으로 물리와 불일치

증상: Rigidbody가 붙은 오브젝트가 순간이동하듯 움직이고, 충돌이 튀거나 관통한다. Inspector에서 Rigidbody가 있는 오브젝트인데 Transform을 건드렸다는 경고성 상황이 발생한다.

원인: Rigidbody는 물리 엔진이 위치/회전을 소유하는 구조다. Transform을 직접 바꾸면 네이티브 물리 바디의 상태와 씬 Transform 동기화가 꼬인다. 특히 FixedUpdate 안에서 Transform을 바꾸면 같은 스텝에서 충돌 해결이 비정상으로 끝날 수 있다.

해결: Rigidbody.MovePosition/MoveRotation 또는 AddForce/velocity 계열을 사용한다. 순간이동이 필요하면 rb.position을 세팅한 뒤 rb.velocity를 정리하고, 필요 시 Physics.SyncTransforms 비용을 고려한다.

실수 5: Update에서 GetComponent를 매 프레임 호출해 CPU가 새는 걸 놓침

증상: 오브젝트 수가 늘면 프레임이 서서히 떨어진다. Profiler에서 Scripts 항목이 두껍게 보이고, 특정 스크립트의 Update가 0.2~1.0ms를 먹는다.

원인: GetComponent<T>()는 네이티브 컴포넌트 리스트를 탐색하고 C# 래퍼로 반환하는 경계를 넘는다. Update에서 매번 호출하면 ‘탐색 + 바인딩’ 비용이 프레임마다 반복된다. 컴포넌트가 많을수록 탐색이 길어진다.

해결: Awake/Start에서 참조를 캐싱한다. 동적으로 바뀌는 경우만 이벤트 시점에 다시 찾는다. Profiler에서 Timeline으로 GetComponent 호출 빈도를 먼저 확인한다.

GetComponentCacheExample.cs
1using UnityEngine;
2
3public class GetComponentCacheExample : MonoBehaviour
4{
5    private Rigidbody _rb;
6
7    private void Awake()
8    {
9        _rb = GetComponent<Rigidbody>();
10    }
11
12    private void Update()
13    {
14        if (_rb == null) return;
15        if (Input.GetKey(KeyCode.R))
16        {
17            _rb.AddForce(Vector3.right * 2f, ForceMode.Acceleration);
18        }
19    }
20}

이 코드는 Update 안에서 GetComponent를 호출하지 않는다. Profiler에서 Deep Profile을 켜면(Profiler 창 우측 상단 톱니 → Deep Profile) GetComponent 관련 샘플이 사라진다. 처음에 나도 “GetComponent 한 번이 얼마나 하겠어”라고 생각했다가, 500개 오브젝트가 매 프레임 호출하면서 스크립트가 1ms 넘게 먹는 걸 보고 멈칫했다. 캐싱 후에는 그 1ms가 거의 0에 가까워졌다.

성능 최적화 체크리스트

  • Update에서 프레임 의존 값은 Time.deltaTime을 곱해 초당 기준으로 환산한다(이동량, 쿨타임).
  • Rigidbody에 힘/속도/MovePosition을 적용하는 코드는 FixedUpdate로 보낸다.
  • Input.GetKeyDown/GetButtonDown 같은 단발 입력은 Update에서 플래그로 저장하고 FixedUpdate에서 1회 소비한다.
  • FixedUpdate에서 문자열 보간 로그를 금지하고, 필요하면 조건부 로그 또는 UI 텍스트로 대체한다(GC.Alloc 방지).
  • Profiler에서 Physics.Simulate, Scripts.UpdateBehaviourManager, GC.Alloc을 같은 프레임에서 함께 확인한다.
  • Time.fixedDeltaTime을 변경하기 전에 물리 비용(오브젝트 수, 콜라이더 복잡도, Solver Iteration)을 먼저 줄인다.
  • Rigidbody Interpolate를 켜고, 카메라 추적은 LateUpdate에서 처리해 렌더 프레임 떨림을 줄인다.
  • 풀링 오브젝트 재초기화는 Start가 아니라 OnEnable/Init(명시 호출)로 설계한다.
  • Update에서 GetComponent/Find/Camera.main 같은 탐색 API를 반복 호출하지 않고 Awake/Start에서 캐싱한다.
  • FixedUpdate에서 Transform.position을 직접 수정하지 않고 Rigidbody API를 사용한다(물리-트랜스폼 불일치 방지).
  • 프레임 드랍 시 catch-up이 과도해지면 Time.maximumDeltaTime을 확인해 물리 폭주를 막는다.
  • Script Execution Order가 필요한 경우 Project Settings → Script Execution Order에서 최소한으로만 조정하고, 의존성은 이벤트/참조로 푼다.

자주 묻는 질문

Update와 FixedUpdate 중 어디에 이동 코드를 둬야 하나? Transform 이동과 Rigidbody 이동 기준이 헷갈린다.

Transform 기반 이동(캐릭터 컨트롤러, UI, 카메라, 단순 연출)은 Update/LateUpdate가 기준이다. 화면 프레임과 동기화된 위치 변화가 필요하고, 물리 적분과 무관하기 때문이다. Rigidbody 기반 이동(AddForce, velocity, MovePosition)은 FixedUpdate가 기준이다. 물리 월드는 fixedDeltaTime 단위로만 상태가 확정되고, 그 구간에서 힘/속도를 적용해야 재현성이 유지된다. 혼합이 필요하면 ‘물리 오브젝트는 FixedUpdate에서 상태 결정, 시각 요소는 Update/LateUpdate에서 보정’으로 분리한다. 다음 학습 키워드는 Rigidbody Interpolation, CharacterController vs Rigidbody, LateUpdate camera follow이다.

FixedUpdate가 한 프레임에 여러 번 실행되는 이유는 무엇이고, 그게 왜 위험한가?

FixedUpdate는 ‘프레임’이 아니라 ‘시간’을 따라간다. 엔진은 Time.fixedDeltaTime 간격으로 물리 스텝을 진행해야 하므로, 프레임이 느려져서 누적 시간이 fixedDeltaTime을 여러 번 넘으면 catch-up으로 물리 스텝을 연속 수행한다. 위험한 지점은 FixedUpdate에서 프레임 이벤트(입력 Down, UI 클릭, 네트워크 패킷 처리)를 처리할 때다. 한 프레임에 FixedUpdate가 3번 돌면 같은 이벤트가 3번 처리될 수 있고, 반대로 0번이면 이벤트를 놓칠 수 있다. 해결은 Update에서 이벤트를 수집해 상태로 저장하고 FixedUpdate에서는 그 상태를 스텝 단위로 소비하는 구조다. 다음 학습 키워드는 fixed timestep, catch-up, maximumDeltaTime이다.

Start와 Awake는 언제 쓰나? 비활성 오브젝트에서 Start가 안 도는 게 정상인가?

Awake는 오브젝트가 로드/생성될 때 호출되고, Start는 활성화된 뒤 첫 Update 직전에 1회 호출된다. 비활성으로 시작하면 Start는 호출되지 않는 것이 정상이며, 활성화되는 순간(OnEnable) 이후 첫 Update 전에 호출된다. 그래서 풀링 오브젝트나 메뉴에서 꺼내 쓰는 프리팹은 Start에 재초기화를 두면 누락된다. Awake에는 ‘참조 캐싱, 컴포넌트 확보, 단 한 번만 필요한 준비’를 넣고, OnEnable에는 ‘활성화될 때마다 리셋되어야 하는 상태’를 넣는 편이 사고가 적다. Start는 “다른 오브젝트들의 Awake/OnEnable이 끝난 뒤 1회”라는 성격을 이용해 의존성 초기화에 쓰되, 재사용이 필요한 로직은 넣지 않는다. 다음 학습 키워드는 execution order, object pooling lifecycle이다.

Update에서 GetComponent를 매번 호출하면 왜 느린가? 캐싱이 왜 효과가 큰가?

GetComponent<T>()는 C# 객체 내부에서 끝나는 함수가 아니라, 네이티브 쪽 GameObject/Component 컨테이너를 탐색하는 바인딩 호출을 포함한다. 컴포넌트 목록은 네이티브 메모리에 있고, 제네릭 GetComponent는 내부적으로 타입 정보를 전달해 탐색한 뒤, 결과를 C# 래퍼로 돌려준다. 이 과정이 매 프레임 반복되면 ‘탐색 비용 + C#↔C++ 경계 비용’이 누적된다. 오브젝트 수가 500개, 각자 Update에서 1회 호출이면 프레임당 500회 경계 왕복이 생긴다. 반면 Awake/Start에서 한 번만 캐싱하면 이후에는 C# 참조 접근만 남아 비용이 사실상 0에 가깝다. 다음 학습 키워드는 native binding, component lookup cost, profiler deep profile이다.

FixedUpdate에서 카메라를 따라가게 하면 왜 떨리나? LateUpdate가 왜 자주 추천되나?

FixedUpdate는 기본 50Hz, 렌더 프레임은 60Hz 이상인 경우가 많다. 카메라를 FixedUpdate에서만 갱신하면 카메라 위치가 50Hz로 계단처럼 변하고, 렌더는 그 사이 프레임을 그리면서 ‘멈췄다-이동’ 패턴이 눈에 띈다. LateUpdate는 같은 프레임에서 대상 오브젝트의 Update/애니메이션 평가가 끝난 뒤 실행되므로, 그 프레임의 최종 위치를 기준으로 카메라가 따라가게 된다. Rigidbody Interpolate까지 켜면 target.position이 렌더 프레임에 맞춰 보간되어 시각적 떨림이 더 줄어든다. 다음 학습 키워드는 interpolation, LateUpdate order, Cinemachine update method이다.

Time.deltaTime과 Time.fixedDeltaTime을 섞어서 쓰면 어떤 문제가 생기나?

deltaTime은 프레임 간격이고 fixedDeltaTime은 물리 스텝 간격이다. Update에서 fixedDeltaTime을 곱하면 프레임이 120fps일 때 초당 이동량이 줄어들고, 30fps일 때 늘어난다. 반대로 FixedUpdate에서 deltaTime을 쓰면 catch-up 상황에서 스텝 간격이 의도와 다르게 계산되어 힘/속도 적분이 흔들린다. 규칙은 단순하다. Update에서는 deltaTime, FixedUpdate에서는 fixedDeltaTime 또는 물리 API가 내부에서 fixedDeltaTime을 쓰는 방식(AddForce 등)을 따른다. 혼합이 필요하면 “입력/시각은 deltaTime, 물리 상태 결정은 fixedDeltaTime”으로 경계를 명확히 둔다. 다음 학습 키워드는 timestep integration, semi-implicit Euler, frame-rate independence이다.

Coroutine의 yield return null은 왜 필요한가? Update와 어떤 관계가 있나?

Coroutine은 스레드가 아니라, 엔진이 Player Loop에서 특정 시점에 IEnumerator를 재개(resume)해주는 스케줄링 기능이다. yield return null은 “다음 프레임까지 중단”을 의미하고, 엔진은 Update 루프가 한 번 돌고 난 뒤(정확한 재개 지점은 Unity의 코루틴 처리 단계) 해당 IEnumerator를 다시 MoveNext 호출한다. 그래서 yield return null이 없으면 한 프레임 안에서 while 루프가 끝날 때까지 계속 실행되어 프레임을 멈춘다. FixedUpdate와 직접 연결되는 코루틴(wait for fixed update)도 있는데, 그 경우 물리 스텝 단계에서 재개된다. 코루틴은 편하지만, 매 프레임 할당을 만들기 쉬워(클로저, 박싱, 문자열) Profiler에서 GC.Alloc을 같이 확인해야 한다. 다음 학습 키워드는 coroutine scheduler, WaitForFixedUpdate, GC allocation in IEnumerator이다.

관련 글

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

Start, Update, FixedUpdate가 Player Loop에서 언제 호출되는지와 C#→C++ 바인딩 흐름, 물리 스텝·프레임 스텝 분리 이유, 성능·GC 관점의 사용 기준을 다룬다. 초보도 납득 가능하게 엔진 관점으로 설명한다. 60fps 기준의 실전 팁 포함.

18. Unity State 패턴: Update 지옥을 끊는 구조와 엔진 호출 흐름

18. Unity State 패턴: Update 지옥을 끊는 구조와 엔진 호출 흐름

Unity에서 State 패턴이 필요한 이유를 PlayerLoop, C#→네이티브 호출, GC/성능 관점으로 설명하고, 이동/점프 예제로 구조를 바로 적용한다. (초보자용) 15년차 실무 튜토리얼 스타일로 구성했다.) 참고: description은 140~160자 제한이므로 아래 문자열을 사용한다.

14. Rigidbody 질량·중력·드래그로 떨어짐 감 빠르게 세팅하는 법

14. Rigidbody 질량·중력·드래그로 떨어짐 감 빠르게 세팅하는 법

Rigidbody mass·useGravity·drag·angularDrag가 낙하 감각에 미치는 영향을 Player Loop·C++ 물리 스텝 관점에서 설명하고, 빠른 세팅 절차와 실무 패턴까지 다룬다. (초보용)​)​)​)​)​)​)​)​)​)​)​)