노트

CPU 아키텍처와 네이티브 모듈

CPU Architecture and Native Addons

인프라#docker#tooling · 연결된 개념 3개

쉽게 말하면

CPU 아키텍처 차이는 나라마다 콘센트 모양이 다른 것과 비슷해요. JS는 현지 Node.js가 그 자리에서 해석해 주니 어디서든 돌지만, sharp처럼 기계어로 미리 굳힌 부품은 꽂을 CPU에 맞는 플러그로 만들어야 해요.

비유가 깨지는 곳 콘센트는 눈에 보이지만 이 불일치는 늦게 터져요. x64 CI에서 만든 node_modules를 ARM64 서버로 옮기면 JS는 돌다가 네이티브 모듈을 불러오는 순간 실패해요. glibc와 musl 차이까지 맞아야 해요.

CPU 아키텍처는 CPU가 실행하는 명령어 집합(Instruction Set Architecture, ISA)의 종류다. 서버와 PC에서는 x86-64와 ARM64 두 계열이 주로 쓰이고, 한 계열용으로 컴파일된 기계어는 다른 계열 CPU에서 실행되지 않는다. Node.js 프로젝트에서는 이 차이가 네이티브 모듈에서 드러난다.

두 계열

계열다른 이름흔한 곳
x86-64x64, amd64대부분의 데스크톱·서버, 인텔 맥
ARM64aarch64, arm64스마트폰, 애플 실리콘 맥, ARM 기반 클라우드 서버

같은 "덧셈"이라도 두 계열의 기계어 인코딩이 다르다. C·C++·Rust 코드를 컴파일하면 대상 아키텍처를 정해서 그 계열의 기계어로 된 네이티브 바이너리(.node·.so·.dll)가 나온다. 태어날 때부터 특정 아키텍처에 묶인다.

JS는 왜 상관없나

JS 파일은 미리 기계어로 굳혀 둔 것이 아니다. 실행하는 Node.js의 V8 엔진이 그 자리에서 바이트코드로 해석하고, 자주 도는 코드는 JIT(Just-In-Time) 컴파일한다. 아키텍처에 묶이는 것은 각 서버에 따로 설치된 Node.js 런타임이지 JS 파일이 아니다. 그래서 같은 JS 번들이 x86-64 서버에서도 ARM64 서버에서도 돈다.

문제는 npm 패키지 중 일부가 내부에 네이티브 바이너리를 품고 있다는 것이다. 이미지 처리 라이브러리 sharp가 대표적이다. sharp는 C 라이브러리 libvips를 감싼 얇은 JS 래퍼라서, 실제 무게 대부분이 플랫폼별로 컴파일된 libvips다.

플랫폼별 패키지로 나눠 배포하기

sharp는 미리 컴파일한 바이너리를 @img/sharp-linux-arm64, @img/sharp-linuxmusl-x64, @img/sharp-darwin-arm64처럼 플랫폼별 패키지로 나누고, 이를 모두 optionalDependencies로 건다. 각 패키지의 package.json에 os·cpu·libc 필드가 있어서, 패키지 매니저는 설치하는 머신에 맞는 것만 받고 나머지는 건너뛴다(2026 기준).

  • 아키텍처뿐 아니라 C 표준 라이브러리도 맞아야 한다. 같은 Linux x64라도 glibc(Debian·Ubuntu)와 musl(Alpine)용 바이너리가 따로 있다
  • 다른 플랫폼용을 함께 받아 두려면 npm v10+는 --os·--cpu·--libc 플래그, pnpm v8+·yarn v3+는 설정 파일의 supportedArchitectures를 쓴다(npm과 pnpm)
  • optionalDependencies 설치를 끄는 설정이면 바이너리가 아예 없어 실행 때 실패한다

빌드 머신과 실행 머신이 다르면

설치 시점의 플랫폼이 결과물에 새겨진다. 그래서 x64 CI 러너에서 node_modules를 만들어 ARM64 서버로 옮기면, JS는 돌지만 네이티브 모듈을 불러오는 순간 로드에 실패한다. 이미지 최적화처럼 특정 요청에서만 불리는 모듈이면 배포 직후가 아니라 런타임에 늦게 터진다.

