인프런/김영한 자바

[김영한의 실전 자바 중급 1편] - 8. 예외 처리

sson-coding 2025. 12. 16. 22:56

본 글은 김영한 님의 『김영한의 실전 자바 - 중급 1편』 강의를 학습하며 정리한 내용입니다.
강의 자료에 포함된 일부 코드와 이미지를 참고하여 발췌·활용하였습니다.

자바 기본기를 제대로 다지고 싶으시다면, 아래 링크에서 강의를 확인해 보세요
『김영한의 실전 자바 - 중급 1편』 보러 가기
본게시물은 파트너스 활동의 일환으로 작성되었으며, 구매 시 소정의 수수료를 받을 수 있습니다.

[김영한의 실전 자바 - 중급 1편| 김영한 - 인프런 강의

현재 평점 5.0점 수강생 10,983명인 강의를 만나보세요. 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다. 실무에 필요한 다양한 자바 중급 기능, Object, 불변 객체, String

www.inflearn.com](https://inf.run/Vvs5C)


예외 계층

자바는 프로그램 실행 중에 발생할 수 있는 예상치 못한 상황, 즉 예외(Exception) 을 처리하기 위한 메커니즘을 제공한다.

또한 다음 키워드들을 제공한다.
try, catch, finally, throw, throws

예외 계층 그림

  • Object
    • 자바에서 기본형을 제외한 모든 것은 객체이다.
    • 예외도 객체이므로 최상위 부모는 Object 이다.
  • Throwable
    • 최상위 예외이다.
  • Error
    • 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구가 불가능한 시스템 예외이다.
    • 애플리케이션 개발자는 이 예외를 잡으려고 해서는 안된다.
  • Exception
    • 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.
    • Exception 과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다.
    • RuntimeException 은 언체크 예외이다.
  • RuntimeException
    • 컴파일러가 체크하지 않는 언체크 예외이다.

체크 예외 vs 언체크 예외

  • 체크 예외
    • 발생한 예외를 개발자가 명시적으로 처리해야 한다.
    • 처리하지 않으면 컴파일 오류가 발생한다.
  • 언체크 예외
    • 개발자가 발생한 예외를 명시적으로 처리하지 않아도 된다.

예외 기본 규칙

예외는 폭탄 돌리기와 같다.

예외가 발생하면 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야한다.

기본 규칙

  1. 예외는 잡아서 처리하거나 밖으로 던져야 한다.
  2. 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리할 수 있다.
    1. Exceptioncatch 로 잡으면 그 하위 예외들도 모두 잡을 수 있다.
    2. Excpetionthrows 로 던지면 그 하위 예외들도 모두 던질 수 있다.
  3. 예외를 처리하지 못하고 계속 던지면 예외 로그를 출력하면서 시스템이 종료된다.

try-catch

  1. 예외를 잡아서 처리하려면 try ~ catch() 사용해 예외를 잡으면 된다.
  2. try 코드 블럭에서 발생하는 예외를 잡아서 catch 로 넘긴다.
  3. try 코드 에서 잡은 예외가 없다면 예외를 밖으로 던져야 한다.

try-catch-finally

try {
 //정상 흐름
} catch {
 //예외 흐름
} finally {
 //반드시 호출해야 하는 마무리 흐름
}
  • try 를 시작하기만 하면, finally 코드 블럭은 어떤 경우라도 반드시 호출된다.

throw, throws

  • throw 예외
    • 새로운 예외를 발생시킬 수 있다.
    • 예외도 객체이기 때문에 new 로 생성하고 예외를 발생시켜야 한다.
  • throws 예외
    • 발생시킨 예외를 메서드 밖으로 던질 때 사용한다.
  • throw 예외는 throws 에 선언된 타입이거나 그 하위 타입이어야 한다.

체크 예외

RuntimeException 을 제외한 Excpetion 과 그 하위 예외는 모두 컴파일러가 체크하는 예외이다.
체크 예외는 잡아서 처리하거나, 밖으로 던지도록 선언해야 한다.

코드를 통해서 알아보자.

public class MyCheckedException extends Exception {
    public MyCheckedException(String message) {
        super(message);
    }
}
  • Exception 을 상속받은 예외는 체크 예외가 된다.
  • 예외가 제공하는 기본 기능이 있는데, 그 중에 오류 메시지를 보관하는 기능도 있다. 생성자를 통해서 해당 기능을 그대로 사용하면 편리하다.
  • super(message) 로 전달한 메시지는 Throwable 에 있는 detailMessage 에 보관된다.
    • getMessage() 를 통해 조회할 수 있다.
public class Client {
    public void call() throws MyCheckedException{
        throw new MyCheckedException("ex");
    }
}
  • throw 예외
    • 새로운 예외를 발생시킬 수 있다.
    • 예외도 객체이기 때문에 new 로 생성하고 예외를 발생시켜야 한다.
  • throws 예외
    • 발생시킨 예외를 메서드 밖으로 던질 때 사용한다.
public class Service {
    Client client = new Client();
  /**
  * 예외를 잡아서 처리하는 코드
  */
    public void callCatch(){
        try{
            client.call();
        } catch (MyCheckedException e) {
            System.out.println("예외 처리, message=" + e.getMessage());
        }
        System.out.println("정상 흐름");
    }

  /**
     * 체크 예외를 밖으로 던지는 코드
     * 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야한다.
     */
    public void callThrow() throws MyCheckedException{
        client.call();
    }
}
public class CheckedCatchMain {
    public static void main(String[] args) {
        Service service = new Service();
        service.callCatch();
        System.out.println("정상 종료");
    }
}

--실행결과--
예외 처리, message=ex
정상 흐름
정상 종료

실행 순서를 분석해보자.

  1. main() → service.callCatch() → client.call() [예외 발생, 던짐]
  2. Client.call() 에서 MyCheckedException 예외가 발생하고, 그 예외를 Service.callCatch() 에서 잡는다.
  3. catch 로 예외를 잡아서 처리하고 나면 코드가 cath 블럭의 다음 라인으로 넘어가서 정상 흐름으로 작동한다.
public class CheckedThrowMain {
    public static void main(String[] args) throws MyCheckedException {
        Service service = new Service();
        service.callThrow();
        System.out.println("정상 종료");
    }
}

--실행결과--
Exception in thread "main" exception.basic.checked.MyCheckedException: ex
at exception.basic.checked.Client.call(Client.java:5)
at exception.basic.checked.Service.callThrow(Service.java:28)
at exception.basic.checked.CheckedThrowMain.main(CheckedThrowMain.java:7)

실행 순서를 분석해보자.

  1. main() → service.callThrow() → client.call() [예외 발생, 던짐]
  2. Service.callThrow() 안에서 예외를 처리하지 않고, 던져서 main() 메서드까지 올라온다.
  3. main() 에서 예외를 처리하지 못했기 때문에 밖으로 던진다.
  4. 예외가 main() 밖으로 던져지면 예외 정보과 스택 트레이스를 출력하고 프로그램이 종료된다.
    • 스택 트레이스 정보를 활용하면 예외가 어디서 발생했는지, 어떤 경로를 거쳐서 넘어왔는지 확인할 수 있다.

장단점

  • 장점
    • 개발자가 실수로 예외를 누락하지 않도록 컴파일러가 문제를 잡아준다.
  • 단점
    • 모든 체크 예외를 잡거나 던지도록 처리해야 하기 때문에, 번거롭다.

언체크 예외

RuntimeException 과 그 하위 예외는 언체크 예외이다.
말 그대로 컴파일러가 예외를 체크하지 않는다는 뜻이다.
체크 예외와의 차이는 예외를 던지는 throws 를 선언하지 않고 생략할 수 있다.

코드를 통해 살펴보자.

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

public class Client {
    public void call(){
        throw new MyUncheckedException("ex");
    }
}

public class Service {
    Client client = new Client();

    public void callCatch() {
        try {
            client.call();
        } catch (MyUncheckedException e) {
            //예외 처리 로직
            System.out.println("예외 처리, message=" + e.getMessage());
        }
        System.out.println("정상 로직");
    }

    public void callThrow() {
        client.call();
    }
}

public class UncheckedThrowMain {
    public static void main(String[] args) {
        Service service = new Service();
        service.callThrow();
        System.out.println("정상 종료");
    }
}

실행 과정은 위에서 체크예외와 같다.

다른 점은 체크 예외와 다르게 throws 예외 를 선언하지 않은 점이다.

장단점

  • 장점
    • 신경쓰고 싶지 않은 언체크 예외를 무시할 수 잇다.
  • 단점
    • 실수로 예외를 누락할 수 있다.

예외 계층

예외를 단순히 오류 코드로 분류하는 것이 아니라, 예외를 계층화해서 다양하게 만들면 더 세밀하게 예외를 처리할 수 있다.

  • NetworkClientExceptionV3
    • NetworkClient 에서 발생하는 모든 예외는 이 예외의 자식이다.
  • ConnectExceptionV3
    • 연결 실패시 발생하는 예외
  • SendExceptionV3
    • 전송 실패시 발생하는 예외

이렇게 예외를 계층화하면 다음과 같은 장점이 있다.

  1. 부모 예외를 잡거나 던지면, 자식 예외도 함께 잡거나 던질 수 있다.
  2. 특정 예외를 잡아서 처리할 수 있다.

활용

모든 예외를 잡아서 처리하려면 마지막에 Exception 을 두면 된다.
예외가 발생했을 때 catch 를 순서대로 실행하므로, 더 디테일한 자식을 먼저 잡아야 한다.

try {
 // 1. RuntimeException 발생
} catch (ConnectExceptionV3 e) { // 2. 대상이 다름
} catch (NetworkClientExceptionV3 e) { // 3.대상이 다름
} catch (Exception e) { // 4.Exception은 RuntimeException의 부모이므로 여기서 잡음
}

또한 다음과 같이 | 를 사용해서 여러 예외를 한번에 잡을 수 있다.

try {
 client.connect();
 client.send(data);
} catch (ConnectExceptionV3 | SendExceptionV3 e) {
 System.out.println("[연결 또는 전송 오류] 주소: , 메시지: " + e.getMessage());
} finally {
 client.disconnect();
}

이 경우 각 예외들의 공통 부모의 기능만 사용할 수 있다.


실무 예외 처리 방안

체크 예외의 부담

체크 예외는 개발자가 실수로 놓칠 수 있는 예외들을 컴파일러가 체크해준다.
처리할 수 없는 예외가 많아지고, 프로그램이 점점 복잡해지면서 체크 예외를 사용하는 것이 점점 더 부담스러워졌다.

체크 예외 사용 시나리오

  1. 실무에서는 수 많은 라이브러리를 사용하고, 또 다양한 외부 시스템과 연동한다.
  2. 사용하는 각각의 클래스들이 자신만의 예외를 모두 체크 예외로 만들어서 전달한다고 가정하자.
  3. 호출하는 어떤 클래스에서 던지는 체크 예외들을 처리해야 한다. 만약 처리할 수 없다면 밖으로 던져야 한다.
  4. 이 클래스에서 예외를 처리할 수 없는 예외들은 하나씩 밖으로 던져야 한다.
  5. 라이브러리가 늘어날수록 다루어야 하는 예외도 더 많아진다.
  6. 다룰 수 없는 수 많은 체크 예외 지옥에 빠지게 돼 Exception 예외를 던진다.

throws Exception 의 문제

체크 예외의 최상위 타입인 Exception 을 던지게 되면 다른 체크 예외를 체크할 수 있는 기능이 무효화되고, 중요한 체크 예외를 다 놓치게 된다.

중간에 중요한 체크 예외가 발생해도 컴파일러는 Exception 을 던지기 때문에 문법에 맞다고 판단해서 컴파일 오류가 발생하지 않는다.

언체크(런타임) 예외 사용 시나리오

  1. 어떤 클래스에서 호출하는 클래스들이 언체크 예외를 전달한다고 가정해보자.
  2. 언체크 예외이므로 throws 를 선언하지 않아도 된다.
  3. 사용하는 라이브러리가 늘어나서 언체크 예외가 늘어도 본인이 필요한 예외만 잡으면 된다.

예외 공통처리

처리할 수 없는 예외들은 중간에 여러곳에서 나누어 처리하기 보다는 예외를 공통으로 처리할 수 있는 곳을 만들어서 한 곳에서 해결하면 된다.

public class Main {
  public static void main(String[] args) {
     NetworkServiceV4 networkService = new NetworkServiceV4();
     Scanner scanner = new Scanner(System.in);
     while (true) {
         System.out.print("전송할 문자: ");
         String input = scanner.nextLine();
         if (input.equals("exit")) {
             break;
         }

         try {
             networkService.sendMessage(input);
         } catch (Exception e) { // 모든 예외를 잡아서 처리
             exceptionHandler(e);
         }
         System.out.println();
     }
     System.out.println("프로그램을 정상 종료합니다.");
 }

 //공통 예외 처리
 private static void exceptionHandler(Exception e) {
     //공통 처리
     System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");
     System.out.println("==개발자용 디버깅 메시지==");
     e.printStackTrace(System.out); // 스택 트레이스 출력
     //e.printStackTrace(); // System.err에 스택 트레이스 출력
     //필요하면 예외 별로 별도의 추가 처리 가능
     if (e instanceof SendExceptionV4 sendEx) { 
         System.out.println("[전송 오류] 전송 데이터: " + sendEx.getSendData());
     }
 }
}
  • exceptionHandler()
    • 해결할 수 없는 예외가 발생하면 사용자에게는 시스템 내에 알 수 없는 문제가 발생했다고 알리는 것이 좋다.
    • 개발자는 빨리 문제를 찾고 디버깅 할 수 있도록 오류 메시지를 남겨두어야 한다.
    • 예외도 객체이므로 필요하면 instanceof 와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수 있다.
  • e.printStackTrace()
    • 예외 메시지와 스택 트레이스를 출력할 수 있다.
    • 예외가 발생한 지점을 역으로 추적할 수 있다.
    • System.err 표준 오류에 결과를 출력한다.
      • 출력 결과를 빨간색으로 보여준다.

try-with-resources

애플리케이션에서 외부 자원을 사용하는 경우 반드시 외부 자원을 해제해야 한다.

자바에서는 try-with-resources 라는 편의 기능을 자바 7에서 도입했다.

이 기능을 사용하려면 먼저 AutoCloseable 인터페이스를 구현해야 한다.
이 인터페이스를 구현하면 try 가 끝나는 시점에 close() 가 자동으로 호출된다.

try (Resource resource = new Resource()) {
 // 리소스를 사용하는 코드
}
  • try 블럭이 끝나면 자동으로 AutoCloseable.close() 를 호출해서 자원을 해제한다.
  • catch 블럭 없이 try 블럭만 있어도 close() 는 호출된다.
public class NetworkClientV5 implements AutoCloseable {
...
@Override
 public void close() {
     System.out.println("NetworkClientV5.close");
     disconnect();
 }
}
  • close()
    • AutoCloseable 인터페이스가 제공하는 메서드는 try 가 끝나면 자동으로 호출된다.
    • 종료 시점에 자원을 반납하는 방법을 여기에 정의하면 된다.

장점

  • 리소스 누수 방지
    • 모든 리소스가 제대로 닫히도록 보장한다.
  • 코드 간결성 및 가독성 향상
    • 명시적인 close() 호출이 필요 없다.
  • 스코프 범위 한정
    • 리소스로 사용되는 변수의 스코프가 try 블럭 안으로 한정된다.
  • 조금 더 빠른 자원 해제
    • 기존에는 try catch finallycatch 이후에 자원을 반납했다. Try with resources
      구분은 try 블럭이 끝나면 즉시 close() 를 호출한다.