[Spring Boot] 7. 블로그 화면 구성하기

김민경·2024년 7월 6일
post-thumbnail

'스프링 부트3 백엔드 개발자 되기' 책을 참고하며 작성 중 입니다.


사전 지식

타임리프 (thymeleaf)

타임리프 = 템플릿 엔진

템플릿 엔진?

스프링 서버에서 데이터를 받아 HTML 상에 그 데이터를 넣어 보여주는 도구
동적인 웹 페이지 구현이 가능

jsp, thymeleaf, 프리마커

thymeleaf 표현식과 문법

표현식

표현식설명
${..}변수의 값 표현식
#{..}속성 파일 값 표현식
@{..}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}(id=${person.id})}"
th:with변숫값으로 지정th:with="name=${person.name}"
th:object선택한 객체로 지정th:object="${person}"

Thymeleaf 사용해보기

build.gradle

의존성 추가

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

Controller

@Controller //컨트롤러라는 것을 명시적으로 표시
public class ExampleController {

  @GetMapping("/thymeleaf/example")
  public String thymeleafExample(Model model){ //Model : HTML(뷰) 쪽으로 값을 넘겨주는 객체
      Person examplePerson = new Person();
      examplePerson.setId(1L);
      examplePerson.setName("A");
      examplePerson.setAge(11);
      examplePerson.setHobbies(List.of("운동","독서"));

      model.addAttribute("person", examplePerson); //모델에 값을 저장 (Person 객체 저장)
      model.addAttribute("today", LocalDate.now());

      return "example"; //view의 이름 (example.html)
  }

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

Model : HTML(뷰) 쪽으로 값을 넘겨주는 객체
addAttribute() : 키-값 형식으로 모델에 값을 저장
example : @Controller 이므로 View의 이름을 반환, 즉 example.html을 반환

Spring Boot는 @Controller 애너테이션을 보고 '반환하는 값의 이름을 가진 뷰의 파일을 찾아라' 라고 이해

example.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
<h1>타임리프 익히기</h1>
<p th:text="${#temporals.format(today, 'yyyy-MM-dd')}"></p>
<!--LocalDate 타입의 today를  yyyy-MM-dd 형식의 String타입으로 포매팅-->
<div th:object="${person}"> <!--person을 선택한 객체로 지정-->
  <p th:text="|이름 : *{name}|"></p> <!-- *{} : 부모 태그에 적용한 객체 값에 접근 -->
  <p th:text="|나이 : *{age}|"></p>
  <p>취미</p>
  <ul th:each="hobby : *{hobbies}"> <!-- th:each : 반복 -->
      <li th:text="${hobby}"></li>
      <span th:if="${hobby == '운동'}">(대표 취미)</span>
  </ul>
</div>

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

결과


블로그 글 목록 View 구현

DTO

뷰에게 데이터를 전달하기 위한 객체

ArticleListViewResponse

@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

요청을 받아 사용자에게 뷰를 보여주려면 뷰 Controller가 필요

BlogViewController

@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);
		//"articles" 키에 글 리스트를 저장

      return "articleList"; //articleList.html을 반환
  }
}

HTML

articleList.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>블로그 글 목록</title>
  <link rel="stylesheet" href="http://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
<!-- bootstrap : 웹 애플리케이션의 화면을 쉽고 빠르게 만들어주는 라이브러리 -->
</head>
<body>
<div class="p-5 mb-5j text-center</> bg-light">
  <h1 class="mb-3">My Blog</h1>
  <h4 class="mb-3">Welcome to my Blog</h4>
</div>

<div class="container">
  <button type="button" id="create-btn" th:onclick="|location.href = '@{/new-article}'|"
          class="btn btn-secondary btn-sm mb-3">글 등록</button>
  <div class = "row-6" th:each="item : ${articles}"> 
    <!-- th:each => "articles" 키에 담긴 데이터 개수만큼 반복, 앞으로 item으로 선언 -->
      <div class="card">
          <div class="card-header" th:text="${item.id}">
            <!-- th:text => item 의 id값 반환 -->
          </div>
          <div class="card-body">
              <h5 class="card-title" th:text="${item.title}"></h5>
              <p class="card-text" th:tex="${item.content}"></p>
              <a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러 가기</a>
          </div>
      </div>
  </div>
