Windows 앱의 다중 실행 방지 ── 이름 있는 Mutex와 이중 실행 시 활성화

· 업데이트: · · 다중 실행 방지, Mutex, Windows, .NET, C#, SetForegroundWindow, Remote Desktop, 이름 있는 파이프, Windows 개발, 기술 상담

수정 이력(7건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
관련 기사 링크를 한국어 permalink에 맞추는 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
기사 서두에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대한 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
기존 인스턴스를 앞으로 가져올 때, 포그라운드 설정 권한을 `ASFW_ANY`로 넘기던 부분을 고쳤습니다. 레퍼런스는 「이 매개변수가 `ASFW_ANY`인 경우, 모든 프로세스가 포그라운드 창을 설정할 수 있게 된다」고 정의합니다. 넘길 상대는 하나로 정해져 있는데 전 프로세스에 나눠 주게 되고, 게다가 이 허가는 다음 입력 또는 다음 `AllowSetForegroundWindow` 호출까지 유효하므로, 사용자가 앱을 시작한 바로 그 순간에 무관한 상주 프로세스가 포커스를 빼앗을 수 있는 틈이 생깁니다. 연결된 파이프에서 `GetNamedPipeServerProcessId`로 상대 프로세스 ID를 가져와, 그 하나에만 넘기는 형태로 바꿨습니다.
다중 실행 방지 장을 범위·이름 선점·권한 상승 수준의 함정으로 나눠 정리했습니다. 함께 세 가지 범위에서 Mutex가 몇 개 생기는지 그림, 긴 코드 예시를 골격과 완전판으로 나누는 구성, 동작 확인 절차를 추가했습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635370)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「Windows 앱의 다중 실행 방지 ── 이름 있는 Mutex와 이중 실행 시 활성화」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/single-instance-mutex-guide/

DOI(등록된 아카이브)
10.5281/zenodo.21635370
DOI(마지막 등록 버전)
10.5281/zenodo.21635371

「업무 앱을 실수로 한 번 더 실행해, 같은 파일을 두 창에서 편집하다가 한쪽 변경이 사라졌다」「상주 도구가 이중으로 떠서 같은 이벤트를 두 번 처리했다」. 데스크톱 앱의 다중 실행은 눈에 띄지 않는 주제이지만, 실무에서는 의외로 사고의 원인이 됩니다. 대책의 골격 자체는 단순합니다. 이름 있는 Mutex(mutual exclusion = 상호 배제의 약자로, 동시에 하나의 스레드만 가질 수 있는 「열쇠」에 해당하는 Windows 커널 객체입니다)로 「자신이 첫 인스턴스인지」를 판정하면 됩니다.

다만 이 단순한 구현에는 주변 함정이 따라붙습니다. 원격 데스크톱 환경에서 방지가 먹히지 않게 되는 네임스페이스 함정, 다른 동기화 객체와는 다른 Mutex 고유의 해제 규칙, 그리고 「찾은 기존 인스턴스를 어떻게 앞으로 가져올지」라는 Win32 포그라운드 창 제한이 얽히는 설계 과제입니다. 이 기사에서는 이름 있는 Mutex로 다중 실행을 검출하는 기본부터, 실무에서 필요해지는 주변 설계 판단까지 한 바퀴 정리합니다.

1. 먼저 결론

  • 다중 실행 검출의 기본형은 new Mutex(true, name, out bool createdNew)입니다. 같은 이름의 Mutex가 이미 있어도 예외는 나지 않고, createdNew가 false가 될 뿐이므로 이 값으로 분기합니다.1
  • 이름에 prefix를 붙이지 않으면, 기본값으로 Local\(세션 한정) 네임스페이스에 만들어집니다. 원격 데스크톱에서 같은 사용자가 여러 세션을 갖는 환경에서는 세션마다 다른 Mutex로 취급되어, 다중 실행 방지가 동작하지 않습니다. 세션을 가로질러 하나로 묶으려면 Global\ prefix가 필수입니다.23
  • Mutex는 획득한 스레드와 같은 스레드에서만 해제할 수 있습니다. 다른 스레드에서 ReleaseMutex를 호출하면 ApplicationException이 됩니다. 소유 프로세스가 해제하지 않고 종료하면 다음에 획득한 스레드에 AbandonedMutexException이 발생하지만, 이는 대기 자체는 성공했다는 신호이므로 삼키지 말고 상태 정합성을 확인한 뒤에 쓰는 것이 올바른 처리입니다.45
  • 「이미 실행 중」임을 안 뒤의 본론은 기존 인스턴스의 창을 앞으로 가져오는 것입니다. SetForegroundWindow는 포그라운드 프로세스가 아닌 곳에서의 호출을 OS가 제한하므로, 단순히 호출하면 실패하고 작업 표시줄 버튼이 깜빡이는 데 그칩니다.6 지금 시작한 두 번째 프로세스가 가진 「포그라운드를 설정할 수 있는 권한」을 AllowSetForegroundWindow로 기존 인스턴스에 넘기는 설계가 정석입니다.7
  • 시작 인자(열어야 할 파일 경로 등)를 기존 인스턴스에 넘기려면 이름 있는 파이프로 전송하는 것이 정석입니다. 수단 비교는 「Windows의 프로세스 간 통신을 어떻게 고를까」에 맡기고, 이 기사에서는 「검출 → 알림 → 활성화」 설계에 집중합니다.
  • 콘솔 앱이나 서비스는 이 기사의 설계를 그대로 가져올 대상이 아닙니다. 서비스는 SCM이 애초에 같은 이름 서비스를 하나만 시작하므로 사고방식이 다릅니다(제8장).

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 19건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 이름 있는 Mutex로 검출하는 기본

