(101)

17. 게시물에 일련번호 추가하기 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 게시물 번호 공식 만들기 만약 질문 게시물이 12개라면 0페이지에는 12번째~3번째 게시물이, 1페이지에는 2번째~1번째 게시물이 역순으로 표시되어야 한다. 질문 게시물의 번호를 역순으로 정렬하려면 다음과 같은 공식을 적용해야 한다. 번호 = 전체 게시물 개수 - (현재 페이지 * 페이지당 게시물 개수) - 나열 인덱스 항목 설명 번호 최종 표시될 게시물 번호 전체 게시물 개수 데이터베이스에 저장된 게시물 전체 개수 현재 페이지 페이징에서 현재 선택한 페이지 (만약 페이지가 1부터 시작한다면 1을 빼주어야 한다. 하지만 스프링부트의 페이징은 0부터 시작하므로 1을 뺄..

16. 페이징 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 테스트 데이터 만들기 페이징을 구현하기 전에 페이징을 테스트할 수 있을 정도로 충분한 데이터를 생성하자. 대량의 테스트 데이터를 만드는 가장 간단한 방법은 스프링부트의 테스트 프레임워크를 이용하는 것이다. PracticeApplicationsTests를 수정하자. package com.example.board; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.Sp..

15. 네비게이션 바 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 내비게이션 바 메인페이지로 돌아갈 수 있는 기능을 구현한 내비게이션 바를 만들어보자. 내비게이션바는 모든 페이지에서 공통적으로 보여야 하므로 layout.html 템플릿에 추가해야 한다. 파일경로 : /practice/src/main/resources/templates/navbar.html 게시판 연습 로그인 항상 홈 페이지로 이동해 주는 로고를 가장 왼쪽에 배치했고, 오른쪽에는 '로그인' 링크를 추가했다 (추후 구현) layout.html을 수정하자. (... 생략 ...) navbar.html을 통해 내비게이션 바를 분리 한 뒤 th:replace를 사용하여 포함..

14. 공통 템플릿 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 오류 메시지 공통 템플릿 오류 메시지를 표시하는 공통 템플릿을 작성하자 파일 경로 : /practice/src/main/resources/templates/form_errors.html 출력할 오류 메세지 부분에 th:fragment="formErrorsFragment" 속성을 추가했다 필요한 템플릿에 적용하기 question_form.html 질문등록 제목 내용 타임리프의 th:replace 속성을 사용하면 공통 템플릿을 템플릿 내에 삽입할수 있다. div 엘리먼트를 form_errors.html 파일의 th:fragment 속성명이 formErrorsFragmen..

13. 질문 등록과 폼 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다 자바 8, 스프링부트 2.7.7버전 입니다. 질문 등록 질문 등록을 위한 질문 등록 버튼을 question_list 템플릿에 생성하자 (... 생략 ...) 질문 등록하기 링크 엘리먼트(..)에 부트스트랩의 btn btn-primary 클래스를 적용시켜 버튼화 했다 "질문 등록하기" 버튼을 누르면 /question/create URL이 호출될 것이다. URL 매핑 컨트롤러에 /question/create에 해당되는 URL 매핑을 추가하자 (... 생략 ...) public class QuestionController { (... 생략 ...) @GetMapping("/create") public String qu..

12. 템플릿 상속 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전입니다. 표준 HTML 구조 지금까지 작성한 질문 목록, 질문 상세 템플릿은 표준 HTML 구조가 아니다. 어떤 웹 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 작동 하게 하려면 반드시 웹 표준을 지키는 HTML 문서를 작성해야 한다. 표준 HTML구조는 아래와 같다 (... 생략 ...) 표준 HTML 문서의 구조는 위의 예처럼 html, head, body 엘리먼트가 있어야 하며, CSS 파일은 head 엘리먼트 안에 링크 되어야 한다. 또한 head 엘리먼트 안에는 meta, title 엘리먼트 등이 포함되어야 한다. 템플릿 상속 앞에서 작성한 질문 목록..

11. 부트스트랩 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전입니다. 부트스트랩 부트스트랩(Bootstrap)은 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들수 있게 도와주는 프레임워크이다. 부트스트랩은 트위터(Twitter)를 개발하면서 만들어졌고 현재 지속적으로 관리되고 있는 오픈소스 프로젝트이다. 부트스트랩 다운로드 - https://getbootstrap.com/docs/5.2/getting-started/download/ 해당 링크에서 5.2.3버전의 부트스트랩을 다운로드한 후 bootstrap.min.css 파일을 스태틱 디렉터리에 저장하자 압축 파일 경로 : bootstrap-5.2.3-dist..

10. 스태틱 디렉터리와 스타일시트 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전입니다. 스타일시트 스타일시트 파일은 스프링부트의 스태틱 디렉토리에 저장해야 한다 스타일시트 파일(style.css)을 작성하자 파일경로 : /practice/src/main/resources/static/style.css textarea { width:100%; } input[type=submit] { margin-top:10px; } 템플릿에 스타일 적용 스타일시트 파일을 질문 상세 템플릿에 적용하자 템플릿 최상단에 style.css를 사용할 수 있는 링크를 추가했다. (... 생략 ...) static 디렉터리에 style.css 파일이 위치하지만 /static/style..

9. 답변 등록 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전입니다. 답변 등록 버튼 만들기 이제 버튼을 누르면 POST 방식으로 /answer/create/ URL이 호출(submit)될 것이다. 하지만 아직 /answer/create/ URL에 대한 매핑이 없으므로 버튼을 누르면 다음과 같은 404 페이지가 나타난다. 답변 서비스 만들기 답변을 저장하는 AnswerService를 작성하자 package com.example.board.answer; import java.time.LocalDateTime; import org.springframework.stereotype.Service; import com.example.board.q..

17. 게시물에 일련번호 추가하기 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 11:49
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

게시물 번호 공식 만들기


만약 질문 게시물이 12개라면 0페이지에는 12번째~3번째 게시물이, 1페이지에는 2번째~1번째 게시물이 역순으로 표시되어야 한다. 질문 게시물의 번호를 역순으로 정렬하려면 다음과 같은 공식을 적용해야 한다.

번호 = 전체 게시물 개수 - (현재 페이지 * 페이지당 게시물 개수) - 나열 인덱스

 

항목 설명
번호 최종 표시될 게시물 번호
전체 게시물 개수 데이터베이스에 저장된 게시물 전체 개수
현재 페이지 페이징에서 현재 선택한 페이지
(만약 페이지가 1부터 시작한다면 1을 빼주어야 한다.
하지만 스프링부트의 페이징은 0부터 시작하므로 1을 뺄 필요가 없다.)
페이지당 게시물 개수 한 페이지당 보여줄 게시물의 개수
나열 인덱스 for 문 안의 게시물 순서
(나열 인덱스는 현재 페이지에서 표시할 수 있는 게시물의 인덱스이므로
10개를 표시하는 페이지에서는 0~9, 2개를 표시하는 페이지에서는 0~1로 반복된다.)

공식이 조금 복잡하니 질문 게시물이 12개인 상황을 예로 들어 설명해 보자.

현재 페이지가 0이면 번호는 전체 게시물 개수 12에서 나열 인덱스 0~9를 뺀 12~3이 된다.

1페이지면 페이지당 게시물 개수는 10이므로 12에서 10을 뺀 값 2에 나열 인덱스 0~1을 다시 빼므로 번호는 2~1이다.

 

질문 목록 템플릿에 적용하기


question_list.html에 코드를 추가하자.

   (... 생략 ...)
        <tbody>
            <tr th:each="question, loop : ${paging}">
                <td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
                <td>
                    <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
                </td>
                <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
            </tr>
        </tbody>
    (... 생략 ...)

 

항목 설명
paging.getTotalElements 전체 게시물 개수
paging.number 현재 페이지 번호
paging.size 페이지당 게시물 개수
loop.index 나열 인덱스(0부터 시작)

 

 

게시물 일련번호 추가 완료

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

16. 페이징 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 10:39
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

 

테스트 데이터 만들기


 

페이징을 구현하기 전에 페이징을 테스트할 수 있을 정도로 충분한 데이터를 생성하자.

대량의 테스트 데이터를 만드는 가장 간단한 방법은 스프링부트의 테스트 프레임워크를 이용하는 것이다.

 

PracticeApplicationsTests를 수정하자.

package com.example.board;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import com.example.board.question.QuestionService;

@SpringBootTest
class PracticeApplicationTests {

    @Autowired
    private QuestionService questionService;

    @Test
    void testJpa() {
        for (int i = 1; i <= 300; i++) {
            String subject = String.format("테스트 데이터입니다:[%03d]", i);
            String content = "내용무";
            this.questionService.create(subject, content);
        }
    }
}

서버를 중지하고 JUnit Test를 실행한 후 다시 서버를 재시작 해보자.

현재 페이징 처리가 안되기 때문에 게시물 300개를 작성하면 한 페이지에 300개의 게시물이 모두 조회된다.

등록한 게시물도 최근 순으로 보여야 하는데 등록한 순서로 보이는 문제도 있다.

 

 

 

페이징 구현하기


 

JPA 환경 구축시 설치했던 JPA 관련 라이브러리에 이미 페이징을 위한 패키지들이 들어있다.

  • org.springframework.data.domain.Page
  • org.springframework.data.domain.PageRequest
  • org.springframework.data.domain.Pageable

 

QuestionRepository에 다음과 같은 findAll 메서드를 추가하자.

package com.example.board.question;

import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface QuestionRepository extends JpaRepository<Question, Integer> {
    Question findBySubject(String subject);
    Question findBySubjectAndContent(String subject, String content);
    List<Question> findBySubjectLike(String subject);
    Page<Question> findAll(Pageable pageable);
}

Pageable 객체를 입력으로 받아 Page<Question> 타입 객체를 리턴하는 findAll 메서드를 생성했다.

 

 

QuestionService도 다음과 같이 수정하자.

(... 생략 ...)
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public Page<Question> getList(int page) {
        Pageable pageable = PageRequest.of(page, 10);
        return this.questionRepository.findAll(pageable);
    }

    (... 생략 ...)
}

질문 목록을 조회하는 getList 메서드를 위와 같이 변경했다. getList 메서드는 정수 타입의 페이지번호를 입력받아 해당 페이지의 질문 목록을 리턴하는 메서드로 변경했다. Pageable 객체를 생성할때 사용한 PageRequest.of(page, 10)에서 page는 조회할 페이지의 번호이고 10은 한 페이지에 보여줄 게시물의 갯수를 의미한다. 이렇게 하면 데이터 전체를 조회하지 않고 해당 페이지의 데이터만 조회하도록 쿼리가 변경된다.

 

 

getList 메서드의 입출력 구조가 변경되었으므로 QuestionController도 수정해야 한다.

package com.example.board.question;

(... 생략 ...)
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.data.domain.Page;
(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @GetMapping("/list")
    public String list(Model model, @RequestParam(value="page", defaultValue="0") int page) {
        Page<Question> paging = this.questionService.getList(page);
        model.addAttribute("paging", paging);
        return "/question/question_list";
    }

    (... 생략 ...)
}

http://localhost:8080/question/list?page=0 처럼 GET 방식으로 요청된 URL에서 page값을 가져오기 위해

@RequestParam(value="page", defaultValue="0") int page 매개변수가 list 메서드에 추가되었다.

URL에 페이지 파라미터 page가 전달되지 않은 경우 디폴트 값으로 0이 되도록 설정했다.

스프링부트의 페이징은 첫페이지 번호가 1이 아닌 0이다.

 

그리고 템플릿에 Page<Question> 객체인 paging을 전달했다. Page 객체에는 다음과 같은 속성이 있다.

다음의 속성들은 템플릿에서 페이징을 처리할 때 사용할 것이다.

 

 

항목 설명
paging.isEmpty 페이지 존재 여부 (게시물이 있으면 false, 없으면 true)
paging.totalElements 전체 게시물 개수
paging.totalPages 전체 페이지 개수
paging.size 페이지당 보여줄 게시물 개수
paging.number 현재 페이지 번호
paging.hasPrevious 이전 페이지 존재 여부
paging.hasNext 다음 페이지 존재 여부

 

 

기존에 전달했던 이름인 "questionList" 대신 "paging" 이름으로 템플릿에 전달했기 때문에

question_list.html도 다음과 같이 변경해야 한다.

        (... 생략 ...)
        <tbody>
            <tr th:each="question, loop : ${paging}">
                (... 생략 ...)
            </tr>
        </tbody>
        (... 생략 ...)

 

 

http://localhost:8080/question/list?page=0 화면

 

 

 

http://localhost:8080/question/list?page=1 화면

 

 

 

템플릿에 페이지 이동 기능 구현하기


질문 목록에서 페이지를 이동하려면 페이지를 이동할 수 있는 "이전", "다음" 과 같은 링크가 필요하다. 

 

question_list.html 템플릿 파일의 </table> 태그 바로 밑에 다음 코드를 추가하자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <table class="table">
        (... 생략 ...)
    </table>
    <!-- 페이징처리 시작 -->
    <div th:if="${!paging.isEmpty()}">
        <ul class="pagination justify-content-center">
            <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
                <a class="page-link"
                    th:href="@{|?page=${paging.number-1}|}">
                    <span>이전</span>
                </a>
            </li>
            <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
		th:if="${page >= paging.number-2 and page <= paging.number+2}"
                th:classappend="${page == paging.number} ? 'active'" 
                class="page-item">
                <a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
            </li>
            <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
                <a class="page-link" th:href="@{|?page=${paging.number+1}|}">
                    <span>다음</span>
                </a>
            </li>
        </ul>
    </div>
    <!-- 페이징처리 끝 -->
    <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>

 

페이지가 없는 경우에는 링크가 비활성화(disabled)되도록 하였다.

페이지 리스트를 루프 돌면서 해당 페이지로 이동할 수 있는 링크를 생성하였다.

이때 루프 도중의 페이지가 현재 페이지와 같을 경우에는 active클래스를 적용하여 강조표시(선택표시)도 해 주었다.

타임리프의 th:classappend="조건식 ? 클래스값" 속성은 조건식이 참인 경우 클래스값을 class 속성에 추가한다.

 

페이징 기능 코드
이전 페이지가 없으면 비활성화 th:classappend="${!paging.hasPrevious} ? 'disabled'"
다음 페이지가 없으면 비활성화 th:classappend="${!paging.hasNext} ? 'disabled'"
이전 페이지 링크 th:href="@{|?page=${paging.number-1}|}"
다음 페이지 링크 th:href="@{|?page=${paging.number+1}|}"
페이지 리스트 루프 th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
현재 페이지와 같으면 active 적용 th:classappend="${page == paging.number} ? 'active'"

 

#numbers.sequence(시작, 끝)은 시작 번호부터 끝 번호까지의 루프를 만들어 내는 타임리프의 유틸리티이다.

그리고 페이지 리스트를 보기 좋게 표시하기 위해 부트스트랩의 pagination 컴포넌트를 이용하였다.

템플릿에 사용한 pagination, page-item, page-link 등이 부트스트랩 pagination 컴포넌트의 클래스이다.

부트스트랩 pagination : https://getbootstrap.com/docs/5.2/components/pagination/

 

해당 코드를 삽입하여 페이지 표시 제한 기능을 구현했다.

th:if="${page >= paging.number-2 and page <= paging.number+2}"

이 코드는 페이지 리스트가 현재 페이지 기준으로 좌우 2개씩 보이도록 만든다.

루프내에 표시되는 페이지가 현재 페이지(paging.number) 보다 2만큼 작거나 큰 경우에만 표시되도록 한 것이다.

 

 

잘 적용된 모습

 

 

 

작성일시 역순으로 조회하기


현재 질문 목록은 등록한 순서로 데이터가 표시된다. 게시판은 가장 최근에 작성한 게시물이 가장 먼저 보이는 것이 일반적이다.

 

QuestionService 수정

(... 생략 ...)
import java.util.ArrayList;
import java.util.List;
import org.springframework.data.domain.Sort;
(... 생략 ...)
public class QuestionService {

   (... 생략 ...)

    public Page<Question> getList(int page) {
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
        return this.questionRepository.findAll(pageable);
    }

    (... 생략 ...)
}

역순으로 조회하기 위해서는 PageRequest.of 메서드의 세번째 파라미터로 Sort 객체를 전달해야 한다.

Sort.Order 객체로 구성된 리스트에 Sort.Order 객체를 추가하고 Sort.by(소트리스트)로 소트 객체를 생성할 수 있다.

작성일시(createDate)를 역순(Desc)으로 조회하려면 Sort.Order.desc("createDate") 같이 작성한다.

만약 작성일시 외에 추가로 정렬조건이 필요할 경우에는 sorts 리스트에 추가하면 된다.

 

 

최신순으로 정렬된 것을 확인할 수 있다

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

15. 네비게이션 바 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 10:16
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

내비게이션 바


메인페이지로 돌아갈 수 있는 기능을 구현한 내비게이션 바를 만들어보자.

내비게이션바는 모든 페이지에서 공통적으로 보여야 하므로 layout.html 템플릿에 추가해야 한다.

파일경로 : /practice/src/main/resources/templates/navbar.html

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">게시판 연습</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
            aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" href="#">로그인</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

항상 홈 페이지로 이동해 주는 로고를 가장 왼쪽에 배치했고, 오른쪽에는 '로그인' 링크를 추가했다 (추후 구현)

 

 

layout.html을 수정하자.

<!doctype html>
<html lang="ko">
(... 생략 ...)
<body>
<!-- 네비게이션바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>

navbar.html을 통해 내비게이션 바를 분리 한 뒤 th:replace를 사용하여 포함시켰다.

 

 

 

 

정상 적용된 모습이지만 부트스트랩의 반응형 기능에 의해 로그인 버튼이 햄버거 메뉴 버튼에 숨겨져있는 상황이다.

 

해당 기능을 사용하기 위해 부트스트랩 자바스크립트 파일(bootstrap.min.js)을 layout.html 파일에 포함시켜야한다.

 

압축파일내 경로: bootstrap-5.2.3-dist.zip/bootstrap-5.2.3-dist/js/bootstrap.min.js

붙여 넣을 위치: /practice/src/main/resources/static/bootstrap.min.js

 

 

추가한 자바스크립트 파일을 사용할 수 있도록 layout.html의 하단에 코드를 추가하였다.

<!doctype html>
<html lang="ko">
(... 생략 ...)
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->

<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>

</body>
</html>

 

이제 정상 작동이 될 것이다.

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

14. 공통 템플릿 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 01:04
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

오류 메시지 공통 템플릿


오류 메시지를 표시하는 공통 템플릿을 작성하자

파일 경로 : /practice/src/main/resources/templates/form_errors.html

<div th:fragment="formErrorsFragment" class="alert alert-danger" 
    role="alert" th:if="${#fields.hasAnyErrors()}">
    <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
</div>

출력할 오류 메세지 부분에 th:fragment="formErrorsFragment" 속성을 추가했다

 

 

필요한 템플릿에 적용하기


question_form.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    <form th:action="@{/question/create}" th:object="${questionForm}" method="post">
	<div th:replace="~{form_errors :: formErrorsFragment}"></div>    
        <div class="mb-3">
            <label for="subject" class="form-label">제목</label>
            <input type="text" th:field="*{subject}" class="form-control">
        </div>
        <div class="mb-3">
            <label for="content" class="form-label">내용</label>
            <textarea th:field="*{content}" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="저장하기" class="btn btn-primary my-2">
    </form>
</div>
</html>

타임리프의 th:replace 속성을 사용하면 공통 템플릿을 템플릿 내에 삽입할수 있다.

div 엘리먼트를 form_errors.html 파일의 th:fragment 속성명이 formErrorsFragment인 엘리먼트로 교체하라는 의미이다.

 

question_detail.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    (... 생략 ...)
    <!-- 답변 작성 -->
    <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
        <div th:replace="~{form_errors :: formErrorsFragment}"></div>
        <textarea th:field="*{content}" rows="10" class="form-control"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
</div>
</html>

마찬가지로 오류메세지 코드를 공통 템플릿으로 대체해주었다.

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

13. 질문 등록과 폼 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2022. 12. 31. 22:26
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

 

질문 등록


질문 등록을 위한 질문 등록 버튼을 question_list 템플릿에 생성하자

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <table class="table">
        (... 생략 ...)
    </table>
    <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>

링크 엘리먼트(<a>..</a>)에 부트스트랩의 btn btn-primary 클래스를 적용시켜 버튼화 했다

"질문 등록하기" 버튼을 누르면 /question/create URL이 호출될 것이다.

 

 

 

 

URL 매핑

컨트롤러에 /question/create에 해당되는 URL 매핑을 추가하자

 

(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @GetMapping("/create")
    public String questionCreate() {
        return "/question/question_form";
    }
}

"질문 등록하기" 버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용하였다.

questionCreate 메서드는 question_form 템플릿을 렌더링하여 출력한다.

 

템플릿

질문 등록을 위한 question_form.html 템플릿을 작성하자

파일 경로 : /practice/src/main/resources/templates/question/question_form.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    <form th:action="@{/question/create}" method="post">
        <div class="mb-3">
            <label for="subject" class="form-label">제목</label>
            <input type="text" name="subject" id="subject" class="form-control">
        </div>
        <div class="mb-3">
            <label for="content" class="form-label">내용</label>
            <textarea name="content" id="content" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="저장하기" class="btn btn-primary my-2">
    </form>
</div>
</html>

 

 

 

질문과 내용을 입력하고 "저장하기" 버튼을 누르면 405 오류가 발생한다.

question_form.html 에서 "저장하기" 버튼으로 폼을 전송하면 <form method="post">에 의해
POST 방식으로 데이터가 요청된다.

 

POST 요청을 처리할 수 있도록 서비스와 컨트롤러를 수정하자

(... 생략 ...)
import java.time.LocalDateTime;
(... 생략 ...)
public class QuestionService {

    (... 생략 ...)
    
    public void create(String subject, String content) {
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(LocalDateTime.now());
        this.questionRepository.save(q);
    }
}
(... 생략 ...)
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @PostMapping("/create")
    public String questionCreate(@RequestParam String subject, @RequestParam String content) {
        this.questionService.create(subject, content);
        return "redirect:/question/list";
    }
}

