
AI 시대에 맞추어 2주에 한 프로덕트를 만들어내는, 작지만 빠른 개발을 지향하는 프로젝트입니다.
저번 포스팅으로 끝낼까 생각했는데 생각해보니 이슈가 하나 있었다.
과금이 일어날 수 있는 클라우드 서비스, Firebase Functions를 이용하고 있었다.
Firebase Functions는 사용량 만큼 비용을 지불하는 구조이기 때문에 너무 많은 사용이 일어나면 프로젝트의 대원칙인 '비용 최소화'에 어긋난다.
문제점
CORS 해결을 위해 Firebase Functions를 의존하고 있다.
처음 React + Github Pages로 개발을 했을 때는 프로젝트 사이즈가 과도하게 커지는 것을 피하기 위해 Firebase Functions로 CORS 문제를 해결했다.
이번 포스팅의 목표는 다음과 같다.
해결방안
단순히 BFF 구조를 위해 Firebase Functions를 활용했기 때문에, EC2 환경을 사용하고 있는 지금은 서버를 따로 띄울 수 있다.
애초에 CORS 오류가 발생하는 것은 '브라우저'에서 요청을 날렸기 때문이다. 원래는 EC2를 사용하는 것이 부담스러워 Functions를 적용했지만, 지금은 EC2를 사용하고 있기 때문에 단순히 인스턴스 내에 우회용 서버를 따로 띄우면 될 것 같다!
구체화
기존 Actions를 통해 배포 자동화를 하던 방식은 지키고 싶다. (편하기 때문)
Docker로 배포하는 것도 환경 세팅 없어서 편리하다.
문제는 도커 한 컨테이너에 Express와 React + nginx 이미지를 다 올릴 것이냐, 다른 컨테이너에 따로 올릴 것이냐
GPT는 다음과 같이 답변했다
하나에 올리는 경우
장점
- 설정이 간단함 (특히 소규모 프로젝트나 MVP)
- 배포가 간편 (하나의 이미지로 끝)
- 네트워킹 이슈가 적음 (같은 프로세스 안에서 동작)
단점
- 확장성 떨어짐 (프론트만 바꾸고 싶어도 전체 다시 빌드 필요)
- 빌드 시간이 길어질 수 있음
- React와 Express의 변경주기를 따로 관리하기 힘듦
나눠서 올리는 경우
장점:
- 확장성과 유지보수 용이 (각각 독립 배포 가능)
- 성능 최적화 쉬움 (React는 CDN에서 캐싱, Express는 API 성능 튜닝 가능)
- DevOps 파이프라인 분리 가능
단점:
- 설정 복잡도 증가 (Nginx 설정 필요)
- 컨테이너 간 통신 설정 필요 (Docker 네트워크 등)
- 배포 파이프라인이 조금 더 복잡함
확장성이 떨어지더라도, 서버가 크게 변화하지 않을 소규모 프로젝트 상황에 설정이 편리한 단일 컨테이너 전략을 택했다.

