728x90
반응형
SMALL

스프링 시큐리티란?

스프링 부트에서는 회원가입 및 로그인을 도오주는 스프링 시큐리티를 사용할 수 있다.
스프링 시큐리티는 스프링 기반 웹 애플리케이션의 인증과 권한을 담당하는 스프링의 하위 프레임워크이다. 
여기서 인증은 로그인 같은 사용자 신원을 확인하는 프로세스이고,
권한은 인증된 사용자가 어떤 일을 할 수 있는지 관리하는 것을 말한다.

 

스프링 시큐리티 설치하기

build.gradle

...
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
...

파일을 마우스 오른쪽 버튼을 눌러 [Gradle->Refresh Gradle Project]를 클릭하여 변경 사항 적용하면 설치가 완료된다.

이후 로컬 서버도 재시작하자

 

스프링 시큐리티 설정하기

서버 재시작 후 질문 목록 화면을 재접속 해보면 아래와 같은 화면이 나올 것이다.

스프링 시큐리티는 기본적으로 인증되지 않은 사용자가 웹서비스를 사용하지 못하도록 한다.

그래서 위와 같은 화면이 나온 것이다.

하지만 로그인 하지 않아도 조회할 수 있어야 하기 때문에 스프링 시큐리티를 설정해보자.

 

SecurityConfig.java

com.mysite.sbb 패키지에 작성한다.

package com.mysite.sbb;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		http
			.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
					.requestMatchers(new AntPathRequestMatcher("/**")).permitAll());
		return http.build();
	}
	
}
@Configuration 은 이 파일이 스프링의 환경 설정파일임을 의미한다.
@EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 어노테이션이다.

내부적으로 SecurityFilterChain 클래스가 동작하여 모든 요청 URL에 이 클래스가 필터로 적용되어 URL별로 특별한 설정을 할 수 있게 된다.

 

다시 목록을 들어가면 이전처럼 인증 없이 들어갈 수 있다.

 

H2 콘솔 오류 수정하기

그런데 스프링 시큐리티를 적용했더니 H2 콘솔 로그인시 403에러가 뜬다.

이 오류는 서버가 클라이언트의 접근을 거부했을 대 반환하는 오류 코드이다.

 

잠시 질문 등록 페이지에서 개발자 모드로 소스코드를 보면

<input type="hidden" name="_csrf" value="0G04yuYo92U05ZJMCWioAvRd6dKEAINhDQr5AyxXzJNTN9xW4lterNUcx1EZhqp9OkWcOsE4xOq2YeJMPG_NZU81_KoyBu43"/>

이러한 input요소가 form태그 안에 자동생성되어 있다.

스프링 시큐리티 때문에 이와같은 CSRF 토큰이 자동으로 생성된 것이다.

 

따라서, H2 콘솔은 스프링 프레임워크가 아니므로 CSRF 토큰을 발행하는 기능이 없어 오류가 발생한다.

 

스프링 시큐리티가 CSRF 처리시 H2콘솔은 예외로 처리할 수 있도록 파일을 수정하자.

 

SecurityConfig.java

package com.mysite.sbb;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		http
			.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
					.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
			.csrf((csrf) -> csrf
					.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
			;
		return http.build();
	}
	
}

재접속 해보면 로그인은 잘 수행됐지만 이렇게 화면이 깨진다.

H2 콘솔의 화면이 프레임 구조로 작성되었기 때문에 레이아웃이 이렇게 나뉘어져 있다.

스프링 시큐리티는 웹 사이트 콘텐츠가 다른 사이트에 포함되지 않도록 하기 위해 X-Frame-Options 헤더의 기본값을 DENY로 사용하는데, 프레임 구조의 웹 사이트는 이 헤더의 값이 DENY인 경우에 이같은 오류가 발생한다.

 

SecurityConfig.java

