Windows의 프로세스 간 통신을 어떻게 선택할까 ── 네임드 파이프 / TCP / gRPC / 공유 메모리 / COM 판단표

· · 프로세스 간 통신, 네임드 파이프, Windows, .NET, C#, gRPC, 공유 메모리, COM, 설계, 판단표, 기술 상담

「UI와 서비스를 분리했는데 그 사이의 통신은 어떻게 해야 할까」「32bit 앱에서 64bit DLL의 기능을 사용하고 싶다」「별도 프로세스의 측정 엔진에서 화면으로 데이터를 흘려보내고 싶다」. 앱을 여러 프로세스로 나누는 설계는 견고성・권한 분리・비트 수 문제에 대한 현실적인 해법으로 자주 등장하지만, 나누는 순간 반드시 「그 사이의 통신을 어떻게 할 것인가」라는 선택이 생겨납니다.

이 블로그에서는 지금까지 공유 메모리의 함정, TCP의 프레이밍, 자식 프로세스의 안전한 처리, 파일 연동과 잠금처럼 개별 통신 수단에 대해서는 다뤄 왔습니다. 그런데 「도대체 어느 수단을 선택해야 하는가」라는 총론은 아직 없었습니다. 이 글에서는 Windows 프로세스 간 통신(IPC)의 주요 선택지──파일 연동・네임드 파이프・로컬 TCP・gRPC・공유 메모리・COM──의 강점과 함정을, 이 블로그에서 즐겨 쓰는 판단표 형식으로 정리합니다.

1. 먼저 결론

  • 같은 컴퓨터 안에서의 요청-응답(명령을 보내고 결과를 받는 방식)이라면 네임드 파이프가 1순위입니다. OS 표준 기능이고 포트가 필요 없으며 Windows의 접근 제어와 통합되어 있어, .NET에서는 System.IO.Pipes로 간단히 구현할 수 있습니다.1
  • 앞으로 네트워크를 넘어갈 가능성이 있다면 처음부터 로컬 TCP나 gRPC로 합니다. 파이프에서 소켓으로 옮기는 작업은 「나중에 조금 고치면 된다」 수준으로 끝나지 않는 차이가 되기 쉽습니다. 다만 순수한 TCP는 바이트 스트림이므로 프레이밍 설계가 필수입니다.
  • 서비스나 호출의 종류가 늘어나 자체 프로토콜 유지가 부담스러워지면 gRPC입니다. proto를 통한 스키마 정의와 코드 생성, 양방향 스트리밍을 얻을 수 있고, .NET 8 이후로는 ASP.NET Core(Kestrel)가 네임드 파이프를 전송 계층으로 쓸 수 있습니다.2
  • 대용량・고빈도 데이터(이미지 프레임, 파형)만 공유 메모리로 처리합니다. 가장 빠르지만 동기화를 전부 직접 설계해야 하므로, 제어 메시지까지 공유 메모리에 싣지 않는 것이 원칙입니다.3
  • 느슨한 결합・비동기로 충분하고 감사 로그를 남기고 싶은 연동이라면 파일 연동이 지금도 유력합니다. 다만 성패는 배타 제어 설계에 따라 갈립니다.
  • COM(아웃프로세스)은 신규 설계의 1순위로 삼지는 않지만, VBA・다른 언어에서의 사용이나 32bit/64bit 브리지라는 맥락에서는 지금도 현역인 도구입니다.4
  • 어떤 수단을 선택하더라도 메시지의 구분・버전 번호・타임아웃・재접속이라는 공통 설계 과제에서는 벗어날 수 없습니다(제6장). 수단을 고르는 것만큼이나 이 부분에 시간을 들이시기 바랍니다.

2. 선택지의 정체

2.1 파일 연동 ── 느슨한 결합・비동기・감사 가능

「A가 출력 폴더에 파일을 두고 B가 가져다 처리한다」는 고전적인 연동 방식입니다. 양쪽이 동시에 실행되어 있을 필요가 없고, 처리 흔적이 파일로 남으며, 장애가 발생했을 때는 사람이 파일을 직접 보고 고쳐서 다시 투입할 수 있습니다. 실시간성이 요구되지 않는 배치성 연동에서는 지금도 강력합니다.

함정은 거의 한 가지, 배타 제어로 집약됩니다. 「쓰는 중인 파일을 읽어 버린다」「두 처리가 같은 파일을 서로 차지하려 한다」는 흔한 사고이며, 임시 파일명으로 쓴 뒤 이름을 바꾸는 등의 정석을 밟아야 합니다. 자세한 내용은 《파일 연동과 잠금의 모범 사례》에 정리해 두었으니, 이 방식을 선택할 경우 반드시 참조하시기 바랍니다. 응답을 즉시 받아야 하는 대화형 통신이나, 초당 수십 번씩 발생하는 교환에는 적합하지 않습니다.

2.2 네임드 파이프 ── 같은 컴퓨터 IPC의 본진

