본문 바로가기
프로그래밍/Effective C#

Effective C# Item 8 : 이벤트 호출 시에는 null 조건 연산자를 사용하라.

by bantomak 2023. 10. 26.

이벤트 호출 시에는 null 조건 연산자를 사용하라

이벤트를 발생시키는 작업은 간단해 보인다. 이벤트를 정의하고, 이벤트를 발생시켜야 할 경우 이벤트를 호출하기만 하면 이벤트에 결합되어 있는 이벤트 핸들러가 호출될 것이기 때문이다. 멀티캐스트 델리게이트의 경우 이벤트에 결합된 이벤트 핸들러가 여러 개지만 내부적으로 이를 순차적으로 호출해 줄 것이므로 복잡성이 외부로 드러나지 않는다. 하지만 실제로는 이벤트를 호출하는 경우 다양한 문제가 발생하곤 한다.

 

우선 이벤트에 결합된 이벤트 핸들러가 없다면 어떻게 될까? 이벤트 핸들러가 결합되어 있는지를 확인하는 코드를 추가하면 된다고 생각할지 모르지만, 이벤트 핸들러가 결합되어 있는지 확인하는 코드와 이벤트를 발생시키는 코드 사이에 경쟁 조건(race condition)이 발생할 가능성이 있다. 이 문제는 C# 6.0에 새롭게 추가된 null 조건 연산자(null conditional operator)를 사용하면 깔끔하게 해결할 수 있다. 가능한 한 빨리 새로운 구문에 익숙해지기를 바란다.

 

먼저 예전 방식을 살펴보고 안전하게 이벤트를 발생시키기 위해서 무엇을 어떻게 해야 하는지 살펴보자. 간단한 이벤트 발생 코드는 다음과 유사할 것이다.

 

public class EventSource
{
   private EventHandler<int> Updated;
   
   public void RaiseUpdates()
   {
      counter++;
      Updated(this, counter);
   }
   
   private int counter;
}

 

이 코드는 문제가 있는 코드다. 우선 Updated 이벤트에 이벤트 핸들러가 결합돼 있지 않다면 NullReferenceException 예외가 발생한다. 이벤트 핸들러가 결합되지 않은 이벤트는 null값을 갖기 때문이다.

 

따라서 이벤트를 발생시키기 이전에 유효한 이벤트 핸들러가 결합되었는지를 확인하도록 코드를 추가해야 한다.

 

public void RaiseUpdates()
{
  counter++;
  if (Updated != null)
  {
     Updated(this, counter);
  }
}

 

이렇게 코드를 수정하면 잘 동작하지만 여전히 숨어 있는 버그가 있다. if문을 호출하여 Updated 이벤트가 null이 아님을 확인했다고 하자. 그런데 이벤트를 발생시키는 코드를 수행하기 직전에 다른 스레드가 이벤트 핸들러의 등록을 취소했다고 생각해 보자. 다시 원래 스레드로 돌아와 이벤트를 발생시키려 하면, 이벤트 핸들러는 null값을 가지게 되므로 NullReferenceException 예외가 발생한다.

 

이러한 버그는 분석하기도 어렵고 문제를 고치는 것도 여간 까다로운 게 아니다. 코드에 문제가 있을 것이라고 가늠하기도 어렵고 오류 재현 환경을 만들려면 여러 스레드의 수행 과정을 정밀하게 제어해야 하기 때문이다. 숙련된 개발자조차 이러한 코드 구성이 매우 위험하다는 사실을 지독한 경험으로부터 체득할 수밖에 없다.

 

public void RaiseUpdates()
{
   counter++;
   var handler = Updated;
   if (handler != null)
   {
      handler(this, counter);
   }
}

 

이 예제가 바로 .NET과 C#을 이용하여 안전하게 이벤트를 발생시키는 권장 코드다. 이 코드는 멀티스레드 환경에서도 안전하게 동작한다. 하지만 여전히 코드의 가독성 측면에서 문제가 있다. 이전 코드를 이렇게 변경함으로써 어떻게 스레드 안정성을 확보할 수 있었는지가 코드를 통해 명확하게 드러나지 않기 때문이다.

 

이 코드가 어떻게 동작하는지 그리고 멀티스레드와 연계된 복잡한 문제를 어떻게 해결했는지 알아보자.

먼저 할당문을 통해 현재 이벤트 핸들러를 새로운 지역변수에 할당했다. 이 지역변수는 멀티캐스트 델리게이트를 포함할 수 있다. 또한 이 델리게이트는 내부적으로 원래 이벤트의 이벤트 핸들러 목록을 그대로 가지고 있을 것이다.

이벤트에 대한 할당 구문은 할당문 오른쪽 객체에 대한 얕은 복사본(shallow copy)을 만든다. 이 복사본은 여러 개의 이벤트 핸들러가 포함된 리스트의 복사본을 생성하게 된다. 만약 이벤트 핸들러가 결합되지 않은 경우라면 할당문 오른쪽 값이 null일 것이기 때문에 새로운 변수의 값도 null이 된다.

 

