하위 태스크 1
도메인 분석
Article/Member/Comment 등 엔티티 관계 파악

Article엔티티- 게시글 정보를 담는다.
Member엔티티와 1:N 관계를 맺는다.
Member엔티티- 회원 정보를 담는다.
Article과Authority엔티티로부터 참조된다.
Authority엔티티- 회원의 권한 정보를 담는다.
Member엔티티와 1:N 관계를 맺는다.
하위 태스크 2
계층 구조 확인
Controller-Service-Repository 역할 정리
- Controller:
com.example.demo.controller- 클라이언트의 요청을 수신한다.
- 모델의 속성을 설정한다.
- 요청에 적합한 뷰를 반환한다.
- Service:
com.example.demo.service- 하나 이상의 Repository를 사용해 비즈니스 로직을 구성한다.
- 엔티티 객체를 DTO로 변환한다.
- Repository:
com.example.demo.repository- 데이터베이스와 직접적으로 통신하며 데이터를 관리한다.
- JpaRepository 인터페이스를 상속받아 기본 CRUD 기능을 제공받는다.
하위 태스크 3
게시글 목록 구현
페이징/정렬을 포함한 목록 페이지 구현
ArticleController의 getArticleList에서 Pageable 객체와 @pageableDefault 어노테이션을 사용해 페이징 및 최신순 정렬을 구현한다.
@Controller
@RequestMapping("/article")
@RequiredArgsConstructor
@Slf4j
public class ArticleController {
// ...
@GetMapping("/list")
public String getArticleList(@PageableDefault(page = 0, size = 10, sort="id", direction = Sort.Direction.DESC) Pageable pageable, Model model) {
Page<ArticleDto> page = articleService.findAll(pageable);
model.addAttribute("page", page);
return "article-list";
}
// ...
}‘윤서준’ 사용자로 로그인 한 뒤 localhost:8080/article/list로 접속한 결과는 다음과 같다.

하위 태스크 4
게시글 상세 구현
단일 게시글 상세 페이지 구현
ArticleController의 getArticle에서 요청 파라미터로 id를 받고, 모델에 ArticleDto 객체를 담는다. 그 후 뷰로 전달한다.
@Controller
@RequestMapping("/article")
@RequiredArgsConstructor
@Slf4j
public class ArticleController {
// ...
@GetMapping("/content")
public String getArticle(@RequestParam("id") Long id, Model model) {
model.addAttribute("article", articleService.findById(id));
return "article-content";
}
// ...
}윤서준 사용자로 로그인한 뒤, localhost:8080/article/content?id=50에 접속한 결과는 다음과 같다.

