Spring

[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 4. 타임리프로 화면 구성하기

주니어주니 2023. 8. 18. 17:34

 

 

책은 머스테치로 했지만 나는 타임리프로 

 

 

1. 템플릿 엔진 

 

1) JSP (JavaServer Pages)

  • 자바 기반의 서버 사이드 템플릿 엔진
  • 자바 코드( <% %> ) + HTML 코드 혼합 
  • 서버 측에서 페이지 동적 생성 
  • 서버에서 자바코드로 문자열을 만든 뒤 HTML로 변환하여 브라우저로 전달

 

2) Thymeleaf 

  • 자바 기반의 서버 사이드 템플릿 엔진
  • HTML 문서 내에서 Thymeleaf 태그 사용
  • 스프링 프레임워크와 통합이 잘 되어있음
  • HTML을 유효한 HTML로 유지하면서, 동시에 동적 페이지를 생성할 수 있음
    (서버에서 페이지 동적 생성 + 서버가 없어도 브라우저에서 실행 가능)

 

3) React

  • 클라이언트 템플릿 엔진
  • 자바스크립트 프론트엔드 라이브러리
  • 서버에서 벗어난, 브라우저에서 화면 생성
  • 서버에서는 Json, xml 데이터만 전달하고 클라이언트에서 조립

 

4) Vue.js

  • 클라이언트 템플릿 엔진 
  • 자바스크립트 프론트엔드 라이브러리 
  • 브라우저에서 화면 생성
  • 서버에서는 Json, xml 데이터만 전달하고 클라이언트에서 조립
  • 리액트보다 진입장벽 낮음

 

 

2. 타임리프로 기본페이지 만들기

 

1) 타임리프 의존성 추가 

 

*build.gradle 

dependencies {
	...
    
    // 타임리프 추가
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

 

 

2) 기본 경로 

 

src > main > resources > static         : css 같은 정적 파일을 담는 폴더

src > main > resources > templates : 타임리프 파일을 담는 폴더

 

 

3) 기본 파일 생성

 

* index.html 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
<body>
    <h1>스프링 부트로 시작하는 웹 서비스</h1>

</body>
</html>
  • <html xmlns:th="http://www.thymeleaf.org">
    • 타임리프 사용 선언
    • 타임리프 표현식을 사용하려면 타임리프 사용 선언을 해줘야 함
    • 타임리프 표현식을 사용하고 있지만, <a th:href>, <span th:text> 등 태그의 속성 값으로 사용되는 경우에는 타임리프 선언 없이 사용 가능

  • <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
    • 다운받아서 css 폴더 밑에 저장한 부트스트랩의 css 적용할 것임

 

 

4) IndexController 

package com.example.studyspringbootwebservice.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {

    @GetMapping("/")
    public String index() {
        return "index";
    }
}
  • 타임리프와 URL 매핑
    • return "index" : 뷰 리졸버가 templates 폴더에 있는 index.html 뷰로 반환해줌

 

 

5) 테스트 

package com.example.studyspringbootwebservice.web;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.Extension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
class IndexControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void 메인페이지_로딩() {
        //when
        String body = this.restTemplate.getForObject("/", String.class);

        //then
        assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
    }
}

 

 

6) 화면 확인

Application의 메인메소드 실행

 

 

 

3. 게시글 등록 화면

 

  • 부트스트랩, 제이쿼리 등 라이브러리 사용법
    1. 직접 라이브러리 다운받아서 경로 작성
    2. CDN 사용

 

레이아웃 만들기 -> 페이지 로딩속도

 

1) header.html (경로 : src>main>resources>templates>fragments>header.html)

<head th:fragment="main-header">
    <meta charset="UTF-8">
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
  • 부트스트랩 css 다운 -> resources>static>css 경로에 파일 저장

 

 

2) footer.html (경로 : src>main>resources>templates>fragments>footer.html)

<footer th:fragment="main-footer">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>
</footer>
  • 제이쿼리 CDN
  • 부트스트랩 js CDN 

 

 

3) index.html 수정

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

    <h1>스프링 부트로 시작하는 웹 서비스</h1>

    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            </div>
        </div>


    <!-- footer 조각 -->
    <div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>

 

 

4) 글 등록 컨트롤러 IndexController 

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }
}

 

 

5) 글 등록 화면 posts-save.html 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

