73. 추상화 수준에 맞는 예외를 던지라
📝 아이템 73: 추상화 수준에 맞는 예외를 던지라
🔹 핵심 요약
✅ 상위 계층에서는 하위 계층의 예외를 그대로 전파하지 말고 추상화 수준에 맞는 예외로 변환하라 ✅ 예외 연쇄를 통해 근본 원인(cause)을 포함시키도록 하여 디버깅과 예외 추적을 용이하게 한다 ✅ 예외 번역(exception translation)은 상위 계층의 이해도를 높이고, 로깅을 활용하면 디버깅에 유용하다. ✅ 가능한 한 저수준 예외가 발생하지 않도록 애초에 예방적으로 설계하는 것이 이상적이다
📚 필수 개념 정리
🔄 예외 번역과 연쇄의 개념
예외 번역(Exception Translation): 저수준 예외를 잡아 현재 추상화 수준에 맞는 예외로 바꿔 던지는 것
예외 연쇄(Exception Chaining): 상위 예외에 원인(cause)이 되는 저수준 예외를 포함시키는 것
⚙️ 예외 연쇄 메커니즘
try {
// 저수준 코드
} catch (LowerLevelException cause) {
// 추상화 수준(상위 계층)에 맞는 예외로 번역하고 원인을 포함
throw new HigherLevelException("고수준 예외 메시지", cause);
}
🚫 예외 처리 안티패턴
❌ 하위 계층 예외를 무작정 전파
// 잘못된 예: 외부에 구현 세부사항 노출
public void businessMethod() throws SQLException {
// JDBC 코드...
}
❌ 예외 연쇄 없이 번역만 수행
// 잘못된 예: 근본 원인 정보 손실
try {
// 저수준 코드
} catch (LowerLevelException e) {
throw new HigherLevelException(); // 원인 정보 누락
}
🔎 예외 번역 패턴 예시
✅ 기본 예외 번역 패턴
/**
* 추상화 수준에 맞는 예외로 번역하는 예시
* - 저수준 SQLException을 애플리케이션 계층에 맞는 예외로 변환
* - 원인(cause)을 연쇄하여 디버깅 정보 보존
*/
public class UserRepository {
public User findUserById(long id) {
try {
// JDBC를 사용한 데이터베이스 조회
Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE id = ?");
stmt.setLong(1, id);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return mapToUser(rs);
} else {
throw new UserNotFoundException("ID가 " + id + "인 사용자를 찾을 수 없습니다.");
}
} catch (SQLException e) {
// 저수준 예외를 적절한 애플리케이션 예외로 번역
throw new DataAccessException("사용자 조회 중 오류 발생", e);
}
}
}
📝 예외 번역 시 고려사항
🔎 예외 번역을 언제 사용해야 할까?
다른 컴포넌트나 라이브러리를 사용할 때(API 경계에서)
계층 간 경계에서(UI → 비즈니스 → 데이터)
외부 라이브러리/프레임워크의 예외를 애플리케이션 예외로 변환할 때
📊 로깅을 활용한 예외 관리
/**
* 로깅과 예외 번역을 함께 사용하는 예시
*/
public class PaymentProcessor {
private static final Logger logger = LoggerFactory.getLogger(PaymentProcessor.class);
public Receipt processPayment(PaymentRequest request) {
try {
// 외부 결제 게이트웨이 API 호출
return paymentGateway.charge(request);
} catch (ApiConnectionException e) {
// 로깅 후 상위 계층에 맞는 예외로 변환
logger.error("결제 게이트웨이 연결 오류", e);
throw new PaymentFailedException("결제 서비스 연결 실패", e);
} catch (ApiAuthException e) {
logger.error("결제 게이트웨이 인증 오류", e);
throw new PaymentFailedException("결제 서비스 인증 실패", e);
} catch (Exception e) {
logger.error("예상치 못한 결제 처리 오류", e);
throw new PaymentFailedException("결제 처리 중 오류 발생", e);
}
}
}
🧠 예외 추상화 베스트 프랙티스
의미 있는 예외 계층 설계
애플리케이션의 도메인과 아키텍처를 반영하는 예외 계층 구조 만들기
예외 계층을 너무 세분화하거나 단순화하지 않도록 주의하기
예외 정보의 충분한 제공
사용자가 문제를 이해하고 해결할 수 있도록 충분한 컨텍스트 정보를 포함
민감한 정보는 제외(보안 고려)
// ⭕ 좋은 예: 충분한 컨텍스트 정보 제공
throw new ResourceNotFoundException(
"Product", "id", productId.toString());
// ❌ 나쁜 예: 정보 부족
throw new ResourceNotFoundException("Not found");
체크 예외 vs 언체크 예외
복구 가능성이 있는 경우에만 체크 예외 사용
대부분의 경우 언체크(런타임) 예외 사용 권장
🌟 실제 사례: Spring의 예외 처리
🔄 Spring의 DataAccessException
Spring Framework는 JDBC의 SQLException을 추상화하여 DataAccessException 계층을 제공합니다.
// Spring JDBC 템플릿 사용 예시
public class SpringJdbcUserRepository implements UserRepository {
private final JdbcTemplate jdbcTemplate;
@Override
public User findById(long id) {
try {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?",
new UserRowMapper(),
id
);
} catch (EmptyResultDataAccessException e) {
// Spring의 예외를 애플리케이션 예외로 변환
throw new UserNotFoundException("ID: " + id, e);
} catch (DataAccessException e) {
// Spring의 다른 데이터 접근 예외 처리
throw new RepositoryException("사용자 조회 실패", e);
}
}
}
🛡️ @ControllerAdvice와 @ExceptionHandler
웹 애플리케이션에서 예외를 일관되게 처리하는 방법입니다:
/**
* Spring MVC에서 전역 예외 처리 예시
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
logger.warn("비즈니스 규칙 위반: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("비즈니스 규칙 위반", e.getMessage()));
}
// 리소스 찾을 수 없음 예외 처리
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFoundException(ResourceNotFoundException e) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("리소스 없음", e.getMessage()));
}
// 데이터 접근 예외 처리
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDataAccessException(DataAccessException e) {
logger.error("데이터 접근 오류", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("시스템 오류", "데이터 처리 중 오류가 발생했습니다"));
}
// 기타 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
logger.error("처리되지 않은 예외", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("시스템 오류", "요청을 처리할 수 없습니다"));
}
}
🔍 예외 번역 회피 전략
🛡️ 예외 발생 예방
예외 번역은 필요하지만, 가능하면 예외 자체가 발생하지 않도록 방지하는 것이 더 좋습니다. 저수준 예외를 방지하기 위해 사전에 검사하는 방법:
/**
* 예외 발생을 사전에 방지하는 패턴 예시
*/
public class FileProcessor {
public String readFileContent(Path path) {
// 사전 검사를 통해 예외 상황 방지
if (!Files.exists(path)) {
throw new ResourceNotFoundException("파일을 찾을 수 없습니다: " + path);
}
if (!Files.isReadable(path)) {
throw new AccessDeniedException("파일 읽기 권한이 없습니다: " + path);
}
try {
// 이제 안전하게 파일 읽기 수행
return Files.readString(path);
} catch (IOException e) {
// 여전히 예상치 못한 IO 오류는 번역 필요
throw new FileProcessingException("파일 읽기 오류", e);
}
}
}
🎯 예외 처리를 잘하기 위한 5가지 핵심 원칙
예외는 발생 자체를 최소화하자
가능한 경우, 예외가 발생하기 전에 사전 조건을 점검하자
예: 입력 값 검증, null 체크, 파일 존재 여부 확인 등
예외는 로깅과 함께!
예외를 잡았다면 반드시 로그를 남겨야 문제 추적이 가능하다
logger.error("에러 메시지", e)
처럼 예외 객체를 포함해서 기록하자
저수준 예외를 그대로 노출하지 말자
DB나 파일 시스템 등의 예외를 서비스나 컨트롤러에서 그대로 다루게 하면 캡슐화가 깨진다
하위 계층 예외는 상위 계층에 맞는 예외로 변환해서 전달하자
예외는 상황에 맞는 메시지와 함께 번역하고, 원인도 함께 담자
예외를 번역할 땐
throw new CustomException("의미 있는 메시지", cause);
처럼 연쇄 예외(cause
)를 꼭 붙여주자이렇게 해야 로그에서 원인을 추적할 수 있다
도메인 중심의 예외 구조를 설계하자
UserNotFoundException
,UnauthorizedAccessException
처럼, 도메인이나 계층에 맞는 예외를 만들어서 읽기 쉽고 관리하기 쉽게 하자
💭 느낀 점
💡 적절한 추상화 수준의 예외는 API 사용자에게 더 의미 있는 피드백을 제공한다
💡 예외 연쇄를 통해 디버깅 정보를 유지하면서도 추상화 수준을 지킬 수 있다는 점이 인상적이다
💡 예외 설계는 애플리케이션 아키텍처의 중요한 부분이며, 잘 설계된 예외 계층은 코드의 가독성과 유지보수성을 크게 향상시킨다
Last updated