질문을 저장하기 위해 서비스에 create 메서드를 추가했다.

QuestionController에서 해당 서비스의 메서드를 활용할 수 있게 컨트롤러에도 메서드를 추가했다

 

POST 방식으로 요청 /question/create URL을 처리하기 위해 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가했다. 메서드명은 @GetMapping시 사용했던 questionCreate 메서드명과 동일하게 사용할 수 있다.

(단, 매개변수의 형태가 다른 경우에 가능하다. - 메서드 오버로딩)

 

questonCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는다.

이 때 질문 등록 템플릿에서 필드 항목으로 사용했던 subject, content의 이름과 동일하게 해야 함에 주의하자.

QuestionService로 질문 데이터를 저장하는 코드를 작성하였다.

이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있을 것이다.

 

 

폼 클래스

화면에서 전달받은 입력 값을 검증하기위해 Spring Boot Validation 라이브러리를 build.gradle 파일에 추가했다.

dependencies {
    (... 생략 ...)
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

"Spring Boot Validation"을 설치하면 애너테이션들을 사용하여 입력 값을 검증할 수 있다.

 

애너테이션들과 기능은 https://beanvalidation.org/에서 혹은

https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec 에서 참조

 

화면에서 전달되는 입력 값을 검증하기 위해서는 폼 클래스가 필요하다.

화면의 입력항목 subject, content에 대응하는 QuestionForm 클래스를 작성하자.

package com.example.board.question;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class QuestionForm {
    @NotEmpty(message="제목은 필수항목입니다.")
    @Size(max=200)
    private String subject;

    @NotEmpty(message="내용은 필수항목입니다.")
    private String content;
}

@NotEmpty와 @Size 애너테이션이 적용되었다.

 

@NotEmpty는 해당 값이 Null 또는 빈 문자열("")을 허용하지 않음을 의미한다.

그리고 여기에 사용된 message 속성은 검증이 실패할 경우 화면에 표시할 오류 메시지이다.

 

@Size(max=200)은 최대 길이가 200 바이트를 넘으면 안된다는 의미이다.

이와 같이 설정하면 길이가 200 byte 보다 큰 제목이 입력되면 오류가 발생할 것이다.

content 속성 역시 @NotEmpty로 빈 값을 허용하지 않도록 했다.

 

 

컨트롤러

QuestionForm을 컨트롤러에서 사용할 수 있도록 컨트롤러를 수정하자.

(... 생략 ...)
import jakarta.validation.Valid;
import org.springframework.validation.BindingResult;
(... 생략 ...)
public class QuestionController {

	(... 생략 ...)    

	@PostMapping("/create")
	public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "/question/question_form";
        }
        this.questionService.create(questionForm.getSubject(), questionForm.getContent());
        return "redirect:/question/list";
    }	
}