하위 태스크 5
게시글 작성/수정/삭제
CRUD 기능 및 뷰/시큐리티 연동
작성 기능
ArticleController의postArticleAdd메서드에서@AuthenticationPrincipal MemberUserDetails userDetails를 통해 로그인한 사용자의 ID를 가져와 게시글과 연결한다.
@Controller
@RequestMapping("/article")
@RequiredArgsConstructor
@Slf4j
public class ArticleController {
// ...
@GetMapping("/add")
public String getArticleAdd(@ModelAttribute("article") ArticleForm articleForm) {
articleForm.setDescription("바르고 고운말을 사용하여 주세요^^");
return "article-add";
}
@PostMapping("/add")
public String postArticleAdd(@Valid @ModelAttribute("article") ArticleForm articleForm,
BindingResult bindingResult,
@AuthenticationPrincipal MemberUserDetails userDetails) {
if (articleForm.getTitle() != null && articleForm.getTitle().contains("T발")) {
bindingResult.rejectValue("title", "SlangDetected", "욕설을 사용하지 마세요");
}
if (articleForm.getDescription() != null && articleForm.getDescription().contains("T발")) {
bindingResult.rejectValue("description", "SlangDetected", "욕설을 사용하지 마세요");
}
if (bindingResult.hasErrors()) {
return "article-add";
}
articleService.create(userDetails.getMemberId(), articleForm);
return "redirect:/article/list";
}
// ...
}수정 기능
ArticleController의postArticleEdit메서드에서@AuthenticationPrincipal MemberUserDetails userDetails를 통해 로그인한 사용자의 ID를 가져와 게시글과 연결한다.
@Controller
@RequestMapping("/article")
@RequiredArgsConstructor
@Slf4j
public class ArticleController {
// ...
@GetMapping("/edit")
public String getArticleEdit(@ModelAttribute("article") ArticleForm articleForm) {
ArticleDto articleDto = articleService.findById(articleForm.getId());
articleForm.setId(articleDto.getId());
articleForm.setTitle(articleDto.getTitle());
articleForm.setDescription(articleDto.getDescription());
return "article-edit";
}
@PostMapping("/edit")
public String postArticleEdit(@Valid @ModelAttribute("article") ArticleForm articleForm,
BindingResult bindingResult,
@AuthenticationPrincipal MemberUserDetails userDetails) throws BadRequestException {
if (bindingResult.hasErrors()) {
return "article-edit";
}
articleService.update(userDetails.getMemberId(), articleForm);
return "redirect:/article/content?id=" + articleForm.getId();
}
// ...
}ArticleService의update메서드에서 게시글 작성자가 맞는지 한 번 더 검증한다.
@Service
@RequiredArgsConstructor
public class ArticleService {
// ...
public ArticleDto update(Long memberId, ArticleForm articleForm) throws BadRequestException {
Article article = articleRepository.findById(articleForm.getId()).orElseThrow();
if (!article.getMember().getId().equals(memberId)) {
throw new BadRequestException();
}
article.setTitle(articleForm.getTitle());
article.setDescription(articleForm.getDescription());
articleRepository.save(article);
return mapToArticleDto(article);
}
// ...
}삭제 기능
뷰에서 게시글 작성자와 로그인한 회원이 다르면 수정 및 삭제 버튼을 표시하지 않도록 구현한다.
article-content.html:
<!-- ... -->
<th:block sec:authorize="isAuthenticated()" th:if="${#authentication.principal.memberId == article.getMemberId}">
<a type="button" class="btn btn-warning btn-sm" th:href="@{/article/edit(id=${article.id})}">수정</a>
<a type="button" class="btn btn-danger btn-sm" th:href="@{/article/delete(id=${article.id})}">삭제</a>
</th:block>
<!-- ... -->윤서준 사용자로 로그인한 뒤, 윤서준이 작성한 게시글의 상세 페이지로 접속한 결과는 다음과 같다. 수정 및 삭제 버튼이 노출된다.

윤서준 사용자로 로그인한 뒤, 윤서준이 작성하지 않은 게시글의 상세 페이지로 접속한 결과는 다음과 같다. 수정 및 삭제 버튼이 노출되지 않는다.

하위 태스크 6
폼 검증 추가
제목/내용 필드 검증 및 에러 메시지 처리
DTO 클래스에 검증 어노테이션을 추가한다.
dto/ArticleForm.java
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ArticleForm {
private Long id;
@NotBlank(message = "게시글 제목을 입력하세요")
private String title;
@NotBlank(message = "게시글 내용을 입력하세요")
private String description;
}컨트롤러에 @Valid 어노테이션을 추가해 검증을 수행하고, 에러가 발생하면 폼 뷰를 반환한다.
@Controller
@RequestMapping("/article")
@RequiredArgsConstructor
@Slf4j
public class ArticleController {
// ...
@PostMapping("/add")
public String postArticleAdd(@Valid @ModelAttribute("article") ArticleForm articleForm,
BindingResult bindingResult,
@AuthenticationPrincipal MemberUserDetails userDetails) {
// ...
}
}뷰에서 #fields.hassErrors와 th:errors를 사용해 에러 메시지를 표시한다.
<!-- ... -->
<div class="mb-3">
<label class="form-label">제목</label>
<input type="text" class="form-control" th:field="*{title}">
<p th:if="${#fields.hasErrors('title')}" th:errors="*{title}" class="text-danger">Title Error</p>
</div>
<div class="mb-3">
<label class="form-label">내용</label>
<textarea class="form-control" th:field="*{description}"></textarea>
<p th:if="${#fields.hasErrors('description')}" th:errors="*{description}" class="text-danger">Description Error</p>
</div>
<!-- ... -->새 게시글 생성 시, 게시글의 제목을 입력하지 않은 결과는 다음과 같다.

