개발/java,spring

Java Exception 구조 이해하기: try-catch만 알면 부족한 이유

Mr.Lee 하루 2026. 3. 24. 14:59

Java Exception 구조 이해하기: try-catch만 알면 부족한 이유

자바를 처음 배울 때 try-catch 문법부터 접하는 경우가 많습니다.
그런데 실무로 넘어가면 단순히 예외를 잡는 것보다 예외가 어떤 구조로 이루어져 있는지, 왜 checked exception과 unchecked exception이 나뉘는지, 어떻게 설계해야 유지보수가 편한지를 이해하는 것이 훨씬 중요합니다.

저도 처음에는 Exception, RuntimeException, throws 정도만 알면 된다고 생각했는데, 프로젝트가 커질수록 예외 구조를 제대로 이해하지 않으면 디버깅도 어려워지고 코드도 금방 지저분해지더라고요.

이번 글에서는 Java Exception 구조를 처음부터 차근차근 정리해보겠습니다.
문법 설명에 그치지 않고, 실무에서 왜 이 구조가 중요한지까지 함께 보겠습니다.


Java Exception이란?

Java에서 Exception은 프로그램 실행 중 발생하는 비정상 상황을 의미합니다.

예를 들면 이런 경우입니다.

  • 숫자로 바꿀 수 없는 문자열을 정수로 변환하려고 할 때
  • 존재하지 않는 파일을 읽으려고 할 때
  • 배열 범위를 벗어난 인덱스에 접근할 때
  • 데이터베이스 연결이 실패했을 때
  • null 객체의 메서드를 호출했을 때

이런 상황이 발생했는데 아무 처리도 하지 않으면 프로그램은 비정상 종료될 수 있습니다.
그래서 자바는 예외를 객체 형태로 다루고, 개발자가 이를 적절히 처리할 수 있도록 구조를 만들어 두었습니다.


Java Exception 구조 전체 그림

자바의 예외 구조는 최상위에서 보면 아래와 같이 이해하면 편합니다.

Throwable
├── Error
└── Exception
    ├── RuntimeException
    └── 기타 Checked Exception

핵심은 Throwable 아래에 ErrorException이 있고,
Exception 아래에 다시 RuntimeException 계열과 일반 예외들이 나뉜다는 점입니다.

이 구조를 이해하면 왜 어떤 예외는 반드시 처리해야 하고, 어떤 예외는 컴파일러가 강제하지 않는지 바로 연결됩니다.


1. Throwable: 모든 예외와 에러의 최상위 클래스

Throwable은 자바에서 던질 수 있는 모든 문제 상황의 부모 클래스입니다.

즉, throw 할 수 있는 대상은 결국 Throwable을 상속받아야 합니다.

public class Throwable

이 클래스는 단순한 부모 역할만 하는 것이 아니라, 예외 처리에 필요한 중요한 정보도 가지고 있습니다.

대표적으로 이런 정보가 들어 있습니다.

  • 예외 메시지
  • 원인(cause)
  • stack trace 정보

예를 들어 아래 코드를 보면 예외 객체 안에 메시지와 원인 정보가 담깁니다.

try {
    int number = Integer.parseInt("abc");
} catch (NumberFormatException e) {
    System.out.println(e.getMessage());
    e.printStackTrace();
}

실무에서는 단순히 예외가 났다는 사실보다도,
어디서 발생했는지, 왜 발생했는지, 원인이 되는 이전 예외가 무엇인지가 훨씬 중요합니다.
그 정보를 담는 뿌리가 바로 Throwable입니다.


2. Error: 애플리케이션이 복구하기 어려운 심각한 문제

Error는 일반적으로 애플리케이션 코드에서 처리 대상으로 보기 어려운 문제입니다.

대표적인 예시는 다음과 같습니다.

  • OutOfMemoryError
  • StackOverflowError
  • VirtualMachineError

예를 들어 메모리가 완전히 부족해서 OutOfMemoryError가 발생했다면,
이건 보통 비즈니스 로직에서 try-catch로 복구할 문제가 아닙니다.

try {
    // 메모리를 과도하게 사용하는 코드
} catch (OutOfMemoryError e) {
    // 일반적인 실무에서는 이렇게 처리하는 것을 권장하지 않음
}

실무에서는 Error를 잡아서 해결하려고 하기보다,
애초에 이런 상황이 발생하지 않도록 설계하거나 서버 설정, 메모리 튜닝, 무한 재귀 방지 같은 방향으로 접근합니다.