questionCreate 메서드의 매개변수를 subject, content 대신 QuestionForm 객체로 변경했다.

subject, content 항목을 지닌 폼이 전송되면 QuestionForm의 subject, content 속성이 자동으로 바인딩 된다.

이것은 스프링 프레임워크의 바인딩 기능이다.

 

QuestionForm 매개변수 앞에 @Valid 애너테이션을 적용했다.

@Valid 애너테이션을 적용하면 QuestionForm의 @NotEmpty, @Size 등으로 설정한 검증 기능이 동작한다.

그리고 이어지는 BindingResult 매개변수는 @Valid 애너테이션으로 인해 검증이 수행된 결과를 의미하는 객체이다.

BindingResult 매개변수는 항상 @Valid 매개변수 바로 뒤에 위치해야 한다.
매개변수의 위치가 정확하지 않다면 @Valid만 적용이 되어 입력값 검증 실패 시 400 오류가 발생한다.

questionCreate 메서드는 bindResult.hasErrors()를 호출하여

오류가 있는 경우에는 다시 폼을 작성하는 화면을 렌더링하게 했고 오류가 없을 경우에만 질문 등록이 진행되도록 했다.

아무런 값도 입력하지 말고 "저장하기" 버튼을 눌러보자. QuestionForm의 @NotEmpty에 의해 Validation이 실패하여 다시 질문 등록 화면에 머물러 있을 것이다. 하지만 QuestionForm에 설정한 "제목은 필수항목입니다." 와 같은 오류 메시지는 보이지 않는다.

 

 

