728x90
반응형
SMALL

질문이나 답변을 작성할 때 사용자 정보도 DB에 같이 저장해보자.

엔티티에 속성 추가하기

질문 엔티티에 속성 추가하기

먼저 기존의 질문과 답변 엔티티에 글쓴이에 해당하는 속성을 추가하자.

/question/Question.java

...
//관계주입
@ManyToOne
private SiteUser author;
...

ManyToOne으로 관계주입을 했는데 이는 사용하 한 명이 여러 질문을 생성할 수 있다는 의미이다.

 

답변 엔티티에 속성 추가하기

위와 같은 방법으로 답변에도 속성을 추가하자.

/answer/Answer.java

...
//관계주입
@ManyToOne
private SiteUser author;
...

 

테이블 확인하기

서버를 재접속하고 H2 서버에 접속하여 QUESTION과 ANSWER 테이블을 확인해보자.

 

질문과 답변 테이블에 작성자가 추가되었다.
author_id의 기본값들이 null로 들어감.

 

author_id의 기본값들이 null로 들어감.

 

글쓴이 저장하기

이제 각 엔티티에 속성이 추가됐으므로 질문이나 답변 데이터를 저장할 때 글쓴이도 함께 저장할 수 있다.

그러기 위해서 컨트롤러와 서비스를 수정해야한다.

 

답변 컨트롤러와 서비스 업데이트하기

/answer/AnswerController.java

package com.mysite.sbb.answer;

import java.security.Principal;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
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.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;

import jakarta.validation.Valid;
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,
			@Valid AnswerForm answerForm, BindingResult bindingResult, 
			Principal principal) {
		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);
	}
}

createAnswer 메서드의 매개변수에 시큐리티가 제공하는 Principal principal을 추가했다. 

이 객체는 현재 로그인한 사용자의 정보를 제공한다.

principal.getName()을 호출하면 로그인한 사용자의 사용자명(ID)를 알 수 있다.

 

principal 객체를 사용하면 사용자명을 알 수 있으므로 그 사용자명으로 SiteUser객체를 조회할 수 있다.

UserService.java

....
	public SiteUser getUser(String username) {
		Optional<SiteUser> siteUser = this.userRepository.findByUsername(username);
		if (siteUser.isPresent()) {
			return siteUser.get();
		} else {
			throw new DataNotFoundException("siteuser not found");
		}
	}
...

여기서 userRepository의 findByUsername메서드를 통해서 쉽게 사용자를 찾을 수 있다.

만약 없을 경우 예외를 발생시킨다.

 

답변 내용을 저장할 때 글쓴이도 저장할 수 있다.

/answer/AnswerService.java

package com.mysite.sbb.answer;

import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class AnswerService {
	private final AnswerRepository answerRepository;
	
	public void create(Question question, String content, SiteUser author) {
		Answer answer = new Answer();
		answer.setContent(content);
		answer.setCreateDate(LocalDateTime.now());
		answer.setQuestion(question);
		answer.setAuthor(author);
		this.answerRepository.save(answer);
	}
}

매개변수로 SiteUser 객체를 받고 answer.setAuthor() 메서드로 함께 저장하도록 수정했다.

 

다시 컨트롤러로 돌아가서 메서드를 완성하자.

/answer/AnswerController.java

package com.mysite.sbb.answer;

import java.security.Principal;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
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.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;

import groovyjarjarantlr4.v4.parse.ANTLRParser.finallyClause_return;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
	private final QuestionService questionService;
	private final AnswerService answerService;
	private final UserService userService;
	
	@PostMapping("/create/{id}")
	public String createAnswer(Model model, @PathVariable("id") Integer id,
			@Valid AnswerForm answerForm, BindingResult bindingResult, 
			Principal principal) {
		Question question = this.questionService.getQuestion(id);
		SiteUser siteUser = this.userService.getUser(principal.getName());
		
		if (bindingResult.hasErrors()) {
			model.addAttribute("question",question);
			return "question_detail";
		}
		this.answerService.create(question, answerForm.getContent(), siteUser);
		
		return String.format("redirect:/question/detail/%s", id);
	}
}

