Java의 예외 처리는 단순한 try-catch 문법을 넘어, 시스템의 견고성과 유지보수성을 결정하는 설계 영역입니다. 예외 계층 구조부터 커스텀 예외 설계, Spring의 예외 전략까지 완전히 정리합니다.


1. 예외 계층 구조

전체 계층도

Throwable
    ├── Error                          (복구 불가 시스템 오류)
    │     ├── OutOfMemoryError
    │     ├── StackOverflowError
    │     ├── VirtualMachineError
    │     └── AssertionError
    │
    └── Exception                      (프로그램 예외)
          ├── IOException              (Checked)
          │     ├── FileNotFoundException
          │     └── SocketException
          ├── SQLException             (Checked)
          ├── ClassNotFoundException   (Checked)
          ├── CloneNotSupportedException (Checked)
          │
          └── RuntimeException         (Unchecked)
                ├── NullPointerException
                ├── ArrayIndexOutOfBoundsException
                ├── ClassCastException
                ├── IllegalArgumentException
                │     └── NumberFormatException
                ├── IllegalStateException
                ├── UnsupportedOperationException
                ├── ConcurrentModificationException
                └── ArithmeticException

Error

// Error — JVM 레벨 문제, 절대 catch하지 말 것
try {
    recurse();
} catch (StackOverflowError e) {
    // 의미 없음 — 스택이 이미 가득 참
    // 복구 불가
}

// 올바른 대응
// → 로직 수정, 메모리/스택 설정 조정
// → catch (Throwable e) 도 피할 것

2. Checked vs Unchecked 예외

Checked 예외 (확인된 예외)

// Exception을 직접 상속 (RuntimeException 제외)
// 컴파일러가 처리를 강제

public void readFile(String path) throws IOException {
    // 반드시 throws 선언 또는 try-catch
    FileReader fr = new FileReader(path);  // FileNotFoundException (Checked)
    // ...
}

// 호출 측도 처리해야 함
try {
    readFile("data.txt");
} catch (IOException e) {
    System.err.println("파일 읽기 실패: " + e.getMessage());
}

Unchecked 예외 (RuntimeException)

// RuntimeException 상속
// 컴파일러가 처리를 강제하지 않음

public int divide(int a, int b) {
    if (b == 0) throw new ArithmeticException("0으로 나눌 수 없음");
    return a / b;
}

// 호출 측이 선택적으로 처리
divide(10, 0);  // 예외 전파 (선택)

설계 철학: 언제 무엇을 쓸까

Checked 예외:
- 호출 측에서 복구 가능한 상황
- 외부 환경 의존 (파일, 네트워크, DB)
- 예외 발생이 충분히 예측 가능하고 처리가 의미 있음

Unchecked 예외:
- 프로그래밍 오류 (버그)
- 잘못된 인수, null 전달, 잘못된 상태
- 호출 측에서 사전 체크로 방지 가능

실무 트렌드:
- Checked 예외 → 점점 기피 (Spring, Hibernate 모두 Unchecked 선호)
- 이유: 예외 전파 시 모든 중간 계층이 throws 선언해야 하는 부담
- 이유: 함수형 프로그래밍(람다/Stream)과 Checked 예외 충돌
// Checked 예외와 Stream의 충돌
List<String> paths = List.of("a.txt", "b.txt");

// 컴파일 에러! — IOException은 Checked
paths.stream()
     .map(p -> new FileReader(p))  // IOException 처리 강요
     .collect(toList());

// 해결: Unchecked로 래핑
paths.stream()
     .map(p -> {
         try { return new FileReader(p); }
         catch (IOException e) { throw new RuntimeException(e); }
     })
     .collect(toList());

// 또는 유틸리티
@FunctionalInterface
interface ThrowingFunction<T, R> {
    R apply(T t) throws Exception;

    static <T, R> Function<T, R> wrap(ThrowingFunction<T, R> f) {
        return t -> {
            try { return f.apply(t); }
            catch (Exception e) { throw new RuntimeException(e); }
        };
    }
}