즉, Error는 보통 시스템 레벨의 심각한 장애로 보는 편이 맞습니다.


3. Exception: 애플리케이션에서 처리 가능한 예외

Exception은 개발자가 처리할 수 있는 문제 상황을 표현합니다.

실무에서 우리가 주로 다루는 예외는 대부분 이쪽입니다.

예를 들면 다음과 같습니다.

  • 파일이 존재하지 않음
  • 잘못된 사용자 입력
  • DB 조회 실패
  • 네트워크 호출 실패
  • 잘못된 상태에서 메서드 실행

Exception은 다시 크게 두 가지로 나뉩니다.

  • Checked Exception
  • Unchecked Exception(RuntimeException)

이 둘의 차이를 이해하는 것이 Java Exception 구조의 핵심입니다.


4. Checked Exception이란?

Checked Exception은 컴파일 시점에 반드시 처리 여부를 확인받는 예외입니다.

즉, 메서드 안에서 Checked Exception이 발생할 수 있으면 개발자는 아래 둘 중 하나를 해야 합니다.

  • try-catch로 직접 처리
  • throws로 호출한 쪽에 위임

대표적인 Checked Exception 예시는 다음과 같습니다.

  • IOException
  • SQLException
  • ClassNotFoundException
  • ParseException

예시 1: 직접 처리

try {
    FileReader fileReader = new FileReader("test.txt");
} catch (FileNotFoundException e) {
    System.out.println("파일을 찾을 수 없습니다.");
}

예시 2: throws로 위임

public void readFile() throws FileNotFoundException {
    FileReader fileReader = new FileReader("test.txt");
}

Checked Exception의 의도는 명확합니다.
복구 가능성이 있는 예외를 개발자가 의식적으로 처리하게 만들자는 것입니다.

예를 들어 파일 읽기나 외부 시스템 접근은 실패 가능성이 있으니,
아예 컴파일 단계에서 “이 예외 처리했어?”라고 확인하는 것이죠.


5. Unchecked Exception이란?

Unchecked Exception은 RuntimeException 계열 예외를 말합니다.

이 예외는 컴파일러가 처리 여부를 강제하지 않습니다.
그래서 try-catch를 쓰지 않아도 컴파일은 됩니다.

대표적인 예시는 아래와 같습니다.

  • NullPointerException
  • ArrayIndexOutOfBoundsException
  • IllegalArgumentException
  • IllegalStateException
  • NumberFormatException

예를 들어 이런 코드는 컴파일은 되지만 실행 중 예외가 납니다.

String text = null;
System.out.println(text.length());

위 코드는 실행 시 NullPointerException이 발생합니다.

또 다른 예시입니다.

int number = Integer.parseInt("hello");

이 경우 NumberFormatException이 발생합니다.

Unchecked Exception은 보통 프로그래밍 실수, 잘못된 상태, 잘못된 인자 전달 같은 경우에 사용됩니다.

즉, 개발자가 코드 자체를 고쳐야 하는 상황이 많습니다.


6. Checked Exception과 Unchecked Exception 차이

이 부분은 면접이나 실무에서도 정말 자주 나옵니다.

Checked Exception

  • 컴파일러가 처리 여부를 강제함
  • try-catch 또는 throws 필요
  • 복구 가능성이 있는 외부 자원 문제에 자주 사용됨
  • 예: 파일, 네트워크, DB, 리플렉션 관련 예외

Unchecked Exception

  • 컴파일러가 처리 여부를 강제하지 않음
  • 보통 개발자 실수나 잘못된 사용에 해당
  • 예: null 참조, 잘못된 인덱스, 잘못된 인자 값

정리하면 이렇습니다.

Checked Exception은 “예상 가능한 실패 상황”,
Unchecked Exception은 “코드 사용 방식 자체가 잘못된 상황”으로 이해하면 편합니다.


7. RuntimeException이 중요한 이유

실무에서는 RuntimeException 계열이 정말 많이 사용됩니다.

이유는 단순합니다.
Checked Exception을 지나치게 많이 쓰면 코드가 너무 무거워지기 때문입니다.

예를 들어 서비스 메서드마다 throws Exception이 늘어나고,
호출하는 모든 계층에서 계속 전달하다 보면 코드가 예외 선언으로 가득 차게 됩니다.

