배포 직전, 개발자가 슬랙에 "빌드 깨졌어요"라고 올립니다. 잠시 뒤 "로컬에선 되는데 CI에서만 안 돼요"가 따라옵니다. PM인 당신은 일정이 밀릴지 가늠해야 하고, 인프라 담당인 당신은 "서버에 뭘 더 깔아야 하나"를 판단해야 합니다. '빌드'가 정확히 무엇을 하는 단계인지, '런타임'과 어떻게 다른지 모르면 이 한 줄짜리 알림 앞에서 아무 판단도 할 수 없습니다.
- 1컴파일 언어와 인터프리터 언어의 차이를 "언제 번역하는가"로 설명할 수 있다
- 2"빌드"가 소스 코드를 배포 가능한 산출물로 바꾸는 과정임을 설명할 수 있다
- 3빌드 산출물(JAR/WAR/번들/바이너리)과 런타임 버전을 짝으로 확인할 수 있다
- 4"로컬은 되는데 CI는 깨진다"의 원인을 환경 차이 관점에서 진단할 수 있다
컴파일 vs 인터프리터 — 언제 번역하는가
번역 시점의 차이: 미리 vs 실행하면서
"인터프리터 언어라 느리다", "컴파일 에러가 났다" — 이 말을 이해하려면 코드가 언제 기계가 읽는 형태로 번역되는가를 봐야 합니다.
- 컴파일 언어(Go, C++, Rust, Java*): 실행 전에 전체 코드를 기계어나 바이트코드로 번역(컴파일)합니다. 번역 중 문법·타입 오류를 미리 잡습니다 → "컴파일 에러". 실행은 빠르지만 빌드 단계가 필요합니다.
- 인터프리터 언어(Python, Ruby, JavaScript): 실행 하면서 한 줄씩 해석합니다. 빌드 없이 바로 돌릴 수 있지만, 일부 오류는 그 줄에 도달해야 드러납니다.
*Java/Kotlin은 '바이트코드로 컴파일 → JVM이 실행 시 해석/JIT'하는 중간형입니다. 그래서 "컴파일도 하고 JVM 런타임도 필요"합니다.
PM 관점: 컴파일 언어는 빌드 단계가 게이트가 되어 배포 전에 일정 부류의 버그를 차단합니다. 인터프리터 언어는 반복이 빠른 대신 런타임 에러 비중이 높아 테스트가 더 중요해집니다.
확대
위 그림처럼 컴파일 언어는 실행 전에 전체를 번역해 빌드 단계에서 오류를 차단하고, 인터프리터 언어는 실행 시점에 줄 단위로 해석해 빌드 없이 바로 실행됩니다.
빌드 — 소스를 '배포 가능한 것'으로
빌드: 사람이 쓴 코드를 기계가 실행할 산출물로 변환
"빌드 깨졌다"의 빌드는 소스 코드 → 실행/배포 가능한 산출물(artifact) 변환 과정입니다. 보통 이런 단계를 포함합니다.
확대
산출물의 형태는 스택마다 다릅니다:
| 스택 | 빌드 산출물 | 배포 시 필요한 런타임 |
|---|---|---|
| Java/Spring | app.jar 또는 app.war | JVM(JRE/JDK) 동일 버전 |
| 프론트엔드(React 등) | 정적 번들(dist/의 JS/CSS/HTML) | 정적 웹서버(Nginx) 또는 CDN |
| Go | 단일 실행 바이너리 | (런타임 내장 — 별도 설치 불필요) |
| Python | (대개 빌드 없이 소스 배포) | Python 인터프리터 + 패키지 |
인프라 관점의 핵심: "무엇을 빌드해서(JAR?) 무엇 위에서 실행하는가(JVM 17?)"를 한 쌍으로 확인하면 배포 준비물이 정확해집니다.
위 그림처럼 빌드는 의존성 설치 → 컴파일/트랜스파일 → 번들/패키징 단계를 거쳐 스택마다 다른 형태의 산출물(JAR·번들·바이너리)을 만들고, 각 산출물은 그에 맞는 런타임을 요구합니다.
빌드 산출물과 런타임 짝 확인 — 직접 보기
배포 대상 서버(또는 컨테이너)의 런타임 버전과, 빌드 산출물이 요구하는 버전이 맞는지 확인합니다. 불일치는 배포 실패의 단골 원인입니다.
# 서버에 설치된 자바 런타임 버전
java -version
# 빌드 산출물(JAR)이 어떤 자바 버전으로 컴파일됐는지(메이저 버전)
# 55=Java11, 61=Java17, 65=Java21
javap -verbose -cp app.jar com.example.Main | grep "major version"
# Node 프로젝트라면: 요구 런타임이 package.json engines에 명시되곤 함
node -v && cat package.json | grep -A2 '"engines"'
openjdk version "11.0.22" ← 서버는 Java 11
major version: 61 ← 그런데 산출물은 Java 17로 빌드됨 → 불일치!
java -version- java -version의 버전과 산출물 major version을 비교한다 — 산출물 버전이 서버보다 높으면 UnsupportedClassVersionError로 실행 실패. 같거나 서버가 높아야 안전
- major version 매핑: 55=Java11, 61=Java17, 65=Java21. 산출물 61인데 서버 11(major 55)이면 즉시 런타임 업그레이드 또는 재빌드 필요
- Node는 package.json의 engines.node 범위를 본다 — 서버 node -v가 그 범위 밖이면 설치 단계나 실행에서 경고/실패
- 버전이 맞는데도 안 되면 빌드 산출물 자체가 최신인지(오래된 JAR 배포) 확인 — 빌드 시각/커밋 해시를 대조한다
확대
위 그림처럼 빌드 환경(Java 17)과 배포 서버 런타임(Java 11)이 다르면 UnsupportedClassVersionError로 즉시 실패합니다. 컨테이너 베이스 이미지에 버전을 명시해 빌드·런타임을 한 곳에서 고정하는 것이 재발 방지 핵심입니다.
상황: 새 버전을 배포했더니 애플리케이션이 시작조차 못 하고 위 에러로 죽습니다. 개발팀은 "로컬·CI에선 됐다"고 합니다.
원인: 빌드는 Java 17로 했는데 배포 서버의 런타임이 Java 11입니다. JVM은 자신보다 높은 버전으로 컴파일된 바이트코드를 실행할 수 없습니다. 로컬·CI엔 17이 깔려 있어 통과했던 것입니다.
진단:
java -version # 서버 런타임
javap -verbose -cp app.jar Main | grep "major version" # 산출물 요구 버전
서버 major(55=11)보다 산출물 major(61=17)가 높으면 확정입니다.
해결: 둘 중 하나로 버전을 맞춥니다. (1) 서버/베이스 이미지의 런타임을 17로 올린다, 또는 (2) 빌드 타깃을 11로 낮춰 재빌드한다. 컨테이너라면 베이스 이미지 태그를 eclipse-temurin:17처럼 명시해 빌드·런타임을 한곳에서 고정하는 것이 재발 방지책입니다.
심화 — 버전이 맞아도 안 도는 이유
심화: 아키텍처·libc·JIT — 산출물 호환성의 세 번째 축
런타임 버전을 짝 맞추는 법을 익혔다면, 다음은 버전이 완전히 같은데도 실행이 실패하거나 느려지는 층을 보는 것입니다. 산출물은 런타임 버전 아래의 더 낮은 층 — CPU 아키텍처와 시스템 라이브러리에도 묶여 있습니다.
- CPU 아키텍처: 'Go는 런타임 내장이라 어디서나 돈다'의 어디서나에는 조건이 있습니다 — 같은 아키텍처. ARM 맥(애플 실리콘)에서 빌드한 바이너리·컨테이너 이미지는 x86_64 서버에서 실행되지 않습니다. 개발 장비는 ARM으로, 서버는 x86과 ARM(Graviton 등) 혼재로 가면서, 아키텍처 불일치는 버전 불일치만큼 흔한 배포 실패가 됐습니다. CI에서 타깃 아키텍처를 지정해 빌드하거나 멀티아키텍처 이미지를 만듭니다.
- 같은 버전, 다른 libc: 이미지를 줄이려 alpine 베이스를 쓰면 표준 C 라이브러리가 glibc가 아니라 musl입니다. Python 휠·Node 네이티브 모듈처럼 C로 컴파일된 의존성은 glibc 기준으로 배포되는 경우가 많아, 런타임 버전이 똑같아도 alpine에서만 깨질 수 있습니다. '버전이 같은데 왜 안 되지'의 단골 범인이라, 베이스 이미지 교체는 버전 유지라도 검증이 필요한 변경입니다.
- JIT 워밍업: JVM 앱은 시작 직후엔 바이트코드를 해석 실행하다가, 자주 도는 코드부터 JIT 컴파일해 점점 빨라집니다. 그래서 배포·스케일아웃 직후의 새 인스턴스는 몇 분간 평소보다 느립니다 — 오토스케일이 '느리니까 더 늘리자'로 오판하면 느린 새 인스턴스만 쌓입니다. 배포 직후의 지연 상승은 버그가 아니라 워밍업일 수 있고, 트래픽을 서서히 넘기는 완화가 필요합니다.
정리하면 산출물의 호환성 축은 세 개입니다 — 런타임 버전, CPU 아키텍처, 시스템 라이브러리. 배포 실패를 진단할 때 이 셋을 차례로 확인하면 '로컬은 되는데'의 대부분이 풀립니다.
상황: 급한 수정이라 개발자가 자기 노트북에서 이미지를 빌드해 레지스트리에 올리고 곧장 서버에 배포했습니다. 컨테이너는 뜨자마자 exec /app/server: exec format error 한 줄을 남기고 재시작을 반복합니다. 같은 이미지가 로컬에선 정상 동작합니다.
원인: 노트북이 ARM(애플 실리콘)이라 이미지가 ARM용으로 빌드됐고, 서버는 x86_64입니다. exec format error는 커널이 실행 파일의 기계어 형식 자체를 읽지 못한다는 뜻으로, 코드·런타임 버전 문제가 아니라 아키텍처 불일치의 전형적 신호입니다. '로컬에선 된다'가 성립한 이유는 단순합니다 — 로컬이 곧 빌드한 아키텍처이기 때문입니다.
진단: 서버에서 uname -m으로 서버 아키텍처(x86_64)를 확인하고, docker inspect 이미지명의 Architecture 필드로 이미지 아키텍처(arm64)를 비교합니다. 둘이 다르면 확정입니다.
해결: 서버 아키텍처에 맞춰 다시 빌드합니다 — 급한 불은 docker buildx build --platform linux/amd64처럼 타깃을 명시해 끄고, 근본적으로는 로컬 빌드 배포를 금지하고 CI에서 빌드하는 원칙으로 되돌립니다(CI 러너의 아키텍처가 곧 통제된 타깃이 됩니다). ARM·x86 서버가 혼재된 환경이라면 멀티아키텍처 이미지를 만들어 레지스트리가 아키텍처별로 맞는 이미지를 내려주게 합니다. '어디서 빌드했는가'는 편의 문제가 아니라 산출물의 실행 가능 여부를 결정합니다.
인프라 엔지니어로서 컨테이너 베이스 이미지를 고를 때, 이 모듈의 지식이 곧바로 쓰입니다. 개발팀이 "Spring Boot, Java 17"이라고 하면 베이스 이미지를 eclipse-temurin:17-jre로 잡고, 산출물 app.jar를 복사해 java -jar app.jar로 실행하도록 Dockerfile을 구성합니다. 프론트엔드라면 "빌드 산출물은 정적 파일"이므로 Node 런타임이 아니라 Nginx로 서빙합니다 — 이 구분을 못 하면 정적 사이트에 불필요한 Node 서버를 띄우는 낭비가 생깁니다.
다음 모듈에서는 이렇게 빌드·실행되는 코드가 프론트엔드와 백엔드로 어떻게 나뉘고, 화면이 어디서 그려지는지(렌더링)를 다룹니다.
실전 랩으로 손에 익히기: 컴파일·빌드·런타임 실습 — 컴파일·빌드·런타임을 구분하고 '내 컴에선 되는데'를 진단합니다.
관련 모듈로 더 깊이:
- 프레임워크·라이브러리·런타임·SDK — 프레임워크·라이브러리·런타임·SDK 용어 정리
- 프론트엔드·백엔드·렌더링 — 빌드 산출물이 프론트/백엔드로 나뉘는 구조
- 기술스택 읽기 — 런타임·빌드 정보를 인프라 준비물로 읽는 법