1, 왜 예외 코드가 엉망이 될까?
실무에서 다음과 같은 코드를 본적이 있을 것입니다. 이런 방식의 코드는 Exception을 에러, 언체크 익셉션, 체크 익셉션의 개념을 충분하게 이해하지 못한채 활용하면서 컴파일러의 감시로부터 피하기 위해 민들어진 기술 부채입니다. Error는 메모리 부족이나 하드웨어 공간 부족 등의 시스템이 비정상적으로 돌아가면서 우리가 제어할 수 없는 상황에 사용해야 합니다.
// 예외 블랙홀
try {
...
} catch (Exception e) {}
// 출력만 하고 끝
catch (Exception e) {
e.printStackTrace();
}
// 무의미한 throws 전파
public void method1() throws Exception {
method2();
}
public void method2() throws Exception {
method3();
}
언체크 익셉션과 체크 익셉션을 구분짓는 포인트는 "예외를 던졌을 때 호출하는 쪽에서 다시 처리가 필요한가?"를 기준으로 예외 처리를 강제시하면 됩니다. 예를들어 Network 요청에 실패했을 때 다시 시도한다면 처리가 성공할 때도 있습니다. 하지만 SQLException이나 DuplicateKeyException은 런타임에서 다시 시도하더라도 할 수 있는 일이 없기 때문에 언체크 익셉션으로 예외를 던지는 것이 좋습니다.
2. 스프링의 설계 철학: 왜 런타임 예외인가
스프링의 JdbcTemplate을 보면 SQLException이 사라지고 DataAccessException으로 대체되어 있습니다.
// JDBC 직접 사용
public void deleteAll() throws SQLException {
this.jdbcContext...
}
// JdbcTemplate 사용
public void deleteAll() {
this.jdbcTemplate...
}
스프링이 이렇게 설계한 이유는 단순히 "편해서"가 아닙니다. 복구 불가능한 예외를 체크 예외로 두면 더 나쁜 코드가 양산된다는 철학적 판단입니다.
SQLException이 발생하는 상황을 생각해보면, SQL 문법 오류는 코드를 수정해야 하고, DB 서버 다운이나 커넥션 풀 고갈은 애플리케이션 레벨에서 복구할 방법이 없습니다. 이런 예외를 체크 예외로 강제하면 개발자는 이전 코드처럼 컴파일러를 속이기 위해 의미없는 핸들링을 하게 됩니다.
또한 계층 간 의존성 문제도 있어요. 서비스 계층이 SQLException을 throws하면, 서비스가 JDBC라는 특정 기술에 의존하게 돼요. 나중에 JPA로 바꾸면 서비스 계층 시그니처까지 바꿔야 하는 문제가 생깁니다. 스프링은 이를 DataAccessException이라는 런타임 예외 계층으로 추상화해서 해결했습니다.
3. 현대 스프링의 예외 처리 도구
과거:컨트롤러마다 try-catch를 하여 예외를 핸들링
@Controller
public class UserController {
@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody UserRequest request) {
try {
userService.createUser(request);
return ResponseEntity.ok().build();
} catch (DuplicateUserIdException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "이미 존재하는 사용자입니다."));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "서버 오류가 발생했습니다."));
}
}
@GetMapping("/users/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
try {
// 또 같은 예외 처리 반복...
} catch (Exception e) {
// 중복 코드
}
}
}
이런 패턴은 모든 컨트롤러 메소드에 동일한 예외 처리 로직이 중복되게 됩니다. 에러 응답 형식을 바꾸려면 모든 컨트롤러를 수정해야 하고, 실수로 빠뜨리는 경우도 생겼을 때 버그가 발생할 수 있습니다.
현재: @ControllerAdvice로 전역 처리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateUserIdException.class)
public ResponseEntity<ErrorResponse> handleDuplicateUser(DuplicateUserIdException e) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("USER_001", "이미 존재하는 사용자입니다."));
}
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDataAccess(DataAccessException e) {
log.error("DB 접근 오류", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("DB_001", "데이터베이스 오류가 발생했습니다."));
}
}
// 컨트롤러는 깔끔해짐
@RestController
public class UserController {
@PostMapping("/users")
public ResponseEntity<Void> createUser(@RequestBody UserRequest request) {
userService.createUser(request);
return ResponseEntity.ok().build();
}
}
전역으로 예외를 관리하면서 예외 처리 로직이 한 곳에 모여있어서 관리가 쉽게 되었고, 컨트롤러는 비즈니스 로직에만 집중할 수 있게 되었습니다.
과거: 자체 에러 응답 형식
// 프로젝트마다, 심지어 같은 프로젝트 내에서도 형식이 제각각
{"error": "message"}
{"code": 100, "msg": "error"}
{"status": "fail", "data": null}
이전에는 예외 처리 응답 표준이 없어서 클라이언트 개발자와 매번 형식을 협의해야 했습니다.
현재: Spring 6의 ProblemDetail (RFC 7807)
이제는 RFC 7807 표준을 따르는 응답이 자동으로 생성되도록 하여 여러 프로젝트에도 공통적인 Exception Response을 만들 수 있게 되었습니다.
@ExceptionHandler(DuplicateUserIdException.class)
public ProblemDetail handleDuplicateUser(DuplicateUserIdException e) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problem.setTitle("Duplicate User");
problem.setDetail("이미 존재하는 사용자 ID입니다.");
problem.setProperty("errorCode", "USER_001");
return problem;
}
{
"type": "about:blank",
"title": "Duplicate User",
"status": 409,
"detail": "이미 존재하는 사용자 ID입니다.",
"errorCode": "USER_001"
}
4. 실무 적용 패턴
커스텀 예외 설계 기준
모든 예외를 커스텀으로 만들 필요는 없습니다. 비즈니스적으로 의미 있는 예외 상황(잔액 부족, 중복 가입)이거나 클라이언트에게 구체적인 에러 코드를 내려줘야 할 때 만들면 됩니다. Validation이나 중복 키 생성 등 이미 적절한 스프링 예외가 있거나 단순히 기술적 오류라면 굳이 만들 필요 없습니다.
public class InsufficientBalanceException extends RuntimeException {
private final BigDecimal currentBalance;
private final BigDecimal requestedAmount;
// 복구에 필요한 정보를 담아두면 유용
}
에러 응답 표준화
프로젝트 전체에서 일관된 에러 응답 형식을 정해두는 것이 좋습니다. ProblemDetail을 쓰거나, 자체 ErrorResponse를 정의하되 프로젝트 내에서 통일하는 게 중요합니다.
로깅 위치 결정
로깅은 전역 예외 핸들러에서 한 번만 하는 게 좋은 패턴입니다. 여러 계층에서 로깅하면 같은 예외가 중복 기록되어 어떤 예외인지 판별하기 쉽지 않습니다. 단, 예외를 전환할 때는 원인 예외를 반드시 포함시키는 것이 좋습니다.
'spring' 카테고리의 다른 글
| 토비의 스프링 정복하기 9편 - 서비스의 추상화 (0) | 2026.02.03 |
|---|---|
| 토비의 스프링 8편 - 스프링 예외 처리 실전편 (0) | 2026.01.31 |
| 토비의 스프링 정복하기 6편 - 제어의 역전 (0) | 2026.01.25 |
| 토비의 스프링 정복하기 5편 - 변하는 것과 변하지 않는 것 (0) | 2026.01.21 |
| 토비의 스프링 정복하기 4편 - 테스트 작성하는 법 (1) | 2026.01.17 |