네임드 파이프는 Windows가 커널에서 제공하는 양방향 통신 경로로, 같은 컴퓨터 안에서의 클라이언트・서버형 IPC의 본진입니다. .NET에서는 NamedPipeServerStream / NamedPipeClientStream으로 다루며, 하나의 파이프 이름에 여러 클라이언트가 접속할 수 있습니다.5 TCP와 달리 포트 번호 관리가 필요 없고 방화벽에도 걸리지 않습니다.

실무에서 짚어 두어야 할 포인트를 들어 보겠습니다.

  • 메시지 모드를 사용할 수 있습니다. PipeTransmissionMode.Message를 지정하면 한 번의 쓰기가 하나의 메시지로 구분되어 도착합니다. TCP에서는 필수인 length 프리픽스 같은 프레이밍을 OS가 대신 처리해 주는, 실무상 큰 장점입니다(수신 측은 IsMessageComplete로 메시지 하나를 끝까지 읽어야 합니다. 제5장 참조).
  • 기본 접근 권한은 의외로 느슨합니다. 파이프의 기본 보안 기술자는 LocalSystem・관리자・작성자에게 모든 권한을 부여하는 한편, Everyone과 익명 계정에도 읽기를 허용합니다.6 같은 사용자의 프로세스 사이라면 PipeOptions.CurrentUserOnly를 붙이는 것만으로 「같은 사용자가 만든 상대와만 접속한다」를 강제할 수 있으며, 이것을 기본 작법으로 삼는 것을 권장합니다.7 서로 다른 계정 사이(서비스와의 통신 등)라면 PipeSecurity로 ACL을 명시합니다.
  • 파이프 이름은 모두에게 보이는 네임스페이스에 있습니다. 파이프 이름은 \\.\pipe\ 아래의 단일 네임스페이스에 놓이며, 같은 컴퓨터의 다른 사용자 프로세스에서도 보입니다. 여기서 주의해야 할 것이 이름 선점(스쿼팅)입니다. 악의적인 프로세스가 먼저 같은 이름으로 서버를 세워 대기하면 클라이언트는 그쪽으로 접속하게 됩니다. 서버 쪽은 PipeOptions.FirstPipeInstance를 지정해 같은 이름의 파이프가 이미 있으면 실패하도록 하고, 클라이언트 쪽은 접속 후 파이프 소유자 계정을 검증하는 등의 대책을, 권한이 다른 사용자가 함께 있는 컴퓨터에서는 넣어 두어야 합니다.8
  • 권한 경계를 넘는 통신도 가능합니다. UAC로 분리된 「표준 권한 UI + 관리자 권한 브로커」 사이의 통신 경로로도 정형입니다. 다만 이 구성에서는 CurrentUserOnly를 사용할 수 없습니다(같은 사용자라도 상승 레벨까지 일치해야 하기 때문입니다7). ACL을 명시적으로 설계해야 하며, 구체적인 구조는 《관리자 권한 분리(브로커)》에서 자세히 다루었습니다.

약점은 컴퓨터를 넘는 통신에는 사실상 적합하지 않다는 점(사양상으로는 가능하지만 운용상 제약이 많습니다), Windows 이외와의 상호 접속에는 쓰기 어렵다는 점입니다. 그러한 요구사항이 보인다면 TCP / gRPC를 선택합니다.

2.3 로컬 TCP ── 언어・OS를 넘나드는 범용성

localhost에 대한 TCP 접속은, 거의 어떤 언어・런타임・OS에서도 통신할 수 있는 가장 범용적인 IPC입니다. 「Linux 위의 분석 엔진과 Windows의 UI」 같은 혼합 구성에서는 우선 TCP(또는 그 위의 HTTP/gRPC)가 후보가 됩니다.

주의할 점은 3가지입니다.

  • 바이트 스트림입니다. TCP에는 「보낸 단위로 도착한다」는 성질이 없습니다. Send한 3개의 메시지가 한 번의 Receive로 이어져서 도착하는 것도, 하나의 메시지가 분할되어 도착하는 것도 정상입니다. length 프리픽스 같은 프레이밍을 애플리케이션 계층에서 설계해야 하며, 이를 생략한 코드는 「우연히 동작하고 있을」 뿐입니다. 자세한 내용은 《TCP에서 Send한 단위대로 Receive할 수 있다는 오해》를 참조하시기 바랍니다.
  • 대기(listen)의 공개 범위를 좁힙니다. 같은 컴퓨터 안의 통신이라고 생각해도 0.0.0.0으로 대기하면 네트워크상의 다른 컴퓨터에서 접속할 수 있게 됩니다. 로컬 IPC라면 127.0.0.1(루프백)에 바인딩하는 것이 원칙입니다. 그래도 같은 컴퓨터 안의 다른 사용자로부터는 접속할 수 있으므로, 상대를 확인해야 한다면 애플리케이션 계층에서 인증을 추가합니다. 파이프처럼 OS에 통합된 접근 제어가 없다는 점은 명확한 차이입니다.
  • 포트 운용이 항상 따라옵니다. 고정 포트는 다른 소프트웨어와 충돌할 수 있고, 방화벽 제품이 「의심스러운 대기」로 감지하는 경우도 있습니다. 포트 번호를 변경할 수 있는 수단을 처음부터 설계에 넣어 둡니다.

