지금까지 만든 SBB로 질문 목록을 조회하고 질문을 등록하고, 다시 내용을 보고 답변을 달고 이렇게 조작을 하다 보면 아마도 불편함을 느끼게 될것이다. 그것은 바로 메인페이지(Home)로 돌아갈 수 있는 기능이 없기 때문이다. 이번에는 이러한 불편을 해소할 수 있는 내비게이션바를 만들어 보자. 내비게이션바는 모든 화면 위쪽에 고정되어 있는 부트스트랩 컴포넌트이다.

부트스트랩 내비게이션바: https://getbootstrap.com/docs/5.2/components/navbar/

내비게이션바

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

[파일명:/sbb/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}">
    <!-- sbb CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
    <title>Hello, sbb!</title>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">SBB</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>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>

항상 홈 페이지로 이동해 주는 'SBB' 로고를 가장 왼쪽에 배치했고, 오른쪽에는 '로그인' 링크를 추가했다. (로그인 기능은 나중에 구현한다.)

이제 질문 목록 페이지를 요청하면 화면 상단에 다음과 같은 내비게이션바가 보일 것이다.

내비게이션바의 'SBB' 로고를 누르면 아무 곳에서나 메인 페이지로 돌아갈 수 있다. 'SBB' 로고를 눌러서 잘 작동하는지 확인해 보자.

그리고 부트스트랩 내비게이션바에는 재미있는 기능이 하나 숨어 있다. 아무 페이지나 접속해서(여기서는 질문 목록에 접속했다) 웹 브라우저의 크기를 마우스를 이용하여 점점 줄여나가 보자. 그러면 어느 순간 햄버거 메뉴 버튼이 생긴다. 그리고 '로그인' 링크는 사라진다.

부트스트랩은 브라우저의 크기가 작아지면 내비게이션 바에 있는 링크들을 작은 햄버거 메뉴 버튼으로 숨긴다. (부트스트랩의 반응형 웹 기능이다.)

메뉴 버튼을 클릭해야 숨겨진 "로그인" 링크가 보일 것이다. 하지만 아직은 메뉴 버튼을 클릭해도 아무런 변화가 없다. 그 이유는 부트스트랩 자바스크립트 파일(bootstrap.min.js)이 layout.html 파일에 포함되지 않았기 때문이다.

부트스트랩 자바스크립트 파일은 이미 다운로드 받은 bootstrap-5.2.3-dist.zip 압축파일에 있으므로 다음 위치에 복사해 주자.

  • 압축파일내 경로: bootstrap-5.2.3-dist.zip/bootstrap-5.2.3-dist/js/bootstrap.min.js
  • 붙여 넣을 위치: /sbb/src/main/resources/static/bootstrap.min.js

즉, 다음처럼 /sbb/src/main/resources/static 디렉터리 하위에 bootstrap.min.js 파일이 위치해 있어야 한다.

이제 추가한 자바스크립트 파일을 사용할 수 있도록 layout.html 하단의 </body> 태그 바로위에 다음처럼 추가하자.