package com.mysite.sbb;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		http
			.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
					.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
			.csrf((csrf) -> csrf
					.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
			.headers((headers)-> headers
					.addHeaderWriter(new XFrameOptionsHeaderWriter(
							XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
			;
		return http.build();
	}
}

이제 로그인하면 정상적으로 동작하는 것을 확인할 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

답변 개수가 질문 제목 오른쪽에 표시되도록 만들어보자.

 

/templates/question_list.html

...
<span class="text-danger small ms-2"
					th:if="${#lists.size(question.answerList) > 0 }"
					th:text="${#lists.size(question.answerList)}"></span>
...

th:if="${#lists.size(question.answerList) > 0 }" 으로 먼저 해당 게시물의 답변이 있는지 검사하고, 있다면 th:text="${#lists.size(question.answerList)}"로 답변 개수를 표시한다.

 

위와 같이 3이라고 표시된 것을 확인.

 

728x90
반응형
LIST
728x90
반응형
SMALL
...
<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>
...

현재 질문 목록을 보면 어느 페이지든 모두 번호가 1부터 시작한다.

각 게시물에 맞는 번호가 표시되도록 하자.

 

게시물 번호 공식 만들기

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

 

게시물 번호 공식 적용하기

/templates/question_index.html

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

역으로 나오는 것을 확인

728x90
반응형
LIST
728x90
반응형
SMALL

데이터 페이징 처리하는 기능은 아래 글 참고

 

[Spring Boot] 20. 페이징 기능 추가하기 (1)데이터 페이징 처리

대량 테스트 데이터 만들기페이징을 구현하기 위해서는 충분한 데이터가 필요하기 때문에 테스트 프레임워크를 이용한다.SbbApplicationTests.java...@Autowiredprivate QuestionService questionService;...@Testvoid tes

exuzii.tistory.com

 

페이징 이동 기능 추가하기

질문 목록에서 url이 아닌 이전, 다음과 같은 링크를 추가해보자

/templates/question_list.html

...
<!-- 페이징 처리 시작 -->
<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: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>
<!-- 페이징 처리 끝 -->
...
페이지 리스트를 보기 좋게 표시하기 위해 부트스트랩의 pagination 컴포넌트를 이용했다.
pagination, page-item, page-link 등이 pagination 컴포넌트의 클래스로,
pagination은 ul 요소 안에 있는 내용을 꾸밀 수 있고,
page-item은 각 페이지 번호, 이전, 다음 버튼을 나타내고, 
page-link는 이전, 다음버튼에 링클를 나타낸다.

page가 11인 url을 조회해보면 페이지 이동 기능이 구현됐지만 페이지가 모두 표시되는 문제가 발생한다.

 

페이징 이동 기능 완성하기

앞의 문제를 해결하기 위해 템플릿을 수정하자.

/templates/question_list.html

...
th:if="${page >= paging.number-5 and page <= paging.number+5}"
...
페이지 숫자 li 태그에 추가한다. 현재 페이지에서 좌우 5개씩 나타내도록 한다.

결과.

 

최신순으로 데이터 조회하기

그런데 보통 1페이지에 최신순으로 데이터가 표시된다. 이를 위해 서비스를 수정해보자.

/question/QuestionService.java

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.questionRepostiory.findAll(pageable);
}

QuestionService의 getList 메서드를 위처럼 수정하자.

작성일시를 기준으로 역순으로 조회하도록 했다.

 

첫 번째 페이지를 조회하면 가장 최근에 등록된 순서대로 나타나는 것을 볼 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

대량 테스트 데이터 만들기

페이징을 구현하기 위해서는 충분한 데이터가 필요하기 때문에 테스트 프레임워크를 이용한다.

SbbApplicationTests.java

...

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

 

로컬 서버를 중지하고 [Run -> Run As -> Junit Test]로 실행하자.

테스트가 성공하면 다시 로컬 서버를 실행 후 페이지를 확인해보자.
데이터 300개가 생성된 것을 볼 수 있다.

 

페이징 구현하기

페이징을 구현하기 위해 추가로 설치해야 하는 라이브러리는 없다.

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

org.springframework.data.domain.Page: 페이징을 위한 클래스
org.springframework.data.domain.PageRequest: 현재 페이지와 한 페이지에 보여줄 게시물 개수 설정하여 요청하는 클래스
org.springframework.data.domain.Pageable: 페이징을 처리하는 인터페이스

 

위에 말한 객체를 이용하여 페이징을 구현해보자.

/question/QuestionRepository.java

package com.mysite.sbb.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 QuestionRepostiory 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 메서드를 생성한다.

 

/qeustion/QuestionService.java

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

원래 있던 getList 메서드를 위와 같이 변경했다.

원래는 모든 데이터를 가져왔지만 원하는 페이지의 데이터를 10개씩만 가져오도록 수정.

 

이 메서드의 입출력 구조가 바뀌었으므로 컨트롤러의 list메서드에 오류가 생겼을 것이다.

컨트롤러의 메서드도 수정해보자.

/question/QuestionController.java

...
@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_list";
}
...
localhost:8080/question/list?page=0 과 같이 GET방식으로 요청된 URL에서 page값을 가져오기 위해 list 메서드 매개변수로 @RequestParam(value="page", defaultValue="0")int page가 추가되었다. 만약 page값이 전달되지 않은 경우에는 기본값을 0으로 지정했다..

 

위 컨트롤러에서 model에 questionList 대신 paging으로 전달하기 때문에 템플릿을 변경해야 한다.

/templates/question_list.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" 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 : ${paging}">
			<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>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>

