Coming in Go 1.16: ReadDir and DirEntry

Ben Hoyt

Go 1.16에 추가되는 ReadDir와 DirEntry

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

Python의 os.scandir 함수와 PEP 471(scandir에 대한 최초 제안)의 주 저자로서, Go 1.16에서 비슷한 기능이 추가되는 것을 보게 되어 매우 기뻤습니다. Go 1.16은 2021년 2월 말에 출시될 예정입니다.

Go에서는 os.ReadDir이라는 이름으로 제공되며, 작년 9월에 제안되었습니다. 100개가 넘는 댓글과 여러 차례의 설계 수정을 거쳐 10월에 Russ Cox가 커밋했습니다. 파일 시스템에 종속되지 않는 버전도 새로운 io/fs 패키지에 fs.ReadDir로 포함되어 있습니다.

왜 ReadDir가 필요한가?

짧게 답하자면 성능 때문입니다.

디렉터리 항목을 읽기 위해 시스템 함수를 호출하면 OS는 일반적으로 파일 이름 그리고 파일 타입을 함께 반환합니다(Windows에서는 파일 크기나 마지막 수정 시간 같은 stat 정보까지 포함합니다). 하지만 기존의 Go와 Python 인터페이스는 이 추가 정보를 버렸기 때문에 항목마다 추가로 stat 호출을 해야 했습니다. 시스템 호출 자체가 결코 가볍지 않은 데다, stat은 디스크, 혹은 최소한 디스크 캐시에서 읽어와야 할 수도 있습니다.

디렉터리 트리를 재귀적으로 순회할 때는 각 항목이 파일인지 디렉터리인지 알아야 재귀적으로 들어가야 할지 결정할 수 있습니다. 그래서 단순한 디렉터리 트리 순회조차도 디렉터리 항목을 읽는 것에 더해 각 항목마다 stat을 호출해야 했습니다. 하지만 OS가 제공하는 파일 타입 정보를 활용하면 이러한 stat 호출을 피할 수 있어 디렉터리를 몇 배, 네트워크 파일 시스템에서는 심지어 수십 배 더 빠르게 순회할 수 있습니다. Python 버전에 대한 벤치마크를 참고하세요.

안타깝게도 두 언어 모두 디렉터리를 읽는 설계가 처음부터 최적이 아니어서, stat을 추가로 호출하지 않고는 타입 정보에 접근할 수 없었습니다. Python에서는 os.listdir, Go에서는 ioutil.ReadDir이 그 예입니다.

저는 2012년에 Python의 scandir에 대한 아이디어를 처음 떠올렸고, 2015년에 출시된 Python 3.5를 위해 구현했습니다(그 과정에 대해 더 읽기). 이후로도 개선과 추가가 계속되었습니다. 예를 들어 with 문 처리나 파일 디스크립터 지원 등이 있습니다.

Go의 경우 Python 버전을 만들며 얻은 경험을 바탕으로 개선을 제안하는 개의 댓글을 남긴 것 외에는 제안이나 구현에 관여한 바가 없습니다.

Python vs Go

새로운 “디렉터리 읽기” 인터페이스를, 특히 Python과 Go에서 얼마나 비슷한지 살펴보겠습니다.

Python에서는 os.scandir(path)를 호출하면 os.DirEntry 객체의 이터레이터가 반환되며, 그 형태는 다음과 같습니다:

class DirEntry:
    # This entry's filename.
    name: str

    # This entry's full path: os.path.join(scandir_path, entry.name).
    path: str

    # Return inode or file ID for this entry.
    def inode(self) -> int: ...

    # Return True if this entry is a directory.
    def is_dir(self, follow_symlinks=True) -> bool: ...

    # Return True if this entry is a regular file.
    def is_file(self, follow_symlinks=True) -> bool: ...

    # Return True if this entry is a symbolic link.
    def is_symlink(self) -> bool: ...

    # Return stat information for this entry.
    def stat(self, follow_symlinks=True) -> stat_result: ...

