본문 바로가기

SpringBoot

[스프링 부트 3 백엔드 개발자 되기] ch 7. 블로그 화면 구성하기

1. 타임리프 

 

1) 템플릿 엔진 개념

 

- 스프링 서버에서 데이터를 받아 우리가 보는 웹 페이지, 즉 HTML 상에 그 데이터를 넣어 보여주는 도구

 

ex)

<h1 text=${이름}>
<p text=${나이}>

 

- h1 태그에는 ${이름}이 text 어트리뷰트로 할당되어 있고, p 태그에는 ${나이}가 text 어트리뷰트로 할당되어 있다.

- 이렇게 해두면 서버에서 이름,나이라는 키로 데이터를 템플릿 엔진에 넘겨주고 템플릿 엔진은 이를 받아 HTML에 값을 적용한다. 

 

ex) 서버에서 보여준 데이터 예

{
    이름:"홍길동"
    나이:11
}

 

- 값이 달라지면 그때그때 화면에 반영하여 동적인 웹 페이지를 만들 수 있다. 

- 대표적인 템플릿 엔진으로는 JSP, 타임리프, 프리마커 등이 있다. 

 

 

타임리프 표현식과 문법 

 

- 표현식은 전달받은 데이터를 사용자들이 볼 수 있는 뷰로 만들기 위해 사용되는 표현식 

 

타임리프 표현식 

표현식 설명
${...} 변수의 값 표현식
#{...}  속성 파일 값 표현식
@{...} URL 표현식
*{...} 선택한 변수의 표현식. th:object에서 선택한 객체에 접근 

 

타임리프 문법 

표현식 설명 예제
th:text 텍스트를 표현할 때 사용 th:text=${person.name}
th:each 컬렉션을 반복할 때 사용 th:each="person : ${persons}"
th:if 조건이 true일 때만 표시 th:if="${person.age}>=20"
th:unless 조건이 false일 때만 표시 th:unless="${person.age}>=20"
th:href 이동 경로 th:href="@{/persons{id=${person.id})}"
th:with 변숫값으로 지정 th:with="name=${person.name}"
th:object 선택한 객체로 지정 th:object="${person}"

 

 

2) 타임리프 사용을 위한 의존성 추가하기 

 

- 타임리프를 사용하기 위해 build.gradle 파일에 의존성 추가 

 

build.gradle

...
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
..

 

 

3) 타임리프 문법 익히기용 컨트롤러 작성 

 

ExampleController.java

- /thymeleaf/example GET 요청이 오면 특정 데이터를 뷰, 즉 HTML로 넘겨주는 모델 객체에 추가하는 컨트롤러 메서드 작성 

@Controller //컨트롤러라는 것을 명시적으로 표시 
public class ExampleController {
    
    @GetMapping("/thymeleaf/example")
    public String thymeleafExample(Model model) { //뷰로 데이터를 넘겨주는 모델 객체
        Person examplePerson = new Person();
        examplePerson.setId(1L);
        examplePerson.setName("홍길동");
        examplePerson.setAge(11);
        examplePerson.setHobbies(List.of("운동","도서"));
        
        model.addAttribute("person", examplePerson); //Person 객체 저장
        model.addAttribute("today", LocalDate.now());
        
        return "example"; //example.html라는 뷰 조회 

    }
    
    @Setter
    @Getter
    class Person {
        private Long id;
        private String name;
        private int age;
        private List<String> hobbies;
    }
}

 

- 모델 객체는 뷰, 즉 HTML 쪽으로 값을 넘겨주는 객체, 모델 객체는 따로 생성할 필요 없이 코드처럼 인자로 선언하기만 하면 스프링이 알아서 만들어주므로 편리하게 사용 가능

 

- addAttribute() 메서드로 모델에 값을 저장. 여기서는 "person"이라는 키에 사람 정보를, "today"라는 키에 날짜 정보를 저장 

 

- thymeleafExample() 메서드가 반환하는 값은 "example"이다. 이 값은 클래스에 붙은 애너테이션인 @Controller이므로 뷰의 이름을 반환하는 것이다. 즉, 스프링 부트는 컨트롤러의 @Controller 애너테이션을 보고 반환하는 값의 이름을 가진 파일을 찾으라는 것이라고 이해하고 resource/templates 디렉터리에서 example.html을 찾은 다음 웹 브라우저에서 해당 파일을 보여준다. 

 

 

 

 

4) 뷰 작성하기 

 

resources/templates/example.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>타임리프 익히기</h1>
<p th:text="${#temporals.format(today, 'yyyy-MM-dd')}"></p>

<div th:object="${person}">
    <p th:text="|이름 : *{name}|"></p>
    <p th:text="|나이 : *{age}|"></p>
    <p>취미</p>
    <ul th:each="hobby : *{hobbies}">
        <li th:text="${hobby}"></li>
        <span th:if="${hobby == '운동'}">(대표 취미)</span>
    </ul>