paths.stream()
     .map(ThrowingFunction.wrap(FileReader::new))
     .collect(toList());

3. try-catch-finally 동작

기본 구조

try {
    // 예외 발생 가능 코드
    int result = riskyOperation();
} catch (NullPointerException e) {
    // 특정 예외 처리
    System.err.println("NPE: " + e.getMessage());
} catch (IllegalArgumentException e) {
    // 또 다른 예외 처리
    System.err.println("잘못된 인수: " + e.getMessage());
} catch (Exception e) {
    // 상위 타입 — 더 구체적인 catch 뒤에 위치
    System.err.println("기타 예외: " + e.getMessage());
} finally {
    // 예외 여부와 무관하게 항상 실행
    cleanup();
}

finally 실행 보장

// return이 있어도 finally 실행
public int test() {
    try {
        return 1;
    } finally {
        System.out.println("finally 실행");  // 항상 출력
        // return 2;  // 이렇게 하면 finally의 return이 이김!
    }
}

// 예외가 발생해도 finally 실행
try {
    throw new RuntimeException("에러");
} finally {
    System.out.println("finally 실행");  // 출력 후 예외 전파
}

finally에서 예외 발생 시

try {
    throw new RuntimeException("원래 예외");
} finally {
    throw new RuntimeException("finally 예외");  // 원래 예외가 사라짐!
}
// → "finally 예외"만 전파 (원래 예외 소멸)
// 이 때문에 finally에서 예외를 던지면 안 됨

catch 순서

// 잘못된 순서 — 컴파일 에러
try { }
catch (Exception e) { }     // 상위 먼저
catch (IOException e) { }   // 컴파일 에러! 도달 불가 코드

// 올바른 순서 — 구체적인 것 먼저
try { }
catch (FileNotFoundException e) { }  // 더 구체적
catch (IOException e) { }            // 덜 구체적
catch (Exception e) { }              // 가장 상위

4. try-with-resources (AutoCloseable)

기본 동작

// Java 7 이전 — 번거로운 finally
BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("file.txt"));
    String line = br.readLine();
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (br != null) {
        try { br.close(); }
        catch (IOException e) { e.printStackTrace(); }
    }
}

// Java 7+ try-with-resources — close() 자동 호출
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
    String line = br.readLine();
    System.out.println(line);
} catch (IOException e) {
    e.printStackTrace();
}
// 블록을 벗어나면 br.close() 자동 호출

다중 리소스

// 선언 역순으로 close() 호출
try (
    Connection conn = dataSource.getConnection();
    PreparedStatement ps = conn.prepareStatement("SELECT * FROM users");
    ResultSet rs = ps.executeQuery()
) {
    while (rs.next()) {
        System.out.println(rs.getString("name"));
    }
}
// rs.close() → ps.close() → conn.close() 순서

// Java 9+: 기존 변수 사용 가능
Connection conn = dataSource.getConnection();
try (conn) {  // effectively final이어야 함
    // ...
}

Suppressed Exception

// try 블록과 close()에서 동시에 예외 발생 시
// → try 예외가 주 예외, close() 예외는 suppressed
class BrokenResource implements AutoCloseable {
    public void use() throws Exception {
        throw new Exception("use 예외");
    }

    @Override
    public void close() throws Exception {
        throw new Exception("close 예외");
    }
}

try (BrokenResource r = new BrokenResource()) {
    r.use();
} catch (Exception e) {
    System.out.println(e.getMessage());           // "use 예외" (주 예외)
    Throwable[] suppressed = e.getSuppressed();
    System.out.println(suppressed[0].getMessage()); // "close 예외" (억제됨)
}

AutoCloseable 구현

public class DatabaseTransaction implements AutoCloseable {
    private final Connection conn;
    private boolean committed = false;

    public DatabaseTransaction(DataSource ds) throws SQLException {
        this.conn = ds.getConnection();
        this.conn.setAutoCommit(false);
    }

    public void commit() throws SQLException {
        conn.commit();
        committed = true;
    }

