폼에 컨트롤을 코드로 만들어 붙였다 뗐다 하는 프로그램에서는 "이 컨트롤 아직 살아 있나?"를 확인할 일이 생깁니다. 이미 Dispose 된 컨트롤에 접근하면 ObjectDisposedException 이 나기 때문이죠. 타이머나 백그라운드 작업이 끝나고 결과를 화면에 쓰려는데 그사이 사용자가 창을 닫아 버린 경우가 대표적입니다.
컨트롤 자신만 보는 게 아니라 부모 쪽까지 거슬러 올라가며 확인하는 확장 메서드입니다.
Module ControlExtensions
<Extension()>
Public Function IsDisposed(ByVal control As Control) As Boolean
If control Is Nothing Then Return True
If control.Disposing OrElse control.IsDisposed Then Return True
Dim parent = control.Parent
While parent IsNot Nothing
If parent.Disposing OrElse parent.IsDisposed Then Return True
parent = parent.Parent
End While
Return False
End Function
End Module
' 사용 예제
If myControl.IsDisposed() Then
' 컨트롤이 삭제되었음을 처리
Else
' 컨트롤이 여전히 존재함을 처리
End If
무엇을 검사하나
세 단계로 봅니다. 변수가 Nothing 이면 삭제된 것으로 치고, 컨트롤 자신이 Disposing(정리 중)이거나 IsDisposed(정리 완료)면 마찬가지입니다. 마지막으로 Parent 를 따라 올라가며 부모 패널이나 폼이 정리 중인지 확인합니다. 폼이 닫히는 도중에는 자식 컨트롤이 아직 IsDisposed = False 인 짧은 순간이 있어서, 부모까지 봐야 그 틈에서 생기는 오류를 피할 수 있습니다.
이름이 겹치면 확장 메서드가 안 불린다
주의할 점이 하나 있습니다. Control 에는 이미 IsDisposed 라는 속성이 있습니다. VB.NET은 같은 이름의 인스턴스 멤버가 있으면 확장 메서드보다 그쪽을 먼저 고르기 때문에, myControl.IsDisposed() 라고 써도 위의 확장 메서드가 아니라 기본 속성이 호출됩니다. 그러면 부모 검사도, Nothing 검사도 하지 않고, myControl 이 Nothing 이면 예외가 납니다.
그래서 이름을 바꿔서 쓰는 걸 권합니다. 로직은 그대로 두고 이름만 바꾼 버전입니다. 확장 메서드를 쓰려면 파일 맨 위에 Imports System.Runtime.CompilerServices 도 있어야 합니다.
Imports System.Runtime.CompilerServices
Module ControlExtensions
<Extension()>
Public Function IsDisposedOrDisposing(ByVal control As Control) As Boolean
If control Is Nothing Then Return True
If control.Disposing OrElse control.IsDisposed Then Return True
Dim parent = control.Parent
While parent IsNot Nothing
If parent.Disposing OrElse parent.IsDisposed Then Return True
parent = parent.Parent
End While
Return False
End Function
End Module
확장 메서드는 변수가 Nothing 이어도 호출 자체는 되기 때문에, 이름을 바꾸면 첫 줄의 Nothing 검사도 제대로 의미가 생깁니다.
실제로 쓰는 형태
필요할 때 컨트롤을 다시 만드는 용도라면 이 한 줄이면 충분합니다. 없거나 이미 정리된 컨트롤이면 새로 만듭니다.
If myControl Is Nothing OrElse myControl.IsDisposed Then myControl = New TextBox
여기서는 기본 IsDisposed 속성을 쓰는 것이고, OrElse 덕분에 Nothing 일 때는 뒤쪽을 평가하지 않아 예외가 나지 않습니다. 새로 만든 컨트롤은 Controls.Add 로 다시 폼에 붙여야 화면에 보인다는 것도 잊지 마세요. 다른 스레드에서 결과를 쓰는 경우라면 이 검사 뒤에 InvokeRequired 확인까지 같이 넣어 두는 게 안전합니다.
백그라운드 작업 결과를 쓸 때 묶어서 쓰기
앞에서 말한 "작업이 끝났는데 창이 이미 닫힌" 상황을 실제 코드로 옮기면 아래처럼 됩니다. 검사, 스레드 확인, 대입을 한 함수에 모아 두면 어디서 불러도 같은 방식으로 처리됩니다.
Private Sub ShowResult(text As String)
If Label1.IsDisposedOrDisposing() Then Return
If Label1.InvokeRequired Then
Try
Label1.BeginInvoke(New Action(Sub() ShowResult(text)))
Catch ex As InvalidOperationException
' 검사 직후 창이 닫힌 경우
End Try
Return
End If
Label1.Text = text
End Sub
검사하고 BeginInvoke 를 부르는 사이에도 사용자가 창을 닫을 수 있어서, 검사만으로 완전히 막을 수는 없습니다. 그 짧은 틈에서 나는 예외는 Try 로 받아 넘기는 게 현실적인 방법입니다. UI 스레드로 넘어온 뒤 다시 ShowResult 가 불리면서 한 번 더 검사하니 대입 직전 상태도 확인됩니다.
Controls.Remove는 Dispose가 아니다
헷갈리기 쉬운 부분이 하나 더 있습니다. Me.Controls.Remove(myControl) 은 화면에서 떼어 낼 뿐 컨트롤을 정리하지 않아서, 떼어 낸 뒤에도 IsDisposed 는 False 입니다. 다시 붙일 생각이 없다면 Remove 뒤에 myControl.Dispose() 까지 불러야 핸들과 리소스가 반환됩니다. 동적으로 컨트롤을 계속 만들고 지우는 화면에서 Dispose 를 빠뜨리면 창 핸들이 쌓여 결국 "핸들을 만드는 동안 오류가 발생했습니다" 같은 예외로 이어집니다.
반대로 Dispose 를 해도 변수 자체가 Nothing 이 되지는 않습니다. 변수에는 정리된 개체가 그대로 들어 있으니, 나중에 Is Nothing 으로만 판단하는 코드가 있다면 Dispose 직후 myControl = Nothing 을 같이 넣어 두는 게 덜 헷갈립니다.