Mutex는 원래 여러 스레드·프로세스가 같은 자원을 동시에 쓰지 않도록 하는 배타 제어용 커널 객체입니다. 이름을 붙여 만들면, 그 이름을 아는 다른 프로세스에서 같은 객체를 참조할 수 있습니다. 다중 실행 검출에서는 배타 제어 자체가 아니라, 이 「이름 공유」만 이용합니다.

using var mutex = new Mutex(initiallyOwned: true, name: MutexName, out bool createdNew);
if (!createdNew)
{
    // 이미 실행 중
    return;
}
// createdNew 가 true 일 때만, 이 스레드가 Mutex 를 소유한다

여기서 잡아 둘 점은 다음 두 가지입니다.

  • 같은 이름의 Mutex가 이미 있어도 예외는 나지 않습니다. createdNew에 false가 들어가고, 기존 객체에 대한 참조가 반환될 뿐입니다. 분기는 반드시 createdNew로 합니다.1
  • initiallyOwned: true는 「새로 만들 수 있었을 때」만 유효합니다. 이미 있었던 경우(createdNew == false) 이 스레드는 자동으로 소유자가 되지 않습니다. 다중 실행 검출에서는 이 부작용이 문제가 되지 않지만, 「두 번째 프로세스가 잘못 ReleaseMutex를 호출하는」 사고를 막기 위해서라도 createdNew가 false인 분기에서는 Mutex에 전혀 손대지 않고 즉시 처리를 끝내는 편이 안전합니다.1

이름은 다른 회사 앱과 충돌하지 않도록, 제품 고유 GUID를 넣은 문자열로 만드는 것이 정석입니다("KomuraSoft.MyApp.SingleInstance.{3F1E2B10-...}"처럼). 파이프 이름 선점(스쿼팅)과 마찬가지로, 이름 있는 커널 객체 네임스페이스는 머신 위 다른 프로세스에서도 보이므로, 추측하기 어려운 이름으로 둘 의미가 있습니다.

3. Global\ 와 Local\ ── 세션을 가로지르면 무슨 일이 일어나는가

Windows 커널 객체 네임스페이스는 세션마다 독립된 네임스페이스와, 시스템 전체가 공유하는 글로벌 네임스페이스로 나뉩니다. 이름 있는 Mutex를 만들 때 prefix를 지정하지 않으면 기본값으로 호출 측 세션 네임스페이스(Local\)에 만들어지고, Global\를 붙였을 때만 모든 세션 공통 네임스페이스에 만들어집니다.3 .NET의 Mutex 클래스도 이 동작을 그대로 따르며, 문서에는 「prefix를 지정하지 않은 이름 있는 Mutex는 기본값으로 Local\를 갖는다」고 명시되어 있습니다.2

이것이 문제가 되는 것은 다음과 같은 장면입니다.

  • 업무용 관리 단말에 콘솔에서 직접 로그온한 사용자가, 원격 데스크톱으로도 자신에게 세션을 하나 더 연결하고 있다(운용에서는 흔합니다).
  • 같은 사용자가 RDP 연결을 끊었다가 다시 연결하기를 반복하고, 그때마다 새 세션 ID가 할당된다.

Local\ 그대로(prefix 없음) Mutex를 만들면, 이들은 다른 세션으로 취급되어 각 세션에서 다중 실행 방지가 독립적으로 동작합니다. 즉 「같은 사용자인데 두 번째 세션에서는 그대로 실행된다」는, 다중 실행 방지가 의도대로 동작하지 않는 장애가 됩니다. 반대로 말하면, 단일 세션만 가정한 일반적인 데스크톱 이용에서는 Local\(기본값) 그대로여도 실제 피해는 없습니다.

여러 사용자가 함께 쓰는 단말의 설계 일반은 「Windows 사용자 프로필 입문」에서도 다루지만, 이 기사의 주제로 좁혀 말하면 주의가 필요합니다. Global\는 머신 위 모든 사용자·모든 세션이 공유하는 단일 네임스페이스이며, 「사용자당 인스턴스 하나」를 자동으로 뜻하지 않습니다. Global\KomuraSoft.MyApp.SingleInstance처럼 이름을 고정한 채 Global\만 붙이면, 사용자 A가 실행 중일 때 사용자 B의 실행도 같은 Mutex에 막혀, 실질적으로 「머신 전체에서 인스턴스 하나」(제5장 표의 3행)가 됩니다. 「사용자당 인스턴스 하나, 다만 그 사용자의 여러 세션은 묶는다」를 구현하려면 Global\에 더해 이름에 사용자 고유 식별자(SID 등)를 넣어야 합니다. 반대로 머신 전체에서 인스턴스 하나로 묶고 싶은 경우(전 사용자 공유가 의도대로인 경우)는 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를 쓸 것인가

다중 실행을 「어느 단위」로 막을지에 따라 네임스페이스와 파이프 측 설계가 달라집니다.

5.1 세 가지 범위의 대응 관계

이름 붙이는 방식에 따라 「몇 개의 Mutex가 생기는지」가 바뀌고, 그 개수가 곧 「동시에 실행할 수 있는 인스턴스 상한」이 됩니다. 같은 단말에 사용자 A가 세션 2개(콘솔과 RDP), 사용자 B가 세션 1개로 로그온한 상황을 예로 들면 다음과 같습니다.

