사이트픽 블로그
← 목록으로

개발일지 2026-07-29

피아노 학원 플랫폼 개발일지 (2026-07-29)

개요

도커로 전환한 백엔드 컨테이너가 계속 (unhealthy) 로 뜨는 오탐(false-positive)을 잡았다. 원인은 도커 HEALTHCHECK 가 시크릿 헤더 없이 /api/v1/public/ping 을 찌르는데, 오리진 시크릿 필터가 그걸 403 으로 막아서 헬스체크가 실패한 것. 헬스체크 경로만 시크릿 검증에서 예외 처리하고, ALB 뒤 EC2 2대를 한 대씩 교체하는 무중단 Rolling 으로 배포했다. 마지막엔 같은 절차를 한 번 더 돌려 무중단이 실제로 되는지(웹에서 로그인이 안 끊기는지) 검증까지 했다.

수정/생성 파일 목록

백엔드 (piano-backend)

  • .../security/OriginSecretFilter.java (수정) — 헬스체크 경로(/api/v1/public/ping)를 시크릿 검증에서 제외하는 isExempt() 추가

프론트 변경은 없음. 인프라(ECR 이미지 재빌드 → 도커 Rolling 배포) 작업이 중심.


파일별 상세

OriginSecretFilter.java — 헬스체크 경로 시크릿 검증 예외

배경: 왜 필터가 있나

프론트/백엔드를 CloudFront 뒤에 두면서 백엔드 8080 포트를 인터넷에 노출한다. 보안그룹은 CloudFront 오리진 프리픽스 리스트(pl-*)로 제한하지만, 그 프리픽스는 "모든 CloudFront 고객"의 엣지 IP를 포함하므로 이론적으로 타인의 CloudFront 를 통해 우리 8080 에 도달할 여지가 있다. 이를 막으려고 우리 CloudFront 배포는 모든 요청에 시크릿 헤더(X-Origin-Secret)를 붙이고, 이 필터가 그 값을 검증한다. 헤더가 없거나 틀리면 403.

문제: 도커 헬스체크가 이 필터에 막힘

도커 전환 후 컨테이너 상태가 계속 Up ... (unhealthy) 로 떴다. Dockerfile 의 HEALTHCHECK 는 이렇게 생겼다:

HEALTHCHECK ... CMD wget -qO- http://127.0.0.1:${SERVER_PORT:-8080}/api/v1/public/ping || exit 1

즉 컨테이너 내부에서 ping 을 찌르는데, 이때 X-Origin-Secret 헤더가 없다. 그러면 OriginSecretFilter 가 403 을 돌려주고 → wget 이 실패(exit 1) → 도커가 컨테이너를 unhealthy 로 마킹한다. 실제 서비스는 멀쩡한데 헬스체크만 오탐으로 실패하는 상황.

ALB Target Group 은 matcher 를 200,403 으로 잡아둬서 트래픽엔 영향이 없었지만(그래서 서비스는 정상), docker ps 에 (unhealthy) 가 계속 찍히는 건 보기 안 좋고 오케스트레이터 붙일 때 문제가 된다.

해결: 헬스체크 경로만 검증에서 제외

doFilter 의 검증 조건에 !isExempt(uri) 를 추가하고, /api/v1/public/ping 을 예외로 뒀다.

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {

    HttpServletRequest req = (HttpServletRequest) request;
    HttpServletResponse res = (HttpServletResponse) response;

    if (enabled && req.getRequestURI().startsWith("/api/") && !isExempt(req.getRequestURI())) {
        String provided = req.getHeader(HEADER);
        if (!constantTimeEquals(expectedSecret, provided)) {
            res.setStatus(HttpServletResponse.SC_FORBIDDEN);
            res.setContentType("application/json;charset=UTF-8");
            res.getWriter().write("{\"success\":false,\"code\":\"FORBIDDEN\",\"message\":\"Forbidden\"}");
            return;
        }
    }

    chain.doFilter(request, response);
}

/** 시크릿 검증에서 제외할 경로. */
private static boolean isExempt(String uri) {
    return uri.equals("/api/v1/public/ping");
}

이 엔드포인트는 고정 문자열만 반환하고 민감정보가 없어 시크릿 없이 노출돼도 안전하다. 게다가 8080 은 여전히 ALB SG 로만 접근 가능해 외부에서 직접 도달할 수도 없다.


검증 & 배포

