Windows에서만 파일을 못 찾을 때: std::filesystem 경로 연산, 디렉터리 순회, 권한

들어가며: “경로가 Windows에서만 깨져요”

ifstream으로 열었는데 Linux에서는 되고 Windows에서는 안 된다

설정 파일 config/settings.json을 읽는 코드를 작성했습니다. Linux와 macOS에서는 잘 동작하는데, Windows에서만 “파일을 찾을 수 없습니다” 에러가 납니다. 원인: 경로 구분자가 다릅니다. Linux/macOS는 /, Windows는 \를 사용합니다. "config/settings.json"처럼 /를 하드코딩하면 Windows에서도 대부분 동작하지만, 경로를 문자열 연결로 조합할 때 path + "/" + filename처럼 하드코딩하면 config\settings.json과 혼용되며 문제가 생길 수 있습니다. 더 큰 문제는 실행 파일 기준 경로 vs 작업 디렉토리 기준 경로를 혼동하는 것입니다. 해결: C++17 std::filesystem의 path를 사용하면 OS에 맞는 구분자로 자동 변환되고, operator/로 경로를 안전하게 조합할 수 있습니다.

// ❌ 나쁜 예: 문자열 연결
std::string configPath = baseDir + "/config/settings.json";
// ✅ 좋은 예: std::filesystem::path
#include <filesystem>
namespace fs = std::filesystem;
fs::path configPath = fs::path(baseDir) / "config" / "settings.json";
std::ifstream file(configPath);

이 글을 읽으면:

  • std::filesystem::path로 크로스 플랫폼 경로를 다룰 수 있습니다.
  • 디렉토리 순회, 파일 복사·삭제·이동을 할 수 있습니다.
  • 파일 권한(permissions)을 확인하고 설정할 수 있습니다.
  • 실전에서 자주 겪는 에러와 프로덕션 패턴을 알 수 있습니다.

로그 디렉토리 부재, 플러그인 스캔, 임시 파일 누적: 흔한 파일 시스템 문제

시나리오 1: 로그 디렉토리가 없어서 프로그램이 크래시

문제: logs/app.log에 로그를 쓰려고 하는데, logs 폴더가 없으면 ofstream 열기가 실패합니다. 해결: std::filesystem::create_directories()로 상위 디렉토리를 먼저 생성합니다.

#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
void ensureLogDir(const fs::path& logPath) {
    fs::path dir = logPath.parent_path();
    if (!dir.empty() && !fs::exists(dir)) {
        fs::create_directories(dir);
    }
}
int main() {
    fs::path logPath = "logs/app.log";
    ensureLogDir(logPath);
    std::ofstream log(logPath);
    log << "Application started\n";
}

시나리오 2: 플러그인 폴더의 .so/.dll 파일만 스캔해야 함

문제: plugins/ 안의 동적 라이브러리만 로드해야 하는데, .txt, .md 등 다른 파일도 섞여 있습니다. 해결: directory_iterator로 순회하면서 path.extension()으로 필터링합니다.

#include <filesystem>
#include <iostream>
#include <vector>
namespace fs = std::filesystem;
std::vector<fs::path> findPlugins(const fs::path& pluginDir) {
    std::vector<fs::path> plugins;
    for (const auto& entry : fs::directory_iterator(pluginDir)) {
        if (entry.is_regular_file()) {
            std::string ext = entry.path().extension().string();
#if defined(_WIN32)
            if (ext == ".dll") plugins.push_back(entry.path());
#else
            if (ext == ".so") plugins.push_back(entry.path());
#endif
        }
    }
    return plugins;
}

시나리오 3: 사용자 업로드 파일의 권한이 너무 열려 있음

문제: 업로드된 파일이 chmod 777처럼 모든 사용자가 쓰기 가능해 보안 위험이 됩니다. 해결: std::filesystem::permissions()로 파일 생성 후 권한을 제한합니다.

#include <filesystem>
namespace fs = std::filesystem;
void restrictUploadedFile(const fs::path& file) {
    // 소유자 읽기/쓰기만 허용
    fs::permissions(file, fs::perms::owner_read | fs::perms::owner_write);
}

시나리오 4: 임시 파일을 정리하지 않아 디스크가 가득 참

