Chapter 12

Java 예외 처리와 오류 읽기

잘못된 체력 데이터와 숫자 변환 오류를 안전하게 처리하며 컴파일 오류, 런타임 예외, try-catch와 stack trace를 배웁니다.

난이도
초급
예상 학습
50분
Java 기준
Java 25 LTS
최종 검토

전체 22개 Chapter 확장 구조 중 Chapter 12입니다.

기능 구분

정식 Java 25에서도 쓸 수 있는 이전 버전 기능

Java SE 정식 기능

try, catch, throw, 사용자 정의 예외는 Java 25에서도 그대로 쓰는 기존 기능입니다.

이 Chapter의 예제는 Preview 또는 Incubator API 실행 옵션 없이 Java 25 LTS 정식 기능 기준으로 읽을 수 있습니다.

데이터 흐름에서 손상된 블록을 찾아 분리하는 Java 예외 처리 학습 이미지
예외 처리는 모든 문제를 덮는 장치가 아니라 잘못된 입력 블록을 찾아 안전한 경로로 보내는 규칙입니다.

Learning goals

오늘 배울 내용

  • 컴파일 오류와 런타임 예외 구분하기
  • stack trace에서 원인과 내 코드 줄 찾기
  • 필요한 범위에 try-catch 적용하기
  • 예외를 숨기지 않고 유용한 메시지 제공하기

오류의 종류부터 나누기

  • 컴파일 오류: 문법이나 타입이 맞지 않아 .class 파일을 만들지 못합니다.
  • 런타임 예외: 컴파일은 됐지만 특정 입력이나 상태에서 실행 중 문제가 생깁니다.
  • 논리 오류: 프로그램은 끝까지 실행되지만 계산 결과가 의도와 다릅니다.

try-catch는 컴파일 오류를 고치는 문법이 아닙니다. 예상 가능한 런타임 문제에 대응하는 도구입니다.

숫자 변환 오류 처리하기

public class Main {
    public static void main(String[] args) {
        String[] healthInputs = {"20", "unknown", "7"};

        for (String input : healthInputs) {
            try {
                int health = Integer.parseInt(input);
                System.out.println("체력 적용: " + health);
            } catch (NumberFormatException error) {
                System.out.println("잘못된 체력 값: " + input);
            }
        }

        System.out.println("나머지 데이터를 계속 처리합니다.");
    }
}

예상 출력:

체력 적용: 20
잘못된 체력 값: unknown
체력 적용: 7
나머지 데이터를 계속 처리합니다.

unknown은 정수로 바꿀 수 없어 NumberFormatException이 발생합니다. 해당 예외만 잡아 어떤 값이 잘못됐는지 알려주고 다음 데이터 처리를 계속합니다.

try 범위는 작게

파일 읽기, 변환, 계산, 저장 전체를 하나의 큰 try로 감싸면 어느 단계가 실패했는지 알기 어렵습니다. 예외가 실제로 예상되는 변환 한 줄과 그 결과를 사용하는 최소 범위만 묶으세요.

예외 변수 이름을 ignored로 만들고 아무것도 하지 않으면 문제를 추적하기 어렵습니다. 복구할 수 없다면 의미 있는 맥락을 더해 다시 던지거나 호출자에게 실패를 전달해야 합니다.

stack trace 읽는 순서

Stack trace가 길어도 먼저 예외 이름과 메시지를 읽고, 위에서부터 자신의 패키지나 클래스가 처음 나오는 줄을 찾습니다.

java.lang.NumberFormatException: For input string: "unknown"
    at java.base/java.lang.Integer.parseInt(...)
    at Main.main(Main.java:7)

여기서는 Main.java:7이 내 코드가 예외를 일으킨 위치입니다. 표준 API 내부 줄을 먼저 고치려 하지 말고 그 API에 어떤 값을 전달했는지 확인합니다.

직접 예외 발생시키기

메서드가 받아들일 수 없는 값은 throw로 즉시 거부할 수 있습니다.

static int requirePositive(int amount) {
    if (amount <= 0) {
        throw new IllegalArgumentException("amount는 1 이상이어야 합니다.");
    }
    return amount;
}

