들어가기에 앞서
실제 프로젝트 진행했던 코드 내용 및 세부 내용은 일부만 업로드하였습니다
@Table(indexes = {
@Index(columnList = "userId", unique = true),
@Index(columnList = "email", unique = true),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@DisplayName("insert 테스트")
@Test
void givenTestData_whenInserting_thenWorksFine() {
// Given
long previousCount = articleRepository.count(); // 현재 repository의 aritcle 개수 카운트
// 추가됨
UserAccount userAccount = userAccountRepository.save(UserAccount.of("newmrcocoball", "pw", null, null, null)); // unique 속성에 따라 중복되지 않은 새 아이디 부여
Article article = Article.of(userAccount, "new article", "new content", "#spring");
// When
// Article savedArticle = articleRepository.save(Article.of("new article", "new content", "#spring"));
articleRepository.save(article);
// repository에 새 article 추가
// Then
assertThat(articleRepository.count()).isEqualTo(previousCount + 1);
}
@DisplayName("[VIEW][GET] 게시글 리스트 (게시판 페이지 - 검색어와 함께 호출")
@Test
public void givenKeyword_whenRequestingSearchingArticlesView_thenReturnsArticlesView() throws Exception {
// Given
SearchType searchType = SearchType.TITLE;
String searchValue = "title";
given(articleService.searchArticles(eq(searchType), eq(searchValue), any(Pageable.class))).willReturn(Page.empty());
given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(0,1,2,3,4));
// When & Then
mvc.perform(
get("/articles")
.queryParam("searchType", "searchType", searchType.name())
.queryParam("searchValue", searchValue)
)
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
.andExpect(view().name("articles/index")) // 뷰 이름 검사
.andExpect(model().attributeExists("articles")) // 내부에 값이 있는지 (이름을 articles로 지정)
.andExpect(model().attributeExists("searchTypes")); // 내부에 값이 있는지 (이름을 searchTypes로 지정)
then(articleService).should().searchArticles(eq(searchType), eq(searchValue), any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
@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 map
) {
Page<ArticleResponse> articles = articleService.searchArticles(searchType, searchValue, pageable)
.map(ArticleResponse::from); // dto를 response로 변환하여 페이지로 만듬
List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
// 현재 페이지 번호와 해당 페이지들의 전체 숫자(getTotalPages)를 매개값으로 페이징 내비게이션 바 구축
map.addAttribute("articles", articles);
map.addAttribute("paginationBarNumbers", barNumbers);
map.addAttribute("searchTypes", SearchType.values()); // 추가
return "articles/index";
}
<div class="row">
<div class="card card-margin search-form">
<div class="card-body p-0">
<form action="/articles" method="get"> <!-- 수정 -->
<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" name="searchType"> // 수정
<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="searchValue"> // search-value > 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>
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header" />
<attr sel="#footer" th:replace="footer :: footer" />
<attr sel="main" th:object="${articles}">
<attr sel="#search-type" th:remove="all-but-first">
<attr sel="option[0]"
th:each="searchType : ${searchTypes}"
th:value="${searchType.name}"
th:text="${searchType.description}"
th:selected="${param.searchType != null && (param.searchType.toString == searchType.name)}"
/>
</attr>
<attr sel="#search-value" th:value="${param.searchValue}" />
<attr sel="#article-table">
<attr sel="thead/tr">
<attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(
page=${articles.number},
sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
page=${articles.number},
sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
page=${articles.number},
sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
page=${articles.number},
sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
</attr>
<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>
<attr sel="#pagination">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles(page=${articles.number - 1}, searchType=${param.searchType}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
/>
<attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
<attr sel="a"
th:text="${pageNumber + 1}"
th:href="@{/articles(page=${pageNumber}, searchType=${param.searchType}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/>
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles(page=${articles.number + 1}, searchType=${param.searchType}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/>
</attr>
</attr>
</thlogic>
th:selected="${param.searchType != null && (param.searchType.toString == searchType.name)}
TITLE("제목"),
CONTENT("본문"),
ID("유저 ID"),
NICKNAME("닉네임"),
HASHTAG("해시태그");
@Getter private final String description;
SearchType(String description) {
this.description = description;
}
@DisplayName("READ - 검색어 없이 게시글을 해시태그 검색하면, 빈 페이지를 반환")
@Test
void givenNoSearchParameters_whenSearchingArticlesViaHashtag_thenReturnsEmptyPage() {
// Given
Pageable pageable = Pageable.ofSize(20);
// When
Page<ArticleDto> articles = sut.searchArticlesViaHashtag(null, pageable);
// Then
// assertThat(articles).isEmpty(); 아래와 동일
assertThat(articles).isEqualTo(Page.empty(pageable));
then(articleRepository).shouldHaveNoInteractions();
}
@DisplayName("READ - 게시글을 해시태그 검색하면, 게시글 페이지를 반환")
@Test
void givenHashtag_whenSearchingArticlesViaHashtag_thenReturnsArticlePage() {
// Given
String hashtag = "#java";
Pageable pageable = Pageable.ofSize(20);
given(articleRepository.findByHashtag(hashtag, pageable)).willReturn(Page.empty(pageable));
// When
Page<ArticleDto> articles = sut.searchArticlesViaHashtag(hashtag, pageable);
// Then
// assertThat(articles).isEmpty(); 아래와 동일
assertThat(articles).isEqualTo(Page.empty(pageable));
then(articleRepository).should().findByHashtag(hashtag, pageable);
}
@DisplayName("READ - 해시태그를 조회하면, 고유 해시태그 리스트를 반환한다")
@Test
void givenNothing_whenCalling_thenReturnsHashtags() {
// Given
List<String> expectedHashtags = List.of("#java", "#spring", "python");
given(articleRepository.findAllDistinctHashtags()).willReturn(expectedHashtags);
// When
List<String> actualHashtags = sut.getHashtags();
// Then
assertThat(actualHashtags).isEqualTo(expectedHashtags);
then(articleRepository).should().findAllDistinctHashtags();
}
public interface ArticleRepositoryCustom {
List<String> findAllDistinctHashtags();
}
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.QArticle;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import java.util.List;
public class ArticleRepositoryCustomImpl extends QuerydslRepositorySupport implements ArticleRepositoryCustom {
public ArticleRepositoryCustomImpl() {
super(Article.class);
}
@Override
public List<String> findAllDistinctHashtags() {
QArticle article = QArticle.article;
return from(article)
.distinct()
.select(article.hashtag)
.where(article.hashtag.isNotNull())
.fetch();
// JPQLQuery<Article> 에서 hashtag가 null이 아닌 것들 중에서 고유의 hashtag를 가져옴
}
}
public interface ArticleRepository extends
JpaRepository<Article, Long>,
ArticleRepositoryCustom, // 추가
QuerydslPredicateExecutor<Article>, // Article 안의 모든 필드에 대한 기본 검색 기능 추가
QuerydslBinderCustomizer<QArticle> {
public List<String> getHashtags() {
return articleRepository.findAllDistinctHashtags();
}
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticlesViaHashtag(String hashtag, Pageable pageable) {
if (hashtag == null || hashtag.isBlank()) {
return Page.empty(pageable);
}
return articleRepository.findByHashtag(hashtag, pageable).map(ArticleDto::from);
}
@GetMapping("/search-hashtag")
public String searchHashtag(
@RequestParam(required = false) String searchValue,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
ModelMap map
) {
return "articles/search-hashtag";
}
@DisplayName("[VIEW][GET] 게시글 해시태그 검색 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticleSearchingHashtagView_thenReturnsArticleSearchHashtagSearchView() throws Exception {
// Given
List<String> hashtags = List.of("#Java", "#Spring", "#Python");
given(articleService.searchArticlesViaHashtag(eq(null), any(Pageable.class))).willReturn(Page.empty());
given(articleService.getHashtags()).willReturn(hashtags);
given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(1, 2, 3, 4, 5));
// When & Then
mvc.perform(get("/articles/search-hashtag"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
.andExpect(view().name("articles/search-hashtag")) // 뷰 이름 검사
.andExpect(model().attribute("articles", Page.empty()))
.andExpect(model().attribute("hashtags", hashtags))
.andExpect(model().attributeExists("paginationBarNumbers"))
.andExpect(model().attribute("searchType", SearchType.HASHTAG));
then(articleService).should().searchArticlesViaHashtag(eq(null), any(Pageable.class));
then(articleService).should().getHashtags();
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
@DisplayName("[VIEW][GET] 게시글 해시태그 검색 페이지 - 정상 호출, 해시태그 입력")
@Test
public void givenHashtag_whenRequestingArticleSearchingHashtagView_thenReturnsArticleSearchHashtagSearchView() throws Exception {
// Given
String hashtag = "#java";
List<String> hashtags = List.of("#Java", "#Spring", "#Python");
given(articleService.searchArticlesViaHashtag(eq(hashtag), any(Pageable.class))).willReturn(Page.empty());
given(articleService.getHashtags()).willReturn(hashtags);
given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(1, 2, 3, 4, 5));
// When & Then
mvc.perform(get("/articles/search-hashtag")
.queryParam("searchValue", hashtag)
)
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
.andExpect(view().name("articles/search-hashtag")) // 뷰 이름 검사
.andExpect(model().attribute("articles", Page.empty()))
.andExpect(model().attribute("hashtags", hashtags))
.andExpect(model().attributeExists("paginationBarNumbers"))
.andExpect(model().attribute("searchType", SearchType.HASHTAG));
then(articleService).should().searchArticlesViaHashtag(eq(hashtag), any(Pageable.class));
then(articleService).should().getHashtags();
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
@GetMapping("/search-hashtag")
public String searchHashtag(
@RequestParam(required = false) String searchValue,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
ModelMap map
) {
Page<ArticleResponse> articles = articleService.searchArticlesViaHashtag(searchValue, pageable)
.map(ArticleResponse::from); // dto를 response로 변환하여 페이지로 만듬
List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
// 현재 페이지 번호와 해당 페이지들의 전체 숫자(getTotalPages)를 매개값으로 페이징 내비게이션 바 구축
List<String> hashtags = articleService.getHashtags();
map.addAttribute("articles", articles);
map.addAttribute("hashtags", hashtags);
map.addAttribute("paginationBarNumbers", barNumbers);
map.addAttribute("searchType", SearchType.HASHTAG);;
return "articles/search-hashtag";
}
<!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/table-header.css" rel="stylesheet">
</head>
<body>
<header id="header">
헤더 삽입부
<hr>
</header>
<main class="container">
<header class="py-5 text-center">
<h1>Hashtags</h1>
</header>
<section class="row">
<div id="hashtags" class="col-9 d-flex flex-wrap justify-content-evenly">
<div class="p-2">
<h2 class="text-center lh-lg font-monospace"><a href="#">#java</a></h2>
</div>
</div>
</section>
<hr>
<table class="table" id="article-table">
<thead>
<tr>
<th class="title col-6"><a>제목</a></th>
<th class="content col-4"><a>본문</a></th>
<th class="user-id"><a>작성자</a></th>
<th class="created-at"><a>작성일</a></th>
</tr>
</thead>
<tbody>
<tr>
<td class="title"><a>첫글</a></td>
<td class="content"><span class="d-inline-block text-truncate" style="max-width: 300px;">본문</span></td>
<td class="user-id">Uno</td>
<td class="created-at"><time>2022-01-01</time></td>
</tr>
<tr>
<td>두번째글</td>
<td>본문</td>
<td>Uno</td>
<td><time>2022-01-02</time></td>
</tr>
<tr>
<td>세번째글</td>
<td>본문</td>
<td>Uno</td>
<td><time>2022-01-03</time></td>
</tr>
</tbody>
</table>
<nav id="pagination" aria-label="Page navigation">
<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="#">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>
</html>
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header" />
<attr sel="#footer" th:replace="footer :: footer" />
<attr sel="main" th:object="${articles}">
<attr sel="#hashtags" th:remove="all-but-first">
<attr sel="div" th:each="hashtag : ${hashtags}">
<attr sel="a" th:class="'text-reset'" th:text="${hashtag}" th:href="@{/articles/search-hashtag(
page=${param.page},
sort=${param.sort},
searchType=${searchType.name},
searchValue=${hashtag}
)}" />
</attr>
</attr>
<attr sel="#article-table">
<attr sel="thead/tr">
<attr sel="th.title/a" th:text="'제목'" th:href="@{/articles/search-hashtag(
page=${articles.number},
sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${searchType.name},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.content/a" th:text="'본문'" th:href="@{/articles/search-hashtag(
page=${articles.number},
sort='content' + (*{sort.getOrderFor('content')} != null ? (*{sort.getOrderFor('content').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${searchType.name},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles/search-hashtag(
page=${articles.number},
sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${searchType.name},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles/search-hashtag(
page=${articles.number},
sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${searchType.name},
searchValue=${param.searchValue}
)}"/>
</attr>
<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.content/span" th:text="${article.content}" />
<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>
<attr sel="#pagination">
<attr sel="ul">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles(page=${articles.number - 1}, searchType=${searchType.name}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
/>
<attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
<attr sel="a"
th:text="${pageNumber + 1}"
th:href="@{/articles(page=${pageNumber}, searchType=${searchType.name}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/>
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles(page=${articles.number + 1}, searchType=${searchType.name}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/>
</attr>
</attr>
</attr>
</thlogic>