    @Override
    public void close() throws SQLException {
        try {
            if (!committed) {
                conn.rollback();  // 커밋 안 하면 자동 롤백
            }
        } finally {
            conn.close();
        }
    }
}

// 사용
try (DatabaseTransaction tx = new DatabaseTransaction(dataSource)) {
    // DB 작업
    tx.commit();
}  // 예외 발생 시 자동 rollback + close

5. 멀티 캐치, 예외 되던지기

멀티 캐치 (Java 7+)

// Java 7 이전 — 반복
try {
    // ...
} catch (IOException e) {
    log.error("IO 오류", e);
    throw new ServiceException(e);
} catch (SQLException e) {
    log.error("DB 오류", e);
    throw new ServiceException(e);
}

// Java 7+ 멀티 캐치 — 중복 제거
try {
    // ...
} catch (IOException | SQLException e) {
    log.error("오류", e);
    throw new ServiceException(e);
    // 주의: e는 사실상 final — e = new IOException() 불가
}

예외 되던지기 (Re-throwing)

// 1. 그대로 되던지기
try {
    riskyOperation();
} catch (IOException e) {
    log.error("실패", e);
    throw e;  // 다시 던지기
}

// 2. 래핑해서 던지기 (예외 번역)
try {
    lowLevelOperation();
} catch (SQLException e) {
    // 저수준 예외를 고수준 예외로 변환
    throw new DataAccessException("DB 오류", e);  // cause 보존!
}

// 3. 예외 체이닝 — cause 보존이 핵심
// e.getCause() 로 원래 예외를 추적 가능

Java 7 정밀 재던지기 (Precise Rethrow)

// Java 7+: catch (Exception e) 로 잡아도 실제 예외 타입으로 던질 수 있음
public void method() throws IOException, SQLException {
    try {
        // IOException 또는 SQLException 발생 가능
        riskyOperation();
    } catch (Exception e) {
        log.error("오류", e);
        throw e;  // 컴파일러가 IOException | SQLException임을 추론
    }
}

6. 커스텀 예외 설계

기본 패턴

// Unchecked 커스텀 예외 (권장)
public class UserNotFoundException extends RuntimeException {

    private final long userId;

    public UserNotFoundException(long userId) {
        super("사용자를 찾을 수 없습니다. id=" + userId);
        this.userId = userId;
    }

    public UserNotFoundException(long userId, Throwable cause) {
        super("사용자를 찾을 수 없습니다. id=" + userId, cause);
        this.userId = userId;
    }

    public long getUserId() {
        return userId;
    }
}

예외 계층 설계

// 도메인별 예외 계층
public class AppException extends RuntimeException {
    private final ErrorCode errorCode;

