[Unity] 코루틴의 동시성

한수찬·2024년 6월 17일

유니티는 프레임워크로서 코루틴이라는 강력한 도구를 매우 손쉽게 제공하기에 게임코딩의 기본기로 알려져 있고, 원론적인 이해없이 공식처럼 사용하는 초보자가 많다. 나 또한 그러했는데 프로젝트 진행 중 애매한 부분을 만나 이번 기회에 공부하며 정리해보기로 한다.


문제상황 : 자연스럽게 움직이며 게임 맵의 풍경을 보여주는 카메라 로테이션, 특정 입력이 들어오면 정해진 위치로 카메라가 이동했다가 시간이 지나면 다시 로테이션을 반복해야함

솔루션 1 : Update문에서 카메라 반복 로테이션을 유지하고 코루틴함수로 카메라 고정을 구현한다.
하나의 카메라이므로 동시에 일어나면 안되므로 코루틴실행중엔 로테이션을 중지시킨다.


	private bool isCoroutineRunning = false; // 코루틴 실행 상태를 추적하는 변수

    
    IEnumerator MoveCameraToTargetAndBack()
    {
        isCoroutineRunning = true;

        // 목표 위치로 이동
        while (Vector3.Distance(mainCamera.transform.position, createCam.position) > 0.01f)
        {
            Vector3 smoothedPosition = Vector3.Lerp(mainCamera.transform.position, createCam.position, smoothSpeed * Time.deltaTime);
            mainCamera.transform.position = smoothedPosition;

            Quaternion targetRotation = Quaternion.Lerp(mainCamera.transform.rotation, createCam.rotation, smoothSpeed * Time.deltaTime);
            mainCamera.transform.rotation = targetRotation;

            yield return null;
        }

        yield return new WaitForSeconds(waitingTime); // 목표 위치에서 잠시 대기

        // 원래 위치로 돌아오기
        while (Vector3.Distance(mainCamera.transform.position, originCam.position) > 0.01f)
        {
            Vector3 smoothedPosition = Vector3.Lerp(mainCamera.transform.position, originCam.position, smoothSpeed * Time.deltaTime);
            mainCamera.transform.position = smoothedPosition;

            Quaternion smoothedRotation = Quaternion.Lerp(mainCamera.transform.rotation, originCam.rotation, smoothSpeed * Time.deltaTime);
            mainCamera.transform.rotation = smoothedRotation;

            yield return null;
        }

        isCoroutineRunning = false;
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space) && !isCoroutineRunning)
        {
            StartCoroutine(MoveCameraToTargetAndBack());
        }

        // 코루틴이 실행 중이 아닐 때만 MoveToNextTargetSmoothly() 호출
        if (!isCoroutineRunning)
        {
            MoveToNextTargetSmoothly();
        }
    }

    void MoveToNextTargetSmoothly()
    {
        if (targets.Count == 0) return; // 타겟이 없으면 함수 종료

        // 현재 타겟으로 카메라를 부드럽게 이동
        Transform targetTransform = targets[currentIndex];

        // Lerp 함수를 사용하여 현재 위치와 목표 위치 사이를 부드럽게 이동
        mainCamera.transform.position = Vector3.Lerp(mainCamera.transform.position, targetTransform.position, cameraSpeed * Time.deltaTime);
        mainCamera.transform.rotation = Quaternion.Lerp(mainCamera.transform.rotation, targetTransform.rotation, cameraSpeed * Time.deltaTime);

        // 카메라가 현재 타겟에 거의 도달했는지 체크 (거리가 매우 가까워졌는지)
        if (Vector3.Distance(mainCamera.transform.position, targetTransform.position) < 0.03f)
        {
            currentIndex = (currentIndex + 1) % targets.Count;
        }
    }
}

키포인트 : bool isCoroutineRunning
불변수를 따로 두어서 코루틴이 실행되는 동안 Update함수의 로테이션을 중지시켜야만 했다.

이렇게 불변수를 따로 설정하여 코드의 흐름을 잡는 방식은 대표적인 테크니컬뎁트이며 코루틴이라는 방식을 썼기에 나타나는 상황이다.


코루틴의 동시성

코루틴은 비동기 작업을 효율적으로 처리해주는 도구이다.

Co-Routine '코'루틴

직역하면 여러 루틴이 동시에, 협력하며 진행된다는 뜻이다.
여기서 루틴이란 하나의 테스크, 함수정도로 생각하면된다.
메인함수와 일반함수가 있다면 이를 main routine, sub routine이라고 부른다.