그래서 최근 자바 실무에서는 다음처럼 분리해서 생각하는 경우가 많습니다.

  • 외부 입출력, 파일, 네트워크처럼 강제 처리가 필요한 경우 → Checked Exception 고려
  • 비즈니스 규칙 위반, 잘못된 요청, 잘못된 상태 → Custom RuntimeException 사용

예를 들어 회원 가입 로직에서 이메일 형식이 잘못되었다면 이런 식으로 만들 수 있습니다.

public class InvalidEmailException extends RuntimeException {
    public InvalidEmailException(String message) {
        super(message);
    }
}

사용 예시:

public void register(String email) {
    if (email == null || !email.contains("@")) {
        throw new InvalidEmailException("올바른 이메일 형식이 아닙니다.");
    }
}

이런 구조는 비즈니스 예외를 명확히 표현하는 데 도움이 됩니다.


8. 예외 전파란 무엇인가?

자바에서는 예외가 발생하면 그 자리에서 끝나는 것이 아니라 호출 스택을 따라 위로 전달됩니다.
이걸 예외 전파라고 합니다.

예를 들어 아래 코드를 보겠습니다.

public class Main {
    public static void main(String[] args) {
        methodA();
    }

    public static void methodA() {
        methodB();
    }

    public static void methodB() {
        throw new RuntimeException("예외 발생");
    }
}

실행 흐름은 이렇습니다.

  1. main()methodA() 호출
  2. methodA()methodB() 호출
  3. methodB()에서 예외 발생
  4. methodA()에서 처리하지 않으면 main()으로 전달
  5. 최종적으로 처리되지 않으면 프로그램 종료

이 구조를 이해해야 어디에서 예외를 잡아야 할지 판단할 수 있습니다.


9. try-catch-finally 구조 이해하기

예외 처리는 보통 try-catch-finally로 작성합니다.

try {
    // 예외가 발생할 수 있는 코드
} catch (Exception e) {
    // 예외 처리
} finally {
    // 예외 발생 여부와 관계없이 항상 실행
}

try

예외가 발생할 가능성이 있는 코드를 넣습니다.

catch

예외가 발생했을 때 처리하는 블록입니다.

finally

예외 발생 여부와 관계없이 마지막에 실행됩니다.
주로 자원 해제에 사용했습니다.

예시를 보면 더 이해가 쉽습니다.

BufferedReader br = null;

