개괄


 

Jira나 Linear을 이용하다보면, 가끔 내가 해결한 티켓들을 그대로 고이 모셔 두기가 너무 아깝다.

같은 프로젝트를 하더라도, 내가 이 사람보다 월등히 일을 많이 해결했는데 인정받지 못하는 순간이 아쉬워서

어떤 프로젝트(또는 회사) 안의 SubTask를 만들고, 그 Task 안에서 Ticket을 발부할 때마다 티켓의 중요도와 난이도를 가지고 인사평가를 할 수 있는 프로젝트를 만들었다.

 

그런데 프로젝트 도중 내부 회의를 하다가 이상한 점을 발견했다.

그동안의 점수 로직은 0~5까지의 0.5단위의 점수를 매길 수 있는 중요도와 난이도를 그냥 합산한 수치로 계산했는데,

나는 어쩔때는 예상 밖의 예외에 집착하고 많은 엣지케이스를 실험해 보는 습관이 있어서

내가 짠 로직들에 대해 악감정을 가지고 어떻게 해야 개발자를 골려먹을 수 있을까에 대해 실험해 보았다.

 

결과적으로는 이 로직을 악용하기에 충분했다. 모든 난이도와 중요도를 5점으로 설정해 놓으면 모든 사원이 만점을 받고, 정상적으로 난이도와 중요도를 매긴 타 업체나 타 프로젝트들에 비해 이 사람이 어떤 능력을 가진 사람인지 예상하기 힘들었고, 편애를 받는 사람은 그 사람이 가진 티켓의 난이도나 중요도만 상위 직급에 있는(Subtask나 Project장) 사람이 올려놓으면 다른 사람의 질타를 받기 충분했다.

 

토의


그럼 이 문제를 어떻게 해결할까 하다가 대학교 2학년때 배운 선형대수가 떠올랐다.

바로 'Weight Sum(가중합)'을 이용하는 것이다.

가중합이라고 하면 이름만 어렵지 사실은 우리가 옛날에 배운 기초 수학이다.

먼저, 우리가 여태 사용하던 평균을 구하는 공식을 가지고 예를 들어 보자.

  국어 수학 음악 평균
영준 70 80 90 80
대현 100 100 40 80

영준이와 대현이는 공부를 열심히 했으나 둘 다 평균 80점을 받았다.

그런데 사실 대현이는 불만이 있었다. 수업 시수는 국어와 수학이 압도적으로 많은데, 비교과 내신인 음악 과목 때문에 결국 똑같은 평균 취급을 받아야 한다니..

대현이는 바로 교장선생님께 달려가서 이 부당함을 일렀고, 교장선생님은 이런 부당함을 없애기 위해 조치를 취했다.

바로 평균을 낼 때, 수업 시수를 고려한 평균을 내는 것이다.

  국어 수학 음악 평균
수업 3 5 2  
영준 70 80 90 79
대현 100 100 40 88

영준이는 ( 3 * 70 + 5 * 80 + 2 * 90 ) / (3+5+2) 인 평균 79점이,

대현이는 ( 3 * 100 + 5 * 100 + 2 * 40 ) / (3+5+2) 인 평균 88점이 나오게 됐다.

이렇게 영준이는 조금은 억울하지만 물론 쉬운 음악 과목을 날먹했으므로 억울함은 토로할 수 없었고,

대현이는 중요한 내신인 국어와 수학을 열심히 공부해서 좋은 평균을 냈으니 이보다 더 공정할 수는 없었다.

벡터의 내적또한 사실 가중합 방식과 같다.

이렇게 둘은 공평한 점수를 얻게 되었다.

이것이 가중합 방식이고, 수업 시수는 가중치라고 생각하면 된다.

이 방식을 이용해 조금 더 공평한 점수를 구할 수 있다.

 

적용


이를 적용하여 나름 공평한 시스템을 설계할 수 있었다.

  1. 티켓과 그 티켓을 담고 있는 하나의 '일'인 Task에 대해서 얼마나 진행되었는지를 조사한다.
    1. 티켓이 완료되지 않았다면 이 티켓에 대해 점수를 줄 수 없어야 하고,
    2. 티켓을 처리하여 완료시간이 나왔다면, 전체 진행도에 비해서 이 티켓을 얼마나 빨리 끝냈는가를 체크한다.
  2. 진행도는 '분 단위'를 기준으로 계산한다.
  3. 그리고는 아까 계산한 평균처럼 점수 * 가중치 / 전체를 계산한다.

DTO로 매핑하기 위한 소스코드를 첨부한다. 매핑 프레임워크는 Mapstruct를 사용하였다.

@Named("calculateScore")
default double calculateScore(Ticket ticket) {
    Task task = ticket.getTask();
    if (ticket.getCompletedAt() == null) {
        return 0;
    } else {
        double taskProgress = task.getExpiredAt() == null ? 0.0d : checkTicketDurationWithTask(ticket);
        double difficultyWeight = 4;
        double priorityWeight = 4;
        double taskProgressWeight = 2;
        return (ticket.getDifficulty() / (double)task.getTotalDifficulty()) * difficultyWeight +
            (ticket.getPriority() / (double)task.getTotalPriority()) * priorityWeight +
            taskProgress * taskProgressWeight;
    }
}
/*
 * 1. 티켓과 task의 expiredAt의 자유도를 고려한다.
 */

default double checkTicketDurationWithTask(Ticket ticket) {
    if (ticket.getCompletedAt() == null || ticket.getCompletedAt()
        .isAfter(ticket.getTask().getExpiredAt().plusDays(1).atStartOfDay())) {
        return 0;
    } else {
        long now = ChronoUnit.MINUTES.between(ticket.getCreatedAt(), ticket.getCompletedAt());
        long total = ChronoUnit.MINUTES.between(ticket.getCreatedAt(),
            ticket.getTask().getExpiredAt().plusDays(1).atStartOfDay());
        return now / (double)total;
    }
}

쉽게 예를 들기 위해 가중치를 보이게 설정했으나, Property 파일에 담아서 암호화해줄 수 있다.

 

결론 및 더 발전할 점


이 방식을 이용해서 조금 더 공정한 점수 방식을 채택할 수 있었다.

이 글만 읽어보면 또 개인이 한 태스크에 한 티켓만 만들면 어떡하나요 라는 문제점을 제기할 수도 있지만,

B2B SaaS 프로젝트를 생각하고 만들었기에 1인이 어뷰징하는 일은 상관 없으며,

프로젝트나 태스크를 만들고 티켓의 난이도나 중요도를 설정하는 것은 각자의 역할을 잘 설정함으로서 해결하였다.

이를테면, 프로젝트의 장은 그 프로젝트 아래의 모든 태스크를 총괄할수 있으며, 태스크의 장은 그 태스크 안의 모든 티켓을 총괄할 수 있다. 또 그 태스크 예하에 라벨(팀)을 설정하여 이런저런 설정과 권한을 부여할 수 있다.

 

프로젝트를 조금 더 돌볼 시간이 있었더라면, 개인적으로는 Kafka를 이용해 티켓을 등록할 때마다 태스크 내의 참여인원들에게 SSE로 Noti를 줄 수 있다거나 스케쥴링을 이용해 태스크가 완료되지 않았더라도 실시간 점수를 계산해서 보여주는 시스템을 만들고 싶었다.

 

다음 진행할 사이드 프로젝트에서는 이를 보완하여 조금 더 안전한 시스템을 만들어볼 생각이다.

이 글은 망나니개발자 민규님의 블로그를 참고하여 같은 팀원들에게 설명해주기 쉽게 하기 위해 변경 및 수정하였습니다.

스프링의 다양한 예외 처리 방법

예외 처리는 어플리케이션을 만드는 데 매우 중요한 부분을 차지한다. 스프링 프레임워크는 매우 다양한 예외 처리 방법을 제공하는데, 어떤 방법들이 있고 가장 좋은 방법이 무엇인지 살펴보자.


스프링의 기본적인 예외 처리 방법

우리가 만든 RecipeController에서 생각해보자.

@RequestMapping("/api")  
@RestController  
@RequiredArgsConstructor  
public class RecipeController {  

    private final RecipeService recipeService;  

    @GetMapping("/recipe")  
    public List<RecipeSearchDto> getRecipes(){  
        return recipeService.getRecipes();  
    }
}

getRecipes에서 만약 IllegalArgumentException이 발생했다면 우리는 500에러가 발생했다는 페이지를 받게 된다.

Spring은 만들어질 때 부터 에러 처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해두었다.
그래서 별도의 설정이 없다면 예외 발생 시 BasicErrorController로 에러 처리 요청이 전달된다.
이는 스프링 부트의 WebMvcAutoConfiguration을 통해 자동 설정이 되게 하는 WAS의 설정이다.

여기서 요청이 /error으로 다시 전달된다는 부분에 주목하자. 일반적인 요청 흐름은 다음과 같다.
WAS(톰캣) -> filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러

