본문 바로가기

.Net Technology/WPF

(8) 의존성 속성과 이벤트 라우팅의 이해

의존성 속성 이해

WPF(Windows Presentation Foundation) API들은 classes, structures, interfaces, delegates, enumerations과 같은 각각의 닷넷 클래스들의 멤버를 이용하고 있고 또한 properties, methods, events, constant data/read-only 와 같은 멤버들을 이용하고 있다. 하지만 WPF는 의존성 속성(Dependency Property)라는 새로운 용어와 새로운 프로그래밍 메커니즘을 소개하고 있다. 

의존성 속성들은 WPF를 위해서 설계된 문법이라 할 수 있다. 현재까지 닷넷이 아닌 다른 언어를 이용할 경우 특정 속성을 정의하기 위해서 네이티브 문법을 이용해서 특정 속성을 정의해야 했었다. 하지만 C#의 코드 조각을 이용하면 새로운 의존 속성의 골격을 기본적으로 만들어 준다.


기본적인 닷넷 속성처럼 의존성 속성은 XAML이나 코드를 이용해서 값을 설정할 수 있다. 그리고 의존성 속성은 특정 데이터 필드로 캡슐화 될 수 있고 또한 읽기전용, 쓰기전용과 같이 설정할 수 있다. 

여기서 재밌는 사실은 의존속성을 사용하고도 의존속성인줄 모른다는 것이다. 예를 들어 Height와 Width와 같은 멤버들은 FrameworkElement를 상속받을 뿐만 아니라 Content 속성은 ControlContent로 부터 상속받은 속성이다. 이것들이 모두 의존성 속성이라는 것이다.

<!-- 여기에는 3개의 의존성 속성을 설정하였다. -->
<Button Name = "btnMyButton" Height = "50" Width = "100" Content = "OK"/>



이렇게 의존성 속성은 일반 속성과 비슷해서 달리 알아보기가 힘들다. 그렇다면 WPF에서는 왜 이런 개념을 만들어 낸 것일까? 그 대답은 의존성 속성이 어떻게 구현되었는지 살펴보면 이해할 수 있다. 먼저 의존성 속성은 데이터 바인딩, 애니메이션, 테마, 스타일 등과 같은 기능을 구현하는데 있어서 필수적인 기능이다. 요약하자면 의존성 속성은 다음과 같은 장점들을 가지고 있고 CLR 속성처럼 데이터의 캡슐화가 가능하다.

- 의존성 속성은 부모 개체에서 정의한 XAML의 값을 상속받을 수 있다. 
- 의존성 속성은 외부 타입의 값을 설정할 수 있게 지원해준다.
- 의존성 속성은 WPF가 여러 개의 외부 값들을 기반으로 값을 계산해 낼 수 있게 해준다.
- 의존성 속성은 자식들에게 알림이나 트리거와 같은 기능을 제공해준다. (애니메이션, 스타일과 테마를 만들 때 꽤 자주 사용한다) 
- 의존성 속성은 스태틱 변수로 값을 저장할 수 있게 해준다.


의존 속성의 중요한 차이점은 WPF가 여러 속성값들을 기반으로 특정 값을 계산해 낼 수 있게 해준다는 것이다. 예를 들어 이 값은 데이터 바인딩과 애니메이션/스토리보드 로직을 기반으로 이 값이 결정되거나 부모/자식과 같은 다른 XAML 엘리먼트를 통해서 값이 결정될 수 있다.

의존성 속성의 다른 차이점은 외부의 어떤 동작에 의해서 속성값이 변경된 것을 모니터링 하기 위해서 설정할 수도 있다. 예를 들어 의존성 속성이 변경되는 것은 아마도 Window 위에 있는 컨트롤들의 레이아웃이 변경되어서일 수도 있고 외부 데이터 소스의 변경이나 특정 애니메이션에 의해서 값이 변경되어서일 수도 있다.

존재하는 의존성 속성 조사하기