머신 단위 ── Global prefix만Mutex 단말에 1개사용자 A 세션 1사용자 A 세션 2사용자 B 세션 3사용자 단위 ── Global prefix와 사용자 SIDMutex 사용자 A용사용자 A 세션 1사용자 A 세션 2사용자 B 세션 3Mutex 사용자 B용세션 단위 ── prefix 없음Mutex 그 1사용자 A 세션 1사용자 A 세션 2Mutex 그 2사용자 B 세션 3Mutex 그 3

화살표가 같은 상자로 모이는 곳이, 인스턴스 하나로 묶이는 범위입니다. 이 예에서는 세션 단위면 합계 3인스턴스, 사용자 단위면 합계 2인스턴스, 머신 단위면 합계 1인스턴스까지 실행할 수 있게 됩니다. 요건이 「같은 사람이 두 번 열지 못하게 하고 싶다」인지 「단말에서 하나만 돌리고 싶다」인지에 따라 고르는 행이 달라집니다.

5.2 판단표

단위 가정 시나리오 Mutex 네임스페이스 시작 인자 전달 주의점
세션 단위(기본값) RDP를 쓰지 않거나, 「세션마다 하나」면 되는 일반적인 데스크톱 이용 prefix 없음(=Local\) CurrentUserOnly에 더해, 파이프 이름에 세션 ID를 넣는다 RDP를 함께 쓰는 환경에서는 다른 세션에서의 시작을 막지 못한다. 같은 사용자가 여러 세션을 갖는 경우, 파이프 이름에 세션 ID를 넣지 않으면 고정 이름 충돌이 난다(이름 있는 파이프는 Mutex의 세션 네임스페이스 대상이 아니기 때문)
사용자 단위(세션 횡단) 같은 사용자가 콘솔과 RDP를 오가거나, RDP 연결을 반복하는 운용 Global\ + 사용자 SID를 이름에 넣는다 + MutexSecurity로 ACL을 명시 CurrentUserOnly(사용자 SID로 판정하므로 세션을 가로질러도 동작한다) 실무에서 가장 필요한 케이스. SID를 넣지 않고 Global\만 쓰면 의도치 않게 머신 단위(다음 행) 동작이 된다. SID는 사용자의 비밀 정보가 아니므로, 공유 PC/RDS 환경에서는 다른 사용자에 의한 이름 스쿼팅도 가정하고 ACL로 보호한다
머신 단위(전 사용자 횡단) 라이선스상 머신당 인스턴스 하나까지, 공유 자원을 전 사용자로 배타하고 싶다 Global\(사용자 고유 정보는 넣지 않는다) + MutexSecurity로 ACL을 명시 CurrentUserOnly를 빼고, PipeSecurity로 허용 사용자를 명시 여러 사용자가 동시에 로그온하는 원격 데스크톱 서비스 환경에서는 업무상 바람직하지 않은 동작이 되기 쉬우므로 요건과 맞는지 확인이 필요하다. 다른 사용자의 시작 인자를 그대로 첫 사용자의 창으로 전달하지 말 것. 파일 경로 유출이나, 의도하지 않은 사용자 세션에서 남의 문서가 열리는 사고로 이어지므로, 다른 사용자로부터의 요청은 거부하거나 UI가 없는 broker를 경유하도록 설계를 바꿔야 한다

5.3 알림용 파이프의 범위를 Mutex에 맞춘다

이름 있는 파이프는 Mutex와 같은 Local\/Global\ 세션 네임스페이스의 대상이 아니며, 기본값으로 세션을 가로질러 도달할 수 있습니다. 즉 CurrentUserOnly를 붙이지 않아도, 파이프 이름만 맞으면 다른 세션의 기존 인스턴스로 연결 자체는 닿습니다. CurrentUserOnly는 여기에서는 도달성의 이야기가 아니라 인가의 이야기이며, 「누구의 연결을 허용할지」를 현재 사용자(그리고 같은 권한 상승 수준)로 좁히는 액세스 제어입니다. 붙이지 않으면 기본 보안 기술자에 따라 Everyone에도 읽기 액세스가 허용되므로, 의도하지 않은 다른 사용자로부터의 연결까지 닿게 됩니다. Global\ + SID의 Mutex로 「사용자 단위·세션 횡단」을 구현하는 설계에서는, 알림용 파이프에도 CurrentUserOnly를 붙여 Mutex와 같은 「대상 사용자만」으로 연결 측을 좁혀 두는 것이 타당합니다. 다행히 PipeOptions.CurrentUserOnly는 세션 ID가 아니라 사용자의 SID(및 권한 상승 수준)로 판정하므로, 위 표의 2행(사용자 단위·세션 횡단)이라면 그대로 조합해 쓸 수 있습니다.

5.4 이름 선점(스쿼팅)과 ACL의 한계

머신 단위(표의 3행)에서 고정 이름의 Global\ Mutex를 쓰는 경우에는 이름 선점(스쿼팅)에 주의하세요. 이름 있는 Mutex로 앱을 단일 인스턴스로 제한하려는 경우, 악의적인 사용자가 먼저 같은 이름의 Mutex를 만들어 앱 시작을 방해할 수 있습니다.8 MutexSecurity(MutexAcl.Create)로 생성 시 ACL을 명시해 두면, 자신이 먼저 만들 수 있었던 경우에 다른 사용자가 그 Mutex를 가로채거나 부정하게 계속 보유하는 일을 막을 수 있습니다. 다만 이는 「나중에 방해받지 않기」 위한 대책이지, 「먼저 선점되는」 일 자체는 막지 못한다는 점에 주의하세요. ACL은 자신이 Mutex를 새로 만들 때 비로소 적용되므로, 악의적인 사용자가 자신보다 먼저 같은 이름으로 Mutex를 만들어 둔 경우, 이쪽 호출은 (ACL을 아무리 준비해도) 상대가 설정한 기존 객체를 열러 가는 것이 되어, 상대의 ACL을 따를 수밖에 없습니다. 이름 자체가 추측하기 어렵다는 점에는 가치가 있지만, 로컬 코드 실행 권한을 가진 악의적인 사용자를 완전히 막고 싶다면 Mutex 이름 선점만 의지하지 말고, 사용자마다 보호된 디렉터리 아래의 잠금 파일 등 다른 배타 제어와 조합하는 설계를 검토하세요.9