문제: 크래시나 예외로 인해 임시 파일이 삭제되지 않고 쌓입니다. 해결: RAII로 임시 파일을 감싸서 스코프를 벗어날 때 자동 삭제합니다.

#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
class TempFile {
    fs::path path_;
public:
    TempFile(const fs::path& base = fs::temp_directory_path()) {
        path_ = base / ("tmp_" + std::to_string(std::rand()) + ".tmp");
        std::ofstream(path_) << "";
    }
    ~TempFile() {
        if (fs::exists(path_)) fs::remove(path_);
    }
    const fs::path& path() const { return path_; }
};

std::filesystem 개요

아키텍처

flowchart TB
    subgraph path["path: 경로 표현"]
        P1[문자열/경로 조합]
        P2[operator/]
        P3[extension, stem, filename]
        P1 --> P2 --> P3
    end
    subgraph ops[파일/디렉토리 연산]
        O1[exists, is_regular_file]
        O2[create_directories, remove]
        O3[copy, rename]
        O1 --> O2 --> O3
    end
    subgraph iter[순회]
        I1[directory_iterator]
        I2[recursive_directory_iterator]
        I1 --> I2
    end
    path --> ops
    path --> iter

위 다이어그램 설명: path는 경로를 표현하고 조합하며, exists·create_directories·copy 등으로 파일/디렉토리를 조작합니다. directory_iterator는 한 단계, recursive_directory_iterator는 하위까지 순회합니다.

컴파일 옵션

# C++17 필요
g++ -std=c++17 -o fs_demo fs_demo.cpp
# Windows (MSVC)
# /std:c++17

헤더와 네임스페이스

#include <filesystem> 후 namespace fs = std::filesystem;으로 축약하는 것이 관례입니다.


경로(path) 연산

path 생성과 조합

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    // 1) 문자열로 생성
    fs::path p1("/home/user/config");
    fs::path p2("settings.json");
    // 2) operator/ 로 경로 조합 (OS 구분자 자동 적용)
    fs::path full = p1 / p2;
    std::cout << full << "\n";  // Linux: /home/user/config/settings.json
                                // Windows: /home/user/config\settings.json
    // 3) 현재 디렉토리
    fs::path cwd = fs::current_path();
    std::cout << "CWD: " << cwd << "\n";
    // 4) 실행 파일 경로 (C++23 전에는 플랫폼별 API 필요)
    // Linux: /proc/self/exe, Windows: GetModuleFileName
}

path는 문자열처럼 생성하며, operator/로 조합하면 OS에 맞는 구분자가 삽입됩니다. current_path()는 프로세스의 작업 디렉토리를 반환합니다.

path 구성 요소 추출

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path p = "/home/user/projects/app/src/main.cpp";
    std::cout << "filename:  " << p.filename()   << "\n";   // main.cpp
    std::cout << "stem:      " << p.stem()       << "\n";   // main
    std::cout << "extension: " << p.extension()  << "\n";   // .cpp
    std::cout << "parent:    " << p.parent_path() << "\n"; // .../src
    std::cout << "root_name: " << p.root_name()  << "\n";   // (Linux: "")
    std::cout << "root_path: " << p.root_path()  << "\n";   // /
    // 상대 경로로 변환
    fs::path base = "/home/user/projects";
    fs::path rel = fs::relative(p, base);
    std::cout << "relative:  " << rel << "\n";  // app/src/main.cpp
}

filename()은 마지막 요소, stem()은 확장자 제외, extension()은 .cpp 같은 확장자, parent_path()는 부모 경로입니다. relative(p, base)는 base 기준 상대 경로를 만듭니다.

경로 정규화와 절대 경로

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path p = "config/../config/settings.json";
    // 정규화: . 과 ...제거
    fs::path canonical = fs::weakly_canonical(p);
    std::cout << "weakly_canonical: " << canonical << "\n";
    // 절대 경로 (경로가 존재해야 함)
    if (fs::exists(p)) {
        fs::path abs = fs::absolute(p);
        std::cout << "absolute: " << abs << "\n";
        fs::path can = fs::canonical(p);  // 심볼릭 링크 해석, 존재 필수
        std::cout << "canonical: " << can << "\n";
    }
    // 경로 비교
    fs::path a = "/a/b/c";
    fs::path b = "/a/b/c";
    std::cout << "equal: " << (a == b) << "\n";
}

