추상화가 깨지는 순간을 존중하기(NestJS)

원민관·2026년 6월 17일

[TIL]

목록 보기
211/215
post-thumbnail

0. 들어가며 ✍️

겉으로 보기에는 아주 단순한, 포트폴리오 목적의 개인 CRUD 게시판 프로젝트를 시작하려고 합니다.

최근 개발에 대한 회의감이 들었습니다. 누구든 AI 프롬프트 몇 줄로 화려한 UI와 동작하는 소프트웨어를 뚝딱 만들어낼 수 있는 시대입니다. 이 시점에서 "개발을 잘한다는 것은 도대체 어떤 함의를 갖는가?"라는 질문을 지울 수 없었습니다. 고민 끝에 내린 결론은, 결국 차별성은 "얕지만 화려하게"가 아닌 "단순하더라도 깊게" 파고들어 본 경험에서 온다는 것이었습니다.

패키지 매니저, 프레임워크, 그리고 AI는 사실 모두 추상화에 불과합니다. 흔히 프로그래밍에서 추상화를 복잡성을 숨기는 것으로 오해하곤 합니다. "코드를 왜 짜냐? 그 복잡한 거 딸깍! 하면 되는데 ㄹㅇ ㅋㅋ"가 추상화에 대한 오해이죠. 하지만 추상화는 복잡성을 숨기는 것이 아니라 미래로 이동시키는 것이며, 당장의 편리함을 위해 시스템 하부의 불확실성을 담보로 당겨쓰는 레버리지일 뿐입니다.

프레임워크를 쓰면서도 HTTP 프로토콜을 공부하고, ORM을 쓰면서도 SQL과 데이터베이스 내부 동작을 깊이 파는 이유는 명확합니다. 좋은 엔지니어란 자신이 어떤 복잡성 위에 서 있는지 정확히 인지하면서, 그럼에도 현재 단계에서는 신경 쓰지 않기로 선택하는 사람이기 때문입니다. 내부 메커니즘을 통제할 수 있는 사람만이 모두가 퇴근한 새벽 3시에 홀로 터진 시스템 장애를 마주하고 근본적인 원인을 찾아 대응할 수 있습니다.

프로젝트의 백엔드 프레임워크로는 NestJS를 선택했습니다. 오늘은, NestJS를 설치하는 부분만 분해해 보겠습니다. 참고로 이 글의 모든 예시는 Homebrew로 Node.js와 Yarn을 설치한 macOS 환경을 기준으로 작성했습니다.


1. [Yarn] 전이적 의존성 추적 ✍️

원격 저장소에 있는 타인의 소스 코드를 내 프로젝트에 가져오기 위해, 과거에는 해당 사이트에 찾아가 ZIP 파일을 다운로드하고, 압축을 풀고, 프로젝트 폴더에 수동으로 넣어야 했습니다. 버전이 업데이트되면 이 번거로운 과정을 매번 반복해야 했고, 유지 보수를 매우 어렵게 만들었죠.

Yarn은 이러한 수동 과정을 명령어 한 줄로 처리할 수 있는 패키지 매니저입니다. npm registry와 동일한 중앙 집중식 원격 저장소에서 원하는 코드를 검색, 설치, 업데이트, 삭제할 수 있게 해줍니다. npm의 대체재로 등장했지만 registry 자체는 그대로 공유합니다.

패키지 매니저의 핵심 메커니즘은 의존성 해결(Dependency Resolution)입니다. 현대의 소프트웨어는 여러 모듈이 거미줄처럼 얽혀 있습니다. A 라이브러리가 작동하려면 B 라이브러리가 필요하고, B가 작동하려면 모듈 C가 필요한 것과 같은 상황이 발생하는데, 이를 전이적 의존성(Transitive Dependency)이라고 합니다.

Yarn은 개발자가 일일이 수동으로 계산할 수 없는 복잡한 종속 관계를 스스로 추적하고 분석하여, 충돌이 나지 않도록 최적의 의존성 지도를 그려 한 번에 모두 다운로드해 줍니다.

# 전역 설치 경로 확인
yarn global dir
# 예: /Users/wonminkwan/.config/yarn/global

그렇다면 Yarn 자체는 어떻게 설치되어 있는 걸까요? npm과 달리 Yarn은 Node.js에 기본 번들로 포함되어 있지 않습니다. 별도의 패키지 매니저인 만큼, Homebrew 같은 시스템 패키지 매니저로 직접 설치해야 합니다.

