나는 Unit Tests(단위 테스트)를 별로 선호하는 편이 아니다.

이전 회사에서 한동안 단위 테스트를 해보았는데, 테스트 케이스를 작성하고 테스트 코드를 작성하는데,

체감상 그 기능을 개발하는 시간만큼의 시간이 들어가는 느낌이었기 때문이다.

그래서 '솔직히 게임 클라이언트 개발에 단위 테스트는 별로 안 맞지 않나?'라는 생각을 하고 있는데,

 

그러다가, 한번 이번에 다른 사람은 어떤 생각을 가지고 있을까 하고 찾아봤다.

그런데, 레딧에서 꽤 괜찮은 코멘트를 하나 발견했다.

...

The single biggest benefit I have personally experienced with writing tests is the impact it has on your code design, not the execution of the test itself. To make code testable you end up doing many things like having small, single-responsibility classes, separating
presentation and logic, injecting dependencies, minimizing number of publicly exposed methods, etc. etc. I would argue that these are almost always good thing. This is why writing tests early (even if not exactly TDD) is more valuable than retrofitting them afterwards.

...

https://www.reddit.com/r/gamedev/comments/4ghpwe/unit_testing_in_game_development/d2hpozk/

 

대충 설명하자면, 코멘트 작성자가 느낀 개인적인 단위 테스트의 가장 큰 장점은, 그 테스트 자체에 있다기 보단, 그 테스트를 하기 위한 과정들이 가장 큰 장점이라는 것이다. 그 과정들 자체가 코드의 품질을 좋게 유지시킨다고 얘기하고 있다.

 

보통 단위 테스트를 하기 위해선, 클래스 자체의 코드 품질이 꽤 좋아야 한다.

일반적인 조건들로는 다음과 같은 게 필요한데,

  • 실제 데이터를 사용하지 않고
  • 단일 책임 원칙을 지키고
  • 로직과 출력 코드를 분리시키고
  • 클래스 간의 의존성을 줄이고
  • 메소드 노출을 최소화
  • ... 등등

이와 같이 단위 테스트를 하기 위해선 클래스에 많은 노력이 들어가야 한다.

이런 조건들이 맞아야지만 단위 테스트를 할 수 있기 때문에, 단위 테스트를 준비하는 것 자체가 코드 품질을 좋게 유지시킨다는 것이다.

 

또한 코멘트 작성자는 단위 테스트는 필요는 하지만 너무 쓸데없이 모든 것을 테스트하려고 하거나 아예 안 하거나 하기보단, 딱 적절한 테스트 수준을 유지하는 게 나은 것 같다고 주장했다.

 

이 코멘트를 읽고 꽤 맞는 이야기라고 생각했다.

생각해보면 예전에 단위 테스트 코드를 만들 때, 테스트를 에러 없이 통과하기 위해서 이것저것 코드를 수정하면서 노력을 많이 해야 했고,

덕분에 관련 코딩 습관을 어느 정도 베개 할 수 있었기 때문이다. (아직 완벽하진 않지만)

어찌 보면, 꽤 좋은 선생님 역할을 해준 셈이다.

 

이 코멘트를 읽으며, 어느 정도 단위 테스트가 필요할 수 있겠구나를 생각할 수 있게 되었다.

 

만약 지금 프로젝트를 진행 중인데, 높은 코드 품질을 장기적으로 유지하고, 다른 개발자분들이 유지보수에 좋은 코딩에 익숙하지 않다면,

단위 테스트를 적용해보는 것도 팀의 실력 향상과 프로젝트의 품질에 꽤 좋은 도움을 줄 거 같다고 생각한다.

명시적 인터페이스를 꽤 최근에서야 알게 되었는데 (ㅎㅎ)

처음 봤을 때 되게 독특한 녀석이라고 느꼈었다.

누가 봐도 평소 내가 하던 C# 코딩과는 좀 이질적으로 느껴졌었기 때문이다.

 

명시적 인터페이스 구현에 대하여

일반적으로 명시적 인터페이스 구현은 한 클래스가 여러 인터페이스를 구현할 때,

