website: 백엔드 도커 빌드에서 BUILDPLATFORM 고정으로 arm64 크로스 빌드 단축
CD 파이프라인의 "이미지 빌드 & 푸시" 단계가 11분 44초나 걸리는 걸 발견하고 원인을 추적한 끝에, `backend/Dockerfile`의 Gradle 빌더 스테이지를 `--platform=$BUILDPLATFORM`으로 고정해 QEMU 에뮬레이션을 걷어냈습니다.
요약
2026년 7월 23일, fix/cd-arm64-build-platform 브랜치에서 작업한 PR #158이 머지됐습니다. 커밋 메시지 그대로 "백엔드 도커 빌드 스테이지 BUILDPLATFORM 고정으로 arm64 크로스 빌드 단축"이 이번 작업의 핵심입니다. 변경된 파일은 backend/Dockerfile과 pm/docs/learnings.md 두 개뿐이고, 추가 5줄·삭제 1줄로 코드 자체는 아주 작은 변경이었습니다. 하지만 배포 속도에 실질적인 영향을 주는 수정이었고, 그 과정에서 얻은 인사이트를 learnings.md에 상세히 기록해뒀습니다.
배경 및 목적
OCI(오라클 클라우드) 서버가 arm64 아키텍처라, GitHub Actions 러너(amd64)에서 docker buildx로 크로스 빌드를 해야 하는 구조였습니다. 문제는 CD 워크플로의 platforms: linux/arm64 설정이 Dockerfile 전체 — 즉 컴파일 스테이지까지 — 를 arm64로 묶어버렸다는 점입니다. 그 결과 Gradle 빌더 스테이지의 compileJava까지 QEMU arm64 에뮬레이션 위에서 돌고 있었고, 이게 배포 시간 대부분(11분 44초)을 잡아먹는 원인이었습니다.
여기서 짚고 넘어가야 할 지점은 "왜 컴파일까지 arm64로 묶였는가"입니다. Java는 네이티브 기계어가 아니라 JVM 바이트코드로 컴파일되기 때문에, 어떤 CPU 위에서 컴파일하든 결과물(class 파일)이 동일합니다. 즉 컴파일 단계는 굳이 타깃 아키텍처에 묶일 이유가 없는데, 관성적으로 전체 스테이지를 같은 플랫폼으로 지정해버린 게 문제였습니다.
구현 내용
해결 방법은 멀티스테이지 Dockerfile에서 빌더 스테이지만 빌드 머신 네이티브 아키텍처에 고정하는 것이었습니다.
FROM --platform=$BUILDPLATFORM gradle:... AS builder
이렇게 빌더 스테이지에 --platform=$BUILDPLATFORM을 명시하면, GitHub Actions 러너(amd64)의 네이티브 아키텍처로 컴파일이 돌아가면서 QEMU 에뮬레이션을 완전히 피하게 됩니다. 반면 최종 런타임 스테이지는 플랫폼 지정을 그대로 둬서 buildx의 타깃(arm64)을 그대로 따라가도록 했습니다. 이렇게 하면 최종 이미지는 여전히 arm64/linux로 나오지만, CPU를 많이 쓰는 컴파일 작업만 네이티브 속도로 처리됩니다.
로컬에서 재현 검증도 진행했는데, 최종 이미지 아키텍처는 arm64/linux로 유지됐고 compileJava는 에뮬레이션 없이 정상 속도(~72초)로 완료됐습니다. sqlite-jdbc처럼 네이티브(JNI) 의존성이 있는 라이브러리도 실행 시점 JVM 기준으로 골라지기 때문에 크로스 컴파일에 영향이 없다는 것도 확인했습니다.
변경 파일은 다음 두 개입니다.
backend/Dockerfile: 빌더 스테이지에--platform=$BUILDPLATFORM추가pm/docs/learnings.md: 이번 트러블슈팅 과정을 기록
기술적 의사결정
왜 --platform=$BUILDPLATFORM을 선택했는가. 대안으로는 GitHub Actions 러너 자체를 arm64로 바꾸거나(비용/설정 부담), 아예 크로스 빌드를 포기하고 서버에서 직접 빌드하는 방법(배포 자동화 포기)도 있었을 겁니다. 하지만 Dockerfile 한 줄 수정으로 문제의 근본 원인(컴파일이 불필요하게 에뮬레이션 위에서 도는 것)을 정확히 겨냥할 수 있었기 때문에 가장 비용 대비 효과가 좋은 선택이었습니다.
다만 이 해법이 모든 컴파일형 언어에 그대로 적용되는 건 아니라는 점도 기록해뒀습니다. Java나 다른 바이트코드 기반 언어는 컴파일 결과물이 CPU 아키텍처에 무관하지만, Go나 Rust처럼 네이티브 기계어로 컴파일되는 언어는 타깃 아키텍처를 위한 별도의 크로스컴파일 플래그(GOARCH, --target 등)가 필요합니다. 즉 "런타임 이미지만 타깃 아키텍처면 된다"는 원칙은 언어별로 적용 방식이 다르다는 걸 짚어둔 셈입니다.
배운 점 및 개선점
이번 작업으로 도커 멀티스테이지 빌드에서 "어떤 스테이지가 실제로 타깃 플랫폼을 필요로 하는가"를 스테이지 단위로 따져봐야 한다는 걸 다시 확인했습니다. platforms: linux/arm64 같은 워크플로 레벨 설정을 아무 생각 없이 Dockerfile 전체에 상속시키면, CPU-bound 작업이 불필요하게 에뮬레이션 위에서 돌 수 있다는 걸 실제 배포 시간 11분 44초로 체감했습니다.
같이 작업하면서 남긴 다른 메모들도 흥미로웠습니다. 도커 기본 로그 드라이버(json-file)는 컨테이너 쓰기 레이어에 저장되기 때문에 재배포하면 로그가 통째로 사라진다는 점 — stage 환경에서 400 에러 스택트레이스를 조사하려다 이미 재배포가 지나가서 로그가 날아간 경험에서 나온 교훈입니다. ./data:/app/data처럼 DB는 볼륨으로 영속화하면서 로그는 놓치는 게 흔한 실수라, 앞으로는 logging.file.name으로 파일 로깅을 켜고 호스트에 바인드 마운트하는 작업도 이어서 진행할 계획입니다(infra/logging.md 참고).
또한 infra/** 경로 단독 변경은 CD 파이프라인의 paths 필터(backend/**, shared/**만 감시)에 걸리지 않아 자동 배포가 안 된다는 점, 그리고 새 볼륨 마운트를 추가할 때 .gitignore 처리를 빼먹으면 git-drift 알람이 오탐한다는 점도 다음 작업에서 유의해야 할 부분으로 남겨뒀습니다.