brew install yarn

이 사실이 다음 챕터로 이어지는 중요한 연결고리가 됩니다. Homebrew로 설치했다는 것은, 진입점 파일이 놓이는 위치도 Homebrew의 규칙을 따른다는 뜻입니다.


2. [Shell] PATH를 통한 실행 파일 탐색 ✍️

yarn global add @nestjs/cli에서 global은 무슨 의미일까요? 이 옵션 없이 설치하면 해당 패키지는 현재 프로젝트의 node_modules/.bin/ 폴더에만 저장됩니다. 즉, 그 프로젝트 디렉터리 안에서만 실행할 수 있습니다. 반면 global을 붙이면 시스템 전역(global) 경로에 설치되어, 어떤 디렉터리에서든 nest 명령어를 바로 사용할 수 있게 됩니다.

그런데 셸(Shell)은 nest라는 명령어를 입력받았을 때, 도대체 어디서 실행 파일을 찾아야 하는지 어떻게 알까요? 여기서 PATH 환경 변수가 등장합니다.

PATH는 셸이 명령어를 찾을 디렉터리 목록을 순서대로 담고 있는 환경 변수입니다. 직접 확인해 보겠습니다.

echo $PATH
# 출력 예시 (Homebrew가 PATH 최우선일 때)
# /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

콜론(:)으로 구분된 각각이 하나의 디렉터리 경로입니다. 셸은 명령어를 입력받으면 PATH에 등록된 디렉터리들을 왼쪽부터 순서대로 탐색하며, 일치하는 실행 파일을 찾는 순간 즉시 실행합니다. 끝까지 찾지 못하면 그제야 우리에게 익숙한 command not found 에러를 반환합니다.

셸이 실제로 nest를 어느 경로에서 찾아냈는지는 which 명령어로 확인할 수 있습니다.

which nest
# 출력 예시
# /opt/homebrew/bin/nest

which는 PATH를 순서대로 탐색해서 처음 발견한 실행 파일의 전체 경로를 출력해 줍니다. 이것이 셸이 nest를 입력받았을 때 실제로 실행하는 파일입니다.

그런데 이 파일의 정체는 실제 구현 코드가 담긴 파일이 아니라 심볼릭 링크(Symbolic Link)입니다. 심볼릭 링크는 실제 파일의 위치를 가리키는 포인터 파일입니다. 셸의 탐색 범위(PATH) 안에 작은 포인터 파일 하나를 놓아두고, 실제 구현은 다른 곳에 보관하는 구조입니다.

-la 옵션으로 파일의 실체를 직접 확인할 수 있습니다.

ls -la $(which nest)
# 출력 예시
lrwxr-xr-x@ 1 wonminkwan  admin  68  6  6 16:33 /opt/homebrew/bin/nest -> ../../../Users/wonminkwan/.config/yarn/global/node_modules/.bin/nest

-> 화살표 뒤에 실제 파일이 있는 경로가 보입니다. 그런데 이 경로를 따라가서 또 한 번 확인해 보면 흥미로운 지점이 있습니다. .bin/nest도 그 자체가 최종 구현 파일이 아니라, yarn global dirnode_modules/@nestjs/cli/bin/nest.js를 가리키는 또 하나의 심볼릭 링크입니다.

cat $(which nest)
#!/usr/bin/env node
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const commander = require("commander");
const commands_1 = require("../commands");
...

cat으로 내용을 열어보면 곧바로 셔뱅과 실제 JavaScript 코드가 등장합니다. 셸이 nest를 찾으면, 심볼릭 링크를 따라가서 곧바로 실제 구현 파일로 제어권이 넘어가는 구조입니다.

실제 구현 파일이 어디 있는지도 직접 확인할 수 있습니다.

ls $(yarn global dir)/node_modules/@nestjs/cli/bin/
# nest.js 파일이 보입니다

3. [Kernel] Shebang을 통한 Node.js 전환 ✍️

셸이 심볼릭 링크를 통해 실제 파일에 도달했습니다. 파일의 첫 줄에는 shebang(셔뱅)이 적혀 있습니다.

head -1 $(yarn global dir)/node_modules/@nestjs/cli/bin/nest.js
# 출력
#!/usr/bin/env node

