하위 태스크 1

Thymeleaf 프로젝트 구조 파악

Controller/Service/Repository/templates 구조 이해

resources/templates 폴더는 Thymeleaf로 작성된 웹 문서 템플릿을 보관한다. 예를 들어 message.html 템플릿은 다음과 같다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Thymeleaf Sample</title>
</head>
<body>
    <p th:text="#{home.welcome}">웰컴 메시지 테스트</p>
    <p th:utext="#{home.welcome.one}">웰컴 메시지 테스트 - unescaped</p>
    <p>환영합니다. <span th:text="#{member.name}">홍길동</span> 고객님</p>
</body>
</html>

클라이언트가 요청한 문서의 경로(pathname)와 템플릿은 HomeController 클래스에 의해 매핑된다. 다음은 HomeController 클래스의 일부를 발췌한 것이다. 클라이언트가 서버의 /message 경로에 요청하면, 서버는 resources/templates/message.html을 찾아 HTML 문서를 완성하고 응답한다.

@Controller
@Slf4j
public class HomeController {
    @GetMapping("/message")
    public String getMessage(Model model) {
        return "message";
    }
    
    // ...
}

웹브라우저에서 http://localhost:8080/message로 GET 요청을 전달한 뒤, 응답된 문서의 모습은 다음과 같다. 요소 텍스트의 내용이 resources/messages.properties에 선언된 값으로 변경되어 나타난다.

하위 태스크 2

도메인 선택/정의

상품/게시글 등 도메인 클래스와 Repository 준비

게시글에 대응하는 Article 도메인 클래스와 ArticleRepository 클래스를 작성한다.

Article.java:

@Data
@Builder
@NoArgsConstructor  
@AllArgsConstructor
public class Article {
    private Long id;
    private String title;
    private String body;
    private String author;
    private LocalDateTime createdDateTime;
    private LocalDateTime modifiedDateTime;
}

ArticleRepository.java:

@Component
public class ArticleRepository {
    static private final Map<Long, Article> registry = new HashMap<>();
    static private long sequence = 0L;
 
    public Article save(Article article) {
        if (article.getId() == null) {
            article.setId(sequence += 1);
            article.setCreatedDateTime(LocalDateTime.now());
        }
        article.setModifiedDateTime(LocalDateTime.now());
        registry.put(article.getId(), article);
        return article;
    }
 
    public Optional<Article> findById(Long id) {
        return Optional.ofNullable(registry.get(id));
    }
 
    public List<Article> findAll() {
        return new ArrayList<>(registry.values());
    }
}

하위 태스크 3

리스트 Controller 구현

리스트 조회용 Controller 메서드 작성

게시글 목록 조회를 위한 컨트롤러인 ArticleController 클래스를 생성한다.

package com.example.demo;
 
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
 
import java.util.List;
 
@Controller
@RequiredArgsConstructor
public class ArticleController {
    private final ArticleRepository repository;
 
    @PostConstruct
    public void init() {
        repository.save(
            Article.builder()
                    .title("죽은 자의 집 청소")
                    .author("김 완")
                    .body("누군가 홀로 죽으면 나의 일이 시작된다.")
                    .build()
        );
        repository.save(
            Article.builder()
                    .title("1984")
                    .author("조지 오웰")
                    .body("빅 브라더가 당신을 주시하고 있다.")
                    .build()
        );
        repository.save(
            Article.builder()
                    .title("별")
                    .author("알퐁스 도데")
                    .body("나는 그 잠든 얼굴을 지켜보며 꼬빡 밤을새웠습니다.")
                    .build()
        );
    }
 
    @GetMapping("/articles")
    public String getArticles(Model model) {
        model.addAttribute("articles", repository.findAll());
        return "articles";
    }
}

하위 태스크 4

리스트 템플릿 작성

th:each 를 사용해 테이블 리스트 화면 구현

resources/templates/articles.html에 템플릿 파일을 작성한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Articles</title>
</head>
<body>
 
<table>
    <tr th:each="article : ${articles}">
        <td th:text="${article.title}"></td>
        <td th:text="${article.author}"></td>
        <td th:text="${article.body}"></td>
    </tr>
</table>
<br>
 
<table>
    <tr th:each="article, iteration : ${articles}" th:style="${iteration.odd}?'background-color: lightgray;'">
        <td th:text="${iteration.count}"></td>
        <td th:text="${article.title}"></td>
        <td th:text="${article.author}"></td>
    </tr>
</table>
<br>
 