템플릿

검증에 실패한 오류메시지를 보여주기 위해 question_form 템플릿을 수정하자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    <form th:action="@{/question/create}" th:object="${questionForm}" method="post">
        <div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
            <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
        </div>
        <div class="mb-3">
            <label for="subject" class="form-label">제목</label>
            <input type="text" name="subject" id="subject" class="form-control">
        </div>
        <div class="mb-3">
            <label for="content" class="form-label">내용</label>
            <textarea name="content" id="content" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="저장하기" class="btn btn-primary my-2">
    </form>
</div>
</html>

#fields.hasAnyErrors가 true인 경우는 QuestionForm 검증이 실패한 경우이다.

QuestionForm에서 검증에 실패한 오류 메시지는 #fields.allErrors()로 구할 수 있다.

부트스트랩의 alert alert-danger 클래스를 사용하여 오류는 붉은 색으로 표시되도록 했다.

그리고 이렇게 오류를 표시하기 위해서는 타임리프의 th:object 속성이 반드시 필요하다.

th:object를 사용하여 폼의 속성들이 QuestionForm의 속성들로 구성된다는 점을 타임리프 엔진에 알려줘야 하기 때문이다.

 

QuestionController의 GetMapping으로 매핑한 메서드도 변경해야 한다.

question_form.html 템플릿은 "질문 등록하기" 버튼을 통해 GET 방식으로 요청되더라도 th:object에 의해 QuestionForm 객체가 필요하기 때문이다.