2.4 gRPC ── 스키마와 코드 생성을 사는 것

순수 소켓 + 자체 프로토콜과 비교했을 때, gRPC가 제공하는 것은 통신 그 자체보다는 개발의 틀입니다. proto 파일로 서비스와 메시지를 정의하면 직렬화・프레이밍・클라이언트/서버 코드가 모두 생성되고, 양방향 스트리밍(서버로부터의 푸시 알림)도 언어 기능처럼 작성할 수 있습니다. 「메시지가 늘어날 때마다 자체 프로토콜의 switch 문과 문서를 고쳐야 하는」 단계에 들어섰다면 이 틀이 효과를 냅니다.

.NET 8 이후로는 ASP.NET Core(Kestrel)가 TCP뿐 아니라 Unix 도메인 소켓과 네임드 파이프를 전송 계층으로 직접 지원합니다.8 서버 쪽은 ListenNamedPipe를 호출하기만 하면 되고, PipeSecurity에 의한 접근 제어도 설정할 수 있습니다.2 「통신 경로는 파이프 그대로 포트리스・ACL 통합, 프로토콜 계층은 gRPC」라는 조합은 UI + 서비스 분리(제4장)에서 유력한 선택지입니다.

한편, 소규모 도구 간 통신에는 과한 선택이 되기 쉽습니다. gRPC 서버를 세운다는 것은 ASP.NET Core의 호스팅 기반을 짊어진다는 뜻으로, 배포물・의존성・기동 비용이 늘어납니다. 명령이 2~3종류밖에 없는 도구 사이라면 네임드 파이프 + JSON(제5장) 쪽이 총비용이 더 낮다는 기준선은 가지고 있으시기 바랍니다. 「proto에 적어야 할 메시지가 10개를 넘었다」「스트리밍 알림이 본질적으로 필요하다」「상대가 .NET 이외의 언어」 중 하나에 해당하게 된 뒤에 도입해도 늦지 않습니다.

2.5 공유 메모리(메모리 매핑 파일) ── 가장 빠르지만 동기화는 전부 직접

같은 컴퓨터 안에서 가장 빠른 IPC는 공유 메모리입니다. .NET에서는 MemoryMappedFile.CreateNew로 이름이 붙은 공유 메모리를 만들어, 여러 프로세스에서 같은 바이트열을 직접 읽고 쓸 수 있습니다.3 복사도 직렬화도 개입하지 않으므로, 이미지 프레임이나 파형 데이터처럼 「크고 빠른」 데이터에는 이것이 유일한 선택이라고 해도 될 성능이 나옵니다.

다만 공유 메모리는 「빠른 파이프」가 아닙니다. 같은 바이트열이 보일 뿐, 동기화는 1바이트도 제공되지 않습니다. 쓰는 중인 데이터를 읽지 않는 장치, 상대의 생사 감지, 한쪽이 비정상 종료된 뒤의 복구──모두 직접 설계해야 합니다. 링 버퍼 구성이나 레이아웃의 버전 관리까지 포함한 설계론은 《공유 메모리의 함정과 실무 모범 사례》에 한 편으로 정리해 두었습니다.

실무에서의 활용 지점은 명확한데, 데이터 면(data plane)만 공유 메모리에 싣고, 제어 면(control plane)은 네임드 파이프 같은 별도 채널로 분리하는 구성입니다(제4장의 구성 3). 시작・정지・설정 변경 같은 제어 메시지까지 공유 메모리로 처리하려 한다면 설계를 다시 검토해야 할 신호입니다.

2.6 COM(아웃프로세스) ── 신규의 1순위는 아니지만 현역인 상황이 있다

COM의 아웃프로세스 서버(EXE 서버)는, Windows가 오래전부터 가지고 있던 「다른 프로세스의 객체를 로컬 함수처럼 호출하는」 방식입니다.4 신규 앱 간 통신에서 1순위로 삼는 일은 더 이상 없지만, 다음과 같은 맥락에서는 지금도 실용적입니다.

  • 32bit/64bit 브리지: 32bit 앱에서 64bit DLL(또는 그 반대)은 같은 프로세스에 로드할 수 없지만, 아웃프로세스 COM이라면 경계를 넘을 수 있습니다. COM 기반이 마셜링을 대신 처리해 주므로 호출 측 코드를 거의 바꾸지 않아도 됩니다. 실제 예시는 《32bit→64bit COM 브리지 실전 사례》에 적었습니다.
  • VBA・다른 언어에서의 사용: Excel VBA 같은 오래된 실행 환경에서 .NET의 기능을 호출하게 하고 싶은 경우, COM으로 공개하는 것이 지금도 가장 자연스러운 경로입니다.
  • 기존 COM 자산과의 연동: 상대가 COM으로만 대화할 수 있다면, 이쪽도 COM으로 대화하는 것이 가장 빠른 길입니다(COM 자체에 대한 설명은 《COM・ActiveX・OCX란 무엇인가》).

