C++17 std::filesystem 빠른 참조: 경로 정규화, exists()의 TOCTOU, remove_all 주의점
이 글의 핵심
std::filesystem의 기본 API뿐 아니라 경로 인코딩 문제, remove_all의 되돌릴 수 없는 위험, exists()와 실제 파일 작업 사이의 TOCTOU 레이스 컨디션까지 실무에서 반드시 알아야 할 함정을 다룹니다.
왜 std::filesystem이 필요한가
C++17 이전에는 파일과 디렉토리를 다루려면 POSIX의 dirent.h, stat, unistd.h와 Windows의 <windows.h> API를 조건부 컴파일로 나눠 써야 했습니다. Boost.Filesystem이 사실상 표준처럼 쓰이다가 C++17에서 std::filesystem으로 표준 라이브러리에 편입되면서, 경로 조작·디렉토리 순회·파일 상태 조회를 플랫폼 독립적인 코드로 작성할 수 있게 되었습니다.
다만 “표준화됐다”는 말이 “안전해졌다”는 뜻은 아닙니다. std::filesystem은 파일시스템이라는, 프로세스 외부의 가변 상태를 다루는 API이기 때문에 일반적인 컨테이너나 알고리즘과는 다른 종류의 함정이 있습니다. 이 글에서는 기본 API를 훑는 것에 그치지 않고, 실무에서 실제로 사고를 낼 수 있는 지점—경로 인코딩, 삭제 연산의 비가역성, TOCTOU 레이스 컨디션, 예외 처리 전략—을 중심으로 정리합니다.
기본 사용법
fs::path는 문자열이 아니라 경로를 표현하는 전용 타입입니다. 단순히 문자열을 감싼 것처럼 보이지만, 내부적으로는 경로를 루트 이름·루트 디렉터리·상대 부분으로 분해해서 저장하고, / 연산자로 안전하게 경로를 결합할 수 있게 해줍니다. filename(), extension(), parent_path() 같은 멤버 함수는 문자열을 직접 파싱하는 것보다 훨씬 신뢰할 수 있는데, 플랫폼별 구분자와 UNC 경로(Windows의 \\server\share 형태) 같은 예외 케이스를 라이브러리가 대신 처리해주기 때문입니다.
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
fs::path p = "/home/user/file.txt";
std::cout << "경로: " << p << std::endl;
std::cout << "파일명: " << p.filename() << std::endl;
std::cout << "확장자: " << p.extension() << std::endl;
std::cout << "디렉토리: " << p.parent_path() << std::endl;
}
여기서 반드시 짚어야 할 것이 인코딩 문제입니다. fs::path는 내부적으로 플랫폼 네이티브 문자 타입(POSIX는 char, Windows는 wchar_t)으로 경로를 저장합니다. 리눅스/macOS에서는 char 기반 API에 UTF-8 문자열을 그대로 넣어도 대체로 문제가 없지만, Windows에서 std::string(즉 narrow string)을 fs::path에 넘기면 시스템 로케일(ANSI 코드페이지)로 해석됩니다. 한글이 포함된 경로를 다국어 환경에서 다룰 때 이 부분에서 실제로 깨집니다. Windows를 지원해야 한다면 fs::path를 std::wstring으로 생성하거나, UTF-8 문자열을 명시적으로 MultiByteToWideChar 등으로 변환한 뒤 넘기는 정책을 프로젝트 초반에 정해두는 것이 안전합니다. “나중에 고치면 되지”라고 미루면, 한글 파일명이 섞인 사용자 데이터로 배포한 뒤에야 버그 리포트로 발견하게 됩니다.
경로 조작
경로 결합에 / 연산자를 쓰는 이유는 단순히 문자열을 이어붙이는 +와 다르게, 두 경로 사이에 구분자가 중복되거나 누락되는 문제를 자동으로 처리해주기 때문입니다. 예를 들어 p1이 이미 /로 끝나든 아니든 결과가 동일하게 정규화됩니다. 이것이 왜 중요하냐면, 문자열 연결로 경로를 만드는 코드는 플랫폼이나 입력값에 따라 이중 슬래시(//) 같은 비정상 경로를 만들어내고, 이런 경로가 일부 API에서만 실패하는 재현하기 어려운 버그로 이어지기 때문입니다.
fs::path p1 = "/home/user";
fs::path p2 = "documents";
fs::path p3 = "file.txt";
// 경로 결합
fs::path full = p1 / p2 / p3;
std::cout << full << std::endl; // /home/user/documents/file.txt
// 확장자 변경
fs::path file = "test.txt";
file.replace_extension(".md");
std::cout << file << std::endl; // test.md
// 절대 경로
fs::path rel = "file.txt";
fs::path abs = fs::absolute(rel);
std::cout << abs << std::endl;
fs::absolute()는 현재 작업 디렉터리(current working directory, CWD)를 기준으로 상대 경로를 절대 경로로 바꿔줍니다. 그런데 CWD는 프로세스 전역 상태라서, 멀티스레드 프로그램에서 다른 스레드가 fs::current_path()로 CWD를 바꿔버리면 같은 상대 경로도 시점에 따라 다른 절대 경로로 해석될 수 있습니다. 서버 프로그램이나 라이브러리 코드에서는 되도록 CWD에 의존하지 말고, 애플리케이션 시작 시점에 기준 디렉터리를 한 번 계산해서 그 값을 명시적으로 넘기는 방식을 권장합니다.
파일 존재 확인과 TOCTOU 문제
fs::exists()는 이름 그대로 “지금 이 순간” 경로가 존재하는지 확인합니다. 문제는 이 “지금 이 순간”과 그 다음 줄에서 실제로 파일을 여는 시점 사이에 시간차가 있다는 것입니다. 이 틈에 다른 프로세스나 스레드가 파일을 지우거나, 심볼릭 링크의 대상을 바꾸거나, 권한을 변경할 수 있습니다. 이를 TOCTOU(Time-Of-Check to Time-Of-Use) 레이스 컨디션이라고 부르며, 파일시스템 API 전반에 걸친 근본적인 한계입니다.
fs::path p = "test.txt";
if (fs::exists(p)) {
std::cout << "파일 존재" << std::endl;
}
if (fs::is_regular_file(p)) {
std::cout << "일반 파일" << std::endl;
}
if (fs::is_directory(p)) {
std::cout << "디렉토리" << std::endl;
}
실제로 이 문제를 겪은 적이 있습니다. 배치 작업으로 수백 개의 임시 파일을 처리하는 파이프라인에서 fs::exists(p)로 확인한 뒤 std::ifstream으로 여는 코드를 짰는데, 같은 디렉터리를 정리하는 별도의 클린업 스레드가 동시에 돌고 있었습니다. 대부분은 문제없이 동작했지만 부하가 몰리는 시간대에 간헐적으로 ifstream::open이 실패하는 로그가 쌓였습니다. 처음에는 디스크 I/O 문제인 줄 알고 한참을 삽질했는데, 원인은 exists() 확인과 실제 오픈 사이의 몇 밀리초 사이에 클린업 스레드가 파일을 지워버린 것이었습니다. 이런 종류의 버그는 로컬 개발 환경에서는 거의 재현되지 않고 운영 환경의 부하 상황에서만 간헐적으로 터지기 때문에 디버깅이 유독 괴롭습니다.
해결책은 “확인 후 사용”이 아니라 “사용하고 실패를 처리”하는 방향으로 코드를 바꾸는 것입니다. exists()로 사전 검사를 하는 대신 바로 열기를 시도하고, 실패했을 때 없는 파일인지 권한 문제인지를 오픈 실패의 에러 코드로 구분합니다. 완전히 레이스 컨디션을 없앨 수는 없지만(파일시스템 연산 자체가 원자적으로 묶여 있지 않으므로), 최소한 불필요한 이중 검사를 없애고 실패 처리 경로를 하나로 통일할 수 있습니다.
실전 예시
예시 1: 디렉토리 순회
fs::directory_iterator는 지정한 디렉터리의 바로 아래 항목만 순회합니다. directory_entry는 이미 stat 결과를 캐싱하고 있는 경우가 많아서, is_regular_file()이나 file_size()를 호출할 때마다 매번 시스템 콜을 다시 하지 않는 구현이 일반적입니다(단, 표준이 캐싱을 보장하지는 않으므로 구현체별 차이가 있을 수 있습니다).
#include <filesystem>
namespace fs = std::filesystem;
void listFiles(const fs::path& dir) {
for (const auto& entry : fs::directory_iterator(dir)) {
std::cout << entry.path() << std::endl;
if (entry.is_regular_file()) {
std::cout << " 파일, 크기: " << entry.file_size() << " bytes" << std::endl;
} else if (entry.is_directory()) {
std::cout << " 디렉토리" << std::endl;
}
}
}
int main() {
listFiles(".");
}
예시 2: 재귀 순회
fs::recursive_directory_iterator는 하위 디렉터리까지 깊이 우선으로 파고듭니다. 기본 동작에서는 심볼릭 링크를 따라 들어가지 않지만(대상이 디렉터리라도 순회하지 않음), fs::directory_options::follow_directory_symlink 옵션을 켜면 링크를 따라갑니다. 이 옵션을 별 생각 없이 켰다가 순환 심볼릭 링크(A가 B를 가리키고 B가 다시 A를 가리키는 구조)를 만나면 무한 루프에 빠지거나 스택이 감당할 수 없을 정도로 깊은 재귀에 들어갈 수 있으므로, 신뢰할 수 없는 디렉터리 트리를 순회할 때는 기본값을 유지하는 것이 안전합니다.
void listFilesRecursive(const fs::path& dir) {
for (const auto& entry : fs::recursive_directory_iterator(dir)) {
// 들여쓰기
int depth = entry.depth();
std::cout << std::string(depth * 2, ' ') << entry.path().filename() << std::endl;
}
}
int main() {
listFilesRecursive(".");
}
예시 3: 파일 검색
확장자로 파일을 필터링하는 흔한 패턴입니다. 대량의 파일을 재귀 순회하면서 매번 path()로부터 새 fs::path 객체를 복사해서 벡터에 담는 방식은 파일 수가 수만 개를 넘어가면 체감할 정도로 느려질 수 있습니다. 이런 경우 결과를 문자열로만 저장하거나, 호출자가 콜백을 넘겨서 즉시 처리하게 만드는 스트리밍 방식이 메모리와 시간 모두에서 유리합니다.
#include <vector>
std::vector<fs::path> findFiles(const fs::path& dir, const std::string& extension) {
std::vector<fs::path> result;
for (const auto& entry : fs::recursive_directory_iterator(dir)) {
if (entry.is_regular_file() && entry.path().extension() == extension) {
result.push_back(entry.path());
}
}
return result;
}
int main() {
auto cppFiles = findFiles(".", ".cpp");
std::cout << "C++ 파일 " << cppFiles.size() << "개:" << std::endl;
for (const auto& file : cppFiles) {
std::cout << " " << file << std::endl;
}
}
예시 4: 디렉토리 생성
create_directory는 부모 디렉터리가 이미 존재해야 하며 한 단계만 생성합니다. 중간 경로가 없을 수도 있는 상황이라면 create_directories(복수형)를 써야 하는데, 이 둘을 헷갈려서 “부모 디렉터리가 없다”는 에러를 겪는 경우가 흔합니다. 이름이 단수/복수로만 구분되어 있어 코드 리뷰에서도 놓치기 쉬운 부분이니, 중첩 경로를 다룰 때는 항상 복수형을 기본으로 쓰는 편이 안전합니다.
void createDirectoryStructure() {
fs::path base = "project";
// 디렉토리 생성
fs::create_directory(base);
fs::create_directory(base / "src");
fs::create_directory(base / "include");
fs::create_directory(base / "build");
// 중첩 디렉토리 생성
fs::create_directories(base / "src" / "utils" / "helpers");
std::cout << "디렉토리 구조 생성 완료" << std::endl;
}
int main() {
createDirectoryStructure();
}
파일 작업과 remove_all의 위험
파일 복사·이동·삭제 자체는 API만 보면 단순합니다. 하지만 이 섹션에서 가장 위험한 함수는 단연 fs::remove_all()입니다. 인자로 넘긴 경로가 디렉터리면 그 안의 모든 내용을 재귀적으로, 그것도 되돌릴 방법 없이 즉시 삭제합니다. 휴지통도 없고 확인 프롬프트도 없습니다.
// 파일 복사
fs::copy("source.txt", "dest.txt");
// 파일 이동
fs::rename("old.txt", "new.txt");
// 파일 삭제
fs::remove("file.txt");
// 디렉토리 삭제 (재귀)
fs::remove_all("directory");
// 파일 크기
uintmax_t size = fs::file_size("file.txt");
// 수정 시간
auto time = fs::last_write_time("file.txt");
빌드 스크립트를 정리하는 유틸리티를 작성하다가 상대 경로 기준을 착각해서 사고를 낼 뻔한 적이 있습니다. 프로그램이 build/ 디렉터리를 지우도록 fs::remove_all("build")를 호출하는 코드였는데, CI 환경에서 작업 디렉터리가 예상과 다른 위치(프로젝트 루트가 아니라 서브모듈 디렉터리)에서 실행되는 바람에, 지우려던 build/가 아니라 그 서브모듈 안의 다른 build/라는 이름의 소스 디렉터리를 지울 뻔했습니다. 다행히 CI 잡에서 dry-run 로그를 먼저 찍어보는 습관 때문에 실행 전에 발견했지만, 만약 그대로 실행됐다면 복구가 거의 불가능했을 것입니다. 이후로는 삭제 연산 앞에 반드시 fs::absolute()로 절대 경로를 계산하고, 그 절대 경로가 프로젝트 루트 하위에 있는지(즉 ..로 루트를 벗어나지 않는지) 문자열 비교로 한 번 더 검증하는 가드를 넣는 습관이 생겼습니다. “내 스크립트는 항상 프로젝트 루트에서 실행될 것”이라는 가정은 로컬에서는 맞아도 CI나 다른 사람의 환경에서는 쉽게 깨집니다.
또한 remove_all은 심볼릭 링크를 만나면 링크 자체를 지우고 링크가 가리키는 대상까지 지우지는 않는 것이 표준 동작이지만, 경로 안의 중간 디렉터리가 심볼릭 링크인 경우(예: /data/current가 실제로는 /data/releases/v3를 가리키는 심링크)에는 그 심링크를 따라 들어가서 진짜 데이터를 지워버릴 수 있습니다. 배포 스크립트에서 “current” 심링크 패턴을 쓰는 경우 이 부분을 특히 조심해야 합니다.
경로 정규화
fs::path p = "/home/user/../user/./file.txt";
// 정규화
fs::path canonical = fs::canonical(p);
std::cout << canonical << std::endl; // /home/user/file.txt
// 상대 경로
fs::path rel = fs::relative("/home/user/file.txt", "/home");
std::cout << rel << std::endl; // user/file.txt
fs::canonical()은 .과 ..을 실제로 해석하고 심볼릭 링크까지 전부 따라가서 최종 실제 경로를 반환합니다. 여기서 중요한 제약은, 경로가 실제로 존재하지 않으면 fs::filesystem_error 예외를 던진다는 점입니다. 존재 여부와 무관하게 경로 문자열만 정리하고 싶다면 fs::weakly_canonical()을 쓰거나, fs::path::lexically_normal()로 파일시스템에 접근하지 않고 순수하게 문자열 수준에서만 정규화하는 방법을 씁니다. 보안 관점에서는 사용자 입력으로 받은 경로가 허용된 루트 디렉터리를 벗어나지 않는지 검사할 때(경로 순회 공격, path traversal 방어) canonical()로 실제 경로를 확정한 뒤 접두사를 비교하는 패턴이 표준적으로 쓰입니다.
예외 vs error_code, 실무에서는 무엇을 선택할까
filesystem 관련 함수는 대부분 두 가지 오버로드를 제공합니다. 인자가 없으면 실패 시 fs::filesystem_error 예외를 던지고, std::error_code&를 마지막 인자로 넘기면 예외 대신 그 객체에 에러가 기록됩니다.
// ❌ 예외 무시
fs::remove("file.txt"); // 파일 없으면 예외
// ✅ 예외 처리
try {
fs::remove("file.txt");
} catch (const fs::filesystem_error& e) {
std::cout << "에러: " << e.what() << std::endl;
}
// ✅ 에러 코드 사용
std::error_code ec;
fs::remove("file.txt", ec);
if (ec) {
std::cout << "에러: " << ec.message() << std::endl;
}
실무에서는 error_code 오버로드를 기본으로 선호하는 편입니다. 이유는 두 가지입니다. 첫째, 파일시스템 연산은 네트워크 호출과 비슷하게 “실패가 예외적인 상황이 아니라 흔히 벌어지는 정상적인 분기”에 가깝습니다. 파일이 없을 수도, 권한이 없을 수도, 디스크가 꽉 찼을 수도 있는데 이런 흔한 케이스마다 예외를 던지고 잡는 것은 제어 흐름을 예외에 의존하게 만들고, 예외 처리 경로가 많은 컴파일러에서 여전히 코드 크기와 실행 비용에 영향을 줍니다. 둘째, 대량의 파일을 순회하는 루프 안에서 예외를 쓰면 하나의 실패로 전체 루프가 끊기기 쉬워서, 개별 항목의 실패를 흡수하고 나머지를 계속 처리하려면 결국 try-catch를 루프 내부 깊숙이 넣어야 하는데, 이는 error_code로 단순 if 분기하는 것보다 코드가 지저분해집니다. 반대로 “이 연산이 실패하면 프로그램을 계속 진행할 이유가 없다”는 초기화 단계의 치명적 실패라면 예외로 즉시 전파시키는 편이 낫습니다. 즉 선택 기준은 “실패가 흔한 정상 분기인가, 아니면 정말 예외적인 상황인가”입니다.
자주 발생하는 문제
문제 1: 심볼릭 링크
fs::path link = "symlink";
// ❌ 심볼릭 링크 따라감
if (fs::is_regular_file(link)) {
// 링크 대상 체크
}
// ✅ 심볼릭 링크 자체 체크
if (fs::is_symlink(link)) {
std::cout << "심볼릭 링크" << std::endl;
}
is_regular_file(), is_directory() 같은 상태 조회 함수는 기본적으로 심볼릭 링크를 따라가서 링크가 가리키는 최종 대상의 속성을 반환합니다. 이 동작 자체는 대부분의 경우 원하는 것이지만, 백업 도구나 파일 동기화 도구를 만들 때는 “이 항목이 링크인지”를 먼저 판별하지 않으면 링크를 실제 파일처럼 복사해버리거나, 순회 중 의도치 않게 링크 바깥의 디렉터리까지 처리 대상에 포함시키는 문제가 생깁니다. 링크를 따라가지 않고 링크 자체의 속성을 보고 싶다면 fs::symlink_status()를 사용합니다.
문제 2: 권한 문제
// ❌ 권한 없는 디렉토리
for (const auto& entry : fs::directory_iterator("/root")) {
// 예외 발생
}
// ✅ 예외 처리
try {
for (const auto& entry : fs::directory_iterator("/root")) {
std::cout << entry.path() << std::endl;
}
} catch (const fs::filesystem_error& e) {
std::cout << "접근 거부" << std::endl;
}
directory_iterator도 error_code를 받는 생성자 오버로드가 있어서, 권한이 없는 디렉터리를 순회 중간에 만나도 예외 없이 반복을 종료시킬 수 있습니다. 여러 사용자 디렉터리를 스캔하는 배치 작업이라면, 하나의 권한 오류로 전체 스캔이 예외로 중단되는 것보다 error_code 버전으로 해당 항목만 건너뛰고 계속 진행하는 편이 운영 관점에서 더 견고합니다.
파일 정보
fs::path p = "file.txt";
// 권한
auto perms = fs::status(p).permissions();
// 크기
uintmax_t size = fs::file_size(p);
// 수정 시간
auto mtime = fs::last_write_time(p);
// 공간 정보
fs::space_info space = fs::space(".");
std::cout << "전체: " << space.capacity << std::endl;
std::cout << "사용 가능: " << space.available << std::endl;
fs::status()는 심볼릭 링크를 따라간 최종 대상의 상태를 반환하고, fs::symlink_status()는 링크 자체의 상태를 반환합니다. 이 두 함수를 혼동해서 쓰면 위의 심볼릭 링크 문제와 같은 종류의 버그가 재발하니, 파일 상태를 다루는 코드를 작성할 때는 항상 “지금 이 호출이 링크를 따라가는가, 아닌가”를 의식적으로 확인하는 것이 좋습니다. last_write_time()이 반환하는 fs::file_time_type은 C++20 이전까지는 시스템 시계와 직접 비교하기 까다로웠는데(구현별로 clock epoch가 달랐기 때문), C++20부터는 std::chrono::file_clock으로 표준화되어 std::chrono::system_clock과의 변환이 명확해졌습니다. C++17만 타겟팅한다면 이 변환 함수가 구현별로 다를 수 있다는 점을 감안해야 합니다.
관련 개념 흐름 정리
파일 하나를 안전하게 다루는 전체 흐름을 도식으로 정리하면 다음과 같습니다. 검사와 사용 사이의 간격이 어디서 발생하는지, 그리고 그 간격을 줄이기 위해 어떤 대안을 선택할 수 있는지를 보여줍니다.
flowchart TD
A["경로 문자열 입력"] --> B["fs::path 생성"]
B --> C{"Windows 환경?"}
C -->|Yes| D["wstring/UTF-8 인코딩 정책 확인"]
C -->|No| E["UTF-8 그대로 사용"]
D --> F["exists() 사전 검사 (선택적)"]
E --> F
F --> G["실제 파일 열기/삭제 시도"]
G --> H{"성공?"}
H -->|No| I["error_code로 원인 분기\n(없음/권한/레이스)"]
H -->|Yes| J["연산 수행"]
I --> K["재시도 또는 사용자에게 보고"]
J --> L["완료"]
이 흐름에서 F(사전 검사)와 G(실제 사용) 사이가 바로 TOCTOU 레이스 컨디션이 발생하는 지점입니다. 사전 검사를 아예 생략하고 G에서 바로 실패를 처리하도록 설계하면 이 간격 자체를 없앨 수 있다는 점을 도식에서도 확인할 수 있습니다.
FAQ
Q1: filesystem은 언제 사용하나요?
A: 빌드 도구, 설정 파일 로더, 로그 로테이션, 캐시 디렉터리 관리처럼 파일/디렉토리 작업이 핵심인 프로그램에 적합합니다. 단순히 한두 개 파일을 여닫는 정도라면 <fstream>만으로 충분하고, 경로 조작·순회·상태 조회가 복잡해질 때 std::filesystem의 이점이 커집니다.
Q2: 기존 방식(POSIX API, WinAPI)과 차이는?
A: 크로스 플랫폼 코드를 조건부 컴파일 없이 작성할 수 있고, fs::path라는 전용 타입 덕분에 문자열 파싱 실수(구분자, 인코딩)가 줄어듭니다. 다만 아주 세밀한 제어(예: 특정 플랫폼 전용 플래그)가 필요하면 여전히 네이티브 API로 내려가야 하는 경우가 있습니다.
Q3: 성능은 기존 시스템 콜과 비교해 어떤가요?
A: 대부분의 구현에서 기존 시스템 콜과 비슷한 수준이지만, fs::path 객체 생성·복사가 잦은 코드(특히 순회 루프 안에서 매번 새 path를 만드는 경우)는 오버헤드가 누적될 수 있습니다. 네트워크 파일시스템(NAS, NFS) 위에서는 각 상태 조회 호출 자체의 latency가 라이브러리 오버헤드보다 훨씬 큰 병목이 됩니다.
Q4: 에러 처리는 예외와 error_code 중 뭘 기본값으로 삼아야 하나요?
A: 위 “예외 vs error_code” 절에서 다뤘듯, 실패가 흔한 정상 분기(파일 없음, 권한 없음 등)라면 error_code, 복구 불가능한 치명적 실패라면 예외가 자연스럽습니다. 하나의 프로젝트 안에서는 이 기준을 문서화해 일관되게 적용하는 것이 유지보수에 유리합니다.
Q5: 경로 구분자는 어떻게 처리되나요?
A: fs::path는 내부적으로 플랫폼 네이티브 구분자를 사용하고, operator/로 결합하면 자동으로 올바른 구분자가 삽입됩니다. 다만 문자열로 직접 출력할 때 원하는 구분자 형태(예: 항상 /로 통일)가 필요하면 generic_string()을 사용해야 합니다.
Q6: Filesystem 학습에 참고할 만한 자료는?
A: cppreference.com의 std::filesystem 문서가 가장 정확하고, 예외/error_code 오버로드 목록도 함수별로 잘 정리되어 있습니다. Boost.Filesystem 문서는 표준화 이전 설계 배경을 이해하는 데 도움이 됩니다.
filesystem 코드에서 습관으로 둘 네 가지
std::filesystem은 표면적으로는 단순한 유틸리티 라이브러리처럼 보이지만, 실제로는 프로세스 외부의 가변 상태(다른 프로세스, 다른 스레드, 네트워크 파일시스템)를 다루는 API라는 점을 항상 염두에 둬야 합니다. 경로 인코딩을 플랫폼별로 명확히 정하고, remove_all 같은 비가역적 연산 앞에는 절대 경로 검증 가드를 넣고, exists()와 실제 연산 사이의 TOCTOU 간격을 인지하고, 에러 처리 전략(예외 vs error_code)을 프로젝트 차원에서 통일하는 것—이 네 가지만 습관화해도 filesystem 관련 버그의 상당수를 예방할 수 있습니다.