try {
    br = new BufferedReader(new FileReader("test.txt"));
    System.out.println(br.readLine());
} catch (IOException e) {
    System.out.println("파일 처리 중 오류가 발생했습니다.");
} finally {
    if (br != null) {
        try {
            br.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

다만 요즘은 finally보다 try-with-resources를 더 많이 씁니다.


10. try-with-resources란?

파일, 소켓, 스트림, DB 연결처럼 사용 후 닫아야 하는 자원은
예외가 나더라도 반드시 정리해야 합니다.

이를 더 안전하고 깔끔하게 처리하기 위해 try-with-resources를 사용합니다.

try (BufferedReader br = new BufferedReader(new FileReader("test.txt"))) {
    System.out.println(br.readLine());
} catch (IOException e) {
    System.out.println("파일 읽기 실패");
}

이 방식의 장점은 명확합니다.

  • 코드가 간결함
  • 자원 누수 방지
  • finally에서 close 처리할 필요가 줄어듦

실무에서는 AutoCloseable 구현 객체를 다룰 때 거의 기본처럼 사용합니다.


11. throws와 throw 차이

헷갈리기 쉬운 부분이라 꼭 구분해야 합니다.

throw

예외를 실제로 발생시킬 때 사용합니다.

throw new IllegalArgumentException("잘못된 값입니다.");

throws

메서드 선언부에서 해당 메서드가 예외를 던질 수 있다고 알릴 때 사용합니다.

public void read() throws IOException {
    // ...
}

즉,

  • throw는 발생
  • throws는 선언

입니다.


12. 다중 catch와 예외 순서

자바에서는 여러 예외를 각각 처리할 수 있습니다.

try {
    // ...
} catch (FileNotFoundException e) {
    System.out.println("파일 없음");
} catch (IOException e) {
    System.out.println("입출력 오류");
}

이때 중요한 점은 구체적인 예외를 먼저 잡아야 한다는 것입니다.

왜냐하면 FileNotFoundExceptionIOException의 자식이기 때문입니다.
만약 부모인 IOException을 먼저 쓰면 뒤의 자식 catch는 도달할 수 없어서 컴파일 오류가 납니다.

잘못된 예:

try {
    // ...
} catch (IOException e) {
    System.out.println("입출력 오류");
} catch (FileNotFoundException e) {
    System.out.println("파일 없음");
}

또한 여러 예외를 한 번에 처리할 수도 있습니다.

try {
    // ...
} catch (IOException | SQLException e) {
    System.out.println("입출력 또는 DB 오류");
}

13. Custom Exception은 왜 만들까?

실무에서는 기본 예외만으로는 상황을 충분히 설명하지 못하는 경우가 많습니다.

예를 들어 이런 상황이 있을 수 있습니다.

  • 주문 수량이 음수인 경우
  • 이미 탈퇴한 회원이 재로그인을 시도한 경우
  • 결제 승인 시간이 초과된 경우
  • 재고 부족 상태에서 주문이 들어온 경우

이런 비즈니스 상황을 IllegalArgumentException 하나로만 처리하면 나중에 로그를 봐도 의미가 모호합니다.
그래서 도메인에 맞는 예외를 직접 정의합니다.

예시:

public class OutOfStockException extends RuntimeException {
    public OutOfStockException(String message) {
        super(message);
    }
}

사용 예시:

public void order(int stock, int quantity) {
    if (quantity > stock) {
        throw new OutOfStockException("재고가 부족합니다.");
    }
}

이렇게 하면 코드의 의도가 훨씬 분명해집니다.


14. 예외 메시지와 원인 보존이 중요한 이유

실무에서는 예외를 새로 감싸서 던지는 경우가 많습니다.
이때 가장 많이 하는 실수 중 하나가 원인 예외를 잃어버리는 것입니다.

잘못된 예:

try {
    // DB 처리
} catch (SQLException e) {
    throw new RuntimeException("회원 저장 실패");
}

이렇게 하면 실제 원인이 되는 SQLException 정보가 사라질 수 있습니다.

좋은 예:

try {
    // DB 처리
} catch (SQLException e) {
    throw new RuntimeException("회원 저장 실패", e);
}

이렇게 하면 상위 계층에서 로그를 볼 때
“회원 저장 실패”라는 비즈니스 문맥과 함께 실제 원인인 SQL 예외도 추적할 수 있습니다.

실무에서 이 차이는 정말 큽니다.


15. Exception 구조를 계층별로 어떻게 다루면 좋을까?

프로젝트를 계층형으로 구성한다고 가정해보겠습니다.

  • Controller
  • Service
  • Repository

이때 예외를 모든 계층에서 무작정 잡아버리면 흐름이 오히려 복잡해집니다.

보통은 이런 식으로 생각하면 좋습니다.

Repository 계층

DB나 외부 시스템 관련 저수준 예외 발생 가능

Service 계층

저수준 예외를 비즈니스 예외로 변환하거나 판단

Controller 계층

최종적으로 사용자에게 어떤 응답을 줄지 결정

예를 들어 스프링 기반이라면 다음처럼 흐름을 잡을 수 있습니다.

public class UserRepository {
    public User findById(Long id) {
        // DB 조회
        return null;
    }
}

public class UserService {
    public User getUser(Long id) {
        User user = userRepository.findById(id);
        if (user == null) {
            throw new UserNotFoundException("해당 사용자를 찾을 수 없습니다.");
        }
        return user;
    }
}

즉, 예외 구조는 단순 문법이 아니라 애플리케이션 아키텍처 설계와도 연결됩니다.


16. 예외를 무조건 catch하면 안 되는 이유

초보 때 많이 하는 실수 중 하나가 모든 예외를 무조건 잡아버리는 것입니다.

예를 들어 이런 코드입니다.

try {
    // 모든 로직
} catch (Exception e) {
    System.out.println("에러 발생");
}

겉보기에는 안전해 보이지만 문제점이 많습니다.

  • 어떤 예외가 발생했는지 알기 어려움
  • 복구 불가능한 예외까지 뭉뚱그려 처리함
  • 디버깅이 어려워짐
  • 중요한 장애를 숨길 수 있음

그래서 예외는 필요한 곳에서, 의미 있게, 구체적으로 처리해야 합니다.


17. 실무에서 자주 보는 예외 설계 방식

실무에서는 보통 아래 같은 원칙을 많이 씁니다.

1) 복구 가능한 경우만 직접 처리

정말 복구할 수 있거나 대체 동작이 가능한 경우에만 catch

2) 의미 없는 catch는 하지 않기

그냥 로그만 찍고 다시 던질 거면 중복 처리일 수 있음

3) 도메인 예외는 명확한 이름으로 만들기

