Checked Exception vs Unchecked Exception – 자바 예외 처리 핵심 차이


자바 개발을 하다 보면 IOException은 반드시 try-catch로 감싸야 하는데, NullPointerException은 왜 그냥 내버려 둬도 컴파일이 될까요? 이 차이의 뿌리에 Checked Exception과 Unchecked Exception의 차이가 있습니다. 이 개념은 단순한 문법 지식을 넘어, 예외를 어떻게 설계하고 어디서 처리해야 하는지를 결정하는 자바 개발의 핵심 철학입니다. 이 글에서는 계층 구조, 컴파일 시점 동작, 실전 코드, 스프링 적용 사례, 커스텀 예외 설계 원칙까지 한 번에 정리합니다.


목차

  1. 자바 예외 계층 구조: Throwable부터 RuntimeException까지
  2. Checked Exception: 컴파일러가 강제하는 예외 처리
  3. Unchecked Exception: 개발자 실수를 나타내는 런타임 예외
  4. Checked vs Unchecked 핵심 차이 비교 총정리
  5. 실전 예외 설계 원칙: 언제 어떤 예외를 써야 하는가
  6. 스프링·실무에서의 예외 처리 전략과 전문가 관점

1. 자바 예외 계층 구조: Throwable부터 RuntimeException까지

자바에서 발생할 수 있는 모든 비정상 상황은 단 하나의 뿌리에서 출발합니다. 바로 java.lang.Throwable입니다. 이 클래스를 최상단으로 하는 계층 구조를 먼저 이해해야 Checked와 Unchecked의 차이가 명확해집니다.

전체 계층 구조 한눈에 보기

java.lang.Throwable
├── java.lang.Error                    ← 복구 불가 시스템 오류
│   ├── OutOfMemoryError               (JVM 메모리 고갈)
│   ├── StackOverflowError             (무한 재귀 등)
│   └── VirtualMachineError
│
└── java.lang.Exception                ← 애플리케이션 수준 예외
    │
    ├── [Checked Exception]            ← 컴파일러가 처리 강제
    │   ├── IOException
    │   │   ├── FileNotFoundException
    │   │   └── SocketException
    │   ├── SQLException
    │   ├── ClassNotFoundException
    │   └── InterruptedException
    │
    └── java.lang.RuntimeException     ← [Unchecked Exception]
        ├── NullPointerException       (null 참조 접근)
        ├── ArrayIndexOutOfBoundsException
        ├── IllegalArgumentException
        ├── IllegalStateException
        ├── NumberFormatException
        ├── ClassCastException
        └── ArithmeticException        (0으로 나누기 등)

이 구조에서 핵심 경계선은 딱 하나입니다.

RuntimeException을 상속하면 Unchecked, 그 외 Exception을 상속하면 Checked.

Error는 OutOfMemoryError나 StackOverflowError처럼 JVM 수준의 심각한 오류로, 애플리케이션 코드에서 복구를 시도해서는 안 되며 처리 대상이 아닙니다.

왜 이렇게 나눠져 있는가?

자바 언어 설계자들은 예외를 성격에 따라 두 가지 질문으로 구분했습니다.

  • “호출자(Caller)가 이 예외를 사전에 예측하고 합리적으로 복구할 수 있는가?” → Checked Exception
  • “이 예외는 프로그래머의 코드 버그에서 비롯된 것인가?” → Unchecked Exception

파일을 읽으려는데 파일이 없을 수 있다는 것은 사전에 충분히 예측 가능합니다. 반면 null인 객체에 메서드를 호출한 것은 프로그래머가 코드를 잘못 작성한 것이지, 외부 환경이 원인이 아닙니다. 이 철학적 구분이 Checked와 Unchecked의 출발점입니다.


2. Checked Exception: 컴파일러가 강제하는 예외 처리

Checked Exception은 컴파일 시점에 자바 컴파일러가 처리 여부를 직접 확인하는 예외입니다. 처리하지 않으면 코드가 아예 컴파일되지 않습니다.

컴파일러가 강제하는 두 가지 선택지

Checked Exception을 마주쳤을 때 개발자는 반드시 둘 중 하나를 선택해야 합니다.

선택 1: try-catch로 직접 처리

java

import java.io.*;

public class FileReaderExample {

