반응형

[PART11.입출력 기본(6/7)] 경로 다루기 — Path 클래스 — 플랫폼 독립적인 경로 조립의 정석

Path.Combine 안전 결합 / Windows \ vs Unix / 경로 분해 메서드 / GetFullPath 정규화로 디렉터리 탈출 방지 / Unity persistentDataPath와 함께 쓰는 패턴


1. 문제 제기 — 경로 문자열 한 줄이 모바일 빌드를 깨뜨리는 이유

다음 코드는 Windows 개발 PC에서는 문제없이 작동합니다.

C#
string path = saveDir + "\\save.json";
File.WriteAllText(path, json);

같은 코드가 Mac 빌드에서는 \save.json이라는 이름의 파일 이름을 만들고, Android 빌드에서는 FileNotFoundException이 뜹니다. 백슬래시(\)는 Unix 계열에서 디렉터리 구분자가 아닙니다.

또 다른 함정도 있습니다.

C#
string user = userInput;                              // 사용자 입력
string fullPath = saveDir + "/" + user;
File.WriteAllText(fullPath, data);                    // user가 "../../etc/passwd"라면?

이 코드는 디렉터리 탈출(path traversal) 취약점을 그대로 갖습니다. 사용자 입력이 ..을 포함하면 의도한 폴더 밖으로 나갑니다.

이 글에서는 System.IO.Path 정적 클래스가 제공하는 메서드를 분류하고, 어떻게 써야 플랫폼 독립적이고, 안전하고, 할당이 적은 경로 조작이 되는지 IL 레벨까지 짚어봅니다.


2. 개념 정의 — Path는 "문자열만" 다룬다

2.1 Path는 정적 메서드 모음

Path 클래스의 메서드 분류

Path 클래스의 메서드는 거의 모두 문자열만 보고 결과를 반환합니다. 파일이 실제 존재하는지, 디렉터리가 권한이 있는지 같은 디스크 접근을 일절 하지 않습니다. 예외는 Path.GetFullPath(현재 작업 디렉터리를 참조해 정규화) 정도입니다.

2.2 가장 자주 쓰는 메서드

C#
string root = "/Users/pjhara/save";

// 결합
string saveFile = Path.Combine(root, "player", "save.json");
// → /Users/pjhara/save/player/save.json

// 분해
string fileName = Path.GetFileName(saveFile);              // save.json
string extOnly  = Path.GetExtension(saveFile);             // .json
string nameOnly = Path.GetFileNameWithoutExtension(saveFile); // save
string dir      = Path.GetDirectoryName(saveFile);         // /Users/pjhara/save/player

// 확장자 변경
string backup = Path.ChangeExtension(saveFile, ".bak");    // save.bak

// 임시 파일
string temp = Path.GetTempFileName();                       // /var/folders/.../tmpXXXXX.tmp (실제 생성됨)
string randName = Path.GetRandomFileName();                 // 무작위 이름 문자열만 생성

2.3 DirectorySeparatorChar — 플랫폼 차이를 흡수하는 상수

C#
char sep = Path.DirectorySeparatorChar;         // Windows: '\', Unix/macOS/Android/iOS: '/'
char alt = Path.AltDirectorySeparatorChar;      // Windows: '/', Unix: '/' (동일)

직접 \ 또는 /를 박지 말고, 이 상수를 통해 플랫폼이 알려 주는 값을 사용해야 합니다. 더 좋은 방법은 그냥 Path.Combine을 쓰는 것입니다 — 알아서 OS의 구분자로 결합해 줍니다.


3. 내부 동작 — Path.Combine vs 문자열 연결의 IL 차이

3.1 두 방식이 만드는 IL

C#
public static string SafeCombine(string root, string sub, string file)
{
    return Path.Combine(root, sub, file);
}

public static string UnsafeConcat(string root, string sub, string file)
{
    return root + "/" + sub + "/" + file;
}
IL
.method public hidebysig static string SafeCombine (string root, string sub, string file) cil managed
{
    .maxstack 3
    .locals init ([0] string)

    IL_0001: ldarg.0
    IL_0002: ldarg.1
    IL_0003: ldarg.2
    IL_0004: call string Path::Combine(string, string, string)         // ★ 단일 메서드 호출
    IL_0009: stloc.0
    IL_000c: ldloc.0
    IL_000d: ret
}
IL
.method public hidebysig static string UnsafeConcat (string root, string sub, string file) cil managed
{
    .maxstack 4
    .locals init ([0] string)

    IL_0001: ldc.i4.5
    IL_0002: newarr [System.Runtime]System.String                       // ★ 5칸짜리 string[] 새 할당
    IL_0007: dup
    IL_0008: ldc.i4.0
    IL_0009: ldarg.0
    IL_000a: stelem.ref                                                 // [0] = root
    IL_000b: dup
    IL_000c: ldc.i4.1
    IL_000d: ldstr "/"
    IL_0012: stelem.ref                                                 // [1] = "/"
    IL_0013: dup
    IL_0014: ldc.i4.2
    IL_0015: ldarg.1
    IL_0016: stelem.ref                                                 // [2] = sub
    IL_0017: dup
    IL_0018: ldc.i4.3
    IL_0019: ldstr "/"
    IL_001e: stelem.ref                                                 // [3] = "/"
    IL_001f: dup
    IL_0020: ldc.i4.4
    IL_0021: ldarg.2
    IL_0022: stelem.ref                                                 // [4] = file
    IL_0023: call string System.String::Concat(string[])                // ★ 배열 통째로 Concat
    IL_0028: stloc.0
    IL_002b: ldloc.0
    IL_002c: ret
}

