이 글을 쓰게 된 배경
예외 처리 관련 로직에서 어려움을 겪다 지금까지 예외 처리에 대한 전반적인 이해와 기본기가 부족하다는 생각에 기본적인 내용을 다시 공부하며 제가 공부한 내용과 이해한 내용을 바탕으로 작성한 글입니다. 예외의 원리에 대한 내용은 김영한 님의 스프링 DB 1편 - 데이터 접근 핵심 원리 를 참고하였고 실제로 코드에 적용한 부분은 제가 프로젝트에서 직접 사용한 부분을 추출하였습니다.
다룰 내용
- 예외
- 체크 예외
- 언체크 예외
- 실제 적용
예외
자바에서는 Throwable을 예외의 최상위 클래스로 제공합니다. Throwable은 자바의 최상위 클래스 Object를 상속받습니다. Throwable에는 Error와 Exception이 있는데 Error는 메모리 부족이나 시스템 오류 같이 복구 불가능한 예외들을 처리합니다. Error는 잡아서 처리해야하는 것이 아닙니다. 그냥 에러가 터지게 두어야 하는 것입니다. Exception은 의도된 예외로 개발자가 잡아서 처리해야 되는 것입니다. 만약에 Throwable을 catch로 잡으면 하위 예외(Error와 Exception)을 다 잡아버리게 됩니다. 그러면 Error까지 처리해 버리기 때문에 개발자는 Exception부터 예외를 처리해 주면 됩니다.
예외는 기본적으로 둘 중에 하나입니다. 예외를 내가 해결하지 못하고 밖으로 던지거나 예외를 내가 해결하거나 둘 중 하나입니다.
아래는 예외 관련 클래스의 구조도 입니다. 여기 적혀 있는 예외들 이외에도 엄청 많은 예외들이 있습니다.
체크 예외
예외에는 크게 2가지 종류가 있는데 그 중 하나는 체크 예외입니다. 체크 예외는 쉽게 말해서 컴파일러가 체크하는 예외 입니다.
체크예외는 컴파일러가 예외가 정상적으로 처리되는지 확인하고 문제가 있을 시 컴파일 오류를 발생시킵니다.
체크 예외는 무조건 선언을 해주어야 하고 무조건 어디선가 예외를 처리하는 로직이 있어야 합니다.
다음은 체크 예외의 예시입니다. 사진과 같이 체크 예외인 IOException은 이렇게 선언을 해주어야 합니다.
IntelliJ에서는 IOException을 선언하지 않으면 IDE가 예외를 처리해야 한다고 알려줍니다.
체크예외를 사용하는 것에는 각각 장단점이 있습니다.
장점
- 예외를 누락할 수 없음 (컴파일러가 잡아주기 때문)
- 가장 좋은 오류 = 컴파일 오류
단점
- 크게 신경 쓰고 싶지 않은 오류들을 다 신경써야 함
- 의존관계에 따라 너무 과한 경우가 생길 수 있음
- 어차피 해결 못하는데 throw가 너무 많아짐
@Transactional
public void createInfo(MemberInfoRequest memberInfoRequest) throws IOException{
//내 정보 입력 로직
Member loginMember = jwtUtil.getLoginMember();
// 내 정보를 생성하는 로직
...
memberRepository.save(loginMember);
}
내 정보를 만드는 메소드에서 현재 로그인한 유저를 가지고 와야 하는 경우가 있습니다. 로그인 한 유저를 가져오는 상황에서 문제가 생긴 것을 체크 예외로 만들면 여기에서 문제를 해결할 수 없기 때문에 예외를 밖으로 던져야 합니다. 로그인한 유저를 가지고 와야 하는 로직은 대부분의 API에 필요한데 그럼 모든 메소드에 throws IOException을 넣어줘야 한다는 것이 문제입니다.
언체크 예외
언체크 예외는 컴파일러가 체크하지 않는 예외입니다. 체크예외와 반대이기 때문에 장단점도 반대입니다. Exception 중 언체크 예외는 RuntimeException이 있고 Error들도 기본적으로 언체크 예외입니다. Error를 잡을 일은 없기 때문에 거의 런타임예외라는 말이 언체크예외라는 말과 동일시 되는 것 같습니다.
장점
- thorws 선언 안해도 됨
- 신경쓰고 싶지 않은 예외 무시 가능
단점
- 컴파일러가 예외를 잡아주지 않기 때문에 예상하지 못한 예외가 발생할 수 있음
- 개발자가 예외 상황을 인지하고 설계해야함
그렇다면 체크 예외랑 언체크 예외 중에 실제로는 무엇을 써야 하는지 어떤 기준을 가지고 써야 하는지는 정답은 없지만 김영한 님의 강의에서는 기본적으로 런타임 예외(언체크 예외)를 사용하고 비즈니스 상 의도적으로 던져야 하는 예외가 있을 때만 체크 예외를 사용하는 것이 좋다고 합니다.
실제 적용
저는 ControllerAdvice라는 예외 처리를 핸들링 하는 방식을 사용합니다. 이 프로젝트에서는 REST API 방식을 사용하고 있기 때문에 json으로 응답을 주기 위해서 @RestControllerAdvice를 사용하고 있습니다. @ControllerAdvice에 @ResponseBody가 추가되어 있는 것입니다. (다른 어노테이션도 있습니다 @Retention 등...)
@ControllerAdvice란?
AOP를 이용하여 모든 컨트롤러 요청에서 실행되면서 예외를 처리하는 방식입니다.
@RestControllerAdvice
public class CustomControllerAdvice {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) {
ErrorCode errorCode = ex.getErrorCode(); // 예외에서 ErrorCode 추출
ErrorResponse errorResponse = ErrorResponse.from(errorCode); // ErrorResponse 생성
return ResponseEntity
.status(errorCode.getStatus()) // HTTP 상태 코드 설정
.body(errorResponse); // ErrorResponse 반환
}
}
응답코드를 커스텀하고 예외 메시지를 보내주기 위한 커스텀예외입니다. 다른 예외들은 모두 BusinessException을 상속 받아 구현됩니다. 아래의 코드와 같이 BusinessExcpetion을 상속받은 MemberNotFoundException을 던져주고 있습니다. 이렇게 하면 코드 상에서도 Exception을 읽어보면 어떤 예외가 발생했는지 알 수 있어 가독성이 좋아 이런 방식을 채택했습니다.
@Getter
public class BusinessException extends RuntimeException {
private ErrorCode errorCode;
public BusinessException(String message, ErrorCode errorCode) {
super(message);
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
//사용 예시
Member member = memberRepository.findByUsername(username)
.orElseThrow(MemberNotFoundException::new);
만약 하나의 클래스나 메소드에 특정 예외를 발생시키고 싶다면 @ExceptionHandler을 해당 클래스에 적용하면 됩니다
@RestController
@RequestMapping("/api")
public class MemberController {
private final MemberService memberService;
@PostMapping("/first-login")
public ResponseEntity<ResultResponse> firstLogin(@RequestBody CreateNewUserRequest createNewUserRequest) {
memberService.firstLogin(createNewUserRequest);
return ResponseEntity.ok().body(ResultResponse.of(FirstLoginSuccess));
}
// 특정 예외 처리 메서드
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ResultResponse> handleIllegalArgumentException(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(ResultResponse.of(ex.getMessage()));
}
}
만약 이런 식으로 작성 되어있다면 MemberController에서 발생하는 모든 요청(API)에서 일어나는 IllegalArguementException이 아래의 예외 처리 핸들러를 호출해서 예외를 처리합니다.
하지만 이 방식은 이 컨트롤러에서만 사용 가능하고 다른 컨트롤러에서 같은 방식으로 예외를 처리하고 싶으면 같은 코드를 한번 더 작성해야 하기 떄문에 @ExceptionHandler를 @ControllerAdvice에 작성해서 전역으로 처리하는 것이 좋은 방식입니다.
'Spring' 카테고리의 다른 글
엔티티 DTO 변환 위치 (2) | 2025.01.10 |
---|---|
스프링 시큐리티의 구조와 대체 방안 (3) | 2025.01.02 |
Spring Security 필터에서 발생한 인증/인가 예외 처리하는 방법 (5) | 2024.12.30 |
커넥션 풀 vs 복잡한 로직 시간 비용 (6) | 2024.12.22 |
트랜잭션 by JPA (7) | 2024.12.17 |