    public String readFile(String path) {
        // try-catch 없이 FileReader를 쓰면 컴파일 에러 발생
        // FileNotFoundException은 Checked Exception이기 때문
        try {
            BufferedReader reader = new BufferedReader(new FileReader(path));
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            reader.close();
            return sb.toString();
        } catch (FileNotFoundException e) {
            System.err.println("파일을 찾을 수 없습니다: " + path);
            return "";           // 기본값 반환으로 복구 시도
        } catch (IOException e) {
            System.err.println("파일 읽기 오류: " + e.getMessage());
            return "";
        }
    }
}

선택 2: throws로 호출자에게 위임

java

import java.io.*;

public class FileReaderExample {

    // throws로 선언하면 이 메서드를 호출하는 쪽에서 처리해야 함
    public String readFile(String path) throws IOException {
        BufferedReader reader = new BufferedReader(new FileReader(path));
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line).append("\n");
        }
        reader.close();
        return sb.toString();
    }

    // 호출자가 처리를 담당
    public void run() {
        try {
            String content = readFile("/etc/config.txt");
            System.out.println(content);
        } catch (IOException e) {
            System.err.println("처리 실패: " + e.getMessage());
        }
    }
}

try-with-resources: Checked Exception 처리의 현대적 표준

Java 7부터 도입된 try-with-resources는 AutoCloseable을 구현한 리소스를 자동으로 닫아주며, 리소스 누수를 방지하는 현대적 패턴입니다.

java

import java.io.*;
import java.nio.file.*;

public class ModernFileReader {

    // try-with-resources: 블록 종료 시 reader.close() 자동 호출
    public String readFile(String path) throws IOException {
        try (BufferedReader reader = Files.newBufferedReader(Path.of(path))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            return sb.toString();
        }
        // catch 없이도 IOException은 throws로 위임됨
        // finally 없어도 reader가 자동 close됨
    }
}

대표 Checked Exception 목록

예외 클래스발생 상황주요 패키지
IOException입출력 작업 실패java.io
FileNotFoundException파일 미존재java.io
SQLExceptionDB 작업 오류java.sql
ClassNotFoundException클래스 로딩 실패java.lang
InterruptedException스레드 대기 중 인터럽트java.lang
ParseException날짜·숫자 파싱 실패java.text
MalformedURLException잘못된 URL 형식java.net

3. Unchecked Exception: 개발자 실수를 나타내는 런타임 예외

Unchecked Exception은 RuntimeException과 그 하위 클래스들입니다. 컴파일러가 처리를 강제하지 않으며, 런타임 시점에 발생합니다. 처리하지 않아도 컴파일은 통과하지만, 실행 중에 예외가 발생하면 해당 스레드가 종료됩니다.

왜 컴파일러가 강제하지 않는가?

Unchecked Exception은 대부분 코드 논리의 버그에서 비롯됩니다. 모든 메서드 호출마다 NullPointerException이나 ArrayIndexOutOfBoundsException 처리를 강제한다면, 자바 코드의 모든 줄에 try-catch가 붙어야 하는 끔찍한 상황이 됩니다. 이를 방지하기 위해 개발자가 코드를 올바르게 작성하는 것으로 예방해야 하는 예외들은 Unchecked로 분류했습니다.

대표 Unchecked Exception 실전 예제

java

public class UncheckedExceptionDemo {

    public static void main(String[] args) {

        // 1. NullPointerException: null 참조 접근
        String str = null;
        // str.length(); // NullPointerException 발생 → 코드 버그

        // Java 14+ NPE 개선: 어떤 변수가 null인지 메시지로 알려줌
        // "Cannot invoke String.length() because 'str' is null"

        // 2. ArrayIndexOutOfBoundsException: 배열 범위 초과
        int[] arr = new int[3];
        // arr[5] = 10; // ArrayIndexOutOfBoundsException → 코드 버그

        // 3. NumberFormatException: 숫자로 변환 불가능한 문자열
        String input = "abc";
        // int num = Integer.parseInt(input); // NumberFormatException

        // 올바른 처리: 변환 전 검증으로 예방
        if (input.matches("\\d+")) {
            int num = Integer.parseInt(input);
            System.out.println(num);
        }

        // 4. IllegalArgumentException: 잘못된 인자 전달
        validateAge(-5); // IllegalArgumentException 발생

        // 5. ClassCastException: 잘못된 타입 캐스팅
        Object obj = "hello";
        // Integer num2 = (Integer) obj; // ClassCastException

        // instanceof로 사전 확인 (Java 16+ pattern matching)
        if (obj instanceof String s) {
            System.out.println(s.toUpperCase());
        }
    }

