컴파일과 모듈
컴파일과 모듈 (Compilation and Modules)
Raku 모듈이 언제, 어떻게 컴파일되는지, 어디에 저장되는지, 그리고 컴파일된 형태로 어떻게 접근하는지가 이 문서의 핵심이에요.
개요 (Overview)
Raku는 Perl 언어 계열의 일원답게, 최상위 레벨에서는 해석형(interpreted) 쪽에 더 가까워요. 여기서 '해석형 프로그램'이라 함은 소스 코드, 그러니까 say 'hello world'; 같은 사람이 읽을 수 있는 텍스트를 Raku 프로그램이 즉시 컴퓨터가 실행할 수 있는 코드로 처리하고, 중간 단계는 메모리에 저장해 두는 방식을 말해요.
반대로 컴파일형(compiled) 프로그램은 사람이 읽는 소스를 먼저 기계가 실행 가능한 코드로 처리해서, 그 코드의 어떤 형태를 '디스크에 저장'해 두는 방식이에요. 프로그램을 실행할 때는 이 기계가 읽는 버전을 메모리에 불러와서 돌리는 거죠.
컴파일형과 해석형은 각자 장점이 있어요. 간단히 말하면, 해석형 프로그램은 '빨리 만들어 내고' 소스도 금방 바꿀 수 있어요. 컴파일형 프로그램은 복잡하고 기계가 읽는 코드로 사전 처리하는 데 시간이 걸리지만, 실행은 훨씬 빠릅니다. 사용자는 컴파일 시간을 보지 못하고 로딩과 실행 시간만 보게 되니까요.
Raku는 이 두 패러다임을 다 품고 있어요. 최상위 레벨에서는 Raku 프로그램이 해석되지만, 모듈로 분리된 코드는 컴파일되고, 전처리된 버전을 필요할 때 불러옵니다. 실제로 커뮤니티에서 작성된 모듈은 zef 같은 모듈 관리자로 '설치'할 때 사용자가 한 번만 사전 컴파일하면 돼요. 그다음부터는 개발자가 자신의 프로그램에서 use로 가져다 쓰면 됩니다. 덕분에 Raku의 최상위 레벨 프로그램은 빠르게 실행돼요.
Perl 계열 언어의 큰 강점 중 하나는 능력 있는 프로그래머들이 작성한 모듈 생태계 전체를 작은 프로그램 하나에 통합할 수 있다는 점이었어요. 이 강점은 널리 모방되어 이제 모든 언어의 표준이 되었죠. Raku는 통합을 한 발 더 나아가서, 다른 언어로 작성된 시스템 라이브러리를 Raku 프로그램에 비교적 쉽게 포함시킬 수 있는데, 자세한 내용은 Native Call을 참고하세요.
Perl과 다른 언어에서 얻은 경험상, 모듈이 분산되어 있다는 특성은 실질적인 어려움 몇 가지를 낳아요.
- 인기 있는 모듈은 API가 개선되면서 여러 버전을 거치기 마련인데, 역호환성이 보장된다는 약속은 없어요. 그래서 프로그램이 특정 함수나 반환값에 의존한다면, Version을 지정할 방법이 있어야 합니다.
- 모듈을 작성한 밥(Bob)이라는 아주 유능한 프로그래머가 다른 길을 가면서 모듈을 방치하면, 앨리스(Alice)가 이어받죠. 그러면 같은 이름, 같은 일반적인 API를 가진 같은 모듈이 세상에 두 버전으로 존재하게 될 수 있어요. 또는 처음에 같이 협력하던 두 개발자(예: 앨리스와 밥)가 개발 방향으로 갈라서기도 하고요. 그래서 때로는 모듈의 Auth(작성자)를 정의할 방법이 필요해져요.
- 모듈이 시간이 지나면서 향상되고, 관리자가 두 버전을 다른 API로 유지할 수도 있어요. 그래서 필요한 API를 정의할 필요가 생깁니다.
- 새 프로그램을 개발할 때 개발자는 앨리스와 밥이 각각 작성한 모듈을 모두 로컬에 설치하고 싶을 수 있어요. 단순히 이름 하나에 버전 하나만 설치할 수는 없다는 뜻이죠.
Raku는 이 모든 가능성을 열어 둡니다. 여러 버전, 여러 작성자(auth), 여러 API가 동시에 설치되고 로컬에서 사용 가능할 수 있어요. 클래스와 모듈을 특정 속성으로 접근하는 방법은 다른 문서에서 설명하고, 이 문서는 Raku가 이런 가능성들을 어떻게 다루는지에 집중할게요.
소개 (Introduction)
Raku 프레임워크를 보기 전에, Perl이나 Python 같은 언어가 모듈 설치와 로딩을 어떻게 다루는지 먼저 볼게요.
ACME::Foo::Bar -> ACME/Foo/Bar.pm
os.path -> os/path.py
이런 언어에서는 모듈 이름이 파일시스템 경로와 1:1 대응돼요. 더블 콜론이나 마침표를 슬래시로 바꾸고 .pm이나 .py를 붙이면 끝입니다.
주의: 이건 상대 경로예요. Python과 Perl 둘 다 이 경로를 완성하기 위해 include 경로 목록을 사용합니다. Perl에서는 전역 배열 @INC에 그 목록이 있어요.
@INC
/usr/lib/perl5/site_perl/5.22.1/x86_64-linux-thread-multi
/usr/lib/perl5/site_perl/5.22.1/
/usr/lib/perl5/vendor_perl/5.22.1/x86_64-linux-thread-multi
/usr/lib/perl5/vendor_perl/5.22.1/
/usr/lib/perl5/5.22.1/x86_64-linux-thread-multi
/usr/lib/perl5/5.22.1/
이 include 디렉터리 각각이, 모듈 이름에서 결정된 상대 경로를 담고 있는지 확인돼요. 조건이 맞으면 그 파일이 로드됩니다.
물론 이건 약간 단순화된 설명이에요. 두 언어 모두 컴파일된 버전의 캐싱을 지원하거든요. 그래서 Perl은 .pm 파일 대신 먼저 .pmc 파일을 찾고, Python은 먼저 .pyc 파일을 찾아요.
두 경우 모두 모듈 설치는 대부분 그 단순한 매핑으로 결정된 위치에 파일을 복사하는 일이에요. 이 시스템은 설명하기 쉽고, 이해하기 쉽고, 단순하고 견고합니다.
왜 바꿔야 하지? (Why change?)
Raku는 왜 다른 프레임워크가 필요했을까요? 그 이유는 다른 언어들이 가지지 못한 기능 때문이에요. 바로:
- 유니코드 모듈 이름
- 다른 작성자가 같은 이름으로 배포한 모듈
- 모듈의 여러 버전을 동시에 설치할 수 있는 것
26개의 라틴 문자 집합은 실제 현대 언어들(영어 포함)에는 너무 제약적이에요. 흔히 쓰는 많은 단어에 발음 구별 기호(diacritics)가 있으니까요.
모듈 이름과 파일시스템 경로가 1:1 대응되면, 여러 플랫폼과 파일시스템에서 유니코드를 지원하려는 순간 고통이 시작됩니다.
그다음은 여러 작성자 사이에서 모듈 이름을 공유하는 문제예요. 이건 실제로 잘 풀릴 수도 있고 아닐 수도 있습니다. 예를 들어 원래 작성자가 '공식' 버전에 어떤 수정을 포함할 때까지, 그 수정이 들어 있는 모듈을 배포하는 용도로 쓸 수 있을 거예요.
마지막이 여러 버전 문제예요. 보통 특정 버전이 필요한 사람들은 local::lib이나 컨테이너, 검증 안 된 자체 해결책 같은 걸 찾게 됩니다. 그것들은 저마다 단점이 있어요. 그런데 애플리케이션이 '예전 그 믿을 만한 2.9 버전이 필요해' 또는 '그 브랜치의 버그 수정 릴리스가 필요해'라고만 말할 수 있다면, 이런 것들이 아예 필요 없어질 테죠.
단순한 이름 매핑을 계속 쓰고 싶은 희망이 있었다면, 버전 관리 요구 앞에서 아마 포기했을 거예요. 2.9 이상을 찾으면서 3.2 버전을 어떻게 찾을 수 있겠어요?
인기 있던 아이디어는 설치된 모듈 정보를 JSON 파일에 모으는 것이었는데, 그게 너무 느리다는 게 드러나자 텍스트 파일 대신 메타데이터를 SQLite 데이터베이스에 넣었죠. 그런데 여기에 또 하나의 요구사항, 즉 배포 패키지(distribution packages)가 추가되면 이런 아이디어는 쉽게 무너져요.
리눅스 배포판의 패키지는 대부분 몇몇 파일과 메타데이터가 담긴 아카이브예요. 이상적으로는 그런 패키지 설치는 파일을 풀고 중앙 패키지 데이터베이스를 갱신하는 일이 전부죠. 제거는 이 방식으로 설치된 파일을 지우고 다시 패키지 데이터베이스를 갱신하면 됩니다. 설치·제거할 때 기존 파일을 변경하게 되면 패키지 관리자들의 삶이 훨씬 어려워지기 때문에, 우리는 그걸 피하고 싶어요. 설치되는 파일의 이름이 이전에 설치된 것에 의존해도 안 되고요. 패키징 시점에 파일 이름이 무엇이 될지 확실히 알아야 합니다.
긴 이름 (Long names)
Foo::Bar:auth<cpan:nine>:ver<0.3>:api<1>
이 상황에서 벗어나는 0단계는 긴 이름(long name)을 정의하는 거예요. Raku의 완전한 모듈 이름은 짧은 이름(short-name), auth, version, API로 이루어집니다.
동시에, 여러분이 설치하는 것은 보통 단일 모듈이 아니라 모듈을 하나 이상 포함하는 배포(distribution)예요. 배포 이름도 모듈 이름과 똑같이 작동합니다. 실제로 배포는 보통 자신의 메인 모듈 이름으로 불리죠. 배포의 중요한 성질은 **불변(immutable)**이라는 점이에요. Foo:auth<cpan:nine>:ver<0.3>:api<1>은 언제나 정확히 같은 코드를 가리키는 이름입니다.
$*REPO
Perl과 Python에서는 파일시스템 디렉터리를 가리키는 include 경로를 다뤘어요. Raku에서는 그런 디렉터리를 "저장소(repository)"라고 부르고, 각 저장소는 CompUnit::Repository 역할(role)을 수행하는 객체가 관리합니다. @INC 배열 대신 $*REPO 변수가 있어요. 그 변수는 단일 저장소 객체를 담고 있고, 이 객체에는 다른 저장소를 담을 수 있는 next-repo 속성이 있어요. 다시 말해 저장소들은 *연결 리스트(linked list)*로 관리됩니다. 전통적인 배열과의 중요한 차이는, 리스트를 따라가면서 각 객체가 요청을 next-repo로 넘길지 말지를 결정할 수 있다는 점이에요. Raku는 표준 저장소 집합인 "core", "vendor", "site"를 설정하고, 여기에 현재 사용자용 "home" 저장소가 더해집니다.
저장소는 need 메서드를 구현해야 해요. Raku 코드의 use나 require 문장은 기본적으로 $*REPO의 need 메서드 호출로 번역됩니다. 이 메서드는 그 요청을 next-repo에 위임할 수도 있고요.
role CompUnit::Repository {
has CompUnit::Repository $.next-repo is rw;
method need(CompUnit::DependencySpecification $spec,
CompUnit::PrecompilationRepository $precomp,
CompUnit::Store :@precomp-stores
--> CompUnit:D
)
{ ... }
method loaded(
--> Iterable
)
{ ... }
method id( --> Str )
{ ... }
}
저장소들 (Repositories)
Rakudo에는 저장소로 쓸 수 있는 클래스가 여러 개 따라옵니다. 가장 중요한 것은 CompUnit::Repository::FileSystem과 CompUnit::Repository::Installation이에요. FileSystem 저장소는 모듈을 개발하는 동안 쓰기 위한 것으로, 실제로 모듈을 찾는 방식이 Perl과 똑같습니다. 버전이나 auth를 지원하지 않고 짧은 이름을 그냥 파일시스템 경로에 매핑할 뿐이에요.
Installation 저장소가 진짜 핵심이에요. 모듈을 요청할 때, 정확한 긴 이름으로 요청하거나 "이 필터에 맞는 모듈을 줘" 같은 방식으로 요청하는 경우가 대부분이죠. 그런 필터는 CompUnit::DependencySpecification 객체로 주어지는데, 이 객체는 다음 필드를 가져요.
- short-name,
- auth-matcher,
- version-matcher,
- api-matcher.
후보를 훑을 때 Installation 저장소는 모듈의 긴 이름을 이 DependencySpecification과 스마트매치(smartmatch)합니다. 좀 더 정확히는 각 필드를 각각의 matcher와 비교하죠. 그래서 matcher는 구체적인 값일 수도, 버전 범위일 수도, 심지어 정규식일 수도 있어요. 물론 .* 같은 임의의 정규식은 별로 쓸모없지만, 3.20.1+ 같은 건 3.20.1보다 높은 후보만 골라냅니다.
설치된 모든 배포의 메타데이터를 통째로 불러오는 건 너무 느려서 현실적이지 않아요. 현재 Raku 프레임워크의 구현은 파일시스템을 일종의 데이터베이스로 사용합니다. 물론 다른 구현은 다른 전략을 쓸 수도 있어요. 아래 설명은 한 구현이 어떻게 동작하는지를 보여주는 것으로, 무슨 일이 일어나는지를 설명하기 위해 실은 거예요.
우리는 배포의 파일뿐 아니라 조회를 빠르게 해 주는 인덱스도 만듭니다. 그런 인덱스 중 하나는 설치된 모듈의 짧은 이름을 딴 디렉터리 형태예요. 그런데 오늘날 대부분의 파일시스템은 유니코드 이름을 제대로 다루지 못해서, 모듈 이름을 그대로 쓸 수 없어요. 여기서 악명 높은 SHA-1 해시가 등장합니다. 디렉터리 이름은 UTF-8로 인코딩된 모듈 짧은 이름의 ASCII 인코딩 SHA-1 해시입니다.
이 디렉터리에는, 일치하는 짧은 이름을 가진 모듈을 포함하는 배포마다 파일이 하나씩 있어요. 이 파일들은 다시 dist의 ID와 긴 이름을 구성하는 나머지 필드, 즉 auth, version, api를 담고 있습니다. 그래서 이 파일들을 읽으면 보통 짧은 auth-version-api 세 개 묶음 목록을 얻고, 이를 DependencySpecification과 대조할 수 있어요. 결국 승리한 배포의 ID를 얻고, 그걸로 JSON 인코딩된 파일에 저장된 메타데이터를 조회합니다. 이 메타데이터에는 요청한 모듈의 코드가 담긴 sources/ 디렉터리의 파일 이름이 들어 있어요. 그걸 불러오면 되는 거죠.
소스 파일 이름을 만드는 것도 또 까다로운데, 여전히 유니코드 문제가 있고, 게다가 같은 상대 파일 이름이 서로 다른 설치 배포(버전이 다른)에서 쓰일 수 있기 때문이에요. 그래서 지금은 적어도 긴 이름의 SHA-1 해시를 사용합니다.
리소스 (Resources)
%?RESOURCES
%?RESOURCES<libraries/p5helper>
%?RESOURCES<icons/foo.png>
%?RESOURCES<schema.sql>
Foo
|___ lib
| |____ Foo.rakumod
|
|___ resources
|___ schema.sql
|
|___ libraries
|____ p5helper
| |___
|___ icons
|___ foo.png
이런 방식으로 저장·발견되는 건 소스 파일만이 아니에요. 배포는 임의의 리소스 파일도 포함할 수 있습니다. 이미지, 언어 파일, 설치할 때 컴파일되는 공유 라이브러리 같은 것들이죠. 그런 것들은 모듈 안에서 %?RESOURCES 해시를 통해 접근할 수 있어요.
배포의 표준 레이아웃 규칙만 지키면, 아무것도 설치하지 않고 개발 중에도 이게 동작합니다.
이 아키텍처의 멋진 부산물 하나는, 특수 목적의 저장소를 만드는 게 상당히 쉽다는 점이에요.
의존성 (Dependencies)
다행히 사전 컴파일은 대부분의 경우 꽤 잘 동작해요. 그래도 그 나름의 도전 과제를 안고 있습니다. 단일 모듈을 로드하는 건 쉽죠. 진짜 재미(?)는 모듈에 의존성이 있고, 그 의존성이 또 자기만의 의존성을 가질 때 시작됩니다.
Raku에서 사전 컴파일된 파일을 로드할 때는 그 모든 의존성의 사전 컴파일된 파일도 함께 로드해야 해요. 그리고 그 의존성들은 반드시 사전 컴파일된 것이어야 합니다. 소스 파일에서 로드할 수 없어요. 더 나쁜 건, 의존성의 precomp 파일이 처음에 우리 모듈을 사전 컴파일할 때 썼던 것과 정확히 같은 파일이어야 한다는 점이에요.
그 위에, 사전 컴파일된 파일은 컴파일에 쓰인 바로 그 Raku 바이너리에서만 동작합니다.
그래도 여기에 하나의 요구사항만 더 없었다면 꽤 감당할 만했을 거예요. 바로, 방금 설치한 모듈의 새 버전이 실제로 쓰이길 기대한다는 점이죠. 그렇지 않나요?
다시 말해, 사전 컴파일된 모듈의 의존성을 업그레이드하면, 우리는 그걸 감지해서 새 의존성으로 모듈을 다시 사전 컴파일해야 합니다.
사전 컴파일 저장소 (Precomp stores)
기억하세요. 우리는 표준 저장소 체인을 갖고 있지만, 사용자는 명령줄의 -I나 코드의 "use lib"로 추가 저장소를 앞에 붙일 수 있어요.
이 저장소들이 사전 컴파일된 모듈의 의존성을 담을 수 있습니다.
이 난제에 대한 우리의 첫 해결책은, 각 저장소마다 사전 컴파일 파일이 저장되는 자체 precomp store를 두는 것이었어요. 우리는 체인에서 가장 첫 번째 저장소의 precomp store에서만 precomp 파일을 로드하는데, 그 이유는 그 저장소만이 모든 후보에게 직접적이거나 적어도 간접적으로 접근할 수 있기 때문이에요.
이 저장소가 FileSystem 저장소라면, 우리는 .precomp 디렉터리에 precomp store를 만듭니다.
안전한 선택이긴 하지만, 그 결과로 새 저장소를 사용할 때마다 사전 컴파일된 파일에 접근할 수 없는 상태에서 시작하게 돼요.
대신, 사용된 모듈이 처음 로드될 때 사전 컴파일을 수행합니다.
감사의 말 (Credit)
이 튜토리얼은 niner의 강연에 기반해요.