Spring 프로젝트를 진행하면서 framework 사용법만큼 Java 자체에 대한 이해가 중요하다고 느꼈다. W3Schools의 Java tutorial을 보던 중 두 가지가 궁금해졌다. try-catch 자체가 성능을 떨어뜨리는지, 그리고 local try-catch와 전역 exception handler를 어디서 나눠야 하는지였다.
지금 다시 정리한 답은 이렇다. 복구할 수 있는 실패는 발생 지점 가까이에서 처리하고, HTTP 응답으로 바꾸는 일은 web 경계에서 일관되게 처리한다. 성능 때문에 둘 중 하나를 고르는 문제가 아니다.
try-catch가 있으면 느려질까
“try block은 공짜고 exception을 던질 때만 비싸다”는 문장은 방향을 잡는 데는 도움이 되지만 절대적인 규칙은 아니다. JVM 구현, 최적화 상태, exception type과 stack trace 깊이에 따라 비용이 달라진다.
대체로 정상 경로에서 try block을 통과하는 비용은 작다. 반면 exception을 생성하고 stack trace를 채우며 call stack을 되감는 작업은 반복되면 부담이 될 수 있다. 그래서 exception은 조건문을 대신하는 정상 흐름 제어 수단으로 쓰지 않는다.
// 입력에 숫자가 아닌 값이 흔히 들어온다면 exception보다 먼저 검증한다.
if (!DIGITS.matcher(value).matches()) {
return OptionalInt.empty();
}
return OptionalInt.of(Integer.parseInt(value));
성능이 실제 문제라면 추측으로 try-catch를 없애기보다 production과 비슷한 workload로 profile하거나 JMH 같은 benchmark 도구를 사용해야 한다.
local try-catch가 필요한 경우
호출한 코드가 실패의 의미를 알고 다음 행동을 결정할 수 있을 때 가까이에서 잡는다.
- 다른 저장소나 cache로 fallback할 수 있을 때
- 외부 library의 exception을 application 의미의 exception으로 변환할 때
- retry 가능 여부를 판단할 때
- 일부 작업 실패를 기록하고 다음 항목을 계속 처리해야 할 때
- resource를 정리할 때. 이 경우에는 수동
finally보다 try-with-resources를 우선한다
public Receipt issueReceipt(OrderId orderId) {
try {
return paymentClient.fetchReceipt(orderId);
} catch (PaymentTimeoutException ex) {
throw new ReceiptUnavailableException(orderId, ex);
}
}
여기서 중요한 것은 처리할 수 없는 exception을 잡아 놓고 무시하지 않는 것이다. 의미 있는 복구도, 문맥을 더한 변환도 할 수 없다면 상위 경계로 전달하는 편이 낫다.
@RestControllerAdvice가 맡을 일
REST API에서는 domain·application exception을 안정적인 HTTP status와 response schema로 바꾸는 경계가 필요하다. 여러 controller에 같은 변환 코드를 반복하지 않도록 @RestControllerAdvice와 @ExceptionHandler를 쓸 수 있다.
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setTitle("주문을 찾을 수 없습니다");
problem.setDetail("요청한 주문이 없거나 접근할 수 없습니다.");
problem.setProperty("code", "ORDER_NOT_FOUND");
return problem;
}
}
Spring의 ProblemDetail은 RFC 9457 형식의 error response를 만들 수 있게 한다. client가 분기할 수 있는 안정적인 code, 사용자가 이해할 message, 추적용 request ID 정도를 계약으로 두는 것이 좋다. stack trace, SQL, 내부 host, exception의 원문 message는 그대로 노출하지 않는다.
controller 안의 local @ExceptionHandler와 전역 advice가 모두 일치하면 local handler가 먼저 적용된다. 여러 @ControllerAdvice를 쓸 때는 order와 exception 범위를 명시해 우선순위를 예측 가능하게 만든다.
둘의 역할을 한 표로 정리하면
| 상황 | 처리 위치 | 이유 |
|---|---|---|
| timeout 후 fallback 가능 | 호출 지점의 try-catch |
복구 방법을 아는 곳이기 때문 |
| library exception을 domain exception으로 변환 | service 또는 adapter | 기술 세부사항이 위로 새지 않게 하기 위해 |
| domain exception을 404·409로 변환 | @RestControllerAdvice |
API 응답 계약을 일관되게 유지하기 위해 |
| 예상하지 못한 exception | 가장 바깥의 fallback handler | 내부 정보는 숨기고 500과 추적 ID만 반환하기 위해 |
| request field 검증 실패 | validation handler | field 단위의 안전한 error 형식을 제공하기 위해 |
입력 검증 오류를 만드는 쪽은 Spring @Valid 입력 검증 정리에 이어서 볼 수 있다.
transaction과 logging에서 놓치기 쉬운 점
Spring transaction 안에서 runtime exception을 잡고 정상 반환하면 기본 rollback 조건이 더 이상 밖으로 전달되지 않을 수 있다. catch 후 복구한 것인지, 다시 던질 것인지, 명시적으로 rollback할 것인지 의도해야 한다. “전역 handler가 처리하니 service에서 모두 잡는다”는 구조는 transaction 의미까지 바꿀 수 있다.
logging도 같은 exception을 모든 layer에서 반복 기록하지 않는다. 복구 지점에서는 복구 사실을, 최종 실패 경계에서는 request ID와 업무 문맥을 포함해 한 번 기록한다. level 선택 기준은 로그 레벨과 작성 기준에 정리했다.
결국 local try-catch와 global exception handler는 경쟁 관계가 아니다. 전자는 실패를 복구하거나 의미를 바꾸는 도구, 후자는 외부에 보여 줄 error contract를 통일하는 도구다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| Spring Boot 버전 선택: Java·Jakarta·지원 기간으로 고르는 법 (0) | 2022.12.03 |
|---|---|
| 우아콘 2022 회원 시스템 이벤트 아키텍처: 3개 계층과 Outbox 학습 노트 (0) | 2022.10.21 |
| 서비스 간 호출과 DTO 범위: 계층보다 경계를 먼저 본다 (0) | 2022.10.13 |
| Spring Boot JSON 날짜 형식: @JsonFormat과 전역 설정의 경계 (0) | 2022.10.11 |
| REST API URI 설계: DB 테이블이 아니라 도메인 리소스를 이름 짓는 법 (1) | 2022.10.02 |
댓글