[내가그린기린기록] EC2 한 대에 모았다 — Docker Compose 배포 삽질기

WonJun Jeon·2026년 8월 11일
post-thumbnail

서비스가 궁금하다면? 내가그린기린기록 서비스 사용해보기

S3 + CloudFront로 시작했는데, 왜 결국 Frontend, Backend, Nginx, PostgreSQL을 한 EC2에 올리게 되었을까?

배포의 목적은 인프라를 멋지게 만드는 것이 아니라, 사용자가 실제로 서비스를 사용할 수 있게 하는 것이다.

처음부터 EC2 한 대에 모든 것을 올릴 생각은 아니었다.

Frontend는 S3와 CloudFront로 분리하고, Backend만 EC2에서 실행하면 더 깔끔하고 효율적이라고 생각했다. 그런데 실제 배포를 시작하자 DNS, HTTPS, CloudFront 라우팅, OAuth 콜백, Next.js Static Export가 한꺼번에 얽히기 시작했다.

결국 한 번의 배포 문제는 Docker Compose로 이어졌고, Docker Compose는 다시 디스크 부족, Dockerfile, 빌드 캐시, EC2 리소스, GitHub Actions 자동 배포 문제로 이어졌다.

이 글은 특정 아키텍처를 정답으로 주장하는 글이 아니다. 당시 어떤 선택을 했고, 어디에서 막혔으며, 그 문제를 해결하면서 배포 구조가 어떻게 바뀌었는지를 기록한 글이다.

이 글의 흐름

S3 + CloudFront로 Frontend 분리
            ↓
DNS / TLS / 라우팅 / OAuth 문제
            ↓
“지금은 배포가 먼저다”
            ↓
EC2 + Docker Compose로 구조 단순화
            ↓
no space left on device
            ↓
EBS 증설 → Dockerfile 개선 → 빌드 단위 분리
            ↓
GitHub Actions + EC2 Self-hosted Runner

1. S3 + CloudFront로 시작하다

처음에는 S3 + CloudFront가 좋아 보였다

Frontend는 Next.js, Backend는 Spring Boot로 구성되어 있었다. Backend는 이미 EC2에서 동작하고 있었기 때문에, 처음에는 Frontend만 정적 배포하면 구조가 깔끔해질 것 같았다.

사용자
   ↓