WPF 프로젝트를 간단하게 하기 위해서 의존 속성을 직접 만들어 사용할 것이다. 실제로 WPF에서는 커스텀 컨트롤을 만들게 될 경우에만 필요로 하게 될 것이다. 만약 WPF 데이터 바인딩이나 테마, 애니메이션과 같은 동작을 처리하기 위해서 속성을 만든다면 아니면 만약 어떤 속성의 값이 변경되었을 때 브로드 캐스트 해주어야만 하는 속성을 만든다면 의존성 속성을 이용해야 할 것이다. 이 외의 다른 경우에서는 일반적인 CLR 속성을 이용하면 될 것이다. 

의존성 속성의 기본 구조를 이해한다면 여러 방면으로 큰 도움이 될 것이다. 하지만 이 속성을 이해하기 위해서 몇가지 WPF 이슈들을 더 알아야 하고 WPF 프로그래밍 모델을 좀 더 깊게 들어가 살펴보아야 한다. 의존성 속성의 내부 구조를 살펴보기 위해서 FrameworkElement 클래스의 Height를 구현하는 코드를 살펴보도록 하자.

public class FrameworkElement : UIElement, IFrameworkInputElement, IInputElement, ISupportInitialize, IHaveResources
{
    ...
    // 의존성 속성의 스태틱 값으로 정의 되어 있고 
    public static readonly DependencyProperty HeightProperty;
    // 생성자에서 DependencyProperty를 만든 후에 등록된다. 
    static FrameworkElement()
    {
        HeightProperty = DependencyProperty.Register(
        "Height",
        typeof(double),
        typeof(FrameworkElement),
        new FrameworkPropertyMetadata((double)1.0 / (double)0.0,
        FrameworkPropertyMetadataOptions.AffectsMeasure,
        new PropertyChangedCallback(FrameworkElement.OnTransformDirty)),
        new ValidateValueCallback(FrameworkElement.IsWidthHeightValid));
    }
    // Height 속성은 get/set속성을 이용하지만 그 값을 가져올 때 
    // GetValue()/SetValue() 메서드를 이용한다.
    public double Height
    {
        get { return (double)base.GetValue(HeightProperty); }
        set { base.SetValue(HeightProperty, value); }
    }
}


의존성 속성에 대해서 살펴보았듯이 의존성속성들은 일반적인 CLR 속성과는 다르게 약간 추가적인 로직을 요구하고 있다. 먼저 의존성 속성은 System.Windows.DependencyProperty를 이용하여 선언되고 거의 public과 읽기전용 필드로 선언되었다. 의존성 속성 중에 가장 큰 장점 중에 하나가 특정 인스턴스에 의지되지 않는다는 것을 생각해볼 때 이 속성은 스태틱 데이터를 이용해야 할 것이다.


의존성 속성의 등록

의존성 속성들은 스태틱으로 선언함으로써 초기 값을 할당해 주어야 한다. 하지만 간단한 숫자 필드와는 다르게 DependencyProperty 객체는 스태틱 DependencyProperty의 값을 반환하는 메서드(Register())를 이용해서 등록해주어야 한다. 이 메서드는 다양하게 오버로딩된 함수들을 가지고 있다. 

HeightProperty = DependencyProperty.Register(
    "Height",
    typeof(double),
    typeof(FrameworkElement),
    new FrameworkPropertyMetadata((double)1.0 / (double)0.0,
    FrameworkPropertyMetadataOptions.AffectsMeasure,
    new PropertyChangedCallback(FrameworkElement.OnTransformDirty)),
    new ValidateValueCallback(FrameworkElement.IsWidthHeightValid));


Register() 메서드의 첫 번째 파마메터로는 DependencyProperty로 사용할 필드의 이름을 지정해주어야 한다. 그리고 두 번째 파라메터는 데이터 타입의 정보를 넣어준다.

세 번째 파라메터에는 자신이 속하게 될 클래스의 정보를 넣어준다. (여기서는 FrameworkElement를 지정하였다.) 물론 이 속성이 등록되어 있을 수도 있지만 WPF는 영리하게도 단 하나의 속성만 지정할 수 있게 된다.