    public static void validateAge(int age) {
        if (age < 0 || age > 150) {
            // IllegalArgumentException은 Unchecked → throws 선언 불필요
            throw new IllegalArgumentException("나이는 0~150 사이여야 합니다: " + age);
        }
    }
}

ArithmeticException — 0으로 나누기

java

public class DivisionExample {

    // 방어적 프로그래밍으로 예외 예방
    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("제수(b)는 0이 될 수 없습니다.");
        }
        return a / b; // b가 0이면 ArithmeticException 발생
    }
}

대표 Unchecked Exception 목록

예외 클래스발생 원인예방법
NullPointerExceptionnull 참조 접근null 체크, Optional 사용
ArrayIndexOutOfBoundsException배열 인덱스 초과범위 검증
IndexOutOfBoundsExceptionList 인덱스 초과size() 확인
NumberFormatException잘못된 숫자 파싱정규식 검증 후 파싱
IllegalArgumentException유효하지 않은 인자메서드 첫 줄 인자 검증
IllegalStateException부적절한 객체 상태상태 전이 로직 검증
ClassCastException잘못된 타입 캐스팅instanceof 확인
StackOverflowError무한 재귀재귀 종료 조건 확인

4. Checked vs Unchecked 핵심 차이 비교 총정리

지금까지 각각의 특성을 살펴봤습니다. 이제 두 예외를 나란히 놓고 핵심 차이를 정리합니다.

핵심 비교표

구분Checked ExceptionUnchecked Exception
상속 계층Exception 직계 하위RuntimeException 하위
컴파일 강제✅ 처리 안 하면 컴파일 에러❌ 컴파일러 무관
발생 시점컴파일·런타임 모두 감지런타임에만 발생
원인외부 환경 (파일, DB, 네트워크)코드 로직 버그
복구 가능성복구 시도 가능·권장코드 수정으로 예방해야 함
throws 선언필수 (처리 또는 위임)선택 사항
대표 예외IOExceptionSQLExceptionNullPointerExceptionIllegalArgumentException
설계 의도“호출자에게 예외 가능성 알림”“코드 버그 즉시 드러냄”

동작 차이 코드로 확인

java

import java.io.*;

public class ExceptionComparison {

    // ① Checked Exception: throws 없으면 컴파일 에러
    // public void readFile() {                  // ← 컴파일 에러!
    public void readFile() throws IOException {  // ← 반드시 선언
        FileReader fr = new FileReader("test.txt"); // IOException 가능
    }

    // ② Unchecked Exception: throws 없어도 컴파일 정상
    public void riskyMethod() {                  // ← 컴파일 통과
        String s = null;
        s.length(); // NullPointerException → 런타임에 터짐
    }

    // ③ 호출 시 강제 처리 여부 차이
    public void caller() {
        // Checked: 반드시 처리해야 컴파일 통과
        try {
            readFile();
        } catch (IOException e) {
            System.err.println("IO 오류: " + e.getMessage());
        }

        // Unchecked: try-catch 없어도 컴파일 통과
        riskyMethod(); // 런타임에 NullPointerException 발생
    }
}

Exception Chaining: 예외 전환 패턴

레이어드 아키텍처에서는 하위 계층의 Checked Exception을 상위 계층이 이해할 수 있는 예외로 변환하는 예외 전환(Exception Translation) 패턴이 자주 사용됩니다.

java

public class UserRepository {

    public User findById(long id) {
        try {
            // DB 접근: Checked Exception 발생 가능
            return jdbcTemplate.queryForObject(...);
        } catch (SQLException e) {
            // Checked → Unchecked로 전환 (cause 체이닝 필수)
            throw new DataAccessException("사용자 조회 실패: id=" + id, e);
            //                                                          ↑
            //                              원인 예외를 반드시 cause로 전달
        }
    }
}

cause를 반드시 전달해야 합니다. 그래야 스택 트레이스에서 원인 예외(SQLException)까지 추적할 수 있습니다. cause를 버리는 것은 실무에서 가장 흔한 예외 처리 안티패턴 중 하나입니다.


5. 실전 예외 설계 원칙: 언제 어떤 예외를 써야 하는가

이론보다 더 중요한 것은 내가 커스텀 예외를 만들 때 Checked와 Unchecked 중 무엇을 선택해야 하는가입니다.

Checked Exception을 선택해야 할 때