th:each문에서 paging으로 변경한 모습

 

localhost:8080/question/list?page=0으로 요청하면 첫 페이지의 10개만 조회된다.

 

page=1로 수정하니 두번째 페이지가 나타나는 것을 볼 수 있다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

네비게이션 바 만들기

네비게이션 바는 모든 페이지에서 공통으로 보여야 한다.

/templates/layout.html

...
<!-- 네비게이션 바 -->
<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>
...
SBB를 클릭하면 메인 화면으로 이동한다.
추후 로그인 기능 추가를 위해 로그인 버튼도 생성해놓았다.
화면 크기에 따라 나타나는 햄버거 메뉴 버튼도 추가했다.

화면 크기가 작을 때 '로그인' 메뉴가 사라지고 오른쪽에 버튼 생성.
화면 크기가 클 때 로그인 메뉴가 나타나고 오른쪽 버튼이 사라짐.

 

네비게이션 바의 숨은 기능 알기

부트스트랩은 위와 같이 브라우저의 크기가 작아지면 자동으로 내비게이션 바에 있는 링크들을 햄버거 메뉴 버튼으로 숨긴다.
이를 반응협 웹 이라고 한다.
하지만 현재 이 버튼을 클릭하면 아무 반응이 일어나지 않는데 이를 활용하기 위해서는 부트스트랩 자바스크립트 파일(bootstrap.min.js)을 static 디렉터리로 복사해야 한다.

 

bootstrap.min.js 파일 복사

 

이제 추가한 자바스크립트 파일을 사용할 수 있도록 태그를 추가하자

/templates/layout.html

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

햄버거 버튼을 클릭 시 숨어 있는 링크가 나타나는 것을 볼 수 있다.

 

네비게이션 바 분리하기

이전에 우리는 오류 메시지를 표시하는 부분을 공통 템플릿으로 분리하였다.

이번에 네비게이션 바도 공통 템플릿으로 분리하여 활용해보자.

 

/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>
nav태그의 th:fragment 속성을 이용하여 그 부분만 코드를 수정하면 된다.

 

/templates/layout.html

이전에 네비게이션 바 코드가 있던 부분을 모두 제거하고 아래처럼 수정해보자.

....
<!--네비게이션 바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
...

 

 

이렇게 분리하면 중복해서 사용하지는 안허달도 독립된 하나의 템플릿으로 관리하는 것이 유지보수에 좋다.

728x90
반응형
LIST
728x90
반응형
SMALL

예를 들어 오류 메시지를 출력하는 HTML 코드는 질문등록과 답변등록 페이지에서 모두 사용한다.

이렇게 반복적으로 사용하는 코드를 공통 템플릿으로 만들어보자.

 

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

이와 같은 오류 메시지를 출력하는 부분을 공통 템플릿으로 만들어 필요한 곳에 삽입할 수 있도록 해보자.

 

오류 메시지 템플릿 만들기

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

th:fragment 속성을 추가했다. 

다른 템플릿에서 이 div태그 영역을 사용할 수 있도록 이름을 설정한 것이다.

 

기존 템플릿에 적용하기

/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>
5행에 원래 중복됐던 코드 부분을
<div th:replace="~{form_errors :: formErrorsFragment}"></div> 코드로 수정했다.

 

/templates/question_detail.html

...
<!-- 답변 작성 -->
	<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>
 ...

 

이렇게 모두 수정 후 제대로 동작하는지 확인해보자.

728x90
반응형
LIST
728x90
반응형
SMALL

답변 등록 기능에 폼 적용하기

 

앞서 질문 등록 폼에 했던 것처럼 답변 등록 폼에도 적용해보자.

아래 링크 참고

 

[Spring Boot] 16. 폼(Form) 활용하기(1)

만약 질문을 등록할 때 질문 내요을 비어 있는 값으로도 등록할 수 있다.하지만 이건 옳지 않는 방법이므로 아무것도 입력하지 않은 상태에서 질문이 등록될 수 없도록 해야한다.따라서 폼 클래

exuzii.tistory.com

 

/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;
}

 

/answer/AnswerController.java

답변을 작성할 때 PostMapping된 메서드에서 바인딩 될 수 있도록 컨트롤러를 수정하자.

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

 

/templates/question_detail.html

...
<!-- 답변 작성 -->
	<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>			
		</div>
		<textarea th:field="*{content}" rows="10" class="form-control"></textarea>
		<input type="submit" value="답변등록" class="btn btn-primary my-2">
	</form>
...

답변 등록 form의 입력 항목과 AnswerForm을 타임리프에 연결하기 위해 th:object 속성을 추가했다.

또 content 항목도 th:field 속성을 사용하도록 수정했다.

 

