Writing
← Writing

재 보니 둘 다 고장 나 있었다 — 압축 없는 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로 측정)

전후
JS3,347,475 B1,015,619 B
CSS36,632 B7,563 B
첫 화면 JS·CSS 합계3.38MB1.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짜리 번들 자체를 나누는 일입니다. 압축으로 전송량은 줄었지만, 브라우저가 해석해야 하는 코드 양은 그대로입니다.