weakly_canonical은 ./..를 정리하며, 경로가 없어도 동작합니다. canonical은 경로가 존재해야 하며 심볼릭 링크를 해석합니다. absolute는 현재 경로 기준 절대 경로를 만듭니다.

완전한 경로 조작 예제

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o path_demo path_demo.cpp && ./path_demo
#include <filesystem>
#include <iostream>
#include <string>
namespace fs = std::filesystem;
int main() {
    fs::path base = fs::current_path();
    fs::path configDir = base / "config";
    fs::path settingsPath = configDir / "app" / "settings.json";
    std::cout << "Base:       " << base << "\n";
    std::cout << "Config dir: " << configDir << "\n";
    std::cout << "Settings:   " << settingsPath << "\n";
    std::cout << "Filename:   " << settingsPath.filename() << "\n";
    std::cout << "Stem:       " << settingsPath.stem() << "\n";
    std::cout << "Ext:        " << settingsPath.extension() << "\n";
    // 문자열로 변환
    std::string strPath = settingsPath.string();   // OS 네이티브 형식
    std::string u8Path = settingsPath.u8string(); // UTF-8 (C++20)
}

디렉토리 순회

directory_iterator: 한 단계만

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path dir = ".";
    for (const auto& entry : fs::directory_iterator(dir)) {
        std::cout << entry.path().filename() << " - ";
        if (entry.is_regular_file()) {
            std::cout << "file, " << fs::file_size(entry) << " bytes\n";
        } else if (entry.is_directory()) {
            std::cout << "dir\n";
        } else if (entry.is_symlink()) {
            std::cout << "symlink -> " << fs::read_symlink(entry) << "\n";
        }
    }
}

directory_iterator는 지정 디렉토리의 직계 항목만 순회합니다. entry.path()로 경로, entry.is_regular_file() 등으로 타입을 확인합니다.

recursive_directory_iterator: 하위까지

#include <filesystem>
#include <iostream>
#include <string>
namespace fs = std::filesystem;
void listAll(const fs::path& dir) {
    for (const auto& entry : fs::recursive_directory_iterator(dir,
            fs::directory_options::skip_permission_denied)) {
        // depth(): 현재 깊이 (0 = 최상위)
        std::string indent(entry.depth() * 2, ' ');
        std::cout << indent << entry.path().filename() << "\n";
    }
}

recursive_directory_iterator는 하위 디렉토리까지 재귀적으로 순회합니다. entry.depth()로 들여쓰기 깊이를 얻으며, skip_permission_denied로 권한 없는 디렉토리는 건너뜁니다.

특정 확장자만 필터링

#include <filesystem>
#include <string>
#include <vector>
namespace fs = std::filesystem;
std::vector<fs::path> findFilesByExtension(const fs::path& dir,
                                           const std::string& ext) {
    std::vector<fs::path> result;
    for (const auto& entry : fs::recursive_directory_iterator(dir,
            fs::directory_options::skip_permission_denied)) {
        if (entry.is_regular_file() && entry.path().extension() == ext) {
            result.push_back(entry.path());
        }
    }
    return result;
}
int main() {
    auto cppFiles = findFilesByExtension(".", ".cpp");
    for (const auto& p : cppFiles) {
        std::cout << p << "\n";
    }
}

디렉토리 순회 에러 처리

#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
bool safeListDir(const fs::path& dir) {
    std::error_code ec;
    auto iter = fs::directory_iterator(dir, ec);
    if (ec) {
        std::cerr << "Cannot open " << dir << ": " << ec.message() << "\n";
        return false;
    }
    for (const auto& entry : fs::directory_iterator(dir, ec)) {
        if (ec) {
            std::cerr << "Iterator error: " << ec.message() << "\n";
            return false;
        }
        std::cout << entry.path().filename() << "\n";
    }
    return true;
}

directory_iterator 생성자와 증가 시 std::error_code를 넘기면 예외 대신 에러 코드로 실패를 처리할 수 있습니다.


파일 연산