namepath 속성에 접근할 때는 절대 예외가 발생하지 않지만, 메서드 호출은 운영체제와 파일 시스템, 그리고 해당 항목이 심볼릭 링크인지 여부에 따라 OSError를 발생시킬 수 있습니다. 예를 들어 Linux에서는 stat이 항상 시스템 호출을 수행하므로 예외가 발생할 수 있지만, is_X 메서드들은 보통 그렇지 않습니다.

Go에서는 os.ReadDir(path)를 호출하면 os.DirEntry 객체의 슬라이스가 반환되며, 그 형태는 다음과 같습니다:

type DirEntry interface {
    // Returns the name of this entry's file (or subdirectory).
    Name() string

    // Reports whether the entry describes a directory.
    IsDir() bool

    // Returns the type bits for the entry (a subset of FileMode).
    Type() FileMode

    // Returns the FileInfo (stat information) for this entry.
    Info() (FileInfo, error)
}

바로 유사점을 알 수 있지만, Go답게 Go 버전은 다소 더 단순합니다. 사실 제가 Python의 scandir를 다시 만든다면 조금 더 단순한 인터페이스를 주장했을 것 같습니다. 특히 follow_symlinks 매개변수를 없애고 기본적으로 심볼릭 링크를 따라가지 않도록 했을 겁니다.

다음은 os.scandir를 사용한 예시입니다. 디렉터리와 그 하위 디렉터리에 있는 파일들의 총 크기를 재귀적으로 계산하는 함수입니다:

def get_tree_size(path):
    total = 0
    with os.scandir(path) as entries:
        for entry in entries:
            if entry.is_dir(follow_symlinks=False):
                total += get_tree_size(entry.path)
            else:
                total += entry.stat(follow_symlinks=False).st_size
    return total

Go 1.16이 나오면 같은 기능은 다음과 같이 작성할 수 있습니다:

func GetTreeSize(path string) (int64, error) {
    entries, err := os.ReadDir(path)
    if err != nil {
        return 0, err
    }
    var total int64
    for _, entry := range entries {
        if entry.IsDir() {
            size, err := GetTreeSize(filepath.Join(path, entry.Name()))
            if err != nil {
                return 0, err
            }
            total += size
        } else {
            info, err := entry.Info()
            if err != nil {
                return 0, err
            }
            total += info.Size()
        }
    }
    return total, nil
}

상위 수준의 구조는 비슷하지만, 물론 누군가는 “봐, Go의 에러 처리가 얼마나 많은 보일러플레이트를 만드는지!”라고 말할 겁니다. 그리고 그 말은 맞습니다. Python 코드는 매우 깔끔합니다. 간단한 스크립트에서는 그 정도로 충분하고, 그게 바로 Python이 빛나는 지점입니다.

하지만 프로덕션 코드나 견고한 커맨드라인 유틸리티에서는 stat 호출 주변에서 에러를 처리하고, 어쩌면 권한 에러를 무시하거나 로그를 남기고 싶을 겁니다. Go 코드는 에러가 발생할 수 있다는 사실을 명시적으로 드러내며, 로깅이나 더 친절한 에러 메시지를 쉽게 추가할 수 있게 해줍니다.

상위 수준의 트리 순회

게다가 두 언어 모두 디렉터리 트리를 재귀적으로 순회하는 상위 수준 함수를 제공합니다. Python에서는 os.walk가 그 역할을 합니다. Python에서 scandir의 장점은 os.walk의 시그니처를 바꿀 필요가 없었다는 점이며, 덕분에 기존의 모든 os.walk 사용자(그 수가 매우 많습니다)는 자동으로 속도 향상을 얻게 되었습니다.

예를 들어 os.walk를 사용해 디렉터리 트리에서 점으로 시작하지 않는 파일 경로를 모두 출력하려면 다음과 같이 할 수 있습니다:

def list_non_dot(path):
    paths = []
    for root, dirs, files in os.walk(path):
        # Modify dirs to skip directories starting with '.'
        dirs[:] = [d for d in dirs if not d.startswith('.')]
        for f in files:
            if f.startswith('.'):
                continue
            paths.append(os.path.join(root, f))
    return sorted(paths)

