KoreanFoodie's Study

C++ 기초 개념 17-4 : C++ 파일 시스템(<filesystem>) 라이브러리 본문

Tutorials/C++ : Beginner

C++ 기초 개념 17-4 : C++ 파일 시스템(<filesystem>) 라이브러리

GoldGiver 2022. 5. 27. 10:32

모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!

 

파일을 찾아보자

파일 시스템 라이브러리는 파일 데이터 의 입출력을 담당하는 파일 입출력 라이브러리 (<fstream>) 과는 다르다. <fstream> 의 경우, 파일 하나가 주어지면 해당 파일의 데이터를 읽어내는 역할을 하지만, 그 외에 파일에 관한 정보 (파일 이름, 위치, 등등) 에 관한 데이터를 수정할 수 는 없다. 반면에 파일 시스템 라이브러리의 경우, 파일에 관한 정보 (파일 메타데이타)에 대한 접근을 도와주는 역할을 수행하며, 파일 자체를 읽는 일은 수행하지 않는다.

쉽게 말해서 하드 디스크 어딘가에 있는 a.txt 라는 파일을 찾고 싶다면 filesystem 라이브러리를 사용하게 되고, 해당 파일을 찾은 이후에 a.txt  읽고 싶다면 fstream 라이브러리를 사용하면 된다! (폴더 추가, 삭제, 권한 조회 등등도 가능)

 

먼저 코드를 보자.

#include <filesystem>
#include <iostream>

int main() {
  std::filesystem::path p("./some_file");

  std::cout << "Does " << p << " exist? [" << std::boolalpha
            << std::filesystem::exists(p) << "]" << std::endl;
  std::cout << "Is " << p << " file? [" << std::filesystem::is_regular_file(p)
            << "]" << std::endl;
  std::cout << "Is " << p << " directory? [" << std::filesystem::is_directory(p)
            << "]" << std::endl;
}

 

참고로 g++ 로 컴파일 할 때는 꼭 8 버전 이상의 컴파일러가 설치되어 있어야 <filesystem> 을 사용할 수 있다. 그 이하 버전의 경우 <experimental/filesystem> 을 사용해야 한다. (없을 수도 있음) 특히 컴파일 시에 반드시 아래와 같이 컴파일 옵션을 줘야 한다.

g++-9 test.cc -o test --std=c++17

또한 필요에 따라서 -lstdc++fs 를 추가해야 할 수 도 있다.

namespace fs = std::filesystem;

또한 매번 std::filesystem 을 쓰기 번거로우므로 위와 같이 정의하도록 하자.

 

 

경로 (path)

경로를 지정하는 방식에는 두 가지가 있는데, 바로 절대 경로 (absolute path) 와 상대 경로 (relative path) 가 있다.

  • 절대 경로는 가장 최상위 디렉토리 (이를 보통 root 디렉토리라고 합니다) 에서 내가 원하는 파일까지의 경로를 의미하는 말이다. 윈도우의 경우 root 디렉토리는 C:\ 나 D:\ 와 같은 것들이 되겠고, 리눅스의 경우 간단히 / 가 된다. 즉, 경로의 맨 앞에 / 거나 C:\ 이면 절대 경로인 것이다.
  • 상대 경로의 경우 반대로 현재 프로그램이 실행되고 있는 위치 에서 해당 파일을 찾아가는 경로이다. 예를 들어서 경로를 그냥 a/b 라고 했다면 이는 현재 프로그램의 실행 위치에서 a 라는 디렉토리를 찾고 그 안에 b 라는 파일을 찾는 식이다.
  std::cout << "Does " << p << " exist? [" << std::boolalpha
            << std::filesystem::exists(p) << "]" << std::endl;
  std::cout << "Is " << p << " file? [" << std::filesystem::is_regular_file(p)
            << "]" << std::endl;
  std::cout << "Is " << p << " directory? [" << std::filesystem::is_directory(p)
            << "]" << std::endl;

