Skip to main content

8. 예외 처리와 디버깅: 오류를 다루는 개발 습관

프로그램을 처음 배울 때는 보통 "오류 없이 실행되면 성공"이라고 생각하기 쉽다. 하지만 실제 개발에서는 조금 다르다. 좋은 프로그램은 오류가 전혀 없는 프로그램이라기보다, 문제가 생겼을 때도 무너지지 않고 원인을 알기 쉽게 드러내는 프로그램에 가깝다. 그리고 좋은 개발자는 오류를 피하기만 하는 사람이 아니라, 오류를 이해하고 다루는 사람이다.

이번 장에서는 자바의 예외 처리와 디버깅의 기초를 배운다. 예외가 무엇인지, 왜 필요한지, try-catch-finally는 어떻게 쓰는지, throw와 throws는 어떤 차이가 있는지, 그리고 버그를 찾기 위해 디버깅 도구를 어떻게 활용하는지까지 차근차근 살펴보자.

예외 처리와 디버깅: 오류를 다루는 개발 습관

예외란 무엇인가

프로그램은 늘 이상적인 입력만 받지 않는다. 숫자를 입력해야 하는데 문자가 들어올 수도 있고, 존재하지 않는 파일을 열려고 할 수도 있으며, 배열의 범위를 벗어난 위치를 읽으려 할 수도 있다. 이런 상황에서 프로그램이 아무 준비 없이 실행되면 갑자기 종료되거나 엉뚱한 결과를 낼 수 있다.

자바에서는 이런 비정상 상황을 예외(Exception)라는 개념으로 다룬다. 예외는 말 그대로 "정상 흐름에서 벗어난 사건"이다. 프로그램이 실행되는 도중 문제가 발생하면 자바는 예외 객체를 만들고, 이를 적절한 곳에서 처리하도록 한다.

예외를 이해할 때 중요한 점은, 예외가 단순히 "나쁜 것"만은 아니라는 점이다. 예외는 프로그램이 어디에서, 왜 문제가 생겼는지 알려주는 신호다. 신호를 무시하면 프로그램은 불안정해지고, 신호를 잘 처리하면 프로그램은 훨씬 견고해진다.

간단한 예를 보자.

public class Main {
    public static void main(String[] args) {
        int result = 10 / 0;
        System.out.println(result);
    }
}

이 코드는 0으로 나누기를 시도하므로 실행 중 예외가 발생한다. 컴파일은 되지만, 실행 중에 ArithmeticException이 발생하면서 프로그램이 중단된다. 이런 식으로 예외는 주로 실행 중 문제를 알려준다.

오류와 예외는 어떻게 다를까

초보자는 오류, 버그, 예외라는 말을 비슷하게 쓰곤 한다. 하지만 자바를 배우면서는 이 차이를 알아두는 것이 좋다.

첫째, 컴파일 오류는 문법이 틀렸거나 타입이 맞지 않을 때 발생한다. 이 경우는 실행 전에 컴파일러가 잡아낸다. 예를 들어 세미콜론을 빼먹거나, 문자열을 정수 변수에 넣으려 하면 컴파일 자체가 되지 않는다.

둘째, 예외는 실행 중 발생하는 비정상 상황이다. 파일이 없거나, 배열 인덱스가 범위를 벗어나거나, null 값을 잘못 사용하는 상황이 여기에 속한다.

셋째, 버그는 프로그램이 개발자의 의도와 다르게 동작하는 문제를 넓게 가리키는 말이다. 예외가 발생할 수도 있고, 예외 없이 조용히 틀린 결과를 낼 수도 있다. 예를 들어 할인율 계산식을 잘못 써서 금액이 이상하게 나온다면, 그것도 버그다.

즉, 예외는 버그를 드러내는 한 방식일 뿐이며, 모든 버그가 예외로 나타나는 것은 아니다. 그래서 예외 처리와 디버깅을 함께 배워야 한다.

예외가 발생하면 무슨 일이 일어날까

자바에서 예외가 발생하면 프로그램은 하던 일을 멈추고, 이 예외를 처리할 수 있는 곳을 찾는다. 이 과정을 따라가려면 메서드 호출 흐름을 떠올리면 쉽다.

예를 들어 main()이 a()를 호출하고, a()가 b()를 호출하고, b() 안에서 예외가 발생했다고 해보자. 그러면 자바는 먼저 b() 안에서 처리할 수 있는지 찾고, 없으면 a()로 올라가고, 그래도 없으면 main()까지 올라간다. 끝까지 처리하는 곳이 없으면 프로그램은 종료되고 예외 정보가 출력된다.