5.5 권한 상승 수준의 함정 ── CurrentUserOnly와 무결성 수준

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는 포그라운드를 설정할 수 있는 프로세스가 그 권한을 프로세스 ID로 지정한 상대에게 양도하기 위한 API입니다.7 두 번째 프로세스가 자신이 가진 권한을 기존 인스턴스에 넘긴 뒤, 이름 있는 파이프로 활성화를 요청하면, 기존 인스턴스 측의 SetForegroundWindow 호출이 성공하게 됩니다.

여기서 ASFW_ANY(-1)를 넘기지 마세요. 레퍼런스는 「이 매개변수가 ASFW_ANY인 경우, 모든 프로세스가 포그라운드 창을 설정할 수 있게 된다」고 정의합니다.7 넘길 상대는 하나로 정해져 있는데 전 프로세스에 나눠 주게 됩니다. 이 허가는 「다음에 사용자가 입력을 일으키거나, 다음에 어떤 프로세스가 AllowSetForegroundWindow를 호출할 때까지」 유효하므로,7 사용자가 앱을 시작한 바로 그 순간에 무관한 상주 프로세스가 포커스를 빼앗을 수 있는 틈이 생깁니다. 자기 앱을 앞으로 가져오기 위해 시스템 전체의 보호를 잠시 걷어내는 셈입니다.

넘길 상대 ── 기존 인스턴스의 프로세스 ID ── 는 연결한 파이프에서 가져올 수 있습니다. GetNamedPipeServerProcessId에 클라이언트 측 파이프 핸들을 넘기면, 그 파이프의 서버 측 프로세스 ID가 반환됩니다.11 「지금 자신이 연결된 상대」이므로, 이름이나 창에서 찾아낼 필요는 없습니다.

[DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(uint dwProcessId);

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetNamedPipeServerProcessId(
    SafePipeHandle pipe, out uint serverProcessId);

// 연결된 파이프에서 상대 프로세스 ID를 가져와, 그 하나에만 권한을 넘긴다.
// 가져오지 못하면 앞으로 가져오기를 포기한다(알림은 닿으므로, 주목적은 달성된 상태)
if (GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId))
{
    AllowSetForegroundWindow(serverProcessId);
}

이것으로도 구하지 못하는 경우(작업 스케줄러를 통한 시작 등, 두 번째 프로세스 자신에도 포그라운드 권한이 없는 경우)가 남습니다. 그때는 작업 표시줄 깜빡임으로 알리는 데 그치고, 무리하게 앞으로 가져오려 하지 않는 것이 타당한 설계입니다. 사용자 알림 수단으로는 토스트 알림이나, 창의 WindowState를 Minimized에서 Normal로 되돌리는 것까지는 항상 하고, 최종적인 앞으로 가져오기는 「가능하면 한다」 정도의 위치로 두면 무너지지 않습니다.

7. 시작 인자를 기존 인스턴스에 넘기기 ── 이름 있는 파이프

다중 실행 검출뿐 아니라 「열어야 할 파일을 인자와 함께 시작하면, 기존 인스턴스가 그 파일을 연다」는 요건도 흔합니다. 이 용도로 WM_COPYDATA(창 메시지로 데이터를 보내는 고전적 수단)도 선택지로는 존재하지만, 창 핸들 획득이나 메시지 마샬링의 번거로움에 비해 얻는 것이 적어, 신규 설계에서는 이름 있는 파이프를 쓰는 편이 자연스럽습니다.

설계는 단순합니다. Mutex로 「이미 실행 중」임을 안 두 번째 프로세스가, 기존 인스턴스가 대기 중인 이름 있는 파이프에 연결해 명령줄 인자를 JSON 등으로 직렬화해 보내면 됩니다. 파이프 이름 선점(스쿼팅) 대책이나 CurrentUserOnly 액세스 제어 등, 이름 있는 파이프 자체의 구현상 주의점은 「Windows의 프로세스 간 통신을 어떻게 고를까」의 이름 있는 파이프 절에 정리되어 있으므로 그쪽을 따르세요. 다중 실행 검출이라는 맥락에 특유한 주의점은 다음 한 가지뿐입니다.

  • 알림 전송에 실패해도, 다중 실행 방지로서는 성공으로 다뤄도 됩니다. 기존 인스턴스가 종료 처리 중이라 파이프 서버를 닫아 둔 경우처럼, 타이밍 문제로 알림이 닿지 않는 일은 있습니다. 이 경우에도 「두 번째 프로세스를 시작시키지 않는다」는 주목적은 달성되어 있으므로, 알림 실패를 이유로 오류 대화상자를 낼 필요는 없습니다.

8. 콘솔 앱·서비스와의 차이

이 기사의 설계는 창을 가진 데스크톱 앱을 전제로 합니다. 콘솔 앱이나 배치 도구에서는 「다중 실행을 막는」 쪽보다 「다중 실행되어도 안전하게 공존할 수 있게 하는」 설계가 현실적인 경우가 많고, 이는 실질적으로 파일 연계의 배타 제어 이야기로 귀착됩니다.