먼저 userService를 사용하기 위해 상단에 선언을 했다.

createAnswer메서드에서 매개변수에 선언해놓은 Principle객체를 이용해서 SiteUser를 선언했고,

답변을 생성하기 위한 this.answerService.create메서드에 siteUser 객체를 추가하여 답변을 등록하도록 했다.

 

질문 컨트롤러와 서비스 업데이트 하기

앞서 답변처럼 똑같이 하면된다.

먼저 글쓴이를 저장하기 위해 QuestionService를 수정한다.

/question/QuestionService.java

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

create메서드의 수정이 필요하다.

매개변수에 SiteUser객체를 선언하고 q.setAuthor()를 통해 질문에 글쓴이도 포함하여 데이터를 생성할 수 있도록 했다.

 

/qeustion/QuestionController.java

...
private final UserService userService;
...
	@PostMapping("/create")
	public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult,
			Principal principal){
		if (bindingResult.hasErrors()) {
			return "question_form";
		}
		SiteUser siteUser = this.userService.getUser(principal.getName());
		this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
		return "redirect:/question/list";
	}
...

글쓴이를 생성하기 위해 상단에 userService를 추가했고

questionCreate메서드의 매개변수에 Principle객체를 선언했다.

그리고 SiteUser객체를 생성 후 this.questionService.create()메서드의 매개변수에 siteUser를 추가했다.

 

로컬 서버를 재시작 후 로그인 한 다음 질문과 답변을 등록해보자.

테스트 코드에 오류가 있으므로 수정한다.

SbbApplicationTests.java

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

create메서드의 매개변수에 사용자를 넣어야하므로 그냥 null로 채워준다.

 

로그인 페이지로 이동시키기

로그아웃 상태에서 질문이나 답변을 등록하려고 하면 에러가 난다.

principal 객체가 널이라서 500에러 발생.

 

따라서 create메서드는 로그인한 상태에서만 호출할 수 있어야한다.

만약 로그아웃 상태에서 create메서드를 호출하면 로그인 페이지로 강제 이동하게끔 해야한다.

/question/QuestionController.java

...
	@PreAuthorize("isAuthenticated()")
	@GetMapping("/create")
	public String questionCreate(QuestionForm questionForm) {
		return "question_form";
	}
	
	@PreAuthorize("isAuthenticated()")
	@PostMapping("/create")
	public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult,
			Principal principal){
		if (bindingResult.hasErrors()) {
			return "question_form";
		}
		SiteUser siteUser = this.userService.getUser(principal.getName());
		this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
		return "redirect:/question/list";
	}
...

두 개의 메서드에 @PreAuthorize("isAuthenticated()")어노테이션을 추가했다.

 

똑같이 답변컨트롤러도 수정하자.

/answer/AnswerController.java

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

 

마지막으로 @PreAuthorize어노테이션이 동작할 수 있도록 스프링 시큐리티 설정을 수정해야한다.

SecurityConfig.java

...
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
...

 

이렇게 모두 수정 후 로그아웃 상태에서 질문이나 답변을 등록하면 자동으로 로그인 화면으로 이동한다.

 

답변 작성 막아두기

현재 로그인하지 않은 상태에서 '질문 등록하기' 버튼을 클릭하면 바로 로그인 페이지로 이동하지만,

답변은 답변을 작성한 후 '답변등록'버튼을 클릭해야 로그인 페이지로 이동해버리는 바람에, 애써 작성한 내용이 모두 사라지는 상황이 발생한다.

따라서 로그아웃 상태일 때는 아예 답변 작성을 하지 못하도록 해야한다.

 

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

위의 2개의 textarea 코드를 보면 사용자의 로그인 상태에 따라 입력 유무를 판단하게 했다.

 

이처럼 로그인 하지 않은 경우에는 textarea가 비활성화된다.

 

화면에 글쓴이 나타내기

질문 목록에 글쓴이 표시하기

