STITCH 스택으로 JavaScript 없이 모던 웹앱 만들기
이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Java 백엔드 개발자가 프론트엔드 프레임워크 없이도 인터랙티브한 웹 애플리케이션을 만들 수 있다면 어떨까요? 이 글에서는 STITCH 스택으로 JavaScript 로직을 최소화한 To-Do 앱을 처음부터 끝까지 만들어봅니다.
이 글은 GoTHIC 스택 가이드의 Java/Spring 버전입니다. Go 백엔드에 관심이 있다면 GoTHIC 편도 확인해보세요.
STITCH 스택이란#
STITCH는 S(pring Boot) + T(hymeleaf) + I(nteractive) + T(ailwindCSS + DaisyUI) + C(omponents) + H(TMX)의 축약어입니다. “꿰매다"라는 뜻 그대로, 서버 사이드 렌더링과 클라이언트 인터랙션을 자연스럽게 이어붙이는 풀스택 조합입니다.
| 기술 | STITCH에서의 역할 |
|---|---|
| Spring Boot 4 | S — Java 웹 프레임워크. MVC, DI, 자동 설정 등 |
| Thymeleaf | T — 서버 사이드 HTML 템플릿 엔진 |
| Alpine.js | I+C — 최소한의 클라이언트 상태 관리 (토글, 모달 등) |
| TailwindCSS + DaisyUI | I+T+C — 유틸리티 퍼스트 CSS + UI 컴포넌트 |
| HTMX | H — HTML 속성만으로 AJAX 요청, DOM 교체 |
React, Vue, Svelte 같은 SPA 프레임워크는 강력하지만, 모든 프로젝트에 필요한 건 아닙니다. 특히 Spring 생태계에 익숙한 개발자라면 프론트엔드 빌드 파이프라인을 별도로 관리하는 것 자체가 부담일 수 있습니다.
STITCH의 핵심 아이디어는 서버가 HTML을 렌더링하고, 브라우저는 그걸 잘 보여주는 것입니다. HTMX가 서버와의 통신을 담당하고, Alpine.js가 순수 클라이언트 사이드 인터랙션(모달 열기/닫기, 드롭다운 토글 등)을 처리합니다. 둘의 역할이 명확히 나뉘기 때문에 JavaScript를 직접 작성할 일이 거의 없습니다.
프로젝트 초기 세팅#
사전 준비#
- Java 25 (또는 21 LTS 이상)
- Node.js (TailwindCSS 빌드용)
Spring Initializr#
start.spring.io에서 다음 설정으로 프로젝트를 생성합니다.
- Project: Gradle (Kotlin DSL)
- Language: Java
- Spring Boot: 4.0.x
- Dependencies: Spring Web, Thymeleaf, Spring Boot DevTools
또는 CLI로 직접 생성할 수도 있습니다.
curl https://start.spring.io/starter.zip \
-d type=gradle-project-kotlin \
-d language=java \
-d bootVersion=4.0.0 \
-d baseDir=todo-app \
-d groupId=com.example \
-d artifactId=todo-app \
-d dependencies=web,thymeleaf,devtools \
-o todo-app.zip
unzip todo-app.zip && cd todo-app
디렉토리 구조#
todo-app/
├── src/main/
│ ├── java/com/example/todoapp/
│ │ ├── TodoAppApplication.java
│ │ ├── todo/
│ │ │ ├── Todo.java
│ │ │ ├── TodoStore.java
│ │ │ └── TodoController.java
│ │ └── config/
│ │ └── WebConfig.java
│ └── resources/
│ ├── templates/
│ │ ├── index.html
│ │ └── fragments/
│ │ ├── layout.html
│ │ ├── todo-form.html
│ │ ├── todo-list.html
│ │ └── todo-item.html
│ ├── static/
│ │ └── css/
│ │ └── output.css
│ └── application.yaml
├── src/frontend/
│ └── css/
│ └── input.css
├── tailwind.config.js
├── package.json
├── build.gradle.kts
└── Makefile
TailwindCSS + DaisyUI 설정#
npm init -y
npm install -D tailwindcss @tailwindcss/cli daisyui
tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/main/resources/templates/**/*.html"],
theme: {
extend: {},
},
plugins: [require("daisyui")],
daisyui: {
themes: ["light", "dark", "cupcake"],
},
}
src/frontend/css/input.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
TailwindCSS 빌드 명령:
npx @tailwindcss/cli -i ./src/frontend/css/input.css \
-o ./src/main/resources/static/css/output.css --watch
개발 시
--watch플래그로 실행해두면 템플릿 변경 시 CSS가 자동으로 재빌드됩니다.
Spring Boot DevTools#
Spring Boot DevTools가 의존성에 포함되어 있으면 클래스 변경 시 자동 재시작, 템플릿 변경 시 라이브 리로드가 동작합니다. application.yaml에서 Thymeleaf 캐시를 꺼두면 템플릿 수정이 즉시 반영됩니다.
src/main/resources/application.yaml:
spring:
thymeleaf:
cache: false
devtools:
restart:
enabled: true
livereload:
enabled: true
모델과 인메모리 스토어#
모델 정의#
src/main/java/com/example/todoapp/todo/Todo.java:
package com.example.todoapp.todo;
import java.time.LocalDateTime;
public record Todo(
String id,
String title,
boolean completed,
LocalDateTime createdAt
) {
public Todo withTitle(String title) {
return new Todo(this.id, title, this.completed, this.createdAt);
}
public Todo toggleCompleted() {
return new Todo(this.id, this.title, !this.completed, this.createdAt);
}
}
Java 25의 record를 사용하여 불변 모델을 간결하게 정의합니다. withTitle과 toggleCompleted는 불변 객체의 상태 변경을 위한 복사 메서드입니다.
인메모리 스토어#
src/main/java/com/example/todoapp/todo/TodoStore.java:
package com.example.todoapp.todo;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class TodoStore {
private final Map<String, Todo> todos = new ConcurrentHashMap<>();
private final AtomicInteger sequence = new AtomicInteger(0);
public Todo create(String title) {
var id = "todo-" + sequence.incrementAndGet();
var todo = new Todo(id, title, false, LocalDateTime.now());
todos.put(id, todo);
return todo;
}
public List<Todo> findAll() {
return todos.values().stream()
.sorted(Comparator.comparing(Todo::createdAt).reversed())
.toList();
}
public Optional<Todo> toggle(String id) {
return Optional.ofNullable(todos.computeIfPresent(id,
(_, todo) -> todo.toggleCompleted()));
}
public Optional<Todo> update(String id, String title) {
return Optional.ofNullable(todos.computeIfPresent(id,
(_, todo) -> todo.withTitle(title)));
}
public boolean delete(String id) {
return todos.remove(id) != null;
}
}
ConcurrentHashMap과 AtomicInteger로 동시성을 처리합니다. computeIfPresent를 활용하면 원자적 업데이트와 존재 여부 확인을 한 번에 할 수 있습니다. 프로덕션에서는 당연히 JPA/DB를 쓰겠지만, 이 예제에서는 스택 자체에 집중하기 위해 인메모리로 충분합니다.
Thymeleaf 템플릿 작성#
레이아웃#
Thymeleaf Fragment를 활용하여 레이아웃을 구성합니다.
src/main/resources/templates/fragments/layout.html:
<!DOCTYPE html>
<html lang="ko" data-theme="cupcake"
xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head(title)">
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title th:text="${title}">To-Do App</title>
<link href="/css/output.css" rel="stylesheet"/>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
</html>
메인 페이지#
src/main/resources/templates/index.html:
<!DOCTYPE html>
<html lang="ko" data-theme="cupcake"
xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('To-Do App')}"></head>
<body class="min-h-screen bg-base-200">
<div class="container mx-auto max-w-2xl px-4 py-8">
<div class="flex flex-col gap-6">
<!-- 헤더: Alpine.js 테마 토글 -->
<div th:replace="~{fragments/todo-form :: theme-toggle}"></div>
<!-- 할일 입력 폼 -->
<div th:replace="~{fragments/todo-form :: form}"></div>
<!-- 할일 목록 -->
<div id="todo-list">
<div th:replace="~{fragments/todo-list :: list(${todos})}"></div>
</div>
</div>
</div>
</body>
</html>
Alpine.js 테마 토글 + 할일 입력 폼#
src/main/resources/templates/fragments/todo-form.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- ThemeToggleHeader: Alpine.js가 빛나는 순간입니다. -->
<!-- 서버 왕복 없이 클라이언트에서 즉시 반응해야 하는 UI에 Alpine.js를 씁니다. -->
<div th:fragment="theme-toggle"
class="navbar bg-base-100 rounded-box shadow-md"
x-data="{
theme: localStorage.getItem('theme') || 'cupcake',
themes: ['cupcake', 'light', 'dark'],
setTheme(t) {
this.theme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
}
}"
x-init="setTheme(theme)">
<div class="flex-1">
<span class="text-xl font-bold px-4">📝 To-Do App</span>
</div>
<div class="flex-none">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1">
🎨 테마
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<ul tabindex="0"
class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-box w-40">
<template x-for="t in themes" :key="t">
<li>
<button x-text="t"
@click="setTheme(t)"
:class="{ 'active': theme === t }"
class="capitalize">
</button>
</li>
</template>
</ul>
</div>
</div>
</div>
<!-- TodoForm: HTMX로 서버에 할일 추가 요청 -->
<div th:fragment="form" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<form hx-post="/todos"
hx-target="#todo-list"
hx-swap="innerHTML"
hx-on::after-request="this.reset()"
class="flex gap-2">
<input type="text"
name="title"
placeholder="할 일을 입력하세요..."
required
class="input input-bordered flex-1"
autocomplete="off"/>
<button type="submit" class="btn btn-primary">추가</button>
</form>
</div>
</div>
</html>
여기서 HTMX와 Alpine.js의 역할 분리가 명확하게 드러납니다.
- 테마 토글: 순수 클라이언트 로직이므로 Alpine.js가 담당합니다. 서버에 요청할 이유가 없습니다.
- 할일 추가 폼: 서버에 데이터를 보내고 응답 HTML로 목록을 교체해야 하므로 HTMX가 담당합니다.
할일 목록 및 아이템#
src/main/resources/templates/fragments/todo-list.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="list(todos)">
<!-- 빈 상태 -->
<div th:if="${#lists.isEmpty(todos)}" class="card bg-base-100 shadow-md">
<div class="card-body items-center text-center py-12">
<p class="text-base-content/50 text-lg">
아직 할 일이 없어요. 위에서 추가해보세요!
</p>
</div>
</div>
<!-- 목록 -->
<div th:unless="${#lists.isEmpty(todos)}" class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<div class="flex flex-col divide-y divide-base-200">
<div th:each="todo : ${todos}"
th:replace="~{fragments/todo-item :: item(${todo})}">
</div>
</div>
</div>
</div>
</div>
</html>
src/main/resources/templates/fragments/todo-item.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- TodoItem: Alpine.js 인라인 편집 + HTMX 서버 동기화 -->
<div th:fragment="item(todo)"
th:id="'todo-' + ${todo.id()}"
class="flex items-center gap-3 py-3 group"
x-data="{ editing: false }">
<!-- 완료 토글: HTMX -->
<input type="checkbox"
class="checkbox checkbox-primary"
th:checked="${todo.completed()}"
th:attr="hx-patch='/todos/' + ${todo.id()} + '/toggle'"
hx-target="#todo-list"
hx-swap="innerHTML"/>
<!-- 보기 모드 -->
<span x-show="!editing"
@dblclick="editing = true"
th:text="${todo.title()}"
th:classappend="${todo.completed()} ? 'line-through opacity-50'"
class="flex-1 cursor-pointer select-none">
</span>
<!-- 편집 모드: Alpine.js 토글 + HTMX 저장 -->
<form x-show="editing"
x-cloak
th:attr="hx-put='/todos/' + ${todo.id()}"
hx-target="#todo-list"
hx-swap="innerHTML"
@htmx:after-request.window="editing = false"
class="flex-1 flex gap-2">
<input type="text"
name="title"
th:value="${todo.title()}"
class="input input-bordered input-sm flex-1"
x-ref="editInput"
@keydown.escape="editing = false"/>
<button type="submit" class="btn btn-sm btn-success">저장</button>
<button type="button" @click="editing = false"
class="btn btn-sm btn-ghost">취소</button>
</form>
<!-- 삭제 버튼: HTMX -->
<button x-show="!editing"
th:attr="hx-delete='/todos/' + ${todo.id()}"
hx-target="#todo-list"
hx-swap="innerHTML"
hx-confirm="정말 삭제하시겠어요?"
class="btn btn-ghost btn-sm opacity-0 group-hover:opacity-100
transition-opacity text-error">
✕
</button>
</div>
</html>
TodoItem이 STITCH 스택의 조합을 가장 잘 보여주는 컴포넌트입니다.
- 더블클릭으로 인라인 편집 전환:
x-data="{ editing: false }"와@dblclick="editing = true"는 Alpine.js가 처리합니다. UI 상태 토글이므로 서버가 관여할 필요가 없습니다. - 편집 내용 저장:
hx-put으로 서버에 보내고 응답 HTML로 목록을 교체합니다. 서버 통신은 HTMX의 영역입니다. - 체크박스 토글, 삭제: 마찬가지로 HTMX가 서버와 통신합니다.
- 호버 시 삭제 버튼 노출:
group-hover:opacity-100은 순수 CSS(Tailwind)입니다.
Thymeleaf에서 HTMX 속성을 동적으로 바인딩할 때는 th:attr을 사용합니다. hx-patch나 hx-delete처럼 Thymeleaf가 기본 지원하지 않는 속성은 th:attr="hx-patch='/todos/' + ${todo.id()} + '/toggle'"으로 처리할 수 있습니다.
Spring MVC 컨트롤러#
src/main/java/com/example/todoapp/todo/TodoController.java:
package com.example.todoapp.todo;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@Controller
public class TodoController {
private final TodoStore store;
public TodoController(TodoStore store) {
this.store = store;
}
@GetMapping("/")
public String index(Model model) {
model.addAttribute("todos", store.findAll());
return "index";
}
@PostMapping("/todos")
public String create(@RequestParam String title, Model model) {
if (title.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
store.create(title.strip());
model.addAttribute("todos", store.findAll());
return "fragments/todo-list :: list";
}
@PatchMapping("/todos/{id}/toggle")
public String toggle(@PathVariable String id, Model model) {
store.toggle(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("todos", store.findAll());
return "fragments/todo-list :: list";
}
@PutMapping("/todos/{id}")
public String update(@PathVariable String id,
@RequestParam String title,
Model model) {
if (title.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
store.update(id, title.strip())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("todos", store.findAll());
return "fragments/todo-list :: list";
}
@DeleteMapping("/todos/{id}")
public String delete(@PathVariable String id, Model model) {
if (!store.delete(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
model.addAttribute("todos", store.findAll());
return "fragments/todo-list :: list";
}
}
컨트롤러 코드를 보면 패턴이 보입니다. HTMX 요청의 응답으로 전체 페이지가 아닌 Thymeleaf Fragment를 반환합니다. "fragments/todo-list :: list"라는 반환값이 핵심인데, Thymeleaf가 해당 fragment만 렌더링하여 응답하고 HTMX가 #todo-list를 이 응답으로 교체합니다.
GoTHIC에서 Templ 컴포넌트를 파셜로 반환하던 것과 같은 패턴이지만, Thymeleaf는 Fragment Expression(:: fragmentName)을 통해 별도 설정 없이 이를 지원합니다. 서버가 곧 진실의 원천(Single Source of Truth)이 되는 점도 동일합니다.
PATCH/PUT/DELETE 지원 설정#
기본적으로 HTML form은 GET/POST만 지원합니다. HTMX는 hx-patch, hx-put, hx-delete 속성으로 직접 HTTP 메서드를 지정하므로 별도 설정이 필요 없지만, Spring의 HiddenHttpMethodFilter를 활성화해두면 일반 form에서도 유용합니다.
src/main/resources/application.yaml에 추가:
spring:
mvc:
hiddenmethod:
filter:
enabled: true
메인 애플리케이션#
src/main/java/com/example/todoapp/TodoAppApplication.java:
package com.example.todoapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TodoAppApplication {
public static void main(String[] args) {
SpringApplication.run(TodoAppApplication.class, args);
}
}
Gradle 빌드 설정#
build.gradle.kts:
plugins {
java
id("org.springframework.boot") version "4.0.0"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
developmentOnly("org.springframework.boot:spring-boot-devtools")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
개발 서버 실행#
터미널을 두 개 열어서 각각 실행합니다.
# 터미널 1: TailwindCSS 빌드 (watch 모드)
npx @tailwindcss/cli -i ./src/frontend/css/input.css \
-o ./src/main/resources/static/css/output.css --watch
# 터미널 2: Spring Boot 서버 (DevTools 핫 리로드)
./gradlew bootRun
브라우저에서 http://localhost:8080에 접속하면 To-Do 앱이 동작합니다. Java 코드를 수정하면 DevTools가 자동으로 재시작하고, Thymeleaf 템플릿을 수정하면 새로고침만으로 반영됩니다. Tailwind 클래스를 변경하면 CSS가 자동으로 재빌드됩니다.
HTMX vs Alpine.js: 언제 무엇을 쓸까#
STITCH 스택에서 가장 중요한 판단은 “이 인터랙션을 HTMX로 할까, Alpine.js로 할까"입니다. 기준은 단순합니다.
HTMX를 쓰는 경우:
- 서버의 데이터가 변경되어야 할 때 (CRUD 작업)
- 응답으로 받은 HTML로 DOM을 교체할 때
- 폼 제출, 페이지네이션, 무한 스크롤 등
Alpine.js를 쓰는 경우:
- 순수 클라이언트 UI 상태 관리 (토글, 모달, 드롭다운)
- 서버 왕복 없이 즉시 반응해야 할 때
- 테마 전환, 폼 유효성 검사, 애니메이션 제어 등
둘 다 쓰는 경우 (이 예제의 인라인 편집):
- Alpine.js가 편집 모드 전환 (UI 상태)을 관리하고
- HTMX가 수정된 내용을 서버로 전송 (데이터 변경)합니다
사용자 액션 → 서버 필요? ─ Yes → HTMX
└ No → Alpine.js (또는 순수 CSS)
GoTHIC vs STITCH: 같은 철학, 다른 도구#
GoTHIC 편을 읽으셨다면 구조가 거의 동일하다는 걸 눈치채셨을 겁니다. 두 스택의 차이는 서버 사이드 기술에 있고, 프론트엔드 레이어(HTMX + Alpine.js + TailwindCSS + DaisyUI)는 완전히 동일합니다.
| GoTHIC | STITCH | |
|---|---|---|
| 언어 | Go | Java 25 |
| 웹 프레임워크 | Gin | Spring Boot 4 |
| 템플릿 | Templ (컴파일 타임 타입 체크) | Thymeleaf (Natural Template) |
| 파셜 응답 | render(c, component) |
"fragment :: name" 반환 |
| 핫 리로드 | air | Spring DevTools |
| 동시성 | sync.RWMutex |
ConcurrentHashMap |
Thymeleaf의 장점은 Natural Template이라는 점입니다. .html 파일을 브라우저에서 직접 열어도 깨지지 않고 정적 HTML로 보입니다. 디자이너와 협업할 때 유리합니다. 반면 Templ은 컴파일 타임에 타입 체크가 되므로 안전성 측면에서 우위가 있습니다.
한 걸음 더: Makefile로 개발 편의성 높이기#
매번 여러 터미널에서 명령어를 실행하는 건 번거롭습니다. Makefile로 정리해봅시다.
.PHONY: dev build css
# 개발 서버 (Spring Boot + Tailwind watch)
dev:
@echo "Starting development server..."
@make -j2 boot css-watch
boot:
./gradlew bootRun
css-watch:
npx @tailwindcss/cli -i ./src/frontend/css/input.css \
-o ./src/main/resources/static/css/output.css --watch
# 프로덕션 빌드
build: css
./gradlew build
css:
npx @tailwindcss/cli -i ./src/frontend/css/input.css \
-o ./src/main/resources/static/css/output.css --minify
이제 make dev 한 번이면 Spring Boot 서버와 TailwindCSS 워치 프로세스가 동시에 실행됩니다.
마무리#
STITCH 스택의 본질은 서버 사이드 렌더링으로 회귀하되, 현대적 DX를 포기하지 않는 것입니다.
- Thymeleaf가 Natural Template 방식의 HTML을 생성하고
- HTMX가 페이지 리로드 없는 인터랙션을 제공하며
- Alpine.js가 서버 왕복이 불필요한 클라이언트 인터랙션을 처리하고
- TailwindCSS + DaisyUI가 빠른 스타일링을 가능하게 하고
- Spring Boot DevTools가 쾌적한 개발 경험을 보장합니다
JavaScript 번들러도 없고, 프론트엔드 빌드 파이프라인도 단순하며, Spring 생태계의 풍부한 인프라 위에서 개발할 수 있습니다. 이미 Spring에 익숙한 팀이라면 STITCH를 한 번 시도해볼 만합니다.
모든 소스 코드는 위에서 다룬 파일들을 그대로 구성하면 동작합니다. Happy hacking!