XR 플밍 - 8. UnityEngine3D 입문 - Coroutine과 유니티 이벤트 (4/18)

이형원·2025년 4월 18일
0

XR플밍

목록 보기
47/215

1. 코루틴(Coroutine)

1.1 Coroutine 이란?

작업을 다수의 프레임에 분산하여 처리하는 동기식 작업으로, 실행을 일시정지하고 중단한 부분부터 재개하여 처리하는 것으로 작업을 분산처리하는 방식이다.
코루틴은 스레드가 아니며 코루틴의 작업은 메인 스레드에서 실행된다.

1.2 yield 키워드

호출자(Caller)에게 컬렉션 데이터를 하나씩 리턴할 때 사용한다. Enumerator(Iterator)라고 불리는 이러한 기능은 집합적인 데이터 셋으로부터 데이터를 하나씩 호출자에게 보내주는 역할을 한다.

기본적인 사용방법은 아래와 같다

yield return (조건)

예시로 사용할 수 있는 코드로 아래와 같이 유니티 내에서 사용해볼 수 있다.

using System.Collections;
using UnityEngine;

public class CoroutineTester2 : MonoBehaviour
{
    private IEnumerator _routine;
    private void Start()
    {
        _routine = Routine();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            _routine.MoveNext();
        }
    }
    IEnumerator Routine()
    {
        Debug.Log(1);
        yield return 1;
        Debug.Log(2);
        yield return 2;
        Debug.Log(3);
        yield return 3;
        Debug.Log(4);
        yield return 4;
        Debug.Log(5);
        yield return 5;
    }
}

결과 : 스페이스를 누를 때마다 1, 2, 3, 4, 5가 출력됨

1.3 코루틴 사용 예시 : 2초 뒤에 점프하는 기능

코루틴이 어떻게 돌아가는지 확인해보기 위해 간단한 기능을 만들어 보자.
스페이스바를 입력하면 Debug.Log로 점프 준비를 출력하고, 2초 뒤에 점프하는 기능을 만들어보자.

using System.Collections;
using UnityEngine;

public class CoroutineTester : MonoBehaviour
{
    [SerializeField] Rigidbody rigid;
    [SerializeField] float jumpPower;

    private Coroutine jumpCoroutine;
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            StartCoroutine(Routine());
        }        
    }
    IEnumerator Routine()
    {
        Debug.Log("점프 대기 시작!");
        yield return new WaitForSeconds(2f);
        Debug.Log("점프!");
        rigid.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
    }
}

이와 같이 코루틴은 몇 초 뒤에 실행시켜야 할 내용 또는 특정 조건 이후에 실행되어야 하는 내용을 만드는 데에 사용할 수 있다.

1.4 코루틴의 사용 이유

가령 이렇게 생각할 수도 있다.
굳이 코루틴을 사용하지 않아도 총알 발사 쿨타임 변수를 만들어 Update문에서 구사할 수 있다고 생각할 수도 있다. 다만 이렇게 구현했을 경우 아래와 같은 문제점이 발생할 수도 있다.

Update문에서 Movement와 총일 발사가 각각 진행되어야 하는데, 특정 조건 때문에 총알 발사를 기다려야 해서 캐릭터가 못 움직이는 경우

특정 조건 이후에 발생하는 이벤트를 만들기 위해 Update문 전체가 멈추는 것은 게임 상황에서 어울리지 않기 때문에, Coroutine을 통해 따로 돌아갈 시간을 주면서 단위 작업을 맡기는 용도로 사용함

대표적으로 사용할 수 있는 예시라고 한다면, 리그 오브 레전드에서 Q, W, E, R 각각의 스킬 쿨타임이 따로 돌아가는 것을 Coroutine으로 구사할 수 있을 것이다.

  • Invoke에 관하여

특정 조건에서 이벤트를 활성화시킬 때 Invoke를 사용할 수도 있지 않은지 생각해볼 수도 있다. 다만 유니티 메뉴얼 상으로 Invoke 사용을 비권장하고, Coroutine을 사용하라고 적혀 있다. 이는 관리 차원에서 Invoke보다 Coroutine이 더 좋기 때문이기도 하다.

1.5 Coroutine 사용 시 유의사항

1. Coroutine은 사용 후 반드시 종료할 것.