이름이 겹쳐서 생기는 문제를 방지하기 위해 사용되는 기능이다.

 

다음과 같은 코드가 예이다.

public interface IMachine
{
    string Id { get; set; }
    
    void Run();
}

public interface IRunnable
{
    int Id { get; set; }

    void Run();
}

public class Machine_A : IMachine
{
    string IRunnable.Id { get; set; }

    int IMachine.Id { get; set; }

    void IRunnable.Run()
    {
        Console.WriteLine("Run!");
    }
        
    void IMachine.Run()
    {
    	Console.WriteLine("Machine starts operation.");
    }
}

IMachine과 IRunnable의 Id라는 속성과 Run이라는 메소드가 겹친다.

일반적으로 이런 상황이 벌어지면 한쪽 이름을 적절하게 변경하면 되겠지만.

만약, IMachine이 A사의 라이브러리에서 정의한 인터페이스고 IRunnable이 B사의 라이브러리에서 정의한 인터페이스라,

내 코드가 아니라 수정할 수가 없다면?

(함수라면 하나의 메소드로 작동해서 IRunnable이건 IMachine이건 같은 동작을 하겠지만, 상식적으로 서로 다른 라이브러리 인터페이스를 사용하는데 같은 동작을 한다면 나중에 좋은 꼴을 보지 못할 것이란 걸 짐작할 수 있다.)

 

이 문제를 해결할 때 적절한 게 바로 명시적 인터페이스 구현이다.

 

근데 이 녀석 참 특이한 게 인터페이스명을 함수명 앞에 붙이고 접근 한정자도 붙이지 않는다.

참 어색하다.

 

더 특이한 건 이 명시적 인터페이스 구현의 특징인데,

이렇게 구현된 인터페이스 요소(속성, 메소드, 이벤트)들은 절대로 해당 클래스의 참조값으로는 접근할 수 없고,

반드시 인터페이스로 형변환을 해줘야 호출할 수 있다.

class MainClass
{
    public static void Main(string[] args)
    {
        Machine_A machine_A = new Machine_A();
        IMachine machine = machine_A as IMachine;

        // 인터페이스로 형변환을 해준뒤 인터페이스 참조값으로만 접근할 수 있다.
        machine.Run();
    }
}

 

심지어 구현된 같은 클래스 내부에서도 함부로 호출할 수가 없다. ㄷㄷ

반드시 같은 클래스라도 형변환을 해야 호출할 수 있다.

public class Machine_A : IRunnable, IMachine
{
    string IRunnable.Id { get; set; }

    int IMachine.Id { get; set; }

    void IRunnable.Run()
    {
        IRunnable thisRunnable = this as IRunnable;
        Console.WriteLine("Machine_A runs! " + thisRunnable.Id);
    }

    void IMachine.Run()
    {
        Console.WriteLine("Machine_A operates!");
    }

    public void WriteLog()
    {
        IRunnable thisRunnable = this as IRunnable;
        thisRunnable.Run();
    }
}

 

명시적 인터페이스 구현의 접근 한정자는 뭘까?

자, 그럼 이렇게 구현된 인터페이스 요소들은 한정자를 지정해주지 않는다.

그럼 제일 궁금한 점이 바로, 이 녀석의 접근 한정자가 뭘까이다.

 

일반적으로 그들은 클래스의 참조로 접근할 수 없는데, 인터페이스를 캐스팅한 참조 변수로는 참조가 가능하다.

길게 가지 않고, 결과만 얘기하면,

 

그들은 private이면서 public이다.

 

이상하게 들리지만 그렇다...

 

일단 왜 private이냐면, 그들이 컴파일로 나왔을 때, 컴파일에 의해 지정된 접근 한정자가 private이기 때문이다.

위의 Machine 클래스 코드를 컴파일하고 어셈블리를 뜯어보면 Machine.IRunnable.Run은 이렇게 되어 있다.

솔직히 필자는 IL 코드를 잘 모르기 때문에 이게 무슨 의미인지는 어렴풋이 만 추측할 뿐이지만

