공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
이번 시간에는 댓글 관리 페이지를 구현한다. 이전에 구현했던 게시글 관리 페이지와 비슷한 흐름으로, 컨트롤러, 서비스, 테스트 코드, 뷰 순서로 진행한다.
1. 컨트롤러 구현
이 컨트롤러는 댓글 관리 페이지를 위한 기능을 담당한다.
package org.example.projectboardadmin.controller;
import lombok.RequiredArgsConstructor;
import org.example.projectboardadmin.dto.response.ArticleCommentResponse;
import org.example.projectboardadmin.service.ArticleCommentManagementService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RequestMapping("/management/article-comments")
@Controller
public class ArticleCommentManagementController {
private final ArticleCommentManagementService articleCommentManagementService;
@GetMapping
public String articleComments(Model model){
model.addAttribute(
"comments",
articleCommentManagementService.getArticleComments().stream().map(ArticleCommentResponse::of).toList()
);
return "management/article-comments";
}
@ResponseBody
@GetMapping("/{articleCommentId}")
public ArticleCommentResponse articleComment(@PathVariable Long articleCommentId) {
return ArticleCommentResponse.of(articleCommentManagementService.getArticleComment(articleCommentId));
}
@PostMapping("/{articleCommentId}")
public String deleteArticleComment(@PathVariable Long articleCommentId) {
articleCommentManagementService.deleteArticleComment(articleCommentId);
return "redirect:/management/article-comments";
}
}
클래스 레벨 설정
- @RequestMapping("/management/article-comments")를 통해 이 컨트롤러의 기본 경로를 설정했다. 따라서 하위 모든 요청은 /management/article-comments 경로에 맵핑된다.
- @RequiredArgsConstructor를 사용하여 final 필드에 대한 생성자를 자동으로 만들어, 의존성 주입을 안전하게 처리한다.
articleComments 메서드 (GET)
- 역할 : 댓글 관리 페이지를 렌더링 하는 역할을 한다.
- 로직 : getArticleComments()로 댓글 목록을 가져온 후, ArticleCommentResponse로 변환해 모델에 추가한다. 마지막으로 뷰 이름인 "management/article-comments"를 반환해 해당 페이지를 렌더링 한다.
articleComment 메서드 (GET)
- 역할 : 특정 댓글을 JSON 형태로 반환한다.
- 특징 : @ResponseBody를 통해 뷰 대신 JSON 응답을 직접 반환하며, @PathVariable로 URL에서 댓글 ID를 받아온다.
deleteArticleComment 메서드 (POST)
- 역할: 댓글을 삭제하는 기능을 한다.
- 로직: 댓글 삭제 후, "redirect:/management/article-comments"를 반환해 댓글 목록 페이지로 리다이렉트 한다.
2. 서비스 구현
이 서비스는 댓글 데이터를 외부 API에서 가져오고, 삭제하는 기능을 제공한다.
package org.example.projectboardadmin.service;
import lombok.RequiredArgsConstructor;
import org.example.projectboardadmin.dto.ArticleCommentDto;
import org.example.projectboardadmin.dto.properties.ProjectProperties;
import org.example.projectboardadmin.dto.response.ArticleCommentClientResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
@RequiredArgsConstructor
@Service
public class ArticleCommentManagementService {
private final RestTemplate restTemplate;
private final ProjectProperties projectProperties;
public List<ArticleCommentDto> getArticleComments() {
URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articleComments")
.queryParam("size", 10000) // TODO: 전체 댓글을 가져오기 위해 충분히 큰 사이즈를 전달하는 방식. 불완전하다.
.build()
.toUri();
ArticleCommentClientResponse response = restTemplate.getForObject(uri, ArticleCommentClientResponse.class);
return Optional.ofNullable(response).orElseGet(ArticleCommentClientResponse::empty).articleComments();
}
public ArticleCommentDto getArticleComment(Long articleCommentId) {
URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articleComments/" + articleCommentId)
.build()
.toUri();
ArticleCommentDto response = restTemplate.getForObject(uri, ArticleCommentDto.class);
return Optional.ofNullable(response)
.orElseThrow(() -> new NoSuchElementException("댓글이 없습니다 - articleCommentId: " + articleCommentId));
}
public void deleteArticleComment(Long articleCommentId) {
URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articleComments/" + articleCommentId)
.build()
.toUri();
restTemplate.delete(uri);
}
}
클래스 레벨 설정
- @RequiredArgsConstructor로 생성자를 자동으로 생성해 의존성을 주입하고 있다. final로 선언된 RestTemplate과 ProjectProperties가 생성자 주입된다.
- @Service 애노테이션으로 스프링에서 이 클래스를 서비스로 인식하게 한다.
getArticleComments 메서드
- 역할: 외부 API에서 댓글 목록을 가져온다.
- 로직: RestTemplate을 사용해 API 요청을 보낸다. queryParam("size", 10000)를 통해 전체 댓글을 가져오도록 설정했지만, 이 방식은 한계가 있으므로 TODO로 표시해 개선이 필요함을 남겼다.
- 리턴: 응답이 없을 경우 ArticleCommentClientResponse::empty를 통해 빈 리스트를 반환하여, NPE를 방지한다.
getArticleComment 메서드
- 역할: 특정 댓글을 가져온다.
- 로직: 댓글 ID를 이용해 API 요청을 보내고, 해당 댓글이 없을 경우 NoSuchElementException을 발생시킨다.
- 리턴: Optional로 감싸 오류 처리를 더 명확하게 했다.
deleteArticleComment 메서드
- 역할: 특정 댓글을 삭제한다.
- 로직: 댓글 ID를 이용해 DELETE 요청을 보낸다. 단순하지만 중요한 기능을 담당한다.
3. ArticleCommentResponse DTO 작성
댓글 데이터를 응답하기 위한 ArticleCommentResponse DTO를 작성한다. 이 DTO는 ArticleCommentDto에서 필요한 정보를 추출하여 클라이언트에 전달하는 역할을 한다.
package org.example.projectboardadmin.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.example.projectboardadmin.dto.ArticleCommentDto;
import org.example.projectboardadmin.dto.UserAccountDto;
import java.time.LocalDateTime;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ArticleCommentResponse(
Long id,
UserAccountDto userAccount,
String content,
LocalDateTime createdAt
) {
public static ArticleCommentResponse of(Long id, UserAccountDto userAccount, String content, LocalDateTime createdAt) {
return new ArticleCommentResponse(id, userAccount, content, createdAt);
}
public static ArticleCommentResponse of(ArticleCommentDto dto) {
return ArticleCommentResponse.of(dto.id(), dto.userAccount(), dto.content(), dto.createdAt());
}
}
- record 키워드를 사용해 클래스를 간결하게 정의했다. Java 14 이상부터 도입된 record는 불변 객체를 쉽게 정의할 수 있으며, DTO의 특성상 적합하다.
- ArticleCommentDto는 댓글 ID, 관련 게시물 ID, 작성자 정보(UserAccountDto), 댓글 내용, 생성 및 수정 시간과 작성자를 포함한다.
- of 정적 팩토리 메서드를 통해 DTO를 생성할 수 있게 했다. 이 방식은 생성자 호출보다 가독성이 좋고, 유연하게 추가 로직을 넣을 수 있어 DTO 생성 시 권장된다.
- 해당 DTO는 댓글 관리에서 필요한 모든 필드를 포함하고 있어, 다양한 상황에서 사용하기 좋다. 데이터 전달 및 뷰 모델 역할을 깔끔하게 수행한다.
4. 테스트 코드 작성
이번 테스트에서는 댓글 관리 기능을 검증하기 위해 WebMvcTest와 MockMvc를 사용했다. 기본적으로 Spring Security를 사용하는 환경이기 때문에 인증된 사용자로 요청을 보내기 위해 @WithMockUser 애노테이션을 추가했다.
package org.example.projectboardadmin.controller;
import org.example.projectboardadmin.config.TestSecurityConfig;
import org.example.projectboardadmin.dto.ArticleCommentDto;
import org.example.projectboardadmin.dto.UserAccountDto;
import org.example.projectboardadmin.service.ArticleCommentManagementService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.BDDMockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("컨트롤러 - 댓글 관리")
@Import(TestSecurityConfig.class)
@WebMvcTest(ArticleCommentManagementController.class)
class ArticleCommentManagementControllerTest {
private final MockMvc mvc;
@MockBean private ArticleCommentManagementService articleCommentManagementService;
public ArticleCommentManagementControllerTest(@Autowired MockMvc mvc) {
this.mvc = mvc;
}
@WithMockUser(username = "tester", roles = "USER")
@DisplayName("[view][GET] 댓글 관리 페이지 - 정상 호출")
@Test
void givenNothing_whenRequestingArticleCommentManagementView_thenReturnsArticleCommentManagementView() throws Exception {
// Given
given(articleCommentManagementService.getArticleComments()).willReturn(List.of());
// When & Then
mvc.perform(get("/management/article-comments"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("management/article-comments"))
.andExpect(model().attribute("comments", List.of()));
then(articleCommentManagementService).should().getArticleComments();
}
@WithMockUser(username = "tester", roles = "USER")
@DisplayName("[data][GET] 댓글 1개 - 정상 호출")
@Test
void givenCommentId_whenRequestingArticleComment_thenReturnsArticleComment() throws Exception {
// Given
Long articleCommentId = 1L;
ArticleCommentDto articleCommentDto = createArticleCommentDto("comment");
given(articleCommentManagementService.getArticleComment(articleCommentId)).willReturn(articleCommentDto);
// When & Then
mvc.perform(get("/management/article-comments/" + articleCommentId))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(articleCommentId))
.andExpect(jsonPath("$.content").value(articleCommentDto.content()))
.andExpect(jsonPath("$.userAccount.nickname").value(articleCommentDto.userAccount().nickname()));
then(articleCommentManagementService).should().getArticleComment(articleCommentId);
}
@WithMockUser(username = "tester", roles = "MANAGER")
@DisplayName("[view][POST] 댓글 삭제 - 정상 호출")
@Test
void givenCommentId_whenRequestingDeletion_thenRedirectsToArticleCommentManagementView() throws Exception {
// Given
Long articleCommentId = 1L;
willDoNothing().given(articleCommentManagementService).deleteArticleComment(articleCommentId);
// When & Then
mvc.perform(
post("/management/article-comments/" + articleCommentId)
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/management/article-comments"))
.andExpect(redirectedUrl("/management/article-comments"));
then(articleCommentManagementService).should().deleteArticleComment(articleCommentId);
}
private ArticleCommentDto createArticleCommentDto(String content) {
return ArticleCommentDto.of(
1L,
1L,
createUserAccountDto(),
null,
content,
LocalDateTime.now(),
"Eunchan",
LocalDateTime.now(),
"Eunchan"
);
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
"ecTest",
"eunchan-test@email.com",
"eunchan-test",
"test memo"
);
}
}
- @WithMockUser는 테스트 시 가짜 사용자로 인증을 처리해 주는 역할을 한다. 이 애노테이션을 통해 스프링 시큐리티 설정을 우회하고, 실제 로그인 없이도 역할과 권한을 테스트할 수 있다.
5. 뷰 구현
댓글 관리 페이지는 HTML과 Thymeleaf XML을 분리하여 디커플드 방식으로 구현되었다. 이 방식은 뷰와 로직을 분리하여 유지보수성을 높이고, 재사용성을 극대화한다.
article-comments.html
HTML 파일은 기본적인 레이아웃과 CSS, JavaScript 라이브러리를 포함한다. 페이지의 주요 구조는 이 파일에 정의되어 있지만, 실제 데이터와 UI 로직은 XML 파일에서 주입된다.
...
<tr>
<td>1</td>
<td><a data-toggle="modal" data-target="#layout-modal">테스트 댓글입니다.</a></td> <td>Eunchan</td>
<td><time datetime="2024-01-01T00:00:00">2024-01-01 00:00:00</time></td>
</tr>
<tr>
<td>2</td>
<td><a data-toggle="modal" data-target="#layout-modal">퍼가요~~</a></td> <td>Eunchan</td>
<td><time datetime="2024-01-02T00:00:00">2024-01-02 00:00:00</time></td>
</tr>
<tr>
<td>3</td>
<td><a data-toggle="modal" data-target="#layout-modal">악성 댓글 XXX</a></td> <td>Eunchan</td>
<td><time datetime="2024-01-03T00:00:00">2024-01-03 00:00:00</time></td>
</tr>
...
<!-- /.content -->
...
<!-- /.modal -->
<div class="modal fade" id="layout-modal"></div>
</div>
<!--/* REQUIRED SCRIPTS */-->
<script id="layout-scripts">/* 공통 스크립트 삽입부 */</script>
<!--/* 페이지 전용 스크립트 */-->
...
...
<script>
$(document).ready(() => {
$('#layout-modal').on('show.bs.modal', (event) => {
const id = $(event.relatedTarget).data('id');
fetch(`/management/article-comments/${id}`)
.then(response => response.json())
.then(data => {
$('.modal-title').text('댓글 내용');
$('.modal-body pre').text(data.content);
$('.modal-footer form').attr('action', `/management/article-comments/${id}`);
})
.catch(error => {
console.error('댓글 로딩 실패: ', error);
});
});
});
</script>
</body>
</html>
article-comments.th.xml
XML 파일은 데이터 바인딩과 UI 로직을 처리하며, Thymeleaf 템플릿 엔진을 통해 동적으로 HTML에 삽입된다.
<?xml version="1.0"?>
<thlogic>
...
<attr sel="#main-table">
<attr sel="thead/tr">
<attr sel="th[0]" th:text="'ID'" />
<attr sel="th[1]" th:text="'댓글 내용'" />
<attr sel="th[2]" th:text="'작성자'" />
<attr sel="th[3]" th:text="'작성일시'" />
</attr>
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="comment : ${comments}">
<attr sel="td[0]" th:text="${comment.id}" />
<attr sel="td[1]/a" th:text="${comment.content}" th:href="@{#}" th:data-id="${comment.id}" />
<attr sel="td[2]" th:text="${comment.userAccount.nickname}" />
<attr sel="td[3]/time" th:datetime="${comment.createdAt}" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
</attr>
</attr>
</attr>
</thlogic>
이 XML 파일은 댓글 목록을 테이블 형식으로 렌더링 하며, 데이터 바인딩 및 UI 로직을 담당한다.
- th:each="comment : ${comments}": 댓글 목록을 반복 처리하여 각 댓글 데이터를 테이블 행에 바인딩한다.
- th:datetime과 #temporals.format: 날짜 데이터를 특정 형식으로 변환하여 출력한다.
이 파일은 댓글 관리 페이지뿐만 아니라 다양한 관리 페이지에서 재사용할 수 있는 모달 레이아웃을 제공하며, 긴 댓글의 내용을 보기 쉽게 처리한다. 주요 기능은 댓글이 길어질 때 자동으로 줄 바꿈을 적용하여 내용이 잘리는 것을 방지하는 것이다.
<?xml version="1.0"?>
<thlogic>
<attr sel=".modal-body/pre" th:style="'white-space: pre-line'" />
<!--/* 실제 url 주입은 javascript에 의해 동적으로 이루어져야 한다. th:action은 해당 form에 csrf 토큰을 자동으로 세팅하는데 사용됨. */-->
<attr sel=".modal-footer/form" th:action="@{#}" th:method="post" />
</thlogic>
- 이 설정은 긴 텍스트도 모달 창에서 가독성 있게 표시되도록 한다.
5. 실행 화면
1) 댓글 목록 테이블
페이지에 진입하면 모든 댓글이 테이블 형식으로 나열된다. 테이블은 ID, 댓글 내용, 작성자, 작성일시로 구성되며, 이 모든 데이터는 외부 API로부터 불러온다. 테이블 상단에는 검색, 정렬, 복사 등 다양한 기능이 포함되어 있어, 많은 댓글을 관리할 때도 효율적이다.
2) 모달 창
댓글 내용을 클릭하면 모달 창이 열리며, 해당 댓글의 자세한 내용을 확인할 수 있다. 길이가 긴 댓글도 자동으로 줄 바꿈이 적용되어 가독성이 좋다.
3) 성능
한 번에 최대 300개의 댓글을 불러와 브라우저에 보관하기 때문에 페이지 이동이나 정렬과 같은 액션은 매우 빠르게 처리된다. 대량의 데이터를 다룰 때도 페이지 반응이 빠른 것이 큰 장점이다.
실제 실행 화면은 다음과 같다.
이번에 구현한 댓글 관리 페이지는 다양한 기능을 효율적으로 처리할 수 있도록 설계되었다. 디커플드 구조를 통해 뷰와 로직을 분리함으로써 유지보수성과 확장성을 높였고, Spring Security를 사용한 접근 제어 및 테스트 코드 작성도 효율적으로 처리하였다.
앞으로 추가적인 기능이 필요할 때도, 이 구조를 활용해 쉽게 확장 가능하다. 댓글 관리 페이지 구현을 통해 전체 시스템의 관리 기능을 안정적이고 효율적으로 구축할 수 있었다.
'BackEnd > Project' 카테고리의 다른 글
[Admin] Ch03. 로그인 페이지 구현 (0) | 2024.08.24 |
---|---|
[Admin] Ch03. 회원 관리 페이지 구현 (0) | 2024.08.24 |
[Admin] Ch03. 게시글 관리 페이지 구현(3) (0) | 2024.08.23 |
[Admin] Ch03. 게시글 관리 페이지 구현(2) (0) | 2024.08.21 |
[Admin] Ch03. 게시글 관리 페이지 구현(1) (0) | 2024.08.21 |