[Copilot과 함께 진행하였습니다.]
Functions 기존 코드와 거의 동일하지만,
app.listen(PORT, '0.0.0.0', () => {
console.log(`TOEIC API server listening on port ${PORT} (http://0.0.0.0:${PORT})`);
});
요청을 오픈해서 접근을 허용하였다.
nginx.conf
Docker 컨테이너 내부에서 api로 들어온 요청은 Express 서버로 돌려주어야 하기 때문에 설정을 추가하였다.
location /api/ {
# Docker 컨테이너 내부에서도 동작하도록 수정
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
# 타임아웃 설정
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
# 디버깅 로그 활성화
proxy_intercept_errors on;
error_log /var/log/nginx/api_error.log debug;
access_log /var/log/nginx/api_access.log;
}
루트 디렉토리에 있는 docker
리액트 앱을 빌드해서 정적 파일을 만들고, express와 nginx를 한 이미지에 담은 후 start.sh 를 실행한다.
# 통합 Dockerfile
FROM node:18 as build
# React 앱 빌드
WORKDIR /app/frontend
COPY location-map-app/package*.json ./
RUN npm install
COPY location-map-app/ ./
RUN npm run build
# API 서버 설정 (여기서는 의존성만 복사하고 설치)
WORKDIR /app/api
COPY toeic-api/package*.json ./
RUN npm install
COPY toeic-api/ ./
# 최종 이미지
FROM nginx:alpine
# Node.js 설치
RUN apk add --update --no-cache nodejs npm curl
# Nginx 설정 및 빌드된 React 앱 복사
COPY --from=build /app/frontend/build /usr/share/nginx/html/location-map-app
COPY location-map-app/nginx.conf /etc/nginx/conf.d/default.conf
# API 서버 설치 (node_modules 없이)
WORKDIR /app/api
COPY toeic-api/package*.json ./
COPY toeic-api/*.js ./
# 로그 디렉토리 생성
RUN mkdir -p /var/log/api /var/log/nginx
# 시작 스크립트 복사
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 80 4000
CMD ["/start.sh"]
컨테이너 내부에서 nginx는 포그라운드로, express 서버는 백그라운드로 실행시키는 bash 프로그램이다. 상태 모니터링과 로깅 기능이 추가되었다.
#!/bin/sh
# 디버깅을 위해 명령어 출력 켜기
set -ex
# 로그 디렉토리 생성
mkdir -p /var/log/nginx
mkdir -p /var/log/api
# API 서버 모듈 설치 및 시작
cd /app/api
echo "Installing API server dependencies..."
npm install --production
echo "Starting API server..."
node index.js > /var/log/api/api.log 2>&1 &
API_PID=$!
# API 서버 정상 작동 확인을 위한 대기
echo "Waiting for API server to be ready..."
sleep 5
# 실제로 서버가 돌아가고 있는지 확인 (Alpine에서는 ps를 다르게 사용)
if ! ps | grep -v grep | grep -q $API_PID; then
echo "API server process died! Check logs:"
cat /var/log/api/api.log
exit 1
fi
# 서버 상태 확인
for i in $(seq 1 12); do
if curl -s http://127.0.0.1:4000/api/health > /dev/null; then
echo "API server is ready! (Attempt $i)"
break
fi
# 마지막 시도에서도 실패하면 로그 출력
if [ $i -eq 12 ]; then
echo "API server failed to start within timeout. Check logs:"
cat /var/log/api/api.log
# 서버 프로세스가 살아있는지 다시 확인 (Alpine용 명령어)
ps
netstat -tulpn | grep LISTEN
else
echo "Waiting for API server... (Attempt $i/12)"
sleep 5
fi
done
# Nginx 설정 파일 권한 확인 및 문법 검사
nginx -t
# Nginx 시작
echo "Starting Nginx..."
nginx -g 'daemon off;'
docker-compose에서는 이미지 실행을 위한 준비를 했다. docker-compose는 로컬 환경에서만 쓰인다. 아무래도 배포를 이미 시작한 프로젝트다 보니, 로컬에서 테스트를 하고 올리는 게 좋을 것 같았다. 그것이 도커의 장점이기도 하니까..!
version: "3.8"
services:
toeic-center-finder:
build:
context: .
dockerfile: Dockerfile
container_name: toeic-center-finder-dev
ports:
- "80:80"
- "4000:4000"
volumes:
- ./location-map-app/src:/app/frontend/src:ro
- ./toeic-api:/app/api:rw
environment:
- NODE_ENV=development
- PORT=4000
restart: unless-stopped
github actions 배포 방식도 변경해주어야 한다.
Build and push 단계에선 이미지를 생성 후 Dockerhub에 push,
deploy 단계에선 ssh에 접속하여 pull하는 과정을 나타낸다.
저번 포스팅에서 Docker 이미지를 SSH로 전달했는데, 생각해보니 그것보다 Docker Hub로 push pull을 진행하는 것이 상태 관리에 있어 더 효율적일 것 같아서 방식을 변경하기로 했다.
deploy.yml
name: Deploy to Docker Hub and EC2
on:
push:
branches: [ deploy ]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies and fix vulnerabilities
run: |
cd location-map-app
npm ci
npm i -D nth-check@latest
npm ls nth-check
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/toeic-center-finder:latest
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.EC2_SSH_KEY }}
- name: Add SSH known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts
- name: Deploy with Docker
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd ~/location-map
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/toeic-center-finder:latest
docker stop toeic-center-finder || true
docker rm toeic-center-finder || true
docker run -d --name toeic-center-finder -p 3000:80 -p 4000:4000 --restart unless-stopped ${{ secrets.DOCKERHUB_USERNAME }}/toeic-center-finder:latest
docker system prune -af
React 파일의 api 요청 경로도 바꾸고
export const API_BASE_URL = '/api';
컨테이너 외부의 nginx 설정도 추가해주면...
location /api/ {
proxy_pass http://localhost:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

성공!
기존 functions로 보내던 요청이 이제 내부 api 서버로 가는 것을 확인할 수 있다!
혹시 모를 과금을 위해 Functions 서버를 삭제해주었고, 필요 없는 파일들을 삭제하고 마무리하였다.
이번 포스팅의 목표는 다음과 같다.
Functions 의존을 제거할 것
Functions 서버, 관련코드를 삭제하고 Express.js로 전환
CORS 문제를 발생시키지 않을 것
서버->서버 요청으로 CORS 문제도 해결
추가적인 비용 발생을 막을 것
EC2 그대로 사용 중이니 추가 비용은 제로!
이전 글들을 쭉 읽다가 이전에 내가 CORS 문제를 접하면서 작성한 글을 발견하게 되었다.
아마 이때는 프로젝트를 간단하게 마무리할 수 있을 것이라고 생각했었고 EC2 배포까지 하는 건 너무 시간적인 비용이 크지 않을까..? 이 생각으로 ec2 도입을 반대했던 것 같다.
그런데 상황이 HTTPS도 도입해야되고 다른 비용도 줄여야 하다보니 자연스레 ec2를 선택하게 되었다.처음 판단이 항상 정답은 아니라는 것, 또 상황에 따라 최선의 선택은 언제나 달라질 수 있음을 명심할 것..!