문제가 생긴 지점 가까이에서 명확한 메시지를 만들면 호출자가 원인을 찾기 쉽습니다.

직접 해보기

  1. 입력 배열에 -3을 추가하고 숫자 변환은 성공한다는 점을 확인합니다.
  2. 변환 뒤 음수인지 검사해 별도 안내를 출력합니다.
  3. catch에서 error.getMessage()를 함께 출력하고 어떤 정보가 추가되는지 봅니다.
음수 검사 힌트

parseInt 다음에 if (health < 0) 조건을 두세요. 숫자 형식 오류와 값 범위 오류는 서로 다른 문제입니다.

예외 처리의 목표

프로그램을 무조건 계속 실행하는 것이 목표가 아닙니다. 안전하게 복구할 수 있으면 복구하고, 그렇지 않으면 원인을 잃지 않은 채 적절한 경계에서 중단하는 것이 올바른 처리입니다.

예외는 계약 위반을 알리는 신호입니다

예외를 처음 만나면 빨간 오류 화면 자체가 무섭게 느껴집니다. 하지만 예외는 프로그램이 더 이상 정상 흐름을 이어갈 수 없다는 사실을 구조적으로 알려 주는 장치입니다. 특히 Java에서는 예외 이름, 메시지, stack trace가 함께 제공되므로 “어디에서 어떤 값 때문에 문제가 생겼는지”를 추적할 수 있습니다.

중요한 것은 예외를 없애는 것이 아니라 올바른 경계에서 다루는 것입니다. 잘못된 숫자 입력처럼 사용자가 수정할 수 있는 문제는 안내 메시지를 보여주고 다시 입력받을 수 있습니다. 프로그래머가 배열 범위를 잘못 계산한 문제는 조용히 무시하면 안 됩니다. 로그와 테스트로 원인을 찾아 코드를 고쳐야 합니다.

try {
    int count = Integer.parseInt(input);
    System.out.println("수량: " + count);
} catch (NumberFormatException error) {
    System.out.println("숫자만 입력하세요.");
}

이 코드는 숫자 변환 실패라는 예상 가능한 문제를 처리합니다. 반면 catch (Exception error)로 모든 예외를 잡고 같은 메시지를 출력하면 null 접근, 인덱스 오류, 프로그래밍 실수까지 숨겨질 수 있습니다. 예외 처리는 넓게 덮는 담요가 아니라 예상한 실패를 구체적으로 다루는 분기입니다.

checked 예외와 unchecked 예외

Java 예외는 크게 checked 예외와 unchecked 예외로 나누어 생각할 수 있습니다. checked 예외는 컴파일러가 처리 여부를 확인하는 예외입니다. 파일 읽기처럼 외부 환경 때문에 실패할 수 있는 작업에서 자주 등장합니다. unchecked 예외는 RuntimeException 계열로, 숫자 변환 실패, null 접근, 잘못된 인덱스처럼 실행 중에 발생합니다.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class Main {
    public static void main(String[] args) {
        try {
            String text = Files.readString(Path.of("missing.txt"));
            System.out.println(text);
        } catch (IOException error) {
            System.out.println("파일을 읽을 수 없습니다: " + error.getMessage());
        }
    }
}

Files.readString은 파일이 없거나 권한이 없거나 디스크 문제가 생길 수 있으므로 IOException을 처리해야 합니다. 예제에서 throws Exception을 붙이면 빠르게 실행할 수는 있지만, 어떤 실패를 예상하는지 흐려집니다. 학습 초반에는 편의를 위해 throws Exception을 쓰는 예제도 볼 수 있지만, 실무 코드에서는 가능한 구체적인 예외를 처리하거나 의미 있는 예외로 바꾸어 전달하는 편이 좋습니다.

unchecked 예외는 컴파일러가 강제하지 않습니다. 그래서 더 조심해야 합니다. NumberFormatException은 잘못된 사용자 입력에서 자주 발생하므로 잡아서 안내할 수 있습니다. NullPointerException은 대부분 코드가 객체 생명주기를 잘못 다룬 신호이므로, catch로 덮기보다 null이 생긴 원인을 고치는 것이 맞습니다.