파일 존재 및 타입 확인

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
void checkPath(const fs::path& p) {
    if (!fs::exists(p)) {
        std::cout << "Does not exist\n";
        return;
    }
    if (fs::is_regular_file(p)) {
        std::cout << "Regular file, size: " << fs::file_size(p) << "\n";
    } else if (fs::is_directory(p)) {
        std::cout << "Directory\n";
    } else if (fs::is_symlink(p)) {
        std::cout << "Symlink -> " << fs::read_symlink(p) << "\n";
    } else if (fs::is_block_file(p)) {
        std::cout << "Block device\n";
    } else if (fs::is_character_file(p)) {
        std::cout << "Character device\n";
    } else if (fs::is_fifo(p)) {
        std::cout << "FIFO\n";
    } else if (fs::is_socket(p)) {
        std::cout << "Socket\n";
    }
}

exists()로 존재 여부, is_regular_file() 등으로 타입을 구분합니다. file_size()는 일반 파일에만 사용 가능합니다.

디렉토리 생성

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path dir = "a/b/c/d";
    // create_directories: 중간 경로까지 모두 생성
    bool created = fs::create_directories(dir);
    std::cout << "Created: " << created << "\n";
    // create_directory: 한 단계만 (부모가 있어야 함)
    fs::create_directory("single_dir");
    // 이미 있으면 false, 없으면 생성 후 true
    if (fs::create_directories("logs/2026/03")) {
        std::cout << "Log dir created\n";
    }
}

create_directories는 mkdir -p처럼 중간 경로를 모두 만들고, create_directory는 한 단계만 만듭니다.

파일 복사

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path src = "source.txt";
    fs::path dst = "backup/copy.txt";
    // 옵션: overwrite_existing, recursive(디렉토리용), copy_symlinks
    fs::copy_options opts = fs::copy_options::overwrite_existing;
    fs::copy_file(src, dst, opts);  // 파일만 복사
    // 디렉토리 전체 복사
    fs::copy("src_dir", "dst_dir",
             fs::copy_options::recursive | fs::copy_options::overwrite_existing);
}

copy_file은 단일 파일, copy는 디렉토리도 recursive로 복사할 수 있습니다. overwrite_existing이 없으면 기존 파일이 있을 때 에러가 납니다.

파일 이동/이름 변경

#include <filesystem>
namespace fs = std::filesystem;
int main() {
    fs::path oldName = "temp.txt";
    fs::path newName = "final.txt";
    fs::rename(oldName, newName);  // 같은 파일시스템: 원자적 이동
    // 다른 파일시스템: 복사 후 삭제
    fs::path otherFs = "/mnt/other/disk/file.txt";
    fs::rename(oldName, otherFs);  // 구현에 따라 copy+remove로 동작
}

rename은 같은 파일시스템 내에서는 원자적으로 동작하며, 다른 파일시스템으로 옮길 때는 복사 후 삭제로 처리될 수 있습니다.

파일 삭제

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path file = "junk.txt";
    fs::path dir = "empty_dir";
    // 파일 삭제
    if (fs::remove(file)) {
        std::cout << "Removed " << file << "\n";
    }
    // 빈 디렉토리 삭제
    fs::remove(dir);
    // 디렉토리 전체 삭제 (하위 포함)
    fs::path tree = "to_delete";
    std::uintmax_t n = fs::remove_all(tree);
    std::cout << "Removed " << n << " items\n";
}

remove는 파일 또는 빈 디렉토리만 삭제하며, remove_all은 하위를 포함해 모두 삭제하며 삭제된 항목 수를 반환합니다.

심볼릭 링크

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path target = "real_file.txt";
    fs::path link = "symlink_to_file";
    fs::create_symlink(target, link);  // 상대 경로 링크
    // fs::create_directory_symlink(target, "symlink_to_dir");  // 디렉토리용
    if (fs::is_symlink(link)) {
        fs::path resolved = fs::read_symlink(link);
        std::cout << "Link points to: " << resolved << "\n";
        // 실제 파일 경로 (절대)
        fs::path canonical = fs::canonical(link);
        std::cout << "Canonical: " << canonical << "\n";
    }
}

create_symlink로 심볼릭 링크를 만들고, read_symlink로 대상 경로를 읽습니다. canonical은 링크를 따라가 최종 경로를 반환합니다.