이때 출력되는 중요한 단서가 바로 스택 트레이스(stack trace)다. 스택 트레이스는 예외의 종류와, 예외가 발생한 메서드 호출 경로를 보여준다. 처음에는 영어 문장처럼 보여도, 익숙해지면 버그를 찾는 데 매우 강력한 힌트가 된다.

예를 들어 다음과 같은 메시지를 본 적이 있을 것이다.

Exception in thread "main" java.lang.NullPointerException
    at Main.printName(Main.java:10)
    at Main.main(Main.java:5)

이 메시지는 대충 이렇게 읽으면 된다. NullPointerException이 발생했고, 가장 직접적인 문제 지점은 Main.java 10번째 줄의 printName()이며, 그 메서드는 main()에서 호출되었다는 뜻이다. 디버깅은 이 정보를 읽는 습관에서 시작된다.

예외 클래스의 기본 구조 이해하기

자바의 예외는 모두 클래스이며, 계층 구조를 가진다. 아주 단순하게 보면 다음과 같이 이해할 수 있다.

  • Throwable은 예외와 오류의 최상위 부모다.

  • Exception은 프로그램에서 처리할 수 있는 예외를 나타낸다.

  • RuntimeException은 실행 중 흔히 발생하는 예외들의 부모다.

  • Error는 일반 애플리케이션 코드에서 복구하기 어려운 심각한 문제를 나타낸다.

여기서 실무와 학습에서 가장 많이 다루는 것은 Exception과 RuntimeException 계열이다. Error는 메모리 부족처럼 시스템 수준의 심각한 문제를 뜻하는 경우가 많아, 보통 애플리케이션 코드에서 직접 처리 대상으로 삼지 않는다.

checked 예외와 unchecked 예외

자바 예외에서 가장 중요한 분류 중 하나가 바로 checked 예외와 unchecked 예외다. 이름은 조금 딱딱하지만, 기준은 생각보다 단순하다.

checked 예외

checked 예외는 컴파일러가 처리 여부를 확인하는 예외다. 즉, 이런 예외가 발생할 수 있는 코드를 쓰면 개발자는 반드시 다음 둘 중 하나를 해야 한다.

  • try-catch로 직접 처리한다.

  • throws로 호출한 쪽에 넘긴다.

예를 들어 파일을 읽는 작업은 파일이 없을 수 있으므로 checked 예외를 동반하는 경우가 많다. 자바는 "이 상황은 충분히 예측 가능하니, 그냥 무시하지 말고 처리 계획을 세워라"라고 요구하는 셈이다.

unchecked 예외

unchecked 예외는 컴파일러가 강제로 처리 여부를 확인하지 않는 예외다. 대표적으로 RuntimeException의 자식들이 여기에 해당한다.

예를 들어 다음과 같은 것들이 있다.

  • NullPointerException

  • ArrayIndexOutOfBoundsException

  • ArithmeticException

  • IllegalArgumentException

이 예외들은 주로 프로그래머의 실수나 잘못된 사용 때문에 발생하는 경우가 많다. 컴파일러는 이를 일일이 강제하지 않지만, 그렇다고 중요하지 않다는 뜻은 아니다. 오히려 자주 만나고, 자주 고쳐야 하는 예외들이다.

둘의 차이를 감각적으로 이해하기

checked 예외는 "미리 예상 가능한 외부 문제를 대비하라"에 가깝고, unchecked 예외는 "코드가 잘못 작성되었거나 잘못 사용되었다"에 가깝다.

물론 현실은 이렇게 깔끔하게만 나뉘지 않는다. 하지만 학습 단계에서는 이렇게 이해하면 충분하다.

try-catch: 예외를 붙잡는 기본 문법

예외가 발생할 수 있는 코드를 안전하게 다루는 가장 기본적인 방법은 try-catch다.

public class Main {
    public static void main(String[] args) {
        try {
            int result = 10 / 0;
            System.out.println(result);
        } catch (ArithmeticException e) {
            System.out.println("0으로 나눌 수 없습니다.");
        }

        System.out.println("프로그램이 계속 실행됩니다.");
    }
}