[파일명:/sbb/src/main/resources/templates/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>

이렇게 수정하면 메뉴 버튼 클릭시 숨겨진 링크가 다음처럼 표시되는 것을 확인할 수 있을 것이다.

네비게이션바 분리하기

이전 장에서 오류메시지를 표시하는 공통 템플릿을 작성하고 질문 등록과 질문 상세 템플릿에 삽입하였다. 네비게이션바도 공통 템플릿으로 다음과 같이 분리해 보자.

먼저 다음과 같이 layout.html에 포함시킬 navbar.html 템플릿을 작성하자.

[파일명:/sbb/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="/">SBB</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>

navbar.html의 내용은 layout.html에 삽입했던 네비게이션바의 내용과 동일하다.

그리고 layout.html을 다음과 같이 수정하자.

[파일명: /sbb/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}">
    <!-- sbb CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
    <title>Hello, sbb!</title>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">SBB</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>
<!-- 네비게이션바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
</body>
</html>

내비게이션바 HTML 코드들을 삭제하고 navbar.html 템플릿을 타임리프의 th:replace 속성으로 포함시켰다.

navbar.html 파일은 다른 템플릿들에서 중복되어 사용되지는 않지만 독립된 하나의 템플릿으로 관리하는 것이 유지 보수에 유리하므로 분리하였다.

'백엔드 > Springboot' 카테고리의 다른 글

게시물에 일련번호 추가하기  (3) 2023.11.09
페이징  (1) 2023.11.09
공통 템플릿  (0) 2023.11.09
질문 등록과 폼  (0) 2023.11.09
템플릿 상속  (0) 2023.11.09

이전 챕터에서 질문 등록과 답변 등록시 오류가 발생하면 다음과 같이 오류를 표시했다.

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

앞으로 추가적으로 만들 템플릿들에도 위와 같이 오류를 표시하는 부분이 필요하다. 이렇게 반복적으로 사용하는 문장은 공통 템플릿으로 만들고 필요한 부분에 삽입하여 쓸 수 있다면 편리하지 않을까?

이번 장에서는 위의 오류 메시지를 출력하는 부분을 공통 템플릿으로 작성하고 필요한 곳에 삽입하여 사용할 수 있도록 해보자.

오류 메시지 공통 템플릿

오류 메시지를 표시하는 공통 템플릿을 다음과 같이 작성하자.

[파일명:/sbb/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 파일에 적용해 보자.

[파일명:/sbb/src/main/resources/templates/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 th:replace="~{form_errors :: formErrorsFragment}"></div> 의 의미는 div 엘리먼트를 form_errors.html 파일의 th:fragment 속성명이 formErrorsFragment인 엘리먼트로 교체하라는 의미이다.

질문 상세 템플릿에 적용하기

답변을 등록하는 question_detail.html 파일도 다음과 같이 수정하자.

[파일명:/sbb/src/main/resources/templates/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>

이렇게 변경하고 질문 등록과 답변 등록을 진행해 보자. 이전과 동일하게 동작하는 것을 확인할 수 있을 것이다.

'백엔드 > Springboot' 카테고리의 다른 글

페이징  (1) 2023.11.09
네비게이션바  (0) 2023.11.09
질문 등록과 폼  (0) 2023.11.09
템플릿 상속  (0) 2023.11.09
부트스트랩  (0) 2023.11.09

질문 등록

질문 등록을 하려면 먼저 "질문 등록하기" 버튼을 만들어야 한다. 다음처럼 질문 목록 하단에 버튼을 생성하자.

[파일이름:/sbb/src/main/resources/templates/question_list.html]

<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 매핑을 추가하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

(... 생략 ...)

public class QuestionController {

    (... 생략 ...)

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

"질문 등록하기" 버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용하였다. questionCreate 메서드는 question_form 템플릿을 렌더링하여 출력한다.

템플릿

질문 등록을 위한 question_form.html 템플릿은 다음과 같이 작성하자.

[파일명:/sbb/src/main/resources/templates/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 오류가 발생한다. 405 오류는 "Method Not Allowed" 오류로 /question/create URL을 POST 방식으로는 처리할 수 없음을 의미한다.

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

따라서 POST 요청을 처리할 수 있도록 다음처럼 컨트롤러를 수정해야 한다.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

package com.mysite.sbb.question;

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

    (... 생략 ...)

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

    @PostMapping("/create")
    public String questionCreate(@RequestParam String subject, @RequestParam String content) {
        // TODO 질문을 저장한다.
        return "redirect:/question/list"; // 질문 저장후 질문목록으로 이동
    }
}

POST 방식으로 요청한 /question/create URL을 처리하기 위해 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가했다. 메서드명은 @GetMapping시 사용했던 questionCreate 메서드명과 동일하게 사용할 수 있다. (단, 매개변수의 형태가 다른 경우에 가능하다. - 메서드 오버로딩)

questonCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는다. 이 때 질문 등록 템플릿에서 필드 항목으로 사용했던 subject, content의 이름과 동일하게 해야 함에 주의하자.

이제 입력으로 받은 subject, content를 사용하여 질문을 저장해야 한다. 일단 질문 저장은 잠시 뒤로 미루고(TODO 주석만 작성했다.) 질문이 저장되면 질문 목록 페이지로 이동하도록 했다.

서비스

질문을 저장하려면 서비스에 해당 기능을 추가해야 한다. QuestionService를 다음과 같이 수정하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionService.java]

(... 생략 ...)
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);
    }
}

제목과 내용을 입력으로 하여 질문 데이터를 저장하는 create 메서드를 만들었다. 이제 Question 컨트롤러에서 이 서비스를 사용할수 있도록 다음과 같이 수정하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

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

    (... 생략 ...)

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

TODO 주석문 대신 QuestionService로 질문 데이터를 저장하는 코드를 작성하였다. 이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있을 것이다.

폼(form)

위에서 질문을 등록하는 기능을 구현했다. 하지만 질문 등록시 간과한 것이 있다. 그것은 바로 질문이나 내용을 등록할 때 비어 있는 값으로 등록이 가능하다는 점이다. 빈 값으로 등록이 불가능하게 하려면 여러 방법이 있지만 여기서는 폼을 사용하여 입력값을 체크하는 방법을 사용해 보자.

Spring Boot Validation

화면에서 전달받은 입력 값을 검증하려면 Spring Boot Validation 라이브러리가 필요하다. 다음과 같이 build.gradle 파일을 수정하자.

[파일명:/sbb/build.gradle]

(... 생략 ...)

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

(... 생략 ...)

이와 같이 수정하고 "Refresh Gradle Project"로 필요한 라이브러리를 설치하자.

라이브러리를 설치한 후에는 반드시 로컬서버를 재시작해야 한다.

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

항목설명
@Size 문자 길이를 제한한다.
@NotNull Null을 허용하지 않는다.
@NotEmpty Null 또는 빈 문자열("")을 허용하지 않는다.
@Past 과거 날짜만 가능
@Future 미래 날짜만 가능
@FutureOrPresent 미래 또는 오늘날짜만 가능
@Max 최대값
@Min 최소값
@Pattern 정규식으로 검증

보다 많은 기능은 다음의 URL을 참고하도록 하자.

폼 클래스

화면에서 전달되는 입력 값을 검증하기 위해서는 폼 클래스가 필요하다. 화면의 입력항목 subject, content에 대응하는 QuestionForm 클래스를 다음과 같이 작성하자.

폼 클래스는 입력 값의 검증에도 사용하지만 화면에서 전달한 입력 값을 바인딩할 때에도 사용한다.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionForm.java]

package com.mysite.sbb.question;

import jakarta.validation.constraints.NotEmpty;
import jakarta.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;
}

