Windows I/O 내부 구조(제1회) ── 모든 읽기·쓰기는 IRP가 된다: I/O 시스템의 전체 그림

· 업데이트: · · Windows, Win32, I/O, 커널, 디바이스 드라이버, .NET, CSharp, 장애 조사

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

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

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
기사 맨 앞에 「이 기사의 knowledge map」을 추가했습니다. 본문에서 다루는 개념 사이의 관계를 한 장의 그림으로 볼 수 있습니다. 관계 전체 목록(근거 URL·확신도·확인 일자 포함)과 주요 개념 정의는 knowledge map 상세 페이지에 모았고, 기계 가독 데이터는 JSON-LD와 Turtle로 공개합니다.
대역표에서 `ReadFile` 호출 1회를 「IRP 1개」라고 단정하던 부분을 고쳤습니다. 캐시에 올라 있는 파일에 대한 동기 읽기·쓰기는 Fast I/O로 끝나고, IRP는 만들어지지 않습니다(같은 기사의 5.2에서 쓴 그대로입니다). 이 표를 보고 Procmon 트레이스를 읽으면, 존재하지 않는 IRP를 찾게 됩니다.
Win32나 .NET에서 보이는 것과 커널 쪽 대응물을 나란히 둔 대역표를 추가했습니다(HANDLE과 file object에 대한 참조, `ReadFile` 1회와 IRP 1개, `GetLastError`와 NTSTATUS 등). 관찰 장에 필요한 것과 구하는 곳을 명시하고, 목적별 건너뛰기 가이드와 선수 지식을 맨 앞에 두었습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22175238)

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

Go Komura (2026). 「Windows I/O 내부 구조(제1회) ── 모든 읽기·쓰기는 IRP가 된다: I/O 시스템의 전체 그림」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-io-internals-architecture-irp/

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

File.ReadAllText 한 줄, 또는 ReadFile 한 번의 호출. 이 함수가 돌아올 때까지 Windows 안에서는 무엇이 일어날까요.

장애 조사에서 Process Monitor를 열면 IRP_MJ_READFASTIO_READ처럼 낯선 말이 줄지어 나옵니다. handle leak을 쫓다 보면, CloseHandle을 호출했을 파일이 아직 살아 있습니다. 「network drive에서는 동작이 다르다」, 「백신 소프트웨어를 넣은 환경에서만 느리다」──업무 앱 현장에서 반복해서 만나는 이런 현상은, 모두 Windows I/O 시스템이라는 같은 바닥 위에서 일어납니다.

이 기사부터 그 바닥을 밑에서부터 파는 연재 「Windows I/O 내부 구조」를 시작합니다. 명저 『Windows Internals』가 커널 설계까지 내려가 설명하듯, 이 연재도 「API 사용법」이 아니라 「왜 그렇게 동작하는가」를 다룹니다. 1 예정 구성은 다음과 같습니다.

  1. I/O 시스템의 전체 그림 ── 모든 읽기·쓰기는 IRP가 된다(이 기사)
  2. 동기 I/O와 비동기 I/O ── OVERLAPPED의 진짜 의미
  3. I/O completion port(IOCP)와 .NET thread pool ── async/await의 지하실
  4. Cache Manager ── 당신의 WriteFile은 언제 디스크에 도착하는가
  5. NTFS 내부 구조 ── MFT부터 이해하는 file system
  6. filter driver와 minifilter ── Procmon과 바이러스 검사가 I/O에 끼어들 수 있는 이유

제1회인 이 기사는, 이후 모든 회의 토대가 되는 「등장 인물과 요청의 흐름」을 그림으로 잡습니다. driver를 작성하기 위한 기사가 아닙니다. 애플리케이션 개발자가, 자신이 호출한 API의 행선지를 끝까지 따라갈 수 있게 되기 위한 기사입니다.

선수 지식: C#에서 FileStream을 써 본 적이 있거나, C/C++에서 CreateFile/ReadFile을 호출해 본 적이 있으면 읽을 수 있습니다. driver 개발 경험은 필요 없고, 커널 쪽 코드는 개념도로만 나옵니다. 읽는 데 걸리는 시간은 그림을 보면서 통으로 약 25분, 1장의 결론만이면 2~3분입니다.

건너뛰기 가이드: 길기 때문에, 목적별 입구를 둡니다.

  • 전체 그림만 잡고 싶다 → 1장(결론)→ 2장(이름 해석)→ 5장(ReadFile 한 왕복)
  • 용어를 정리하고 싶다(driver/device/file object, IRP) → 3장·4장
  • 현장의 「깨지는 방식」을 알고 싶다(handle leak, 「파일이 사용 중」) → 6장
  • 직접 확인하고 싶다 → 7장

1. 먼저 결론

  • Windows I/O는 packet-driven입니다. device driver로 가는 요청의 대부분은 IRP(I/O Request Packet)라는 패킷에 담겨, driver 더미(device stack)를 위에서 아래로 흐릅니다. 23
  • CreateFile이 여는 대상이 「파일」이라고 단정할 수는 없습니다. 이름 해석은 Object Manager namespace에서 이루어지고, C:의 실체는 \\Device\\HarddiskVolume3 같은 NT device name으로의 symbolic link입니다(2장). 45
  • 등장 인물은 세 가지 object입니다. 처리 함수 표를 가진 「driver object」, 요청의 목적지가 되는 「device object」, 연 한 번의 상태를 가진 「file object」. 당신이 가진 HANDLE은 file object에 대한 참조입니다(3장). 6789
  • 각 driver의 선택지는 세 가지뿐입니다. IRP를 「직접 완료한다」, 「아래 driver로 넘긴다」, 「pending으로 두었다가 나중에 완료한다」. 이 세 가지 조합이 filter driver도 cache도 비동기 I/O도 전부 설명합니다(4장). 310
  • 커널 바닥에 「동기 I/O」라는 별도의 구조는 없습니다. 동기 I/O의 정체는 「완료되기 전에는 반환되지 않는다」는 보장이고, 요청이 pending이 되었을 때만 대기가 발생합니다. 여기가 제2회·제3회의 입구입니다(5장). 11
  • CloseHandle은 「닫기」가 아니라 「handle을 하나 반환하기」입니다. 마지막 handle이 닫히면 cleanup, 커널 안의 참조까지 모두 사라지면 close라는 2단계이고, 「닫았는데 사용 중」의 정체는 이 차이에 있습니다(6장). 1213
  • 이 계층은 직접 관찰할 수 있습니다. WinObj로 namespace를, Process Monitor로 IRP의 흐름을 볼 수 있습니다. Procmon의 표시 어휘는 이 기사의 용어 그대로입니다(7장). 14

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

