C++ filesystem::path: 경로 결합·분해, absolute·canonical·relative 차이

이 글의 핵심

경로를 문자열 덧셈으로 만들면 구분자가 두 번 들어가거나 플랫폼마다 슬래시 방향이 달라 버그가 생깁니다. path는 이 차이를 흡수하지만, / 연산자의 오른쪽이 절대 경로면 왼쪽이 버려지고 canonical은 존재하지 않는 경로에서 예외를 던지는 것처럼 알아야 할 동작이 있습니다. 경로 분해, 변환, 비교를 예제로 짚고 주요 메서드를 표로 정리했습니다.

들어가며

std::filesystem::path는 C++17의 플랫폼 독립적 경로 처리 클래스입니다.

C++17 이전에는 경로를 std::string으로 다루며 dir + "/" + name처럼 이어 붙이는 코드가 흔했습니다. 이 방식은 dir이 이미 /로 끝나면 //가 생기고, Windows의 C:\와 POSIX의 /처럼 루트 표기가 다르며, 확장자를 떼어 내려고 마지막 .을 찾다 보면 .bashrc나 archive.tar.gz에서 틀리는 등 작은 예외가 끝없이 나옵니다. path는 경로를 “루트 + 디렉터리들 + 파일명”이라는 구조로 이해하고 그 단위로 조작하게 해 줍니다.

중요한 전제가 하나 있습니다. path의 대부분 메서드는 문자열만 보는 연산이라 파일 시스템에 접근하지 않습니다. filename(), extension(), / 결합은 파일이 없어도 동작하고, 실제 디스크를 조회하는 것은 fs::exists, fs::canonical 같은 자유 함수입니다. 이 구분을 알면 어떤 호출이 예외를 던지고 어떤 호출이 느린지 예측할 수 있습니다.


문자열 대신 path를 쓰는 이유

path 만들고 출력하기

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
    fs::path p = "/home/user/file.txt";
    fs::path p2 = "C:\\Users\\user\\file.txt";  // Windows
    
    std::cout << p << std::endl;
    std::cout << p2 << std::endl;
    
    return 0;
}

특징:

  • 플랫폼 독립적인 인터페이스
  • Windows에서는 /와 \를 모두 구분자로 인식, POSIX에서는 /만 구분자
  • 필요하면 make_preferred()로 플랫폼 기본 구분자로 변환
  • 다양한 메서드 제공

구분자는 자동으로 바뀌지 않습니다. Windows에서는 "C:/Users/user"처럼 슬래시로 써도 구분자로 인식되지만 문자열 자체는 그대로 유지되고, make_preferred()를 호출해야 \로 바뀝니다. 반대로 Linux에서 \는 구분자가 아니라 평범한 파일 이름 문자입니다. 그래서 위의 p2를 Linux에서 만들면 filename()이 C:\Users\user\file.txt 전체가 됩니다. 코드 안에 경로를 쓸 때는 두 플랫폼 모두에서 구분자로 인식되는 /를 쓰는 것이 안전합니다.

std::cout << p의 출력에는 따옴표가 붙습니다. operator<<가 std::quoted로 출력하기 때문이며, 공백이 든 경로를 구분하기 쉽게 하려는 의도입니다. 따옴표 없이 출력하려면 p.string()을 씁니다. Windows에서는 path가 내부적으로 UTF-16(wchar_t)을 저장해서, 한글 경로에 string()을 호출하면 시스템 코드 페이지로 변환되며 글자가 깨지거나 예외가 날 수 있습니다. 한글 경로를 다루는 크로스 플랫폼 코드라면 u8string()을 쓰거나 Windows API에는 wstring()/c_str()을 넘기는 편이 안전합니다.


경로 분해와 / 연산자 결합

filename·stem·extension·parent_path

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
    fs::path p = "/home/user/documents/report.pdf";
    
    std::cout << "전체: " << p << std::endl;
    std::cout << "파일명: " << p.filename() << std::endl;      // "report.pdf"
    std::cout << "확장자: " << p.extension() << std::endl;     // ".pdf"
    std::cout << "기본명: " << p.stem() << std::endl;          // "report"
    std::cout << "부모: " << p.parent_path() << std::endl;     // "/home/user/documents"
    std::cout << "루트: " << p.root_path() << std::endl;       // "/"
    
    return 0;
}