그리고 컨트롤러 하위에서 예외가 발생했을 때, 별도의 예외 처리를 하지 않으면 WAS까지 에러가 전달된다. 그러면 WAS는 애플리케이션에서 처리를 못하는 예외라고 생각하고, 대응 작업을 진행한다.
컨트롤러(예외 발생) -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣)

그래서 WAS는 스프링 부트가 등록한 에러 설정(/error)에 맞게 요청을 전달하는데, 이러한 흐름을 총 정리하면 다음과 같다.

WAS(톰캣) -> 필터 -> DispatcherServlet -> 인터셉터 -> 컨트롤러 -> '예외 발생' -> 
컨트롤러 -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣) ->
다시 filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러(BasicErrorController)

위의 코드를 보면 위의 일반적인 요청 흐름이 계속 이어진다는 것을 알 수 있다.

결국 기본적인 에러 처리 방식이 결국 '에러 컨트롤러'를 한 번 더 호출하는 것임을 알 수 있다.

그러므로 filter나 인터셉터가 다시 호출되는 작업을 제어하려면 별도의 설정이 필요하다.
Servlet은 DispatcherType으로 요청의 종류를 구분하는데, 일반적인 요청은 REQUEST이며, 에러 처리 요청은 ERROR이다.
filter는 Servlet의 기술이므로 필터 등록(FilterRegisterationBean) 시에 호출될 DispatcherType을 설정할 수 있고, 별도의 설정이 없다면 REQUEST일때만 필터가 호출된다.
하지만 인터셉터는 스프링 기술이므로 DispatcherType을 설정할 수 없어서 별도의 URI 패턴으로 처리가 필요하다.

그러나 Spring Boot로 넘어오게 되면서, 스프링 부트가 WAS까지 직접 제어하게 되면서 이러한 WAS의 에러 설정까지 가능해졌다. 이것은 요청이 2번 생기는 것은 아니고, 1번의 요청이 2번 전달되는 것이다. 그리고 클라이언트는 이런 에러 처리 작업이 진행되었는지 알 수 없다.

BasicErrorController의 동작 및 에러 속성들

BasicErrorController는 accept 헤더에 따라 에러 페이지를 반환하거나 에러 메세지를 반환한다. 에러 경로는 기본적으로 /error로 정의되어 있으며, properties에서 server.error.path로 변경가능하다.

@Controller 
@RequestMapping("${server.error.path:${error.path:/error}}") 
public class BasicErrorController extends AbstractErrorController { 

    private final ErrorProperties errorProperties; 

    ... 
    @RequestMapping(produces = MediaType.TEXT_HTML_VALUE) 
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
         ... 
     } 

     @RequestMapping public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { 
         ... 
         return new ResponseEntity<>(body, status); 
     }

     ... 
 }
  • errorHtml()과 error()는 모두 getErrorAttributeOptions를 호출해 반환할 에러 속성을 얻는데, 기본적으로 DefaultErrorAttributes으로부터 반환할 정보를 가져온다.
  • DefaultErrorAttributes는 전체 항목들에서 설정에 맞춰 알아서 필요한 속성들만 가지고 온다.
    • timestamp: 에러가 발생한 시간
    • status: 에러의 Http 상태
    • error: 에러 코드
    • path: 에러가 발생한 uri
    • exception: 최상위 예외 클래스의 이름(설정 필요함)
    • message: 에러에 대한 내용(설정 필요)
    • errors: BindingException에 의해 생긴 에러 목록(설정 필요)
    • trace: PrintStackTrace(설정 필요)
  • 따라서 다음과 같은 result를 가지고 온다.
  • { "timestamp": "2021-12-31T03:35:44.675+00:00", "status": 500, "error": "Internal Server Error", "path": "/product/5000" }

그러나 이 에러는 클라이언트 입장에서는 유용하지 못하다. 클라이언트는 '어디서' 에러가 터졌는지 보다는 '왜' 에러가 터졌는지가 중요하기 때문이다.
그래서 다음과 같이 properties를 통해 응답을 조정할 수있다.
물론 운영 환경을 생각하면 구현이 노출되는 trace는 제공하지 않는것이 좋다.

server.error.include-message: always 
server.error.include-binding-errors: always 
server.error.include-stacktrace: always 
server.error.include-exception: false

하지만 설정을 변경했음에도 불구하고 status는 여전히 500이고, 유의미한 에러 응답을 전달하지 못한다. (여기서 status가 500인 이유는 에러가 처리되지 않고 WAS가 에러를 전달받았기 때문에 서버에러인 500을 뱉는다.) 또한 흔히 사용되는 API 에러 처리 응답으로는 보다 세밀한 제어가 요구된다. 그러므로 우리는 별도의 에러 처리 전략을 통해 상황에 맞는 응답을 제공해야 한다.

{ 
    "timestamp": "2021-12-31T03:35:44.675+00:00", 
    "status": 500, "error": "Internal Server Error", 
    "trace": "java.util.NoSuchElementException: No value present ...", 
    "message": "No value present", 
    "path": "/product/5000" 
}

와 같이 말이다.

스프링이 제공하는 다양한 예외 처리 방법

JAVA에서는 예외 처리를 위해 try-catch문을 써야 하지만 try-catch를 모든 코드에 붙이는 것은 비효율적이다. Spring은 AOP라는 개념을 통해 에러 처리라는 공통 관심사(Cross-cutting concerns)를 메인 로직으로부터 분리 하는 다양한 예외 처리 방식을 고안하였고, 예외 처리 전략을 추상화한 HandlerExceptionResolver라는 인터페이스를 만들었다.
대부분의 HandlerExceptionResolver는 발생한 Exception을 catch하고 HTTP상태나 응답 메세지 등을 설정한다. 그래서 WAS 입장에서는 해당 요청이 정상적인 응답인 것으로 인식되며, 위에서 설명한 복잡한 WAS 에러 전달(WAS로부터 컨트롤러까지의)이 진행되지 않는다.

public interface HandlerExceptionResolver { 
    ModelAndView resolveException(HttpServletRequest request, 
        HttpServletResponse response, Object handler, Exception ex); 
}

위의 Object 타입인 handler는 예외가 발생한 컨트롤러 객체이다. 예외가 throw되면 DispatcherServlet까지 전달되는데, 적합한 예외 처리를 위해 HandlerExceptionResolver 구현체들을 빈으로 등록해서 관리한다. 그리고 적용 가능한 구현체를 찾아 예외 처리를 하는데, 우선순위대로 아래의 4가지 구현체들이 Bean으로 등록되어 있다.

<현재 상황>

WAS(톰캣) -> 필터 -> DispatcherServlet -> 인터셉터 -> 컨트롤러 -> '예외 발생' ->
                          ⇩ (We are here)
컨트롤러 -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣) ->
다시 filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러(BasicErrorController)
  • DefaultErrorAttributes: 에러 속성을 저장하며 직접 예외를 처리하지는 않는다.
  • ExceptionHandlerExceptionResolver: 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
  • ResponseStatusExceptionResolver: Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함
  • DefaultHandlerExceptionResolver: 스프링 내부의 기본 예외들을 처리한다.

DefaultErrorAttributes는 직접 예외를 처리하지 않고 속성만 관리하므로 성격이 다르다. 그래서 내부적으로 DefuaultErrorAttributes를 제외하고 직접 예외를 처리하는 3가지 ExceptionResolver들을 HandlerExceptionResolverComposite로 모아서 관리한다. 즉, 컴포지트 패턴을 이용해 실제 예외 처리기들을 따로 관리하는 것이다.

Spring은 아래와 같은 도구들로 ExceptionResolver를 동작시켜 에러를 처리할 수 있는데, 각각의 방식에 대해 자세히 살펴보도록 하자.

  1. ResponseStatus
  2. ResponseStatusException
  3. ExceptionHandler
  4. ControllerAdvice, RestControllerAdvice 👈 결국 마지막에 우리가 쓸 것

@ResponseStatus

어노테이션 이름에서 예측가능하듯이, @ResponseStatus는 Error의 HttpStatus를 변경하도록 도와주는 어노테이션이다. @ResponseStatus는 다음과 같은 경우들에 적용가능하다.

  • Exception class 자체
  • 메소드에 @ExceptionHandler와 함께
  • 클래스에 @RestControllerAdvice 와 함께

그래서 우리가 만든 예외 클래스에 다음과 같이 @ResponseStatus로 응답 상태를 지정해 줄 수 있다.

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class NoSuchElementFoundException extends RuntimeException{
    ...
}

그 결과 RepsonseStatusExceptionResolver가 지정해준 상태로 에러 응답이 내려가도록 처리한다.

 { 
     "timestamp": "2021-12-31T03:35:44.675+00:00", 
     "status": 404, 
     "error": "Not Found", 
     "path": "/product/5000" 
 }

