이벤트와 델리게이트 - 화면 프로그램의 기초
이 장에서 배우는 것
앞 장에서 비품 정보를 파일에 저장하고 읽는 방법을 다뤘다. 이번에는 비품을 빌리거나 돌려주었을 때 다른 코드에 그 사실을 알리는 방법을 배운다. 대여 상태를 바꾸는 코드와 화면에 안내를 쓰는 코드를 나누면, 안내 방식을 바꿀 때 대여 규칙까지 손대는 일을 줄일 수 있다.
이벤트(event)는 어떤 일이 일어났다는 알림이고, 델리게이트(delegate)는 정해진 형태의 프로시저를 가리키는 형식이다. 두 개념은 버튼을 누르거나 창을 닫는 화면 프로그램에서도 쓰인다. 여기서는 작은 사무실의 비품 대여 관리 콘솔 앱으로 알림의 연결과 해제를 실습한다. 화면 프로그램은 구조만 살펴보며, 모든 실행 예제는 macOS와 Linux에서 사용할 수 있는 콘솔 코드로 작성한다.
- 델리게이트에 프로시저를 연결하고, 그 형식이 검사하는 내용을 설명한다.
- Event로 알림을 선언하고 RaiseEvent로 알림을 발생시킨다.
- WithEvents와 Handles를 함께 사용해 알림을 받는다.
- AddHandler와 RemoveHandler로 연결을 조절하고 람다를 처리 코드로 사용한다.
- Windows Forms의 버튼과 이벤트 처리 구조를 콘솔 예제와 연결해 이해한다.
문제 상황
사무실에 공동으로 쓰는 프로젝터 한 대가 있다. 직원이 프로젝터를 빌리면 프로그램은 대여 상태를 바꾸고, 대여자 이름을 출력해야 한다. 담당자는 대여 사실을 별도의 기록에도 남기고 싶어 한다. 반납할 때는 안내를 출력하고, 프로그램 실행 중 처리한 반납 횟수도 세려고 한다.
대여 메서드 안에 출력과 기록과 횟수 계산을 모두 넣을 수는 있다. 하지만 안내 문구를 바꾸거나 기록 기능을 잠시 끄려 할 때마다 대여 메서드를 수정해야 한다. 나중에 화면 프로그램으로 바꾸면 같은 메서드 안에서 화면의 표시까지 다루게 될 수 있다. 비품 상태를 관리하는 책임과 그 변화에 반응하는 책임이 한곳에 모이는 것이다.
여기서는 비품을 관리하는 객체가 대여와 반납을 알리도록 만든다. 콘솔 안내와 기록은 그 알림을 받는 쪽에서 처리한다. 알림을 발생시키는 객체를 발행자라고 하고, 알림을 받도록 연결한 쪽을 구독자라고 한다. 알림을 받아 실행되는 프로시저는 이벤트 처리기라고 부른다.
이 예제의 대여 대상은 프로젝터 한 대뿐이다. 목록 검색이나 파일 저장은 넣지 않는다. 대여 성공, 중복 대여 거절, 반납 성공, 처리기 연결 해제에 집중한다. 사용자 입력도 받지 않으므로 실행할 때마다 같은 순서로 진행되고 같은 결과가 출력된다.
델리게이트와 이벤트는 무엇을 연결하는가
프로시저의 모양을 정하는 델리게이트
변수에는 숫자나 문자열뿐 아니라 호출할 프로시저를 나타내는 값도 담을 수 있다. 델리게이트는 이때 사용할 자료형이다. 선언에는 매개변수의 개수와 형식, 반환값의 유무를 적는다. 다음 선언은 문자열 두 개를 받고 값을 반환하지 않는 Sub를 연결할 수 있다는 뜻이다.
Public Delegate Sub LoanNoticeHandler(itemName As String, borrower As String)
이름이 같은 프로시저를 찾는 방식은 아니다. 연결할 프로시저의 매개변수와 반환 형태가 중요하다. 이 장에서는 델리게이트와 처리기의 매개변수 형식을 똑같이 작성한다. Option Strict On을 사용하므로 암시적인 형식 변환에 기대지 않고 연결 관계를 확인할 수 있다.
AddressOf는 프로시저를 그 자리에서 실행하지 않고, 델리게이트로 연결할 대상을 지정한다. 다음 조각에서 첫 줄은 연결을 만들고, 둘째 줄은 연결한 프로시저를 호출한다. WriteAudit은 문자열 두 개를 받는 Sub라고 가정한다.
Dim audit As LoanNoticeHandler = AddressOf WriteAudit
audit("프로젝터", "김하늘")
프로시저를 직접 호출하는 것과 달리, 델리게이트 변수는 같은 형식의 다른 프로시저를 담을 수도 있다. 호출하는 코드는 변수에 현재 연결된 프로시저를 실행한다. 이벤트에서는 이런 연결을 이용해 알림을 받을 처리기를 지정한다.
알림의 이름과 발생 시점을 나누기
Event는 클래스가 제공할 알림의 이름을 선언한다. RaiseEvent는 그 알림을 실제로 발생시키는 문장이다. 선언만 해서는 처리기가 실행되지 않는다. 발행자의 코드가 RaiseEvent를 실행해야 연결된 처리기가 호출된다.
Public Event Loaned As LoanNoticeHandler
' 대여 상태를 바꾼 뒤 실행한다.
RaiseEvent Loaned(_itemName, _borrower)
Loaned는 대여가 완료되었다는 사실을 알린다. 따라서 상태를 바꾸기 전에 발생시키지 않는다. 처리기가 알림을 받았을 때 발행자의 상태도 이미 대여 중이어야 한다. 중복 대여처럼 요청이 거절되면 상태를 바꾸지 않고, 성공을 뜻하는 알림도 발생시키지 않는다.
공개 이벤트가 있다고 해서 외부 코드가 그 이벤트를 마음대로 발생시킬 수 있는 것은 아니다. 외부 코드는 처리기를 연결하거나 해제한다. 이 예제에서 RaiseEvent는 이벤트를 선언한 LoanDesk 클래스 내부에 둔다. 알림을 발생시킬 시점은 비품 상태를 관리하는 클래스가 결정한다.
이 프로그램의 이벤트 처리는 동기 방식이다. 이는 RaiseEvent가 실행되면 연결된 처리기를 같은 실행 흐름에서 호출하고, 처리기가 끝난 다음에 RaiseEvent 뒤의 코드로 진행한다는 뜻이다. 이벤트를 선언했다고 별도 작업이 자동으로 시작되지는 않는다. 처리기가 오래 걸리는 일을 하면 대여 메서드가 끝나는 시점도 늦어진다.
| 문법 | 역할 | 이 예제의 의미 |
|---|---|---|
| Delegate Sub | 연결할 프로시저의 형식을 선언한다. | 문자열 두 개를 받는 알림 형식이다. |
| Event | 발행자가 제공하는 알림을 선언한다. | 대여와 반납 알림의 이름을 만든다. |
| RaiseEvent | 연결된 처리기를 호출한다. | 상태 변경이 끝났음을 알린다. |
| AddressOf | 이름 있는 프로시저를 연결 대상으로 지정한다. | 기록용 Sub를 델리게이트에 연결한다. |
처리기를 연결하는 두 가지 방법
WithEvents와 Handles로 선언하기
WithEvents는 이벤트를 제공하는 객체를 담는 변수에 붙인다. Handles는 처리기 선언 끝에 붙여 어떤 변수의 어떤 이벤트를 처리할지 적는다. 두 문법은 함께 사용한다. 다음 코드는 desk에 담긴 객체의 Loaned 이벤트를 ShowLoan이 받도록 선언한다.
Private WithEvents desk As New LoanDesk("프로젝터")
Private Sub ShowLoan(itemName As String, borrower As String) Handles desk.Loaned
Console.WriteLine($"대여: {itemName} / {borrower}")
End Sub
WithEvents 변수는 이 예제처럼 모듈 수준에 선언한다. Main 내부의 지역 변수로 선언하지 않는다. Handles는 처리할 이벤트가 코드 작성 시점에 정해져 있을 때 읽기 쉽다. 처리기의 선언만 보아도 어떤 알림을 받는지 알 수 있다.
WithEvents 변수에 다른 객체를 대입하면, Handles가 지정한 연결도 새 객체를 대상으로 바뀐다. 변수에 Nothing을 대입하면 그 변수를 통한 연결은 해제된다. 이 장에서는 객체를 교체하지 않지만, Handles가 클래스의 모든 인스턴스를 대상으로 하는 것이 아니라 특정 변수에 담긴 객체를 대상으로 한다는 점은 알아 두어야 한다.
AddHandler와 RemoveHandler로 실행 중 조절하기
AddHandler는 프로그램이 실행되는 도중에 처리기를 연결한다. RemoveHandler는 그 연결을 해제한다. 기록 기능을 잠시 켜거나, 한동안만 필요한 안내를 연결할 때 사용할 수 있다.
Dim audit As LoanNoticeHandler = AddressOf WriteAudit
AddHandler desk.Loaned, audit
' 기록 연결을 사용한 뒤 해제한다.
RemoveHandler desk.Loaned, audit
AddHandler는 기존 처리기를 바꾸는 문장이 아니라 연결을 추가하는 문장이다. 같은 처리기를 두 번 추가하면 한 번의 알림에 두 번 실행될 수 있다. 연결과 해제는 보통 한 쌍으로 관리한다. 특히 오래 살아 있는 발행자에 처리기를 계속 추가하면 출력이 중복되거나, 필요가 끝난 객체가 계속 연결된 상태로 남을 수 있다.
Handles와 AddHandler는 함께 사용할 수 있다. 완성 코드에서는 대여 안내를 Handles로 연결하고, 대여 기록을 AddHandler로 추가한다. 첫 번째 대여에서는 두 처리기가 모두 실행된다. 두 번째 대여 전에는 기록 연결을 해제하므로 안내 처리기만 실행된다.
짧은 처리기를 람다로 작성하기
람다(lambda)는 별도의 프로시저 이름을 선언하지 않고 처리 내용을 식이나 문장 형태로 적는 방법이다. 다음 람다는 문자열 두 개를 받아 안내를 출력한다. 앞에서 선언한 델리게이트 변수에 담기므로 연결에 필요한 형식도 확인할 수 있다.
Dim showReturn As LoanNoticeHandler =
Sub(itemName As String, borrower As String)
Console.WriteLine($"반납: {itemName} / {borrower}")
End Sub
여러 문장이 필요하면 Sub부터 End Sub까지 적는다. 값을 반환하는 람다는 Function으로 작성하지만, 이번 알림은 반환값이 없으므로 Sub를 사용한다. 처리 내용이 길어지면 이름 있는 프로시저로 옮겨 역할을 드러내는 편이 읽기 쉽다.
완성 코드의 반납 람다는 Main의 지역 변수 returnCount도 사용한다. 이처럼 람다 바깥에 선언된 변수를 함께 사용하는 것을 변수 캡처라고 한다. 람다는 연결할 때의 숫자를 따로 복사해 두는 것이 아니라 그 변수를 사용한다. 반납 알림이 올 때마다 returnCount가 증가하고, Main에서도 증가한 값을 읽을 수 있다.
나중에 연결을 해제할 람다는 변수에 보관한다. RemoveHandler에 같은 내용을 다시 작성해도 원래 연결했던 델리게이트를 지정한 것이 되지는 않는다. 내용이 같다는 것과 같은 연결을 가리킨다는 것은 구분해야 한다.
Windows Forms에서도 같은 연결을 사용한다
Windows Forms는 창과 버튼 같은 화면 요소를 제공하는 .NET의 화면 프로그램 기술이다. Windows를 대상으로 하므로, 이 장의 macOS와 Linux 콘솔 프로젝트에서는 실행하지 않는다. 여기서는 화면 요소가 이벤트를 제공하고 처리기가 그 알림을 받는 구조만 살펴본다.
화면 프로그램에는 창을 나타내는 Form과 버튼 같은 컨트롤이 있다. 컨트롤은 사용자가 조작하는 화면 요소를 뜻한다. 버튼의 Click 이벤트는 버튼을 눌렀다는 알림이다. 처리기는 이 알림을 받아 대여 메서드를 호출할 수 있다. 대여 메서드가 성공하면 다시 비품의 Loaned 이벤트가 발생하고, 다른 처리기가 안내를 갱신할 수 있다.
화면에서 입력을 받는 일과 비품 상태를 바꾸는 일은 서로 다른 역할이다. 버튼 처리기에 대여 규칙을 모두 넣기보다, 처리기가 비품 관리 객체의 메서드를 호출하도록 구성하면 콘솔에서 사용하던 규칙을 화면에서도 활용하기 쉽다.
Windows Forms에서 흔히 보는 Click 처리기는 다음과 같은 형태다. 이 코드는 구조 설명용이며, 아래 완성 코드에 붙이지 않는다. loanButton은 화면 프로젝트에서 선언한 버튼이라고 가정한다.
Private Sub LoanButton_Click(sender As Object, e As EventArgs) Handles loanButton.Click
' 버튼을 눌렀을 때 대여 메서드를 호출한다.
End Sub
sender는 알림을 보낸 객체이고, e는 알림에 딸린 정보다. EventArgs는 이벤트 정보를 전달하는 기본 클래스다. .NET의 여러 화면 이벤트는 이런 형태를 사용한다. 이번 콘솔 예제는 매개변수 전달을 쉽게 살펴보도록 비품 이름과 대여자 이름을 직접 받는 델리게이트를 선언했다. 이벤트마다 처리기가 받아야 하는 매개변수 형식이 정해져 있다는 원리는 같다.
화면 프로그램은 사용자의 다음 조작을 기다리면서 운영체제의 알림을 처리한다. 콘솔 예제는 Main에 적힌 요청을 차례로 실행하고 끝난다. 실행을 유지하는 구조는 다르지만, 발행자와 처리기를 연결하고 알림에 반응하는 방법은 이어진다.
완성 코드
다음 프로그램은 Program.vb 한 파일로 구성한다. 대여 관리 객체는 프로젝터 한 대의 상태와 현재 대여자를 보관한다. 첫 대여에는 안내와 기록이 함께 실행되고, 첫 반납에는 람다가 실행된다. 이후 기록과 반납 처리기를 해제하여 두 번째 대여에서는 대여 안내만 출력되도록 한다.
Option Strict On
Option Explicit On
Imports System
Public Delegate Sub LoanNoticeHandler(itemName As String, borrower As String)
Public Class LoanDesk
Private ReadOnly _itemName As String
Private _isLoaned As Boolean
Private _borrower As String = String.Empty
Public Event Loaned As LoanNoticeHandler
Public Event Returned As LoanNoticeHandler
Public Sub New(itemName As String)
_itemName = itemName
End Sub
Public ReadOnly Property IsLoaned As Boolean
Get
Return _isLoaned
End Get
End Property
Public Function TryLoan(borrower As String) As Boolean
If _isLoaned OrElse String.IsNullOrWhiteSpace(borrower) Then
Return False
End If
_borrower = borrower
_isLoaned = True
RaiseEvent Loaned(_itemName, _borrower)
Return True
End Function
Public Function TryReturn() As Boolean
If Not _isLoaned Then
Return False
End If
Dim previousBorrower As String = _borrower
_borrower = String.Empty
_isLoaned = False
RaiseEvent Returned(_itemName, previousBorrower)
Return True
End Function
End Class
Module Program
Private WithEvents desk As New LoanDesk("프로젝터")
Sub Main()
Dim returnCount As Integer = 0
Dim audit As LoanNoticeHandler = AddressOf WriteAudit
Dim showReturn As LoanNoticeHandler =
Sub(itemName As String, borrower As String)
returnCount += 1
Console.WriteLine($"반납: {itemName} / {borrower}")
End Sub
AddHandler desk.Loaned, audit
AddHandler desk.Returned, showReturn
If Not desk.TryLoan("김하늘") Then
Console.WriteLine("첫 대여 요청이 거절되었다.")
End If
If Not desk.TryLoan("박민수") Then
Console.WriteLine("대여 거절: 이미 대여 중이다.")
End If
If Not desk.TryReturn() Then
Console.WriteLine("첫 반납 요청이 거절되었다.")
End If
RemoveHandler desk.Loaned, audit
RemoveHandler desk.Returned, showReturn
Console.WriteLine("기록과 반납 안내 연결을 해제했다.")
If Not desk.TryLoan("이서준") Then
Console.WriteLine("두 번째 대여 요청이 거절되었다.")
End If
If desk.TryReturn() Then
Console.WriteLine("두 번째 반납이 완료되었다.")
End If
Console.WriteLine($"반납 처리기 실행 횟수: {returnCount}")
If desk.IsLoaned Then
Console.WriteLine("현재 상태: 대여 중")
Else
Console.WriteLine("현재 상태: 대여 가능")
End If
End Sub
Private Sub ShowLoan(itemName As String, borrower As String) Handles desk.Loaned
Console.WriteLine($"대여: {itemName} / {borrower}")
End Sub
Private Sub WriteAudit(itemName As String, borrower As String)
Console.WriteLine($"기록: {borrower}에게 {itemName} 대여")
End Sub
End Module
줄별 해설
Option Strict On과 Option Explicit On은 형식과 변수 사용을 명확하게 한다. 이벤트 처리기도 일반 프로시저처럼 형식을 맞춰야 한다. Imports System은 Console과 String 같은 이름을 사용할 수 있도록 한다.
Public Delegate Sub LoanNoticeHandler는 두 이벤트가 사용할 처리기 형식을 선언한다. itemName과 borrower는 모두 String이다. ShowLoan, WriteAudit, showReturn의 람다도 같은 순서로 문자열 두 개를 받는다. 처리기마다 역할은 달라도 연결되는 형식은 같다.
_itemName, _isLoaned, _borrower는 비품 상태를 보관한다. _itemName은 생성자에서 정하고 이후 바꾸지 않으므로 ReadOnly로 선언한다. Boolean 필드인 _isLoaned의 초기값은 False이며, _borrower는 빈 문자열로 시작한다.
Event Loaned와 Event Returned는 서로 다른 두 알림이다. 같은 델리게이트 형식을 사용한다고 같은 이벤트가 되는 것은 아니다. Loaned에 연결한 처리기는 Returned가 발생할 때 실행되지 않는다.
생성자 New는 전달받은 비품 이름을 필드에 저장한다. IsLoaned 속성은 현재 대여 상태를 읽을 수 있게 한다. 쓰기 부분이 없는 속성이므로 외부 코드가 속성에 True를 대입해 대여 절차를 건너뛸 수는 없다.
TryLoan의 첫 If는 이미 대여 중이거나 대여자 이름이 비어 있으면 False를 반환한다. OrElse는 왼쪽 조건이 True이면 오른쪽 조건을 평가하지 않는 논리 연산자다. 이 경로에서는 상태 변경과 RaiseEvent를 실행하지 않는다. 이 메서드의 False는 요청이 거절되었다는 뜻이다.
_borrower = borrower와 _isLoaned = True는 대여 상태를 확정한다. 다음의 RaiseEvent가 안내와 기록 처리기를 호출한다. 처리기가 정상적으로 끝나면 Return True로 대여 성공을 호출자에게 돌려준다. 반환값은 요청한 코드가 확인하고, 이벤트는 연결된 처리기가 받는다.
TryReturn의 previousBorrower는 반납 전의 이름을 잠시 보관한다. 현재 대여자 필드를 먼저 비운 뒤 이벤트를 발생시키므로, 보관한 이름을 이벤트 인수로 전달한다. 이 변수가 없다면 안내에 필요한 이름을 지운 뒤 읽게 된다.
_isLoaned = False 다음의 RaiseEvent Returned는 반납이 끝난 상태에서 알림을 보낸다. 연결된 처리기가 없어도 반납 자체는 진행된다. 상태를 바꾸는 일과 알림을 받아 출력하는 일이 분리되어 있기 때문이다.
Private WithEvents desk는 프로그램에서 사용할 발행자 객체를 만든다. ShowLoan 선언의 Handles desk.Loaned가 이 변수의 대여 이벤트를 처리하도록 연결한다. 이 연결은 Main에서 기록 처리기를 추가하기 전에 준비된다.
returnCount는 반납 처리기의 실행 횟수다. 비품 객체 안에 보관한 총 반납 횟수가 아니다. audit는 WriteAudit을 연결한 델리게이트 변수이고, showReturn은 여러 줄 람다를 연결한 변수다. 람다는 returnCount를 캡처하여 알림을 받을 때 값을 1씩 늘린다.
두 AddHandler 문장은 대여 기록과 반납 안내를 연결한다. 첫 대여에서는 먼저 연결된 ShowLoan이 실행되고, 이어서 추가한 WriteAudit이 실행된다. 이 예제의 등록 순서가 출력 순서를 정하지만, 업무 규칙이 특정 처리기 순서에 의존하도록 설계하지는 않는 편이 좋다.
김하늘의 대여 요청은 성공하므로 실패 안내를 쓰는 If 본문은 실행되지 않는다. 이어지는 박민수의 요청은 이미 대여 중이므로 False를 반환한다. 이때 대여 이벤트는 발생하지 않고 Main의 거절 문장만 출력된다.
첫 TryReturn은 반납 상태로 바꾸고 람다를 호출한다. 반납 안내 한 줄이 출력되며 returnCount는 1이 된다. 이후 두 RemoveHandler 문장이 Main에서 추가했던 연결을 해제한다. Handles로 선언한 ShowLoan 연결은 그대로 유지된다.
이서준의 대여에는 대여 안내만 출력된다. 두 번째 반납은 성공하지만 반납 람다는 이미 해제되어 실행되지 않는다. 따라서 Main이 직접 완료 문장을 출력하고, returnCount는 여전히 1이다. 마지막 If는 비품 객체의 상태를 확인하여 대여 가능이라고 출력한다.
실행 결과
.NET 10 SDK가 설치된 환경에서 다음 명령으로 프로젝트를 만든다. 생성된 Program.vb의 내용을 완성 코드로 바꾼 뒤 실행한다.
dotnet new console -lang VB -n OfficeEvents
cd OfficeEvents
dotnet run
프로그램의 예상 출력은 다음과 같다. 사용자 입력이나 현재 시각을 사용하지 않으므로 같은 코드로 실행하면 같은 내용이 나온다.
대여: 프로젝터 / 김하늘
기록: 김하늘에게 프로젝터 대여
대여 거절: 이미 대여 중이다.
반납: 프로젝터 / 김하늘
기록과 반납 안내 연결을 해제했다.
대여: 프로젝터 / 이서준
두 번째 반납이 완료되었다.
반납 처리기 실행 횟수: 1
현재 상태: 대여 가능
반납은 두 번 성공했지만 반납 처리기는 한 번 실행되었다. 연결 해제는 반납 기능을 끈 것이 아니라 그 알림에 반응하는 코드 하나를 떼어 낸 것이다. 이 차이를 출력과 마지막 상태에서 확인할 수 있다.
실무에서 자주 틀리는 것
Handles 대상 변수에 WithEvents를 빠뜨린다
다음은 잘못된 선언이다. 일반 객체 변수만 선언하고 Handles에서 그 변수의 이벤트를 지정하면 컴파일 오류가 발생한다. Handles로 연결하려면 대상 변수를 WithEvents로 선언해야 한다.
Private desk As New LoanDesk("프로젝터")
Private Sub ShowLoan(itemName As String, borrower As String) Handles desk.Loaned
Console.WriteLine(itemName)
End Sub
고친 코드는 다음과 같다. WithEvents를 사용할 때는 아래처럼 모듈 또는 클래스 수준에 변수를 둔다.
Private WithEvents desk As New LoanDesk("프로젝터")
Private Sub ShowLoan(itemName As String, borrower As String) Handles desk.Loaned
Console.WriteLine(itemName)
End Sub
처리기를 연결하면서 호출까지 하려 한다
AddHandler의 두 번째 부분에는 델리게이트가 필요하다. 다음 코드는 WriteAudit을 인수 없이 호출하려는 형태이며, 필요한 문자열 인수도 없고 연결할 델리게이트도 제공하지 않는다.
AddHandler desk.Loaned, WriteAudit()
이름 있는 프로시저를 지정할 때는 AddressOf를 사용한다. 연결 시점에는 WriteAudit이 실행되지 않는다. Loaned가 발생할 때 이벤트 인수가 전달되면서 실행된다.
AddHandler desk.Loaned, AddressOf WriteAudit
람다를 새로 적어서 기존 연결을 해제하려 한다
다음 코드는 연결할 때와 해제할 때 람다를 각각 작성한다. 문장이 같아 보여도 원래 추가한 연결을 지정하지 않으므로 해제를 기대할 수 없다.
AddHandler desk.Returned,
Sub(itemName As String, borrower As String)
Console.WriteLine(itemName)
End Sub
RemoveHandler desk.Returned,
Sub(itemName As String, borrower As String)
Console.WriteLine(itemName)
End Sub
고친 코드는 델리게이트를 변수에 보관한다. 연결과 해제에 같은 변수를 사용하면 대상이 분명해진다.
Dim handler As LoanNoticeHandler =
Sub(itemName As String, borrower As String)
Console.WriteLine(itemName)
End Sub
AddHandler desk.Returned, handler
RemoveHandler desk.Returned, handler
상태를 바꾸기 전에 완료 이벤트를 발생시킨다
다음 순서에서는 처리기가 IsLoaned를 읽으면 아직 False일 수 있다. 대여 완료라는 알림과 실제 상태가 서로 맞지 않는다. 아래 조각은 LoanDesk의 대여 성공 경로를 나타낸다.
RaiseEvent Loaned(_itemName, borrower)
_borrower = borrower
_isLoaned = True
고친 순서는 상태 변경이 먼저다. 처리기가 속성을 확인하더라도 대여가 끝난 상태를 읽는다.
_borrower = borrower
_isLoaned = True
RaiseEvent Loaned(_itemName, _borrower)
이벤트 처리기에서 예외가 발생하면 이 예제의 RaiseEvent 뒤로 정상 진행하지 못할 수 있다. 앞 장에서 배운 예외 처리를 출력이나 기록 같은 경계에 적용할 필요가 있다. 이미 바꾼 비품 상태가 처리기의 예외 때문에 자동으로 되돌아가는 것은 아니다. 또한 처리기가 다시 같은 객체의 메서드를 호출하면 실행 흐름이 복잡해질 수 있으므로, 이번 처리기처럼 알림에 대한 반응을 짧고 분명하게 유지한다.
한눈에 보기
| 요소 | 사용 위치 | 하는 일 | 확인할 점 |
|---|---|---|---|
| Delegate Sub | 형식 선언 | 처리기의 매개변수 형태를 정한다. | 인수 형식과 반환 형태를 맞춘다. |
| Event | 발행자 클래스 | 외부에서 구독할 알림을 선언한다. | 선언만으로 알림이 발생하지 않는다. |
| RaiseEvent | 발행자 내부 | 연결된 처리기를 호출한다. | 완료 알림은 상태 변경 뒤에 보낸다. |
| WithEvents · Handles | 객체 변수와 처리기 선언 | 선언으로 연결 관계를 나타낸다. | 특정 변수에 담긴 객체가 대상이다. |
| AddHandler | 실행되는 프로시저 | 처리기 연결을 추가한다. | 중복 연결을 피한다. |
| RemoveHandler | 실행되는 프로시저 | 지정한 처리기 연결을 해제한다. | 해제할 델리게이트를 보관한다. |
| 람다 | 델리게이트를 만드는 곳 | 이름 없이 처리 내용을 적는다. | 캡처한 변수도 함께 사용한다. |
요청의 성공 여부는 Function의 반환값으로 확인하고, 성공에 대한 다른 반응은 이벤트로 연결할 수 있다. 이벤트 자체는 화면 기능이 아니다. 콘솔에서도 상태 변화에 반응하는 코드를 나눌 수 있으며, 화면 프로그램에서는 사용자의 조작도 이벤트로 전달된다는 점이 더해진다.
다음 장의 종합 실습에서는 비품 관리 기능을 한 프로그램으로 묶는다. 그때도 대여 규칙을 처리하는 코드와 결과를 안내하는 코드의 역할을 구분하면 프로그램을 읽고 수정하기 쉬워진다.
연습 문제
- 완성 코드에서 두 RemoveHandler 문장만 삭제하면 어떤 출력이 추가되며, 반납 처리기 실행 횟수는 얼마가 되는지 설명하라.
- 첫 대여 전에 AddHandler desk.Loaned, audit을 한 번 더 실행하면 첫 대여의 기록 출력은 몇 번 발생하는지 설명하라. 중복 출력을 없애도록 코드를 고쳐라.
- 반납 안내 람다를 ShowReturn이라는 이름 있는 Sub로 바꾸고, AddressOf를 사용해 연결하라. 횟수 계산은 제외하고 반납 안내만 출력하도록 작성하라.
- 처리기를 모두 해제한 상태에서 TryReturn이 성공할 수 있는지 설명하라. 성공할 수 있다면 처리기가 맡지 말아야 할 핵심 작업을 하나 적어라.
정답과 해설
-
두 번째 대여에도 기록 처리기가 연결되어 있으므로 대여 안내 다음에 기록 한 줄이 추가된다. 두 번째 반납에도 람다가 실행되므로 반납 안내가 추가된다. 반납 처리기 실행 횟수는 2가 된다.
기록: 이서준에게 프로젝터 대여 반납: 프로젝터 / 이서준연결 해제 문장만 삭제했다면 “기록과 반납 안내 연결을 해제했다.”라는 출력 문장은 남는다. 실제 동작과 안내가 맞도록 그 Console.WriteLine도 삭제해야 한다. 위 두 줄은 전체 출력이 아니라 새로 추가되는 줄이다.
-
같은 처리기를 두 번 연결하면 첫 대여의 기록 출력도 두 번 발생한다. AddHandler는 이미 연결되어 있는지 확인해 하나만 남기는 문장이 아니다. 추가한 중복 문장을 삭제하고 다음 연결을 한 번만 실행한다.
AddHandler desk.Loaned, audit반복문이나 여러 초기화 경로에서 연결하는 경우에도 호출 횟수를 확인해야 한다. 초기화할 때 한 번 연결하고, 사용이 끝날 때 한 번 해제하는 흐름을 정하는 것이 좋다.
-
다음 Sub를 Module Program 안에 추가한다. 매개변수의 형식과 순서가 LoanNoticeHandler와 같아야 한다.
Private Sub ShowReturn(itemName As String, borrower As String) Console.WriteLine($"반납: {itemName} / {borrower}") End SubMain의 showReturn 람다 선언을 다음 선언으로 바꾼다. 기존 AddHandler와 RemoveHandler는 같은 변수 이름을 사용하므로 그대로 둘 수 있다.
Dim showReturn As LoanNoticeHandler = AddressOf ShowReturn이 처리기는 횟수를 계산하지 않는다. 횟수 변수가 더는 필요하지 않다면 returnCount 선언과 마지막 횟수 출력도 함께 삭제한다.
-
성공할 수 있다. TryReturn은 먼저 대여 중인지 확인한 뒤 필드를 바꾸고, 그 다음에 알림을 발생시킨다. 연결된 처리기가 없으면 알림으로 실행되는 코드가 없을 뿐이다. 대여 상태를 False로 바꾸거나 현재 대여자를 비우는 핵심 작업은 발행자 메서드가 맡아야 한다.
이 작업을 반납 안내 처리기에 넣으면 안내 연결을 해제했을 때 반납 상태도 바뀌지 않게 된다. 알림을 받는 코드의 존재 여부와 비품 관리 규칙의 실행 여부를 분리하는 것이 이 예제의 설계 목적이다.