try 블록 안에는 예외가 발생할 가능성이 있는 코드를 넣는다. 예외가 발생하면 그 아래의 catch가 해당 예외를 받아 처리한다. 처리에 성공하면 프로그램은 완전히 종료되지 않고 이후 코드를 계속 실행할 수 있다.

여기서 e는 발생한 예외 객체다. 이 객체 안에는 예외 종류, 메시지, 발생 위치 같은 정보가 담겨 있다.

catch (ArithmeticException e) {
    System.out.println("예외 메시지: " + e.getMessage());
}

이처럼 예외 객체의 정보를 출력해 보면 문제를 이해하는 데 도움이 된다.

여러 개의 catch 사용하기

하나의 try 블록에서 여러 종류의 예외가 발생할 수도 있다. 이때는 catch를 여러 개 둘 수 있다.

public class Main {
    public static void main(String[] args) {
        try {
            String text = null;
            System.out.println(text.length());
        } catch (NullPointerException e) {
            System.out.println("문자열이 비어 있습니다.");
        } catch (Exception e) {
            System.out.println("예상하지 못한 예외가 발생했습니다.");
        }
    }
}

중요한 점은 더 구체적인 예외를 먼저, 더 넓은 예외를 나중에 써야 한다는 것이다. Exception은 범위가 넓기 때문에 앞에 두면 뒤의 세부 예외들이 도달 불가능해진다.

즉, 예외 처리의 순서는 "작은 그물 먼저, 큰 그물 나중"이라고 생각하면 편하다.

finally: 예외가 있든 없든 반드시 실행할 코드

어떤 작업은 예외가 발생하더라도 반드시 마무리되어야 한다. 예를 들어 파일을 열었다면 닫아야 하고, 네트워크 연결을 사용했다면 해제해야 하며, 로그를 남겨야 할 수도 있다. 이런 코드는 finally에 작성한다.

public class Main {
    public static void main(String[] args) {
        try {
            System.out.println("작업 시작");
            int result = 10 / 0;
            System.out.println(result);
        } catch (ArithmeticException e) {
            System.out.println("예외 처리 완료");
        } finally {
            System.out.println("마무리 작업 실행");
        }
    }
}

이 코드에서는 예외가 발생하든 말든 finally 블록이 실행된다. 그래서 정리 작업을 넣기에 적합하다.

흐름은 다음처럼 이해하면 된다.

  • try 실행

  • 예외가 없으면 catch는 건너뛰고 finally 실행

  • 예외가 있으면 해당 catch 실행 후 finally 실행

다만 요즘 자바에서는 파일이나 스트림처럼 닫아야 하는 자원을 다룰 때 try-with-resources를 더 자주 사용한다. 하지만 finally의 개념 자체는 여전히 중요하다. "어떤 일이 있어도 마지막에 해야 할 일"을 표현하는 기본 도구이기 때문이다.

try-with-resources: 자원 정리를 더 안전하게

파일, 입력 스트림, 출력 스트림, 데이터베이스 연결처럼 사용 후 반드시 닫아야 하는 것을 자원(resource)이라고 부른다. 이런 자원을 finally에서 직접 닫을 수도 있지만, 코드가 길어지고 실수할 가능성도 있다.

그래서 자바는 try-with-resources 문법을 제공한다.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class Main {
    public static void main(String[] args) {
        try (BufferedReader br = new BufferedReader(new FileReader("sample.txt"))) {
            System.out.println(br.readLine());
        } catch (IOException e) {
            System.out.println("파일을 읽는 중 문제가 발생했습니다.");
        }
    }
}

괄호 안에서 생성한 자원은 try 블록이 끝나면 자동으로 닫힌다. 덕분에 코드가 간결해지고 자원 누수를 줄일 수 있다. 자원 누수란 닫아야 할 것을 닫지 않아 메모리나 시스템 자원을 계속 잡아두는 문제를 말한다.

초반에는 try-catch-finally의 구조를 먼저 익히고, 자원을 다룰 때는 try-with-resources가 더 편리하다는 점을 함께 기억하면 좋다.

throw와 throws의 차이

처음 배울 때 가장 많이 헷갈리는 부분 중 하나가 throw와 throws다. 철자도 비슷해서 자꾸 섞인다. 하지만 역할은 분명히 다르다.

throw: 예외를 직접 발생시키기

throw는 예외 객체를 실제로 던지는 명령어다.

public class Main {
    public static void main(String[] args) {
        int age = -1;

        if (age < 0) {
            throw new IllegalArgumentException("나이는 0보다 작을 수 없습니다.");
        }
    }
}