(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @GetMapping("/create")
    public String questionCreate(QuestionForm questionForm) {
        return "/question/question_form";
    }

    (... 생략 ...)
}

 

GetMapping으로 매핑한 questionCreate 메서드에 매개변수로 QuestionForm 객체를 추가했다.

이렇게 하면 이제 GET 방식에서도 question_form 템플릿에 QuestionForm 객체가 전달될 것이다.

QuestionForm과 같이 매개변수로 바인딩한 객체는 Model 객체로 전달하지 않아도 템플릿에서 사용이 가능하다.

 

 

잘 적용된 모습이다

 

 

오류 발생 시 입력한 내용 유지하기

오류 발생 시 입력한 내용 유지를 위해 question_form 템플릿을 수정하자.

 	(... 생략 ...) 
	<div class="mb-3">
            <label for="subject" class="form-label">제목</label>
            <input type="text" th:field="*{subject}" class="form-control">
        </div>
        <div class="mb-3">
            <label for="content" class="form-label">내용</label>
            <textarea th:field="*{content}" class="form-control" rows="10"></textarea>
        </div>        
	(... 생략 ...)

name="subject", name="content"와 같이 사용하던 부분을 위와 같이 th:field 속성을 사용하도록 변경하였다.

이렇게 하면 해당 태그의 id, name, value 속성이 모두 자동으로 생성되고 타임리프가 value 속성에 기존 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.

 

 