코루틴을 실행하면 클래스 객체가 생성되며 메모리가 할당된다. 이를 별도로 해제하지 않는 상태로 반복/누적이 될 경우, 메모리 누수가 발생할 수 있다.

코루틴은 해제하지 않는 이상 계속 남아 있기 때문에 별도로 해제가 필요하다. 위 점프 코드를 예시로 해당 내용을 추가하자면 아래와 같이 된다.

using System.Collections;
using UnityEngine;

public class CoroutineTester : MonoBehaviour
{
    [SerializeField] Rigidbody rigid;
    [SerializeField] float jumpPower;

    private Coroutine jumpCoroutine;
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            jumpCoroutine = StartCoroutine(Routine());
        }
        Input.GetKeyDown(KeyCode.Escape))
        {            
            StopCoroutine(jumpCoroutine);
            
            // 매우 주의할 것 - 이렇게 쓰면 안 멈춘다!!!
            // 루틴을 멈추는 것이 아니라 코루틴을 멈춰야 한다 매우 주의할 것!!!
            // StopCoroutine(Routine());
	    }
    }
    IEnumerator Routine()
    {
        Debug.Log("점프 대기 시작!");
        yield return new WaitForSeconds(2f);
        Debug.Log("점프!");
        rigid.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
    }
}

여기서 위 코드에도 적었다시피 유의해야 할 점이 있는데, StopCoroutine을 해제할 때의 유의점이다.

StopCoroutine은 코루틴을 해제하기 위한 키워드이지 루틴을 해제하기 위한 키워드가 아니다. 따라서 루틴을 코루틴 매개변수로 받아 작업을 시작, 해제해야 한다.

2. Coroutine을 시작하고, 종료할 시에 조건을 확인할 것,

  1. 코루틴 시작 시에 존재 여부 추가 조건을 넣지 않았을 시에 이런 문제가 발생할 수도 있다.

    스페이스바를 눌렀을 때 코루틴이 실행된다. 그러면 카운트다운 코루틴이 있다고 할 때, 스페이스바를 빠르게 세 번 누르면 코루틴이 세 개가 한 번에 만들어져, 5, 5, 5, 4, 4, 4, 3, 3, 3, 2, 2, 2, 1, 1, 1 로 실행되는 것을 볼 수 있다.

따라서 코루틴을 시작할 때 코루틴이 존재하지 않는지 조건을 확인하고 실행되도록 한다.

  1. 코루틴을 종료할 시에 존재 여부 추가 조건을 넣지 않았을 때 이런 문제가 발생할 수도 있다.

    스페이스바를 누르면 코루틴이 실행되고, Esc를 누르면 코루틴이 종료된다. 하지만 코루틴이 실행되지 않은 상태로 Esc를 눌러버리면, 존재하지 않는 것을 제거해야 하는 상황이 생길 수 있다.

이 경우에서는 프로그램이 터질 수도 있기 때문에, 코루틴을 종료할 경우 코루틴이 존재하는지 조건을 확인하고 실행되도록 한다.

예시 코드의 일부분은 아래와 같다.

if (Input.GetKeyDown(KeyCode.Space))
{
// 진행중인 코루틴이 없는지 미리 확인해야 함
	if (jumpCoroutine == null)
	{
		jumpCoroutine = StartCoroutine(Routine());
	}
}
if( Input.GetKeyDown(KeyCode.Escape))
{
	// 진행중인 코루틴이 있는지 확인하고 코루틴을 멈출 것
	if (jumpCoroutine != null)
	{
		StopCoroutine(jumpCoroutine);
	}
}

3. Tip - Coroutine을 변수에 담아 사용하기

코루틴을 실행하면 클래스 객체가 생성되기 때문에, 힙 메모리에 클래스 인스턴스가 할당된다.
주기적으로 실행되어야 하는 코루틴의 경우 변수를 선언해 미리 힙에 할당해놓고, 해당 변수에 생성되는 코루틴 객체를 할당하는 방식을 사용할 수 있따. 이와 같은 방법으로 메모리 공간을 절약하고, 메모리 단편화를 방지할 수 있다

아래와 같은 예시를 생각해 보자.

IEnumerator CountDownRoutine()
{
    Debug.Log(5);
    yield return WaitForSeconds(1f);
    Debug.Log(5);
    yield return WaitForSeconds(1f);
    Debug.Log(5);
    yield return WaitForSeconds(1f);
    Debug.Log(5);
    yield return WaitForSeconds(1f);
    Debug.Log(5);
    yield return WaitForSeconds(1f);
    countDownCoroutine = null;
}