subject 속성에는 @NotEmpty와 @Size 애너테이션이 적용되었다. @NotEmpty는 해당 값이 Null 또는 빈 문자열("")을 허용하지 않음을 의미한다. 그리고 여기에 사용된 message 속성은 검증이 실패할 경우 화면에 표시할 오류 메시지이다. @Size(max=200)은 최대 길이가 200 바이트를 넘으면 안된다는 의미이다. 이와 같이 설정하면 길이가 200 byte 보다 큰 제목이 입력되면 오류가 발생할 것이다. content 속성 역시 @NotEmpty로 빈 값을 허용하지 않도록 했다.

컨트롤러

그리고 위에서 작성한 QuestionForm을 컨트롤러에서 사용할 수 있도록 다음과 같이 컨트롤러를 수정하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

(... 생략 ...)
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_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 매개변수 바로 뒤에 위치해야 한다. 만약 2개의 매개변수의 위치가 정확하지 않다면 @Valid만 적용이 되어 입력값 검증 실패 시 400 오류가 발생한다.

따라서 questionCreate 메서드는 bindResult.hasErrors()를 호출하여 오류가 있는 경우에는 다시 폼을 작성하는 화면을 렌더링하게 했고 오류가 없을 경우에만 질문 등록이 진행되도록 했다.

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

오류메시지가 보이지 않는다면 어떤 항목에서 검증이 실패했는지 알 수가 없다. 어떻게 해야 할까?

템플릿

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

[파일명:/sbb/src/main/resources/templates/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 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 객체가 필요하기 때문이다.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

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

    (... 생략 ...)

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

    (... 생략 ...)
}

GetMapping으로 매핑한 questionCreate 메서드에 매개변수로 QuestionForm 객체를 추가했다. 이렇게 하면 이제 GET 방식에서도 question_form 템플릿에 QuestionForm 객체가 전달될 것이다.

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

이렇게 수정하고 제목 또는 내용에 값을 채우지 않은 상태로 질문 등록을 진행하면 다음과 같은 오류가 화면에 표시될 것이다.

검증에 실패한 오류 메시지가 표시되는 것을 확인할 수 있다.

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

하지만 테스트를 진행하다보니 또 다른 문제를 발견했다. 그것은 이미 입력한 "제목"이나 "내용"이 사라진다는 점이다. 즉, 제목에 값을 채우고 내용을 비워둔 채로 "저장하기" 버튼을 누르면 오류 메시지가 나타남과 동시에 이미 입력한 제목의 내용도 사라진다는 점이다. 입력한 제목은 남아 있어야 하지 않겠는가?

이러한 문제를 해결하려면 이미 입력한 값이 유지되도록 다음과 같이 템플릿을 수정해야 한다.

[파일명:/sbb/src/main/resources/templates/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 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" 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>

name="subject", name="content"와 같이 사용하던 부분을 위와 같이 th:field 속성을 사용하도록 변경하였다. 이렇게 하면 해당 태그의 id, name, value 속성이 모두 자동으로 생성되고 타임리프가 value 속성에 기존 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.

이제 위와 같이 수정하고 다시 질문 등록을 진행해 보자. 이전에 입력했던 값이 유지되는 것을 확인할 수 있을 것이다.

제목에 아무 값을 채우고 내용에는 값을 비워둔채로 "저장하기" 버튼을 누르면 위와 같이 제목에 입력한 내용이 사라지지 않고 남아있다.

답변 등록

질문 등록에 폼을 적용한 것처럼 답변 등록을 할 때에도 폼을 적용해 보자. 질문 등록과 동일한 방법이므로 조금 빠르게 적용해 보자. 먼저 답변을 등록할 때 사용할 AnswerForm을 다음과 같이 작성하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/answer/AnswerForm.java]