</div>

<a th:href="@{/api/articles/{id}(id=${person.id})}">글 보기</a>

</body>
</html>

 

- #temporals.format() 함수로 LocalDate 타입인 오늘 날짜를 yyyy-MM-dd 형식의 String 타입으로 포매팅한다. 

- th:object를 사용해 모델에서 받은 객체 중 "person"이라는 키를 가진 객체의 데이터를 하위 태그에 지정한다. 

- 하위 태그에서는 *{...}를 사용해 부모 태그에 적용한 객체 값에 접근할 수 있다. 

- th:text는 텍스트를 표현한다. 

- th:each는 객체의 hobbies 개수만큼 반복하는 반복자이다. 

 

 

 

2. 블로그 글 목록 뷰 구현하기 

 

1) 컨트롤러 메서드 작성하기 

 

- 요청을 받아 사용자에게 뷰를 보여주려면 뷰 컨트롤러가 필요하다. 

- 뷰 컨트롤러 메서드는 뷰의 이름을 반환하고, 모델 객체에 값을 답는다. 

 

dto/ArticleListViewResponse.java

@Getter
public class ArticleListViewResponse {
    
    private final Long id;
    private final String title;
    private final String content;
    
    public ArticleListViewResponse(Article article) {
        this.id = article.getId();
        this.title = article.getTitle();
        this.content = article.getContent();
    }
}

 

 

controller/BlogViewController.java

 

- /articles  GET 요청을 처리할 코드를 작성 

- 블로그 글 전체 리스트를 담은 뷰를 반환 

 

@RequiredArgsConstructor
@Controller
public class BlogViewController {

    private final BlogService blogService;

    @GetMapping("/articles")
    public String getArticles(Model model) {
        List<ArticleListViewResponse> articles = blogService.findAll()
                .stream()
                .map(ArticleListViewResponse::new)
                .toList();
        model.addAttribute("articles", articles); //1.블로그 글 리스트 저장

        return "articleList"; //2. articleList.html라는 뷰 조회 
    }
}

 

- addAttribute() 메서드를 사용해 모델에 값을 저장한다. 여기서는 "articles" 키에 블로그 글 리스트를 저장한다. 

- 반환값인 "articleList"는 resource/templates/articleList.html을 찾도록 뷰의 이름을 적은 것이다. 

 

 

2) HTML 뷰 만들고 테스트 하기

 

templates/articleList.html

- 모델에 전달한 블로그 글 리스트 개수만큼 반복해 글 보여주기 

- th:each로 "articles"키에 담긴 데이터 개수만큼 반복

- th:text는 반복 대상 객체의 id, "text"를 출력 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>블로그 글 목록</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
    <h1 class="mb-3">My Blog</h1>
    <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>

<div class="container">
    <div class="row-6" th:each="item:${articles}">
        <div class="card">
            <div class="card-header" th:text="${item.id}">
            </div>
            <div class="card-body">
                <h5 class="card-title" th:text="${item.title}"></h5>
                <p class="card-text" th:text="${item.content}"></p>
                <a href="#" class="btn btn-primary">보러가기</a>
            </div>
        </div>
    </div>
</div>


</body>

 

3. 블로그 글 뷰 구현하기 

 

1) 엔티티에 생성, 수정 시간 추가하기 

 

 

Article.java

...
@CreatedDate //엔티티가 생성될 때 생성시간 저장 
@Column(name="created_at")
private LocalDateTime createdAt;
    
@LastModifiedDate //엔티티가 수정될 때 수정 시간 저장 
@Column(name="updated_at")
private LocalDateTime updatedAt;

 

- @CreatedDate 애너테이션을 사용하면 엔티티가 생성될 때 생성 시간을 created_at 컬럼에 저장한다. 

- @LastModifiedDate 애너테이션을 사용하면 엔티티가 수정될 때 마지막으로 수정된 시간을 updated_at 컬럼에 저장한다. 

- 또한 엔티티의 생성 및 수정 시간으로 자동으로 감시하고 기록하기 위해@EntityListeners(AuditingEntityListener.class)에너테이션을 추가해준다. 

 

- SpringBootDeveloperApplication.java 파일을 열어 엔티티의 created_at, updated_at을 자동으로 업데이트하기 위한 애너테이션을 추가 

 

@EnableJpaAuditing 
@SpringBootApplication
public class SpringBootDeveloperApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootDeveloperApplication.class, args);
    }
}

 

 

2) 컨트롤러 메서드 작성하기 

 

dto/ArticleViewResponse.java

@NoArgsConstructor
@Getter
public class ArticleViewResponse {

    private Long id;
    private String title;
    private String content;
    private LocalDateTime createdAt;