이와 같이 작성했을 때, WaitForSeconds(1f) 가 다섯 개나 할당이 되는 셈이 된다. 이와 같은 방식 대신에 아래와 같은 방식으로 사용하는 게 좋을 것이다.

IEnumerator CountDownRoutine()
{
    WaitForSeconds delay = new WaitForSeconds(1f);

    Debug.Log(5);
    yield return delay;
    Debug.Log(5);
    yield return delay;
    Debug.Log(5);
    yield return delay;
    Debug.Log(5);
    yield return delay;
    Debug.Log(5);
    yield return delay;
    countDownCoroutine = null;
}

1.6 Coroutine에서 사용할 수 있는 키워드들

IEnumerator TestRoutine()
{
    yield return null;                              // Update 끝날 때
    yield return new WaitForSeconds(1f);            // 게임 시간 n초간 기다리고, Update 끝날 때
    yield return new WaitForSecondsRealtime(1f);    // 현실 시간 n초간 기다리고, Update 끝날 때
    yield return new WaitForFixedUpdate();          // FixedUpdate 끝날 때
    yield return new WaitForEndOfFrame();           // 프레임이 끝날 때 (LaeUpdate 다음)

    yield return new WaitUntil(() => Input.GetKeyDown(KeyCode.Space)); // 조건이 맞을 때까지 대기
}

1.7 사용 예시 1 - 키를 입력받고 있을 때에만 총을 일정 시간마다 발사하게 하기

지금까지 한 작업에서 TankController의 내용에서 Coroutine을 추가해 키를 누르고 있는 동안에만 총알이 나가도록 해 보자.

using System.Collections;
using UnityEngine;

public class TankController : MonoBehaviour
{
    [SerializeField] Shooter shooter;
    [SerializeField] float repeatTime;
    [SerializeField] float fireCoolTime;

    private Coroutine fireCoroutine;
    
    private void Awake()
    {
        // 처음에 실행될 때는 스페이스 입력 시에 바로 총알이 나가도록 함.
        fireCoolTime = 0.5f;
    }
    private void Update()
    {
        FireWhenSpaceDown();
    }

    public void FireWhenSpaceDown()
	{
	    // 이후 스페이스를 뗐다가 다시 입력할 때에는 쿨타임을 넣어줌
	    fireCoolTime += Time.deltaTime;
    	if (fireCoolTime >= 0.5f)
    	{
        	if (Input.GetKeyDown(KeyCode.Space) && fireCoroutine == null)
        	{
            	fireCoroutine = StartCoroutine(FireRoutine());
        	}
        	if (Input.GetKeyUp(KeyCode.Space) && fireCoroutine != null)
        	{
        	    StopCoroutine(fireCoroutine);
            	fireCoolTime = 0;
            	fireCoroutine = null;
        	}
    	}
	}
    
    IEnumerator FireRoutine()
    {
        WaitForSeconds delay = new WaitForSeconds(repeatTime);
        while (true)
        {
            shooter.Fire();
            yield return delay;
        }
    }
}

1.8 사용 예시 2. 차징하여 거리를 조절하는 총알 만들기

우선 이와 같은 작업을 위해선, Shooter 스크립트의 내용을 수정해 줄 필요가 있다.

함수 오버로딩을 통해, float 매개변수를 받는 Fire 스크립트를 추가해주자.

  • Shooter 스크립트
using UnityEngine;

public class Shooter : MonoBehaviour
{
    [SerializeField] GameObject bulletPrefeb;
    [SerializeField] Transform muzzlePoint;
    [SerializeField] ObjectPool bulletPool;

    [Range(10, 30)]
    [SerializeField] float bulletSpeed;
    
    // 기존에 있었던 스크립트
    public void Fire()
    {
        GameObject instance = Instantiate(bulletPrefeb, muzzlePoint.position, muzzlePoint.rotation);
        Rigidbody bulletRigidbody = instance.GetComponent<Rigidbody>();
        bulletRigidbody.velocity = muzzlePoint.forward * bulletSpeed;
    }
    