package com.mysite.sbb.answer;

import jakarta.validation.constraints.NotEmpty;

import lombok.Getter;
import lombok.Setter;

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

그리고 AnswerController를 다음과 같이 수정하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/answer/AnswerController.java]

(... 생략 ...)
import jakarta.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_detail";
        }
        this.answerService.create(question, answerForm.getContent());
        return String.format("redirect:/question/detail/%s", id);
    }
}

AnswerForm을 사용하도록 컨트롤러를 변경했다. QuestionForm을 사용했던 방법과 마찬가지로 @Valid와 BindingResult를 사용하여 검증을 진행한다. 검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 렌더링하게 했다. 이때 question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 저장한 후에 question_detail 템플릿을 렌더링해야 한다.

그리고 question_detail.html 템플릿 파일은 다음과 같이 수정하자.

[파일명:/sbb/src/main/resources/templates/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 메서드도 다음과 같이 수정해야 한다.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

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

    (... 생략 ...)

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

    (... 생략 ...)
}

이와 같이 수정하고 답변 등록을 진행해 보자. 만약 답변 내용 없이 답변을 등록하려고 시도하면 다음과 같은 검증 오류가 발생할 것이다.

'백엔드 > Springboot' 카테고리의 다른 글

네비게이션바  (0) 2023.11.09
공통 템플릿  (0) 2023.11.09
템플릿 상속  (0) 2023.11.09
부트스트랩  (0) 2023.11.09
스태틱 디렉터리와 스타일시트  (0) 2023.11.09

HTML에 익숙한 독자라면 눈치겠지만, 지금까지 작성한 질문 목록, 질문 상세 템플릿은 표준 HTML 구조가 아니다. 어떤 웹 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 작동 하게 하려면 반드시 웹 표준을 지키는 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 엘리먼트 등이 포함되어야 한다.

점프 투 스프링부트태그와 엘리먼트

<table> (... 생략 ...) </table>  <!-- table 엘리먼트 -->

위에서 <table>은 table 태그이고 <table> ~ </table> 처럼 table 태그로 시작해서 table 태그로 닫힌 구간(Block)은 table 엘리먼트이다.

템플릿 상속

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

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

템플릿 상속에 대해서 자세히 알아보자.

layout.html

먼저 표준 HTML 구조의 기본 틀이 되는 layout.html 템플릿을 다음처럼 작성하자.

[파일명:/sbb/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}">
    <!-- sbb CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
    <title>Hello, sbb!</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 템플릿을 다음과 같이 변경하자.

[파일명:/sbb/src/main/resources/templates/question_list.html]

<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <table class="table">
        (... 생략 ...)
    </table>
</div>
</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

question_detail.html도 마찬가지 방법으로 수정하자.

[파일명: /sbb/src/main/resources/templates/question_detail.html]

<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
    (... 생략 ...)
    </form>
</div>
</html>

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

템플릿 상속을 적용한 후 질문 목록, 질문 상세를 조회해 보자. 화면에 보여지는 것은 동일하지만 표준 HTML 구조로 변경된 것을 확인할 수 있다.

브라우저에서 소스보기 기능으로 생성된 HTML을 확인할 수 있다.

[질문 목록 - 브라우저 소스보기 화면]

style.css

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

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

[파일명: /sbb/src/main/resources/static/style.css]

textarea {
    width:100%;
}

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

'백엔드 > Springboot' 카테고리의 다른 글

공통 템플릿  (0) 2023.11.09
질문 등록과 폼  (0) 2023.11.09
부트스트랩  (0) 2023.11.09
스태틱 디렉터리와 스타일시트  (0) 2023.11.09
답변 등록  (0) 2023.11.09

마찬가지로 백엔드의 영역이 아니지만 교재에 있길래 추가한다.

웹 디자이너 없이 혼자서 웹 프로그램을 작성해 보았다면 화면 디자인 작업에 얼마나 많은 시간과 고민이 필요한지 알고 있을 것이다. 이번에 소개하는 부트스트랩(Bootstrap)은 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들수 있게 도와주는 프레임워크이다. 부트스트랩은 트위터(Twitter)를 개발하면서 만들어졌고 현재 지속적으로 관리되고 있는 오픈소스 프로젝트이다.

부트스트랩을 적용하여 SBB 서비스를 이쁘게 만들어 보자.

부트스트랩 설치

우선 다음 URL에서 부트스트랩을 다운로드 하자.

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

점프 투 스프링부트부트스트랩 주의사항