신규 도입을 주저하게 되는 이유는 레지스트리 등록을 수반하는 배포의 번거로움, 인터페이스 설계와 참조 카운트 학습 비용, 그리고 문제 발생 시 조사의 어려움입니다. 「32/64 브리지가 목적이라면 64bit 쪽을 그냥 헬퍼 프로세스로 만들어 네임드 파이프로 대화하게 한다」는 대안(제4장의 구성 2)과 비교한 뒤 결정하시기 바랍니다.

2.7 고전적인 수단 ── 신규에서는 선택하지 않는다

Windows에는 이 밖에도 WM_COPYDATA(윈도우 메시지로 데이터를 보냄), 클립보드, DDE, 메일슬롯 같은 IPC 수단이 있으며, 공식 IPC 개요 페이지에는 지금도 나열되어 있습니다.4 다만 윈도우의 존재가 전제이거나 폐지가 진행 중이거나(원격 메일슬롯은 폐지 절차가 진행 중입니다) 해서, 신규 설계에서 선택할 이유는 거의 없습니다. 기존 앱을 유지보수하다가 마주쳤을 때 알아볼 수 있으면 충분합니다.

3. 판단표

관점 파일 네임드 파이프 로컬 TCP gRPC 공유 메모리 COM
통신 범위 공유 폴더로 컴퓨터를 넘을 수 있음 같은 컴퓨터가 실용 범위 컴퓨터를 넘을 수 있음 컴퓨터를 넘을 수 있음 같은 컴퓨터로 한정 같은 컴퓨터가 실용 범위
통신 모델 파일 전달(비동기) 스트림+메시지 모드 바이트 스트림 RPC+스트리밍 공유 상태 메서드 호출
서버→클라이언트 알림 ✕(폴링) ◎(양방향 스트리밍) △(이벤트 병용) △(만들 수 있지만 복잡)
권한 경계 넘기(UAC・서비스) ○(폴더 ACL) ◎(PipeSecurity) △(인증은 직접) △~○(파이프 전송이면 ◎) ○(ACL 가능, 설계는 어려움)
32/64・언어 혼재 ◎(proto에서 각 언어로 생성) △(ABI 설계가 필수) ○(브리지는 강점)
구현 비용 낮음 낮음~중간 중간(프레이밍 직접) 중간(기반 도입) 높음 높음(신규에는)
디버깅 편의성 ◎(내용이 파일로 남음) ○(캡처 가능) △(HTTP/2+바이너리) △(증상이 화려함)
처리량/지연시간 낮음 중간~높음 중간 중간 중간

보충 설명 하나. 「◎가 많은 수단」을 고르는 것이 아니라, 요구사항에 영향을 주는 행만 보고 제외법으로 좁혀가는 것이 올바른 사용법입니다. 예를 들어 「초당 30프레임의 이미지」로 정해진 순간, 데이터 면은 공유 메모리가 유일한 선택입니다.

4. 정형 구성의 예

판단표를 개별 요구사항에 적용해 보면, 실무에서는 다음 3가지 구성으로 정리되는 경우가 많습니다.

구성 1: UI 앱 + Windows 서비스의 분리. 상주 처리나 특권이 필요한 처리를 서비스에 두고, UI는 일반 사용자 프로세스로 하는 구성입니다(서비스 쪽을 만드는 방법은 같은 날 공개된 《Windows 서비스 기사》를 참조하시기 바랍니다). 통신은 네임드 파이프가 본진이며, 프로토콜은 JSON 메시지+메시지 모드(또는 바이트 모드+length 프리픽스)로 시작하는 것이 견실하고, 호출 종류가 늘어나면 gRPC의 네임드 파이프 전송 방식으로 옮겨가는 방법도 있습니다.2 서비스는 별도 계정(LocalService 등)으로 동작하므로 CurrentUserOnly는 사용할 수 없고, PipeSecurity로 「Users에게 읽기/쓰기 허용, 원격은 거부」 같은 ACL을 명시하는 것이 포인트입니다.

구성 2: 32bit 앱 → 64bit 기능 브리지. 64bit에서만 동작하는 DLL・드라이버 SDK를 32bit의 기존 앱에서 사용하고 싶은 경우, 64bit 쪽을 별도 프로세스로 분리합니다. 구현은 두 가지로, (a) 64bit 헬퍼 프로세스를 세워 네임드 파이프로 대화하는 방법, (b) 64bit 아웃프로세스 COM 서버로 만드는 방법입니다. 호출하는 쪽이 VBA나 오래된 언어라면 (b)가 자연스럽고, C# 사이라면 (a) 쪽이 배포와 조사 모두 더 편합니다. (a)의 경우, 헬퍼 프로세스의 기동・감시・부모-자식 연동 종료는 《자식 프로세스를 안전하게 다루기》에서 다룬 Job Object 패턴을 그대로 사용하시기 바랍니다.