1) 커밋 → 새 이미지 해시 확보

Dockerfile 이 워킹트리 src 를 그대로 COPY 하기 때문에, 이미지 태그로 쓰는 git 짧은 해시가 배포된 이미지와 겹치지 않도록 먼저 커밋했다. (커밋 안 하면 HEAD 해시가 기존 배포 이미지와 같아 태그 충돌)

git commit -m "fix(security): 헬스체크 경로(/api/v1/public/ping) 시크릿 검증 제외"
# HEAD: 6521991 → ff43e4a (새 태그 확보)

2) arm64 빌드 → ECR push

로컬 맥(Apple Silicon = arm64)과 EC2(aarch64)가 동일 아키텍처라 크로스빌드가 필요 없다.

docker build --platform linux/arm64 -t piano-api:ff43e4a -t piano-api:latest .
# ECR 로그인 후
docker push .../piano-api:ff43e4a   # digest sha256:f4ab95ed...
docker push .../piano-api:latest

3) 무중단 Rolling 배포 (한 대씩)

ALB Target Group 에 EC2 2대가 물려 있으니, 한 대씩 빼고(deregister→draining) 컨테이너를 새 이미지로 교체 후 다시 넣는다(register). 항상 최소 1대가 트래픽을 받으므로 다운타임 0.

TG 조작은 이 환경에서 AWS CLI 가 깨져서 boto3 로 했다:

import boto3
elb = boto3.Session(profile_name='piano-backend', region_name='ap-northeast-2').client('elbv2')
tg = 'arn:...:targetgroup/mforet-api-tg/...'
elb.deregister_targets(TargetGroupArn=tg, Targets=[{'Id':'i-052f249a2a34ca356'}])  # #2 빼기
# ... 교체 후
elb.register_targets(TargetGroupArn=tg, Targets=[{'Id':'i-052f249a2a34ca356'}])    # #2 다시 넣기

각 EC2 에서 컨테이너 교체:

sudo docker pull .../piano-api:ff43e4a
sudo docker rm -f piano-api
sudo docker run -d --name piano-api --restart=always \
  -p 8080:8080 --env-file /etc/piano/piano.env .../piano-api:ff43e4a

⚠️ 반드시 -p 8080:8080(0.0.0.0). 127.0.0.1:8080 으로 바인딩하면 ALB 헬스체크가 못 닿아 unhealthy → 503. 8080 은 SG 가 ALB SG 만 허용이라 0.0.0.0 노출해도 안전하다.

순서: #2 빼기→교체→검증→넣기(both healthy) → #1 빼기→교체→검증→넣기(both healthy).

4) 결과

  • 교체 직후 컨테이너 로그에 Started PianoApiApplication, HikariPool-1 - Start completed 정상.
  • 핵심: 헤더 없는 ping 이 이제 403 이 아니라 200 을 돌려준다 → 도커 상태가 (unhealthy) → (healthy) 로 바뀜. 오탐 해결 확인.
  • E2E(mforet.kr CloudFront 경유): ping=200, auth/me=401(토큰 없으니 정상).

5) 무중단 검증(추가 rolling)

"진짜 무중단 되나?"를 눈으로 보려고, 코드 변경 없이 같은 이미지(ff43e4a)로 rolling 을 한 번 더 돌렸다. 한 대를 TG 에서 빼는 동안 웹에서 로그인이 끊기지 않는지 직접 확인 → 끊김 없이 정상. Rolling 무중단이 실제로 동작함을 검증했다.


트러블슈팅 메모 (삽질 기록)

  • (unhealthy) 는 오탐이었다: 서비스는 멀쩡한데 헬스체크만 실패. ALB matcher 가 200,403 이라 트래픽엔 영향이 없어 처음엔 그냥 뒀지만, 도커 상태 표시가 지저분하고 향후 오케스트레이터에 문제가 되어 잡았다.
  • git 해시 = 이미지 태그의 함정: Dockerfile 이 워킹트리를 COPY 하므로, 커밋 안 하고 빌드하면 HEAD 해시가 기존 배포 이미지와 같아진다(= 같은 태그, 다른 내용). 반드시 먼저 커밋해서 새 해시를 확보.
  • AWS CLI 가 이 환경(프록시/래퍼)에서 깨짐: elbv2 등 변경/조회는 전부 boto3 로 처리. ECR push 도 $VAR 확장/파이프가 깨져서 전체 경로 인라인 + 명령 개별 실행.
  • Bash cwd 매 호출 리셋: cd A && cmd 가 다음 호출로 안 이어짐. 빌드/tar 는 절대경로로.
  • EC2 #2 는 EIP 를 붙여둬서 stop/start 해도 IP(43.201.231.242)가 안 바뀐다. (예전엔 자동 퍼블릭 IP라 재기동마다 바뀌어 SSH 대상이 흔들렸다.)