부트스트랩은 3.x, 4.x, 5.x 등의 버전이 존재하고 메이저 번호(3, 4, 5)에 따라 그 사용방법이 다르다. 이 책은 부트스트랩 버전 5 기준으로 실습을 진행한다. 다른 부트스트랩 버전을 사용하면 이 책의 예제는 정상 동작하지 않는다.

부트스트랩 다운로드 페이지 접속후 "Download" 버튼을 누르면 다음과 같은 파일이 다운로드 된다.

bootstrap-5.2.3-dist.zip

이 책을 작성하는 시점의 부트스트랩 최신 버전은 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

나중에 진행되는 챕터에서 bootstrap.min.js 파일도 필요하니 bootstrap-5.2.3-dist.zip 파일을 삭제하지 말자.

부트스트랩 적용

먼저 질문 목록 템플릿에 부트스트랩을 다음처럼 적용하자.

[파일명:/sbb/src/main/resources/templates/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을 참조하자.

앞으로 템플릿 작성시에 계속 부트스트랩 스타일들을 사용할 것이다. 물론 사용하는 부트스트랩 클래스들에 대해서 간단한 설명은 하겠지만 위 문서를 간단하게라도 한번 먼저 읽어보기를 당부한다.

이제 다음처럼 부트스트랩이 적용된 질문 목록을 볼 수 있을 것이다.

이어서 질문 상세 템플릿에도 다음처럼 부트스트랩을 적용하자.

[파일명:/sbb/src/main/resources/templates/question_detail.html]

<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>

이번에는 수정사항이 좀 많다. 부트스트랩으로 화면을 구성하다 보면 가끔은 이렇게 많은 양의 HTML코드를 작성해야 한다. 하지만 어렵지 않으니 찬찬히 살펴보자. 질문이나 답변은 하나의 뭉치에 해당하므로 부트스트랩의 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;" 과 같은 스타일을 지정해 주었다. 글 내용의 줄 바꿈을 정상적으로 보여주기 위해 적용한 스타일이다.

부트스트랩을 적용한 질문 상세 화면은 다음과 같다.

부트스트랩을 사용하면 정말 빠르게 만족스러운 화면을 만들 수 있다.

'백엔드 > Springboot' 카테고리의 다른 글

질문 등록과 폼  (0) 2023.11.09
템플릿 상속  (0) 2023.11.09
스태틱 디렉터리와 스타일시트  (0) 2023.11.09
답변 등록  (0) 2023.11.09
질문 상세  (1) 2023.11.09

엄밀히 말하면 스프링부트, 백엔드의 영역은 아니지만 교재에 있길래 추가하는 내용이다.

지금까지 질문 목록 화면과 질문 상세 화면을 만들었다. 하지만 좀 더 그럴싸한 화면을 만들기 위해서는 화면에 디자인을 적용해야 한다. 디자인을 적용하기 위해서는 스타일시트(stylesheet, CSS파일)를 사용해야 한다.

이번 장에서는 SBB에 스타일시트를 적용해 보자.

스태틱(static) 디렉터리

스타일시트 파일은 스프링부트의 스태틱 디렉터리에 저장해야 한다. 스프링부트의 스태틱 디렉터리는 다음과 같다.

/sbb/src/main/resources/static

스타일시트

스타일시트 파일은 스태틱 디렉터리에 저장해야 한다. 스타일시트 파일(style.css)을 다음과 같이 작성하자.

[파일명:/sbb/src/main/resources/static/style.css]

textarea {
    width:100%;
}

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

style.css 파일은 질문 상세 화면에 사용하기 위해 작성했다. 답변 등록시 사용하는 텍스트 창의 넓이를 100%로 하고 "답변등록" 버튼 상단에 10 픽셀의 마진을 설정했다.

템플릿에 스타일 적용

이제 작성한 스타일시트 파일을 질문 상세 템플릿에 적용하자.

[파일명: /sbb/src/main/resources/templates/question_detail.html]

<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<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>

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

static 디렉터리에 style.css 파일이 위치하지만 /static/style.css 대신 /style.css로 사용해야 함에 주의하자. 왜냐하면 static 디렉터리가 스태틱 파일들의 루트 디렉터리이기 때문이다.

이제 질문 상세 화면이 어떻게 변경되는지 확인해 보자. 다음처럼 스타일이 적용된 화면을 볼 수 있을 것이다.

텍스트 창의 넓이가 넓어지고 "답변등록" 버튼 위에 여유공간이 생겼다.

'백엔드 > Springboot' 카테고리의 다른 글

템플릿 상속  (0) 2023.11.09
부트스트랩  (0) 2023.11.09
답변 등록  (0) 2023.11.09
질문 상세  (1) 2023.11.09
서비스  (0) 2023.11.09

질문 상세 링크 추가하기

먼저 질문 목록의 제목을 클릭했을때 상세화면이 호출되도록 제목에 링크를 추가하자. 질문 목록 템플릿을 다음과 같이 수정하자.

[파일명: /sbb/src/main/resources/templates/question_list.html]

<table>
    <thead>
        <tr>
            <th>제목</th>
            <th>작성일시</th>
        </tr>
    </thead>
    <tbody>
        <tr th:each="question, index : ${questionList}">
            <td>
                <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
            </td>
            <td th:text="${question.createDate}"></td>
        </tr>
    </tbody>
</table>

제목을 <td> 엘리먼트의 텍스트로 출력하던 것에서 링크로 변경했다. 타임리프에서 링크의 주소는 th:href 속성을 사용한다. 타임리프에서 th:href 처럼 URL 주소를 나타낼때는 반드시 @{ 문자와 } 문자 사이에 입력해야 한다. 그리고 URL 주소는 문자열 /question/detail/과 ${question.id} 값이 조합되어 /question/detail/${question.id}로 만들어졌다. 이때 좌우에 | 문자없이 다음과 같이 사용하면 오류가 발생한다.

<a th:href="@{/question/detail/${question.id}}" th:text="${question.subject}"></a>

/question/detail/과 같은 문자열과 ${question.id}와 같은 자바 객체의 값을 더할 때는 반드시 다음처럼 |과 | 기호로 좌우를 감싸 주어야 한다.

<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>

타임리프는 문자열을 연결(concatenation)할 때 | 문자를 사용한다.

질문 상세 컨트롤러 만들기

이제 질문 목록 페이지에 접속하여 링크를 클릭해 보자. 아마도 다음과 같은 오류가 발생할 것이다.

http://localhost:8080/question/detail/2 URL 요청에 대한 매핑이 없기 때문에 404(Page not found) 오류가 발생한다. 오류를 해결하기 위해 질문 상세 페이지에 대한 URL 매핑을 QuestionController에 다음과 같이 추가하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

(... 생략 ...)
import org.springframework.web.bind.annotation.PathVariable;
(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @GetMapping(value = "/question/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id) {
        return "question_detail";
    }
}

요청 URL http://localhost:8080/question/detail/2의 숫자 2처럼 변하는 id 값을 얻을 때에는 위와 같이 @PathVariable 애너테이션을 사용해야 한다. 이 때 @GetMapping(value = "/question/detail/{id}") 에서 사용한 id와 @PathVariable("id")의 매개변수 이름이 동일해야 한다.

위와 같이 수정하고 다시 URL을 호출하면 이번에는 404 대신 500 오류가 발생할 것이다. 왜냐하면 응답으로 리턴한 question_detail 템플릿이 없기 때문이다. 다음과 같이 question_detail.html 파일을 신규로 작성하자.

[파일명:/sbb/src/main/resources/templates/question_detail.html]

<h1>제목</h1>
<div>내용</div>

그러면 이제 오류없이 다음과 같은 화면이 나타날 것이다.

서비스

이제 화면에 출력한 "제목", "내용" 문자열 대신 데이터의 실제 제목과 내용을 출력해 보자. Question 데이터를 조회하기 위해서 QuestionService를 다음과 같이 수정하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionService.java]

