WPF에서 PasswordBox를 처음 마주치는 순간 불편함을 느끼게 됩니다.
TextBox의 Text 속성은 자연스럽게 바인딩이 되는데, PasswordBox의 Password 속성은 바인딩을 지원하지 않습니다.
뭔가 불편합니다.
이로 인해 인터넷 검색을 해보면 Attached Property, Behavior, Interaction, EventHandler 등을 이용해 이 문제를 “해결” 하려는 다양한 기법들을 쉽게 찾아볼 수 있습니다.
이러한 기법이 많다는 사실 자체가, 개발자들이 공통적으로 겪는 불편함이 크다는 의미이기도 합니다.
동시에 한 가지 중요한 질문이 떠오릅니다.
“WPF는 왜 PasswordBox.Password를 DependencyProperty로 만들지 않았을까?”
이 글에서는 단순한 우회 기법 소개가 아니라, 왜 이렇게 설계되었는지를 중심으로 WPF PasswordBox의 내부 구현과 의도를 고찰해봅니다.
PasswordBox.Password는 왜 DependencyProperty가 아닌가
결론부터 말하면, 이는 의도적인 설계입니다.
PasswordBox는 처음부터 MVVM 친화적인 컨트롤로 설계되지 않았습니다.
이를 확인하기 위해 WPF 소스 코드를 직접 살펴보는 것이 가장 명확합니다.
아래 분석은 .NET WPF 공식 소스의 PasswordBox.cs를 기준으로 합니다.
Password 속성의 실제 구현
PasswordBox.Password는 DependencyProperty가 아닙니다.
단순한 CLR 속성이며, 내부적으로는 SecureString 형식으로 구현되어 있습니다.
public string Password
{
get
{
using (SecureString securePassword = this.SecurePassword)
{
IntPtr ptr = Marshal.SecureStringToBSTR(securePassword);
try
{
return new string((char*)ptr);
}
finally
{
Marshal.ZeroFreeBSTR(ptr);
}
}
}
set
{
using (SecureString securePassword = new SecureString())
{
foreach (char c in value)
securePassword.AppendChar(c);
SetSecurePassword(securePassword);
}
}
}
여기서 중요한 사실은 다음과 같습니다.
- 입력된 패스워드 값은 암호화 되어 저장된다.
Password를 읽는 순간, 평문 문자열이 새로 생성된다.
만약 DependencyProperty였다면?
만약 Password가 DependencyProperty였다면 어떤 일이 발생할까요.
1. 평문 문자열의 다중 복제
DependencyProperty는 다음과 같은 경로를 통해 값이 복제됩니다.
DependecyObject의EffectiveValueEntry내부- 바인딩 엔진 내부 캐시
- 변경 전/후 값 스냅샷
- 스타일, 트리거, 애니메이션
- 디버깅 및 진단 인프라
즉, 하나의 비밀번호 입력이 프레임워크 내부에서 여러 개의 평문 복사본으로 확산되는 구조가 됩니다.
이는 SecureString을 사용하는 설계 의도와 정면으로 충돌합니다.
2. Journaling(탐색 기록) 문제
PasswordBox는 Navigation 기반 애플리케이션에서 저널링을 명시적으로 차단합니다.
여기서 말하는 저널링(Journaling))은 NavigationWindow/Frame 기반 WPF 내비게이션에서 제공하는 “이전/다음(Back/Forward)” 탐색 기록 기능입니다. 이 과정에서 페이지 인스턴스 및 일부 상태가 기록/복원될 수 있는데, 비밀번호처럼 민감한 값이 의도치 않게 기록되는 상황을 차단하려고 합니다.
// Prevent journaling
NavigationService.NavigationServiceProperty.OverrideMetadata(
typeof(PasswordBox),
new FrameworkPropertyMetadata(OnParentNavigationServiceChanged)
);
...
// Set up listener for Navigating event
private static void OnParentNavigationServiceChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
PasswordBox passwordBox = (PasswordBox)o;
NavigationService navService = NavigationService.GetNavigationService(o);
if (passwordBox._navigationService != null)
{
passwordBox._navigationService.Navigating -= new NavigatingCancelEventHandler(passwordBox.OnNavigating);
}
if (navService != null)
{
navService.Navigating += new NavigatingCancelEventHandler(passwordBox.OnNavigating);
passwordBox._navigationService = navService;
}
else
{
passwordBox._navigationService = null;
}
}
그리고 실제로 페이지 이동이 발생하면 비밀번호를 강제로 초기화합니다.
// Clear password on navigation to prevent journaling.
private void OnNavigating(object sender, NavigatingCancelEventArgs e)
{
this.Password = String.Empty;
}
3. IME / Clipboard 입력 미지원
PasswordBox는 IME 입력을 강제로 비활성화합니다.
InputMethod.IsInputMethodEnabledProperty.OverrideMetadata(
typeof(PasswordBox),
new FrameworkPropertyMetadata(BooleanBoxes.FalseBox, ...));
Clipboard 역시 일반적인 TextBox와는 다른 제한된 동작을 합니다.
PasswordBox는 붙여넣기(Paste)기능만을 제공합니다.
public void Paste()
{
RoutedCommand command = ApplicationCommands.Paste;
command.Execute(null, this);
}
반면 복사/잘라내기(Copy/Cut)처럼 “컨트롤 내부의 비밀번호를 외부로 내보내는” 동작은 구현되어 있지 않습니다.
핵심 설계 의도
이 모든 설계를 종합하면 PasswordBox의 핵심 목표를 다음과 같이 추측해 볼 수 있습니다.
평문 비밀번호가 메모리에 존재하는 시간을 가능한 한 최소화한다
완벽한 보안은 불가능합니다.
그러나 WPF는 “프레임워크 차원에서 불필요한 노출을 만들지 않겠다” 는 방향을 선택했습니다.
MVVM의 편의성보다 보안을 우선한 매우 드문 컨트롤 중 하나가 바로 PasswordBox입니다.
흔히 사용되는 바인딩 우회 기법에 대한 평가
인터넷에 널리 퍼진 방식들은 대개 다음 중 하나입니다.
PasswordChanged이벤트를 수신해서 저장- Attached Property로 Password를 동기화
- Behavior를 사용한 양방향 바인딩 흉내
이 방식들은 기술적으로는 동작합니다.
그러나 본질적으로는 다음 문제를 그대로 안고 갑니다.
- ViewModel 또는 DO/DP 내부 및 전달과정에 평문 비밀번호가 저장됨
- 메모리 상주 시간이 길어짐
- 디버깅, 덤프, 스냅샷에 노출될 가능성 증가
즉, 문제를 해결한 것이 아니라 설계 의도를 무시한 것에 가깝습니다.
WPF로 구현된 응용프로그램에서 만약 사용자가 패스워드를 한 번 입력 완료한 상태에서 악의적으로 메모리 덤프를 수행하면 어떻게 될까요?
평문으로 저장된 패스워드를 찾을 수 있는 가능성이 높아질 것입니다.
대안은? “필요한 순간에만” 가져오기
로그인 시나리오를 다시 생각해보면, 실제로 필요한 것은 다음 중 하나입니다.
- 비밀번호 문자열 그 자체
- 혹은 단방향 Hash 등 인증에 필요한 최소한의 입력값
이 경우 현실적인 접근은 다음과 같습니다.
- View에서 “비밀번호를 제공할 수 있는 작은 인터페이스”를
CommandParameter를 통해 필요한 순간에 한 번만 노출시킨다. - Command 실행 시점에만
SecureString을 즉시 처리한다. (평문 Password 문자열을 전달 할 경우CanExecute평가 시 힙에 생성되므로SecureString으로 처리)
이 방식의 특징은 다음과 같습니다.
- MVVM 구조를 완전히 깨지 않는다.
- 평문 문자열을 ViewModel 상태로 보존하지 않는다(필요한 순간에만 읽음).
- 비밀번호의 생존 범위를 명확히 제한할 수 있다.
아래는 PasswordProxy.IsEnabled라는 Attached Property를 통해, ViewModel이 PasswordBox를 직접 모르면서도 “읽는 순간에만” 비밀번호를 가져올 수 있게 하는 구현 예시입니다.
public interface IPasswordProvider
{
SecureString GetSecurePassword();
bool IsEmpty { get; }
}
public sealed class PasswordBoxPasswordProvider : IPasswordProvider
{
private readonly WeakReference<PasswordBox> _passwordBox;
public PasswordBoxPasswordProvider(PasswordBox passwordBox)
=> _passwordBox = new WeakReference<PasswordBox>(passwordBox);
public SecureString? GetSecurePassword()
{
_passwordBox.TryGetTarget(out var passwordBox);
return passwordBox?.SecurePassword;
}
public bool IsEmpty => GetSecurePassword()?.Length > 0 != true;
}
public static class PasswordProxy
{
public static readonly DependencyProperty IsEnabledProperty =
DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(PasswordProxy),
new PropertyMetadata(false, OnIsEnabledChanged));
public static readonly DependencyProperty ProviderProperty =
DependencyProperty.RegisterAttached(
"Provider",
typeof(IPasswordProvider),
typeof(PasswordProxy),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public static bool GetIsEnabled(DependencyObject obj) => (bool)obj.GetValue(IsEnabledProperty);
public static void SetIsEnabled(DependencyObject obj, bool value) => obj.SetValue(IsEnabledProperty, value);
public static IPasswordProvider? GetProvider(DependencyObject obj) => (IPasswordProvider?)obj.GetValue(ProviderProperty);
public static void SetProvider(DependencyObject obj, IPasswordProvider? value) => obj.SetValue(ProviderProperty, value);
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not PasswordBox passwordBox)
return;
if (e.NewValue is true)
SetProvider(passwordBox, new PasswordBoxPasswordProvider(passwordBox));
else
SetProvider(passwordBox, null);
}
}
Provider를 ViewModel 상태로 저장하지 않고 로그인 버튼의 CommandParameter로 Provider를 전달합니다.
<PasswordBox x:Name="PasswordBox"
local:PasswordProxy.IsEnabled="True" />
<Button Content="Login"
Command="{Binding LoginCommand}"
CommandParameter="{Binding local:PasswordProxy.Provider, ElementName=PasswordBox}" />
그리고 ViewModel의 Command에서는 컨트롤이 아니라 IPasswordProvider만 “그 순간” 인자로 받아 사용합니다.
public ICommand LoginCommand => new RelayCommand<IPasswordProvider?>(
execute: provider =>
{
using var secure = provider.GetSecurePassword();
string password;
IntPtr ptr = Marshal.SecureStringToBSTR(securePassword);
try
{
password = new string((char*)ptr); // new string으로 생성될 경우 GC 전까지 힙에 남아 있으므로 주의
}
finally
{
Marshal.ZeroFreeBSTR(ptr);
}
/// ...
/// 로그인 처리 (가능하면 ptr의 raw string값을 직접 처리)
},
canExecute: provider => provider?.IsEmpty == false);
결론
PasswordBox.Password가 바인딩을 지원하지 않는 것은 WPF의 미완성이나 실수가 아닙니다.
이는 의도적이고 보수적인 보안 설계의 결과입니다.
우리는 종종 “불편하다”는 이유로 문제를 해소하는 데만 급급해집니다.
그러나 그 과정에서 진짜 문제가 무엇인지를 놓치는 경우가 많습니다.
PasswordBox는 우리에게 다음 질문을 던집니다.
“정말로 이 값은 바인딩되어야 하는가?”
“편의성을 위해 보안 모델을 깨도 되는가?”
이렇게 진짜 문제가 무엇인지 한 번 더 고민해볼 필요가 있습니다.