Windows 서비스는 사정이 더 다릅니다. 서비스 제어 관리자(SCM)는 같은 서비스 이름의 서비스를 애초에 동시에 두 개 시작하지 않으므로, 이 기사의 Mutex 검출은 기본적으로 불필요합니다. 「UI 앱 + 상주 서비스」 같은 구성에서는 UI 측만 이 기사의 설계를 쓰고, 서비스 측의 다중 시작·다중 실행에 관한 사고방식은 또 다른 이야기가 됩니다. 서비스 만드는 법이나 설계의 핵심은 다른 기사에 맡깁니다.

9. 구현 예 ── Mutex로 검출하고 활성화를 요청하기

9.1 필요한 것

코드에 들어가기 전에 전제를 명시합니다.

항목 내용
대상 프레임워크 .NET 8 이후의 Windows용 TFM(net8.0-windows 등). WPF를 쓰므로 프로젝트 파일에 <UseWPF>true</UseWPF>가 필요합니다
NuGet 패키지 System.Threading.AccessControl. MutexAcl.Create와 MutexSecurity는 이 패키지의 어셈블리 System.Threading.AccessControl.dll에 포함되어 있으며, 기본 참조에는 들어 있지 않습니다12
사용할 네임스페이스 System.IO.Pipes / System.Runtime.InteropServices / System.Security.AccessControl / System.Security.Principal / System.Text.Json
대상 OS Windows만. Global\ 네임스페이스·AllowSetForegroundWindow·CurrentUserOnly는 모두 Windows 고유 메커니즘입니다
WinForms의 경우 Application.Current.Dispatcher.Invoke를 Control.Invoke로, Application.Current.MainWindow를 대상 폼 참조로 바꾸면 거의 그대로 쓸 수 있습니다

패키지는 dotnet add package System.Threading.AccessControl로 추가합니다. 참고로 ACL을 명시하지 않고 단순한 new Mutex(...)로 끝내는 경우(제5.2절 표의 1행·세션 단위)에는 이 패키지가 필요 없습니다.

9.2 골격 ── 검출·알림·서버 시작

코드가 길어서, 먼저 전체 흐름만 보입니다. 실질적으로는 다음 4단계뿐입니다. NotifyRunningInstanceAsync ShowMainWindow StartActivationServer의 내용은 제9.3절에 있습니다.

// args 는 Main 의 명령줄 인자
string mutexName = $@"Global\KomuraSoft.MyApp.SingleInstance.{WindowsIdentity.GetCurrent().User}";
var security = new MutexSecurity();
security.AddAccessRule(new MutexAccessRule(
    WindowsIdentity.GetCurrent().User!, MutexRights.FullControl, AccessControlType.Allow));

// 1. 검출 ── 자신이 첫 인스턴스인지를 createdNew 로 판정한다
var mutex = MutexAcl.Create(
    initiallyOwned: true, name: mutexName, createdNew: out bool createdNew, mutexSecurity: security);

if (!createdNew)
{
    // 2. 알림 ── 두 번째라면, 기존 인스턴스에 인자를 보내고 자신은 종료한다
    NotifyRunningInstanceAsync(args).GetAwaiter().GetResult();
    return;
}

// 3. 시작 ── 첫 번째라면, 창을 표시한 뒤 파이프 서버를 세운다
ShowMainWindow();
StartActivationServer();

// 4. 뒷정리 ── 획득한 것과 같은 스레드에서 해제한 뒤 종료한다
mutex.ReleaseMutex();

순서에서 하나라도 양보할 수 없는 것이 3.이며, 창을 표시한 뒤에 파이프 서버를 시작한다는 점입니다. 반대로 하면 서버가 받은 활성화 요청의 대상이 아직 없어, 알림이 조용히 사라집니다.

9.3 완전판

위의 골격에 제2〜7장에서 든 주의점을 모두 반영한 것이 다음 코드입니다. WPF를 가정하지만, WinForms에서도 제9.1절 표대로 바꾸기만 하면 거의 그대로 쓸 수 있습니다.

using System.IO.Pipes;
using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;