답변 등록


질문 등록과 동일하게 하면 된다. AnswerForm을 작성하자.

package com.example.board.answer;

import javax.validation.constraints.NotEmpty;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AnswerForm {
    @NotEmpty(message = "내용은 필수항목입니다.")
    private String content;
}

 

AnswerController을 사용하도록 변경하자.

package com.example.board.answer;

import javax.validation.Valid;
import org.springframework.validation.BindingResult;
(... 생략 ...)
public class AnswerController {

	(... 생략 ...)
	
	@PostMapping("/create/{id}")
	public String createAnswer(Model model, @PathVariable("id") Integer id,
				  @Valid AnswerForm answerForm, BindingResult bindingResult) {		
		Question question = this.questionService.getQuestion(id);
		if(bindingResult.hasErrors()) {
			model.addAttribute("question", question);
			return "/question/question_detail";
		}
		this.answerService.create(question, answerForm.getContent());
		return String.format("redirect:/question/detail/%s", id);
	}	
}

QuestionForm을 사용했던 방법과 마찬가지로 @Valid와 BindingResult를 사용하여 검증을 진행한다.

검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 렌더링하게 했다.

이때 question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 저장한 후에 question_detail 템플릿을 렌더링해야 한다.

 

question_detail.html 템플릿 파일을 수정하자

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">

    (... 생략 ...)
    <!-- 답변 작성 -->
 	<form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
		<div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
			<div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
		</div>
        <textarea th:field="*{content}" rows="10" class="form-control"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
</div>
</html>

답변 등록 폼의 속성이 AnswerForm을 사용하기 때문에 th:object 속성을 추가했다. 그리고 검증이 실패할 경우 오류메시지를 출력하기 위해 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류를 표시했다.

content 항목도 th:field 속성을 사용하도록 변경했다.

 

question_detail 템플릿이 AnswerForm을 사용하기 때문에 QuestionController의 detail 메서드도 수정해야 한다.

(... 생략 ...)
import com.mysite.sbb.answer.AnswerForm;
(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @GetMapping(/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
        (... 생략 ...)
    }

    (... 생략 ...)
}

 

 

답변 검증 오류가 잘 들어왔다

 

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

12. 템플릿 상속 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2022. 12. 31. 21:53
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전입니다.

 

 

 

 

 

 

 

 

 

표준 HTML 구조


지금까지 작성한 질문 목록, 질문 상세 템플릿은 표준 HTML 구조가 아니다. 어떤 웹 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 작동 하게 하려면 반드시 웹 표준을 지키는 HTML 문서를 작성해야 한다.

 

표준 HTML구조는 아래와 같다

<!doctype html>
<html lang="ko">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
    <!-- sbb CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
    <title>Hello, sbb!</title>
</head>
<body>
(... 생략 ...)
</body>
</html>

표준 HTML 문서의 구조는 위의 예처럼 html, head, body 엘리먼트가 있어야 하며, CSS 파일은 head 엘리먼트 안에 링크 되어야 한다. 또한 head 엘리먼트 안에는 meta, title 엘리먼트 등이 포함되어야 한다.

 

 

템플릿 상속


앞에서 작성한 질문 목록, 질문 상세 템플릿을 표준 HTML 구조가 되도록 수정해 보자. 그런데 템플릿 파일들을 모두 표준 HTML 구조로 변경하면 body 엘리먼트 바깥 부분(head 엘리먼트 등)은 모두 같은 내용으로 중복된다. 그러면 CSS 파일 이름이 변경되거나 새로운 CSS 파일이 추가될 때마다 모든 템플릿 파일을 일일이 수정해야 한다.

타임리프는 이런 중복의 불편함을 해소하기 위해 템플릿 상속 기능을 제공한다. 템플릿 상속은 기본 틀이 되는 템플릿을 먼저 작성하고 다른 템플릿에서 그 템플릿을 상속해 사용하는 방법이다.

 

layout.html

표준 HTML 구조의 기본 틀이 되는 layout.html 템플릿을 작성하자

파일 경로 : /practice/src/main/resources/templates/layout.html

<!doctype html>
<html lang="ko">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
    <!-- practice CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
    <title>게시판 만들기 연습</title>
</head>
<body>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>