3.2 IL 분석 포인트

1. Path.Combine — 단일 호출, 14바이트 IL

인자 3개를 그대로 전달하고 결과를 받습니다. 추가 배열·박싱 없이 끝납니다.

2. + 연결 — newarr System.String + Concat(string[]) 호출

컴파일러가 + "/" + 연결을 만나면 5개짜리 string[] 배열을 힙에 새로 만들고, String.Concat(string[]) 메서드를 호출합니다. IL 길이가 45바이트로 3배 늘어났을 뿐 아니라, 호출마다 GC heap에 작은 배열이 추가됩니다.

3. 결정적 차이 — 구분자가 자동 처리되지 않는다

IL 차이보다 더 큰 문제는 +로 직접 만든 경로는 구분자 일관성이 없다는 점입니다. root/save/로 끝나면 결과가 /save//player//save.json이 됩니다. Path.Combine은 이런 중복·누락을 알아서 해결합니다.

3.3 Path.Combine의 의외의 규칙 — 절대 경로가 들어오면 앞은 무시

C#
string a = Path.Combine("/Users/pjhara", "/etc/passwd");
// a == "/etc/passwd"   ← 앞 인자가 통째로 무시됨!

이 동작은 의도적입니다. 두 번째 이후 인자가 절대 경로라면 앞쪽 경로를 무시하고 절대 경로를 그대로 반환합니다. 사용자 입력을 그대로 결합하면 루트로 탈출이 가능한 보안 취약점이 됩니다.

C#
// ❌ 사용자 입력이 "/etc/passwd"라면 saveDir 무시되고 시스템 파일 노출
string path = Path.Combine(saveDir, userInput);

// ✅ Path.GetFullPath로 정규화 후 saveDir 안인지 검증
string combined = Path.Combine(saveDir, userInput);
string full = Path.GetFullPath(combined);
if (!full.StartsWith(Path.GetFullPath(saveDir)))
{
    throw new UnauthorizedAccessException("디렉터리 밖 접근 시도");
}

3.4 GetFullPath — 정규화의 IL

C#
public static string Normalize(string root, string userInput)
{
    return Path.GetFullPath(Path.Combine(root, userInput));
}
IL
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: call string Path::Combine(string, string)
IL_0008: call string Path::GetFullPath(string)        // ★ ..과 . 제거 + 절대 경로 반환
IL_000d: stloc.0
IL_0010: ldloc.0
IL_0011: ret

4. GetFullPath는 디스크 액세스를 한다

대부분의 Path 메서드와 달리 GetFullPath현재 작업 디렉터리(Environment.CurrentDirectory)를 참조해 상대 경로를 절대 경로로 바꿉니다. 또한 ../. 같은 탐색 문자를 실제로 적용해 정규화합니다.

C#
Path.GetFullPath("/save/../etc/passwd");          // /etc/passwd  ← .. 적용됨
Path.GetFullPath("relative/path");                // /current/working/dir/relative/path

이 동작이 디렉터리 탈출 방어의 핵심 도구입니다.


4. 실전 적용 — Unity 모바일 안전 패턴

4.1 세이브 파일 경로 — 항상 Path.Combine + persistentDataPath

C#
// ❌ Before — 백슬래시 하드코딩 (Windows 외 실패)
string path = Application.persistentDataPath + "\\save.json";

// ❌ Before — 슬래시 하드코딩 (작동은 하지만 코드 의도가 명확하지 않음)
string path = Application.persistentDataPath + "/save.json";

// ✅ After — Path.Combine
string path = Path.Combine(Application.persistentDataPath, "save.json");

// ✅ 폴더 구조가 깊을 때 — params 가변 인자
string path = Path.Combine(
    Application.persistentDataPath,
    "users",
    userId,
    "saves",
    "save.json");

4.2 사용자 입력 파일명을 받을 때 — 검증 필수