/templates/question_list.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
	<thead class="table-dark">
		<tr class="text-center">
			<th>번호</th>
			<th style="width: 50%;">제목</th>
			<th>글쓴이</th>
			<th>작성일시</th>
		</tr>
	</thead>
	<tbody>
		<tr class="text-center" th:each="question, loop : ${paging}">
			<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
			<td class="text-start">
				<a th:href="@{|/question/detail/${question.id}|}"
				th:text="${question.subject}"></a>
				<span class="text-danger small ms-2"
					th:if="${#lists.size(question.answerList) > 0 }"
					th:text="${#lists.size(question.answerList)}"></span>
			</td>
			<td>
				<span th:if="${question.author != null}"
				th:text="${question.author.username}"></span>
			</td>
			
			<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
		</tr>
	</tbody>
</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-5 and page <= paging.number+5}"
		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>

테이블에 글쓴이를 추가했다.

 

질문에 글쓴이를 추가한 상태

 

질문 상세에 글쓴이 표시하기

/templates/question_detail.html

...
<!-- 질문 -->
	<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 class="mb-2">
						<span th:if="${question.author != null}"
						th:text="${question.author.username}">
						</span>
					</div>
					<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
				</div>
			</div>
		</div>
	</div>
	<!-- 질문 끝-->
...

작성일자 바로 위에 사용자 id가 나타나도록 했다.

 

답변 부분에도 글쓴이를 추가해보자.

...
	<!-- 답변 반복 시작 -->
	<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 class="mb-2">
						<span th:if="${answer.author != null}"
						th:text="${answer.author.username}">
						</span>
					</div>
					<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
				</div>
			</div>
		</div>
	</div>
	<!-- 답변 반복 끝  -->
...

위와 똑같이 답변의 글쓴이를 추가했다.

완성된 화면.

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

로그인 기능 구현하기

회원가입 단계에서 SITE_USER 테이블에 정보를 저장했다.

여기에 저장된 데이터로 로그인을 하려면 복잡한 단계가 필요하지만 스프링 시큐리티를 활용하면 쉽게 진행할 수 있다.

 

로그인 URL 등록하기

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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
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)))
			.formLogin((formLogin)-> formLogin
					.loginPage("/user/login")
					.defaultSuccessUrl("/"))
			;
		return http.build();
	}
	
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

.formLogin 메서드를 추가했다. 이 메서드는 스프링 시큐리티의 로그인 설정을 담당하는 부분으로,

설정 내용은 로그인 페이지의 URL은 /user/login이고 성공시 / 루트로 이동함을 의미한다.

 

User 컨트롤러에 URL 매핑

UserController.java

...
@GetMapping("/login")
	public String login() {
		return "login_form";
	}
...

 

로그인 템플릿 작성하기

/templates/login_form.html

<html layout:decorate="~{layout}">
	<div layout:fragment="content" class="container my-3">
		<form th:action="@{/user/login}" method="post">
			<div th:if="${param.error}">
				<div class="alert alert-danger">
					사용자 ID 또는 비밀번호를 확인해 주세요.
				</div>
			</div>
			<div class="mb-3">
				<label for="username" class="form-label">사용자 ID</label>
				<input type="text" name="username" id="username" class="form-control">
			</div>
			<div class="mb-3">
				<label for="password" class="form-label">비밀번호</label>
				<input type="password" name="password" id="password" class="form-control">
			</div>
			<button type="submit" class="btn btn-primary">로그인</button>
		</form>
	</div>
</html>

스프링 시큐리티의 로그인이 실패할 경우에 시큐리티 기능으로 로그인 페이지로 리다이렉트된다.

이 때 매개변수로 error가 함께 전달된다.

따라서 error가 전달될 경우 오휴 메시지를 출력한다.

 

 

http://localhost:8080/user/login 확인을 해보자.

 

하지만 우리가 등록했던 데이터로 아직 로그인할 수 없다. 왜냐면 스프링 시큐리티에 어떤 기준으로 로그인을 해야 하는지 설정하지 않았기 때문이다.

 

스프링 시큐리티를 통해 로그인을 수행하는 방법은 SecurityConfig.java와 같은 시큐리티 설정 파일에 사용자ID와 비밀번호를 등록하여 인증하는 방법이 있는데, 우리는 DB에서 회원 정보를 조회하여 로그인하는 방법을 이용할 것이다.

 

