SSH 없는 AWS에서 CI/CD 붙이기
카카오테크캠퍼스 4기 2단계 팀프로젝트 백엔드 인프라 구축기
0. 시작점: 제약이 먼저 있었다
팀프로젝트용 AWS 환경을 받았는데, 스펙보다 제한 목록이 먼저 눈에 들어왔다.
항목 내용
| 서버 | t3.medium 1대 (2 vCPU / 4GB). 추가 생성 불가, 사양 변경 불가 |
| 디스크 | 50GB 고정. 확장 불가 |
| 접속 | SSH 키 없음. SSM Session Manager만 |
| 차단 | RDS, ALB, EKS, NAT Gateway, ElastiCache, Elastic IP |
| 계정 | IAM 사용자·액세스 키 생성 불가 |
보통 "EC2에 GitHub Actions로 배포" 글들은 이렇게 시작한다.
- uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
key: ${{ secrets.SSH_KEY }}
우린 이 두 줄을 쓸 수가 없었다. SSH 키가 없고, 22번 포트도 닫혀 있다. 그리고 secrets.AWS_ACCESS_KEY_ID도 못 만든다.
제약이 설계를 결정하는 상황이었다. 그래서 처음부터 "무엇을 쓸 수 있나"가 아니라 **"무엇이 남아 있나"**를 세는 것부터 시작했다.
1. 첫 설계: self-hosted runner
남은 선택지를 정리해보니 이랬다.
방식 가능?
| SSH 배포 | ❌ 키 없음, 포트 닫힘, 공인 IP도 안 고정됨(EIP 차단) |
| ECR push + SSM 명령 | ⚠️ GitHub에 AWS 자격증명 필요 → 액세스 키 생성 차단 |
| self-hosted runner | ✅ 크리덴셜 0개, 인바운드 포트 0개 |
self-hosted runner는 서버가 GitHub에 "할 일 있어요?" 하고 물어보는 구조다. 방향이 반대라서 포트를 열 필요가 없다.
① 서버의 러너가 GitHub에 폴링
② 누가 develop에 push
③ GitHub: "이거 해"
④ 러너가 받아서 서버에서 실행
집에 손님을 들이는 게 아니라 우리가 나가서 심부름을 받아오는 구조. 이걸로 가기로 했다.
결론부터 말하면 이 설계는 나중에 폐기된다. 그 얘기는 5장에서.
2. 서버 준비 — 그리고 삽질들
2-1. CloudShell은 우리 서버가 아니다
첫 삽질. AWS 콘솔에 CloudShell이 있길래 거기서 Docker를 깔려고 했다.
CloudShell은 AWS가 브라우저에 띄워주는 별도의 임시 환경이다. 우리 EC2와 아무 관계가 없고, 세션이 끝나면 홈 디렉터리 1GB 빼고 초기화된다. 거기에 러너를 띄우면 창 닫는 순간 죽는다.
진짜 서버 터미널은 EC2 → 인스턴스 → 연결 → Session Manager 탭이다.
접속하면 whoami부터 확인해야 한다. ssm-user로 붙으면 sudo su - ubuntu.
2-2. 설치한 것
# Docker 공식 저장소 (apt의 docker.io는 compose v2가 없다)
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker ubuntu
여기서 두 번째 삽질. usermod로 그룹을 추가해도 지금 열려 있는 터미널에는 적용되지 않는다. 그룹은 로그인할 때 한 번만 읽힌다. docker ps가 permission denied로 튕겨서 한참 헤맸다. 답은 재접속.
2-3. 스왑 4GB — 선택이 아니라 필수
RAM이 4GB인데 postgres + redis + api + worker를 동시에 돌린다. 여기에 pip install이 순간적으로 메모리를 크게 먹는다.
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
스왑이 없으면 빌드 중 OOM Killer가 postgres를 죽인다. 디스크를 빌려 쓰는 거라 느리지만, 죽는 것보단 낫다.
>>를 >로 잘못 치면 /etc/fstab이 그 한 줄로 덮어써진다. 부팅이 막힐 수 있는 유일한 파일이라 복붙을 권한다.
2-4. 디스크 방어
50GB 고정인데 도커 로그는 기본이 무제한이다. 몇 주 돌면 수십 GB가 된다.
# 컨테이너당 10MB × 3개까지
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' \
| sudo tee /etc/docker/daemon.json && sudo systemctl restart docker
# 매일 새벽 4시, 7일 넘게 안 쓴 이미지·캐시 정리
(sudo crontab -l 2>/dev/null; echo "0 4 * * * docker system prune -af --filter until=168h") | sudo crontab -
prune에 --volumes를 안 붙이면 볼륨은 안 건드린다. DB 데이터는 안전하다.
사실 이 단계는 한 번 건너뛰었다가 멘토님 조언으로 다시 넣었다. "오래된 로그가 지워질까 걱정"이라고 여쭤봤더니, 답은 "보존이 필요하면 로그를 서버 밖으로 빼는 게 맞고, 이번 프로젝트는 디스크 사용량 보면서 운영해도 충분하다" 였다. 보존과 정리는 별개 문제였던 것.
2-5. .env를 워크스페이스 밖에 둔 이유
이게 나중에 큰 사고를 막았다.
배포 워크플로가 코드를 받을 때 actions/checkout은 기본으로 git clean -ffdx를 돌린다. git이 추적하지 않는 파일을 전부 지운다는 뜻이고, .env가 정확히 거기 해당한다.
그러면 매 배포마다 .env가 지워지고 → 재생성되고 → DB 비밀번호가 바뀐다. 그런데 postgres 볼륨에는 처음 만들 때의 비밀번호가 박혀 있어서 인증 실패가 난다. 원인 찾기 지독하게 어려운 버그다.
그래서 워크스페이스 바깥에 원본을 두고, 배포할 때마다 복사해 넣기로 했다.
sudo mkdir -p /opt/aidam && sudo chown ubuntu:ubuntu /opt/aidam
touch /opt/aidam/backend.env && chmod 600 /opt/aidam/backend.env
2-6. 비밀번호를 스크린샷으로 유출한 이야기
nano로 .env를 편집하다가 막혀서 화면을 캡처해 물어봤다. 거기에 방금 생성한 DB 비밀번호가 평문으로 그대로 찍혀 있었다.
다행히 아직 docker compose up을 안 한 상태라 볼륨이 없었고, 비밀번호가 DB에 박히기 전이었다. 새로 만들어 쓰는 걸로 끝났다.
교훈: 터미널 화면 공유는 가려서 한다.
sed 's/\(PASSWORD\|KEY\)=.*/\1=***/' /opt/aidam/backend.env
nano 편집 중인 화면은 가릴 방법이 없으니 아예 안 찍는 게 맞다.
참고로 nano에서 Permission denied가 났던 진짜 원인은 /opt/aidam 디렉터리 자체가 없었던 것이었다. ls -l로 확인했더니 No such file or directory. 에러 메시지를 믿지 말고 상태를 직접 봐야 한다.
3. 배포보다 CI가 먼저였다
멘토님이 이런 피드백을 주셨다.
GitHub Actions를 활용해 CI 환경도 구축해보시면 좋을 것 같아요. PR이 올라올 때 테스트와 린트 등을 자동으로 실행해서, 수정한 코드가 기존 테스트를 깨뜨리지는 않는지 확인하도록 구성해보면 좋겠습니다.
CI와 CD는 이름이 붙어다녀서 헷갈리는데 완전히 다른 일이다.
CI CD
| 언제 | PR이 올라올 때 | develop에 머지된 뒤 |
| 어디서 | GitHub 서버 | 우리 EC2 |
| 뭘 | 테스트·린트로 문제 찾기 | 서버에 올려서 돌리기 |
PR 올림 → [CI] 통과? → 리뷰 승인 → develop 머지 → [CD] 서버 배포
CI가 앞단, CD가 뒷단. 둘 다 있어야 온전하다.
3-1. 규칙과 현실이 어긋나 있었다
CI에 린트를 넣으려고 보니 문제가 있었다. 팀 CLAUDE.md에는 이렇게 적혀 있었다.
코드 변경을 마치면 ruff check --fix && ruff format을 돌리고 결과를 보고합니다.
그런데 ruff가 프로젝트에 설치돼 있지 않았다. requirements-dev.in에도 없고 설정 파일도 없었다. 심지어 open-questions.md에 "Ruff가 아직 설치·설정되지 않음"이라고 적혀 있었다.
규칙은 있는데 도구가 없는 상태. CI를 붙이려면 이것부터 해결해야 했다.
3-2. 기존 코드에 처음 돌려보기
ruff check → 22개 지적 (17개 자동 수정 가능)
ruff format → 41개 중 21개 파일이 재포맷 대상, 약 496줄
CI를 그냥 붙이면 첫 PR부터 빨간불이라는 뜻이다. 기존 코드를 먼저 정리해야 했다.
여기서 팀원들이 걱정한 게 "코드가 바뀌면 어떡하냐"였다. 그래서 ruff format이 뭘 하는지 실제 코드로 보여줬다.
# 전
issues.append(VerificationIssue(check_type=VerificationCheckType.WRONG_CHILD_EVIDENCE,
reason="요청과 문서의 대상 원아가 다릅니다."))
# 후
issues.append(
VerificationIssue(
check_type=VerificationCheckType.WRONG_CHILD_EVIDENCE,
reason="요청과 문서의 대상 원아가 다릅니다.",
)
)
파이썬 입장에서 완전히 같은 코드다. 바뀐 건 세 가지뿐:
- 88자 넘으면 인자를 한 줄에 하나씩
- 앞 줄에 억지로 맞춘 들여쓰기를 일정한 4칸 단위로
- 마지막 인자 뒤에 쉼표 (나중에 인자 추가할 때 그 줄만 바뀌어서 diff가 깨끗해짐)
그래도 불안하니 구문 트리(AST)를 비교해서 증명했다.
for f in changed_files:
old = git_show(f"HEAD:{f}")
new = open(f).read()
assert ast.dump(ast.parse(old)) == ast.dump(ast.parse(new))
검사한 파일: 21개
구문 트리가 달라진 파일: 없음 — 코드 의미가 완전히 동일합니다
파이썬이 코드를 이해한 결과가 글자 하나 안 틀리고 같다는 뜻. 이걸 커밋 메시지에도 적었다.
3-3. 커밋을 쪼갠 이유
팀 규약에 **"포맷팅 커밋과 로직 커밋을 섞지 않는다"**가 있었다. 그래서 5개로 나눴다.
chore: add ruff as a dev dependency
style: apply ruff automatic fixes (import 정렬 등 17건)
style: apply ruff format (22개 파일, 496줄)
test: make subprocess and dict usage explicit for lint
ci: run backend tests and lint on pull requests
리뷰어가 3번 커밋은 안 읽어도 되게 하려는 것. 496줄짜리 diff에 로직 3줄이 섞여 있으면 아무도 그 3줄을 못 본다.
3-4. 자동 수정이 안 되는 것들
5건이 남았는데 전부 테스트 파일이었다.
tests/test_alembic_environment.py:22 PLW1510 subprocess.run without explicit check
tests/test_init_env.py:22 PLW1510
tests/test_model_imports.py:26 PLW1510
tests/agents/.../test_critic_result.py:140,153 C408 Unnecessary dict() call
subprocess.run에 check= 인자가 없다는 지적. 여기서 중요한 건 check=True로 고치면 안 된다는 것이었다. 세 테스트 모두 result.returncode를 직접 검사하는 게 의도인데, check=True면 실패 시 예외가 나서 테스트 의도가 깨진다.
생략했을 때의 기본값과 같은 check=False를 명시하는 게 맞는 수정이었다.
subprocess.run(
[...],
timeout=30,
# 실패도 검사 대상이라 예외 대신 returncode로 확인합니다.
check=False,
)
린터가 시키는 대로가 아니라 코드의 의도대로 고쳐야 한다.
4. CI가 첫날부터 버그를 잡았다
워크플로를 만들고 PR을 올렸더니 빨간불이 떴다.
ERROR collecting tests/test_celery_smoke.py
→ scripts.celery_smoke → celery_app → get_settings()
→ ValidationError: postgres_password Field required
로컬에서는 104개가 전부 통과했는데 CI에서만 실패했다.
원인은 이거였다. test_celery_smoke.py가 celery_app을 임포트하고, celery_app은 임포트되는 순간 get_settings()를 호출한다. POSTGRES_PASSWORD가 없으면 테스트가 실행되기도 전에 수집 단계에서 죽는다.
내 로컬에는 backend/.env가 있었고, CI에는 없었다(커밋 금지 파일이니 당연히).
# 로컬에서 CI와 같은 조건으로 재현
mv .env .env.bak && pytest -q; mv .env.bak .env
똑같이 재현됐다.
이건 CI가 없었으면 저장소를 새로 받은 팀원이 그대로 겪었을 문제다. "내 컴에선 되는데요"의 교과서적인 사례이고, CI를 붙이라고 한 이유가 정확히 이거였다.
고친 방법은 conftest.py. pytest는 테스트 모듈보다 conftest.py를 먼저 읽는다.
"""테스트 수집이 개발자의 .env나 실행 중인 PostgreSQL에 의존하지 않게 합니다."""
import os
os.environ.setdefault("POSTGRES_PASSWORD", "test-only-password")
setdefault라 이미 설정된 환경변수는 안 건드린다.
5. 멘토님 한마디로 설계가 뒤집혔다
CI를 하는 동안 CD 쪽으로 멘토님께 여쭤봤더니 이런 답이 왔다.
저도 가이드를 봤는데요, OIDC IAM role도 제공받을 수 없는 걸까요? 이거는 한번 확인해보면 좋을 것 같습니다.
확인해보니 캠퍼스 가이드에 ktc-github-deploy 역할이 이미 팀 계정에 만들어져 있었다. 액세스 키는 못 만들지만, OIDC로 1시간짜리 임시 토큰을 받을 수 있었던 것.
5-1. 그런데 OIDC는 "배포 방법"이 아니다
여기가 헷갈리기 쉬운 지점이다.
- OIDC = GitHub Actions가 AWS API를 호출할 수 있게 해주는 인증
- 배포 = 우리 EC2 안에서 명령을 실행하는 것
OIDC를 붙여도 "EC2에서 docker compose up을 어떻게 실행하냐"는 그대로 남는다. 그걸 하려면 SSM SendCommand(AWS가 EC2에 명령을 대신 전달)가 필요한데, 역할에 그 권한이 있는지는 가이드에 안 나와 있었다.
5-2. 그래서 권한 탐색 워크플로를 먼저 만들었다
배포를 짜기 전에 인증만 따로 검증했다. 문제가 생겼을 때 원인이 인증인지 배포인지 가려지니까.
name: OIDC 연결 확인
on: workflow_dispatch
permissions:
id-token: write # 빠지면 인증 실패. 가장 흔한 원인
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/ktc-github-deploy
aws-region: ap-northeast-2
- run: aws sts get-caller-identity
- name: 역할 권한 탐색
run: |
echo "===== SSM: 원격 명령 실행 (배포 경로 결정) ====="
aws ssm send-command \
--instance-ids "$EC2_INSTANCE_ID" \
--document-name AWS-RunShellScript \
--parameters 'commands=["echo deploy-path-ok"]' \
--query 'Command.CommandId' --output text \
|| echo "NO ssm:SendCommand"
# ECR, S3도 같은 방식으로 확인
서버에서 echo만 실행하는 거라 무해하다. 결과:
인증 assumed-role/ktc-github-deploy/GitHubActions ✅
SSM 인스턴스 조회 Online ✅
SSM 원격 명령 실행 CommandId 반환 (서버에서 echo 실행됨) ✅
ECR / S3 접근 가능 ✅
self-hosted runner가 필요 없어졌다.
self-hosted runner OIDC + SSM
| 서버 자원 | 러너가 상시 점유 | 없음 |
| 관리 | 등록·토큰 갱신·버전 업 | 없음 |
| 보안 | public 저장소라 fork PR 위험 | GitHub 쪽에서 실행 |
게다가 저장소가 public이라 서버가 코드를 받을 때 인증도 필요 없었다. Deploy key 단계가 통째로 빠졌다.
2장에서 세운 설계를 3일 만에 버렸다. 확인 워크플로를 먼저 만들어본 게 다행이었다.
6. 자동화 전에 손으로 한 번
팀 deploy 스킬에 이런 결정이 있었다.
완성본을 한 번에 올리지 않고, 단계마다 서버가 정상 동작하는지 확인하고 다음으로 갑니다.
그래서 자동화 전에 서버에서 손으로 띄워봤다.
# ① 데이터 저장소 먼저
docker compose up -d postgres redis && docker compose ps
# → 둘 다 healthy 확인하고 다음으로
# ② 빌드
docker compose build
# ③ 마이그레이션 + 기동
docker compose run --rm api alembic upgrade head
docker compose up -d
③에서 터졌다.
pydantic_core.ValidationError: 1 validation error for Settings
app_env
Input should be 'local', 'test' or 'production' [input_value='prod']
.env에 APP_ENV=prod라고 썼는데, core/config.py가 받는 값은 Literal["local", "test", "production"]이었다.
app_env: Literal["local", "test", "production"] = "local"
이게 pydantic이 하는 일이다. 서버가 반쯤 뜬 채로 이상하게 도는 대신, 시작하자마자 "이 값 틀렸다"고 멈추는 것. 배포에서는 이런 게 훨씬 낫다.
고치고 다시:
$ curl -s localhost:8000/health/db
{"status":"ok","database":"ok"}
첫 배포 성공. 이 시점에 사람이 친 명령이 전부 확정됐다. 자동화는 이걸 그대로 옮기기만 하면 된다.
7. 배포 자동화
7-1. 스크립트를 저장소에 둔 이유
워크플로에 배포 절차를 인라인으로 박으면 서버에서 똑같이 돌려볼 수가 없다. 문제가 생겼을 때 "코드가 문제야, 워크플로가 문제야"를 못 가린다.
그래서 backend/scripts/deploy.sh로 뺐다.
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE=/opt/aidam/backend.env
COMPOSE_DIR=/opt/aidam/app/backend
echo "== 1/6 서버 설정 파일 확인 =="
[ -f "$ENV_FILE" ] || { echo "실패: $ENV_FILE 이 없습니다."; exit 1; }
cp "$ENV_FILE" "$COMPOSE_DIR/.env"
chmod 600 "$COMPOSE_DIR/.env"
echo "== 2/6 .env 키 대조 =="
missing=$(comm -23 \
<(grep -o '^[A-Z_][A-Z0-9_]*=' "$COMPOSE_DIR/.env.example" | sort) \
<(grep -o '^[A-Z_][A-Z0-9_]*=' "$COMPOSE_DIR/.env" | sort))
if [ -n "$missing" ]; then
echo "실패: 서버 .env에 없는 키가 있습니다."
echo "$missing"
exit 1
fi
cd "$COMPOSE_DIR"
echo "== 3/6 이미지 빌드 =="
docker compose build
echo "== 4/6 postgres·redis 기동 =="
docker compose up -d --wait --wait-timeout 180 postgres redis
echo "== 5/6 마이그레이션 =="
docker compose run --rm api alembic upgrade head
echo "== 6/6 api·worker 기동 =="
docker compose up -d
echo "== 헬스 체크 =="
API_PORT=$(sed -n 's/^API_PORT=//p' "$COMPOSE_DIR/.env" | tail -1 | tr -d '"'"'"' ')
for _ in $(seq 1 30); do
if curl -fsS "localhost:${API_PORT:-8000}/health/db"; then
echo "배포 성공"; exit 0
fi
sleep 2
done
echo "실패: 헬스 체크가 60초 안에 통과하지 못했습니다."
docker compose logs --tail 50 api
exit 1
2/6 단계가 이 스크립트에서 제일 마음에 드는 부분이다. 서버 .env는 저장소에 없어서 자동으로 안 따라온다. 누군가 .env.example에 키를 추가하면 배포된 앱이 기동 중에 죽는데, 그때는 이미 5분을 빌드에 쓴 뒤다. 시작하자마자 멈추는 게 낫다.
(실제로 이 방어가 곧 발동했다. 얼굴 임베딩 암호화 PR이 FACE_EMBEDDING_KEY를 추가하면서.)
7-2. 워크플로
name: Deploy to EC2
on:
push:
branches: [develop]
workflow_dispatch:
permissions:
id-token: write # OIDC
contents: write # 배포 태그 push
concurrency:
group: deploy-ec2
cancel-in-progress: false # 배포가 겹치면 서버 상태가 꼬인다
핵심은 SSM으로 스크립트를 호출하는 부분이다.
SCRIPT="set -e
cd /opt/aidam/app
git fetch --prune origin
git reset --hard ${GITHUB_SHA}
bash backend/scripts/deploy.sh"
ENCODED=$(printf '%s' "$SCRIPT" | base64 -w0)
REMOTE="set -e
trap 'rm -f /tmp/aidam-deploy.sh' EXIT
umask 077
printf '%s' '${ENCODED}' | base64 -d > /tmp/aidam-deploy.sh
chown ubuntu:ubuntu /tmp/aidam-deploy.sh
runuser -l ubuntu -c 'bash /tmp/aidam-deploy.sh'"
COMMAND_ID=$(aws ssm send-command \
--instance-ids "$EC2_INSTANCE_ID" \
--document-name AWS-RunShellScript \
--parameters "$(jq -n --arg s "$REMOTE" '{commands: [$s]}')" \
--query Command.CommandId --output text)
설계 포인트 세 개:
① origin/develop이 아니라 ${GITHUB_SHA}로 고정한다. 배포 도중 새 커밋이 들어와도 무엇이 올라갔는지 분명해진다.
② runuser -l ubuntu로 실행한다. SSM은 기본이 root라 그대로 두면 .env와 도커 산출물이 root 소유가 되어 팀원이 손으로 다룰 때 막힌다.
③ base64로 실어 보낸다. 처음엔 스크립트를 runuser -c '...' 안에 그대로 끼워 넣었는데, 리뷰에서 "값에 작은따옴표가 섞이면 조용히 깨진다"는 지적을 받았다. base64는 문자 집합에 따옴표가 없어서 안전하고, 서버에서 실행할 명령이 변수 없는 고정 문자열이 된다.
7-3. 결과를 안 기다리면 의미가 없다
send-command는 fire-and-forget이다. 그대로 두면 배포가 실패해도 워크플로는 초록불이 된다.
aws ssm wait command-executed는 최대 100초라 빌드 시간을 못 버틴다. 직접 폴링했다.
STATUS=Pending
for _ in $(seq 1 120); do
if OUT=$(aws ssm get-command-invocation ... --query Status --output text 2>&1); then
STATUS="$OUT"
elif printf '%s' "$OUT" | grep -q InvocationDoesNotExist; then
STATUS=Pending # 전달 직후 잠시 나타난다
else
echo "명령 상태를 조회하지 못했습니다:"; echo "$OUT"; exit 1
fi
case "$STATUS" in Success|Failed|Cancelled|TimedOut) break ;; esac
sleep 10
done
처음엔 2>/dev/null || echo Pending으로 썼다가 리뷰에서 지적받았다. 권한 오류도 "아직 진행 중"으로 삼켜져서, 권한이 없으면 20분을 다 채우고서야 실패한다는 것. 실제로 ssm:GetCommandInvocation 권한이 미확인 상태였으니 정확한 지적이었다.
7-4. 첫 자동 배포
CommandId: ea7ca3a0-d2b6-46ce-9896-e5a9bc16dc97
최종 상태: Success
== 1/6 ~ 6/6 전부 통과
헬스 체크 포트: 8000 → 배포 성공
태그 생성: deploy-20260922-1358
걱정했던 GetCommandInvocation 권한도 있었다.
8. 리뷰에서 배운 것
팀원이 must 3개, nit 2개를 달아줬다. 전부 유효했다.
must 1 — --wait에 타임아웃이 없다
postgres·redis가 healthy에 못 들어가면 --wait가 무한 대기합니다. GitHub Actions는 20분 후 실패로 끝나지만, EC2 위 실제 프로세스는 계속 살아있어요. cancel-in-progress: false라 다음 배포들이 그 뒤에 줄서서 무한정 막히고, 결국 직접 죽여야 복구됩니다.
내가 못 본 건 워크플로 실패와 서버 프로세스 종료가 별개라는 점이었다. 원격 실행 구조에서는 "이쪽이 포기해도 저쪽은 계속 돈다".
must 2 — 헬스체크 포트 하드코딩
compose는 호스트 포트를 ${API_PORT:-8000}로 가변으로 여는데, 나는 localhost:8000을 박아놨다. .env에서 포트를 바꾸면 앱은 멀쩡한데 헬스체크만 실패해서 배포가 잘못 실패 처리된다.
must 3 — 권한 오류를 "진행중"으로 오인
7-3에서 쓴 그것. PR 본문에 "권한 미확인"이라고 내가 적어놓고, 정작 코드는 그 오류를 삼키게 짜놨다.
nit — "자동 정리" 주석이 실제 동작과 다름
"옛 이미지는 자동 정리되지만"이라는 주석이 있는데, 실제로는 deploy.sh/deploy.yml 어디에도 정리 단계가 없습니다.
이건 리뷰어가 틀렸다 — 정리는 실제로 자동이다. 다만 배포가 아니라 EC2의 cron이 한다. 2-4에서 걸어둔 것.
그런데 이 지적이 더 큰 문제를 짚고 있었다. 서버 설정이 저장소 밖에 있어서 코드만 보면 알 수 없다는 것. 스왑, cron, daemon.json 전부. 서버를 새로 받으면 재현도 못 한다. 이건 지금도 미해결 과제로 남겨뒀다.
9. 머지 차단을 켰다가 껐다가
CI가 develop에 들어가도 빨간 X만 뜨고 머지 버튼은 멀쩡히 눌린다. 실제로 막으려면 브랜치 보호(룰셋)를 걸어야 한다.
gh api repos/{owner}/{repo}/rulesets -X POST --input - <<'JSON'
{
"name": "develop-ci",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/heads/develop"], "exclude": [] } },
"rules": [{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [{ "context": "backend" }]
}
}]
}
JSON
켜자마자 문제가 생겼다.
#14 BLOCKED
#13 BLOCKED
두 PR 모두 ci.yml이 develop에 들어오기 전에 열린 것이라 backend 검사가 한 번도 안 돌았다. 룰셋은 그 검사를 요구하니 GitHub이 "검사 대기 중"에서 영원히 멈춘다.
같은 이유로 ci.yml에 paths 필터를 일부러 안 넣었다. 필터에 걸려 실행되지 않은 PR이 똑같이 영원히 대기한다. 문서만 고친 PR에서도 그냥 돌리고 30초 만에 끝나게 두는 편이 낫다.
임시로 껐다.
gh api repos/{owner}/{repo}/rulesets/{id} -X PUT -f enforcement=disabled
그리고 두 PR에 안내 코멘트를 달았다 — develop 받기 → 의존성 재설치 → ruff 돌리기 → push. 저자들이 정리한 뒤 다시 켰다.
순서가 중요했다. 켜둔 채로 뒀으면 두 분이 88자 기준으로 고쳤다가, 다음 장의 100자 변경 때문에 또 고쳐야 했을 것이다.
10. 내가 만든 불일치
CI를 다 붙이고 나서 팀원이 물었다.
pytest + ruff 기준은 사람들이 어떻게 알아?
찾아봤더니 아무 데도 안 적혀 있었다. 이슈 코멘트에만 있었는데, 이슈는 묻힌다.
그리고 더 나쁜 걸 발견했다. backend/CLAUDE.md에 팀이 정해둔 기준이 있었다.
들여쓰기 스페이스 4칸, 줄 길이 100자, 문자열 큰따옴표. Ruff 하나로 린트+포맷
그런데 내가 만든 ruff.toml은 target-version만 적어놔서 ruff 기본값인 88자로 돌고 있었다. 팀이 정한 100자가 아니라.
여기서 두 갈래가 있었다.
- (a) 설정을 100자로 고치고 재포맷 — 22개 파일이 또 바뀜
- (b) 문서를 88자로 고침 — 이미 88로 포맷돼 있으니 훨씬 쉬움
(a)를 골랐다. 팀이 먼저 정한 기준이고, 내 실수를 근거로 팀 규칙을 바꾸는 건 순서가 거꾸로다. 규칙을 바꾸려면 [rule]로 팀 승인을 받아야 한다.
재포맷했더니 오히려 줄이 줄었다.
22 files changed, 56 insertions(+), 192 deletions(-)
88자에서 억지로 접혀 있던 게 100자에서 펴진 것.
문서를 어디에 둘지
낡은 문장 세 곳도 고쳤다.
위치 적혀 있던 것
| backend/CLAUDE.md | "ruff ... 아직 설치·설정되지 않았습니다" |
| backend/README.md | "Ruff는 실행 환경에 설치되어 있지 않아 미실행" |
| docs/open-questions.md | "Ruff가 아직 설치·설정되지 않음" |
그리고 배치를 나눴다.
문서 담은 것
| backend/CLAUDE.md | 규칙과 명령 2줄 + README 가리키기 |
| backend/README.md §린트와 포맷 | 설치, 명령, CI가 보는 것, 실패했을 때, 머지 차단 |
CLAUDE.md는 매 세션 로드되는 문서라 절차까지 담으면 길어진다.
README에는 "로컬은 되는데 CI만 실패할 때" 절을 따로 넣었다. 4장에서 실제로 겪은 일이라 재현 방법까지 남겼다.
mv .env .env.bak && .venv/bin/python -m pytest -q; mv .env.bak .env
팀 CLAUDE.md 맨 아래에 이런 문장이 있다.
계속 지켜야 할 규칙은 채팅으로 말하지 말고 문서에 적으세요. 대화로만 준 지시는 컨텍스트가 압축되면 사라집니다.
이슈 코멘트도 똑같다. 3주 뒤에 들어온 사람은 못 찾는다.
11. 최종 구조
PR 올림
↓
[CI] GitHub 서버 — ruff check → ruff format --check → pytest
↓ 통과해야 머지 버튼이 열림 (develop-ci 룰셋)
리뷰 승인 → develop 머지
↓
[CD] GitHub Actions
↓ OIDC 인증 (액세스 키 없음)
AWS Systems Manager
↓ SSM SendCommand
EC2에서 deploy.sh 실행
1/6 .env 복사 → 2/6 키 대조 → 3/6 빌드
→ 4/6 DB·Redis healthy → 5/6 마이그레이션 → 6/6 기동
↓
헬스 체크 통과 → 배포 태그 생성 (deploy-YYYYMMDD-HHMM)
저장한 비밀값: 0개. GitHub에는 계정 ID(Variables)만 있고, 나머지는 서버 .env에 있다.
12. 돌아보며
제약이 오히려 더 나은 설계를 만들었다
액세스 키를 만들 수 있었다면 아마 secrets.AWS_ACCESS_KEY_ID를 넣고 끝냈을 것이다. 못 만들어서 OIDC를 찾았고, 결과적으로 유출될 장기 자격증명이 아예 없는 구조가 됐다.
SSH를 쓸 수 있었다면 키를 만들고 22번 포트를 열었을 것이다. 못 써서 SSM을 찾았고, 인바운드 포트가 하나도 열려 있지 않은 구조가 됐다.
손으로 먼저 성공시키고 자동화한다
APP_ENV=prod 같은 건 손으로 돌려봐야 나온다. 자동화부터 짰으면 워크플로 로그를 뒤지면서 "이게 워크플로 문제인가 코드 문제인가"를 한참 헤맸을 것이다.
자동화는 사람이 성공시킨 절차를 옮기는 일이지, 새로 만드는 일이 아니다.
CI는 실제로 버그를 잡는다
"테스트가 이미 있는데 CI가 왜 필요하냐"는 생각을 할 수 있다. 그런데 CI가 붙은 첫날 .env 의존 버그를 잡았다. 내 노트북에서는 절대 안 나타나는 버그였다.
CI의 가치는 테스트를 돌려주는 게 아니라 깨끗한 환경에서 돌려주는 것이다.
내 실수로 남의 규칙을 바꾸지 않는다
ruff.toml에 line-length를 빠뜨린 건 내 실수였다. 문서를 88로 고치면 diff 하나 없이 "해결"됐겠지만, 그러면 팀이 합의한 기준이 조용히 사라진다.
기록된 결정이 대화보다 세다. 바꾸려면 절차를 밟아야 한다.
아직 안 된 것들
정직하게 남겨두면 이렇다.
- 외부 공개 — API가 127.0.0.1에만 묶여 있다. CORS 설정, 포트 개방, 그리고 EIP가 차단이라 서버를 껐다 켜면 공인 IP가 바뀌는 문제가 남아 있다
- DB 백업 — RDS를 못 쓰니 postgres가 서버 안 볼륨 하나에 들어 있다. 지금 백업이 없다
- 서버 설정 코드화 — 8장의 그 지적. 스왑, cron, daemon.json이 저장소 어디에도 없다
- 무중단 배포 — 서버가 1대라 블루/그린이 원천적으로 불가능하다. 재시작 순간 몇 초 끊긴다
마지막 항목은 환경 제약이라 해결이 아니라 수용해야 하는 것이다. 이런 걸 구분하는 것도 배운 점 중 하나였다.
'백엔드' 카테고리의 다른 글
| 카테캠4기 2단계/아이담 개발 1주차 — 백엔드 실행 기반을 다지고 협업 구조 정리하기 (0) | 2026.09.15 |
|---|---|
| Git & GitHub 완벽 정리 (0) | 2026.03.30 |
| [2025 하계 모각코] 4회차 Spring Boot 공부와 중앙해커톤 준비 (3) | 2025.08.11 |
| [2025 하계 모각코] HTTP 기초 공부2 (0) | 2025.07.20 |
| Git과 Gradle, Branch, Commit, Push의 개념 및 IntelliJ에서의 사용법 [깃허브] (1) | 2025.01.25 |