마지막 파라메터에는 실제로 어떻게 동작할 것인지에 대한 옵션을 설정을 할당해주게 된다. 예를 들어 FrameworkPropertyMetadata객체를 통하여 만약 값이 수정되었을 때의 알림을 어떻게 받을 것인 것 또한 값의 유효성 검사를 어떻게 할 것인지 등과 같은 다양한 옵션들을 설정하게 된다. 


DependencyProperty 속성을 위한 래퍼 속성의 정의

앞에서는 스태틱 생성자에서 DependencyProperty 객체를 선언하였고 이제 마지막으로 CLR 속성으로 이 함수를 캡슐화 시키는 작업이 남았다. 하지만 여기서 get, set을 이용해서 간단하게 double 타입의 변수를 반환하는 것이 아니라 System.Windows.DependencyObject 클래스의 GetValue()와 SetValue()와 같은 메서드를 이용해서 값을 반환해 주어야 한다. 다음 코드를 살펴보자. 


System.Windows.DependencyObject base class:
public double Height
{
    get { return (double) base.GetValue(HeightProperty); }
    set { base.SetValue(HeightProperty, value); }
}



엄밀히 말하자면 DependencyProperty를 래핑하는 속성은 없어도 상관없다. 만약 DependencyProperty가 public이라면 우리는 GetValue()/SetValue() 메서드를 이용해서 쉽게 값을 가져올 수 있다. 실제로 대부분의 의존성 속성들은 래퍼 속성을 가지고 있기 때문에 XAML에서 쉽게 접근할 수 있는 것이다.


지금까지 의존성 속성이 어떻게 구성되어 있는지 자세히 살펴보았다. 만약 일반적인 CLR 속성으로 의존성 속성과 같은 기능을 구현한다고 한다면 상당히 많은 코드와 노력이 필요할 것이다. 하지만 DependencyProperty 타입을 이용하면 우리는 보다 쉽게 많은 기능들을 구현할 수 있을 것이다. 

의존성 속성은 WPF에서 다양한 기능을 제공하기 위해서 만들어 졌기 때문에 당연히 우리가 원하는 커스텀 의존성 속성을 만들 수 있다. 이 책에서는 커스텀 의존성 속성을 만들어 사용하는 예제를 살펴보지는 않을 것이고 대신 간단히 선언하여 사용하는 예제를 살펴보도록 하자. 다음 예제는 읽기 전용의 DependencyProperty 타입을 선언하고 의존성 속성으로 등록하는 예를 보여주고 있다.



public class MyOwnerClass : DependencyObject
{
    // MyProperty라는 DependencyProperty을 선언하였다.
    // 이 속성은 애니메이션, 스타일, 바인딩과 같은 기능을 처리할 수 있다.
    public static readonly DependencyProperty MyPropertyProperty =
        DependencyProperty.Register("MyProperty", typeof(int),
        typeof(OwnerClass), new UIPropertyMetadata(0));

    // XAML에서 편리하게 사용할 수 있게 래퍼속성을 만든다.
    // 때문에 XAML에서 특정 메서드를 호출할 필요가 없다.

    public int MyProperty
    {
        // GetValue/SetValue come from the
        // DependencyObject base class.
        get { return (int)GetValue(MyPropertyProperty); }
        set { SetValue(MyPropertyProperty, value); }
    }
}


만약 이 내용에 대해서 좀 더 심도있고 공부하고 싶다면 .NET Framework 3.5 SDK의 “Custom Dependency Properties” 토픽을 살펴보는 것을 추천한다. 


이벤트 라우팅의 이해

WPF는 기존의 닷넷 프로그래밍 모델을 그대로 물려받아 사용하고 있다. 그 중에서 CLR의 이벤트 모델에 대한 정교한 동작들 또한 XAML의 객체 트리에서 비슷하게 동작되는 것을 볼 수 있다. 그럼 WPFControlEvents라는 새로운 WPF 프로젝트를 생성하여 이벤트 동작에 대해서 자세하게 살펴보도록 하자. WPF 프로젝트를 생성하였다면 처음에 생성된 <Grid> 개체 안으로 다음과 같은 버튼을 삽입하여 보도록 하자.