다음 조건을 모두 만족할 때 Checked Exception이 적합합니다.

  1. 외부 시스템·환경 의존: 파일 시스템, 네트워크, 외부 API처럼 코드 밖의 요인으로 실패할 수 있다.
  2. 호출자가 합리적으로 복구할 수 있다: 파일이 없으면 기본값을 쓰거나, 재시도를 하거나, 사용자에게 알릴 수 있다.
  3. API 계약(Contract)의 일부: 이 메서드를 호출하는 모든 사람이 반드시 알아야 할 실패 가능성이다.

java

// 좋은 Checked Exception 설계 예시
public class PaymentGateway {

    // 외부 결제 API 호출 → 네트워크·서버 오류는 사전 예측 가능
    // 호출자가 재시도·알림·롤백 등 복구 로직을 작성해야 함
    public PaymentResult charge(PaymentRequest request)
            throws PaymentException {        // Checked
        // 결제 게이트웨이 HTTP 호출
    }
}

// 커스텀 Checked Exception
public class PaymentException extends Exception {
    private final String errorCode;

    public PaymentException(String message, String errorCode, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

Unchecked Exception을 선택해야 할 때

다음 상황에서는 Unchecked Exception이 적합합니다.

  1. 프로그래밍 오류: 잘못된 인자, null 전달, 인덱스 초과 등 코드를 올바르게 작성하면 발생하지 않는다.
  2. 복구가 의미 없다: 어떤 컨텍스트에서도 호출자가 이 예외를 잡아서 할 수 있는 합리적인 행동이 없다.
  3. 모든 계층에 throws 전파가 부담스럽다: API의 모든 메서드 시그니처를 오염시키게 된다.

java

// 좋은 Unchecked Exception 설계 예시
public class OrderService {

    public void createOrder(OrderRequest request) {
        // 인자 검증: 프로그래밍 오류 → Unchecked
        if (request == null) {
            throw new IllegalArgumentException("OrderRequest는 null이 될 수 없습니다.");
        }
        if (request.getQuantity() <= 0) {
            throw new IllegalArgumentException("수량은 1 이상이어야 합니다: " + request.getQuantity());
        }

        // 비즈니스 규칙 위반: 도메인 수준 Unchecked 예외
        if (request.getQuantity() > stockService.getStock(request.getItemId())) {
            throw new InsufficientStockException("재고 부족: " + request.getItemId());
        }
    }
}

// 커스텀 Unchecked Exception (RuntimeException 상속)
public class InsufficientStockException extends RuntimeException {
    private final String itemId;

    public InsufficientStockException(String message) {
        super(message);
        this.itemId = null;
    }

    public InsufficientStockException(String message, String itemId) {
        super(message);
        this.itemId = itemId;
    }

    public String getItemId() {
        return itemId;
    }
}

이펙티브 자바의 예외 설계 원칙 요약

조슈아 블로크는 Effective Java 3판에서 예외 설계에 대해 다음과 같이 명확히 권고합니다.

항목원칙아이템
복구 가능 조건Checked Exception 사용Item 70
프로그래밍 오류Unchecked Exception 사용Item 70
예외 정보실패 상황을 메시지에 담아라Item 75
예외 전환cause 체이닝 필수Item 73
예외 무시 금지catch 블록을 비워 두지 마라Item 77

특히 “catch 블록을 절대 비워 두지 마라” 는 원칙은 실무에서 가장 많이 위반되는 규칙입니다.

java

// 절대 안 되는 패턴 (예외 삼키기)
try {
    doSomething();
} catch (Exception e) {
    // ← 아무것도 안 함. 예외가 사라짐. 디버깅 불가
}

// 최소한 이렇게는 해야 함
try {
    doSomething();
} catch (Exception e) {
    log.error("doSomething 실패: {}", e.getMessage(), e); // 로그 필수
    throw e; // 또는 상위로 전파
}

6. 스프링·실무에서의 예외 처리 전략과 전문가 관점

스프링 프레임워크는 Checked Exception과 Unchecked Exception의 차이를 매우 명확한 방향성으로 활용합니다. 그 전략을 이해하면 실무 코드의 품질이 크게 달라집니다.

스프링은 왜 Unchecked Exception을 선호하는가

Spring의 JdbcTemplateJpaRepository 등은 내부적으로 발생하는 SQLException(Checked)을 모두 DataAccessException(Unchecked)으로 래핑하여 반환합니다. 이유는 명확합니다. DB 접근 예외를 모든 Repository 메서드의 throws에 선언하면 서비스·컨트롤러 계층까지 SQLException이 전파되어 계층 간 결합도가 높아지기 때문입니다.

java

// 스프링 JPA 방식: throws 선언 없음 (Unchecked 기반)
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email); // DataAccessException(Unchecked) 가능
}