완전한 파일 복사 유틸리티

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o fs_copy fs_copy.cpp && ./fs_copy src dst
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
bool copyRecursive(const fs::path& src, const fs::path& dst) {
    std::error_code ec;
    if (!fs::exists(src, ec)) {
        std::cerr << "Source does not exist: " << src << "\n";
        return false;
    }
    if (fs::is_regular_file(src)) {
        fs::create_directories(dst.parent_path(), ec);
        if (ec) {
            std::cerr << "Cannot create parent: " << ec.message() << "\n";
            return false;
        }
        fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
        if (ec) {
            std::cerr << "Copy failed: " << ec.message() << "\n";
            return false;
        }
        return true;
    }
    if (fs::is_directory(src)) {
        fs::create_directories(dst, ec);
        for (const auto& entry : fs::directory_iterator(src)) {
            fs::path newDst = dst / entry.path().filename();
            if (!copyRecursive(entry.path(), newDst)) {
                return false;
            }
        }
        return true;
    }
    std::cerr << "Unsupported type: " << src << "\n";
    return false;
}
int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "Usage: " << argv[0] << " <source> <destination>\n";
        return 1;
    }
    return copyRecursive(argv[1], argv[2]) ? 0 : 1;
}

권한(permissions)

권한 확인

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
void showPermissions(const fs::path& p) {
    if (!fs::exists(p)) return;
    auto perms = fs::status(p).permissions();
    auto has = [perms](fs::perms p) { return (perms & p) != fs::perms::none; };
    std::cout << "Owner read:  " << has(fs::perms::owner_read) << "\n";
    std::cout << "Owner write: " << has(fs::perms::owner_write) << "\n";
    std::cout << "Owner exec:  " << has(fs::perms::owner_exec) << "\n";
    std::cout << "Group read:  " << has(fs::perms::group_read) << "\n";
    std::cout << "Others read: " << has(fs::perms::others_read) << "\n";
}

fs::status(p).permissions()로 권한 비트를 얻으며, owner_read 등과 AND 연산으로 확인합니다.

권한 설정

#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
    fs::path file = "secret.txt";
    // 소유자만 읽기/쓰기 (600)
    fs::permissions(file,
        fs::perms::owner_read | fs::perms::owner_write,
        fs::perm_options::replace);
    // 실행 권한 추가 (기존 유지)
    fs::permissions(file,
        fs::perms::owner_exec,
        fs::perm_options::add);
    // 권한 제거
    fs::permissions(file,
        fs::perms::others_read | fs::perms::others_write,
        fs::perm_options::remove);
}

replace는 지정한 권한으로 교체, add는 추가, remove는 제거합니다. owner_read | owner_write는 chmod 600에 해당합니다.

권한과 함께 파일 생성

#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
void createRestrictedFile(const fs::path& path, const std::string& content) {
    std::ofstream out(path);
    if (!out) return;
    out << content;
    out.close();
    // 생성 직후 권한 제한 (소유자만 읽기/쓰기)
    fs::permissions(path,
        fs::perms::owner_read | fs::perms::owner_write,
        fs::perm_options::replace);
}

파일을 쓴 뒤 permissions로 권한을 제한하면, 업로드·설정 파일 등에 적용할 수 있습니다.


filesystem_error 종료, 권한 거부 순회 중단, copy_file 덮어쓰기 같은 에러

문제 1: filesystem_error 예외로 프로그램이 종료됨

증상: fs::copy_file이나 fs::remove 호출 시 filesystem_error가 발생합니다. 원인: 기본적으로 std::filesystem 함수는 실패 시 예외를 던집니다. 파일이 없거나 권한이 없으면 예외가 발생합니다. 해결:

// ❌ 예외 발생
fs::remove("nonexistent.txt");  // throws filesystem_error
// ✅ error_code 사용
std::error_code ec;
fs::remove("nonexistent.txt", ec);
if (ec) {
    std::cerr << "Remove failed: " << ec.message() << "\n";
}

문제 2: file_size가 디렉토리에 호출되어 에러

증상: fs::file_size(dir) 호출 시 예외 또는 잘못된 결과. 원인: file_size는 일반 파일에만 사용 가능합니다. 해결:

// ❌ 잘못된 사용
auto size = fs::file_size(somePath);  // 디렉토리면 에러
// ✅ 타입 확인 후 호출
if (fs::is_regular_file(path)) {
    auto size = fs::file_size(path);
}

문제 3: recursive_directory_iterator가 권한 거부로 중단

증상: /나 시스템 디렉토리를 순회하다가 예외가 발생합니다. 원인: 권한이 없는 디렉토리에 접근하면 기본적으로 예외가 납니다. 해결:

