시작하기에 앞서 기존에 했던 방식에서 리팩토링하여 개선된 코드를 살펴보자.
포스트맨으로 테스트 시 반환되는 값을 비교해보자.
다음이 기존에 했던 방식으로 Exception 발생 시 timeStamp(예외 발생 시간)
, message(예외 발생 메세지)
, detail(예외 발생 url)
을 ResponseBody로 클라이언트에게 전달한다.
다음은 리팩토링한 방식으로, success(성공 여부)
, status(상태 코드)
, code(세부 상태 코드)
, message(예외 발생 메시지)
, timeStamp(예외 발생 시간)
, path(예외 발생 url)
를 ResponseBody로 클라이언트에게 전달한다.
** detail 이라는 이름은 유효성 검사 시에 사용하려고 path로 변경
반환되는 값만 가지고 비교했을 때는 큰 차이가 없어보인다. 이번엔 코드를 살펴보자.
package place.skillexchange.backend.exception;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import java.util.List;
//일반화된 예외 객체 생성
public class ErrorResponse {
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class OneDetail{
private Date timeStamp;
private String message;
private String details;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ManyDetails{
private Date timeStamp;
private String message;
private List<String> details;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class SmallDetails{
private String message;
private String details;
}
}
ErrorResponse
라는 객체를 생성하여 예외가 발생한 시간, 예외 메시지, 상세 정보를 담아 일반화된 예외 객체로 사용하였다.
@RestController
@ControllerAdvice //AOP
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(Exception.class) //타 Controller 실행 중 Exception 에러 발생 시 handlerAllExceptions()가 작업 우회
public final ResponseEntity<Object> handlerAllExceptions(Exception ex, WebRequest request) {
ErrorResponse.OneDetail errorResponse = new ErrorResponse.OneDetail(new Date(), ex.getMessage(), request.getDescription(false));
//request.getDescription(false) : 클라이언트에게 상세정보를 보여주지 않을 것
return new ResponseEntity(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); //반환은 ResponseEntity
}
/**
* User를 찾을 수 없음
*/
@ExceptionHandler(UsernameNotFoundException.class) //각 타입마다 별도의 핸들러
public final ResponseEntity<Object> handlerUserNotException(UsernameNotFoundException ex, WebRequest request) {
ErrorResponse.OneDetail errorResponse = new ErrorResponse.OneDetail(new Date(), ex.getMessage(), request.getDescription(false));
return new ResponseEntity(errorResponse, HttpStatus.NOT_FOUND);
}
/**
* 비인증
*/
@ExceptionHandler(UserUnAuthorizedException.class)
public final ResponseEntity<Object> handlerUserUnAuthorizedException(UserUnAuthorizedException ex, WebRequest request) {
ErrorResponse.OneDetail errorResponse = new ErrorResponse.OneDetail(new Date(), ex.getMessage(), request.getDescription(false));
return new ResponseEntity(errorResponse, HttpStatus.UNAUTHORIZED);
}
...(중략)
}
전역 예외 처리
: 애플리케이션 전체의 예외를 중앙에서 처리
사용자 정의 핸들러 클래스에 ResponseEntityExceptionHandler
를 상속
AOP (=Aspect Oriented Programming)
: 어떤 로직을 기준으로 핵심적인 관점이라던가 부가적인 관점을 나눠 보았을 때 그 관점을 기준으로 모듈화 하는 것을 말한다핵심적인 관점이라는 것은 개발자가 적용하고자 하는 핵심 비즈니스 로직을 말한다.
부가적인 관점이라는 것은
1) 핵심 로직을 수용하기 위해서 필요한 데이터베이스 연결이라든가
2) 로깅 작업, 예외 처리 등 어플리케이션 내에서 공통적으로 처리되어야 하는 로직이든가
3) 어떤 조건에 의해 처리되어야 하는 메소드 같은 것, 이런 것들을 부가적인 관점이라고 한다.
@ControllerAdvice
: 특정한 컨트롤러를 등록해 놓으면 모든 컨트롤러가 실행될 때마다 이 @ControllerAdvice 빈을 실행하도록 설정할 수 있다.클래스 안에서 발생하는 예외가 있다면(@ExceptionHandler(Exception.class) Exception.class가 예외라면) 해당 메소드 내 코드가 대체하여 작업을 우회한다.
package place.skillexchange.backend.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.UNAUTHORIZED)
//RunTimeException(500번) 예외 클래스 상속받아서 생성
public class UserUnAuthorizedException extends RuntimeException{
//받은 message를 부모클래스인 RunTimeException에 던짐
public UserUnAuthorizedException(String message) {
super(message);
}
}
UserUnAuthorizedException
와 같이 만든 사용자 정의 예외 클래스는 커스텀 예외 처리 메커니즘에 따라서 위와 동일하게 예외가 발생했을 때 적절한 응답을 제공하기 위해 사용된다.
/**
* 로그인
*/
@Override
public ResponseEntity<UserDto.SignUpInResponse> login(UserDto.SignInRequest dto) {
//authenticationManager가 authenticate() = 인증한다.
try {
//authenticationManager가 authenticate() = 인증한다.
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getId(),
dto.getPassword()
)
);
} catch (AuthenticationException ex) {
// 잘못된 아이디 패스워드 입력으로 인한 예외 처리
throw new UserUnAuthorizedException("잘못된 정보입니다. 다시 입력하세요.");
}
//유저의 아이디 및 계정활성화 유무를 가지고 유저 객체 조회
User user = userRepository.findByIdAndActiveIsTrue(dto.getId());
if (user == null) {
throw new UsernameNotFoundException(String.format("ID[%s]를 찾을 수 없습니다.", dto.getId()));
}
//accessToken 생성
String accessToken = jwtService.generateAccessToken(user);
//refreshToken 생성
RefreshToken refreshToken = refreshTokenService.createRefreshToken(dto.getId());
// 헤더에 access 토큰 추가
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
//쿠키에 refresh 토큰 추가
ResponseCookie responseCookie = ResponseCookie
.from("refreshToken", refreshToken.getRefreshToken())
.secure(true)
.httpOnly(true)
.path("/")
.sameSite("None")
.build();
headers.add(HttpHeaders.SET_COOKIE, responseCookie.toString());
// ResponseEntity에 헤더만 설정하여 반환
return ResponseEntity
.status(HttpStatus.OK)
.headers(headers)
.body(new UserDto.SignUpInResponse(user, 200, "로그인 성공!"));
}
Exception이 발생하는 일부 코드를 가져와 보았다.
잘못된 아이디 패스워드 입력으로 인한 예외 처리 부분을 보면 AuthenticationException
이 발생하면 UserUnAuthorizedException
으로 예외를 던지도록 구현하고 있다.
또한 User를 Repository에서 가져오지 못하여 null일 경우에 UsernameNotFoundException
이 발생하도록 구현하고 있다.
이처럼 Exception이 발생하는 지점마다 UsernameNotFoundException
와 같은 예외 객체 생성 시 메세지를 함께 적어 넘겨주었다.
기존에 만든 방식을 보면 최대한 간단하게 기본적인 틀만 가지고 구현했다고 볼 수 있다.
리팩토링 이전에 변경해야 할 사항들이다.
1. 예외 메세지를 고칠 때 예외 발생 지점까지 찾아야된다는 단점
기존에는 Exception이 발생하는 지점마다 예외 객체 생성 시 메세지를 함께 적어 넘겨주었다.
이는 개발자가 유연하게 생각나는 예외 객체를 적어줄 수 있다는 장점이 있었으나 이는 통일성이 없고, 일일이 찾아야 된다는 단점이 있었다.
2. Exception이 발생할 때마다 GlobalExceptionHandler
에 메서드를 계속 추가해야 된다는 단점
쓸데없이 코드가 길어지며 불필요한 코드가 많아진다고 느꼈다.
이 두 가지 단점을 고치기 위해 자바의 다형성
을 최대한 이용하여 구현하였다.
package place.skillexchange.backend.common.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ErrorReason {
private final Integer status; //상태코드
private final String code; //세부상태코드
private final String message; //에러메세지
}
package place.skillexchange.backend.exception;
import place.skillexchange.backend.common.dto.ErrorReason;
public interface BaseErrorCode {
public ErrorReason getErrorReason();
//모든 에러 코드 enum들이 구현해야 하는 기본 형태
}
package place.skillexchange.backend.exception.user;
import lombok.AllArgsConstructor;
import lombok.Getter;
import place.skillexchange.backend.common.annotation.ExplainError;
import place.skillexchange.backend.common.dto.ErrorReason;
import place.skillexchange.backend.exception.BaseErrorCode;
import java.lang.reflect.Field;
import java.util.Objects;
import static place.skillexchange.backend.common.consts.ConstFields.*;
@Getter
@AllArgsConstructor
public enum UserErrorCode implements BaseErrorCode {
@ExplainError("인증 및 권한이 없는 경우")
USER_TOKEN_EXPIRED(UNAUTHORIZED, "USER_401_1", "토큰이 만료 되었습니다."),
REFRESHTOKEN_NOT_FOUND(UNAUTHORIZED, "USER_401_2", "Refresh Token을 찾을 수 없습니다."),
REFRESHTOKEN_EXPIRED(UNAUTHORIZED, "USER_401_3", "Refresh Token이 만료되었습니다."),
USER_ACCESS_DENIED(FORBIDDEN, "USER_403_1", "접근이 거부되었습니다."),
@ExplainError("사용자 정보를 찾을 수 없는 경우")
USER_NOT_FOUND(BAD_REQUEST, "USER_400_1", "사용자 정보를 찾을 수 없습니다."),
USER_LOGIN_INVALID(BAD_REQUEST, "USER_400_2", "일치하는 로그인 정보가 없습니다."),
EMAIL_SEND_FAILURE(BAD_REQUEST, "USER_400_3", "이메일 전송 중 문제가 생겼습니다."),
WRITER_LOGGEDINUSER_INVALID(INTERNAL_SERVER, "USER_500_1", "로그인한 회원 정보와 글쓴이가 다릅니다."),
ACCOUNT_LOGIN_REQUIRED(NOT_FOUND,"USER_404_1", "계정에 다시 로그인 해야 합니다."),
USER_EMAIL_NOT_FOUND(NOT_FOUND, "USER_404_2", "등록된 계정 중 없는 이메일 주소 입니다.");
private Integer status;
private String code;
private String reason;
public ErrorReason getErrorReason() {
return ErrorReason.builder().message(reason).code(code).status(status).build();
}
}
ErrorReason
클래스와 BaseErrorCode
인터페이스를 만들어 enum 타입의 클래스인 UserErrorCode
에서 상속 및 구현하여 사용하고 있다.
예외 발생 시 메세지를 객체를 만들며 전달하는 것이 아니라, 미리 enum 타입의 클래스에 정의를 해두고 이를 꺼내 쓰는 방식으로 사용하고 있다.
package place.skillexchange.backend.exception.user;
import place.skillexchange.backend.exception.AllCodeException;
public class UserIdLoginException extends AllCodeException {
public static final AllCodeException EXCEPTION = new UserIdLoginException();
private UserIdLoginException() {
super(UserErrorCode.USER_LOGIN_INVALID);
}
}
예시로 UserIdLoginException
을 보면 UserErrorCode
에서 USER_LOGIN_INVALID
을 가져와 메세지와 상태코드, 세부 상태코드 등을 상속 받은 AllCodeException
에 super() 키워드를 활용하여 전달하고 있다.
final 키워드를 통해 정적 예외 인스턴스를 만들어 사용하고 있다.
package place.skillexchange.backend.exception;
import lombok.AllArgsConstructor;
import lombok.Getter;
import place.skillexchange.backend.common.dto.ErrorReason;
@Getter
@AllArgsConstructor
public class AllCodeException extends RuntimeException {
private BaseErrorCode errorCode;
public ErrorReason getErrorReason() {
return this.errorCode.getErrorReason();
}
}
AllCodeException
은 사용자 정의 Exception들의 부모 클래스로 사용되며 이를 활용해 ExceptionHandler
시에 불필요한 코드 없이 한 번의 메서드로 요청에 알맞는 자식 클래스가 호출된다.
package place.skillexchange.backend.common.dto;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class ErrorResponse {
private final boolean success = false;
private final int status;
private final String code;
private final String message;
private final LocalDateTime timeStamp;
private final String path;
public ErrorResponse(ErrorReason errorReason, String path) {
this.status = errorReason.getStatus();
this.code = errorReason.getCode();
this.message = errorReason.getMessage();
this.timeStamp = LocalDateTime.now();
this.path = path;
}
public ErrorResponse(int status, String code, String message, String path) {
this.status = status;
this.code = code;
this.message = message;
this.timeStamp = LocalDateTime.now();
this.path = path;
}
}
ErrorResponse
는 클라이언트에게 반환되는 최종 에러 응답 형식을 정의한다.
생성자가 오버로딩되었기 때문에 상황에 따라 생성자가 호출된다.
@RestController
@ControllerAdvice //AOP
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(Exception.class) //타 Controller 실행 중 Exception 에러 발생 시 handlerAllExceptions()가 작업 우회
public final ResponseEntity<Object> handlerAllExceptions(Exception ex, WebRequest request) {
ServletWebRequest servletWebRequest = (ServletWebRequest) request;
String url =
UriComponentsBuilder.fromUri(
new ServletServerHttpRequest(servletWebRequest.getRequest()).getURI())
.build()
.toUriString();
GlobalErrorCode internalServerError = GlobalErrorCode.INTERNAL_SERVER_ERROR;
ErrorResponse errorResponse =
new ErrorResponse(
internalServerError.getStatus(),
internalServerError.getCode(),
internalServerError.getReason(),
url);
return ResponseEntity.status(HttpStatus.valueOf(internalServerError.getStatus()))
.body(errorResponse);
}
/**
* 전체 eception 처리
*/
@ExceptionHandler(AllCodeException.class)
public ResponseEntity<ErrorResponse> CodeExceptionHandler(
AllCodeException e, HttpServletRequest request) {
BaseErrorCode code = e.getErrorCode();
ErrorReason errorReason = code.getErrorReason();
ErrorResponse errorResponse =
new ErrorResponse(errorReason, request.getRequestURL().toString());
return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus()))
.body(errorResponse);
}
...(중략)
}
'전체 eception 처리'라고 주석처리가 되어있는 곳을 보면 앞서 말했듯이 한 번의 메서드로 AllCodeException
의 자식클래스가 올바르게 호출된다.
/**
* 로그인
*/
@Override
public ResponseEntity<UserDto.SignUpInResponse> login(UserDto.SignInRequest dto) {
try {
//authenticationManager가 authenticate() = 인증한다.
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getId(),
dto.getPassword()
)
);
} catch (AuthenticationException ex) {
// 잘못된 아이디 패스워드 입력으로 인한 예외 처리
throw UserIdLoginException.EXCEPTION;
}
//유저의 아이디 및 계정활성화 유무를 가지고 유저 객체 조회
User user = userRepository.findByIdAndActiveIsTrue(dto.getId());
if (user == null) {
throw UserIdLoginException.EXCEPTION;
}
//accessToken 생성
String accessToken = jwtService.generateAccessToken(user);
//refreshToken 생성
RefreshToken refreshToken = refreshTokenService.createRefreshToken(dto.getId());
// 헤더에 access 토큰 추가
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
//쿠키에 refresh 토큰 추가
ResponseCookie responseCookie = ResponseCookie
.from("refreshToken", refreshToken.getRefreshToken())
.secure(true)
.httpOnly(true)
.path("/")
.sameSite("None")
.build();
headers.add(HttpHeaders.SET_COOKIE, responseCookie.toString());
// ResponseEntity에 헤더만 설정하여 반환
return ResponseEntity
.status(HttpStatus.OK)
.headers(headers)
.body(new UserDto.SignUpInResponse(user, 200, "로그인 성공!"));
}
리팩토링 이전 코드와 비교했을 때 동일 코드인데 예외처리 부분만 변경하였다.
잘못된 아이디 패스워드 입력으로 인한 예외 처리 부분을 보면 AuthenticationException
이 발생하면 UserIdLoginException
으로 예외를 던지도록 바꾸었다.
또한 User를 Repository에서 가져오지 못하여 null일 경우에 UserIdLoginException
이 발생하도록 바꿨다.
static final로 선언되어 있기 때문에 싱글톤 예외 인스턴스
로 작용한다.
클래스가 로드될 때 한 번만 생성되고, 이후에는 이 인스턴스를 계속 재사용하여 매번 새 인스턴스를 생성할 필요 없이 throw UserIdLoginException.EXCEPTION;만으로 충분하다.
이전 코드와 비교했을 때, 리팩토링하고자 했던 모든 부분이 해결되었다.
1. 예외 발생 지점까지 와서 예외 메세지를 확인할 필요 없이 ErrorCode를 모아놓은 Enum 타입의 클래스를 확인하면 된다는 점 (어떤 에러들을 User 도메인에서 사용했는지 한 눈에 확인 가능)
2. ExceptionHandler에 AllCodeException만 @ExceptionHandler로 메서드를 추가해주면, 자식 클래스로부터 발생한 예외도 자동으로 처리된다는 점
사실 프론트엔드와 협업 하면서 예외 처리가 조금 부족한 것 같다는 피드백을 받았었다.
이대로 넘어가기엔 추후에 동일한 문제가 또 발생할 것 같아 제대로 짚고 넘어가자고 정했고, 에러핸들링 처리를 더 정확하고, 세부적으로 기입하려고 노력하였다.
결과적으로 프론트엔드도 "이렇게 결과가 오기를 바랬어요!"라는 평을 들었고 스스로 해결했다는 성취감이 들었다.
문제 발생 시 찾아보고, 공부하고 하는 과정을 즐기고 있는 자신을 발견할 수 있었다. 🤭