지금까지 Entity, API, QueryDsl, DTO 까지 구현을 했습니다. 이제 이에 맞게 게시판, 댓글 컨트롤러, 뷰, 서비스클래스를 만들어 테스트까지 해보도록하겠습니다.
ArticleController
RequiredArgsConstructor
@RequestMapping("/articles")
@Controller
public class ArticleController {
private final ArticleService articleService;
@GetMapping
public String articles(@RequestParam(required = false) SearchType searchType,
@RequestParam(required = false) String searchValue,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
ModelMap modelMap) {
modelMap.addAttribute("articles", articleService.searchArticles(searchType, searchValue, pageable).map(ArticleResponse::from));
return "articles/index";
}
@GetMapping("/{articleId}")
public String article(@PathVariable Long articleId, ModelMap modelMap) {
ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticle(articleId));
modelMap.addAttribute("article", article);
modelMap.addAttribute("articleComments", article.getArticleCommentResponse());
return "articles/detail";
}
}
ArticleRepository
@RepositoryRestResource
public interface ArticleRepository extends JpaRepository<Article, Long>, QuerydslPredicateExecutor<Article>, QuerydslBinderCustomizer<QArticle> {
Page<Article> findByTitleContaining(String title, Pageable pageable);
Page<Article> findByContentContaining(String content, Pageable pageable);
Page<Article> findByUserAccount_UserIdContaining(String userId, Pageable pageable);
Page<Article> findByUserAccount_NicknameContaining(String nickname, Pageable pageable);
Page<Article> findByHashtag(String hashtag, Pageable pageable);
}
이렇게 검색을 통해서 결과값을 받았을때 Page타입으로 리턴받을 수 있게 메소드를 만들어 줍니다.
ArticleService
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
private Page<ArticleDto> map;
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
if (searchKeyword == null || searchKeyword.isBlank()) {
return articleRepository.findAll(pageable).map(ArticleDto::from);
}
switch (searchType) {
case TITLE:
map = articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from); break;
case CONTENT:
map = articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from); break;
case ID:
map = articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from); break;
case NICKNAME:
map = articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from); break;
case HASHTAG:
map = articleRepository.findByHashtag("#" + searchKeyword, pageable).map(ArticleDto::from); break;
}
return map;
}
@Transactional(readOnly = true)
public ArticleWithCommentsDto getArticle(Long articleId) {
return articleRepository.findById(articleId)
.map(ArticleWithCommentsDto::from)
.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
}
public void saveArticle(ArticleDto dto) {
articleRepository.save(dto.toEntity());
}
public void updateArticle(ArticleDto dto) {
try {
Article article = articleRepository.getReferenceById(dto.getId());
if (dto.getTitle() != null) {
article.setTitle(dto.getTitle());
}
if (dto.getContent() != null) {
article.setContent(dto.getContent());
}
article.setHashtag(dto.getHashtag());
} catch (EntityNotFoundException e) {
log.warn("게시글 업데이트 실패. 게시글을 찾을 수 없습니다. -dto: {}", dto);
}
}
public void deleteArticle(long articleId) {
articleRepository.deleteById(articleId);
}
}
searchArticles : enum 타입의 searchType을 받아서 검색에 따라 조회를 해와서 map을 리턴해주는 구조입니다.
getArticle : @Transactional(readonly=true) 속성을 줘서 조회전용이다.게시글을 id로 하나 가져온다.
saveArticle : 게시글을 등록,저장하는 메서드
updatArticle : 게시글을 업데이트하는 메서드
deleteArticle : 삭제하는 메서드
Index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="Uno Kim">
<title>게시판 페이지</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
<link href="/css/search-bar.css" rel="stylesheet">
</head>
<body>
<header id="header">
헤더 삽입부
<hr>
</header>
<main class="container">
<div class="row">
<div class="card card-margin search-form">
<div class="card-body p-0">
<form id="card search-form">
<div class="row">
<div class="col-12">
<div class="row no-gutters">
<div class="col-lg-3 col-md-3 col-sm-12 p-0">
<label for="search-type" hidden>검색 유형</label>
<select class="form-control" id="search-type">
<option>제목</option>
<option>본문</option>
<option>id</option>
<option>닉네임</option>
<option>해시태그</option>
</select>
</div>
<div class="col-lg-8 col-md-6 col-sm-12 p-0">
<label for="search-value" hidden>검색어</label>
<input type="text" placeholder="검색어..." class="form-control" id="search-value" name="search-value">
</div>
<div class="col-lg-1 col-md-3 col-sm-12 p-0">
<button type="submit" class="btn btn-base">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<table class="table" id="article-table">
<thead>
<tr>
<th class="title col-6">제목</th>
<th class="hashtag col-2">해시태그</th>
<th class="user-id col">작성자</th>
<th class="created-by col">작성일</th>
</tr>
</thead>
<tbody>
<tr>
<td class="title"><a>첫글</a></td>
<td class="hashtag">#java</td>
<td class="user-id">jisu</td>
<td class="created-at"><time>2022-01-01</time></td>
</tr>
<tr>
<td>두번째글</td>
<td>#spring</td>
<td>jisu</td>
<td><time>2022-01-02</time></td>
</tr>
<tr>
<td>세번째글</td>
<td>#java</td>
<td>jisu</td>
<td><time>2022-01-03</time></td>
</tr>
</tbody>
</table>
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center">
<li class="page-item"><a class="page-link" href="#">Previous</a></li>
<li class="page-item"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item"><a class="page-link" href="#">Next</a></li>
</ul>
</nav>
</main>
<footer id="footer">
<hr>
푸터 삽입부
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>
Index.th.html
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header"></attr>
<attr sel="#footer" th:replace="footer :: footer"></attr>
<attr sel="#article-table">
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="article : ${articles}">
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" />
<attr sel="td.hashtag" th:text="${article.hashtag}" />
<attr sel="td.user-id" th:text="${article.nickname}" />
<attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
</attr>
</attr>
</attr>
</thlogic>
index.th.html에서 id = #article-table 인 속성을 찾아서 tbody를 지웁니다. 그리고 th:each를 통해 반복하면 컨트롤러에서 넘긴 articles를 가져와 그에 맞게 title, hashtag, nickname 속성을 찾아 넣어줍니다.
ArticleCommentRepository
@RepositoryRestResource
public interface ArticleCommentRepository extends JpaRepository<ArticleComment, Long>, QuerydslPredicateExecutor<ArticleComment>, QuerydslBinderCustomizer<QArticleComment> {
List<ArticleComment> findByArticle_Id(Long articleId);
@Override
default void customize(QuerydslBindings bindings, QArticleComment root) {
bindings.excludeUnlistedProperties(true);
bindings.including(root.content, root.createdAt, root.createdBy);
bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
bindings.bind(root.createdAt).first(DateTimeExpression::eq);
bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
}
}
ArticleCommentService
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleCommentService {
private final ArticleRepository articleRepository;
private final ArticleCommentRepository articleCommentRepository;
@Transactional(readOnly = true)
public List<ArticleCommentDto> searchArticleComments(Long articleId) {
return articleCommentRepository.findByArticle_Id(articleId)
.stream()
.map(ArticleCommentDto::from).collect(toList());
}
public void saveArticleComment(ArticleCommentDto dto) {
try {
articleCommentRepository.save(dto.toEntity(articleRepository.getReferenceById(dto.getArticleId())));
} catch (EntityNotFoundException e) {
log.warn("댓글 저장 실패. 댓글의 게시글을 찾을 수 없습니다 - dto: {}", dto);
}
}
public void updateArticleComment(ArticleCommentDto dto) {
try {
ArticleComment articleComment = articleCommentRepository.getReferenceById(dto.getId());
if (dto.getContent() != null) { articleComment.setContent(dto.getContent()); }
} catch (EntityNotFoundException e) {
log.warn("댓글 업데이트 실패. 댓글을 찾을 수 없습니다 - dto: {}", dto);
}
}
public void deleteArticleComment(Long articleCommentId) {
articleCommentRepository.deleteById(articleCommentId);
}
}
detail.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="Uno Kim">
<title>게시글 페이지</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
<link href="/css/articles/article-content.css" rel="stylesheet">
</head>
<body>
<header id="header">
헤더 삽입부
<hr>
</header>
<main id="article-main" class="container">
<header id="article-header" class="py-5 text-center">
<h1>첫번째 글</h1>
</header>
<div class="row g-5">
<section class="col-md-5 col-lg-4 order-md-last">
<aside>
<p><span id="nickname">jisu</span></p>
<p><a id="email" href="mailto:djkehh@gmail.com">jisu3316@naver.com</a></p>
<p><time id="created-at" datetime="2022-01-01T00:00:00">2022-01-01</time></p>
<p><span id="hashtag">#java</span></p>
</aside>
</section>
<article id="article-content" class="col-md-7 col-lg-8">
<pre>본문</pre>
</article>
</div>
<div class="row g-5">
<section>
<form class="row g-3">
<div class="col-8">
<label for="comment-textbox" hidden>댓글</label>
<textarea class="form-control" id="comment-textbox" placeholder="댓글 쓰기.." rows="3"></textarea>
</div>
<div class="col-auto">
<label for="comment-submit" hidden>댓글 쓰기</label>
<button class="btn btn-primary" id="comment-submit" type="submit">쓰기</button>
</div>
</form>
<ul id="article-comments" class="row col-7">
<li>
<div>
<strong>jisu</strong>
<small><time>2022-01-01</time></small>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
Lorem ipsum dolor sit amet
</p>
</div>
</li>
<li>
<div>
<strong>jisu</strong>
<small><time>2022-01-01</time></small>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
Lorem ipsum dolor sit amet
</p>
</div>
</li>
</ul>
</section>
</div>
<div class="row g-5">
<nav id="pagination" aria-label="Page navigation">
<ul class="pagination">
<li class="page-item">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">« prev</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">next »</span>
</a>
</li>
</ul>
</nav>
</div>
</main>
<footer id="footer">
<hr>
푸터 삽입부
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>
</html>
detail.th.xml
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header"></attr>
<attr sel="#footer" th:replace="footer :: footer"></attr>
<attr sel="#article-main" th:object="${article}">
<attr sel="#article-header/h1" th:text="*{title}" />
<attr sel="#nickname" th:text="*{nickname}" />
<attr sel="#email" th:text="*{email}" />
<attr sel="#created-at" th:datetime="*{createdAt}" th:text="*{#temporals.format(createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
<attr sel="#hashtag" th:text="*{hashtag}" />
<attr sel="#article-content/pre" th:text="*{content}" />
<attr sel="#article-comments" th:remove="all-but-first">
<attr sel="li[0]" th:each="articleComment : ${articleComments}">
<attr sel="div/strong" th:text="${articleComment.nickname}" />
<attr sel="div/small/time" th:datetime="${articleComment.createdAt}" th:text="${#temporals.format(articleComment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
<attr sel="div/p" th:text="${articleComment.content}" />
</attr>
</attr>
<attr sel="#pagination">
<attr sel="ul">
<attr sel="li[0]/a"
th:href="*{id} - 1 <= 0 ? '#' : |/articles/*{id - 1}|"
th:class="'page-link' + (*{id} - 1 <= 0 ? ' disabled' : '')"/>
<attr sel="li[1]/a"
th:href="*{id} + 1 > ${totalCount} ? '#' : |/articles/*{id + 1}|"
th:class="'page-link' + (*{id} + 1 > ${totalCount} ? ' disabled' : '')"/>
</attr>
</attr>
</attr>
</thlogic>
detail.th.xml 에서 위의는 게시글과 댓글의 대한 내용이고 밑에는 이전 게시글 다음 게시글에 대한 설정만 잡아놓은 상태이다. 이제 다음글인 페이지네이션에서 구현을 하면된다.
게시판이라 비즈니스로직이라고 해봤자 별게 없다....
'Project > project-board' 카테고리의 다른 글
[Project] 게시판 검색기능 (0) | 2022.07.15 |
---|---|
[Project] 게시글 페이징 처리 구현, 정렬 및 테스트 (0) | 2022.07.14 |
[Project] DTO 설계 (0) | 2022.07.14 |
[Project] QueryDSL 적용 (0) | 2022.07.11 |
[Project] API 설계 (0) | 2022.07.11 |
댓글