path 객체 만으로는 실제 해당 경로에 파일이 존재하는지 아닌지 알 수 없다. path 클래스는 그냥 경로를 나타낼 뿐 실제 파일을 지칭하는 것은 아니다. 따라서 exists 함수를 사용해 주어야 한다.

비슷하게 해당 위치에 있는 것이 파일인지 아니면 디렉토리인지 is_regular_file  is_directory 함수로 확인할 수 있다.

참고로 왜 그냥 is_file 이 아니라 굳이 regular 파일인지 궁금할 수 있는데 이는 리눅스 상에서 주변 장치(device) 나 소켓(socket) 들도 다 파일로 취급하기 때문이다. ("Everything is a File" 글 참고!)

리눅스에서 파일의 종류

 

 

여러 경로 관련 함수들

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
  fs::path p("./some_file.txt");

  std::cout << "내 현재 경로 : " << fs::current_path() << std::endl;
  std::cout << "상대 경로 : " << p.relative_path() << std::endl;
  std::cout << "절대 경로 : " << fs::absolute(p) << std::endl;
  std::cout << "공식적인 절대 경로 : " << fs::canonical(p) << std::endl;
}
// 출력 결과
// 내 현재 경로 : "/Users/jblee/Test"
// 상대 경로 : "./some_file.txt"
// 절대 경로 : "/Users/jblee/Test/./some_file.txt"
// 공식적인 절대 경로 : "/Users/jblee/Test/some_file.txt"

canonical 의 경우 해당 파일의 경로를 가장 짧게 나타낼 수 있는 공식적인 절대 경로를 제공한다.

위 모든 함수들의 경우 입력 받는 경로에 파일이 존재하지 않는다면 모두 예외를 throw 한다. 따라서 위 함수들을 호출하기 전에 반드시 exist 를 통해서 파일이 존재하는지 확인해야 한다.

만약에 예외를 처리하고 싶지 않다면 마지막 인자로 발생한 오류를 받는 std::error_code 객체를 전달하면 된다. 이 경우 예외를 던질 상황이 생기면 예외를 던지는 대신에 error_code 객체에 발생한 오류를 설정한다. 참고로 filesystem 에서 예외를 던지는 함수들의 경우 이처럼 마지막 인자로 error_code 를 받는 오버로딩이 제공된다.

 

 

디렉토리 관련 작업들

디렉토리 안에 모든 파일들 순회하기

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
  fs::directory_iterator itr(fs::current_path() / "a");
  while (itr != fs::end(itr)) {
    const fs::directory_entry& entry = *itr;
    std::cout << entry.path() << std::endl;
    itr++;
  }
}

// 출력 결과
// "/Users/jblee/Test/a/3.txt"
// "/Users/jblee/Test/a/2.txt"
// "/Users/jblee/Test/a/1.txt"
// "/Users/jblee/Test/a/b"

path 에는 operator/ 가 정의되어 있어 위와 같이 경로에 /a 를 추가할 수 있다. 따라서 위 코드는 "현재 경로/a" 안에 있는 파일들을 탐색하게 된다!

위코드는 아래와 같이 간략화할 수 있다.

#include <iostream>

namespace fs = std::filesystem;

int main() {
  for (const fs::directory_entry& entry :
       fs::directory_iterator(fs::current_path() / "a")) {
    std::cout << entry.path() << std::endl;
  }
}

위 방식은 내부 폴더를 재귀적으로 탐색하지 않는다. 만약 재귀적으로 서브 디렉토리까지 순회하고 싶다면, directory_iterator 대신 recursive_directory_iterator 를 사용하면 된다!

 

 

디렉토리 생성하기

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
  fs::path p("./a/c");
  std::cout << "Does " << p << " exist? [" << std::boolalpha << fs::exists(p)
            << "]" << std::endl;

  fs::create_directory(p);

  std::cout << "Does " << p << " exist? [" << fs::exists(p) << "]" << std::endl;
  std::cout << "Is " << p << " directory? [" << fs::is_directory(p) << "]"
            << std::endl;
}

// 출력 결과
// Does "./a/c" exist? [false]
// Does "./a/c" exist? [true]
// Is "./a/c" directory? [true]

