C++ std::filesystem 심화 | POSIX·Win32 구현
이 글의 핵심
C++17 std::filesystem은 표준 인터페이스 뒤에서 POSIX 계열 API와 Win32 API를 매핑합니다. 구현별 차이, 경로 정규화, 디렉터리 순회, error_code 처리, 운영 환경에서의 안전한 패턴까지 한 글에서 다룹니다.
filesystem이란?
표준 라이브러리만으로 경로 조합, 존재 확인, 디렉터리 생성·삭제를 하려면 filesystem의 설계를 갖추는 것이 좋습니다. 이 글은 namespace fs와 path 사용법에 더해, 구현이 어떤 시스템 호출에 연결되는지, 경로 문자열을 어떻게 정규화하는지, 디렉터리 순회가 내부적으로 어떻게 동작하는지, 오류를 예외와 error_code로 어떻게 나누는지, 그리고 프로덕션에서 반복되는 안전한 패턴까지 다룹니다.
파일시스템 조작 라이브러리 (C++17)
#include <filesystem>
namespace fs = std::filesystem;
fs::path p = "/home/user/file.txt";
bool exists = fs::exists(p);
기본 연산
#include <filesystem>
namespace fs = std::filesystem;
// 존재 확인
if (fs::exists("file.txt")) {}
// 디렉토리 생성
fs::create_directory("mydir");
// 파일 삭제
fs::remove("file.txt");
// 복사
fs::copy("src.txt", "dst.txt");
실전 예시
예시 1: 파일 정보
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
void printFileInfo(const fs::path& p) {
if (!fs::exists(p)) {
std::cout << "파일 없음" << std::endl;
return;
}
std::cout << "경로: " << p << std::endl;
std::cout << "크기: " << fs::file_size(p) << " bytes" << std::endl;
if (fs::is_regular_file(p)) {
std::cout << "일반 파일" << std::endl;
} else if (fs::is_directory(p)) {
std::cout << "디렉토리" << std::endl;
}
}
예시 2: 디렉토리 순회
void listFiles(const fs::path& dir) {
for (const auto& entry : fs::directory_iterator(dir)) {
std::cout << entry.path() << std::endl;
}
}
void listFilesRecursive(const fs::path& dir) {
for (const auto& entry : fs::recursive_directory_iterator(dir)) {
std::cout << entry.path() << std::endl;
}
}
예시 3: 파일 복사
void backupFiles(const fs::path& src, const fs::path& dst) {
if (!fs::exists(dst)) {
fs::create_directories(dst);
}
for (const auto& entry : fs::directory_iterator(src)) {
fs::path dstPath = dst / entry.path().filename();
if (fs::is_regular_file(entry)) {
fs::copy_file(entry.path(), dstPath,
fs::copy_options::overwrite_existing);
}
}
}
예시 4: 임시 파일
#include <fstream>
void createTempFile() {
fs::path tempDir = fs::temp_directory_path();
fs::path tempFile = tempDir / "myapp_temp.txt";
std::ofstream ofs(tempFile);
ofs << "임시 데이터" << std::endl;
ofs.close();
// 사용 후 삭제
fs::remove(tempFile);
}
구현과 플랫폼: POSIX 대 Win32
std::filesystem은 이식 가능한 파사드(facade)입니다. 동일한 시그니처 뒤에서 구현체(libc++, libstdc++, MSVC STL 등)는 호스트 OS의 네이티브 파일 API로 매핑합니다.
POSIX 계열(Linux, macOS, BSD)
대표적인 매핑은 다음과 같습니다. 정확한 심볼은 구현·버전에 따라 다를 수 있으나, 개념적 연결은 이와 같습니다.
- 경로·메타데이터:
stat,lstat,fstatat(존재, 타입, 크기, symlink 여부). - 디렉터리 열기·순회:
opendir/fdopendir,readdir,closedir. 일부 구현은getdents계열로 최적화합니다. - 생성·삭제·이름 변경:
open,unlink,mkdir,rename,symlink,readlink. - 복사·재귀 삭제: 표준에 없는 연산은 사용자 공간에서
read/write루프나 트리 순회로 합성합니다.
path::native()는 narrow 또는 wide가 아니라, 해당 플랫폼의 네이티브 문자 인코딩을 따릅니다. 유닉스 계열은 일반적으로 UTF-8 로케일 환경에서 바이트 시퀀스로 경로를 전달합니다(파일시스템 인코딩은 배포판·마운트 옵션에 좌우됨).
Windows(Win32)
Windows 구현은 Win32/NT 커널 API 위에 올라갑니다.
- 핸들 기반 I/O:
CreateFileW,GetFileInformationByHandle,DeviceIoControl등. - 디렉터리 열거:
FindFirstFileW/FindNextFileW/FindClose패턴이 전형적입니다(와일드카드 없이 디렉터리 핸들을 여는 변형도 사용될 수 있음). - 경로: Win32에서는 와이드 문자 API(
*W)가 주류이며,std::filesystem::path의 내부 타입은 Windows에서 UTF-16 코드 유닛을 다루는 경우가 많습니다.std::u8string과의 상호 운용은 C++20char8_t경로 오버로드로 정리됩니다.
구현체별 차이가 실무에 미치는 점
- 대소문자 구분: POSIX는 대개 case-sensitive, Windows는 case-insensitive(보존은 하되 비교는 규칙적). 동일 코드베이스에서 정규화 전략이 달라질 수 있습니다.
- 경로 루트: Windows는 드라이브 문자,
\\?\장경로, UNC\\server\share등 문법이 별도입니다.path는 이를 구조적으로 보관합니다. - 하드 링크·정션·심볼릭 링크: OS가 지원하는 조합이 다릅니다.
is_symlink,hard_link_count등은 가능할 때만 의미가 있습니다.
이 차이를 알면, “왜 이 환경에서만 equivalent가 실패하는가?” 같은 이슈를 API가 아니라 OS 의미론에서 설명할 수 있습니다.
경로 정규화 알고리즘
std::filesystem은 두 종류의 “정규화”를 제공합니다. 혼동하지 않는 것이 중요합니다.
1) 순수 구문(lexical): lexically_normal, lexically_relative
이 연산은 파일시스템에 질의하지 않습니다. 규칙은 CppReference의 path 규격 요약과 동일하게, 대략 다음을 수행합니다.
- 구분자 정리: 연속 구분자 축약, 선행·후행 구분자는 플랫폼 규칙에 맞게 처리.
.제거: 현재 디렉터리 참조 제거...해석: 부모 세그먼트와 상쇄. 루트를 넘어가면 루트에 고정되는 등, 순수 문법 규칙으로만 처리.- 상대·절대 보존: 입력이 상대 경로면 결과도 상대 경로인 경우가 많습니다.
따라서 실제로 존재하지 않는 경로도 “정규형”을 가질 수 있습니다. 심볼릭 링크는 따라가지 않습니다.
2) 파일시스템 기반: canonical, weakly_canonical
canonical: 기준 경로에서..와.를 해소하기 위해 실제 디렉터리를 따라가며 절대 경로를 만듭니다. 최종 타깃이 존재해야 하며, 중간에 symlink가 있으면 기본적으로 따라갑니다(권한·링크 순환에 실패하면 오류).weakly_canonical: 존재하지 않는 꼬리 세그먼트를 허용하는 쪽에 가깝게 동작하며, 앞부분만 실제 FS로 해석하고 나머지는 lexical로 붙이는 식으로 부분적으로 강건합니다. 플랫폼·버전별 세부는 표준 문구를 참고해야 합니다.
실무 팁
- 비교·키로 쓸 경로: 서로 다른 입력 문자열을 동일 키로 쓰려면, 가능하면
canonical또는weakly_canonical+lexically_normal조합을 검토합니다. 다만canonical은 I/O 비용이 있습니다. - 존재하지 않는 출력 경로를 다룰 때:
temp_directory_path() / "prefix"처럼 조합한 뒤, 생성 전에는 lexical 정규화만 하는 경우가 많습니다.
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
fs::path p("foo/bar/../baz/./");
std::cout << fs::path(p).lexically_normal() << "\n";
std::error_code ec;
fs::path w = weakly_canonical(p, ec);
if (ec)
std::cerr << ec.message() << "\n";
}
directory_iterator 내부 동작
directory_iterator는 “한 번에 디렉터리 전체를 읽어 vector에 담는” 컨테이너가 아닙니다. 일반적인 구현은 디렉터리 핸들 + 다음 항목을 한 건씩 읽는 커서입니다.
POSIX 쪽의 전형적 모델
opendir(또는open+fdopendir)로 DIR* 스트림을 연 뒤,readdir호출마다 다음 디렉터리 엔트리를 채웁니다.- 엔트리에는 이름과 타입 힌트가 오지만, 상세 타입은
stat추가 호출이 필요한 경우가 많습니다. 그래서directory_entry::status()가 캐시·지연 조회를 하는 설계입니다.
Windows 쪽의 전형적 모델
FindFirstFileW로 첫 항목을 얻고, 이후FindNextFileW로 진행합니다.- 내부적으로 검색 핸들을 들고 있으며, 반복자 소멸 시 닫힙니다.
recursive_directory_iterator와 심볼릭 링크
- 기본적으로 symlink 디렉터리를 따라 들어가지 않도록 방어하는 옵션이 있습니다(
directory_options::follow_directory_symlink등). - 깊이 우선 순회는 스택/큐로 하위
directory_iterator를 유지하는 형태로 구현되는 경우가 많습니다.
성능·정확성 관점
- 한 디렉터리에 항목이 매우 많을 때: 순회 자체는 선형이지만, 항목마다
status/symlink_status를 적극적으로 호출하면 N번의 추가 syscall이 붙을 수 있습니다. 필요한 필드만 조건부로 조회하세요. - 순회 중 변경: 표준은 강한 일관성 보장을 요구하지 않습니다. 동시에 파일이 생기거나 사라지면 건너뛰거나 오류가 날 수 있으며, 이는 운영 설계(잠금, 스냅샷, 재시도)로 보완합니다.
std::error_code ec;
for (const auto& ent : fs::directory_iterator(dir, ec)) {
if (ec) break;
// ent.path(), ent.is_regular_file(ec) 등
}
if (ec) { /* 로깅 */ }
오류 처리: std::error_code와 예외
std::filesystem 함수는 대개 두 벌의 오버로드를 제공합니다.
- 예외 버전: 실패 시
filesystem_error를 던집니다.filesystem_error는path1/path2와code()를 담아 어느 경로에서 실패했는지 추적하기 쉽습니다. std::error_code버전: 실패 시 예외 대신ec에 값을 설정합니다. 성공 시ec.clear()에 해당하는 상태가 됩니다.
카테고리와 매핑
- POSIX 계열에서는 흔히
generic_category()에 매핑된errno값(ENOENT,EACCES,ENOTEMPTY등)을 봅니다. - Windows 구현에서는
system_category()와 Win32 오류(ERROR_FILE_NOT_FOUND등)가 섞여 나올 수 있습니다. 문자열 메시지는ec.message()로 얻되, 로깅 시 플랫폼별 코드를 함께 남기는 편이 디버깅에 유리합니다.
어떤 오버로드를 쓸까
- 도구·서버의 핫 루프, 예외 안전성이 제한된 맥락( destructor,
noexcept경계):error_code우선. - 스크립트형·초기화 코드, 오류가 곧 실패인 경로: 예외도 무방. 다만 경계에서 한 번만 잡아 상위 정책 오류로 변환하는 패턴이 깔끔합니다.
std::error_code ec;
fs::remove(path, ec);
if (ec == std::errc::no_such_file_or_directory) {
// 멱등: 없으면 성공으로 간주
ec.clear();
}
if (ec) {
// 기타 오류는 로깅/알림
}
프로덕션 파일시스템 패턴
1) 멱등한 생성·삭제
배포 스크립트와 에이전트는 create_directories는 이미 있어도 성공, remove는 없으면 실패처럼 의미가 다릅니다. 기대 상태를 명시하고, 허용 오류 코드를 화이트리스트하세요.
2) 원자적 교체(쓰기)
설정 파일을 덮어쓸 때 임시 이름으로 쓴 뒤 rename으로 스왑하면, 크래시 시 반쯤 쓴 파일이 노출되는 위험을 줄입니다. Windows와 POSIX에서 rename의 동작(기존 파일 덮어쓰기 가능 여부)은 다를 수 있으므로, 플랫폼 테스트가 필요합니다.
3) TOCTOU(검사 후 사용) 인지
exists 확인 후 open은 레이스가 있습니다. 보안 경계(권한 상승, 신뢰 경계)에서는 파일 디스크립터를 먼저 열고, 이후 fstat으로 속성을 확인하는 OS 저수준 API가 요구되는 경우가 있습니다. filesystem만으로 “완전한 안전”을 보장하려는 착각을 피하세요.
4) 잠금과 동시성
std::filesystem은 파일 잠금(flock/lockf/Windows locking) 을 표준화하지 않습니다. 다중 프로세스 합의가 필요하면 OS별 잠금 또는 DB·메시지 큐로 상위에서 조율합니다.
5) 대용량 트리와 메모리
재귀 순회는 깊이 폭발에 취약합니다. 매우 깊은 트리에서는 명시적 스택, BFS, 또는 운영체제 도구 위임을 고려합니다.
6) 관측 가능성
실패 시 path, ec.value(), ec.category().name(), 작업 종류를 한 줄로 남기면, 현장 재현 없이도 원인 추적이 빨라집니다.
경로 조작
C/C++ 예제 코드입니다.
fs::path p = "/home/user/file.txt";
p.filename(); // "file.txt"
p.extension(); // ".txt"
p.stem(); // "file"
p.parent_path(); // "/home/user"
// 경로 결합
fs::path dir = "/home/user";
fs::path file = dir / "file.txt";
자주 발생하는 문제
문제 1: 예외 처리
// ❌ 예외 무시
fs::remove("file.txt"); // 없으면 예외
// ✅ 예외 처리
try {
fs::remove("file.txt");
} catch (const fs::filesystem_error& e) {
std::cout << "에러: " << e.what() << std::endl;
}
// ✅ error_code 사용
std::error_code ec;
fs::remove("file.txt", ec);
if (ec) {
std::cout << "에러: " << ec.message() << std::endl;
}
문제 2: 경로 구분자
// ❌ 플랫폼 의존
fs::path p = "dir\\file.txt"; // Windows만
// ✅ 플랫폼 독립
fs::path p = "dir" / "file.txt";
문제 3: 상대 경로
fs::path rel = "file.txt";
fs::path abs = fs::absolute(rel);
std::cout << "상대: " << rel << std::endl;
std::cout << "절대: " << abs << std::endl;
문제 4: 디렉토리 삭제
// ❌ 비어있지 않으면 실패
fs::remove("dir");
// ✅ 재귀 삭제
fs::remove_all("dir");
파일 타입 확인
fs::path p = "file.txt";
if (fs::is_regular_file(p)) {
std::cout << "일반 파일" << std::endl;
}
if (fs::is_directory(p)) {
std::cout << "디렉토리" << std::endl;
}
if (fs::is_symlink(p)) {
std::cout << "심볼릭 링크" << std::endl;
}
FAQ
Q1: filesystem은?
A: C++17부터. 파일시스템 조작의 표준화입니다.
Q2: 플랫폼 독립?
A: API는 이식 가능하나, OS 의미론(대소문자, symlink, 권한)은 다릅니다. 이 글의 “POSIX 대 Win32” 절을 참고하십시오.
Q3: 예외 처리?
A: try-catch로 filesystem_error를 잡거나, 동일 함수의 error_code 오버로드를 사용합니다. 운영 코드에서는 후자를 자주 씁니다.
Q4: 성능?
A: 본질적으로 시스템 콜입니다. 순회·메타데이터 조회를 남발하면 비용이 큽니다. 필요한 속성만 조회하세요.
Q5: 경로 구분자?
A: path의 operator/로 결합하는 방식이 가장 안전합니다.
Q6: filesystem 학습 리소스는?
A: ISO/IEC 표준 문서, cppreference — filesystem, 구현체 소스(libc++/libstdc++/MSVC STL)를 함께 보시면 매핑이 명확해집니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Filesystem | “파일시스템” C++17 라이브러리 가이드
- C++ File Operations | “파일 연산” 가이드
- C++ path | “경로 처리” 가이드
- C++ Directory Iterator
- C++ File Status
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「[2026] C++ std::filesystem 심화 | POSIX·Win32 구현, 정규화, 이터레이터, error_code」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「[2026] C++ std::filesystem 심화 | POSIX·Win32 구현, 정규화, 이터레이터, error_code」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, filesystem, C++17, POSIX, Win32, error_code 등으로 검색하시면 이 글이 도움이 됩니다.