당신이 이 TaskBoard를 운영하게 됐다고 상상해 보자. API는 탄탄하다. REST로 잘 설계했고, 검증도 하고, JPA로 데이터도 영속화했고, JWT와 세션 두 방식으로 인증까지 붙였다. 그런데 어느 날 팀에서 이런 요청이 들어온다. “운영자가 등록된 할 일 전체를 한눈에 훑어볼 수 있는 간단한 관리자 페이지 하나만 만들어줘. 디자인은 신경 안 써도 돼. 그냥 목록이랑 상세만 보이면 돼.”
자, 당신은 React 개발자다. 이 요청을 받으면 손이 먼저 어떻게 움직일까? 아마 머릿속에 즉시 그림이 그려질 것이다. 새 Next.js 프로젝트를 하나 띄우고, 라우팅을 깔고, TaskBoard API에서 데이터를 fetch로 받아와 컴포넌트에 뿌리고, 로딩 스피너를 달고, 빌드 파이프라인을 세팅하고… 잠깐. 운영자 몇 명이 가끔 들여다볼, 목록과 상세 두 화면짜리 페이지 하나를 위해 이게 다 필요할까?
여기서 한 가지 의문이 생긴다. 화면을 만드는 길이 정말 React 하나뿐일까? 이번 장에서는 서버가 직접 HTML을 그려서 내려 주는 길인 서버 사이드 렌더링(SSR)을 Thymeleaf로 함께 걸어 본다. 그리고 React 개발자인 당신이 진짜로 궁금해할 질문, “굳이 이걸 왜 배워야 하나”에 정직하게 답해 보자.
React를 두고 왜 서버 템플릿을 보나
먼저 솔직해지자. 이 장을 펼친 당신의 속마음은 아마 이럴 것이다. “나는 이미 React를 잘 쓴다. 컴포넌트도, 상태 관리도, 서버 컴포넌트도 안다. 그런데 2000년대에나 쓰던 서버 템플릿을 지금 와서 배우라고? 이건 퇴보 아닌가?”
이 의심은 아주 정당하다. 그리고 이 의심을 가진 채로 끝까지 읽는 게 이 장의 목적이다. 결론의 방향만 미리 살짝 흘리자면, 이건 퇴보가 아니라 도구 하나를 더 갖는 일이다. 망치만 가진 사람에게는 모든 문제가 못으로 보인다. React라는 훌륭한 망치를 가진 우리가 가끔은 드라이버가 필요한 나사를 망치로 두들기고 있던 건 아닐까?
조금 더 구체적으로 들어가 보자. React/Next.js로 화면을 만들 때 실제로 무슨 일이 벌어지는가? 브라우저는 거의 빈 HTML 껍데기를 받고, 이어서 자바스크립트 번들을 내려받는다. 그 자바스크립트가 실행되면서 비로소 화면이 그려진다. 데이터가 필요하면 거기서 또 API를 fetch로 부른다. 이 모델은 실시간으로 바뀌는 대시보드, 드래그 앤 드롭이 난무하는 보드, 무한 스크롤 피드처럼 풍부한 상호작용이 필요한 애플리케이션에서 빛을 발한다. 클라이언트가 화면 그리기를 책임지니까 CSR(Client-Side Rendering), 클라이언트 사이드 렌더링이라 부른다.
그런데 운영자용 할 일 목록 페이지를 떠올려 보자. 상호작용이랄 게 거의 없다. 들어오면 목록이 보이고, 하나 누르면 상세가 보이고, 그게 끝이다. 이런 화면을 위해 별도 프로젝트, 별도 빌드, 별도 배포, 프런트와 백엔드 사이의 API 계약과 버저닝까지 짊어지는 건 솔직히 좀 번거롭지 않은가?
Thymeleaf의 단순한 모델 — 데이터 더하기 템플릿은 완성된 HTML
그렇다면 서버는 화면을 어떻게 그릴까? 여기서 Thymeleaf가 등장한다. 개념 자체는 놀랄 만큼 단순하다. 한 줄로 줄이면 이렇다.
데이터 + 템플릿 → 완성된 HTML
서버가 데이터를 들고 있고, HTML 모양의 템플릿을 들고 있다. 이 둘을 합쳐서 완성된 HTML 문서를 만들어 브라우저에 통째로 내려 준다. 브라우저는 그 HTML을 받아서 그리기만 하면 된다. 자바스크립트로 화면을 다시 조립할 필요가 없다. 데이터가 이미 HTML 안에 박혀서 도착하기 때문이다.
이 모델이 React와 얼마나 다른지 느껴 보자. React는 “빈 화면을 먼저 주고, 자바스크립트가 데이터를 채운다.” Thymeleaf는 “데이터가 이미 채워진 화면을 통째로 준다.” 전자가 조립 키트를 보내며 사용자더러 조립하라는 쪽이라면, 후자는 완성품을 보내는 쪽이다.
백문이 불여일견이다. TaskBoard에 관리자용 할 일 목록 화면을 SSR로 붙여 보자. 먼저 화면을 그릴 컨트롤러가 필요하다. 5장 내내 써 온 @RestController가 아니라는 점을 눈여겨보자.
package com.example.taskboard.controller;
import com.example.taskboard.service.TaskService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequestMapping("/admin/tasks")
public class TaskAdminController {
private final TaskService taskService;
public TaskAdminController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public String list(Model model) {
model.addAttribute("tasks", taskService.findAll());
return "tasks/list";
}
}
@Controller 메서드가 return "tasks/list"를 하면 응답 바디에는 무엇이 실릴까?
둘째, Model이라는 낯선 친구가 등장했다. 생성자 주입은 4장 이후로 익숙한 모양이지만, 메서드 파라미터의 Model은 처음 본다. 이건 서버가 들고 있는 데이터를 템플릿에 건네주는 소포 상자라고 생각하면 이해하기 쉽다. model.addAttribute("tasks", ...)는 “tasks라는 이름표를 붙여 할 일 목록을 상자에 담는다”는 뜻이고, 템플릿은 나중에 이 이름표로 데이터를 꺼내 쓴다. React로 치면 컴포넌트에 props를 내려 주는 것과 닮았다. 다만 그 전달은 클라이언트가 아니라 서버 안에서 일어난다.
<TaskList tasks={tasks} /> 그리고 props.tasksmodel.addAttribute("tasks", …) 그리고 템플릿의 ${tasks}원문 보정 · 앱 보충위 컨트롤러를 그대로 붙여 넣으면 두 군데에서 막힌다. 첫째,
@RequestMapping의 import(org.springframework.web.bind.annotation.RequestMapping)가 빠져 있어 컴파일되지 않는다. 아래 상세 메서드의@PathVariable도 같은 패키지에서 import해야 한다. 둘째, 이 장 어디에도 Thymeleaf 의존성을 추가하는 단계가 없다.build.gradle의dependencies에implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'를 넣어야 Spring Boot가templates/아래 템플릿을 찾아 렌더링한다. 이 의존성 없이return "tasks/list"를 하면 HTML 대신 에러가 돌아온다.
이제 데이터를 꺼내 쓸 템플릿 차례다. Thymeleaf 템플릿은 src/main/resources/templates/ 아래에 둔다. 방금 return "tasks/list"라고 했으니, 파일 경로는 templates/tasks/list.html이 된다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>할 일 목록 (관리자)</title>
</head>
<body>
<h1>할 일 목록</h1>
<table>
<thead>
<tr>
<th>제목</th>
<th>완료 여부</th>
</tr>
</thead>
<tbody>
<tr th:each="task : ${tasks}">
<td>
<a th:href="@{/admin/tasks/{id}(id=${task.id})}" th:text="${task.title}">제목 자리</a>
</td>
<td th:text="${task.done} ? '완료' : '진행 중'">상태 자리</td>
</tr>
</tbody>
</table>
</body>
</html>
여기서 가장 중요한 한 가지를 먼저 짚자. 이건 그냥 HTML 파일이다. 브라우저로 이 파일을 열어도 (데이터는 안 채워졌지만) 표 모양이 그대로 보인다. JSX처럼 자바스크립트와 마크업이 한 덩어리로 섞인 게 아니라, 정직한 HTML에 th:로 시작하는 특수 속성 몇 개를 얹은 것뿐이다. 이게 Thymeleaf가 “자연 템플릿(natural template)”이라 불리는 이유다.
th:each="task : ${tasks}"를 보자. 아까 컨트롤러가 Model에 tasks라는 이름표로 담아 둔 그 목록을 하나씩 꺼내 task라는 이름으로 반복한다. React의 tasks.map(task => ...)와 머릿속 그림이 똑같다. th:text="${task.title}"는 그 자리에 task.title 값을 넣으라는 뜻이고, th:href는 링크 주소를 동적으로 만든다. 표현식 안의 ${...}는 “상자에서 데이터를 꺼낸다”는 신호다.
{task.title} / dangerouslySetInnerHTMLth:text / th:utext<를 < 같은 엔티티로 바꿔 HTML 원문에 박는다. 그래서 “페이지 소스 보기”에서 이스케이프 흔적이 그대로 보인다.표를 <table th:if="${tasks}">로 감쌌다. 컨트롤러가 빈 리스트를 담으면 표는?
왼쪽은 컨트롤러가 Model에 담는 값, 가운데는 templates/tasks/list.html입니다. 값을 바꾼 뒤 요청을 보내면 서버가 그린 HTML 원문과 브라우저 화면이 나옵니다.
templates/tasks/list.html (body 일부)
<p th:if="${#lists.isEmpty(tasks)}">할 일이 없습니다.</p>
<table th:unless="${#lists.isEmpty(tasks)}">
<tr th:each="task : ${tasks}">
<td><a th:href="@{/admin/tasks/{id}(id=${task.id})}" th:text="${task.title}">제목 자리</a></td>
<td th:text="${task.done} ? '완료' : '진행 중'">상태 자리</td>
</tr>
</table>응답 HTML 원문
브라우저 화면
- 해볼 것
- 목록을 비워 “할 일이 없습니다” 문구가 대신 나오게 하기
th:if="${tasks}"조건으로 빈 목록을 렌더링해 빈 표가 남는 것 보기th:text로<script>제목을 렌더링해 글자로 무력화되는 것 보기th:utext로 같은 제목을 렌더링해 스크립트가 실행되는 것 보기
ch12-1 서버가 완성해서 보내는 HTML, Thymeleaf명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun브라우저로 http://localhost:8080/admin/tasks를 연다. 초기 데이터 5개가 표로 나온다.
"페이지 소스 보기"(Ctrl/Cmd + U)로 도착한 HTML을 본다.
<tr>
<td>
<a href="/admin/tasks/1">JPA 익히기</a>
</td>
<td>진행 중</td>
</tr>데이터가 이미 박힌 HTML이 왔다. React 앱이라면 빈 <div id="root">가 먼저 오고, 자바스크립트가 fetch로 데이터를 받아 그렸을 것이다. 여기서는 별도 fetch가 없다. Network 탭에도 문서 요청 하나뿐이다.
템플릿의 제목 자리, 상태 자리 같은 글자는 사라졌다. th:text가 서버에서 진짜 값으로 바꿨다. 템플릿 파일을 브라우저로 직접 열면(서버 없이) 그 글자들이 보인다. 디자이너가 서버 없이도 화면을 볼 수 있게 한 Thymeleaf의 설계다.
TaskService.findAll()은 TaskResponse(record)를 돌려준다. 템플릿의 ${task.title}은 record의 title()을 읽는다. 엔티티가 아니라 DTO를 넘겼으니 OSIV를 꺼 둔 지금도 템플릿에서 지연 로딩이 터질 일이 없다.
직접 깨뜨려 보기: 원서 그대로라면
-
TaskAdminController에서import org.springframework.web.bind.annotation.RequestMapping;줄을 지우고./gradlew compileJava:TaskAdminController.java:9: error: cannot find symbol @RequestMapping("/admin/tasks") ^ symbol: class RequestMapping -
되돌린 뒤
build.gradle에서spring-boot-starter-thymeleaf줄을 지우고 띄워/admin/tasks를 요청한다. 브라우저로 열면 404 오류 페이지(Whitelabel Error Page)가, curl로 받으면 이런 JSON이 온다:curl -i http://localhost:8080/admin/tasks HTTP/1.1 404 {"timestamp":"…","status":404,"error":"Not Found","path":"/admin/tasks"}템플릿 엔진이 없으면
"tasks/list"는 템플릿 이름이 아니라/tasks/list라는 경로로 넘겨지고, 그런 경로가 없으니 404다.
확인했으면 git checkout -- .으로 되돌린다.
상세 화면도 같은 방식으로 붙인다. 컨트롤러에 메서드 하나를 더한다.
@GetMapping("/{id}")
public String detail(@PathVariable Long id, Model model) {
model.addAttribute("task", taskService.findById(id));
return "tasks/detail";
}
@PathVariable로 URL의 id를 받아 해당 할 일 하나를 찾고(5장에서 REST API로 했던 조회를 그대로 재사용한다. 같은 TaskService다), task라는 이름으로 상자에 담아 tasks/detail 템플릿으로 넘긴다. 여기서 한 가지 안도할 만한 점이 있다. 비즈니스 로직은 새로 만들지 않았다. 6~8장에서 정성껏 키운 TaskService를 그대로 다시 쓴다. SSR 화면은 그 위에 얇은 표현 계층 하나를 더 얹은 것뿐이다. 기억해 두자. SSR을 붙인다고 해서 우리가 쌓아 온 서비스·도메인 계층이 흔들리지 않는다.
ch12-2 상세 페이지, 그리고 th:text가 막아 주는 것명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRunhttp://localhost:8080/admin/tasks에서 제목을 눌러 상세로 간다.
XSS: th:text와 th:utext
제목이 스크립트인 할 일을 만든다(10장 로그인이 필요하다).
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "ara", "password": "pass1234"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "<script>alert(1)</script>"}'새 할 일의 id는 6이다. 응답 원문을 본다.
curl -s http://localhost:8080/admin/tasks/6 | grep "<h1"<h1><script>alert(1)</script></h1>th:text는 값을 HTML로 해석하지 않고 글자로 이스케이프한다. 브라우저에는 <script>alert(1)</script>라는 글자가 보이고, 스크립트는 실행되지 않는다. React의 {title}이 기본으로 이스케이프하는 것과 같다.
이제 detail.html의 <h1 th:text=…>를 th:utext로 바꾸고 서버를 다시 띄워(초기 데이터부터 다시 들어가니 위 POST도 다시) 같은 요청을 한다.
<h1><script>alert(1)</script></h1>이스케이프 없이 그대로 박혔다. 브라우저로 열면 알림 창이 뜬다. 사용자가 입력한 값을 th:utext로 내보내면, 누군가 심은 스크립트가 이 페이지를 연 모든 관리자의 브라우저에서 실행된다(XSS). React의 dangerouslySetInnerHTML과 같은 자리다. 확인했으면 git checkout -- .으로 되돌린다.
템플릿을 고쳤는데 그대로라면
서버를 켠 채 detail.html의 문구를 고치고 새로 고쳐 보자. 바뀌지 않는다. 이유가 둘이다.
./gradlew bootRun은src/main/resources가 아니라 빌드할 때 복사해 둔build/resources/main의 템플릿을 읽는다.spring.thymeleaf.cache기본값이true라, 한 번 읽은 템플릿을 다시 읽지 않는다.
application.yml에 개발 중에만 쓸 설정을 넣고 띄워 본다.
spring:
thymeleaf:
cache: false템플릿을 고친 뒤 다른 터미널에서 복사만 다시 한다.
./gradlew processResources새로 고치면 이번엔 바뀐다. 같은 순서를 cache: true(기본값)로 하면 복사해도 그대로다. IDE에서 실행하거나 spring-boot-devtools를 쓰면 이 과정을 대신해 준다. 운영에서는 캐시를 켜 둔다. 확인했으면 git checkout -- .으로 되돌린다.
프런트 관점으로 정렬하기 — Next.js의 그 개념들과 어떻게 맞물리나
여기까지 따라온 React 개발자라면 머릿속에서 자꾸 무언가가 겹쳐 떠오를 것이다. “이거… 어디서 본 것 같은데?” 맞다. React 진영도 이미 서버 렌더링을 향해 한참 걸어왔다. 그 익숙한 개념들과 Thymeleaf를 나란히 놓아 보면 SSR이 결코 낯선 외계 기술이 아니라는 게 보인다.
Next.js를 떠올려 보자. Next.js의 SSR은 서버에서 React 컴포넌트를 미리 렌더링해 완성된 HTML을 내려 준 뒤, 클라이언트에서 자바스크립트로 다시 “수화(hydration)”한다. 더 최근의 서버 컴포넌트(React Server Components)는 한 발 더 나아가, 일부 컴포넌트를 아예 서버에서만 렌더링하고 클라이언트로는 결과 HTML만 보낸다. 데이터 페칭도 서버에서 끝내 버린다. 이 흐름은 어디를 향하는가? “클라이언트에서 다 하지 말고, 서버가 할 수 있는 건 서버에서 하자”는 쪽이다.
Next.js SSR과 비교했을 때 Thymeleaf SSR에 없는 단계는?
이 대응을 한눈에 정리하면 이렇다.
- Next.js CSR(클라이언트 컴포넌트) ↔︎ 브라우저가 자바스크립트로 화면을 조립. 풍부한 상호작용.
- Next.js SSR / 서버 컴포넌트 ↔︎ 서버가 미리 그려서 내려 주되, 일부는 클라이언트가 이어받음.
- Thymeleaf SSR ↔︎ 서버가 전부 그려서 완성된 HTML만 내려 줌. 가장 단순한 모델. (앞서 본 “데이터 + 템플릿 → 완성된 HTML”이 이것이다.)
이렇게 줄 세워 보면, Thymeleaf는 React 진영이 SSR·서버 컴포넌트로 다시 발견하고 있는 “서버 렌더링의 단순함과 빠른 첫 화면”이라는 가치를 처음부터 붙들고 있던 도구다. 우리가 새것을 배우는 게 아니라, 익숙한 개념의 가장 단순한 사촌을 만나는 셈이다. (프런트엔드 개념과 Spring의 대응을 한 번에 훑고 싶다면 부록 A의 멘탈 모델 대조표를 함께 펼쳐 두자.)
그래서, 굳이 Thymeleaf? — 언제 무엇을 쓸지
이제 가장 솔직한 질문에 답할 차례다. “그래서 언제 Thymeleaf를 쓰고 언제 React를 쓰나?” 답은 정해진 게 아니라 상황에 달려 있다. 양쪽이 빛나는 자리가 다르다.
SSR(Thymeleaf)이 더 단순하고 빠른 자리부터 보자.
먼저 관리자 화면이나 사내 도구다. 이 장 첫머리의 그 운영자 페이지가 딱 그렇다. 사용자가 적고, 상호작용이 단순하고, 화려한 UX가 필요 없다. 여기에 별도 React 프로젝트를 세우는 건 못 하나 박으려고 공장을 짓는 격이다. 백엔드 프로젝트 안에 컨트롤러와 템플릿 몇 개를 더하는 편이 훨씬 빠르고 가볍다.
다음은 간단한 CRUD 페이지다. 목록을 보고, 하나를 누르고, 폼을 채워 저장하는 전형적인 게시판류 화면이다. 이런 건 클라이언트 상태 관리가 거의 필요 없다. 서버가 그려서 주면 그만이다.
그리고 SEO가 중요한 페이지다. 검색 엔진 크롤러는 자바스크립트를 실행해 화면을 조립하는 데 약하다(점점 나아지고는 있지만 여전히 까다롭다). 완성된 HTML이 처음부터 도착하는 SSR은 크롤러에게 친절하다. 마케팅 랜딩 페이지나 공개 콘텐츠 페이지가 여기 해당한다.
관리자 화면을 React 대신 Thymeleaf로 만들면 통째로 사라지는 짐은?
물론 React/CSR이 옳은 자리도 분명하다. 실시간으로 상태가 바뀌는 대시보드, 복잡한 폼 인터랙션, 드래그 앤 드롭, 낙관적 업데이트(optimistic update), 풍부한 애니메이션처럼 사용자와 화면이 끊임없이 대화하는 애플리케이션이라면 클라이언트가 화면을 쥐고 있어야 한다. 그리고 모바일 앱처럼 여러 종류의 클라이언트가 같은 백엔드를 공유한다면, 화면에서 분리된 순수한 REST API가 반드시 필요하다. 우리가 5장부터 공들여 만든 그 API가 가치 있는 이유가 바로 이것이다.
어느 쪽이 항상 낫다고 여기기 쉽다. 하지만 “React가 무조건 현대적이고 우월하다”거나 “SSR이 무조건 단순하고 빠르다”는 단정은 둘 다 틀렸다. 화면의 성격이 결정한다. 상호작용이 풍부하면 CSR, 단순한 표현이 목적이면 SSR. 그뿐이다.
그러니 이 장 첫머리에서 품었던 “서버 템플릿을 배우는 건 퇴보 아닌가”라는 의심으로 돌아가 답하자. 퇴보가 아니다. 도구 선택이다. 모든 화면을 React로 짓던 사람이, 어떤 화면 앞에서는 “이건 Thymeleaf가 더 낫겠는데” 하고 판단할 수 있게 되는 것, 그게 진보다. 가진 연장이 하나 더 늘었고, 무엇보다 언제 어느 연장을 들지 가늠하는 눈을 얻었다.
원문 보정 · 앱 보충이 사이드바의 첫 문단은 “React 경험 때문에 빠지기 쉬운 함정”을 예고하지만, 실제로 설명하는 함정은 AI가 Thymeleaf 표현식을 다른 템플릿 엔진과 헷갈리는 문제다. React 경험과 직접 이어지는 함정은 아니니, “AI가 템플릿 문법을 섞어 주는 함정”으로 읽으면 된다.
“TaskBoard 관리자용 할 일 목록 Thymeleaf 템플릿 만들어줘. 컨트롤러는
model.addAttribute("tasks", …)로 넘겨.”의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
마무리
이번 장에서는 화면을 만드는 또 하나의 길을 손에 넣었다. Thymeleaf의 모델은 단순했다. 데이터 + 템플릿 → 완성된 HTML. @RestController 대신 @Controller를 쓰고, Model이라는 상자에 데이터를 담아 템플릿으로 넘기면, 서버가 직접 HTML을 그려 내려 준다. TaskBoard에 관리자용 목록·상세 화면을 SSR로 붙이면서, 6~8장에서 키운 TaskService를 한 줄도 새로 짜지 않고 그대로 재사용했다는 점도 확인했다.
그리고 React 개발자로서 가장 정직한 질문에 답했다. “굳이 Thymeleaf?” 답은 상황에 달려 있다. 관리자 화면·간단한 CRUD·SEO 페이지처럼 표현이 목적인 화면은 SSR이 빠르고 단순하며, 프런트/백 버저닝과 API 계약의 짐을 덜어 준다. 반대로 풍부한 상호작용이 필요하거나 여러 클라이언트가 한 백엔드를 공유한다면, 우리가 5장부터 공들여 만든 순수 REST API와 React/CSR이 답이다. 어느 쪽도 항상 옳지 않다. 기억해 두자. 이건 퇴보가 아니라 도구 선택이고, 진짜 실력은 언제 어느 도구를 드는지 가늠하는 눈에서 나온다.
이제 우리 곁엔 제법 많은 연장이 모였다. REST API, JPA, 두 가지 인증, 그리고 방금 더한 SSR. 다음 장에서는 이 연장들을 한자리에 모아 진짜 일을 해 본다. 백엔드를 이제 좀 아는 우리가 AI와 함께 실제 기능 하나를 스펙에서 설계, 구현, 테스트까지 처음부터 끝까지 완주하는 워크플로우다. 지금까지 장마다 곁눈질로 익혀 온 AI 검수 감각이 거기서 비로소 하나의 흐름으로 꿰어진다. 그럼, 우리가 쌓은 모든 것을 한 번에 써먹으러 가 보자.
1. @Controller 메서드의 return "tasks/list"는?
2. 사용자가 입력한 제목을 출력할 때 기본으로 써야 하는 속성은?
3. th:if="${tasks}"에 빈 리스트가 오면?
4. SSR 관리자 화면이 덜어 주는 짐은?
이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch12-1