// ✅ skip_permission_denied 옵션
for (const auto& entry : fs::recursive_directory_iterator("/",
        fs::directory_options::skip_permission_denied)) {
    // 권한 없는 디렉토리는 건너뜀
}

문제 4: Windows에서 경로가 깨져 보임

증상: path << u8"한글" 출력 시 깨집니다. 원인: Windows 콘솔 기본 인코딩이 UTF-8이 아닐 수 있습니다. 해결:

// Windows: 콘솔 UTF-8 설정
#ifdef _WIN32
#include <windows.h>
SetConsoleOutputCP(65001);
#endif
// 또는 UTF-8 문자열로 꺼내기
auto u8 = path.u8string();   // C++17: std::string, C++20: std::u8string

C++20부터 u8string()은 std::string이 아니라 std::u8string을 반환하므로, std::string u8 = path.u8string();처럼 쓴 C++17 코드는 C++20으로 올리면 컴파일 에러가 납니다. std::string이 꼭 필요하면 std::string(reinterpret_cast<const char*>(u8.data()), u8.size())처럼 명시적으로 옮겨 담습니다. 콘솔 출력이 깨지는 문제와 파일이 안 열리는 문제는 다른 문제라는 점도 구분하세요. 출력이 깨져도 path로 넘긴 파일 열기는 정상 동작합니다.

문제 5: copy_file이 기존 파일이 있어도 덮어쓰지 않음

증상: 대상 파일이 이미 있는데 복사가 실패합니다. 원인: 기본 동작은 기존 파일을 덮어쓰지 않습니다. 해결:

// ✅ overwrite_existing 옵션
fs::copy_file(src, dst, fs::copy_options::overwrite_existing);

문제 6: create_directories가 실패함

증상: create_directories("a/b/c")가 false를 반환합니다. 원인: a가 이미 일반 파일로 존재하면, 그 아래에 디렉토리를 만들 수 없습니다. 또는 권한 부족. 해결:

std::error_code ec;
bool ok = fs::create_directories(path, ec);
if (!ok && ec) {
    std::cerr << "Create failed: " << ec.message()
              << " (path: " << path << ")\n";
}
// a가 파일인지 확인
if (fs::exists("a") && fs::is_regular_file("a")) {
    std::cerr << "Cannot create: 'a' is a file\n";
}

error_code 오버로드, path 조합, TOCTOU 주의

error_code로 예외 비활성화

프로덕션에서는 예외 대신 std::error_code를 사용해 파일 시스템 오류를 처리하는 것이 안정적입니다.

std::error_code ec;
if (!fs::exists(path, ec) || ec) {
    logError("Path check failed", ec);
    return false;
}

경로는 항상 path로 조합

문자열 연결 대신 path와 operator/를 사용합니다.

// ❌
std::string p = base + "/" + sub + "/" + file;
// ✅
fs::path p = fs::path(base) / sub / file;

exists + is_regular_file로 이중 확인

파일 연산 전에 타입을 확인합니다.

if (fs::exists(p) && fs::is_regular_file(p)) {
    auto size = fs::file_size(p);
}

TOCTOU 주의

exists 확인과 실제 사용 사이에 파일이 삭제되거나 바뀔 수 있습니다. 최종적으로는 ifstream::is_open() 등 실제 연산 결과를 검사해야 합니다.

if (fs::exists(p)) {
    std::ifstream f(p);
    if (!f) { /* 열기 실패 - exists 후 삭제됐을 수 있음 */ }
}

recursive 순회 시 skip_permission_denied

시스템 전체를 스캔할 때는 skip_permission_denied를 사용합니다.


안전한 디렉토리 생성, 임시+rename 쓰기, 임시 디렉토리 RAII

패턴 1: 안전한 디렉토리 생성

#include <filesystem>
#include <system_error>
namespace fs = std::filesystem;
bool ensureDirectory(const fs::path& dir) {
    std::error_code ec;
    if (fs::exists(dir, ec)) {
        if (ec) return false;
        return fs::is_directory(dir, ec);
    }
    return fs::create_directories(dir, ec) && !ec;
}

패턴 2: 원자적 파일 쓰기 (임시 + rename)