(... 생략 ...)
import java.util.Optional;
import com.mysite.sbb.DataNotFoundException;
(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public Question getQuestion(Integer id) {  
        Optional<Question> question = this.questionRepository.findById(id);
        if (question.isPresent()) {
            return question.get();
        } else {
            throw new DataNotFoundException("question not found");
        }
    }
}

id 값으로 Question 데이터를 조회하는 getQuestion 메서드를 추가했다. 리포지터리로 얻은 Question 객체는 Optional 객체이기 때문에 위와 같이 isPresent 메서드로 해당 데이터가 존재하는지 검사하는 로직이 필요하다. 만약 id 값에 해당하는 Question 데이터가 없을 경우에는 DataNotFoundException을 발생시키도록 했다. DataNotFoundException 클래스는 아직 존재하지 않기 때문에 컴파일 오류가 발생할 것이다. DataNotFoundException 클래스를 다음과 같이 작성하자.

[파일명: /sbb/src/main/java/com/mysite/sbb/DataNotFoundException.java]

package com.mysite.sbb;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "entity not found")
public class DataNotFoundException extends RuntimeException {
    private static final long serialVersionUID = 1L;
    public DataNotFoundException(String message) {
        super(message);
    }
}

DataNotFoundException은 RuntimeException을 상속하여 만들었다. 만약 DataNotFoundException이 발생하면 @ResponseStatus 애너테이션에 의해 404 오류(HttpStatus.NOT_FOUND)가 나타날 것이다.

그리고 QuestionController에서 QuestionService의 getQuestion 메서드를 호출하여 Question 객체를 템플릿에 전달할 수 있도록 다음과 같이 수정하자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

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

    (... 생략 ...)

    @GetMapping(value = "/question/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id) {
        Question question = this.questionService.getQuestion(id);
        model.addAttribute("question", question);
        return "question_detail";
    }
}