    public ArticleViewResponse(Article article) {
        this.id = article.getId();
        this.title = article.getTitle();
        this.content = article.getContent();
        this.createdAt = article.getCreatedAt();
    }
}

 

- 블로그 글을 반환할 컨트롤러의 메서드 작성 

- BlogViewController.java 파일을 열어 getArticle() 메서드를 추가 

 

BlogViewController.java

...
@GetMapping("/articles/{id}")
    public String getArticle(@PathVariable Long id, Model model) {
        Article article = blogService.findById(id);
        model.addAttribute("article", new ArticleViewResponse(article));
        
        return "article";
}

 

- getArticle() 메서드는 인자 id에 URL로 넘어온 값을 받아 findById() 메서드로 넘겨 글을 조회하고, 화면에서 사용할 모델에 데이터를 저장한 다음, 보여줄 화면의 템플릿 이름을 반환 

 

3) HTML 뷰 만들기 

 

resource/templates/article.html 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>블로그 글</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
    <h1 class="mb-3">My Blog</h1>
    <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>

<div class="container mt-5">
    <div class="row">
        <div class="col-lg-8">
            <article>
                <input type="hidden" id="article-id" th:value="${article.id}">
                <header class="mb-4">
                    <h1 class="fw-bolder mb-1" th:text="${article.title}"></h1>
                    <div class="text-muted fst-italic mb-2" th:text="|Posted on ${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm')}|"></div>
                </header>
                <section class="mb-5">
                    <p class="fs-5 mb-4" th:text="${article.content}"></p>
                </section>
                <button type="button" id="modify-btn"
                        th:onclick="|location.href='@{/new-article?id={articleId}(articleId=${article.id})}'|"
                        class="btn btn-primary btn-sm">수정</button>
                <button type="button" id="delete-btn"
                        class="btn btn-secondary btn-sm">삭제</button>
            </article>
        </div>
    </div>
</div>

</body>

 

 

articleList.html

- 글 리스트 화면에 있는 [보러 가기] 버튼을 수정 

- href 속성을 th:href 속성으로 변경하고 URL 표현식 @{...}을 사용해 [보러가기]를 눌렀을 때 주소창의 값을 /articles/{item.id}로 변경해 글 상세 화면으로 이동 

...
<a th:href="@{/articles/{id}(id=${item.id})}" 
              class="btn btn-primary">보러가기</a>
...

 

 

4. 삭제 기능 추가하기 

 

1) 삭제 기능 코드 작성하기 

 

resource/static/article.js

//삭제 기능
const deleteButton = document.getElementById('delete-btn');

if(deleteButton) {
    deleteButton.addEventListener('click', event => {
    let id = document.getElementById('article-id').value;

    fetch(`/api/articles/${id}`, {
        method:'DELETE'
    })
    .then(() => {
        alert('삭제가 완료되었습니다.');
        location.replace('/articles');
    });
    });
}

 

- HTML에서 id를 delete-btn으로 설정한 엘리먼트를 찾아 그 엘리먼트에서 클릭 이벤트가 발생하면 fetch() 메서드를 통해 /api/articles/DELETE 요청을 보내는 역할을 한다. 

- then() 메서드는 fetch()가 잘 완료되면 연이어 실행되는 메서드이다. 

- alert() 메서드는 then() 메서드가 실행되는 시점에 웹 브라우저 화면으로 삭제가 완료되었음을 알리는 팝업을 띄어주는 메서드이다. 

- location.replace() 메서드는 실행 시 사용자의 웹 브라우저 화면을 현재 주소를 기반해 옮겨주는 역할을 한다. 

 

 

5. 수정/생성 기능 추가하기 

 

- 블로그 글 수정과 생성은 같은 화면에서 벌어진다. 

 

1) 수정/생성 뷰 컨트롤러 작성하기 

 

 

- 글을 생성할 때는 URL에 별도 쿼리 파라미터가 없다. 하지만 수정할 때는 URL에 ?id=123과 같이 수정할 글의 id를 쿼리 파라미터에 추가해 요청한다. 

- 쿼리 파라미터란 HTTP 요청에서 URL 끝에 '?'로 시작하는 키 값으로 이루어진 문자열이며 '&'로 구분한다. 

- 쿼리 파라미터가 있는 경우 컨트롤러 메서드를 수정해야 하므로 엔티티를 조회해 기존 글 데이터를 모델에 넣어 화면에 보여줘야 한다. 쿼리 파라미터가 없을 때에는 새 글이므로 화면에 아무것도 보여줄 필요 없다. 

- 또한 뷰에서는 쿼리 파라미터의 id 여부에 따라 [수정]과 [생성] 적절한 버튼을 보여줘야 한다. 

 

BlogViewController.java

- newArticle() 메서드 추가 