public static class Program
{
    // 제품 고유 GUID를 넣어, 다른 앱과 이름이 충돌하지 않게 한다.
    // 「사용자 단위·세션 횡단」으로 인스턴스 하나를 원하므로, Global\ 에 더해
    // 사용자 SID를 이름에 넣는다. SID를 넣지 않고 Global\ 만 쓰면, 모든 사용자가
    // 같은 Mutex를 공유해 「머신 전체에서 인스턴스 하나」로 바뀐다(제3장·제5.1절)
    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.4절)
        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.5절). 「권한 상승 수준이 다른 별도 인스턴스가
            // 이미 있다」고 보고, 알림은 포기하고 안전한 쪽(시작하지 않음)으로 기울인다
            return;
        }
        using var _ = mutex;

        if (!createdNew)
        {
            // 이미 실행 중. 기존 인스턴스에 알리고 자신은 종료한다
            NotifyRunningInstanceAsync(args).GetAwaiter().GetResult();
            return;
        }

        try
        {
            // App.xaml 의 StartupUri 는 삭제해 둘 것. 남겨 두면,
            // 여기서 수동 생성한 MainWindow 에 더해 StartupUri 측 창도
            // 자동 생성·표시되고(app.MainWindow 도 그쪽으로 덮어써지며),
            // 창이 두 개 열린 끝에 활성화 요청이 닿지 않는 쪽을 향하는 사고가 된다
            var app = new App();
            app.InitializeComponent();
            var mainWindow = new MainWindow();
            app.MainWindow = mainWindow;
            mainWindow.Show();

            // MainWindow 의 생성·표시가 끝난 뒤에 파이프 서버를 시작한다.
            // 반대 순서면, 창이 아직 없는 상태에서 활성화 요청이 닿아
            // ActivateMainWindow 호출이 실패할 수 있다(예외는 아래 catch-all 에서
            // 삼켜지고, 알림이 그냥 사라질 뿐이다). 이 사이에 닿은 요청은
            // 「서버 미시작→연결 실패」로서 NotifyRunningInstanceAsync 측의
            // best-effort 설계(제7장)에 그대로 맡긴다
            StartActivationServer();

            app.Run();
        }
        finally
        {
            // 소유 스레드(이 스레드)에서 명시적으로 해제한 뒤 종료한다.
            // 해제하지 않고 종료하면, 다음 시작 때 AbandonedMutexException 의
            // 원인이 된다(제4장)
            mutex.ReleaseMutex();
        }
    }

    [DllImport("user32.dll")]
    private static extern bool AllowSetForegroundWindow(uint dwProcessId);

    // 연결 중인 파이프에서, 서버 측(=기존 인스턴스)의 프로세스 ID를 가져온다
    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool GetNamedPipeServerProcessId(
        SafePipeHandle pipe, out uint serverProcessId);

    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장).
            // ASFW_ANY(-1)를 넘기면 「모든 프로세스」가 대상이 된다
            if (GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId))
            {
                AllowSetForegroundWindow(serverProcessId);
            }

            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.5절) 등, 알림이 닿지 않는 이유를 가리지 않고,
            // 다중 실행 방지로서의 주목적(두 번째를 시작시키지 않음)은 달성된 상태다(제7장)
        }
    }

    private static void StartActivationServer()
    {
        // 1메시지 상한. 같은 사용자의 오래된 헬퍼나 깨진 클라이언트가
        // 한없이 보내더라도, 서버 측 메모리를 지킨다
        const int MaxPayloadBytes = 64 * 1024;

        _ = Task.Run(async () =>
        {
            while (true)
            {
                try
                {
                    // 생성자 자체도 try 안에 넣는다. maxNumberOfServerInstances 가
                    // 1이므로, 직전 연결의 뒷정리가 끝나지 않은 타이밍 등에서
                    // 여기가 IOException 을 던질 수 있고, try 밖에 두면 이
                    // 한 번의 실패로 백그라운드 태스크 전체가 멈춘다
                    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);
}

설계 판단을 세 가지 덧붙입니다.

  • Global\에 덧붙이는 사용자 식별자는, 이름이 바뀌지 않는 것을 고른다. WindowsIdentity.GetCurrent().User가 반환하는 SecurityIdentifier는 사용자 이름과 달리 개명의 영향을 받지 않고, ToString()으로 S-1-5-21-... 형식 문자열이 됩니다.13 사용자 이름을 직접 넣으면, 계정 이름 변경이나 도메인 이전으로 다중 실행 방지가 동작하지 않게 되는 사고로 이어집니다.
  • Mutex 해제와 파이프 서버 중지는 앱 종료 처리의 일부로 명시적으로 한다. 위 예에서는 finally에서 ReleaseMutex를 호출하지만, 실제 앱에서는 창을 닫는 처리 안에서 취소 토큰으로 파이프 서버 루프도 멈추도록 하세요.
  • version 필드는 처음부터 넣는다. 시작 인자 형식을 나중에 바꿀 가능성은 충분합니다. 「모르는 버전은 무시한다」는 판정을 처음부터 넣어 두면, 이전 버전 실행 파일이 남은 단말에서도 안전하게 동작합니다.

10. 동작 확인 절차

다중 실행 방지는 「동작하지 않는 것을 알아채기 어려운」 기능입니다. 구현했다면 반드시 다음 순서로 손으로 확인해 주세요. 1〜3은 필수, 4 이후는 제5.2절 표에서 고른 범위에 따라 실시합니다.

  1. 같은 세션에서의 이중 실행 ── 앱을 실행한 채로 실행 파일을 한 번 더 더블클릭합니다. 두 번째 창이 열리지 않고, 기존 창이 앞으로 오면 성공입니다. 작업 관리자의 「세부 정보」 탭에서 대상 실행 파일이 한 줄만 남아 있는지도 확인합니다.
  2. 최소화에서의 복귀 ── 첫 번째를 최소화한 상태에서 1.을 반복합니다. 최소화된 채 복귀하지 않으면, 제9.3절 ActivateMainWindow에 있는 WindowState를 되돌리는 처리가 호출되지 않은 것입니다.
  3. 시작 인자 전달 ── 명령 프롬프트에서 MyApp.exe C:\temp\sample.txt처럼 인자와 함께 두 번째를 시작하고, 기존 인스턴스 측에서 그 경로가 처리되는지 확인합니다. 여기가 동작하지 않으면 파이프 이름 불일치나, 파이프 서버 시작 순서(제9.2절)를 의심합니다.
  4. 세션을 가로지르는 확인(사용자 단위·머신 단위를 고른 경우) ── 같은 사용자로 콘솔 로그온과 원격 데스크톱 연결의 세션 두 개를 만들고, 양쪽에서 시작합니다. 현재 세션 목록과 ID는 qwinsta 명령으로 확인할 수 있습니다.14 prefix 없음 그대로면 여기서 두 개가 실행됩니다(제3장). 이것이 다중 실행 방지 장애로 가장 많이 보고되는 형태입니다.
  5. 사용자를 가로지르는 확인(머신 단위를 고른 경우) ── 다른 사용자로 세션을 하나 더 만들고, 앞의 사용자가 실행 중일 때 시작되지 않는지 확인합니다. Global\에 SID를 넣었는지 여부로 결과가 달라지므로, 제5.1절 그림과 맞춰 보세요.
  6. 권한 상승 수준이 다른 경우의 확인 ── 첫 번째를 일반 권한으로 실행한 채로, 두 번째를 「관리자로 실행」으로 시작합니다. 제5.5절에서 쓴 UnauthorizedAccessException 경로로 떨어지는 경우가 있으며, 그때 두 번째가 조용히 종료하는지(오류 대화상자를 내고 죽지 않는지) 확인합니다.
  7. 포기된 Mutex의 확인 ── 첫 번째를 작업 관리자의 「작업 끝내기」로 강제 종료한 뒤, 한 번 더 시작합니다. 문제없이 시작되면 정상입니다. 여기서 시작되지 않으면 Mutex를 보유한 스레드나 프로세스가 남아 있습니다. 같은 Mutex를 다른 배타 제어에도 쓰고 있다면 AbandonedMutexException 처리(제4장)도 함께 확인합니다.

만든 Mutex가 실제로 어떤 이름으로 존재하는지 눈으로 확인하고 싶다면, Sysinternals의 WinObj로 객체 관리자 네임스페이스를 열고 이름으로 찾는 것이 확실합니다.15 「Global\를 붙인 줄 알았는데 붙어 있지 않았다」는 실수는 이것으로 한 번에 알 수 있습니다.

11. 정리

Windows 앱의 다중 실행 방지는 new Mutex(true, name, out createdNew)라는 몇 줄만 보면 단순합니다. 그러나 실무에서 사고 없이 동작시키려면, Global\/Local\에 따른 세션 가시성 차이, Mutex 고유의 스레드 소유권 제약과 AbandonedMutexException 처리, 그리고 SetForegroundWindow 제한을 반영한 활성화 설계까지, 주변 지식을 한 바퀴 잡아 둘 필요가 있습니다.

구현 순서로는, 먼저 「어느 단위(세션·사용자·머신)로 다중 실행을 막을지」를 정하고(제5장), 그에 맞춰 Mutex 네임스페이스와 알림용 파이프 범위를 맞춥니다. 그다음 기존 인스턴스를 앞으로 가져올 때는 AllowSetForegroundWindow로 권한을 위임하는 정석을 쓰고, 그래도 실패하는 경우는 무리하게 앞으로 가져오지 않고 알림에 그친다는 선에서 타협해 두면 구현이 무너지지 않습니다. 요건이 「사용자당 인스턴스 하나인지, 머신 전체에서 인스턴스 하나인지」로 헷갈리면, 운용 환경(RDP 병용 여부, 여러 사용자의 동시 로그온 여부)을 먼저 확인할 것을 권합니다.

관련 기사

관련 상담 영역

合同会社小村ソフト에서는 다중 실행 방지와 창 제어를 포함한 Windows 데스크톱 앱의 설계·구현, 원격 데스크톱 환경 특유의 장애 원인 조사, 기존 앱의 설계 리뷰를 다룹니다.

참고 링크

  1. Microsoft Learn, Mutex Constructor. 이름 있는 Mutex가 이미 있는 경우 createdNew가 false가 되고 예외는 나지 않는다는 점, initiallyOwned에 의한 초기 소유권은 createdNew가 true일 때만 유효하다는 점에 대해. ↩ ↩2 ↩3

  2. Microsoft Learn, Mutex Class. 이름 있는 Mutex에 prefix를 지정하지 않으면 기본값으로 Local\가 된다는 점, Global\/Local\에 따른 터미널 서비스 세션 간 가시성 차이, Mutex가 스레드 단위로 소유권을 강제한다는 점(다른 동기화 객체와 다름)에 대해. ↩ ↩2 ↩3

  3. Microsoft Learn, Kernel Object Namespaces. 세션마다 독립된 네임스페이스와 글로벌 네임스페이스의 구조, Global\/Local\ prefix로 네임스페이스를 지정하는 방법에 대해. ↩ ↩2

  4. Microsoft Learn, Mutex.ReleaseMutex Method. 소유하지 않은 스레드가 ReleaseMutex를 호출하면 ApplicationException이 발생한다는 점, 스레드가 Mutex를 해제하지 않고 종료하면 Mutex가 포기된 상태가 된다는 점에 대해. ↩ ↩2

  5. Microsoft Learn, AbandonedMutexException Class. 포기된 Mutex를 다음에 획득한 스레드에 AbandonedMutexException이 발생한다는 점, 대기 자체는 성공했고 호출 측이 Mutex 소유권을 얻었다는 점에 대해. ↩ ↩2

  6. Microsoft Learn, SetForegroundWindow function. 포그라운드 창을 설정할 수 있는 프로세스의 조건과, 조건을 충족하지 않으면 작업 표시줄 버튼 깜빡임에 그친다는 점에 대해. ↩ ↩2

  7. Microsoft Learn, AllowSetForegroundWindow function. 포그라운드 창을 설정할 수 있는 프로세스가 그 권한을 dwProcessId로 지정한 프로세스에 양도할 수 있다는 점, 「이 매개변수가 ASFW_ANY인 경우, 모든 프로세스가 포그라운드 창을 설정할 수 있게 된다」는 점, 양도한 권한은 「다음에 사용자가 입력을 일으켰을 때(그 입력이 그 프로세스를 향한 경우를 제외), 또는 다음에 어떤 프로세스가 AllowSetForegroundWindow를 호출했을 때(이전과 같은 프로세스가 지정된 경우를 제외)」 사라진다는 점에 대해. ↩ ↩2 ↩3 ↩4

  8. Microsoft Learn, CreateMutexW function (synchapi.h). 이름 있는 Mutex로 단일 인스턴스로 제한할 때, 악의적인 사용자가 먼저 같은 이름의 Mutex를 만들어 앱 시작을 방해할 수 있다는 점, 대책으로 무작위 이름이나 사용자당 인스턴스 하나라면 사용자 프로필 아래의 잠금 파일을 쓰는 대안에 대해. ↩

  9. Microsoft Learn, Mutexes. 이름 있는 시스템 Mutex는 OS 전체에서 보이며 글로벌이므로, 생성 시점부터 액세스 제어 보안으로 보호하는 것이 권장된다는 점, MutexSecurity에 의한 액세스 제어에 대해. ↩

  10. Microsoft Learn, PipeOptions Enum. CurrentUserOnly가 Windows에서는 사용자 계정에 더해 권한 상승 수준까지 검증한다는 점에 대해. ↩

  11. Microsoft Learn, GetNamedPipeServerProcessId function. 지정한 이름 있는 파이프에 대해 서버 측 프로세스 식별자를 가져올 수 있다는 점에 대해(Windows Vista 이후). ↩

  12. Microsoft Learn, MutexAcl.Create Method. MutexAcl.Create가 네임스페이스 System.Threading·어셈블리 System.Threading.AccessControl.dll·NuGet 패키지 System.Threading.AccessControl로 제공된다는 점, Global\/Local\ prefix로 네임스페이스를 지정하고 기본값이 Local\라는 점, 기본값으로는 이름 있는 Mutex를 생성자 이외도 열 수 있으므로 액세스 제한에는 MutexSecurity를 넘겨야 한다는 점에 대해. ↩

  13. Microsoft Learn, WindowsIdentity.User Property. 사용자의 보안 식별자(SID)를 반환하는 속성이며, SID가 모든 Windows NT 구현에서 사용자 또는 그룹을 고유하게 식별한다는 점에 대해. ↩

  14. Microsoft Learn, qwinsta. 원격 데스크톱 세션 호스트의 세션 목록(세션 이름·ID·상태)을 표시하는 명령에 대해. ↩

  15. Microsoft Learn, WinObj - Sysinternals. WinObj가 NT 객체 관리자 네임스페이스를 표시하는 도구이며, 이름 있는 커널 객체를 열람할 수 있다는 점에 대해. ↩

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

C#에서 앱의 다중 실행을 방지하려면 어떻게 하면 됩니까?
기본형은 new Mutex(true, name, out bool createdNew)입니다. 같은 이름의 Mutex가 이미 있어도 예외는 나지 않고, createdNew가 false가 될 뿐입니다. 이 값으로 분기해 두 번째 프로세스를 종료합니다. 이름은 다른 회사 앱과 충돌하지 않도록, 제품 고유 GUID를 넣은 문자열로 만드는 것이 정석입니다. createdNew가 false인 분기에서는 Mutex에 전혀 손대지 않고 즉시 처리를 끝내는 편이 안전합니다.
원격 데스크톱 환경에서 다중 실행 방지가 동작하지 않는 이유는 무엇입니까?
Mutex 이름에 prefix를 붙이지 않으면, 기본값으로 Local\(세션 한정) 네임스페이스에 만들어지기 때문입니다. 같은 사용자가 콘솔과 RDP로 여러 세션을 갖는 환경에서는 세션마다 다른 Mutex로 취급되어, 두 번째 세션에서는 그대로 실행됩니다. 세션을 가로질러 하나로 묶으려면 Global\ prefix가 필수입니다. 다만 Global\만 쓰면 모든 사용자가 공유하는 머신 단위가 되므로, 사용자당 인스턴스 하나로 제한하려면 이름에 사용자의 SID도 넣습니다.
기존 인스턴스의 창을 앞으로 가져오려면 어떻게 하면 됩니까?
단순히 SetForegroundWindow만 호출하면, OS 제한 때문에 대부분 실패하고 작업 표시줄 버튼이 깜빡이는 데 그칩니다. 정석은, 지금 사용자가 더블클릭해 시작한 두 번째 프로세스가 가진 포그라운드 설정 권한을 AllowSetForegroundWindow로 기존 인스턴스에 넘긴 뒤, 이름 있는 파이프로 활성화를 요청하는 설계입니다. 양도 대상은 프로세스 ID로 지정하고, ASFW_ANY(모든 프로세스에 허용)는 쓰지 않습니다. 상대 프로세스 ID는 연결한 파이프에서 GetNamedPipeServerProcessId로 가져올 수 있습니다. 그래도 실패하는 경우(작업 스케줄러를 통한 시작 등)는 무리하게 앞으로 가져오지 않고 알림에 그치는 것이 타당합니다.
AbandonedMutexException은 어떻게 다루면 됩니까?
Mutex를 소유하던 스레드가 ReleaseMutex를 호출하지 않고 종료하면(프로세스 크래시 등), 다음에 획득한 스레드에 AbandonedMutexException이 발생합니다. 이는 대기 자체는 성공했고, 호출 측이 이미 소유권을 얻었다는 뜻의 예외입니다. 삼키지 말고, 보호 대상 상태의 정합성을 확인한 뒤에 쓰는 것이 올바른 처리입니다. 정상 종료 경로에서는 finally에서 명시적으로 ReleaseMutex를 호출해 두면, 다음 시작 때 불필요하게 발생하는 일을 막을 수 있습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기