하지만 에러 응답에서 볼 수 있듯이 이는 BasicErrorController에 의한 응답이다. 즉, @ResponseStatus를 처리하는 ResponseStatusExceptionResolver는 WAS까지 예외를 전달시키고, 복잡한 WAS의 에러 요청이 진행되는 것이다. 그래서 @ResponseStatus는 다음과 같은 한계점을 가진다.

<현재 상황>

WAS(톰캣) -> 필터 -> DispatcherServlet -> 인터셉터 -> 컨트롤러 -> '예외 발생' ->
컨트롤러 -> 인터셉터 -> DispatcherServlet -> filter -> WAS(톰캣) ->
                                                          ⇩ (We are here)
다시 filter -> DispatcherServlet -> 인터셉터 -> 컨트롤러(BasicErrorController)
  • 에러 응답의 내용(Payload)를 수정할 수 없음 (DefaultErrorAttributes를 수정하면 가능하긴 함)
  • 예와 클래스와 강하게 결합되어 같은 예외는 같은 상태와 에러 메세지를 반환함
  • 별도의 응답 상태가 필요하다면 예외 클래스를 추가해야 됨
  • WAS까지 예외가 전달되고, WAS의 에러 요청 전달이 진행됨
  • 외부에서 정의한 Exception 클래스에는 @ResponseStatus를 붙여줄 수 없음

결국 위와 같은 한계의 이유로 우리는 다른 방법을 이용해야 한다.

ResponseStatusException

외부 라이브러리에서 정의한 코드는 우리가 수정할 수 없으므로 @ResponseStatus를 붙여줄 수 없다. Spring 5부터는 @ResponseStatus의 프로그래밍적 대안으로써 손쉽게 에러를 반환할 수 있는 ResponseStatusException이 추가되었다. ResponseStatuseException은 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있고, Unchecked(Runtime) 예외를 상속받고 있어 명시적으로 에러를 처리해주지 않아도 된다. 이러한 ResponseStatusException은 다음과 같이 사용할 수 있다.

@GetMapping("/recipes/{id}")
public ResponseEntity<Recipes> getRecipe(@Pathvariable Long id){
    try{
        return ResponseEntity.ok(recipeService.getRecipe(id));
    } catch (NoSuchElementFoundException e){
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "레시피가 없습니다");
    }
}

예외가 발생하면 ResponseStatusExceptionResolver가 에러를 처리한다는 점에서 @ResponseStatus와 동일하다. 그러나 ResponseStatusException을 터트리면 다음과 같은 이점이 있다.

  • 기본적인 예외 처리를 빠르게 적용할 수 있으므로 손쉽게 프로토타이핑할 수 있음
  • HttpStatus를 직접 설정하여 예외 클래스와의 결합도를 낮출 수 있음
    • ResponseEntity에서 설정하는 방식과 비슷하다
  • 불필요하게 많은 별도의 예외 클래스를 만들지 않아도 됨
    • 아까 ResponseStatus같은 경우에는 전부 다 클래스를 만들고 @Value(=status)로 지정해 주었음
  • 프로그래밍 방식으로 예외를 직접 생성하므로 예외를 더욱 잘 제어할 수 있음

그렇지만 ResponseStatusException같은 경우도 한계가 있었는데, 다음과 같은 이유 때문에@ExceptionHandler를 사용하는 방식이 더 많이 사용된다.

  • 직접 예외 처리를 프로그래밍하므로 일관된 예외 처리가 어려움
    • 실수라도 일어난다...? ^^
  • 예외 처리 코드가 중복될 수 있음
    • Spring을 사용하면서 AOP적인 처리가 안된다...?
  • Spring 내부의 예외를 처리하는 것이 어려움
  • 예외가 WAS까지 전달되고, WAS의 에러 요청 전달이 진행됨
    • 결국 Controller까지 가는 부분은 똑같잖아!

@ExceptionHandler

ExceptionHandler는 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 기능이다.
다음에서 @ExceptionHandler라는 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있다.

  • 컨트롤러의 메소드 부분에 추가
  • @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드에 추가

예를 들어 컨트롤러의 메소드에 @ExceptionHandler를 추가함으로써 에러를 처리할 수 있다. @ExceptionHandler에 의해 발생한 예외는 ExceptionHandlerExceptionResolver에 의해 처리된다.

@RequestMapping("/api")  
@RestController  
@RequiredArgsConstructor  
public class RecipeController {  

    private final RecipeService recipeService;  

    @GetMapping("/recipe/{id}")  
    public RecipeResponseDto getRecipe(@PathVariable Long id){  
        return recipeService.getRecipe(id);  
    }
                                    ⇩ (We are here)
    @ExceptionHandler(NoSuchElementFoundException.class)
    public ResponseEntity<String> handleNoSuchFoundException{
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
    }
}

@ExceptionHandler는 Exception 클래스를 속성으로 받아 처리할 예외를 지정할 수 있다.
만약 ExceptionHandler 어노테이션에 예외 클래스를 지정하지 않는다면, 파라미터에 설정된 에러 클래스를 처리하게 된다. 또한 @ResponseStatus와도 결합은 가능하지만, ResponseEntity에서도 status를 지정하고 @ResponseStatus도 있다면 ResponseEntity의 status가 우선순위를 갖는다.

ExceptionHandler는 @ResponseStatus와는 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연하다. 예를 들어 응답을 다음과 같이 정의해서 내려준다면 좋을 것이다.

  • code: '어떠한 종류'의 에러가 발생하는지에 대한 에러 코드
  • message: '왜' 에러가 발생했는지?에 대한 설명
  • errors: '어디에서' @Valid에 의한 검증이 실패했는지를 위한 에러 목록
    ☝ 이제 우리가 슬슬 보던 ErrorResponse의 형태가 나오죠?

여기서 code로 Error001, Error002와 같은 값보다는 BAD_REQUEST와 같은 Http 표준 상태와 같이 가독성을 고려해 사용하는것이 FrontEnd의 입장에서도 대응하기 좋고, 유지보수하는 입장에서도 좋다.

@RestController 
@RequiredArgsConstructor 
public class RecipeController { 

    ... 

    @ExceptionHandler(NoSuchElementFoundException.class) 
    public ResponseEntity<ErrorResponse> handleItemNotFoundException(NoSuchElementFoundException exception) { 
        ... 
    } 

    @ExceptionHandler(MethodArgumentNotValidException.class) 
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { 
        ... 
    } 
                            👇          얘랑 얘가 동일해야 한다.           👇
    @ExceptionHandler(Exception.class)                                      👇
    public ResponseEntity<ErrorResponse> handleAllUncaughtException(Exception exception) { 
        ... 
    } 
}

Spring은 예외가 발생하면 가장 구체적인 예외 핸들러를 먼저 찾고, 없으면 부모 예외의 핸들러를 찾는다. 예를 들어 위에서 NPE가 발생했는데 NPEHandler가 없으면 Exception에 대한 처리기가 찾아진다. @ExceptionHandler를 사용할 때 주의할 점은, @ExceptionHandler에 등록된 예외 클래스파라미터로 받는 예외 클래스동일해야 한다는 것이다. 만약 값이 다르면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킨다. (이게 무서워서 우리는 지네릭스를 사용하기도 한다. )
런타임 시점에 다음과 같은 에러 메세지가 출력된다.

java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...] HandlerMethod details: ...

참고 : Exception은 크게 Exception과 그 자손, 그리고 RuntimeException과 그 자손으로 나뉜다.
기준 : Complier가 예외 처리여부를 체크하느냐(Exception) / 체크하지 않느냐(RuntimeException)으로 나누기 때문에 Checked Exception / UncheckedException으로 분류하기도 한다.

ExceptionHandler의 파라미터로 HttpServletRequest나 WebRequest등을 얻을 수 있으며 반환 타입으로는 자유롭게 올 수 있다. 와일드카드를 이용해 타입을 정해줄수도 있다.
@ExceptionHandler는 이러한 장점이 있지만, 다음과 같은 단점도 있다.

  • 컨트롤러에 구현하므로 특정 컨트롤러에서만 발생하는 예외만 처리된다.
  • 컨트롤러에 에러 처리 코드가 섞여서 코드가 지저분해진다.
  • 에러 처리 코드가 중복될 가능성이 높다.
  • AOP스럽지 않다.

그래서 스프링에서는 다음과 같은, 전역적으로(Global) 예외를 처리할 수 있는 좋은 기술을 제공한다.

@ConrollerAdvice, @RestControllerAdvice

Spring은 전역적으로 @ExceptionHandeler를 적용할 수 있는 @ControllerAdvice@RestControllerAdvice를 제공한다. 두 개의 차이는 @Controller@RestController같이 @ResponseBody가 붙어 있어 응답을 JSON으로 내려준다는 점에서 다르다.

@ControllerAdvice와 @RestController의 구성

@Target(ElementType.TYPE) 
@Retention(RetentionPolicy.RUNTIME) 
@Documented 
@ControllerAdvice 
@ResponseBody 
public @interface RestControllerAdvice { 
    ... 
} 