분해 메서드는 모두 새 path를 반환하며 원본은 바뀌지 않습니다. 경계 사례를 알아 두면 버그를 줄일 수 있습니다.

  • extension()은 마지막 .부터입니다. archive.tar.gz의 확장자는 .gz이고 stem()은 archive.tar입니다. 두 단계 확장자를 다루려면 stem()에 다시 extension()을 적용해야 합니다.
  • 이름이 .으로 시작하는 파일(.bashrc)은 확장자가 없는 것으로 봅니다. stem()이 .bashrc, extension()이 빈 경로입니다.
  • 경로가 /로 끝나면(/home/user/) filename()은 빈 경로입니다. 디렉터리 이름을 얻으려고 filename()을 불렀는데 빈 값이 나온다면 끝의 슬래시를 의심하세요. parent_path()도 /home/user가 되어 기대보다 한 단계 덜 올라갑니다.

root_path()는 플랫폼에 따라 다릅니다. Windows에서 C:\Users\user\file.txt의 루트는 C:\이고, 이는 루트 이름(C:)과 루트 디렉터리(\)로 다시 나뉩니다. for (const auto& part : p)로 순회하면 루트, 디렉터리, 파일명을 하나씩 꺼낼 수 있어 경로 깊이를 세거나 특정 폴더가 포함됐는지 검사할 때 유용합니다.

/ 연산자로 경로 결합

fs::path dir = "/home/user";
fs::path file = "file.txt";

// / 연산자
fs::path full = dir / file;  // "/home/user/file.txt"

// /= 연산자
fs::path p = "/home/user";
p /= "documents";
p /= "file.txt";
std::cout << p << std::endl;  // "/home/user/documents/file.txt"

// append
fs::path p2 = "/home";
p2.append("user").append("file.txt");

/ 연산자는 필요할 때만 구분자를 넣습니다. dir이 /home/user/처럼 이미 구분자로 끝나 있어도 /home/user/file.txt가 되고 슬래시가 겹치지 않습니다. 반면 +=(또는 concat)은 구분자 없이 문자열만 이어 붙이므로 p += ".bak"처럼 파일명 뒤에 접미사를 붙일 때 씁니다. 두 연산자를 바꿔 쓰면 report.txt/.bak 같은 엉뚱한 경로가 나옵니다.

FAQ에서 다루듯 오른쪽 피연산자가 절대 경로면 왼쪽은 버려집니다. fs::path("/data") / "/etc/passwd"는 /etc/passwd입니다. 설정 파일에서 읽은 하위 경로가 실수로 /로 시작하면 기준 디렉터리가 무시되는데, 오류가 나지 않고 다른 위치의 파일을 읽거나 쓰기 때문에 발견이 늦습니다. Windows에서는 규칙이 조금 더 복잡해서 C:\data / \temp는 C:\temp가 되고(루트 디렉터리만 있는 경로), C:\data / D:file은 D:file이 됩니다.


absolute·canonical·relative 변환

absolute로 절대 경로 만들기

fs::path p = "file.txt";

// 절대 경로로 변환
fs::path abs = fs::absolute(p);
std::cout << abs << std::endl;  // "/current/directory/file.txt"

absolute는 현재 작업 디렉터리(fs::current_path())를 앞에 붙일 뿐, ..이나 .을 정리하지 않고 파일 존재 여부도 확인하지 않습니다. 결과가 실행 위치에 따라 달라진다는 점이 중요합니다. IDE에서 실행할 때와 터미널에서 실행할 때, 서비스로 등록해 실행할 때 작업 디렉터리가 서로 달라서 “개발 PC에서는 설정 파일을 찾는데 배포 후에는 못 찾는” 문제가 자주 생깁니다. 실행 파일 기준으로 파일을 찾아야 한다면 작업 디렉터리 대신 실행 파일 경로(플랫폼별 API로 구함)를 기준으로 삼아야 합니다.