    // 추가한 스크립트 - bulletSpeed값을 외부에서 넣어서 사용할 수 있는 스크립트 추가
    public void Fire(float bulletSpeed)
    {
        GameObject instance = Instantiate(bulletPrefeb, muzzlePoint.position, muzzlePoint.rotation);
        Rigidbody bulletRigidbody = instance.GetComponent<Rigidbody>();
        bulletRigidbody.velocity = muzzlePoint.forward * bulletSpeed;
    }
}

이제 탱크 컨트롤러를 통해서 bulletSpeed를 조절할 수 있는 Coroutine을 추가해주자.

public class TankController : MonoBehaviour
{
    [SerializeField] Shooter shooter;
    [SerializeField] float repeatTime;
    [SerializeField] float fireCoolTime;

    private Coroutine fireCoroutine;
    private Coroutine chargeCoroutine;
    private void Awake()
    {
        // 처음에 실행될 때는 스페이스 입력 시에 바로 총알이 나가도록 함.
        fireCoolTime = 0.5f;
    }
    private void Update()
    {
        ChargeWhenSpaceDown();
    }

    private void ChargeWhenSpaceDown()
    {
        // 이후 스페이스를 뗐다가 다시 입력할 때에는 쿨타임을 넣어줌
        fireCoolTime += Time.deltaTime;
        if (fireCoolTime >= 0.5f)
        {
            if (Input.GetKeyDown(KeyCode.Space) && chargeCoroutine == null)
            {
                chargeCoroutine = StartCoroutine(ChargeRoutine());
                fireCoolTime = 0;
            }
        }
    }

    IEnumerator ChargeRoutine()
    {
        float timer = 0;

        while (true)
        {
            timer += Time.deltaTime * 30;
            yield return null;

            if (Input.GetKeyUp(KeyCode.Space))
                break;
        }
        float bulletSpeed = Mathf.Clamp(timer, 3f, 30f);
        shooter.Fire(bulletSpeed);
        chargeCoroutine = null;
    }
}

1.9 코루틴의 작동원리

1. 싱글 스레드와 멀티 스레드

스레드라는 개념이 있다.

하나의 프로세스 안에서 다양한 작업을 담당하는 최소 실행 단위를 스레드라고 한다.
스레드는 프로세스 내의 Heap, Data, Code 영역을 공유한다.

이러한 스레드를 처리하는 방식엔 싱글 스레드와 멀티 스레드 방식이 있다.

  • 싱글 스레드 : 하나의 프로세스에서 하나의 스레드를 실행하는 방식.
  • 멀티 스레드 : 하나의 프로세스에서 다수의 스레드를 실행하는 방식.

2. 코루틴은 싱글 스레드이다.

코루틴은 언뜻 보면 Update와 따로 돌아가는 멀티 스레드처럼 보일 수 있다. 하지만 유니티 이벤트 함수 순서에 따르면, 코루틴은 전체적인 흐름 사이에 들어가는 싱글 스레드 방식으로 작동한다. 작업 자체가 동시성을 갖고 있어 따로 돌아가는 것처럼 보일 뿐, 실제로는 Update 작업을 하다가, Coroutine 작업을 하다가를 반복하면서 한 사람이 일을 동시에 처리하는 방식이란 것이다.

  • 왜 Coroutine은 싱글 스레드 방식으로 진행될까?
    Coroutine을 멀티 스레드로 진행하면 문제가 생길 가능성이 크기 때문

예를 들어 플레이어 hp를 치료하는 함수-힐과 hp를 깎는 함수-데미지가 멀티 스레드로 진행할 때 아래와 같은 상황이 벌어질 수 있다.

플레이어 hp는 100인데 힐과 데미지의 타이밍이 동시에 일어났다. 힐 연산이 조금 더 빨리 끝나서 100 + 20 = 120이 되었는데, 데미지는 기존의 100의 체력에 연산을 하였으므로 120 이라는 플레이어 체력 정보에 90이라는 정보를 덮어쓰게 된다. 이렇게 되면 플레이어는 힐이 적용되지 않은 것처럼 보일 수 있다.

따라서 코루틴은 싱글 스레드 방식으로 진행되며, 코루틴이 많아지면 그만큼 게임이 느려진다는 사실을 알고 있어야 한다.

* 코루틴은 작업을 나누기 위해 쓰는 것이지, 코드 자체의 간결화를 위해서 쓰는 기능은 아니다.

2. 이벤트

이벤트에 대해 알아보고자 한다.
이벤트는 기본적으로 using UnityEngine.Events; 를 가져와야 사용할 수 있다.