이제 DB에서 조회하는 서비스를 만들고, 서비스를 시큐리티에 등록해보자.

 

User 리포지터리 수정하기

사용자 ID를 조회하는 기능을 추가하자.

/user/UserRepository.java

package com.mysite.sbb.user;

import java.util.Optional;

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

public interface UserRepository extends JpaRepository<SiteUser, Long> {
	Optional<SiteUser> findByUsername(String username);
}

 

UserRole 파일 생성하기

스프링 시큐리티는 인증뿐만 아니라 권한도 관리한다.

사용자가 로그인을 하면 ADMIN 또는 USER같은 권한을 줘야 한다.

/user/UserRole.java

package com.mysite.sbb.user;

import lombok.Getter;

@Getter
public enum UserRole {
	ADMIN("ROLE_ADMIN"),
	USER("ROLE_USER");
	
	UserRole(String value) {
		this.value = value;
	}
	
	private String value;
}

 

UserSecurityService 서비스 생성하기

/user/UserSecurityService.java

package com.mysite.sbb.user;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService{

	private final UserRepository userRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		Optional<SiteUser> _siteUser = this.userRepository.findByUsername(username);
		
		if (_siteUser.isEmpty()) {
			throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
		}
		SiteUser siteUser = _siteUser.get();
		List<GrantedAuthority> authorities = new ArrayList<>();
		if ("admin".equals(username)) {
			authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
		} else {
			authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
		}
		
		return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
	}
	

}

스프링 시큐리티가 로그인 시 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 구현해야한다.

상속받은 메서드 loadUserByUsername 메서드는 사용자명으로 스프링 시큐리티의 사용자 객체를 조회하여 리턴하는 메서드이다.

사용자명으로 SiteUser 객체를 조회하고, 만약 데이터가 없으면 예외를 발생시킨다.

만약 사용자명이 admin이면 ADMIN권한을 부여하고 이외의 사용자명이면 USER권한을 부여한다.

마지막으로 User객체를 반환하는데, 이 객체는 스프링 시큐리티에서 사용하며 User 생성자에 사용자명, 비밀번호, 권한 리스트를 전달한다.

 

스프링 시큐리티 설정 수정하기

로그인 기능을 완성하기 위해 파일을 수정하자.

SecurityConfig.java

...
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
	return authenticationConfiguration.getAuthenticationManager();
}
...

위처럼 AuthenticationManager 빈을 생성했다. 이 빈은 스프링 시큐리티의 인증을 처리한다.

AuthenticationManager는 사용자 인증 시 앞에서 작성한 UserSecurityService와 PasswordEncoder를 내부적으로 사용해 인증과 권한 부여를 한다.

 

로그인 화면 수정하기

/templates/navbar.html

...
<li class="nav-item">
	<a class="nav-link" th:href="@{/user/login}">로그인</a>
</li>
...

네비게이션 바에서 로그인을 클릭 시 화면이동이 되게끔 url을 변경했다.

 

DB에 없는 사용자나 잘못된 비밀번호를 입력하면 오류 메시지가 발생한다.

 

현재 로그인을 해도 상단바에 아직도 '로그인'이라고 나와있다.

 

로그인 하면 '로그아웃', 로그아웃 상태에서는 '로그인'으로 바뀌어야 한다.

 

아래와 같은 스프링 시큐리티의 타임리프 확장 기능을 사용해보자.

sec:authorize="isAnonymous()" //로그인 되지 않은 경우에 '로그인' 표시
sec:authorize="isAuthenticated()" //로그인된 경우에 '로그아웃' 표시 

 

/templates/navbar.html

...
<li class="nav-item">
	<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
	<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
</li>
...

화면을 다시 실행해보면 로그아웃으로 변경된걸 볼 수 있다.

 

로그아웃 기능 구현하기

위 html에서 로그아웃의 링크를 '/user/logout'으로 지정했다. 

하지만 로그아웃을 누르면 기능 구현이 안되어 있어 404 오류가 발생한다.

 

로그아웃 설정 추가하기

SecurityConfig.java