C#
// ❌ Before — 사용자가 ".." 또는 "/etc/passwd"를 입력하면 탈출
public static string LoadUserSlot(string slotName)
{
    string path = Path.Combine(SaveDir, slotName);
    return File.ReadAllText(path);
}
C#
// ✅ After — 정규화 후 SaveDir 안인지 검증
public static string LoadUserSlot(string slotName)
{
    string saveDir = Path.GetFullPath(SaveDir);
    string combined = Path.Combine(saveDir, slotName);
    string full = Path.GetFullPath(combined);

    if (!full.StartsWith(saveDir + Path.DirectorySeparatorChar)
        && full != saveDir)
    {
        throw new ArgumentException("잘못된 슬롯 이름입니다.");
    }
    return File.ReadAllText(full);
}

// 또는 더 단순한 화이트리스트 접근
public static string LoadUserSlotSafe(string slotName)
{
    if (slotName.Contains('/') || slotName.Contains('\\') || slotName.Contains(".."))
        throw new ArgumentException("잘못된 슬롯 이름");

    string path = Path.Combine(SaveDir, slotName + ".json");
    return File.ReadAllText(path);
}

4.3 임시 파일 — GetTempFileName과 GetRandomFileName

C#
// ❌ GetTempFileName — 실제 디스크에 빈 파일 생성, 65535개 한도
string tempPath = Path.GetTempFileName();           // /var/folders/.../tmpXXXX.tmp 가 디스크에 생긴다
File.WriteAllText(tempPath, data);
// 사용 후 반드시 삭제
File.Delete(tempPath);
C#
// ✅ GetRandomFileName — 문자열만 반환, 디스크 안 건드림
string randomName = Path.GetRandomFileName();        // "abc123.def" 형태 — 파일 안 만들어짐
string tempPath = Path.Combine(Path.GetTempPath(), randomName);
File.WriteAllText(tempPath, data);

GetTempFileName은 충돌하지 않는 파일을 보장해 주지만 65535개 누적 한도가 있어 대량 생성이 필요한 도구라면 위험합니다. 그냥 무작위 이름이 필요하면 GetRandomFileName + GetTempPath 조합을 씁니다.

4.4 파일 이름·확장자 분리 — Span 오버로드

.NET Core 2.1+부터 Path.GetFileNameGetExtensionReadOnlySpan<char> 오버로드를 가집니다. 새 string을 만들지 않고 부분 슬라이스를 돌려줍니다.

C#
// ❌ Before — 매 호출마다 새 string
foreach (var p in paths)
{
    string ext = Path.GetExtension(p);              // 새 string 객체
    if (ext == ".json") Process(p);
}

// ✅ After — Span 비교 (할당 0)
foreach (var p in paths)
{
    if (Path.GetExtension(p.AsSpan()).SequenceEqual(".json"))
        Process(p);
}

수천 개의 파일 목록을 검사할 때 GC 압박이 사라집니다.

4.5 확장자 변경 — 수동 자르기 vs ChangeExtension

C#
// ❌ Before — 직접 자르기는 다중 점·소문자 가정에 약함
string backup = filename.Substring(0, filename.LastIndexOf('.')) + ".bak";

// ✅ After — ChangeExtension은 점 없는 파일도 안전 처리
string backup = Path.ChangeExtension(filename, ".bak");

// ✅ 확장자 제거
string noExt = Path.ChangeExtension(filename, null);

5. 함정과 주의사항

5.1 ❌ 백슬래시 하드코딩

C#
// ❌ Windows 외에서 작동 안 함
string path = saveDir + "\\config\\game.json";
C#
// ✅ Path.Combine
string path = Path.Combine(saveDir, "config", "game.json");

5.2 ❌ 후행 슬래시 가정

C#
// ❌ 폴더 경로가 /로 끝나는지 안 끝나는지에 따라 동작이 달라짐
string parent = Path.GetDirectoryName("/Users/pjhara/save");        // /Users/pjhara
string parent2 = Path.GetDirectoryName("/Users/pjhara/save/");      // /Users/pjhara/save

Path.GetDirectoryName마지막 슬래시 이후의 부분을 파일 이름으로 간주합니다. 폴더 경로를 다룰 때는 후행 슬래시 처리에 주의하거나, DirectoryInfo를 사용합니다.

5.3 ❌ Combine에 절대 경로 흘려넣기

C#
// ❌ userInput이 "/etc/passwd"이면 saveDir 통째로 무시
string path = Path.Combine(saveDir, userInput);
File.ReadAllText(path);       // 시스템 파일 노출 위험
C#
// ✅ 사용자 입력은 IsPathRooted로 거른다
if (Path.IsPathRooted(userInput))
    throw new ArgumentException("절대 경로는 받지 않습니다");

