Skip to content
Go back
✍️ 에세이

패키지 설치가 해킹이 된다 — 제로 디펜던시의 중요성

핵심 요약

left-pad(2016)에서 colors.js(2022)로, 다시 litellm/axios(2026)로 — 오픈소스 의존성 사고는 중단에서 파손으로, 파손에서 침투로 진화했다. Superpowers 프로젝트의 server.cjs 354줄 코드 해부를 통해 제로 디펜던시 설계의 실물을 보여주고, AI 시대에 넘쳐나는 라이브러리가 지켜야 할 원칙을 정리했다.

들어가며

2022년 1월 무렵, colors.jsfaker.js의 메인 maintainer(Marak Squires)는 대기업들이 자신의 오픈소스를 무료로 가져다 쓰면서도 금전적 보상이 없다고 공개적으로 불만을 표출했고, 이에 대한 항의(protest) 차원에서 라이브러리를 스스로 망가뜨리는 사건이 일어났다. 이 두 패키지는 수천 개 프로젝트에서 사용되고 있었고(대기업 포함), colors.js는 주당 2,000만 이상 다운로드되는 수준이라 많은 빌드와 서비스가 깨지는 사태로 이어졌다.

Ruby의 gem으로 시작하여, Python pip, JavaScript npm, Rust의 cargo까지 — 언어별로 대표되는 패키지 매니저들이 있다. 나도 수많은 오픈소스 라이브러리 생태계 위에서 빠르게 서비스를 배포한 경험부터, 의존성 호환성 이슈로 밤을 지새운 날도, 오픈소스를 몽키 패치하여 직접 사용했던 기억들이 떠오른다. 물론 대부분은 이런 오픈소스들 덕분에 쉽게 개발할 수 있었고, Stack Overflow의 고수들이 미리 고생한 흔적들을 쉽게 받아먹으며 개발할 수 있음에 감사하고 있었다. 그리고 오픈소스들을 이것저것 임포트해서 쓰다 보니, 나중에는 코딩이 레고인가 하는 생각이 드는 지경이 되었었다. (AI 시대 이전에도, “오픈소스 가져다가 개발하는데 그게 개발인가요”라는 어그로는 항상 화두였다.)

그런 와중에 위에서 언급한 colors.js 공급망 공격 사건은 나에겐 하나의 충격이었다. “오픈소스 가져다가 개발하는데 그게 개발인가요”라는 말이 단순히 어그로가 아닌, 내 결과물에 대한 일종의 존재론적 위기 — 내가 쌓아서 만들고 있는 건 무엇이며, 나는 코더인가 엔지니어인가 — 같은 질문을 던지는 사건이었다. “don’t reinvent the wheel”(바퀴를 다시 재발명하지 마라)이라는 오래된 소프트웨어 격언에 대해서 “대체로 그렇지만, 정말로 그러한가?”라는 물음을 가지게 된 계기이기도 했다. (내가 흘러흘러 Python/Node.js 대신 Go를, 모바일 클라이언트에서 Flutter 대신 Swift를 선호하게 된 것도 이런 영향이 없지 않다. 패키지 의존성을 아예 안 쓰는 건 힘들지만, 최소화할 수 있는 방향을 지향하게 된 것도.)

공교롭게도 이번에 두 개의 사건이 연달아 터졌다. 하나는 2026년 3월 24일에 일어난 litellm(PyPI) 메인테이너 계정 탈취 사건이고, 다른 하나는 3월 31일에 일어난 axios(npm) OS별 악성 RAT 배포 사건이다.

개인적으로 두 개 다 꽤 피부에 와닿았다. litellm은 최근 많은 오픈소스 에이전트 플러그인(Claude Code나 Codex용)에서 활용되는 패키지이고, axios는 React를 작업하면서 항상 사용했던 라이브러리였기 때문이다.

LLM AI 시대 이후에 정말 많은 한국 개발자들이 오픈소스를 너도나도 공개하기 시작했다. 개중에는 꽤 좋은 라이브러리들이 쏟아지고 있는데, 나는 아직 그럴듯한 오픈소스를 만들고 있지 않지만 litellm과 axios 사태를 통해 어떻게 오픈소스를 설계해야 하는지, 왜 zero dependency가 중요한지를 정리해보았다.

“don’t reinvent the wheel”은 맞는 말인데, 그 바퀴를 나는 어디까지 믿어도 되는 걸까?

2026년 3월, 두 개의 사건 — litellm과 axios

litellm — LLM 통합 라이브러리가 자격증명 탈취 도구가 된 날

litellm은 OpenAI, Anthropic, Bedrock, Vertex AI 등 100개 이상의 LLM 프로바이더를 단일 인터페이스로 통합해주는 Python 라이브러리다. LLM 기반 서비스를 운영하는 조직이라면 한 번쯤은 pip install litellm을 실행해봤을 가능성이 높다. PyPI 기준으로 월간 다운로드가 수백만 건에 달하는, AI 인프라 생태계의 핵심 패키지 중 하나였다.

2026년 3월 24일, litellm의 PyPI 메인테이너 계정이 탈취되었다. 공격자는 정상 배포 프로세스를 통해 악성 코드가 삽입된 버전을 PyPI에 업로드했다. 겉으로 보면 평범한 버전 업데이트였다. pip install --upgrade litellm을 실행한 개발자는 아무런 경고 없이 악성 패키지를 받게 되었다.

공격의 기술적 구조는 교묘했다. 악성 코드는 Python의 .pth 파일 메커니즘을 이용했다. .pth 파일은 Python이 시작될 때 자동으로 실행되는 경로 설정 파일인데, 공격자는 여기에 코드를 삽입하여 Python 프로세스가 시작되는 순간 — import litellm을 호출하지 않아도 — 악성 페이로드가 실행되도록 만들었다. 즉, litellm을 설치만 하고 한 줄도 import하지 않은 환경에서도 Python이 구동되는 순간 감염되는 구조였다.

