# 앱이 실행되는 서버 포트 설정:
server.port=8090
# DataSource 관련 설정:
spring.datasource.driver-class-name=oracle.jdbc.OracleDriver
spring.datasource.url=jdbc:oracle:thin:@localhost:1521:xe
spring.datasource.username=scott
spring.datasource.password=tiger
# Spring Data JPA(Java Persistence API) 관련 설정:
spring.jpa.database=oracle
# DDL(Data Definition Language: create table ...) 자동 실행을 사용하지 않음
spring.jpa.hibernate.ddl-auto=none
# Log4j 기능을 사용해서 JPA가 실행하는 SQL 문장을 로그로 출력
spring.jpa.show-sql=true
# 로그로 출력하는 SQL 문장을 보기 좋게 포맷팅.
spring.jpa.properties.hibernate.format_sql=true
# 로그 레벨 설정:
logging.level.org.hibernate.type.descriptor=trace
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8" />
<title>Spring Boot</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous">
</head>
<body class="container-fluid bg-warning bg-opacity-25" >
<div>
<header class="my-2 p-5 text-center">
<h1 class="text-uppercase fw-bold font-monospace">Spring Boot App</h1>
</header>
<nav class="my-2">
<ul class="nav nav-tab justify-content-center" >
<li class="nav-item">
<a class="text-uppercase font-monospace nav-link link-dark active" aria-current="page" th:href="@{/}">home</a>
</li>
<li class="nav-item">
<a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/post}">list</a>
</li>
<li class="nav-item">
<a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/post/create}">create</a>
</li>
<!-- 인증된 사용자이면(로그인 상태이면) -->
<th:block sec:authorize="isAuthenticated()">
<li class="nav-item">
<a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/logout}">
<!-- 로그인한 유저 이름 -->
[<span sec:authentication="name"></span>]
logout</a>
</li>
</th:block>
<!-- 익명 사용자이면(로그아웃 상태이면) -->
<th:block sec:authorize="isAnonymous()">
<li class="nav-item">
<a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/member/signup}">sign-up</a>
</li>
<li class="nav-item">
<a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/login}">log-in</a>
</li>
</th:block>
</ul>
</nav>
<!-- main 컨텐츠가 삽입될 위치 -->
<th:block layout:fragment="main" style="margin-left: 100px; margin-right: 100px;"></th:block>
<footer class="text-center font-monospace my-2 p-5">
<small>rosybb13@gmail.com</small>
</footer>
</div>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
crossorigin="anonymous"></script>
<!-- javascript 파일 추가할 위치 -->
<th:block layout:fragment="myscripts"></th:block>
</body>
</html>
<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base_layout}">
<!-- th 태그 사용했을 때 나오는 경고 표시 제거 -->
<main layout:fragment="main">
<h1 class="fw-light" th:text="'현재시간: '+${now}">현재 시간</h1>
<!-- 문자열 붙여줄 때 "'문자열'+ " -->
<h1 class="fw-medium" th:text="|현재시간: ${now}|">현재 시간</h1>
<!-- thymeleaf 태그 text 속성 value 같은 것으로 우선 적용됨. -->
<!-- 함수와 함께 쓸 때 "||" 파이프 연산자 사용 -->
<h1 class="fw-bold">현재시간: [[${now}]]</h1>
<!-- 안에서 직접 출력 -->
</main>
</html>
<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base_layout}">
<main layout:fragment="main">
<div class="my-2 card border-dark bg-transparent">
<div class="card-header text-center d-none">
<h1>회원가입 페이지</h1>
</div>
<div class="card-body">
<form method="post" th:action="@{ /member/signup }">
<div class="my-2 form-floating">
<input class="form-control bg-transparent border-dark" id="username" type="text"
name="username" placeholder="Leave a comment here" required autofocus />
<label for="username">ID</label>
</div>
<div class="my-2 form-floating">
<input class="form-control bg-transparent border-dark" id="password" type="password"
name="password" placeholder="Leave a comment here" required autofocus />
<label for="password">PASSWORD</label>
</div>
<div class="my-2 form-floating">
<input class="form-control bg-transparent border-dark" id="email" type="email"
name="email" placeholder="Leave a comment here" required autofocus />
<label for="email">EMAIL</label>
</div>
<div class="text-center">
<input class="btn btn-warning col-4 font-monospace" type="submit"
value="SIGN-UP" />
</div>
</form>
</div>
</div>
</main>
</html>
<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base_layout}">
<main layout:fragment="main">
<div>
<h1 class="d-none">포스트 목록 페이지</h1>
</div>
<div class="card bg-transparent border-dark">
<div class="card-body">
<table class="table table-hover ">
<thead>
<tr>
<th class="text-center bg-transparent" scope="col">No</th>
<th class="text-center bg-transparent" scope="col">Title</th>
<th class="text-center bg-transparent" scope="col">Author</th>
<th class="text-center bg-transparent" scope="col">Modified
Time</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr th:each="post: ${ posts }" class="border-dark">
<th scope="row" class="col-1 text-center bg-transparent"
th:text="${ post.id }"></th>
<td class="col bg-transparent"><a
th:href="@{/post/details?id={id} (id=${post.id}) }"
th:text="${ post.title }"
class="link-dark link-underline-opacity-0"></a>
</td>
<td class="col-2 text-center bg-transparent"
th:text="${ post.author }"></td>
<td class="col-3 text-center bg-transparent"
th:text="${ #temporals.format(post.modifiedTime, 'yy/MM/dd HH:mm:ss')}"></td>
</tr>
</tbody>
</table>
<div class="">
<form method="get" th:action="@{/post/search}">
<div class="input-group">
<div class="col-3">
<select class="form-select bg-transparent border-dark" name="type">
<option value="t">title</option>
<option value="c">content</option>
<option value="tc">title+content</option>
<option value="a">author</option>
</select>
</div>
<input class="form-control bg-transparent border-dark" id="keyword"
name="keyword" type="text"
placeholder="keyword"
aria-describedby="btnSearch" required />
<input class="btn btn-warning bg-transparent border-dark" type="submit"
value="search" id="btnSearch" />
</div>
</form>
</div>
</div>
</div>
</main>
</html>
modify.js
<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base_layout}">
<main layout:fragment="main">
<div>
<h1 class="d-none">포스트 목록 페이지</h1>
</div>
<div class="card bg-transparent border-dark">
<div class="card-body">
<table class="table table-hover ">
<thead>
<tr>
<th class="text-center bg-transparent" scope="col">No</th>
<th class="text-center bg-transparent" scope="col">Title</th>
<th class="text-center bg-transparent" scope="col">Author</th>
<th class="text-center bg-transparent" scope="col">Modified
Time</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr th:each="post: ${ posts }" class="border-dark">
<th scope="row" class="col-1 text-center bg-transparent"
th:text="${ post.id }"></th>
<td class="col bg-transparent"><a
th:href="@{/post/details?id={id} (id=${post.id}) }"
th:text="${ post.title }"
class="link-dark link-underline-opacity-0"></a>
</td>
<td class="col-2 text-center bg-transparent"
th:text="${ post.author }"></td>
<td class="col-3 text-center bg-transparent"
th:text="${ #temporals.format(post.modifiedTime, 'yy/MM/dd HH:mm:ss')}"></td>
</tr>
</tbody>
</table>
<div class="">
<form method="get" th:action="@{/post/search}">
<div class="input-group">
<div class="col-3">
<select class="form-select bg-transparent border-dark" name="type">
<option value="t">title</option>
<option value="c">content</option>
<option value="tc">title+content</option>
<option value="a">author</option>
</select>
</div>
<input class="form-control bg-transparent border-dark" id="keyword"
name="keyword" type="text"
placeholder="keyword"
aria-describedby="btnSearch" required />
<input class="btn btn-warning bg-transparent border-dark" type="submit"
value="search" id="btnSearch" />
</div>
</form>
</div>
</div>
</div>
</main>
</html>
reply.js
/**
* 댓글 영역 보이기/숨기기 토글
* 댓글 검색, 등록, 수정, 삭제
*/
document.addEventListener('DOMContentLoaded', () => {
// 로그인한 사용자 이름
const authName = document.querySelector('input#authName').value;
// 부트스트랩 Collapse 객체를 생성. 초기 상태는 화면에 보이지 않는 상태.
const bsCollapse = new bootstrap.Collapse('div#replyToggleDiv', {toggle: false});
// 토글 버튼을 찾고, 이벤트 리스너를 등록.
const btnToggleReply = document.querySelector('#btnToggleReply');
btnToggleReply.addEventListener('click', (e) => {
bsCollapse.toggle();
// console.log(e.target.innerText);
if (e.target.innerText === 'SHOW') {
e.target.innerText = 'HIDE';
// 댓글 목록 불러오기:
getRepliesWithPostId();
} else {
e.target.innerText = 'SHOW';
}
});
//댓글 삭제 버튼 클릭을 처리하는 이벤트 리스너
const deleteReply= (e) => {
//console.log(e.target);
const result = confirm('DELETE?');
if(!result){
return;
}
const id = e.target.getAttribute('data-id');
const reqUrl = `/api/reply/${id}`;
axios
.delete(reqUrl) // ajax DELETE 요청
.then((response) => { //then(function ()) 익명함수
console.log(response);
getRepliesWithPostId();
}) // 성공 응답일 때 실행할 콜백 등록
.catch((error) => console.log(error)); // 실패 응답일 때
}
const updateReply = (e) => {
//console.log(e.target);
const replyId = e.target.getAttribute('data-id'); // 수정할 댓글 아이디
const textAreaId = `textarea#replyText_${replyId}`; // 댓글 입력 textarea 아이디
//console.log(document.querySelector(textAreaId));
// 수정할 댓글 내용
const replyText = document.querySelector(textAreaId).value;
if (replyText === '') { // 댓글 내용이 비어있으면
alert('cannot insert blank');
return;
}
const reqUrl = `/api/reply/${replyId}`; // 요청 주소
const data = { replyText }; // {replyText: replyText}, 요청 데이터(수정할 댓글 내용)
axios
.put(reqUrl, data) // PUT 방식의 Ajax 요청을 보냄.
.then((response) => {
console.log(response);
// TODO:
}) // 성공 응답일 때 동작할 콜백을 등록.
.catch((error) => console.log(error)); // 에러 응답일 때 동작할 콜백을 등록.
};
const makeReplyElements = (data) => {
// 댓글 개수를 배열의 원소 개수로 업데이트
document.querySelector('span#replyCount').innerText = data.length;
// 댓글 목록을 삽입할 div 요소를 찾음.
const replies = document.querySelector('div#replies');
//div 안에 작성된 기존 내용은 삭제.
replies.innerHTML = '';
// div 안에 삽입할 HTML 코드 초기화
let htmlStr = '';
for(let reply of data) {
htmlStr += `
<div class="card mb-2 bg-transparent border-warning">
<div class="row mt-2">
<div class="col">
<span class="fw-bold">#${reply.id}</span>
<span class="fw-bold">${reply.writer}</span>
</div>
`;
// 로그인한 사용자와 댓글 작성자가 같을 때만 삭제, 수정 버튼 보여줌.
if (reply.writer === authName){
htmlStr += `
<div class="col text-end">
<button class="btnModify btn btn-sm btn-warning" data-id="${reply.id}">MOD</button>
<button class="btnDelete btn btn-sm btn-outline-warning" data-id="${reply.id}">DEL</button>
</div>
</div>
<textarea id="replyText_${reply.id}" class="my-2 form-control border-warning bg-transparent">${reply.replyText}</textarea>
</div>
`;
} else {
htmlStr += `
</div>
<textarea id="replyText_${reply.id}" class="my-2 form-control border-warning bg-transparent" readonly>${reply.replyText}</textarea>
</div>
`;
}
}
//작성된 HTML 문자열을 div 요소의 innerHTML로 설정.
replies.innerHTML = htmlStr;
//모든 댓글 삭제 버튼들에게 이벤트 리스너를 등록
const btnDeletes = document.querySelectorAll('button.btnDelete');
for(let btn of btnDeletes){
btn.addEventListener('click', deleteReply);
}
// 수정 버튼
const btnModifies = document.querySelectorAll('button.btnModify');
for(let btn of btnModifies){
btn.addEventListener('click', updateReply);
}
};
// 포스트 번호에 달려 있는 댓글 목록을 (Ajax 요청으로) 가져오는 함수:
const getRepliesWithPostId = async () => {
const postId = document.querySelector('input#id').value; // 포스트 아이디(번호)
console.log(postId);
const reqUrl = `/api/reply/all/${postId}`; // Ajax 요청 주소
// Ajax 요청을 보내고 응답을 기다림.
try {
const response = await axios.get(reqUrl);
// console.log(response);
makeReplyElements(response.data);
} catch (error) {
console.log(error);
}
};
// 댓글 등록 버튼을 찾고 이벤트 리스너 등록
const btnReplyCreate = document.querySelector('button#btnReplyCreate');
btnReplyCreate.addEventListener('click', () => {
// 포스트 아이디
const postId = document.querySelector('input#id').value;
// 댓글 내용
const replyText = document.querySelector('input#replyText').value;
// 댓글 작성자(나중에 로그인한 사요ㅕㅇ자)
const writer = authName;
//alert(`${postId}, ${replyText}, ${writer}`);
if(replyText === ''){
alert('cannot input blank');
return;
}
//Ajax 요청에서 보낼 데이터: /배열아니고 object,
const data = {postId, replyText, writer};
//Ajax 요청을 보낼 URL
const reqUrl = '/api/reply';
axios
.post(reqUrl,data)//Ajax POST 방식 요청을 보냄.
.then((response) => {
console.log(response);
// 댓글목록 새로고침
getRepliesWithPostId();
// textarea 비우기
document.querySelector('input#replyText').value = '';
}) //성공 응답(response)일 때 실행할 콜백 등록
.catch((error) => console.log(error)); //실패(error)일 때 실행할 콜백 등록.
});
});
package com.itwill.spring3.web;
import java.time.LocalDateTime;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Controller
public class HomeController {
@GetMapping("/")
public String home(Model model) {
log.info("home");
LocalDateTime now = LocalDateTime.now();
model.addAttribute("now", now);
return "/main/index"; // View의 이름
}
}
package com.itwill.spring3.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.itwill.spring3.dto.member.MemberSignUpDto;
import com.itwill.spring3.service.MemberService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("/member")
public class MemberController {
private final MemberService memberService;
@GetMapping("/signup")
public void signUp() {
log.info("signUp() GET");
}
@PostMapping("/signup")
public String signup(MemberSignUpDto dto) {
log.info("signUp(dto={}) POST",dto);
// 회원가입 서비스 호출
Long id = memberService.reqisterMember(dto);
log.info("회원가입 id = {}", id);
//회원가입 이후에 로그인 화면으로 이동(redirect):
return "redirect:/login";
}
}
package com.itwill.spring3.web;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.itwill.spring3.dto.post.PostCreateDto;
import com.itwill.spring3.dto.post.PostSearchDto;
import com.itwill.spring3.dto.post.PostUpdateDto;
import com.itwill.spring3.repository.post.Post;
import com.itwill.spring3.repository.reply.Reply;
import com.itwill.spring3.service.PostService;
import com.itwill.spring3.service.ReplyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/post")
public class PostController {
private final PostService postService;
private final ReplyService replyService;
@GetMapping
public String read(Model model) {
log.info("read()");
List<Post> list = postService.read();
model.addAttribute("posts", list);
return "/post/read";
}
@PreAuthorize("hasRole('USER')") // 페이지 접근 이전에 인증(권한, 로그인) 여부를 확인.
@GetMapping("/create")
public void create() {
log.info("create() GET");
}
@PreAuthorize("hasRole('USER')")
@PostMapping("/create")
public String create(PostCreateDto dto) {
log.info("create(dto={}) POST", dto);
postService.create(dto);
// DB 테이블 insert 후 포스트 목록 페이지로 이동.
return "redirect:/post";
}
// "/details", "/modify" 요청 주소를 처리하는 컨트롤러 메서드
@PreAuthorize("hasRole('USER')")
@GetMapping({ "/details", "/modify" })
public void read(Long id, Model model) {
log.info("read(id={})", id);
// POSTS 테이블에서 ID에 해당하는 포스트를 검색.
Post post = postService.read(id);
// 결과를 model에 저장.
model.addAttribute("post", post);
// Replies 테이블에서 해당 포스트에 달린 댓글 개수를 검색
long count = replyService.countByPost(post);
model.addAttribute("replyCount", count);
// 컨트롤러 메서드의 리턴값이 없는 경우(void인 경우),
// 뷰의 이름은 요청 주소와 같다!
// details -> details.html, modify -> modify.html
}
@PreAuthorize("hasRole('USER')")
@PostMapping("/update")
public String update(PostUpdateDto dto) {
log.info("update(dto={}, id={})", dto);
postService.update(dto);
return "redirect:/post/details?id=" + dto.getId();
}
@PreAuthorize("hasRole('USER')")
@PostMapping("/delete")
public String delete(long id) {
log.info("delete(id={})", id);
postService.delete(id);
log.info("삭제 결과={}", id);
return "redirect:/post";
}
@GetMapping("/search")
public String search(PostSearchDto dto, Model model) {
log.info("search(dto={})", dto);
// TODO postService의 검색 기능 호출:
List<Post> list = postService.search(dto);
model.addAttribute("posts", list);
return "/post/read";
}
}
package com.itwill.spring3.web;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.itwill.spring3.dto.reply.ReplyCreateDto;
import com.itwill.spring3.dto.reply.ReplyUpdateDto;
import com.itwill.spring3.repository.reply.Reply;
import com.itwill.spring3.service.ReplyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/reply")
public class ReplyRestController {
private final ReplyService replyService;
@PreAuthorize("hasRole('USER')")
@GetMapping("/all/{postId}")
public ResponseEntity<List<Reply>> all(@PathVariable long postId) {
log.info("all(postId={})", postId);
List<Reply> list = replyService.read(postId);
// 클라이언트에 댓글 리스트를 응답으로 보냄.
return ResponseEntity.ok(list);
}
@PreAuthorize("hasRole('USER')")
@PostMapping
public ResponseEntity<Reply> create(@RequestBody ReplyCreateDto dto){
log.info("create(dto={})", dto);
Reply reply = replyService.create(dto);
return ResponseEntity.ok(reply);
}
@PreAuthorize("hasRole('USER')")
@PutMapping("/{id}")
public ResponseEntity<String> update(@PathVariable long id, @RequestBody ReplyUpdateDto dto) {
log.info("update(id={}, dto={})", id, dto);
replyService.update(id, dto);
return ResponseEntity.ok("Success");
}
@PreAuthorize("hasRole('USER')")
@DeleteMapping("/{id}")
public ResponseEntity<String> delete(@PathVariable long id){
log.info("delete(id= {})", id);
replyService.delete(id);
return ResponseEntity.ok("success");
}
}
package com.itwill.spring3.repository;
import java.time.LocalDateTime;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
// 여러 테이블에서 공통으로 사용되는 생성시간, 수정시간을 프로퍼티로 갖는 객체를 설계:
@Getter
//-> getter 메서드 자동 생성.
@MappedSuperclass
//-> 다른 도메인(엔터티) 클래스의 상위 클래스로 사용됨.
//-> 상속하는 하위 클래스는 BaseTimeEntity가 정의하는 커럼들을 갖게 됨.
@EntityListeners(AuditingEntityListener.class)
//-> main 메서드를 갖고 있는 메인 클래스에서 JPA Auditing 기능이 활성화되어 있는 경우에
//-> 엔터티가 삽입/수정되는 시간이 자동으로 기록되도록 하기 위해서.
public class BaseTimeEntity {
// 엔터티 클래스의 필드 이름은
// 데이터 베이스 테이블의 컬럼 이름과 같거나, 컬럼 이름을 camel 표기법으로 변환한 이름으로 작성.
// (예) 테이블 created_time - 클래스 createdTime
@CreatedDate // insert 될 때의 시간이 자동으로 기록됨
private LocalDateTime createdTime;
//-> 엔터티 클래스의 필드 이름은 자바의 관습(camel 표기법)으로 작성.
//-> 테이블의 컬럼 이름은 데이터베이스의 관습(snake 표기법)을 따름.
@LastModifiedDate
private LocalDateTime modifiedTime;
}
package com.itwill.spring3.repository.post;
import com.itwill.spring3.dto.post.PostUpdateDto;
import com.itwill.spring3.repository.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
@Entity //JPA 엔터티 클래스- 데이터 베이스 테이블과 매핑되는 클래스
@Table(name = "POSTS") // 엔터티 클래스 이름이 데이터 베이스 테이블 이름과 다른 경우, 테이블 이름을 명시/
@SequenceGenerator(name = "POSTS_SEQ_GEN", sequenceName = "POSTS_SEQ", allocationSize = 1)
public class Post extends BaseTimeEntity {
@Id //Primary key 제약조건
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "POSTS_SEQ_GEN")
private Long id;
@Column(nullable = false) // Not Null 제약 조건
private String title;
@Column(nullable = false)
private String content;
@Column(nullable = false)
private String author;
//for Post entity title, content update:
public Post update(PostUpdateDto dto) {
this.title = dto.getTitle();
this.content = dto.getContent();
return this;
}
}
package com.itwill.spring3.repository.post;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PostRepository extends JpaRepository<Post, Long> {
// id 내림차순 정렬:
// select * from POSTS order by ID desc
List<Post> findByOrderByIdDesc();
// 제목으로 검색:
// select * from posts p where lower(p.title) like lower('%' || ? || '%') order by p.id desc
List<Post> findByTitleContainsIgnoreCaseOrderByIdDesc(String title);
// 내용으로 검색:
List<Post> findByContentContainsIgnoreCaseOrderByIdDesc(String content);
// 제목과 내용으로 검색:
// select *
// from posts p
// where lower(p.title) like lower('%' || ? || '%')
// or lower(p.content) like lower('%' || ? || '%')
// order by p.id desc
List<Post> findByTitleContainsIgnoreCaseOrContentContainsIgnoreCaseOrderByIdDesc(String title, String content);
// 작성자로 검색:
List<Post> findByAuthorContainsIgnoreCaseOrderByIdDesc(String author);
// JPQL(JPA Query Language) 문법으로 쿼리를 작성하고, 그 쿼리를 실행하는 메서드 이름을 설정:
// JPQL은 Entity 클래스의 이름과 필드 이름들을 사용해서 작성
// (주의) DB테이블 이름과 컬럼 이름을 사용하지 않음
@Query(
"select p from Post p "+
" where lower(p.title) like lower('%' || :keyword || '%') " +
" or lower(p.content) like lower('%' || :keyword || '%') " +
" order by p.id desc"
)
List<Post> searchByKeyword(@Param("keyword") String keyowrd);
}
package com.itwill.spring3.repository.reply;
import com.itwill.spring3.dto.reply.ReplyUpdateDto;
import com.itwill.spring3.repository.BaseTimeEntity;
import com.itwill.spring3.repository.post.Post;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
@ToString(exclude = {"post"} )
@Entity
@Table(name = "REPLIES")
@SequenceGenerator(name = "REPLIES_SEQ_GEN", sequenceName = "REPLIES_SEQ", allocationSize = 1 )
public class Reply extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "REPLIES_SEQ_GEN")
private Long id; // Primary Key
@ManyToOne(fetch = FetchType.LAZY)
// EAGER(기본값): 즉시 로딩(join 문장 실행), LAZY: 지연 로딩(나중에 필요한 경우 join 문장 실행)
private Post post; // Foreign Key, 관계를 맺고 있는 엔터티
@Column(nullable = false)
private String replyText; // 댓 내용
@Column(nullable = false)
private String writer; // 댓 작성자
public Reply update(String replyText) {
this.replyText = replyText;
return this;
}
}
package com.itwill.spring3.repository.reply;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.itwill.spring3.repository.post.Post;
public interface ReplyRepository extends JpaRepository<Reply, Long> {
// Post(id)로 검색하기:
List<Reply> findByPostId(Long postId);
// Post(post)로 검색하기:
List<Reply> findByPostOrderByIdDesc(Post post);
// Post에 달린 댓글 개수:
Long countByPost(Post post);
}
package com.itwill.spring3.repository.member;
import java.util.Arrays;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.itwill.spring3.repository.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@NoArgsConstructor
@Getter
@ToString
@Entity
@Table(name = "MEMBERS")
@SequenceGenerator(name = "MEMBERS_SEQ_GEN", sequenceName = "MEMBERS_SEQ", allocationSize = 1)
// Member IS-A UserDetails
// 스프링 시큐리티는 로그인 처리를 위해서 UserDetails 객체를 사용하기 때문에
// 회원 정보 엔터티는 UserDetails 인터페이스를 구현해야 함.
public class Member extends BaseTimeEntity implements UserDetails{
@Id //pk
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBERS_SEQ_GEN")
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private Role role;
@Builder
private Member(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
this.role = Role.USER; // 회원가입 사용자 권한의 기본값은 USER
}
// UserDetails 인터페이스의 추상 메서드들을 구현
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority(role.getKey()));
}
@Override
public boolean isAccountNonExpired() {
return true; // 계정(account)이 non-expired(만료되지 않음).
}
@Override
public boolean isAccountNonLocked() {
return true; // 계정이 non-lock(잠기지 않음)
}
@Override
public boolean isCredentialsNonExpired() {
return true; // 비밀번호가 non-expried
}
@Override
public boolean isEnabled() {
return true; // 사용자 상세정보(UserDetails)가 활성화(enable)
}
}
package com.itwill.spring3.repository.member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long>{
Member findByUsername(String Username);
}
package com.itwill.spring3.repository.member;
public enum Role {
USER("ROLE_USER", "USER"),
ADMIN("ROLE_ADMIN", "ADMIN");
private final String key;
private final String name;
Role(String key, String name){
this.key = key;
this.name = name;
}
public String getKey() {
return this.key;
}
}
(1) 메서드에 @Transactional 애너테이션을 설정
(2) DB에서 엔터티를 검색
(3) 검색한 엔터티를 수정
트랜잭션이 끝나는 시점에 DB 업데이트가 자동으로 수행
package com.itwill.spring3.service;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.itwill.spring3.dto.post.PostCreateDto;
import com.itwill.spring3.dto.post.PostSearchDto;
import com.itwill.spring3.dto.post.PostUpdateDto;
import com.itwill.spring3.repository.post.Post;
import com.itwill.spring3.repository.post.PostRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@Service
public class PostService {
// 생성자를 사용한 의존성 주입:
private final PostRepository postRepository;
// DB POST 테이블에서 전체 검색한 결과를 리턴
public List<Post> read() {
log.info("read()");
return postRepository.findByOrderByIdDesc();
}
// DB에 있는 POSTS 테이블에 새 entity를 insert:
public Post create(PostCreateDto dto) {
log.info("create(dto={}",dto);
// DTO를 entity로 변환:
Post entity = dto.toEntity();
log.info("entity= {}", entity);
// DB 테이블에 저장
postRepository.save(entity);
log.info("entity= {}", entity);
return entity;
}
public Post read(Long id) {
return postRepository.findById(id).orElseThrow();
}
@Transactional
public Post update(PostUpdateDto dto) {
log.info("update({})", dto);
//Post entity = postRepository.findById(id).orElseThrow();
//entity.update(dto);
Post entity = postRepository.findById(dto.getId()).orElseThrow();
entity.update(dto);
return postRepository.saveAndFlush(entity);
}
public void delete(long id) {
log.info("delete(id={})",id);
postRepository.deleteById(id);
}
@Transactional(readOnly = true)
public List<Post> search(PostSearchDto dto){
List<Post> list = null;
switch(dto.getType()) {
case "t" :
list = postRepository.findByTitleContainsIgnoreCaseOrderByIdDesc(dto.getKeyword());
break;
case "c" :
list = postRepository.findByContentContainsIgnoreCaseOrderByIdDesc(dto.getKeyword());
break;
case "tc" :
list = postRepository.findByTitleContainsIgnoreCaseOrContentContainsIgnoreCaseOrderByIdDesc(dto.getKeyword(), dto.getKeyword());
break;
case "a" :
list = postRepository.findByAuthorContainsIgnoreCaseOrderByIdDesc(dto.getKeyword());
break;
}
return list;
}
}
package com.itwill.spring3.service;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.itwill.spring3.dto.reply.ReplyCreateDto;
import com.itwill.spring3.dto.reply.ReplyUpdateDto;
import com.itwill.spring3.repository.post.Post;
import com.itwill.spring3.repository.post.PostRepository;
import com.itwill.spring3.repository.reply.Reply;
import com.itwill.spring3.repository.reply.ReplyRepository;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@AllArgsConstructor
@Service
@Slf4j
public class ReplyService {
private final ReplyRepository replyRepository;
private final PostRepository postRepository;
public void delete(long id) {
log.info("delete(id = {})", id);
//DB replies 테이블에서 ID(고유키)로 댓글 삭제
replyRepository.deleteById(id);
}
@Transactional(readOnly = true)
public List<Reply> read(Long postId){
log.info("read(postId={})", postId);
// 1. postId로 post 검색.
Post post = postRepository.findById(postId).orElseThrow();
// 2. 찾은 post에 달려 있는 댓글 목록을 검색.
List<Reply> list = replyRepository.findByPostOrderByIdDesc(post);
//List<Reply> list = replyRepository.findByPostId(postId);
return list;
}
@Transactional(readOnly = true)
public List<Reply> read(Post post) {
log.info("read(post={}", post);
List<Reply> list = replyRepository.findByPostOrderByIdDesc(post);
return list;
}
public Long countByPost(Post post) {
log.info("countByPost(post={})", post);
return replyRepository.countByPost(post);
}
public Reply create(ReplyCreateDto dto) {
log.info("create(dto={})", dto);
//1. Post 엔터티 검색
Post post = postRepository.findById(dto.getPostId()).orElseThrow();
//2. ReplyCreateDto 객체를 Reply 엔터티 객체로 변환
Reply entity = Reply.builder()
.post(post)
.replyText(dto.getReplyText())
.writer(dto.getWriter())
.build();
//3. DB replies 테이블에 insert
replyRepository.saveAndFlush(entity);
return entity;
}
@Transactional
//-> DB에서 검색한 Entity를 수정하면 transaction이 끝나는 시점에 update 쿼리가 자동으로 실횅
public void update(long id, ReplyUpdateDto dto) {
log.info("update(dto={})", dto);
//1. 댓글 아이디로 DB에서 엔터티 검색
Reply entity = replyRepository.findById(id).orElseThrow();
//2. 검색한 엔터티의 프로퍼티 수정
entity.update(dto.getReplyText());
}
}
package com.itwill.spring3.service;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.itwill.spring3.dto.member.MemberSignUpDto;
import com.itwill.spring3.repository.member.Member;
import com.itwill.spring3.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@Service
// Security Filter Chain에서 UserDetialsService 객체를 사용할 수 있도록 하기 위해서
public class MemberService implements UserDetailsService{
private final MemberRepository memberRepository;
// SecurityConfig에서 설정한 PasswordEncoder 빈(bean)을 주입해줌.
private final PasswordEncoder passwordEncoder;
// 회원가입
public Long reqisterMember(MemberSignUpDto dto) {
log.info("registerMember(dto={})", dto);
Member entity = Member.builder()
.username(dto.getUsername())
.password(passwordEncoder.encode(dto.getPassword()))
.email(dto.getEmail())
.build();
log.info("before save: entity={}", entity);
memberRepository.save(entity); // DB insert
log.info("after save: entity={}", entity);
return entity.getId(); // DB에 저장된 ID(고유키)를 리턴
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("loadUserByUsername(username={})", username);
// DB에서 username으로 사용자 정보 검색(select)
UserDetails user = memberRepository.findByUsername(username);
if(user != null) {
return user;
}
throw new UsernameNotFoundException(username + " not found");
}
}
package com.itwill.spring3.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@EnableMethodSecurity
@Configuration // 스프링 컨테이너에서 빈(bean)으로 생성, 관리 - 필요한 곳에 의존성 주입.
public class SecurityConfig {
// Spring Security 5 버전부터는 비밀번호는 반드시 암호화를 해야 함.
// 비밀번호를 암호화하지 않으면 HTTP 403(access denied, 접근 거부) 또는
// HTTP 500(internal server error, 내부 서버 오류)가 발생함.
// 비밀번호 인코더(Password encoder) 객체를 bean으로 생성해야 함.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 로그인할 때 사용할 임시 사용자(메모리에 임시 저장) bean 생성
/*
@Bean
public UserDetailsService inMemoryUserDetailsService() {
// 사용자 상세 정보
UserDetails user1 = User
.withUsername("user1") // 로그인할 때 사용할 사용자 이름(아이디)
.password(passwordEncoder().encode("1111")) // 로그인할 때 사용할 비밀번호
.roles("USER") // 사용자 권한(USER, ADMIN, ...)
.build(); // UserDetails 객체 생성.
UserDetails user2 = User
.withUsername("user2")
.password(passwordEncoder().encode("2222"))
.roles("USER", "ADMIN")
.build();
UserDetails user3 = User
.withUsername("user3")
.password(passwordEncoder().encode("3333"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user1, user2, user3);
}
*/
// Security Filter 설정 bean:
// 로그인/로그아웃 설정
// 로그인 페이지 설정, 로그아웃 이후 이동할 페이지.
// 페이지 접근 권한 - 로그인해야만 접근 가능한 페이지, 로그인 없이 접근 가능한 페이지.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF(Cross Site Request Forgery) 기능 활성화하면,
// Ajax POST/PUT/DELETE 요청에서 CSRF 토큰을 서버로 전송하지 않으면 403 에러가 발생.
// -> CSRF 기능 비활성화.
http.csrf((csrf) -> csrf.disable());
// 로그인 페이지 설정 - 스프링에서 제공하는 기본 로그인 페이지를 사용.
http.formLogin(Customizer.withDefaults());
// 로그아웃 이후 이동할 페이지 - 메인 페이지
http.logout((logout) -> logout.logoutSuccessUrl("/"));
// 페이지 접근 권한 설정
/*
http.authorizeHttpRequests((authRequest) ->
authRequest // 접근 권한을 설정할 수 있는 객체
// 권한이 필요한 페이지들을 설정
.requestMatchers("/post/create", "/post/details", "/post/modify",
"/post/update", "/post/delete", "/api/reply/**")
// .authenticated() // 권한여부에 상관없이 아이디/비밀번호가 일치하면
.hasRole("USER") // 위에서 설정한 페이지들이 USER 권한을 요구함을 설정.
.anyRequest() // .requestMatchers("/**"). 위 페이지들 이외의 모든 페이지
.permitAll() // 권한없이 접근 허용.
);
*/
// 단점: 새로운 요청 경로, 컨트롤러를 작성할 때마다 Config 자바 코드를 수정해야 함.
//-> 컨트롤러 메서드를 작성할 때 애너테이션을 사용해서 접근 권한을 설정할 수도 있음.
// (1) SecurityConfig 클래스에서 @EnableMethodSecurity 애너테이션 설정.
// (2) 각각의 컨트롤러 메서드에서 @PreAuthorize 또는 @PostAuthorize 애너테이션을 사용.
return http.build();
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.1'
id 'io.spring.dependency-management' version '1.1.0'
}
group = 'com.itwill'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
// thymeleaf-layout-dialect 기능 사용:
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.2.1'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.oracle.database.jdbc:ojdbc11'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
package com.itwill.spring3;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing //JPA Auditing 기능 활성화
@SpringBootApplication
public class Spring3Application {
public static void main(String[] args) {
SpringApplication.run(Spring3Application.class, args);
}
}