이 코드는 잘못된 값이 들어왔을 때 IllegalArgumentException을 직접 발생시킨다. 즉, 문제가 있다는 사실을 코드가 명시적으로 선언하는 것이다.

throws: 이 메서드가 예외를 넘길 수 있다고 알리기

throws는 메서드 선언부에 적어, 이 메서드에서 특정 예외가 발생할 수 있음을 알리는 문법이다.

import java.io.IOException;

public class Main {
    public static void readFile() throws IOException {
        throw new IOException("파일 읽기 실패");
    }

    public static void main(String[] args) {
        try {
            readFile();
        } catch (IOException e) {
            System.out.println("예외를 처리했습니다: " + e.getMessage());
        }
    }
}

이 코드에서 readFile()은 IOException을 처리하지 않고 호출한 쪽으로 넘긴다. 그래서 main()이 책임지고 처리한다.

아주 짧게 정리하면 이렇다.

  • throw: 지금 여기서 예외를 던진다.

  • throws: 이 메서드는 이런 예외를 던질 수 있다고 알린다.

예외를 직접 처리할까, 호출한 쪽에 넘길까

예외를 만나면 두 가지 선택지가 있다. 그 자리에서 catch로 처리하거나, throws로 위쪽 호출자에게 넘기는 것이다. 어떤 방법이 맞는지는 상황에 따라 다르다.

원칙은 꽤 현실적이다. 그 예외를 가장 잘 이해하고, 적절한 대응을 결정할 수 있는 곳에서 처리하는 것이 좋다.

예를 들어 파일을 읽는 유틸리티 메서드가 있다고 해보자. 그 메서드는 파일이 없다는 사실을 발견할 수는 있지만, 파일이 없을 때 "기본값을 보여줄지, 사용자에게 경고할지, 프로그램을 종료할지"는 화면을 담당하는 상위 로직이 더 잘 결정할 수 있다. 이 경우에는 throws로 넘기는 편이 자연스럽다.

반대로 어떤 메서드 안에서 문제를 충분히 해결할 수 있다면, 그 자리에서 try-catch로 처리하는 것이 좋다.

즉, 예외 처리는 단순히 문법 문제가 아니라 책임의 위치를 정하는 설계 문제이기도 하다.

사용자 정의 예외 만들기

자바는 기본 예외 클래스도 많지만, 프로그램 상황에 맞는 예외를 직접 만들 수도 있다. 특정 비즈니스 규칙을 명확하게 표현할 때 유용하다.

예를 들어 잔액보다 많은 금액을 출금하려고 할 때를 표현해 보자.

class InsufficientBalanceException extends Exception {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

class Account {
    private int balance;

    public Account(int balance) {
        this.balance = balance;
    }

    public void withdraw(int amount) throws InsufficientBalanceException {
        if (amount > balance) {
            throw new InsufficientBalanceException("잔액이 부족합니다.");
        }
        balance -= amount;
    }
}

public class Main {
    public static void main(String[] args) {
        Account account = new Account(1000);

        try {
            account.withdraw(1500);
        } catch (InsufficientBalanceException e) {
            System.out.println(e.getMessage());
        }
    }
}

이렇게 하면 단순히 Exception 하나로 뭉뚱그리지 않고, "무슨 문제가 생겼는지"를 이름만으로도 더 선명하게 드러낼 수 있다.

사용자 정의 예외는 특히 다음과 같은 장점이 있다.

  • 도메인 규칙을 코드에 분명히 드러낼 수 있다.

  • catch에서 상황별 처리를 구분하기 쉬워진다.