<h1>게시글 등록</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" placeholder="제목을 입력하세요">
            </div>
            <div class="form-group">
                <label for="author">작성자</label>
                <input type="text" class="form-control" id="author" placeholder="작성자를 입력하세요">
            </div>
            <div class="form-group">
                <label for="content">내용</label>
                <textarea type="text" class="form-control" id="content" placeholder="내용을 입력하세요"></textarea>
            </div>
        </form>
        <a href="/" role="button" class="btn btn-secondary">취소</a>
        <button type="button" class="btn btn-primary" id="btn-save">등록</button>
    </div>
</div>

<!-- footer 조각 -->
<div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>

 

 

7) 등록 버튼에 JS 기능 추가 index.js (src>main>resources>static>js>app>index.js) 

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });
    },
    save : function () {
        var data = {
            title: $('#title').val(),
            author: $('#author').val(),
            content: $('#content').val()
        };

        $.ajax({
            type: 'POST',
            url: '/api/v1/posts',
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 등록되었습니다.');
            window.location.href = '/';     // 글 등록 성공하면 메인페이지로 이동
        }).fail(function(error) {
            alert(JSON.stringify(error));
        });
    }
};

main.init();
  • 자바스크립트 파일을 따로 분리 -> footer에 자바스크립트 파일 추가 
  • 굳이 맨 위에 var main = { ... } 선언 후 그 안에 작성하는 이유
    • 만약 이름이 같은 init(), save() 함수가 중복 생성될 경우, 다른 JS와 겹칠 위험
    • main의 init() 실행

 

 

8) footer에 자바스크립트 추가 

<footer th:fragment="main-footer">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>

    <!-- index.js 추가 -->
    <script src="/js/app/index.js"></script>
</footer>
  • 절대 경로 (/)로 바로 시작
    • 스프링 부트는 기본적으로 src>main>resources>static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들을 URL에서 /로 설정함

 

 

 

 

4. 전체 목록 조회 화면

 

1) index.html 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

    <h1>스프링 부트로 시작하는 웹 서비스</h1>

    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            </div>
        </div>

        <!-- 목록 출력 영역 -->
        <table class="table table-horizontal table-bordered">
            <thead class="thead-strong">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            <tr th:each="post : ${posts}">
                <td th:text="${post.id}">id</td>
                <td th:text="${post.title}">title</td>
                <td th:text="${post.author}">author</td>
                <td th:text="${post.modifiedDate}">modifiedDate</td>
            </tr>
            </tbody>
        </table>
    </div>

    <!-- footer 조각 -->
    <div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>

 

 

2) Repository 인터페이스에 조회 쿼리 추가

package com.example.studyspringbootwebservice.domain.posts;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface PostsRepository extends JpaRepository<Posts, Long> {

    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

 

 

3) PostsService에 목록 조회 추가

package com.example.studyspringbootwebservice.service.posts;

import com.example.studyspringbootwebservice.domain.posts.Posts;
import com.example.studyspringbootwebservice.domain.posts.PostsRepository;
import com.example.studyspringbootwebservice.web.dto.PostsResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsListResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsSaveRequestDto;
import com.example.studyspringbootwebservice.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    // 등록
    // 수정
    // 조회
    // 전체 조회
    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }
}
  • @Transactional(readOnly = true)
    • 트랜잭션 범위 유지 + 조회 기능만 -> 조회 속도 개선

  • postsRepository.findAllDesc().stream().map(PostsListResponseDto::new).collect(Collectors.toList());
    • findAllDesc().stream() : 조회한 Posts 목록을 stream으로 변환
    • map(PostsListResponseDto::new) : 스트림 내의 각 Posts를 PostsListResponseDto의 생성자를 통해 새로운 PostsListResponseDto 객체로 변환 ( = map(posts -> new PostsListResponseDto(posts) )
    • collect(Collectors.toList()) : 스트림 요소를 List 컬렉션으로 반환

 

 

4) PostsListResponseDto

package com.example.studyspringbootwebservice.web.dto;

import com.example.studyspringbootwebservice.domain.posts.Posts;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class PostsListResponseDto {

    private Long id;
    private String title;
    private String author;
    private LocalDateTime modifiedDate;

    public PostsListResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.author = entity.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }

}

 

 

5) IndexController 

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        return "index";
    }
}

 

 

 

 

5. 게시글 수정, 삭제 

 

 