이제 답변을 작성하고 등록할 때 AnswerForm을 사용하기 위해 템플릿도 수정했으므로

QuestionController의 detail메서드도 수정해야 한다.

 

/qeustion/QuestionController.java

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

 

결과 테스트

먼저 질문 하나를 등록한다.

 

답변에 아무 내용을 입력하지 않고 답변등록을 누르면 오류가 나는 것을 확인할 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL
만약 질문을 등록할 때 질문 내요을 비어 있는 값으로도 등록할 수 있다.
하지만 이건 옳지 않는 방법이므로 아무것도 입력하지 않은 상태에서 질문이 등록될 수 없도록 해야한다.
따라서 폼 클래스를 사용하여 입력값을 체크하는 방법을 이요해보자.

 

Spring Boot Validation 라이브러리 설치하기

build.gradle

...
//SpringBoot Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
...
항목 설명
@Size 문자 길이 제한
@NotNull Null을 허용하지 않음
@NotEmpty Null 또는 빈 문자열("")을 허용하지 않음
@Past 과거 날짜만 입력 가능
@Future 미래 날짜만 입력 가능
@FutureOrPresent 미래 또는 오늘 날짜만 입력 가능
@Max 최댓값 이하의 값만 입력할 수 있도록 제한
@Min 최솟값 이상의 값만 입력할 수 있도록 제한
@Pattern 입력값을 정규식 패턴으로 검증

 

폼 클래스 만들기

질문 등록 페이지에서 사용자로부터 입력받은 값을 검증하는데 필요한 폼 클래스를 만들어보자.

/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;
}

 

컨트롤러에 전송하기

/question/QuestionController.java

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

 

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

이미 class QuestionForm에는 subject, content  항목을 포함하고 있기때문에 자동으로 바인딩 된다.

앞의 @Valid 어노테이션은 검증 기능이 동작한다.

bindingResult는 검증 결과를 의미한다. 

 

템플릿 수정하기

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

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

form의 속성 th:object는 <form>의 입력 항목들이 QuestionForm과 연결된다는 점을 타임리프에 알려준다.

그런데 여기 th:object 속성을 추가했으므로 controller에서 getMapping으로 매핑한 메서드에서도 QuestionForm 객체를 넘겨주어야 한다.

 

/question/QuestionController.java

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

 

모두 수정한 후에 아무 값을 입력하지 않고 저장하기 버튼을 클릭하면 오류가 표시된다.

 

오류 처리하기

만약 내용을 입력하지 않고 제목만 입력 후 저장하기 버튼을 클릭하게 되면

이처럼 제목값이 사라지게 된다.

 

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

입력값을 담는 input태그와 textarea에 name속성 대신 th:field로 수정했다. 

그럼 태그의 id, name, value 속성이 모두 자동으로 생성되고 타임리프가 value 속성에 기존에 입력된 값을 채워 넣어준다.

 

그럼 제목만 입력 후 저장하기 버튼을 클릭하면 기존 값이 유지된다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

질문 등록 버튼 및 화면 생성

/templates/question_list.html

...
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
....

 

localhost:8080/question/list

질문 등록하기 버튼 생성.

 

URL 매핑하기

/question/create에 해당하는 URL 매핑 추가.

/question/QuestionController.java

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

 

템플릿 만들기

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

질문 내용은 글자 수 제한이 없는 textarea 사용.

 

질문 저장 POST 요청 처리 

질문등록 화면에서 저장하기 버튼을 클릭하면 form의 action url로 이동하며 POST방식으로 전송된다. 또한 제목과 내용의 값이 담기는 곳에 name 속성을 지정해놨기 때문에 그 변수에 담아 값을 전달한다.

/question/QuestionController.java

@PostMapping("/create")
public String questionCreate(@RequestParam(value="subject") String subject, 
	@RequestParam(value="content") String content) {
	//TODO: 질문 저장.
	return "redirect:/question/list";
}

 

=>저장하기 버튼을 클릭하면 question/list로 돌아가는지 확인.

 

서비스 수정하기

질문 데이터를 저장하기 위해 서비스를 수정해보자.

/qeustion/QuestionService.java

public void create(String subject, String content) {
		Question q = new Question();
		q.setSubject(subject);
		q.setContent(content);
		q.setCreateDate(LocalDateTime.now());
		this.questionRepostiory.save(q);
}

 

다음으로 이 생성한 서비스를 사용할 수 있도록 컨트롤러도 수정해보자.

/question/QuestionController.java

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

 

테스트를 해보면

질문의 제목과 내용을 입력하고 저장하기 버튼을 클릭한다.
question/list로 다시 이동하고 방금 등록한 질문이 등록된 것을 확인할 수 있다.

 

728x90
반응형
LIST

+ Recent posts