이 문서는 여러 저장소에서 만들어진 산출물들이 어떻게 하나의 배포 트리로 합쳐지고, 사용자가 byeol 명령을 실행했을 때 실제로 무슨 일이 벌어지는지를 설명합니다. 저장소들이 어떤 관계로 엮여 있는지는 아키텍쳐와 설계 의 저장소 구조 섹션에서 다루므로, 여기서는 빌드가 끝난 다음의 이야기에 집중합니다.
이 문서를 관통하는 원칙은 하나입니다. 사용자는 byeol 하나만 본다는 것이죠. 내부적으로 아무리 여러 실행파일이 협력하더라도, 사용자가 알아야 하는 이름은 byeol 뿐이어야 합니다. 아래의 모든 결정은 이 원칙에서 파생됩니다.
배포된 byeol에는 실행파일이 두 개 있습니다. 이름이 똑같이 byeol이라서 혼란스러울 수 있지만, 역할은 전혀 다릅니다.
첫 번째는 launcher입니다. 사용자의 PATH에 잡히는 진입점이며, 실제로 언어를 실행하지는 않습니다. 어느 버전의 인터프리터를 써야 하는지 결정하고, 업데이트를 처리하고, 패키지 매니저 명령을 전달하는 얇은 프록시입니다.
두 번째는 인터프리터 본체입니다. 파싱, 검증, 실행 등 언어의 모든 일을 실제로 수행하는 프로그램이며, 아키텍쳐와 설계 에서 설명하는 계층 구조를 담고 있는 것이 바로 이 실행파일입니다.
launcher를 따로 둔 이유는 버전을 자유롭게 바꾸기 위해서입니다. rbenv나 pyenv가 하는 일과 같습니다. 사용자가 치는 명령은 ruby이지 rbenv가 아니듯, byeol에서도 사용자가 치는 명령은 언제나 byeol이고 그 뒤에서 어떤 버전이 선택되는지는 launcher가 알아서 처리합니다.
두 실행파일의 이름이 모두 byeol이면 개발자가 빌드 디렉토리에서 둘을 구분할 수 없습니다. 그래서 빌드할 때와 배포할 때 이름을 다르게 씁니다.
빌드 단계에서는 각자 고유한 타깃 이름을 가집니다. launcher 저장소의 CMake 타깃은 launcher이고, byeol 저장소의 타깃은 byeol입니다. 개발자는 빌드 디렉토리에서 이름만 보고 둘을 구분할 수 있죠.
배포 단계에서는 둘 다 최종 파일명이 byeol이 됩니다. launcher 저장소의 CMakeLists.txt에는 이 의도가 그대로 드러나 있습니다.
launcher라는 이름은 저장소와 빌드와 문서에서만 쓰이는 내부 식별자이며, 사용자 시야에는 절대 닿지 않습니다. launcher에 그럴싸한 브랜드명을 붙이지 않는 것도 같은 이유입니다. 사용자가 가장 먼저 만지는 프로그램에 독립된 이름을 붙이면 그게 언어 이름이라고 오해하게 되니까요.
이름이 같은 두 실행파일은 경로로 구분됩니다.
규칙은 단순합니다. 루트의 byeol은 절대로 버전 폴더 안으로 들어가지 않고, 인터프리터는 절대로 루트에 나오지 않습니다. 그래서 경로만 보면 어느 쪽인지 즉시 알 수 있죠. 디버깅을 하거나 로그를 읽을 때 "이 byeol이 대체 뭐냐"는 질문에 절대경로가 바로 답을 줍니다. 루트에 있으면 launcher, toolchains/<버전>/ 아래에 있으면 인터프리터입니다.
여러 버전이 toolchains/ 아래에 나란히 설치될 수 있다는 점도 중요합니다. 버전을 바꾼다는 것은 새로 설치하고 덮어쓰는 일이 아니라, 이미 있는 폴더 중 어느 것을 쓸지 고르는 일입니다.
사용자가 byeol hello.by를 입력하면 다음 순서로 일이 진행됩니다.
먼저 PATH에 잡힌 launcher가 실행됩니다. launcher는 인자를 해석해서 이것이 자기가 직접 처리할 명령인지, 인터프리터에게 넘길 명령인지 판단합니다. toolchain 관련 명령이나 self update는 launcher가 직접 처리하고, 코드를 실행하는 명령은 인터프리터에게 넘깁니다.
넘길 명령이라면 launcher는 활성 버전을 해소합니다. 버전 설정 파일을 읽어서 어느 toolchain을 쓸지 결정하고, 그에 해당하는 toolchains/<버전>/byeol 경로를 얻습니다.
그다음 launcher는 그 인터프리터를 자식 프로세스로 spawn하면서 자신의 표준 입출력을 그대로 물려줍니다. 그리고 자식이 끝날 때까지 부모로 남아 기다립니다. 자식이 종료되면 그 종료 코드를 그대로 반환하고 자신도 종료합니다.
여기서 핵심은 launcher가 데이터를 중계하지 않는다는 점입니다. 표준 입출력을 상속시켰기 때문에 터미널이나 IDE는 인터프리터와 직접 이야기하며, launcher는 오가는 바이트를 건드리지 않습니다. 그래서 launcher가 중간에 살아 있어도 속도에 아무런 영향이 없습니다. 기다리는 동안 launcher는 잠들어 있으므로 비용도 사실상 없죠.
그런데도 launcher를 종료시키지 않고 남겨두는 이유는 세션 생명주기 관리 때문입니다. 인터프리터가 끝나면 launcher의 대기가 풀리면서 세션이 깔끔하게 닫히고, IDE 입장에서는 추적해야 할 프로세스가 명확해집니다.
IDE에서 byeol 코드를 디버깅할 때는 DAP(Debug Adapter Protocol) 방식으로 통신합니다. JSON 기반의 메시지를 주고받으며, 전송 수단으로는 소켓이 아니라 파이프(표준 입출력)를 사용합니다.
파이프를 고른 이유는 앞서 설명한 프로세스 모델과 자연스럽게 맞물리기 때문입니다. launcher가 이미 표준 입출력을 인터프리터에게 물려주고 있으므로, IDE는 추가 설정 없이 인터프리터와 직접 대화할 수 있습니다. 소켓을 쓰면 포트를 협상하고, 충돌을 처리하고, 보안 바인딩을 신경 써야 하는데 그 고민을 통째로 피할 수 있죠. 대부분의 DAP 어댑터가 표준 입출력을 기본으로 쓰는 것도 같은 이유입니다.
IDE는 어느 버전의 인터프리터가 실제로 실행되는지 알 필요가 없습니다. IDE는 launcher만 바라보고, 버전 해소는 launcher가 책임집니다. 이것이 launcher를 버전 스위처로 둔 설계가 디버깅까지 일관되게 이어지는 지점입니다.
다음 문서: 테스트 케이스 작성 가이드