자바스크립트 함수 너무 어렵다
하시면 여기서 해당 코드 찾으면 HTML로만 구현한 버전
확인할 수도 있어 좋을 것 같습니다!의존성
설정들
ORM : 데이터베이스에 데이터를 저장하는 테이블을 자바 클래스로 만들어 관리하는 기술
JPA(Java Persistr API) 사용하여 데이터베이스 처리, ORM의 기술 표준으로 사용하는 인터페이스 모음
@ResponseBody : 응답 결과가 String임을 명시
@lombok : 자바 클래스에 Getter, Setter, 생성자 등을 자동으로 만들어 주는 도구
@RequitedArgsConstructor : 해당 속성을 필요로하는 생성자가 롬복에 의해 자동으로 필요됨(final 키워드 붙은 속성을 생성자에 포함)
@SpringBootApplication : 스프링부트의 모든 설정 관리됨
@Getter
@Setter
@Entity
public class Question {
@Id // 기본키 지정
// 값 생성 자동화 / 값 증가하는 타입으로
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
// 속성 세부 설정 가능
@Column(length = 200)
private String subject;
@Column(columnDefinition = "TEXT")
private String content;
// 속성명은 create_date 형태로 저장됨
// 카멜케이스 형태로 사용
private LocalDateTime createDate;
}
@Getter
@Setter
@Entity
public class Answer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(columnDefinition = "TEXT")
private String content;
private LocalDateTime createDate;
// N : 1 관계 설정, ForeignKey 관계 설정
// 부모, 자식 관계를 갖는 구조에서 사용, 부모는 Question, 자식은 Answer
@ManyToOne
private Question question;
}
부모 자식 관계 설정 : OneToMany or ManyToOne
Question {
// 반대방향 참조 방식
// 질문 하나에 답변 여러개 1:N 관계는 리스트로 표현
// mappedBy는 참조 엔티티의 속성명(Answer 엔티티에서 question 속성명을 전달해야 함)
// 질문 삭제하면 답변들도 모두 삭제하기 위해 cascade = CascadeType.REMOVE 사용
@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
private List<Answer> answerList;
}
리포지터리 : 엔티티에 의해 생성된 데이터베이스 테이블에 접근하는 메서드들(findAll, save 등)을 사용하기 위한 인터페이스
@Autowired : 객체를 주입하기 위해 사용하는 스프링 어노테이션으로 다른 방법으로는 Setter 또는 생성자를 사용하는 방식이 있다.
assertEquals : assertEquals(기대값, 실제값)와 같이 사용하고 기대값과 실제값이 동일한지를 조사한다. 만약 기대값과 실제값이 동일하지 않다면 테스트는 실패로 처리된다.
@Transactional // 이녀석이 있어야 DB세션 유지
@Test
void testJpa() {
Optional<Question> oq = this.questionRepository.findById(2);
assertTrue(oq.isPresent());
Question q = oq.get();
List<Answer> answerList = q.getAnswerList();
assertEquals(1, answerList.size());
assertEquals("네 자동으로 생성됩니다.", answerList.get(0).getContent());
}
findById 메서드가 끝나면 DB세션이 끊어짐 -> getAnswerList() 메서드는 세션이 종료되어 오류 발생
답변 데이터 리스트는 메소드를 호출하는 시점에 가져오기 때문
하지만 실제 서버에서 JPA 프로그램 실행할 때 DB 세션이 종료되지 않기 때문에 오류 발생하지 않음
테스트 코드에서 @Transactional 어노테이션 사용하면 메서드 종료될 때까지 DB 세션 유지
@BeforeEach : 아래 메서드는 각 테스트케이스가 실행되기 전에 실행된다.
@Modifying : 쿼리가 select가 아닐 경우 사용
public interface QuestionRepository extends JpaRepository<Question, Integer> {
@Transactional // modify 붙을 때 transactional 붙는다.
// @Modifying // 만약 아래 쿼리가 select가 아니라면 이걸 붙여야 한다.
@Modifying
// nativeQuery = true 여야 MySQL 쿼리문법 사용 가능
@Query(value = "ALTER TABLE question AUTO_INCREMENT = 1", nativeQuery = true)
void clearAutoIncrement();
@OneToMany에는 직접 객체 초기화
템플릿 : 자바 코드를 삽입할 수 있는 HTML 형식의 파일
Model 객체 : 자바 클래스와 템플릿 간 연결고라 역할
자주 사용하는 타임리프 속성
th:if = "${question !- null}"
th:each="question:${questionList}"
th:each ="question, loop : ${questionList}"
th:text = "${question.subject}"
<tr th:each="question : ${questionList}">
<td>[[${question.subject}]]</td>
<td>[[${question.createDate}]]</td>
</tr>
redirect:URL => URL로 리다이렉트
forward:URL => URL로 포워드
th:href
속성 사용<a th:href = "@{|/question/detail/${question.id}|}"></a>
@{
문자와 }
문자 사이에 입력해야 함/question/detail/
과 ${question.id}
값이 조합되어 /question/detail/${question.id}
로 만들어짐|
과 |
기호로 좌우를 감싸주어야 한다. (타임리프에서 문자열을 연결할 때 |문자 사용) @GetMapping(value = "/question/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id) {
return "question/question_detail";
}
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id, @RequestParam String content) {
Question question = questionService.getQuestion(id);
answerService.create(question, content);
return String.format("redirect:/question/detail/%s", id);
}
<h5 th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
#lists.size(이터러블객체)
는 타임리프가 제공하는 유틸리티로 객체의 길이 반환<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
#temporals.format(날짜객체, 날짜포맷)
: 날짜객체를 날짜포맷에 맞게 변환한다.
템플릿 : 표준 HTML 구조를 가질 시 body 엘리먼트 바깥 부분(헤드 등)은 모두 같은내용으로 중복됨
템플릿 상속
기능 제공<!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>
<th:block layout:fragment="content"></th:block>
영역에 해당되는 부분만 작성해도 표준 HTML 문서가 됨<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
(... 생략 ...)
</table>
</div>
</html>
<html layout:decorate="~{layout}">
처럼 사용함~{layout}
은 layout.html 파일을 의미<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<div layout:fragment="content" class="container my-3">
(... 생략 ...)
</div>
@Getter
@Setter
public class QuestionForm {
@NotEmpty(message = "제목은 필수항목입니다.")
@Size(max=200)
private String subject;
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult) {
if(bindingResult.hasErrors()) {
return "question/question_form";
}
questionService.create(questionForm.getSubject(), questionForm.getContent());
return "redirect:/question/list"; // 질문 저장 후 질문목록으로 이동
}
<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>
#fields.hasAnyErros
가 true인 경우에는 QuestionForm 검증이 실패한 경우를 나타냄#fields.allErrors()
로 구할 수 있음 <div th:replace="~{form_errors :: formErrorsFragment}"></div>
div
엘리먼트를 form_erros.html
파일의 th:fragment
속성명이 formErrosFragment
인 엘리먼트로 교체하라는 뜻
Pageable 객체 생성할 때 사용
PageRequest.of(page, 10)
: page는 조회할 페이지 번호, 10은 한 페이지에 보여줄 게시물의 갯수타임리프의 th:classappend="조건식 ? 클래스값"
속성은 조건식이 참인 경우 클래스값을 class 속성에 추가
#numbers.sequence(시작, 끝)
은 시작 번호부터 끝 번호까지의 루프를 만들어 내는 타임리프의 유틸리티.
페이징 정렬 기준을 넣기 위해서는 PageRequest.of
메서드의 세번째 파라미터로 Sort 객체를 전달해야 함
Sort.Order
객체로 구성된 리스트에 Sort.Order
객체를 추가하고 Sort.by(소트리스트)
로 소트 객체를 생성할 수 있다. public Page<Question> getList(int page) {
// Sort.Order 리스트 생성
List<Sort.Order> sorts = new ArrayList<>();
// Sort.Order 객체를 리스트에 추가(정렬 기준)
sorts.add(Sort.Order.desc("createDate"));
// Sort.by(리스트)로 Sort 객체 생성
Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
return this.questionRepository.findAll(pageable);
}
인증 : 로그인
권한 : 인증된 사용자가 어떤 것을 할 수 있는지를 의미
@Configuration : 스프링의 환경설정 파일임을 의미하는 어노테이션
@EnableWebSecurity : 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 어노테이션
스프링 시큐리티 세부 설정은 SecurityFilterChain 빈을 생성하여 설장할 수 있다.
BCryptPasswordEncoder : BCrypt 해싱 함수를 사용해 비밀번호를 암호화 함
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Getter
@Setter
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(min = 3, max = 25)
어노테이션으로 문자열 길이를 검증할 수 있음.@Email
어노테이션으로 해당 속성이 이메일 형식과 일치하는지 검사 @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:/";
}
bindingResult.rejectValue(필드명, 오류코드, 에러메세지)
를 의미하며 여기서 오류코드는 "passwordInCorrect"로 정의함@PostMapping("/signup")
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
if(bindingResult.hasErrors()) {
return "user/signup_form";
}
if(!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
bindingResult.rejectValue("password2", "passwordInCorrect", "2개의 패스워드가 일치하지 않습니다.");
return "user/signup_form";
}
try {
userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1());
}catch (DataIntegrityViolationException e) {
e.printStackTrace();
bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
return "user/signup_form";
}catch (Exception e) {
e.printStackTrace();
bindingResult.reject("signupFailed", e.getMessage());
return "user/signup_form";
}
return "redirect:/";
}
bindingResult.reject(오류코드, 오류메세지)
: 특정 필드의 오류가 아닌 일반적인 오류를 등록할 때 사용<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>
@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);
}
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(
formLogin -> formLogin
// 로그인폼 url 알려줌, 없으면 기본 페이지 url은 "/login"임(GET)
.loginPage("/user/login")
// 로그인 폼 처리 url을 알려줌 POST, form의 th:action과 맞춰야 함
// loginPage와 주소가 같아서 생략 가능
// .loginProcessingUrl("/user/login")
.defaultSuccessUrl("/") // 로그인 성공 시 이동
)
.logout(
logout -> logout
.logoutUrl("/user/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true) // 로그아웃 시 세션키를 날림
);
return http.build();
}
@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);
}
}
2.UserDetailsService
인터페이스를 구현해야 함.
현재 로그인한 사용자의 정보를 알기 위해서 스프링 시큐리티가 제공하는 Principal 객체 이용
@PreAuthorize("isAuthenticated()")
어노테이션을 사용해야 null값을 방지할 수 있음(로그인 된 사용자만 이용 가능)@Configuration
@EnableWebSecurity
// @PreAuthirize 어노테이션을 사용하기 위해 필요
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@PreAuthorize 어노테이션을 사용하기 위해 @EnableMethodSecurity 어노테이션 필요
자바스크립트 코드는 </body>
위에 삽입 추천
<a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="삭제"></a>
자바스크립트 함수에서 this.dataset.uri로 데이터셋 접근 가능(th:data-uri)
<script type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
bean으로 등록된 컴포넌트는 템플릿에서 바로 사용할 수 있음
th:utext는 HTML태그들을 이스케이프 하지 않음
<div class="row my-3">
<div class="col-6">
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
<div class="col-6">
<form>
<div class="input-group">
<input type="text" class="form-control" name="kw" placeholder="검색어" th:value="${param.kw}">
<button class="btn btn-outline-secondary">찾기</button>
</div>
</form>
</div>
</div>
th:with : 태그 내에서 변수로 사용
<!-- 페이징처리 시작 -->
<!-- th:with : 변수 선언 -->
<div th:if="${!paging.isEmpty()}" th:with="queryStrBase = '?kw=' + ${param.kw != null ? param.kw : ''}">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${paging.number == 0} ? 'disabled'">
<a class="page-link"
th:href="@{|${queryStrBase}&page=0|}">
<span>처음</span>
</a>
</li>
<li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
<a class="page-link"
th:href="@{|${queryStrBase}&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="@{|${queryStrBase}&page=${page}|}"></a>
</li>
<li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
<a class="page-link" th:href="@{|${queryStrBase}&page=${paging.number+1}|}">
<span>다음</span>
</a>
</li>
<li class="page-item" th:classappend="${paging.number == paging.totalPages - 1} ? 'disabled'">
<a class="page-link"
th:href="@{|${queryStrBase}&page=${paging.totalPages-1}|}">
<span>마지막</span>
</a>
</li>
</ul>
</div>
<!-- 페이징처리 끝 -->
검색 후 페이지 이동시 검색어 유지하도록 설정
감 잡을 겸 복습 했는데 새로우면서 재미가있다. 원티드 프로젝트 준비 완료~!
추가 기능은 이전에 포스팅 하였습니다!
출처 : 점프 투 스프링부트 2~3장