이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.

Java 백엔드 개발자가 프론트엔드 프레임워크 없이도 인터랙티브한 웹 애플리케이션을 만들 수 있다면 어떨까요? 이 글에서는 STITCH 스택으로 JavaScript 로직을 최소화한 To-Do 앱을 처음부터 끝까지 만들어봅니다.

이 글은 GoTHIC 스택 가이드의 Java/Spring 버전입니다. Go 백엔드에 관심이 있다면 GoTHIC 편도 확인해보세요.


STITCH 스택이란#

STITCHS(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를 사용하여 불변 모델을 간결하게 정의합니다. withTitletoggleCompleted는 불변 객체의 상태 변경을 위한 복사 메서드입니다.

인메모리 스토어#

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;
    }
}

ConcurrentHashMapAtomicInteger로 동시성을 처리합니다. 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-patchhx-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!