canonical로 심볼릭 링크와 .. 정리

fs::path p = "/home/user/../user/./file.txt";

// 정규 경로 (파일 존재 필요)
fs::path canonical = fs::canonical(p);
std::cout << canonical << std::endl;  // "/home/user/file.txt"

// weakly_canonical (파일 존재 불필요)
fs::path p2 = "/home/user/nonexistent.txt";
fs::path weak = fs::weakly_canonical(p2);
std::cout << weak << std::endl;

canonical은 디스크를 실제로 조회해 ..과 .을 없애고 심볼릭 링크까지 따라가 최종 위치를 돌려줍니다. 경로 중 하나라도 존재하지 않으면 std::filesystem::filesystem_error 예외를 던지므로, 예외를 쓰지 않는 코드라면 fs::canonical(p, ec)처럼 std::error_code를 받는 오버로드를 씁니다. 파일 시스템 함수 대부분이 이 두 가지 형태를 제공합니다.

문자열 수준의 정리만 필요하다면 p.lexically_normal()이 있습니다. 디스크를 보지 않으므로 빠르고 예외가 없지만, /home/link/../file.txt에서 link가 심볼릭 링크라면 실제 위치와 다른 결과를 낼 수 있습니다. 보안 검사처럼 “실제로 어느 파일을 가리키는가”가 중요한 곳에서는 canonical 계열을, 표시용이나 비교용으로 경로 모양만 정리할 때는 lexically_normal을 쓰는 것이 기준입니다.

relative로 기준 경로 대비 상대 경로

fs::path abs = "/home/user/documents/file.txt";
fs::path base = "/home/user";

// 상대 경로 계산
fs::path rel = fs::relative(abs, base);
std::cout << rel << std::endl;  // "documents/file.txt"

fs::relative는 두 경로를 먼저 weakly_canonical로 정리한 뒤 차이를 계산하므로 파일 시스템에 접근합니다. 순수 문자열 계산이 필요하면 abs.lexically_relative(base)를 씁니다. 공통 부분이 없거나 Windows에서 드라이브가 다르면 빈 경로가 반환되므로, 결과가 비었는지 확인해야 합니다. 기준 디렉터리 밖에 있으면 ../other/file.txt처럼 ..으로 시작하는 결과가 나오는데, 이 성질을 이용해 “경로가 기준 디렉터리 안에 있는가”를 검사하기도 합니다.


exists·is_regular_file·is_directory로 경로 확인

fs::path p = "/home/user/file.txt";

// 절대 경로?
if (p.is_absolute()) {
    std::cout << "절대 경로" << std::endl;
}

// 상대 경로?
if (p.is_relative()) {
    std::cout << "상대 경로" << std::endl;
}

// 비어있음?
if (p.empty()) {
    std::cout << "빈 경로" << std::endl;
}

// 경로 존재?
if (fs::exists(p)) {
    std::cout << "존재함" << std::endl;
}

is_absolute()의 판정은 플랫폼마다 다릅니다. /home/user/file.txt는 Linux에서는 절대 경로지만, Windows에서는 드라이브 이름이 없어서 상대 경로로 판정됩니다(현재 드라이브의 루트 기준). 같은 테스트 코드가 Windows CI에서만 실패한다면 이 차이를 의심해 보세요.

is_absolute, empty는 문자열만 보지만 fs::exists는 디스크를 조회합니다. 그리고 exists로 확인한 뒤 파일을 여는 코드는 그 사이에 다른 프로세스가 파일을 지우거나 만들 수 있어 결과가 보장되지 않습니다(TOCTOU). 존재 확인은 사용자에게 안내 메시지를 보여 주는 용도로 쓰고, 실제 동작은 파일을 열거나 생성하는 호출의 결과(실패 여부)로 판단하는 편이 견고합니다.


확장자 확인과 변경

replace_extension으로 확장자 변경

fs::path p = "document.txt";

// 확장자 변경
p.replace_extension(".pdf");
std::cout << p << std::endl;  // "document.pdf"

// 확장자 제거
p.replace_extension();
std::cout << p << std::endl;  // "document"