2. 「모든 것이 파일처럼 보인다」의 정체

2.1. CreateFile에 넘긴 이름은 어디로 가는가

Win32 파일 API는 모두 「이름」에서 시작합니다. C:\project\report.csv 같은 path, \\server\share\data.csv 같은 UNC path, \\.\COM3 같은 device 지정──이들은 모두 같은 CreateFile에 넘길 수 있습니다. 15

이 일관성 뒤에는, 커널의 Object Manager가 관리하는 단일 namespace가 있습니다. 커널 안에서는 device도 event도 공유 메모리 section도, 모두 「object」로서 이 트리 모양 namespace에 등록되어 있습니다. Sysinternals의 WinObj를 쓰면, 이 namespace를 그대로 들여다볼 수 있습니다. 14

\\ (namespace의 루트)\\Device(driver가 만드는 device object)\\GLOBAL??(Win32에서 보이는 전역 이름의 보관 장소)\\BaseNamedObjects(이름이 있는 mutex 등)HarddiskVolume3Serial0Mup (network redirector)C: → \\Device\\HarddiskVolume3COM1 → \\Device\\Serial0PhysicalDrive0 → \\Device\\Harddisk0\\DR0

그림 1: Object Manager namespace(발췌). \\GLOBAL?? 아래에 있는 것은 symbolic link이고, 실체는 \\Device 아래에 있습니다

핵심은, Win32 앱이 쓰는 이름(drive letter나 COM port 이름)과, 커널이 쓰는 이름(NT device name)이 다른 계층에 있다는 점입니다. 둘을 잇는 것이 symbolic link입니다.

2.2. drive letter는 symbolic link이다

driver는 자신의 device를 Win32 앱에서 보이게 하고 싶을 때, IoCreateSymbolicLink\\DosDevices\\COM1 같은 MS-DOS device name에서 NT device name으로의 symbolic link를 만듭니다. 4 C:도 같은 구조이고, 실체는 \\Device\\HarddiskVolume3 같은 volume device로의 링크입니다.

그래서 CreateFile("C:\\project\\report.csv")의 이름 해석은 이렇게 진행됩니다.

앱이 넘긴 이름C:\\project\\report.csvWin32 계층이 NT 형식으로 변환\\??\\C:\\project\\report.csvObject Manager가 namespace를 검색\\??\\C: 가 symbolic link임을 알아낸다링크를 따라가 치환\\Device\\HarddiskVolume3\\project\\report.csv\\Device\\HarddiskVolume3 에서volume의 device object에 도달남은 \\project\\report.csv 의 해석은I/O Manager가 IRP_MJ_CREATE를 발행해file system driver(NTFS)에 맡긴다

그림 2: CreateFile의 이름 해석. 전반은 Object Manager, device에 도달한 후반은 file system의 일

이 그림에서 평소의 의문이 몇 가지 풀립니다.

  • \\\\.\\ prefix의 의미. \\\\.\\PhysicalDrive0이나 \\\\.\\COM10\\\\.\\는 Win32 device namespace(≒ \\?? directory)를 직접 가리키기 위한 표기입니다. drive letter를 거치지 않고, 링크가 놓인 장소를 직접 지정합니다. 515
  • CON이나 NUL을 파일 이름으로 쓸 수 없는 이유. 이들은 MS-DOS device name으로 예약되어 있어, path 어디에 적어도 device 쪽으로 해석될 수 있기 때문입니다. 5 이 부근의 실무적 함정은 「MAX_PATH와 Windows path·파일 이름의 함정」에 정리해 두었습니다.
  • UNC path도 특별 취급이 아닙니다. \\\\server\\share는 network redirector의 device(\\Device\\Mup)로 해석되고, 그 이후는 SMB client가 네트워크 너머로 요청을 나릅니다. 로컬과 UNC에서 동작이 달라지는 문제의 뿌리는 해석 대상 device가 다르다는 점에 있습니다(「network drive와 UNC path의 함정」 참조).

정확한 표현을 보태면, \\??는 실제로 존재하는 directory의 별명이 아니라, 「먼저 로그온 세션마다의 로컬 DOS device map을 보고, 없으면 \\GLOBAL??로 떨어진다」는 검색 순서를 나타내는 가상의 입구입니다. net usesubst로 만든 drive letter는 이 로컬 쪽에 들어가므로, 같은 PC라도 사용자(로그온 세션)마다 보이는 drive가 다르고, 서비스에서는 사용자의 network drive가 보이지 않는 현상이 일어납니다. 그림 1에 그린 것은 전역 쪽(\\GLOBAL??)뿐입니다.

즉 「Windows에서는 모든 것이 파일처럼 보인다」의 정확한 말은, 「모든 이름이 최종적으로 device object로 해석되고, 그 이후 요청이 같은 형식(IRP)으로 통일되어 있다」입니다. 그렇다면 그 device object는 무엇인가. 등장 인물을 정리합니다.

3. 등장 인물은 세 가지 object

Windows I/O 시스템의 구조는, 세 종류의 커널 object의 관계로 그릴 수 있습니다.

여기서부터 전문 용어가 이어집니다. 길을 잃지 않도록, 먼저 「평소 작성하는 코드에서 보이는 것」과 「커널 쪽 이름」의 대역표를 둡니다. 3장과 4장은 오른쪽 열의 말로 쓰여 있습니다.

Win32 / .NET 쪽에서 보이는 것 커널 쪽 대응물 한 줄로 말하면
HANDLE / SafeFileHandle handle table의 항목(file object에 대한 참조) 「연 한 번」을 가리키는 번호표(3.3)
FileStream이 쥐고 있는 연 상태 file object 공유 모드·flag·현재 위치의 그릇(3.3)
drive letter C:\\\\.\\COM3 device object(로의 이름 해석 결과) 요청의 목적지(2장·3.2)
「NTFS driver」「disk driver」 driver object 요청 종류마다의 처리 함수 표(3.1)
ReadFile / stream.Read 호출 1회 보통은 IRP 1개 목적지로 운반되는 요청 패킷(4장). 캐시에 올라 있는 파일에 대한 동기 읽기·쓰기는 Fast I/O라는 지름길로 IRP를 만들지 않고 끝냅니다(5.2). Procmon에서 FASTIO_로 시작하는 행이 그것입니다
FileOptions / CreateFile의 flag file object에 기록되는 속성 미리 읽기나 비동기 동작을 정합니다(7장의 대응표)
GetLastError의 값 / .NET 예외 NTSTATUS(STATUS_PENDING 등) 완료 상태. Win32 오류 코드로 번역되어 올라옵니다

3.1. driver object ── 처리 함수의 표

driver가 로드되면, I/O Manager는 그 driver를 나타내는 driver object(DRIVER_OBJECT)를 만듭니다. 6 앱 개발자 관점에서 가장 중요한 멤버는 MajorFunction 배열입니다. 이것은 「요청 종류 → 처리 함수」의 대응표이고, 요청 종류는 IRP_MJ_CREATE(열기), IRP_MJ_READ(읽기), IRP_MJ_WRITE(쓰기), IRP_MJ_CLEANUP, IRP_MJ_CLOSE 같은 major function code로 나타냅니다. 16

C#으로 억지로 쓰면, 이런 이미지입니다.

// 개념도. 실제는 커널 안의 C 구조체
class DriverObject
{
    // IRP_MJ_XXX 가 인덱스. 전부 28종류
    public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// ntfs.sys 라면 MajorFunction[IRP_MJ_READ] 에 「NTFS의 읽기 처리」가 들어 있다

3.2. device object ── 요청의 목적지

driver는 자신이 맡은 device마다 device object(DEVICE_OBJECT)를 만듭니다. 7 이것이 I/O 요청의 목적지입니다. 물리 device와 1대1이라고 단정할 수는 없고, volume(HarddiskVolume3) 같은 논리적인 존재나, filter driver가 만드는 「끼어들기만을 위한 device」도 있습니다. device object는 자신을 만든 driver object를 가리키므로, 목적지가 정해지면 처리 함수의 표도 정해집니다.

3.3. file object ── 「연 한 번」의 상태

CreateFile이 성공할 때마다, 커널은 file object를 하나 만듭니다. 이것은 「디스크 위의 파일」 그 자체가 아니라, 그 파일(또는 device)을 연 한 번의 세션을 나타내는 object입니다. 8 같은 파일을 두 번 열면, file object는 두 개 생깁니다. 현재 file pointer(동기 handle인 경우), 열 때의 공유 모드나 flag는 여기에 들어 있습니다.

그리고 앱이 받는 HANDLE은, 프로세스마다의 handle table을 거친, file object에 대한 참조입니다. 9

커널 공간프로세스(user mode)file object 1report.csv를 읽기로 연 상태현재 offset: 4096file object 2report.csv를 이어 쓰기로 연 상태현재 offset: 65536device objectHarddiskVolume3 상당driver object NTFSMajorFunction = 처리 함수의 표HANDLE 0x1A4HANDLE 0x1B8

그림 3: 세 가지 object의 관계. handle은 handle table을 거쳐 file object를 가리키고, file object는 device로, device는 driver로 이어집니다

이 그림을 머리에 넣으면, 몇 가지 실무 지식이 「암기」에서 「당연」으로 바뀝니다.

  • handle leak의 정체는, 계속 참조되어 해제되지 못하는 file object(와 그 앞의 리소스)의 더미입니다. 세어야 할 것은 handle table의 항목이고, Process Explorer나 handle.exe가 보여주는 것이 바로 이것입니다(「Process Explorer / Handle / VMMap 실전」, 조사기로서는 「산업용 카메라 장기 가동 크래시 조사 - handle leak편」).
  • 같은 파일을 연 두 handle에서 file pointer가 독립되어 있는 것은, pointer가 file object 쪽에 있기 때문입니다. 반대로 DuplicateHandle로 복제한 handle은 같은 file object를 가리키므로, pointer를 공유합니다.
  • 공유 위반(sharing violation)은, 기존 file object들의 공유 모드와 새로운 CreateFile 요청을 커널이 맞춰 보고 판정합니다. 배타 제어의 실무는 「파일 연동의 배타 제어 기초 지식」에서 다루었습니다.

4. IRP ── I/O 요청은 소포가 된다

4.1. 왜 패킷으로 만드는가

I/O Manager는 앱으로부터의 요청(열기·읽기·쓰기…)을 받으면, IRP(I/O Request Packet)라는 패킷에 담아 driver에 넘깁니다. device driver로의 요청의 대부분은 IRP로 도착합니다. 3 device는 OS와 속도가 맞지 않으므로(디스크는 CPU보다 몇 자릿수나 느림), 요청을 「호출」이 아니라 「짐」으로 만들어, 발행과 완료를 떼어낼 수 있는 형태로 한 것입니다. 2

IRP에는 요청의 전체 정보를 가진 헤더에 더해, I/O stack location이라는 영역이 경유 예정 driver 수만큼 늘어서 있습니다. 각 driver는 자기용 stack location에서 「자신에게 향한 지시」(major function code, parameter)를 읽습니다. 17

4.2. device stack을 내려간다

device object는 쌓여 device stack을 만듭니다. 18 예를 들어 로컬 디스크 위 파일에 대한 읽기는, 대략 다음 길을 지납니다.

storage 쪽 stackfile system 쪽 stackvolume/partition 관리(volmgr 등)disk class driver(disk.sys)storage port/miniport(storport 등)file system filter(백신·암호화·Procmon 등)NTFS파일 안 offset을 volume 위 위치로 변환I/O Manager가 IRP를 조립한다(IRP_MJ_READ+stack location)디스크 장치

그림 4: 읽기 요청이 지나는 길. file system 쪽과 storage 쪽은 별도의 device stack이고, NTFS는 storage 쪽을 향한 하위 IRP를 새로 발행해 일을 맡깁니다

이 그림에서 기억할 것은 두 가지입니다.

  1. filter는 정규 구성원입니다. 백신 소프트웨어가 모든 파일 I/O를 검사할 수 있는 것은 편법이 아니라, 이 「사이에 끼는」 구조가 OS의 공식 확장 지점이기 때문입니다. 19 Process Monitor도 같은 자리에 서서 모든 I/O를 기록합니다. 「그 환경에서만 파일 접근이 느리다」는 조사에서 먼저 의심해야 할 장소이기도 합니다(자세한 내용은 제6회).
  2. 계층마다 요청의 의미가 번역됩니다. 앱은 「이 파일의 offset 4096부터 8KB」라고 말하고, NTFS가 그것을 「이 volume의 이 cluster」로, storage stack이 「이 디스크의 이 sector」로 번역합니다. 위 계층은 아래 계층의 사정을 모릅니다. 여기서 중요한 보충이 하나 있습니다. 하나의 IRP가 앱에서 디스크까지 그대로 통과해 도착하는 것은 아니다는 점입니다. file system 쪽과 storage 쪽은 별도의 stack이고, NTFS는 파일 대상 IRP를 처리한 결과로서, volume(storage stack) 대상의 하위 IRP를 새로 만들어 발행합니다. 단편화된 파일이라면 하나의 읽기가 여러 하위 IRP로 나뉘기도 하고, 요청의 입도와 수명도 계층마다 달라집니다.

4.3. 각 driver의 세 가지 선택지

IRP를 받은 driver가 할 수 있는 일은, 본질적으로 세 가지뿐입니다. 310

아래 계층이 그 자리에서 완료했다아래 계층이 pending으로 두었다(pending은 호출자까지 전달된다)driver가 IRP를 받는다이 요청을 어떻게 다룰까(1) 직접 완료한다IoCompleteRequest 를 호출예: cache에 있는 데이터로 즉시 응답(2) 아래 device로 넘긴다IoCallDriver 를 호출예: filter가 검사하고 그대로 통과(3) pending으로 둔다STATUS_PENDING 을 반환하고 IRP를 queue에예: 하드웨어 응답 대기interrupt 등을 계기로나중에 IoCompleteRequest완료 처리가 stack을 역순으로 올라간다(각 계층의 completion routine이 호출된다)

그림 5: IRP를 받은 driver의 세 갈래. 셋은 배타가 아니라 「아래로 넘긴 곳에서 pending이 된다」가 가장 흔한 길이고, 모두 마지막은 IoCompleteRequest에 의한 완료로 끝납니다

이 세 가지는 배타적인 선택지가 아닙니다. 가장 흔한 길은 「(2)로 아래로 넘긴 곳에서, 어느 계층이 (3)의 pending을 고르는」 것이고, 그 경우 STATUS_PENDING은 사이의 driver에도 호출자에게도 그대로 전달됩니다. 나중에 아래 계층이 완료시키면, 넘길 때 completion routine을 등록해 둔 각 계층이 역순으로 다시 호출됩니다──즉 「넘기기」와 「pending」은 같은 하나의 IRP 위에서 겹쳐 일어납니다.

이 세 갈래가 Windows I/O 유연성의 원천입니다.

  • cache hit로 즉시 완료하면 빠릅니다(제4회의 Cache Manager).
  • 그대로 통과시키는 filter를 몇 장이든 끼울 수 있습니다(제6회의 minifilter).
  • pending으로 둘 수 있으므로, 느린 device를 기다리는 동안 thread를 붙잡아 두지 않아도 됩니다(제2회·제3회의 비동기 I/O).

연재의 나머지 전부가, 실은 이 그림의 각주입니다.

5. ReadFile 한 왕복을 따라간다

등장 인물과 도구가 갖춰졌으므로, ReadFile 1회의 왕복을 통으로 따라갑니다. cache를 벗어나 디스크까지 가는 경우입니다(cache에 오르는 이야기는 제4회에서).

디스크 장치storage stackfilter+NTFSI/O Manager앱의 thread디스크 장치storage stackfilter+NTFSI/O Manager앱의 threadhandle에서 file object를 해석하고IRP(IRP_MJ_READ)를 조립한다동기 I/O: 여기서 완료를 기다리며 잠든다비동기 I/O: 제어가 돌아와 다른 일을 할 수 있다interrupt 처리(ISR)에서DPC로 완료 처리를 계속필요한 하위 IRP가모두 완료되면각 계층의 completion routine을 역순으로 실행하고요청한 thread로의 APC로 결과를 확정ReadFile → NtReadFile(시스템 호출)IoCallDriver(stack의 선두로)위치를 volume 위로 번역하고storage stack 대상의 하위 IRP를 발행읽기 명령을 발행STATUS_PENDING(하위 IRP는 pending)원래 IRP도 pending인 채로 돌아온다(가는 길은 여기서 끝)interrupt 「데이터를 다 읽었다」하위 IRP를 완료(IoCompleteRequest)NTFS 쪽 completion routine이 받는다원래 IRP(IRP_MJ_READ)를 완료상태와 바이트 수가 확정(event 통지 등)

그림 6: cache를 벗어난 ReadFile의 한 왕복. 하위 IRP의 완료와 원래 IRP의 완료는 별도 단계이고, 「가는 길」과 「오는 길」도 별도의 이벤트로 진행됩니다

5.1. 가는 길과 오는 길은 별개의 사건

이 그림의 급소는 STATUS_PENDING 행입니다. storage driver는 하드웨어에 명령을 발행한 시점에서 일단 손을 놓고, 「가는 길」의 처리는 거기서 끝납니다. 데이터가 읽혀 올라왔다는 사실은, 나중에 interrupt라는 별도 이벤트로 통지되고, 거기서부터 「오는 길」의 완료 처리가 시작됩니다. 10

즉 커널 안에서는, I/O의 발행과 완료를 분리할 수 있게 만들어져 있습니다. 「동기 I/O」라는 별도의 배관이 있는 것이 아니라, 동기 I/O의 정확한 의미는 「완료되기 전에는 호출이 반환되지 않는다」는 보장입니다. 대기가 발생하는 것은 요청이 pending이 되었을 때뿐이고, driver가 그 자리에서 완료할 수 있는 요청(그림 5의 「완료한다」 길)이라면, 동기 I/O라도 thread는 한 번도 잠들지 않고 결과를 들고 돌아옵니다. Win32가 동기·비동기를 handle을 여는 방식(FILE_FLAG_OVERLAPPED)으로 바꾸는 것도, 비동기가 「특별한 추가 기능」이 아니라 기다리는 방식의 차이이기 때문입니다. 11

이 관점을 가지면, 제2회에서 다룰 의문──왜 비동기 여부는 호출마다가 아니라 handle을 열 때 정해지는가(OVERLAPPED 구조체 자체는 발행 중인 조작 하나마다 필요합니다), 「비동기여야 하는데 동기적으로 완료된다」는 무엇인가──가, 구조의 필연으로 보입니다. .NET의 async/await에서 I/O 대기가 thread를 소비하지 않는 이유(「C# async/await 실무 판단표」에서 실무 쪽에서 쓴 이야기)도, 근거는 이 그림입니다.

5.2. 예외도 있다 ── IRP를 만들지 않는 지름길

솔직히 덧붙이면, 모든 I/O가 IRP가 되는 것은 아닙니다. 캐시에 올라 있는 파일에 대한 동기 읽기·쓰기를 위해, file system은 Fast I/O라는 「IRP를 조립하지 않고 cache에서 직접 복사하는」 지름길을 제공합니다. Procmon의 Operation 열에서 FASTIO_로 시작하는 행이 그것입니다. 지름길을 쓸 수 없는 조건이나 Cache Manager와의 관계는 제4회에서 다룹니다.

6. CloseHandle의 이면 ── cleanup과 close는 별개

마지막으로, 연 것을 닫는 방식입니다. 여기는 handle leak 조사나 「파일이 사용 중」 문제에 직결됩니다.

CloseHandle이 하는 일은, 프로세스의 handle table에서 항목을 하나 지우는 것입니다. file object에는 handle count(handle의 수)와 reference count(커널 안으로부터의 참조 수)가 있고, 둘은 따로 줄어듭니다.

file systemI/O ManagerObject Managerfile systemI/O ManagerObject Managerhandle table에서 항목을 삭제handle count를 줄인다그 file object의미완료 I/O 취소나 lock 해제alt[그것이 마지막 handle이었다]다만 미완료 I/O나 section(memory map) 등커널 안의 참조가 남아 있으면file object는 아직 살아 있다file object의 뒷정리가 완료여기서 비로소 「닫기가 끝났다」alt[reference count도 0이 되었다]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

그림 7: cleanup(마지막 handle이 닫힘)과 close(참조도 모두 사라짐)의 2단계

  • IRP_MJ_CLEANUP은 「마지막 handle이 닫혔다」는 통지입니다. 다만 미완료 I/O가 남아 있으면, file object의 해제는 아직일 수 있다고 문서 자신이 명시합니다. 12
  • IRP_MJ_CLOSE는 「reference count가 0이 되었다」는 통지입니다. cleanup과 close 사이에는 틈이 있고, 즉시 이어진다고 단정할 수는 없습니다. 13

이 2단계를 알면, 현장의 이상한 현상을 설명할 수 있습니다.

  • memory-mapped file을 닫아도 파일이 해제되지 않는다. map된 section이 file object에 대한 참조를 계속 쥐고 있기 때문이고, view의 unmap과 section의 close가 끝날 때까지 close는 오지 않습니다. 공유 메모리 주변의 실무는 「공유 메모리의 함정과 실무 베스트 프랙티스」에서 다루었습니다.
  • 「사용 중인 파일」의 범인 찾기는 handle만으로 끝나지 않습니다. handle을 전부 닫아도, 커널 안의 참조(map된 이미지 등)가 파일을 붙잡고 있는 경우가 있습니다. Process Explorer의 검색이 handle과 DLL(map된 파일) 양쪽을 대상으로 하는 것은, 바로 이 이유입니다.
  • .NET의 SafeFileHandle이나 finalizer가 「언젠가 닫아 준다」에 기대면 안 되는 것도, 닫기를 잊은 handle이 cleanup을 늦추고, 공유 위반이나 lock 유지를 길게 끌기 때문입니다.

7. 직접 눈으로 확인한다

여기까지의 내용은, 관리자 권한이 있는 Windows PC가 한 대 있으면 전부 관찰할 수 있습니다.

필요한 것은 세 가지뿐입니다.

  • 관리자 권한. Process Monitor는 커널 driver를 로드하므로, 관리자로 실행해야 합니다. WinObj도, 관리자가 아니면 보이지 않는 object가 있습니다.
  • Sysinternals 도구. WinObj와 Process Monitor는 Microsoft가 무상으로 배포합니다. 개별 다운로드 페이지(WinObj, Process Monitor)에서 구하거나, 한꺼번에 넣으려면 Sysinternals Suite입니다. 설치 프로그램은 없고, 펼쳐서 exe를 실행하면 됩니다.
  • 관찰에 쓸 적당한 조작. 메모장으로 파일을 하나 저장하거나, 작은 파일을 하나 복사하는 정도면 충분합니다. 업무용 공유 폴더나 운영 장비가 아니라, 손안의 로컬 디스크에서 시험해 주세요.

WinObj로 namespace를 본다. Sysinternals의 WinObj를 실행하고, GLOBAL?? directory를 열면, C:\\Device\\HarddiskVolumeN으로의 symbolic link라는 것(그림 1)이 그대로 표시됩니다. \\Device 아래에서는, driver들이 만든 device object의 실명을 볼 수 있습니다. 14

Procmon으로 IRP의 말을 읽는다. Process Monitor 메뉴에서 Filter > Enable Advanced Output을 켜면, Operation 열이 ReadFile이 아니라 IRP_MJ_READFASTIO_READ라는 커널 쪽 어휘로 바뀝니다. 파일 복사 한 번만 따라가도, IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_MJ_CLOSE라는 이 기사의 흐름이 실제로 줄지어 나옵니다. Procmon의 실무적 사용법은 「Process Monitor(ProcMon) 실전 가이드」에 정리해 두었습니다.

.NET에서 바닥을 의식한다. C#의 FileStream은 내부에서 CreateFileW를 호출하고, SafeFileHandle로 handle을 유지합니다. 생성자의 FileOptions는, 거의 그대로 Win32 flag로의 직통입니다.

.NET (FileOptions) Win32 (CreateFile flag) 의미(관련 회)
Asynchronous FILE_FLAG_OVERLAPPED 비동기 I/O용으로 handle을 연다(제2회·제3회)
WriteThrough FILE_FLAG_WRITE_THROUGH 쓰기를 cache에서 멈추지 않는다(제4회)
SequentialScan FILE_FLAG_SEQUENTIAL_SCAN 미리 읽기 힌트(제4회)
RandomAccess FILE_FLAG_RANDOM_ACCESS 미리 읽기 억제 힌트(제4회)
DeleteOnClose FILE_FLAG_DELETE_ON_CLOSE 마지막 handle이 닫히면 삭제(6장 구조의 응용)

.NET 6 이후라면 File.OpenHandleRandomAccess 클래스로, FileStream의 추상을 끼우지 않고 「handle+offset 지정 I/O」라는 Win32의 날것에 가까운 쓰기도 가능합니다. 이 표의 오른쪽 의미를 알고 있으면, 왼쪽의 선택은 암기가 아니게 됩니다.

8. 정리

  • Windows I/O는, Object Manager의 namespace에서 목적지(device object)를 정하고, 요청을 IRP에 담아 device stack으로 흘리는, 한 줄기의 설계로 관통되어 있습니다. 2318
  • C:는 symbolic link, \\\\.\\는 이름이 놓인 장소의 직접 지정, UNC는 redirector로의 해석. 「모든 것이 파일처럼 보인다」의 정체는 이름 해석의 일관성입니다. 45
  • 등장 인물은 driver object(처리 함수의 표), device object(목적지), file object(연 한 번의 상태) 세 가지. HANDLE은 file object에 대한 참조입니다. 6789
  • driver의 선택지는 완료·통과·pending 세 가지. filter의 끼어들기도, cache의 즉시 응답도, 비동기 I/O도, 이 세 갈래의 응용입니다. 310
  • 발행과 완료는 분리할 수 있게 만들어져 있고, 동기 I/O는 「완료되기 전에는 반환되지 않는다」는 보장입니다. pending이 된 요청에서는, 가는 길(발행)과 오는 길(interrupt→완료)이 별도 이벤트로 진행됩니다. 1110
  • CloseHandle은 「handle을 반환하는」 일뿐입니다. cleanup(마지막 handle)과 close(마지막 참조)의 2단계를 알고 있으면, 「닫았는데 사용 중」은 수수께끼가 아니게 됩니다. 1213

이어지는 글은 제2회 「동기 I/O와 비동기 I/O ── OVERLAPPED의 진짜 의미」입니다. 이 기사에서 본 「발행과 완료의 분리」를 앱 쪽에서 쓰는 구조──FILE_FLAG_OVERLAPPED, 완료 통지의 4가지 방식, 취소, 그리고 「비동기여야 하는데 동기로 돌아온다」는 함정──을 팝니다.

관련 기사

관련 상담 영역

고무라소프트에서는 Windows 업무 앱의 파일 I/O 주변 설계·장애 조사(handle leak, 「파일이 사용 중」, 특정 환경에서의 I/O 지연 등)를 다룹니다.

참고 링크

  1. Microsoft Learn, Windows Internals - Sysinternals. 책 『Windows Internals』의 소개 페이지. Windows 커널 아키텍처와 I/O 시스템을 포함한 내부 구조를 다루는 정평 있는 책이며, 이 연재가 다루는 내용을 더 깊게 배우기 위한 출발점으로서. 

  2. Microsoft Learn, I/O manager. Windows 커널 모드 I/O Manager가 애플리케이션과 device driver가 제공하는 인터페이스 사이의 통신을 관리한다는 점, device는 OS와 일치하지 않는 속도로 동작하므로 OS와 driver 사이의 통신은 주로 IRP(I/O Request Packet)를 통해 이루어진다는 점, IRP가 네트워크 패킷이나 Windows 메시지와 비슷한 존재로서 OS에서 driver로, 또 driver에서 driver로 전달된다는 점에 대해.  2 3

  3. Microsoft Learn, I/O request packets. device driver로 가는 요청의 대부분이 IRP에 담긴다는 점, OS 구성 요소나 driver가 IoCallDriver(device object에 대한 포인터와 IRP에 대한 포인터를 받음)로 driver에 IRP를 보낸다는 점, IRP가 보통 device stack으로 쌓인 여러 driver에 의해 처리되고 먼저 stack 최상위 device object로 보내진다는 점, 각 driver가 IRP를 처리해 완료하거나 아래 driver로 전달할지를 고를 수 있다는 점에 대해.  2 3 4 5 6

  4. Microsoft Learn, Introduction to MS-DOS device names. MS-DOS device name이 NT 스타일 device name으로의 symbolic link라는 점, user mode Windows 앱은 MS-DOS device name(drive letter나 COM port 이름)으로 device에 접근하는 반면 driver나 커널은 NT 스타일 이름을 쓴다는 점, driver가 IoCreateSymbolicLink로 \DosDevices\이름 에서 device로의 symbolic link를 만든다는 점에 대해.  2 3

  5. Microsoft Learn, Naming files, paths, and namespaces. CON, PRN, AUX, NUL, COM1~COM9, LPT1~LPT9가 파일 이름으로 예약되어 있다는 점, Win32 namespace에 「file namespace」와 「device namespace」가 있고, “\\.\” prefix가 Win32 device namespace로의 접근을 의미한다는 점(예: \\.\PhysicalDrive0), 이들 이름 해석 규칙에 대해.  2 3 4

  6. Microsoft Learn, Introduction to driver objects. driver 로드 시 I/O Manager가 DRIVER_OBJECT 구조체를 만든다는 점, driver object가 driver의 표준 routine 묶음으로의 입구(dispatch table인 MajorFunction 배열을 포함)를 유지한다는 점, I/O Manager가 이 표를 써서 요청에 대응하는 처리 함수를 호출한다는 점에 대해.  2 3

  7. Microsoft Learn, Introduction to device objects. DEVICE_OBJECT 구조체가 논리·가상·물리 device를 나타내고 I/O 요청의 목적지(target)가 된다는 점, driver가 IoCreateDevice로 device object를 만든다는 점, device object가 자신을 만든 driver(driver object)와 묶여 있다는 점에 대해.  2 3

  8. Microsoft Learn, Using files in a driver. 커널에서 file object가 「열린 파일(또는 device)의 인스턴스」를 나타낸다는 점, 파일을 열 때마다 file object가 만들어지고, 열 때의 context(현재 바이트 offset 등)를 유지한다는 점에 대해.  2 3

  9. Microsoft Learn, File handles. CreateFile이 반환하는 file handle이 프로세스에 고유하고 열린 file object와 묶여 있다는 점, 같은 파일을 여러 번 열면 각각 별도 handle(과 연 상태)이 된다는 점, handle이 필요 없어지면 CloseHandle로 닫아야 한다는 점에 대해.  2 3

  10. Microsoft Learn, Completing IRPs. I/O 조작을 완료시키는 것은 IoCompleteRequest 호출이라는 점, 완료 시에는 stack 상위 driver가 등록한 IoCompletion routine이 차례로 호출된다는 점, 요청의 완료가 발행과는 다른 타이밍에 일어나고 최종적으로 요청자에게 상태가 반환되기까지의 흐름에 대해.  2 3 4 5

  11. Microsoft Learn, Synchronous and asynchronous I/O. 동기 I/O에서는 함수가 I/O 완료까지 반환되지 않고 thread가 기다리게 되는 반면, 비동기 I/O(overlapped I/O)에서는 요청을 발행한 함수가 바로 반환되어 thread가 다른 일을 계속할 수 있다는 점, 비동기 I/O에는 FILE_FLAG_OVERLAPPED를 지정해 handle을 열 필요가 있다는 점, 완료 통지를 받는 여러 방법이 있다는 점에 대해.  2 3

  12. Microsoft Learn, IRP_MJ_CLEANUP. 이 요청의 수신이 「대상 device object에 연관된 file object의 마지막 handle이 닫혔다」는 것을 나타낸다는 점, 다만 미처리 I/O 요청 때문에 file object의 해제는 아직일 수 있다는 점, 이 IRP가 handle을 닫은 프로세스의 context에서 보내진다는 점에 대해.  2 3

  13. Microsoft Learn, IRP_MJ_CLOSE. 이 요청의 수신이 「file object의 reference count가 0이 되어 file object가 해제되려 한다」는 것을 나타낸다는 점, cleanup 요청 뒤에 보내지지만 미처리 I/O의 완료를 기다리므로 즉시 이어진다고 단정할 수는 없다는 점에 대해.  2 3

  14. Microsoft Learn, WinObj - Sysinternals. WinObj가 NT Object Manager namespace를 표시하는 도구이며, device object나 symbolic link를 포함한 namespace 안의 object를 열람할 수 있다는 점에 대해.  2 3

  15. Microsoft Learn, CreateFileW function. CreateFile이 파일뿐 아니라 물리 디스크·volume·콘솔·통신 포트(COM port)·pipe 등의 device를 열어 handle을 반환할 수 있다는 점, device를 열 때 “\\.\” 형식의 이름을 쓴다는 점, FILE_FLAG_OVERLAPPED를 비롯한 각종 flag의 의미에 대해.  2

  16. Microsoft Learn, IRP major function codes. IRP의 major function code(IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_PNP 등) 목록과, 각 요청이 무엇을 의미하고 어느 driver가 대응해야 하는지에 대해. 

  17. Microsoft Learn, I/O stack locations. I/O Manager가 layered driver 체인의 driver마다 IRP 안에 I/O stack location을 마련한다는 점, 각 stack location에 major/minor function code와 그 요청의 parameter가 들어간다는 점, 각 driver가 IoGetCurrentIrpStackLocation으로 자기용 stack location을 얻어 요청 내용을 안다는 점에 대해. 

  18. Microsoft Learn, Device nodes and device stacks. device object가 쌓여 device stack을 구성한다는 점, IRP가 먼저 stack 최상위 device object로 보내지고 각 계층에서 처리 또는 하위로의 전달이 이루어진다는 점, filter driver의 device object가 stack에 끼는 형태로 존재한다는 점에 대해.  2

  19. Microsoft Learn, Filter Manager Concepts. Filter Manager가 Windows에 포함된 커널 모드 driver이고, minifilter driver가 file system으로의 I/O 요청에 대해 사전(pre)·사후(post) callback으로 끼어들 수 있다는 점, 각 minifilter의 끼어들 위치(altitude)에 따라 I/O stack 안의 순서가 정해진다는 점에 대해. 

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

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

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

자주 묻는 질문

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

IRP란 무엇입니까?
IRP(I/O Request Packet)는 Windows 커널의 I/O Manager가 애플리케이션의 읽기·쓰기 요청 등을 담아 device driver에 넘기기 위한 패킷입니다. Microsoft의 driver 개발 문서는, device driver로 가는 요청의 대부분이 IRP에 담긴다고 설명합니다. IRP는 요청 종류(생성·읽기·쓰기·cleanup 등)를 나타내는 major function code와, 거치는 driver마다의 stack location을 가지며, device stack을 위에서 아래로 전달되면서 처리됩니다. 각 driver는 IRP를 직접 완료하거나, 아래 driver로 넘기거나, pending으로 두었다가 나중에 완료합니다. 앱 개발자가 IRP를 직접 다루지는 않지만, Process Monitor의 Operation 열에 나오는 IRP_MJ_READ 같은 표기는 바로 이 구조입니다.
Windows에서는 파일도 시리얼 포트도 프린터도 같은 CreateFile로 여는 이유는 무엇입니까?
CreateFile에 넘긴 이름이 모두 Object Manager namespace에서 최종적으로 device object로 해석되고, I/O Manager가 그 device에 묶인 file object를 만들어 handle을 반환하는, 같은 경로를 따르기 때문입니다. C: 같은 drive letter의 실체는 \\Device\\HarddiskVolume3 같은 NT device name으로의 symbolic link이고, \\\\.\\COM1 같은 지정도 마찬가지로 시리얼 포트의 device object로 해석됩니다. 해석 대상이 어떤 device이든, 그 이후 요청은 모두 IRP라는 같은 형식에 담겨 driver에 도착하므로, 파일도 device도 같은 API로 열어 읽고 쓸 수 있습니다. UNC path도 같은 구조이고, network redirector의 device로 해석될 뿐입니다. 이 「namespace + packet」 설계가 Windows I/O 일관성의 정체입니다.
CloseHandle을 호출했는데도 파일이 바로 해제되지 않는 경우가 있는 이유는 무엇입니까?
CloseHandle이 하는 일은 「handle을 하나 반환하는 것」이지, 「파일을 닫는 것」이 아니기 때문입니다. 커널 안의 file object에는 handle 수(handle count)와, 커널 구성 요소로부터의 참조 수(reference count)라는 두 카운트가 있습니다. 마지막 handle이 닫히면 file system에는 IRP_MJ_CLEANUP이 전달되지만, 미완료 I/O나 memory-mapped file의 section처럼 커널 안의 참조가 남아 있는 동안은 file object 자체가 살아 있고, reference count가 0이 되어야 비로소 IRP_MJ_CLOSE가 전달됩니다. memory-mapped file을 사용한 뒤에 파일을 삭제하지 못하거나, 앱을 닫았는데도 「파일이 사용 중」이라고 나오는 현상은 상당수가 이 2단계 구조로 설명됩니다.
IRP나 device stack 지식은 앱 개발자에게 어떤 도움이 됩니까?
IRP를 직접 작성하지는 않더라도, 조사와 설계 양쪽에서 효과가 있습니다. 먼저 Process Monitor의 Operation 열(상세 출력을 켠 경우)은 IRP_MJ_CREATE나 IRP_MJ_READ 같은 IRP 용어 그대로 표시되므로, 이 계층의 말을 알면 로그를 읽을 수 있습니다. 다음으로, 백신 소프트웨어 같은 filter driver가 모든 파일 I/O 경로에 끼어 있다는 점을 알면, 「특정 환경에서만 파일 접근이 느리다」는 문제를 조사할 때 실마리를 잡을 수 있습니다. 더 나아가 Windows I/O가 발행과 완료를 분리할 수 있는 구조이고, 동기 I/O는 「완료되기 전에는 반환되지 않는다」는 보장에 지나지 않는다고 이해하면, 비동기 I/O나 I/O completion port, .NET의 async/await 동작을 구조에서부터 납득하고 쓸 수 있습니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기