눈에 띄는 부분이 바로 함수명 쪽이다.

private final hidebysig newslot virtual instance void TestLibrary.IRunnable.Run() cil managed

 

함수가 private으로 되어있다.

그래서 명시적 인터페이스로 구현된 요소는 private이다.

 

하지만, 실제로는 조금 다르다.

interface의 기본적인 특징으로 모든 요소는 public이기 때문에, 다른 클래스에서도 인터페이스로 캐스팅만 하면 접근이 가능하다.

당연하지만, 다른 어셈블리에서도 호출이 가능하다.

(필자는 테스트를 해보았지만, 생략하겠다. ㅎㅎ)

 

private인데, public처럼 캐스팅을 하면 호출이 가능하기에 이 녀석은 private이면서 public인 것이다. 

 

이유?

솔직히 위에서 글을 마무리하려고 했는데, 몇 가지 il코드를 둘러보다가

뭔가 의심되는 부분을 발견했다.

이것은 IRunnable 인터페이스의 IL 코드이다.

그런데, 코드를 살펴보면 꽤 신기하게 생겼다.

.class interface public auto ansi abstract TestLibrary.IRunnable
{
    ....
    .method public hidebysig newslot abstract virtual instance void Run () cil managed 
    {
    }
}

인터페이스 코드가 마치 추상 클래스처럼 되어 있고 메소드는 마치 추상 메소드처럼 구현되어 있다!

 

헉, 너무 신기하다.

즉, IL 코드로 추측할 수 있는 의미는 인터페이스는 결국 내부적으론 추상 클래스처럼 다뤄지고 있다는 것이다.

헉 사실 내가 클래스를 사용하고 있었다니.

똑같이 class이면서 굳이 인터페이스라고 따로 둔 이유는, 아마, 인터페이스 같은 완전 추상 클래스의 이점은 가져가면서 다중 클래스 상속의 문제를 회피하기 위해서가 아닐까 추측해본다.

 

그렇다면, 명시적 인터페이스로 구현된 private 요소를 접근할 수 있는 이유에 대해서도 납득할 수 있는 추측을 할 수 있다.

기본적으로 클래스에 정의 된 요소는 private이기 때문에 접근할 수 없지만, 인터페이스에서는 public으로 선언되었기 때문에,

명시적으로 구현된 인터페이스 요소들은 해당 인터페이스로 형변환한 후에만 접근 가능한 것이다.

그리고 만약 해당 인터페이스 요소를 접근하게 되면, 이는 abstract로 선언된 것들이기 때문에,

결과적으로 오버라이드에 의해서 클래스 내에 구현된 요소들이 호출된다!

 

실제로 위 Machine_A.Run의 IL코드를 다시 보면,

private final hidebysig newslot virtual
    instance void TestLibrary.IRunnable.Run() cil managed
{
    .override method instance void TestLibrary.IRunnable::Run()
    
    ...
}

이렇게 되어 있는데, 이 코드를 아까 서술한 이유를 떠올리면서 읽어보면 이렇게 볼 수 있다.

'void IRunnable.Run 메소드는 가상 메소드이며, TestLibrary.IRunnable::Run()을 재정의 한다.'

 

즉, Run 함수는 인터페이스의 추상 메소드를 구현하는 것이기 때문에 virtual 속성이 붙고,

명시적 인터페이스 구현에 의해, 컴파일일 할 때, .override method instance void TestLibrary.IRunnable::Run() 코드가 추가되어

이 함수는 IRunnable.Run()을 오버라이드 한 것임을 명시적으로 정의하고 있는 것이다.

 

결론?

결론은 아까 내린 것과 변함이 없다.

명시적으로 구현된 인터페이스 요소들은 private이지만, 인터페이스에선 public으로 선언됐기 때문에 public이다.

하지만, 이유를 알게 되었기 때문에 좀 더 정확하게 이것의 접근자는 이렇다!라고 얘기할 수 있게 되었다.

 

[참고]

알다시피, 인터페이스는 public 외에 internal로도 지정할 수 있다.