create_directory 함수는 주어진 경로를 인자로 받아서 디렉토리를 생성한다. 다만 한 가지 주의할 점은 생성하는 디렉토리의 부모 디렉토리는 반드시 존재 하고 있어야 한다는 것이다. (없을 경우 예외 발생)

만약에 부모 디렉토리들까지 한꺼번에 만들고 싶다면 create_directory 대신 create_directories 함수를 사용하면 된다!

 

 

파일과 폴더 복사/삭제하기

디렉토리 구조가 다음과 같다고 가정해 보자.

$ tree
├── a
│   ├── a.txt
│   └── b
│       └── c.txt
├── c

이 때, a 의 파일들을 c 에 복사하고 싶으면, 다음과 같이 하면 된다.

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
  fs::path from("./a");
  fs::path to("./c");

  fs::copy(from, to, fs::copy_options::recursive);
}

// 실행 결과
├── a
│   ├── a.txt
│   └── b
│       └── c.txt
├── c
│   ├── a.txt
│   └── b
│       └── c.txt

 copy 에서의 옵션을 recursive 로 주었는데, 만약 이 옵션이 없으면 a.txt 만 복사가 된다.또한 a.txt 가 이미 있을 경우, 에러가 발생하는데, 다음과 같은 옵션을 주어 이를 해결할 수 있다.

  • skip_existing : 이미 존재하는 파일은 무시 (예외 안던지고)
  • overwrite_existing : 이미 존재하는 파일은 덮어 씌운다.
  • update_existing : 이미 존재하는 파일이 더 오래되었을 경우 덮어 씌운다.
fs::copy(from, to, fs::copy_options::overwrite_existing);

 

파일 / 디렉토리 삭제

remove 함수를 사용하자!

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
  fs::path p("./a/b.txt");
  std::cout << "Does " << p << " exist? [" << std::boolalpha
            << std::filesystem::exists(p) << "]" << std::endl;
  fs::remove(p);
  std::cout << "Does " << p << " exist? [" << std::boolalpha
            << std::filesystem::exists(p) << "]" << std::endl;
}

// 실행 결과
// Does "./a/b.txt" exist? [true]
// Does "./a/b.txt" exist? [false]

remove 함수를 통해서 디렉토리 역시 지울 수 있다. 단, 해당 디렉토리는 반드시 빈 디렉토리여야 한다. 만일 비어있지 않은 디렉토리를 삭제하고 싶다면 remove_all 함수를 사용하면 된다!

 

directory_iterator 사용시 주의할 점

예를 들어서 해당 디렉토리 안에 확장자가 txt 인 파일을 모두 삭제하는 프로그램을 만들고 싶다고 해보자.

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
  fs::path p("./a");
  for (const auto& entry : fs::directory_iterator("./a")) {
    const std::string ext = entry.path().extension();
    if (ext == ".txt") {
      fs::remove(entry.path());
    }
  }
}

하지만 사실 이 코드는 한 가지 문제점이 있습니다.

 
fs::remove(entry.path());

./a 디렉토리에서 파일을 하나 삭제할 때 마다 해당 디렉토리의 구조가 바뀌게 된다. 그런데 directory_iterator  디렉토리의 구조가 바뀔 때 마다 무효화 된다! 따라서 fs::remove 후에 entry 는 사용할 수 없는 반복자가 된다. 따라서 ++entry 가 다음 디렉토리를 가리키는 것을 보장할 수 없게 된다. 따라서 이 경우 어쩔 수 없이

 
#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
  fs::path p("./a");
  while (true) {
    bool is_modified = false;
    for (const auto& entry : fs::directory_iterator("./a")) {
      const std::string ext = entry.path().extension();
      if (ext == ".txt") {
        fs::remove(entry.path());
        is_modified = true;
        break;
      }
    }

    if (!is_modified) {
      break;
    }
  }
}

 

위와 같이 파일을 삭제할 때 마다 반복자를 초기화 해줘야만 한다.

Comments