이것이 shebang(셔뱅) 입니다. #!로 시작하는 이 한 줄은 운영체제 커널에게 "이 파일을 어떤 인터프리터로 실행할지"를 알려주는 지시어입니다.

운영체제는 파일을 실행할 때 가장 먼저 해당 파일이 네이티브 바이너리(컴파일된 기계어)인지 스크립트인지를 판별합니다. 판별 방식은 파일의 맨 앞 몇 바이트를 읽는 것입니다. 이 식별자를 매직 바이트(Magic Bytes) 혹은 파일 시그니처라고 합니다. 이것도 직접 확인할 수 있습니다.

# 파일의 앞 4바이트를 16진수로 출력
xxd $(yarn global dir)/node_modules/@nestjs/cli/bin/nest.js | head -1
00000000: 2321 2f75 7372 2f62 696e 2f65 6e76 206e  #!/usr/bin/env n

맨 앞 두 바이트가 23 21, 즉 ASCII 코드로 #!입니다. 커널은 이 두 바이트를 보는 순간 "스크립트 파일이구나, shebang 뒤에 적힌 프로그램으로 실행해야겠다"라고 판단합니다. 참고로 PDF는 25 50 44 46으로 시작합니다. 파일의 정체는 확장자가 아니라 매직 바이트가 결정합니다.

shebang 뒤의 /usr/bin/env node는 조금 특별합니다. /usr/bin/node처럼 Node.js의 절대 경로를 직접 적는 대신, env라는 유틸리티를 통해 간접적으로 node를 찾습니다. env는 현재 사용자의 환경 변수(PATH 포함)를 기반으로 node를 탐색합니다.

# env가 찾아낸 node의 실제 경로 확인
which node
# 출력 예시 (Homebrew로 Node.js를 설치한 경우)
# /opt/homebrew/bin/node

이렇게 하면 사용자마다 Node.js 설치 경로가 달라도 항상 올바른 node를 찾아 실행할 수 있습니다. 만약 /usr/bin/node를 직접 적었다면, Homebrew로 별도 경로에 Node.js를 설치한 사용자에게는 즉시 No such file or directory 에러가 발생했을 것입니다.

결국 우리가 터미널에 nest를 입력하는 순간, 운영체제가 실제로 수행하는 작업은 다음 명령어와 동일합니다.

node /Users/wonminkwan/.config/yarn/global/node_modules/@nestjs/cli/bin/nest.js

이 시점부터 실행 주체는 셸이 아니라 Node.js 런타임입니다. 셸은 역할을 다했고, 이제 Node.js가 JavaScript 파일을 해석하기 시작합니다.


4. [Runtime] process 객체와 인자 전달 ✍️

Node.js 런타임이 시작되었습니다. 이제 우리가 터미널에 입력한 추가 인자들, 가령 new my-app이나 --version 같은 것들은 어떻게 프로그램 내부로 전달될까요?

Node.js는 실행될 때 process라는 전역 객체를 자동으로 생성합니다. 이 객체는 현재 실행 중인 Node.js 프로세스에 대한 모든 정보를 담고 있습니다. 그중에서 process.argv가 바로 명령줄 인자(CLI arguments)를 담는 배열입니다.

process.argv가 실제로 어떻게 생겼는지 직접 출력해 볼 수 있습니다. 터미널에서 바로 Node.js를 실행하면서 인자를 넘겨보면 됩니다.

node -e "console.log(process.argv)" -- hello world
[
  '/opt/homebrew/bin/node',  // [0] Node.js 실행 파일 경로
  '',                        // [1] 실행된 스크립트 경로 (-e 플래그로 실행 시 빈 문자열)
  'hello',                   // [2] 첫 번째 실제 인자
  'world'                    // [3] 두 번째 실제 인자
]

실제로 nest new my-app을 실행할 때는 이렇게 됩니다.

[
  '/opt/homebrew/bin/node',
  '/Users/wonminkwan/.config/yarn/global/node_modules/@nestjs/cli/bin/nest.js',
  'new',
  'my-app'
]

process 객체는 특정 JavaScript 코드가 만들어내는 것이 아닙니다. Node.js 런타임이 시작하는 순간, C++로 작성된 Node.js 내부 코드가 운영체제로부터 프로세스 정보를 받아 JavaScript 전역 네임스페이스에 이 객체를 주입합니다. process는 어떤 requireimport 없이도 어디서나 접근할 수 있는 이유가 여기에 있습니다.

