다음은 예외처리의 흐름을 도식화한 것이다.
이 부분에서 예외가 발생하지 않을시 '정상 응답 반환' 하는 부분에서 이해가 가지 않는 부분이 있었다.
ResponseEntity<>
를 쓰는 이유는 HTTP 상태 코드와 응답 바디를 함께 제어할 수 있기 때문이다.
따라서 예외 상황에서도 JSON 응답 구조를 직접 구성하고, 원하는 상태 코드를 함께 내려보낼 수 있다.
예외 처리를 ResponseEntity
로 처리했던 것은,
해당 예외를 직접 return 하는 것이 아니라 예외를 throw 하는 개념이다.
@GetMapping("/{id}")
public ResponseEntity<MemberResponseDto> getMember(@PathVariable Long id) {
Member member = memberService.findByIdOrElseThrow(id); // 여기서 예외 날 수 있음
return ResponseEntity.ok(new MemberResponseDto(member));
}
이렇게 쓰면, 정상일 땐 200 OK 로 ResponseEntity
가 동작하고,
예외가 발생하면 @ExceptionHandler
나 Spring 기본 에러 처리로 빠진다.
ResponseEntity.method()
- 예외가 아니라 정상적인 흐름에서 응답을 명시적으로 정의하는 것
- 클라이언트의 요청이 올바른 상황에서, 비즈니스 로직 상 실패라고 판단될 때 사용
- 예외를 던지는 것이 아니라, 상태 코드(ex. 404) 를 직접 반환
@GetMapping("/{id}")
public ResponseEntity<MemberResponseDto> getMember(@PathVariable Long id) {
Optional<Member> member = memberService.findById(id);
if (member.isEmpty()) {
return ResponseEntity.notFound().build(); // 비즈니스 로직 내에서 실패 응답을 직접 반환
}
return ResponseEntity.ok(new MemberResponseDto(member.get()));
}
- 예외를 던지는 방식
- 예외가 발생하면, 이를 처리하는
@ExceptionHandler
에서 HTTP 상태 코드와 응답을 정의하고 클라이언트에 보냄- 이때는 예외 상황이므로, 요청 자체가 잘못된 것이 아니거나 비즈니스 로직이 실패했다고 판단되면 예외를 던져
@ExceptionHandler
로 처리
public Member findByIdOrElseThrow(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "없는 유저입니다"));
}
@GetMapping("/{id}")
public ResponseEntity<MemberResponseDto> getMember(@PathVariable Long id) {
Member member = memberService.findByIdOrElseThrow(id); // 예외가 발생할 경우 @ExceptionHandler에서 처리됨
return ResponseEntity.ok(new MemberResponseDto(member));
}
비즈니스 실패나 예측 가능한 오류는 ResponseEntity로 처리
ResponseEntity.status().body()
로 응답예기치 못한 오류는 ResponseStatusException
이나 @ExceptionHandler
를 사용
@ExceptionHandler
가 예외를 잡아서 응답 처리
- 예외(Exception) = 시스템 레벨에서 "예상하지 못한 오류"
- 예: DB 연결 실패, NullPointerException, 존재하지 않는 리소스 접근 등
- 실패 응답(ResponseEntity 400/401/403/422 등) = 비즈니스 로직상 실패지만, 예상 가능한 정상 흐름
- 예: 비밀번호 틀림, 중복된 이메일, 권한 없음 등
: 비밀번호가 틀린 것은 서버 잘못 X
: 사용자 입력의 문제니까 예외를 throw 하는 것이 아니라 실패 응답을 줘도 됨
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequestDto request) {
Member member = memberRepository.findByEmail(request.getEmail());
if (member == null || !member.getPassword().equals(request.getPassword())) {
// ❗ 비즈니스 실패 → 401 반환 (정상 흐름)
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("이메일 또는 비밀번호가 틀렸습니다");
}
// 로그인 성공
return ResponseEntity.ok("로그인 성공");
}
: 이미 있는 이메일로 요청한 건 예상 가능한 상황이니까,
: 굳이 예외로 처리할 필요 없이 409 CONFLICT 응답으로 처리 가능
@PostMapping("/signup")
public ResponseEntity<?> signup(@RequestBody SignupRequestDto request) {
if (memberRepository.existsByEmail(request.getEmail())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body("이미 존재하는 이메일입니다");
}
memberService.createMember(request);
return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공");
}
throw new Exception()
을 하면 실제로 stack trace
를 캡처하고 처리하는 데 비용이 큼이메일 중복 ❌ → 409 CONFLICT 응답으로 충분
비밀번호 틀림 ❌ → 401 UNAUTHORIZED 응답으로 충분
DB 연결 오류 ✅ → 예외
존재하지 않는 ID 조회 ✅ → 보통 예외 던짐 (404 Not Found)
"Exception 은 Controller 밖에서 처리되기 때문에 비용이 크다" 라는 말이 있다.
이것은 예외가 발생하면 실행 흐름이 '정상 루트'에서 벗어나서 스프링 내부의 예외 처리 시스템으로 빠진다는 뜻이다.
throw new RuntimeException("에러 발생");
이런 코드가 실행되면, 자바는 아래와 같은 과정을 밟는다.
try-catch
로 잡지 않으면 --> Spring 이 최상단에서 잡음@ExceptionHandler
, ResponseEntityExceptionHandler
, DispatcherServlet
까지 거슬러 올라가게 됨Java 에서 예외가 비용이 크다는 말은,
try-catch 를 반복적으로 마이크로성능 튜닝할 때와 같은 경우의 고려사항이라고 한다.
HTTP 요청/응답 단위에서 던지는 예외는 비용 측면에서 무시해도 됨
stack trace 가 생성되더라도 예외 처리의 비용은 크지 않다!
HTTP 단위에서는 신경쓰지 말자!
그래서 tight loop 안에서 try-catch 를 수천, 수만 번 반복하면 --> 성능 저하 있음