#include <filesystem>
#include <fstream>
#include <system_error>
namespace fs = std::filesystem;
bool atomicWrite(const fs::path& target, const std::string& content) {
    std::error_code ec;
    fs::path tmp = target.parent_path() / (target.filename().string() + ".tmp");
    std::ofstream out(tmp, std::ios::binary);
    if (!out) return false;
    out << content;
    out.close();
    if (!out) {
        fs::remove(tmp, ec);
        return false;
    }
    fs::rename(tmp, target, ec);
    if (ec) {
        fs::remove(tmp, ec);
        return false;
    }
    return true;
}

패턴 3: 디렉토리 크기 계산

#include <filesystem>
#include <cstdint>
namespace fs = std::filesystem;
std::uintmax_t directorySize(const fs::path& dir) {
    std::uintmax_t total = 0;
    std::error_code ec;
    for (const auto& entry : fs::recursive_directory_iterator(dir,
            fs::directory_options::skip_permission_denied, ec)) {
        if (ec) return 0;
        if (entry.is_regular_file(ec) && !ec) {
            total += fs::file_size(entry, ec);
        }
    }
    return total;
}

패턴 4: 임시 디렉토리 RAII

#include <filesystem>
#include <random>
#include <sstream>
namespace fs = std::filesystem;
class TempDirectory {
    fs::path path_;
public:
    TempDirectory() {
        std::ostringstream oss;
        oss << fs::temp_directory_path() / "tmp_" << std::rand();
        path_ = oss.str();
        fs::create_directories(path_);
    }
    ~TempDirectory() {
        std::error_code ec;
        fs::remove_all(path_, ec);
    }
    const fs::path& path() const { return path_; }
};

패턴 5: 설정 파일 경로 해석

#include <filesystem>
#include <string>
namespace fs = std::filesystem;
fs::path resolveConfigPath(const std::string& filename) {
    // 1) 환경 변수
    const char* configHome = std::getenv("XDG_CONFIG_HOME");
    if (configHome) {
        fs::path p = fs::path(configHome) / "myapp" / filename;
        if (fs::exists(p)) return fs::canonical(p);
    }
    // 2) 홈 디렉토리
    const char* home = std::getenv("HOME");
    if (home) {
        fs::path p = fs::path(home) / ".config" / "myapp" / filename;
        if (fs::exists(p)) return fs::canonical(p);
    }
    // 3) 현재 디렉토리
    if (fs::exists(filename)) return fs::canonical(filename);
    return {};
}

내부 동작을 알면 풀리는 문제들

std::filesystem은 OS 파일 API 위의 얇은 파사드입니다. Linux·macOS 구현은 stat/lstat, opendir/readdir, rename, unlink 같은 POSIX 호출로, MSVC 구현은 CreateFileW, FindFirstFileW/FindNextFileW, MoveFileExW 같은 wide Win32 API로 내려갑니다. 같은 코드가 플랫폼마다 다르게 동작하는 대부분의 이유가 여기서 나옵니다.

정규화는 두 종류입니다. lexically_normal()은 디스크를 전혀 보지 않고 문자열 규칙으로만 .과 ..를 정리합니다. 그래서 빠르고 존재하지 않는 경로에도 쓸 수 있지만, 심볼릭 링크를 따라가지 않기 때문에 link/../file이 실제로 가리키는 곳과 결과가 다를 수 있습니다. canonical()은 실제 파일시스템을 따라가며 링크를 풀므로 정확하지만 경로가 존재해야 하고 시스템 호출 비용이 듭니다. “두 경로가 같은 파일인가”를 판단해야 한다면 문자열 비교 대신 fs::equivalent(a, b)를 쓰는 게 가장 정확합니다. 사용자 입력 경로가 허용된 디렉터리 밖으로 나가는지 검사할 때 lexically_normal만 쓰면 심볼릭 링크로 우회될 수 있어서, 보안 검사는 canonical 결과로 해야 합니다.

