코드 로딩
코드 로딩 (Code Loading)
참고: 이 장은 패키지 로딩의 기술적 세부 사항을 다루어요. 패키지를 설치하려면 줄리아 내장 패키지 관리자인
Pkg를 써서 활성 환경(active environment)에 패키지를 추가하세요. 활성 환경에 이미 있는 패키지를 사용하려면 모듈(Modules) 문서에 설명된 대로import X나using X를 쓰세요.
본문
정의 (Definitions)
줄리아에는 코드를 로딩하는 두 가지 메커니즘이 있어요.
-
코드 포함(Code inclusion): 예:
include("source.jl"). 포함은 단일 프로그램을 여러 소스 파일로 나눌 수 있게 해 줘요.include("source.jl")표현식은include호출이 일어나는 모듈의 전역 스코프에서source.jl파일의 내용을 평가하게 해요.include("source.jl")이 여러 번 호출되면source.jl도 여러 번 평가돼요. 포함되는 경로인source.jl은include호출이 일어나는 파일을 기준으로 해석돼요. 이 덕분에 소스 파일의 하위 트리를 쉽게 옮길 수 있어요. REPL에서 포함 경로는 현재 작업 디렉터리인pwd()를 기준으로 해석돼요. -
패키지 로딩(Package loading): 예:
import X또는using X. import 메커니즘은 패키지 — 즉 모듈로 감싸진 독립적이고 재사용 가능한 줄리아 코드 컬렉션 — 를 로드하게 해 주고, 결과 모듈을 import하는 모듈 안에서 이름X로 사용할 수 있게 해 줘요. 같은X패키지가 같은 줄리아 세션에서 여러 번 import되면 처음에만 로드되고, 이후의 import에서는 importing 모듈이 같은 모듈에 대한 참조를 받아요. 단,import X는 문맥에 따라 다른 패키지를 로드할 수 있다는 점에 주의하세요.X는 메인 프로젝트에서는X라는 이름의 패키지 하나를 가리키지만, 각 의존성에서는X라는 이름의 다른 패키지를 가리킬 수도 있어요. 이에 대해서는 아래에서 더 자세히 다룰게요.
코드 포함은 아주 단순하고 간단해요. 주어진 소스 파일을 호출자의 문맥에서 평가하죠. 패키지 로딩은 코드 포함 위에 구축되며 다른 목적을 제공해요. 이 장의 나머지는 패키지 로딩의 동작과 메커니즘에 초점을 맞출게요.
패키지는 다른 줄리아 프로젝트에서 재사용할 수 있는 기능을 제공하는, 표준 레이아웃을 가진 소스 트리예요. 패키지는 import X 또는 using X 문으로 로드돼요. 이 문들은 또한 패키지 코드를 로드한 결과인 X라는 모듈을 import 문이 있는 모듈 안에서 사용할 수 있게 해 줘요. import X에서 X의 의미는 문맥에 따라 달라져요. 어떤 X 패키지가 로드되는지는 그 문이 어떤 코드 안에 있느냐에 달려 있죠. 따라서 import X의 처리는 두 단계로 일어나요. 첫째, 이 문맥에서 어떤 패키지가 X로 정의되는지를 결정하고, 둘째, 그 특정 X 패키지가 어디에 있는지를 결정합니다.
이 질문들은 LOAD_PATH에 나열된 프로젝트 환경들을 뒤져 프로젝트 파일(Project.toml 또는 JuliaProject.toml), 매니페스트 파일(Manifest.toml 또는 JuliaManifest.toml, 또는 특정 버전에 대해 -v{주}.{부}.toml이 붙은 같은 이름), 또는 소스 파일 폴더를 찾아서 답해요.
패키지의 연합 (Federation of packages)
대부분의 경우 패키지는 이름만으로 고유하게 식별할 수 있어요. 하지만 때로는 프로젝트가 같은 이름을 공유하는 서로 다른 두 패키지를 사용해야 하는 상황에 부닥칠 수 있어요. 패키지 중 하나를 이름 바꿔서 해결할 수도 있지만, 그렇게 강제하는 것은 크고 공유된 코드베이스에서 매우 파괴적일 수 있어요. 대신 줄리아의 코드 로딩 메커니즘은 같은 패키지 이름이 애플리케이션의 서로 다른 구성 요소에서 서로 다른 패키지를 가리킬 수 있게 해 줘요.
줄리아는 연합 패키지 관리(federated package management)를 지원해요. 즉 여러 독립적인 주체가 공개·비공개 패키지와 패키지 레지스트리를 모두 유지할 수 있고, 프로젝트는 서로 다른 레지스트리의 공개·비공개 패키지의 혼합에 의존할 수 있어요. 다양한 레지스트리의 패키지는 공통된 도구·워크플로 집합으로 설치되고 관리돼요. 줄리아에 동봉된 Pkg 패키지 관리자는 프로젝트의 의존성을 설치하고 관리하게 해 줘요. 그것은 프로젝트 파일(프로젝트가 의존하는 다른 프로젝트를 설명하는)과 매니페스트 파일(프로젝트의 완전한 의존성 그래프의 정확한 버전을 스냅샷으로 담는)을 만들고 조작하는 것을 도와요.
연합의 한 결과는 패키지 명명의 중앙 권위자가 있을 수 없다는 점이에요. 서로 다른 주체가 같은 이름을 사용해 서로 무관한 패키지를 가리킬 수 있죠. 이런 가능성은 피할 수 없어요. 이 주체들은 조정하지 않고 서로를 모를 수도 있으니까요. 중앙 명명 권위자가 없기 때문에, 단일 프로젝트가 결국 같은 이름의 서로 다른 패키지에 의존하게 될 수 있어요. 줄리아의 패키지 로딩 메커니즘은 단일 프로젝트의 의존성 그래프 안에서조차 패키지 이름이 전역적으로 고유할 것을 요구하지 않아요. 대신 패키지는 각 패키지가 만들어질 때 할당되는 범용 고유 식별자(UUIDs)로 식별돼요. 보통 이런 다소 번거로운 128비트 식별자를 직접 다룰 필요는 없어요. Pkg가 생성과 추적을 처리해 주니까요. 하지만 이 UUID들은 "X가 가리키는 패키지는 무엇인가?"라는 질문에 대한 결정적인 답을 제공해요.
분산 명명 문제는 다소 추상적이라, 실제 시나리오를 따라가 보면 문제를 이해하는 데 도움이 될 거예요. App이라는 애플리케이션을 개발 중이고, 그것이 Pub과 Priv 두 패키지를 사용한다고 가정해 볼게요. Priv는 당신이 만든 비공개 패키지이고, Pub은 당신이 사용하지만 통제하지 않는 공개 패키지예요. Priv를 만들 때 Priv라는 이름의 공개 패키지는 없었어요. 그런데 이후에 Priv라는 이름의 무관한 패키지가 게시되어 인기를 얻었죠. 실제로 Pub 패키지가 그것을 사용하기 시작했어요. 따라서 다음에 최신 버그 수정과 기능을 얻으려고 Pub을 업그레이드하면, App은 결국 Priv라는 이름의 서로 다른 두 패키지에 의존하게 돼요. 업그레이드 외에 당신이 한 일이 없는데 말이죠. App은 당신의 비공개 Priv 패키지에 직접 의존하고, Pub을 통해 새 공개 Priv 패키지에 간접적으로 의존해요. 이 두 Priv 패키지는 서로 다르지만 App이 계속 올바르게 동작하는 데 둘 다 필요하므로, import Priv라는 표현식은 그것이 App의 코드에 있느냐 Pub의 코드에 있느냐에 따라 서로 다른 Priv 패키지를 가리켜야 해요. 이를 처리하기 위해 줄리아의 패키지 로딩 메커니즘은 두 Priv 패키지를 UUID로 구분하고, 문맥(import를 호출한 모듈)에 따라 올바른 것을 선택해요. 이 구분이 어떻게 동작하는지는 다음 절들에서 설명하는 환경(environments)에 따라 결정돼요.
환경 (Environments)
환경은 다양한 코드 문맥에서 import X와 using X가 무엇을 의미하는지, 그리고 이 문들이 어떤 파일을 로드하게 하는지를 결정해요. 줄리아는 두 종류의 환경을 이해해요.
-
**프로젝트 환경(project environment)**은 프로젝트 파일과 선택적 매니페스트 파일이 있는 디렉터리이며, 명시적 환경을 형성해요. 프로젝트 파일은 프로젝트의 직접 의존성의 이름과 정체를 결정해요. 매니페스트 파일이 있으면 그것은 직접·간접 의존성을 모두 포함한 완전한 의존성 그래프, 각 의존성의 정확한 버전, 그리고 올바른 버전을 찾아 로드하는 데 충분한 정보를 제공해요.
-
**패키지 디렉터리(package directory)**는 일련의 패키지의 소스 트리를 하위 디렉터리로 담는 디렉터리이며, 암시적 환경을 형성해요.
X가 패키지 디렉터리의 하위 디렉터리이고X/src/X.jl이 존재하면, 패키지X가 패키지 디렉터리 환경에서 사용 가능하며X/src/X.jl이 그것이 로드되는 소스 파일이에요.
이들은 섞여서 **계층 환경(stacked environment)**을 만들 수 있어요. 프로젝트 환경과 패키지 디렉터리들의 정렬된 집합으로, 겹쳐서 단일 복합 환경을 만들죠. 그러면 우선순위·가시성 규칙이 결합해 어떤 패키지를 사용할 수 있고 어디서 로드되는지 결정해요. 줄리아의 로드 경로(load path)가 예를 들어 계층 환경을 형성해요.
이 환경들은 각각 다른 목적을 제공해요.
-
프로젝트 환경은 재현성(reproducibility)을 제공해요. 프로젝트 환경을 프로젝트의 나머지 소스 코드와 함께 버전 관리(예: git 저장소)에 체크인하면 프로젝트와 모든 의존성의 정확한 상태를 재현할 수 있어요. 특히 매니페스트 파일은 각 의존성의 정확한 버전을 포착하는데, 그 소스 트리의 암호화 해시로 식별해서
Pkg가 올바른 버전을 검색하고 모든 의존성에 대해 기록된 정확한 코드를 실행하고 있음을 확신하게 해 줘요. -
패키지 디렉터리는 완전히 신중하게 추적되는 프로젝트 환경이 필요하지 않을 때 편의를 제공해요. 일련의 패키지를 어딘가에 두고, 그것들을 위해 프로젝트 환경을 만들 필요 없이 직접 사용할 수 있게 하고 싶을 때 유용하죠.
-
계층 환경은 기본 환경에 도구를 추가할 수 있게 해 줘요. 개발 도구의 환경을 스택 끝에 밀어 넣으면 REPL과 스크립트에서는 사용할 수 있지만 패키지 안에서는 사용할 수 없게 만들 수 있어요.
참고: 스택에서 활성 환경이 아닌 다른 환경에서 패키지를 로드하면, 패키지는 활성 환경의 문맥에서 로드돼요. 즉 패키지가 활성 환경에서 import된 것처럼 로드되어 의존성 버전이 어떻게 해석되는지에 영향을 줄 수 있다는 뜻이에요. 그런 패키지를 사전 컴파일할 때 (직렬) 사전 컴파일 작업으로 표시되는데, 이는 그 의존성들이 같은 작업 안에서 직렬로 사전 컴파일된다는 뜻이며 아마 더 느릴 거예요.
높은 수준에서 각 환경은 개념적으로 roots, graph, paths라는 세 가지 맵을 정의해요. import X의 의미를 해석할 때 roots와 graph 맵은 X의 정체를 결정하고, paths 맵은 X의 소스 코드를 찾는 데 쓰여요. 세 맵의 구체적 역할은 다음과 같아요.
-
roots:
name::Symbol ⟶ uuid::UUID환경의 roots 맵은 환경이 메인 프로젝트(즉
Main에서 로드할 수 있는 것들)에 제공하는 모든 최상위 의존성에 대해 패키지 이름을 UUID에 할당해요. 줄리아가 메인 프로젝트에서import X를 만나면X의 정체를roots[:X]로 찾아요. -
graph:
context::UUID ⟶ name::Symbol ⟶ uuid::UUID환경의 graph는 다중 레벨 맵으로, 각 문맥 UUID에 대해 이름에서 UUID로 가는 맵(roots 맵과 비슷하지만 그 문맥에 특화된)을 할당해요. 줄리아가 UUID가
context인 패키지의 코드에서import X를 보면X의 정체를graph[context][:X]로 찾아요. 특히 이는import X가 문맥에 따라 서로 다른 패키지를 가리킬 수 있음을 뜻해요. -
paths:
uuid::UUID × name::Symbol ⟶ path::Stringpaths 맵은 각 패키지 UUID-이름 쌍에 그 패키지의 진입점(entry-point) 소스 파일 위치를 할당해요.
import X에서X의 정체가roots나graph(메인 프로젝트에서 로드하는지 의존성에서 로드하는지에 따라)로 UUID에 해석된 후, 줄리아는 환경에서paths[uuid,:X]를 찾아X를 얻기 위해 로드할 파일을 결정해요. 이 파일에는X라는 모듈이 정의되어 있어야 해요. 이 패키지가 로드되면, 같은 uuid로 해석되는 이후의 import는 이미 로드된 패키지 모듈에 대한 새 바인딩을 만들게 돼요.
각 종류의 환경은 이 세 맵을 다르게 정의하는데, 다음 절들에서 자세히 다룰게요.
참고: 이해를 돕기 위해 이 장 전체의 예시는 roots, graph, paths의 완전한 데이터 구조를 보여 줘요. 하지만 줄리아의 패키지 로딩 코드는 이것을 명시적으로 만들지 않아요. 대신 주어진 패키지를 로드하는 데 필요한 만큼만 각 구조를 지연(lazily) 계산해요.
프로젝트 환경 (Project environments)
프로젝트 환경은 Project.toml이라는 프로젝트 파일과 선택적인 Manifest.toml이라는 매니페스트 파일을 담은 디렉터리로 결정돼요. 이 파일들은 JuliaProject.toml과 JuliaManifest.toml이라고 부를 수도 있는데, 그 경우 Project.toml과 Manifest.toml은 무시돼요. 이는 Project.toml과 Manifest.toml이라는 파일을 의미 있게 여길 수 있는 다른 도구들과 공존할 수 있게 해 줘요. 순수 줄리아 프로젝트에서는 Project.toml과 Manifest.toml 이름이 선호되지만요. 다만 줄리아 v1.10.8부터 (Julia)Manifest-v{주}.{부}.toml이 특정 줄리아 버전이 특정 매니페스트 파일을 사용하게 하는 형식으로 인식돼요. 즉 같은 폴더에서 Manifest-v1.11.toml은 v1.11이 사용하고 Manifest.toml은 그 외의 줄리아 버전이 사용해요.
프로젝트 환경의 roots, graph, paths 맵은 다음과 같이 정의돼요.
환경의 roots 맵은 프로젝트 파일의 내용, 구체적으로 최상위 name·uuid 항목과 [deps] 섹션(모두 선택적)에 의해 결정돼요. 앞서 설명한 가상 애플리케이션 App에 대한 다음 프로젝트 파일 예시를 보세요.
name = "App"
uuid = "8f986787-14fe-4607-ba5d-fbff2944afa9"
[deps]
Priv = "ba13f791-ae1d-465a-978b-69c3ad90f72b"
Pub = "c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1"
이 프로젝트 파일이 줄리아 딕셔너리로 표현된다면 다음의 roots 맵을 의미해요.
roots = Dict(
:App => UUID("8f986787-14fe-4607-ba5d-fbff2944afa9"),
:Priv => UUID("ba13f791-ae1d-465a-978b-69c3ad90f72b"),
:Pub => UUID("c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1"),
)
이 roots 맵이 주어지면 App의 코드에서 import Priv 문은 줄리아가 roots[:Priv]를 찾게 하고, 그것은 ba13f791-ae1d-465a-978b-69c3ad90f72b를 산출해요. 이것이 그 문맥에서 로드될 Priv 패키지의 UUID죠. 이 UUID는 메인 애플리케이션이 import Priv를 평가할 때 로드하고 사용할 Priv 패키지를 식별해요.
프로젝트 환경의 의존성 그래프는 매니페스트 파일(있으면)의 내용으로 결정돼요. 매니페스트 파일이 없으면 graph는 비어 있어요. 매니페스트 파일은 프로젝트의 직접·간접 의존성 각각에 대한 스탠자(stanza)를 담아요. 각 의존성에 대해 파일은 패키지의 UUID와 소스 트리 해시 또는 소스 코드에 대한 명시적 경로를 나열해요. App에 대한 다음 매니페스트 파일 예시를 보세요.
[[Priv]] # the private one
deps = ["Pub", "Zebra"]
uuid = "ba13f791-ae1d-465a-978b-69c3ad90f72b"
path = "deps/Priv"
[[Priv]] # the public one
uuid = "2d15fe94-a1f7-436c-a4d8-07a9a496e01c"
git-tree-sha1 = "1bf63d3be994fe83456a03b874b409cfd59a6373"
version = "0.1.5"
[[Pub]]
uuid = "c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1"
git-tree-sha1 = "9ebd50e2b0dd1e110e842df3b433cb5869b0dd38"
version = "2.1.4"
[Pub.deps]
Priv = "2d15fe94-a1f7-436c-a4d8-07a9a496e01c"
Zebra = "f7a24cb4-21fc-4002-ac70-f0e3a0dd3f62"
[[Zebra]]
uuid = "f7a24cb4-21fc-4002-ac70-f0e3a0dd3f62"
git-tree-sha1 = "e808e36a5d7173974b90a15a353b564f3494092f"
version = "3.4.2"
이 매니페스트 파일은 App 프로젝트에 가능한 완전한 의존성 그래프를 설명해요.
- 애플리케이션이 사용하는
Priv라는 이름의 서로 다른 두 패키지가 있어요. 하나는 루트 의존성인 비공개 패키지를, 다른 하나는Pub을 통한 간접 의존성인 공개 패키지를 써요. 이 둘은 서로 다른 UUID로 구분되고 서로 다른 deps를 가져요. 비공개Priv는Pub과Zebra패키지에 의존해요. - 공개
Priv는 의존성이 없어요. - 애플리케이션은 또한
Pub패키지에 의존하는데, 그것은 다시 공개Priv와 비공개Priv패키지가 의존하는 같은Zebra패키지에 의존해요.
이 의존성 그래프를 딕셔너리로 표현하면 이렇게 보여요.
graph = Dict(
# Priv – the private one:
UUID("ba13f791-ae1d-465a-978b-69c3ad90f72b") => Dict(
:Pub => UUID("c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1"),
:Zebra => UUID("f7a24cb4-21fc-4002-ac70-f0e3a0dd3f62"),
),
# Priv – the public one:
UUID("2d15fe94-a1f7-436c-a4d8-07a9a496e01c") => Dict(),
# Pub:
UUID("c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1") => Dict(
:Priv => UUID("2d15fe94-a1f7-436c-a4d8-07a9a496e01c"),
:Zebra => UUID("f7a24cb4-21fc-4002-ac70-f0e3a0dd3f62"),
),
# Zebra:
UUID("f7a24cb4-21fc-4002-ac70-f0e3a0dd3f62") => Dict(),
)
이 의존성 그래프가 주어지면, 줄리아가 UUID c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1를 가진 Pub 패키지에서 import Priv를 보면 다음을 찾아요.
graph[UUID("c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1")][:Priv]
그리고 2d15fe94-a1f7-436c-a4d8-07a9a496e01c를 얻는데, 이는 Pub 패키지의 문맥에서 import Priv가 애플리케이션이 직접 의존하는 비공개 패키지가 아니라 공개 Priv 패키지를 가리킨다는 것을 나타내요. 이렇게 Priv라는 이름이 메인 프로젝트에서와 그 프로젝트의 의존성 중 하나에서는 서로 다른 패키지를 가리킬 수 있고, 이는 패키지 생태계에서 중복 이름을 허용하게 해 줘요.
메인 App 코드베이스에서 import Zebra를 평가하면 어떻게 될까요? Zebra는 프로젝트 파일에 나타나지 않으므로, Zebra가 매니페스트 파일에는 나타나더라도 import는 실패해요. 게다가 공개 Priv 패키지(UUID 2d15fe94-a1f7-436c-a4d8-07a9a496e01c)에서 import Zebra가 발생하면 그것도 실패해요. 그 Priv 패키지는 매니페스트 파일에 선언된 의존성이 없어서 어떤 패키지도 로드할 수 없으니까요. Zebra 패키지는 매니페스트 파일에서 명시적 의존성으로 나타나는 패키지 — Pub 패키지와 Priv 패키지 중 하나 — 에 의해서만 로드될 수 있어요.
프로젝트 환경의 paths 맵은 매니페스트 파일에서 추출돼요. X라는 이름의 패키지 uuid의 경로는 다음 규칙(순서대로)으로 결정돼요.
- 디렉터리의 프로젝트 파일이 uuid와 이름
X와 일치하면:- 최상위
entryfile항목이 있으면 uuid를 그 경로에 매핑하고, 그것은 프로젝트 파일을 담은 디렉터리를 기준으로 해석돼요. - 아니면 uuid를 프로젝트 파일을 담은 디렉터리를 기준으로 한
src/X.jl에 매핑해요.
- 최상위
- 위가 해당하지 않고 프로젝트 파일이 대응하는 매니페스트 파일을 가지며 매니페스트에 uuid와 일치하는 스탠자가 있으면:
path항목이 있으면 그 경로(매니페스트 파일을 담은 디렉터리 기준)를 써요.git-tree-sha1항목이 있으면 uuid와git-tree-sha1의 결정적 해시 함수를 계산해slug라고 부르고, DEPOT_PATH 전역 배열의 각 디렉터리에서packages/X/$slug라는 디렉터리를 찾아요. 그런 디렉터리 중 존재하는 첫 번째 것을 써요.- 이것이 디렉터리면 uuid를
src/X.jl에 매핑하는데, 일치하는 매니페스트 스탠자에entryfile항목이 있으면 그것을 써요. 두 경우 모두 2.1의 디렉터리를 기준으로 해요.
이 중 어느 하나라도 성공하면 소스 코드 진입점의 경로는 그 결과이거나, 그 결과에서 src/X.jl까지의 상대 경로가 돼요. 그렇지 않으면 uuid에 대한 경로 매핑이 없어요. X를 로드할 때 소스 코드 경로를 찾지 못하면 조회가 실패하고, 사용자에게 적절한 패키지 버전을 설치하거나 다른 시정 조치(예: X를 의존성으로 선언)를 취하라는 프롬프트가 나올 수 있어요.
위 예시 매니페스트 파일에서 첫 Priv 패키지(UUID ba13f791-ae1d-465a-978b-69c3ad90f72b)의 경로를 찾으려면, 줄리아는 매니페스트 파일에서 그 스탠자를 찾아 path 항목이 있음을 확인하고, App 프로젝트 디렉터리 기준의 deps/Priv를 봐요 — App 코드가 /home/me/projects/App에 있다고 가정할게요 — /home/me/projects/App/deps/Priv가 존재함을 확인하고 거기서 Priv를 로드해요.
반면 다른 Priv 패키지(UUID 2d15fe94-a1f7-436c-a4d8-07a9a496e01c)를 로드한다면, 줄리아는 그 스탠자를 매니페스트에서 찾아 path 항목은 없지만 git-tree-sha1 항목은 있음을 확인해요. 그런 다음 이 UUID/SHA-1 쌍의 slug를 계산하는데, HDkrT가 돼요 (이 계산의 정확한 세부 사항은 중요하지 않지만 일관되고 결정적이에요). 이는 이 Priv 패키지의 경로가 패키지 데포(depot) 중 하나의 packages/Priv/HDkrT/src/Priv.jl이 됨을 뜻해요. DEPOT_PATH의 내용이 ["/home/me/.julia", "/usr/local/julia"]라고 가정하면, 줄리아는 존재하는지 다음 경로들을 봐요.
/home/me/.julia/packages/Priv/HDkrT/usr/local/julia/packages/Priv/HDkrT
줄리아는 존재하는 첫 번째 것을 사용해서, 찾은 데포에서 packages/Priv/HDKrT/src/Priv.jl 파일에서 공개 Priv 패키지를 로드하려 해요.
다음은 로컬 파일 시스템을 검색한 뒤, 위에 의존성 그래프로 주어진 Manifest로 제공되는, 우리 예시 App 프로젝트 환경에 대한 가능한 paths 맵의 표현이에요.
paths = Dict(
# Priv – the private one:
(UUID("ba13f791-ae1d-465a-978b-69c3ad90f72b"), :Priv) =>
# relative entry-point inside `App` repo:
"/home/me/projects/App/deps/Priv/src/Priv.jl",
# Priv – the public one:
(UUID("2d15fe94-a1f7-436c-a4d8-07a9a496e01c"), :Priv) =>
# package installed in the system depot:
"/usr/local/julia/packages/Priv/HDkrT/src/Priv.jl",
# Pub:
(UUID("c07ecb7d-0dc9-4db7-8803-fadaaeaf08e1"), :Pub) =>
# package installed in the user depot:
"/home/me/.julia/packages/Pub/oKpw/src/Pub.jl",
# Zebra:
(UUID("f7a24cb4-21fc-4002-ac70-f0e3a0dd3f62"), :Zebra) =>
# package installed in the system depot:
"/usr/local/julia/packages/Zebra/me9k/src/Zebra.jl",
)
이 예시 맵은 세 가지 서로 다른 종류의 패키지 위치를 포함해요 (첫 번째와 세 번째는 기본 로드 경로의 일부예요).
- 비공개
Priv패키지는App저장소 안에 "벤더링(vendored)"되어 있어요. - 공개
Priv와Zebra패키지는 시스템 데포에 있는데, 시스템 관리자가 설치·관리하는 패키지가 사는 곳이에요. 이것들은 시스템의 모든 사용자가 사용할 수 있어요. Pub패키지는 사용자 데포에 있는데, 사용자가 설치한 패키지가 사는 곳이에요. 이것은 그것을 설치한 사용자만 사용할 수 있어요.
패키지 디렉터리 (Package directories)
패키지 디렉터리는 이름 충돌을 처리하는 능력 없이 더 단순한 종류의 환경을 제공해요. 패키지 디렉터리에서 최상위 패키지 집합은 "패키지처럼 보이는" 하위 디렉터리 집합이에요. 패키지 X는 디렉터리가 다음 "진입점(entry point)" 파일 중 하나를 담고 있으면 패키지 디렉터리에 존재해요.
X.jlX/src/X.jlX.jl/src/X.jl
패키지 디렉터리의 패키지가 어떤 의존성을 import할 수 있는지는 패키지가 프로젝트 파일을 포함하는지에 달려 있어요.
- 프로젝트 파일이 있으면, 프로젝트 파일의
[deps]섹션에 식별된 패키지만 import할 수 있어요. - 프로젝트 파일이 없으면, 어떤 최상위 패키지든 — 즉
Main이나 REPL에서 로드할 수 있는 것과 같은 패키지 — import할 수 있어요.
roots 맵은 패키지 디렉터리의 내용을 조사해 존재하는 모든 패키지의 목록을 생성해서 결정돼요. 추가로 각 항목에 UUID가 다음과 같이 할당돼요. 폴더 X 안에서 발견된 주어진 패키지에 대해:
X/Project.toml이 존재하고uuid항목이 있으면 uuid는 그 값이에요.X/Project.toml이 존재하지만 최상위 UUID 항목이 없으면, uuid는X/Project.toml의 정규(실제) 경로를 해시해서 생성된 더미 UUID예요.- 그 외(Project.toml이 존재하지 않으면) uuid는 전부 0인 nil UUID예요.
프로젝트 디렉터리의 의존성 그래프는 각 패키지 하위 디렉터리에 프로젝트 파일의 존재와 내용으로 결정돼요. 규칙은 다음과 같아요.
- 패키지 하위 디렉터리에 프로젝트 파일이 없으면 graph에서 생략되고 그 코드의 import 문은 메인 프로젝트와 REPL처럼 최상위로 취급돼요.
- 패키지 하위 디렉터리에 프로젝트 파일이 있으면, 그 UUID의 graph 항목은 프로젝트 파일의
[deps]맵이며, 섹션이 없으면 비어 있는 것으로 간주돼요.
예를 들어 패키지 디렉터리가 다음 구조와 내용을 가진다고 가정해 볼게요.
Aardvark/
src/Aardvark.jl:
import Bobcat
import Cobra
Bobcat/
Project.toml:
[deps]
Cobra = "4725e24d-f727-424b-bca0-c4307a3456fa"
Dingo = "7a7925be-828c-4418-bbeb-bac8dfc843bc"
src/Bobcat.jl:
import Cobra
import Dingo
Cobra/
Project.toml:
uuid = "4725e24d-f727-424b-bca0-c4307a3456fa"
[deps]
Dingo = "7a7925be-828c-4418-bbeb-bac8dfc843bc"
src/Cobra.jl:
import Dingo
Dingo/
Project.toml:
uuid = "7a7925be-828c-4418-bbeb-bac8dfc843bc"
src/Dingo.jl:
# no imports
다음은 그것에 대응하는 roots 구조를 딕셔너리로 표현한 것이에요.
roots = Dict(
:Aardvark => UUID("00000000-0000-0000-0000-000000000000"), # no project file, nil UUID
:Bobcat => UUID("85ad11c7-31f6-5d08-84db-0a4914d4cadf"), # dummy UUID based on path
:Cobra => UUID("4725e24d-f727-424b-bca0-c4307a3456fa"), # UUID from project file
:Dingo => UUID("7a7925be-828c-4418-bbeb-bac8dfc843bc"), # UUID from project file
)
다음은 그것에 대응하는 graph 구조를 딕셔너리로 표현한 것이에요.
graph = Dict(
# Bobcat:
UUID("85ad11c7-31f6-5d08-84db-0a4914d4cadf") => Dict(
:Cobra => UUID("4725e24d-f727-424b-bca0-c4307a3456fa"),
:Dingo => UUID("7a7925be-828c-4418-bbeb-bac8dfc843bc"),
),
# Cobra:
UUID("4725e24d-f727-424b-bca0-c4307a3456fa") => Dict(
:Dingo => UUID("7a7925be-828c-4418-bbeb-bac8dfc843bc"),
),
# Dingo:
UUID("7a7925be-828c-4418-bbeb-bac8dfc843bc") => Dict(),
)
주목할 몇 가지 일반 규칙이 있어요.
- 프로젝트 파일이 없는 패키지는 어떤 최상위 의존성에든 의존할 수 있고, 패키지 디렉터리의 모든 패키지는 최상위에서 사용 가능하므로 환경의 모든 패키지를 import할 수 있어요.
- 프로젝트 파일이 있는 패키지는 없는 패키지에 의존할 수 없어요. 프로젝트 파일이 있는 패키지는 graph의 패키지만 로드할 수 있고, 프로젝트 파일이 없는 패키지는 graph에 나타나지 않으니까요.
- 프로젝트 파일은 있지만 명시적 UUID가 없는 패키지는 프로젝트 파일이 없는 패키지만 의존할 수 있어요. 이 패키지들에 할당된 더미 UUID는 엄격히 내부적이기 때문이에요.
우리 예시에서 이 규칙의 구체적 사례를 관찰해 보세요.
Aardvark는Bobcat,Cobra,Dingo중 아무거나 import할 수 있어요. 실제로Bobcat과Cobra를 import해요.Bobcat은 UUID가 있는 프로젝트 파일을 가진Cobra와Dingo를 모두 import할 수 있고 실제로 import해요. 둘 다Bobcat의[deps]섹션에 의존성으로 선언되어 있어요.Bobcat은Aardvark에 의존할 수 없어요.Aardvark는 프로젝트 파일이 없으니까요.Cobra는 프로젝트 파일과 UUID를 가진Dingo를 import할 수 있고 실제로 import해요.Dingo는Cobra의[deps]섹션에 의존성으로 선언되어 있어요.Cobra는Aardvark나Bobcat에 의존할 수 없어요. 둘 다 실제 UUID가 없으니까요.Dingo는 아무것도 import할 수 없어요.[deps]섹션 없는 프로젝트 파일을 가지니까요.
패키지 디렉터리의 paths 맵은 간단해요. 하위 디렉터리 이름을 대응하는 진입점 경로에 매핑하죠. 다시 말해 우리 예시 프로젝트 디렉터리의 경로가 /home/me/animals라면 paths 맵은 이 딕셔너리로 표현할 수 있어요.
paths = Dict(
(UUID("00000000-0000-0000-0000-000000000000"), :Aardvark) =>
"/home/me/AnimalPackages/Aardvark/src/Aardvark.jl",
(UUID("85ad11c7-31f6-5d08-84db-0a4914d4cadf"), :Bobcat) =>
"/home/me/AnimalPackages/Bobcat/src/Bobcat.jl",
(UUID("4725e24d-f727-424b-bca0-c4307a3456fa"), :Cobra) =>
"/home/me/AnimalPackages/Cobra/src/Cobra.jl",
(UUID("7a7925be-828c-4418-bbeb-bac8dfc843bc"), :Dingo) =>
"/home/me/AnimalPackages/Dingo/src/Dingo.jl",
)
패키지 디렉터리 환경의 모든 패키지는 정의상 기대되는 진입점 파일을 가진 하위 디렉터리이므로, paths 맵 항목은 항상 이 형태를 가져요.
환경 스택 (Environment stacks)
세 번째이자 마지막 종류의 환경은 여러 환경을 겹쳐서 결합해 각각의 패키지를 단일 복합 환경에서 사용할 수 있게 하는 것이에요. 이런 복합 환경을 **환경 스택(environment stacks)**이라고 불러요. 줄리아의 LOAD_PATH 전역이 환경 스택 — 줄리아 프로세스가 동작하는 환경 — 을 정의해요. 줄리아 프로세스가 하나의 프로젝트 또는 패키지 디렉터리의 패키지에만 접근하게 하려면 그것을 LOAD_PATH의 유일한 항목으로 만들면 돼요. 하지만 자신이 가장 좋아하는 도구 — 표준 라이브러리, 프로파일러, 디버거, 개인 유틸리티 등 — 에 접근하는 것은 종종 매우 유용해요. 그것들이 작업 중인 프로젝트의 의존성이 아니더라도 말이죠. 이런 도구를 담은 환경을 로드 경로에 추가하면, 프로젝트에 추가할 필요 없이 최상위 코드에서 즉시 접근할 수 있어요.
환경 스택의 구성 요소들의 roots, graph, paths 데이터 구조를 결합하는 메커니즘은 간단해요. 딕셔너리로 병합하되, 키 충돌 시 앞선 항목을 나중 항목보다 선호해요. 다시 말해 stack = [env₁, env₂, …]이면 다음과 같아요.
roots = reduce(merge, reverse([roots₁, roots₂, …]))
graph = reduce(merge, reverse([graph₁, graph₂, …]))
paths = reduce(merge, reverse([paths₁, paths₂, …]))
아래첨자 rootsᵢ, graphᵢ, pathsᵢ 변수는 스택에 담긴 아래첨자 환경 envᵢ에 대응해요. reverse가 있는 이유는 merge가 인자 딕셔너리 사이에 키 충돌이 있을 때 첫 번째가 아니라 마지막 인자를 선호하기 때문이에요. 이 설계에는 주목할 만한 특징이 몇 가지 있어요.
- 기본 환경(primary environment) — 즉 스택의 첫 번째 환경 — 은 계층 환경에 충실하게 내장돼요. 스택에서 첫 번째 환경의 완전한 의존성 그래프는 모든 의존성의 같은 버전을 포함해 계층 환경에 온전하게 포함되는 것이 보장돼요.
- 기본이 아닌 환경의 패키지는 자기 환경이 완전히 호환 가능하더라도 호환되지 않는 버전의 의존성을 쓰게 될 수 있어요. 자신의 의존성 중 하나가 스택에서 더 앞선 환경의 버전(그래프나 경로, 또는 둘 다)에 가려질 때 이런 일이 발생할 수 있어요.
기본 환경이 보통 작업 중인 프로젝트의 환경이고, 스택의 뒤쪽 환경은 추가 도구를 담으므로, 이는 올바른 절충이에요. 개발 도구를 망가뜨려도 프로젝트는 작동하게 두는 게 낫죠. 그런 비호환이 발생하면, 보통 메인 프로젝트와 호환되는 버전으로 개발 도구를 업그레이드하고 싶을 거예요.
패키지 확장 (Package Extensions)
패키지 "확장(extension)"은 지정된 다른 패키지 집합(그것의 "트리거(triggers)")이 현재 줄리아 세션에서 로드될 때 자동으로 로드되는 모듈이에요. 확장은 프로젝트 파일의 [extensions] 섹션 아래에 정의돼요. 확장의 트리거는 프로젝트 파일의 [weakdeps](그리고 드물지만 [deps]) 섹션에 나열된 패키지 중 부분집합이에요. 그 패키지들은 다른 패키지처럼 compat 항목을 가질 수 있어요.
name = "MyPackage"
[compat]
ExtDep = "1.0"
OtherExtDep = "1.0"
[weakdeps]
ExtDep = "c9a23..." # uuid
OtherExtDep = "862e..." # uuid
[extensions]
BarExt = ["ExtDep", "OtherExtDep"]
FooExt = "ExtDep"
...
extensions 아래의 키는 확장의 이름이에요. 그것들은 해당 확장의 오른쪽에 있는 모든 패키지(트리거)가 로드될 때 로드돼요. 확장에 트리거가 하나뿐이면 트리거 목록을 간결하게 문자열로만 쓸 수 있어요. 확장 FooExt의 진입점 위치는 ext/FooExt.jl 또는 ext/FooExt/FooExt.jl이에요. 확장의 내용은 보통 다음과 같이 구성돼요.
module FooExt
# Load main package and triggers
using MyPackage, ExtDep
# Extend functionality in main package with types from the triggers
MyPackage.func(x::ExtDep.SomeStruct) = ...
end
확장이 있는 패키지가 환경에 추가되면 weakdeps와 extensions 섹션은 매니페스트 파일의 해당 패키지 섹션에 저장돼요. 패키지의 의존성 조회 규칙은 "부모(parent)"와 같되, 나열된 트리거도 의존성으로 간주돼요.
워크스페이스 (Workspaces)
프로젝트 파일은 그 워크스페이스의 일부인 프로젝트 집합을 주어 워크스페이스를 정의할 수 있어요.
[workspace]
projects = ["test", "benchmarks", "docs", "SomePackage"]
projects 배열에 나열된 각 프로젝트는 워크스페이스 루트에서의 상대 경로로 지정돼요. 직접 자식 디렉터리(예: "test")일 수도 있고 중첩 하위 디렉터리(예: "nested/subdir/MyPackage")일 수도 있어요. 각 프로젝트는 자신의 Project.toml 파일을 가지는데, 추가 의존성과 호환성 제약을 포함할 수 있어요. 그런 경우 패키지 관리자는 워크스페이스의 모든 프로젝트에서 모든 의존성 정보를 모아, 모든 의존성의 버전을 결합한 단일 매니페스트 파일을 생성해요.
줄리아가 프로젝트를 로드할 때, 사용자의 홈 디렉터리에 도달할 때까지 부모 디렉터리들을 위로 검색해 그 프로젝트를 포함하는 워크스페이스를 찾아요. 이는 워크스페이스 프로젝트를 워크스페이스 디렉터리 트리 안의 임의의 깊이에 중첩할 수 있게 해 줘요.
게다가 워크스페이스는 "중첩"될 수 있어요. 워크스페이스를 정의하는 프로젝트가 다른 워크스페이스의 일부일 수도 있죠. 이 시나리오에서도 단일 매니페스트 파일이 사용되며, "루트 프로젝트"(자신을 포함하는 다른 워크스페이스가 없는 프로젝트) 옆에 저장돼요. 예시 파일 구조는 다음과 같을 수 있어요.
Project.toml # projects = ["MyPackage"]
Manifest.toml
MyPackage/
Project.toml # projects = ["test"]
test/
Project.toml
패키지/환경 환경설정 (Package/Environment Preferences)
환경설정(Preferences)은 환경 안에서 패키지 동작에 영향을 주는 메타데이터의 딕셔너리예요. 환경설정 시스템은 컴파일 시점에 환경설정을 읽는 것을 지원하는데, 이는 코드 로딩 시점에 줄리아가 선택한 사전 컴파일 파일들이 현재 환경과 같은 환경설정으로 빌드되었는지 로드하기 전에 확인해야 한다는 뜻이에요. 환경설정을 수정하는 공개 API는 Preferences.jl 패키지에 담겨 있어요. 환경설정은 현재 활성 프로젝트 옆에 (Julia)LocalPreferences.toml 파일 안에 TOML 딕셔너리로 저장돼요. 환경설정이 "exported"되면 대신 (Julia)Project.toml 안에 저장돼요. 의도는 공유 프로젝트가 공유 환경설정을 담게 하면서, 사용자는 LocalPreferences.toml 파일에 자신의 설정으로 그 환경설정을 덮어쓸 수 있게 하는 것이에요. 그 파일은 이름이 암시하듯 .gitignore되어야 해요.
컴파일 중에 접근되는 환경설정은 자동으로 컴파일 시점 환경설정으로 표시되고, 이 환경설정에 기록된 변경은 줄리아 컴파일러가 해당 모듈의 캐시된 사전 컴파일 파일들(.ji와 대응하는 .so, .dll, 또는 .dylib 파일)을 다시 컴파일하게 해요. 이는 컴파일 중에 모든 컴파일 시점 환경설정의 해시를 직렬화한 다음, 로드할 적절한 파일들을 찾을 때 그 해시를 현재 환경과 대조해 확인하는 방식으로 이뤄져요.
환경설정은 데포 전체 기본값으로 설정할 수 있어요. 패키지 Foo가 전역 환경에 설치되어 있고 환경설정이 있다면, 전역 환경이 LOAD_PATH의 일부인 한 그 환경설정이 적용돼요. 환경 스택에서 더 위쪽 환경의 환경설정은 로드 경로에서 더 가까운 항목이 덮어쓰며, 마지막에는 현재 활성 프로젝트가 끝나요. 이는 데포 전체 환경설정 기본값이 존재하게 하면서, 활성 프로젝트가 상속된 환경설정을 병합하거나 완전히 덮어쓸 수 있게 해 줘요. 병합을 허용할지 불허용할지 환경설정을 설정하는 방법의 전체 세부 사항은 Preferences.set_preferences!()의 독스트링을 보세요.
결론 (Conclusion)
연합 패키지 관리와 정밀한 소프트웨어 재현성은 패키지 시스템에서 어렵지만 가치 있는 목표예요. 결합하면 이 목표들은 대부분의 동적 언어가 가진 것보다 더 복잡한 패키지 로딩 메커니즘으로 이어지지만, 동시에 보통 정적 언어와 연관되는 확장성과 재현성을 만들어 내요. 일반적으로 줄리아 사용자는 내장 패키지 관리자를 써서 이러한 상호작용의 정확한 이해 없이도 프로젝트를 관리할 수 있어야 해요. Pkg.add("X") 호출은 Pkg.activate("Y")로 선택된 적절한 프로젝트와 매니페스트 파일에 추가해서, 이후의 import X 호출이 추가 생각 없이 X를 로드하게 해요.
더 알아보기 (Learn more)
코드 로딩을 이해하는 핵심은 roots, graph, paths라는 세 가지 맵의 역할과, 프로젝트 환경·패키지 디렉터리·환경 스택이라는 세 환경의 차이를 파악하는 거예요. 같은 이름 X가 문맥(모듈)에 따라 서로 다른 패키지를 가리킬 수 있고, 그것은 UUID로 구분된다는 점을 직접 프로젝트를 만들어 확인해 보면 자연스럽게 정리돼요.