구성 3: 측정 엔진 → 표시용 고빈도 데이터. 계측・이미지 처리 엔진을 별도 프로세스로 만들어 UI로 데이터를 흘려보내는 구성에서는, 제어(시작・정지・설정)는 네임드 파이프, 데이터(프레임・파형)는 공유 메모리+네임드 이벤트라는 2채널 구성이 정형입니다. 제어는 초당 몇 번뿐이므로 파이프로 충분하고, 데이터는 공유 메모리로 제로카피에 가깝게 만듭니다. 채널을 나눔으로써 데이터 쪽 설계(링 버퍼 등)를 제어의 사정과 분리해 최적화할 수 있습니다. 엔진이나 외부 기기의 상태 표시는 《외부 기기의 상태 표시》도 참조하시기 바랍니다.

5. 구현 예 ── 네임드 파이프의 비동기 서버와 클라이언트

네임드 파이프 구현의 토대가 되는, .NET 8에서의 최소 구성을 보여드립니다. 메시지 모드・다중 클라이언트 대응・연결 해제 처리까지 포함한 형태입니다. 프로토콜은 「UTF-8 JSON을 메시지 1건당 1통」으로 하고, 반드시 version 필드를 갖도록 합니다. 아래 예시는 같은 사용자의 프로세스 간 통신을 전제로 CurrentUserOnly를 사용하고 있습니다. 구성 1처럼 서비스(다른 계정)와 통신하는 경우에는 뒤에서 설명하는 대로 CurrentUserOnly를 빼고 PipeSecurity로 ACL을 명시하시기 바랍니다.

먼저 서버 쪽입니다. 접속을 받을 때마다 새로운 서버 스트림을 다시 만들고, 클라이언트별 처리는 태스크로 분리합니다.

using System.IO.Pipes;
using System.Text.Json;

public sealed class PipeServer(string pipeName)
{
    // camelCase・대소문자를 구분하지 않는 Web 기본값입니다. System.Text.Json의 기본값은
    // 대소문자를 구분하므로, 이를 공유하지 않으면 "version"이
    // Request.Version에 바인딩되지 않아 정상적인 요청이 unknown_type으로 떨어집니다
    internal static readonly JsonSerializerOptions JsonOptions =
        new(JsonSerializerDefaults.Web);

    public async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var pipe = new NamedPipeServerStream(
                pipeName,
                PipeDirection.InOut,
                NamedPipeServerStream.MaxAllowedServerInstances,
                PipeTransmissionMode.Message,   // 쓰기 1회 = 메시지 1개
                PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

            await pipe.WaitForConnectionAsync(ct);
            _ = Task.Run(() => HandleClientAsync(pipe, ct), ct);  // 접속마다 분리
        }
    }

    // 메시지 1개의 상한. 상대가 거대한 메시지를 보내와도
    // 서비스 쪽 메모리를 지킵니다(IPC의 상대도 외부 입력. 제6장)
    private const int MaxMessageBytes = 1024 * 1024;

    private static async Task HandleClientAsync(
        NamedPipeServerStream pipe, CancellationToken ct)
    {
        await using (pipe)
        {
            var buffer = new byte[64 * 1024];
            try
            {
                while (!ct.IsCancellationRequested)
                {
                    // 메시지 모드에서도 한 번의 Read로 메시지 하나 전체를
                    // 읽을 수 있다고 단정할 수 없습니다. IsMessageComplete까지 다 읽습니다
                    using var ms = new MemoryStream();
                    do
                    {
                        int n = await pipe.ReadAsync(buffer, ct);
                        if (n == 0) return;  // 클라이언트가 연결을 끊었습니다
                        ms.Write(buffer, 0, n);
                        if (ms.Length > MaxMessageBytes) return;  // 상한 초과. 연결을 끊습니다
                    } while (!pipe.IsMessageComplete);

                    byte[] response = Dispatch(ms.ToArray());
                    await pipe.WriteAsync(response, ct);
                }
            }
            catch (IOException)
            {
                // 이 클라이언트와의 통신 경로만 끊어졌을 뿐입니다.
                // 서버 전체는 멈추지 않고 다른 접속의 처리를 계속합니다
            }
        }
    }

    private static byte[] Dispatch(byte[] payload)
    {
        Request? req;
        try { req = JsonSerializer.Deserialize<Request>(payload, JsonOptions); }
        catch (JsonException) { req = null; }

        // 형식 오류・알 수 없는 버전・알 수 없는 type은 여기서 검사해 거부합니다
        object result = req switch
        {
            null => new { version = 1, error = "bad_request" },
            // version 미지정(0으로 바인딩됨)이나 알 수 없는 버전은 여기서 거부합니다
            { Version: not 1 } => new { version = 1, error = "version_unsupported" },
            { Type: "getStatus" } => new { version = 1, running = true },
            { Type: "startJob" } => new { version = 1, jobId = StartJob(req) },
            _ => new { version = 1, error = "unknown_type" },
        };
        return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
    }
}