Sonatype의 분석에 따르면, 악성 페이로드는 다단계로 동작했다. 1단계에서 시스템 환경 정보를 수집하고, 2단계에서 환경 변수에 저장된 API 키, 클라우드 자격증명, 데이터베이스 연결 문자열 등을 외부 서버로 전송했다. LLM 서비스를 운영하는 조직의 특성상 환경 변수에는 OpenAI API 키, AWS 자격증명, 데이터베이스 비밀번호 같은 민감 정보가 가득한 경우가 많다. 공격자는 이걸 정확히 노렸다.

Kaspersky의 보고서는 이 공격이 litellm만을 대상으로 한 것이 아니라 Trivy, Checkmarx 등 다른 보안 도구 패키지까지 함께 노린 조율된 공급망 공격의 일환이었다고 지적했다. 보안 도구가 보안 취약점이 되는 아이러니다.

이 사건에서 특히 주목할 부분은 전이 의존성(transitive dependency) 경로를 통한 유입이다. litellm을 직접 설치한 조직만 영향을 받은 게 아니었다. litellm을 의존성으로 포함하는 다른 패키지, 가령 일부 Cursor MCP 플러그인이나 에이전트 프레임워크를 설치한 경우에도 litellm이 함께 설치되면서 감염 경로가 열렸다. HeroDevs의 분석에 따르면, 직접 litellm을 쓰지 않는데도 취약해진 프로젝트가 상당수 존재했다.

나도 아찔한 순간이 있었다. 최근에 Claude Code 에이전트 스킬 중 하나인 ouroboros를 설치한 적이 있는데, 그 의존성 트리 어딘가에 litellm이 포함되어 있었다. 다행히 해당 스킬을 실제로 사용하지 않아서 Python 환경에 litellm이 활성화되지 않았지만, 만약 한 번이라도 실행했더라면 내 환경 변수에 있는 API 키들이 유출됐을 수 있다. 그 생각을 하니까 등이 서늘해졌다.

axios — HTTP 클라이언트가 RAT 배포 도구가 된 날

axios는 JavaScript/TypeScript 생태계에서 가장 널리 쓰이는 HTTP 클라이언트 라이브러리다. npm 기준 주간 다운로드 1억 건 이상. React, Vue, Node.js 프로젝트에서 API 호출이 필요하면 거의 본능적으로 npm install axios를 치는 수준이다. 나도 React 프로젝트를 할 때마다 빠지지 않고 썼던 라이브러리다.

2026년 3월 31일, axios의 npm 패키지가 타협되었다. Elastic Security Labs의 상세 분석에 따르면, 공격자는 package.jsonpostinstall 스크립트를 이용했다. npm에서 패키지를 설치하면 자동으로 실행되는 이 훅(hook)에 악성 스크립트를 삽입한 것이다. npm install을 실행하는 순간, 코드를 한 줄도 import하기 전에 악성 코드가 돌아간다.

공격의 교묘한 점은 OS별로 다른 페이로드를 배포했다는 것이다. Windows에서는 PowerShell을 통해 실행 파일을 다운로드하고, macOS에서는 curl과 bash를 조합하여 바이너리를 내려받았으며, Linux에서도 별도의 페이로드가 준비되어 있었다. Huntress의 보고서는 이 페이로드가 RAT(Remote Access Trojan) 이라고 확인했다. 원격 접속 트로이 목마. 공격자가 감염된 시스템에 원격으로 접속하여 파일을 읽고, 키 입력을 감시하고, 추가 악성코드를 설치할 수 있는 백도어다.

Sophos의 분석은 RAT의 구체적인 기능을 나열했다. 시스템 정보 수집, 파일 시스템 탐색, 키로깅, 스크린 캡처, 추가 페이로드 다운로드 및 실행. 사실상 감염된 개발 머신에 대한 완전한 원격 제어 권한을 획득하는 수준이었다.

npm의 postinstall 훅이 왜 위험한지를 이 사건이 명확하게 보여줬다. npm은 패키지를 설치할 때 preinstall, install, postinstall 순서로 스크립트를 실행하는데, 대부분의 개발자는 이 과정을 인지하지 못한다. npm install axios를 치면 눈에 보이는 건 설치 진행 바뿐이다. 그 뒤에서 어떤 스크립트가 돌아가는지 확인하는 개발자는 극소수다. npm 공식 문서에서조차 postinstall 훅의 사용을 최소화하라고 권고하고 있지만, 수많은 패키지가 여전히 이 훅에 의존하고 있다.

두 사건을 나란히 놓고 보면 공통점이 보인다. 둘 다 메인테이너 계정 또는 배포 파이프라인을 타협한 공급망 공격이다. 둘 다 패키지를 설치하는 것만으로 — 코드를 한 줄도 실행하지 않아도 — 감염된다. 그리고 둘 다 수백만 개 프로젝트에 의존성으로 깊숙이 박혀 있는 핵심 패키지를 노렸다.

litellm은 AI 인프라의 중추를, axios는 웹 개발의 중추를 공격한 셈이다. 일주일 간격으로.

10년의 패턴 — 중단에서 파손으로, 파손에서 침투로

오픈소스 의존성 사고는 2026년에 갑자기 시작된 게 아니다. 10년의 궤적이 있다. 그리고 그 궤적은 한 방향으로 진화해왔다.

2016: left-pad — 중단의 시대

2016년 3월, Azer Koçulu라는 개발자가 npm에 등록한 left-pad 패키지를 삭제했다. 11줄짜리 함수였다. 문자열 왼쪽에 공백이나 특정 문자를 채워주는, 정말 그게 전부인 코드. 그런데 이 11줄이 사라지자 React, Babel을 포함한 수천 개 프로젝트의 빌드가 깨졌다.