@Target(ElementType.TYPE) 
@Retention(RetentionPolicy.RUNTIME) 
@Documented 
@Component 
public @interface ControllerAdvice {
    ... 
}

ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다. 위에서 보이듯 ControllerAdvice 어노테이션에는 @Component 어노테이션이 있어서 스프링 컨테이너가 뜰 때 자동으로 스프링 빈으로 등록된다. 그래서 우리는 다음과 같은 전역적 에러 핸들링 클래스를 만들어 어노테이션을 붙여주면 에러 처리를 이 클래스에 위임해 줄 수 있다.

@RestControllerAdvice 
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementFoundException.class) 
    protected ResponseEntity<?> handleNoSuchElementFoundException(NoSuchElementFoundException e) { 
    final ErrorResponse errorResponse = ErrorResponse.builder() 
                    .code("Item Not Found") 
                    .message(e.getMessage()).build(); 
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); 
    } 
}

ControllerAdvice는 전역적으로 적용되는데, 만약 특정 클래스에만 제한적으로 적용하고 싶다면 @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있다.

Spring 예외에는 대표적으로 잘못된 URI를 호출하여 발생하는 NoHandlerFoundException등이 있다. Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공하고 있다. ResponseEntityExceptionHandler에는 스프링 예외에 대한 ExceptionHandler가 모두 미리 구현되어 있으므로, ControllerAdvice가 이를 상속받게 하면 된다.
만약 ResponseEntityExceptionHandler를 상속받지 않는다면 스프링 예외들은 DefaultHandlerExceptionResolver가 처리하게 되는데, 그러면 예외 처리기가 달라지므로 클라이언트가 일관되지 못한 에러 응답을 받는 것이 아니게 된다.
그래서 ResponseEntityExceptionHandler 를 상속시키는 것이 좋다. 또한 이는 기본적으로 에러 메세지를 반환하지 않으므로, 스프링 예외에 대한 응답 에러를 보내려면 아래의 메소드를 오버라이딩해야 한다.

public abstract class ResponseEntityExceptionHandler { 

    ... 

    protected ResponseEntity<Object> handleExceptionInternal ( Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request) { 
    ... 
    } 
}

우리는 이런 ControllerAdvice를 사용함으로써 다음과 같은 이점을 누릴 수 있다.

  • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외처리가 가능하다
  • 직접 정의한 에러 응답을 일관성있게 클라이언트에 내려줄 수 있다.
  • 별도의 try-catch문이 없어 코드의 가독성이 높아진다.

이러한 이유로 API에 의한 예외 처리를 할 때에는 ControllerAdvice를 이용하면 평가된다. 하지만 ControllerAdvice를 사용할 때에는 항상 다음의 내용들을 주의해야 한다. 여러 ControllerAdvice가 있을 때 @Order 어노테이션으로 순서를 지정하지 않는다면 Spring은 ControllerAdvice를 임의의 순서로 처리할 수 있으므로 일관된 예외 응답을 위해서는 이러한 점에 주의해야 한다.

  • 한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋다.
  • 만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.
  • 직접 구현한 Exception 클래스들은 한 공간에서 관리한다.

JuBTI 프로젝트에 작성된 ExceptionHandler를 확인해보자.

@Slf4j  
@RestControllerAdvice  
public class GlobalExecptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = { ConstraintViolationException.class, DataIntegrityViolationException.class})  
    protected ResponseEntity<ErrorResponse> handleDataException() {  
        log.error("handleDataException throw Exception : {}", DUPLICATE_RESOURCE);  
        return ErrorResponse.toResponseEntity(DUPLICATE_RESOURCE);  
    }  

    @ExceptionHandler(value = { CustomException.class })  
    protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {  
        log.error("handleCustomException throw CustomException : {}", e.getErrorCode());  
        return ErrorResponse.toResponseEntity(e.getErrorCode());  
    }  
}
  • @RestControllerAdvice를 씀으로써 전역적으로 적용되는 ExceptionHandler라는 것을 확인할 수 있고, 클라이언트는 응답값을 JSON형태로 받을 것이라는 것을 알 수 있다.
  • ResponseEntityExceptionHandler를 상속받음으로써 모든 스프링 예외에 대해 대응할 수 있다.
  • @ExceptionHandler 안에 있는 Exception class를 통해 미루어 볼 때 어느 에러에 대응되는 메소드인지 알 수 있다.
    • ConstraintViolentException : 데이터의 삽입/수정이 무결성 제약 조건을 위반할 때 발생하는 예외, hibernate단에서 발생하는 예외이다.
    • CustomException : 우리가 만든 커스텀한 에러이다.

CustomException과 ErrorResponse에 대해 설명하지 않았으므로, 이제 설명하겠다.
설명하기 전에 Spring의 예외 처리 흐름에 대해 알아보고 출발하자.

Spring의 예외 처리 흐름

다음과 같은 예외 처리기들은 @Configuration 어노테이션을 통해 스프링의 빈으로 등록되어 있고, 예외가 발생하면 순차적으로 다음의 Resolver들이 처리가능한지 판별한 후에 예외가 처리된다.

  1. ExceptionHandlerExceptionResolver: 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함
  2. ResponseStatusExceptionResolver: Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함
  3. DefaultHandlerExceptionResolver: 스프링 내부의 기본 예외들을 처리한다.
    img
    위의 동작방식을 하나하나 뜯어보자.
  4. ExceptionHandlerExceptionResolver가 동작함
    1. 예외가 발생한 컨트롤러 안에 적합한 @ExceptionHandler가 있는가? 를 검사
    2. 컨트롤러의 @ExceptionHandler에서 처리가능하다면 거기서 처리하고, 그렇지 않으면 ControllerAdvice로 넘어간다.
    3. ControllerAdvice 안에 적합한 @ExceptionHandler가 있는지 검사한다.
    4. 처리가능하면 @ExceptionHandler에 Exception.class에 맞는 메소드가 동작한다.
  5. ResponseStatusExceptionResolver가 동작함
    1. @ResponseStatus가 있는지, 혹은 ResponseStatusException인지 검사함
    2. 맞으면 ServeletResponse sendError()로 예외를 서블릿까지 던지고, 서블릿이 BasicErrorController로 요청을 전달한다.
  6. DefaultHandlerExceptionResolver가 동작함
    1. Spring 예외인지 검사하고 맞으면 에러처리, 아니면 넘어감
  7. 적합한 ExceptionResolver가 없으므로 예외가 서블릿까지 전달되고, 서블릿은 스프링 부트가 진행한 Configuration에 맞게 BasicErrorController로 요청을 다시 전달한다.

결국 ExceptionHandler나 ControllerAdvice같이 직접 에러를 반환하는 경우가 아니면 BasicController를 거쳐 에러가 처리되기 때문에 컨트롤러로 요청이 2번씩 가게 된다.

결론 : ControllerAdvice를 이용하는게 최선이다.

RestControllerAdvice를 이용한 Spring 예외 처리 방법

에러 코드 정의하기

우리가 클라이언트에 보내줄 에러 코드를 정의해야 한다. 기본적으로 ErrorName과 HTTP status, message를 가지고 있는 에러 코드 클래스를 만들어 보자. 에러 코드는 애플리케이션에서 전역적으로 사용되는 CommonErrorCode와 특정 도메인에 대해 구체적으로 내려가는 UserErrorCode로 나누고, 인터페이스를 이용해 공통되는 부분만 추상화해두도록 하자.

public interface ErrorCode{

    String name();
    HttpStatus getHttpStatus();
    String getMessage();

}

그리고 발생할 수 있는 에러 코드를 다음과 같이 정의할 수 있다.

@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode{

    INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter included"),
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not exists"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
    ;

    private final HttpStatus httpStatus; 

    private final String message;

}

이제는 우리가 발생할 예외를 처리해줄 예외 클래스(Exception Class)를 추가해주어야 한다. 우리는 Unchecked Exception(RuntimeException)를 상속받는 예외 클래스를 다음과 같이 추가해 줄 수 있다.

@Getter
@RequiredArgsConstructor
public class CustomException extends RuntimeException{

    private final ErrorCode errorCode;

}

Question : 어 근데 아까 예외는 RuntimeException과 Exception으로 나눌 수 있다고 했는데 왜 RuntimeException만 처리해주나요?

Answer : 일반적인 비즈니스 로직들은 따로 catch해서 처리할 것이 없으므로, 만약 checked 예외로 한다면 불필요하게 throws가 생겨야 한다.

참고 : Checked Exception
체크드 예외는 RuntimeException 클래스를 상속받지 않은 예외 클래스들이다. 체크드 예외는 복구 가능성이 있는 예외이므로 반드시 예외를 처리하는 코드를 함께 작성해야 한다. 대표적으로 IOException, SQLException등이 있으며, 예외를 처리하기 위해서는 반드시 catch문으로 잡거나 throws를 통해 메소드 밖으로 던져야 한다. 만약 예외를 처리하지 않으면 컴파일 에러가 발생한다.
체크 예외는 개발자가 실수로 예외 처리를 누락하지 않도록 컴파일러가 도와준다. 하지만 개발자가 모든 체크 예외를 처리해주어야 하므로 번거로우며, 신경쓰지 않고 싶은 예외까지 처리해야 한다는 단점이 있다.
또한 실제로 애플리케이션 개발에서 발생하는 예외들은 복구 불가능한 경우가 많다. 예를 들어 SQLExceptoin과 같은 체크 예외를 catch해도, 쿼리를 수정하여 재배포하지 않는 이상 복구되지 않는다. 그래서 실제 개발에서는 대부분 언체크 예외를 사용한다.

또한 Spring은 내부적으로 발생한 예외를 확인하여 Unchecked Exception(Runtime Exception과 그 자손)이거나 Error라면 자동으로 트랜잭션을 롤백시키도록 처리한다. Spring에서 체크 예외만 롤백을 하지 않는 이유는 처리가 강제되기 때문에 개발자가 어련히 처리해주겠지....하는 기대 때문이다.

에러 응답 클래스 생성하기

우리는 클라이언트에게 다음과 같은 포맷의 에러를 던져주도록 해야 한다고 하자.

{ 
    "code": "INACTIVATE_USER", 
    "message": "User is inactive" 
}

이를 위해 다음과 같은 에러 응답 클래스를 추가해줄 수 있다.

@Getter 
@Builder 
@RequiredArgsConstructor 
public class ErrorResponse { 

    private final String code; 

    private final String message; 

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final List<ValidationError> errors; 

    @Getter 
    @Builder 
    @RequiredArgsConstructor 
    public static class ValidationError { 

        private final String field; 
        private final String message;

        public static ValidationError of(final FieldError fieldError) { 
            return ValidationError.builder() 
                    .field(fieldError
                    .getField()) 
                    .message(fieldError.getDefaultMessage()) 
                    .build();
        }
    } 
}

추가적으로 위의 예외에서는 @Valid를 사용했을 때 에러가 발생한 경우 어느 필드에서 에러가 발생했는지 응답을 위한 ValidationError를 내부의 static class로 추가해두었다. 만약 errors가 없다면 응답으로 내려가지 않도록 @JsonInclude 어노테이션도 추가해주었다.

@RestControllerAdvice 구현하기

이제는 전역적으로 에러를 처리해주는 @RestControllerAdvice 클래스를 추가해주어야 한다. Spring은 스프링 예외를 미리 처리해둔 ResponseExceptionHandler를 추상 클래스로 제공하고 있다. ResponseEntityExceptionHandler에는 스프링 예외에 대한 ExceptionHandler가 모두 구현되어 있으므로 ControllerAdvice 클래스가 이를 상속받게 하면 된다. 하지만 에러 메세지는 반환하지 않으므로 스프링 예외에 대한 에러 응답을 보내려면 아래 메소드를 오버라이딩 해야 한다.

public abstract class ResponseEntityExceptionHandler { 

    ... 

    protected ResponseEntity<Object> handleExceptionInternal( Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request){ 

    ... 
    } 
}

이제는 예제에서 만든 CustomException 예외, @Valid에 의한 유효성 검증에 실패했을 때 발생하는 IllegalArgumentException 예외, 그리고 잘못된 파라미터를 넘겼을 경우 발생하는 IllegalArgumentException 에러를 처리해주도록 하자.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler{

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<Object> handleCustomException(CustomException E){
        ErrorCode errorcode = e.getErrorCode();
        return handleExceptionInternal(errorCode);    
    }

    @ExceptionHandler(IllegalException.class)
    public ResponseEntity<Object> handleIllegalArgumentException
                                        (IllegalArgumentException e){
        log.warn("handleIllegalArgument", e);
        ErrorCode errorcode = commonErrorCode.INVALID_PARAMETER;
        return handleExceptionInternal(errorcode, e.getMessage());
    }

    @Override 
    public ResponseEntity<Object> handleMethodArgumentNotValid( MethodArgumentNotValidException e, HttpHeaders headers, HttpStatus status, WebRequest request) { 
        log.warn("handleIllegalArgument", e); 
        ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER; 
        return handleExceptionInternal(e, errorCode); 
    } 

    @ExceptionHandler({Exception.class}) 
    public ResponseEntity<Object> handleAllException(Exception ex) {
         log.warn("handleAllException", ex); 
         ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; 
         return handleExceptionInternal(errorCode); 
     } 

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode) {
              return ResponseEntity.status(errorCode.getHttpStatus())
                                .body(makeErrorResponse(errorCode)); 
    } 

    private ErrorResponse makeErrorResponse(ErrorCode errorCode) { 
        return ErrorResponse.builder() 
                        .code(errorCode.name())
                        .message(errorCode.getMessage()) 
                        .build(); 
    } 

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode, String message) { 
        return ResponseEntity.status(errorCode.getHttpStatus()) 
                        .body(makeErrorResponse(errorCode, message)); 
    } 

    private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) {
        return ErrorResponse.builder() 
                        .code(errorCode.name()) 
                        .message(message) .build(); 
    } 

    private ResponseEntity<Object> handleExceptionInternal(BindException e, ErrorCode errorCode) { 
        return ResponseEntity.status(errorCode.getHttpStatus())
                             .body(makeErrorResponse(e, errorCode)); 
     } 

     private ErrorResponse makeErrorResponse(BindException e, ErrorCode errorCode) {
        List<ErrorResponse.ValidationError> validationErrorList = e.getBindingResult()
                             .getFieldErrors() 
                             .stream() 
                             .map(ErrorResponse.ValidationError::of) 
                             .collect(Collectors.toList()); 
        return ErrorResponse.builder() 
                        .code(errorCode.name()) 
                        .message(errorCode.getMessage()) 
                        .errors(validationErrorList) 
                        .build(); 
    }

}

CustomException 예외와 IllegalArgumentException의 경우에는 이를 캐치해서 핸들링하는 @ExceptionHandler를 구현해주면 됐다. 하지만 @Valid에 의한 MethodArgumentNotValidException의 경우에는 에러 필드와 메세지를 추가해주어야 하는데, 관련 정보는 MethodNotValidException의 getBindingResult를 통해서 얻을 수 있다.

에러 응답 확인

이제 실제로 우리가 원하는 대로 응답이 내려오는지 확인할 차례이다. 이를 위해 우리의 컨트롤러를 조금 변경했다.

@RequestMapping("/api")  
@RestController  
@RequiredArgsConstructor  
public class RecipeController {  

    private final RecipeService recipeService;  

    @GetMapping("/recipe/{id}")  
    public ResponseEntity<Recipe> getRecipe(){  
        throw new CustomException(UserErrorCode.RECIPE_NOT_FOUND)
    }
}

그리고 해당 API를 호출해보면 다음과 같이 우리가 원하는대로 에러 응답이 내려오는 것을 확인할 수 있다.

{
    "code" : "RECIPE_NOT_FOUND"
    "message" : "레시피가 존재하지 않습니다."
}

흐름 정리

  • 스프링의 기본적인 예외 처리인 BasicErrorController란 무엇이고, 어떻게 호출되는지에 대해 알았다.
  • Controller를 2번 거치지 않는 방법으로 @ResponseStatus와 ResponseStatusException라는 것으로 발전했고, 이것이 무엇이고 어떻게 호출되는지에 대해 알았다.
  • @ResponseStatus와 ResponseStatusException 각각의 한계에 대해 이해했고, 이를 극복하기 위해 @ExceptionHandler라는 것이 왜 나왔는지에 대해 이해했다.
  • @ExceptionHandler의 한계에 대해 이해했고, 이제는 ControllerAdvice와 RestControllerAdvice가 왜 나왔는지, 어떻게 쓰는지에 대해 이해했다.

JuBTI의 ExceptionHandler를 보며 다시 되새기자 :)

  1. 먼저 RuntimeException을 상속받는 CustomException을 만든다.
  2. @Getter public class CustomException extends RuntimeException { private final ErrorCode errorCode; public CustomException(ErrorCode errorCode){ this.errorCode = errorCode; }

}


2. ErrorCode를 Enum type으로 만들어준다.
    1. 이번에는 일정한 도메인에서만 내려 줄 에러코드 없이 전역적으로 ErrorCode를 사용할 것이므로, CommonErrorCode와 UserErrorCode를 나누지 않고, 하나의 ErrorCode Enum에서 사용했습니다.
```java
@Getter  
@AllArgsConstructor  
public enum ErrorCode {  

    /* 400 BAD_REQUEST : 잘못된 요청 */    
    INVALID_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰이 유효하지 않습니다"),  
    MISMATCH_REFRESH_TOKEN(BAD_REQUEST, "리프레시 토큰의 유저 정보가 일치하지 않습니다"),  
    DUPLICATE_USERS(BAD_REQUEST, "중복된 유저가 존재합니다"),
    INVALID_REQUEST(BAD_REQUEST, "잘못된 요청입니다."),  

    /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */    
    INVALID_AUTH_TOKEN(UNAUTHORIZED, "권한 정보가 없는 토큰입니다"),  
    UNAUTHORIZED_MEMBER(UNAUTHORIZED, "현재 내 계정 정보가 존재하지 않습니다"),

    /* 403 FORBIDDEN : 권한 없음*/
    UNAUTHORIZED_USER(FORBIDDEN, "접근 권한이 없습니다."),

    /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */    
    MEMBER_NOT_FOUND(NOT_FOUND, "해당 유저 정보를 찾을 수 없습니다"),  
    EMPTY_CLIENT(NOT_FOUND, "등록된 유저가 없습니다."),  
    NOT_FOUND_CLIENT(NOT_FOUND, "해당 유저를 찾을 수 없습니다."),  
    NOT_FOUND_RECIPE(NOT_FOUND,"해당 레시피를 찾을 수 없습니다."),
    NOT_FOUND_COMMENT(NOT_FOUND, "해당 댓글을 찾을 수 없습니다."),  

    /* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */
    DUPLICATE_RESOURCE(CONFLICT, "데이터가 이미 존재합니다"),  

    ;  

    private final HttpStatus httpStatus;  
    private final String detail;  
}
  1. ErrorCode를 내려줄 수 있는 ErrorResponse class를 만든다
  2. @Getter @Builder public class ErrorResponse { private final int status; // private final String error; // private final String code; private final String message; @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List<ValidationError> errors; public static ResponseEntity<ErrorResponse> of(ErrorCode errorCode) { return ResponseEntity .status(errorCode.getHttpStatus()) .body(ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) // .error(errorCode.getHttpStatus().name()) // .code(errorCode.name()) .message(errorCode.getDetail()) .build() ); } public static ResponseEntity<ErrorResponse> from(ErrorCode errorCode, String message){ return ResponseEntity .status(errorcode.getHttpStatus()) .body(ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) .message(message) .build() ); } @Getter @Builder @RequiredArgsConstructor public static class ValidationError{ private String field; private String message; public static ValidationError of(final FieldError fieldError){ return ValidationError.builder() .field(fieldError.getField()) .message(fieldError.getDefaultMessage()) .build(); } } }

참고 : 우리가 클라이언트에 던져주어야 하는 response 형식은

{
    "status" : 200,
    "message" : "ErrorMessage"
}

와 같은 int 형식의 status code와 string형식의 message를 던져주어야 한다. (JuBTI API명세서 참고)만약에 status가 아닌 "code" : "200"이나 "error" : "NOT_FOUND"라면 String 형식을 이용하자. 또한 밑에 return도 무엇을 선택할지에 따라 바꾸어줄 수 있다.

  1. ErrorResponse를 반환할 수 있는 GlobalExceptionHandler를 만든다.
  2. @Slf4j @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(value = { ConstraintViolationException.class, DataIntegrityViolationException.class}) protected ResponseEntity<ErrorResponse> handleDataException() { log.error("handleDataException throw Exception : {}", DUPLICATE_RESOURCE); return ErrorResponse.of(DUPLICATE_RESOURCE); } @ExceptionHandler(value = { CustomException.class }) protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) { log.error("handleCustomException throw CustomException : {}", e.getErrorCode()); return ErrorResponse.of(e.getErrorCode()); } @ExceptionHandler(value = {IllegalArgumentException.class}) protected ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e){ ErrorCode errorCode = ErrorCode.INVALID_REQUEST; return ErrorResponse.from(errorCode, e.getMessage()); } protected ErrorResponse makeErrorResponse(ErrorCode errorCode){ return ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) .message(errorCode.getDetail()) .build(); } private ResponseEntity<ErrorResponse> handleExceptionInternal(ErrorCode errorCode) { return ResponseEntity.status(errorCode.getHttpStatus()) .body(makeErrorResponse(errorCode)); } private ResponseEntity<ErrorResponse> handleExceptionInternal(ErrorCode errorCode, String message) { return ResponseEntity.status(errorCode.getHttpStatus()) .body(makeErrorResponse(errorCode, message)); } protected ErrorResponse makeErrorResponse(ErrorCode errorCode, String message){ return ErrorResponse.builder() .status(errorCode.getHttpStatus().value()) .message(message) .build(); }

}

- ConstraintViolationException.class, DataIntegrityViolationException.class는 데이터의 무결성과 관련된 Exception으로 데이터 무결성과 관련된 예외입니다. SQL ERROR라고 생각하면 쉽습니다.

5. 원하는 곳에 Error 처리를 해봅시다.
```java
@Service  
@RequiredArgsConstructor  
public class CommentsService {  
    //작성자 권재현  
    private final RecipeRepository recipeRepository;  
    private final CommentsRepository commentsRepository;  
    private final UserRepository userRepository;  

    @Transactional  
    public StatusResponseDto updateComments(Long commentsid, CommentsRequestDto requestDto,String username) {

        User user = userRepository.findByUsername(username).orElseThrow(
            () -> new CustomException(EMPTY_CLIENT)
        );

        Comments comments = commentsRepository.findById(commentsid).orElseThrow(
            ()->new CustomException(NOT_FOUND_COMMENT));  

        if(!comments.getUser().equals(user)){  
            throw new IllegalArgumentException("본인 댓글만 삭제 가능")
        }

        comments.update(requestDto);  

        return StatusResponseDto.success(200,"수정 완료");  
    }  

}

ERD 설계시 Entity 타입은 개개인의 마음대로 primitive type를 받거나 하지만 오직 ID(PK값)만은 모두 Wrapper class로 Long을 선언한다.

type를 int로 설정해줘도 되는데, int도 아니고 Integer도 아니고 Long이다.

왜 그럴까?

먼저 Primitive type과 Wrapper class를 비교해보자.

  • Primitive type
    • 실제 값(data)를 저장한다
    • 객체지향적 언어는 아니다.
    • 성능이 좋다.
    • NPE를 피할 수 있다.(int든 long이든 0으로 잡아줌)
  • Wrapper class
    • 매개변수로 객체를 요구할 때 사용한다.
    • 객체간의 비교가 필요할 때 사용한다.
    • equual()가 오버라이딩 되어있어서 주소값이 아닌 객체가 가지고 있는 값을 비교한다.
    • Collections에서 오토박싱/언박싱을 해주지 않아도 되어서 편리하다.
    • NPE와 마주쳐야 한다. 그러나 primitive type과는 별개로, null을 피할 수 있는 방법이 생각외로 있다.

여기까지만 보면 primitive type을 쓰는 것이 좋을 수도 있다.
그러나 때로는 null을 표기해 주는것이 더 좋을때도 있다. Quantity같은 경우에서 '존재하지 않음'을 표시해야 하는데, primitive class에서는 '존재하지 않음'을 표기할 수가 없기 때문이다.
그러나 단순히 이렇게만 비교해서는 위와 같은 질문의 답을 얻기가 어렵다. 다음 차이점을 보자.

  • Primitive type
    • 선언시 stack 영역에 데이터가 저장된다.
  • Wrapper class
    • 실제 데이터는 Heap 영역에 저장되고, 레퍼런스가 stack에 생긴다.
    • immutable하다.
    • Primitive type는 매번 값을 복사하지만, Wrapper class는 레퍼런스만 복사한다.

여기서 힌트를 얻을 수 있다. JPA의 데이터 타입 분류에서 주의해야 할 점은 immutable한 type를 사용하고, 서로간에 값 타입이 공유되면 안된다고 배웠다. 그러나 래퍼 클래스는 레퍼런스만 복사되기 때문에 공유가 가능하지만 변경이 불가능해서 사용한다.
물론, 객체간 공유 참조는 피해야 한다.

int a = 20;
int b = a;
// 이 경우에는 값이 복사되지만

Integer a = new Integer(20);
Integer b = a;
// 이 경우에는 레퍼런스가 넘어간다.

이제 Wrapper 클래스를 id값의 type에 쓰는 이유는 대충 짐작을 했다. 그럼 왜 Integer가 아니라 Long일까?
id값은 대체적으로 데이터의 개수만큼 생성되는데, Integer값의 한계는 21억밖에 되지 않기 때문에 Long을 사용한다.

싱글톤 패턴이란?

인스턴스를 오직 하나만 제공하는 패턴을 말한다.

시스템 런타임, 환경 세팅에 관한 정보 등 인스턴스가 여러개일때 문제가 생길 수 있는 경우 인스턴스를 하나만 제공한다.

  • 마우스 포인터가 2개일경우 생기는 문제상황
  • 회원 정보를 관리하는 리포지토리
  • 주문 서비스 도우미 등

이럴 때 유의해야 할 사항은 어떤게 있을까?

  • new 연산자를 사용하지 못하게끔 Access Modifier를 private로 설정해 둔다.
  • Global Access를 하려면 getter, setter, 그리고 getInstance를 메서드로 둔다.
  • getInstance 메서드 안에도 new가 함부로 들어가면 안 되니 null check를 꼭 해둔다.

예제

영준이네 동아리 '프로토스' 는 서로 '칼라'라는 중앙 소통 장치로 소통을 한다.

그런 입장에서 칼라가 2개일 경우 서로 소통이 되지 않아 큰 문제가 발생한다. 그래서 칼라는 꼭 단일 개체로 유지해주어야 한다.

public class Protoss{
	public static void main(String[] args){
		Khala khala = Khala.getInstance()
		Khala khala1 = Khala.getInstance()
		System.out.println(khala==khala1)
	}
}

칼라와 칼라1이 같은 인스턴스임을 확인하려면 어떻게 해야 할까?

class Khala{
	private static Khala khala
	public static Khala getInstance(){
		khala = new Khala(); // 매번 new 연산자 돌기때문에 다중분신술됨
	}
}

먼저 이렇게 클래스를 설계해봤다. 여기서 문제가 발생한다.

=> getInstance를 실행할때마다 새로운 칼라 객체가 등장한다.

그래서 칼라 클래스를 다음과 같이 바꿨다.

class Khala{
	private static Khala khala
	public static Khala getInstance(){
		If (khala == null){
			khala = new Khala();
		}
		return khala;
	}
}

칼라 객체가 없을 때만 새로운 객체를 생성하고, 칼라 객체가 있을때는 기존 칼라 객체를 반환했다.

그런데 여기서 또 문제가 발생한다.

=> 우리는 보통 싱글쓰레드가 아닌 멀티쓰레드 환경에서 프로그램을 실행하기 때문에 쓰레드가 2개일 경우 다음과 같은 상황이 발생한다. 거의 속도차이가 없는 쓰레드1과 쓰레드2가 동시에 getInstance() 안으로 들어왔다고 생각해보자.

  • 쓰레드1 : khala == null을 확인하고 새로운 칼라 객체를 반환한다.
  • 쓰레드2 : khala == null을 확인하고 새로운 칼라 객체를 반환한다.

이 상황일경우, 칼라 객체는 2개가 되어버린다. 

 

문제 해결

그러면 이 문제를 어떻게 해결할까?

1. Synchronized

-> getInstance 앞에 Synchronized을 붙여준다.

class Khala{
	private static Khala khala
	public static synchronized Khala getInstance(){
		if (khala == null){
			khala = new Khala();
		}
		return khala;
	}
}

이렇게 되면 위의 멀티쓰레드 문제는 해결했지만, 동기화 과정에서 성능 이슈가 생길 수 있다.

성능을 신경쓰고 싶고, 객체를 만드는 비용이 비싸지 않으면 다음과 같은 과정도 시도해볼 수 있다.

2. 이른 초기화(Eager Initialization)

class Khala{
	private static Khala khala = new Khala()
	public static Khala getInstance(){
		return khala;
	}
}

이렇게 되면 동기화 성능이슈에서도 자유로워질 뿐 아니라 멀티쓰레드 환경에서도 자유롭고, 코드를 조금 더 깔끔하게 쓸 수 있다.

 

3. 이중 체크 잠금(Double Check Locking)

이중 체크 잠금은 1, 2번의 문제를 보완한 방법이다. Synchronized 블럭을 밑에서 사용함으로써 비용을 절감하고, 인스턴스 생성도 필요할 때 해줄 수 있다.

class Khala{
	private static volatile Khala khala;
	
    Khala(){}
	
    public static Khala getInstance(){
		if (khala==null){
			(synchronized(Khala.class){ // 쓰레드 두개가 한번에 들어와도 여기서 걸리게됨
				if(khala==null){
					khala = new Khala()
				}
			}
		}
		return khala;
	}
}

 

 

3번의 이중 체크에서 눈여겨 볼 곳은 바로 volatile이라는 키워드다.

더보기

Volatile을 쓰면 ?
멀티 코어 프로세서에서는 각 코어마다 Cache가 있어서 RAM의 값을 복사해온다.

그러다 보니 메모리의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아

각 Cache 안의 변수값이 Cache마다, 그리고 원본과도 달라질 수가 있다.

따라서 volatile을 붙여 코어의 Cache에서 RAM의 값을 복사하지 말고,

코어가 RAM의 원본을 매번 복사하게 만든다.

나중에 포스팅 하겠으나, 더욱 자세한 설명은

https://www.charlezz.com/?p=45959 을 참조하면 된다.

 

그러나 1,2,3번에도 문제가 있었으니 바로 자바 1.4 이상에서만 동작한다는 것이다.

자바의 핵심은 호환성에 있어서, 정말 크리티컬한 기술이 아니면 자바에서는 호환성이 좋은 코드를 추천한다.

그래서 자바의 추천은 다음과 같다.

 

4. 스태틱 이너 클래스(Static Inner class) 

동기화에서도 안전하고, 자바 전 구간에서 호환되는 방법이다.

public class Khala {
    private Khala(){}

    private static class KhalaHolder{
        private static final Khala khala = new Khala();
    }

    public static Khala getInstance(){
        return Khala.khala;
    }

}

클래스홀더라는 이너클래스를 만들고, 이너클래스에서 객체를 생성해 반환한다.

 

1,2,3,4의 문제점

완벽할 것 같았던 이 싱글톤 방식에도 허점이 있었으니, 바로 자바의 reflection과 serialization/deserialization이다.

Reflection은 쉽게 말하자면 class가 어떻게 구성되어있는지를 몰라도, 클래스의 객체를 생성해버릴수가 있다.

Serialization또한 바이트코드를 건드려 응용 프로그램에서 쓰는 데이터를 네트워크를 통해 전송하거나 DB 또는 파일에 저장 가능한 형식으로 바꾼다.

Deserialization은 외부 소스에서 데이터를 읽고 이를 런타임 객체로 바꾸는 반대 프로세스다.

 

그걸 또 막는 방법?

이 무슨 강퇴->강퇴반사->슈퍼방장같은 flow인가 싶지만, 다 방법이 있다.

1. enum의 사용

enum은 reflection을 못하게 만들기 때문에 reflection에 대해 안전하다.

또한 Serialize를 상속하고 있기 때문에 이또한 방어할 수 있다.

enum Khala{
	khala;
}

enum은 이렇게만 만들어 주면 된다. 그러나 enum에도 문제점이 있으니, 바로 '상속 불가능'하다는 점이다.

또한 클래스가 이미 만들어져 있어서 리소스 낭비가 있을 수 있다. 그러나 이정도 쯤이야...

 

Spring에서의 싱글톤 방식?

스프링에서 ApplicationContext등의 Stateless해야한 객체들은 늘 싱글톤 방식으로 구현해야 한다.

싱글톤 방식으로 구현할때에는 위의 reflection등을 비롯한 여러 문제가 발생하는데, 대표적으로

  • 의존관계상 클라이언트가 구체 클래스에 의존한다 ⇒ DIP 위반
  • 따라서 OCP 위반의 가능성이 높다.

등이 있다. 그러나 스프링 컨테이너가 이와 같은 문제들을 대부분 해결해준다.

@Conflguration과 @Autowired가 그 예이다.

그래서 객체 생성시 getBean으로 내용물을 꺼내오면 언제나 같은 객체가 반환된다. 이는 추후에 스프링 포스팅에서 다시 작성하도록 하겠다.

 

좋은 객체지향설계의 5가지 원칙 : SOLID

  • SRP : 단일 책임 원칙(single responsibility principle)
  • OCP : 개방-폐쇄 원칙(Open/closed principle)
  • LSP : 리스코프 치환 원칙(Liskov substitution principle)
  • ISP : 인터페이스 분리 원칙(Interface segregation principle)
  • DIP : 의존관계 역전 원칙(Dependency inversion principle)

SRP : 단일 책임 원칙

  • 한 클래스는 하나의 책임만 가져야 한다.
  • 하나의 책임이라는것은 모호하지만, 중요한 기준은 변경이다
    • 변경이 있을 때 파급 효과가 적어야 한다.

OCP : 개방/폐쇄 원칙

  • 소프트웨어 요소는 확장에는 열려있으나, 변경에는 닫혀 있어야 한다.
  • 다형성을 활용해 인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현

LSP : 리스코프 치환 원칙

  • 프로그램의 객체는 프로그램의 정확성을 깨트리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
  • 단순히 컴파일에 성공하는 것을 넘어서서, 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것, 그래서 기능적으로 보장이 되어야 한다는 것을 말한다.
  • 하위 클래스의 인스턴스는 상위형 객체 참조변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다

ISP : 인터페이스 분리 원칙

  • 특정 클라이언트를 위한 인터페이스 여러개가 범용 인터페이스 하나보다 낫다.
  • 예를 들어 자동차 인터페이스 → 운전 인터페이스, 정비 인터페이스 등으로 분리
  • 인터페이스가 명확해지고, 대체 가능성이 높아진다.
  • 인터페이스는 여러개를 상속받을 수 있기 때문에 좋은 코드를 만들 수 있다.
    • 그러나 너무 분리하면 코드를 다 까봐야해서 어느정도 선까지 구체화할지, 추상화할지를 정해야 한다.
  • 클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안되고 최대한 인터페이스를 작게 유지해야 한다.

DIP : 의존관계 역전 원칙

  • “추상화에 의존해야 하고, 구체화에 의존하면 안된다”는 말은 여기서 나온다.
  • 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻
  • 역할에 의존해야 한다는 것. 줄리엣은 로미오가 무엇을 하든, 누구든 상관 없어야 하고, 운전자는 K3 자체에 매여서는 안된다는 것이다.

다형성 만으로는 OCP, DIP를 지킬 수 없다.

그럼 뭐가 더 있어야 하는데?

그래서 스프링이 등장했다.

스프링은 DI와 DI 컨테이너로 OCP, DIP를 가능하게 지원한다.

 

좋은 객체지향설계의 5가지 원칙 : SOLID

  • SRP : 단일 책임 원칙(single responsibility principle)
  • OCP : 개방-폐쇄 원칙(Open/closed principle)
  • LSP : 리스코프 치환 원칙(Liskov substitution principle)
  • ISP : 인터페이스 분리 원칙(Interface segregation principle)
  • DIP : 의존관계 역전 원칙(Dependency inversion principle)

SRP : 단일 책임 원칙

어떤 클래스를 변경해야 하는 이유는 오직 하나 뿐이어야 한다.
로버트 C. 마틴(밥 아저씨)
  • 한 클래스(객체)는 하나의 책임만 가져야 한다.
  • 하나의 책임이라는것은 모호하지만, 중요한 기준은 변경이다.
    • 변경이 있을 때 파급 효과가 적어야 한다. (매우 중요)
class Calculator {

    public int calculate(String operator, int firstNumber, int secondNumber) {
        int answer = 0;

        if(operator.equals("+")){
            answer = firstNumber + secondNumber;
        }else if(operator.equals("-")){
            answer = firstNumber - secondNumber;
        }else if(operator.equals("*")){
            answer = firstNumber * secondNumber;
        }else if(operator.equals("/")){
            answer = firstNumber / secondNumber;
        }

        return answer;
    }
}

다음 코드의 문제점은 뭘까?

  1. 첫 번째로, Calculate의 책임감이 너무 무겁다. 혼자 "+"를 연산하는 메서드, "-"를 연산하는 메서드, "*"를 연산하는 메서드, "/"를 연산하는 메서드를 전부 짊어지고 있다.
  2. 두 번째로, 연산 기호가 바뀌었을 때나 각 연산 과정에서 변화가 생겼을 때 다른 연산 과정에도 영향을 끼칠 수 있다.
  3. "/"에서 예외처리가 되지 않았다.

=> 해결 방안

  1. Calculate에는 사칙연산을 실행하는 메서드만 넣는다.
  2. 상세한 내용은 각 클래스로 구현하되, calculate 클래스에서는 각 클래스를 불러온다.
  3. 예외처리를 해준다.
class Calculator {
    private int firstNumber;
    private int secondNumber;
    public Calculator(){
        this.firstNumber = 0;
        this.secondNumber = 0;
    }

    public Calculator(int firstNumber, int secondNumber) {
        this.firstNumber = firstNumber;
        this.secondNumber = secondNumber;
    }

    public int addOperation() {
        AddOperation addOperation = new AddOperation(firstNumber,secondNumber);
        return addOperation.operate();
    }
    public int subtractOperation(){
        SubstractOperation substractOperation = new SubstractOperation(firstNumber, secondNumber);
        return substractOperation.operate();
    }

    public int multiplyOperation(){
        MultiplyOperation multiplyOperation = new MultiplyOperation(firstNumber,secondNumber);
        return multiplyOperation.operate();
    }
    public double divideOperation() {
        DivideOperation divideOperation = new DivideOperation(firstNumber, secondNumber);
        return divideOperation.operate();
    }

    public void setFirstNumber(int firstNumber) {
        this.firstNumber = firstNumber;
    }

    public void setSecondNumber(int secondNumber) {
        this.secondNumber = secondNumber;
    }
}

class DivideOperation{
    private int firstNumber;
    private int secondNumber;

    public DivideOperation(int firstNumber, int secondNumber) {
        this.firstNumber = firstNumber;
        this.secondNumber = secondNumber;
    }

    public void setFirstNumber(int firstNumber) {
        this.firstNumber = firstNumber;
    }

    public void setSecondNumber(int secondNumber) {
        this.secondNumber = secondNumber;
    }

    public double operate(){
        try{
            return firstNumber/secondNumber;
        } catch(ArithmeticException e){
            System.out.println("0으로 나눌수 없습니다. 다시 계산해주세요");
            exit(0);
        }
        return firstNumber/secondNumber;
    }
}

Calculator와 나눗셈 영역만 업로드했다. 나눗셈은 try-catch로 0으로 나눌 때를 대비해 ArithmeticException을 걸렀고,

지금은 아니지만 혹시나 계산기 내의 메서드가 비밀이라고 할 때를 대비해 인자들을 private로 감쌌다.

 

의외로 try-catch문으로 감싸는걸 까먹어서 오래 걸렸다.

REST란?

REpresentational State Transfer의 약자로, 자원(리소스)의 표현에 의한 상태 전달을 하는, HTTP를 잘 활용하기 위해 만든 ‘아키텍쳐’이다. 따라서 REST를 잘 지키지 않는다고 해서 ‘trash code’나 ‘동작하지 않음’ 이런것은 절대 아니다.

다만 내가 RESTful한 API를 설계한다면 다른 crue, 아니면 혹시나 내 업무를 넘겨받을 사람에게는 충분한 도움이 될 것이다.

 

문제 발생

플라스크를 이용한 프로젝트를 설계하고 구현하는 과정에서, 상대방이 시간이 없어 구동 방식이나 완성본을 보지는 못하고 메인 컨트롤러만 확인할 수 있는 상황이었다. 그런데 메인 컨트롤러의 코드가 너무 중구난방이고, URL에서도 이게 어떤 코드인지, 어떻게 동작하는지를 알 수가 없었다.

다음은 app.py에 들어있는 api의 url과 method다.

  URL method 기능
home /    
search_get /search GET 검색 결과를 받아오는 메서드
list_post /playlist POST 플레이리스트에 추가하는 메서드
list_get /playlist GET 플레이리스트를 받아오는 메서드
list_update /playlist PUT 플레이리스트에서 삭제하는 메서드
getlogin /auth/login GET 로그인 화면을 렌더링하는 메서드
login /auth/login POST 로그인을 하는 메서드
logout /logout GET 로그아웃을 하는 메서드
getSignIn /auth/signin GET 회원가입 화면을 렌더링하는 메서드
signIn /auth/signin POST 회원가입을 하는 메서드

제일 문제인 것은, 지금 내가 이 포스트를 쓰면서도 이게 무슨 기능인지 몰라 다시 app.py를 열어봤다는 점이다.

 

해결

CRUD에 맞게 method를 설계했다고 생각은 하지만, directory나 url 설계과정에서는 전혀 RESTful하다고 느껴지지 않는다. Auth나 Playlist같은 directory를 만들어서 따로 관리했어야 했다.

또한 code-convention이 잘 되지도 않았고(snake와 camel의 혼용) 내가 가지고 있는 resource가 무엇인지도 잘 파악되지 않는다.

URL같은 경우에는 users/{id}/playlist같이 동적 라우팅을 해 주는것이 좋다.

지금은 리소스를 활용하는 프로젝트가 아니기도 하고, 코드가 많이 들어가는 복잡한 작업은 아니지만 git을 활용할 때 충분히 conflict 문제가 날 수 있는 문제이다.

그래서 url등의 문제를 다음과 같이 바꿔 준다.

  URL method 기능
home /    
search /users/{id}/search GET 검색 결과를 받아오는 메서드
addList /users/{id}/playlist POST 플레이리스트에 추가하는 메서드
getList /users/{id}/playlist GET 플레이리스트를 받아오는 메서드
updateList /users/{id}/playlist PUT 플레이리스트에서 삭제하는 메서드
getLogin /login GET 로그인 화면을 렌더링하는 메서드
login /login POST 로그인을 하는 메서드
logout /logout GET 로그아웃을 하는 메서드
getSignIn /signin GET 회원가입 화면을 렌더링하는 메서드
signIn /signin POST 회원가입을 하는 메서드

다음 목표는 method를 나누지 않고 methods=['GET', 'POST']로 하는걸로..

+ Recent posts