    public AppException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public AppException(ErrorCode errorCode, Throwable cause) {
        super(errorCode.getMessage(), cause);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

public class UserException extends AppException {
    public UserException(ErrorCode errorCode) {
        super(errorCode);
    }
}

public class OrderException extends AppException {
    private final long orderId;

    public OrderException(ErrorCode errorCode, long orderId) {
        super(errorCode);
        this.orderId = orderId;
    }
}

// 에러 코드 Enum
public enum ErrorCode {
    USER_NOT_FOUND("U001", "사용자를 찾을 수 없습니다"),
    USER_ALREADY_EXISTS("U002", "이미 존재하는 사용자입니다"),
    ORDER_NOT_FOUND("O001", "주문을 찾을 수 없습니다"),
    INSUFFICIENT_STOCK("O002", "재고가 부족합니다");

    private final String code;
    private final String message;

    ErrorCode(String code, String message) {
        this.code = code;
        this.message = message;
    }

    public String getCode() { return code; }
    public String getMessage() { return message; }
}

커스텀 예외 생성자 4종

public class CustomException extends RuntimeException {

    // 1. 메시지만
    public CustomException(String message) {
        super(message);
    }

    // 2. 메시지 + 원인
    public CustomException(String message, Throwable cause) {
        super(message, cause);
    }

    // 3. 원인만
    public CustomException(Throwable cause) {
        super(cause);
    }

    // 4. 모두 (suppression, writable stacktrace 제어)
    protected CustomException(String message, Throwable cause,
                              boolean enableSuppression,
                              boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

7. 예외 처리 안티패턴

안티패턴 1: catch로 예외 무시

// 최악의 패턴 — 예외 삼키기
try {
    importantOperation();
} catch (Exception e) {
    // 아무것도 안 함 — 문제가 있음을 나중에야 알게 됨
}

// 최소한 로깅
try {
    importantOperation();
} catch (Exception e) {
    log.error("예상치 못한 오류", e);
    // 또는 재던지기
}

안티패턴 2: 너무 광범위한 catch

// 나쁜 예
try {
    String s = null;
    s.length();      // NPE
    int[] arr = {};
    arr[5] = 1;      // ArrayIndexOutOfBoundsException
    Integer.parseInt("abc");  // NumberFormatException
} catch (Exception e) {
    // 무슨 예외인지 알 수 없음
    System.out.println("오류: " + e.getMessage());
}

// 좋은 예 — 구체적인 예외 처리
try {
    processInput(input);
} catch (NumberFormatException e) {
    throw new IllegalArgumentException("숫자가 아닌 입력: " + input, e);
}

안티패턴 3: 흐름 제어에 예외 사용

// 나쁜 예 — 예외로 흐름 제어 (매우 느림)
try {
    int value = Integer.parseInt(input);
    return value;
} catch (NumberFormatException e) {
    return defaultValue;  // 예외로 기본값 분기 — 느리고 나쁜 설계
}

// 좋은 예 — 명시적 체크
if (isNumeric(input)) {
    return Integer.parseInt(input);
}
return defaultValue;

// 또는 Optional 활용
return parseIntSafely(input).orElse(defaultValue);

안티패턴 4: cause 없이 예외 번역

// 나쁜 예 — 원인 소멸
try {
    db.query(sql);
} catch (SQLException e) {
    throw new ServiceException("DB 오류");  // cause 누락! 스택 트레이스 소실
}

// 좋은 예 — cause 보존
try {
    db.query(sql);
} catch (SQLException e) {
    throw new ServiceException("DB 오류", e);  // 원인 체이닝
}

안티패턴 5: 스택 트레이스 출력 후 재던지기

// 나쁜 예 — 중복 로깅
try {
    operation();
} catch (Exception e) {
    e.printStackTrace();      // 여기서 한 번
    throw new RuntimeException(e);  // 상위에서 또 로깅 → 중복
}

// 좋은 예 — 한 곳에서만 로깅
// 중간 계층: 로깅 없이 재던지기
try {
    operation();
} catch (Exception e) {
    throw new ServiceException("처리 실패", e);  // 로깅은 최상위에서
}

안티패턴 6: finally에서 return

// 나쁜 예 — try의 return/예외를 덮어씀
public int calculate() {
    try {
        return 1;
    } finally {
        return 2;  // try의 return 1이 사라짐!
    }
}
// → 항상 2 반환 (예외도 삼킴!)

8. Spring의 예외 처리 전략

@ControllerAdvice / @RestControllerAdvice

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 커스텀 비즈니스 예외
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        log.warn("사용자 없음: {}", e.getMessage());
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse(e.getErrorCode().getCode(), e.getMessage()));
    }

    // 유효성 검증 실패 (Bean Validation)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
            .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
            .collect(joining(", "));
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse("VALIDATION_FAILED", message));
    }

    // 최상위 예외 — 예상치 못한 오류
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("처리되지 않은 예외", e);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "서버 내부 오류가 발생했습니다"));
    }
}

// 응답 DTO
public record ErrorResponse(String code, String message) { }

@ResponseStatus

@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(long id) {
        super("User not found: " + id);
    }
}
// @ControllerAdvice 없이 간단하게 HTTP 상태 코드 매핑

Spring의 DataAccessException

// Spring은 JDBC/JPA 예외를 DataAccessException (Unchecked)으로 변환
// SQLException (Checked) → DataAccessException (Unchecked)