<Button Name="btnClickMe" Height="75" Width = "250" Click ="btnClickMe_Clicked">
    <StackPanel Orientation ="Horizontal">
    <Label Height="50" FontSize ="20">Fancy Button!</Label>
    <Canvas Height ="50" Width ="100" >
        <Ellipse Name = "outerEllipse" Fill ="Green" Height ="25"
            Width ="50" Cursor="Hand" Canvas.Left="25" Canvas.Top="12"/>
        <Ellipse Name = "innerEllipse" Fill ="Yellow" Height = "15" Width ="36"
        Canvas.Top="17" Canvas.Left="32"/>
    </Canvas>
</StackPanel>
</Button>



여기서 <Button> 태그를 열어 Click 이벤트를 지정해 주었고 버튼이 클릭될 때 지정한 메서드가 호출될 것이다. Click 이벤트는 RoutedEventHandler 델리게이트를 기반으로 동작되기 때문에 첫 번째 파라메터로 Object를 두 번째 파라메터로 System.Windows.RoutedEventArgs 파라메터를 전달하게 된다. 

public void btnClickMe_Clicked(object sender, RoutedEventArgs e)
{
    // 버튼이 클릭되었을 때의 동작
    MessageBox.Show("Clicked the button");
}


다음 [그림29-4]는 현재 컨트롤을 클릭했을 때의 동작을 보여주고 있다. (다음과 같이 버튼이 정렬되는 이유는 필자는 처음에 생성된 <Grid>를 <StackPanel>로 변경하고 실행했기 때문이다.)

 
[그림29-4] Button 타입의 이벤트

그럼 버튼의 구조에 대해서 살펴보도록 하자. 버튼은 UI를 표현하기 위한 여러 개의 자식 엘리먼트들을 가지고 있다. 만약 WPF가 이러한 자식 엘리먼트들에 각각의 Click 이벤트를 지정해주어야 한다고 생각한다면 굉장히 당황스러울 것이다. 결국 사용자는 버튼의 어느 영역이라도 클릭만 한다면 클릭이벤트를 발생시킬 수 있는 것이다. 뿐만 아니라 이벤트들이 분리되어 있다고 생각해보면 그 코드는 상당히 더러워질 것이다. 

윈도우 폼에서 버튼을 가지고 커스텀 컨트롤을 만든다고 한다면 버튼에 추가된 모든 항목별로 Click 이벤트를 추가해주어야 한다. 하지만 WPF는 다행스럽게도 자동적으로 이벤트를 라우팅 시켜주게 된다. 즉, WPF의 이벤트 라우팅 모델은 자동으로 이벤트를 상위 객체로 라우팅 시켜주는 것이다. 

특히 이벤트 라우팅은 세 가지 분야로 분리하여 정리할 수 있다. 첫 번째로 이벤트가 발생했을 때 현재개체에서 상위로 올라가면서 이벤트가 전달되는 경우를 우리는 버블링 이벤트라고 한다. 이와 반대로 이벤트가 자식 개체로 전달되는 경우를 터너링 이벤트라고 한다. 마지막으로 이벤트가 단 하나의 개체에서만 발생하게 된다면 우리는 이것을 다이렉트 이벤트라고 한다.

의존성 속성처럼 이벤트 라우팅은 WPF 구조를 위해서 생성된 타입이라고 보면 된다. 그렇기 때문에 C# 문법을 공부할 필요는 없다.



이벤트 버블링의 역할

앞에서 살펴본 예제에서 만약 사용자가 노란 타원을 클릭했을 때 Click 이벤트는 상위 계층 <Canvas>로 이벤트가 전달되고 그 다음에는 <StackPanel>으로 이벤트가 전달되고 마지막으로 버튼으로 그 벤트가 전달되는 것이다. 이와 비슷하게 만약 Label이 클릭하게 되었다면 그 이벤트는 <StackPanel>로 그리고 버튼으로 이벤트가 전달될 것이다.

