Node.js 진영에는 많고 많은 패키지 매니저들이 있습니다. 그 기원에는 Node.js를 설치할 때 같이 생성되는 npm 명령줄 인터페이스(이하 npm-cli)가 있습니다. 하지만 yarn, pnpm, bun 등 숱한 대체재가 나왔고 npm-cli는 현재 잘 사용하지 않게 되고 있는 것 같습니다. 하지만 npm-cli는 계속해서 발전을 이어나갔고 성능과 기능이 많이 개선된 상황입니다. 이 시점에 우리는 npm을 다시 바라볼 필요가 있습니다.
먼저 npm의 기원에 대해 간략하게 짚고 넘어가겠습니다. 여러분도 알고 계시듯, npm은 전 세계에서 가장 큰 소프트웨어 레지스트리를 자랑합니다. 오픈 소스 개발자들은 npm을 통해 자신의 패키지를 공유하며, 많은 조직들이 이를 활용해 비공개적으로 개발 프로젝트를 관리하기도 합니다. npm 레지스트리에는 수많은 패키지가 포함되어 있으며, 이 중 상당수는 Node 모듈이거나 Node 모듈을 포함하고 있습니다.
패키지란 package.json 파일에 정의된 파일이나 디렉터리를 의미하며, 모듈은 import 또는 require를 통해 로드할 수 있는 node_modules 디렉터리 안의 파일이나 디렉터리를 가리킵니다.
npm은 세 가지 개별 구성 요소로 구성됩니다.
이 중 npm-cli가 우리가 흔히 말하는 패키지 매니저의 역할을 합니다.
npm-cli를 사용하면 독립적으로 실행할 수 있는 도구를 다운받을 수도 있고, 다운 받지 않고 npx를 사용하여 패키지를 실행할 수도 있습니다.
npm-cli에는 66개의 명령어가 있는데요, 생각보다 많아서 놀랐습니다. 하지만 모든 명령어를 사용하지는 않으니 패키지 게시와 관련된 명령은 제외하고 실제로 사용하는 것 몇 가지만 살펴보겠습니다.
package.json 파일을 만들어주는 명령어입니다.
사실상 다른 프레임워크를 사용한다면 보일러플레이트 형식으로 항상 제공해주기 때문에 직접 사용할 일은 거의 없습니다.
패키지와 해당 패키지가 종속된 모든 패키지를 설치하는 명령어입니다. 패키지에 npm-shrinkwrap.json, package-lock.json, yarn.lock의 우선순위로 종속성을 설치합니다.
흥미롭게도 yarn.lock 파일을 인식합니다.
별칭으로 i로 줄여서 사용할 수 있습니다.
테스트 플랫폼, 지속적인 통합, 배포 또는 새 종속성을 설치하려는 모든 상황과 같은 자동화된 환경에서 사용된다는 점을 제외하면 npm install과 유사합니다.
npm install
과 npm ci
사용 시의 주요 차이점을 정리하자면:
필수 파일: npm ci
를 사용하기 위해서는 프로젝트에 package-lock.json
또는 npm-shrinkwrap.json
파일이 반드시 존재해야 합니다. 이는 npm ci
가 해당 파일들을 기반으로 정확한 의존성을 설치하기 때문입니다.
의존성 불일치 처리: package.json
과 패키지 잠금 파일 사이에 의존성 불일치가 있을 경우, npm ci
는 오류를 발생시키며 실행을 중단합니다. 반면, npm install
은 패키지 잠금 파일을 업데이트하여 불일치를 해결합니다.
설치 범위: npm ci
는 오직 전체 프로젝트 설치만을 지원합니다. 개별 의존성을 추가할 때는 사용할 수 없습니다. 이는 프로젝트의 의존성을 일관되게 유지하는 데 도움이 됩니다.
node_modules 디렉터리 처리: 이미 node_modules
디렉터리가 존재한다면, npm ci
실행 전에 이를 자동으로 제거합니다. 이는 깨끗한 설치 환경을 보장합니다.
파일 변경: npm ci
는 package.json
이나 패키지 잠금 파일에 어떠한 변경도 하지 않습니다. 설치 과정에서 생성되는 모든 변경 사항은 반영되지 않으며, 이로 인해 설치된 의존성은 변경되지 않고 고정됩니다.
이러한 차이점들을 때문에 지속적 통합(CI) 또는 지속적 배포(CD)와 같은 자동화된 환경에서 사용할 때 예측 가능하고 일관된 의존성 설치를 제공하여 프로젝트의 안정성을 향상시킵니다.
참고: yarn에서는
yarn install --frozen-lockfile
,yarn install --immutable --immutable-cache --check-cache
이렇게 사용하면 동일한 동작입니다.(npm ci가 훨씬 간결한데?)
package.json scripts의 start에 미리 정해둔 명령어를 실행하는 명령어입니다. 만일 정의되지 않았다면, 자동으로 node server.js를 실행시킵니다.
scripts의 test 속성에 지정된 미리 정의된 명령을 실행합니다.
scripts에 정의된 임의의 명령을 실행합니다. 명령이 정의되지 않으면, 사용 가능한 스크립트를 나열합니다. 이 명령어의 재밌는 것점은 오타(rum, urn)들을 별칭으로 지정해 놓았다는 것입니다.
패키지에서 특정 도구나 라이브러리를 사용에 관한 호환성을 표현하고 싶고 이것이 필수가 아닐 때 사용합니다. 이렇게 표현된 패키지를 "플러그인"이라 부를 수 있습니다.
npm 버전 3-6에서는 PeerDependency가 자동으로 설치되지 않고 트리에서 잘못된 버전의 피어 종속성이 발견되면 경고가 표시되었습니다.
npm v7부터 PeerDependency가 기본적으로 설치됩니다.
요구 사항이 충돌하는 다른 플러그인을 설치하려고 하면 트리가 올바르게 해결되지 않으면 오류가 발생할 수 있습니다. 이러한 이유로 플러그인 요구 사항을 가능한 한 광범위하게 만들고 특정 패치 버전으로 제한하지 않도록 해야합니다.
이름에서 볼 수 있다 싶이 legacy인 peerDeps즉 예전 peerdependencies 동작을 의미합니다.
위에서 보다 싶이 npm 버전 3-6에서는 peerdependencies를 완전히 무시하였었습니다.
현재 동작은 트리가 올바르게 해결되지 않으면 에러가 나므로 지나치게 엄격한 peerDependency 충돌로 인해 패키지를 설치할 수 없는 상황을 해결하는 방법을 제공합니다. 하지만 이는 곧 메타 종속성이 있을 수 있는 트리 구조를 그대로 가져가지 않는다는 의미로 사용하는 것을 권장하지는 않습니다.
사실 Workspace 기능을 사용하고 싶어서 이 글을 쓴 것도 있습니다
npm 버전 7이상 부터 사용할 수 있는 기능으로 npm에서 workspace를 구성할 수 있는데요, 생각보다 부가적인 도구들이 필요 없을 정도로 기능이 잘 동작하는 것으로 보입니다.
npm 설치를 하며 동시에 심볼릭 링크를 생성해주기에 부가적으로 설정해줘야하는 것이 없습니다.
workspaces에 포함된 패키지들을 비교하여 같은 패키지를 다른 버전으로 사용하고 있다면 최신 버전은 루트 디렉토리에 이전 버전은 해당 workspace 내부에 node_modules에 설치됩니다.
이를 사용하여 프론트 모노래포를 한번 구성해보겠습니다.
우선 폴더를 하나 만들어줍니다.
거기서 터미널을 열고 우리의 npm을 npm init
으로 시작해봅시다.
루트에 package.json이 하나 생성되었고 이제 워크스페이스를 생성해봅시다.
여러 앱이 들어갈 packages에 app을 하나 생성해줍니다.
npm create vite@latest ./packages/app
package name은 @wrokspaces/app으로 설정해줍니다.
저는 react.js + swc로 vite 프로젝트를 만들어주었습니다.
공용 컴포넌트와 유틸등을 담아줄 프로젝트를 생성해줍니다.
npm create vite@latest ./shared
package name은 @wrokspaces/shared으로 설정해줍니다.
동일하게 react.js + swc을 선택해줍니다.
그 후 아래의 명령어를 각각 선언하여 워크스페이스로 등록해줍니다.
npm init -w ./packages/app
npm init -w ./shared
수행하고 나면 루트 packages.json에 workspaces가 추가됩니다.
루트에서 앱의 실행을 손쉽게 하기 위해서 scripts에 한줄 추가해줍니다.
"dev:app": "npm run dev -w @workspaces/app"
shared에 컴포넌트를 하나 만들어보겠습니다. 스타일 라이브러리를 하나 받아야겠습니다.
emotion/css는 추가로 다른 설정을 해주지 않아도 되서 편리합니다.
npm i @emotion/css -w @workspaces/shared
이제 shared에 components 폴더를 하나 생성하고 거기에 counter 컴포넌트를 하나 만들어 보겠습니다.
// shared/components/Counter.jsx
import { useState } from "react";
import { css } from "@emotion/css";
const styles = {
container: css({
width: 80,
height: 80,
display: "flex",
placeContent: "center",
placeItems: "center",
color: "white",
borderRadius: "50%",
backgroundColor: "black",
userSelect: "none",
fontSize: "2rem",
fontWeight: "bold",
":hover": {
color: "black",
backgroundColor: "white",
},
}),
};
function Counter() {
const [count, setCount] = useState(0);
const handleOnClick = () => {
setCount((prev) => prev + 1);
};
return (
<div className={styles.container} onClick={handleOnClick}>
{count}
</div>
);
}
export default Counter;
이제 packages/app의 App.jsx를 수정해주겠습니다.
import "./App.css";
import Counter from "@workspaces/shared/components/Counter";
function App() {
return <Counter />;
}
export default App;
루트 node_modules를 살펴보면 @workspaces 폴더안에 심볼릭 링크가 걸린 shared와 app이 들어있는 것을 볼 수 있습니다. 때문에 @workspaces/shared/components/Counter와 같이 접근할 수 있습니다.
이제 루트에서 npm run dev:app
를 실행하면 프로젝트가 잘 열리는 것을 볼 수 있습니다.
이전 버전의 emotion을 app에서 쓴다고 가정한다면 아래의 명령어 수행 이후에 모듈은 app 폴더에 생성됩니다.
npm i @emotion/css@10.0.27 -w @workspaces/app
사실 workspaces가 지원되는 것도 모를 정도로 npm을 쓸 생각을 못하고 있었는데요, 최근에 react-strict-dom이라는 프로젝트에 기여하려고 살펴보던 도중 npm으로 모노래포를 간단하게 구성해놓은 것을 보고 영감을 얻었습니다. npm이 근본인만큼 한번씩 돌아보고 살펴보면 좋을 것 같습니다.
결론 생각보다 힙하다