// 서비스 계층: throws 전파 없이 깔끔한 비즈니스 로직
@Service
public class UserService {

    private final UserRepository userRepository;

    public UserDto getUser(Long id) {
        return userRepository.findById(id)
                .map(UserDto::from)
                .orElseThrow(() -> new UserNotFoundException("사용자 없음: " + id));
                //                   ↑ Unchecked 커스텀 예외
    }
}

@ExceptionHandler와 @ControllerAdvice: 전역 예외 처리

스프링 MVC에서는 컨트롤러 전역 예외 처리를 @ControllerAdvice로 중앙화합니다.

java

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 비즈니스 예외 처리 (Unchecked)
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        log.warn("사용자 조회 실패: {}", e.getMessage());
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("USER_NOT_FOUND", e.getMessage()));
    }

    // 잘못된 입력값 처리 (Unchecked)
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
        log.warn("잘못된 요청: {}", e.getMessage());
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("INVALID_INPUT", e.getMessage()));
    }

    // 예상치 못한 모든 예외 처리 (최후 보루)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(Exception e) {
        log.error("예상치 못한 오류 발생", e); // 전체 스택 트레이스 로깅
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponse("INTERNAL_ERROR", "서버 오류가 발생했습니다."));
    }
}

@Transactional의 롤백 전략

스프링의 @Transactional은 Checked와 Unchecked를 다르게 처리합니다.

java

@Service
public class OrderService {

    @Transactional
    public void placeOrder(OrderRequest request) throws PaymentException {
        orderRepository.save(new Order(request));

        // Unchecked Exception → 자동 롤백 (기본 동작)
        // Checked Exception   → 롤백 안 됨! (기본 동작)
    }

    // Checked Exception도 롤백하려면 명시적 설정 필요
    @Transactional(rollbackFor = PaymentException.class)
    public void placeOrderWithRollback(OrderRequest request) throws PaymentException {
        orderRepository.save(new Order(request));
        paymentGateway.charge(request.getPayment()); // PaymentException (Checked)
        // rollbackFor 설정 시 Checked도 롤백 처리됨
    }
}

이 동작을 모르면 Checked Exception이 발생했을 때 DB에 데이터는 저장되고 결제는 실패하는 심각한 데이터 불일치가 발생할 수 있습니다. @Transactional을 사용할 때 예외 종류에 따른 롤백 전략은 반드시 명시적으로 설정해야 합니다.

실무 예외 처리 계층별 책임

[Controller 계층]
  - @ExceptionHandler로 HTTP 응답 코드·메시지 변환
  - 비즈니스 로직 예외를 클라이언트 친화적 응답으로 변환

[Service 계층]
  - 비즈니스 규칙 위반 → 도메인 커스텀 예외 발생
  - 하위 계층 예외를 비즈니스 의미 있는 예외로 래핑

[Repository 계층]
  - 인프라 예외(SQLException 등)를 도메인 예외로 변환
  - Spring Data 사용 시 대부분 자동 처리

[공통 원칙]
  - 예외는 발생한 곳과 가장 가까운 적절한 계층에서 처리
  - 처리할 수 없는 예외는 삼키지 말고 위로 전파
  - 모든 예외는 반드시 로그를 남긴다

결론

Checked Exception과 Unchecked Exception의 차이는 단순히 try-catch 필요 여부를 넘어, 예외의 책임이 누구에게 있는가에 대한 철학적 선택입니다. Checked는 “외부 환경으로 인한 실패를 호출자가 반드시 인지하고 대비하라”는 컴파일러의 계약이며, Unchecked는 “코드 버그는 예외 처리가 아니라 코드 수정으로 해결하라”는 설계 원칙입니다. 오늘부터 예외를 만들 때 “이것이 호출자가 복구할 수 있는 상황인가, 아니면 코드 버그인가”라는 질문을 먼저 던져보세요.


⚠️ 기술 적용 면책 고지: 본 포스트의 코드 예제는 교육 목적으로 작성되었습니다. 실제 프로덕션 환경에서는 사용하는 프레임워크 버전, 트랜잭션 설정, 로깅 전략에 따라 적용 방법이 달라질 수 있으므로, 반드시 공식 문서와 팀 컨벤션을 함께 참고하시기 바랍니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다