개발 slecs

정적 리소스 캐시 무효화로 배포 후 구버전 문제 해결

목차

정적 리소스 캐싱은 양날의 검이다. 잘 쓰면 서버 부하를 낮추고 페이지 로딩을 확 줄여주는데, 배포 주기와 맞물리는 순간 골치가 된다. 배포 후 "왜 CSS가 이상해요?" 같은 문의가 들어올 때마다 개발자 입장에서는 "브라우저 캐시 지우고 새로고침 해보세요"라고 안내하게 되는데, 이걸 계속 반복하면 운영팀도 지치고 사용자 경험도 나빠진다. 몇 번 겪다 보면 결국 구조적으로 해결하는 게 낫다는 결론에 이른다.

이번 작업은 그 반복을 끊기 위한 것이었다.

캐시 무효화 전략

브라우저 캐시를 길게 잡는 건 맞다. 변경이 거의 없는 CSS, JS, 이미지 파일에 매번 서버 요청을 보내는 건 낭비다. 하지만 "길게 잡되, 파일이 바뀌었을 때는 반드시 새 버전을 받도록 보장"하는 부분이 까다롭다. 브라우저 입장에서 max-age가 살아있으면 서버에 묻지도 않고 캐시에서 꺼내는 게 정상 동작이라, 이걸 깨려면 URL 자체가 바뀌어야 한다.

방식은 크게 두 가지다.

방식 예시 특징
쿼리스트링 버전 /style.css?v=a3f9b 구현 간단, 일부 프록시 캐시 우회 안 됨
파일명 해시 /style.a3f9b.css CDN·프록시까지 확실히 무효화, 빌드 파이프라인 필요

이번엔 쿼리스트링 방식을 택했다. 빌드 타임에 커밋 해시나 빌드 번호를 변수로 주입해서 JSP 템플릿에서 <c:url>로 붙이는 구조다. Spring MVC 쪽 리소스 핸들러는 max-age를 1년으로 잡았다.

registry.addResourceHandler("/static/**")
    .addResourceLocations("classpath:/static/")
    .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS));

JSP 쪽에서는 대략 이런 식으로 처리한다.

<link rel="stylesheet" href="<c:url value='/static/css/main.css'/>?v=${buildVersion}">
<script src="<c:url value='/static/js/app.js'/>?v=${buildVersion}"></script>

buildVersion은 빌드 시점에 주입되는 값이라서 배포가 될 때마다 URL이 달라지고, 브라우저는 캐시를 무시하고 새 파일을 받아온다. 같은 배포 내에서 파일이 여러 개여도 버전 값을 일괄 적용할 수 있어서 관리도 편하다.

트레이드오프를 짚자면, 쿼리스트링 방식은 일부 구형 프록시 서버나 CDN이 쿼리스트링을 무시하고 이전 캐시를 내려주는 경우가 있다. CDN을 통해 정적 리소스를 서빙하는 구조라면 파일명 해시 방식이 더 안전하다. 파일명 해시는 Webpack이나 Vite 같은 번들러가 기본으로 지원하는 기능이라서, 프론트엔드 빌드 파이프라인이 이미 갖춰져 있으면 그쪽이 훨씬 깔끔하다. 현재 구조에서는 CDN 없이 직접 서빙하고 있어서 쿼리스트링으로 충분했다.

배포 이후 캐시 관련 문의가 눈에 띄게 줄었다. 작은 변경이었지만 효과는 명확했고, 이런 종류의 개선이 쌓이면서 운영 노이즈가 줄어든다.

CI/CD 파이프라인에서 자주 겪는 문제

배포 자동화를 구성하다 보면 한 번씩은 다 마주치는 문제들이 있다. 미리 알고 있으면 삽질 시간을 아낄 수 있어서 정리해둔다.

  • SSH 타임아웃: 빌드 시간이 길면 SSH 연결이 중간에 끊긴다. CI 설정에서 command_timeout을 빌드 예상 시간보다 여유 있게 잡아야 한다. 특히 Gradle 풀 빌드는 의존성 다운로드까지 합치면 첫 실행에서 생각보다 오래 걸린다.
  • 키 인증 오류: SSH 개인키를 CI 시크릿으로 등록할 때 개행 문자가 포함되거나 잘리는 경우가 있다. -----BEGIN OPENSSH PRIVATE KEY----- 앞뒤로 줄바꿈이 제대로 살아있는지, 마지막 줄에 불필요한 공백이 붙지 않는지 확인해야 한다.
  • Gradle 캐시 충돌: CI 환경에서 Gradle 캐시가 이전 빌드 상태를 물고 있으면 이상하게 실패한다. --no-daemon 옵션이나 캐시 디렉토리 클리어로 대부분 해결된다. 캐시를 아예 안 쓰는 게 느리지만 가장 확실한 방법이기도 하다.
  • 환경변수 누락: Secrets 등록을 빠뜨리면 빌드는 성공해도 앱이 뜨지 않는다. 배포 직후 헬스체크가 실패했는데 로그 보니 환경변수가 없어서 초기화 단계에서 터진 경우가 몇 번 있었다. 배포 후 앱 로그 첫 줄부터 확인하는 습관이 돼야 한다.

이 중에서 가장 시간을 잡아먹는 게 환경변수 누락 케이스다. 빌드 로그만 보면 성공으로 찍히니까 처음엔 배포가 잘 됐다고 생각하고, 한참 뒤에 앱 로그를 뒤지다가 원인을 찾게 된다. CI 파이프라인에 배포 직후 헬스체크 단계를 넣어두면 이 시간을 많이 줄일 수 있다.

무중단 배포 전략

서비스 중단 없이 배포하는 게 목표인데, 핵심은 트래픽 전환 타이밍이다. 신규 버전이 완전히 기동된 뒤에 프록시(nginx 등)의 upstream을 바꾸는 방식을 기본으로 쓴다.

애플리케이션 프로세스가 떠있다고 해서 바로 트래픽을 받을 수 있는 상태가 아니다. DB 커넥션 풀 초기화, 외부 서비스 연결 확인, 내부 캐시 웜업 같은 과정이 끝나야 실제로 정상 응답이 가능하다. 그래서 헬스체크 엔드포인트가 있어야 한다. /actuator/health나 직접 만든 /health 엔드포인트가 200을 내려올 때까지 기다린 뒤 upstream을 전환하는 방식이다. 헬스체크 없이 프로세스 기동 여부만 보고 전환하면, 초기화 중에 들어온 요청이 에러를 받는 상황이 생긴다.

롤백도 배포 전략의 일부다. 이전 버전 JAR이나 Docker 이미지를 일정 기간 보존해두고, 문제가 생겼을 때 프록시 upstream을 되돌리거나 이전 이미지로 재기동할 수 있게 해두는 게 기본이다. 실제로 롤백을 쓰는 경우가 많지는 않아도, 준비가 안 돼 있으면 장애 상황에서 수동 복구하느라 시간을 잃는다. 배포 성공 직후 일정 시간은 모니터링을 더 촘촘하게 보는 편인데, 앱 레벨에서 뒤늦게 에러가 터지는 경우가 있어서다.

이번 작업 자체는 규모가 크지 않았다. 그래도 "배포 후 화면이 이상하다"는 피드백이 반복되면 결국 팀과 사용자 사이의 신뢰도 문제로 번진다. 이런 수정들이 쌓이면서 전체 시스템이 조금씩 더 단단해진다.

댓글 0

첫 댓글 달아줘.