  • 에러 메시지와 의미를 일관되게 관리할 수 있다.

다만 너무 자잘한 예외 클래스를 끝없이 만드는 것은 오히려 복잡도를 높일 수 있으니, 의미가 분명한 경우에만 사용하는 것이 좋다.

자주 만나는 런타임 예외들

자바를 배우는 동안 특히 많이 만나게 되는 예외들이 있다. 이 예외들은 이름과 원인을 눈에 익혀두면 디버깅 속도가 훨씬 빨라진다.

NullPointerException

가장 유명한 예외다. 참조 변수가 null인데 그 객체를 사용하는 순간 발생한다.

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

여기서 name은 아무 객체도 가리키지 않는데 length()를 호출했기 때문에 문제가 생긴다.

예방 방법은 단순하지만 중요하다. 객체를 사용하기 전에 null 가능성을 의식하고, 필요하면 미리 검사하는 습관을 들여야 한다.

if (name != null) {
    System.out.println(name.length());
}

ArrayIndexOutOfBoundsException

배열 범위를 벗어난 인덱스에 접근할 때 발생한다.

int[] numbers = {10, 20, 30};
System.out.println(numbers[3]);

인덱스는 0부터 시작하므로 이 배열의 마지막 인덱스는 2다. 반복문에서 조건을 잘못 쓰면 자주 발생한다.

NumberFormatException

문자열을 숫자로 바꾸는 과정에서 형식이 맞지 않으면 발생한다.

String text = "abc";
int number = Integer.parseInt(text);

사용자 입력을 숫자로 바꿀 때 특히 자주 만난다. 이럴 때는 변환 전에 입력 형식을 확인하거나, 예외를 처리해 사용자에게 다시 입력받도록 해야 한다.

IllegalArgumentException

메서드에 잘못된 인자가 전달되었을 때 사용한다. 이 예외는 자바가 자동으로 던지기도 하지만, 개발자가 직접 throw로 던지기도 한다.

예를 들어 음수가 들어오면 안 되는 메서드라면 다음처럼 표현할 수 있다.

public static void setAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("나이는 음수가 될 수 없습니다.");
    }
}

이 예외는 "이 메서드를 이렇게 사용하면 안 된다"는 신호다.

예외 메시지는 친절해야 한다

예외를 처리할 때 많은 초보자가 하는 실수는 메시지를 너무 대충 쓰는 것이다. 예를 들어 단순히 오류 발생이라고만 출력하면, 나중에 문제가 생겼을 때 원인을 추적하기 어렵다.

좋은 메시지는 최소한 다음 질문에 답해야 한다.

  • 무엇을 하던 중에 문제가 생겼는가?

  • 어떤 값이나 조건 때문에 문제가 생겼는가?

  • 다음에 무엇을 확인하면 좋은가?

예를 들어 다음 두 메시지를 비교해 보자.

System.out.println("오류 발생");
System.out.println("사용자 나이 입력값이 잘못되었습니다: " + input);

두 번째 메시지가 훨씬 유용하다. 예외 메시지는 미래의 나, 그리고 함께 일할 동료를 위한 안내문이다.

예외를 삼키지 말자

초보 단계에서 꽤 자주 보이는 나쁜 습관이 있다. 바로 예외를 catch만 해놓고 아무 일도 하지 않는 것이다.

try {
    int num = Integer.parseInt("abc");
} catch (Exception e) {
}

이 코드는 겉보기에는 안전해 보이지만, 실제로는 매우 위험하다. 예외가 발생해도 아무 기록이 남지 않고, 프로그램은 조용히 잘못된 상태로 흘러갈 수 있다. 이런 경우 버그는 더 찾기 어려워진다.

최소한 학습 단계에서는 다음 중 하나는 해야 한다.

  • 사용자에게 이해 가능한 메시지를 보여준다.

  • 로그나 콘솔에 원인을 남긴다.

  • 더 적절한 예외로 바꿔서 다시 던진다.

예외를 처리한다는 것은 예외를 숨기는 것이 아니라, 의미 있게 대응하는 것이다.

printStackTrace는 왜 도움이 될까

디버깅 초반에는 e.printStackTrace()가 아주 유용하다.

try {
    String text = null;
    System.out.println(text.length());
} catch (Exception e) {
    e.printStackTrace();
}

이 메서드는 예외 이름, 메시지, 발생 위치, 호출 흐름을 자세히 출력해 준다. 학습할 때는 "어느 줄에서 문제가 터졌는지"를 빠르게 찾는 데 매우 큰 도움이 된다.

다만 실제 서비스 환경에서는 무조건 콘솔에 찍는 방식보다 로깅 도구를 쓰는 경우가 많다. 하지만 지금 단계에서는 스택 트레이스를 읽을 줄 아는 것이 훨씬 중요하다.

디버깅이란 무엇인가

예외 처리는 문제가 발생했을 때 프로그램의 반응을 설계하는 일이고, 디버깅은 문제가 왜 발생했는지 추적하는 과정이다.

많은 초보자는 디버깅을 "코드를 한참 들여다보는 일" 정도로 생각하지만, 실제로는 가설을 세우고 확인하는 탐정 놀이에 가깝다. 증상만 보고 감으로 고치면 비슷한 문제가 반복된다. 원인을 찾아야 한다.