1) 해당 게시글로 이동하는 링크 추가 index.html 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

    <h1>스프링 부트로 시작하는 웹 서비스</h1>

    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            </div>
        </div>

        <!-- 목록 출력 영역 -->
        <table class="table table-horizontal table-bordered">
            <thead class="thead-strong">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            <tr th:each="post : ${posts}">
                <td th:text="${post.id}">id</td>
                <td><a th:href="@{/posts/update/{id}(id = ${post.id})}" th:text="${post.title}">title</a></td>
                <td th:text="${post.author}">author</td>
                <td th:text="${post.modifiedDate}">modifiedDate</td>
            </tr>
            </tbody>
        </table>
    </div>

    <!-- footer 조각 -->
    <div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>

 

 

2) 수정화면으로 이동하는 컨트롤러 IndexController

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        return "index";
    }

    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }

    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);
        return "posts-update";
    }
}

 

 

3) 수정, 삭제 로직 PostsService

package com.example.studyspringbootwebservice.service.posts;

import com.example.studyspringbootwebservice.domain.posts.Posts;
import com.example.studyspringbootwebservice.domain.posts.PostsRepository;
import com.example.studyspringbootwebservice.web.dto.PostsResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsListResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsSaveRequestDto;
import com.example.studyspringbootwebservice.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    // 등록
    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }

    // 수정
    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        posts.update(requestDto.getTitle(), requestDto.getContent());
        return id;
    }

    // 조회
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시물이 없습니다. id=" + id));

        return new PostsResponseDto(entity);
    }

    // 전체 조회
    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }

    // 삭제
    @Transactional
    public void delete(Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
        postsRepository.delete(posts);
    }
}

 

 

4) 수정, 삭제 API 컨트롤러 PostsApiController

package com.example.studyspringbootwebservice.web;

import com.example.studyspringbootwebservice.service.posts.PostsService;
import com.example.studyspringbootwebservice.web.dto.PostsResponseDto;
import com.example.studyspringbootwebservice.web.dto.PostsSaveRequestDto;
import com.example.studyspringbootwebservice.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    // 등록
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }

    // 수정
    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
        return postsService.update(id, requestDto);
    }

    // 조회
    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById(@PathVariable Long id) {
        return postsService.findById(id);
    }

    // 삭제
    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }
}

 

 

5) 수정, 삭제 게시글 화면 posts-update.html 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

<h1>게시글 수정</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="id">글 번호</label>
                <input type="text" class="form-control" id="id" th:value="${post.id}" readonly>
            </div>
            <div class="form-group">
                <label for="author">작성자</label>
                <input type="text" class="form-control" id="author" th:value="${post.author}" readonly>
            </div>
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" th:value="${post.title}">
            </div>
            <div class="form-group">
                <label for="content">내용</label>
                <textarea type="text" class="form-control" id="content" th:text="${post.content}"></textarea>
            </div>
        </form>
        <a th:href="@{/}" role="button" class="btn btn-secondary">취소</a>
        <button type="button" class="btn btn-primary" id="btn-update">수정</button>
        <button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
    </div>
</div>

<!-- footer 조각 -->
<div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>

 

 

6) 수정, 삭제 자바스크립트 index.js

var main = {
    init : function () {
        var _this = this;

        $('#btn-save').on('click', function () {
            _this.save();
        });

        $('#btn-update').on('click', function () {
            _this.update();
        });

        $('#btn-delete').on('click', function () {
            _this.delete();
        });
    },

    save : function () {
        var data = {
            title: $('#title').val(),
            author: $('#author').val(),
            content: $('#content').val()
        };

        $.ajax({
            type: 'POST',
            url: '/api/v1/posts',
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 등록되었습니다.');
            window.location.href = '/';     // 글 등록 성공하면 메인페이지로 이동
        }).fail(function(error) {
            alert(JSON.stringify(error));
        });
    },

    update : function () {
        var data = {
            title : $('#title').val(),
            content: $('#content').val()
        };

        var id = $('#id').val();

        $.ajax( {
            type: 'PUT',
            url: '/api/v1/posts/' + id,
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 수정되었습니다.');
            window.location.href = '/';
        }).fail(function(error) {
            alert(JSON.stringify(error));
        });
    },

    delete : function () {
        var id = $('#id').val();

        $.ajax( {
            type : 'DELETE',
            url : '/api/v1/posts/' + id,
            dataType : 'json',
            contentType : 'application/json; charset=utf-8',
        }).done(function() {
            alert('글이 삭제되었습니다.');
            window.location.href = '/';
        }).fail(function(error) {
            alert(JSON.stringify(error));
        })
    }
};

main.init();