// 체계:
DataAccessException
    ├── DataIntegrityViolationException  (제약 위반, 중복 )
    ├── EmptyResultDataAccessException   (결과 없음)
    ├── CannotAcquireLockException        ( 획득 실패)
    └── QueryTimeoutException             (쿼리 타임아웃)

// 활용
try {
    userRepository.save(user);
} catch (DataIntegrityViolationException e) {
    throw new UserAlreadyExistsException(user.getEmail());
}

트랜잭션과 예외

@Service
public class UserService {

    @Transactional
    public void createUser(UserDto dto) {
        // RuntimeException → 자동 rollback
        // Checked Exception → rollback 안 함 (기본값)
        userRepository.save(dto.toEntity());
        sendWelcomeEmail(dto.getEmail());  // 실패해도 rollback 안 됨
    }

    // rollbackFor로 명시적 지정
    @Transactional(rollbackFor = Exception.class)
    public void createUserWithRollback(UserDto dto) throws Exception {
        userRepository.save(dto.toEntity());
        sendWelcomeEmail(dto.getEmail());  // 예외 시 rollback
    }

    // noRollbackFor — 특정 예외는 rollback 제외
    @Transactional(noRollbackFor = UserNotFoundException.class)
    public void process() { ... }
}

9. 예외 처리 Best Practice 종합

계층별 예외 처리 전략

Controller Layer
    → 예외를 잡지 않음, @ControllerAdvice가 처리
    → 또는 HTTP 응답 변환만 담당

Service Layer
    → 비즈니스 예외 발생 (UserNotFoundException 등)
    → 하위 계층 예외를 비즈니스 예외로 번역

Repository Layer
    → DataAccessException (Spring이 자동 변환)
    → 필요 시 도메인 예외로 번역

Infrastructure Layer
    → IOException, SQLException 등
    → Unchecked로 래핑하여 상위로 전파
// 실전 예시
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final UserRepository userRepository;

    @Transactional
    public OrderDto createOrder(long userId, OrderRequest request) {
        // 1. 비즈니스 검증 — 구체적 예외
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));

        if (!user.isActive()) {
            throw new IllegalStateException("비활성 사용자는 주문할 수 없습니다");
        }

        // 2. 도메인 로직
        Order order = Order.create(user, request.getItems());

        // 3. 저장 — DataAccessException 가능 (자동 rollback)
        try {
            return orderRepository.save(order).toDto();
        } catch (DataIntegrityViolationException e) {
            throw new OrderException(ErrorCode.ORDER_DUPLICATE, order.getId());
        }
    }
}

10. 전체 요약

예외 처리 핵심 정리:
┌──────────────────────────────────────────────────────────┐
│  계층 구조                                               │
│  Throwable → Error (잡지 말 것)                         │
│           → Exception → Checked (복구 가능 외부 의존)   │
│                       → Unchecked (프로그래밍 오류)     │
│                                                          │
│  Checked vs Unchecked                                    │
│  - 신규 코드: Unchecked 선호 (Spring 철학)               │
│  - Checked: 파일/네트워크 등 외부 의존, 복구 의미 있을 때│
│                                                          │
│  필수 규칙                                               │
│  1. 예외를 무시하지 말 것 (빈 catch 금지)               │
│  2. cause 항상 보존 (throw new Ex(msg, e))               │
│  3. 로깅은 한 곳에서만                                  │
│  4. 흐름 제어에 예외 사용 금지                          │
│  5. finally에서 return/throw 금지                       │
│  6. try-with-resources로 리소스 관리                     │
│                                                          │
│  커스텀 예외                                             │
│  - ErrorCode Enum으로 에러 코드 체계화                  │
│  - 도메인별 예외 계층 설계                              │
│                                                          │
│  Spring                                                  │
│  - @RestControllerAdvice로 전역 처리                    │
│  - @Transactional + RuntimeException → 자동 rollback    │
│  - DataAccessException 계층 활용                        │
└──────────────────────────────────────────────────────────┘

카테고리:

업데이트: