MAX_PATH와 Windows 경로·파일 이름의 함정 ── 260자 제한, 예약 이름, 끝의 점, 대소문자

· 업데이트: · · MAX_PATH, 파일 경로, 긴 경로, 파일 이름, NTFS, Win32, C#, .NET, Windows 개발, 장애 조사, 기술 상담

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

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

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대한 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
MAX_PATH의 260이라는 숫자의 구성을 표로 보였습니다. `longPathAware`를 두는 매니페스트의 완전한 형태와 Visual Studio에서의 추가 절차, csproj에서의 지정을 추가하고, `Path.GetRelativePath`가 .NET Framework에 없는 점과 대체 코드, 파일 이름을 안전한 형태로 고치는 처리의 입력과 출력 대응표(예약 이름과 끝 점 포함), `dir /x`의 읽는 법을 추가했습니다. Windows 11에서 예약 이름 취급의 변경에 대해서는, 공식에 빌드 번호 기재가 없음을 명시합니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22174288)

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

Go Komura (2026). 「MAX_PATH와 Windows 경로·파일 이름의 함정 ── 260자 제한, 예약 이름, 끝의 점, 대소문자」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-max-path-filename-pitfalls/

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

「사용자 PC에서만 『파일을 찾을 수 없습니다』가 난다」, 「복사는 됐는데 그 파일을 열지 못한다」── 파일 입출력을 수반하는 업무 앱의 장애 조사에서, 결국 경로 길이거나 파일 이름 자체가 원인이었던 경우는 드물지 않습니다. 사용자는 프로젝트 이름이나 날짜를 폴더 이름에 밀어 넣고 계층을 깊게 파서, 이쪽 예상을 가뿐히 넘깁니다.

까다로운 점은 이 영역의 제한이 「Win32 API의 제한」, 「파일 시스템의 제한」, 「셸(Explorer)의 제한」, 「.NET 런타임의 제한」처럼 여러 겹으로 나뉘어, 어디까지 처리할 수 있고 무엇이 남는지 파악하기 어렵다는 것입니다. 이 글에서는 MAX_PATH=260자의 실체부터, 긴 경로를 적법하게 다루는 조건, CON 등의 예약 이름과 끝의 점 같은 파일 이름 함정, 대소문자 취급까지 C# 실무 대응과 함께 정리합니다.

1. 먼저 결론

  • MAX_PATH=260은 「드라이브 문자+콜론+백슬래시+최대 256자의 경로 문자열+종료 NUL」을 포함한 Win32 API의 제한입니다. 파일 시스템 쪽(NTFS 등)은 더 긴 경로를 다룰 수 있고, Unicode 버전 API에 \\?\ 접두사를 붙이면 합계 약 32,767자까지 지정할 수 있습니다.12
  • 260자 제한을 해제하려면 Windows 10 버전 1607 이후에서 「레지스트리의 LongPathsEnabled=1」과 「앱 매니페스트의 longPathAware」가 둘 다 필요합니다. 한쪽만으로는 적용되지 않습니다.3
  • .NET(Core)/.NET 5 이후 런타임은 MAX_PATH 검사를 하지 않고, 긴 경로를 암묵적으로 다룹니다. .NET Framework는 4.6.2 이후를 대상으로 하면 런타임 쪽의 260자 검사가 해제됩니다.45
  • 다만 긴 경로를 지원하지 않는 앱은 실제로 남습니다. Win32 API로 만들 수 있는 경로를 셸(Explorer)이 올바르게 해석하지 못하는 경우가 있다고, 공식 문서 자체가 명시합니다.1
  • 파일 이름에는 < > : " / \ | ? *와 제어 문자(0〜31)를 쓸 수 없고, CON·PRN·AUX·NUL·COM1〜9·LPT1〜9는 확장자를 붙여도(CON.txt여도) 예약 이름으로 취급됩니다.6
  • 이름 끝의 공백과 점은 경로 정규화 과정에서 조용히 제거됩니다. 「hoge.」를 지정한 셈인데 「hoge」로 바뀌거나, 다른 OS가 만든 끝에 공백이 붙은 파일에 Windows에서 접근하지 못하는 사고의 원인입니다.67
  • Windows의 파일 이름은 「대소문자를 유지하지만 구분하지 않는다」가 기본입니다. NTFS는 디렉터리 단위의 구분(fsutil.exe file setCaseSensitiveInfo)도 지원하지만, Windows 앱 쪽이 따라가지 못하는 부작용이 있습니다.89
  • 구현 면에서는 경로 결합은 Path.Combine의 「루트가 붙은 인수로 앞 단계가 버려진다」는 사양에 주의하고(.NET Core 계열이라면 Path.Join도 선택지입니다), 사용자 입력 파일 이름은 Path.GetInvalidFileNameChars+예약 이름·끝 문자의 자체 검사로 sanitize합니다.1011

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

2. MAX_PATH=260의 실체

Win32 API에서 경로의 최대 길이는 일부 예외를 제외하고 MAX_PATH=260자로 정의되어 있습니다. 이 260에는 구성이 있습니다. 로컬 경로는 「드라이브 문자, 콜론, 백슬래시, 백슬래시로 구분된 이름 부분, 종료 NUL 문자」로 구성되며, 예를 들어 D 드라이브라면 「D:\ + 256자분의 경로 문자열 + 종료 NUL」이 최대입니다.1

구성을 분해하면 다음과 같습니다.

위치 구성 요소 문자 수
앞부분 드라이브 문자+콜론+백슬래시 D:\ 3
중간 백슬래시로 구분된 이름 부분(폴더 이름·파일 이름과 구분 기호) 2026\프로젝트\…\보고서.xlsx 최대 256
끝부분 종료 NUL 문자(화면에는 보이지 않음) 1
  합계   260 = MAX_PATH

즉 260자를 「파일 이름에 쓸 수 있는 길이」로 생각하면, 실제로는 드라이브 표기와 종료 NUL로 4자만큼 줄어든다는 뜻입니다. 더 세밀한 제한으로, 디렉터리 생성 API는 8.3 형식 파일 이름을 뒤에 붙일 여유를 요구하므로, 디렉터리 경로는 MAX_PATH−12자를 넘을 수 없습니다.1

중요한 점은 이것이 Win32 API 계층의 제한이지, 파일 시스템의 한계가 아니라는 것입니다. NTFS는 긴 파일 이름과 확장 경로를 지원하며, 많은 Win32 함수의 Unicode 버전은 합계 약 32,767자의 확장 긴 경로를 받아들입니다. 경로를 구성하는 개별 컴포넌트(폴더 이름·파일 이름 하나)의 상한은 GetVolumeInformation이 반환하는 값으로, 일반적으로 255자입니다.12

이 「API는 260, 파일 시스템은 약 32,767」이라는 간극이야말로 현장 장애의 원천입니다. 어떤 도구에서는 만들 수 있었던 경로가, 다른 도구(또는 자사 앱)에서는 열리지 않는다는 비대칭 상황이 정당하게 발생합니다. git clone으로 깊은 리포지토리를 이름이 긴 폴더에 펼쳤더니 빌드가 통과하지 않게 되었다는 것은, 공식 문서에도 실려 있는 전형적인 예입니다.1

참고로 종래의 .NET Framework에서는 전체 경로가 260자 이상이 되면 System.IO.PathTooLongException이 발생했습니다. 이 예외를 보면 먼저 경로 길이를 의심하십시오.12

3. 260자의 벽을 넘는 방법과 그 조건

긴 경로를 다루는 수단은 크게 두 가지, 「\\?\ 접두사」와 「OS의 긴 경로 활성화」입니다.

3.1. \\?\ 접두사

경로 문자열 앞에 \\?\를 붙이면, Win32 API는 문자열 해석을 멈추고 그대로 파일 시스템에 넘깁니다. 이로써 MAX_PATH 제한을 넘을 수 있습니다(UNC 경로는 \\?\UNC\server\share 형식). 다만 조건과 부작용이 있습니다.16

  • Unicode 버전 API(〜W나 .NET처럼 UTF-16으로 호출하는 것)일 것.
  • 정규화가 건너뛰어지므로, / 구분이나 .·..에 의한 상대 지정은 쓸 수 없습니다. 상대 경로에는 \\?\를 붙일 수 없으므로, 상대 경로는 항상 MAX_PATH까지입니다.1
  • 모든 API가 대응하는 것은 아니며, 대응 여부는 각 API 레퍼런스에서 확인해야 합니다.6

3.2. Windows 10 1607 이후의 긴 경로 활성화 ── 조건은 「둘 다」

Windows 10 버전 1607 이후에서는 많은 일반적인 Win32 파일·디렉터리 함수(CreateFileW, FindFirstFileW, GetFileAttributesW 등)에서 MAX_PATH 제한을 해제할 수 있습니다. 다만 앱 쪽의 옵트인이 전제이며, 다음 두 조건을 모두 충족해야 합니다.3

  1. 레지스트리 값 HKLM\SYSTEM\CurrentControlSet\Control\FileSystemLongPathsEnabled(REG_DWORD)가 1일 것. 그룹 정책 「컴퓨터 구성 > 관리 템플릿 > 시스템 > 파일 시스템 > Win32의 긴 경로를 사용하도록 설정」으로도 설정할 수 있습니다.
  2. 애플리케이션 매니페스트에 longPathAware 요소가 있을 것.
<application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings xmlns:ws2="http://schemas.microsoft.com/SMI/2016/WindowsSettings">
        <ws2:longPathAware>true</ws2:longPathAware>
    </windowsSettings>
</application>
# 레지스트리 쪽(관리자 권한)
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force

매니페스트 쪽 절차도 보충합니다. 위에 든 longPathAware의 XML은 공식 문서에 실린 조각이며, 이것만으로는 파일이 되지 않습니다. 실제로는 애플리케이션 매니페스트 파일(관례적으로 app.manifest)의 assembly 요소 안에 둡니다. Visual Studio라면 「프로젝트를 마우스 오른쪽 단추로 클릭 > 추가 > 새 항목」에서 「애플리케이션 매니페스트 파일」을 추가하면 UAC 설정 등이 들어간 템플릿이 생성되므로, 그 assembly 요소 안에 application 요소로 덧붙입니다.13

<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <!-- 여기에 VS가 생성한 trustInfo 등이 들어 있습니다 -->
  <application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings xmlns:ws2="http://schemas.microsoft.com/SMI/2016/WindowsSettings">
      <ws2:longPathAware>true</ws2:longPathAware>
    </windowsSettings>
  </application>
</assembly>

추가한 항목을 빌드에 묶는 것은 MSBuild의 ApplicationManifest 속성입니다. Visual Studio에서 항목을 추가하면 보통 자동으로 기록되지만, csproj를 직접 편집할 때는 다음 한 줄을 더합니다(이 속성이 가리키는 매니페스트는 기본값으로 exe에 포함됩니다).14

<PropertyGroup>
  <ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>

「레지스트리를 설정했는데도 효과가 없다」는 상담은 대개 매니페스트 쪽 누락입니다. 공식 문서도 「이 레지스트리 설정은 새 기능을 쓰도록 수정된 앱에만 영향을 준다」고 못 박습니다. 또한 레지스트리 값은 첫 파일 함수 호출 시 프로세스 단위로 캐시되며, 프로세스가 살아 있는 동안에는 다시 읽히지 않습니다. 설정 변경을 모든 앱에 확실히 적용하려면 재부팅이 필요한 경우가 있습니다.3

3.3. .NET에서는 어떻게 되는가

  • .NET (Core) / .NET 5 이후: 런타임은 MAX_PATH 검사를 하지 않고, 긴 경로를 암묵적으로 다룹니다. 앱 쪽에서 특별한 코드는 필요 없습니다.4
  • .NET Framework: 4.6.2 이후를 대상으로 하면 260자 런타임 검사가 해제되고, PathTooLongException은 「32,767자 초과」 또는 「OS가 오류를 반환한 경우」에 한정됩니다. 그 이전을 대상으로 한 기존 앱에서도, Switch.System.IO.BlockLongPaths=false(그리고 구 경로 처리를 끄는 Switch.System.IO.UseLegacyPathHandling=false)의 AppContext 스위치로 옵트인할 수 있습니다.512
  • .NET Framework 앱이 실무에서 긴 경로를 통과시키려면, 위 런타임 설정에 더해 OS 쪽 긴 경로 활성화와 매니페스트의 병용이 필요합니다. NuGet.exe의 긴 경로 대응 문서가 이 구성(Windows 10 1607 이후+longPathAware 매니페스트+UseLegacyPathHandling 무효화)을 실례로 명시합니다.15

3.4. 그래도 남는 「지원하지 않는 앱」이라는 현실

여기까지 해도 세상의 모든 앱이 긴 경로를 다룰 수 있게 되는 것은 아닙니다. 공식 문서는 「셸과 파일 시스템은 요구 사항이 다르다. Win32 API로 만들 수 있는 경로를 셸 UI가 올바르게 해석하지 못할 수 있다」고 명시하며1, 실제로 긴 경로 대응을 내세우지 않는 도구도 남아 있습니다(예를 들어 NuGet 문서는 Visual Studio나 msbuild -t:restore의 restore가 긴 경로를 지원하지 않는다고 적습니다15). 자사 앱이 긴 경로로 파일을 만들어도, 사용자가 Explorer나 다른 도구로 그것을 열 수 있는지는 별개의 문제입니다. 이 비대칭을 반영한 설계 판단은 제6장의 판단표에 정리합니다.

4. 쓸 수 없는 문자와 예약 디바이스 이름, 끝의 점·공백

경로 길이와 나란히 또 하나의 지뢰밭이 파일 이름 자체의 규칙입니다. 공식 명명 규칙에서 업무 앱이 밟기 쉬운 것을 정리합니다.6

분류 내용 비고
예약 문자 < > : " / \ \| ? * 경로 구분의 \(와 /), 드라이브의 :를 포함
제어 문자 정수값 0(NUL)과 1〜31 대체 데이터 스트림 안을 제외하고 불가
예약 디바이스 이름 CON, PRN, AUX, NUL, COM1〜COM9, LPT1〜LPT9(및 위 첨자 숫자의 COM¹〜³, LPT¹〜³) 확장자를 붙여도 불가(NUL.txt나 NUL.tar.gz는 NUL과 등가)
끝의 문자 공백 또는 점으로 끝나는 이름 파일 시스템은 허용해도 셸과 UI가 지원하지 않음

4.1. 예약 디바이스 이름 ── CON.txt여도 안 됨

CON이나 NUL은 MS-DOS 시절의 디바이스 이름이며, 지금도 NT 네임스페이스의 예약 이름으로 남아 있습니다. 그래서 「CON」이라는 파일은 보통 방법으로는 만들 수 없고, CON.txt처럼 확장자를 붙여도 예약 이름으로 해석됩니다.6 시리얼 포트 연동의 로그를 「COM1.log」 같은 이름으로 저장하려다 사고가 나거나, Linux 쪽에서 만든 「aux」라는 폴더가 Windows에서 펼쳐지지 않는 것이 현장에서의 모습입니다.

보충하면, 경로 정규화 사양상 종래는 「CON」「COM1.TXT」처럼 예약 이름으로 시작하는 경로가 디바이스 경로(\\.\CON)로 변환되어 해석되어 왔습니다. Windows 11에서는 이 해석이 바뀌어, 레거시 디바이스를 가리키려면 \\.\CON처럼 완전한 형식의 지정이 필요해졌습니다.7 다만 공식 문서의 쓰임은 「Windows 11보다 이전은」「Windows 11에서는 이것이 들어맞지 않는다」라는 수준의 구분이며, 어느 빌드·어느 업데이트부터 바뀌었는지는 나와 있지 않습니다.7 혼재 환경에서 영향 범위를 가늠할 때는, 빌드 단위가 아니라 「Windows 10 이전인지, Windows 11 이후인지」까지밖에 가를 수 없다고 생각하십시오. 그렇다고 해도 오래된 OS와 기존 앱 양쪽이 종래 해석 그대로 남아 있으므로, 업무 데이터의 이름으로서 예약 이름을 피해야 한다는 결론은 바뀌지 않습니다.

4.2. 끝의 공백·점은 「조용히」 사라진다

공식 명명 규칙은 「파일 이름·디렉터리 이름을 공백 또는 점으로 끝내지 말 것」으로 정합니다.6 더 들어가면, Windows의 경로 정규화에는 「경로가 구분 문자로 끝나지 않으면, 끝의 점과 공백(U+0020)을 모두 제거한다」는 명확한 규칙이 있습니다.7

이것이 실무에서 까다로운 이유는 오류가 나지 않고 조용히 다른 이름이 되기 때문입니다. 사용자가 「보고서v2.」라는 이름을 입력하면, 만들어지는 것은 「보고서v2」입니다. 반대로 SMB를 통해 Linux 쪽에서 만든 「report 」(끝 공백) 같은 파일은, Windows의 보통 경로 지정에서는 정규화로 이름이 바뀌어 도달할 수 없습니다. 이런 「정규화로는 도달할 수 없지만 적법한 이름」에 접근하는 수단이, 정규화를 건너뛰는 \\?\ 접두사입니다. 공식 문서도 「hidden. 같은 파일은 다른 방법으로는 접근 불가능」하다고 이 용도를 명시합니다.7

참고로 앞의 점은 적법합니다(.gitignore 같은 이름은 문제 없이 만들 수 있습니다).6

5. 대소문자는 「유지하지만 구분하지 않는다」

Windows 파일 시스템의 기본 동작은 case-preserving, case-insensitive입니다. Readme.txt라는 이름으로 만들면 그 대소문자가 유지되어 표시되지만, 검색이나 비교에서는 대소문자가 무시되어 README.TXT로도 같은 파일에 도달합니다. 드라이브 문자도 마찬가지로 대소문자를 구분하지 않습니다.86

공식 명명 규칙은 앱 개발자를 향해 「대소문자 구분을 가정하지 말 것(OSCAR, Oscar, oscar는 같은 이름으로 보라)」고 명시하는 한편, NTFS 자체는 POSIX적인 대소문자 구분을 지원한다(다만 기본값은 사용 안 함)고도 말합니다.6

이것이 겉으로 나오는 것이 Linux 연계입니다. Windows 10 빌드 17107 이후에서는 디렉터리 단위로 대소문자 구분을 켤 수 있습니다.9

# 관리자 권한의 PowerShell에서
fsutil.exe file setCaseSensitiveInfo C:\work\linux-src enable
fsutil.exe file queryCaseSensitiveInfo C:\work\linux-src

WSL에서 Linux 유래의 소스 트리(Makefilemakefile이 공존하는 등)를 다룰 때는 유효한 수단이지만, 공식 문서 자체가 경고하는 부작용이 있습니다. 파일 시스템을 대소문자 비구분으로 가정하는 Windows 앱은, 구분이 켜진 디렉터리에서 파일에 접근하지 못하게 될 수 있습니다. 또한 플래그 변경은 대상 디렉터리가 비어 있지 않으면 할 수 없고, 새로 만들어지는 하위 디렉터리는 부모 설정을 상속합니다.9 역사적으로는, 이름만 대소문자가 다른 두 파일이 있으면 Explorer에는 둘 다 보이는데 어느 쪽을 골라도 같은 한쪽만 열리는 현상도 공식으로 기록되어 있습니다.9

업무 앱 설계로는 「Windows 위에서는 대소문자 차이는 같은 이름」으로 다루는 것이 기본, 다만 Linux로 넘어가는 파일 이름은 대소문자만 다른 충돌을 검사한다가 실무적인 타협점입니다. Linux 연계에서는 파일 이름 이전에 문자 코드에서도 함정이 있으므로, 「Windows 문자 코드 입문 - Linux 연계에서 일어나는 문자 깨짐」도 함께 확인하십시오.

6. 업무 앱에서의 실무 ── 경로 결합·sanitize·판단표

6.1. 경로 결합은 Path.Combine의 사양을 알고 쓴다

경로 문자열 연결을 +로 쓰는 것은 논외로 하고, Path.Combine에도 알아 둘 사양이 있습니다. 두 번째 이후 인수에 루트가 붙은 경로가 넘어오면, 그보다 앞의 인수는 모두 무시됩니다.10

var baseDir = @"C:\App\Data";

// 사용자 입력이 루트가 붙어 있으면 baseDir은 조용히 버려진다
Path.Combine(baseDir, @"C:\Windows\secret.txt"); // → "C:\Windows\secret.txt"
Path.Combine(baseDir, @"\evil.txt");             // → "\evil.txt"(현재 드라이브의 루트)

사용자 입력이나 설정 파일에서 온 문자열을 그대로 두 번째 인수로 넘기면, 의도한 저장 폴더 밖으로 쓰는 취약점이 됩니다. 공식 문서도 이 동작이 민감한 파일에 대한 의도하지 않은 접근으로 이어질 수 있다고 주의하며, 대안으로 Path.Join / Path.TryJoin(.NET Framework에는 없습니다)을 듭니다.1016 어느 쪽을 쓰든, 최종적으로는 Path.GetFullPath로 정규화한 결과가 베이스 디렉터리 아래인지 검증하는 것이 정석입니다.

// 베이스 쪽도 정규화한 뒤, 상대 경로로 변환해 판정한다.
// 문자열의 앞부분 일치보다, 끝 구분자의 유무나 베이스가 드라이브 루트인 경우의 흔들림에 강하다
var baseFull = Path.GetFullPath(baseDir);
var fullPath = Path.GetFullPath(Path.Combine(baseFull, userInput));
var relative = Path.GetRelativePath(baseFull, fullPath);
if (relative == ".." ||
    relative.StartsWith(".." + Path.DirectorySeparatorChar) ||
    Path.IsPathRooted(relative)) // 다른 드라이브·UNC로 빠져나간 경우는 절대 경로가 반환된다
{
    throw new InvalidOperationException("저장 위치가 예정 폴더 밖을 가리킵니다.");
}

참고로 Path.GetRelativePath는 .NET Core 2.0 이후·.NET Standard 2.1 이후·.NET 5 이후의 API이며, .NET Framework에는 없습니다.17 Framework 쪽에서는 「베이스를 정규화한 뒤 끝에 구분 문자를 붙이고, 앞부분 일치로 판정」하는 형태로 바꿉니다. 끝의 구분 문자가 핵심이며, 이것이 없으면 C:\App\Data 검사가 C:\App\DataBackup을 아래로 오판합니다.

// .NET Framework 대상의 대체(Path.GetRelativePath가 없는 환경)
var baseFull = Path.GetFullPath(baseDir);
if (!baseFull.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal))
{
    baseFull += Path.DirectorySeparatorChar;   // "C:\App\Data"가 "C:\App\DataBackup"에 앞부분 일치하지 않게 한다
}

var fullPath = Path.GetFullPath(Path.Combine(baseFull, userInput));
if (!fullPath.StartsWith(baseFull, StringComparison.OrdinalIgnoreCase)) // Windows 기본에 맞춰 대소문자는 무시
{
    throw new InvalidOperationException("저장 위치가 예정 폴더 밖을 가리킵니다.");
}

Path.GetRelativePath는 OS 기본 방식으로 경로를 비교합니다. 즉 Windows에서는 대소문자를 구분하지 않는다는 전제의 판정이며, 이는 다음 장에서 설명하는 「Windows의 기본은 대소문자를 구분하지 않는다」는 동작에 맞습니다. 반대로 말하면, 디렉터리 단위로 대소문자 구분을 켠 장소(다음 장 참조)에서는 Datadata가 다른 디렉터리가 될 수 있으므로, 대소문자를 무시한 판정에서는 「대소문자만 다른 다른 폴더」를 아래로 오판할 여지가 생깁니다. 그런 구성을 다룰 가능성이 있다면, 구분이 켜진 장소를 베이스 디렉터리로 받지 않는 방침이 안전합니다.

한 가지 더, 이 판정은 문자열로 정규화한 경로에 대한 판정일 뿐이라는 점도 의식하십시오. 베이스 디렉터리 아래에 junction이나 심볼릭 링크가 있으면, 문자열상으로는 아래를 가리켜도 실체는 베이스 밖인 일이 일어날 수 있습니다. 게다가 링크는 끝의 파일뿐 아니라 중간 폴더에도 끼어들 수 있으므로(베이스\링크\파일.txt 형태), 끝만 File.ResolveLinkTarget으로 조사해도 보이지 않습니다. 먼저, 신뢰할 수 없는 이용자가 베이스 아래에 링크나 junction을 만들 수 있는 구성 자체를 피하는 것이 첫째입니다. 그 위에서 엄밀히 지켜야 한다면, 실제로 파일을 연 핸들에서 확정 경로를 얻어(Win32의 GetFinalPathNameByHandle) 그것이 베이스 아래인지 검증하거나, 경로의 각 폴더 구성 요소를 순서대로 링크가 아닌지 검사하십시오.

6.2. 사용자 입력 파일 이름의 sanitize

「거래처명+날짜.csv」처럼 사용자 입력에서 파일 이름을 만드는 기능에서는, sanitize를 한곳에 모읍니다. Path.GetInvalidFileNameChars가 출발점이지만, 이 배열은 잘못된 문자의 완전한 집합을 보장하지 않는다고 공식으로 명시되어 있습니다.11 예약 디바이스 이름과 끝의 점·공백은 이 API로는 검출할 수 없으므로, 자체 검사를 더합니다.

private static readonly HashSet<string> ReservedNames =
    new(StringComparer.OrdinalIgnoreCase)
    {
        "CON", "PRN", "AUX", "NUL",
        "COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9",
        "LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9",
        "COM¹","COM²","COM³",  // 위 첨자 숫자의 COM¹〜COM³도 예약 이름
        "LPT¹","LPT²","LPT³",  // 마찬가지로 LPT¹〜LPT³
    };

public static string SanitizeFileName(string input)
{
    var invalid = Path.GetInvalidFileNameChars();
    var name = new string(input.Select(c => invalid.Contains(c) ? '_' : c).ToArray());

    name = name.TrimEnd(' ', '.');            // 끝 공백·점은 조용히 떨어지므로 제거

    // 파일 이름 1요소의 길이 상한(보통 255자)에도 맞춘다.
    // 폴더 계층이나 앱이 뒤에 붙이는 접미사의 여지를 남겨 소극적으로 자른다
    const int MaxNameLength = 120;
    if (name.Length > MaxNameLength)
    {
        var ext = Path.GetExtension(name);
        if (ext.Length > 20)
        {
            ext = ""; // 비정상적으로 긴 「확장자」는 확장자로 보존하지 않는다(음의 범위 지정으로 예외가 나는 것을 막는다)
        }
        name = name[..(MaxNameLength - ext.Length)].TrimEnd(' ', '.') + ext;
    }

    // 빈 값·예약 이름 판정은 반드시 「최종형」에 대해 수행한다.
    // 자르기나 TrimEnd 결과로 빈 문자나 예약 이름(NUL 등)으로 바뀌는 경우를 잡기 위해
    var stem = name.Split('.')[0];            // NUL.txt 대책: 확장자 앞 부분으로 예약 이름을 판정
    if (name.Length == 0 || ReservedNames.Contains(stem))
    {
        name = "_" + name;                    // 1자를 더해도 상한 255에는 충분히 들어간다
    }
    return name;
}

이 함수의 의도는 입출력 대응표로 두면 전달하기 쉬우므로, 단위 테스트의 첫 케이스로 그대로 쓸 수 있는 형태로 둡니다.

입력 출력 적용되는 처리
보고서v2. 보고서v2 끝 점을 TrimEnd로 제거(정규화로 조용히 사라지는 것을 앞질러 처리)
CON.txt _CON.txt 확장자를 뺀 CON이 예약 이름. Split('.')[0]으로 판정하고 접두사를 붙인다
nul.tar.gz _nul.tar.gz 예약 이름 판정은 OrdinalIgnoreCase. 이중 확장자여도 맨 앞 요소를 본다
COM1의 끝에 공백 1개 _COM1 TrimEnd 결과가 예약 이름으로 바뀐다. 그래서 판정은 최종형에 대해 수행한다
... _ TrimEnd로 빈 문자가 된 경우. 빈 파일 이름을 반환하지 않는다
A/B:C.csv A_B_C.csv GetInvalidFileNameChars에 포함된 /:_로 치환
150자+.csv 앞 116자+.csv(계 120자) 길이 상한으로 자르고, 확장자는 보존한다

4행과 5행이, 코드에서 「판정은 반드시 최종형에 대해 수행한다」고 주석한 이유 그 자체입니다. 치환·자르기·TrimEnd를 먼저 마친 뒤 예약 이름과 빈 문자를 보지 않으면, 끝에 공백이 붙은 COM1 같은 입력이 검사를 빠져나갑니다.

CSV 출력의 파일 이름에서 이 문제를 만나는 일이 많으므로, CSV 자체의 실무는 「CSV는 「그냥 텍스트」가 아니다 ── C# 업무 앱의 CSV 실무」도 참고하십시오.

6.3. 상대 경로와 현재 디렉터리의 함정

상대 경로에는 두 가지 함정이 있습니다. 첫째, 현재 디렉터리는 프로세스 단위 설정이므로, 어느 스레드에서든 언제든 바뀔 수 있습니다. 공식 문서는 「상대 경로는 멀티스레드 앱에서는 위험하다」고까지 적으며, .NET Core 2.1 이후라면 기준 경로를 명시할 수 있는 Path.GetFullPath(string, string)을 쓸 수 있습니다.7 둘째, C:tmp.txt처럼 드라이브 문자 직후에 백슬래시가 없는 형식은 「C 드라이브의 현재 디렉터리에서의 상대 경로」이며, 절대 경로가 아닙니다. 이 「드라이브 상대 경로」는 프로그램이나 스크립트의 단골 버그 원인으로 공식에 지목되어 있습니다.7

설정 파일이나 사용자 입력으로 받은 경로는, 받은 시점에 Path.GetFullPath로 절대 경로화한 뒤 로그에 남기고 검증하는 습관을 들이십시오.

6.4. 판단표 ── 긴 경로를 지원해야 하는가, 입구에서 걸러야 하는가

상황 권장 이유
사용자가 저장 위치를 자유롭게 고르는 일반 업무 앱 입구에서 검증해 걸러 낸다(전체 경로 길이·파일 이름을 저장 전에 검사하고 명확한 오류를 낸다) 자사 앱이 대응해도 Explorer나 연동 대상 도구가 열지 못하는 사고가 남기 때문1
백업·동기화·아카이브 압축 해제처럼, 남이 만든 깊은 계층을 「읽는」 쪽 긴 경로에 대응한다(.NET Core 계열+필요하면 매니페스트, Framework라면 4.6.2+ 설정) 입력을 제어할 수 없고, 읽지 못하면 업무가 멈추기 때문54
자사 앱이 깊은 계층을 「만드는」 쪽 원칙 만들지 않는 설계로 다시 본다(계층의 평탄화, 해시 이름 채택 등) 만든 경로의 이용자(사람·다른 앱)가 지원하지 않을 가능성이 높기 때문1
Linux/WSL과 파일을 주고받는다 예약 이름·대소문자 충돌·끝 문자를 전송 전에 검사 Windows 쪽에서 도달 불가능한 파일이 생기기 때문69
사용자 입력에서 파일 이름을 생성한다 GetInvalidFileNameChars+예약 이름·끝 문자의 sanitize를 공통 함수에 모은다 API 배열만으로는 불완전하기 때문11

7. 문제 해결 ── 「탐색기에는 보이는데 열리지 않는다」

「Explorer에는 파일이 보이는데, 앱에서 열면 『파일을 찾을 수 없습니다』」라는 상담에서 원인을 가르는 절차입니다.

확인 포인트 수단 해당하면
전체 경로가 260자 근처인가 PowerShell에서 (Get-ChildItem -Recurse).FullName \| Where-Object { $_.Length -ge 250 } 상위 폴더 이름을 짧게 하거나, 긴 경로 대응(제3장)을 검토
파일 이름이 예약 이름인가(aux, con, com1 등) 이름을 눈으로 확인. 확장자 있는 것도 대상6 이름 변경(만든 곳이 Linux 등이면 전송 시 변환)
끝에 공백·점이 없는가 cmd /c dir /x나 따옴표로 감싼 표시로 확인 \\?\ 접두사가 붙은 경로로 삭제·이름 변경7
대소문자만 다른 같은 이름 파일이 없는가 WSL/Git 유래 폴더에서 발생하기 쉽다9 한쪽 이름을 바꾸거나, 대상 디렉터리의 용도를 다시 본다
상대 경로·드라이브 상대 경로를 쓰지 않았는가 로그에 실제로 열려고 한 경로를 절대 경로로 기록 Path.GetFullPath로 절대 경로화한 뒤 사용7

표에서 쓰는 dir /x의 읽는 법만 보충합니다. /x는 「8.3 형식에 들어가지 않는 이름에 대해 생성된 짧은 이름을 표시하는」 옵션이며, 표시 형식은 /n(이름이 오른쪽 끝에 오는 형태)과 같고, 그곳에 짧은 이름 열이 긴 이름 앞에 끼워집니다.18 즉 「날짜 시각 크기 ─ 짧은 이름 ─ 긴 이름」 나열이며, 오른쪽 끝이 본래 이름, 그 왼쪽 옆이 짧은 이름(T97B4~1.TXT 같은 형태)입니다. 이름이 처음부터 8.3 형식에 들어가는 파일에는 짧은 이름이 생성되지 않으므로, 그 열은 빈칸이 됩니다. 경로 길이가 상한에 닿아 열리지 않을 때, 중간 폴더를 이 짧은 이름으로 지정해 경로를 줄이는 응급 조치에도 쓸 수 있습니다.

조사의 첫걸음으로 권하는 것은, 앱의 오류 로그에 「열려고 한 경로 그 자체」를 절대 경로·따옴표로 감싸 기록하는 것입니다. 「파일을 찾을 수 없습니다」라는 예외 메시지만으로는, 경로가 잘렸는지, 정규화로 이름이 바뀌었는지, 애초에 다른 디렉터리를 보고 있었는지를 사후에 구분할 수 없습니다. 따옴표로 감싸 기록해 두면, 끝 공백처럼 눈에 잘 안 띄는 문제도 한눈에 알 수 있습니다.

참고로 DLL 로드 실패도 「파일을 찾을 수 없습니다」계 오류의 단골이지만, 이쪽은 경로 길이보다 검색 순서 문제인 경우가 많아 「Windows DLL 이름 해석의 구조 - 검색 순서와 SxS」에서 정리합니다.

8. 정리

  • MAX_PATH=260은 「D:\+최대 256자+종료 NUL」을 포함하는 Win32 API의 제한이며, NTFS 자체는 약 32,767자의 확장 긴 경로를 다룹니다. 디렉터리는 추가로 MAX_PATH−12까지라는 제한도 있습니다.
  • 260자를 넘으려면 \\?\ 접두사(Unicode 버전 API 한정·상대 경로 불가)이거나, Windows 10 1607 이후의 긴 경로 활성화(레지스트리 LongPathsEnabled+매니페스트 longPathAware둘 다)가 필요합니다.
  • .NET (Core)/5+는 긴 경로를 암묵적으로 다루고, .NET Framework는 4.6.2 이후 대상이면 런타임 검사가 해제됩니다. 다만 Explorer를 포함한 지원하지 않는 앱이 남으므로, 「만들 수 있는가」와 「사용자가 다룰 수 있는가」를 나눠 판단합니다.
  • 파일 이름은 예약 문자(< > : " / \ | ? *)와 제어 문자가 불가하고, CON·NUL·COM1 등의 예약 디바이스 이름은 확장자가 있어도 불가하며, 끝의 공백·점은 정규화로 조용히 사라집니다.
  • 대소문자는 「유지하지만 구분하지 않는다」가 기본입니다. fsutil file setCaseSensitiveInfo에 의한 디렉터리 단위 구분은 WSL 연계에서는 유효하지만, Windows 앱 쪽 오작동 위험과 맞바꿈입니다.
  • 구현은 Path.Combine의 루트가 붙은 인수 사양을 반영한 베이스 디렉터리 검증, GetInvalidFileNameChars+예약 이름·끝 문자 검사의 sanitize를 공통으로 두는 것, 상대 경로 배제(Path.GetFullPath로 절대 경로화)가 정석입니다.

관련 글

관련 상담 영역

합동회사 고무라 소프트에서는 「특정 환경·특정 파일만 열리지 않는다」와 같은 파일 I/O 기인의 장애 조사, 기존 업무 앱의 긴 경로 대응이나 파일 이름 유효성 검사 설계의 재검토, Windows·Linux 혼재 환경에서의 파일 연계 설계 상담을 다룹니다.

참고 링크

  1. Microsoft Learn, Maximum Path Length Limitation. MAX_PATH=260의 정의와 「드라이브 문자+콜론+백슬래시+256자+종료 NUL」이라는 구성, Unicode 버전 API와 \\?\ 접두사에 의한 약 32,767자의 확장 긴 경로, 컴포넌트 길이(일반적으로 255자), 상대 경로가 항상 MAX_PATH로 제한되는 것, 디렉터리 생성이 MAX_PATH−12까지인 것, 셸과 파일 시스템의 요구 사항이 달라 Win32로 만들 수 있는 경로를 셸 UI가 해석하지 못하는 경우가 있는 것에 대해.  2 3 4 5 6 7 8 9 10 11

  2. Microsoft Learn, NTFS overview. NTFS가 긴 파일 이름과 약 32,767자의 확장 긴 경로를 지원하는 것, 8.3 별칭에 의한 하위 호환에 대해.  2

  3. Microsoft Learn, Maximum Path Length Limitation ── Enable long paths in Windows 10, version 1607, and later. Windows 10 1607 이후에서 레지스트리 값 LongPathsEnabled=1과 앱 매니페스트의 longPathAware 요소가 둘 다 필요한 것, 그룹 정책으로의 설정, 레지스트리 값이 프로세스 단위로 캐시되는 것, 제한이 해제되는 Win32 함수 목록에 대해.  2 3

  4. Microsoft Learn, File path formats on Windows systems ── Skip normalization. .NET Core 및 .NET 5 이후가 긴 경로를 암묵적으로 처리하고 MAX_PATH 검사를 하지 않는 것(MAX_PATH 검사는 .NET Framework만), \\?\가 정규화를 건너뛰는 구조인 것에 대해.  2 3

  5. Microsoft Learn, Retargeting changes for migration to .NET Framework 4.6.x. .NET Framework 4.6.2 대상에서 긴 경로(최대 32K자)가 지원되고 260자 제한이 제거된 것, 구 대상 앱이 Switch.System.IO.BlockLongPaths=false로 옵트인할 수 있는 것에 대해.  2 3

  6. Microsoft Learn, Naming Files, Paths, and Namespaces. 예약 문자(< > : " / \ | ? *)와 제어 문자(0〜31), 예약 디바이스 이름(CON/PRN/AUX/NUL/COM1〜9/LPT1〜9 및 위 첨자 숫자), NUL.txt처럼 확장자가 있는 것도 예약 이름과 등가인 것, 이름을 끝 공백·점으로 끝내지 말 것, 앞의 점이 적법한 것, 대소문자 구분을 가정하지 말아야 하는 것과 NTFS의 POSIX 시맨틱스, \\?\ 접두사의 동작과 Unicode API 요건에 대해.  2 3 4 5 6 7 8 9 10 11 12

  7. Microsoft Learn, File path formats on Windows systems ── Path normalization. 경로 정규화에서 끝의 점과 공백이 제거되는 것, hidden. 같은 이름은 \\?\로만 접근할 수 있는 것, CON 등 레거시 디바이스 이름의 해석과 Windows 11에서의 변경, 드라이브 상대 경로(C:tmp.txt)가 버그의 일반적인 원인인 것, 현재 디렉터리가 프로세스 단위이며 상대 경로가 멀티스레드에서 위험한 것, Path.GetFullPath(String, String)에 대해.  2 3 4 5 6 7 8 9

  8. Microsoft Learn, File path formats on Windows systems ── Case and the Windows file system. 디렉터리 이름·파일 이름이 만들 때의 대소문자를 유지하는 한편, 이름 비교는 대소문자를 구분하지 않는 것에 대해.  2

  9. Microsoft Learn, Adjust case sensitivity. Windows 10 빌드 17107 이후의 디렉터리 단위 대소문자 구분(fsutil.exe file setCaseSensitiveInfo), 변경에는 관리자 권한과 빈 디렉터리가 필요한 것, 새 하위 디렉터리가 설정을 상속하는 것, 대소문자 비구분을 가정하는 Windows 앱이 오작동할 수 있다는 경고, 대소문자만 다른 두 파일은 Explorer에 둘 다 보여도 한쪽만 열렸던 것에 대해.  2 3 4 5 6

  10. Microsoft Learn, Path.Combine Method. 첫 인수 이외의 인수에 루트가 붙은 경로가 포함되면 그보다 앞의 경로 요소가 무시되고 루트가 붙은 요소부터 시작하는 문자열이 반환되는 것, 민감한 파일에 대한 의도하지 않은 접근으로 이어질 수 있는 것, 대안으로 Join/TryJoin(.NET Framework에서는 이용 불가)이 거론되는 것에 대해.  2 3

  11. Microsoft Learn, Path.GetInvalidFileNameChars Method. 파일 이름에 쓸 수 없는 문자의 배열을 반환하는 것, 반환 배열이 잘못된 문자의 완전한 집합을 보장하지 않고 파일 시스템에 따라 달라질 수 있는 것에 대해.  2 3

  12. Microsoft Learn, PathTooLongException Class. 경로가 시스템 정의의 최대 길이를 넘었을 때 발생하는 예외인 것, .NET Framework 4.6.2 이후에서는 「32,767자 초과」 또는 「OS가 오류를 반환한 경우」에 한정해 발생하는 것에 대해.  2

  13. Microsoft Learn, Application Manifests. 애플리케이션 매니페스트가 루트 요소 assembly를 갖는 XML인 것, application 요소와 windowsSettings에 의한 런타임 설정 선언에 대해. 

  14. Microsoft Learn, Common MSBuild Project Properties. ApplicationManifest 속성이 매니페스트 파일의 경로를 지정하는 것이며, 많은 경우 매니페스트는 실행 파일에 포함되는 것에 대해. 

  15. Microsoft Learn, Long Path Support (NuGet CLI). .NET Framework 기반 도구가 긴 경로를 쓰기 위한 실제 구성(Windows 10 1607 이후 또는 1511+.NET Framework 4.6.2, Win32 long paths 정책, longPathAware 매니페스트+UseLegacyPathHandling 무효화)과, Visual Studio나 msbuild의 restore가 긴 경로를 지원하지 않는 것에 대해.  2

  16. Microsoft Learn, Path.Join Method. Join이 루트가 붙은 후속 경로를 버리지 않고 연결하는 것, Combine과의 동작 차이 실례에 대해. 

  17. Microsoft Learn, Path.GetRelativePath Method. 대응 버전(.NET Core 2.0 이후, .NET Standard 2.1, .NET 5 이후. .NET Framework는 포함되지 않음)과, 비교에 플랫폼 기본 방식(Windows와 macOS는 OrdinalIgnoreCase, Linux는 Ordinal)을 쓰는 것에 대해. 

  18. Microsoft Learn, dir. /x 옵션이 8.3 형식이 아닌 이름에 대해 생성된 짧은 이름을 표시하는 것, 표시 형식은 /n과 같고 짧은 이름이 긴 이름 앞에 삽입되는 것에 대해. 

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

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

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

자주 묻는 질문

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

Windows 경로는 몇 글자까지 사용할 수 있나요?
Win32 API의 기본값은 MAX_PATH=260자이며, 이는 「드라이브 문자+콜론+백슬래시+256자분의 경로 문자열+종료 NUL」을 포함한 길이입니다. NTFS 등의 파일 시스템 자체는 더 긴 경로를 다룰 수 있고, Unicode 버전 API에 \\?\ 접두사가 붙은 경로를 넘기면 합계 약 32,767자까지 지정할 수 있습니다. 다만 폴더 이름·파일 이름 하나(컴포넌트)는 일반적으로 255자까지이며, 상대 경로는 항상 MAX_PATH까지로 제한됩니다.
MAX_PATH의 260자 제한은 어떻게 해제할 수 있나요?
Windows 10 버전 1607 이후에서 레지스트리 값 LongPathsEnabled=1(또는 그룹 정책의 「Win32의 긴 경로를 사용하도록 설정」)과 애플리케이션 매니페스트의 longPathAware 요소를 모두 설정하면, 많은 Win32 파일 함수에서 260자 제한이 해제됩니다. 한쪽만으로는 적용되지 않습니다. .NET(Core)/.NET 5 이후 런타임은 MAX_PATH 검사를 하지 않고 긴 경로를 암묵적으로 다루며, .NET Framework는 4.6.2 이후를 대상으로 하면 런타임 쪽의 260자 검사가 해제됩니다. 다만 Explorer를 포함해 긴 경로를 지원하지 않는 앱은 남으므로, 만든 긴 경로를 누가 다루는지까지 포함해 판단해야 합니다.
CON이나 NUL이라는 이름의 파일을 만들 수 없는 이유는 무엇인가요?
CON, PRN, AUX, NUL, COM1〜COM9, LPT1〜LPT9 등은 MS-DOS 시절부터 이어지는 디바이스의 예약 이름이며, Windows는 이 이름들을 파일이 아니라 디바이스로 해석하기 때문입니다. NUL.txt처럼 확장자를 붙여도 NUL과 같이 취급되므로 피할 수 없습니다. Windows 11에서는 경로 해석 동작이 일부 바뀌었지만, 오래된 OS나 다수의 앱이 종래 해석 그대로이므로, 업무 데이터의 파일 이름으로는 계속 피하는 편이 안전합니다.
파일 이름의 대소문자는 Windows에서 구분되나요?
기본값은 「유지하지만 구분하지 않는다」(case-preserving, case-insensitive)입니다. Readme.txt라는 이름으로 만들면 표시상 그 대소문자가 유지되지만, README.TXT를 열려고 해도 같은 파일에 도달합니다. NTFS는 POSIX적인 대소문자 구분도 지원하며, Windows 10 빌드 17107 이후에서는 fsutil.exe file setCaseSensitiveInfo로 디렉터리 단위의 구분을 켤 수 있지만, 대소문자를 구분하지 않는다는 전제의 Windows 앱이 오작동하는 부작용이 있으므로, WSL 연계처럼 필요한 장면으로 한정해야 합니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기