템플릿

QuestionController의 detail 메서드에서 Model 객체에 "question" 이라는 이름으로 Question 객체를 저장했으므로 템플릿은 다음과 같이 수정할 수 있다.

[파일명:/sbb/src/main/resources/templates/question_detail.html]

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

질문 상세 확인하기

이제 다시 질문 상세 페이지를 요청해 보자. 다음과 같은 화면이 나타날 것이다.

조회한 Question 데이터의 제목과 내용이 화면에 잘 출력된 것을 확인할 수 있다. 이번에는 다음처럼 33과 같은 존재하지 않는 id 값으로 페이지를 요청해 보자.

http://localhost:8080/question/detail/33

다음처럼 존재하지 않는 데이터를 조회하려고 할 경우에는 404 Not found 오류가 발생하는 것을 확인할 수 있다.

URL 프리픽스(prefix)

다음으로 넘어가기 전에 QuestionController의 URL 매핑을 잠시 살펴보자. 현재 QuestionController에는 다음 2개의 URL 매핑이 있다.

  1. @GetMapping("/question/list")
  2. @GetMapping(value = "/question/detail/{id}")

URL 매핑시 value 매개변수는 생략할수 있다.

URL의 프리픽스가 모두 /question으로 시작한다는 것을 알수 있다. 이런 경우 클래스명 위에 @RequestMapping("/question") 애너테이션을 추가하고 메서드 단위에서는 /question 를 생략한 그 뒷 부분만을 적으면 된다.

다음과 같이 QuestionController를 수정해 보자.

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

Copy(... 생략 ...)
import org.springframework.web.bind.annotation.RequestMapping;
(... 생략 ...)

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionService questionService;

    @GetMapping("/list")
    public String list(Model model) {
        (... 생략 ...)
    }

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

list 메서드의 URL 매핑은 /list 이지만 클래스에 /question이라는 URL 매핑이 있기 때문에 /question + /list가 되어 최종적인 URL 매핑은 /question/list가 된다. 위와 같이 수정하면 기존과 완전히 동일한 기준으로 URL 매핑이 이루어 진다. 다만, 앞으로 QuestionController에서 사용하는 URL 매핑은 항상 /question 으로 시작해야 하는 규칙이 생긴 것이다.

컨트롤러의 클래스 단위의 URL 매핑은 필수사항이 아니다. 컨트롤러의 성격에 맞게 사용하면 된다.

 

타임리프 500 오류가 해결되지 않는 상황이 지속되고 있다

이 오류를 해결하고 게시글을 전면 수정 예정.

 

'백엔드 > Springboot' 카테고리의 다른 글

스태틱 디렉터리와 스타일시트  (0) 2023.11.09
답변 등록  (0) 2023.11.09
서비스  (0) 2023.11.09
Root URL  (0) 2023.11.09
질문 목록과 템플릿(2)  (1) 2023.11.09

이제 질문 목록의 제목 링크를 누르면 질문 상세 화면이 보이게 하는 작업을 수행할 것이다.

하지만 기능을 추가하기 전에 잠시 생각할 것이 있다. QuestionController에서 QuestionRepository를 직접 사용하여 질문 목록 데이터를 조회했다. 하지만 대부분의 규모있는 스프링부트 프로젝트는 컨트롤러에서 리포지터리를 직접 호출하지 않고 중간에 서비스(Service)를 두어 데이터를 처리한다. 서비스는 스프링에서 데이터 처리를 위해 작성하는 클래스이다.

서비스가 필요한 이유

서비스가 필요한 이유는 무엇일까?

모듈화

예를들어 어떤 컨트롤러가 여러개의 리포지터리를 사용하여 데이터를 조회한후 가공하여 리턴한다고 가정해 보자. 이러한 기능을 서비스로 만들어 두면 컨트롤러에서는 해당 서비스를 호출하여 사용하면 된다. 하지만 서비스로 만들지 않고 컨트롤러에서 구현하려 한다면 해당 기능을 필요로 하는 모든 컨트롤러가 동일한 기능을 중복으로 구현해야 한다. 이러한 이유로 서비스는 모듈화를 위해서 필요하다.

보안

컨트롤러는 리포지터리 없이 서비스를 통해서만 데이터베이스에 접근하도록 구현하는 것이 보안상 안전하다. 이렇게 하면 어떤 해커가 해킹을 통해 컨트롤러를 제어할 수 있게 되더라도 리포지터리에 직접 접근할 수는 없게 된다.

엔티티 객체와 DTO 객체의 변환