빌드 머신 arch ──결정──▶ 결과물 속 바이너리 arch ──일치해야 함──▶ 실행 머신 arch

해결은 셋 중 하나다.

  1. 실행 아키텍처에서 설치·빌드한다. ARM 러너를 쓰거나 Docker에서 --platform을 지정한다. docker buildx build --platform linux/arm64는 다른 아키텍처면 QEMU 에뮬레이션으로 돌아서 느리다. 멀티 스테이지에서 FROM --platform=$BUILDPLATFORM인 스테이지가 설치를 하면 빌더 아키텍처용 바이너리가 들어가니 주의한다(Dockerfile: 레이어 캐시와 멀티 스테이지)
  2. 대상 플랫폼 바이너리를 명시해 설치한다. 위의 --cpu·supportedArchitectures
  3. 쓰지 않는 네이티브 모듈을 결과물에서 뺀다. 결과물이 순수 JS가 되면 빌드와 실행 아키텍처가 서로 독립한다

Next.js와 sharp

Next.js 16은 sharp를 optionalDependencies로 갖고 있고, next/image의 이미지 최적화에 쓴다(Next.js 16.3 기준). 이미지 최적화를 쓰지 않는다면 images.unoptimized: true로 끄고, standalone 출력의 outputFileTracingExcludes로 sharp와 @img/*를 트레이스에서 빼는 방법을 쓸 수 있다. 문서가 직접 안내하는 조합은 아니므로, 뺀 뒤 이미지가 나오는 화면을 실제로 확인한다. libvips는 이미 컴파일된 바이너리라 gzip으로 잘 줄지 않아서, 압축한 배포 아티팩트에서도 꽤 큰 비중을 차지하곤 한다. 반대로 최적화를 쓴다면 실행 플랫폼용 sharp가 결과물에 들어가도록 설치 플랫폼을 맞춰야 한다. glibc Linux에서는 메모리 사용이 커질 수 있다는 sharp 쪽 안내도 있다.

출처: sharp - Installation: Cross-platform · Next.js - Self-Hosting: Image Optimization · Docker - Multi-platform builds

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • Nx

    Nx는 캐싱과 병렬 실행에 더해 프로젝트 그래프, 코드 생성, 아키텍처 규칙 강제까지 제공하는 모노레포 플랫폼이다. "빠른 실행 한 가지"에 집중하는 turborepo와 달리 기능을 두루 갖춘 쪽을 지향한다.

  • 로컬 퍼스트 아키텍처

    클라이언트 기기의 로컬 데이터를 진실의 원천(Source of Truth)으로 삼고, 서버는 동기화와 백업을 맡는 설계 방식. 전통적인 "서버가 원천, 클라이언트는 요청해서 그린다" 구조를 뒤집어 로컬 DB → UI 렌더링 → 백그라운드 동기화 순서로 흐른다.

  • 컨테이너와 이미지

    이미지는 앱 실행에 필요한 코드·런타임·라이브러리·설정을 묶은 읽기 전용 패키지이고, 컨테이너는 그 이미지를 실행한 격리된 프로세스다. 클래스와 인스턴스처럼 이미지 하나로 컨테이너를 여러 개 띄울 수 있다.

  • NestJS

    NestJS는 TypeScript를 전제로 모듈·의존성 주입(Dependency Injection, DI)·데코레이터(decorator) 구조를 제공하는 Node.js 서버 프레임워크다. 2017년 Kamil Myśliwiec가 만들었고, 구조를 강제하는 방식 때문에 흔히 "Node.js의 Spring"이라 불린다.

  • next-intl과 서버 컴포넌트 친화 i18n

    next-intl은 Next.js App Router와 서버 컴포넌트를 전제로 설계된 국제화(Internationalization, i18n) 라이브러리다. 번역 메시지를 React Context가 아니라 요청 단위 설정 함수에서 읽어서, 번역만 쓰는 컴포넌트를 서버 컴포넌트로 남길 수 있다.

보기 옵션