이 루틴은 시작과 끝이 명시적으로 정해져있다.

void main()
{
...
	int num = 1;
    Plus(1);
...
}

void Plus(int a)
{
	num += a;
    return num;
}

여기서 Plus함수가 호출될때가 서브루틴의 시작이며 return으로 루틴이 종료된다.

이 코드가 실행되는 과정을 보면 단일스레드가 메인진입-메인중단-서브실행-서브종료-메인재진입의 과정을 거친다.

코루틴의 명칭으로 봤을 때 이러한 서브루틴이 여럿있을 때 한 프레임에서 동시에 실행시켜주는 것이라 예상할 수 있다.

유니티 프로세스 처리

유니티는 기본적으로 씬이 나뉘어져있고 각 씬을 기준으로 메인스레드에서 대부분의 작업을 처리하는 구조이다. 따라서 주로 단일 스레드 모델을 사용하게 되어있다.

하지만 대부분의 게임의 구조에서 단 하나의 오브젝트만 생각해도 네트워크요청, 애니메이션 실행, 시간 지연 등등... 굉장히 복합적으로 로직이 들어간다.

그래서 단일 스레드에서 동시성 프로그래밍이 가능한 코루틴의 기능이 필수인것이다.

코루틴 주요 특징

앞서 얘기한 내용과 함께 추가적인 특징을 정리해보겠다.

  • 메인(단일)스레드에서 실행 : 코루틴은 메인 스레드 안에서 비동기적 작업을 수행하기에 UI업데이트와 같은 작업을 차단하지 않고 상호작용하며 로직을 실행시킬 수 있다.

  • 일시 중단, 재진입 : 각 코루틴은 yield 문에 의해 멈추고 다음 프레임에서 다시 재개될 수 있다. 즉 함수를 중간에 빠져나왔다가, 다른 함수에 진입하고, 다시 원점으로 돌아와 멈추었던 부분부터 다시 시작할 수 있다. (동시성 프로그래밍)

  • 비동기적 처리 : 단일 스레드에서 여러 루틴이 동시에 실행되는 것이므로 코루틴으로 서브루틴이 하나 실행되어도 진행중이던 다른 코루틴(서브루틴)들이 중단되는 것이 아니다. 병렬처럼 실행되는것으로 보이지만 사실을 메인스레드에서 순차적으로 실행되고 있는 것이다.
    그래서 위의 내 코드에서 Update함수에서 로테이션을 따로 중단시킬 필요가 있었던 것이다.

  • 디버그에 어려움 : 하나의 코루틴 내에서 여러 개의 yield 문이 사용될 수 있고, 여러 코루틴이 동시에 실행될 수 있다. 이로 인해 실행 흐름이 멀티플렉스되어 코드의 복잡성이 증가하며, 특정 시점에서 어떤 코루틴이 실행 중이고 어떤 상태에 있는지 이해하기 어렵다.
    또한 코루틴은 일시 중지된 상태에서 재개될 때 이전 상태를 유지한다. 이로 인해 변수의 값이 예상치 못하게 변경되거나, 다른 코루틴이 예상치 못한 타이밍에 실행되어 예기치 않은 결과를 초래할 수 있다.

결국 여러 서브루틴이 하나의 스레드에서 독립적으로 실행되고, 때문에 각각의 상황을 저장, 유지, 재개할 수 있다는 것이다.

유니티에서 코루틴

유니티에서의 코루틴 특징

유니티에서 코루틴이 동작하는 특징으로 인해 트러블슈팅에 시간이 오래걸린 적이 많은데 모아 정리해보았다.

  • 프레임워크 : 유니티는 하나의 프레임워크이고, 게임 툴 특성 상 코루틴이 주요한 기능이기에 여러 함수를 통해 간편하게 구현하도록 지원한다. 그렇기에 지켜줘야할 몇가지 특징들이 있다.
    1) MonoBehaviour를 상속받는 객체가 있어야 한다. 코루틴을 다루는 메서드들은 모두 MonoBehaviour 클래스에 구현되어 있기 때문이다.
    2) 코루틴은 반복자(Iterator)를 사용해서 구현되었다. 따라서 반드시 IEnuemrator형으로 반환해야 하며, yield return을 만나는 순간마다 다음 구문이 실행되는 프레임으로 나누고,
    yield break를 만나 코루틴을 바로 종료시킨다.

  • 소유권 : 코루틴은 객체에게 소유되어 있다. 따라서 코루틴 소유권이 있는 객체가 파괴되면 코루틴이 끝까지 실행되지 못하고 중단되며, 비활성화되어있는 객체의 코루틴은 실행명령을 내려도 실행되지 않는다!