Python 3.5부터 os.walk가 내부적으로 listdir 대신 scandir를 사용하므로, 이 코드는 운영체제와 파일 시스템에 따라 마법처럼 1.5배에서 20배까지 빨라집니다.

Go(1.16 이전)에도 비슷한 함수인 filepath.Walk가 있지만, 안타깝게도 FileInfo 인터페이스는 여러 메서드 호출에서 에러를 보고할 수 있도록 설계되지 않았습니다. 앞서 봤듯이 이들 메서드는 때때로 시스템 호출을 수행할 수 있습니다. 예를 들어 Size 같은 stat 정보는 Linux에서 항상 시스템 호출을 필요로 합니다. 그래서 Go에서는 메서드가 에러를 반환해야 합니다(Python에서는 예외를 발생시킵니다).

에러 처리를 대충 넘기고 FileInfo 인터페이스를 재사용해서 기존 코드가 마법처럼 빨라지도록 하려는 유혹이 있었습니다. 실제로 issue 41188은 Russ Cox가 바로 그런 내용을 제안한 이슈입니다(생각만큼 끔찍한 아이디어는 아니라는 것을 보여주는 데이터와 함께). 하지만 stat은 실제로 에러를 반환할 수 있어, 에러 발생 시 파일 크기가 0으로 반환되는 등의 문제가 생길 여지가 있었습니다. 그 결과 기존 API에 끼워 넣으려는 시도에 상당한 반대가 있었고, 결국 Russ는 합의가 이루어지지 않았음을 인정하고 대신 DirEntry 인터페이스를 제안했습니다.

이는 성능 향상을 얻으려면 filepath.Walk 호출을 filepath.WalkDir로 바꿔야 한다는 의미입니다. 두 함수는 매우 비슷하지만, walk 함수가 FileInfo 대신 DirEntry를 받는다는 점이 다릅니다.

기존 filepath.Walk 함수를 사용한 list_non_dot의 Go 버전은 다음과 같습니다:

func ListNonDot(path string) ([]string, error) {
    var paths []string
    err := filepath.Walk(path, func(p string, info os.FileInfo,
                                    err error) error {
        if strings.HasPrefix(info.Name(), ".") {
            if info.IsDir() {
                return filepath.SkipDir
            }
            return err
        }
        if !info.IsDir() {
            paths = append(paths, p)
        }
        return err
    })
    return paths, err
}

물론 이 코드는 Go 1.16에서도 계속 동작하지만, 성능상의 이점을 얻으려면 아주 작은 변경이 필요합니다. 이 경우 WalkWalkDir로, os.FileInfoos.DirEntry로 바꾸기만 하면 됩니다:

    err := filepath.WalkDir(path, func(p string, info os.DirEntry,

참고로 Linux에서 제 홈 디렉터리를 대상으로 캐시된 상태에서 첫 번째 함수를 실행하면 약 580ms가 걸립니다. Go 1.16의 새 버전을 사용하면 약 370ms가 걸려 대략 1.5배 더 빠릅니다. 엄청난 차이는 아니지만 충분히 가치 있는 개선이며, 네트워크 파일 시스템이나 Windows에서는 훨씬 더 큰 속도 향상을 얻을 수 있습니다.

요약

새로운 ReadDir API는 사용하기 쉽고, fs.ReadDir를 통해 새로운 파일 시스템 인터페이스와도 잘 통합됩니다. 그리고 기존 Walk 호출의 속도를 높이기 위해 WalkDir로 전환하는 데 필요한 수정도 아주 사소합니다.

API 설계는 어렵습니다. 크로스 플랫폼, OS 관련 API 설계는 더욱 어렵습니다. 다음에 프로그래밍 언어의 표준 라이브러리를 설계할 때는 꼭 제대로 해내시길 바랍니다! :-)

어쨌든 Go의 디렉터리 읽기 지원이 더 이상 Python에 뒤처지지 않게 되어 — 혹은 walking 뒤처지지 않게 되어 — 기쁩니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글