본문 바로가기
블로그 이미지
Dev-RiQ
Back-end Developer Studying Record
✉️ lwk525678@gmail.com
Back-End/Spring Boot

[Spring Boot] Global Exception, 무지성 try-catch 멈춰!

by Dev-RiQ 2025. 5. 11.

Global Exception Catcher를 이용하여 try-catch 지옥에서 벗어나고,
클라이언트 측에 명시적인 에러 상황을 전달하자.

 

 

  • 왜 Global Exception Catcher 씀?

백엔드, 프론트엔드 모두 편하기 위해 사용했다고 보면된다. 어떻게 보면 백엔드 측에서 에러에 대한 상황과 설명을 사전에 처리하여 프론트엔드 측에서 추가 작업을 하지 않기 위함이고, 백엔드 측에서 좀 귀찮아 지는것 같지만 try-catch를 대부분 없엘 수 있다는 점에서 번거로움이 3000% 정도 감소된다.(아마도)

 

이러한 코드를 작성해 봤을 것이라 생각한다.

User user;
try {
    user = userRepository.findById(1).orElseThrow();
} catch(NoSuchElementException e) {
    log.info(e.printStackTrace())
}
return user;

 

userRepository를 이용하여 DB에서 Id가 1에 해당하는 유저를 찾고, 없다면 NoSuchElementException을 던지도록 하는 메서드이다. 하지만 우리가 Repository를 이용하는 구간이 한 두개도 아니고... 매번 저렇게 처리하기엔 상당히 귀찮다. 그래서 사용하는게 Global Exception Catcher !!!

 

 

  • Global Exception Catcher 준비

 

먼저, 프로젝트 내부에 common 폴더를 생성하고 하위에 exception 폴더를 생성해준다. 이후 예외 클래스, 에러 코드 Enum, ErrorResponse class, GlobalCatcher class 4개를 생성해준다. (이름은 기호에따라 변경해도됨)

(만약 메시지 모르겠고 그냥 캐치처리만하고싶다면 다 필요없고 GlobalCatcher 하나만 만들어도 된다.) 

 

 

  • ErrorCode
@Getter
@AllArgsConstructor
public enum ErrorCode {

    /* 400 BAD_REQUEST : 잘못된 요청 */
    INVALID_PASSWORD(BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
    
    /* 403 FORBIDDEN : 접근 권한 제한 */
    VALID_USER_ID(FORBIDDEN, "해당 정보에 접근 권한이 존재하지 않습니다."),
    
    /* 404 NOT_FOUND : 해당 정보 존재하지 않음 */
    USER_NOT_FOUND(NOT_FOUND, "해당 유저 정보를 찾을 수 없습니다."),

    /* 409 CONFLICT : 중복 데이터 존재 */
    DUPLICATE_USER(CONFLICT, "이미 존재하는 사용자입니다");

    private final HttpStatus httpStatus;
    private final String detail;
}

 

먼저 기본적으로 에러 메시지와 status를 반환할 Enum 객체를 만들어준다. 추후 추가될 기능별 에러들을 모두 여기에 생성할 예정이다. 예시로 몇가지만 적어보았다. 해당 Enum은 HttpStatus, ErrorMessage로 구성되게 설정했고, 클라이언트 사이드에서 요청에 대한 에러사항을 메시지로 바로 확인하며, 사용자에게 전달할 수 있도록 만들었다.

 

  • CustomException
@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;
}

 

다음으로 우리가 처리할 에러들은 모두 RuntimeException 하위의 에러들이기 때문에 상속 처리를 해준 후, 우리가 만든 에러를 담기 위해 ErrorCode를 인자로 사용한다.

 

  • ErrorResponse
@Getter
@EqualsAndHashCode
public class ErrorResponse {
    private final Integer status;
    private final String error;
    private final String message;
    
    public ErrorResponse(HttpStatus status, String error, String message) {
        this.status = status.value();
        this.errors = error;
        this.message = message;
    }
    
