하위 태스크 1

도메인 분석

Article/Member/Comment 등 엔티티 관계 파악

  • Article 엔티티
    • 게시글 정보를 담는다.
    • Member 엔티티와 1:N 관계를 맺는다.
  • Member 엔티티
    • 회원 정보를 담는다.
    • ArticleAuthority 엔티티로부터 참조된다.
  • 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

게시글 목록 구현

페이징/정렬을 포함한 목록 페이지 구현

ArticleControllergetArticleList에서 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

게시글 상세 구현

단일 게시글 상세 페이지 구현

ArticleControllergetArticle에서 요청 파라미터로 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 기능 및 뷰/시큐리티 연동

작성 기능

  • ArticleControllerpostArticleAdd 메서드에서 @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";  
	}
	
	// ...
}

수정 기능

  • ArticleControllerpostArticleEdit 메서드에서 @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();
    }
	
	// ...
}
  • ArticleServiceupdate 메서드에서 게시글 작성자가 맞는지 한 번 더 검증한다.
@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.hassErrorsth: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>

등록되지 않은 페이지에 접속한 결과는 다음과 같다.