...
	@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)))
			.formLogin((formLogin)-> formLogin
					.loginPage("/user/login")
					.defaultSuccessUrl("/"))
			.logout((logout) -> logout
					.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
					.logoutSuccessUrl("/")
					.invalidateHttpSession(true))
			;
		return http.build();
	}
...

로그아웃 URL을 '/user/logout'으로 설정하고 로그아웃을 성공했을 경우 '/' 루트 페이지로 이동하도록 했다.

또한 .invaliddateHttpSession(true)를 통해 로그아웃 시 생성된 사용자 세션도 삭제하게 했다.

 

로그아웃하면 메인으로 이동하고 로그인으로 변경된 것을 볼 수 있다.

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

회원가입 기능 구성하기

게시판이 얼추 만들어졌으니 회원 가입 기능을 완성해보자

 

회원 엔티티 생성하기

sbb아래 user 패키지 생성

/user/SiteUser.java

package com.mysite.sbb.user;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class SiteUser {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Long id;
	
	@Column(unique = true)
	private String username;
	
	private String password;
	
	@Column(unique = true)
	private String email;

}

회원의 필요한 속성은 사용자id, 비밀번호, 이메일이 필요하다.

또 username, email 에는 @Column(unique = true) 를 지정했는데 유일한 값만 저장이 가능하다는 것을 의미한다.

 

로컬 서버를 재시작한 후 H2 콘솔에 접속해보자.

SITE_USER 테이블이 잘 생성된 것을 볼 수 있다.

 

User 리포지터리와 서비스 생성하기

/user/UserRepository.java

package com.mysite.sbb.user;

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

public interface UserRepository extends JpaRepository<SiteUser, Long> {

}

@@리포지터리는 인터페이스이다.

SiteUser의 기본키의 타입이 Long이므로 JpaRepository<SiteUser, Long>로 설정해주었다.

 

/user/UserService.java

package com.mysite.sbb.user;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserService {
	private final UserRepository userRepository;
	
	public SiteUser create(String username, String email, String password) {
		SiteUser user = new SiteUser();
		user.setUsername(username);
		user.setEmail(email);
		BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
		user.setPassword(passwordEncoder.encode(password));
		this.userRepository.save(user);
		
		return user;
	}
}

UserService에는 회원 데이터를 생성하는 create 메서드를 추가했다.

여기서 비밀번호는 암호화를 하기 위해 스프링 시큐리티의 BCryptPasswordEncoder 클래스를 사용했다.

 

하지만 이렇게 객체를 new로 생성하게 되면 암호화 방식이 변경될 때 BCryptPasswordEncoder 를 사용한 부분을 모두 수정해야하는 번거로움이 있기 때문에 빈을 설정해서 사용하는 것이 좋다.

 

가장 쉬운 방법은 @Configuration이 적용됐던 SecurityConfig.java 파일에 @Bean 메서드를 새로 추가하는 것이다.

 

/sbb/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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
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();
	}
	
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

 

이렇게 설정해놓으면 UserService도 수정해야한다.

 

/user/UserService.java

package com.mysite.sbb.user;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserService {
	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
	
	public SiteUser create(String username, String email, String password) {
		SiteUser user = new SiteUser();
		user.setUsername(username);
		user.setEmail(email);
		user.setPassword(passwordEncoder.encode(password));
		this.userRepository.save(user);
		
		return user;
	}
}

new로 생성하지 않고 빈으로 등록한 PasswordEncoder 객체를 주입받아 사용했다.

 

회원가입 폼 생성하기

/user/UserCreateForm.java

package com.mysite.sbb.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class UserCreateForm {
	@Size(min = 3, max = 25)
	@NotEmpty(message = "사용자 ID는 필수 항목입니다.")
	private String username;
	
	@NotEmpty(message = "비밀번호는 필수 항목입니다.")
	private String password1;

	@NotEmpty(message = "비밀번호 확인은 필수 항목입니다.")
	private String password2;
	
	@NotEmpty(message = "이메일은 필수 항목입니다.")
	@Email
	private String email;
}