David Haney는 그때 블로그 글에서 이렇게 물었다. “Have we forgotten how to program?” 우리는 프로그래밍하는 법을 잊어버린 건 아닌가? 11줄짜리 문자열 패딩 함수를 직접 쓰는 대신 외부 패키지에 의존한다는 사실이, 그때는 웃기면서도 씁쓸한 현상이었다.

left-pad 사건의 본질은 중단(disruption) 이었다. 의존성이 사라지면 빌드가 깨진다. 그게 전부였다. 악의는 없었다. 의도적인 피해도 없었다. 단지 “내 패키지 내가 삭제하겠다는데”에서 시작된 사건이었고, 피해는 빌드 실패와 배포 지연에 그쳤다. npm은 이후 unpublish 정책을 강화했고, 사람들은 잠시 의존성에 대해 생각하다가 곧 잊어버렸다.

2022: colors.js — 파손의 시대

6년 뒤, Marak Squires는 자신이 만든 colors.jsfaker.js에 무한루프 코드를 삽입했다. 이번에는 실수가 아니라 의도적인 파괴였다. 대기업들이 자신의 오픈소스로 돈을 벌면서 자신에게는 한 푼도 돌아오지 않는다는 분노가 동기였다. colors.js를 import한 모든 애플리케이션이 콘솔에 “LIBERTY LIBERTY LIBERTY”를 무한 출력하며 멈췄다.

중단에서 파손으로의 전환이 일어났다. left-pad는 부재로 인한 고장이었지만, colors.js는 존재하는 코드가 적극적으로 해를 끼치는 사건이었다. 패키지가 없어서가 아니라, 패키지가 있어서 문제가 생긴 것이다. 방향이 뒤집혔다.

그래도 colors.js 사건에는 아직 “사람의 얼굴”이 있었다. 공격자는 익명의 해커가 아니라 프로젝트의 원작자였고, 동기는 (동의하든 말든) 이해할 수 있는 불만이었으며, 피해는 서비스 장애에 한정되었다. 데이터 유출이나 자격증명 탈취로 이어지지는 않았다.

2026: litellm/axios — 침투의 시대

2026년 3월의 두 사건은 완전히 다른 영역으로 넘어갔다. 메인테이너의 개인적 항의가 아니라 조직화된 공격자의 계획적인 침투다. .pth 자동실행, postinstall 훅, OS별 맞춤 RAT — 기술적 정교함의 수준이 다르다. 그리고 목적도 다르다. 서비스를 멈추는 것이 아니라 자격증명을 훔치고, 시스템에 백도어를 심고, 지속적인 접근 권한을 확보하는 것이다.

이 10년의 궤적을 정리하면 이렇다.

연도사건유형동기피해 범위
2016left-pad중단개인 결정빌드 실패
2022colors.js파손항의서비스 장애
2026litellm/axios침투조직적 공격자격증명 탈취, 원격 제어

Russ Cox는 2019년에 쓴 Our Software Dependency Problem에서 이렇게 경고했다. “소프트웨어 의존성은 소프트웨어 공급망의 일부이며, 공급망 보안은 가장 약한 고리에 의해 결정된다.” 그때는 이론적인 경고처럼 들렸다. 7년이 지난 지금, 그 경고는 현실이 되었다.

예전엔 의존성이 깨질까 봐 무서웠다. 이제는 의존성이 나를 깨뜨릴까 봐 무섭다.

내가 쓰는 도구 — Superpowers와 제로 디펜던시

4-1. Superpowers를 선택한 이유

이전에 블로그 글에서 소개한 적 있는데, Superpowers는 Claude Code나 Codex 같은 AI 코딩 에이전트에게 체계적인 개발 워크플로우를 부여하는 스킬 프레임워크다. 질문 → 설계 → 계획 → TDD → 코드 리뷰까지의 흐름을 명령어 없이 자동으로 강제한다. 내가 직접 만들어 쓰던 interview 커맨드, TDD 스킬, 코드 리뷰 스킬을 하나로 통합한 구조라서 도입했다.

솔직히 말하면, 내가 Superpowers를 고른 이유에 “제로 디펜던시”는 없었다. 워크플로우가 내 작업 방식과 맞았고, 스킬의 구조가 직관적이었고, 브레인스토밍 → 플래닝 → 실행의 흐름이 내가 지향하는 개발 프로세스와 일치했기 때문이다. 그런데 이번 litellm/axios 사태를 겪고 나서 Superpowers의 코드를 다시 들여다보니, 거기에 “좋은 오픈소스는 어떻게 설계해야 하는가”의 구체적인 답이 있었다.

4-2. 코드로 증명하기 — server.cjs 354줄

Superpowers에는 브레인스토밍 세션에서 사용하는 로컬 웹 서버가 있다. HTML 화면을 브라우저로 서빙하고, WebSocket으로 실시간 업데이트를 보내고, 파일 변경을 감지하는 서버다. 웹 개발을 해본 사람이라면 이 정도 기능을 만들기 위해 보통 어떤 패키지를 쓰는지 안다. Express(HTTP 서버), ws(WebSocket), chokidar(파일 감시). 그리고 이 세 개를 설치하면 node_modules에 수백 개의 하위 패키지가 따라 들어온다.

실제로 Superpowers의 이 서버도 처음에는 그렇게 만들어져 있었다. v5.0.2 릴리즈 노트에는 이런 기록이 남아 있다.

Zero-Dependency Brainstorm Server Removed all vendored node_modules — server.js is now fully self-contained. Replaced Express/Chokidar/WebSocket dependencies with zero-dependency Node.js server using built-in http, fs, and crypto modules. Removed ~1,200 lines of vendored node_modules/, package.json, and package-lock.json.

Express, chokidar, ws — 이 세 패키지와 그 하위 의존성을 합쳐서 약 1,200줄의 벤더링된 코드를 제거하고, Node.js 내장 모듈만으로 전체 서버를 다시 작성한 것이다. 결과물은 server.cjs라는 단일 파일, 354줄 이다.