결론

코루틴은 해당 객체의 상태가 다양할 수 있고, 네트워크, 애니메이션등 복잡한 로직들의 비동기 처리가 필요할 때 유용하다. 하지만 상태관리, 디버깅에 어려움이 있고 가독성 또한 저하되니 모든 상황에 필수적인 것은 아니다.

내 문제상황에서는 카메라에 대해서 시점 이동에만 변경이 없으며, 입력이 들어온 상태, 평상태 로테이션 두가지로 나뉘기 때문에 Update함수에서 단순 메소드들을 순차적으로 호출하며 메소드들 간 책임 위임을 확실하게 하면서 가독성까지 높이는 것이 더 나은 방법으로 보인다.

코루틴을 사용한다고 얻어지는 장점이 미약하다.

솔루션 2 : Update문에서 메소드를 조건에 따라 분리 호출하며 책임위임과 가독성에 우선을 둔다.

void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space) && !isMovingToTarget && !isWaiting && !isMovingBack)
        {
            isMovingToTarget = true;
        }

        if (isMovingToTarget)
        {
            MoveCameraToTarget();
        }
        else if (isWaiting)
        {
            WaitAtTarget();
        }
        else if (isMovingBack)
        {
            MoveCameraBack();
        }

        if (!isMovingToTarget && !isWaiting && !isMovingBack)
        {
            MoveToNextTargetSmoothly();
        }
    }

    void MoveCameraToTarget()
    {
        // 목표 위치로 이동
        if (Vector3.Distance(mainCamera.position, createCam.position) > 0.01f)
        {
            Vector3 smoothedPosition = Vector3.Lerp(mainCamera.position, createCam.position, smoothSpeed * Time.deltaTime);
            mainCamera.position = smoothedPosition;

            Quaternion targetRotation = Quaternion.Lerp(mainCamera.rotation, createCam.rotation, smoothSpeed * Time.deltaTime);
            mainCamera.rotation = targetRotation;
        }
        else
        {
            isMovingToTarget = false;
            isWaiting = true;
            waitTimer = waitingTime;
        }
    }

    void WaitAtTarget()
    {
        // 대기 시간 동안 대기
        if (waitTimer > 0)
        {
            waitTimer -= Time.deltaTime;
        }
        else
        {
            isWaiting = false;
            isMovingBack = true;
        }
    }

    void MoveCameraBack()
    {
        // 원래 위치로 이동
        if (Vector3.Distance(mainCamera.position, originCam.position) > 0.01f)
        {
            Vector3 smoothedPosition = Vector3.Lerp(mainCamera.position, originCam.position, smoothSpeed * Time.deltaTime);
            mainCamera.position = smoothedPosition;

            Quaternion smoothedRotation = Quaternion.Lerp(mainCamera.rotation, originCam.rotation, smoothSpeed * Time.deltaTime);
            mainCamera.rotation = smoothedRotation;
        }
        else
        {
            isMovingBack = false;
        }
    }

    void MoveToNextTargetSmoothly()
    {
        if (targets.Count == 0) return; // 타겟이 없으면 함수 종료

        // 현재 타겟으로 카메라를 부드럽게 이동
        Transform targetTransform = targets[currentIndex];

        // Lerp 함수를 사용하여 현재 위치와 목표 위치 사이를 부드럽게 이동
        mainCamera.position = Vector3.Lerp(mainCamera.position, targetTransform.position, cameraSpeed * Time.deltaTime);
        mainCamera.rotation = Quaternion.Lerp(mainCamera.rotation, targetTransform.rotation, cameraSpeed * Time.deltaTime);

        // 카메라가 현재 타겟에 거의 도달했는지 체크 (거리가 매우 가까워졌는지)
        if (Vector3.Distance(mainCamera.position, targetTransform.position) < 0.03f)
        {
            // 다음 타겟으로 인덱스 업데이트 (순환)
            currentIndex = (currentIndex + 1) % targets.Count;
        }
    }

참고

https://wooooooak.github.io/kotlin/2019/08/25/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B0%9C%EB%85%90-%EC%9D%B5%ED%9E%88%EA%B8%B0/
.
https://medium.com/supercent-blog/%EC%9C%A0%EB%8B%88%ED%8B%B0-%EA%B8%B0%EB%B3%B8%EA%B8%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-coroutine-5048334a2e2f

0개의 댓글