재 보니 둘 다 고장 나 있었다 — 압축 없는 3.4MB와 24번 연속 빨간 CI
포트폴리오에 Plan Do!의 숫자를 넣으려고 측정을 시작했습니다. 좋은 숫자를 찾으러 갔는데, 먼저 나온 건 고장 난 곳 두 개였습니다. 하나는 사용자 쪽, 하나는 개발 쪽입니다.
1. JS 3.4MB가 압축 없이, 캐시 금지로
발견
웹 앱 첫 화면이 받는 파일을 curl로 하나씩 받아 봤습니다.
Accept-Encoding: gzip을 보내도 응답 크기가 원본과 똑같았습니다.
/assets/index-*.js 3,347,475 B (Content-Encoding 없음)
/assets/index-*.css 36,632 B (Content-Encoding 없음)
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
문제가 두 개 겹쳐 있었습니다.
- 압축이 안 됨 — nginx 기본 설정은
gzip on이지만gzip_types와gzip_proxied가 주석 처리돼 있습니다. Plan Do!는 Spring Boot가 정적 파일까지 내보내고 nginx는 그 앞에서 프록시만 하기 때문에, 프록시 응답인 JS·CSS는 하나도 압축되지 않았습니다. - 캐시 금지 — 위
Cache-Control값은 Spring Security가 모든 응답에 붙이는 기본 헤더와 정확히 같습니다. API 응답에는 맞는 설정이지만, 파일 이름에 해시가 들어간/assets/파일까지 매번 새로 받게 만들고 있었습니다.
고친 방법
Spring 쪽을 건드리지 않고 nginx에서만 처리했습니다. 웹 SPA는 jar 안에 같이 실려 나가서, 백엔드를 다시 배포하지 않고 끝내고 싶었습니다.
# conf.d — 응답 종류에 따라 캐시 값을 고른다
map $upstream_http_content_type $asset_cc {
~^text/html "no-cache";
default "public, max-age=31536000, immutable";
}
# server 블록
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/css text/javascript application/javascript application/json
image/svg+xml application/manifest+json;
location /assets/ {
proxy_pass http://plando_app;
proxy_hide_header Cache-Control;
proxy_hide_header Pragma;
proxy_hide_header Expires;
add_header Cache-Control $asset_cc;
}
결정한 것 세 가지:
- 장기 캐시는
/assets/에만 — 파일 이름에 해시가 있어서, 내용이 바뀌면 이름도 바뀝니다. 그래서 1년immutable을 줘도 안전합니다. HTML과 서비스 워커(sw.js)는 그대로 캐시 금지로 둬서, 배포하면 바로 새 버전을 받게 했습니다. - 경로가 아니라
Content-Type으로 한 번 더 거른다 —/assets/아래로 HTML이 돌아오는 경우(예: SPA 대체 페이지)가 생겨도 1년 캐시가 붙지 않게map으로 HTML은 제외했습니다. 오류 응답(4xx·5xx)에는always없이 쓴add_header가 붙지 않아서, 없는 파일 응답도 캐시되지 않습니다. - 적용 전에 백업하고
nginx -t— 통과할 때만 reload 하고, 실패하면 백업으로 되돌리게 했습니다.
함정이 하나 있었습니다. sites-enabled/todo가 sites-available을 가리키는 링크가 아니라 따로 있는 일반 파일이었습니다.
sites-available 쪽을 고쳤다면 아무 일도 일어나지 않았을 겁니다. 고치기 전에 nginx가 실제로 읽는 파일이 어느 것인지(nginx -T)부터 확인하는 게 맞았습니다.
결과 (2026-10-13, 같은 맥에서 curl로 측정)
| 전 | 후 | |
|---|---|---|
| JS | 3,347,475 B | 1,015,619 B |
| CSS | 36,632 B | 7,563 B |
| 첫 화면 JS·CSS 합계 | 3.38MB | 1.02MB (−70%) |
| JS 받는 시간 (이 네트워크 기준) | 약 0.34초 | 약 0.17초 |
/assets/ 캐시 | 매번 다시 받음 | 1년, immutable |
적용한 뒤 회귀도 확인했습니다.
- API는 200, WebSocket(
/ws) 업그레이드는 101로 그대로입니다. - HTML과
sw.js는 여전히 캐시 금지입니다. - 없는
/assets/파일 응답에는 캐시 헤더가 붙지 않습니다. - 실제 브라우저에서 로그인 화면이 정상으로 뜹니다.
다만 “재방문 때 다시 받지 않는다”는 응답 헤더로 확인한 것입니다. 브라우저에서 재방문해서 네트워크를 직접 본 건 아닙니다.
2. CI가 24번 연속 빨간불이었다
발견
결과 목록에 “테스트가 push마다 돈다”고 쓰려고 GitHub Actions를 열었는데, 8월 2일 이후 24번이 전부 실패였습니다.
148 tests completed, 1 failed
TodoApplicationTests > contextLoads() FAILED
147개는 통과했습니다. 실패한 건 Spring 컨텍스트가 뜨는지만 보는 contextLoads() 하나였습니다.
Spring 컨텍스트를 띄우는 다른 테스트는 H2를 쓰는 test 프로필로 돌고 있었는데, 이 테스트에만 프로필 지정이 빠져 있었습니다.
그래서 CI 러너에는 없는 실제 PostgreSQL에 접속하려다 실패한 것입니다.
고친 방법
@SpringBootTest
@ActiveProfiles("test") // 다른 테스트와 같은 H2 설정으로 뜬다
class TodoApplicationTests {
한 줄이었고, 로컬과 CI 둘 다 148/148 통과로 돌아왔습니다.
진짜 문제는 한 줄이 아니었다
147개가 통과하고 있었으니 테스트가 아예 안 돈 건 아닙니다. 문제는 빨간불이 기본값이 되어 있었다는 것입니다. 그 상태에서 진짜 회귀가 생겨 테스트가 하나 더 깨져도, 화면은 똑같이 빨간색입니다. 두 달 동안 아무도 몰랐다는 게, 그 상태가 얼마나 쉽게 익숙해지는지 보여 줍니다.
혼자 운영하면 CI 알림을 봐 줄 사람도 저 하나입니다. 그래서 이제는 실패한 채로 두지 않는 것을 규칙으로 삼았습니다. 깨진 테스트는 바로 고치거나, 이유를 적고 명시적으로 끄거나 둘 중 하나입니다.
정리
- 측정은 좋은 숫자를 찾는 일이 아니라 고장을 찾는 일이었다. 숫자를 만들려고 시작했는데 가장 큰 개선은 고장 수리에서 나왔습니다.
- 기본값을 의심하기. Spring Security의 캐시 헤더도, nginx의 gzip도 기본값은 “합리적”이었습니다. 다만 이 구성(Spring이 정적 파일까지 내보내고 nginx는 프록시만 하는 구조)에는 맞지 않았습니다.
- 바꾼 다음엔 안 바뀌어야 할 것도 확인하기. 압축과 캐시를 넣은 뒤 API, WebSocket, HTML, 없는 파일 응답을 같이 확인한 게 이번에 제일 잘한 일이었습니다.
남은 것은 3.3MB짜리 번들 자체를 나누는 일입니다. 압축으로 전송량은 줄었지만, 브라우저가 해석해야 하는 코드 양은 그대로입니다.