2.1 이벤트 만들기

아래와 같이 간단하게 코드를 작성해 보자.

using UnityEngine;
using UnityEngine.Events;

public class UnityEventTester : MonoBehaviour
{
    public UnityEvent myEvent;

    private void Update()
    {
        if(Input.GetKeyDown(KeyCode.C))
        {
            myEvent.Invoke();
        }
    }
}

이렇게 간단하게 코드를 만들고 유니티 에디터로 돌아가보자.

이와 같이 유니티 이벤트 스크립트를 단 오브젝트에 리스트가 생간 것을 확인할 수 있다.
리스트에 목록을 추가하면 이렇게 나오는 것을 확인할 수 있다.

기능을 설명하면 아래와 같다.

  • Off(끄기),
  • Editor And Runtime(게임 테스트 이전/이후 모두 적용),
  • Runtime Only(게임 테스트 중에서만 적용)
    중 선택 가능.
  1. 참조할 오브젝트를 선택

  2. 참조할 오브젝트의 상태를 결정, 혹은 해당 오브젝트의 Public으로 선언된 함수를 사용할 수 있음

예를 들어, 아래와 같이 사용할 수 있다.

Tank라는 오브젝트를 참조했고, 이벤트 발생 조건을 C를 눌렀을 때라고 하였으므로, C를 누르는 순간 탱크가 비활성화된다. (체크박스 해제는 False, 체크박스 체크는 True를 의미한다.)

이와 같이 이벤트를 사용할 수 있으며, 이벤트의 대표적인 예시라 할 수 있는 것이 유니티에서 기본적으로 제공하는 Button UI이다.

2.2 이벤트의 특징

1. 유니티 이벤트도 매개변수 정할 수 있다.

public class UnityEventTester : MonoBehaviour
{
    public UnityEvent<int> myEvent;

    private void Update()
    {
        myEvent.AddListener(Test); // 오류
        myEvent.AddListener(Func);
    }
    public void Test()
    {

    }
    public void Func(int num)
    {

    }
}

2. 유니티 이벤트에 매개변수를 받을 시 해당 입력값을 인스펙터에서 넣을 수 있다.

예를 들어 아래와 같이 매개변수를 받는 함수가 있다고 해 보자.

public void Fire(float speed)
{
	shooter.Fire(speed)
}

이 함수를 이벤트로 가져오기 위해서 사용하면 아래와 같이 입력할 수 있는 칸이 나온다.

* Tip : event함수 자체에 매개변수 설정을 안 해 놔도 해당 함수를 가져와서 사용이 가능하다.(다만 매개변수가 다르면 사용 불가)

3. 유니티 이벤트에서 매개변수를 받을 시 값을 입력하는 방법은 두 가지로 선택할 수 있다.

이벤트에 매개변수를 넣고, 해당 매개변수를 스크립트 상으로 입력할 수 있다.

이와 같이 작성하고 에디터로 돌아가보면 아래와 같이 선택이 가능하다.

Dynamic Float와 Static Parameters로 영역이 나뉘어 있는 것을 확인할 수 있는데, 각각의 차이는 다음과 같다.

  • Dynamic : 스크립트에서 작성한 값이 들어감
  • Static : 유니티 에디터에서 넣은 값이 들어감

* Static 에 Fire이 두 개가 있는 건, 값을 입력할 건지, 입력하지 않을 건지 선택하게 하는 것이다.

2.3 이벤트 사용 시 유의점

이벤트는 매우 유용한 기능이지만 단점이라 할 수 있는 게 있다.

  • 이벤트는 에디터에서 함수를 추가하는 경우이기 때문에 문제가 생겼을 경우 디버깅으로 추적이 어렵다.

2.4 유니티 액션

이전에 C# 프로그래밍을 공부할 때 Action 함수에 대해 공부한 적이 있었다.
여기서, 유니티에서도 액션 함수가 따로 있다.

public event UnityAction Ondied;

public event Action myDelegate;

참고로 두 개의 기능 차이는 없다.

두 개가 존재하는 이유는 구 버전의 유니티에서는 언어 호환 때문에 Action 함수를 따로 지원하지 않았기 때문에 자체적으로 만들었으나, C#으로 유니티를 사용하기 시작하면서 예전 함수의 잔재가 남아있는 경우다.

profile
게임 만들러 코딩 공부중

0개의 댓글