예외 메시지는 사용자용과 개발자용을 나누기

예외 메시지에는 내부 파일 경로, 클래스 이름, 외부 API 응답, 민감한 값이 섞일 수 있습니다. 운영 서비스에서 stack trace 전체를 사용자에게 보여주면 보안과 신뢰성 문제가 생깁니다. 사용자는 “파일을 읽을 수 없습니다. 파일이 존재하는지 확인하세요”처럼 행동 가능한 메시지가 필요하고, 개발자는 로그에서 원인을 추적할 정보가 필요합니다.

입문 콘솔 예제에서는 error.getMessage()를 출력해 학습할 수 있습니다. 다만 실제 웹 애플리케이션에서는 사용자 응답과 서버 로그를 분리합니다.

try {
    int health = Integer.parseInt(input);
    System.out.println("체력 적용: " + health);
} catch (NumberFormatException error) {
    System.out.println("체력은 숫자로 입력해야 합니다.");
    System.err.println("개발자 로그: " + error.getMessage());
}

System.err는 오류 출력 스트림입니다. 작은 예제에서는 콘솔에 함께 보일 수 있지만, 표준 출력과 오류 출력을 구분하는 습관은 좋습니다. 나중에 로깅 프레임워크를 사용하면 수준별 로그와 파일 저장을 더 체계적으로 다룰 수 있습니다.

예외를 삼키지 않기

가장 위험한 패턴 중 하나는 catch 블록을 비워 두는 것입니다.

try {
    int health = Integer.parseInt(input);
} catch (NumberFormatException ignored) {
}

이 코드는 문제가 있었는지 아무도 알 수 없게 만듭니다. 정말 무시해도 되는 예외라면 왜 무시해도 되는지 짧은 주석이나 대체 흐름이 있어야 합니다. 대부분의 경우에는 안내 메시지를 출력하거나, 기본값을 사용하거나, 호출자에게 실패를 알려야 합니다.

기본값을 사용하는 것도 신중해야 합니다.

static int parseHealthOrDefault(String input) {
    try {
        return Integer.parseInt(input);
    } catch (NumberFormatException error) {
        return 0;
    }
}

이 메서드는 편리하지만 "unknown""0"을 구분할 수 없게 됩니다. 게임에서 잘못된 체력 값을 0으로 취급하는 것이 맞는지 정책을 정해야 합니다. 입력 오류를 사용자에게 알려야 하는 프로그램이라면 기본값보다 실패 메시지가 더 적절합니다.

예외를 다시 던질 때 맥락 추가하기

낮은 수준의 예외를 그대로 올리면 호출자가 업무 맥락을 알기 어려울 수 있습니다. 예를 들어 점수 파일을 읽다가 IOException이 났다면 “파일 읽기 실패”보다 “점수 파일 score.txt를 읽지 못했다”가 원인 파악에 유용합니다.

static String loadScoreText(Path path) {
    try {
        return Files.readString(path);
    } catch (IOException error) {
        throw new IllegalStateException("점수 파일을 읽지 못했습니다: " + path, error);
    }
}

새 예외를 만들 때 원래 예외를 두 번째 인수로 전달하면 stack trace의 원인 체인이 유지됩니다. 이것을 잃어버리면 실제 원인이 파일 없음인지 권한 문제인지 추적하기 어려워집니다. 예외를 바꾸어 던질 때는 원인을 보존하세요.

물론 모든 예외를 unchecked로 바꾸라는 뜻은 아닙니다. 메서드의 호출자가 복구할 수 있는 실패라면 checked 예외를 유지하거나 결과 타입으로 실패를 표현할 수 있습니다. 기준은 호출자가 무엇을 할 수 있는가입니다.

try-with-resources 맛보기