Superpowers의 package.json을 보면 이 결정의 결과가 명확하다.

{
  "name": "superpowers",
  "version": "5.0.7",
  "type": "module",
  "main": ".opencode/plugins/superpowers.js"
}

dependencies 필드가 없다. devDependencies도 없다. npm 패키지로서 외부 의존성이 문자 그대로 제로다. 이 프로젝트를 npm install해도 node_modules 폴더에 추가되는 외부 패키지가 하나도 없다.

이제 354줄이 실제로 어떻게 구성되어 있는지 코드를 살펴보자.

1) WebSocket 프로토콜 — RFC 6455 직접 구현

ws 라이브러리를 쓰는 대신, WebSocket 프로토콜(RFC 6455)을 직접 구현했다. WebSocket의 핵심은 프레임 인코딩/디코딩이다. 서버가 보내는 데이터를 바이너리 프레임으로 감싸고, 클라이언트가 보내는 마스킹된 프레임을 풀어내는 작업이다.

const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

function computeAcceptKey(clientKey) {
  return crypto.createHash('sha1')
    .update(clientKey + WS_MAGIC)
    .digest('base64');
}

WebSocket 핸드셰이크에서 클라이언트가 보내는 Sec-WebSocket-Key에 매직 문자열을 붙이고 SHA-1 해시를 돌려 Sec-WebSocket-Accept를 만드는 코드다. RFC 6455 Section 4.2.2에 정의된 그대로다. ws 라이브러리가 내부에서 하는 일이 정확히 이것인데, 직접 쓰면 5줄이다.

프레임 인코딩도 마찬가지다.

function encodeFrame(opcode, payload) {
  const fin = 0x80;
  const len = payload.length;
  let header;

  if (len < 126) {
    header = Buffer.alloc(2);
    header[0] = fin | opcode;
    header[1] = len;
  } else if (len < 65536) {
    header = Buffer.alloc(4);
    header[0] = fin | opcode;
    header[1] = 126;
    header.writeUInt16BE(len, 2);
  } else {
    header = Buffer.alloc(10);
    header[0] = fin | opcode;
    header[1] = 127;
    header.writeBigUInt64BE(BigInt(len), 2);
  }

  return Buffer.concat([header, payload]);
}

페이로드 길이에 따라 2바이트, 4바이트, 10바이트 헤더를 선택하는 로직이다. WebSocket 프레임 포맷의 핵심이 이 분기 하나에 다 들어 있다. 126 미만이면 7비트로 직접 표현하고, 126 이상 65536 미만이면 16비트 확장을, 그 이상이면 64비트 확장을 쓴다.

디코딩은 역방향이다. 클라이언트 프레임은 반드시 마스킹되어야 하므로(RFC 6455 요구사항), 4바이트 마스크 키로 XOR 디마스킹을 수행한다.

function decodeFrame(buffer) {
  if (buffer.length < 2) return null;
  const secondByte = buffer[1];
  const opcode = buffer[0] & 0x0F;
  const masked = (secondByte & 0x80) !== 0;
  let payloadLen = secondByte & 0x7F;
  let offset = 2;

  if (!masked) throw new Error('Client frames must be masked');
  // ... 길이 파싱, 마스크 키 추출 ...

  const mask = buffer.slice(maskOffset, dataOffset);
  const data = Buffer.alloc(payloadLen);
  for (let i = 0; i < payloadLen; i++) {
    data[i] = buffer[dataOffset + i] ^ mask[i % 4];
  }

  return { opcode, payload: data, bytesConsumed: totalLen };
}

ws 라이브러리는 이 외에도 permessage-deflate 압축, 프래그먼테이션, 서브프로토콜 협상 같은 기능을 제공한다. 하지만 Superpowers의 브레인스토밍 서버에 그런 기능이 필요한가? 로컬호스트에서 HTML 리로드 메시지를 보내는 데 압축이 필요한가? 필요 없다. 그래서 필요한 것만 구현했다. TEXT, CLOSE, PING, PONG — 이 네 가지 opcode만 처리하면 충분하다.

2) HTTP 서버 — Express 없이

Express를 제거하고 Node.js 내장 http 모듈로 서버를 구성했다. 라우팅이 필요한 경로가 세 개밖에 없으니, 미들웨어 스택이 필요 없다.

function handleRequest(req, res) {
  touchActivity();
  if (req.method === 'GET' && req.url === '/') {
    const screenFile = getNewestScreen();
    let html = screenFile
      ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))
          (fs.readFileSync(screenFile, 'utf-8'))
      : WAITING_PAGE;

    if (html.includes('</body>')) {
      html = html.replace('</body>', helperInjection + '\n</body>');
    } else {
      html += helperInjection;
    }

    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(html);
  } else if (req.method === 'GET' && req.url.startsWith('/files/')) {
    // 정적 파일 서빙
    const fileName = req.url.slice(7);
    const filePath = path.join(CONTENT_DIR, path.basename(fileName));
    if (!fs.existsSync(filePath)) {
      res.writeHead(404); res.end('Not found'); return;
    }
    const ext = path.extname(filePath).toLowerCase();
    const contentType = MIME_TYPES[ext] || 'application/octet-stream';
    res.writeHead(200, { 'Content-Type': contentType });
    res.end(fs.readFileSync(filePath));
  } else {
    res.writeHead(404); res.end('Not found');
  }
}