하위 태스크 7
권한 제어 적용
작성자/관리자만 수정/삭제 가능하도록 설정
서비스 계층에 권한 확인 코드를 추가한다.
service/ArticleService.java:
@Service
@RequiredArgsConstructor
public class ArticleService {
// ...
- public ArticleDto update(Long memberId, ArticleForm articleForm) throws BadRequestException {
+ public ArticleDto update(MemberUserDetails userDetails, ArticleForm articleForm) throws BadRequestException {
Article article = articleRepository.findById(articleForm.getId()).orElseThrow();
- if (!article.getMember().getId().equals(memberId)) {
- throw new BadRequestException();
+ if (!article.getMember().getId().equals(userDetails.getMemberId()) &&
+ userDetails.getAuthorities().stream().noneMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
+ throw new BadRequestException();
}
article.setTitle(articleForm.getTitle());
article.setDescription(articleForm.getDescription());
articleRepository.save(article);
return mapToArticleDto(article);
}
- public void delete(Long id)
+ public void delete(MemberUserDetails userDetails, Long id) throws BadRequestException {
Article article = articleRepository.findById(id).orElseThrow();
+ if (!article.getMember().getId().equals(userDetails.getMemberId()) &&
+ userDetails.getAuthorities().stream().noneMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
+ throw new BadRequestException();
+ }
articleRepository.delete(article);
}
// ...
}ArticleController에서 MemberUserDetails 객체를 사용하도록 수정한다.
@Controller
@RequestMapping("/article")
@RequiredArgsConstructor
@Slf4j
public class ArticleController {
// ...
@PostMapping("/edit")
public String postArticleEdit(@Valid @ModelAttribute("article") ArticleForm articleForm,
BindingResult bindingResult,
@AuthenticationPrincipal MemberUserDetails userDetails) throws BadRequestException {
if (bindingResult.hasErrors()) {
return "article-edit";
}
- articleService.update(userDetails.getMemberId(), articleForm);
+ articleService.update(userDetails, articleForm);
return "redirect:/article/content?id=" + articleForm.getId();
}
@GetMapping("/delete")
- public String getArticleDelete(@RequestParam("id") Long id) {
- articleService.delete(id);
+ public String getArticleDelete(@RequestParam("id") Long id,
+ @AuthenticationPrincipal MemberUserDetails userDetails) throws BadRequestException {
+ articleService.delete(userDetails, id);
return "redirect:/article/list";
}
}article-content.html 뷰의 조건 비교 표현식을 수정한다.
<!-- ... -->
- <th:block sec:authorize="isAuthenticated()" th:if="${#authentication.principal.memberId == article.getMemberId}">
+ <th:block sec:authorize="isAuthenticated()" th:if="${#authentication.principal.memberId == article.getMemberId │
│ or #authorization.expression('hasAuthority(''ROLE_ADMIN'')')}">
<a type="button" class="btn btn-warning btn-sm" th:href="@{/article/edit(id=${article.id})}">수정</a>
<a type="button" class="btn btn-danger btn-sm" th:href="@{/article/delete(id=${article.id})}">삭제</a>
</th:block>
<!-- ... -->하위 태스크 8
성공/에러 메시지 처리
Flash Attribute 및 메시지 출력 구현
base-layout.html에 메시지가 출력될 영역을 마크업한다.
<!-- ... -->
<br>
+ <div class="container">
+ <div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
+ <span th:text="${successMessage}"></span>
+ <button type="button" class="btn-close" data-bs-dismiss="alert" ></button>
+ </div>
+ <div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
+ <span th:text="${errorMessage}"></span>
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+ </div>
+ </div>
<div th:replace="${content}"></div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous">
</script>
<!-- ... -->ArticleController의 메서드가 RedirectAttributes 매개변수를 전달 받도록 수정한다.
@Controller
@RequestMapping("/article")
@RequiredArgsConstructor
@Slf4j
public class ArticleController {
// ...
@PostMapping("/add")
public String postArticleAdd(@Valid @ModelAttribute("article") ArticleForm articleForm,
BindingResult bindingResult,
- @AuthenticationPrincipal MemberUserDetails userDetails) {
+ @AuthenticationPrincipal MemberUserDetails userDetails,
+ RedirectAttributes redirectAttributes) {
) {
// ...
articleService.create(userDetails.getMemberId(), articleForm);
+ redirectAttributes.addFlashAttribute("successMessage", "게시글이 등록되었습니다.");
return "redirect:/article/list";
}
// ...
@PostMapping("/edit")
public String postArticleEdit(@Valid @ModelAttribute("article") ArticleForm articleForm,
BindingResult bindingResult,
- @AuthenticationPrincipal MemberUserDetails userDetails) throws BadRequestException {
+ @AuthenticationPrincipal MemberUserDetails userDetails,
+ RedirectAttributes redirectAttributes) throws BadRequestException {
if (bindingResult.hasErrors()) {
return "article-edit";
}
articleService.update(userDetails, articleForm);
+ redirectAttributes.addFlashAttribute("successMessage", "게시글이 수정되었습니다.");
return "redirect:/article/content?id=" + articleForm.getId();
}
@GetMapping("/delete")
public String getArticleDelete(@RequestParam("id") Long id,
- @AuthenticationPrincipal MemberUserDetails userDetails) throws BadRequestException {
+ @AuthenticationPrincipal MemberUserDetails userDetails,
+ RedirectAttributes redirectAttributes) throws BadRequestException {
articleService.delete(userDetails, id);
+ redirectAttributes.addFlashAttribute("successMessage", "게시글이 삭제되었습니다.");
return "redirect:/article/list";
}
}새로운 게시글 발행 시 노출되는 알림 요소는 다음과 같다.

하위 태스크 9
에러 페이지 커스터마이징
404/500 등의 커스텀 에러 페이지 구현
templates/error 폴더 안에 에러 페이지 뷰를 작성한다.
templates/error/404.html:
<!doctype html>
<html lang="ko"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="https://www.thymeleaf.org"
th:replace="~{base-layout::layout(~{::section})}">
<body>
<section class="container text-center">
<div class="py-5">
<h1 class="display-1 fw-bold text-secondary">404</h1>
<h2 class="mb-4">페이지를 찾을 수 없습니다</h2>
<p class="lead mb-5">요청하신 페이지가 존재하지 않거나 삭제되었을 수 있습니다.</p>
<a th:href="@{/}" class="btn btn-primary">홈으로 돌아가기</a>
</div>
</section>
</body>
</html>templates/error/500.html:
<!doctype html>
<html lang="ko"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="https://www.thymeleaf.org"
th:replace="~{base-layout::layout(~{::section})}">
<body>
<section class="container text-center">
<div class="py-5">
<h1 class="display-1 fw-bold text-danger">500</h1>
<h2 class="mb-4">서버 오류가 발생했습니다</h2>
<p class="lead mb-5">요청을 처리하는 중에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.</p>
<a th:href="@{/}" class="btn btn-primary">홈으로 돌아가기</a>
</div>
</section>
</body>
</html>등록되지 않은 페이지에 접속한 결과는 다음과 같다.
