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);
        }
    }
}

🧠 예외 추상화 베스트 프랙티스

  1. 의미 있는 예외 계층 설계

    • 애플리케이션의 도메인과 아키텍처를 반영하는 예외 계층 구조 만들기

    • 예외 계층을 너무 세분화하거나 단순화하지 않도록 주의하기

  2. 예외 정보의 충분한 제공

    • 사용자가 문제를 이해하고 해결할 수 있도록 충분한 컨텍스트 정보를 포함

    • 민감한 정보는 제외(보안 고려)

// ⭕ 좋은 예: 충분한 컨텍스트 정보 제공
throw new ResourceNotFoundException(
    "Product", "id", productId.toString());

// ❌ 나쁜 예: 정보 부족
throw new ResourceNotFoundException("Not found");
  1. 체크 예외 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가지 핵심 원칙

  1. 예외는 발생 자체를 최소화하자

    • 가능한 경우, 예외가 발생하기 전에 사전 조건을 점검하자

    • 예: 입력 값 검증, null 체크, 파일 존재 여부 확인 등

  2. 예외는 로깅과 함께!

    • 예외를 잡았다면 반드시 로그를 남겨야 문제 추적이 가능하다

    • logger.error("에러 메시지", e)처럼 예외 객체를 포함해서 기록하자

  3. 저수준 예외를 그대로 노출하지 말자

    • DB나 파일 시스템 등의 예외를 서비스나 컨트롤러에서 그대로 다루게 하면 캡슐화가 깨진다

    • 하위 계층 예외는 상위 계층에 맞는 예외로 변환해서 전달하자

  4. 예외는 상황에 맞는 메시지와 함께 번역하고, 원인도 함께 담자

    • 예외를 번역할 땐 throw new CustomException("의미 있는 메시지", cause);처럼 연쇄 예외(cause)를 꼭 붙여주자

    • 이렇게 해야 로그에서 원인을 추적할 수 있다

  5. 도메인 중심의 예외 구조를 설계하자

    • UserNotFoundException, UnauthorizedAccessException처럼, 도메인이나 계층에 맞는 예외를 만들어서 읽기 쉽고 관리하기 쉽게 하자


💭 느낀 점

💡 적절한 추상화 수준의 예외는 API 사용자에게 더 의미 있는 피드백을 제공한다

💡 예외 연쇄를 통해 디버깅 정보를 유지하면서도 추상화 수준을 지킬 수 있다는 점이 인상적이다

💡 예외 설계는 애플리케이션 아키텍처의 중요한 부분이며, 잘 설계된 예외 계층은 코드의 가독성과 유지보수성을 크게 향상시킨다

Last updated