결론 / 배운 점

  • 헬스체크는 인증/필터의 무풍지대여야 한다. 보안 필터가 헬스체크 경로까지 막으면 "서비스는 정상인데 unhealthy" 라는 헷갈리는 상황이 생긴다. 공개 헬스 엔드포인트는 검증에서 예외로 두는 게 정석.
  • ALB Rolling 은 절차만 지키면(한 번에 한 대) 진짜 무중단이다. 한 대씩 draining→교체→register, both healthy 확인 후 다음 대로. 이번에 웹에서 직접 확인해 확신을 얻었다.
  • 인프라 자동화는 환경 제약을 문서화해두는 게 중요. AWS CLI 깨짐 → boto3, cwd 리셋 → 절대경로, git 해시 태그 → 선(先)커밋 같은 함정은 deploy 스킬에 박제해두면 다음 배포가 안전해진다.

추가 정리 (17:06) — 프론트를 S3 + CloudFront 로 이전

백엔드 도커화와 별개로, 프론트엔드를 기존 EC2/nginx 서빙에서 S3(정적) + CloudFront(CDN) 구조로 이전하는 작업도 오늘 마무리했다. 이제 mforet.kr 로 들어오는 트래픽은 EC2 를 거치지 않고 CloudFront 가 처리한다.

최종 인프라 구조

사용자
  │  https://mforet.kr, https://www.mforet.kr
  ▼