...
@GetMapping("/new-article")
    //id 키를 가진 쿼리 파라미터의 값을 id 변수에 매핑(id는 없을 수도 있음)
    public String newArticle(@RequestParam(required=false) Long id, Model model) {
        if(id==null) {
            //id가 없으면 생성
            model.addAttribute("article", new ArticleViewResponse());
        }else {
            //id가 있으면 수정
            Article article = blogService.findById(id);
            model.addAttribute("article", new ArticleViewResponse(article));
        }
        
        return "newArticle";
    }

 

- 쿼리 파라미터로 넘어온 id값은 newArticle() 메서드의 Long 타입 id 인자에 매핑한다. 

- id가 있으면 수정, 없으면 생성이므로 id가 없는 경우 기본 생성자를 이용해 빈 ArticleViewResponse 객체를 만들고, id가 있으면 기존 값을 가져오는 findById() 메서드를 호출 

 

 

2) 수정/생성 뷰 만들기 

 

newArticle.html

- 수정할 때는 id가 필요하므로 input 엘리먼트의 type을 hidden으로 설정해 엘리먼트를 숨긴다. 

- th:value로 글의 id를 저장한다. 

- th:if로는 id가 있을 때 [수정] 버튼, 없을 때 [등록] 버튼이 나타나도록 한다. 

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>블로그 글 </title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
  <div class="p-5 mb-5 text-center</> bg-light">
    <h1 class="mb-3">My Blog</h1>
    <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
  </div>

  <div class="container mt-5">
    <div class="row">
      <article>
        <!--아이디 정보 저장 -->
        <input type="hidden" id="article-id" th:value="{article.id}">

        <header class="mb-4">
          <input type="text" class="form-control" placeholder="제목" id="title" th:value="${article.title}">
        </header>
        <section class="mb-5">
          <textarea class="form-control h-25" rows="10" placeholder="내용" id="content" th:text="${article.content}"></textarea>
        </section>
        <button th:if="${article.id} != null" type="button" id="modify-btn" class="btn btn-primary btn-sm">수정</button>
        <button th:if="${article.id} == null" type="button" id="create-btn" class="btn btn-primary btn-sm">등록</button>
      </article>
    </div>
  </div>

 <script src="js/article.js"></script>

</body>
</html>

 

 

 

 

 

article.js

- 실제 수정, 생성 기능을 위한 API 구현 

// 수정 기능
const modifyButton = document.getElementById('modify-btn');

if (modifyButton) {
    modifyButton.addEventListener('click', event => {
        let params = new URLSearchParams(location.search);
        let id = params.get('id');

        fetch(`/api/articles/${id}`, {
            method: 'PUT',
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                title: document.getElementById('title').value,
                content: document.getElementById('content').value
            })
        })
            .then(() => {
                alert('수정이 완료되었습니다.');
                location.replace(`/articles/${id}`);
            });
    });
}

 

- id가 modify-btn인 엘리먼트를 찾고 그 엘리먼트에서 클릭 이벤트가 발생하면 id가 title, content인 엘리먼트의 값을 가져와 fetch() 메서드를 통해 수정 API로 /api/articles/ PUT 요청을 보낸다. 

 

- 요청을 보낼 때는 headers에 요청 형식을 지정하고, body에 HTML에 입력한 데이터를 JSON 형식으로 바꿔 보낸다. 

 

article.html

- [수정] 버튼에 id값과 클릭 이벤트 추가 

...
<button type="button" id="modify-btn"
                        th:onclick="|location.href='@{/new-article?id={articleId}(articleId=${article.id})}'|"
                        class="btn btn-primary btn-sm">수정</button>
...

 

 

6. 생성 기능 마무리하기 

 

1) 생성 기능 작성하기 

 

article.js

- [등록] 버튼을 누르면 입력 칸에 있는 데이터를 가져와 게시글 생성 API에 글 생성 관련 요청을 보내주는 코드를 추가 

// 생성 기능
const createButton = document.getElementById('create-btn');

if (createButton) {
    createButton.addEventListener('click', event => {
        fetch('/api/articles', {
            method: 'POST',
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                title: document.getElementById('title').value,
                content: document.getElementById('content').value
            })
        })
            .then(() => {
                alert('등록 완료되었습니다.');
                location.replace('/articles');
            });
    });

 

- id가 create-btn인 엘리먼트를 찾아 그 엘리먼트에서 클릭 이벤트가 발생하면 id가 title, content인 엘리먼트의 값을 가져와 fetch() 메서드를 통해 생성 API로 /api/articles/POST 요청을 보내준다. 

 

articleList.html

- id가 create-btn인 [생성] 버튼을 추가 

...
<button type="button" id="create-btn"
            th:onclick="|location.href='@{/new-article}'|"
            class="btn btn-secondary btn-sm mb-3">글 등록</button>
...