// 확장자 추가
p.replace_extension(".docx");
std::cout << p << std::endl;  // "document.docx"

replace_extension은 원본 path를 직접 수정하고 자기 자신의 참조를 반환합니다. 인자에 .을 빼고 "pdf"로 넘겨도 점을 붙여 주므로 document.pdf가 됩니다. 확장자가 두 단계인 backup.tar.gz에 replace_extension(".zip")을 하면 마지막 .gz만 바뀌어 backup.tar.zip이 된다는 점을 주의하세요.

extension()으로 확장자 확인

fs::path p = "image.png";

if (p.extension() == ".png") {
    std::cout << "PNG 파일" << std::endl;
}

// 여러 확장자 확인
std::vector<std::string> image_exts = {".png", ".jpg", ".jpeg", ".gif"};
if (std::find(image_exts.begin(), image_exts.end(), p.extension()) != image_exts.end()) {
    std::cout << "이미지 파일" << std::endl;
}

이 비교는 대소문자를 구분하므로 photo.PNG나 카메라가 저장한 IMG_0001.JPG는 이미지로 판정되지 않습니다. 사용자 파일을 다루는 코드에서 가장 흔한 누락입니다. 확장자를 p.extension().string()으로 꺼내 std::tolower로 소문자로 바꾼 뒤 비교하세요(한글이 아닌 ASCII 확장자라는 전제에서 충분합니다). 확장자는 파일 내용을 보장하지 않으므로, 업로드된 파일처럼 신뢰할 수 없는 입력이라면 파일 앞부분의 시그니처(매직 넘버)를 확인해야 합니다.


== 비교와 fs::equivalent

fs::path p1 = "/home/user/file.txt";
fs::path p2 = "/home/user/file.txt";
fs::path p3 = "/home/user/other.txt";

if (p1 == p2) {
    std::cout << "같음" << std::endl;
}

if (p1 != p3) {
    std::cout << "다름" << std::endl;
}

// path의 == 비교는 모든 플랫폼에서 문자열(구성 요소) 비교
// Windows에서도 "File.txt" != "file.txt" (파일 시스템은 같은 파일로 취급)
// 같은 파일인지는 fs::equivalent(p1, p2)로 확인

path의 ==는 구성 요소를 사전식으로 비교할 뿐 파일 시스템의 규칙을 반영하지 않습니다. 그래서 Windows에서 C:/Users/a.txt와 C:\Users\a.txt는 구분자 차이만 있으므로 같다고 판정되지만, C:\Users\A.txt와 C:\Users\a.txt는 NTFS에서 같은 파일인데도 다르다고 판정됩니다. /home/user/../user/file.txt와 /home/user/file.txt도 다릅니다.

“두 경로가 같은 파일을 가리키는가”를 알고 싶다면 fs::equivalent(p1, p2)를 씁니다. 디스크를 조회해 같은 파일(같은 inode나 파일 ID)인지 확인하고, 심볼릭 링크와 하드 링크까지 고려합니다. 둘 다 존재해야 하며 없으면 예외를 던집니다. 파일이 없을 수도 있다면 양쪽을 weakly_canonical로 정리한 뒤 비교하는 방법이 차선책입니다.


확장자 일괄 변경·백업 경로 생성 예제

폴더의 파일 확장자 일괄 변경

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

void rename_extensions(const fs::path& dir, 
                       const std::string& old_ext, 
                       const std::string& new_ext) {
    for (const auto& entry : fs::directory_iterator(dir)) {
        if (entry.is_regular_file() && entry.path().extension() == old_ext) {
            fs::path new_path = entry.path();
            new_path.replace_extension(new_ext);
            fs::rename(entry.path(), new_path);
            std::cout << "변경: " << entry.path().filename() 
                      << " → " << new_path.filename() << std::endl;
        }
    }
}

int main() {
    rename_extensions("./images", ".jpeg", ".jpg");
    return 0;
}

directory_iterator는 하위 폴더로 내려가지 않고 한 폴더만 순회합니다. 하위 폴더까지 처리하려면 recursive_directory_iterator를 씁니다. entry.is_regular_file()은 순회 중에 캐시된 정보를 쓰는 경우가 많아 fs::is_regular_file(entry.path())보다 시스템 호출이 적습니다.

