예외처리는 예외가 발생할 수 있는 곳에 예외처리를 해 어플리케이션이 멈추지 않게끔 만드는 것이다.
스프링의 기본 예외처리는 예외 발생시 WAS에서 /error request가 발생해 BasicErrorController에서 받는다.
컨트롤러 내부에서 발생하면 WAS까지 갔다가 다시 에러 컨트롤러를 호출하는 것이다.
이런 낭비를 막고 예외처리를 커스텀 하기 위해 이번 글에선 두 부분을 다룬다.
1. Unchecked Exception 처리(예외 처리가 가능한 예외/ 컨트롤러 하위)
Exception 중 컴파일 에러가 뜨는 Checked Exception을 제외한 Runtime Exception을 상속받는 모든 예외 처리.
사용자가 클릭만 이용한다면 터질일이 없지만 URI로 접근해서 해당 글, 해당 사용자, 해당 댓글 등이 없는 상태에서 조회, 수정, 삭제 등의 행위를 하거나 글 작성자가 아닌데 다른 사람의 글의 수정, 삭제 등의 행위를 할 때를 대비한 예외 처리
스프링에서 런타임 예외처리의 기본 전략이 트랜잭션을 전부 롤백하는 것이므로 예외 발생 시 처리과정을 롤백하고 에러 메시지를 띄울 예정
2. 에러 페이지 설정(예외 처리가 불가능한 예외)
예외 처리를 해줄 수 없는 영역에 대한 정보를 보여주기 위한 페이지
1. UncheckedException 처리
예외처리 방법은 모든 메서드에 try/catch를 걸거나 trhows로 부모 메서드에 예외처리를 넘기는 책임 전가 방식이 있다.
try/catch는 코드가 비즈니스 로직에 집중하기 힘들다는 단점이 있고, trhows는 printStackTrace()로 로그를 뿌리고 예외 처리가 되기 때문에 어떤 에러인지 모호하다는 단점이 있다.
여기서 공통 관심사를 메인 로직에서 분리하는 예외처리 방식을 고안하고 추상화한게 HandlerExceptionResolver이다.
비즈니스 로직에선 예외처리를 trhows만 사용해서 비즈니스 로직에 집중하고, 예외처리가 터진 컨트롤러나 @ControllerAdvice로 전역적인 컨트롤러에서 터진 예외처리를 @ExceptionHandler를 통해 처리해 로그보다 정확한 에러 정보들을 넘겨준다.
일단 예외처리 클래스부터 만든다.
Abstract Exception Class
public abstract class EmotionException extends RuntimeException {
public EmotionException(String message) {
super(message);
}
public abstract int getStatusCode();
}
해당 클래스를 상속한 예외처리를 모두 잡기 위한 추상 클래스
에러 페이지로 넘길 때 HTTP Status Code 와 메시지를 넘겨준다.
PostNotFound
public class PostNotFound extends EmotionException {
private static final String MESSAGE = "존재하지 않는 글입니다.";
public PostNotFound() {
super(MESSAGE);
}
@Override
public int getStatusCode() {
return 404;
}
}
메시지를 상위 클래스 생성자로 넘겨주고, StatusCode 오버라이딩
User, Comment도 메세지만 다르고 내용이 같기 때문에 생략한다.
Unauthorized
public class Unauthorized extends EmotionException{
private static final String MESSAGE = "권한이 필요합니다.";
public Unauthorized() {
super(MESSAGE);
}
@Override
public int getStatusCode() {
return 401;
}
}
NotFound 와 마찬가지로 메시지와 코드 설정
PostService
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserRepository userRepository;
@Transactional
public void write(PostCreate postCreate, String username) {
User user = userRepository.findByUsername(username).orElseThrow(UserNotFound::new);
postCreate.setUser(user);
Post writePost = postCreate.toEntity();
postRepository.save(writePost);
}
public Page<PostResponse> getList(Pageable pageable) {
return postRepository.findAll(pageable).map(PostResponse::new);
}
public Page<PostResponse> search(String searchText, Pageable pageable) {
return postRepository.findByTitleContainingOrContentContaining(searchText, searchText, pageable).map(PostResponse::new);
}
public PostResponse get(Long id) {
Post post = postRepository.findById(id).orElseThrow(PostNotFound::new);
return PostResponse.builder()
.post(post)
.build();
}
@Transactional
public void edit(Long postId, PostEdit postEdit) {
Post post = postRepository.findById(postId)
.orElseThrow(PostNotFound::new);
post.edit(postEdit);
}
@Transactional
public void delete(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(PostNotFound::new);
postRepository.delete(post);
}
}
Service에서 Repository를 이용할 때 해당 엔티티가 없을 때의 경우 Not Found 예외 처리 클래스를 throw 한다.
쓰기의 영역인 edit, delete 메소드엔 @Transactional을 이용해 예외 발생 시 해당 메소드 롤백
PostController
@Controller
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
// ...
@GetMapping("/posts/edit/{postId}")
public String edit(@PathVariable Long postId, Model model, @AuthenticationPrincipal CustomUserDetails userDetails) {
PostResponse post = postService.get(postId);
if (userDetails != null) {
existsSession(model, userDetails);
}
userCheck(userDetails, postId); // 권한 예외 처리
model.addAttribute("postEdit", new PostEdit(post.getTitle(), post.getContent()));
model.addAttribute("postId", postId);
return "post/postEdit";
}
@PutMapping("/posts/{postId}")
public String editSave(@PathVariable Long postId, @Valid PostEdit postEdit,
BindingResult result, Model model, @AuthenticationPrincipal CustomUserDetails userDetails) {
userCheck(userDetails, postId);
if (result.hasErrors()) {
model.addAttribute("postEdit", postEdit);
return "post/postEdit";
}
postService.edit(postId, postEdit);
return "redirect:/posts";
}
@DeleteMapping("/posts/{postId}")
public String delete(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails userDetails) {
userCheck(userDetails, postId);
postService.delete(postId);
return "redirect:/posts";
}
private void userCheck(CustomUserDetails user, Long postId) {
// 권한 예외 처리
if (!user.getUsername().equals(postService.get(postId).getUsername()))throw new Unauthorized();
}
private void existsSession(Model model, CustomUserDetails user) {
model.addAttribute("nickname", user.getNickname());
model.addAttribute("emotion", user.getEmotion());
}
}
Controller에서 수정, 삭제 시에 세션 유저네임과 접근 하는 글의 작성자를 비교해서 일치하지 않으면 Unauthorized 예외 처리 클래스를 trhow 한다.
예외 발생을 메소드의 시작에 둬 Service까지 내려가기전에 예외처리 시킴
UncheckedExceptionController
@ControllerAdvice
public class UnCheckedExceptionController {
@ExceptionHandler(EmotionException.class)
public String exception(EmotionException exception, Model model){
model.addAttribute("code", exception.getStatusCode());
model.addAttribute("message", exception.getMessage());
return "error/error";
}
}
@ExceptionHandler(Class)
Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다.
만약 ExceptionHandler 어노테이션에 예외 클래스를 지정하지 않는다면, 파라미터에 설정된 에러 클래스를 처리하게 된다
@ControllerAdvice
@Controller나 @RestController 등 여러 컨트롤러에서 발생한 예외를 한 곳에서 관리하고 처리할 수 있게 도와준다.
Post, Comment, User 등 전체적인 컨트롤러에서 예외를 잡기 위해 사용했다.
추상 예외 클래스에서 적용한 HTTP Status Code 와 에러 메시지를 에러 페이지로 넘겨준다.
Thymeleaf View
https://freefrontend.com/html-funny-404-pages/
공짜 에러 페이지 템플렛을 가져와서 적용했다.
컨트롤러에서 넘겨준 뷰를 사용해 화면의 Code, Message 등을 보여준다.
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:justify-content="http://www.w3.org/1999/xhtml">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link th:href="@{/css/bootstrap.min.css}"
href="../../static/css/bootstrap.min.css"
rel="stylesheet">
<title>Emotion Community</title>
<link th:href="@{/css/page404.css}"
href="../../static/css/sticky-footer-navbar.css" rel="stylesheet">
</head>
<body>
<section class="page_404">
<div class="container">
<div class="row">
<div class="col-sm-12 ">
<div class="col-sm-10 col-sm-offset-1 text-center">
<div class="four_zero_four_bg">
<h1 class="text-center " th:text="${code}">404</h1>
</div>
<div class="contant_box_404">
<h3 class="h2" th:text="${message}">
Look like you're lost
</h3>
<p>the page you are looking for not avaible!</p>
<a href="" class="link_404" th:href="@{|/posts|}">Go to Home</a>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
결과
2. 에러 페이지 설정
스프링 부트 프로젝트에서 에러 페이지를 따로 설정하지 않으면 White Label 에러 페이지를 출력한다.
여기엔 개발자가 의도하지 않은 에러 정보가 담겨있기 때문에 개발자가 직접 설정해주는 과정이 필요하다.
타임리프를 사용하면 resource/templates 내에 error.html을 저장하거나 resource/templates/erros 내에 HttpCode.html(404.html, 500.html)등으로 정적 페이지를 저장하면 스프링부트의 BasicErrorController가 뷰를 보여준다.
Thymeleaf View
<body>
<section class="page_404">
<div class="container">
<div class="row">
<div class="col-sm-12 ">
<div class="col-sm-10 col-sm-offset-1 text-center">
<div class="four_zero_four_bg">
<h1 class="text-center ">[[${status}]]</h1>
</div>
<div class="contant_box_404">
<h3 class="h2">
[[${path}]]
</h3>
<h3 class="h2">
[[${message}]]
</h3>
<p>the page you are looking for not avaible!</p>
<a href="" class="link_404" th:href="@{|/posts|}">Go to Home</a>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
결과
참고
https://mangkyu.tistory.com/204
https://www.baeldung.com/spring-boot-custom-error-page