파일이나 네트워크 스트림처럼 사용 후 닫아야 하는 자원은 정리가 중요합니다. Java에는 try-with-resources 문법이 있어 try 블록을 벗어날 때 자동으로 자원을 닫습니다. Files.readString 같은 편의 메서드는 내부에서 처리해 주지만, 스트림을 직접 다룰 때는 이 문법을 자주 만납니다.

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class Main {
    public static void main(String[] args) {
        Path path = Path.of("score.txt");

        try (BufferedReader reader = Files.newBufferedReader(path)) {
            System.out.println(reader.readLine());
        } catch (IOException error) {
            System.out.println("파일 처리 실패: " + error.getMessage());
        }
    }
}

괄호 안에서 만든 reader는 try가 끝날 때 자동으로 닫힙니다. 예외가 발생해도 닫기 처리가 수행됩니다. 직접 finally에서 닫는 코드보다 실수 가능성이 적습니다.

입력 검증과 예외의 경계

예외는 비정상 상황을 표현하지만, 모든 조건 검사를 예외로 처리해야 하는 것은 아닙니다. 사용자가 입력한 값이 비어 있는지는 예외를 던지기 전에 isBlank로 확인할 수 있습니다. 값 범위가 자주 틀릴 수 있는 입력 화면에서는 예외보다 조건문으로 안내하는 편이 읽기 쉽습니다.

static boolean isValidHealthText(String input) {
    if (input == null || input.isBlank()) {
        return false;
    }

    for (int index = 0; index < input.length(); index++) {
        if (!Character.isDigit(input.charAt(index))) {
            return false;
        }
    }

    return true;
}

위처럼 미리 검증할 수도 있고, parseInt를 시도하고 NumberFormatException을 잡을 수도 있습니다. 입력이 간단하면 조건 검사가 명확하고, 숫자 형식 전체를 정확히 맡기려면 표준 파서와 예외 처리가 편합니다. 중요한 것은 실패를 예상 가능한 흐름으로 설계하는 것입니다.

stack trace를 읽는 연습 절차

stack trace가 나오면 다음 순서로 읽어 보세요.

  • 첫 줄의 예외 클래스 이름을 확인합니다.
  • 메시지에서 잘못된 값이나 상태를 찾습니다.
  • 위에서부터 내 코드 파일과 줄 번호를 찾습니다.
  • 그 줄에서 표준 API에 넘긴 값을 확인합니다.
  • 바로 고치기 전에 왜 그 값이 들어왔는지 호출 흐름을 거슬러 올라갑니다.

예외가 발생한 줄은 “문제가 발견된 위치”입니다. 실제 원인은 그보다 앞에서 잘못된 값을 만든 코드일 수 있습니다. Integer.parseInt에서 예외가 났다면 parseInt를 고치는 것이 아니라, 그곳에 들어간 문자열이 어디서 왔는지 봐야 합니다.

이번 장의 정리

예외 처리는 오류를 숨기는 기술이 아니라 실패를 의미 있게 다루는 기술입니다. 컴파일 오류, 런타임 예외, 논리 오류를 구분하고, 복구할 수 있는 구체적인 예외만 필요한 범위에서 처리하세요. 사용자에게는 행동 가능한 메시지를 제공하고, 개발자에게는 원인을 추적할 정보를 남겨야 합니다. 예외를 다시 던질 때는 원인 체인을 보존하고, 자원 정리가 필요한 작업에는 try-with-resources를 고려하세요. 이런 습관이 있어야 파일, JSON, 외부 데이터처럼 실패 가능성이 높은 실무 데이터를 안전하게 다룰 수 있습니다.

FAQ

자주 묻는 질문

Exception을 한 번에 catch하면 편하지 않나요?

예상하지 못한 프로그래밍 오류까지 숨길 수 있습니다. 복구 방법을 아는 구체적인 예외만 필요한 범위에서 처리하세요.

finally는 언제 사용하나요?

성공 여부와 상관없이 정리해야 하는 자원이 있을 때 사용합니다. 파일과 스트림은 보통 try-with-resources가 더 안전합니다.

오류 메시지를 사용자에게 그대로 보여줘도 되나요?

내부 경로나 민감 정보가 포함될 수 있어 운영 서비스에서는 사용자용 메시지와 개발자 로그를 구분해야 합니다.

작성·편집 ToolPado

콘텐츠 유형 Java 학습 가이드

Java 기준 Java 25 LTS

마지막 검토