디버깅의 기본 흐름은 대개 이렇다.

  • 문제를 재현한다.

  • 어디서부터 이상해졌는지 범위를 좁힌다.

  • 변수 값과 흐름을 확인한다.

  • 원인을 수정한 뒤 다시 재현해 본다.

즉, 디버깅은 우연히 맞히는 기술이 아니라 문제를 좁혀가는 기술이다.

가장 쉬운 디버깅: 출력으로 확인하기

디버깅을 처음 배울 때 가장 쉬운 방법은 System.out.println()으로 중간 값을 출력해 보는 것이다. 이 방식은 단순하지만 효과가 좋다.

public class Main {
    public static void main(String[] args) {
        int total = 0;

        for (int i = 1; i <= 5; i++) {
            total += i;
            System.out.println("i=" + i + ", total=" + total);
        }

        System.out.println("최종 결과: " + total);
    }
}

이렇게 하면 반복문이 어떻게 동작하는지, 어떤 시점에 값이 이상해지는지 눈으로 확인할 수 있다.

물론 출력 디버깅에는 한계도 있다. 출력문이 많아지면 코드가 지저분해지고, 복잡한 흐름에서는 추적이 어려워진다. 그래서 다음 단계로 넘어가면 IDE 디버거를 사용하게 된다.

하지만 디버깅 감각을 기르는 첫걸음으로는 출력만큼 좋은 것도 드물다. 값의 흐름을 눈으로 보는 습관이 생기기 때문이다.

IDE 디버거의 핵심 개념

IntelliJ IDEA, Eclipse 같은 자바 개발 도구에는 디버거가 들어 있다. 디버거를 쓰면 프로그램을 한 줄씩 실행하며 변수 값을 확인할 수 있다. 처음에는 낯설어 보여도, 핵심 개념만 익히면 훨씬 편해진다.

브레이크포인트

브레이크포인트는 "이 줄에서 잠깐 멈춰라"라는 표시다. 코드 왼쪽 여백을 클릭하면 설정할 수 있다. 프로그램이 그 줄에 도달하면 실행이 멈추고, 그 시점의 변수 값과 호출 흐름을 살펴볼 수 있다.

브레이크포인트는 버그를 잡는 함정 같은 것이다. 문제가 생길 것 같은 지점에 설치해 두고, 프로그램이 거기에 걸리길 기다리는 셈이다.

Step Over

현재 줄을 실행하고 다음 줄로 이동한다. 메서드 호출이 있어도 그 안으로 들어가지 않고, 결과만 반영한 채 다음 줄로 간다. 간단한 흐름을 빠르게 볼 때 편하다.

Step Into

현재 줄의 메서드 호출 안으로 들어간다. 내가 만든 메서드가 실제로 어떻게 동작하는지 세부적으로 보고 싶을 때 유용하다.

Step Out

현재 메서드 내부를 보고 있는 중이라면, 그 메서드 실행을 마치고 호출한 쪽으로 빠져나온다. 너무 깊이 들어갔을 때 다시 위로 올라오기에 좋다.

Resume

다음 브레이크포인트까지 계속 실행한다. 중간 과정을 모두 한 줄씩 볼 필요가 없을 때 사용한다.

디버깅할 때 꼭 확인해야 할 것

버그를 찾을 때는 무작정 코드를 처음부터 끝까지 보는 것보다, 특정 포인트를 집중적으로 확인하는 것이 낫다.

첫째, 입력값이 정말 내가 기대한 값인지 확인하자. 사용자 입력, 파일 데이터, 메서드 인자 값이 이미 이상한 경우가 많다.

둘째, 조건문이 내가 생각한 방향으로 분기되었는지 보자. if 조건이 예상과 반대로 평가되는 경우는 꽤 흔하다.

셋째, 반복문의 종료 조건이 올바른지 확인하자. 반복 횟수가 하나 부족하거나 하나 많은 문제는 초보자에게 매우 흔하다.

넷째, 객체 상태가 언제 바뀌는지 추적하자. 필드 값이 중간에 다른 메서드에서 변경되는 경우 버그가 숨어 있기 쉽다.

다섯째, 예외가 실제로 처음 발생한 지점이 어디인지 확인하자. 겉으로 드러난 위치와 원인이 되는 위치가 다를 수 있다.

디버깅 예제: 왜 평균이 이상할까