    public static ErrorResponse toErrorResponse(ErrorCode errorCode) {
        return new ErrorResponse(
            errorCode.getHttpStatus(),
            errorCode.name(),
            errorCode.getDetail()
        );
    }
}

 

이제 에러를 내용을 담아 보낼 수 있는 response 객체를 생성해준다. ErrorCode 넣으면 JSON 형태로 이쁘게 나가게 하도록 설정해 준다.

 

  • GlobalCatcher
@RestControllerAdvice
public class GlobalCatcher {

    @ExceptionHandler({Exception.class, RuntimeException.class})
    protected ResponseEntity<HttpStatus> catchException(RuntimeException ex) {
        return ResponseEntity.internalServerError()
                             .body(HttpStatus.INTERNAL_SERVER_ERROR);
    }

    //== 커스텀 예외 발생시 ==//
    @ExceptionHandler(CustomException.class)
    protected ErrorResponse handleCustomException(CustomException ex) {
        ErrorCode errorCode = ex.getErrorCode();
        return ErrorResponse.toErrorResponse(errorCode);
    }
    
}

 

@RestControllerAdvice 어노테이션을 적용해준 후, 각 에러별로 @ExceptionHandler를 적용하여 구획을 나누어준다. 여기서는 처리한 CustomException을 제외한 모든 에러를 500번, INTERNAL_SERVER_ERROR로 반환해주도록 설정되어있다. (기호에따라 세분화 및 변경가능)

 

 

  • try-catch 지옥에서 벗어나기

자 그럼 설정한 Global Catcher를 이용해 try-catch 지옥에서 벗어나는 방법을 알아보자!

User user;
try {
    user = userRepository.findById(1).orElseThrow();
} catch(NoSuchElementException e) {
    log.info(e.printStackTrace())
}
return user;

 

위에서 작성했던 기본적인 try-catch문에서 orElseThrow() 안에 우리가 만든 CustomException을 담아주면 catch가 필요없다. 물론 실제로 담지 않고 try-catch문만 제거해도 Global Catcher가 NoSuchElementException을 자동으로 캐치해 처리해 주지만, 좀 더 명시적인 에러 메시지를 전달하기위해 우리는 해당 방식을 사용했기 때문에 담아주는것이 좋다.

(만약 그냥 캐치처리만하고싶다면 위의 클래스들 다 필요없고 GlobalCatcher 하나만 만들어도 된다.) 

 

return userRepository.findById(1)
       .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

 

이러한 방식으로 처리하면 매번 catch할 일이 없어지고, 단순히 만들어둔 ErrorCode를 담아 던지는 것 만으로 해당 에러의 status와 메시지를 전달할 수 있다. 이 얼마나 깔끔하고 편하고 명시적인 에러처리인가 !  꼭.. 써야겠지..?

 

 

  • 주의사항

기능 처리과정에서 해당 결과가 없더라도 계속 진행되어야하는 부분이 있을 수 있다. 예로 채팅방의 경우 채팅 상대방이 탈퇴했더라도 메시지와 채팅방의 내용은 전달되어야한다. 그럴경우를 잘 체크하여 .orElseThrow 대신 .orElse(null) 등을 이용하거나 Optional로 감싸지 않으며 에러를 던지지 않고 진행하는 방식을 고려해야 할 것이다.

 

또한, Repository 내에서 리턴하는 클래스를 Optional<반환클래스> 로 감싸주지 않는다면 .or~ 메서드 체인을 사용할 수 없다. 사용하고 싶다면 꼭 Optional로 감싸주도록하자. 감싸지 않았다면 데이터가 없을 시 자연스럽게 null이 반환될 것이다.

public interface ChatUserRepository extends JpaRepository<ChatUser, Long> {
    ChatUser findByRoomIdAndUserIdNot(Long roomId, Long userId);  // .or~ 사용불가
    Optional<ChatUser> findByRoomIdAndUserIdNot(Long roomId, Long userId);   // .or~ 사용가능
}

 

 

 

블로그 이미지
Dev-RiQ
Back-end Developer Studying Record
✉️ lwk525678@gmail.com