프로필

데브고래밥

@devgoraebap

스택오버플로우의 단골손님이였던 Claude를 채찍질하는 개발자

Album Art

0:00 0:00
방문자 정보

요즘 관심있는

HDA 시리즈 마무리 - Vite로 정적 리소스 관리하기 thumbnail image
45
0

HDA 시리즈 마무리 - Vite로 정적 리소스 관리하기

이번 글에서는 SSR 환경에서 정적 리소스(JavaScript, CSS)를 효율적으로 관리하는 방법에 대해 다룬다. 지금까지 프로젝트에서 사용해온 JavaScript 구성을 살펴보자.

예제 코드: https://github.com/dev-goraebap/hypermedia-driven-demo/tree/step-08

<!-- tailwindcss 추가 -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

<!-- htmx 추가 -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"
        integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"
        crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4"
        integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg"
        crossorigin="anonymous"></script>

<script type="module" src="/modal.js"></script>

TailwindCSS와 HTMX 관련 외부 라이브러리를 CDN 형태로 사용하고 있다. 그 외에 우리가 직접 작성한 모달 관련 JavaScript 코드도 포함되어 있다.

이런 구성은 서버 측 프레임워크에서 템플릿을 활용하여 화면을 만들 때 매우 흔하게 나타나는 모습이다.

CDN 의존의 문제점

CDN 파일을 프로덕션에서 그대로 사용하는 것은 몇 가지 이유로 권장되지 않는다.

  • 렌더링 차단: <head>에 있는 스크립트는 기본적으로 HTML 파싱을 차단한다. 브라우저가 스크립트를 다운로드하고 실행할 때까지 페이지 렌더링이 멈춘다.
  • 네트워크 지연: 외부 서버에서 파일을 가져오는 동안 추가적인 DNS 조회, TCP 연결, TLS 핸드셰이크가 필요하다.
  • 외부 의존성: CDN 서버에 장애가 발생하면 사이트가 제대로 동작하지 않을 수 있다.

CDN으로 여러 라이브러리를 불러오는 사이트는 Lighthouse 성능 점수가 눈에 띄게 떨어지는 것을 확인할 수 있다.

로컬 파일로 전환하기

가장 간단한 개선 방법은 CDN 링크에서 제공하는 JavaScript 파일을 다운로드하여 프로젝트 로컬에 추가하는 것이다.

<!-- tailwindcss 추가 -->
<script src="/tailwind.js"></script>

<!-- htmx 추가 -->
<script src="/htmx.js"></script>
<script src="/htmx-ext-response.js"></script>

<script type="module" src="/modal.js"></script>

추가로 스크립트의 성격에 따라 defer 속성을 사용하거나 <body> 태그 맨 아래에 배치하면 HTML을 먼저 렌더링할 수 있다.

  • defer: HTML 파싱과 병렬로 스크립트를 다운로드하고, 파싱이 완료된 후 작성된 순서대로 실행한다. DOM이 필요한 스크립트에 적합하다.
  • async: HTML 파싱과 병렬로 다운로드하되, 다운로드 완료 즉시 실행한다. 실행 순서가 보장되지 않으므로 독립적인 스크립트(예: 분석 도구)에 적합하다.
  • type="module": ES 모듈은 기본적으로 defer처럼 동작한다. 별도로 defer를 붙일 필요가 없다.

JavaScript 관리의 한계

또 다른 문제는 이런 방식으로 JavaScript 파일을 관리하면 프론트엔드 제어가 쉽지 않다는 것이다. 지금은 모달 관련 코드만 존재해서 별 탈 없이 시리즈를 마무리하지만, 실제 프로젝트에서는 상황이 다르다.

좋은 사용자 경험을 위해서는 화면의 상태를 제어하는 JavaScript 코드들이 필연적으로 추가된다. HDA 방식을 사용한다고 해서 모든 것을 서버 요청으로 해결할 수는 없다. 다음과 같은 경우에는 클라이언트 측 JavaScript가 필요하다.

  • 복잡한 폼의 상태 관리
  • 토스트 메시지, 달력 같은 UI 컴포넌트
  • 차트 라이브러리 (Chart.js, ApexCharts 등)
  • 드래그 앤 드롭 정렬 기능
  • 실시간 검색 자동완성

이런 기능들을 직접 구현하기보다 잘 만들어진 라이브러리를 가져다 쓰고 싶을 때가 많은데, 단순히 스크립트 태그를 추가하는 방식으로는 관리가 어려워진다.

