Загружаем каталог…
Загружаем каталог…
TL;DR 테스트가 ../../fixtures/... 처럼 프로젝트 폴더 밖의 파일 을 읽고 있었다. 상대경로는 코드 파일의 위치가 아니라 프로세스의 현재 작업 디렉터리(cwd) 를 기준으로 해석된다. 로컬과 Docker 컨테이너의 cwd와 디렉터리 구조가 달라 같은 코드가 서로 다른 경로를 가리켰다. Docker 이미지에는 호스트의 파일이 자동으로 들어가지 않는다. 필요한 파일은 COPY 등으로 명시적으로 포함해야 한다. 테스트가 실제로 찾고 있던 /fixtures 를 빌드 스테이지에 복사해 해결했다. 근본적으로는 빌드와 테스트가 기대하는 입력을 명시적으로 관리하는 것이 중요하다. 목차 증상 로그 읽기: ENOENT 상대경로는 어디를 기준으로 계산될까? Docker 이미지 안에는 어떤 파일이 있을까? 두 조건이 만나면서 문제가 발생했다 해결 멀티스테이지 빌드라서 운영 이미지에는 들어가지 않는다 더 나은 설계: 암묵적인 입력을 명시적으로 만들기 해결 방법 비교 Docker 없이 비슷한 환경 재현하기 체크리스트 마치며 1. 증상 로컬에서는 npm test 가 전부 통과했다. 그런데 CI에서 Docker 이미지를 빌드하자 테스트 단계에서 실패했다. FAIL lib/price.test.ts Error: ENOENT: no such file or directory, open '/fixtures/products.json' ❯ lib/price.test.ts:12:14 Test Files 1 failed | 8 passed (9) Tests 5 failed | 146 passed (151) ERROR: process "/bin/sh -c npm test && npm run build" did not complete successfully: exit code: 1 여기서 가장 먼저 눈에 들어온 것은 두 가지였다. 첫째, ENOENT 둘째, 전체 테스트가 아니라 151개 중 파일을 읽는 5개만 실패했다는 점 즉 테스트 러너 전체나 Node.js 버전 문제보다는, 먼저 파일 접근과 경로 문제 를 의심할 수 있었다. 2. 로그 읽기: ENOENT ENOENT 는 POSIX 계열 운영체제에서 사용하는 오류 코드 중 하나로, 의미는 다음과 같다. ENOENT No such file or directory Node.js가 fs.readFileSync() 같은 파일시스템 API를 호출했는데, 운영체제가 해당 경로에서 파일을 찾지 못했을 때 발생한다. 즉 이것은 보통 "비즈니스 로직이 틀렸다" 라는 오류라기보다 "운영체제가 요청받은 위치에서 파일을 찾지 못했다" 라는 의미다. 따라서 먼저 확인해야 할 것은 코드 로직보다 실제로 어떤 경로를 찾았는가 였다. 경로 테스트가 기대한 파일 shop/fixtures/products.json 에러 메시지의 경로 /fixtures/products.json 저장소 내부가 아니라 파일시스템 최상위 /fixtures 를 찾고 있었다. 같은 코드가 왜 환경에 따라 서로 다른 위치를 가리켰을까? 원인을 이해하려면 두 가지를 알아야 한다. 상대경로는 무엇을 기준으로 계산되는가 Docker 이미지 안에는 어떤 파일이 존재하는가 3. 상대경로는 어디를 기준으로 계산될까? 3-1. 기준은 코드 파일의 위치가 아니라 cwd 다음과 같은 코드를 생각해보자. readFileSync("../../fixtures/products.json", "utf8"); 많은 경우 이 경로가 현재 .ts 파일을 기준으로 계산될 것처럼 느껴진다. 하지만 Node.js의 fs API에 상대경로를 넘기면 기본적으로 프로세스의 현재 작업 디렉터리(current working directory) 를 기준으로 해석한다. Node.js에서는 process.cwd() 로 확인할 수 있다. 예를 들어 아래 두 코드는 개념적으로 같다. path.resolve("../../fixtures"); path.resolve(process.cwd(), "../../fixtures"); 값 의미 언제 바뀌는가 process.cwd() 프로세스를 어디에서 실행했는가 cd , Docker WORKDIR , 실행 스크립트 등에 따라 달라짐 import.meta.url 현재 모듈 파일이 어디에 있는가 파일 위치가 바뀌지 않는 한 동일 __dirname 현재 파일이 있는 디렉터리 CommonJS에서 사용 즉 상대경로가 cwd에 의존한다면 같은 코드라도 어디에서 실행하느냐에 따라 다른 파일을 읽게 된다. 이번 경우 로컬에서는 shop/apps/web 에서 테스트를 실행했고, Docker 안에서는 Dockerfile의 WORKDIR /app 때문에 테스트 프로세스의 cwd가 /app 이었다. 3-2. / 보다 위로는 올라갈 수 없다 POSIX 파일시스템에서 루트 디렉터리 / 의 부모는 사실상 / 자신이다. 따라서 다음 코드는 오류를 내지 않는다. path.resolve("/app", "../../fixtures"); // → "/fixtures" 과정을 보면 이해하기 쉽다. /app ↓ .. / ↓ .. ← 루트보다 위로 못 가고 그대로 / / ↓ fixtures /fixtures 반면 로컬에서는 결과가 다르다. path.resolve("/home/me/shop/apps/web", "../../fixtures"); // → "/home/me/shop/fixtures" 이 차이가 이번 문제의 첫 번째 원인 이었다. 4. Docker 이미지 안에는 어떤 파일이 있을까? 4-1. Docker 이미지는 호스트 폴더의 복사본이 아니다 Docker 이미지를 만들면 프로젝트 폴더 전체가 자동으로 들어가는 것처럼 생각하기 쉽지만, 실제로는 그렇지 않다. 이미지는 Dockerfile 명령을 순서대로 실행하면서 만들어진 파일시스템 변경의 결과 다. FROM node:24-alpine WORKDIR /app COPY package*.json ./ RUN npm ci COPY apps/web/ ./ RUN npm test COPY 는 호스트의 파일을 이미지 안으로 가져온다. RUN npm ci 역시 이미지 안에서 새로운 파일(예: node_modules )을 생성할 수 있다. 즉 이미지는 단순히 " COPY 한 것의 합"이라기보다 Dockerfile 명령들이 각 레이어에 기록한 파일시스템 변경의 결과 라고 보는 것이 정확하다. 다만 호스트에 있는 파일은 자동으로 이미지 안에 나타나지 않으며, 사용하려면 COPY 나 ADD 등으로 명시적으로 가져와야 한다. 4-2. 빌드 컨텍스트와 COPY 다음 명령을 실행했다고 하자. docker build -f apps/web/Dockerfile . 마지막의 . 이 빌드 컨텍스트(build context) , 즉 Docker 빌드가 참조할 수 있는 범위다. shop/ ├─ fixtures/ ├─ apps/ │ └─ web/ └─ ... 저장소 루트에서 위 명령을 실행했다면 fixtures/ 역시 빌드 컨텍스트 안에 있다. 하지만 빌드 컨텍스트 안에 있다는 것과 이미지 안에 들어간다는 것은 다른 이야기다. 실제로 이미지 안에 넣으려면 COPY 가 필요하다. COPY fixtures/ /fixtures/ 또한 .dockerignore 에 제외된 파일은 빌드 컨텍스트에서 사용할 수 없게 될 수 있으므로 함께 확인해야 한다. 핵심: Docker 빌드가 어떤 파일을 볼 수 있는가와, 그 파일이 실제 이미지 안에 들어가는가는 별개의 문제다. 이삿짐에 비유하면 다음과 비슷하다. Docker 비유 빌드 컨텍스트 이사할 집 COPY 실제 이삿짐 목록 이미지 트럭에 실린 물건 집에 물건이 있다고 해서 전부 트럭에 실리는 것은 아니다. 5. 두 조건이 만나면서 문제가 발생했다 저장소 구조와 테스트 코드는 다음과 같았다. shop/ ├─ fixtures/ │ └─ products.json │ └─ apps/ └─ web/ ├─ Dockerfile ├─ package.json └─ lib/ └─ price.test.ts const products = JSON.parse( readFileSync( resolve(process.cwd(), "../../fixtures/products.json"), "utf8", ), ); 로컬과 Docker를 비교하면 다음과 같다. 로컬 Docker process.cwd() shop/apps/web /app ../../ 결과 shop/ / 최종 경로 shop/fixtures/products.json /fixtures/products.json 파일 존재 여부 ✅ ❌ [로컬] [Docker] shop/apps/web /app │ ../../ │ ../../ ▼ ▼ shop / ▼ ▼ fixtures/products.json ✅ /fixtures/products.json ❌ 여기에 두 번째 문제가 겹쳤다. 기존 Dockerfile은 웹 애플리케이션 디렉터리만 이미지 안으로 복사하고 있었다. COPY apps/web/ ./ 따라서 호스트 저장소의 fixtures/products.json 은 Docker 이미지 안에 존재하지 않았다. 정리하면 이번 장애는 두 조건이 동시에 만나 발생했다. 조건 1: 상대경로가 process.cwd() 를 기준으로 계산되면서 ../../fixtures 가 Docker 안에서 /fixtures 로 바뀌었다. 조건 2: fixtures/ 를 별도로 COPY 하지 않아 이미지 안에 /fixtures 자체가 없었다. 그래서 Node.js가 ENOENT: no such file or directory 를 반환했다. 6. 해결 이번에는 테스트 코드의 경로 계산 방식은 그대로 두고, 테스트가 Docker 환경에서 실제로 찾고 있는 위치에 필요한 파일을 넣었다. FROM node:24-alpine AS build WORKDIR /app COPY apps/web/package*.json ./ RUN npm ci COPY apps/web/ ./ # 테스트의 cwd는 /app. # ../../fixtures 는 /fixtures 로 해석되므로 # 테스트가 실제로 찾는 위치에 fixture를 복사한다. COPY fixtures/ /fixtures/ RUN npm test && npm run build FROM node:24-alpine WORKDIR /app COPY --from=build /app/.next/standalone ./ CMD ["node", "server.js"] 에러 메시지에서 테스트가 찾던 경로는 /fixtures/products.json 이었고, Dockerfile에서도 COPY fixtures/ /fixtures/ 로 정확히 같은 위치를 만들었다. 이후 테스트가 정상적으로 통과했다. 이번 실패의 직접적인 원인 은 테스트 로직 자체라기보다, 테스트가 필요로 하는 파일이 빌드 이미지에 포함되지 않았다는 점이었다. 다만 ../../fixtures 라는 디렉터리 구조를 암묵적으로 가정하는 방식 역시 장기적으로는 개선할 여지가 있다. 7. 멀티스테이지 빌드라서 운영 이미지에는 들어가지 않는다 COPY fixtures/ /fixtures/ 를 추가하면 테스트용 JSON 파일이 운영 이미지에도 들어가는 것 아닐까? 이번 Dockerfile에서는 그렇지 않다. 멀티스테이지 빌드 를 사용하고 있기 때문이다. FROM node:24-alpine AS build 에서 첫 번째 스테이지가 시작된다. 이곳에는 /app 과 /fixtures 가 모두 존재하며, npm test && npm run build 를 실행한다. 새로운 FROM node:24-alpine 이 등장하는 순간 새로운 파일시스템 이 시작된다. 첫 번째 스테이지의 파일은 자동으로 넘어오지 않는다. 필요한 결과물만 COPY --from=build /app/.next/standalone ./ 로 선택해서 가져온다. ┌─ Build stage ──────────────┐ │ /app │ │ ├─ source │ │ ├─ node_modules │ │ └─ build output │ │ /fixtures │ │ └─ products.json │ └────────────┬───────────────┘ │ npm test │ npm run build ▼ ┌─ Final stage ──────────────┐ │ /app │ │ └─ standalone build │ │ /fixtures 없음 │ └────────────────────────────┘ 테스트에 필요한 파일은 빌드 단계에서만 사용하고, 운영 이미지에는 필요한 결과물만 포함할 수 있다. 8. 더 나은 설계: 암묵적인 입력을 명시적으로 만들기 COPY 한 줄로 문제는 해결됐지만, 왜 이런 문제가 생겼는지 조금 더 생각해볼 필요가 있다. 기존 테스트에는 다음 가정이 숨어 있었다. 저장소 루트에 fixtures/ 가 존재한다. 테스트 프로세스는 저장소 루트에서 두 단계 아래에서 실행된다. 즉 테스트의 입력 파일 위치가 코드에 명확히 선언된 것이 아니라, 디렉터리 구조와 실행 위치에 암묵적으로 의존하고 있었다. 이런 값을 흔히 암묵적인 입력(implicit input) 이라고 볼 수 있고, 환경이 조금만 바뀌어도 쉽게 깨진다. 8-1. cwd 의존성을 파일 위치 의존성으로 바꾸기 ES Module 환경이라면 현재 파일의 위치를 기준으로 경로를 계산할 수 있다. const fixture = new URL( "../../../fixtures/products.json", import.meta.url, ); 이제 cd /somewhere && npm test 처럼 실행 위치가 달라져도 현재 모듈 파일을 기준으로 같은 상대 위치를 찾는다. 다만 이것도 완전히 독립적인 설계는 아니다. 여전히 다음 구조를 가정하기 때문이다. lib/price.test.ts │ ../../../ ▼ fixtures/ 즉 cwd 의존성 이 저장소 디렉터리 구조 의존성 으로 바뀐 것이다. 그리고 어떤 방식으로 경로를 계산하든 실제 파일이 Docker 이미지 안에 존재해야 한다는 조건은 변하지 않는다. 8-2. 파일 URL을 실제 경로로 변환할 때는 fileURLToPath() URL.pathname 을 바로 파일시스템 경로로 쓰는 코드를 볼 때가 있다. // ⚠️ Windows에서 문제가 될 수 있음 new URL("../../../fixtures/", import.meta.url).pathname 파일 URL과 운영체제의 경로 표현 방식이 다르기 때문이다. Node.js에서는 fileURLToPath() 를 사용하는 것이 안전하다. import { fileURLToPath } from "node:url"; const fixturesDir = fileURLToPath( new URL("../../../fixtures/", import.meta.url), ); Windows에서 개발하고 Linux Docker 컨테이너에서 빌드하는 프로젝트라면 이런 차이를 더욱 신경 쓰는 것이 좋다. 8-3. 입력 위치 자체를 설정으로 명시하기 조금 더 명시적으로 만들려면 fixture 위치를 한 곳에서 관리할 수도 있다. import { fileURLToPath } from "node:url"; export const FIXTURES_DIR = process.env.FIXTURES_DIR ?? fileURLToPath(new URL("../../../fixtures/", import.meta.url)); COPY fixtures/ /fixtures/ ENV FIXTURES_DIR=/fixtures 그러면 테스트는 fixture가 저장소에서 정확히 몇 단계 위에 있는가 보다 현재 환경에서 fixture 디렉터리가 어디인가 라는 명시적인 설정을 사용하게 된다. 로컬에서는 기본값을, Docker나 CI에서는 환경변수를 주입할 수 있고, 문제가 생겼을 때 확인해야 할 곳도 명확해진다. 9. 해결 방법 비교 방법 장점 단점 Dockerfile에 COPY 추가 코드 수정 없이 바로 해결 가능 Dockerfile이 테스트의 경로 규칙을 알아야 함 fixture를 apps/web/ 내부로 이동 프로젝트가 자체 테스트 데이터를 가지므로 구조가 단순 여러 프로젝트가 같은 fixture를 공유하기 어려움 fixture를 import 번들러/테스트 러너가 파일 의존성을 추적 가능 프로젝트 외부 파일 import 설정이 필요할 수 있음 환경변수로 fixture 경로 주입 환경마다 명시적으로 경로 설정 가능 설정 항목이 늘어남 import.meta.url 기반 경로 cwd 변화에 영향받지 않음 저장소 디렉터리 구조에는 계속 의존 이번 프로젝트에서는 여러 프로젝트가 같은 fixture 원본을 공유하고 있었기 때문에, 우선 가장 작은 변경인 COPY fixtures/ /fixtures/ 방식을 적용했다. 필요하다면 이후 FIXTURES_DIR 같은 환경변수 기반 구조로 확장할 수 있다. 10. Docker 없이 비슷한 환경 재현하기 CI가 오래 걸린다면 매번 Docker 이미지를 빌드해 확인하기 번거롭다. 이번 문제는 Docker 없이도 비슷한 디렉터리 구조를 만들어 재현할 수 있다. mkdir -p /tmp/sim/a/app cp -r apps/web/. /tmp/sim/a/app/ cp -r fixtures /tmp/sim/fixtures cd /tmp/sim/a/app npm ci npm test npm run build 일부러 다음 구조를 만든 것이다. /tmp/sim/ ├─ fixtures/ └─ a/ └─ app/ ← cwd cwd가 /tmp/sim/a/app 이므로 ../../fixtures 는 정확히 /tmp/sim/fixtures 를 가리킨다. Docker에서 기대하는 관계를 로컬 디렉터리 구조로 흉내 낸 것이다. 이 상태에서 /tmp/sim/fixtures 를 삭제하고 테스트를 실행하면 동일한 ENOENT 를 재현할 수 있다. 트러블슈팅에서 중요한 기준 중 하나는 문제를 재현할 수 있는가 다. 수정 전에는 실패하고 수정 후에는 성공하는 상황을 반복해서 만들 수 있어야, 수정이 실제 원인을 해결했다고 판단하기 쉽다. 11. 체크리스트 Docker나 CI에서만 파일 관련 테스트가 실패한다면 다음 항목부터 확인해볼 수 있다. 경로 테스트나 스크립트가 ../ 또는 ../../ 로 프로젝트 외부 파일을 읽고 있는가? 상대경로가 process.cwd() 기준인가? 로컬과 Docker의 process.cwd() 값이 같은가? Dockerfile의 WORKDIR 은 어디인가? 에러 메시지에서 실제로 어떤 절대경로를 찾고 있는가? Docker 이미지 필요한 파일이 Docker 빌드 컨텍스트 안에 있는가? 필요한 파일을 Dockerfile에서 실제로 COPY 했는가? .dockerignore 가 해당 파일이나 폴더를 제외하고 있지는 않은가? 테스트용 파일과 운영 이미지에 필요한 파일을 구분하고 있는가? 멀티스테이지 빌드라면 최종 스테이지에 불필요한 테스트 데이터가 넘어가고 있지 않은가? 설계 fixture나 설정 파일의 위치가 코드에 암묵적으로 숨어 있지는 않은가? 필요한 입력 위치를 환경변수나 설정 값으로 명시할 수 있는가? 12. 마치며: "로컬에서는 되는데 CI에서는 안 된다" 개발 중 자주 듣는 말이 있다. "로컬에서는 되는데 CI에서는 안 돼요." 이 문제의 원인은 많은 경우 코드 자체보다 환경 차이 에 있다. 이번에는 차이가 두 가지였다. 현재 작업 디렉터리(cwd) Docker 이미지 내부의 파일 구조 로컬에서는 cwd가 shop/apps/web 이라 ../../fixtures 가 자연스럽게 저장소 루트의 fixtures 를 가리켰다. Docker에서는 cwd가 /app 이라 같은 코드가 /fixtures 를 가리켰고, 이미지에는 해당 폴더를 COPY 하지 않았기 때문에 파일이 존재하지 않았다. 원인 해결 상대경로 테스트가 실제로 찾는 위치 확인 ↓ process.cwd() ↓ ↓ 환경마다 다른 절대경로 필요한 입력을 Dockerfile에 명시 ↓ Docker 이미지에 필요한 파일 없음 ↓ COPY fixtures/ /fixtures/ ENOENT 테스트 통과 조금 더 넓게 보면 이번 문제는 재현 가능한 빌드 에 대한 이야기이기도 하다. 빌드에 어떤 파일, 어떤 환경변수, 어떤 디렉터리 구조가 필요한지 명확하게 선언되어 있을수록 환경 차이에 덜 흔들린다. 이렇게 외부 환경의 숨은 상태에 의존하지 않고 선언된 입력만으로 동일한 결과를 만드는 빌드를 흔히 h
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
로컬에선 통과하던 테스트가 Docker 빌드에서만 깨진 이유. TL;DR 테스트가 ../../fixtures/... 처럼 프로젝트 폴더 밖의 파일 을 읽고 있었다. 상대경로는 코드 파일의 위치가 아니라 프로세스의 현재 작업 디렉터리(cwd) 를 기준으로 해석된다. 로컬과 Docker 컨테이너의 cwd와 디렉터리 구조가 달라 같은 코드가 서로 다른 경로를 가리켰다. Docker 이미지에는 호스트의 파일이 자동으로 들어가지 않는다. 필요한 파일은 COPY 등으로 명시적으로 포함해야 한다. 테스트가 실제로 찾고 있던 /fixtures 를 빌드 스테이지에 복사해 해결했다. 근본적으로는 빌드와 테스트가 기대하는 입력을 명시적으로 관리하는 것이 중요하다. 목차 증상 로그 읽기: ENOENT 상대경로는 어디를 기준으로…
Открыть источник