공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
이번 포스팅에서는 게시판의 댓글 기능을 구현한 후, 전체 테스트 동작을 확인하는 과정을 다루고자 한다.
댓글 기능은 게시글에 대한 사용자들의 의견을 남길 수 있게 해주는 중요한 기능이며, 댓글의 CRUD(Create, Read, Update, Delete) 작업을 포함한다.
본격적으로 게시판 검색 기능(제목, 본문, 이름 검색)을 구현하기 전에, 댓글 기능이 제대로 동작하는지 확인하고 넘어가겠다.
댓글 기능 구현
package org.example.projectboard.service;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.projectboard.domain.ArticleComment;
import org.example.projectboard.dto.ArticleCommentDto;
import org.example.projectboard.repository.ArticleCommentRepository;
import org.example.projectboard.repository.ArticleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@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.findByArticleId(articleId)
.stream()
.map(ArticleCommentDto::from)
.toList();
}
public void saveArticleComment(ArticleCommentDto dto) {
try{
articleCommentRepository.save(dto.toEntity(articleRepository.getReferenceById(dto.articleId())));
}catch (EntityNotFoundException e){
log.warn("댓글 저장 실패. 댓글의 게시글을 찾을 수 없습니다. - dto: {}", dto);
}
}
public void updateArticleComment(ArticleCommentDto dto) {
try {
ArticleComment articleComment = articleCommentRepository.getReferenceById(dto.id());
if (dto.content() != null) {
articleComment.setContent(dto.content());
}
} catch (EntityNotFoundException e) {
log.warn("댓글 업데이트 실패. 댓글을 찾을 수 없습니다. - dto: {}", dto);
}
}
public void deleteArticleComment(Long articleCommentId) {
articleCommentRepository.deleteById(articleCommentId);
}
}
- 댓글 조회 (searchArticleComments)
- 특정 articleId를 사용해 해당 게시글에 달린 모든 댓글을 ArticleCommentRepository를 통해 조회한다.
- 조회된 댓글들을 ArticleCommentDto로 변환하여 리스트로 반환한다.
- 댓글 저장 (saveArticleComment)
- 사용자가 입력한 댓글 정보를 받아, 이를 엔티티로 변환한 후 ArticleCommentRepository를 통해 저장한다.
- 저장 전에 댓글이 연결될 게시글을 ArticleRepository에서 조회한다.
- 만약 게시글이 존재하지 않으면 EntityNotFoundException을 발생시키고, 경고 로그를 남기며 저장 작업을 중단한다.
- 댓글 수정 (updateArticleComment)
- 수정할 댓글의 ID를 사용해 ArticleCommentRepository에서 해당 댓글을 조회한다.
- 댓글이 존재하는 경우 새로운 내용으로 댓글을 업데이트한다.
- 만약 댓글이 존재하지 않으면 EntityNotFoundException을 발생시켜 로그를 남기고 작업을 중단한다.
- 댓글 삭제 (deleteArticleComment)
- 댓글 ID를 받아 해당 댓글을 ArticleCommentRepository를 통해 삭제한다.
- 만약 댓글이 존재하지 않으면 EntityNotFoundException을 발생시키고, 경고 로그를 남기며 삭제를 중단한다.
댓글 기능 테스트
위 코드를 기반으로 작성된 댓글 기능을 전체 테스트를 통해 확인했다.
아래는 댓글 기능에 대한 테스트 코드이다
package org.example.projectboard.service;
import jakarta.persistence.EntityNotFoundException;
import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.ArticleComment;
import org.example.projectboard.domain.UserAccount;
import org.example.projectboard.dto.ArticleCommentDto;
import org.example.projectboard.dto.UserAccountDto;
import org.example.projectboard.repository.ArticleCommentRepository;
import org.example.projectboard.repository.ArticleRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.BDDMockito.*;
@DisplayName("비즈니스 로직 - 댓글")
@ExtendWith(MockitoExtension.class)
class ArticleCommentServiceTest {
@InjectMocks private ArticleCommentService sut;
@Mock private ArticleRepository articleRepository;
@Mock private ArticleCommentRepository articleCommentRepository;
@DisplayName("게시글 ID로 조회하면, 해당하는 댓글 리스트를 반환한다.")
@Test
void givenArticleId_whenSearchingArticleComments_thenReturnsArticleComments(){
// given
Long articleId = 1L;
ArticleComment expected = createArticleComment("content");
given(articleCommentRepository.findByArticleId(articleId)).willReturn(List.of(expected));
// when
List<ArticleCommentDto> actual = sut.searchArticleComments(articleId);
// then
assertThat(actual)
.hasSize(1)
.first().hasFieldOrPropertyWithValue("content", expected.getContent());
then(articleCommentRepository).should().findByArticleId(articleId);
}
@DisplayName("댓글 정보를 입력하면, 댓글을 저장한다.")
@Test
void givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment(){
// given
ArticleCommentDto dto = createArticleCommentDto("댓글");
given(articleRepository.getReferenceById(dto.articleId())).willReturn(createArticle());
given(articleCommentRepository.save(any(ArticleComment.class))).willReturn(null);
// when
sut.saveArticleComment(dto);
// then
then(articleRepository).should().getReferenceById(dto.articleId());
then(articleCommentRepository).should().save(any(ArticleComment.class));
}
@DisplayName("댓글 저장을 시도했는데 맞는 게시글이 없으면, 경고 로그를 찍고 아무것도 안 한다.")
@Test
void givenNonexistentArticle_whenSavingArticleComment_thenLogsSituationAndDoesNothing() {
// Given
ArticleCommentDto dto = createArticleCommentDto("댓글");
given(articleRepository.getReferenceById(dto.articleId())).willThrow(EntityNotFoundException.class);
// When
sut.saveArticleComment(dto);
// Then
then(articleRepository).should().getReferenceById(dto.articleId());
then(articleCommentRepository).shouldHaveNoInteractions();
}
@DisplayName("댓글 정보를 입력하면, 댓글을 수정한다.")
@Test
void givenArticleCommentInfo_whenUpdatingArticleComment_thenUpdatesArticleComment() {
// Given
String oldContent = "content";
String updatedContent = "댓글";
ArticleComment articleComment = createArticleComment(oldContent);
ArticleCommentDto dto = createArticleCommentDto(updatedContent);
given(articleCommentRepository.getReferenceById(dto.id())).willReturn(articleComment);
// When
sut.updateArticleComment(dto);
// Then
assertThat(articleComment.getContent())
.isNotEqualTo(oldContent)
.isEqualTo(updatedContent);
then(articleCommentRepository).should().getReferenceById(dto.id());
}
@DisplayName("없는 댓글 정보를 수정하려고 하면, 경고 로그를 찍고 아무 것도 안 한다.")
@Test
void givenNonexistentArticleComment_whenUpdatingArticleComment_thenLogsWarningAndDoesNothing() {
// Given
ArticleCommentDto dto = createArticleCommentDto("댓글");
given(articleCommentRepository.getReferenceById(dto.id())).willThrow(EntityNotFoundException.class);
// When
sut.updateArticleComment(dto);
// Then
then(articleCommentRepository).should().getReferenceById(dto.id());
}
@DisplayName("댓글 ID를 입력하면, 댓글을 삭제한다.")
@Test
void givenArticleCommentId_whenDeletingArticleComment_thenDeletesArticleComment() {
// Given
Long articleCommentId = 1L;
willDoNothing().given(articleCommentRepository).deleteById(articleCommentId);
// When
sut.deleteArticleComment(articleCommentId);
// Then
then(articleCommentRepository).should().deleteById(articleCommentId);
}
// 테스트용 데이터 생성 메서드들...
private ArticleCommentDto createArticleCommentDto(String content) {
return ArticleCommentDto.of(
1L,
1L,
createUserAccountDto(),
content,
LocalDateTime.now(),
"eunchan",
LocalDateTime.now(),
"eunchan"
);
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
1L,
"eunchan",
"password",
"eunchan@mail.com",
"Eunchan",
"This is memo",
LocalDateTime.now(),
"eunchan",
LocalDateTime.now(),
"eunchan"
);
}
private ArticleComment createArticleComment(String content) {
return ArticleComment.of(
Article.of(createUserAccount(), "title", "content", "hashtag"),
createUserAccount(),
content
);
}
private UserAccount createUserAccount() {
return UserAccount.of(
"eunchan",
"password",
"eunchan@email.com",
"Eunchan",
null
);
}
private Article createArticle() {
return Article.of(
createUserAccount(),
"title",
"content",
"#java"
);
}
}
- 댓글 조회 테스트 (givenArticleId_whenSearchingArticleComments_thenReturnsArticleComments)
- 특정 articleId로 댓글을 조회했을 때, 해당 댓글 리스트가 반환되는지 검증한다.
- ArticleCommentRepository가 설정된 댓글 리스트를 반환하도록 하고, 조회된 결과가 예상과 일치하는지 확인한다.
- 댓글 저장 테스트 (givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment)
- ArticleCommentDto를 입력받아 댓글을 저장할 때, 저장이 정상적으로 이루어지는지 검증한다.
- 저장을 위해 필요한 게시글이 정상적으로 조회되는지 확인한 후, 댓글이 ArticleCommentRepository에 저장되는지를 검증한다.
- 게시글 없는 댓글 저장 테스트 (givenNonexistentArticle_whenSavingArticleComment_thenLogsSituationAndDoesNothing)
- 존재하지 않는 게시글에 댓글을 저장하려고 할 때, 경고 로그를 남기고 저장이 중단되는지 검증한다.
- 예외 발생 시 ArticleCommentRepository에 아무런 작업이 일어나지 않음을 확인한다.
- 댓글 수정 테스트 (givenArticleCommentInfo_whenUpdatingArticleComment_thenUpdatesArticleComment)
- 댓글 정보를 입력 받아 댓글을 수정할 때, 수정이 정상적으로 이루어지는지 검증한다.
- 기존 댓글의 내용이 새로운 내용으로 업데이트되는지를 확인한다.
- 존재하지 않는 댓글 수정 테스트 (givenNonexistentArticleComment_whenUpdatingArticleComment_thenLogsWarningAndDoesNothing)
- 존재하지 않는 댓글을 수정하려고 할 때, 경고 로그를 남기고 수정이 중단되는지 검증한다.
- 예외 발생 시 ArticleCommentRepository에 아무런 작업이 일어나지 않음을 확인한다.
- 댓글 삭제 테스트 (givenArticleCommentId_whenDeletingArticleComment_thenDeletesArticleComment)
- 댓글 ID를 입력 받아 댓글을 삭제할 때, 삭제가 정상적으로 이루어지는지 검증한다.
- ArticleCommentRepository의 댓글 삭제 메서드가 호출되었는지를 확인한다.
댓글 기능이 올바르게 동작하는 것을 확인했다. 댓글 CRUD 기능이 안정적으로 구현되었으며, 로그를 통해 문제 상황을 모니터링할 수 있게 했다. 이제 본격적으로 게시판 검색 기능 구현에 들어갈 준비가 되었다.
기본 게시판 페이지의 검색바 기능 구현
게시판 페이지에서 각 항목을 검색할 수 있게 하며, 검색 결과에 따라 게시글을 정렬할 수 있는 기능을 포함한다.
검색창과 정렬 기능이 어떻게 구현되었는지, 그리고 각각의 코드가 어떤 역할을 하는지 설명하겠다.
1. 검색창 Select 박스와 검색어 유지 기능 구현
검색창의 Select 박스에서 제목, 본문, ID, 닉네임, 해시태그 등의 항목을 선택하여 검색할 수 있게 구현했다. 이전에는 사용자가 검색을 수행할 때마다 선택한 옵션이 리셋되어 다시 제목으로 돌아가는 불편함이 있었다. 이를 개선하기 위해 selected 기능을 추가하여, 사용자가 직전에 선택했던 옵션이 그대로 유지되도록 했다.
또한, 검색어를 입력한 후, 타이틀을 눌러 정렬을 해도 검색어가 사라지지 않도록 하기 위해 param.searchValue를 활용했다. 페이지를 이동해도 검색어가 유지되도록 구현했다.
마지막으로, 컨트롤러에서 searchType을 통해 검색 조건을 전달받아 실제 데이터에 기반한 검색 결과를 반환하도록 로직을 구성했다. 이로 인해 이전에 사용했던 목업 데이터 대신, 데이터베이스에서 검색된 실제 결과를 화면에 표시할 수 있게 되었다.
index.th.xml
이 코드에서는 searchTypes를 통해 서버에서 전달된 검색 옵션들을 받아오고, 이전에 선택했던 옵션이 유지되도록 selected 속성을 사용했다. 또한, searchType과 searchValue를 URL에 함께 전달하여, 정렬을 클릭해도 검색어와 검색 옵션이 유지되도록 했다.
<!-- index.th.xml 파일의 검색창 관련 부분 -->
<attr sel="#search-form" th:action="@{/articles}" th:method="get" />
<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="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}
)}"/>
검색창 관련 부분
- 검색 옵션 유지 기능
- th:each를 사용해 서버에서 전달된 searchTypes 리스트를 반복하여 <option> 태그를 생성한다.
- 각 <option>의 값은 searchType.name이 되고, 화면에 표시되는 텍스트는 searchType.description이 된다.
- 이전 검색 옵션 유지
- th:selected를 사용해, 현재 선택된 searchType이 이전 검색과 동일한 경우 해당 옵션을 기본적으로 선택된 상태로 만든다.
- 검색어 유지
- th:value를 사용해 검색어 입력 필드에 이전에 검색한 값을 유지하도록 한다.
검색어 유지 관련 부분
- 정렬 유지
- 제목 컬럼의 <a> 태그는 클릭 시 정렬이 수행되도록 설정된다.
- sort 파라미터를 통해 정렬할 필드를 지정하며, 현재 정렬 방향을 검사하여 오름차순/내림차순을 설정한다.
- 검색어와 검색 옵션 유지
- 사용자가 정렬을 클릭하더라도, 이전에 입력한 searchType과 searchValue가 URL에 포함되어 검색 조건이 유지된다.
2. 컨트롤러 코드 업데이트
컨트롤러에서는 SearchType을 전달하고, 검색 기능을 위한 로직을 추가했다.
또한, 테스트 코드도 함께 업데이트하여 이 기능이 정상적으로 동작하는지 확인했다.
ArticleController.java
@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);
List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
map.addAttribute("articles", articles);
map.addAttribute("paginationBarNumbers", barNumbers);
map.addAttribute("searchTypes", SearchType.values());
return "articles/index";
}
- 검색 기능 구현
- searchType과 searchValue를 파라미터로 받아서 ArticleService의 searchArticles 메서드를 호출한다.
- 이 메서드는 주어진 검색 조건에 따라 게시글을 필터링하여 결과를 반환한다.
- 페이징 처리
- Pageable 객체를 사용해 페이지 번호와 페이지당 게시글 수, 정렬 조건을 설정한다.
- 이를 바탕으로 게시글 리스트를 페이징 처리한다.
- 검색 옵션 전달
- SearchType.values()를 통해 검색 옵션을 뷰에 전달한다.
- 이는 앞서 설명한 검색창의 Select 박스에서 사용된다.
3. 테스트 코드 업데이트
검색 기능과 정렬 기능이 정상적으로 동작하는지 확인하기 위해 테스트 코드를 업데이트했다.
ArticleControllerTest.java
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 검색어와 함께 호출")
@Test
public void givenSearchKeyword_whenSearchingArticlesView_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.name())
.queryParam("searchValue", searchValue)
)
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/index"))
.andExpect(model().attributeExists("articles"))
.andExpect(model().attributeExists("searchTypes"));
then(articleService).should().searchArticles(eq(searchType), ArgumentMatchers.eq(searchValue), any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
- 검색 기능 테스트
- 이 테스트는 SearchType과 searchValue를 기반으로 검색이 제대로 수행되고, 결과가 제대로 반환되는지를 확인한다.
- 정렬과 페이징 기능 테스트
- 검색어와 검색 조건이 유지된 상태에서 정렬을 수행하고, 페이지를 이동할 때 검색 조건이 유지되는지 확인한다.
- 뷰 검사
- 테스트를 통해 뷰에서 articles와 searchTypes 모델이 제대로 설정되어 있는지 확인한다.
그럼 실제로 실행하여 확인해 보자.
검색어를 입력한 후에도 사라지지 않으며, 정렬을 하더라도 검색어가 유지되는 것을 확인할 수 있다.
이번 작업에서는 게시판 페이지에서 검색 기능과 정렬 기능을 통합하여 사용자가 더 편리하게 게시글을 검색하고 정렬할 수 있도록 구현했다. SearchType을 통해 검색 옵션을 서버로부터 받아 사용하고, 검색 결과가 정렬과 페이징에 영향을 미치지 않도록 로직을 추가했다.
이제 사용자들은 원하는 검색 옵션을 선택하고 검색어를 입력한 후, 정렬과 페이징을 하더라도 검색 조건이 유지되는 기능을 사용할 수 있게 되었다.
'BackEnd > Project' 카테고리의 다른 글
[Board] Ch03. 게시글 뷰 기능 구현 (0) | 2024.08.12 |
---|---|
[Board] Ch03. 게시판 검색 구현 - 해시태그 (0) | 2024.08.11 |
[Board] Ch03. 게시판 정렬 구현 (0) | 2024.08.10 |
[Board] Ch03. 게시글 페이징 구현 (0) | 2024.08.10 |
[Board] Ch03. 게시판 페이징 구현 (0) | 2024.08.10 |