[Spring] Spring Boot로 블로그 만들어보기(타임리프) - (1)

 

블로그 간단 기능(CRUD) 구현

  • 블로그 글 작성
  • 블로그 글 수정
  • 블로그 글  삭제
  • 블로그 글 조회(단건 , 전체)

Build.gradle

 

SpringBoot 2.7.X

Java 17

DB - mariaDB를 했지만 H2 나 Mysql 등 사용해도 무관

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.12'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

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


    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' // 선택사항
    annotationProcessor 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

 


Entity

Article (블로그 글) 객체 생성

ID, title(제목), content(본문,내용), createAt(생성시간), updateAt(수정시간)

 

Builder 패턴을 통해 객체 생성

* Builder 패턴의 장점: 어느 필드에 데이터가 들어가는 지 명시적으로 확인할 수 있음.

 

@CreateDate, @LastModifiedDate 애노테이션 사용을 위해, MainApplication 에 @EnableJpaAuditing 추가

@EnableJpaAuditing
@SpringBootApplication
public class MyBlogApplication {

    public static void main(String[] args) {
        SpringApplication.run(MyBlogApplication.class, args);
    }

}
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;

import javax.persistence.*;
import java.time.LocalDateTime;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 1씩 증가
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "content", nullable = false)
    private String content;

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

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

    @Builder
    public Article(String title, String content){
        this.title = title;
        this.content = content;
    }

    public void update(String title, String content){
        this.title = title;
        this.content = content;
    }

}

 


Repository

JpaRepository 를 상속받은 레포지토리 생성

public interface BlogRepository extends JpaRepository<Article, Long> {
}

 

 


Service

블로그 글 조회, 수정, 삭제 기능을 JpaRepository 내의 save(저장), findAll(전체), delete(삭제), findById(단건 조회) 활용

update 의 경우, findById 로 존재유무 확인 후 Entity 내의 update 메서드를 통해서 수정 기능 구현

package com.myblog.service;

import com.myblog.domain.Article;
import com.myblog.dto.request.ArticleRequest;
import com.myblog.repository.BlogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.List;

@RequiredArgsConstructor // final or @NotNull 이 붙은 필드의 생성자 추가
@Service
public class BlogService {
    private final BlogRepository blogRepository;

    // 블로그 글 추가
    public Article save(ArticleRequest request){
        return blogRepository.save(request.toEntity());
    }

    // 블로그 글 조회(전체)
    public List<Article> findAll(){
        return blogRepository.findAll();
    }

    // 블로그 글 삭제
    public void delete(long id){
        blogRepository.deleteById(id);
    }

    // 블로그 수정
    @Transactional
    public Article update(long id, ArticleRequest request){
        Article article = blogRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("not Found :"+id));

        article.update(request.getTitle(), request.getContent());

        return article;
    }

    public Article findById(Long id){
        return blogRepository.findById(id).orElseThrow(()-> new RuntimeException("ID를 찾을 수 없음"));
    }
}

 


Controller

 

BlogViewController

Model(HTML로 값을 넘겨주는 객체) 을 통해서, 데이터 설정 및 View 로 해당 데이터를 전달해 View에서 조회하게 함.

Html Code로 반환

package com.myblog.controller;

import com.myblog.domain.Article;
import com.myblog.dto.response.ArticleListViewResponse;
import com.myblog.dto.response.ArticleViewResponse;
import com.myblog.service.BlogService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

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


    @GetMapping("/articles/{id}")
    public String getArticle(@PathVariable Long id, Model model){

        Article article = blogService.findById(id);
        model.addAttribute("article", new ArticleViewResponse(article));

        return "article";
    }

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

}

 

BlogApiController

 

블로그 api 호출 관련 컨트롤러

package com.myblog.controller;

import com.myblog.domain.Article;
import com.myblog.dto.request.ArticleRequest;
import com.myblog.dto.response.ArticleResponse;
import com.myblog.service.BlogService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@RestController
public class BlogApiController {
    private final BlogService blogService;

