PDF를 유저에게 보여줘야 하는 일이 생겼다. 먼저 생각난 것은 SpringBoot의 라이브러리(OpenHTMLtoPDF등)를 사용해 BE에서 생성하는 방법이었다. 하지만, 유저에게 보여주어야 할 PDF가 단순히 text로만 구성된 것이 아니라 복잡한 레이아웃과 다양한 CSS 기법이 적용되어야 했다. 또한, 유저에 따라 데이터가 다르고 데이터에 따라 동적인 레이아웃이 적용되어야 했다.
SpringBoot에서 만들 경우 text 기반으로 PDF를 생성하는 방법도 있고, HTML을 렌더링해서 생성하는 방법도 존재했지만, 두 경우 모두 스타일 지원이 제한적이고 HTML을 렌더링하는 방법에서 CSS가 미묘한 깨짐이 발생할 수 있다는 사실을 알게 되었다. 이번 경우와 같이 복잡한 레이아웃의 PDF는 FE쪽 리소스를 투입하는게 낫다고 생각했다.
FE에서 PDF를 생성할 수 있는 라이브러리로, Puppeteer, Playwright와 같은 헤드리스 브라우저를 이용해 PDF를 렌더링 하는 방법이 떠올랐다. Playwright를 선택한 이유는 앞으로 Playwright로 E2E Testing을 할 계획을 가지고 있기도 했고, Playwright를 이전 Puppeteer 개발자 주도해 만들었기 때문이다(사실 아주 거창한 이유는 없다).
Next.js를 사용하고 있기 때문에 Node.js 서버 영역에서 Playwright의 헤드리스 브라우저를 구동시켜 페이지를 렌더링 시키고, 그 페이지를 PDF로 만들어내면 되겠다. 자세한 기법은 추후 블로깅 할 생각.
이번 글은 PDF 생성 개발을 끝낸 뒤, 이걸 어떻게 배포 환경까지 안정적으로 가져갔는지에 대한 기록이다. 특히 ‘로컬에서는 되는데 배포에서는 깨지는’ 문제를 피하기 위해 실행 환경을 로컬에서 재현 하고, 재현된 것을 실제 배포 환경에 적용하는 내용이다.
다루는 것
- Playwright 실행 환경(브라우저/OS 의존성/폰트)을 베이스 이미지로 고정하는 방법
- 로컬에서 배포 환경과 유사하게 재현하는 로컬 Docker 테스트 패턴
- Playwright npm 버전 변화에 맞춰 베이스 이미지 버전과 Dockerfile FROM을 자동 갱신하는 방법
- Docker build cache / tag 문제를 피하는 timestamp 태깅 전략
- 이미지 이동/적재(docker save/load + gzip)로 외부 다운로드 의존성 제거
전제
Next.js 기반 애플리케이션의 복잡한 PDF 생성 모듈을 Playwright를 사용하여 Docker/Kubernetes(EKS) 환경에 배포하는 과정을 다루기 때문에 아래 기술들에 대한 간단한 이해가 있다면 내용을 더 쉽게 파악할 수 있다.
- Next.js (Page Router / Standalone): next build 이후 standalone 아티팩트가 어떤 식으로 서버를 구성하는지
- Playwright: 헤드리스 브라우저(Chromium)를 제어해서 페이지를 렌더링하고 PDF를 생성한다는 개념
- Docker: Dockerfile, 이미지/컨테이너, FROM/ARG/ENV/USER 같은 기본 문법과 컨테이너 네트워크
- ECR/EKS: 이미지 push/pull, 그리고 그 이미지를 기반으로 Pod가 뜬다는 흐름
- CI/CD: GitHub Actions로 빌드→이미지 생성→레지스트리 push→배포까지 이어지는 전체 흐름
- 기본 쉘 명령: apt-get, sed, gzip, docker save/load 같은 명령의 역할
환경
FE
- Next.js (Page Router)
- Playwright
배포
- Next.js Standalone
- GitHub Actions
- Docker / ECR(Amazon Elastic Container Registry)
- EKS(Amazon Elastic Kubernetes Service)
아래는 현재 CI/CD 배포 파이프라인이다.
sequenceDiagram
actor Developer
participant GitHub
participant Actions Runner
participant ECR
participant EKS
Developer ->> GitHub: FE commit (Push)
GitHub -->> Actions Runner: Workflow Trigger
activate Actions Runner
Actions Runner ->> Actions Runner: Next.js build (Standalone Artifacts 생성)
Actions Runner ->> Actions Runner: Docker build 시작 (Dockerfile 실행)
Actions Runner ->> ECR: Base Image Pull (FROM)
ECR -->> Actions Runner: Base Image 제공
Actions Runner ->> Actions Runner: Next.js Artifacts Copy (COPY)
Actions Runner ->> Actions Runner: 새로운 Docker Image 생성 완료
Actions Runner ->> ECR: Docker Image Push
ECR -->> Actions Runner: Image Push Success
Actions Runner ->> EKS: 배포 요청
EKS -->> EKS: 새로운 Image로 Pod 업데이트
deactivate Actions RunnerPDF가 유저에게 도달하는 흐름 (요약)
sequenceDiagram
autonumber
actor 사용자
participant Page as PDF렌더링 페이지<br/>(PdfViewer)
box "Node.js 런타임 영역"
participant API as API Routes
participant Headless as Playwright<br/>(헤드리스 브라우저)
end
participant Backend as 백엔드 API<br/>(SpringBoot)
사용자->>Page: /pdf/something 접속
Page->>API: PDF 요청
API->>Headless: 헤드리스 브라우저 오픈
Headless->>Backend: SSR PDF 데이터 API 호출
Backend-->>Headless: 데이터 응답
Headless->>Headless: 1. SSR 페이지 오픈<br/>2. PDF Buffer 생성
Headless-->>API: PDF Buffer 응답
API->>Page: PDF Buffer 전달
Page->>사용자: 환경에 따라 Blob(네이티브 뷰어)<br/>혹은 pdf.js(Canvas)로 렌더링- 서버(Node)에서 Playwright로 헤드리스 브라우저를 구동한다.
- 헤드리스 브라우저가 Next.js로 배포된 “PDF 렌더링 페이지”를 연다.
- 해당 페이지는 SSR 과정에서 백엔드(SpringBoot)로부터 데이터를 받아 렌더링된다.
- Playwright는 렌더링 결과를 PDF로 변환(
page.pdf())한다. - 생성된 PDF 바이너리를 API Routes가 받아 유저 브라우저로 내려준다.
Playwright 구성 요소
먼저, Playwright를 사용하기 위해서는 3가지를 알고 있어야 한다.
- Playwright npm 패키지: 브라우저를 제어하는 코드(런타임 라이브러리)
- 브라우저 바이너리(Chromium 등): 실제 실행 엔진
- 브라우저 시스템 종속성(OS Dependencies): 브라우저가 뜨기 위해 필요한 OS 라이브러리/폰트/로케일 등
비유를 들면,
- Playwright npm 패키지 = 운전자 + 운전 매뉴얼
- 브라우저 바이너리 = 실제 자동차
- OS 종속성 = 자동차가 달릴 수 있는 도로(기반 환경)
PDF는 결과물이라서, 이 3개 중 하나라도 흔들리면 폰트/간격/줄바꿈 같은 것이 달라진다. 즉, PDF는 코드만 맞추면 끝나는 문제가 아니라 실행 환경까지 같이 고정해야 한다.
브라우저 시스템 종속성(OS Dependencies)
로컬 환경(mac)
로컬에서 개발할 때는 $ npx playwright install chromium 명령어로 로컬에 헤드리스 브라우저를 설치할 수 있다. 이때 CDN으로 https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1187/chromium-mac-arm64.zip 경로를 통해 자동으로 바이너리를 받아온다(맥의 경우임, OS마다 다른 바이너리 파일을 다운 받음).
브라우저 시스템 종속성은 로컬 환경에서는 다운 받을 필요 없다. 브라우저 실행에 필요한 OS 라이브러리가 이미 OS 자체에 있기 때문이다.
배포 환경(docker container - linux)
배포 환경은 Docker 이미지를 생성하고 실행하지만, 외부 네트워크 접근에 제약이 있는 환경(내부망)이다. 이로 인해 다음과 같은 문제가 존재함.
npx playwright install명령어를 통한 브라우저 바이너리(Chromium) 다운로드 불가.- Playwright가 요구하는 운영체제 종속성(OS Dependencies) 설치 및 버전 관리의 어려움. (내부 저장소 부재, 환경 통제의 필요성)
Playwright의 공식 문서에서 docker 이미지를 따로 제공하고 있다는 걸 알게 되었다. 이 이미지는 헤드리스 브라우저 바이너리와 브라우저 시스템 종속성을 포함하고 있다. 따라서 이미지를 빌드할 때 일일이 바이너리와 종속성을 따로 받지 않아도 된다. 따라서 Next.js 빌드 결과물을 이 이미지에 입혀 EKS Pod에 배포하면 되는 것이다.
문제 발생
처음에는 공식 Playwright Docker 이미지를 쓰면 다 해결될 것이라고 생각했다. 실제로 공식 이미지는 브라우저 바이너리와 대부분의 OS 의존성을 포함하고 있고, Playwright가 기대하는 디렉토리 구조나 권한도 맞춰져 있다.
그런데 배포 환경에서 돌려보니, 로컬에서 멀쩡하던 PDF가 깨지거나, 오류가 발생했다.
- 브라우저 실행 경로 문제(/ms-playwright 하위 경로 등)
- 브라우저 콘솔 에러, 렌더링 타이밍 이슈
- SSR에서 백엔드 호출 URL/네트워크 설정 문제
- 한글 폰트/이모지 렌더링 문제
- 쿠키/세션 처리 문제, user-agent 차이 등
세부 오류는 여기서 다 풀지는 않겠지만 공식 이미지 만으로는 결과물이 고정되지 않는다. 브라우저/폰트/아키텍처/환경변수/네트워크가 조금만 달라도 PDF는 달라진다.
그래서 완벽히 동일하진 않더라도, 최소한 로컬에서 배포 환경과 유사한 조건을 강제할 수 있어야 디버깅이 가능하다고 판단했다. 이게 Part 1(로컬 Docker 환경 구축)을 시작한 이유다.
Part 1: 로컬 Docker 환경 구축
Step 1. docker/Dockerfile.local (신규 생성)
기존 docker/Dockerfile 은 프로덕션 배포용이었기 때문에, 로컬 테스트를 위해 별도의 Dockerfile을 만든다. Dockerfile.local 파일을 작성한다.
# Dockerfile.local
# 로컬 Docker 테스트용 Dockerfile (Playwright 1.46.1 사용)
FROM mcr.microsoft.com/playwright:v1.46.1-jammy AS base
WORKDIR /app
EXPOSE 3000
ENV PORT=3000
ENV HOST=0.0.0.0
COPY public ./public
COPY .next/standalone ./
COPY .next/static ./.next/static
USER root
RUN mkdir -p .next/cache && chown -R pwuser:pwuser .next
USER pwuser
ENTRYPOINT ["node", "server.js"]세부 설명
FROM mcr.microsoft.com/playwright:v1.46.1-jammy
- Microsoft 공식 Playwright 이미지 사용
- npm에 설치된 Playwright 버전과 명시적으로 맞춘다
- 브라우저 바이너리와 OS 종속성을 직접 설치하지 않아도 됨
ENV HOST=0.0.0.0
- Next.js 서버를 모든 네트워크 인터페이스에 바인딩
- 컨테이너 내부에서 들어오는 요청을 정상적으로 받기 위함
USER pwuser
- Playwright 공식 이미지에 포함된 일반 사용자
- root로 실행하지 않도록 기본 보안 설정 유지
- 단, .next/cache 디렉토리는 root에서 권한만 미리 맞춰준다
Step 2. .env.local-docker (신규 생성)
로컬 개발 서버(pnpm dev)와 Docker 컨테이너에서 실행되는 서버는 네트워크 구조가 완전히 다르다. 그래서 기존 .env.localhost를 그대로 쓰면 의도하지 않은 문제가 생긴다.
Docker 환경 전용으로 .env.local-docker 를 따로 만들었다.
# .env.local-docker
NEXT_PUBLIC_APP_ENV=local-docker
NEXT_PUBLIC_BROWSER_API_URL=http://localhost:3000
NEXT_PUBLIC_INTERNAL_API_URL=...
NEXT_PUBLIC_PDF_INTERNAL_URL=http://host.docker.internal:3000
SERVER_API_URL=...# .env.local
NEXT_PUBLIC_APP_ENV=localhost
NEXT_PUBLIC_BROWSER_API_URL=http://localhost:3000
NEXT_PUBLIC_INTERNAL_API_URL=http://localhost:3000
NEXT_PUBLIC_PDF_INTERNAL_URL=http://localhost:3000
SERVER_API_URL=....env.local-docker 설명
NEXT_PUBLIC_BROWSER_API_URL=http://localhost:3000
- 브라우저(클라이언트)에서 API 호출 시 사용하는 URL
- 사용자가
http://localhost:3000으로 접속하므로 그대로 유지
단, 여기서 dev 서버는 CORS 때문에 next.config.js에 프록시 역할을 하도록 설정해두었었고, docker 환경에서도 프록시 역할을 하도록 해준다.
// next.config.js
// ...
async rewrites() {
return process.env.NEXT_PUBLIC_APP_ENV === 'localhost' ||
process.env.NEXT_PUBLIC_APP_ENV === 'local-docker' ? [
{
// api 프록시
source: '/b2c-api/api/:path*',
destination: `${process.env.SERVER_API_URL}/api/:path*`,
},
]
: [];
},
// ...NEXT_PUBLIC_INTERNAL_API_URL=...
- SSR시, middleware 등 Node.js 런타임에서 API 호출에 사용
- Docker 컨테이너 내부에서
localhost:3000은 자기 자신을 가리키므로 외부 테스트 서버 사용 - EKS 환경에서는 내부에 배포된 Pod 서버 주소를 사용한다. (브라우저가 아니라 node 서버에서 SpringBoot 서버로 쏘므로)
NEXT_PUBLIC_PDF_INTERNAL_URL=http://host.docker.internal:3000
- PDF 생성 시 Playwright가 페이지 접속에 사용하는 URL
- 로컬 개발 서버(호스트 PC)와 달리, Docker 컨테이너 내부에서
localhost는 컨테이너 자신을 가리킴 - 따라서 컨테이너에서 **호스트 머신(Next.js 서버가 실행 중인 PC)**으로 루프백 요청을 보내기 위해 Docker Desktop이 제공하는 특수 DNS인
host.docker.internal을 사용함.
Playwright는 헤드리스 브라우저에서 PDF 생성 대상이 되는 페이지를 열기 위해 자기 자신을 호출한다. (그래야 FE에서 복잡한 CSS를 가진 페이지를 만들 수 있고, PDF 관리도 FE에서 가능하기 때문이다.)
sequenceDiagram
box "단일 로컬 PC 환경"
participant PlaywrightApp as Playwright/Node.js 서버
participant Browser as Playwright 헤드리스 브라우저
end
PlaywrightApp ->> Browser: 1. 브라우저 실행 (launch browser())
activate Browser
PlaywrightApp ->> Browser: 2. 새 페이지 열기 (newPage())
Browser -->> PlaywrightApp: 3. 페이지 인스턴스 반환
Note over PlaywrightApp: 4. PDF 생성 대상 URL 결정<br>(예: http://localhost:3000/content)
PlaywrightApp ->> Browser: 5. page.goto('http://localhost:PORT/경로')
Browser ->> PlaywrightApp: 6. 페이지 콘텐츠를 위한 HTTP 요청 (Loopback 통신)
activate PlaywrightApp
PlaywrightApp ->> Browser: 7. HTTP 응답 (HTML/CSS/JS)
deactivate PlaywrightApp
Browser ->> Browser: 8. 페이지 콘텐츠 렌더링
PlaywrightApp ->> Browser: 9. PDF 파일 생성 명령 (page.pdf())
Browser -->> PlaywrightApp: 10. PDF 이진 데이터 반환
deactivate Browser
PlaywrightApp ->> PlaywrightApp: 11. PDF 파일 저장 또는 스트림 처리자기 자신을 호출하는 것을 **로컬 루프백(Local Loopback)**이라고 한다. 이때 로컬(pnpm dev로 실행하는 dev서버)에서는 http://localhost:3000 주소를 사용해도 된다. 왜냐하면 Next.js가 떠있는게 localhost:3000 이기 때문이다. 같은 OS(호스트 PC) 위에서 실행되기 때문이다.
- Host PC (Next.js Server): PDF 렌더링에 필요한 HTML/CSS/데이터를 제공하는 웹 서버.
- Docker Container (Playwright App): Next.js 코드를 실행하며, 헤드리스 브라우저를 구동하여 PDF를 생성하는 주체.
하지만 Docker 환경은 다르다. Docker Container 내부에서 localhost는 호스트 PC가 아닌 Docker Container 자기 자신이기 때문이다. 컨테이너 내부 3000번 포트로 요청을 보낸다. 그러면 dev 서버와 동일하게 자기자신을 가리키기 때문에 문제 없이 호출되어야 하는 것이 맞다. 하지만 localhost:3000이 실패했고, host.docker.internal:3000 으로 접근했을 경우만 성공했다.
왜냐면 docker container 내부에 localhost가 네트워크로 바인딩 되어있지 않기 때문이다.
# docker container 내부
$ cat /etc/hosts
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00:: ip6-localnet
ff00:: ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.2 3c2c98fbe2aeDNS로 127.0.0.1에 localhost가 바인딩 되어있지만, 실제로는 172.17.0.2에 Next.js 어플리케이션이 떠있다.
# docker container 내부
$ node /app/server.js
⨯ Failed to start server
Error: listen EADDRINUSE: address already in use 172.17.0.2:3000
at <unknown> (Error: listen EADDRINUSE: address already in use 172.17.0.2:3000) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '172.17.0.2',
port: 3000
}Next.js 서버를 다시 한번 실행하는 명령어인데, 이미 172.17.0.2:3000 에 떠 있는 것을 볼 수 있기 때문이다. 그렇다면 방법은 두 가지다.
NEXT_PUBLIC_PDF_INTERNAL_URL에 localhost:3000 대신,172.17.0.2:3000이 주소를 넣어준다.- docker run 시
--dns=127.0.01옵션을 준다.
NEXT_PUBLIC_PDF_INTERNAL_URL에 host.docker.internal을 주어 호스트 NAT 우회 한다.
나는 3번을 선택했는데, 호스트 PC를 호출해서 호스트 PC가 다시 docker container의 Next.js 서버를 찌르게 만드는 방법이다. 이는 docker에서 제공하는 특수 DNS 이름이다.
sequenceDiagram
autonumber
box "Host PC"
participant Browser as Browser (Client)
participant NAT as Docker NAT / Port Publish
end
box "Docker Container"
participant App as Next.js + Playwright
participant B as Headless Browser
end
Browser->>App: /api/pdf 요청 (localhost:3000)
App->>B: launch + page.newPage
Note over App: 내부 접근 시도<br/>http://localhost:3000/content
B->>App: GET localhost:3000 ❌ (실패)
Note over App: 우회 경로 선택<br/>http://host.docker.internal:3000/content
B->>NAT: GET host.docker.internal:3000
NAT->>App: 포트 매핑으로 전달
App-->>B: HTML/CSS/JS
B->>B: 렌더링 + PDF 생성
B-->>App: PDF 바이너리
App-->>Browser: PDF 응답위와 같은 경로로 전달된다. 당연히 로컬에서 docker를 통해 테스트를 하기 때문에 host.docker.internal을 사용했지만, 실제 배포 환경에서는 저런 방식으로 우회해서 사용하는게 아니라 명확한 주소를 매핑 시켜놓는 것이 좋다. 또한 만약 docker 정책이 container 자신의 주소가 172.17.0.2가 아닌 다른 주소가 된다해도 host.docker.internal를 사용하고 있으므로 로컬에서 테스트 용도로 적합하다고 생각했다.
Step 3. package.json
로컬 Docker 테스트는 빌드 -> 이미지 생성 -> 실행 을 매번 손으로 하면 바로 귀찮아진다. 한 줄로 끝나야 한다.
// package.json
{
"scripts": {
"local-docker.build": "env-cmd -f .env.local-docker next build",
"local-docker.start": "pnpm local-docker.build && docker build -f ./docker/Dockerfile.local -t my-next-app:local --platform linux/amd64 . && docker run --rm -p 3000:3000 my-next-app:local"
// ...
}
}세부 설명
local-docker.build
.env.local-docker환경변수를 사용하여 Next.js 빌드env-cmd -f플래그로 특정 환경 파일 지정
local-docker.start
- 전체 프로세스를 한 번에 실행하는 통합 스크립트
- Next.js 빌드 -> Docker 이미지 생성 -> 컨테이너 실행
-platform linux/amd64: M1/M2/M3 Mac에서도 호환성 보장-rm: 컨테이너 종료 시 자동 삭제
이제 아래 명령어 한 줄로 현재 작성된 Next.js 코드를 로컬에서 docker container로 띄운 환경에 접속 가능하다.
$ pnpm local-docker.start
로컬 Docker에서도 완전히 같지는 않다
PDF 결과물의 폰트 및 간격이 amd64로 빌드 했을 때 달라져 꽤 오랜시간 애를 먹었다. 원인은 docker build 할 때 platform을 설정했기 때문이었다.
--platform linux/amd64 같은 경우 EKS Pod 환경이 linux/amd64 이기 때문에 플랫폼을 맞춰주기 위해서 넣은 것이다. 하지만, mac에서 amd64 플랫폼으로 동작하지만, PDF를 브라우저에 렌더링 했을 경우 폰트 및 간격이 실제 EKS에 배포했을 때와는 다르다. 이유는 Mac은 arm64 환경이기 때문이다.
따라서 --platform 옵션은 실제 ECR에 올릴 이미지를 생성할 때만 넣어주어야 한다.
그래서 로컬 검증은 기능 동작 여부 중심으로 하고, 결과물의 픽셀 단위 동일성은 실제 아키텍처 amd64(Mac)에서 최종 확인하는 방식으로 했다.
Part 2: ECR 베이스 이미지 작성 및 배포 프로세스
로컬에서 Playwright 이미지에 Next.js 빌드 결과물을 Docker Container로 띄웠다면, 이제 실제 EKS에 배포될 이미지의 기본값이 될 Playwright 베이스 이미지를 만들어야 한다.
Playwright는 단순한 npm 패키지가 아니다.
- 브라우저 바이너리
- OS 종속성
- 폰트, locale, timezone
- Chromium 버전
이 모든 것이 베이스 이미지에 강하게 결합되어 있다. 즉, 베이스 이미지가 흔들리면 PDF 결과물도 같이 흔들린다. Playwright 베이스 이미지를 직접 만들고, ECR에 올리고, 그 버전만 사용하도록 한다.
※ Playwright 버전
기존에 playwright@1.41.1을 사용하고 있었지만, 보안 취약점이 발견되어 최신 버전의 이미지(1.57.0)를 사용해야 했다. npm package에서 Playwright 버전이 올라가면 Playwright docker 이미지도 버전이 올라가야 한다. 만약 npm Playwright 버전과 Playwright 이미지의 버전이 맞지 않는다면 헤드리스 브라우저가 실행되지 않는다.
따라서 FE에서 npm을 통해 Playwright를 업데이트하면 당연히 베이스 이미지도 업데이트 해야 한다.
위에서 봤듯, FE 배포 프로세스를 보면 아래와 같다.
graph LR
A[developer] -->|FE commit push| B
B[GitHub] --> C(Actions Runner)
C -->|Playwright 베이스 이미지 요청| D[(ECR)]
D -->|Playwright 베이스 이미지 응답| C
C -->|베이스 이미지에 Next.js Artifact ㅊ추가| F[EKS]배포때 GitHub Actions에서 ECR로 Playwright 베이스 이미지를 요청하게 된다. 저 베이스 이미지를 로컬 PC에서 만들고 내부망으로 이동시켜 ECR에 푸시하도록 한다.
graph LR
A[로컬 빌드] --> B(tar 압축)
B --> C[내부망 이동]
C --> D[(ECR 푸시)]Step 1. docker/Dockerfile.base (신규 생성)
- Playwright 공식 이미지를 기반,
- 한국 시간대 / 한글 + 이모지 폰트 / HTTPS APT / 안정적인 apt-get 패턴 / 이미지 용량 정리를 설정하고
- 마지막에 비루트 계정(pwuser) 로 내려서 브라우저와 Node.js 서버를 구동하는, PDF/스크린샷 생성에 최적화된 베이스 이미지를 만든다.
# docker/Dockerfile.base
# 골든 베이스: Playwright + 폰트/타임존
FROM mcr.microsoft.com/playwright:latest
USER root
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Seoul
# ── 견고한 APT 패턴: HTTPS 미러, 인덱스 초기화, 재시도, 한 RUN에서 update→install→clean ──
RUN set -eux; \
# 0) 미러 HTTPS로 고정(사내 미러 쓰면 여기서 sources.list 교체)
sed -i 's|http://archive.ubuntu.com|https://archive.ubuntu.com|g' /etc/apt/sources.list; \
sed -i 's|http://security.ubuntu.com|https://security.ubuntu.com|g' /etc/apt/sources.list; \
# 1) 인덱스 초기화 + 재시도 옵션
rm -rf /var/lib/apt/lists/*; mkdir -p /var/lib/apt/lists/partial; \
echo 'Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries; \
# 2) 타임존 사전 지정(프롬프트 방지)
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; echo "$TZ" > /etc/timezone; \
# 3) 업데이트
apt-get update -o Acquire::http::No-Cache=true -o Acquire::http::Pipeline-Depth=0; \
# 4) 필요한 패키지 일괄 설치(폰트 + tzdata + 인증서)
apt-get install -y --no-install-recommends \
fonts-noto-cjk \
fonts-noto-color-emoji \
tzdata \
ca-certificates \
; \
# (환경에 따라) tzdata 재설정도 비대화식으로 강제
dpkg-reconfigure -f noninteractive tzdata; \
# 5) 클린업
rm -rf /var/lib/apt/lists/*
# 권장: 기본 유저를 Playwright의 pwuser로
USER pwuser세부 설명
FROM mcr.microsoft.com/playwright:latest
- 브라우저 설치, OS 의존성, 디스플레이 관련 설정 등을 직접 신경쓰지 않아도 됨.
- Playwright가 기대하는 디렉토리 구조, 유저, 퍼미션이 맞춰져 있음.
- 즉, Node.js 서버 + 브라우저까지 설치 되어 있는 OS
ENV TZ=Asia/Seoul
- 컨테이너 기본 타임존을 한국 시간으로 맞춘다.
- PDF 생성 시각/로그 타임스탬프/서버 시간 해석이 로컬/서버에서 어긋나는 문제를 줄인다.
apt-get update ...
No-Cache,Pipeline-Depth=0같은 옵션으로 네트워크/프록시 환경에서 실패율을 낮추려는 설정.
apt-get install -y --no-install-recommends ...
- 필요한 것만 최소 설치.
- 설치 항목 의미:
fonts-noto-cjk: 한글(CJK) 폰트. PDF에서 한글 깨짐 방지fonts-noto-color-emoji: 컬러 이모지 렌더링tzdata: 타임존 데이터ca-certificates: HTTPS 통신(내부/외부) 인증서 신뢰 기반
dpkg-reconfigure -f noninteractive tzdata
- tzdata 설치 후 재설정도 비대화식으로 강제 시킴.
- 빌드 환경에 따라 타임존 적용이 안되는 것을 막음.
rm -rf /var/lib/apt/lists/*
- 레이어 용량을 줄이기 위해 apt-get으로 설치한 라이브러리 리스트 제거.
- 이미지 사이즈 감소 + 캐시 오염 방지 목적.
Step 2. 베이스 이미지 빌드 (로컬)
package.json에 스크립트를 생성하자.
{
"scripts": {
// ...
"docker.build-base:amd64": "docker build -f ./docker/Dockerfile.base -t [...].dkr.ecr.ap-northeast-2.amazonaws.com/common/playwright:latest-with-font --platform linux/amd64 ."
}
}build -f ./docker/Dockerfile.base:./docker/Dockerfile.base파일을 사용해 빌드-t [...].dkr.ecr.ap-northeast-2.amazonaws.com/common/playwright:latest-with-font: ECR 리포지토리:common/playwright주소 설정과 태그를 붙임--platform linux/amd64: ECR은 linux/amd64이므로 플랫폼 강제 고정
$ pnpm docker.build-base:amd64
Step 3. 베이스 이미지를 tar 파일로 고정
docker 이미지를 내부망으로 들이기위해 tar 파일로 저장한다.
$ docker save [...].dkr.ecr.ap-northeast-2.amazonaws.com/common/playwright:latest-with-font -o playwright-base.tarStep 4. 내부망으로 전송 및 ECR push
내부망으로 들일 때는 tar로 압축해서 보내면 리소스를 아낄 수 있다.
$ gzip playwright-base.tar
Step 5. 내부망에서 ECR 푸시
$ gunzip playwright-base.tar.gz압축을 푼다.
$ docker load -i playwright-base.tar
$ docker images | grep playwrightdocker 이미지를 내부망 컴퓨터에 로드시킨 후, docker images 명령어로 이미지가 제대로 로드 되었는지 확인한다.
그다음 PC에서 aws cli로 ECR에 로그인 후, push 하면 된다.
결과 확인
이제 FE에서 Playwright 버전(1.57.0)을 업데이트하고 commit 후 push해서 GitHub Actions가 돌게 한 후, 배포를 기다려보자. 완료 이후 PDF를 출력해보니 문제가 생겼다.
Next.js가 실행되는 Node.js 서버 Playwright 버전은 1.57.0 버전이지만, 이미지 버전은 여전히 1.46.1 버전이라는 에러 문구다.
어떻게 된건지 GitHub Actions의 Dockerfile을 보자.
FROM [...].dkr.ecr.ap-northeast-2.amazonaws.com/common/playwright:latest-with-font AS base
# ...ECR에 push 했을 때 분명 새로운 이미지로 교체 되었는데 여전히 새로운 이미지로 빌드되지 않았다. 문제는 태그였는데, 새로운 이미지의 태그도 latest-with-font 였고, docker build --pull 명령어가 아닌 docker build로만 실행되었기 때문에 기존에 있는 베이스 이미지로만 사용했기 때문에 문제가 되었다. 같은 태그를 쓰면 Docker는 로컬에 캐시된 이미지를 우선 사용해서, ECR에 새 이미지를 올려도 빌드가 그대로 지난 이미지가 사용된다.
태그를 timestamp 기반으로
새로운 이미지는 새로운 태그로 대체하기로 했다. docker build --pull 옵션을 주면 docker 이미지 digest가 변경되었다면 새로 pull 받아 이미지를 생성한다. 하지만, 사람이 알아보는 것은 쉽지 않다. 따라서 현재 빌드 시점을 태깅해서 ECR로 push 하고, 빌드 시 해당 태그의 이미지를 사용하도록 한다.
이렇게 하면 Docker cache가 무력화되고 FROM 단계에서 반드시 새 이미지를 사용하게 된다.
# scripts/build-and-update-base.sh
#!/bin/bash
set -e
# Timestamp 생성
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
VERSION_TAG="v${TIMESTAMP}"
# ECR 정보
ECR_REGISTRY="[...].dkr.ecr.ap-northeast-2.amazonaws.com"
IMAGE_NAME="common/playwright"
FULL_IMAGE="${ECR_REGISTRY}/${IMAGE_NAME}:${VERSION_TAG}"
echo "🚀 Docker 베이스 이미지 빌드 중..."
echo " Version: ${VERSION_TAG}"\
echo ""
# Docker 빌드
docker build \
-f ./docker/Dockerfile.base \
-t "${FULL_IMAGE}" \
--platform linux/amd64 \
--no-cache \
.
echo ""
echo "✅ Docker 이미지 빌드 완료: ${FULL_IMAGE}"
echo ""자동화 스크립트를 작성하고 package.json에서 명령어에 이 sh 파일을 실행시킨다.
"docker.build-base:amd64": "sh ./scripts/build-and-update-base.sh"하지만, GitHub Actions에서 도는 Dockerfile은 하드코딩으로 태그 값이 박혀있다. 조금 위험할 수도 있지만 이것도 자동화 해보자. Dockerfile FROM절에 방금 만든 timestamp로 태그 명을 대체하고 자동 commit을 생성하는 방법이다. 쉘 스크립트에 이어서 추가한다.
# scripts/build-and-update-base.sh 이어서
# ...
# Dockerfile 업데이트
DOCKERFILE_PATH="./docker/Dockerfile"
echo "📝 ${DOCKERFILE_PATH} 태그 업데이트 중..."
# FROM 절 업데이트 (sed를 사용하여 기존 태그를 새 버전으로 변경)
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
sed -i '' "s|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:.*AS base|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:${VERSION_TAG} AS base|g" "${DOCKERFILE_PATH}"
else
# Linux
sed -i "s|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:.*AS base|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:${VERSION_TAG} AS base|g" "${DOCKERFILE_PATH}"
fi
echo "✅ Dockerfile 업데이트 완료 (버전: ${VERSION_TAG})"
echo ""
# Git commit
echo "📦 변경사항 커밋 중..."
git add "${DOCKERFILE_PATH}"
git commit -m "chore: bump version docker golden image ${VERSION_TAG}"
echo ""
echo "🎉 완료!"이렇게 해주면 pnpm docker.build-base:amd64 한 줄로
- Playwright 최신 버전 이미지 pull
- Dockerfile.base에서 font, tz 등 작업
- 이미지 생성(태그 timestamp)
- ECR에서 pull 받아 작업할 Dockerfile FROM절에 timestamp 태깅
- Dockerfile commit
자동화가 완성되었다.
Playwright 버전 자동화
다시 돌아가보자. Playwright가 1.47.1 버전이 취약함에 따라 버전 업데이트가 필요했고, 베이스 이미지도 업데이트가 필요했으므로 일일이 버전 업데이트를 진행해주어야 한다. 일일이 변경해주기보다, package.json에 Playwright를 업데이트하면 그 버전 값을 보고 그 버전에 해당하는 Playwright docker 이미지를 base로 하도록 하자.
Dockerfile에서 ARG 를 이용해 외부에서 값을 받아올 수 있도록 하자.
# Dockerfile.base
ARG PLAYWRIGHT_VERSION
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-jammy
# ...그리고 로컬에서 docker 이미지 빌드, ECR에 배포할 이미지 빌드시 --build-arg 에 Playwright 버전 값을 넣어주도록 하자.
"local-docker.build-base": "docker build -f ./docker/Dockerfile.base -t application-base:local --build-arg PLAYWRIGHT_VERSION=$(node -p \"require('./package.json').dependencies.playwright\") ."$ node -p "require('./package.json').dependencies.playwright" 로 package.json을 참조하도록 한다.
이제 FE에서 npm playwright npm 패키지만 업데이트 하면 베이스 이미지는 자동으로 해당 버전에 맞는 이미지를 가져오도록 설정 되었다.
로컬 docker 이미지와 ECR에 배포할 베이스 이미지를 한 파일로
현재 로컬에서 테스트용 docker 이미지, ECR에 배포할 베이스 이미지를 만드는 Dockerfile은 다르지만 내용은 동일하게 맞춰주어야 로컬에서 테스트하는 의미가 있다.
따라서, 아래와 같이 베이스 이미지 생성 Dockerfile과 어플리케이션 전용 Dockerfile만 나누어 진행하도록 하자.
docker/
├── Dockerfile.base # 공통 베이스 이미지 - 로컬/프로덕션 모두 사용
├── Dockerfile.local # 로컬 테스트용 애플리케이션
└── Dockerfile # 프로덕션용 애플리케이션최종 Dockerfile이다.
# Dockerfile.base
# ============================================
# 골든 베이스: Playwright + 폰트/타임존
# ============================================
ARG PLAYWRIGHT_VERSION
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-jammy
# FROM 이후 ARG 재선언 (scope 유지)
ARG PLAYWRIGHT_VERSION
USER root
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Seoul
# ARG 검증: PLAYWRIGHT_VERSION이 비어있으면 빌드 실패
RUN test -n "${PLAYWRIGHT_VERSION}" || (echo "ERROR: PLAYWRIGHT_VERSION is required" && exit 1)
# 견고한 APT 패턴: HTTPS 미러, 인덱스 초기화, 재시도, 한 RUN에서 update→install→clean
RUN set -eux; \
# 0) 미러 HTTPS로 고정
sed -i 's|http://archive.ubuntu.com|https://archive.ubuntu.com|g' /etc/apt/sources.list; \
sed -i 's|http://security.ubuntu.com|https://security.ubuntu.com|g' /etc/apt/sources.list; \
# 1) 인덱스 초기화 + 재시도 옵션
rm -rf /var/lib/apt/lists/*; mkdir -p /var/lib/apt/lists/partial; \
echo 'Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries; \
# 2) 타임존 사전 지정(프롬프트 방지)
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; echo "$TZ" > /etc/timezone; \
# 3) 업데이트
apt-get update -o Acquire::http::No-Cache=true -o Acquire::http::Pipeline-Depth=0; \
# 4) 필요한 패키지 일괄 설치(폰트 + tzdata + 인증서)
apt-get install -y --no-install-recommends \
fonts-noto-cjk \
fonts-noto-color-emoji \
tzdata \
ca-certificates \
; \
# 5) tzdata 재설정도 비대화식으로 강제
dpkg-reconfigure -f noninteractive tzdata; \
# 6) 클린업
rm -rf /var/lib/apt/lists/*
USER pwuser# Dockerfile.local
# ============================================
# Application (베이스 이미지 참조)
# ============================================
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
WORKDIR /app
EXPOSE 3000
ENV PORT=3000
ENV HOST=0.0.0.0
COPY public ./public
COPY .next/standalone ./
COPY .next/static ./.next/static
USER root
RUN mkdir -p .next/cache && chown -R pwuser:pwuser .next
USER pwuser
ENTRYPOINT ["node", "server.js"]# Dockerfile
FROM [...].dkr.ecr.ap-northeast-2.amazonaws.com/common/playwright:v20251213... AS base
WORKDIR /app
EXPOSE 3000
ENV PORT 3000
ENV HOST 0.0.0.0
COPY public ./public
COPY .next/standalone ./
COPY .next/static ./.next/static
USER root
RUN mkdir -p .next/cache && chown -R pwuser:pwuser .next
USER pwuser
ENTRYPOINT ["node", "server.js"]// package.json
{
"scripts": {
"local-docker.build": "env-cmd -f .env.local-docker next build",
"local-docker.build-base": "docker build -f ./docker/Dockerfile.base -t application-base:local --build-arg PLAYWRIGHT_VERSION=$(node -p \"require('./package.json').dependencies.playwright\") .",
"local-docker.start": "pnpm local-docker.build-base && pnpm local-docker.build && docker build -f ./docker/Dockerfile.local -t application-local:local --build-arg BASE_IMAGE=application-base:local . && docker run --rm -p 3000:3000 application-local:local",
"docker.build-base:amd64": "sh ./scripts/build-and-update-base.sh"
}
}# scripts/build-and-update-base.sh
#!/bin/bash
set -e
# Timestamp 생성
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
VERSION_TAG="v${TIMESTAMP}"
# ECR 정보
ECR_REGISTRY="[...].dkr.ecr.ap-northeast-2.amazonaws.com"
IMAGE_NAME="common/playwright"
FULL_IMAGE="${ECR_REGISTRY}/${IMAGE_NAME}:${VERSION_TAG}"
# Playwright 버전 가져오기
PLAYWRIGHT_VERSION=$(node -p "require('./package.json').dependencies.playwright")
echo "🚀 Docker 베이스 이미지 빌드 중..."
echo " Version: ${VERSION_TAG}"
echo " Playwright: ${PLAYWRIGHT_VERSION}"
echo ""
# Docker 빌드
docker build \
-f ./docker/Dockerfile.base \
-t "${FULL_IMAGE}" \
--platform linux/amd64 \
--build-arg PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION}" \
--no-cache \
.
echo ""
echo "✅ Docker 이미지 빌드 완료: ${FULL_IMAGE}"
echo ""
# Dockerfile 업데이트
DOCKERFILE_PATH="./docker/Dockerfile"
echo "📝 ${DOCKERFILE_PATH} 태그 업데이트 중..."
# FROM 절 업데이트 (sed를 사용하여 기존 태그를 새 버전으로 변경)
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
sed -i '' "s|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:.*AS base|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:${VERSION_TAG} AS base|g" "${DOCKERFILE_PATH}"
else
# Linux
sed -i "s|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:.*AS base|FROM ${ECR_REGISTRY}/${IMAGE_NAME}:${VERSION_TAG} AS base|g" "${DOCKERFILE_PATH}"
fi
echo "✅ Dockerfile 업데이트 완료 (버전: ${VERSION_TAG})"
echo ""
# Git commit
echo "📦 변경사항 커밋 중..."
git add "${DOCKERFILE_PATH}"
git commit -m "chore: bump version docker golden image ${VERSION_TAG}"
echo ""
echo "🎉 완료!"마치며
환경 통제에 관련한 부분은 쉽지 않은 것 같다. 환경이 서로 다르기 때문에 개발 결과물과 배포 시 결과물이 서로 다를 때는 어떤 부분을 어떻게 접근해야하는지 때로는 감이 오지 않는 경우가 많다. 이번 기회에 docker로 로컬에서 배포 환경과 최대한 비슷하게 환경을 맞추고 어떤 부분에서 문제가 생겼는지 디버깅 하면서 인프라에 대한 지식을 조금이나마 얻을 수 있었던 것 같다.
덤으로, 이 구조의 단점은 이미지가 크다는 점이다. 로컬에서 확인하기 위해 만들어지는 이미지는 총 2개로, Playwright 베이스 이미지와 베이스 이미지에 Next.js 빌드 결과물을 COPY 한 이미지(application 이미지) 총 2가지인데, 각각 3.5GB, 3.79GB이다. 그리고 ECR에 push할 이미지는 802MB다. 이는 Playwright의 이미지 자체가 alpine처럼 최소화 한 이미지가 아닌, ubuntu 기반의 이미지라서인데 Playwright 공식 문서에서 Docker 커스텀 이미지 생성 방법도 제안하고 있다. 단, Node.js, Playwight 브라우저 바이너리, OS 종속성을 직접 설치하는 방법이다. 이 방법을 통해 추후에 이미지 용량을 최적화해볼 수 있을 것 같다.
그리고 작업을 하면서 Next.js가 동작할 Application 이미지에 굳이 Playwright 용 이미지 위에 올릴 필요는 없겠다는 생각도 들었다. 지금이야 하나의 어플리케이션이 Pod에 배포되는 구조이지만, PDF 렌더링만 담당하는 Node.js 서버를 따로 만들고 해당 서버에서 Next.js 페이지를 참조할 통로를 열고 Next.js -> Playwright PDF 렌더링 서버 -> Nest.js 리턴 하는 방법을 사용하면 서로를 격리시키면서 단일 책임만 가져가게끔 작업하면 좋겠다는 생각도 했다.
그리고 build-and-update-base.sh에서 이미지 버전을 자동으로 commit을 남겨주는 방법을 사용한건 GitHub Actions를 최대한 건들이고 싶지 않았는데, 충분히 다른 방법을 통해 개선할 수 있을 것 같다. 예를 들면, GitHub Actions에서 BASE_IMAGE_TAG를 입력값/환경변수로 받아 Dockerfile을 건드리지 않고 빌드하도록 만드는 방법이 있을 것 같다.