layout.html 템플릿은 모든 템플릿이 상속해야 하는 템플릿으로 표준 HTML 문서의 기본 틀이된다.

body 엘리먼트 안의 <th:block layout:fragment="content"></th:block> 부분이 바로 layout.html을 상속한 템플릿에서 개별적으로 구현해야 하는 영역이 된다. 즉, layout.html 템플릿을 상속하면 <th:block layout:fragment="content"></th:block> 영역에 해당되는 부분만 작성해도 표준 HTML 문서가 되는 것이다.

 

question_list.html

question_list.html 템플릿을 다음과 같이 변경하자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    (... 생략 ...)
</div>
</html>

부트스트랩 스타일 링크는 삭제했다. 왜냐하면 부모 템플릿인 layout.html 템플릿에서 이미 부트스트랩 스타일을 링크하기 때문이다.

부트스트랩 스타일 링크는 부모 템플릿인 layout.html에서 이미 링크하기 때문에 삭제했다

layout.html 템플릿을 상속하기 위해 <html layout:decorate="~{layout}"> 처럼 사용했다.

타임리프의 layout:decorate 속성은 템플릿의 레이아웃(부모 템플릿)으로 사용할 템플릿을 설정한다.

속성의 값인 ~{layout}은 layout.html 파일을 의미한다.

 

부모 템플릿인 layout.html 에는 다음과 같은 내용이 있었다.

<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->

부모 템플릿의 위 부분을 자식 템플릿의 내용으로 바꾸기 위해 다음과 같이 사용했다.

<div layout:fragment="content" class="container my-3">
    (... 생략 ...)
</div>

이렇게 하면 부모 템플릿의 th:block 엘리먼트의 내용이 자식 템플릿의 div 엘리먼트의 내용으로 교체 된다.

question_list.html은 layout.html 템플릿을 상속받아 표준 HTML문서가 된다.

 

question_detail.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    (... 생략 ...)
</div>
</html>

question_list.html 템플릿과 동일한 방법으로 layout.html 템플릿을 상속했다.

 

style.css

부트스트랩 적용으로 인해 style.css의 내용은 필요가 없어졌으므로 기존 내용을 모두 삭제하자.

style.css 파일은 이후 부트스트랩으로 표현할 수 없는 스타일 작성을 위해 내용만 삭제하자.

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

11. 부트스트랩 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2022. 12. 31. 21:24
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전입니다.

 

 

 

 

부트스트랩


부트스트랩(Bootstrap)은 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들수 있게 도와주는 프레임워크이다. 부트스트랩은 트위터(Twitter)를 개발하면서 만들어졌고 현재 지속적으로 관리되고 있는 오픈소스 프로젝트이다.

 

부트스트랩 다운로드 - https://getbootstrap.com/docs/5.2/getting-started/download/

해당 링크에서 5.2.3버전의 부트스트랩을 다운로드한 후 bootstrap.min.css 파일을 스태틱 디렉터리에 저장하자

 

압축 파일 경로 : bootstrap-5.2.3-dist.zip/bootstrap-5.2.3-dist/css/bootstrap.min.css

카피할 경로 : /sbb/src/main/resources/static/bootstrap.min.css

 

 

부트스트랩 적용


question_list.html 템플릿에 부트스트랩을 적용하자

<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<div class="container my-3">
    <table class="table">
        <thead class="table-dark">
            <tr>
                <th>번호</th>
                <th>제목</th>
                <th>작성일시</th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="question, loop : ${questionList}">
                <td th:text="${loop.count}"></td>
                <td>
                    <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
                </td>
                <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
            </tr>
        </tbody>
    </table>
</div>

테이블 항목으로 "번호"를 추가했다. 번호는 loop.count를 사용하여 표시했다.

날짜를 보기 좋게 출력하기 위해 타임리프의 #temporals.format 유틸리티를 사용했다.

#temporals.format은 다음과 같이 사용한다.

#temporals.format(날짜객체, 날짜포맷) - 날짜객체를 날짜포맷에 맞게 변환한다.

그리고 가장 윗줄에 bootstrap.min.css 스타일시트를 사용할수 있도록 링크를 추가했다.

class="container my-3", class="table", class="table-dark 등은 부트스트랩 스타일시트에 정의되어 있는 클래스들이다.

부트스트랩에 대한 자세한 내용은 다음 URL을 참조하자.

https://getbootstrap.com/docs/5.2/getting-started/introduction/

 

question/list에 부트스트랩이 적용된 모습

 

부트스트랩이 잘 적용되었다

 

 

 

question_detail에도 부트스트랩을 적용시키자

<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<div class="container my-3">
    <!-- 질문 -->
    <h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
    <div class="card my-3">
        <div class="card-body">
            <div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
            <div class="d-flex justify-content-end">
                <div class="badge bg-light text-dark p-2 text-start">
                    <div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                </div>
            </div>
        </div>
    </div>
    
    <!-- 답변의 갯수 표시 -->
    <h5 class="border-bottom my-3 py-2" 
        th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
        
    <!-- 답변 반복 시작 -->
    <div class="card my-3" th:each="answer : ${question.answerList}">
        <div class="card-body">
            <div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
            <div class="d-flex justify-content-end">
                <div class="badge bg-light text-dark p-2 text-start">
                    <div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                </div>
            </div>
        </div>
    </div>
    <!-- 답변 반복 끝  -->
    
    <!-- 답변 작성 -->
    <form th:action="@{|/answer/create/${question.id}|}" method="post" class="my-3">
        <textarea name="content" id="content" rows="10" class="form-control"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
</div>

질문이나 답변은 하나의 뭉치에 해당하므로 부트스트랩의 card 컴포넌트를 사용했다.

https://getbootstrap.com/docs/5.2/components/card/

 