다음 코드를 보자.

public class Main {
    public static void main(String[] args) {
        int sum = 0;
        int[] scores = {80, 90, 100};

        for (int i = 0; i < scores.length; i++) {
            sum += i;
        }

        double average = sum / scores.length;
        System.out.println("평균: " + average);
    }
}

이 코드는 얼핏 보면 평균을 구하는 것처럼 보이지만, 결과가 이상하다. 왜일까?

출력 디버깅을 해보면 금방 드러난다.

for (int i = 0; i < scores.length; i++) {
    sum += i;
    System.out.println("i=" + i + ", sum=" + sum);
}

이렇게 보면 sum에 점수 값이 아니라 인덱스 i가 더해지고 있다는 사실을 발견할 수 있다. 즉, sum += scores[i];가 되어야 한다.

게다가 평균 계산도 미묘한 함정이 있다.

double average = (double) sum / scores.length;

이렇게 형변환을 해 주어야 정수 나눗셈이 아니라 실수 나눗셈이 된다. 디버깅은 이런 식으로 "어디서 생각과 실제가 갈라졌는지" 찾는 과정이다.

디버깅 예제: NullPointerException 추적하기

다음 코드를 보자.

class Member {
    String name;
}

public class Main {
    public static void main(String[] args) {
        Member member = new Member();
        printUpperName(member);
    }

    public static void printUpperName(Member member) {
        System.out.println(member.name.toUpperCase());
    }
}

여기서 member 객체 자체는 만들어졌지만, name 필드는 초기화되지 않았다. 즉 name은 null이다. 그래서 toUpperCase()를 호출하는 순간 NullPointerException이 발생한다.

이럴 때 초보자는 종종 member가 null이라고 생각하지만, 실제로는 member.name이 null인 것이다. 이 차이를 구분하는 습관이 매우 중요하다.

수정 방법은 여러 가지가 있다.

public static void printUpperName(Member member) {
    if (member != null && member.name != null) {
        System.out.println(member.name.toUpperCase());
    } else {
        System.out.println("이름 정보가 없습니다.");
    }
}

디버깅의 핵심은 "어느 변수가 null인가?"를 정확히 찾는 것이다. NullPointerException이 난다고 해서 항상 같은 원인은 아니다.

예외 처리와 디버깅은 함께 가야 한다

예외 처리만 잘해도 충분할 것 같지만, 사실 그렇지 않다. 예외를 잘 처리해도 원인을 모르면 비슷한 문제가 반복된다. 반대로 디버깅만 잘하고 예외 처리를 안 하면, 문제가 생길 때 프로그램이 너무 쉽게 무너진다.

둘은 역할이 다르다.

  • 예외 처리는 문제가 생겼을 때 프로그램의 대응 방식을 정한다.

  • 디버깅은 문제가 왜 생겼는지 원인을 찾는다.

  • 둘을 함께 익혀야 안정적인 프로그램을 만들 수 있다.

즉, 예외 처리는 안전벨트이고, 디버깅은 사고 원인 조사에 가깝다. 둘 중 하나만으로는 부족하다.

좋은 예외 처리 습관

예외를 다루는 방식에는 분명 습관의 차이가 드러난다. 좋은 습관은 프로그램의 품질을 눈에 띄게 올려 준다.

첫째, 예외를 너무 넓게 한 번에 잡지 말자. 무조건 catch (Exception e)로 처리하면 편해 보이지만, 실제 원인을 구분하기 어려워질 수 있다. 가능한 한 의미 있는 예외를 구체적으로 처리하는 편이 좋다.

둘째, 예외 메시지를 구체적으로 남기자. 어떤 작업에서 실패했는지, 어떤 값이 문제였는지 알 수 있어야 한다.

셋째, 복구 가능한 문제와 복구 불가능한 문제를 구분하자. 다시 입력받으면 되는 문제와, 프로그램 실행을 계속하면 더 위험한 문제는 대응 방식이 달라야 한다.

넷째, 자원 정리를 잊지 말자. 파일과 스트림은 열었으면 닫아야 한다.

다섯째, 예외를 숨기지 말자. 조용히 묻어두면 결국 더 큰 문제로 돌아온다.

초보자가 자주 하는 실수

예외 처리와 디버깅을 배우는 초반에는 비슷한 실수가 반복된다. 미리 알아두면 훨씬 덜 헤맨다.

모든 곳에 try-catch를 붙인다

