공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
이번 포스팅에서는 게시글 기능과 뷰를 연결하기 위해 기존의 코드를 개선하고, 새로운 기능을 추가하며 어떻게 변경되었는지 살펴본다. 이를 통해 게시글 작성, 수정, 삭제 등의 기능이 어떻게 구현되고, 뷰에서 어떻게 활용되는지 설명한다.
1. ArticleController 수정
ArticleController는 게시글의 CRUD 기능을 담당하는 컨트롤러로, 수정 전후 코드에서 다음과 같은 변화가 있었다
- 기존 기능 유지: 기존의 게시글 리스트 조회, 단일 게시글 조회, 해시태그로 게시글 검색 기능은 그대로 유지되었지만, 메서드 명과 로직에서 약간의 수정이 이루어졌다.
- 추가된 기능: 게시글 작성 폼과 수정 폼을 제공하는 기능이 추가되었다. 이로 인해, 사용자는 게시글을 작성하거나 수정할 수 있게 되었다. 이와 함께 FormStatus라는 새로운 enum 클래스가 도입되어, 폼이 게시글 작성 모드인지 수정 모드인지 구분할 수 있게 되었다.
package org.example.projectboard.controller;
import lombok.RequiredArgsConstructor;
import org.example.projectboard.domain.constant.FormStatus;
import org.example.projectboard.domain.constant.SearchType;
import org.example.projectboard.dto.UserAccountDto;
import org.example.projectboard.dto.request.ArticleRequest;
import org.example.projectboard.dto.response.ArticleResponse;
import org.example.projectboard.dto.response.ArticleWithCommentsResponse;
import org.example.projectboard.service.ArticleService;
import org.example.projectboard.service.PaginationService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RequiredArgsConstructor
@RequestMapping("/articles")
@Controller
public class ArticleController {
private final ArticleService articleService;
private final PaginationService paginationService;
@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";
}
@GetMapping("/{articleId}")
public String article(@PathVariable Long articleId, ModelMap map) {
ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticleWithComments(articleId));
map.addAttribute("article", article);
map.addAttribute("articleComments", List.of(article.articleCommentsResponse()));
map.addAttribute("totalCount", articleService.getArticleCount());
return "articles/detail";
}
@GetMapping("/search-hashtag")
public String searchArticleHashtag(
@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);
List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
List<String> hashtags = articleService.getHashtags();
map.addAttribute("articles", articles);
map.addAttribute("hashtags", hashtags);
map.addAttribute("paginationBarNumbers", barNumbers);
map.addAttribute("searchValue", searchValue);
return "articles/search-hashtag";
}
@GetMapping("/form")
public String articleForm(ModelMap map) {
map.addAttribute("formStatus", FormStatus.CREATE);
return "articles/form";
}
@PostMapping("/form")
public String postNewArticle(ArticleRequest articleRequest) {
// TODO : 인증 정보를 넣어줘야 한다.
articleService.saveArticle(articleRequest.toDto(UserAccountDto.of(
"eunchan", "asdf1234", "eunchan@mail.com", "Eunchan", "memo", null, null, null, null
)));
return "redirect:/articles";
}
@GetMapping("/{articleId}/form")
public String updateArticleForm(@PathVariable Long articleId, ModelMap map) {
ArticleResponse article = ArticleResponse.from(articleService.getArticle(articleId));
map.addAttribute("article", article);
map.addAttribute("formStatus", FormStatus.UPDATE);
return "articles/form";
}
@PostMapping("/{articleId}/form")
public String updateArticle(@PathVariable Long articleId, ArticleRequest articleRequest) {
// TODO: 인증 정보를 넣어줘야 한다.
articleService.updateArticle(articleId, articleRequest.toDto(UserAccountDto.of(
"eunchan", "asdf1234", "eunchan@mail.com", "Eunchan", "memo", null, null, null, null
)));
return "redirect:/articles/" + articleId;
}
@PostMapping("/{articleId}/delete")
public String deleteArticle(@PathVariable Long articleId) {
// TODO: 인증 정보를 넣어줘야 한다.
articleService.deleteArticle(articleId);
return "redirect:/articles";
}
}
- article 메서드 (GET /articles/{articleId})
- 기능: 특정 articleId에 해당하는 게시글을 가져와서 해당 게시글과 관련된 댓글을 포함한 상세 정보를 보여준다.
- 변경 사항: 이전에는 articleService.getArticle(articleId)를 사용하여 단순히 게시글 정보를 가져왔지만, 수정된 코드에서는 articleService.getArticleWithComments(articleId)를 사용하여 해당 게시글과 그에 달린 댓글들을 함께 가져온다. 이를 통해 댓글까지 포함된 게시글 상세 정보를 사용자에게 제공한다.
- searchArticleHashtag 메서드 (GET /articles/search-hashtag)
- 기능: 특정 해시태그로 게시글을 검색하고, 검색된 게시글 목록을 보여준다.
- 변경 사항: 메서드 이름이 searchHashtag에서 searchArticleHashtag로 변경되었다. 이 메서드는 전달받은 해시태그를 기준으로 게시글을 검색하며, 검색된 결과와 함께 페이징 및 관련 해시태그 목록도 제공한다. 또한 searchType 속성을 제거하고 searchValue를 사용하여 검색 파라미터를 일관되게 유지했다.
- articleForm 메서드 (GET /articles/form)
- 기능: 새로운 게시글을 작성할 수 있는 폼을 사용자에게 보여준다.
- 상세: formStatus라는 속성을 CREATE로 설정하여 폼이 새로운 게시글을 작성하기 위한 것임을 명시한다.
- postNewArticle 메서드 (POST /articles/form)
- 기능: 사용자가 작성한 새로운 게시글을 저장한다.
- 상세: 폼에서 입력받은 게시글 데이터를 ArticleRequest로 받아와서 ArticleDto로 변환한 뒤, ArticleService를 통해 저장한다.
- 추가 사항: 현재는 인증 정보가 하드코딩된 상태로 전달되고 있으며, 추후에 실제 사용자 인증 정보를 연동해야 한다.
- updateArticleForm 메서드 (GET /articles/{articleId}/form)
- 기능: 기존 게시글을 수정할 수 있는 폼을 사용자에게 보여준다.
- 상세: URL에서 전달받은 articleId로 게시글을 조회한 뒤, 해당 게시글 정보를 수정할 수 있는 폼에 미리 채워 넣는다. 또한 formStatus를 UPDATE로 설정하여 수정 모드임을 명시한다.
- updateArticle 메서드 (POST /articles/{articleId}/form)
- 기능: 수정된 게시글 정보를 저장한다.
- 상세: 폼에서 입력받은 데이터를 ArticleRequest로 받아와 ArticleDto로 변환하고, 이를 ArticleService를 통해 기존 게시글을 업데이트한다. 업데이트가 완료되면 해당 게시글의 상세 페이지로 리다이렉트 한다.
- deleteArticle 메서드 (POST /articles/{articleId}/delete)
- 기능: 특정 게시글을 삭제한다.
- 상세: URL에서 전달받은 articleId를 사용하여 게시글을 삭제하고, 삭제가 완료되면 게시글 목록 페이지로 리다이렉트 한다.
2. ArticleService 수정
ArticleService는 게시판의 핵심 비즈니스 로직을 처리하는 서비스로, 게시글과 관련된 다양한 작업을 수행한다. 이번 업데이트에서는 서비스 로직의 안정성과 확장성을 높이기 위해 몇 가지 중요한 수정이 이루어졌다.
package org.example.projectboard.service;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.UserAccount;
import org.example.projectboard.domain.constant.SearchType;
import org.example.projectboard.dto.ArticleDto;
import org.example.projectboard.dto.ArticleWithCommentsDto;
import org.example.projectboard.repository.ArticleRepository;
import org.example.projectboard.repository.UserAccountRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
private final UserAccountRepository userAccountRepository;
...
@Transactional(readOnly = true)
public ArticleWithCommentsDto getArticleWithComments(Long articleId) {
return articleRepository.findById(articleId)
.map(ArticleWithCommentsDto::from)
.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
}
@Transactional(readOnly = true)
public ArticleDto getArticle(Long articleId) {
return articleRepository.findById(articleId)
.map(ArticleDto::from)
.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
}
public void saveArticle(ArticleDto dto) {
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
articleRepository.save(dto.toEntity(userAccount));
}
public void updateArticle(Long articleId, ArticleDto dto) {
try{
Article article = articleRepository.getReferenceById(articleId);
if (dto.title() != null) {article.setTitle(dto.title());}
if (dto.content() != null) {article.setContent(dto.content());}
article.setHashtag(dto.hashtag());
}catch(EntityNotFoundException e){
log.warn("게시글 업데이트 실패. 게시글을 찾을 수 없습니다. - dto : {}", dto);
}
}
...
}
- UserAccountRepository 추가 및 저장 로직 변경:
- ArticleService에 UserAccountRepository가 추가되었고, 게시글을 저장할 때 UserAccount 정보를 조회한 후 저장하도록 변경되었다. 이는 데이터의 무결성을 보장하고, 확장성을 높이기 위함이다.
- getArticleWithComments 메서드 추가:
- 댓글을 포함한 게시글을 조회하는 메서드가 추가되었다. 이 메서드는 댓글과 함께 게시글을 조회할 때 사용되며, 데이터 접근의 효율성을 높인다.
- getArticle 메서드 변경:
- 기존 getArticle 메서드는 댓글이 포함된 게시글을 반환했으나, 이제는 댓글 없이 게시글만 반환하도록 변경되었다. 이를 통해 메서드의 역할이 명확히 분리되었다.
- updateArticle 메서드 수정:
- 게시글 수정 시 ArticleDto와 게시글 ID를 함께 받아 해당 게시글을 수정하도록 변경되었다. 이는 어떤 게시글을 수정할지 명확하게 지정할 수 있어 데이터 수정의 신뢰성을 높인다.
3. FormStatus 추가
새로운 FormStatus enum이 도입되어, 게시글 작성과 수정 시 폼의 상태를 나타낼 수 있다. CREATE와 UPDATE 두 가지 상태로 구분되며, 뷰에서 이를 바탕으로 버튼의 텍스트나 폼의 동작을 제어할 수 있다.
public enum FormStatus {
CREATE("저장", false),
UPDATE("수정", true);
@Getter private final String description;
@Getter private final boolean update;
FormStatus(String description, boolean update) {
this.description = description;
this.update = update;
}
}
4. ArticleDto 수정 및 ArticleRequest 추가
ArticleDto와 ArticleRequest 객체도 수정되었다. 이제 ArticleDto는 UserAccount 객체와 함께 사용할 수 있으며, 이를 통해 Article 엔티티를 생성할 수 있다.
public static ArticleDto of(UserAccountDto userAccountDto, String title, String content, String hashtag) {
return new ArticleDto(null, userAccountDto, title, content, hashtag, null, null, null, null);
}
public Article toEntity(UserAccount userAccount) {
return Article.of(userAccount, title, content, hashtag);
}
- 새로운 of 메서드가 추가되었다. 이 메서드는 id, createdAt, createdBy, modifiedAt, modifiedBy를 받지 않고, 기본값으로 null을 설정한다.
- toEntity 메서드가 수정되었다. 이제 이 메서드는 UserAccount 객체를 인자로 받아, 해당 사용자 계정 정보를 포함한 Article 엔티티를 생성한다.
- 이로 인해 UserAccountDto를 UserAccount로 변환하는 로직이 DTO 클래스에서 제거되었고, toEntity 메서드 호출 시 UserAccount 객체를 직접 전달받아야 한다.
- 이 변경은 서비스 레이어나 컨트롤러에서 DTO를 엔티티로 변환할 때, 이미 조회된 UserAccount 객체를 재사용할 수 있게 하여 코드의 명확성과 성능을 향상시킨다.
또한, ArticleRequest 클래스가 추가되었다.
뷰(클라이언트)에서 전달된 폼 데이터를 ArticleDto로 변환할 수 있게 되었다.
package org.example.projectboard.dto.request;
import org.example.projectboard.dto.ArticleDto;
import org.example.projectboard.dto.UserAccountDto;
public record ArticleRequest(
String title,
String content,
String hashtag
) {
public static ArticleRequest of(String title, String content, String hashtag) {
return new ArticleRequest(title, content, hashtag);
}
public ArticleDto toDto(UserAccountDto userAccountDto) {
return ArticleDto.of(
userAccountDto,
title,
content,
hashtag
);
}
}
- ArticleRequest Record:
- ArticleRequest는 Java의 record 타입으로 정의되어 있다. record는 불변 객체(Immutable Object)를 생성하는 데 유용하며, 데이터 전달 객체에 적합하다.
- 이 클래스는 title, content, hashtag라는 세 가지 필드를 가지며, 이 필드들은 게시글의 제목, 내용, 해시태그를 나타낸다.
- of 메서드:
- public static ArticleRequest of(String title, String content, String hashtag):
- 정적 팩토리 메서드로, 새로운 ArticleRequest 객체를 생성하는 데 사용된다.
- 이 메서드를 통해 생성된 객체는 title, content, hashtag 필드를 가지며, 이는 게시글 생성 또는 수정 시 사용된다.
- public static ArticleRequest of(String title, String content, String hashtag):
- toDto 메서드:
- public ArticleDto toDto(UserAccountDto userAccountDto):
- 이 메서드는 ArticleRequest 객체를 ArticleDto 객체로 변환하는 역할을 한다.
- 변환 과정에서 UserAccountDto 객체를 함께 받아 ArticleDto 객체를 생성하는 데 필요한 모든 데이터를 제공한다.
- ArticleDto.of 메서드를 호출하여, UserAccountDto, title, content, hashtag 데이터를 이용해 ArticleDto 객체를 생성하고 반환한다.
- UserAccountDto는 게시글 작성자와 관련된 사용자 정보를 포함하는 객체이다.
- public ArticleDto toDto(UserAccountDto userAccountDto):
5. FormDataEncoder 및 테스트 추가
폼 데이터를 POST 요청으로 전달할 때, 데이터를 URL 인코딩 된 문자열로 변환하기 위해 FormDataEncoder 유틸리티가 추가되었다. 이 유틸리티는 테스트에서 사용되며, 객체를 적절한 폼 데이터 형식으로 변환하는 데 도움을 준다.
package org.example.projectboard.util;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.test.context.TestComponent;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
@TestComponent
public class FormDataEncoder {
private final ObjectMapper mapper;
public FormDataEncoder(ObjectMapper mapper) {
this.mapper = mapper;
}
public String encode(Object obj) {
Map<String, String> fieldMap = mapper.convertValue(obj, new TypeReference<>() {});
MultiValueMap<String, String> valueMap = new LinkedMultiValueMap<>();
valueMap.setAll(fieldMap);
return UriComponentsBuilder.newInstance()
.queryParams(valueMap)
.encode()
.build()
.getQuery();
}
}
- 생성자는 ObjectMapper를 매개변수로 받아 필드에 저장한다. ObjectMapper는 JSON 직렬화 및 역직렬화를 처리하는 데 사용되는 Jackson 라이브러리의 핵심 클래스이다. 이 클래스는 객체를 JSON 형식으로 변환하거나 JSON을 객체로 변환할 수 있다.
- encode 메서드는 객체를 URL 인코딩된 폼 데이터 문자열로 변환한다.
- mapper.convertValue 메서드를 사용해 객체 obj를 Map<String, String> 형태로 변환한다. 이 과정에서 TypeReference를 사용하여 제네릭 타입 정보를 제공한다. 이는 JSON 데이터를 특정 형식으로 변환할 때 유용하다.
- LinkedMultiValueMap 객체를 생성한 후, fieldMap에서 얻은 데이터를 valueMap에 설정한다. MultiValueMap은 하나의 키에 여러 값을 저장할 수 있는 맵 형태이다.
- UriComponentsBuilder를 사용해 valueMap에 있는 데이터를 URL 쿼리 파라미터로 변환하고, 인코딩한 후 쿼리 문자열을 반환한다.
테스트 코드에서는 이 FormDataEncoder를 활용하여, 실제 사용자가 폼 데이터를 제출하는 것과 유사한 방식으로 테스트를 진행할 수 있다.
package org.example.projectboard.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@DisplayName("테스트 도구 - Form 데이터 인코더")
@Import({FormDataEncoder.class, ObjectMapper.class})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = Void.class)
class FormDataEncoderTest {
private final FormDataEncoder formDataEncoder;
public FormDataEncoderTest(@Autowired FormDataEncoder formDataEncoder) {
this.formDataEncoder = formDataEncoder;
}
@DisplayName("객체를 넣으면, url encoding 된 form body data 형식의 문자열을 돌려준다.")
@Test
void givenObject_whenEncoding_thenReturnsFormEncodedString() {
// Given
TestObject obj = new TestObject(
"This 'is' \"test\" string.",
List.of("hello", "my", "friend").toString().replace(" ", ""),
String.join(",", "hello", "my", "friend"),
null,
1234,
3.14,
false,
BigDecimal.TEN,
TestEnum.THREE
);
// When
String result = formDataEncoder.encode(obj);
// Then
assertThat(result).isEqualTo(
"str=This%20'is'%20%22test%22%20string." +
"&listStr1=%5Bhello,my,friend%5D" +
"&listStr2=hello,my,friend" +
"&nullStr" +
"&number=1234" +
"&floatingNumber=3.14" +
"&bool=false" +
"&bigDecimal=10" +
"&testEnum=THREE"
);
}
record TestObject(
String str,
String listStr1,
String listStr2,
String nullStr,
Integer number,
Double floatingNumber,
Boolean bool,
BigDecimal bigDecimal,
TestEnum testEnum
) {}
enum TestEnum {
ONE, TWO, THREE
}
}
- 클래스 및 애너테이션 설정:
- @SpringBootTest와 @Import 애너테이션을 사용해 FormDataEncoder와 ObjectMapper를 테스트 환경에 주입한다.
- 테스트 메서드 (givenObject_whenEncoding_thenReturnsFormEncodedString):
- TestObject라는 객체를 생성하고, 이를 formDataEncoder.encode() 메서드로 인코딩한다.
- 인코딩 된 결과를 예상된 URL 인코딩 문자열과 비교하여 검증한다.
- 내부 클래스:
- TestObject 레코드와 TestEnum 열거형은 테스트 데이터로 사용된다.
5. 뷰 파일 수정
뷰 파일에서도 여러 가지 개선이 이루어졌다. 특히, form.html과 form.th.xml 파일이 추가되어 게시글 작성 및 수정 폼이 구현되었다.
form.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">
</head>
<body>
<header id="header">
헤더 삽입부
<hr>
</header>
<div class="container">
<header id="article-form-header" class="py-5 text-center">
<h1>게시글 작성</h1>
</header>
<form id="article-form">
<div class="row mb-3 justify-content-md-center">
<label for="title" class="col-sm-2 col-lg-1 col-form-label text-sm-end">제목</label>
<div class="col-sm-8 col-lg-9">
<input type="text" class="form-control" id="title" name="title" required>
</div>
</div>
<div class="row mb-3 justify-content-md-center">
<label for="content" class="col-sm-2 col-lg-1 col-form-label text-sm-end">본문</label>
<div class="col-sm-8 col-lg-9">
<textarea class="form-control" id="content" name="content" rows="5" required></textarea>
</div>
</div>
<div class="row mb-4 justify-content-md-center">
<label for="hashtag" class="col-sm-2 col-lg-1 col-form-label text-sm-end">해시태그</label>
<div class="col-sm-8 col-lg-9">
<input type="text" class="form-control" id="hashtag" name="hashtag">
</div>
</div>
<div class="row mb-5 justify-content-md-center">
<div class="col-sm-10 d-grid gap-2 d-sm-flex justify-content-sm-end">
<button type="submit" class="btn btn-primary" id="submit-button">저장</button>
<button type="button" class="btn btn-secondary" id="cancel-button">취소</button>
</div>
</div>
</form>
</div>
<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>
form.th.xml
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header" />
<attr sel="#footer" th:replace="footer :: footer" />
<attr sel="#article-form-header/h1" th:text="${formStatus} ? '게시글 ' + ${formStatus.description} : _" />
<attr sel="#article-form" th:action="${formStatus?.update} ? '/articles/' + ${article.id} + '/form' : '/articles/form'" th:method="post">
<attr sel="#title" th:value="${article?.title} ?: _" />
<attr sel="#content" th:text="${article?.content} ?: _" />
<attr sel="#hashtag" th:value="${article?.hashtag} ?: _" />
<attr sel="#submit-button" th:text="${formStatus?.description} ?: _" />
<attr sel="#cancel-button" th:onclick="'history.back()'" />
</attr>
</thlogic>
- form.html 및 form.th.xml: 게시글을 작성하고 수정할 수 있는 폼이 추가되었다. 이 폼은 제목, 본문, 해시태그 입력란을 제공하며, 제출 버튼과 취소 버튼을 포함한다.
detail.html
<div class="row g-5" id="article-buttons">
<form id="delete-article-form">
<div class="pb-5 d-grid gap-2 d-md-block">
<a class="btn btn-success me-md-2" role="button" id="update-article">수정</a>
<button class="btn btn-danger me-md-2" type="submit">삭제</button>
</div>
</form>
</div>
detail.th.xml
<attr sel="#article-buttons">
<attr sel="#delete-article-form" th:action="'/articles/' + *{id} + '/delete'" th:method="post">
<attr sel="#update-article" th:href="'/articles/' + *{id} + '/form'" />
</attr>
</attr>
- detail.html 및 detail.th.xml: 기존의 게시글 상세 페이지 뷰에도 수정 기능이 추가되었다. 삭제, 수정 버튼이 추가되었으며, 해당 버튼을 통해 사용자는 게시글을 수정하거나 삭제할 수 있다.
search-hashtag.th.xml
<attr sel="#pagination">
<attr sel="ul">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles/search-hashtag(page=${articles.number - 1}, 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/search-hashtag(page=${pageNumber}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/>
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles/search-hashtag(page=${articles.number + 1}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/>
</attr>
</attr>
- searchType 파라미터를 제거하고, searchValue만을 사용하여 검색 파라미터가 일관성 있게 전달되도록 했다.
- 페이지네이션 링크가 올바르게 작동하도록 하며, 이전에 발생했던 페이지네이션 버그를 해결한다.
index.html
<div class="row">
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a class="btn btn-primary me-md-2" role="button" id="write-article">글쓰기</a>
</div>
</div>
<div class="row">
<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>
</div>
- 글쓰기 버튼 추가: 게시글 목록 페이지에 "글쓰기" 버튼이 추가되었다. 이 버튼은 새 게시글을 작성하기 위한 페이지로 이동시켜 준다.
- 페이지 내비게이션 수정: 페이지 네비게이션 부분의 aria-label 속성이 간단해졌으며, 전체 레이아웃에 조금 더 정돈된 구조를 제공하기 위해 div 요소들이 추가되었다.
index.th.xml
<attr sel="#write-article" th:href="@{/articles/form}" />
- 추가된 이 코드 덕분에, 게시판 페이지에서 글쓰기 버튼을 클릭하면 사용자가 새로운 게시글을 작성할 수 있는 폼 페이지로 이동하게 된다.
6. 테스트 코드 수정
ArticleControllerTest와 ArticleServiceTest에서도 새로운 기능을 테스트하기 위해 코드를 수정했다. 특히, 게시글 작성, 수정, 삭제 기능이 추가되었으므로, 이를 검증하는 테스트 케이스를 추가하였다.
package org.example.projectboard.controller;
import org.example.projectboard.config.SecurityConfig;
import org.example.projectboard.domain.constant.FormStatus;
import org.example.projectboard.domain.constant.SearchType;
import org.example.projectboard.dto.ArticleDto;
import org.example.projectboard.dto.ArticleWithCommentsDto;
import org.example.projectboard.dto.UserAccountDto;
import org.example.projectboard.dto.request.ArticleRequest;
import org.example.projectboard.dto.response.ArticleResponse;
import org.example.projectboard.service.ArticleService;
import org.example.projectboard.service.PaginationService;
import org.example.projectboard.util.FormDataEncoder;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
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.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
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("View 컨트롤러 - 게시글")
@Import({SecurityConfig.class, FormDataEncoder.class})
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {
private final MockMvc mvc;
private final FormDataEncoder formDataEncoder;
@MockBean private ArticleService articleService;
@MockBean private PaginationService paginationService;
ArticleControllerTest(
@Autowired MockMvc mvc,
@Autowired FormDataEncoder formDataEncoder
) {
this.mvc = mvc;
this.formDataEncoder = formDataEncoder;
}
@DisplayName("[view][GET] 게시글 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
// given
Long articleId = 1L;
long totalCount = 1L;
given(articleService.getArticleWithComments(articleId)).willReturn(createArticleWithCommentsDto());
given(articleService.getArticleCount()).willReturn(totalCount);
// when & then
mvc.perform(get("/articles/" + articleId))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/detail"))
.andExpect(model().attributeExists("article"))
.andExpect(model().attributeExists("articleComments"))
.andExpect(model().attribute("totalCount", totalCount));
then(articleService).should().getArticleWithComments(articleId);
then(articleService).should().getArticleCount();
}
...
@DisplayName("[view][GET] 새 게시글 작성 페이지")
@Test
void givenNothing_whenRequesting_thenReturnsNewArticlePage() throws Exception {
// Given
// When & Then
mvc.perform(get("/articles/form"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/form"))
.andExpect(model().attribute("formStatus", FormStatus.CREATE));
}
@DisplayName("[view][POST] 새 게시글 등록 - 정상 호출")
@Test
void givenNewArticleInfo_whenRequesting_thenSavesNewArticle() throws Exception {
// Given
ArticleRequest articleRequest = ArticleRequest.of("new title", "new content", "#new");
willDoNothing().given(articleService).saveArticle(any(ArticleDto.class));
// When & Then
mvc.perform(
post("/articles/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(articleRequest))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles"))
.andExpect(redirectedUrl("/articles"));
then(articleService).should().saveArticle(any(ArticleDto.class));
}
@DisplayName("[view][GET] 게시글 수정 페이지")
@Test
void givenNothing_whenRequesting_thenReturnsUpdatedArticlePage() throws Exception {
// Given
long articleId = 1L;
ArticleDto dto = createArticleDto();
given(articleService.getArticle(articleId)).willReturn(dto);
// When & Then
mvc.perform(get("/articles/" + articleId + "/form"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/form"))
.andExpect(model().attribute("article", ArticleResponse.from(dto)))
.andExpect(model().attribute("formStatus", FormStatus.UPDATE));
then(articleService).should().getArticle(articleId);
}
@DisplayName("[view][POST] 게시글 수정 - 정상 호출")
@Test
void givenUpdatedArticleInfo_whenRequesting_thenUpdatesNewArticle() throws Exception {
// Given
long articleId = 1L;
ArticleRequest articleRequest = ArticleRequest.of("new title", "new content", "#new");
willDoNothing().given(articleService).updateArticle(eq(articleId), any(ArticleDto.class));
// When & Then
mvc.perform(
post("/articles/" + articleId + "/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(articleRequest))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleService).should().updateArticle(eq(articleId), any(ArticleDto.class));
}
@DisplayName("[view][POST] 게시글 삭제 - 정상 호출")
@Test
void givenArticleIdToDelete_whenRequesting_thenDeletesArticle() throws Exception {
// Given
long articleId = 1L;
willDoNothing().given(articleService).deleteArticle(articleId);
// When & Then
mvc.perform(
post("/articles/" + articleId + "/delete")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles"))
.andExpect(redirectedUrl("/articles"));
then(articleService).should().deleteArticle(articleId);
}
private ArticleDto createArticleDto() {
return ArticleDto.of(
createUserAccountDto(),
"title",
"content",
"#java"
);
}
...
}
- FormDataEncoder 추가 및 사용:
- FormDataEncoder는 POST 요청에서 사용되는 데이터를 폼 형식으로 인코딩하는 역할을 한다.
- 테스트에서 객체를 URL-encoded 형식으로 변환해 주는 유틸리티로, 이 인코더를 통해 테스트에서 객체 데이터를 쉽게 처리할 수 있다.
- formDataEncoder.encode(articleRequest)를 통해 POST 요청에서 데이터를 인코딩해 전송한다.
- 게시글 작성 및 수정 기능 추가:
- 새 게시글 작성(GET /articles/form): 사용자가 새 게시글을 작성할 수 있는 폼 페이지를 요청하는 기능을 테스트한다.
- 새 게시글 등록(POST /articles/form): 사용자가 작성한 게시글을 서버에 저장하는 기능을 테스트한다.
- 게시글 수정(GET /articles/{articleId}/form 및 POST /articles/{articleId}/form): 기존 게시글을 수정할 수 있는 폼 페이지를 요청하고, 수정된 데이터를 서버에 저장하는 기능을 테스트한다.
- 게시글 삭제 기능 추가:
- POST /articles/{articleId}/delete를 통해 특정 게시글을 삭제하는 기능을 테스트한다.
package org.example.projectboard.service;
import jakarta.persistence.EntityNotFoundException;
import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.UserAccount;
import org.example.projectboard.domain.constant.SearchType;
import org.example.projectboard.dto.ArticleDto;
import org.example.projectboard.dto.ArticleWithCommentsDto;
import org.example.projectboard.dto.UserAccountDto;
import org.example.projectboard.repository.ArticleRepository;
import org.example.projectboard.repository.UserAccountRepository;
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 org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.BDDMockito.*;
@DisplayName("비즈니스 로직 - 게시글")
@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
@InjectMocks private ArticleService sut;
@Mock private ArticleRepository articleRepository;
@Mock private UserAccountRepository userAccountRepository;
...
@DisplayName("게시글 ID로 조회하면, 댓글 달긴 게시글을 반환한다.")
@Test
void givenArticleId_whenSearchingArticleWithComments_thenReturnsArticleWithComments() {
// Given
Long articleId = 1L;
Article article = createArticle();
given(articleRepository.findById(articleId)).willReturn(Optional.of(article));
// When
ArticleWithCommentsDto dto = sut.getArticleWithComments(articleId);
// Then
assertThat(dto)
.hasFieldOrPropertyWithValue("title", article.getTitle())
.hasFieldOrPropertyWithValue("content", article.getContent())
.hasFieldOrPropertyWithValue("hashtag", article.getHashtag());
then(articleRepository).should().findById(articleId);
}
@DisplayName("댓글 달린 게시글이 없으면, 예외를 던진다.")
@Test
void givenNonexistentArticleId_whenSearchingArticleWithComments_thenThrowsException() {
// Given
Long articleId = 0L;
given(articleRepository.findById(articleId)).willReturn(Optional.empty());
// When
Throwable t = catchThrowable(() -> sut.getArticleWithComments(articleId));
// Then
assertThat(t)
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("게시글이 없습니다 - articleId: " + articleId);
then(articleRepository).should().findById(articleId);
}
@DisplayName("게시글을 조회하면, 게시글을 반환한다.")
@Test
void givenArticleId_whenSearchingArticle_thenReturnsArticle() {
// given
Long articleId = 1L;
Article article = createArticle();
given(articleRepository.findById(articleId)).willReturn(Optional.of(article));
// when
ArticleDto dto = sut.getArticle(articleId);
// then
assertThat(dto)
.hasFieldOrPropertyWithValue("title", article.getTitle())
.hasFieldOrPropertyWithValue("content", article.getContent())
.hasFieldOrPropertyWithValue("hashtag", article.getHashtag());
then(articleRepository).should().findById(articleId);
}
@DisplayName("게시글 정보를 입력하면, 게시글을 생성한다.")
@Test
void givenArticleInfo_whenSavingArticle_thenSavesArticle() {
// given
ArticleDto dto = createArticleDto();
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(createUserAccount());
given(articleRepository.save(any(Article.class))).willReturn(createArticle());
// when
sut.saveArticle(dto);
// then
then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
then(articleRepository).should().save(any(Article.class));
}
@DisplayName("게시글의 수정 정보를 입력하면, 게시글을 수정한다.")
@Test
void givenModifiedArticleInfo_whenUpdatingArticle_thenUpdatesArticle() {
// given
Article article = createArticle();
ArticleDto dto = createArticleDto("새 타이틀", "새 내용", "#springboot");
given(articleRepository.getReferenceById(dto.id())).willReturn(article);
// when
sut.updateArticle(dto.id(), dto);
// then
assertThat(article)
.hasFieldOrPropertyWithValue("title", dto.title())
.hasFieldOrPropertyWithValue("content", dto.content())
.hasFieldOrPropertyWithValue("hashtag", dto.hashtag());
then(articleRepository).should().getReferenceById(dto.id());
}
...
private Article createArticle() {
Article article = Article.of(
createUserAccount(),
"title",
"content",
"#java"
);
ReflectionTestUtils.setField(article, "id", 1L);
return article;
}
...
}
- UserAccountRepository 추가 및 사용
- UserAccountRepository가 추가되어, saveArticle 메서드에서 게시글을 저장할 때 사용자 계정 정보를 가져오는 데 사용된다. 이를 통해 게시글이 저장될 때 관련 사용자 정보가 포함된다.
- 댓글이 포함된 게시글 조회 테스트 (getArticleWithComments):
- givenArticleId_whenSearchingArticleWithComments_thenReturnsArticleWithComments 테스트 메서드는 게시글 ID로 조회 시 해당 게시글과 함께 댓글을 반환하는지 확인한다.
- given(articleRepository.findById(articleId)).willReturn(Optional.of(article));를 통해 articleRepository가 주어진 ID로 게시글을 찾을 때, 해당 게시글을 반환하도록 설정한다.
- 이후 sut.getArticleWithComments(articleId); 메서드를 호출하여, 반환된 DTO가 예상대로 title, content, hashtag 등의 필드를 가진다는 것을 assertThat(dto)로 검증한다.
- 마지막으로, then(articleRepository).should().findById(articleId);를 통해 articleRepository.findById(articleId) 메서드가 호출되었음을 확인한다.
- 댓글이 없는 게시글 조회 시 예외 처리
- givenNonexistentArticleId_whenSearchingArticleWithComments_thenThrowsException 테스트 메서드는 존재하지 않는 게시글 ID로 조회를 시도할 때, EntityNotFoundException 예외가 발생하는지 확인한다.
- given(articleRepository.findById(articleId)).willReturn(Optional.empty());를 통해 articleRepository가 주어진 ID로 게시글을 찾지 못했을 때, 빈 결과를 반환하도록 설정한다.
- 이후 sut.getArticleWithComments(articleId); 메서드를 호출하여 예외가 발생하는지 확인하며, catchThrowable을 사용해 발생한 예외를 캡처한다.
- assertThat(t).isInstanceOf(EntityNotFoundException.class).hasMessage("게시글이 없습니다 - articleId: " + articleId);을 통해 예외가 EntityNotFoundException 유형이고, 메시지가 예상한 대로 "게시글이 없습니다 - articleId: 0"인지 확인한다.
- then(articleRepository).should().findById(articleId);를 통해 articleRepository.findById(articleId) 메서드가 호출되었음을 확인한다.
- 게시글 조회 시 ArticleWithCommentsDto 반환
- 기존에는 ArticleWithCommentsDto를 반환하는 getArticleWithComments 메서드가 있었지만, 이제 getArticle 메서드는 댓글 정보 없이 ArticleDto만을 반환한다.
- ReflectionTestUtils를 사용한 ID 설정
- createArticle 메서드에서 ReflectionTestUtils.setField(article, "id", 1L)을 사용하여 테스트 목적으로 엔티티의 ID를 직접 설정한다. 이를 통해 ID가 필요한 테스트 케이스에서 실제 데이터베이스를 사용하지 않고도 테스트할 수 있다.
- 게시글 저장 및 수정 로직 수정:
- 게시글 저장(saveArticle)과 수정(updateArticle)에서 userAccountRepository를 사용하여 UserAccount 객체를 가져오고, 이를 사용해 Article 엔티티를 저장하거나 수정한다. 이를 통해 사용자 정보가 정확하게 연계되도록 한다.
'BackEnd > Project' 카테고리의 다른 글
[Board] Ch03. 인증 기능 구현(1) (0) | 2024.08.13 |
---|---|
[Board] Ch03. 게시글 댓글 구현 (0) | 2024.08.12 |
[Board] Ch03. 게시판 검색 구현 - 해시태그 (0) | 2024.08.11 |
[Board] Ch03. 게시판 검색 구현 - 제목, 본문, 이름 (0) | 2024.08.11 |
[Board] Ch03. 게시판 정렬 구현 (0) | 2024.08.10 |