변수는 외부 스코프에 대해 수명을 어떻게 가져야 하는가?

Sharlotte ·2023년 5월 22일
1

Unity

목록 보기
3/4

기본적인 스코프간 수명 차이

Javascript

자 여기 간단한 javascript 코드가 있습니다. 크롬 개발자도구의 콘솔에서 실행해보죠.

for(let i = 0; i < 10; i++) {
  setTimeout(() => console.log(i), 1000);
}

그리고 이 코드는 예상하셨다시피 1초 뒤에 0부터 9까지의 수를 순서대로 출력할 것입니다. 그리고 이것은 우리에게 상당히 직관적이며 일리 있는 코드입니다.

C#

그러나 아래 c# 코드를 봅시다. 한번 Unity에서 실행해보죠.

class MyMonoBehaivour : MonoBehaviour {
    [Button("start")]
    private void Run()
    {
        for (int i = 0; i < 10; i++)
        {
        	// coroutine with WaitForSeconds
            SetTimeout(() => Debug.Log(i), 1000);
        }
    }
    
    private IEnumerator SetTimeoutCoroutine(float delay, Action callback)
    {
        yield return new WaitForSeconds(delay / 1000);
        callback();
    }

    private void SetTimeout(float delay, Action callback)
    {
        IEnumerator coroutine = SetTimeoutCoroutine(delay, callback);
        StartCoroutine(coroutine);
    }
}

그리고 이 코드는 javascript와 달리, 아래와 같은 출력을 냅니다.

javascript와는 다른 출력이 나옵니다. for 반복문을 모두 끝낸 i 변수만을 출력합니다.
뭔가 이상합니다. 다른 언어... Java를 한번 보죠.

Java

아래 Java 코드는 StackOverflow 답변에 따라 Timer를 통해 setTimeout를 구현하여 online java complier에서 실행시켰습니다.

import java.util.*;

class HelloWorld {
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++) {
            setTimeout(() -> System.out.println(i), 1000);
        }
    }
    
    public static void setTimeout(Runnable runner, long delay) {
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                runner.run();
            }
        }, delay);
    }
}

interface Runnable {
    void run();
}

그러면 아래와 같이 에러가 발생합니다.

ERROR!
javac /tmp/DUnT7cR2vD/HelloWorld.java
/tmp/DUnT7cR2vD/HelloWorld.java:8: 
	error: local variables referenced from a lambda expression must be 
    	   final or effectively final
    setTimeout(() -> System.out.println(i), 1000);
                                                ^
1 error

보아하니 Java는 c#, javascript와 달리 외부 스코프로부터 가변적인 변수의 참조를 거부하고 있습니다. Jetbrain의 IntelliJ IDEA IDE는 이러한 에러에 대해 몇가지 솔루션을 제안하는데, final 변수에 매번 할당하여 넘기거나 배열을 사용하여 넘기는 방법입니다.

아래와 같이 배열을 사용하면 C#과 유사한 출력이 나옵니다.

public static void main(String[] args) {
    int[] arr = {0};
    
    for(int i = 0; i < 10; i++) {
        arr[0] = i;
        setTimeout(() -> System.out.println(arr[0]), 1000);
    }
}
java -cp /tmp/DUnT7cR2vD HelloWorld
9
9
9
9
9
9
9
9
99

두고 보면 C#에서 10이 나오는게 이상하게 느껴질 수 있지만 중요한 문제가 아니니 넘어갑시다.

final 방식을 사용하면 javascript와 유사한 출력이 나옵니다.

public static void main(String[] args) {
    for(int i = 0; i < 10; i++) {
        final int j = i;
        setTimeout(() -> System.out.println(j), 1000);
    }
}
java -cp /tmp/DUnT7cR2vD HelloWorld
24
5
0
1
3
6
7
98

출력이 순서대로가 아닌 이유는 아마 Timer의 내부적인 스케쥴 처리에 있을 것이지만 이것도 여기선 딱히 중요한 문제가 아니니 넘어갑시다.

스코프에서 참조하는 외부 변수의 수명은 스코프와 독립적입니다.

결과적으로 전 몇가지 결론을 얻을 수 있었습니다.

위 코드에서 공통적으로 출연한 for문의 변수 i는 매 루프마다 선언된 것이 아니라, 루프마다 재할당되고 있습니다. 이는 성능 관점에서 합리적이며 대중적인 while를 통한 for문 구현문을 봤을 때 일리있습니다.

위 이유로 인해 C#과 Java의 첫번째 시도에서 내부 스코프에서 참조된 변수 i는 루프가 모두 끝났을 때 최종값 9를 가지므로 1초 뒤에 i를 출력했을 때 9만 나왔습니다.

그러나 Java의 두번째 시도에선 지역변수 j를 통해 출력했는데, 이때는 매 루프마다 j가 선언되었으므로 매 루프마다 있었던 i를 고스란히 출력하기 때문에 0부터 9까지의 수들이 나왔습니다.

이를 통해 C#과 Java에선 외부 변수의 수명이 스코프에 종속적이지 않음을 알 수 있습니다. 이건 꽤나 중요한 주제이며 왜 Java가 외부 스코프로부터 가변적인 변수의 참조를 거부하는지에 대한 이유기도 합니다. 높은 직관성을 줄지언정 대신 개발자에게 변수를 의도적으로 캐싱하게 만들어 안정성을 추구하고자 한 것입니다.

