Spring boot 기반 REST API 제작 (4)

Asterisk
8 min readOct 14, 2018

--

PREV: Spring boot 기반 REST API 제작 (3)

앞서 언급했듯이 이번에는 (3)에서 작성한 코드를 바탕으로 어플리케이션 내에서 공통적으로 사용되는 exception을 정의하고 그것을 핸들링하는 ControllerAdvice에 대해 알아보려고 한다.

기본적으로 Spring boot에서 핸들링되지 않은 exception이 발생하면 아래와 같은 형태의 500 Internal Server Error 응답을 내려준다.

{
“timestamp”: 1539495758635,
“status”: 500,
“error”: “Internal Server Error”,
“exception”: “java.lang.RuntimeException”,
“message”: “No message available”,
“path”: “/users/me”
}

그렇기 때문에 예외 상황에 대해 아무런 조치를 취하지 않는다면 클라이언트 입장에선 자신이 보낸 요청에는 문제가 없고 서버에 문제가 발생한 것으로 치부할 가능성이 높다. 클라이언트 담당자와의 원활한 소통과 디버깅을 위해서는 각 예외에 대해 적절한 HTTP 상태 코드와 메시지를 전달해주자.

우선 상황에 따른 예외를 구분하기 위해 RuntimeException을 상속하는 어플리케이션 exception을 만들어보자.
앞서 작성한 기능들에 비춰보면 Authroization 헤더를 통한 인증이 잘못된 경우, 중복된 username 존재하여 가입이 불가능한 경우 정도가 필요할 것 같다.

인증과 관련된 오류는 401 Unauthorized 응답이 적절하기 때문에 아래와 같이@ResponseStatus annotation을 통해 HTTP 상태 코드와 함께 message 설정이 없을 경우 기본으로 사용될 문자열을 정의하였다.

// 인증이 필요한 경우, 혹은 잘못된 인증
@ResponseStatus(value = HttpStatus.UNAUTHORIZED, reason = "Unauthorized")
public class UnauthorizedException extends RuntimeException {

public UnauthorizedException(String message) {
super(message);
}
}

중복된 username의 경우는 409 Conflict 응답 정도가 적절한 느낌?
(400 Bad Request로 정의하는 경우도 간혹 봤는데, 클라이언트와 협의하여 더 적절하다고 생각되는 것으로 지정하면 된다.)

// 이미 존재하는 리소스이기 때문에 중복 생성이 불가능한 경우
@ResponseStatus(value = HttpStatus.CONFLICT, reason = "Already exists")
public class AlreadyExistsException extends RuntimeException {

public AlreadyExistsException(String message) {
super(message);
}
}

이렇게 정의한 exception을 아래와 같이 이전에 구현한 UserService의 가입과 인증 메서드에 적용한다.

// 가입
@Override
public User join(String username, String password) {
User user = userRepository.findByUsername(username);
if (user != null)
throw new AlreadyExistsException(“Duplicate username”);
return userRepository.save(new User(username, password));
}
// 인증 & 개인정보 조회
@Override
public User authentication(String token) {
try {
// authorization으로부터 type과 credential을 분리
String[] split = token.split(“ “);
String type = split[0];
String credential = split[1];
if (“Basic”.equalsIgnoreCase(type)) {
// credential을 디코딩하여 username과 password를 분리
String decoded = new String(Base64Utils.decodeFromString(credential));
String[] usernameAndPassword = decoded.split(“:”);
User user = userRepository.findByUsernameAndPassword(usernameAndPassword[0], usernameAndPassword[1]);
if (user == null)
throw new UnauthorizedException(“Invalid credential”);
return user;
} else {
throw new UnauthorizedException(“Unsupported type: “ + type);
}
} catch (IllegalArgumentException | ArrayIndexOutOfBoundsException ex) {
throw new UnauthorizedException("Invalid credentials");
}
}

가입하지 않은 사용자 정보로 회원 정보 조회를 시도하면 이전과 달리 다음과 같은 401 Unauthorized응답이 내려오는 것을 확인할 수 있다.

{
“timestamp”: 1539500265838,
“status”: 401,
“error”: “Unauthorized”,
“exception”: “io.github.devasterisk.springboot.api.exception.UnauthorizedException”,
“message”: “Unauthorized”,
“path”: “/users/me”
}

마찬가지로 중복된 username으로 가입을 시도할 경우 409 Conflict 응답이 내려온다.

{
“timestamp”: 1539500392954,
“status”: 409,
“error”: “Conflict”,
“exception”: “io.github.devasterisk.springboot.api.exception.AlreadyExistsException”,
“message”: “Already exists”,
“path”: “/users”
}

그런데 RFC 7235에는 401 Unauthorized 응답에는 WWW-Authenticate 헤더를 사용하여 인증 방법을 함께 알려주어야한다고 되어있다.
이를 위해 throw만 하면 Spring이 알아서 잘 처리하던 예외를 수동으로 처리하여 응답헤더를 추가해야한다.

여기서 사용할 수 있는 것이 @ControllerAdvice@ExceptionHandler 이다. @ExceptionHandler만을 사용하여 개별 컨트롤러에서 예외를 처리할 수도 있지만 별도의 클래스에서 전역적으로 예외를 처리할 수 있도록 하겠다.

@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(UnauthorizedException.class)
protected ModelAndView handleUnauthorizedException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {

// 응답헤더에 WWW-Authenticate를 추가
response.setHeader("WWW-Authenticate", "Basic realm=\"Access to user information\"");

// ResponseStatusExceptionResolver를 통해 exception을 응답 모델로 변경
return new ResponseStatusExceptionResolver()
.resolveException(request, response, handler, ex);
}
}

위와 같이 @ExceptionHandler를 통해 대상이 되는 예외 클래스를 지정하면 예외 발생시 해당 메서드를 수행한다.

특정한 예외에 대해 응답헤더를 추가하는 경우보다 별도로 커스텀한 예외 응답 모델로의 변형을 할 때 주로 사용한다.
HTTP 상태 코드와 메시지 등으로는 자세한 상황을 표현하기 어렵기 때문에 사전 정의된 어플리케이션 에러코드와 추가적인 정보를 제공하는 필드 등을 추가한 모델로 변형하여 내려주는 것이 관습화 되어있다. (때문에 위에서 작성한 코드에서처럼 ResponseStatusExceptionResolver를 사용하는 예는 좀처럼찾아보기 힘들다.)

project source: https://github.com/devAsterisk/spring-boot-rest-api-example/tree/master/sample-04

--

--