React에서 Spring으로

서버가 그리는 화면

Thymeleaf와 SSR vs CSR

12장 · TASKBOARD에 관리자 화면 붙이기

목차 · 진행 0%

    당신이 이 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를 내려 주는 것과 닮았다. 다만 그 전달은 클라이언트가 아니라 서버 안에서 일어난다.

    프런트 다리 · 앱 보충Model은 서버 안에서 건네는 props
    프런트에서<TaskList tasks={tasks} /> 그리고 props.tasks
    Spring에서model.addAttribute("tasks", …) 그리고 템플릿의 ${tasks}
    같은 점이름표를 붙여 데이터를 넘기고, 받는 쪽은 그 이름으로 꺼내 쓴다.
    여기서 비유가 깨진다props가 바뀌면 React는 다시 렌더링한다. Model은 요청 한 번에 한 번 쓰이고 버려진다. 서버의 값이 바뀌어도 브라우저가 다시 요청하기 전까지 화면은 그대로다. 그리고 이름표가 틀려도 TypeScript처럼 미리 잡아 주지 않고, 그 자리가 조용히 비어 버린다.
    원문 보정 · 앱 보충

    위 컨트롤러를 그대로 붙여 넣으면 두 군데에서 막힌다. 첫째, @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는 링크 주소를 동적으로 만든다. 표현식 안의 ${...}는 “상자에서 데이터를 꺼낸다”는 신호다.

    프런트 다리 · 앱 보충자동 이스케이프와 그 탈출구
    프런트에서JSX의 {task.title} / dangerouslySetInnerHTML
    Spring에서th:text / th:utext
    같은 점기본은 값을 글자로 이스케이프해서 넣고, 생 HTML을 넣는 길은 따로 있다. 사용자 입력을 생 HTML로 넣는 순간 XSS(11장)가 열린다.
    여기서 비유가 깨진다React는 브라우저에서 DOM에 텍스트 노드로 넣는다. Thymeleaf는 서버에서 <를 &lt; 같은 엔티티로 바꿔 HTML 원문에 박는다. 그래서 “페이지 소스 보기”에서 이스케이프 흔적이 그대로 보인다.
    예측 · 앱 보충먼저 답해 보세요

    표를 <table th:if="${tasks}">로 감쌌다. 컨트롤러가 빈 리스트를 담으면 표는?

    실험 · 앱 보충Model + 템플릿 → 완성된 HTML

    왼쪽은 컨트롤러가 Model에 담는 값, 가운데는 templates/tasks/list.html입니다. 값을 바꾼 뒤 요청을 보내면 서버가 그린 HTML 원문과 브라우저 화면이 나옵니다.

    모델 · tasks[0].title
    모델
    템플릿 · 제목 속성
    템플릿 · 표 조건
    요청

    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 원문

    아직 요청하지 않았습니다.

    브라우저 화면

    빈 화면
    서버 로그 · 브라우저
    -- 왼쪽에서 모델 값을 바꾸고 “GET /admin/tasks”를 눌러 서버가 그린 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를 꺼 둔 지금도 템플릿에서 지연 로딩이 터질 일이 없다.

    직접 깨뜨려 보기: 원서 그대로라면

    1. TaskAdminController에서 import org.springframework.web.bind.annotation.RequestMapping; 줄을 지우고 ./gradlew compileJava:

      TaskAdminController.java:9: error: cannot find symbol
      @RequestMapping("/admin/tasks")
       ^
        symbol: class RequestMapping
      
    2. 되돌린 뒤 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 bootRun

    http://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>&lt;script&gt;alert(1)&lt;/script&gt;</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의 문구를 고치고 새로 고쳐 보자. 바뀌지 않는다. 이유가 둘이다.

    1. ./gradlew bootRun은 src/main/resources가 아니라 빌드할 때 복사해 둔 build/resources/main의 템플릿을 읽는다.
    2. 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가 템플릿 문법을 섞어 주는 함정”으로 읽으면 된다.

    함정 찾기 · 앱 보충AI가 준 Thymeleaf 템플릿, 그대로 받아도 될까?
    내가 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 관리자 화면이 덜어 주는 짐은?

    로컬 실습 · 앱 보충TaskBoard 레포로 단계별 실습하기

    이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.

    git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
    cd toby-react-to-spring-taskboard
    git checkout ch12-1
    1. ch12-1서버가 완성해서 보내는 HTML, Thymeleaf
    2. ch12-2상세 페이지, 그리고 th:text가 막아 주는 것
    다음13장. 스펙에서 동작까지다음 장에서는 지금까지 모은 연장을 모두 들고, AI와 함께 기능 하나를 스펙부터 테스트까지 완주합니다.

    원문: 『React에서 Spring으로』 12장. 서버가 그리는 화면 — Thymeleaf, Toby-AI · CC BY-NC-SA 4.0. 이 페이지는 원문에 실습 블록을 더하고, 기술 내용은 그대로 둔 채 한국어 문장을 읽기 쉽게 다듬은 2차 저작물이며 같은 라이선스를 따릅니다. ‘앱 보충’ 표시가 붙은 블록은 원문에 없는 내용입니다.

    보기 옵션