이 코드를 실제 폴더에 돌리기 전에 세 가지를 생각해 봐야 합니다. 첫째, 순회 도중 디렉터리 내용을 바꾸고 있습니다. 표준은 반복자를 만든 뒤 추가·삭제된 항목이 순회에 나타날지 여부를 정하지 않으므로, 바꾼 파일이 다시 나오거나 일부가 빠질 가능성이 있습니다. 대상 경로를 먼저 std::vector에 모은 뒤 이름을 바꾸면 안전합니다. 둘째, photo.jpg와 photo.jpeg가 함께 있으면 fs::rename이 기존 photo.jpg를 덮어쓸 수 있습니다. POSIX의 rename은 대상 파일을 교체하는 것이 기본 동작이므로, fs::exists(new_path)를 먼저 검사해 건너뛰어야 합니다. 셋째, 권한이 없는 파일에서 fs::rename이 예외를 던지면 반복문 전체가 중단됩니다. fs::rename(from, to, ec)로 파일별 오류를 기록하고 계속 진행하는 편이 실무에 맞습니다. 확장자 비교가 대소문자를 구분하므로 .JPEG 파일도 빠진다는 점은 앞 절과 같습니다.

원본 옆에 _backup 경로 만들기

fs::path create_backup_path(const fs::path& original) {
    fs::path backup = original;
    backup.replace_extension();
    
    std::string backup_name = backup.filename().string() + "_backup";
    backup = backup.parent_path() / backup_name;
    backup.replace_extension(original.extension());
    
    return backup;
}

int main() {
    fs::path original = "/home/user/document.txt";
    fs::path backup = create_backup_path(original);
    
    std::cout << "원본: " << original << std::endl;
    std::cout << "백업: " << backup << std::endl;
    // 백업: /home/user/document_backup.txt
    
    return 0;
}

문자열을 직접 자르지 않고 replace_extension()으로 확장자를 떼고, 파일명에 접미사를 붙인 뒤, 원래 확장자를 다시 붙이는 순서로 만들었습니다. 같은 결과를 original.parent_path() / (original.stem().string() + "_backup" + original.extension().string()) 한 줄로도 만들 수 있습니다. archive.tar.gz라면 archive.tar_backup.gz가 되므로, 두 단계 확장자를 유지하고 싶다면 별도 처리가 필요합니다.

백업 파일명이 고정되어 있으면 두 번째 백업이 첫 번째를 덮어씁니다. 여러 세대를 남기려면 document_20260924_153000.txt처럼 시각을 넣거나 번호를 붙이고, 존재하는 이름이면 다음 번호를 시도하는 방식을 씁니다. 실제 복사는 fs::copy_file(original, backup, fs::copy_options::overwrite_existing)처럼 옵션으로 덮어쓰기 정책을 명시할 수 있습니다.


path 정리

핵심 요약

  1. path: C++17 플랫폼 독립 경로
  2. / 연산자: 경로 결합
  3. filename(): 파일명 추출
  4. extension(): 확장자 추출
  5. absolute(): 절대 경로 변환
  6. canonical(): 정규 경로 (존재 필요)

주요 메서드 표

메서드설명
filename()파일명
extension()확장자
stem()확장자 제외 파일명
parent_path()부모 경로
root_path()루트 경로
replace_extension()확장자 변경

이어서 볼 글


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. path의 / 연산자로 결합할 때 오른쪽이 절대 경로면 어떻게 되나요?

A. 오른쪽 피연산자가 루트를 포함한 절대 경로이면 왼쪽 경로는 버려지고 오른쪽 경로로 대체되므로, path("/data") / "/etc/passwd"의 결과는 /etc/passwd입니다. 사용자 입력 파일명을 기준 디렉터리에 붙이는 코드라면 이 동작이나 .. 때문에 기준 디렉터리 밖을 가리킬 수 있습니다. 결합 후 weakly_canonical로 정규화하고, 결과가 여전히 기준 디렉터리 아래에 있는지 비교한 뒤에 파일을 여는 것이 안전합니다.