// 또는 Path.GetFullPath로 정규화 후 폴더 검증
string full = Path.GetFullPath(Path.Combine(saveDir, userInput));
if (!full.StartsWith(Path.GetFullPath(saveDir)))
    throw new UnauthorizedAccessException();

5.4 ❌ Windows 드라이브 문자 가정

C#
// ❌ 절대 경로면 항상 "C:\"로 시작한다고 가정
if (path.StartsWith("C:\\"))
    // ...
C#
// ✅ 플랫폼 독립
if (Path.IsPathRooted(path))
    // ...

5.5 ❌ 사용자 파일 이름의 특수 문자

C#
// ❌ 사용자 닉네임에 ":"나 "/"가 있으면 파일 시스템 거부
string saveFile = Path.Combine(SaveDir, $"{nickname}.json");
File.WriteAllText(saveFile, data);                // IOException
C#
// ✅ Path.GetInvalidFileNameChars로 거르기
char[] invalid = Path.GetInvalidFileNameChars();
string clean = string.Concat(nickname.Where(c => !invalid.Contains(c)));
string saveFile = Path.Combine(SaveDir, $"{clean}.json");

윈도우는 < > : " / \ | ? * 같은 문자를 파일명에 못 쓰게 하고, Mac/Linux도 /\0은 금지합니다. 사용자 닉네임을 파일명으로 쓸 때는 반드시 정제 합니다.

5.6 ❌ Unity StreamingAssets에서 Path.Combine 사용 후 File.* 호출

C#
// ❌ Android에서 StreamingAssets는 압축 안에 있음 → File.ReadAllText 실패
string path = Path.Combine(Application.streamingAssetsPath, "data.json");
string json = File.ReadAllText(path);     // FileNotFoundException

Path.Combine 자체는 문제가 없지만, Android에서 streamingAssetsPath가 가리키는 곳은 APK 안의 jar:file://... 경로라 표준 File.*로 읽을 수 없습니다. UnityWebRequest로 접근합니다(PART 11-4 참조).


6. C# / .NET 버전별 변화

.NET 버전 변경점
.NET Framework 1.0+ Path 정적 클래스 도입
.NET Core 2.1+ ReadOnlySpan<char> 오버로드 — GetFileName(ReadOnlySpan<char>)
.NET Core 2.1+ Path.IsPathFullyQualifiedIsPathRooted로 부족한 부분 보완
.NET 5+ Path.JoinCombine과 비슷하지만 절대 경로를 받아도 앞부분을 무시하지 않는다
.NET 7+ Path.Exists — 파일·디렉터리 어느 쪽이든 존재 검사

.NET 5+ — Path.Join

C#
// Combine — 절대 경로면 앞 무시
string a = Path.Combine("/save", "/etc/passwd");        // "/etc/passwd"

// Join — 항상 그대로 이어 붙임
string b = Path.Join("/save", "/etc/passwd");           // "/save/etc/passwd"

Path.Join은 사용자 입력이 절대 경로일 때도 안전한 결합이 됩니다. 단, 디렉터리 탈출은 여전히 ..로 가능하므로 GetFullPath로 정규화 검증이 필요합니다.

.NET 7+ — Path.Exists

C#
// 기존 — 파일/디렉터리 따로 확인
if (File.Exists(path) || Directory.Exists(path)) { ... }

// .NET 7+
if (Path.Exists(path)) { ... }

7. 정리 — 이것만 기억하라

  • Path 메서드는 거의 모두 문자열만 다룬다. 파일이 실제 존재하는지 확인하지 않는다 (GetFullPath만 예외).
  • Path.Combine을 무조건 사용하고 슬래시·백슬래시를 직접 박지 않는다. IL이 단일 호출로 짧고, OS 구분자도 자동 처리된다.
  • + 연산자로 경로를 만들면 매번 string[] 배열이 새로 할당된다. 핫패스에서는 GC 부담.
  • Path.Combine은 절대 경로를 만나면 앞을 무시한다. 사용자 입력에 그대로 흘려보내면 디렉터리 탈출 위험. Path.Join 또는 정규화 검증 필요.
  • Path.GetFullPath로 정규화 후 폴더 안인지 StartsWith로 검증해 디렉터리 탈출을 막는다.
  • 사용자 입력 파일명은 Path.GetInvalidFileNameChars 로 정제한 뒤 결합한다.
  • Unity 세이브 경로는 Path.Combine(Application.persistentDataPath, ...) 가 표준. 백슬래시·하드코딩 금지.
  • .NET Core 2.1+의 ReadOnlySpan<char> 오버로드로 핫패스 GC를 피한다. Path.GetExtension(p.AsSpan()).SequenceEqual(".json") 같은 패턴.
  • Path.GetTempFileName은 디스크에 실제 파일을 만든다 (65535 한도). 그냥 무작위 이름만 필요하면 GetRandomFileName + GetTempPath.
반응형

+ Recent posts