Windows 앱의 중복 실행 방지 ── 네임드 Mutex와 재실행 시 창 활성화
· 小村 豪 · 중복 실행 방지, Mutex, Windows, .NET, C#, SetForegroundWindow, 원격 데스크톱, 네임드 파이프, Windows 개발, 기술 상담
“업무 앱을 실수로 한 번 더 실행해서, 같은 파일을 두 개의 창에서 편집하다가 한쪽의 변경 내용이 사라졌다.” “상주 도구가 두 번 실행되어, 같은 이벤트를 두 번 처리해 버렸다.” 데스크톱 앱의 중복 실행은 소소한 주제처럼 보이지만, 실무에서는 의외로 사고의 원인이 되곤 합니다. 대책의 골격 자체는 단순해서, 네임드 Mutex로 “자신이 첫 번째 인스턴스인가”를 판정하기만 하면 됩니다.
그런데 이 단순한 구현에는 주변의 함정들이 따라붙습니다. 원격 데스크톱 환경에서 방지가 동작하지 않게 되는 네임스페이스의 함정, 다른 동기화 객체와는 다른 Mutex 특유의 해제 규칙, 그리고 “찾아낸 기존 인스턴스를 어떻게 앞으로 가져올 것인가”라는 Win32의 포그라운드 창 제한이 관여하는 설계 과제입니다. 이 글에서는 네임드 Mutex에 의한 중복 실행 탐지의 기본부터, 실무에서 필요해지는 주변 설계 판단까지를 한 번에 정리합니다.
1. 먼저 결론
- 중복 실행 탐지의 기본형은
new Mutex(true, name, out bool createdNew)입니다. 같은 이름의Mutex가 이미 존재해도 예외는 발생하지 않고createdNew가false가 될 뿐이므로, 이 값으로 분기합니다.1 - 이름에 접두사를 붙이지 않으면 기본적으로
Local\(세션 한정) 네임스페이스에 생성됩니다. 원격 데스크톱에서 같은 사용자가 여러 세션을 가진 환경에서는 세션마다 별개의Mutex로 취급되어, 중복 실행 방지가 동작하지 않습니다. 세션을 넘어 하나로 묶고 싶다면Global\접두사가 필수입니다.23 Mutex는 획득한 스레드와 같은 스레드에서만 해제할 수 있습니다. 다른 스레드에서ReleaseMutex를 호출하면ApplicationException이 발생합니다. 소유 프로세스가 해제하지 않고 종료된 경우 다음에 획득한 스레드에AbandonedMutexException이 발생하지만, 이는 대기 자체는 성공했다는 신호이므로 무시하지 말고 상태의 일관성을 확인한 뒤 사용하는 것이 올바른 처리입니다.45- “이미 실행 중이다”라는 것을 알게 된 후의 본론은, 기존 인스턴스의 창을 앞으로 가져오는 것입니다.
SetForegroundWindow는 포그라운드 프로세스 이외의 호출을 OS가 제한하고 있어, 단순히 호출하는 것만으로는 실패하고 작업 표시줄 버튼이 깜빡이는 데 그칩니다.6 지금 실행된 두 번째 프로세스가 가진 “포그라운드를 설정할 수 있는 권한”을AllowSetForegroundWindow로 기존 인스턴스에 넘겨주는 설계가 정석입니다.7 - 실행 인수(열어야 할 파일 경로 등)를 기존 인스턴스에 전달하려면 네임드 파이프로 전송하는 것이 정석입니다. 수단의 비교는 「Windows의 프로세스 간 통신을 어떻게 선택할까」에 맡기고, 이 글에서는 “탐지 → 알림 → 활성화”의 설계에 집중합니다.
- 콘솔 앱이나 서비스는 이 글의 설계를 그대로 가져올 대상이 아닙니다. 서비스는 SCM이 애초에 같은 이름의 서비스를 하나만 실행하므로 사고방식이 다릅니다(8장).
2. 네임드 Mutex에 의한 탐지의 기본
Mutex는 커널 객체로, 이름을 붙여 생성하면 그 이름을 아는 다른 프로세스에서 같은 객체를 참조할 수 있습니다. 중복 실행 탐지에는 이 “이름 공유”만을 이용합니다.
using var mutex = new Mutex(initiallyOwned: true, name: MutexName, out bool createdNew);
if (!createdNew)
{
// 이미 실행 중이다
return;
}
// createdNew가 true일 때만 이 스레드가 Mutex를 소유한다
여기서 짚어 두어야 할 것은 다음 2가지입니다.
- 같은 이름의
Mutex가 이미 존재해도 예외는 발생하지 않습니다.createdNew에false가 들어가고, 기존 객체에 대한 참조가 반환될 뿐입니다. 분기는 반드시createdNew로 합니다.1 initiallyOwned: true는 “새로 생성할 수 있었을 때”만 유효합니다. 이미 존재하던 경우(createdNew == false)에는 이 스레드가 자동으로 소유자가 되지 않습니다. 중복 실행 탐지에서는 이 부작용이 문제가 되지 않지만, “두 번째 프로세스가 실수로ReleaseMutex를 호출해 버리는” 사고를 막기 위해서도,createdNew가false인 분기에서는Mutex를 전혀 건드리지 않고 즉시 처리를 마치는 것이 안전합니다.1
이름은 다른 회사 앱과 충돌하지 않도록 제품 고유의 GUID를 넣은 문자열로 만드는 것이 정석입니다("KomuraSoft.MyApp.SingleInstance.{3F1E2B10-...}"처럼). 파이프 이름 선점(스쿼팅)과 마찬가지로, 명명된 커널 객체의 네임스페이스는 머신 위의 다른 프로세스에서도 보이므로 추측하기 어려운 이름으로 해 두는 데 의미가 있습니다.
3. Global\ 과 Local\ ── 세션을 넘어가면 무슨 일이 일어나는가
Windows의 커널 객체 네임스페이스는 세션마다 독립된 네임스페이스와, 시스템 전체에서 공유되는 글로벌 네임스페이스로 나뉩니다. 명명된 Mutex를 만들 때 접두사를 지정하지 않으면 기본적으로 호출한 쪽의 세션 네임스페이스(Local\)에 생성되고, Global\을 붙였을 때만 모든 세션 공통의 네임스페이스에 생성됩니다.3 .NET의 Mutex 클래스도 이 동작을 그대로 따르고 있으며, 문서에는 “접두사를 지정하지 않은 네임드 Mutex는 기본적으로 Local\을 가진다”라고 명시되어 있습니다.2
이것이 문제가 되는 것은 다음과 같은 상황입니다.
- 업무용 관리 단말에, 콘솔에서 직접 로그온한 사용자가 원격 데스크톱으로도 자기 자신에게 또 하나의 세션을 연결하고 있다(운영에서는 흔한 일입니다).
- 같은 사용자가 RDP 접속을 끊고 다시 연결하기를 반복하고, 그때마다 새로운 세션 ID가 할당된다.
Local\인 채로(접두사 없이) Mutex를 만들면, 이들은 서로 다른 세션으로 취급되어, 각 세션에서 중복 실행 방지가 독립적으로 작동합니다. 즉 “같은 사용자인데 두 번째 세션에서는 그냥 실행이 되어 버린다”라는, 중복 실행 방지가 의도한 대로 동작하지 않는 버그가 됩니다. 반대로 말하면, 단일 세션만을 가정한 일반적인 데스크톱 사용에서는 Local\(기본값) 그대로여도 실제 피해는 없습니다.
여러 사용자가 함께 쓰는 단말의 설계 일반에 대해서는 「Windows 사용자 프로필 입문」에서도 다루고 있지만, 이 글의 주제에 한정해서 말하면 주의가 필요합니다. Global\은 머신 위의 모든 사용자·모든 세션이 공유하는 단일 네임스페이스이며, “사용자 1명당 1인스턴스”를 자동으로 의미하지는 않습니다. Global\KomuraSoft.MyApp.SingleInstance처럼 이름을 고정한 채 Global\만 붙이면, 사용자 A가 실행 중일 때 사용자 B의 실행도 같은 Mutex에 막혀 버려, 실질적으로 “머신 전체에서 1인스턴스”(5장 표의 세 번째 행)가 됩니다. “사용자 1명당 1인스턴스, 다만 그 사용자의 여러 세션은 하나로 묶는다”를 실현하고 싶다면, Global\에 더해 이름에 사용자 고유의 식별자(SID 등)를 넣어야 합니다. 반대로 머신 전체에서 1인스턴스로 제한하고 싶은 경우(모든 사용자 공유가 의도된 것)라면, SID를 포함하지 않은 Global\ 그대로 두어도 됩니다.
4. Mutex의 해제 규칙 ── 소유 스레드와 AbandonedMutexException
Mutex에는 Semaphore나 AutoResetEvent 같은 다른 동기화 객체에는 없는 제약이 있습니다. 획득한 스레드와 같은 스레드에서만 해제할 수 있다는, 스레드 ID를 강제하는 규칙입니다.2 다른 스레드에서 ReleaseMutex를 호출하면 ApplicationException(“호출 스레드가 뮤텍스를 소유하고 있지 않습니다”)이 발생합니다.4
.NET의 async/await를 사용한 코드에서는, await 뒤에 처리가 다른 스레드 풀 스레드에서 재개되는 경우가 있습니다. “Mutex를 획득한 직후에 비동기 처리를 끼워 넣고, 그 이어지는 부분에서 해제하는” 코드를 작성하면 이 제약을 조용히 위반할 수 있으므로 주의하시기 바랍니다. 중복 실행 탐지 용도에서는 획득과 해제를 모두 동기적인 짧은 코드 안에 가두는 것이 안전합니다.
또 하나의 주의점이 포기(abandoned)입니다. Mutex를 소유하던 스레드가 ReleaseMutex를 호출하지 않고 종료(프로세스가 크래시했다, 처리되지 않은 예외로 떨어졌다 등)하면, 그 Mutex는 포기된 상태가 됩니다. 다음에 이 Mutex를 획득한 스레드에는 AbandonedMutexException이 발생하지만, 이는 대기 자체는 성공했고, 호출자는 이미 Mutex의 소유권을 얻었다는 것을 나타내는 예외입니다.5 중복 실행 탐지만을 위한 용도라면 보통은 발생하지 않습니다(획득한 뒤 해제하지 않고 프로세스 종료까지 계속 들고 있기 때문입니다). 하지만 같은 Mutex를 다른 배타 제어에도 함께 사용하고 있다면, 보호 대상의 상태가 깨져 있을 가능성을 염두에 두고 다음과 같이 처리합니다.
try
{
if (mutex.WaitOne(TimeSpan.FromSeconds(5)))
{
// 평소대로의 처리
}
}
catch (AbandonedMutexException)
{
// 대기는 성공했고, 이 스레드는 이미 소유권을 얻었다.
// 보호 대상의 상태를 검사한 뒤 사용하거나, 안전하게 다시 초기화한다
}
“예외를 무시할 것인가, 상태를 검사하고 계속할 것인가, 포기하고 비정상 종료할 것인가”의 구분은, 이 블로그의 「예상치 못한 예외에서 종료해야 할지 계속해야 할지의 판단표」의 사고방식이 그대로 적용됩니다. AbandonedMutexException은 “깨졌을 가능성이 있는 범위”를 명시적으로 알려 주는 예외이므로, 무시하지 말고 그 범위(보호 대상 데이터)만을 검사하는 것이 기본 방침입니다.
또한 정상 종료하는 경로에서는 finally에서 명시적으로 ReleaseMutex를 호출해 두어야 합니다. 소유 스레드가 프로세스 종료로 사라질 때 Mutex는 “포기된” 것으로 취급되므로, 명시적으로 해제해 두지 않으면 다음 실행 시 불필요한 AbandonedMutexException을 일으키게 됩니다.
5. 판단표 ── 어느 스코프에서 Mutex를 사용할 것인가
중복 실행을 “어느 단위”로 막고 싶은가에 따라 네임스페이스와 파이프 쪽의 설계가 달라집니다.
| 단위 | 예상 시나리오 | Mutex 네임스페이스 | 실행 인수 전달 | 주의점 |
|---|---|---|---|---|
| 세션 단위(기본값) | RDP를 쓰지 않거나, “세션마다 하나”면 충분한 일반적인 데스크톱 사용 | 접두사 없음(=Local\) |
CurrentUserOnly에 더해, 파이프 이름에 세션 ID를 포함 |
RDP를 함께 사용하는 환경에서는 다른 세션에서의 실행을 막을 수 없다. 같은 사용자가 여러 세션을 가진 경우, 파이프 이름에 세션 ID를 넣지 않으면 고정된 이름의 충돌이 일어난다(네임드 파이프는 Mutex의 세션 네임스페이스 대상이 아니기 때문) |
| 사용자 단위(세션 횡단) | 같은 사용자가 콘솔과 RDP를 오가거나, RDP 접속을 반복하는 운영 | Global\ + 사용자 SID를 이름에 포함 + MutexSecurity로 ACL을 명시 |
CurrentUserOnly(사용자 SID로 판정하므로 세션을 넘어도 동작) |
실무에서 가장 필요한 사례. SID를 포함하지 않고 Global\만 붙이면 의도치 않게 머신 단위(다음 행)의 동작이 된다. SID는 사용자의 비밀 정보가 아니므로, 공유 PC/RDS 환경에서는 다른 사용자에 의한 이름 선점도 염두에 두고 ACL로 보호한다 |
| 머신 단위(모든 사용자 횡단) | 라이선스상 머신당 1인스턴스까지, 공유 리소스를 모든 사용자가 배타적으로 써야 하는 경우 | Global\(사용자 고유 정보는 포함하지 않음) + MutexSecurity로 ACL을 명시 |
CurrentUserOnly를 제거하고, PipeSecurity로 허용 사용자를 명시 |
여러 사용자가 동시에 로그온하는 원격 데스크톱 서비스 환경에서는 업무상 바람직하지 않은 동작이 되기 쉬우므로 요건과 맞는지 확인이 필요하다. 다른 사용자의 실행 인수를 그대로 첫 번째 사용자의 창에 전달하지 않을 것. 파일 경로 유출이나, 의도하지 않은 사용자의 세션에서 다른 사람의 문서가 열리는 사고로 이어지므로, 다른 사용자로부터의 요청은 거부하거나, UI가 없는 브로커를 거치는 설계로 바꿔야 한다 |
덧붙이면, 네임드 파이프는 Mutex처럼 Local\/Global\ 세션 네임스페이스의 대상이 아니고, 기본적으로 세션을 넘어 도달 가능합니다. 즉 CurrentUserOnly를 붙이지 않아도 파이프 이름만 일치하면 다른 세션의 기존 인스턴스로의 접속 자체는 도달합니다. CurrentUserOnly는 여기서는 도달성의 문제가 아니라 인가의 문제로, “누구의 접속을 허용할 것인가”를 현재 사용자(그리고 같은 상승 레벨)로 한정하는 접근 제어입니다. 붙이지 않으면 기본 보안 기술자에 의해 Everyone에게도 읽기 접근이 허용되므로, 의도하지 않은 다른 사용자로부터의 접속까지 도달해 버립니다. Global\ + SID의 Mutex로 “사용자 단위·세션 횡단”을 실현하는 설계에서는, 알림용 파이프에도 CurrentUserOnly를 붙여 Mutex와 같은 “대상 사용자만”으로 접속처를 한정해 두는 것이 타당합니다. 다행히 PipeOptions.CurrentUserOnly는 세션 ID가 아니라 사용자의 SID(및 상승 레벨)로 판정하므로, 위 표의 두 번째 행(사용자 단위·세션 횡단)이라면 그대로 조합해서 쓸 수 있습니다.
한 가지 더, 머신 단위(표의 세 번째 행)에서 고정된 이름의 Global\ Mutex를 쓰는 경우는 이름 선점(스쿼팅)에도 주의하시기 바랍니다. 네임드 Mutex를 이용해 앱을 단일 인스턴스로 제한하려는 경우, 악의적인 사용자가 미리 같은 이름의 Mutex를 생성해서 앱의 실행을 방해할 수 있습니다.8 MutexSecurity(MutexAcl.Create)로 생성 시에 ACL을 명시해 두면, 자신이 먼저 생성할 수 있었던 경우에 다른 사용자가 그 Mutex를 가로채거나 부당하게 계속 붙잡고 있는 것을 막을 수 있습니다. 다만 이것은 “나중에 방해받지 않기” 위한 대책이며, “먼저 선점당하는” 것 자체는 막을 수 없다는 점에 주의하시기 바랍니다. ACL은 자신이 Mutex를 새로 생성했을 때 비로소 적용되는 것이므로, 악의적인 사용자가 자신보다 먼저 같은 이름으로 Mutex를 생성해 두었다면, 이쪽의 호출은(ACL을 얼마나 준비해 두었든) 상대가 설정한 기존 객체를 열러 갈 뿐이고 상대의 ACL을 따를 수밖에 없습니다. 이름 자체가 추측하기 어렵다는 점 자체에는 가치가 있지만, 로컬 코드 실행 권한을 가진 악의적인 사용자를 완전히 차단하고 싶다면 Mutex의 이름 선점만에 의존하지 말고, 사용자별로 보호된 디렉터리 아래의 잠금 파일 같은 다른 배타 제어와 조합하는 설계를 검토하시기 바랍니다.9
한 가지 더, CurrentUserOnly에는 놓치기 쉬운 제약이 있습니다. Windows에서는 사용자 계정뿐만 아니라 상승 레벨(관리자로 실행하고 있는지 여부)까지 일치해야 접속을 허용합니다.10 예전에는 “Mutex의 이름은 사용자의 SID만으로 구성하므로, 탐지 자체는 상승 여부와 무관하게 동작한다”라고 생각하기 쉽지만, ACL을 명시한 Mutex를 사용하는 경우는 여기도 상승의 영향을 받습니다. Windows는 기본적으로 높은 무결성 수준(관리자로 실행)의 프로세스가 만든 객체에 높은 무결성 수준 라벨을 부여하고, 더 낮은 무결성 수준의 프로세스로부터의 쓰기 계열 접근을 거부합니다. .NET의 Mutex/MutexAcl.Create는 내부적으로 SYNCHRONIZE와 MUTEX_MODIFY_STATE에 더해 DELETE/READ_CONTROL/WRITE_DAC/WRITE_OWNER(STANDARD_RIGHTS_REQUIRED)까지 요구하므로, “관리자로 실행”으로 첫 번째를 실행하고, 일반 권한으로 두 번째를 실행한 경우, Mutex의 생성·열기 호출 자체가 UnauthorizedAccessException을 발생시킬 수 있습니다. 즉 7장에서 쓴 “알림 파이프만 ACL에 막히고, 중복 실행 방지 자체는 성공한다”라는 전제가 무너져, 탐지의 더 앞 단계에서 예외가 날아올 가능성이 있다는 것입니다. 이 경로도 “다른 인스턴스가 이미 동작 중이거나, 상승 레벨이 달라서 일반적인 수단으로는 판정할 수 없다”라는 것을 알려주는 신호로 취급하여, Mutex/MutexAcl.Create의 호출 자체를 try/catch (UnauthorizedAccessException)으로 감싸고, 예외 시에는 실행을 포기하고 조용히 종료하는(또는 7장의 “알림이 도착하지 않아도 된다”와 마찬가지로, 중복 실행 방지로서는 안전한 쪽으로 기울이는) 구현으로 해 두는 것이 타당합니다. 상승 레벨을 넘는 알림까지 필요한 경우에는 CurrentUserOnly를 쓰지 않고 PipeSecurity로 사용자 SID 기반의 ACL을 명시적으로 구성하는 설계로 전환하시기 바랍니다.
6. 기존 인스턴스를 앞으로 가져오기 ── SetForegroundWindow의 제한
Mutex로 “이미 실행 중이다”라는 것을 알게 된 후, 많은 앱은 기존 인스턴스의 창을 앞으로 가져오고 싶어할 것입니다. 여기서 단순히 기존 프로세스에 대해 SetForegroundWindow를 호출해도 대부분의 경우 실패합니다.
Windows는 어느 프로세스가 포그라운드 창을 설정할 수 있는지를 엄격하게 제한하고 있습니다. 공식 문서에 따르면, 호출한 프로세스가 다음 중 하나에 해당하지 않는 한, SetForegroundWindow는 창을 실제로 앞으로 가져오지 않고 작업 표시줄 버튼을 깜빡이게 하는 데 그칩니다.6
- 호출한 프로세스 자신이 현재 포그라운드 프로세스이다
- 호출한 프로세스가 포그라운드 프로세스에 의해 실행되었다
- 호출한 프로세스가 가장 최근의 입력 이벤트를 받았다
- 현재 포그라운드 창이 존재하지 않는다
- 포그라운드 프로세스 또는 호출한 프로세스가 디버깅 중이다
중복 실행 탐지 시나리오에서는 기존 인스턴스(백그라운드에서 동작 중인 첫 번째 프로세스)는 이 조건을 대부분 만족하지 못합니다. 반면 지금 사용자가 더블클릭해서 실행한 두 번째 프로세스는, 가장 최근의 입력 이벤트를 받은 지 얼마 되지 않은 상태인 경우가 많아, 포그라운드를 설정할 수 있는 권한을 가지고 있습니다. 이 비대칭성을 이용하는 것이 실무에서의 정석입니다.
AllowSetForegroundWindow는, 포그라운드를 설정할 수 있는 프로세스가 그 권한을 다른 프로세스에 양도하기 위한 API입니다.7 두 번째 프로세스가 자신이 가진 권한을 ASFW_ANY(모든 프로세스에 허용)로 넘겨준 다음 네임드 파이프로 기존 인스턴스에 활성화를 요청하면, 기존 인스턴스 쪽의 SetForegroundWindow 호출이 성공하게 됩니다.
[DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
private const int ASFW_ANY = -1;
// 두 번째 프로세스(나)가 포그라운드 설정 권한을 가지고 있다는 전제로,
// 그 권한을 무조건 넘겨준다. 기존 인스턴스 쪽의
// SetForegroundWindow 호출을 이것으로 성공시킨다
AllowSetForegroundWindow(ASFW_ANY);
이렇게 해도 구제되지 않는 경우(작업 스케줄러를 통한 실행 등, 두 번째 프로세스 자신에게도 포그라운드 권한이 없는 경우)도 남습니다. 그 경우는 작업 표시줄의 깜빡임으로 알리는 데 머물고, 무리하게 전면화하려 하지 않는 것이 타당한 설계입니다. 사용자에게 알리는 수단으로는 토스트 알림이나, 창의 WindowState를 Minimized에서 Normal로 되돌리는 것까지는 항상 수행하고, 최종적인 전면화는 “가능하면 한다” 정도의 위치로 두면 무너지지 않습니다.
7. 실행 인수를 기존 인스턴스에 전달하기 ── 네임드 파이프
중복 실행 탐지뿐만 아니라, “열어야 할 파일을 인수로 붙여 실행하면 기존 인스턴스가 그 파일을 여는” 요건도 흔히 있습니다. 이 용도로는 WM_COPYDATA(윈도우 메시지로 데이터를 보내는 고전적인 수단)도 선택지로 존재하지만, 창 핸들 취득과 메시지 마샬링의 번거로움에 비해 얻는 것이 적어, 새로운 설계에서는 네임드 파이프를 쓰는 것이 자연스럽습니다.
설계는 단순해서, Mutex로 “이미 실행 중이다”라고 알게 된 두 번째 프로세스가, 기존 인스턴스가 대기하고 있는 네임드 파이프에 접속해서 명령줄 인수를 JSON 등으로 직렬화해 보내기만 하면 됩니다. 파이프 이름 선점(스쿼팅) 대책이나 CurrentUserOnly에 의한 접근 제어 등, 네임드 파이프 자체의 구현상 주의점은 「Windows의 프로세스 간 통신을 어떻게 선택할까」의 네임드 파이프 절에 정리되어 있으므로 그쪽을 따르시기 바랍니다. 중복 실행 탐지라는 맥락에 특유한 주의점은 다음 1가지뿐입니다.
- 알림 전송에 실패해도, 중복 실행 방지로서는 성공으로 취급해도 됩니다. 기존 인스턴스가 종료 처리 중이라 파이프 서버를 닫아 두었던 경우 등, 타이밍 문제로 알림이 도착하지 않는 경우가 있습니다. 이 경우에도 “두 번째 프로세스를 실행시키지 않는다”라는 주된 목적은 달성되었으므로, 알림의 실패를 이유로 오류 대화상자를 낼 필요는 없습니다.
8. 콘솔 앱·서비스와의 차이
이 글의 설계는 창을 가진 데스크톱 앱을 전제로 합니다. 콘솔 앱이나 배치 도구에서는 “중복 실행을 막는” 것보다 “중복으로 실행되어도 안전하게 공존할 수 있게 하는” 설계가 더 현실적인 경우가 많고, 이는 실질적으로 파일 연동의 배타 제어 문제로 귀결됩니다.
Windows 서비스는 사정이 더 다릅니다. 서비스 제어 관리자(SCM)는 같은 서비스 이름의 서비스를 애초에 동시에 두 개 실행하지 않으므로, 이 글의 Mutex에 의한 탐지는 기본적으로 불필요합니다. “UI 앱 + 상주 서비스” 같은 구성에서는 UI 쪽에만 이 글의 설계를 쓰고, 서비스 쪽의 중복 실행·중복 처리에 관한 사고방식은 또 다른 이야기가 됩니다. 서비스를 만드는 방법이나 설계의 요령은 다른 글에서 다루겠습니다.
9. 구현 예시 ── Mutex에 의한 탐지와 활성화 요청
2~7장의 내용을 정리한 실무적인 최소 구성입니다. WPF를 가정했지만, WinForms에서도 Application.Current.Dispatcher를 Control.Invoke로 바꾸기만 하면 거의 그대로 쓸 수 있습니다.
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
public static class Program
{
// 제품 고유의 GUID를 넣어 다른 앱과 이름이 충돌하지 않도록 한다.
// "사용자 단위・세션 횡단"으로 1인스턴스로 제한하고 싶으므로, Global\에 더해
// 사용자의 SID를 이름에 넣는다. SID를 포함하지 않고 Global\만 붙이면
// 모든 사용자가 같은 Mutex를 공유하게 되어 "머신 전체에서 1인스턴스"로
// 바뀐다(3장)
private static readonly string MutexName =
$@"Global\KomuraSoft.MyApp.SingleInstance.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";
// 알림용 파이프도 Mutex와 같은 스코프(사용자 단위)로 맞춘다. SID를 포함하지 않고
// 고정된 이름으로 두면, 다른 사용자가 같은 이름으로 서버를 세웠을 때
// 충돌·혼선이 일어날 수 있다(5장. CurrentUserOnly는 ACL만 좁힐 뿐 이름은 나누지 않는다)
private static readonly string PipeName =
$"KomuraSoft.MyApp.Activate.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";
[STAThread]
private static void Main(string[] args)
{
// 현재 사용자에게만 전체 제어를 허용하는 ACL을 명시해 두면,
// 자신이 먼저 생성할 수 있었던 경우에 다른 사용자로부터의
// 가로채기・방해를 막을 수 있다
// (NuGet 패키지 System.Threading.AccessControl 필요).
// 다만 이것이 "먼저 선점당하는" 것 자체에 대한 대책은 되지 않는다(5장)
var mutexSecurity = new MutexSecurity();
mutexSecurity.AddAccessRule(new MutexAccessRule(
WindowsIdentity.GetCurrent().User!, MutexRights.FullControl, AccessControlType.Allow));
bool createdNew;
Mutex mutex;
try
{
// createdNew가 true일 때만 이 호출로 Mutex를 획득할 수 있다
mutex = MutexAcl.Create(
initiallyOwned: true, name: MutexName, createdNew: out createdNew, mutexSecurity: mutexSecurity);
}
catch (UnauthorizedAccessException)
{
// 같은 사용자여도 상승 레벨이 다르면, 기존 Mutex를 여는 것 자체가
// 거부될 수 있다(5장). "상승 레벨이 다른 별개의 인스턴스가
// 이미 있다"라고 간주하고, 알림은 포기하고 안전한 쪽(실행하지 않음)으로 기울인다
return;
}
using var _ = mutex;
if (!createdNew)
{
// 이미 실행 중이다. 기존 인스턴스에 알리고 자신은 종료한다
NotifyRunningInstanceAsync(args).GetAwaiter().GetResult();
return;
}
try
{
// App.xaml의 StartupUri는 제거해 두어야 한다. 남겨 두면
// 여기서 수동으로 생성한 MainWindow에 더해 StartupUri 쪽의
// 창도 자동으로 생성・표시되어 버려(app.MainWindow도 그쪽으로 덮어써진다),
// 창이 2개 열린 끝에 활성화 요청이 도착하지 않는 쪽을 향하는 사고가 된다
var app = new App();
app.InitializeComponent();
var mainWindow = new MainWindow();
app.MainWindow = mainWindow;
mainWindow.Show();
// MainWindow의 생성・표시가 끝난 뒤에 파이프 서버를 시작한다.
// 순서가 반대이면, 창이 아직 없는 상태에서 활성화 요청이
// 도착해 ActivateMainWindow 호출이 실패할 수 있다(예외는 아래의
// catch-all에서 삼켜져, 알림이 그냥 사라지는 결과가 된다). 이 사이에
// 도착한 요청은 "서버 미기동 → 접속 실패"로서
// NotifyRunningInstanceAsync 쪽의 최선형 설계(7장)에 그대로 실린다
StartActivationServer();
app.Run();
}
finally
{
// 소유 스레드(이 스레드)에서 명시적으로 해제한 뒤 종료한다.
// 해제하지 않고 종료하면 다음 실행 시
// AbandonedMutexException의 원인이 된다(4장)
mutex.ReleaseMutex();
}
}
private const int ASFW_ANY = -1;
[DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
private static async Task NotifyRunningInstanceAsync(string[] args)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
using var pipe = new NamedPipeClientStream(
".", PipeName, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await pipe.ConnectAsync(cts.Token);
// 자신(지금 실행된 두 번째 프로세스)은 가장 최근의 입력 이벤트를 받고
// 실행된 지 얼마 안 된 상태로, 포그라운드 설정 권한을 가지고 있는 경우가 많다.
// 그 권한을 무조건 넘겨주어 기존 인스턴스 쪽의 SetForegroundWindow를
// 성공시킨다(6장)
AllowSetForegroundWindow(ASFW_ANY);
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new ActivateRequest(1, args));
await pipe.WriteAsync(payload, cts.Token);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or OperationCanceledException)
{
// 기존 인스턴스가 종료 처리 중이라 응답하지 않았다(IOException),
// 상승 레벨이 달라 CurrentUserOnly의 인가에 실패했다(UnauthorizedAccessException.
// 5장 참고) 등, 알림이 도착하지 않은 이유를 구분하지 않고,
// 중복 실행 방지로서의 주된 목적(두 번째를 실행시키지 않음)은 달성되었다(7장)
}
}
private static void StartActivationServer()
{
// 메시지 1개당 상한. 같은 사용자의 오래된 헬퍼나 고장난 클라이언트가
// 끝없이 계속 보내더라도, 서버 쪽의 메모리를 지킨다
const int MaxPayloadBytes = 64 * 1024;
_ = Task.Run(async () =>
{
while (true)
{
try
{
// 생성자 자체도 try 안에 넣는다. maxNumberOfServerInstances가
// 1이기 때문에, 직전 접속의 마무리가 끝나지 않은 타이밍 등에서
// 여기가 IOException을 발생시킬 수 있으며, try 밖에 두면 이
// 1회의 실패로 백그라운드 태스크 자체가 멈춰 버린다
using var pipe = new NamedPipeServerStream(
PipeName, PipeDirection.In, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await pipe.WaitForConnectionAsync();
// maxNumberOfServerInstances가 1이므로, 이 접속이 응답하지 않는
// 클라이언트에 고정되면 이후의 정상적인 실행 요청을 전혀
// 받을 수 없게 된다. 접속 1회분에 상한 시간을 둔다
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var ms = new MemoryStream();
var buffer = new byte[4096];
int n;
while ((n = await pipe.ReadAsync(buffer, cts.Token)) > 0)
{
ms.Write(buffer, 0, n);
if (ms.Length > MaxPayloadBytes)
throw new IOException("페이로드가 상한 크기를 넘었습니다.");
}
var req = JsonSerializer.Deserialize<ActivateRequest>(ms.ToArray());
// Version뿐만 아니라 Args도 검사한다. 이전 버전의 발신자나
// 손으로 만든 잘못된 페이로드가 {"Version":1}처럼 Args가
// 빠진 JSON을 보내오면, Args는 null인 채로 역직렬화된다
if (req is { Version: 1, Args: not null })
{
// UI 조작은 UI 스레드로 되돌려서 수행한다
Application.Current.Dispatcher.Invoke(() => ActivateMainWindow(req.Args));
}
}
catch (Exception)
{
// 접속이 끊겼다, 상한 시간・상한 크기를 넘었다, 페이로드가 깨져 있다,
// 디스패치 중에 예상치 못한 예외가 일어났다 등 이유를 불문하고,
// 이 접속 1회분의 이상으로 삼킨다. 이 태스크 자체를 죽게 만들면
// 이후 모든 알림을 받을 수 없게 되므로, 루프는 반드시 계속된다.
// 다만 파이프 구성 자체가 await 전에 즉시 계속 실패하는 경우
// (단일 인스턴스 슬롯을 다른 프로세스가 붙잡고 있는 등) 핫스핀하지
// 않도록, 다음 루프 전에 반드시 한 번 숨을 돌린다
await Task.Delay(TimeSpan.FromSeconds(1));
}
}
});
}
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
private static void ActivateMainWindow(string[] args)
{
var window = Application.Current.MainWindow;
if (window is null) return;
if (window.WindowState == System.Windows.WindowState.Minimized)
window.WindowState = System.Windows.WindowState.Normal;
window.Show();
window.Activate();
// WPF의 Activate()는 내부에서 SetForegroundWindow를 호출하지만, 제한(6장)으로
// 실패할 수 있으므로 AllowSetForegroundWindow가 끝난 상태에서 명시적으로도 호출한다
var hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle;
SetForegroundWindow(hwnd);
if (args.Length > 0)
{
// args[0]을 열어야 할 파일 경로로 취급하는 등 앱 고유의 처리
}
}
private sealed record ActivateRequest(int Version, string[] Args);
}
설계 판단을 3가지 덧붙입니다.
Global\에 추가하는 사용자 식별자는 이름이 바뀌지 않는 것을 고를 것.WindowsIdentity.GetCurrent().User가 반환하는SecurityIdentifier는 사용자 이름과 달리 개명의 영향을 받지 않으며,ToString()으로S-1-5-21-...형식의 문자열이 됩니다.11 사용자 이름을 직접 넣으면, 계정 이름 변경이나 도메인 이전으로 중복 실행 방지가 동작하지 않게 되는 사고로 이어집니다.- Mutex의 해제와 파이프 서버의 정지는 앱의 종료 처리의 일부로 명시적으로 수행할 것. 위 예시에서는
finally에서ReleaseMutex를 호출하고 있지만, 실제 앱에서는 창을 닫는 처리 안에서 취소 토큰을 사용해 파이프 서버의 루프도 함께 멈추도록 하시기 바랍니다. version필드는 처음부터 넣을 것. 실행 인수의 형식을 나중에 바꿀 가능성은 충분히 있습니다. “모르는 버전은 무시한다”라는 판정을 처음부터 넣어 두면, 이전 버전의 실행 파일이 남아 있는 단말에서도 안전하게 동작할 수 있습니다.
10. 정리
Windows 앱의 중복 실행 방지는 new Mutex(true, name, out createdNew)라는 몇 줄만 보면 단순합니다. 하지만 실무에서 사고 없이 동작시키려면, Global\/Local\에 의한 세션 가시성의 차이, Mutex 특유의 스레드 소유권 제약과 AbandonedMutexException의 처리, 그리고 SetForegroundWindow의 제한을 고려한 활성화 설계까지, 주변 지식을 두루 갖추어 둘 필요가 있습니다.
구현 순서로는 먼저 “어느 단위(세션・사용자・머신)로 중복 실행을 막고 싶은가”를 정하고(5장), 그에 맞춰 Mutex의 네임스페이스와 알림용 파이프의 스코프를 맞춥니다. 그런 다음, 기존 인스턴스의 전면화는 AllowSetForegroundWindow로 권한을 위임하는 정석을 쓰고, 그래도 실패하는 경우는 무리하게 전면화하지 않고 알림에 머무른다는 원칙을 가지고 있으면 구현이 무너지지 않습니다. 요건이 “사용자 1명당 1인스턴스인가, 머신 전체에서 1인스턴스인가”로 고민된다면, 운영 환경(RDP 병용 여부, 여러 사용자의 동시 로그온 여부)을 먼저 확인하시기를 권장합니다.
관련 기사
- Windows의 프로세스 간 통신을 어떻게 선택할까 ── 네임드 파이프 / TCP / gRPC / 공유 메모리 / COM 판단표
- Windows 사용자 프로필 입문 - AppData와 NTUSER.DAT
- 예상치 못한 예외에서 종료해야 할지 계속해야 할지의 판단표
관련 상담 분야
합동회사 코무라소프트는 중복 실행 방지나 창 제어를 포함한 Windows 데스크톱 앱의 설계・구현, 원격 데스크톱 환경 특유의 버그 원인 조사, 기존 앱의 설계 리뷰를 다루고 있습니다.
참고 링크
-
Microsoft Learn, Mutex Constructor. 네임드 Mutex가 이미 존재하는 경우 createdNew가 false가 되고 예외는 발생하지 않는다는 점, initiallyOwned에 의한 초기 소유권은 createdNew가 true일 때만 유효하다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Mutex Class. 네임드 Mutex에 접두사를 지정하지 않으면 기본적으로 Local\이 된다는 점, Global\/Local\에 의한 터미널 서비스 세션 간 가시성의 차이, Mutex가 스레드 단위로 소유권을 강제한다는 점(다른 동기화 객체와 다름)에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Kernel Object Namespaces. 세션마다 독립된 네임스페이스와 글로벌 네임스페이스의 구조, Global\/Local\ 접두사에 의한 네임스페이스 지정 방법에 대해. ↩ ↩2
-
Microsoft Learn, Mutex.ReleaseMutex Method. 소유하지 않은 스레드가 ReleaseMutex를 호출하면 ApplicationException이 발생한다는 점, 스레드가 Mutex를 해제하지 않고 종료된 경우 Mutex가 포기된 상태가 된다는 점에 대해. ↩ ↩2
-
Microsoft Learn, AbandonedMutexException Class. 포기된 Mutex를 다음에 획득한 스레드에 AbandonedMutexException이 발생한다는 점, 대기 자체는 성공했고 호출자가 Mutex의 소유권을 얻었다는 점에 대해. ↩ ↩2
-
Microsoft Learn, SetForegroundWindow function. 포그라운드 창을 설정할 수 있는 프로세스의 조건과, 조건을 만족하지 못하는 경우 작업 표시줄 버튼이 깜빡이는 데 그친다는 점에 대해. ↩ ↩2
-
Microsoft Learn, AllowSetForegroundWindow function. 포그라운드 창을 설정할 수 있는 프로세스가 그 권한을 다른 프로세스에 양도할 수 있다는 점, ASFW_ANY를 지정하면 임의의 프로세스에 허용할 수 있다는 점에 대해. ↩ ↩2
-
Microsoft Learn, CreateMutexW function (synchapi.h). 네임드 Mutex로 단일 인스턴스로 제한하는 경우, 악의적인 사용자가 먼저 같은 이름의 Mutex를 생성해서 앱의 실행을 방해할 수 있다는 점, 대책으로 무작위 이름이나 사용자 1명당 1인스턴스라면 사용자 프로필 아래의 잠금 파일을 쓰는 대안에 대해. ↩
-
Microsoft Learn, Mutexes. 명명된 시스템 Mutex는 OS 전체에서 보이는 전역적인 것이므로, 생성 시점부터 접근 제어 보안으로 보호하는 것이 권장된다는 점,
MutexSecurity에 의한 접근 제어에 대해. ↩ -
Microsoft Learn, PipeOptions Enum. CurrentUserOnly가 Windows에서는 사용자 계정에 더해 상승 레벨까지 검증한다는 점에 대해. ↩
-
Microsoft Learn, WindowsIdentity.User Property. 사용자의 보안 식별자(SID)를 반환하는 속성이며, SID가 모든 Windows NT 구현에서 사용자 또는 그룹을 고유하게 식별한다는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows의 세션 분리를 어떻게 이해할까 ── Session 0・RDP・다중 사용자 동시 실행
Windows 앱 개발자가 혼란스러워하기 쉬운 '세션' 개념을 정리합니다. 서비스가 UI를 표시할 수 없는 Session 0 분리의 이유, RDP 접속 시 세션의 동작, 명명된 개체의 세션 분리, 공유 PC・RDS 환경에서 흔히 발생하는 설계 실...
Windows의 프로세스 간 통신을 어떻게 선택할까 ── 네임드 파이프 / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱 사이의 연동 수단을 어떻게 선택할지 정리합니다. 네임드 파이프, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM 각각의 강점과 함정을 판단표로 정리하고, UI+서비스 분리・32bit/64bit 브리지・권한 경계 같은 ...
C#에서 Win32 API를 안전하게 호출하기 — P/Invoke 실무 가이드(DllImport / LibraryImport / CsWin32)
C#에서 P/Invoke로 Win32 API나 네이티브 DLL을 호출할 때의 실무 포인트를 정리합니다. DllImport와 LibraryImport의 차이, CsWin32를 이용한 시그니처 자동 생성, 문자열 마샬링의 함정, SafeHandle을...
WinForms/WPF 앱에 Entra ID 인증 넣기 ── MSAL.NET과 WAM 브로커 실무 구성
WinForms/WPF 데스크톱 앱에 Entra ID(구 Azure AD) 인증을 도입하는 절차를 실무 관점에서 정리합니다. 퍼블릭 클라이언트의 개념, ROPC 폐지 현황, 앱 등록, MSAL.NET의 AcquireTokenSilent 패턴, W...
업무 앱의 날짜/시간과 시간대 ── DateTime의 함정부터 UTC 저장 원칙, 테스트 설계까지
서버 이전 시 시간이 9시간 어긋나거나, 해외 지사에서만 날짜가 하루 전날이 되는 문제──날짜/시간 사고의 원인을 DateTime의 Kind와 암묵적 변환부터 정리합니다. DateTimeOffset과의 구분 사용, UTC 저장과 ISO 8601 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- C#에서 앱의 중복 실행을 방지하려면 어떻게 해야 하나요?
- 기본형은 new Mutex(true, name, out bool createdNew)입니다. 같은 이름의 Mutex가 이미 존재해도 예외는 발생하지 않고 createdNew가 false가 될 뿐이므로, 이 값으로 분기해서 두 번째 프로세스를 종료시킵니다. 이름은 다른 회사 앱과 충돌하지 않도록 제품 고유의 GUID를 넣은 문자열로 만드는 것이 정석입니다. createdNew가 false인 분기에서는 Mutex를 전혀 건드리지 않고 즉시 처리를 마치는 것이 안전합니다.
- 원격 데스크톱 환경에서 중복 실행 방지가 동작하지 않는 이유는 무엇인가요?
- Mutex 이름에 접두사를 붙이지 않으면 기본적으로 Local\(세션 한정) 네임스페이스에 생성되기 때문입니다. 같은 사용자가 콘솔과 RDP로 여러 세션을 가진 환경에서는 세션마다 별개의 Mutex로 취급되어, 두 번째 세션에서는 그냥 실행이 되어 버립니다. 세션을 넘어 하나로 묶으려면 Global\ 접두사가 필수입니다. 다만 Global\만 붙이면 모든 사용자가 공유하는 머신 단위가 되므로, 사용자 1명당 1인스턴스로 하고 싶다면 이름에 사용자의 SID도 함께 넣어야 합니다.
- 기존 인스턴스의 창을 앞으로 가져오려면 어떻게 해야 하나요?
- 단순히 SetForegroundWindow를 호출하는 것만으로는 OS의 제한 때문에 대부분 실패하고, 작업 표시줄 버튼이 깜빡이는 데 그칩니다. 정석은 지금 사용자가 더블클릭해서 실행한 두 번째 프로세스가 가진 포그라운드 설정 권한을, AllowSetForegroundWindow(ASFW_ANY)로 기존 인스턴스에 넘겨준 다음, 네임드 파이프로 활성화를 요청하는 설계입니다. 그래도 실패하는 경우(작업 스케줄러를 통한 실행 등)는 무리하게 전면화하려 하지 말고 알림에 머무는 것이 타당합니다.
- AbandonedMutexException은 어떻게 처리해야 하나요?
- Mutex를 소유하던 스레드가 ReleaseMutex를 호출하지 않고 종료(프로세스 크래시 등)하면, 다음에 그 Mutex를 획득한 스레드에 AbandonedMutexException이 발생합니다. 이는 대기 자체는 성공했고 호출자가 이미 소유권을 얻었다는 것을 나타내는 예외입니다. 무시하지 말고 보호 대상 상태의 일관성을 확인한 뒤 사용하는 것이 올바른 처리입니다. 정상 종료 경로에서는 finally에서 명시적으로 ReleaseMutex를 호출해 두면, 다음 실행 시 불필요하게 발생하는 것을 막을 수 있습니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크