Go 파일시스템과 파일 임베딩
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
Go 팀은 최근 언어와 표준 라이브러리, 툴링에 대한 변경을 제안하는 여러 초안 설계를 공개했다. 그중 제네릭에 관한 내용은 지난 6월에 다룬 바 있다. 지난주 Go 팀은 파일과 관련된 두 가지 초안 설계를 추가로 공개했다. 하나는 파일시스템을 위한 최소 인터페이스를 규정하는 새로운 읽기 전용 파일시스템 인터페이스이며, 다른 하나는 Go 바이너리에 파일을 임베드하는 표준 방식을 제안하는 설계다(인터페이스를 기반으로 한다)(파일 임베딩). Go 바이너리에 파일을 임베드하는 것은 프로그램의 모든 리소스를 단일 바이너리에 포함시켜 배포를 단순화하려는 목적이며, 파일시스템 인터페이스 설계는 주로 이를 위한 토대로 마련됐다. 두 초안에 대해서는 활발한 논의가 이어졌으며 전반적으로 긍정적인 반응이지만, 적지 않은 우려도 제기됐다.
파일시스템 인터페이스 설계는 Go 팀의 테크니컬 리드인 Russ Cox와 Go의 창시자 중 한 명인 Rob Pike가 작성했다. 파일 임베딩 설계는 오랫동안 Go에 기여해 온 Brad Fitzpatrick과 함께 Cox가 공동 저자로 참여했다. 또한 Cox는 두 설계를 각각 영상으로 설명하는 YouTube 발표를 제작해 영상 형식을 선호하는 독자들을 배려했다(파일시스템 인터페이스 영상과 파일 임베딩 영상). 두 설계 모두 아직 정식 제안이 아니라는 점을 분명히 밝히고 있다.
이 문서는 정식 Go 제안이 아닌 초안 설계(Draft Design)이다. 많은 서드파티 패키지가 해결하려는 문제와 동일한 요구를 다루는 잠재적으로 큰 변경을 설명하고, 그 구현에 영향을 줄 수 있기 때문이다(바라건대 단순화하는 방향으로!). 이 초안을 공개하는 목적은 피드백을 모아 향후 정식 제안의 방향을 잡는 데 있다.
작은 규모의 언어 및 라이브러리 변경은 대개 GitHub 이슈 트래커에서 논의되지만, 이렇게 규모가 큰 논의에 대해서는 Go 팀이 r/golang 레딧 스레드를 활용해 논의를 확장하려 하고 있다. GitHub 이슈에는 스레드 기능이 없어 여러 대화를 동시에 추적하기 어렵기 때문이다. 각 초안마다 레딧 스레드가 하나씩 마련돼 있다. 파일시스템 인터페이스 스레드와 파일 임베딩 스레드가 그것으로, 각각에 상당한 수의 댓글이 달렸다. 파일 임베딩 설계를 다루는 긴 Hacker News 스레드도 있다.
파일시스템 인터페이스
파일시스템 인터페이스 설계의 핵심은 새로운 표준 라이브러리 패키지인 io/fs에 정의된, 단일 메서드를 가진 FS 인터페이스다.
type FS interface {
Open(name string) (File, error)
}즉, 모든 파일시스템 구현은 최소한 이름으로 파일을 열어 File과 에러를 반환하는 기능을 구현해야 한다. File 인터페이스는 다음과 같이 정의된다.
type File interface {
Stat() (os.FileInfo, error)
Read(buf []byte) (int, error)
Close() error
}다시 말해 파일은 stat()이 반환하는 것과 같은 파일 정보를 제공할 수 있고, 읽을 수 있으며, 닫을 수 있다는 특성을 갖는다. 이는 표준을 준수하는 파일시스템이 제공해야 할 최소 요건이지만, 구현체는 “연산을 최적화하거나 새로운 기능을 추가하기 위해 다른 메서드를 제공할 수도 있다”. 표준 라이브러리의 파일 타입(os.File)은 이미 이 세 가지 메서드를 구현하고 있으므로 fs.File 구현으로 적합하다.
File이 실제로 디렉터리라면 Stat()이 반환하는 파일 정보가 이를 나타내며, 이 경우 Open()이 반환하는 File은 File 인터페이스에 더해 Readdir() 메서드도 구현해야 한다. Readdir()은 디렉터리 내부 파일들을 나타내는 os.FileInfo 객체 목록을 반환한다.
파일시스템 구현은 설계에서 “확장 인터페이스(extension interface)”라 부르는 방식을 통해 추가 기능을 노출할 수 있다. 이는 “임베드된 기본 인터페이스에 하나 이상의 추가 메서드를 더해, 기본 인터페이스의 인스턴스가 선택적으로 제공할 수 있는 기능을 명시하는” 인터페이스다. 예컨대 파일 전체를 한 번에 읽는 일은 흔한데, 인메모리 파일시스템 구현에서는 Open()과 여러 차례의 Read() 호출, Close()를 거쳐 이를 수행하는 것이 비효율적일 수 있다. 이런 경우 개발자는 ReadFileFS 확장 인터페이스에 정의된 ReadFile() 메서드를 구현할 수 있다.
type ReadFileFS interface {
FS // embed the filesystem interface (Open method)
ReadFile(name string) ([]byte, error)
}확장 인터페이스와 함께 설계는 io/fs 패키지에 ReadFile() 헬퍼 함수를 추가한다. 이 함수는 파일시스템이 ReadFileFS 확장을 제공하는지 확인하고, 있으면 이를 사용하고 없으면 open/read/close 시퀀스로 대체한다. 초안에는 StatFS, ReadDirFS, GlobFS 등 여러 다른 확장 인터페이스도 정의돼 있다. 설계 자체는 파일 이름 변경이나 쓰기 방법을 제공하지 않지만, 이 역시 확장을 통해 구현할 수 있다.
새로운 io/fs 타입과 헬퍼 함수 외에도, 설계는 새로운 FS 인터페이스를 활용하도록 여러 표준 라이브러리 패키지를 변경할 것을 제안한다. 예를 들어 추가하는 html/template 패키지의 ParseFS() 메서드를 통해 인메모리 파일시스템에서 템플릿을 파싱할 수 있게 하거나, 만드는 archive/zip 패키지가 FS를 구현하도록 해 개발자가 zip 파일을 파일시스템처럼 다루며 FS가 허용되는 곳 어디서든 사용할 수 있게 하는 식이다.
레딧 토론에서의 피드백은 대체로 긍정적이었으며, 이런 종류의 인터페이스는 개발자들이 원하던 것으로 보인다. 하지만 여러 사람이 지적한 비판 중 하나는 확장 인터페이스의 단점에 관한 것이다. “Acln0”는 우려를 다음과 같이 정리했다.
확장 인터페이스와 확장 패턴에 대해 한 가지 언급하고 싶은 점이 있다. http.ResponseWriter와 http 패키지가 활용하는 선택적(optional) 인터페이스가 떠오른다. 이러한 선택적 인터페이스들 때문에 http.ResponseWriter를 래핑하는 것이 어렵다. 이를 “일반적으로(generically)” 수행하려면 선택적 인터페이스의 조합이 폭발적으로 늘어나고, 다음과 같은 방식으로 쉽게 잘못되기 마련이다. “우리는 http.ResponseWriter를 래핑해 상태 로깅을 추가했는데, 이제 래퍼가 하위 핸들러에 Push 메서드를 숨겨버려 HTTP/2 push가 더 이상 동작하지 않는다.”
유명한 Go 블로거이자 연사인 Peter Bourgon은 확장 인터페이스를 이렇게 활용하는 것은 “(극히 유용한) 데코레이터 패턴을 사실상 사용할 수 없게 만든다. 정말 유감이다. 내게는 이 때문에 제안 자체가 거의 시작조차 불가능해 보인다. 데코레이터 패턴은 이런 식으로 망가뜨리기에는 너무 유용하다”고 본다. 데코레이터 패턴은 인터페이스를 감싸 기능을 추가하는 패턴이다. 웹 서버에서는 로깅이나 인증 미들웨어에 자주 쓰이며, 파일시스템 맥락에서는 캐싱이나 변환 계층을 추가하는 데 쓰일 가능성이 높다. 미들웨어 작성자가 다양한 선택적 인터페이스를 고려하지 않으면 결과물인 래퍼는 이를 지원하지 않게 된다. Go로 작성된 클라우드 스토리지 도구인 Rclone의 저자 Nick Craig-Wood 역시 제안을 긍정적으로 평가하면서도 비슷한 우려를 표했다. “확장(혹은 내가 보통 부르는 대로 선택적) 인터페이스는 유지보수 부담이 크다. 래핑하기가 정말 어렵다.”
설계는 “이러한 미들웨어를 가능하게 하는 것이 이번 초안 설계의 핵심 목표 중 하나”라고 밝히고 있어, 저자들이 이 문제를 정면으로 다루는 것이 현명해 보인다. Cox는 아직 해결책을 제시하지 않았지만, 문제는 인정했다. “맞습니다. 확장과 래퍼 사이에는 분명 긴장이 존재합니다. 이에 대한 완벽한 해결책은 아직 보지 못했습니다.”
또 다른 우려는 “TheSwedeheart”가 컨텍스트(Go에서 타임아웃과 취소 신호, 요청 범위 값을 호출 체인을 따라 명시적으로 전파하는 표준 방식)에 대해 제기했다. “[자신의 가상 파일시스템을] 이쪽으로 이전하기 위해 빠진 것 하나는 취소를 위해 각 연산에 컨텍스트를 전파하는 지원이다.
” Cox는 라이브러리 작성자가 “아마도 컨텍스트를 생성자에 전달해 그 컨텍스트를 내장한 FS를 반환받고, 그 특정 FS로 수행되는 호출들에 그 컨텍스트가 적용되도록 할 수 있을 것”이라고 답했다. “lobster_johnson”이 지적했듯 이는 컨텍스트를 구조체 안에 저장하지 말고 첫 번째 함수 인자로 명시적으로 전달하라는 context 패키지의 가이드라인에 어긋난다. 하지만 Cox는 http.Request가 유사한 방식을 쓰는 사례를 들어 반박했다. “그건 규칙이라기보다는 가이드라인에 가깝다. […] 때로는 그렇게 하는 것이 타당하다.
”
물론 네이밍을 둘러싼 전형적인 바이크셰딩(bikeshedding) 스레드도 있다. “olegkovalov”는 “io/fs라는 이름이 다소 걱정된다. fs는 변수명으로 자주 쓰이는데, io/fs가 등장하면 사용자에게 많은 혼란을 줄 것 같다”고 말했다. 몇 차례 공방 끝에 Cox는 파일시스템 구현자보다 애플리케이션 개발자에 초점을 두기 위해 짧은 이름이 필요하다고 강조했다.
당신은 사용자 대신 파일시스템 구현자에 초점을 맞추고 있다. os.FileInfo, os.ModeDir, os.PathError, os.ErrNotExist 같은 것들을 참조하는 코드는 이제 모두 정식 명칭으로 fs.FileInfo, fs.ModeDir, fs.PathError, fs.ErrNotExist를 참조하게 될 것이다. 이는 예컨대 filesystem.ErrNotExist보다 훨씬 나아 보인다. 그리고 파일시스템을 구현하는 코드보다 이러한 이름을 참조하는 코드가 훨씬 더 많을 것이다.
바이너리에 파일 임베딩
다른 초안 설계는 Go 바이너리에 파일(또는 “정적 자산(static assets)”)을 임베드하고 런타임에 그 내용을 읽는 방법을 제안한다. 이는 릴리스와 배포를 단순화한다. 개발자가 SQL 스니펫이나 HTML 템플릿, 웹 애플리케이션용 CSS 및 JavaScript 자산 등에 대해 외부 의존성 없이 하나의 큰 바이너리만 복사해 배포하면 되기 때문이다. 문서가 지적하듯 이미 이를 수행할 수 있는 서드파티 도구만 십여 개가 넘지만, “임베딩의 기본 기능에 대해 go 명령에 직접 지원을 추가하면 이러한 도구 중 일부는 필요 없어지고 적어도 나머지 도구의 구현은 단순해질 것”이다. 표준 go 도구에 임베딩을 포함하면 파일을 Go 소스 코드의 데이터로 변환하는 사전 빌드 단계가 필요 없고, 생성된 파일을 버전 관리에 커밋할 필요도 없어진다.
설계의 저자들은 이것이 Go 언어 변경이 아니라 툴링 변경임을 분명히 한다.
또 다른 명시적 목표는 언어 변경을 피하는 것이다. 우리에게 정적 자산을 임베딩하는 것은 언어 문제가 아니라 툴링 문제로 보인다. 언어 변경을 피하면 goimports, gopls, staticcheck 등 Go 코드를 처리하는 많은 도구를 업데이트해야 할 필요도 없앨 수 있다.
go 도구는 이미 Go 소스 파일에서 다양한 목적을 위해 특수한 주석을 찾는다. 특정 아키텍처에서만 파일을 포함시키기 위한 // +build 태그나, 코드 생성을 위해 go generate가 실행할 명령을 알려주는 //go:generate 주석 등이 그 예다. 이번 파일 임베딩 설계는 변수 선언 바로 위에 위치하며 go build가 해당 변수와 연결된 결과 바이너리에 그 파일들을 포함하도록 지시하는 새로운 //go:embed 주석 지시문을 제안한다. 구체적인 예는 다음과 같다.
// The "content" variable holds our static web server content.
//go:embed image/* template/*
//go:embed html/index.html
var content embed.Files이렇게 하면 go build가 image와 template 디렉터리의 모든 파일, 그리고 html/index.html 파일을 포함시켜 content 변수(embed.Files 타입)를 통해 접근할 수 있게 된다. embed 패키지는 임베드된 파일에 접근하기 위한 API를 담는 새로운 표준 라이브러리 패키지로 제안됐다. 또한 embed.Files 타입은 앞서 논의한 파일시스템 설계의 fs.FS 인터페이스를 구현하므로, 임베드된 파일을 net/http나 html/template 같은 다른 표준 라이브러리 패키지, 그리고 새로운 파일시스템 인터페이스를 지원하는 모든 서드파티 패키지에서 바로 사용할 수 있다.
이 설계는 제안의 범위를 중요한 측면에서 제한한다. 파일의 데이터를 바이너리에 포함하기 전에 변환하는 방법은 데이터 압축, TypeScript 컴파일, 이미지 리사이징 등 다양하게 존재한다. 이 설계는 단순히 원본 파일 데이터를 그대로 포함하는 방식을 택한다.
go 명령이 바람직할 수 있는 모든 변환을 예측하거나 포함하는 것은 가능하지 않다. go 명령은 일반적인 빌드 시스템도 아니다. 특히 빌드 중에 사용자 프로그램을 절대 실행하지 않는다는 설계 제약을 기억해야 한다. 이러한 변환은 go 명령이 임베드해야 할 정확한 바이트를 출력할 수 있는 Make나 Bazel 같은 외부 빌드 시스템에 맡기는 것이 가장 좋다.
이번에도 레딧 스레드의 피드백은 대체로 긍정적이었으며, “bojanz”의 “좋은 출발점으로 보인다. 이 문제를 다뤄줘서 감사하다” 같은 댓글이 대표적이다. 몇 가지 사소한 제안도 있었다. 예컨대 “zikaeroh”는 Python의 glob('**/*.png', recursive=True)처럼 재귀적 경로 매칭을 위한 더블 스타(double star)를 지원하는 더 강력한 경로 매칭 API 추가를 지지하는 댓글을 남겼다. 파일 임베딩 패키지의 메인테이너인 Kevin Burke는 각 파일 내용의 암호화 해시를 함께 저장해 개발자가 런타임에 직접 해시할 필요가 없도록 하자고 제안했다. “이는 예컨대 정적 파일 서버의 캐시 버스팅(cache busting)에 유용하다.”
반복적으로 제기된 비판 중 하나는 특수한 //go:embed 구문으로 소스 코드 주석을 오버로딩하는 것을 좋아하지 않는 개발자들의 의견이다. “Saturn_vk”는 “주석이 실제 작업을 위해 남용되는 것이 정말 마음에 들지 않는다”고 직설적으로 말했고, Hacker News 댓글 작성자 “breakingcups”는 주석 내 지시문 대신 프로젝트 파일을 사용할 것을 강력히 주장했다.
또 매직 주석(magic comments)이다.
제안된 기능 자체는 훌륭하지만, Go 팀이 별도로 명확히 정의된 프로젝트 파일을, 혹은 최소한 코드 파일 내 별도의 구문을 사용하려 하지 않기 때문에 모든 추가 기능을 인간의 메모를 위한 공간인 주석에 욱여넣게 된다.
Cox는 이 구문을 C의 #pragma에 비유하며 다음과 같은 댓글로 자신의 생각을 정리했다.
참고로 우리는 이미 //go:generate와 그 외 잘 알려지지 않은 몇 가지를 가지고 있다. 그리고 // +build를 //go:build로 교체하려는 별도의 초안 설계도 있다. 그렇게 되면 완전히 일관성을 갖추게 된다. 이러한 종류의 지시문은 모두 //go:로 시작한다는 점에서다. 핵심은 이를 알 필요가 없는 도구는 무시할 수 있을 만큼은 주석처럼 보이면서도, 무언가 특별한 일이 일어나고 있음을 사람들에게 알릴 만큼은 주석과 달라 보이도록 하는 것이다.
C는 이를 위해 #pragma foo를 사용한다. Go는 단지 #pragma를 //go:라고 표기할 뿐이다.
다음 단계
두 초안 설계 모두, 특히 사용자에게 더 직접적으로 와닿는 파일 임베딩 제안에 대해 커뮤니티의 지지가 적지 않다. 이미 많은 개발자가 배포를 단순화하기 위해 서드파티 파일 임베딩 라이브러리를 사용하고 있으며, 이번 작업은 그러한 툴링을 표준화할 것이다. 설계들은 다듬어져 정식 제안으로 발전할 가능성이 높아 보인다. Go 1.15가 8월 1일 출시될 예정인 만큼, 이 제안들이 Go 1.16(6개월 후로 예정)에 맞춰 준비될 가능성도 있지만, 예컨대 확장 인터페이스 문제와 관련해 추가 피드백 라운드가 필요하다면 1년 뒤인 Go 1.17에 포함될 가능성이 더 크다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기