@Size는 데이터 길이가 3~25 사이여야 한다는 조건, @Email 은 속성 값이 이메일 형식과 일치하는지 확인.

 

회원가입 컨트롤러 생성하기

/user/UserController.java

package com.mysite.sbb.user;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
	
	private final UserService userService;
	
	@GetMapping("/signup")
	public String signup(UserCreateForm userCreateForm) {
		return "signup_form";
	}
	
	@PostMapping("/signup")
	public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
		if (bindingResult.hasErrors()) {
			return "signup_form";
		}
		
		if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
			bindingResult.rejectValue("password2", "passwordInCorrect", "2개의 비밀번호가 일치하지 않습니다.");
			return "signup_form";
		}
		
		userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1());
		
		return "redirect:/";
	}
}

Get으로 요청 시 템플릿을 제공하고, Post로 요청 시 회원가입을 진행하도록 했다.

또 비밀번호1과 2가 동일한지 검증하는 조건문이 추가됐고, 만약 일치하지 않는다면 오류가 발생하게 했다.

오류가 없다면 userService.create로 사용자 데이터를 저장한다.

 

 

회원 가입 화면 구성하기

회원가입 템플릿 생성하기

/templates/signup_form.html

<html layout:decorate="~{layout}">
	<div layout:fragment="content" class="container my-3">
		<div class="my-3 border-bottom">
			<div>
				<h4>회원 가입</h4>
			</div>
		</div>
		<form th:action="@{/user/signup}" th:object="${userCreateForm}" method="post">
			<div th:replace="~{form_errors :: formErrorsFragment}"></div>
			<div class="mb-3">
				<label for="username" class="form-label">사용자 ID</label>
				<input type="text" th:field="*{username}" class="form-control">
			</div>
			<div class="mb-3">
				<label for="password1" class="form-label">비밀번호</label>
				<input type="password" th:field="*{password1}" class="form-control">
			</div>
			<div class="mb-3">
				<label for="password2" class="form-label">비밀번호 확인</label>
				<input type="password" th:field="*{password2}" class="form-control">
			</div>
			<div class="mb-3">
				<label for="email" class="form-label">이메일</label>
				<input type="email" th:field="*{email}" class="form-control">
			</div>
			<button type="submit" class="btn btn-primary">회원 가입</button>
		</form>
	</div>
</html>

 

네비게이션 바에 회원 가입 링크 추가하기

/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>
				<li class="nav-item">
					<a class="nav-link" th:href="@{/user/signup}">회원 가입</a>
				</li>
			</ul>
		</div>
	</div>
</nav>

로그인 아래에 회원가입 메뉴바를 추가했다.

 

회원 가입 기능 확인하기

로컬 서버를 재시작한 후 확인해보자.

메뉴에 회원 가입이 추가되었다.

 

회원가입 화면
비밀번호 두개를 서로 다르게 입력하면 위의 오류를 확인할 수 있다.

올바르게 회원가입을 진행하면 메인 페이지로 리다이렉트 된다.

 

데이터가 잘 들어갔는지 확인해보자.

비밀번호는 암호화되어 들어간다.

 

중복 회원 가입 방지하기

회원가입 시 이미 등록한 사용자 아이디 또는 이메일 주소로 가입해보면 오류가 발생할 것이다.

 

이렇게 오류 화면을 그대로 보여주는 것을 옳지 않다.

이미 메일주소가 있다는 것을 알리는 메시지가 나오도록 수정해보자.

 

/users/UserController.java

@PostMapping("/signup")
	public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
		if (bindingResult.hasErrors()) {
			return "signup_form";
		}
		
		if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
			bindingResult.rejectValue("password2", "passwordInCorrect", "2개의 비밀번호가 일치하지 않습니다.");
			return "signup_form";
		}
		
		try {
			userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1());
		} catch (DataIntegrityViolationException e) {
			e.printStackTrace();
			bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
			return "signup_form";
		} catch (Exception e) {
			e.printStackTrace();
			bindingResult.reject("signupFailed", e.getMessage());
			return "signup_form";
		}
		
		return "redirect:/";
	}

수정 후 다시 시도한 화면

 

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

+ Recent posts