이전 회사에서 한동안 단위 테스트를 해보았는데, 테스트 케이스를 작성하고 테스트 코드를 작성하는데,
체감상 그 기능을 개발하는 시간만큼의 시간이 들어가는 느낌이었기 때문이다.
그래서 '솔직히 게임 클라이언트 개발에 단위 테스트는 별로 안 맞지 않나?'라는 생각을 하고 있는데,
그러다가, 한번 이번에 다른 사람은 어떤 생각을 가지고 있을까 하고 찾아봤다.
그런데, 레딧에서 꽤 괜찮은 코멘트를 하나 발견했다.
...
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.
(함수라면 하나의 메소드로 작동해서 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로 선언된 것들이기 때문에,
'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 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);
}
}
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 버전부터 이다. 이제는 최신 정보로 업데이트 돼야할 때가 되지 않았나 싶다.