public sealed record Request(int Version, string Type, JsonElement? Body);

클라이언트 쪽은 「접속 → 요청 → 응답」을 하나의 메서드로 닫아 두고, 타임아웃과 재시도를 처음부터 넣어 둡니다.

using System.IO.Pipes;
using System.Text.Json;

public sealed class PipeClient(string pipeName)
{
    // idempotent: 이 요청이 두 번 도착해도 안전하다고 호출 측이 선언했을 때만
    // 재시도합니다. 기본값은 「재시도하지 않음」입니다
    public async Task<TResponse?> RequestAsync<TResponse>(
        object request, CancellationToken ct, bool idempotent = false)
    {
        for (int attempt = 1; ; attempt++)
        {
            // 접속뿐 아니라 요청→응답 교환 전체에 상한 시간을 둡니다.
            // 서버가 접속만 받고 응답을 쓰지 않은 채 멈춰 버린 경우의
            // 무한 대기를 방지합니다
            using var deadline =
                CancellationTokenSource.CreateLinkedTokenSource(ct);
            deadline.CancelAfter(TimeSpan.FromSeconds(10));
            try
            {
                using var pipe = new NamedPipeClientStream(
                    ".", pipeName, PipeDirection.InOut,
                    PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                // 무한히 기다리지 않습니다. 서버가 기동되어 있지 않으면 예외가 발생합니다
                await pipe.ConnectAsync(timeout: 3000, deadline.Token);
                pipe.ReadMode = PipeTransmissionMode.Message;

                await pipe.WriteAsync(
                    JsonSerializer.SerializeToUtf8Bytes(
                        request, PipeServer.JsonOptions),
                    deadline.Token);

                using var ms = new MemoryStream();
                var buffer = new byte[64 * 1024];
                do
                {
                    int n = await pipe.ReadAsync(buffer, deadline.Token);
                    if (n == 0) throw new IOException("서버가 연결을 끊었습니다.");
                    ms.Write(buffer, 0, n);
                } while (!pipe.IsMessageComplete);

                return JsonSerializer.Deserialize<TResponse>(
                    ms.ToArray(), PipeServer.JsonOptions);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                throw;  // 호출 측의 취소입니다. 재시도하지 않습니다
            }
            catch (Exception ex) when (
                ex is IOException or TimeoutException or OperationCanceledException
                && idempotent && attempt < 3)
            {
                // 서버 재기동 중 등의 일시적인 실패를 재시도합니다.
                // 「보냈지만 응답을 읽기 전에 끊긴」 경우에는 요청이 이미 실행되었을
                // 가능성이 있으므로, startJob처럼 부작용이 있는 요청은
                // 기본값대로 재시도하지 않고, 요청 ID로 중복을 걸러낼 수 있도록 설계한 뒤에
                // idempotent: true를 전달합니다(제6장)
                await Task.Delay(500 * attempt, ct);
            }
        }
    }
}

이 코드의 설계 판단을 3가지만 보충하겠습니다.

  • version을 첫 릴리스부터 넣습니다. UI와 서비스는 각자 따로 업데이트되는(한쪽만 새로워지는) 순간이 반드시 옵니다. 응답 측이 「모르는 버전은 명시적으로 거부한다」는 것만으로, 조용히 이상하게 동작하는 사고가 「알 수 있는 오류」로 바뀝니다.
  • 접속을 매번 새로 만들지, 상시 접속으로 할지를 정합니다. 위 예시는 요청마다 접속하는 일회용 방식으로, 연결 해제・재접속의 상태 관리가 필요 없는 대신 고빈도 호출에는 적합하지 않습니다. 상시 접속+서버로부터의 푸시 알림이 필요해지면, 그 복잡성은 gRPC의 양방향 스트리밍으로 옮겨갈 판단 재료가 됩니다.
  • 서비스와 통신하는 경우에는 CurrentUserOnly를 빼고 PipeSecurity를 설계합니다. 위 예시는 같은 사용자의 프로세스 사이를 가정하고 있습니다. 상대가 다른 계정의 서비스라면 NamedPipeServerStreamAcl.Create로 ACL이 붙은 서버 스트림을 만들고, 접속을 허용할 그룹을 명시하시기 바랍니다.6

6. 프로토콜 설계의 공통 주의점 ── 수단을 고른 다음의 작업

어떤 IPC를 선택하더라도, 통신 내용의 설계에는 공통의 과제가 있습니다. 수단을 고르는 것보다 사실은 이쪽이 사고의 원천입니다.