Route53 (A/Alias) ──▶ CloudFront (ELRQQUHYTM1OV, d2bxzbea3i4wt8.cloudfront.net)
                          ├─ /*     → S3  (mforet-frontend-873961272731, OAC 비공개 버킷)
                          └─ /api/* → ALB (mforet-alb) → TG(8080) → EC2 #1,#2 도커 컨테이너
  • 프론트(정적 파일)는 S3, API 는 ALB→EC2 도커. 한 도메인(mforet.kr) 아래서 경로(behavior)로 갈라진다.
  • 이전엔 EC2 nginx 가 /var/www/piano 를 서빙했는데, 이제 정적 자산은 EC2 를 아예 안 거친다(CloudFront 엣지 캐시 → S3). EC2 부하·대역폭이 줄고, 엣지 캐싱으로 초기 로딩도 빨라진다.

1) S3 버킷 — 정적 호스팅이 아니라 OAC(비공개) 방식

버킷: mforet-frontend-873961272731 (ap-northeast-2)

  • Public Access Block 4종 전부 ON → 버킷은 완전 비공개. S3 "정적 웹사이트 호스팅"도 끔.
  • 대신 CloudFront OAC(Origin Access Control) 로만 접근한다. 즉 브라우저가 S3 를 직접 못 열고, 반드시 CloudFront 를 통해서만 객체를 받는다. (버킷 정책에 "이 CloudFront 배포의 요청만 허용" 조건이 걸림)
  • 정적 웹호스팅 엔드포인트를 쓰지 않고 REST origin + OAC 를 쓴 이유: 버킷을 퍼블릭으로 열지 않아도 되고(보안↑), HTTPS·서명·OAC 를 CloudFront 가 일괄 관리하기 때문.

버킷 안 디렉터리 구조(앱별 prefix):

mforet-frontend-873961272731/
├── index.html, favicon.ico, assets/ ...   # 브랜드(루트 SPA)
├── admin/      # 관리자
├── academy/    # 학원
├── student/    # 학생
└── sheets/     # 악보(Next.js static export)

nginx 시절의 /var/www/piano/<앱> 경로 구조를 S3 prefix 로 그대로 옮겨왔다. 배포 스크립트 관점에서 "루트=브랜드, 하위=각 앱" 규칙이 동일해 혼선이 적었다.

2) 프론트 빌드 → S3 업로드

각 앱을 빌드해 해당 prefix 로 올렸다. (배포 전 .env.local 을 치워 dev URL(localhost:8080)이 번들에 박히지 않게 하고, 산출물에 mforet.kr 만 남는지 스캔하는 절차는 기존과 동일 — dev URL 박제 사고 방지 게이트.)

# 예: 앱 빌드 후 S3 동기화 (--delete 로 옛 파일 정리, 정적 자산은 장기 캐시)
aws s3 sync ./build s3://mforet-frontend-873961272731/<앱prefix>/ --delete --profile piano-backend
# 브랜드(루트 SPA)만 버킷 루트로: s3://mforet-frontend-873961272731/
  • 업로드 뒤 CloudFront 캐시 무효화로 즉시 반영:
    aws cloudfront create-invalidation --distribution-id ELRQQUHYTM1OV --paths "/*" --profile piano-backend
    
    (엣지에 남은 옛 index.html/번들 때문에 배포가 안 보이는 걸 막는다. SPA 는 index.html 이 캐시되면 새 번들 해시를 못 물어와 흰 화면이 나기 쉬움.)

3) CloudFront behavior — 경로별 오리진 라우팅

한 배포(distribution) 안에서 origin 2개 + behavior 로 트래픽을 가른다:

Behavior (경로)Target Origin설명
/api/* (ordered)ec2-api (ALB custom origin)API 는 ALB→EC2 도커로. CloudFront 가 X-Origin-Secret 헤더를 주입 → 백엔드 필터가 검증
Default (/*)s3-frontend (S3 OAC origin)그 외 모든 정적 파일은 S3 에서
  • 순서가 중요: /api/* 를 ordered behavior 로 먼저 매칭시키고, 나머지를 default 로 S3 에 보낸다. (default 가 S3 라서, api 경로를 명시적으로 ALB 로 빼주지 않으면 API 호출이 전부 S3 로 가 404 가 난다.)
  • API origin 은 S3 가 아니라 custom origin(ALB DNS) 이고, 여기서 CloudFront 가 오리진 시크릿 헤더를 붙인다. (백엔드 OriginSecretFilter 가 이 헤더를 검증 — 오늘 오전에 고친 그 필터. 단 헬스체크 /api/v1/public/ping 은 예외.)

4) 도메인 / DNS / 인증서

  • ACM 인증서(us-east-1, CloudFront 는 버지니아 리전 인증서만 씀): mforet.kr + SAN www.mforet.kr. DNS 검증(CNAME)으로 발급.
  • CloudFront Aliases(CNAMEs): mforet.kr, www.mforet.kr 를 배포에 연결하고 위 ACM 인증서를 붙임.
  • Route53 (mforet.kr. 호스팅 존):
    • mforet.kr A(Alias) → CloudFront (d2bxzbea3i4wt8.cloudfront.net)
    • www.mforet.kr A(Alias) → CloudFront
    • _...mforet.kr / _...www.mforet.kr CNAME → ACM 검증 레코드
  • 즉 apex(mforet.kr)도 A/Alias 로 CloudFront 를 직접 가리킨다. (CNAME 은 apex 에 못 쓰므로 Route53 Alias 로 처리.)

검증

  • CloudFront 배포 상태 Deployed, 오늘 최종 수정 반영됨.
  • https://mforet.kr/ → 프론트 정상 로딩(S3), https://mforet.kr/api/v1/public/ping → 200(ALB→EC2, 오늘 오전 배포한 백엔드), auth/me → 401(토큰 없음, 정상).
  • www.mforet.kr 도 동일 CloudFront 로 뜨는지 확인.

배운 점 (프론트 이전)

  • 정적은 S3+CloudFront, 동적은 ALB — 한 도메인에서 behavior 로 가른다. EC2 는 API 만 담당하게 되어 역할이 깔끔해졌다.
  • S3 는 굳이 퍼블릭으로 열 필요 없다. OAC 로 CloudFront 만 접근하게 하면 버킷은 비공개로 두고도 정적 서빙이 된다(보안·비용 모두 이득).
  • 배포 뒤 CloudFront invalidation 은 필수. 특히 SPA 의 index.html 이 엣지에 캐시되면 새 번들을 못 물어와 배포가 "안 보이는" 착각이 생긴다.
  • apex 도메인은 Route53 A/Alias 로 CloudFront 를 가리킨다(CNAME 불가). 인증서는 CloudFront 용이라 반드시 us-east-1 ACM.

댓글 0

  • 첫 번째 댓글을 남겨보세요.