← 개발일지

Thymeleaf Layout 분리로 로그인 페이지 로딩 시간 개선하기


모니터링 알림 하나에서 시작된 구조 점검

운영 중인 Spring + Thymeleaf 기반 쇼핑몰에서 외부 모니터링 서비스의 알림이 떴다. 로그인 페이지에 접속했을 때 30초 안에 페이지 로딩이 완료되지 않았다는 navigation timeout이었다. 서버는 정상이었고, 직접 접속하면 7초 이내로 로딩됐다. 재현이 안 되는 일회성 이슈였지만, 구조를 열어보니 언제든 재발할 수 있는 문제가 숨어 있었다.

문제: 로그인 페이지가 쇼핑몰 전체 리소스를 로드하고 있었다

이 프로젝트는 Thymeleaf Layout Dialect를 사용해서 하나의 공통 layout에 header, footer, 정적 리소스 선언 등을 두고, 모든 페이지가 이 layout을 상속하는 구조였다. 로그인 페이지도 예외 없이 같은 layout을 사용하고 있었다.

서버 사이드: header에 숨어 있던 DB 호출

header fragment 안에 th:with로 다양한 service 메서드가 선언되어 있었다. 장바구니 수, 배너 목록, 마케팅 메뉴, 포인트 잔액, 예산, 승인 건수 등 쇼핑몰 운영에 필요한 데이터를 한꺼번에 조회하는 구조였고, 그 수가 15개 이상이었다.

Thymeleaf의 th:with는 선언 시점에 SpEL 표현식을 즉시 평가한다. 페이지에서 해당 변수를 사용하든 안 하든 DB 호출이 전부 실행된다. 로그인 페이지는 비로그인 상태에서 접근하는 페이지인데, 장바구니 수나 포인트 잔액을 조회할 이유가 없다.

클라이언트 사이드: 전역으로 로드되는 불필요 리소스

공통 리소스 선언 fragment에서 엑셀 라이브러리, 슬라이드 배너 라이브러리, 애니메이션 CSS 등을 전역으로 로드하고 있었다. 로그인 페이지는 폼 두 개와 AJAX 호출 하나가 전부인데, 요청 수가 40개 이상이었고 전송량이 약 1MB에 달했다.

평소에는 7초 안에 로딩이 끝나지만, 서버 부하가 일시적으로 올라가거나 외부 CDN이 느려지면 30초 timeout을 넘기는 구조였다.

th:with의 eager evaluation

이 문제의 핵심은 th:with의 동작 방식에 있다.

<th:block th:with="a = ${@serviceA.query()},
                   b = ${@serviceB.query()},
                   c = ${@serviceC.query()}">
    <!-- 실제로 a만 사용하더라도 -->
    <span th:text="${a}"></span>
    <!-- b, c의 service 메서드도 이미 실행된 뒤다 -->
</th:block>

이게 개별 페이지에 있으면 그 페이지만의 문제지만, layout의 공통 fragment에 들어가면 그 layout을 상속하는 모든 페이지가 같은 비용을 지불한다. 10개의 service 호출이 있는 header를 공유하면, 100개의 페이지가 각각 10번씩 불필요한 DB 호출을 하게 되는 셈이다.

해결: 로그인 전용 minimal layout

접근은 단순하다. 로그인 페이지에는 header, footer, GNB가 필요 없으니 이것들을 포함하지 않는 별도의 layout을 만들면 된다.

개념적으로는 이런 구조다:

기존:
  login.html → default_layout → header(DB 15건+) + config(리소스 40개+) + footer

변경:
  login.html → login_layout → 최소 CSS/JS만 포함 (리소스 ~10개)
  나머지 페이지 → default_layout → 기존과 동일

login_layout에는 페이지 렌더링에 필수적인 기본 스타일시트와 jQuery 정도만 포함하고, 엑셀 라이브러리, 슬라이드, 애니메이션 등은 모두 제외한다. header/footer fragment를 아예 포함하지 않으므로 th:with에 선언된 service 호출도 전부 실행되지 않는다.

로그인 페이지에서는 layout 선언만 변경하면 된다:

<!-- 변경 전 -->
layout:decorate="~{layout/default_layout}"

<!-- 변경 후 -->
layout:decorate="~{layout/login_layout}"

이 변경 하나로 서버 측 DB 호출 15건 이상이 제거되고, 클라이언트 측 요청 수가 40개 이상에서 10개 미만으로 줄어든다.

어디까지 분리할 것인가

layout 분리의 트레이드오프는 관리 포인트가 늘어난다는 것이다. layout이 2개가 되면 공통 변경 사항을 두 곳에 반영해야 할 수 있다.

하지만 모든 페이지에 동일한 layout을 적용하는 것이 편하다는 이유만으로 로그인 페이지에서 15건의 DB 호출과 1MB의 리소스를 로드하는 건 명백한 낭비다. 분리 기준은 간단하다: 그 페이지에서 header/footer/GNB를 사용하는가? 사용하지 않는다면 분리 대상이다.

로그인 외에도 에러 페이지, 약관 페이지, 비밀번호 재설정 페이지 등이 같은 기준으로 분리할 수 있는 후보다.

검증할 때는 해당 페이지의 기본 기능(폼 submit, 유효성 검증, 페이지 내 화면 전환, 외부 링크 이동 등)만 확인하면 된다. layout 분리는 template 계층만 바꾸는 것이므로 비즈니스 로직에 영향을 주지 않는다.