  • 메시지의 구분(프레이밍): 파이프의 메시지 모드처럼 OS가 구분해 주는 경우를 제외하면, 「어디부터 어디까지가 메시지 1개인가」는 앱의 책임입니다. 공유 메모리의 레코드 경계도 같은 문제입니다. length 프리픽스 방식을 기본으로 하시기 바랍니다.
  • 버저닝: 메시지에 형식 버전을 포함시키고, 수신 측은 「모르는 버전을 명시적으로 거부」합니다. 두 프로세스가 항상 동시에 업데이트된다는 보장은, 같은 설치 프로그램으로 배포하더라도 없습니다.
  • 타임아웃: 「상대가 응답하지 않는다」는 반드시 일어나는 일로 간주하고 설계합니다. 접속・요청・응답 각각에 상한 시간을 정하고, 무한 대기 코드를 남기지 않을 것. UI 스레드에서 동기적으로 기다리는 것은 논외입니다.
  • 재접속과 멱등성: 재시도한다는 것은 같은 요청이 두 번 도착할 가능성을 만든다는 뜻입니다. 「두 번 실행해도 안전한 요청인가」를 요청 종류별로 분류하고, 안전하지 않은 것(작업 투입 등)에는 요청 ID를 붙여 중복을 걸러냅니다.
  • 상대도 외부 입력으로 검증한다: 같은 컴퓨터 안의 통신이면 「상대는 내 앱이니까」라며 검증을 생략하기 쉽지만, 권한 경계가 있는 경우 IPC의 상대는 네트워크로부터의 입력과 같은 「신뢰할 수 없는 입력」입니다. 관리자 권한의 브로커나 서비스는 표준 권한 클라이언트로부터 받은 경로나 명령을 그대로 실행해서는 안 됩니다. 파이프 이름 선점(2.2절)도 포함해서, 「누가」「무엇을」 양쪽을 의심하는 것이 권한 경계를 넘는 IPC의 작법입니다.
  • 관측 수단을 남긴다: 통신 로그(최소한 메시지 종류와 상대・결과)를 남길 수 있게 해 두면, 「연결이 안 된다」「응답이 안 온다」류의 조사 시간이 자릿수로 달라집니다.

7. 정리

프로세스 간 통신의 선택은 다음 순서로 생각하면 거의 헤매지 않습니다. 먼저 같은 컴퓨터로 한정해도 되는가. 한정해도 된다면 요청-응답은 네임드 파이프, 대용량 고빈도 데이터만 공유 메모리, 느슨한 결합의 배치는 파일 연동. 컴퓨터를 넘거나 언어 혼재가 보인다면 로컬 TCP나 gRPC로, 자체 프로토콜 관리가 부담스러운 규모라면 gRPC. COM은 신규의 1순위로 삼지 않고 32/64 브리지와 VBA 연동의 도구로 남겨 둔다──이 글의 판단표는 이 흐름을 표로 만든 것입니다.

그리고 수단이 정해졌다면, 제6장의 공통 설계(프레이밍・버전・타임아웃・재접속・상대 검증)를 첫 릴리스에 넣을 것. IPC 문제는 「수단 선택의 실수」보다 「프로토콜 설계를 생략한」 것이 원인인 경우가 압도적으로 많다는 것이 실무에서의 체감입니다. 프로세스 분할이나 통신 방식의 재검토는, 먼저 「어느 프로세스가・누구의 권한으로・무엇을・어느 빈도로 주고받는가」를 점검하는 것부터 시작하시기를 권장합니다.

관련 기사

관련 상담 분야

합동회사 코무라소프트는 프로세스 분할・통신 방식 설계 검토, 32bit/64bit 브리지를 포함한 기존 자산과의 연동 설계, 「연결이 안 된다・응답이 안 온다」류의 통신 문제 원인 조사를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, How to: Use Named Pipes for Network Interprocess Communication. NamedPipeServerStream / NamedPipeClientStream을 이용한 다중 클라이언트 대응 서버・클라이언트 구현 예시에 대해. 

  2. Microsoft Learn, Inter-process communication with gRPC and Named pipes. .NET 8 이후 Kestrel의 ListenNamedPipe로 gRPC를 네임드 파이프 위에서 동작시키는 구성과 PipeSecurity에 의한 접근 제어에 대해.  2 3

  3. Microsoft Learn, MemoryMappedFile Class. 메모리 매핑 파일의 .NET API. CreateNew로 만드는, 파일에 연결되지 않은 공유 메모리가 IPC 용도에 적합하다는 점에 대해.  2

  4. Microsoft Learn, Interprocess communications. Windows가 제공하는 IPC 메커니즘(클립보드, COM, WM_COPYDATA, DDE, 파일 매핑, 메일슬롯, 파이프, RPC, Windows 소켓)의 목록과 선택 기준에 대해.  2 3