/ 경로에서 가장 최근 HTML 화면을 서빙하고, /files/* 경로에서 정적 리소스를 내려주고, 나머지는 404. 이게 전부다. Express의 app.get(), app.use(), app.static() 같은 편의 기능이 안에서 하는 일이 결국 이것이다. 라우팅 경로가 세 개인 서버에 미들웨어 체인을 위한 프레임워크가 필요할까?

3) 파일 감시 — chokidar 없이

Node.js의 fs.watch()는 플랫폼마다 동작이 다르기로 유명하다. macOS에서는 rename 이벤트가 새 파일 생성에도, 기존 파일 덮어쓰기에도 발생한다. 그래서 대부분의 프로젝트가 chokidar를 쓴다. 크로스 플랫폼 호환성, 재귀 감시, 안정적인 이벤트 바운싱 등을 제공하니까.

하지만 Superpowers의 서버는 감시 대상이 딱 하나다. CONTENT_DIR 폴더의 .html 파일 변경. 단일 디렉토리, 단일 확장자. 이 경우 fs.watch()에 디바운싱 타이머를 직접 붙이면 충분하다.

const debounceTimers = new Map();

const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {
  if (!filename || !filename.endsWith('.html')) return;

  if (debounceTimers.has(filename))
    clearTimeout(debounceTimers.get(filename));

  debounceTimers.set(filename, setTimeout(() => {
    debounceTimers.delete(filename);
    const filePath = path.join(CONTENT_DIR, filename);
    if (!fs.existsSync(filePath)) return;
    touchActivity();

    if (!knownFiles.has(filename)) {
      knownFiles.add(filename);
      // 새 화면 추가 시 이벤트 파일 초기화
      const eventsFile = path.join(STATE_DIR, 'events');
      if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
    }

    broadcast({ type: 'reload' });
  }, 100));
});

Map으로 파일별 디바운스 타이머를 관리하고, Set으로 이미 알려진 파일을 추적한다. macOS의 rename 이중 호출 문제도 100ms 디바운스로 자연스럽게 해결된다. chokidar가 제공하는 재귀 감시, 글로브 패턴 매칭, symlink 추적 같은 기능은 여기서 필요 없다.

4) WebSocket 업그레이드 핸드셰이크

HTTP 서버에서 WebSocket으로의 프로토콜 업그레이드도 직접 처리한다.

function handleUpgrade(req, socket) {
  const key = req.headers['sec-websocket-key'];
  if (!key) { socket.destroy(); return; }

  const accept = computeAcceptKey(key);
  socket.write(
    'HTTP/1.1 101 Switching Protocols\r\n' +
    'Upgrade: websocket\r\n' +
    'Connection: Upgrade\r\n' +
    'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
  );
  // ... 이후 프레임 기반 통신
}

HTTP 101 응답을 직접 소켓에 쓰는 4줄짜리 핸드셰이크다. ws 라이브러리의 WebSocket.Server가 내부에서 하는 일이 이것이다.

4-3. 비용과 가치 — 1,200줄을 354줄로

이전 버전의 벤더링된 node_modules는 약 1,200줄이었다. Express의 미들웨어 체인, chokidar의 크로스 플랫폼 추상화, ws의 전체 WebSocket 스펙 구현이 모두 포함된 양이다. 리팩토링 후에는 354줄이 되었다. 줄어든 846줄이 불필요한 코드였다는 뜻이 아니다. 그 846줄은 “이 프로젝트에서 필요하지 않은 기능을 위한 코드”였다.

Rob Pike는 Go Proverbs에서 이렇게 말했다.

“A little copying is better than a little dependency.” 약간의 복사가 약간의 의존성보다 낫다.

이 문장을 처음 봤을 때는 “그래, 맞는 말이긴 한데 현실적으로…”라는 생각이 들었다. 하지만 server.cjs를 보면 그 “약간의 복사”가 구체적으로 얼마나인지 알 수 있다. WebSocket 핸드셰이크 5줄, 프레임 인코딩 20줄, 프레임 디코딩 30줄, HTTP 라우팅 25줄, 파일 감시 15줄. 합쳐서 100줄 정도가 “라이브러리가 해주던 일”이다. 나머지 254줄은 비즈니스 로직 — 화면 관리, 이벤트 처리, 생명주기 관리 — 으로, 어차피 어떤 라이브러리를 쓰든 직접 작성해야 하는 코드다.

100줄의 “복사”로 1,200줄의 의존성을 제거한 셈이다. 그리고 그 100줄은 읽을 수 있다. RFC 6455 스펙을 읽어본 사람이라면, 이 WebSocket 구현이 올바른지 직접 검증할 수 있다. Express 내부 코드를 따라가며 미들웨어 체인이 올바른지 검증하는 것과는 다른 수준의 투명성이다.

제로 디펜던시의 가치는 보안만이 아니다. 가독성 이다. 354줄은 한 사람이 앉아서 전체를 읽을 수 있는 분량이다. 코드의 모든 동작을 이해할 수 있다. 내 프로젝트에서 어떤 코드가 실행되는지 빠짐없이 알 수 있다. 1,200줄의 벤더링된 의존성 코드까지 포함하면, “대부분은 안 봐도 되겠지”라는 신뢰에 기대게 된다. 그 신뢰가 깨지는 순간이 litellm이고 axios다.

좋은 오픈소스 설계란 무엇인가 — 코드 레벨의 패턴

Superpowers의 server.cjs는 하나의 사례에 불과하다. 제로 디펜던시를 지향하는 프로젝트들은 생각보다 많고, 그 설계에는 공통된 코드 레벨 패턴이 있다. 이 패턴을 세 가지 레이어로 정리할 수 있다.

레이어 1: 핵심 가치는 코드 밖에 둔다

좋은 오픈소스의 핵심 가치는 코드 자체가 아니라, 코드가 구현하는 사고 모델 에 있다. Superpowers의 핵심 가치는 server.cjs의 354줄이 아니라, “질문 → 설계 → 계획 → TDD → 리뷰”라는 워크플로우 사고 모델이다. 이 모델은 마크다운 스킬 파일에 담겨 있고, 코드는 그 모델을 보조하는 도구일 뿐이다.

이런 구조에서는 코드의 의존성이 줄어들 수밖에 없다. 핵심 가치가 코드 실행에 있지 않으므로, 실행 코드는 최소한의 보조 역할만 하면 된다. 의존성이 필요한 복잡한 기능을 “핵심 가치”에 포함시키지 않는 것이 설계의 첫 번째 원칙이다.

레이어 2: 실행 코드는 작게 유지한다

실행이 필요한 코드가 있다면, 그 코드가 하는 일의 범위를 최소화한다. server.cjs는 “로컬 브레인스토밍 서버”라는 좁은 범위만 담당한다. 범용 웹 프레임워크를 만드는 것이 아니라, 딱 이 하나의 유스케이스에 필요한 기능만 구현한다. 범위가 좁으면 필요한 기능이 적고, 필요한 기능이 적으면 외부 라이브러리 없이도 구현할 수 있는 확률이 높아진다.

레이어 3: 설치와 실행을 분리한다

litellm 사건에서 가장 무서웠던 것은 .pth 파일을 통한 자동 실행이었다. 설치하는 순간 코드가 실행되는 구조. axios의 postinstall 훅도 마찬가지다. 좋은 설계는 “설치”와 “실행”을 명확히 분리한다. 패키지를 설치하는 것은 파일을 디스크에 복사하는 것일 뿐, 어떤 코드도 자동으로 실행되어서는 안 된다. 사용자가 명시적으로 import하거나 실행 명령을 내릴 때 비로소 코드가 동작해야 한다.

제로 디펜던시 프로젝트들 — 왜 가능한가

이 세 가지 레이어가 실제 프로젝트에서 어떻게 구현되는지, 제로 디펜던시를 달성한 프로젝트들의 기술적 전략을 살펴보자.

zod — TypeScript 스키마 검증 라이브러리. npm 주간 다운로드 3,000만 이상. 의존성 제로. zod가 0-dep으로 가능한 이유는 TypeScript 타입 시스템 자체가 검증 로직을 표현하기에 충분하기 때문이다. 런타임 검증 로직은 순수 JavaScript 조건문과 타입 가드의 조합으로 구현된다. 외부 파서 엔진이나 코드 생성기가 필요 없다. 스키마 정의와 검증 실행이 모두 TypeScript의 타입 추론 시스템 위에서 이루어진다. 라이브러리가 하는 일이 “JavaScript 값을 받아서 조건문으로 검사하고, 맞으면 타입을 좁혀주는 것”이므로, 외부 도움이 필요한 영역이 없다.

nanoid — 고유 ID 생성기. 의존성 제로. 파일 하나, 130바이트(gzip 기준). nanoid의 핵심은 crypto.getRandomValues()라는 웹 표준 API 하나다. 브라우저와 Node.js 모두에 내장된 이 API가 암호학적으로 안전한 난수를 제공하고, nanoid는 그 난수를 URL-safe 문자열로 인코딩하기만 하면 된다. UUID 라이브러리들이 의존성을 가지는 이유 중 하나가 구버전 환경에서의 폴리필인데, nanoid는 최신 런타임만 지원함으로써 이 문제를 회피했다.

picocolors — 터미널 색상 라이브러리. chalk의 제로 디펜던시 대안. 파일 하나, 번들 사이즈 0.1KB. 터미널 색상의 본질은 ANSI 이스케이프 시퀀스 — \x1b[31m(빨강), \x1b[0m(리셋) — 를 문자열 앞뒤에 붙이는 것이다. chalk이 10개 이상의 의존성을 가졌던 이유는 색상 공간 변환, 256색 지원, Windows 콘솔 호환성 등 부가 기능 때문이었는데, 대부분의 CLI 도구에서 실제로 쓰는 건 빨강/초록/노랑/볼드 정도다. picocolors는 그 “대부분”만 지원하기로 결정했고, 결과적으로 의존성이 제로가 되었다.

Hono — 웹 프레임워크. Node.js, Deno, Bun, Cloudflare Workers 등 멀티 런타임 지원. 의존성 제로. Hono가 0-dep으로 가능한 이유는 Web Standard API(Request, Response, URL, Headers)만을 사용하기 때문이다. Express가 Node.js의 http 모듈 위에 자체 추상화 레이어를 구축한 반면, Hono는 모든 런타임이 공통으로 지원하는 웹 표준 인터페이스 위에서 동작한다. 런타임별 차이를 라이브러리가 아니라 표준이 해결해주는 구조다.

chi — Go 언어 HTTP 라우터. 의존성 제로. Go 표준 라이브러리의 net/http가 이미 충분히 강력한 HTTP 서버를 제공하고, chi는 그 위에 라우팅 트리(기수 트리, radix tree) 하나만 얹는다. Go 생태계에서 제로 디펜던시가 유난히 많은 이유는 표준 라이브러리가 워낙 포괄적이기 때문이기도 하지만, Rob Pike의 “약간의 복사가 약간의 의존성보다 낫다”는 철학이 커뮤니티 전반에 스며들어 있기 때문이기도 하다.

sqlc — SQL 쿼리를 Go 코드로 컴파일하는 도구. 런타임 의존성 제로. ORM이 런타임에 쿼리를 생성하고 결과를 매핑하려면 리플렉션, 코드 생성, 연결 풀링 같은 복잡한 런타임 로직이 필요하다. sqlc는 이 접근을 뒤집었다. 빌드 타임에 SQL을 파싱하여 타입 안전한 Go 코드를 생성하므로, 런타임에는 표준 database/sql 패키지만으로 충분하다. 복잡성을 런타임에서 빌드 타임으로 옮긴 것이다.

이 프로젝트들의 공통점을 정리하면:

  1. 범위를 좁게 잡는다. 한 가지 일을 잘 한다. 모든 경우를 커버하려 하지 않는다.
  2. 플랫폼 내장 기능을 활용한다. 런타임이 이미 제공하는 것을 다시 구현하지 않는다.
  3. 부가 기능을 핵심으로 혼동하지 않는다. “있으면 좋은 기능”을 빼는 용기가 있다.

벤더링과 런타임 진화 — 기술이 만드는 구조적 변화

Google은 대규모 모노레포에서 벤더링(vendoring) 전략을 사용한다. 외부 의존성의 소스 코드를 자사 저장소에 직접 복사하고, 이후 독립적으로 패치하는 방식이다. 의존성의 “제어권”을 확보하는 것이다. npm이나 PyPI에서 패키지가 변조되어도, 벤더링된 코드는 영향을 받지 않는다.

Bun과 Deno의 등장도 주목할 만하다. Bun은 번들러, 트랜스파일러, 패키지 매니저를 런타임에 내장했다. 예전에는 webpack, babel, npm이라는 세 개의 외부 도구(그리고 각각의 의존성 트리)가 필요했던 작업이 런타임 하나로 해결된다. Deno는 처음부터 npm 없이 URL 기반 모듈 시스템을 도입했고, TypeScript를 네이티브로 지원하면서 ts-node 같은 도구의 필요성을 없앴다.

이 흐름은 Node.js 자체에서도 보인다. Node.js 18에서 내장 fetch()가 추가되면서 HTTP 클라이언트용 외부 패키지의 필요성이 줄었고, Node.js 20에서 내장 테스트 러너가 안정화되면서 단순한 테스트를 위해 jestmocha를 설치할 이유도 줄었다. 런타임이 커질수록 외부 의존성의 필요성은 줄어든다. 그리고 외부 의존성이 줄어들수록 공격 표면도 줄어든다. 이것은 개발자의 의지와 무관하게, 기술 인프라 자체가 “의존성을 줄이는 방향”으로 진화하고 있다는 뜻이다.

개발자의 행동이 바뀌어야 한다

여기까지는 설계와 기술의 이야기였다. 좋은 오픈소스가 어떤 구조를 가지는지, 런타임이 어떻게 진화하는지. 하지만 아무리 런타임이 fetch()를 내장하고, 프로젝트가 제로 디펜던시를 지향해도, 결국 npm install을 치는 건 개발자의 손이다. 기술이 가능성을 열어줄 뿐, 실제로 행동을 바꾸는 것은 사람의 몫이다.

의존성 비용의 3층 모델

의존성을 추가하는 비용은 세 층으로 나눌 수 있다.

1층: 표면 비용 — 눈에 보이는 것들. 번들 사이즈 증가, 설치 시간 증가, node_modules 용량 증가. 대부분의 개발자가 인식하는 비용이고, 가장 자주 논의되는 비용이다. 하지만 실제로는 가장 덜 중요한 비용이기도 하다.

2층: 유지보수 비용 — 시간이 지나면서 드러나는 것들. 의존성 버전 충돌, breaking change 대응, deprecated API 마이그레이션, 보안 패치 적용. 프로젝트를 1년, 2년, 5년 운영하면서 누적되는 비용이다. 의존성이 10개인 프로젝트와 100개인 프로젝트의 유지보수 부담은 선형이 아니라 지수적으로 차이난다.

3층: 신뢰 비용 — 사고가 나기 전까지 보이지 않는 것들. 공급망 공격 노출, 메인테이너 이탈, 라이선스 변경, 악성 코드 삽입. litellm과 axios가 보여준 것이 이 층의 비용이다. 발생 확률은 낮지만, 발생했을 때의 피해는 1층과 2층의 합보다 크다. 그리고 의존성이 많을수록 이 층의 확률은 올라간다. 의존성 하나하나가 공격 표면을 넓히기 때문이다.

AI가 의존성을 추천하는 시대

재미있는 — 그리고 약간 무서운 — 현상이 하나 있다. AI 코딩 어시스턴트에게 HTTP 요청 코드를 작성해달라고 하면, 상당수가 axios를 import하는 코드를 생성한다. Node.js 18 이후로 내장 fetch()가 있는데도 말이다. AI 모델의 훈련 데이터에 axios를 사용하는 코드가 압도적으로 많으니, 가장 “확률적으로 그럴듯한” 코드로 axios를 추천하는 것이다.

실제로 비교하면 이렇다.

// Before: axios 의존성
const { data } = await axios.get('https://api.example.com/data');

// After: 내장 fetch (Node.js 18+)
const data = await fetch('https://api.example.com/data').then(r => r.json());

이 2줄을 위해 외부 패키지를 설치할 이유가 있는가? axios가 제공하는 인터셉터, 요청 취소, 자동 JSON 변환 같은 고급 기능이 필요한 프로젝트라면 모를까, 단순 GET/POST 요청만 하는 대다수의 경우에는 내장 fetch()로 충분하다. 그런데 AI는 이런 맥락 판단 없이, 훈련 데이터에서 가장 빈번하게 등장하는 패턴을 그대로 출력한다.

이건 기존의 “인기가 많으니까 좋은 패키지”라는 논리가 AI 시대에 더 강화되는 피드백 루프를 만든다. 많이 쓰이니까 AI가 추천하고, AI가 추천하니까 더 많이 쓰이고, 더 많이 쓰이니까 공격 가치가 올라간다. axios의 주간 1억 다운로드는 공격자에게 “여기를 노리면 1억 개 프로젝트에 접근할 수 있다”는 신호이기도 하다.

AI가 생성해주는 코드를 무비판적으로 수용하는 습관이 위험한 이유가 여기 있다. AI는 “이 패키지의 메인테이너 계정 보안이 2FA를 사용하는가”를 확인하지 않는다. “이 패키지가 postinstall 훅에서 무슨 일을 하는가”를 검사하지 않는다. AI는 패턴 매칭으로 가장 흔한 코드를 생성할 뿐이고, 가장 흔한 코드가 가장 안전한 코드라는 보장은 어디에도 없다.

체크리스트 — 의존성을 추가하기 전에 확인할 6가지

실천 가능한 체크리스트를 만들어보았다. 새로운 패키지를 프로젝트에 추가하기 전에, 최소한 이 여섯 가지는 확인하자.

① 내장 대안이 있는가? Node.js의 fetch(), crypto, test runner, path, url — 런타임 내장 모듈로 할 수 있는 일에 외부 패키지를 쓰고 있지 않은지 확인한다.

# Node.js 내장 모듈 목록 확인
node -e "console.log(require('module').builtinModules.join('\n'))"

② 의존성 트리의 깊이는? 직접 의존성 하나가 100개의 전이 의존성을 끌고 올 수 있다. 설치 전에 트리를 확인한다.

# npm 패키지의 의존성 트리 확인
npm view <package> dependencies
# 전체 트리 시각화
npm ls --all
# Python의 경우
pip show <package> | grep Requires

③ 메인테이너 정보와 활동 이력은? OpenSSF Scorecard는 오픈소스 프로젝트의 보안 관행을 자동으로 평가해준다. 메인테이너의 2FA 사용 여부, 코드 리뷰 관행, CI/CD 보안, 브랜치 보호 규칙 등을 점수화한다.

# OpenSSF Scorecard로 프로젝트 보안 점수 확인
# https://scorecard.dev/ 에서 GitHub 레포 URL 입력
# 또는 CLI 설치 후:
scorecard --repo=github.com/<owner>/<repo>

④ postinstall 훅이나 자동 실행 코드가 있는가? axios 사건의 핵심이 postinstall 훅이었다. 설치 전에 패키지의 scripts 필드를 확인한다.

# 패키지의 install 스크립트 확인
npm view <package> scripts
# 이미 설치된 패키지 중 postinstall 훅이 있는 것 찾기
find node_modules -name "package.json" -exec grep -l "postinstall" {} \;

SLSA 레벨은? Supply-chain Levels for Software Artifacts. 패키지의 빌드 프로세스가 얼마나 검증 가능한지를 나타내는 프레임워크다. SLSA Level 3 이상이면 빌드 과정이 변조 불가능한 방식으로 기록되어, 공격자가 빌드 파이프라인을 타협해도 추적할 수 있다.

⑥ 직접 구현하면 몇 줄인가? left-pad는 11줄이었다. Superpowers의 WebSocket 핸드셰이크는 5줄이었다. 직접 구현하는 비용이 낮다면, 의존성을 추가하는 것보다 직접 쓰는 게 나을 수 있다. Rob Pike의 “약간의 복사가 약간의 의존성보다 낫다”를 떠올리자.

이 체크리스트가 모든 의존성에 대해 “쓰지 마라”고 말하는 건 아니다. 암호화 라이브러리를 직접 구현하는 건 보안 재앙의 지름길이다. 데이터베이스 드라이버를 직접 쓰는 건 비현실적이다. 하지만 “이 패키지가 정말 필요한가, 아니면 습관적으로 추가하는 건가”라는 질문을 던지는 것만으로도 공격 표면을 줄일 수 있다.

나가며

npm install은 편의 명령이 아니었다. 신뢰 명령이었다.

npm install axios를 치는 순간, 나는 axios의 메인테이너를, axios가 의존하는 패키지의 메인테이너들을, 그 메인테이너들의 계정 보안 수준을, npm 레지스트리의 무결성을, 그리고 postinstall 훅이 내 시스템에서 실행하는 모든 코드를 신뢰한다고 선언한 셈이다. pip install litellm도 마찬가지다. 그리고 우리 대부분은 이 신뢰의 무게를 인식하지 못한 채 매일 수십 번씩 이 명령을 실행해왔다.

354줄은 읽을 수 있다. 1,200줄은 신뢰할 수밖에 없다.

Superpowers의 server.cjs가 보여주는 것은 미니멀리즘이나 과시가 아니다. “내가 이해할 수 있는 코드만 내 프로젝트에 넣겠다”는 결정이다. 물론 모든 프로젝트가 354줄짜리 자체 구현을 해야 한다는 뜻은 아니다. 그건 비현실적이다. 하지만 “이 의존성이 정말 필요한가, 직접 구현하면 어려운가, 이 패키지를 신뢰할 근거가 있는가”라는 질문을 매번 던지는 것은 현실적이다.

의존성이 적다는 건 미니멀리즘이 아니라, 내가 믿어야 할 것을 줄이는 일이다.

결국 don’t reinvent the wheel이라는 격언은 여전히 맞다. 바퀴를 직접 만드는 건 대부분의 경우 시간 낭비다. 하지만 그 격언에 한 줄을 덧붙여야 할 때가 된 것 같다.

바퀴를 다시 발명하지 마라. 단, 그 바퀴가 내 차에서 무슨 일을 하는지는 알고 있어라.


References

  1. litellm, Security Update — March 2026
  2. Sonatype, Compromised litellm PyPI Package
  3. Kaspersky, Critical Supply Chain Attack
  4. HeroDevs, The LiteLLM Supply Chain Attack
  5. Elastic Security Labs, Axios: One RAT to Rule Them All
  6. Huntress, Supply Chain Compromise: Axios
  7. Sophos, Axios npm Package Compromised
  8. Russ Cox, Our Software Dependency Problem (2019)
  9. Rob Pike, Go Proverbs (2015)
  10. David Haney, Have We Forgotten How to Program? (2016)
  11. Suckless, Philosophy
  12. Wikipedia, npm left-pad incident
  13. SLSA, Supply-chain Levels for Software Artifacts
  14. OpenSSF, Scorecard
  15. npm, Scripts Best Practices
  16. GitHub, obra/superpowers
  17. Flowkater.io, Superpowers 소개
Tony Cho profile image

About the author

Tony Cho

Indie Hacker, Product Engineer, and Writer

제품을 만들고 회고를 남기는 개발자. AI 코딩, 에이전트 워크플로우, 스타트업 제품 개발, 팀 빌딩과 리더십에 대해 쓴다.


Share this post on:

댓글


Next Post
AI Native Engineer — 원리 위의 감각