해당 어셈블리 내에서만 사용되길 원한다면, internal로 지정하면 된다.

 

p.s.

원래는 이유를 빼고 끝내려고 했던 건데, 찾다 보니 처음 글 쓰던 의도와 다르게 꽤 재미난 사실을 알게 되어서 좋은 시간이었다.

 

 

유니티 최적화 관련해서 이런 얘기가 있다.

'Dictionary에 Enum을 Key로 사용하면 내부적으로 박싱이 일어나기 때문에, 사용해선 안된다.'

 

나도 최근에야 친한 지인분에게 이 얘기를 들었고 처음에 충격 먹었다. 여태까지 잘만 사용하고 있었으니까... :O

 

일단, 왜 사용하면 안 될까를 살펴보면 이와 같다.


Dictionary는 키값이 같은지 여부를 판단할 때, 내부적으로 IEqualityComparer를 사용하는데, 만약 따로 생성자로 IEqualityComparer를 전달해주지 않으면 Dictionary 내부에서 Property 멤버인 EqualityComparer.Default를 호출한다.

이 Property는 내부적으로 Key 타입에 따라 적절한 Comparer를 생성해서 리턴하는 CreateComparer 메소드를 호출하는데, Enum에 경우엔 적절한 Comparer가 없어서 기본 타입인 ObjectEqualityComparer를 리턴한다.

문제는 이 ObjectEqualityComparer 녀석은 이름처럼 Object를 사용하는 녀석이라, Enum이 키값일 때 Object로 형 변환하는 박싱이 일어난다. (Enum은 숨겨진 타입으로 int를 상속하는 ValueType이다.)


그런데 문득 정말 박싱이 일어나는지 궁금해져 테스트를 해보기로 했다.

(테스트 버전: Unity 2018 LTS)

 

테스트 코드는 단순하다.

public class EnumTest : MonoBehaviour
{
    private Dictionary<EnumKey, int> _dicEnum = new Dictionary<EnumKey, int>();

    private void Update()
    {
        Profiler.BeginSample("Test Enum");
        _dicEnum.Add(EnumKey.One, 1);
        _dicEnum.Add(EnumKey.Two, 2);
        _dicEnum.Add(EnumKey.Three, 3);

        _dicEnum.Remove(EnumKey.One);
        _dicEnum.Remove(EnumKey.Two);
        _dicEnum.Remove(EnumKey.Three);
        Profiler.EndSample();
    }

    enum EnumKey
    {
        One,
        Two,
        Three
    }
}

매 프레임마다, Add와 Remove를 반복해서 이를 프로파일러에서 확인할 수 있게 하는 것.

 

테스트 결과는 이렇게 나왔다.

...?! 기대한 것과는 다르게 GC Alloc이 0이다. 박싱이 일어나지 않았다는 뜻이다.

혹시 이전 유니티 버전에서 해결된 건 아닌지, 어떤 건지 2017 버전도 테스트해보다가 Script Runtime Version을 바꾸고 나서, 유의미한 결과 값을 얻을 수 있었다.

 

.NET Framework의 버전을 3,5로 바꾸면?

이번엔 가비지가 생겼다.

이 말인즉, 닷넷이 4.0으로 업데이트 되면서 방식 문제가 해결됐다는 뜻이다.

 

어떻게 해결된 것일까 찾아보던 중, 한 분의 블로그를 보고 해답을 찾을 수 있었다.


...

 

4 버전대 이상 닷넷에선 EqualityComparer.CreateComparer()의 로직이 바뀌었고, 이젠 타입이 enum인지 아닌지를 봐서 EnumEqualityComparer라는 전용 비교자를 만들어 넘겨준다. 진짜 그런지 한번 들여다본다

 

...

출처: https://enghqii.tistory.com/69 [그냥저냥 휴학생]

 

C#에서 Dictionary에 Enum을 써도 괜찮은것 같다