C#은 이러한 불편을 문법에서 축출한 것으로 보이나 Javascript처럼 외부 변수의 수명이 내부 스코프에 종속적이게 만들지도 않았습니다. 사실 종속적이게 만들면 메모리 누수가 우려되긴 합니다.

Javascript는 이상합니다.

Javascript는 이상하게도 C#과 같은 방법으로 출력했음에도 불구하고 변수 i의 수명이 setTimeout의 람다 함수의 스코프에 종속적입니다. 이에 대한 이유를 추가적으로 알아볼 필요는 있으나 일단 언어 철학 관점에서 javascript가 맥락에 의존하는 언어라는 점을 두고 봤을 때 그럴 듯한 현상처럼 보입니다.

C#에선 매우 조심해야 합니다.

아무튼 이러한 점 때문에, Java에선 문법 차원의 제재를 가하고 있고 Javascript는 문법 차원의 근본적인 문제 해결(오히려 수명을 종속시킴)을 하고 있으나 C#은 둘 다 하지 않음으로 인해 각별히 주의를 할 필요가 있습니다.

물론 Javascript도 유의해야 합니다.

수명을 종속시킨다는건 해당 변수의 값입니다. 그리고 일반적으로 그 값이란 원시값 입장에선 그 값 자체지만 참조값(객체) 입장에선 객체의 주소입니다.
C#에선 이 문제가 원시값에게도 해당되어 더 심각할 뿐, Javascript도 객체에 대해서 유의미한 문제가 발생합니다.
Typescript에서 가끔 null check를 다시 해야 하는 경우가 생기는데, 이게 그러한 경우입니다.

이게 왜 문제냐면, Javascipt에서도 그랬듯이 C#에서 외부 객체의 수명이 직관적으로 이뤄지지 않기 때문입니다. 내부 스코프가 참조된 외부 객체를 잡고 있다(bind)고 생각해선 안됩니다. 성능 설계상 이건 매모리 누수를 초례하므로 그 자체로 이미 좋지 않은 상상입니다.
심지어, Javascript와 달리, C#에선 원시값조차 이러한 문제에 직면하므로 더 각별히 주의를 주어야 합니다.
비단 for문에서의 이야기가 아닙니다. 이벤트리스너와 같은 비동기적인 모든 것에 대하여 이러한 문제가 발생합니다.

Unity에서의 예시

위 C# 예제에서 SetTimeout 메서드를 유틸리티화하여 Timer 싱글톤 클래스를 만들어봤습니다.

internal class Timer : LazyDDOLSingletonMonoBehaviour<Timer>
{
    IEnumerator SetTimeoutCoroutine(float durationInSecond, Action callback)
    {
        yield return new WaitForSeconds(durationInSecond/1000);
        callback();
    }

    public Action SetTimeout(float durationInSecond, Action callback)
    {
        IEnumerator coroutine = SetTimeoutCoroutine(durationInSecond, callback);
        StartCoroutine(coroutine);
        return () => StopCoroutine(coroutine);
    }

    IEnumerator SetIntervalCoroutine(float durationInSecond, Action callback)
    {
        while(true)
        {
            yield return new WaitForSeconds(durationInSecond);
            callback();
        }
    }

    public Action SetInterval(float durationInSecond, Action callback)
    {
        IEnumerator coroutine = SetIntervalCoroutine(durationInSecond, callback);
        StartCoroutine(coroutine);
        return () => StopCoroutine(coroutine);
    }
}

작동 방식은 크게 다르지 않습니다. delay가 초단위가 된 것 빼고요

이 유틸리티를 활용하여 10초 뒤에 특정 유닛의 이름을 출력하는 코드를 구현해봅시다.

class Unit : MonoBehaviour {
    public void PrintUnit()
    {
        Timer.Main.SetTimeout(10, () => Debug.Log(this.name));
    }
}

아마 일반적인 상황에서, 이 코드는 정상적으로 10초 뒤에 유닛의 이름을 출력할 것입니다.
그러나 만약 10초 안에 그 유닛이 삭제된다면?
unit는 null이 될테고 name는 null를 참조했으므로 에러를 내놓을 것입니다.

이 경우에는 Timer가 코루틴을 실행시키는 대신 Unit가 직접 코루틴을 실행시켜서 Unit가 삭제되면 코루틴 실행도 취소되도록 설계하면 됩니다.

class Unit : MonoBehaviour {
    public void PrintUnit()
    {
        StartCoroutine(
        	Timer.Main.SetTimeoutCoroutine(10, () => Debug.Log(this.name))
        );
    }
}

이렇게 외부 변수의 수명이 스코프에 종속적이지 않은 문제 때문에 변수가 어느순간 null이 될지 모릅니다. C#은 기본적으로 null safty를 지향하지만 Unity에선 그럴 일이 너무나도 드뭅니다.
예를 들어, 다른 경우인 가령 이벤트 리스너에선 일반적으로 제지할 방법이 없습니다. 그 변수가 절대 null이 되지 않는단 보장을 하거나, 기본값을 주거나, 그 변수가 변화할 때 이벤트 리스너도 같이 변화하도록 부가적인 설계 레이어를 구축해야 합니다.
이 과정이 귀찮게 느껴질 수도 있지만 어찌보면 당연한 일입니다. 이런 부가적인 과정이 요구된다면 설계 미스를 한번 의심해보세요.

아 물론, 객체가 아니라 원시값의 경우에는 캐싱 말곤 답이 없습니다. 이러한 문제를 예방하는 가장 간단한 마인드는 자바처럼 사고하기라고 생각합니다.

profile
샤르르르

0개의 댓글