process가 담고 있는 정보는 argv만이 아닙니다. 현재 Node.js 버전, 운영체제 정보, 환경 변수 전체까지 확인할 수 있습니다.

node -e "console.log(process.versions.node)"    # Node.js 버전
node -e "console.log(process.platform)"         # 운영체제 (darwin, linux, win32)
node -e "console.log(process.env.PATH)"         # 환경 변수 PATH

개발자가 직접 CLI 도구를 만든다면 process.argv.slice(2)로 인덱스 0, 1을 걷어내고 실제 인자만 파싱해야 합니다. NestJS CLI가 내부적으로 사용하는 Commander.js 같은 라이브러리가 이러한 작업을 처리합니다. process.argv 배열을 파싱해서 어떤 명령(new, generate, build, start 등)이 왔는지, 어떤 옵션 플래그(--skip-git, --strict 등)가 붙었는지를 구조화된 객체로 변환해 줍니다.

Commander.js가 실제로 nest.js 내부에서 어떻게 사용되는지도 직접 열어볼 수 있습니다.

cat $(yarn global dir)/node_modules/@nestjs/cli/bin/nest.js

require('commander') 구문과 함께, 각 명령어를 등록하는 코드를 확인할 수 있습니다.


5. [Bootstrap] 안전한 실행을 위한 사전 검증 ✍️

인자 파싱과 함께, 혹은 그 직전에 NestJS CLI는 자신이 올바른 환경 위에서 실행되고 있는지를 검증합니다. 이 과정은 일종의 부트스트랩 안전장치입니다.

가장 먼저 확인하는 것은 Node.js 버전입니다. NestJS CLI가 요구하는 최소 버전은 패키지의 package.json에 명시되어 있습니다. 직접 열어볼 수 있습니다.

cat $(yarn global dir)/node_modules/@nestjs/cli/package.json | grep -A 3 '"engines"'
"engines": {
  "node": ">= 20.11"
}

런타임에는 앞서 확인한 process.versions.node로 현재 Node.js 버전을 읽어, 요구 사항을 만족하지 못하면 즉시 에러를 출력하고 종료합니다. 이처럼 프로그램이 실제 작업을 시작하기 전에 선행 조건을 명시적으로 검사하는 패턴을 Fail Fast 원칙이라고 합니다. 잘못된 환경에서 절반쯤 진행된 후 실패하는 것보다, 초입에 명확한 이유를 제시하며 빠르게 종료하는 편이 디버깅과 사용자 경험 모두에 유리합니다.

이 검증 단계가 완료되면, CLI는 Commander.js 인스턴스를 초기화하고 지원 가능한 모든 명령어(new, generate, build, start, info 등)를 등록합니다. 어떤 명령어가 등록되어 있는지는 help 플래그로 직접 확인할 수 있습니다.

nest --help
Usage: nest <command> [options]

Options:
  -v, --version                                   Output the current version.
  -h, --help                                       Output usage information.

Commands:
  new|n [options] [name]                          Generate Nest application.
  build [options] [app]                           Build Nest application.
  start [options] [app]                           Run Nest application.
  generate|g [options] <schematic> [name] [path]  Generate a Nest element.
  ...

만약 목록에 없는 명령어를 입력하면, Commander.js가 등록된 명령어 목록에서 특정 명령어를 찾지 못해 즉시 에러를 반환합니다. 이 역시 부트스트랩 단계에서 모든 명령어를 미리 등록해 두었기에 가능한 동작입니다.

nest fly
# error: unknown command 'fly'

6. [Logic] Registry 통신과 스캐폴딩 ✍️

모든 사전 검증이 완료되었습니다. 이제 NestJS CLI는 파싱된 명령어를 기반으로 실제 작업을 수행합니다. nest new my-app을 예로 들어 이 과정을 끝까지 추적해 보겠습니다.

1단계: 패키지 매니저 선택 프롬프트 ✅

CLI는 가장 먼저 어떤 패키지 매니저를 사용할지 묻습니다. --package-manager 플래그를 미리 넘기면 이 프롬프트를 건너뛸 수 있습니다.

nest new my-app --package-manager yarn

2단계: registry 통신 ✅

사용자가 패키지 매니저를 선택하면, CLI는 @nestjs/core, @nestjs/common 등 새 프로젝트에 필요한 기본 패키지 목록을 registry에 요청합니다. Node.js 내장 모듈 https를 사용해 https://registry.npmjs.org/에 HTTP GET 요청을 보냅니다. 브라우저에서 직접 이 URL에 접속해 보면 registry가 어떤 JSON을 응답하는지 확인할 수 있습니다.