TailwindCSS의 특수한 문제

위에서 외부 라이브러리들을 로컬로 잘 가져온 것처럼 이야기했지만, TailwindCSS를 다시 살펴보자. 지금은 JavaScript로 가져온 상태다. 이 상태로 프로덕션에 배포하면 페이지 로드 시 잠깐 동안 CSS가 적용되지 않은 깨진 UI가 보이게 된다.

이런 현상이 발생하는 이유는 CSS와 JavaScript의 처리 순서 때문이다.

  • 일반 CSS 파일(<link rel="stylesheet">)은 HTML 파싱 중에 바로 적용된다.
  • JavaScript로 주입되는 CSS는 스크립트가 실행된 후에야 적용된다.
  • 브라우저는 CSS가 적용되기 전의 HTML을 먼저 렌더링하기 때문에, 스타일이 없는 상태가 잠깐 보인다. 이를 FOUC(Flash of Unstyled Content)라고 한다.

보통 TailwindCSS를 제대로 사용하려면 빌드 과정이 필요하다. 개발 환경에서는 watch 모드가 HTML에 사용된 클래스를 감지하고 동적으로 CSS를 생성한다. 빌드할 때는 사용된 클래스만 포함한 최적화된 CSS 파일을 만들어 별도로 불러오는 것이다.

번들러 도입으로 해결하기

이런 문제들을 해결하려면 프론트엔드 번들러를 도입해야 한다. 대표적으로 Vite, Webpack, esbuild 등이 있다. 이 중 Vite는 빠른 개발 서버와 간편한 설정으로 최근 가장 많이 사용된다.

번들러를 도입하면 다음과 같은 이점이 있다.

  • 의존성 관리: npm으로 라이브러리를 설치하고 import 문으로 불러올 수 있다.
  • 코드 번들링: 여러 JavaScript 파일을 하나로 합쳐 네트워크 요청을 줄인다.
  • CSS 처리: TailwindCSS, Sass 등을 빌드 시점에 처리하여 최적화된 CSS를 생성한다.
  • 개발 경험: Hot Module Replacement(HMR)로 코드 변경 시 페이지를 새로고침하지 않고 즉시 반영된다.
  • 최적화: 프로덕션 빌드 시 코드 압축, 트리 쉐이킹 등을 자동으로 적용한다.

Spring Boot + Vite 구성

Spring Boot 프로젝트에서 Vite를 사용하는 구조는 다음과 같다.

src/main/
├── java/                    # Spring Boot 코드
├── frontend/                # Vite 프로젝트
│   ├── src/
│   │   ├── app.main.js      # 메인 진입점
│   │   └── style.css        # TailwindCSS 진입점
│   ├── views/               # JTE 템플릿
│   │   ├── .jteroot
│   │   ├── layouts/
│   │   ├── pages/
│   │   └── partials/
│   ├── package.json
│   └── vite.config.js
└── resources/
    ├── static/              # 기타 정적 파일
    └── vite/                # Vite 빌드 결과물
        └── builds/
            ├── app.main.js
            └── style.css

JTE 템플릿을 frontend/views로 옮긴 이유는 Vite가 템플릿 파일을 스캔하여 사용된 TailwindCSS 클래스를 추출하기 위함이다.

package.json

{
  "name": "frontend",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "devDependencies": {
    "@tailwindcss/vite": "^4.1.17",
    "tailwindcss": "^4.1.17",
    "vite": "^7.2.4"
  },
  "dependencies": {
    "htmx.org": "^2.0.8",
    "htmx-ext-response-targets": "^2.0.2"
  }
}

vite.config.js

import tailwindcss from '@tailwindcss/vite';
import { resolve } from 'path';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [tailwindcss()],
  build: {
    outDir: '../resources/vite',
    emptyOutDir: true,
    rollupOptions: {
      input: {
        'app.main': resolve(__dirname, 'src/app.main.js'),
        style: resolve(__dirname, 'src/style.css'),
      },
      output: {
        entryFileNames: 'builds/[name].js',
        assetFileNames: 'builds/[name].[ext]',
      },
    },
  },
});

src/style.css

@import "tailwindcss";
@source "../views/**/*.jte";

@source 지시어로 JTE 템플릿 파일들을 스캔 대상에 포함시킨다.

src/app.main.js

import htmx from 'htmx.org';
import 'htmx-ext-response-targets';