</div>
</body>
</html>

결과


블로그 글 View

Entity에 생성, 수정 시간 추가

Article

@Entity //엔티티로 지정
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class) //Entity의 생성 및 수정 시간을 자동으로 감시 및 기록
public class Article {

  //..생략

  @CreatedDate
  @Column(name="created_at")
  private LocalDateTime createdAt;

  @LastModifiedDate
  @Column(name="updated_at")
  private LocalDateTime updatedAt;

}

@CreatedDate : Entity가 생성될 때 생성 시간을 저장
@LastModifiedDate : Entity가 수정될 때 마지막으로 수정된 시간을 저장
@EntityListenrs(AuditingEntityListener.class) : Entity의 생성 및 수정 시간을 자동으로 감시하고 기록

data.sql

created_at, updated_at 추가

INSERT INTO article(title, content, created_at, updated_at) VALUES ('title1','content1', NOW(), NOW())
INSERT INTO article(title, content, created_at, updated_at) VALUES ('title2','content2', NOW(), NOW())
INSERT INTO article(title, content, created_at, updated_at) VALUES ('title3','content3', NOW(), NOW())

SpringBootDeveloperApplication

@SpringBootApplication
@EnableJpaAuditing //created_at, updated_at 자동 업데이트
public class SpringBootDeveloperApplication {
  public static void main(String[] args){
      SpringApplication.run(SpringBootDeveloperApplication.class, args);
  }
}

@EnableJpaAuditing : created_at, updated_at 자동 업데이트

DTO

뷰에서 사용할 DTO

ArticleViewResponse

@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();
  }
}

Controller

BlogViewController

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

      return "article";
  }

View

article.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>블로그 글</title>
  <link rel="stylesheet" href="http://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5j text-center</> bg-light">
  <h1 class="mb-3">My Blog</h1>
  <h4 class="mb-3">Welcome to my Blog</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>

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

결과


블로그 글 삭제

JavaScript

article.js

const deleteButton = document.getElementById('delete-btn');
//HTML에서 delete-btn으로 설정한 element를 찾아

if (deleteButton) {
//그 element에서 click 이벤트가 발생한다면
  deleteButton.addEventListener('click', event => {
      let id = document.getElementById('article-id').value;
//fetch() 메서드를 통해 API를 요청
      fetch(`/api/articles/${id}`, {
          method : 'DELETE'
      })
//fetch()가 잘 완료되면 연이어 실행
      .then(() => {
          alert('삭제가 완료되었습니다'); //팝업 띄우기
          location.replace('/articles'); //화면을 현재 주소를 기반해 옮겨주는 역할
      }); 
  });
}

결과 (1번 게시물 삭제)


블로그 글 등록/수정

쿼리 파라미터의 유무에 따라 등록 / 수정을 판단

Controller

BlogViewController

@GetMapping("/new-article")
public String newArticle(@RequestParam(required=false)Long id, Model model){
  if (id==null){
  	model.addAttribute("article", new ArticleViewResponse());
  	//없으면 생성
  } else{
  	Article article = blogService.findById(id);
  	model.addAttribute("article", new ArticleViewResponse(article));
  	//있으면 수정
  }

  return "newArticle";
}

@RequestParam : id 키를 가진 쿼리 파라미터의 값을 id 변수에 매핑
reqired = false 을 통해 선택적이라는 것을 명시


JavaScript

article.js

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

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

View

newArticle.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="http://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5j text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">Welcome to my Blog</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">
        <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-secondary btn-sm">등록</button>
    </article>
  </div>

</div>
</div>

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

결과

  • 수정

  • 등록



템플릿 엔진인 Thymeleaf를 사용해서 게시글 리스트 뷰, 상세 뷰, 삭제 기능, 수정 기능, 생성 기능을 추가했습니다.

@Controller, 템플릿 엔진, Thymeleaf
profile
뭐든 기록할 수 있도록

0개의 댓글