예외가 무서워서 여기저기 try-catch를 마구 붙이는 경우가 있다. 하지만 이러면 흐름이 복잡해지고, 예외 책임이 여기저기 흩어진다. 어디에서 처리해야 가장 자연스러운지 생각하는 습관이 필요하다.

원인보다 증상만 고친다

예외가 난 줄만 급하게 바꿨더니, 다른 입력에서 같은 문제가 다시 터지는 경우가 많다. 디버깅은 "이 줄을 바꾸면 되나?"가 아니라 "왜 이런 상태가 되었지?"를 묻는 과정이다.

콘솔 메시지를 읽지 않는다

예외 메시지와 스택 트레이스는 힌트 덩어리다. 그런데 초보자는 종종 길고 영어라서 그냥 지나친다. 처음에는 느리더라도 차근차근 읽는 습관을 들이자. 줄 번호 하나만 제대로 읽어도 절반은 해결된 셈이다.

정상 입력만 가정한다

프로그램은 항상 착한 사용자만 만나지 않는다. 빈 문자열, 음수, 너무 큰 값, 잘못된 형식, null 같은 입력을 의식해야 한다. 예외 처리는 결국 현실적인 입력을 받아들이는 연습이다.

실전 감각: 어떤 경우에 어떤 예외를 떠올릴까

자바 코드를 읽다가 다음과 같은 상황을 보면 어떤 예외가 떠오르는지 연습해 보자.

배열 인덱스를 직접 계산하고 있다면 범위 초과 가능성을 떠올려야 한다. 문자열을 숫자로 바꾸고 있다면 형식 오류를 의심해야 한다. 객체 필드를 연달아 참조하고 있다면 null 가능성을 생각해야 한다. 외부 파일이나 네트워크를 다루고 있다면 checked 예외와 자원 정리 문제를 의식해야 한다.

이 감각이 생기면 코드를 짜는 순간부터 "어디가 위험한가"가 보이기 시작한다. 예외 처리는 사후 대처이기도 하지만, 동시에 사전 예방이기도 하다.

작은 프로그램을 더 튼튼하게 만드는 방법

예외 처리는 거대한 시스템에서만 필요한 기술이 아니다. 오히려 작은 콘솔 프로그램에서부터 습관을 들이는 것이 중요하다. 예를 들어 숫자를 입력받는 프로그램이라면, 사용자가 문자를 입력했을 때 프로그램이 종료되지 않고 다시 입력받도록 만들 수 있다. 메뉴 선택 프로그램이라면 잘못된 번호가 들어왔을 때 안내 메시지를 출력할 수 있다. 파일을 읽는 프로그램이라면 파일이 없을 때 기본 메시지를 보여줄 수 있다.

이런 배려가 쌓이면 프로그램은 훨씬 친절해지고, 개발자는 훨씬 덜 당황하게 된다.

정리하며

예외 처리는 프로그램을 보호하는 기술이고, 디버깅은 프로그램을 이해하는 기술이다. 둘 다 개발자에게 필수다. 오늘 코드는 간단하더라도, 입력이 조금만 달라지면 언제든 문제가 생길 수 있다. 그래서 안정적인 프로그램은 "문제가 없을 때만 잘 동작하는 프로그램"이 아니라, 문제가 생겨도 원인을 드러내고 적절히 반응하는 프로그램이다.

이번 장에서 꼭 기억할 핵심은 다음과 같다.

  • checked 예외는 컴파일러가 처리 여부를 확인하고, unchecked 예외는 주로 실행 중 실수에서 많이 발생한다.

  • try-catch-finally는 예외 처리와 마무리 작업의 기본 도구다.

  • throw는 예외를 던지는 것이고, throws는 예외 가능성을 선언하는 것이다.

  • 스택 트레이스를 읽고, 브레이크포인트와 변수 확인으로 원인을 좁혀가는 것이 디버깅의 핵심이다.

  • 좋은 개발자는 오류가 없는 사람보다, 오류를 침착하게 다루는 사람이다.

다음 장에서는 여러 데이터를 더 효율적으로 다루기 위한 컬렉션 프레임워크를 배운다. 예외 처리와 디버깅 습관을 익혀 두면, 컬렉션처럼 조금 더 복잡한 구조를 배울 때도 훨씬 안정적으로 코드를 다룰 수 있다.

책 목록

처음부터 실무까지 단계별로 배우는 JAVA 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기