  5. Microsoft Learn, NamedPipeServerStream Class. 네임드 파이프의 서버 측 스트림. PipeTransmissionMode나 PipeSecurity를 지정하는 생성자에 대해. 

  6. Microsoft Learn, Named Pipe Security and Access Rights. 네임드 파이프의 기본 보안 기술자가 Everyone과 익명 계정에 읽기를 허용한다는 점과 접근 권한의 구성에 대해.  2

  7. Microsoft Learn, PipeOptions Enum. CurrentUserOnly가 「같은 사용자가 만든 상대」와의 접속만 허용하며, Windows에서는 사용자 계정뿐 아니라 상승 레벨까지 검증한다는 점에 대해.  2

  8. Microsoft Learn, Inter-process communication with gRPC. gRPC를 IPC에 사용할 때의 전송 계층(Unix 도메인 소켓, 네임드 파이프)과 서버 소유자 검증・위장(impersonation) 대책 등의 보안 고려사항에 대해.  2

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

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

ActiveX 이관

COM / ActiveX / OCX 자산을 유지할지, 감쌀지, 교체할지의 단계적 판단을 정리한 토픽 페이지입니다.

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

자주 묻는 질문

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

Windows의 프로세스 간 통신은 무엇을 1순위로 삼아야 하나요?
같은 컴퓨터 안에서 이루어지는 요청-응답(명령을 보내고 결과를 받는) 방식이라면 네임드 파이프가 1순위입니다. OS 표준 기능이라 포트 번호 관리가 필요 없고 방화벽에도 걸리지 않으며, Windows의 접근 제어(PipeSecurity)와 통합되어 있어 .NET에서는 System.IO.Pipes로 간단히 구현할 수 있습니다. 앞으로 네트워크를 넘어갈 가능성이 있다면 처음부터 로컬 TCP나 gRPC를, 대용량・고빈도 데이터(이미지 프레임, 파형)만 다룬다면 공유 메모리를, 느슨한 결합・비동기 방식으로 감사 로그를 남기고 싶은 연동이라면 파일 연동을 선택하는 식으로, 제외법으로 좁혀가는 것이 올바른 선택 방법입니다.
gRPC를 프로세스 간 통신에 써야 하는 경우는 언제인가요?
서비스나 호출의 종류가 늘어나서 자체 프로토콜의 switch 문과 문서 유지가 부담스러워졌을 때입니다. proto 파일을 통한 스키마 정의와 코드 생성, 양방향 스트리밍을 얻을 수 있고, .NET 8 이후로는 ASP.NET Core(Kestrel)가 네임드 파이프를 전송 계층으로 직접 지원합니다. 반면 명령이 2~3종류밖에 없는 도구 사이에는 ASP.NET Core의 호스팅 기반을 짊어지는 만큼 과한 선택이 되기 쉬워, 네임드 파이프+JSON 쪽이 총비용이 더 낮습니다. proto에 적어야 할 메시지가 10개를 넘었다, 스트리밍 알림이 본질적으로 필요하다, 상대가 .NET 이외의 언어라는 조건 중 하나에 해당하게 된 뒤에 도입해도 늦지 않습니다.
32bit 앱에서 64bit DLL을 사용하려면 어떻게 해야 하나요?
32bit 프로세스에는 64bit DLL을 같은 프로세스에 로드할 수 없으므로, 64bit 쪽을 별도 프로세스로 분리합니다. 구현 방법은 두 가지로, (a) 64bit 헬퍼 프로세스를 띄워 네임드 파이프로 통신하는 방법과, (b) 64bit 아웃프로세스 COM 서버로 만드는 방법입니다. 호출하는 쪽이 VBA나 오래된 언어라면 (b)가 자연스러운데, COM 기반이 마셜링을 대신 처리해 주므로 호출 측 코드를 거의 바꾸지 않아도 됩니다. C# 사이라면 (a) 쪽이 배포와 조사 모두 더 편합니다. (a)의 경우에는 Job Object로 헬퍼 프로세스의 부모-자식 연동 종료를 설계합니다.
공유 메모리는 어떤 상황에서 사용해야 하나요?
이미지 프레임이나 파형 데이터처럼 대용량・고빈도 데이터로 한정해서 사용합니다. 복사나 직렬화가 개입하지 않는, 같은 컴퓨터 안에서 가장 빠른 IPC이지만 동기화는 1바이트도 제공되지 않으므로, 쓰는 중인 데이터를 읽지 않는 장치, 상대의 생사 감지, 비정상 종료 후 복구를 모두 직접 설계해야 합니다. 실무에서는 데이터 면(data plane)만 공유 메모리에 싣고, 시작・정지・설정 변경 같은 제어 면(control plane)은 네임드 파이프 같은 별도 채널로 분리하는 2채널 구성이 정형입니다. 제어 메시지까지 공유 메모리로 처리하려 하기 시작했다면 설계를 다시 검토해야 할 신호입니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기