순회는 항목마다 추가 호출이 붙을 수 있습니다. directory_iterator는 디렉터리 전체를 한 번에 읽어 두는 컨테이너가 아니라 readdir/FindNextFileW를 한 번씩 호출하는 커서입니다. 이때 OS가 이름과 함께 돌려주는 정보(Linux의 d_type, Windows의 파일 속성·크기·시간)는 directory_entry에 캐시되지만, 그 밖의 정보를 요청하면 항목마다 stat이 한 번씩 더 나갑니다. 수십만 개 파일이 있는 디렉터리에서 fs::is_regular_file(entry.path())처럼 path를 다시 넘기면 캐시를 버리고 매번 새로 조회하므로, entry.is_regular_file()처럼 엔트리의 멤버 함수를 쓰는 편이 빠릅니다. 또 순회 도중 다른 프로세스가 파일을 만들거나 지우면 그 항목이 보일지 말지는 정해져 있지 않습니다.

에러 코드는 플랫폼별 값이 섞입니다. error_code 버전 함수의 ec에는 POSIX에서는 errno 값이, Windows에서는 Win32 오류 코드(system_category)가 들어옵니다. 그래서 ec.value() == ENOENT처럼 숫자로 비교하면 Windows에서 틀리고, ec == std::errc::no_such_file_or_directory처럼 std::errc와 비교해야 두 플랫폼에서 같게 동작합니다. 로그에는 ec.message()와 함께 ec.value(), 경로를 같이 남기면 재현 없이도 원인을 좁히기 쉽습니다.

“없으면 성공”인 연산과 아닌 연산을 구분하세요. create_directories는 이미 있으면 에러 없이 false를 반환하고, remove도 대상이 없으면 에러 없이 false, remove_all은 0을 반환합니다. 반면 rename, copy_file, file_size는 대상이 없으면 실패합니다. 정리 스크립트처럼 여러 번 실행돼도 같은 결과가 나와야 하는 코드는 이 차이를 알고 반환값과 ec를 따로 봐야 합니다.

Windows의 rename은 “다른 프로세스가 연 파일”에서 실패합니다. MSVC의 fs::rename은 기존 파일을 덮어쓰도록 MoveFileExW를 호출하지만, 대상 파일을 다른 프로세스가 공유 삭제 권한 없이 열고 있으면 접근 거부로 실패합니다. 백신이나 검색 인덱서가 방금 쓴 파일을 잠깐 열어 두는 경우가 흔해서, 설정 파일을 원자적으로 교체하는 코드가 Windows에서만 가끔 실패한다면 대부분 이 원인입니다. 짧은 간격으로 몇 번 재시도하는 로직을 넣는 것이 현실적인 대응입니다.


std::filesystem 코드 점검 항목

구현 시 확인할 항목:

  • -std=c++17 이상으로 컴파일
  • 경로 조합에 path와 operator/ 사용
  • 프로덕션에서는 std::error_code 오버로드 사용
  • file_size 전에 is_regular_file 확인
  • recursive_directory_iterator에 skip_permission_denied 적용
  • copy_file 시 overwrite_existing 필요 여부 확인
  • 중요 데이터 쓰기는 원자적 쓰기(임시 + rename) 고려
  • 임시 파일/디렉토리는 RAII로 정리

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. lexically_normal과 weakly_canonical, canonical은 어떻게 다른가요?

A. lexically_normal은 디스크를 보지 않는 문자열 정리, canonical은 실제 파일시스템을 따라가 링크까지 푼 절대 경로(경로가 존재해야 함), weakly_canonical은 존재하는 앞부분만 canonical로 풀고 나머지를 붙이는 절충입니다. 보안 검사처럼 실제 위치가 중요하면 canonical을 씁니다.

Q. 예외 버전과 error_code 버전 중 무엇을 써야 하나요?

A. 실패가 곧 프로그램 실패인 초기화 코드는 예외 버전, 루프 안이나 소멸자·noexcept 함수, 실패가 흔한 정리 코드는 error_code 버전을 씁니다. error_code는 숫자 대신 std::errc와 비교해야 플랫폼 간에 같게 동작합니다.

Q. std::filesystem으로 파일 잠금도 할 수 있나요?

A. 표준에는 없습니다. POSIX flock/fcntl, Windows LockFileEx 같은 OS API나 잠금 파일, DB로 조율합니다. 한 줄 요약 std::filesystem::path로 경로를 조합하며, create_directories·copy_file·directory_iterator로 파일 시스템을 안전하게 다룹니다. 다음으로 파일 I/O 기초(#11-1)를 읽어보면 좋습니다. 이전 글: C++23 핵심 기능(#37-1) 다음 글: C++ 실전 가이드 #38-1: 클린 코드 기초