window.htmx = htmx;

class Modal {
    static #currentTrigger = null;

    static open(triggerElt) {
        this.#currentTrigger = triggerElt;
        const container = document.querySelector('#modalContainer');
        container.classList.replace('hidden', 'block');
        container.animate([{opacity: 0}, {opacity: 1}], {duration: 200, easing: 'ease-out'});
    }

    static close() {
        if (this.#currentTrigger) {
            htmx.trigger(this.#currentTrigger, 'htmx:abort');
            this.#currentTrigger = null;
        }
        const container = document.querySelector('#modalContainer');
        const content = document.querySelector('#HTMX_MODAL');
        return new Promise((resolve) => {
            container.animate([{opacity: 1}, {opacity: 0}], {duration: 200, easing: 'ease-out'}).onfinish = () => {
                container.classList.replace('block', 'hidden');
                content.innerHTML = '로딩중...';
                resolve();
            };
        });
    }
}

window.Modal = Modal;

window.addEventListener('htmx:beforeRequest', (event) => {
    if (event.detail.target.id === 'HTMX_MODAL') {
        Modal.open(event.detail.elt);
    }
});

window.addEventListener('htmx:beforeOnLoad', async (event) => {
    const xhr = event.detail.xhr;
    const hxLocation = xhr.getResponseHeader('HX-Location');

    const isModalOpened = document.querySelector('#modalContainer')?.classList.contains('block');
    if (hxLocation && isModalOpened) {
        event.preventDefault();
        await Modal.close();
        htmx.ajax('GET', hxLocation, {target: 'body', swap: 'innerHTML'});
    }
});

기존에 별도 파일이었던 modal.js를 메인 진입점에 통합했다. npm으로 설치한 htmx와 확장을 import하여 사용한다.

Spring Boot 설정 변경

JTE 템플릿 경로가 변경되었으므로 설정을 업데이트해야 한다.

application.properties

gg.jte.development-mode=true
gg.jte.templateLocation=src/main/frontend/views
spring.web.resources.static-locations=file:src/main/resources/static/,file:src/main/resources/vite/,classpath:/static/,classpath:/vite/

build.gradle.kts

jte {
    sourceDirectory = file("src/main/frontend/views").toPath()
    generate()
    binaryStaticContent = true
}

템플릿 수정

<!-- 기존 CDN/로컬 스크립트들을 제거하고 Vite 빌드 파일로 교체 -->
<link rel="stylesheet" href="/builds/style.css">
<script type="module" src="/builds/app.main.js"></script>

빌드 및 실행

# 패키지 설치
cd src/main/frontend
npm install

# 빌드
npm run build

# Spring Boot 실행 (프로젝트 루트에서)
./gradlew bootRun

더 알아보기

이 글에서 다룬 내용은 Vite 도입의 기본적인 부분이다. 실제 프로덕션 환경에서는 추가적인 설정이 필요할 수 있다.

  • 캐시 버스팅: 파일명에 해시를 추가하여 브라우저 캐시 문제 해결
  • 개발 서버 연동: npm run dev로 HMR을 활용한 실시간 CSS/JS 변경 반영
  • 환경별 빌드: 개발/프로덕션 환경에 따른 다른 빌드 설정

이런 고급 설정들은 백엔드 프레임워크와의 추가적인 통합이 필요하다. 관심이 있다면 아래 프로젝트들을 참고하면 도움이 될 것이다.

시리즈를 마치며

지금까지 HDA(Hypermedia-Driven Application) 시리즈를 통해 다음 내용들을 다뤘다.

  • HTMX의 기본 개념과 사용법
  • 폼 처리와 유효성 검사
  • 부분 렌더링과 상태 관리
  • 모달을 활용한 UX 개선
  • hx-boost를 이용한 SPA 같은 페이지 전환
  • Vite를 활용한 정적 리소스 관리

HDA 방식은 복잡한 프론트엔드 프레임워크 없이도 충분히 좋은 사용자 경험을 제공할 수 있다는 것을 보여준다. 물론 모든 상황에 적합한 것은 아니지만, 많은 웹 애플리케이션에서 효과적인 선택이 될 수 있다.

앞으로 HTMX를 활용한 다양한 프로젝트를 진행할 예정이다. 좋은 내용이 나오면 시리즈에 글을 추가하거나 새로운 주제로 찾아올 것이다.

읽어주셔서 감사합니다.