이벤트 라우팅을 살펴보았듯이 우리는 Click 이벤트를 다루기 위해서 모든 개체에 이벤트를 각각 넣어주는 수고는 걱정하지 않아도 되는 것이다. 하지만 만약 이러한 클릭 이벤트를 원하는 대로 수정하여 사용하고 싶은 경우가 있을 것이다. 이 경우 또한 우리가 그렇게 수정하는 것이 가능하다. 먼저 outerEllipse이란 컨트롤을 클릭했을 때만 이벤트를 발생시키고 싶다고 가정하자. 먼저 이 객체에 MouseDown 이벤트를 설정해 두도록 하자. 참고로 그래픽 개체들은 Click 이벤트를 지원하지 않고 있지만 MouseDown, MouseUp 이벤트를 이용해서 버튼과 같은 Click 이벤트를 구현할 수 있다.


<Button Name="btnClickMe" Height="75" Width = "250" Click ="btnClickMe_Clicked">
    <StackPanel Orientation ="Horizontal">
    <Label Height="50" FontSize ="20">Fancy Button!</Label>
    <Canvas Height ="50" Width ="100" >
        <Ellipse Name = "outerEllipse" Fill ="Green"
            Height ="25" MouseDown ="outerEllipse_MouseDown"
            Width ="50" Cursor="Hand" Canvas.Left="25" Canvas.Top="12"/>
        <Ellipse Name = "innerEllipse" Fill ="Yellow" Height = "15" Width ="36"
            Canvas.Top="17" Canvas.Left="32"/>
    </Canvas>
    </StackPanel>
</Button>



그리고 나서 각각의 이벤트 메서드에서는 제목 표시줄의 제목을 간단하게 변경하는 로직을 작성해 보도록 하자.

public void outerEllipse_MouseDown(object sender, RoutedEventArgs e)
{
    // Window의 제목 변경 이벤트
    this.Title = "You clicked the outer ellipse!";
}



이러한 방식으로 우리는 원하는 동작을 커스텀하게 작성할 수 있을 것이다. 

버블링 이벤트 라우팅은 언제나 상위의 개체로 이벤트를 이동시킨다. 그렇기 때문에 이번 예제에서 innerEllipse 객체를 클릭하면 outerEllipse가 아닌 상위 Canvas에 그 이벤트가 전달될 것이다. 즉, 두 개의 Ellipse 개체는 모두 동일한 레벨에 위치되어 있기 때문이다.



연속적이거나 불완전한 이벤트 버블링

여기서 만약 사용자가 outerEllipse객체를 클릭한다면 트리거에 Ellipse 타입을 위한 MouseDown 이벤트가 등록될 것이고 이 버블링은 해당 객체를 만나게 되면 이벤트는 중지된다. 대부분의 경우 이렇게 되길 바랄 수도 있지만 어떤 경우에서는 이 이벤트가 계속 상위로 전달되기를 바랄 수도 있다. 이 때 우리는 RountedEventArgs 타입에 false를 할당해 줘서 이벤트를 계속 유지시키는 것이 가능하다.

public void outerEllipse_MouseDown(object sender, RoutedEventArgs e)
{
    // Window 제목의 변경
    this.Title = "You clicked the outer ellipse!";
    // 버블링의 유지
    e.Handled = false;
}


이 경우 우리는 제목을 변경한 후에 버튼의 Click 이벤트에 전달되어 메시지 박스가 실행되는 것을 볼 수 있다. 요약하자면 이벤트 버블링을 단 하나의 이벤트로 처리되는 것을 가능하고 또한 불연속적인 이벤트를 제어하는 것 또한 가능하다.


터너링 이벤트의 역할