CloudFront
   ├── /*       → S3 (Next.js Frontend)
   └── /api/*   → EC2 (Spring Boot Backend)

Frontend를 EC2에서 직접 실행하지 않아도 되니 서버 자원을 아낄 수 있고, 정적 파일과 API 서버의 책임도 분리할 수 있었다.

하지만 Next.js를 S3에 올리려면 일반적인 서버 실행과 다른 준비가 필요했다. Static Export를 사용하려면 정적으로 생성할 수 없는 Route나 서버 기능 사용 여부를 먼저 확인해야 했다.

Next.js
   ├── output: export
   ├── 정적으로 생성 가능한 Route 확인
   └── 서버 기능 사용 여부 확인
            ↓
          out/
            ↓
           S3
Next.js Static Export 결과

그림 1. Next.js Static Export 후 생성된 정적 파일

문제는 실제 도메인과 CloudFront를 연결하면서 시작됐다.

CloudFront 하나를 붙였는데 고려할 게 왜 이렇게 많지?

기존 Backend는 girinlog.site에서 동작하고 있었다. 이 도메인 자체를 CloudFront에 연결하면 API Origin이 다시 같은 주소를 바라보는 구조가 될 수 있었다.

그래서 Frontend와 Backend의 도메인을 나누었다.

Frontend 요청Backend 요청
girinlog.site → CloudFront → S3 → Next.js Frontendapi.girinlog.site → EC2 → Nginx → Spring Boot Backend

이제 하나의 문제를 해결할 때마다 다른 설정이 따라왔다.

  • 가비아 DNS
  • api.girinlog.site
  • EC2 Nginx
  • HTTPS와 Certbot
  • ACM 인증서
  • CloudFront Origin과 Behavior
  • OAC
  • GitHub OAuth Callback URL

가비아 DNS 레코드 설정

그림 2. DNS 레코드 하나를 추가하는 것부터 시작

처음에는 “Frontend 정적 파일을 S3에 올리고 CloudFront를 앞에 두자” 정도로 생각했다. 실제로는 DNS, TLS, CDN, API 라우팅, OAuth를 함께 이해해야 하는 작업이었다.

Certbot 인증서 상태 확인

그림 3. HTTPS를 붙이는 과정에서도 이미 발급된 인증서와 갱신 상태를 확인해야 했다.

CloudFront Origin 설정

그림 4. S3와 API 서버를 각각 Origin으로 구성한 CloudFront 설정

CloudFront에서 /api/* Behavior를 설정하는 화면

그림 5. 실제 설정 화면을 보면 /api/* 요청만 API Origin으로 보내도록 경로 패턴을 따로 지정해야 했다.

CloudFront 라우팅에서 계속 문제가 생겼다

CloudFront 주소로 접속했는데 어느 순간 401 Unauthorized가 발생했다. 처음에는 Backend 인증이나 OAuth 설정을 의심했다.

하지만 원인은 CloudFront의 Default Behavior였다. Default Behavior가 S3가 아니라 API Origin을 향하고 있었던 것이다.

내가 원했던 구조는 다음과 같았다.

S3로 보내야 하는 요청EC2로 보내야 하는 요청
/, /login, /app/api/*

그런데 실제 설정은 사실상 다음과 같았다.

// API Origin 설정
* → EC2

그러니 /에 접근해도 요청이 Spring Boot로 전달되었다. 인증되지 않은 요청이었기 때문에 화면에 들어가기도 전에 401이 반환됐다.

여기서 401과 403은 서로 다른 문제였다. / 요청이 API Origin으로 전달됐을 때는 401이 발생했고, /api가 /api/*에 매칭되지 않아 S3로 전달됐을 때는 403이 발생했다.

반대 방향의 문제도 있었다. /api/users/me처럼 긴 경로는 /api/*에 매칭되지만, /api 자체는 같은 패턴에 매칭되지 않았다.

/api/users/me 요청/api 요청
/api/users/me → /api/* 매칭 → EC2/api → /api/* 미매칭 → Default Behavior → S3 → 403

Next.js Route도 별도의 문제였다. Next 서버가 있다면 /login 요청을 알아서 처리할 수 있지만, S3에는 그런 서버가 없다. 실제 파일이 /login/index.html이라면 /login을 /login/index.html로 바꿔주는 Rewrite가 필요했다.

CloudFront Function, S3 Origin Path, OAC, 정적 경로 매핑까지 확인해야 했다. 문제를 하나 해결할 때마다 새로운 설정을 이해해야 하는 상황이었다.

CloudFront에서 API 요청이 403으로 반환된 화면

그림 6. 요청 경로, Origin, 응답 상태를 각각 확인

그때 질문이 바뀌었다.

이 구조를 끝까지 완성하는 것이 지금 프로젝트에서 가장 중요한 일인가?


2. 한 EC2에 모으고 한계를 마주하다

좋은 구조보다 지금은 배포가 먼저였다

S3 + CloudFront가 잘못된 구조라고 생각한 것은 아니다. 제대로 구성하면 충분히 좋은 구조였다.

다만 당시 프로젝트의 목표는 인프라 구조를 완벽하게 만드는 것보다 사용자가 실제 서비스에 접속할 수 있게 만드는 것이었다.

그래서 구조를 단순화했다.

BeforeAfter
사용자 → CloudFront →
S3(Frontend) + EC2(Backend)
사용자 → Nginx →
Frontend(Next.js) + Backend(Spring Boot) → PostgreSQL

Frontend, Backend, Nginx, PostgreSQL을 모두 Docker 컨테이너로 실행하고 Docker Compose로 관리하기로 했다.

EC2
├── nginx
├── frontend
├── backend
└── postgres

Next.js도 Static Export 대신 next start 기반 서버로 실행할 수 있었다.

Docker Compose로 실행 중인 서비스

그림 7. Frontend, Backend, Nginx, PostgreSQL을 하나의 EC2에서 실행한 모습

당시 선택을 한 문장으로 정리하면 이렇다.

좋아 보이는 아키텍처보다 지금 서비스에 필요한 아키텍처를 선택했다.

그런데 구조를 단순화하자 Docker라는 새로운 문제가 시작됐다.

docker compose up -d --build 한 방이면 끝날 줄 알았다

처음에는 다음 명령 하나면 충분할 것이라고 생각했다.

docker compose up -d --build

실제로 Docker Compose는 모든 일을 해주었다. 문제는 작은 EC2 한 대가 너무 많은 일을 동시에 처리하게 된다는 점이었다.

Backend 빌드Frontend 빌드
Gradle dependency → compile → bootJarnpm ci → next build
Docker
  └── BuildKit
  └── 이미지 레이어 생성

실행 중인 서비스
  ├── PostgreSQL
  └── Nginx

Backend와 Frontend를 동시에 빌드하면 CPU, 메모리, 디스크 사용량이 순간적으로 올라갔다. 운영 서버가 사용자 요청을 처리하면서 동시에 빌드 서버 역할까지 맡고 있던 셈이다.

그러다 Docker 빌드가 끝나지 않기 시작했다. 명령을 입력해도 진행되지 않았고, 로그에는 다음 오류가 남았다.

ResourceExhausted:
failed to copy files:
no space left on device

Docker 빌드 중인 터미널

그림 8. Frontend와 Backend 빌드가 동시에 진행되면서, 실행 로그가 멈춘 것처럼 보이는 순간도 있었다.

첫 번째로 확인한 범인은 디스크였다

Backend의 Gradle 빌드는 끝났고 Frontend의 npm ci도 실행됐다. 하지만 Docker 이미지 레이어를 만들기 위해 node_modules를 복사하는 과정에서 실패했다.

node_modules
      ↓
Docker Layer 생성
      ↓
no space left on device

EC2 상태를 다음 명령으로 확인했다.

df -h
df -i
docker system df

EC2 디스크와 Docker 사용량 확인

그림 9. df와 docker system df로 디스크, inode, Docker 사용량을 확인했다.

루트 파일시스템은 약 6.8GB였고, 4.7GB를 사용해 2.1GB 정도의 여유 공간이 남아 있었다. inode는 약 15%만 사용 중이었다.

여기서 의문이 생겼다.

2GB나 남아 있는데 왜 no space left on device가 발생하지?

docker system df를 확인하니 Docker 이미지 약 917MB 중 약 740MB가 reclaimable 상태였고, 빌드 캐시도 별도로 쌓여 있었다.

문제는 현재 남아 있는 디스크 용량과 빌드 순간 필요한 공간이 다르다는 점이었다. npm ci, Gradle 빌드, Docker 레이어 생성, 빌드 캐시가 같은 시점에 디스크를 사용한다.

반복된 빌드 뒤에는 다음과 같은 데이터가 남아 있었다.

Docker
├── images
├── build cache
├── dangling layer
├── stopped container
└── volume

사용하지 않는 리소스는 범위를 확인하며 정리했다.

docker buildx prune
docker builder prune
docker image prune
docker container prune

하지만 docker volume prune은 바로 실행하지 않았다. PostgreSQL도 Docker Volume을 사용하고 있었기 때문이다. Docker 정리 명령도 결국 무엇을 지워도 되는지 알고 실행해야 했다.

EBS를 8GB에서 20GB로 늘렸다

캐시를 정리해도 반복적인 빌드를 수행하기에는 루트 볼륨 자체가 너무 작았다. 당시 목표는 일단 서비스를 배포하는 것이었기 때문에 EBS 볼륨을 8GB에서 20GB로 늘렸다.

8GB
  ↓
Docker 빌드 실패
  ↓
EBS 볼륨 확장
  ↓
20GB
  ↓
배포 성공

20GB로 늘린 뒤에는 배포가 성공했다. 하지만 곧 다음 질문이 남았다.

다음에는 20GB가 차면 40GB로 늘리면 되는 걸까?

용량을 늘리는 것은 필요한 대응이었지만 원인을 해결한 것은 아니었다.

8GB  → 부족
20GB → 언젠가 부족
40GB → 언젠가 또 부족

그래서 이번에는 Frontend 이미지가 왜 그렇게 많은 공간을 사용하는지 Dockerfile을 다시 보기로 했다.

Dockerfile을 다시 보니 node_modules가 보였다

Frontend Dockerfile은 멀티 스테이지 빌드를 사용하고 있었다. 처음에는 멀티 스테이지라는 이유만으로 충분히 최적화되어 있다고 생각했다.

하지만 자세히 보니 node_modules가 Stage 사이에서 반복해서 복사되고 있었다.

deps
  └── node_modules 생성
          ↓ COPY
builder
  └── node_modules 존재
          ↓ COPY
runner
  └── node_modules 존재

문제가 발생한 지점도 다음과 같은 COPY였다.

COPY --from=deps /app/node_modules ./node_modules

그래서 Next.js의 standalone 빌드를 적용했다.

output: "standalone"

Standalone 빌드를 사용하면 실행에 필요한 파일만 별도로 추출할 수 있다. 최종 Runner 이미지에 전체 node_modules를 넣는 대신 필요한 런타임 파일을 가져가는 방식이다.

BeforeAfter
Runner 이미지에 Next 빌드 결과와 전체 node_modules 포함Runner 이미지에 .next/standalone, .next/static, public만 포함

당시 최종 standalone 산출물은 약 38MB 수준까지 줄었다. 하지만 여기서 끝이 아니었다.

이미지가 작아졌는데 왜 또 ENOSPC가 발생하지?

Standalone을 적용했으니 이제 괜찮을 것이라고 생각했다. 그런데 다시 빌드를 수행하자 다음 오류가 나타났다.

npm error errno -28
npm error nospc
ENOSPC: no space left on device

처음에는 이해하기 어려웠다.

이미지를 줄였는데 왜 또 디스크가 부족하지?

여기서 최종 Docker 이미지의 크기와 Docker 이미지를 만드는 동안 필요한 공간은 서로 다른 문제라는 것을 알게 됐다.

Standalone은 런타임 이미지를 줄여준다. 하지만 빌드 과정에서는 여전히 다음 작업이 필요하다.

npm ci
   ↓
node_modules 생성
   ↓
next build
   ↓
빌드 결과 생성
   ↓
Docker Layer 생성

Backend도 마찬가지다.

Gradle dependency
      ↓
    compile
      ↓
    bootJar
      ↓
  Docker Layer

즉 다음 두 문장은 동시에 참일 수 있다.

  • 최종 실행 이미지는 작아졌다.
  • 빌드 순간에는 여전히 디스크와 메모리가 부족하다.

이때부터 문제를 “이미지를 얼마나 줄일까?”에서 “빌드 책임을 어디에 둘까?”로 확장해서 생각하게 됐다.

모든 서비스를 매번 다시 빌드할 필요가 있을까?

Dockerfile을 개선한 뒤에도 docker compose up -d --build가 모든 서비스를 함께 다룬다는 문제는 남아 있었다.

Frontend 코드 하나를 수정했는데 Backend와 PostgreSQL까지 다시 배포할 이유는 없었다. 그래서 빌드와 컨테이너 교체 단위를 서비스별로 줄였다.

변경된 것빌드컨테이너 교체
Frontenddocker compose build frontenddocker compose up -d --no-deps frontend
Backenddocker compose build backenddocker compose up -d --no-deps backend
Nginx 변경 없음하지 않음하지 않음
PostgreSQL 변경 없음하지 않음하지 않음

전체 흐름은 다음처럼 바뀌었다.

Frontend 변경		|		Backend 변경
      ↓          	|			↓
frontend만 빌드		|		backend만 빌드
      ↓				|			↓
frontend만 교체		|		backend만 교체

Docker Compose는 여러 서비스를 함께 정의하는 도구이지, 모든 배포에서 반드시 모든 서비스를 다시 빌드해야 한다는 뜻은 아니었다.

한 번은 EC2 자체가 먹통이 됐다

가장 당황스러웠던 순간은 Docker 빌드를 중단한 뒤 새 SSH 연결조차 되지 않았을 때였다.

처음에는 Security Group이나 네트워크 문제를 의심했다. 하지만 AWS Console에서 Instance Status Check가 정상 상태가 아니었다.

단순히 컨테이너 하나가 종료된 것이 아니라, 운영 서비스와 Docker 빌드가 작은 EC2의 자원을 함께 사용하면서 OS 응답까지 불안정해진 것으로 보였다. 결국 AWS Console에서 EC2를 재부팅해 복구했다.

EC2 인스턴스 상태 검사 실패

그림 10. 당시에는 시스템 상태 검사는 통과했지만 인스턴스 상태 검사가 실패했다.

그때부터 다음 질문이 더 선명해졌다.

운영 중인 서버에서 Docker 이미지까지 빌드하는 것이 맞을까?

당시 EC2가 맡고 있던 역할은 다음과 같았다.

EC2
├── 사용자 요청 처리
├── Nginx 실행
├── Frontend 실행
├── Backend 실행
├── PostgreSQL 실행
└── 배포 시
    ├── npm ci
    ├── next build
    ├── Gradle 빌드
    └── Docker 이미지 빌드

작은 EC2 한 대가 운영과 빌드를 동시에 맡고 있었다.


3. 수동 배포에서 자동 배포로

배포는 됐는데 매번 EC2에 들어가야 했다

리소스 문제를 어느 정도 정리하고 나니 또 다른 불편함이 보였다. 코드를 main에 반영한 뒤 매번 직접 EC2에 접속하고 있었다.

PR Merge
   ↓
main 변경
   ↓
EC2 SSH 접속
   ↓
git pull
   ↓
Docker 빌드
   ↓
컨테이너 교체

한두 번은 괜찮았지만 배포할 때마다 반복하기에는 번거로웠다. 목표는 단순했다.

main 변경
   ↓
자동 검증
   ↓
EC2 배포
   ↓
실제 사이트 반영

자동화를 붙였다고 바로 안정화된 것은 아니었다. 수동 실행에서 검증 단계는 통과했지만, Deploy 단계에서 self-hosted runner와의 통신이 끊겨 12분 넘게 실행된 Job이 실패한 적도 있다.

Self-hosted Runner 통신 끊김으로 실패한 GitHub Actions 실행

그림 11. 자동 배포는 Runner가 빌드 중에도 살아 있도록 만드는 것

GitHub Actions가 EC2에 어떻게 들어오지?

처음에는 GitHub-hosted Runner가 EC2에 SSH로 접근하는 방식을 생각했다.

GitHub-hosted Runner
          │
         SSH
          │
          ↓
         EC2

하지만 EC2 Security Group은 SSH 접근을 특정 IP에만 허용하고 있었다. GitHub Actions를 위해 SSH Inbound 범위를 넓히고 싶지는 않았다.

SSM도 고민했지만 당시에는 필요한 IAM 권한을 추가하기 어려웠다. 그래서 방향을 바꾸어 EC2 내부에 GitHub Actions Self-hosted Runner를 설치했다.

GitHub
   │
  Job
   ↓
EC2 Self-hosted Runner
   ├── git pull
   ├── Docker 빌드
   └── 컨테이너 교체

방향이 반대가 된 것이다.

GitHub → EC2 접속

EC2 → GitHub 연결

이 방식은 GitHub Actions를 위해 EC2의 SSH Inbound 범위를 추가로 열지 않아도 된다는 장점이 있었다.

EC2 내부 Self-hosted Runner가 GitHub 연결을 기다리는 화면

그림 12. Runner를 EC2 안에서 실행하면 GitHub-hosted Runner가 EC2로 SSH 접속하는 대신, EC2가 GitHub의 Job을 기다리게 된다.

다만 Runner를 설치하는 과정에서 또 하나의 문제가 있었다. ARM 계열 EC2에 x64용 Runner를 설치하자 다음 오류가 발생했다.

Runner.Listener: cannot execute binary file
Exec format error

uname -m으로 서버의 CPU 아키텍처를 확인하고 ARM64용 Runner를 다시 설치하면서 해결했다.

uname -m

이 경험으로 기능을 설정하기 전에 실행 환경의 CPU 아키텍처부터 확인해야 한다는 것을 배웠다.

이제 main에 반영되면 알아서 배포된다

최종적으로 원하는 흐름은 다음처럼 만들어졌다.

feature branch
      ↓
     PR
      ↓
    Merge
      ↓
     main
      ↓
GitHub Actions
      ↓
Self-hosted Runner
      ↓
변경된 Repository pull
      ↓
변경된 서비스 빌드
      ↓
--no-deps로 컨테이너 교체
      ↓
실제 서비스 반영

Frontend Repository가 변경된 경우에는 Frontend만 배포했다.

git pull
docker compose build frontend
docker compose up -d --no-deps frontend

Backend도 같은 방식으로 처리했다.

git pull
docker compose build backend
docker compose up -d --no-deps backend

Frontend와 Backend 배포가 동시에 들어오면 Compose 명령이 충돌할 수 있어 배포 잠금도 적용했다.

exec 9>/tmp/girinlog-deploy.lock
flock 9

이제 코드를 Merge한 뒤 배포를 위해 매번 EC2에 접속할 필요가 없어졌다.


4. 현재 구조를 돌아보고 다음을 생각하다

결국 처음의 구조는 어떻게 바뀌었을까?

처음 구상한 구조시행착오 후 현재 구조
사용자 → CloudFront →
S3(Frontend) + EC2(Backend)
GitHub main 병합 → GitHub Actions → EC2 내부 Self-hosted Runner → Nginx → Frontend/Backend → PostgreSQL

S3 + CloudFront와 비교하면 지금 구조는 훨씬 단순하다. 대신 그 단순함에는 대가가 있다.

빌드와 운영이 하나의 EC2에서 일어나기 때문에 CPU, 메모리, 디스크 사용량에 더 민감하다. 지금의 구조는 완성된 최종형이라기보다 현재 서비스 규모와 목표에 맞춘 단계적인 선택이다.

지금 다시 한다면?

앞으로는 Docker 이미지 빌드 책임을 EC2 밖으로 옮길 수 있다.

GitHub Actions
       ↓
  Docker 빌드
       ↓
    Registry
   ECR / GHCR
       ↓
      EC2
       ↓
  Docker Pull
       ↓
       Run

EC2는 이미지를 만드는 곳이 아니라, 이미 만들어진 이미지를 실행하는 곳으로 사용하는 방식이다. 이렇게 하면 배포 때마다 npm ci, next build, Gradle 빌드가 운영 EC2 자원을 차지하는 문제를 줄일 수 있다.

이미지 태그를 이용한 버전 관리와 롤백도 지금보다 쉬워질 것이다.

다만 이 구조도 “더 좋아 보인다”는 이유만으로 바로 적용하고 싶지는 않다. S3 + CloudFront를 경험하며 배운 것이 있기 때문이다.

기술적으로 더 완성된 구조와 현재 서비스에 필요한 구조가 항상 같지는 않다.

결국 배포의 목적은 사용자가 서비스를 사용하고, 팀이 그 과정을 돌아볼 수 있게 만드는 것이다.


마치며

처음에는 Frontend를 어디에 배포할지에 대한 고민이었다. 하지만 하나씩 문제를 해결하다 보니 질문은 계속 바뀌었다.

S3 + CloudFront
        ↓ 401 / 403 원인 추적
EC2 + Docker Compose
        ↓ ENOSPC
Dockerfile 개선 + 서비스별 빌드
        ↓
GitHub Actions 자동화

돌아보면 처음에는 문제가 생길 때마다 무언가를 더 추가하려 했다. 모든 서비스를 한 번에 빌드했고, 디스크가 부족하자 EBS 크기부터 늘렸다. 하지만 더 중요한 질문은 “왜 이만큼의 자원을 사용하고 있을까?”였다.

이번 배포 경험을 통해 가장 크게 배운 것은 Docker 명령어나 AWS 설정 방법만이 아니었다.

아키텍처는 처음부터 완성되는 것이 아니라, 현재 해결해야 하는 문제와 운영하면서 얻은 근거를 바탕으로 계속 바뀐다.

S3 + CloudFront를 끝까지 완성하지 않은 것도 실패라고 생각하지 않는다. 그 시점에는 사용자에게 서비스를 먼저 제공하는 것이 더 중요했고, 그 선택 덕분에 Docker와 EC2 안에서 또 다른 문제를 직접 경험할 수 있었다.

이제는 다음 질문을 더 분명하게 할 수 있다.

운영 서버와 빌드 서버의 책임은 언제 분리해야 할까?

아마 다음 인프라 개선은 이 질문에서 시작될 것 같다.

profile
안녕하세요. 앞으로 나아가는 개발자, 전원준입니다.

0개의 댓글