예: PaymentFailedException, InvalidOrderStateException

4) 원인 예외는 보존하기

예외 감쌀 때 cause를 꼭 넣기

5) 최상위에서 공통 처리하기

웹 애플리케이션이라면 전역 예외 처리로 응답 포맷 통일


18. 자주 등장하는 대표 예외 클래스

Java Exception 구조를 공부할 때 아래 예외들은 익숙해지는 것이 좋습니다.

NullPointerException

null 객체 참조 시 발생

String name = null;
name.length();

IllegalArgumentException

잘못된 인자가 전달되었을 때 사용

public void setAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("나이는 0 이상이어야 합니다.");
    }
}

IllegalStateException

객체 상태가 메서드 호출에 적절하지 않을 때 사용

if (!isOpen) {
    throw new IllegalStateException("현재 닫힌 상태입니다.");
}

IOException

입출력 관련 문제

NumberFormatException

문자열 숫자 변환 실패

ArithmeticException

0으로 나누는 경우 등 산술 오류


19. Java Exception 구조를 공부할 때 꼭 잡아야 할 포인트

이 주제는 단순 암기보다 흐름으로 이해해야 합니다.

꼭 기억하면 좋은 핵심은 아래입니다.

  1. 자바에서 모든 예외의 최상위는 Throwable
  2. Throwable 아래에 ErrorException이 있음
  3. Exception은 다시 Checked / Unchecked로 나뉨
  4. RuntimeException은 Unchecked Exception 계열
  5. Checked Exception은 컴파일러가 처리 여부를 강제
  6. Unchecked Exception은 주로 코드 실수나 잘못된 상태 표현
  7. 실무에서는 Custom Exception과 전역 처리 구조가 중요

20. 초보자가 헷갈리는 부분 정리

Q1. Exception과 Error는 둘 다 예외인가요?

넓게 보면 문제 상황이지만, 자바 구조상 Error는 일반 애플리케이션이 처리할 대상으로 잘 보지 않습니다.

Q2. RuntimeException은 왜 굳이 따로 나뉘나요?

컴파일 시 강제 처리하면 오히려 코드가 과하게 복잡해질 수 있는 문제들을 분리하기 위해서입니다.

Q3. Checked Exception이 무조건 더 좋은가요?

그렇지 않습니다. 상황에 따라 다릅니다.
외부 자원 접근에는 의미가 있지만, 비즈니스 검증 예외는 RuntimeException이 더 자연스러운 경우가 많습니다.

Q4. Exception을 부모로 해서 다 잡으면 안 되나요?

가능은 하지만 추천되지는 않습니다.
너무 넓게 잡으면 원인 파악과 정확한 처리 흐름이 흐려질 수 있습니다.


예외 구조를 제대로 이해하면 코드가 달라집니다

Java Exception 구조를 이해한다는 것은 단순히 try-catch 문법을 아는 수준이 아닙니다.
어떤 예외를 어디서 처리해야 하는지, 어떤 예외를 직접 만들어야 하는지, 로그와 디버깅을 어떻게 남겨야 하는지까지 연결됩니다.

특히 실무에서는 아래 차이가 큽니다.

  • 예외를 대충 처리하는 코드 → 문제를 숨김
  • 예외 구조를 이해하고 설계한 코드 → 원인 파악이 쉬움

결국 예외 처리는 “에러를 없애는 기술”이 아니라,
문제가 생겼을 때 시스템을 더 잘 이해하고 통제하기 위한 기술에 가깝습니다.


마무리

이번 글에서는 Java Exception 구조 이해하기를 주제로 아래 내용을 정리했습니다.

  • Throwable, Error, Exception의 차이
  • Checked Exception과 Unchecked Exception 구조
  • RuntimeException의 역할
  • throwthrows 차이
  • 예외 전파와 실무 설계 방식
  • Custom Exception이 필요한 이유

자바를 공부하다 보면 예외는 귀찮은 문법처럼 느껴질 수 있습니다.
그런데 프로젝트를 오래 운영할수록 느끼는 건, 예외 처리를 잘한 코드가 결국 유지보수도 쉽고 장애 대응도 훨씬 빠르다는 점입니다.

그래서 Java를 제대로 공부하려면 문법만 외우기보다,
왜 이런 Exception 구조를 만들었는지를 이해하는 것이 정말 중요합니다.