Spring

Spring 커스텀 예외 만들기와 전역 예외 처리

chungmani 2026. 8. 4. 22:26

Spring으로 회원 조회 API를 구현하면서 존재하지 않는 회원을 조회했을 때 예외를 어떻게 발생시키고, 발생한 예외를 어떻게 처리하는지 학습했다.

처음에는 IllegalStateException을 사용했다.

@Transactional(readOnly = true)
public GetMemberResponse getOne(Long memberId) {
    Member member = memberRepository.findById(memberId)
            .orElseThrow(
                    () -> new IllegalStateException("없는 멤버입니다.")
            );

    return new GetMemberResponse(
            member.getId(),
            member.getName(),
            member.getAge(),
            member.getMbti()
    );
}
 

회원이 존재하지 않는다면 orElseThrow()를 통해 예외가 발생한다.

 

그런데 여기서 중요한 점!

 

예외를 발생시키는 것과 예외를 처리하는 것은 다르다.

 

orElseThrow()는 예외를 던지는 역할을 할 뿐,

클라이언트에게 어떤 HTTP 상태코드를 반환할지 등의 처리를 직접 해주는 것은 아니다.


1. Custom Exception이란?

기존에는 Java에서 제공하는 IllegalStateException을 사용했다.

throw new IllegalStateException("없는 멤버입니다.");
 

하지만 IllegalStateException이라는 이름만으로는 왜 예외가 발생했는지 명확하게 알기 어렵다.

이번 경우는 정확히 말하면 회원(Member)을 찾을 수 없어서 발생한 예외이다.

그래서 해당 상황을 명확하게 표현하는 커스텀 예외를 직접 만들었다.

public class MemberNotFoundException extends IllegalStateException {

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

2. 직접 만든 Custom Exception 사용하기

 

기존의 IllegalStateException을 직접 만든 MemberNotFoundException으로 변경했다.

@Transactional(readOnly = true)
public GetMemberResponse getOne(Long memberId) {
    Member member = memberRepository.findById(memberId)
            .orElseThrow(
                    () -> new MemberNotFoundException("없는 멤버입니다.")
            );

    return new GetMemberResponse(
            member.getId(),
            member.getName(),
            member.getAge(),
            member.getMbti()
    );
}
 

 

 
 

흐름은 다음과 같다.

memberRepository.findById(999)
        ↓
회원이 존재하지 않음
        ↓
Optional.empty()
        ↓
orElseThrow()
        ↓
MemberNotFoundException 발생
 

이제 예외의 이름만 봐도 Member를 찾지 못해서 발생한 문제라는 것을 알 수 있다.


3. 예외 발생과 예외 처리의 차이

.orElseThrow(
    () -> new MemberNotFoundException("없는 멤버입니다.")
);
 

여기까지 작성했다고 해서 직접적인 예외 처리가 끝난 것은 아니다.

 

throw
→ 예외라는 공을 던진다.

ExceptionHandler
→ 날아온 예외를 받아서 처리한다.
 

현재 Service는 MemberNotFoundException을 발생시키는 역할을 한다.

이 예외를 받아서 HTTP 응답으로 변환해주는 별도의 처리가 필요하다.


4. @RestControllerAdvice로 예외 처리하기

Controller에서 발생하여 올라오는 예외를 공통적으로 처리하기 위해 @RestControllerAdvice를 사용할 수 있다.

@RestControllerAdvice
public class MemberExceptionHandler {

}
 

쉽게 생각하면 @RestControllerAdvice는 여러 Controller에서 발생한 예외를 받아 처리하는 중앙 예외 처리 담당자와 비슷하다.

그리고 어떤 예외를 처리할 것인지는 @ExceptionHandler로 지정할 수 있다.

@ExceptionHandler(MemberNotFoundException.class)
 

MemberNotFoundException이 발생하면 이 메서드에서 처리하겠다라는 의미

@Slf4j
@RestControllerAdvice
public class MemberExceptionHandler {

    @ExceptionHandler(MemberNotFoundException.class)
    public ResponseEntity<String> handleMemberNotFound(
            MemberNotFoundException e
    ) {
        log.error("[ERROR] MemberNotFoundException 발생", e);

        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(e.getMessage());
    }
}
 

5. Stack Trace란?

예외가 발생했을 때 단순히 로그에 

없는 멤버입니다.
 

만 출력된다면 어디에서 예외가 발생했는지 찾기가 어렵다.

 

Stack Trace는 예외가 발생한 위치와 해당 위치까지 어떤 메서드들이 호출되었는지를 보여준다.

MemberNotFoundException: 없는 멤버입니다.
    at MemberService.getOne(MemberService.java:...)
    at MemberController.getMember(MemberController.java:...)
    ...
 

 

 

log.error("[ERROR] MemberNotFoundException 발생", e);
 

두 번째 인자로 예외 객체 e 자체를 전달하면 예외 메시지뿐 아니라 Stack Trace도 함께 로그에 출력된다.

 

log.error("[ERROR] {}", e.getMessage());
 

메시지만 출력하면 전체 Stack Trace는 남지 않는다.


오늘의 핵심 정리

1. Custom Exception
   → 상황에 맞는 예외를 직접 정의할 수 있다.

2. orElseThrow()
   → 예외를 발생시킨다.

3. @RestControllerAdvice
   → Controller에서 발생해 올라온 예외를 공통 처리할 수 있다.

4. @ExceptionHandler
   → 어떤 예외를 처리할 것인지 지정한다.

5. 예외 발생 ≠ 예외 처리
   throw → 던지기
   ExceptionHandler → 받아서 처리하기

6. Stack Trace
   → 예외가 어디에서 발생했고 어떤 호출 경로를 거쳤는지 보여준다.

7. log.error("...", e)
   → ERROR 로그와 Stack Trace를 함께 남길 수 있다.