https://registry.npmjs.org/@nestjs/core

버전 목록, 각 버전의 의존성 정보, tarball 다운로드 URL 등이 담긴 거대한 JSON 문서가 응답됩니다. CLI가 받아보는 메타데이터가 정확히 이것입니다.

3단계: 의존성 트리 계산 및 다운로드 ✅

Yarn은 응답받은 메타데이터를 바탕으로 전이적 의존성을 포함한 전체 의존성 트리를 계산합니다. 그리고 각 패키지의 tarball(.tgz 압축 파일)을 병렬로 다운로드하여, 로컬 캐시에 저장한 뒤 node_modules/에 압축을 풉니다. 로컬 캐시 경로는 아래 명령어로 확인할 수 있습니다.

yarn cache dir
# 출력 예시
# /Users/wonminkwan/Library/Caches/Yarn/v6

이 경로로 이동해 보면, 이전에 설치했던 패키지들의 캐시가 쌓여 있는 것을 볼 수 있습니다. 이미 캐시에 있는 패키지는 네트워크 요청 없이 즉시 사용되므로, 같은 패키지를 여러 프로젝트에서 설치할 때 두 번째부터는 훨씬 빠릅니다.

4단계: 파일 시스템 작업 ✅

네트워크 작업과 별개로, CLI는 Node.js의 fs 모듈을 사용해 새 프로젝트의 뼈대를 구성합니다. 이 템플릿들이 어디서 오는지 직접 확인할 수 있습니다.

ls $(yarn global dir)/node_modules/@nestjs/cli/lib/schematics/

nest new가 생성해 주는 src/main.ts, src/app.module.ts, tsconfig.json 등의 원본 템플릿 파일들이 여기 들어 있습니다. CLI는 이 파일들을 읽어서 우리가 지정한 프로젝트 폴더에 복사해 줍니다.

5단계: yarn.lock 생성 ✅

모든 설치가 완료되면 Yarn은 실제로 설치된 패키지의 정확한 버전과 의존성 트리를 yarn.lock에 기록합니다. 이 파일은 "지금 이 순간의 의존성 스냅샷"입니다. 프로젝트 생성 후 이 파일을 열어보면, package.json에는 "^10.0.0"처럼 범위로 표기된 버전이 yarn.lock에는 정확한 버전과 다운로드 주소, 체크섬까지 함께 고정되어 있는 것을 확인할 수 있습니다.

# 생성된 프로젝트 폴더로 이동 후
cat yarn.lock | grep -A 2 "@nestjs/core@"

이후 다른 팀원이 같은 프로젝트를 클론하고 yarn install을 실행하면, Yarn은 package.json을 다시 해석하는 대신 yarn.lock을 그대로 재현합니다. 이로써 "내 컴퓨터에서는 되는데"라는 환경 불일치 문제를 근본적으로 방지합니다.

이 모든 과정이 끝나면 터미널에 익숙한 성공 메시지가 출력됩니다. nest new my-app이 한 줄의 명령어 뒤에서 수행한 대장정이 비로소 마무리됩니다.


7. 마치며 ✍️

brew install yarnnest new my-app을 분해하는 일은 단순히 두 개의 명령어를 설명하는 것이 아니었습니다.

패키지 관리와 의존성 해결, PATH와 심볼릭 링크를 통한 실행 파일 탐색, 매직 바이트와 shebang을 통한 런타임 전환, process 전역 객체와 명령줄 인자 파싱, Fail Fast 원칙에 기반한 환경 검증, 그리고 HTTP 통신과 파일 시스템 작업으로 이루어진 실제 비즈니스 로직까지를 순서대로 살펴봤습니다.

코드를 AI가 작성하든, 사람이 작성하든 그것은 본질적인 문제가 아닙니다. AI뿐만 아니라 라이브러리, 프레임워크, 패키지 매니저 등 추상화된 도구를 사용할 때 의도적으로 내가 어떤 복잡성 위에 서 있는지를 알고 사용하려는 노력이 중요합니다. 원래 중요했는데 AI 사용 덕분에 티가 더 잘, 많이 나게 된 것이죠. 반성하고, 깊게 공부해야겠습니다.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글