Question, Answer 클래스는 엔티티(Entity) 클래스이다. 엔티티 클래스는 데이터베이스와 직접 맞닿아 있는 클래스이기 때문에 컨트롤러나 타임리프 같은 템플릿 엔진에 전달하여 사용하는 것은 좋지 않다. 컨트롤러나 타임리프에서 사용하는 데이터 객체는 속성을 변경하여 비즈니스적인 요구를 처리해야 하는 경우가 많은데 엔티티를 직접 사용하여 속성을 변경한다면 테이블 컬럼이 변경되어 엉망이 될수도 있기 때문이다.

이러한 이유로 Question, Answer 같은 엔티티 클래스는 컨트롤러에서 사용할수 없게끔 설계하는 것이 좋다. 그러기 위해서는 Question, Answer 대신 사용할 DTO(Data Transfer Object) 클래스가 필요하다. 그리고 Question, Answer 등의 엔티티 객체를 DTO 객체로 변환하는 작업도 필요하다. 그러면 엔티티 객체를 DTO 객체로 변환하는 일은 어디서 처리해야 할까? 그렇다. 바로 서비스이다. 서비스는 컨트롤러와 리포지터리의 중간자적인 입장에서 엔티티 객체와 DTO 객체를 서로 변환하여 양방향에 전달하는 역할을 한다.

이 책은 간결한 설명을 위해 별도의 DTO를 만들지 않고 엔티티 객체를 컨트롤러와 타임리프에서 그대로 사용할 것이다. 하지만 실제 업무 프로그램을 작성할 때는 엔티티 클래스를 대신할 DTO 클래스를 만들어 사용하기를 권한다.

 

다음과 같이 Questionservice를 작성한다.

 

스프링의 서비스로 만들기 위해서는 위와 같이 클래스명 위에 @Service 애너테이션을 붙이면 된다. @Controller, @Entity 등과 마찬가지로 스프링부트는 @Service 애너테이션이 붙은 클래스는 서비스로 인식한다.

questionRepository 객체는 생성자 방식으로 DI 규칙에 의해 주입된다.

그리고 질문 목록을 조회하여 리턴하는 getList 메서드를 추가했다. 이전 컨트롤러에서 리포지터리를 사용했던 부분을 그대로 옮긴 것이다.

 

다음으로는, QuestionController를 다음처럼 수정하자.

questionService 객체는 생성자 방식으로 DI 규칙에 의해 주입된다.

브라우저로 http://localhost:8080/question/list 페이지에 접속하면 이전과 동일한 화면을 볼수 있다. 앞으로 작성할 컨트롤러들도 리포지터리를 직접 사용하지 않고 Controller -> Service -> Repository 구조로 데이터를 처리할 것이다.

'백엔드 > Springboot' 카테고리의 다른 글

답변 등록  (0) 2023.11.09
질문 상세  (1) 2023.11.09
Root URL  (0) 2023.11.09
질문 목록과 템플릿(2)  (1) 2023.11.09
질문 목록과 템플릿(1)  (0) 2023.11.09

루트 URL은 http://localhost:8080 처럼 도메인명과 포트 뒤에 아무것도 붙이지 않은 URL을 말한다. 우리는 아직 루트 URL에 대한 매핑을 만들지 않았기 때문에 브라우저에서 루트 URL에 접속하면 다음과 같은 404 페이지가 나타난다.

이번에는 루트 URL 호출시 404 페이지 대신 질문 목록을 출력하도록 해보자. 다음과 같이 MainController를 수정하자.

package com.mysite.sbb;

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

@Controller
public class MainController {

    @GetMapping("/sbb")
    @ResponseBody
    public String index() {
        return "안녕하세요 sbb에 오신것을 환영합니다.";
    }

    @GetMapping("/")
    public String root() {
        return "redirect:/question/list";
    }
}

root 메서드를 추가하고 / URL을 매핑했다. 리턴 문자열 redirect:/question/list는 /question/list URL로 페이지를 리다이렉트 하라는 명령어이다. 스프링부트는 리다이렉트 또는 포워딩을 다음과 같이 할 수 있다.

  • redirect:<URL> - URL로 리다이렉트 (리다이렉트는 완전히 새로운 URL로 요청이 된다.)
  • forward:<URL> - URL로 포워드 (포워드는 기존 요청 값들이 유지된 상태로 URL이 전환된다.)

이제 http://localhost:8080 페이지 접속을 하면 root 메서드가 실행되어 질문 목록이 표시되는 것을 확인할 수 있을 것이다.

'백엔드 > Springboot' 카테고리의 다른 글

질문 상세  (1) 2023.11.09
서비스  (0) 2023.11.09
질문 목록과 템플릿(2)  (1) 2023.11.09
질문 목록과 템플릿(1)  (0) 2023.11.09
<번외> 프로젝트 github 등록 삽질기  (2) 2023.11.09

+ Recent posts