콘텐츠로 이동

로컬 환경 정적 리소스 로딩 성능 개선

개요

로컬 개발 환경에서 페이지 로딩 지연 문제를 분석하고 개선한 내용을 정리한다.


증상

  • 로컬(localhost:8000) 접속 시 페이지 로딩 15초 이상 소요
  • 브라우저 네트워크 탭에서 2.8KB 짜리 JS 파일이 12초 걸리는 등 파일 크기와 무관하게 모든 정적 리소스가 느림
  • Timing 탭 확인 결과: 서버 응답 대기(TTFB) = 11.45초, 다운로드·연결은 정상

원인 분석

1. 정적 리소스가 Spring Security 필터 체인을 전부 통과

SecurityConfig에서 정적 리소스를 아래와 같이 처리하고 있었다.

.authorizeHttpRequests(authorizeRequests -> authorizeRequests
    .requestMatchers(IGNORE_RESOURCES).permitAll()  // /js/**, /css/**, /assets/** 등
    ...
)

permitAll()은 인가 단계에서 통과시킬 뿐, Spring Security 필터 체인 자체는 그대로 통과한다. 필터 체인 안에는 SecurityContextHolderFilterHttpSessionSecurityContextRepository 가 포함되어 있어, 매 요청마다 HTTP 세션을 저장소에서 조회·갱신한다.

2. 세션 저장소가 원격 Redis

로컬 프로파일의 세션 저장소 설정:

# application-local.yml (gist-webapp / gist-mobile 공통)
spring:
  data:
    redis:
      host: 123.214.170.232   # 원격 서버 (로컬 PC가 아님)
      port: 31010

Spring Session + SpringSessionBackedSessionRegistry 구성으로 인해 모든 HTTP 세션 조회가 이 원격 Redis로 향한다.

3. 인과 체인

정적 리소스 요청 (예: /js/app.js, /assets/plugins/filepond/filepond.js)
  → permitAll() 이므로 Spring Security 필터 체인 전체 통과
  → HttpSessionSecurityContextRepository.loadDeferredContext()
  → Spring Session → 원격 Redis (123.214.170.232:31010) 왕복
  → 매 요청마다 원격 Redis 조회 발생

한 페이지에 86개 정적 리소스 요청이 있고, HTTP/2로 동시에 몰리면 Lettuce 커넥션 풀(소수)에서 줄서기 현상이 발생하여 TTFB가 6~12초로 누적된다.

4. 왜 로컬에서만 느린가

환경 Redis 위치 왕복 지연 체감
로컬(local) 원격 서버(인터넷/VPN 경유) 수십~수백 ms 정적 리소스 86개 × 지연 = 수십 초
dev/prod 서버 동일 망 내 Redis 1ms 미만 사실상 무시 가능

해결 방법

1. ignoring() — 정적 리소스를 보안 체인에서 완전 제외 (local 전용)

ignoring()permitAll()과 달리 요청이 Spring Security 필터 체인 자체에 진입하지 않는다. 결과적으로 원격 Redis 세션 조회가 발생하지 않는다.

// SecurityConfig.java (gist-webapp)
@Bean
@Profile("local")
public WebSecurityCustomizer staticResourceSecurityCustomizer() {
    return web -> web.ignoring().requestMatchers(IGNORE_RESOURCES);
}
  • @Profile("local") 로 로컬 환경에서만 활성화
  • dev/prod 는 기존 permitAll() 유지 (동일 망 Redis라 영향 미미, 보안 헤더 그대로)
방식 필터 체인 통과 세션 조회 용도
permitAll() 통과함 발생함 dev/prod
ignoring() 통과 안 함 발생 안 함 local

2. 벤더 리소스 캐싱 — 반복 다운로드 제거 (local 전용)

application-local.yml 은 개발자 코드 즉시 반영을 위해 모든 정적 리소스에 no-cache 를 강제한다. 하지만 /assets/** 하위 벤더 라이브러리(filepond, select2, pq-grid, tus, jszip, 아이콘폰트 등)는 개발자가 직접 수정하지 않으므로 매 요청마다 재다운로드할 이유가 없다.

LocalStaticResourceConfig 를 신규 추가하여 벤더 영역에만 장기 캐싱을 적용한다.

// LocalStaticResourceConfig.java (gist-webapp / gist-mobile 공통)
@Configuration
@Profile("local")
public class LocalStaticResourceConfig implements WebMvcConfigurer {

    private static final CacheControl CACHE_DURATION =
            CacheControl.maxAge(Duration.ofDays(365)).cachePublic();

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/assets/**")
                .addResourceLocations("classpath:/static/assets/")
                .setCacheControl(CACHE_DURATION);

        registry.addResourceHandler("/images/**")
                .addResourceLocations("classpath:/static/images/")
                .setCacheControl(CACHE_DURATION);
    }
}

동작 원리: /assets/**, /images/** 가 Spring Boot 기본 /** 핸들러보다 구체적 패턴이므로 해당 경로에 한해 이 핸들러가 우선 적용된다.


적용 범위 및 결과

경로 처리 방식 캐시
/assets/** (벤더 전체) ignoring() + 커스텀 핸들러 365일
/images/** ignoring() + 커스텀 핸들러 365일
/js/**, /css/** (개발자 코드) ignoring() no-cache (즉시 반영)
API (/api/**) 기존 보안 체인 그대로
  • 로컬 재기동 후 첫 로딩: ignoring() 효과로 TTFB가 ms 단위로 단축
  • 두 번째 로딩부터: 벤더 리소스 브라우저 캐시 적용으로 네트워크 요청 자체가 사라짐

주의사항

  • 벤더 파일 교체 시: 365일 캐시 때문에 브라우저가 즉시 반영하지 않는다. 교체 후 Ctrl+F5(강력 새로고침) 또는 DevTools → 캐시 사용 중지 체크 후 새로고침으로 우회한다.
  • dev/prod 영향 없음: @Profile("local") 로 격리되어 있으므로 운영 환경에는 영향을 주지 않는다.
  • application-local.ymlno-cache 블록 유지 필수: 개발자 코드(/js, /css)의 즉시 반영을 보장한다. 제거 시 브라우저 휴리스틱 캐싱으로 수정 내용이 반영되지 않는 문제가 생긴다.

관련 파일

파일 변경 내용
gist-webapp/.../config/SecurityConfig.java @Profile("local") WebSecurityCustomizer 빈 추가
gist-webapp/.../config/LocalStaticResourceConfig.java 신규 — 벤더 리소스 365일 캐싱
gist-mobile/.../config/SecurityConfig.java @Profile("local") WebSecurityCustomizer 빈 추가
gist-mobile/.../config/LocalStaticResourceConfig.java 신규 — 벤더 리소스 365일 캐싱