부트스트랩 클래스 설명
card, card-body, card-text 부트스트랩 Card 컴포넌트
badge 부트스트랩 Badge 컴포넌트
form-control 부트스트랩 Form 컴포넌트
border-bottom 아래방향 테두리 선
my-3 상하 마진값 3
py-2 상하 패딩값 2
p-2 상하좌우 패딩값 2
d-flex justify-content-end 컴포넌트의 우측 정렬
bg-light 연회색 배경
text-dark 검은색 글씨
text-start 좌측 정렬
btn btn-primary 부트스트랩 버튼 컴포넌트

그리고 질문 내용과 답변 내용에는 style="white-space: pre-line;" 과 같은 스타일을 지정해 주었다.

글 내용의 줄 바꿈을 정상적으로 보여주기 위해 적용한 스타일이다.

 

 

detail에 부트스트랩이 적용된 모습

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

10. 스태틱 디렉터리와 스타일시트 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2022. 12. 31. 20:56
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전입니다.

 

 

 

 

스타일시트


스타일시트 파일은 스프링부트의 스태틱 디렉토리에 저장해야 한다

 

static 디렉터리

 

 

 

스타일시트 파일(style.css)을 작성하자

파일경로 : /practice/src/main/resources/static/style.css

textarea {
    width:100%;
}

input[type=submit] {
    margin-top:10px;
}

 

 

템플릿에 스타일 적용


스타일시트 파일을 질문 상세 템플릿에 적용하자

템플릿 최상단에 style.css를 사용할 수 있는 링크를 추가했다.

<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
(... 생략 ...)
static 디렉터리에 style.css 파일이 위치하지만 /static/style.css 대신 /style.css로 사용해야 함에 주의하자.
왜냐하면 static 디렉터리가 스태틱 파일들의 루트 디렉터리이기 때문이다.

 

 

잘 적용되었다

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

9. 답변 등록 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2022. 12. 31. 20:43
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전입니다.

 

 

 

 

답변 등록 버튼 만들기


<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>

<form th:action="@{|/answer/create/${question.id}|}" method="post">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변등록">
</form>

이제 <답변등록> 버튼을 누르면 POST 방식으로 /answer/create/<질문id> URL이 호출(submit)될 것이다. 하지만 아직 /answer/create/<질문id> URL에 대한 매핑이 없으므로 버튼을 누르면 다음과 같은 404 페이지가 나타난다.

 

답변 서비스 만들기


답변을 저장하는 AnswerService를 작성하자

package com.example.board.answer;

import java.time.LocalDateTime;

import org.springframework.stereotype.Service;

import com.example.board.question.Question;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class AnswerService {

	private final AnswerRepository answerRepository;
	
	public void create(Question question, String content) {
		Answer answer = new Answer();
		answer.setContent(content);
		answer.setCreateDate(LocalDateTime.now());
		answer.setQuestion(question);
		this.answerRepository.save(answer);
	}	
}

답변 생성을 위해 create 메서드를 추가했다.

메서드 내의 입력받은 content와 question을 사용하여 객체를 생성하여 리파지토리에 저장했다.

 

 

답변 컨트롤러 만들기


package com.example.board.answer;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.example.board.question.Question;
import com.example.board.question.QuestionService;

import lombok.RequiredArgsConstructor;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {

	private final QuestionService questionService;
	private final AnswerService answerService;
	
	@PostMapping("/create/{id}")
	public String createAnswer(Model model, @PathVariable("id") Integer id, @RequestParam String content) {		
		Question question = this.questionService.getQuestion(id);
		this.answerService.create(question, content);
		return String.format("redirect:/question/detail/%s", id);
	}	
}

 

답변 컨트롤러의 URL 프리픽스도 /answer로 고정했다.

그리고 /answer/create/{id}와 같은 URL 요청시 createAnswer 메서드가 호출되도록 @PostMapping으로 매핑했다.

@PostMapping은 @GetMapping과 동일하게 매핑을 담당하는 역할을 하지만 POST요청만 받아들일 경우에 사용한다.

만약 위 URL을 GET방식으로 요청할 경우에는 오류가 발생한다.

@PostMapping(value="/create/{id}") 대신 @PostMapping("/create/{id}") 처럼 value는 생략해도 된다.

 

그리고 createAnswer 메서드의 매개변수에는 @RequestParam String content 항목이 추가되었다.

이 항목은 템플릿에서 답변으로 입력한 내용(content)을 얻기 위해 추가되었다.템플릿의 답변 내용에 해당하는 textarea의 name 속성명이 content이기 때문에 여기서도 변수명을 content로 사용해야 한다.

만약 content 대신 다른 이름으로 사용하면 오류가 발생할 것이다.

 

createAnswer 메서드의 URL 매핑 /create/{id}에서 {id}는 질문의 id 이므로 이 id 값으로 질문을 조회하고 없을 경우에는 404 오류가 발생할 것이다.

 

AnswerService의 create 메서드를 호출하여 답변을 저장할수 있게 했다.

 

 

질문 상세 페이지 답변 표시하기


<h1 th:text="${question.subject}"></h1>
<div th:text="${question.content}"></div>
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<div>
    <ul>
        <li th:each="answer : ${question.answerList}" th:text="${answer.content}"></li>
    </ul>
</div>

<form th:action="@{|/answer/create/${question.id}|}" method="post">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변등록">
</form>

기존 코드에서 답변을 확인할 수 있는 영역을 추가했다.

#lists.size(question.answerList)}는 답변 개수를 의미한다.

#lists.size(이터러블객체)는 타임리프가 제공하는 유틸리티로 객체의 길이를 반환한다.

답변은 question 객체의 answerList를 순회하여 <li> 엘리먼트로 표시했다.

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록