    @PostMapping("/api/articles")
    public ResponseEntity<Article> addArticle(@RequestBody ArticleRequest request){
        Article savedArticle = blogService.save(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedArticle);
    }

    @GetMapping("/api/articles")
    public ResponseEntity<List<ArticleResponse>> findAllArticle(){
        List<ArticleResponse> articles = blogService
                .findAll()
                .stream().map(ArticleResponse::new)
                .toList();
        return ResponseEntity.status(HttpStatus.OK).body(articles);
    }

    @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Void> deleteArticle(@PathVariable long id){
        blogService.delete(id);

        return ResponseEntity.ok().build();
    }

    @PutMapping("/api/articles/{id}")
    public ResponseEntity<Article> upateArticle(@PathVariable long id, @RequestBody ArticleRequest request){
        Article updateArticle = blogService.update(id, request);

        return ResponseEntity.ok().body(updateArticle);
    }
}

 


타임리프

 

템플릿 엔진 

: 템플릿 엔진은 스프링 서버에서 데이터를 받아 웹페이지(HTML) 상에 데이터를 넣어 보여주는 도구

: JSP, 타임리프, 프리마커 등등이 있음.

 

[관련 문법]

${...}  변수 값 표현

#{...}  속성 파일 값 표현

@{..}  URL 표현

*{...}   선택한 변수의 표현식, th:object에서 선택한 객체에 접근

 

th:text  텍스트 표현할 때 사용

th:each 컬랙션 반복할 때 사용

th:if       if 조건문

th:href   이동경로

th:object  선택한 객체로 지정

 

 

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">
    <h4 class="mb-3">My Blog</h4>
    <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>
                    <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-5" 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>
                <button type="button" th:href="@{/articles}"  class="btn btn-sm">돌아가기</button>
            </article>
        </div>
    </div>
</div>
<script src="/js/article.js"></script>
</body>
</html>

 

articleList.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">
    <h4 class="mb-3">My Blog</h4>
    <h4 class="mb-3">블로그에 오신 걸 환영합니다</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}">
        <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 th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러 가기</a>
            </div>
            <br>
        </div>
    </div>
</div>
<button type="button" class="btn btn-secondary" onclick="location.href='/logout'">로그아웃</button>
<script src="/js/article.js"></script>
</body>
</html>

 

newArticle.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">
    <h4 class="mb-3">My Blog</h4>
    <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">
                    <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-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>

 

 


application.yml

 

테스트용을 위해서 SpringBoot 기동 시 data.sql 로 데이터 넣기

 

data.sql 이 실행 안되는 경우 확인해볼 것

  * defer-datasource-initialization: true ( true로 셋팅 시,  Hibernate 초기화 전에 쿼리가 실행됨)

  *  hibernate ddl-auto 는 create / create-drop 으로 초기화

 

스프링 버전

  • v2.5.x 이상 설정: spring.sql.init.mode
  • 위 버전 이하 설정 : spring.datasource.initialization-mode
spring:
  # 데이터 소스 설정
  datasource:
    url: jdbc:mariadb://localhost:3306/myblog
    driver-class-name: org.mariadb.jdbc.Driver
    username: root
    password: 1234
    data: classpath:data.sql
    initialization-mode: always

  jpa:
    show-sql: true
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true

  sql:
    init:
      mode: always

jwt:
  issuer: yhmJwtToken
  secret_key: 1233j23j23hjj4kbknzdfdsfkaadfjdsf

 

data.sql

 

INSERT INTO article (title, content, created_at, updated_at) VALUES ('제목1', '내용1', NOW(), NOW())
INSERT INTO article (title, content, created_at, updated_at) VALUES ('제목2', '내용2', NOW(), NOW())
INSERT INTO article (title, content, created_at, updated_at) VALUES ('제목3', '내용3', NOW(), NOW())

추후 진행 

 

Spring Security 를 이용한 로그인/로그아웃 관련 기능 구현

JWT Token 기반 인증 구현

OAuth 2.0 로그인 기능 구현

 

 

[참고, Ref]

스프링 부트 개발자3 백엔드 개발자 되기 https://github.com/shinsunyoung/springboot-developer