만약 다른 스레드가 이벤트에 대한 구독을 취소하면 기존 객체에 포함된 이벤트 필드의 내용은 수정되겠지만 복사된 지역변수의 내용은 변경되지 않는다. 따라서 지역변수에는 이전에 복사됐던 이벤트 핸들러가 그대로 남아 있게 된다.

 

이제 복사된 지역변수의 null 여부를 확인해 보면 복사가 수행되었던 시점에 이벤트 핸들러가 존재했는지를 확인하게 되므로 모든 이벤트 핸들러가 정상적으로 호출된다.

 

이 코드는 잘 수행되는 코드이지만 경험이 많지 않은 .NET 개발자들이 이 코드를 처음 보았을 때 이 같은 세부적인 내용을 온전히 이해하기란 쉽지 않다. 게다가 이벤트를 발생시키려는 위치마다 매번 이러한 코드를 반복해서 사용하거나, 혹은 유사한 코드를 포함하고 있는 private 메서드를 만들어두고 이 메서드를 이용하여 이벤트를 발생시키도록 코드를 작성해야만 한다.

 

이는 마치 필요없는 코드처럼 보이도 할뿐더러 이벤트를 발생하기 위해서 이처럼 복잡한 코드를 사용하는 것도 적절해 보이지 않는다. 

 

null 조건 연산자 적용한 코드

public void RaiseUpdates()
{
   counter++;
   Updated?.Invoke(this, counter);
}

 

이 코드는 null 조건 연산자(?.)를 사용하여 안전하게 이벤트 핸들러를 호출한다. '?.' 연산자의 동작 방식은 연산자의 왼쪽을 평가하여 이 값이 null이 아닌 경우에만 연산자 오른쪽의 표현식을 실행한다. 만약 연산자 왼쪽이 null이면 아무 작업도 수행하지 않고 다음 단락으로 이동한다.

 

이는 if 문을 사용하는 이전 예제와 언뜻 비슷해 보인다. 하지만 if 문과 다른 점은 '?' 연산자의 왼쪽을 평가하고 메서드를 수행하는 과정이 원자적으로 수행된다는 점이다.

 

Invoke() 메서드를 사용하는 이유

'?.' 연산자를 이용하여 이벤트를 발생시킬 때는 이벤트 이름 뒤에 ()를 붙여 호출할 수 없으므로 Invoke 메서드를 사용해야 한다.

 

Invoke() 메서드는 안전한가?

C# 컴파일러는 모든 델리게이트와 이벤트에 대하여 Invoke() 메서드를 타입 안정적 형태로 생성해 주므로 이 메서드를 호출하는 것은 ()를 이용하여 이벤트를 직접 발생시키는 코드와 완전히 동일하다.

 

이 코드는 멀티스레드 환경에서도 안전할 뿐 아니라 이전 코드보다 더욱 간결하다. 겨우 한줄에 불과하므로 이를 위해 따로 헬퍼 메서드를 만들거나, 이러한 기능을 구현하기 위해서 독립적인 클래스를 작성할 필요가 없다. 우리가 진정으로 원한 것은 간략하게 한 줄로 이벤트를 발생시키는 것이지 않은가?

 

. NET을 오랫동안 사용해 온 개발자라 하더라도 새로운 방식을 사용할 것을 추천한다. 이전에 널리 사용한 코드가 도처에 널려 있을 수 있고, 팀 내에서 새로운 방식을 사용하자고 이야기하는 것이 어려울 수도 있다. 게다가 온라인상에는 이미 수십 년에 걸쳐 쌓아 온 오래된 관례가 있기 때문에 NullReferenceExceptions에 대한 해결책으로 새로운 방법보다 이전 방식의 해결책을 찾을 가능성이 매우 높다.

 

하지만 어떤 경우라도 null 조건 연산자를 이용하는 방식을 고수하기 바란다. 이 방식이 기존 방식보다 더욱 단순하고 명확하기 때문이다.

 

함께 읽으면 좋은 글

 

C# 이벤트(Event)에 대해서 알아보자

이벤트(Event)란? C#에서 모든 이벤트(event)는 특수한 형태의 delegate이다. 이벤트는 특수한 제약조건이 추가된 delegate라고 생각하면 이해하기 편하다. 이벤트의 추가(+=) 및 제거(-=)만 가능하다. 할당

jettstream.tistory.com

 

출처

 

이펙티브 C# - 예스24

더 나은 C# 코드를 작성하는 새로운 방법 50가지 C#은 전통적인 .NET 기반 개발에서 유니티 게임 엔진으로 개발 영역을 확대하면서 더욱 주목받고 있다. 또한 자마린으로 다양한 모바일 플랫폼에

댓글