<th:block th:each="article : ${articles}">
    <p>
        제목: <span th:text="${article.title}"></span>
        저자: <span th:text="${article.author}"></span>
        본문: <span th:text="${article.body}"></span>
        생성: <span th:text="${article.createdDateTime.toString()}"></span>
        수정: <span th:text="${article.modifiedDateTime.toString()}"></span>
    </p>
</th:block>
</body>
</html>

브라우저 렌더링 결과는 다음과 같다.

하위 태스크 5

상세 Controller 구현

단건 조회용 Controller 메서드 작성

ArticleController 클래스에 게시글 단건 조회를 위한 getArticle 메서드를 추가한다.

@Controller
@RequiredArgsConstructor
public class ArticleController {
    // ...
    
    @GetMapping("/articles/{id}")  
	public String getArticle(@PathVariable Long id, Model model) {  
	    Article article = repository.findById(id)  
	            .orElse(Article.builder().title("무제").author("미상").body("").build());  
	    model.addAttribute("article", article);  
	    return "article";  
	}
}

하위 태스크 6

상세 템플릿 작성

상세 정보를 보여주는 템플릿 구현

게시글 단건 조회를 위한 resources/templates/article.html 템플릿을 작성한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Articles</title>
</head>
<body>
 
<th:block>
  <p>
    제목: <span th:text="${article.title}"></span>
    저자: <span th:text="${article.author}"></span>
    본문: <span th:text="${article.body}"></span>
    생성: <span th:text="${article.createdDateTime.toString()}"></span>
    수정: <span th:text="${article.modifiedDateTime.toString()}"></span>
  </p>
</th:block>
</body>
</html>

브라우저 렌더링 결과는 다음과 같다.

http://localhost:8080/articles/1:

하위 태스크 7

검증 어노테이션 추가

DTO/엔티티에 @NotBlank, @Min 등 검증 추가

build.gradle에 검증을 위한 의존성을 추가한다.

dependencies {
+   implementation 'org.springframework.boot:spring-boot-starter-validation'
	// ...
}

Article 엔티티 클래스에 검증 어노테이션을 추가한다.

public class Article {
    @Min(1L)
    private Long id;
 
    @NotBlank
    private String title;
 
    @NotBlank
    private String body;
 
    @NotBlank
    private String author;
 
    private LocalDateTime createdDateTime;
    private LocalDateTime modifiedDateTime;
}

하위 태스크 8 ~ 9

등록 폼/저장 로직 구현

GET/POST /items/new 흐름 구현

검증 에러 처리

BindingResult를 이용해 에러 메시지 출력

GET /articles/new 흐름을 구현한다. ArticleControllernewArticle 메서드를 추가한다.

@Controller
@RequiredArgsConstructor
public class ArticleController {
    // ...
 
    @GetMapping("/articles/new")
    public String newArticle(Model model) {
        model.addAttribute("article", new Article());
        return "new-article";
    }
    
	// ...
}

resources/templates/new-articles.html에 새로운 게시글을 추가하는 폼을 포함한 웹 문서를 작성한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>New Article</title>
</head>
<body>
 
<form th:action="@{/articles}" th:object="${article}" method="post">
    <div>
        <label for="title">제목:</label>
        <input type="text" id="title" th:field="*{title}" />
        <span th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></span>
    </div>
    <div>
        <label for="author">저자:</label>
        <input type="text" id="author" th:field="*{author}" />
        <span th:if="${#fields.hasErrors('author')}" th:errors="*{author}"></span>
    </div>
    <div>
        <label for="body">본문:</label>
        <textarea id="body" th:field="*{body}"></textarea>
        <span th:if="${#fields.hasErrors('body')}" th:errors="*{body}"></span>
    </div>
    <div>
        <button type="submit">게시글 추가</button>
    </div>
</form>
</body>
</html>

브라우저에서 http://localhost:8080/articles/new에 접속한 결과는 다음과 같다.

이제 POST /articles 흐름을 구현한다. ArticleController 클래스에 createArticle 메서드를 구현한다.

public class ArticleController {
    // ...
 
    @PostMapping("/articles")
    public String createArticle(@Valid @ModelAttribute Article article, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "new-article";
        }
        repository.save(article);
        return "redirect:/articles";
    }
}

웹브라우저에서 http://localhost:8080/articles/new에 접속해 폼을 작성한다.

게시글 추가 버튼을 클릭하면 /articles 경로로 이동하며, 결과는 다음과 같이 새로운 게시글이 추가되었다.

http://localhost:8080/articles/new에서 유효하지 않은 값이 제출되면, 에러 메시지가 출력된다.