엄밀히 말해서 이벤트 라우팅은 실제로 버블링(Bubbling) 아니면 터너링(Tunneling) 이벤트 중에 하나이다. 터너링 이벤트들은 원래 엘리먼트가 가지고 있는 자식들 개체로 이벤트를 전달하게 된다. 대체로 WPF 클래스 라이브러리의 각각의 버블링 이벤트는 터너링 이벤트와 짝을 이루게 된다. 예를 들어 앞에서 MouseDown이 발생했을 때 PreviewMouseDown 이벤트가 먼저 발생하게 된다. 

터너링 이벤트는 다른 이벤트들을 선언하는 것과 같이 선언할 수 있다. 간단하게 XAML안에 이벤트 이름을 할당하고 거기에 메서드 이름을 지정해주면 되는 것이다. 그럼 이 터너링과 버블링 이벤트들을 확인해 보기 위해서 outerEllipse에 PreviewMouseDown를 추가해 보도록 하자.

<Ellipse Name = "outerEllipse" Fill ="Green" Height ="25"
MouseDown ="outerEllipse_MouseDown"
PreviewMouseDown ="outerEllipse_PreviewMouseDown"
Width ="50" Cursor="Hand" Canvas.Left="25" Canvas.Top="12"/>


다음으로 현재 C# 클래스를 정의했던 메서드들을 string 문자열에 정보를 업데이트 하게 수정하자. 그리고 마지막에 이 문자열들을 한꺼번에 보여주게 수정해보도록 하자.

public partial class MainWindow : System.Windows.Window
{
    // 마우스 관련 이벤트 정보를 담을 문자열
    string mouseActivity = string.Empty;
    public MainWindow()
    {
        InitializeComponent();
    }
    public void btnClickMe_Clicked(object sender, RoutedEventArgs e)
    {
        // 마지막으로 문자열 보여주기
        mouseActivity += "Button Click event fired!\n";
        MessageBox.Show(mouseActivity);
        // 문자열 클리어
        mouseActivity = string.Empty;
    }
    public void outerEllipse_MouseDown(object sender, RoutedEventArgs e)
    {
        // 문자열 추가
        mouseActivity += "MouseDown event fired!\n";
        // 버블링 유지
        e.Handled = false;
    }
    public void outerEllipse_PreviewMouseDown(object sender, RoutedEventArgs e)
    {
        // 문자열 추가
        mouseActivity = "PreviewMouseDown event fired!\n";
        // 버블링 유지
        e.Handled = false;
    }
}



이렇게 수정한 후에 프로그램을 실행해본 후에 밖의 원을 피해서 클릭해보면 간단하게 “Button Click event fired!” 라는 이벤트가 보여지는 것을 볼 수 있을 것이다. 하지만 만약 밖의 원을 클릭해보면 다음 [그림29-5]와 같은 메시지가 보여지는 것을 볼 수 있을 것이다.

 
[그림29-5] 터너링 이벤트 후 버블링 이벤트의 발생

그렇다면 WPF는 왜 이렇게 한 쌍으로 이벤트를 발생시키는 것인지 궁금할 것이다. 그 이유는 이벤트 미리보기를 통해서 데이터 유효성 검사나 버블링 동작의 실행여부와 같은 로직을 좀 더 유연하게 구현할 수 있게 하기 위해서라고 보면 된다. 대부분의 경우 Preview 접두사로 이용하는 터너링 이벤트들을 많이 이용하지 않을 것이고 보다 간단한 버블링 이벤트를 사용할 것이다.

의존성 속성을 직접 손으로 구현하는 경우처럼 터너링 이벤트는 서브 클래스를 가지고 있는 WPF 컨트롤에서 일반적으로 사용된다. 만약 이벤트가 버블링 되는 커스텀 컨트롤을 만든다면 의존 속성과 비슷한 메커니즘을 이용해서 커스텀 라우팅 로직을 구현해주어야 한다. 만약 이 내용에 대한 더 자세한 정보를 보고 싶다면  .NET Framework 3.5 SDK 문서의 “How to: Create a Custom Routed Event”를 살펴볼 것을 권한다.


이 내용은 C#.NET Platform 원서의 내용을 기반으로 작성되었습니다.