전에 이런 글에서 Dictionary에 Key값으로 enum을 넣으면 내부에서 boxing이 일어나는데, 그 이유는 Dictionary 내부에서 IEqualityComarer로 ObjectEqualityComparer를 사용하게 되기 때문이라고 했다. 4 버전대..

enghqii.tistory.com


다시 말하자면, .NET 4.0 버전 이상에서는 로직이 개선돼서, 더 이상 Enum 타입의 Comparer를 구할 때, ObjectEqualityComparer가 아닌, 박싱이 일어나지 않는 EnumEqualityComparer 타입이 사용된다는 것이다.

실제로 블로거분이 걸어준 mscorlib 안의 코드를 볼 수 있는 사이트로 가서 CreateComparer 함수 구현을 보면, 다음과 같이 if 문으로 Enum 타입을 체크하고 Enum을 위한 Comparer를 리턴하는 것을 볼 수 있다.

// See the METHOD__JIT_HELPERS__UNSAFE_ENUM_CAST and METHOD__JIT_HELPERS__UNSAFE_ENUM_CAST_LONG cases in getILIntrinsicImplementation
if (t.IsEnum) {
    TypeCode underlyingTypeCode = Type.GetTypeCode(Enum.GetUnderlyingType(t));
 
    // Depending on the enum type, we need to special case the comparers so that we avoid boxing
    // Note: We have different comparers for Short and SByte because for those types we need to make sure we call GetHashCode on the actual underlying type as the 
    // implementation of GetHashCode is more complex than for the other types.
    switch (underlyingTypeCode) {
        case TypeCode.Int16: // short
            return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(ShortEnumEqualityComparer<short>), t);
        case TypeCode.SByte:
            return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(SByteEnumEqualityComparer<sbyte>), t);
        case TypeCode.Int32:
        case TypeCode.UInt32:
        case TypeCode.Byte:
        case TypeCode.UInt16: //ushort
            return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(EnumEqualityComparer<int>), t);
        case TypeCode.Int64:
        case TypeCode.UInt64:
            return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(LongEnumEqualityComparer<long>), t);
    }
}

https://referencesource.microsoft.com/#mscorlib/system/collections/generic/equalitycomparer.cs,d8e28972e89a3e86

 

그러니 결과는..?

이제는 사용해도 무방하다!

 

Unity 2018부터는 기본적으로 4.x 버전을 사용하도록 하고 있다. 심지어 2019 버전에서는 아예 '.NET 3.5'는 삭제되고, '.NET 4.X'와 '.NET Standard 2.0'만 있으니, 억지로 2017, 2018 버전으로 가서 3.5 버전으로 다운그레이드 하지 않는 이상 이런 걱정은 하지 않아도 된다는 말이다.

 

실제로 위 프로파일러 스샷에 보여주지 않았지만, Deep Profile로 설정해 놓고 Dictionary.Add와 Remove 항목을 펼쳐보면,

실제로 내부적으로 EnumEqualityComparer를 사용하는 것을 확인할 수 있다.

 

 

[추가 정보]

위의 코드를 보면 단순히 Enum타입만 체크해서 EnumEqualityComparer를 리턴하는 게 아닌, 다양한 Primitive Data Type을 체크해서 알맞게 리턴하는 것을 볼 수 있는데. 이유에 대해 설명하자면, 앞서 박싱이 일어나는 이유에 대해 설명할 때 언급했듯이. Enum은 숨겨진 타입(UnderlyingType)으로 int32를 상속하기 때문이다.

하지만 개발자가 원하면 Enum에 int가 아닌 short(int16) 같은 다른 integer type을 상속받게 할 수 있기 때문에 위에서 Comparer를 리턴할 때, 숨겨진 타입이 뭔지 하나씩 체크하는 것이다.

 

p.s. 2019년까지도 블로그나 심지어 Unity 2019.03 버전 매뉴얼에도(!!) Dictionary의 Key로 Enum을 쓰지 말라는 자료가 수두룩 한데, 실제로 문제가 수정된 건 2017 버전부터 이다. 이제는 최신 정보로 업데이트 돼야할 때가 되지 않았나 싶다.

+ Recent posts