효과적인 디버깅 예외 처리방법

다른 언어: English Español Português 中文

자바와 함께하는 여정에서 배워야 할 가장 기본적인 개념 중 하나는 예외 입니다. 이 용어는 프로그램 실행 중 예상치 못한 시나리오를 정의합니다, 네트워크 실패나 예상치 못한 파일 끝과 같은 것입니다. 혹은, Oracle documentation에서 말하길,

Exception 클래스와 그 하위 클래스는 합리적인 애플리케이션이 잡아야 할 조건을 나타내는 Throwable의 한 형태입니다.

프로그램이 이러한 상황을 효과적으로 처리하는 “비상 계획”으로 갖추어져 있다면, 어떤 것이 발생하더라도 계속 원활하게 운영될 것입니다. 그렇지 않으면, 프로그램은 예상치 못하게 충돌하거나 잘못된 상태에 빠질 수 있습니다.

프로그램이 예외로 인해 실패하면 디버그해야 합니다. 프로그래밍 언어는 예외 관련 오류 디버깅을 돕기 위해 스택 추적을 제공합니다. 이것은 실패로 이끈 코드 경로를 가리키는 특별한 메시지입니다. 이 정보는 매우 유용하고 때때로 충분하지만, 추가적인 세부사항과 기술이 필요한 경우도 있습니다.

이 글에서는, JSON 파싱 중에 발생하는 예외를 디버깅하는 것에 초점을 맞춘 케이스 스터디를 진행하며, 스택 추적만 바라보는 것을 넘어서 디버거 사용의 이점을 발견하게 될 것입니다.

예제 어플리케이션

이 사용 예제에 대한 예제 애플리케이션은 세계 공항에 대한 데이터를 포함하는 JSON 파일 세트를 파싱하는 작은 자바 프로그램입니다. 파일에는 공항의 IATA 코드, 국가, 위도, 경도 등의 세부 정보가 포함되어 있습니다. 여기에 항목의 예제가 있습니다.

{
    "iso_country": "AR",
    "iata_code": "MDQ",
    "longitude_deg": "-57.5733",
    "latitude_deg": "-37.9342",
    "elevation_ft": "72",
    "name": "Ástor Piazzola International Airport",
    "municipality": "Mar del Plata",
    "iso_region": "AR-B"
}

프로그램은 매우 간단합니다. 파일 세트에 대해 반복하고, 각각을 읽고 파싱하고, 공항 객체를 입력 제한 조건에 따라 필터링하고, "country=AR"와 같은 후 일치하는 공항 목록을 출력합니다:

public class Airports {

    static Path input = Paths.get("./data");
    static Gson gson = new Gson();
    static {
        System.setOut(new PrintStream(System.out, true, StandardCharsets.UTF_8));
    }

    public static void main(String[] args) throws IOException {

        List<Predicate<Airport>> filters = new ArrayList<>();

        for (String arg : args) {
            if (arg.startsWith("country=")) {
                filters.add(airport -> airport.isoCountry.equals(arg.substring("country=".length()))) ;
            } else if (arg.startsWith("region=")) {
                filters.add(airport -> airport.isoRegion.equals(arg.substring("region=".length()))) ;
            } else if (arg.startsWith("municipality=")) {
                filters.add(airport -> airport.municipality.equals(arg.substring("municipality=".length()))) ;
            }
        }

        try (Stream<Path> files = Files.list(input)) {
            Stream<Airport> airports = files.map(Airports::parse);
            for (Predicate<Airport> f : filters) {
                airports = airports.filter(f);
            }
            airports.forEach(System.out::println);
        }
    }

    static Airport parse(Path path) {
        try {
            JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
            String name =           root.get("name").getAsString();
            String isoCountry =     root.get("isoCountry").getAsString();
            String iataCode =       root.get("iataCode").getAsString();
            String longitudeDeg =   root.get("longitudeDeg").getAsString();
            String latitudeDeg =    root.get("latitudeDeg").getAsString();
            String municipality =   root.get("municipality").getAsString();
            String isoRegion =      root.get("isoRegion").getAsString();
            Integer elevationFt =   Integer.parseInt(root.get("elevationFt").getAsString());
            return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    record Airport(String name, String isoCountry, String iataCode, String longitudeDeg, String latitudeDeg, Integer elevationFt, String municipality, String isoRegion) { }
}

문제

프로그램을 실행하면 NumberFormatException 와 함께 실패합니다. 다음은 우리가 얻는 스택 추적입니다:

Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java:53)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java:39)

목표지점은 Airports.java 파일의 53번째 줄입니다. 이줄을 보면, 공항 중 하나의 elevationFt 속성을 숫자로 변환하는 데 문제가 있음을 알 수 있습니다. 예외 메세지는 해당 파일에서 이 속성이 빈 공간(blank)이기 때문에 이 문제가 발생한다고 알려줍니다.

스택 추적만으로 충분하지 않은가?

위의 스택 추적은 이미 많은 정보를 제공하지만, 여러 가지 방면에서 제한적입니다. 실패하는 코드의 줄을 가리키지만, 그것이 어떤 데이터 파일이 원인인지 알려줍니까? 파일의 이름은 더 자세히 조사하거나 데이터를 수정하는 데 유용할 것입니다. 아쉽게도 애플리케이션이 충돌할 때 이런 런타임 세부사항은 분실됩니다.

그러나 애플리케이션이 종료되기 전에 일시 중지시키는 방법이 있습니다. 이를 통해 스택 추적과 로그에 포착되지 않은 컨텍스트에 액세스할 수 있습니다. 게다가 일시 중지된 애플리케이션을 활용하면 프로토타입, 테스트, 심지어 애플리케이션이 계속 실행되는 동안 수정 사항을 로드할 수 있습니다.

예외 시 일시 중지

우선, 예외 브레이크포인트 설정부터 시작하겠습니다. 이는 브레이크포인트 대화상자에서 할 수도 있고 스택 추적에 있는 Create breakpoint를 클릭함으로써 할 수도 있습니다:

'브레이크 포인트 만들기' 버튼이 콘솔의 스택 추적 근처에 표시됩니다. '브레이크 포인트 만들기' 버튼이 콘솔의 스택 추적 근처에 표시됩니다.

특정 행을 목표하는 대신 이 타입의 브레이크포인트는 예외가 던져지기 직전에 애플리케이션을 일시 중지합니다. 현재, 우리는 NumberFormatException 에 관심이 있습니다.

강조된 줄이 브레이크포인트가 작동하여 프로그램이 일시 중지되었음을 나타냅니다. 강조된 줄이 브레이크포인트가 작동하여 프로그램이 일시 중지되었음을 나타냅니다.

따라서 예외를 던지려 할 때 애플리케이션을 일시 중지했습니다. 오류가 이미 발생했지만 애플리케이션이 아직 충돌하지 않았습니다. 우리는 딱 맞는 시간에 도착했으니, 이것이 우리에게 무엇을 줄 수 있는지 살펴봅시다.

스레드 (Threads) 탭에서는 parse() 프레임으로 이동합니다:

디버거의 '스레드' 탭에서 'parse()' 프레임을 선택하는 것 디버거의 '스레드' 탭에서 'parse()' 프레임을 선택하는 것

지금 우리가 얼마나 많은 정보를 가지고 있는지 보세요: 오류가 발생하는 파일 이름, 파일의 전체 내용, 모든 인근 변수들, 그리고, 물론, 오류를 일으키는 속성을 볼 수 있습니다. 파일 816.json에서 문제가 생긴 것이 명확해졌습니다. 왜냐하면 이 파일은 elevationFt 속성이 없기 때문입니다.

문제를 수정할 수 있는 방법이 있습니다. 우리의 사용 사례에 따라, 데이터를 그냥 수정하거나 프로그램이 오류를 다루는 방법을 수정하려고 할 수 있습니다. 데이터를 수정하는 것은 단순하므로 디버거가 오류 처리에 어떻게 도움이 될 수 있는지 살펴봅시다.

수정 프로토타입

표현식 평가은 변경 사항을 프로토타이핑하는 데 훌륭한 도구입니다. 포함하여 기존 코드에 대한 수정 사항입니다. 정확히 우리가 찾고 있는 것입니다. 전체 parse() 메서드 본문을 선택하고 실행 (Run) | 디버그 액션 (Debugging Actions) | 표현식 평가 (Evaluate Expression) 로 이동합니다.

Tip icon

평가 (Evaluate) 다이얼로그를 열기 전에 코드를 선택하면, 선택한 스니펫이 복사되므로 수동으로 입력할 필요가 없습니다.

메서드에서 코드를 평가하면 예외가 발생하는데, 이것은 프로그램 코드에서 발생했던 것과 같습니다:

'평가하기'를 클릭한 후에도 같은 실패 코드를 평가하면 NumberFormatException이 결과로 나옵니다. '평가하기'를 클릭한 후에도 같은 실패 코드를 평가하면 NumberFormatException이 결과로 나옵니다.

데이터가 누락되었을 때 null 을 저장하도록 코드를 조금 변경해 봅시다. 또한, 오류를 표준 오류 스트림에 출력하도록 출력문을 추가하겠습니다:

try {
    JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
    String name =           root.get("name").getAsString();
    String isoCountry =     root.get("isoCountry").getAsString();
    String iataCode =       root.get("iataCode").getAsString();
    String longitudeDeg =   root.get("longitudeDeg").getAsString();
    String latitudeDeg =    root.get("latitudeDeg").getAsString();
    String municipality =   root.get("municipality").getAsString();
    String isoRegion =      root.get("isoRegion").getAsString();
    Integer elevationFt;
    try {
        elevationFt = Integer.parseInt(root.get("elevationFt").getAsString());
    } catch (NumberFormatException e) {
        elevationFt = null;
        System.err.println("Failed to parse elevation for file: " + path);
    }
    return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
} catch (IOException e) {
    throw new RuntimeException(e);
}

평가 (Evaluate) 대화 상자에서 조정된 코드를 실행하면, 이 코드가 실제로 예정한대로 오류를 처리한다는 것을 확인할 수 있습니다. 이제 앱을 충돌시키는 대신 필드를 null로 설정합니다:

'평가'를 수정된 코드에 대해 클릭하면 결과는 유효한 반환 값을 보여줍니다. '평가'를 수정된 코드에 대해 클릭하면 결과는 유효한 반환 값을 보여줍니다.

메서드가 예외를 던지지 않고 반환하는 것뿐만 아니라, 콘솔에서 어떻게 오류 텍스트가 표시될지도 확인할 수 있습니다:

콘솔은 '파일에 대한 고도 파싱에 실패했습니다: ./data/816.json' 콘솔은 '파일에 대한 고도 파싱에 실패했습니다: ./data/816.json'

프레임 재설정

코드를 수정해서 앞으로는 에러가 발생하지 않도록 했지만, 현재 발생한 에러를 없앨 수 있을까요? 실제로 가능합니다! IntelliJ IDEA의 디버거를 사용하면 잘못된 프레임을 스택에서 팝하고 메서드를 처음부터 다시 실행할 수 있습니다.

스레드 (Threads) 탭에서 프레임 재설정 (Reset Frame)을 클릭하세요:

'Threads' 탭에서 'parse()' 프레임 아이콘을 가리키는 화살표 'Threads' 탭에서 'parse()' 프레임 아이콘을 가리키는 화살표
Info icon

프레임 재설정 (Reset Frame)은 프레임의 내부 상태만 되돌립니다. 우리의 경우, 메서드는 순수하므로 로컬 범위를 벗어나는 것은 변경하지 않으므로 문제가 되지 않습니다. 그러나, 전역 애플리케이션의 상태에 변경 사항이 있다면, 이들은 되돌려지지 않을 것입니다. 평가 (Evaluate) 와 프레임 재설정 (Reset Frame) 같은 기능을 사용할 때 이를 기억하세요.

수정하고 핫리로드

오류를 배제하고, 오류가 발생하지 않았던 것처럼 행동한 후, 수정 사항을 런타임에도 전달할 수 있습니다. 실행점이 우리가 변경하려는 메서드를 벗어나 있고, 메서드의 서명을 변경하지 않았으므로, 변경된 클래스 다시 로드 (Reload Changed Classes) 옵션을 사용할 수 있습니다. 이는 이전 게시물 중 하나에서 이미 다루었습니다.

먼저, 수정된 코드를 평가 (Evaluate) 대화창에서 편집기로 복사합니다. 이전에 평가된 코드는 히스토리(⌥↓ / Alt↓)를 통해 찾을 수 있습니다. Airports 클래스에서 코드를 대체한 후, 실행 중인 JVM에 로드할 수 있습니다. 이를 위해, 실행 | 디버그 액션 | 변경된 클래스 다시 로드 (Run | Debugging Actions | Reload Changed Classes)를 선택하세요.

클래스를 다시 로드했다는 메시지가 표시되는 말풍선이 나타납니다 클래스를 다시 로드했다는 메시지가 표시되는 말풍선이 나타납니다

애플리케이션을 실행하면서 수정 사항이 적용되었다는 것을 확인하는 풍선이 나타납니다.

Tip icon

현재 IntelliJ IDEA의 EAP 버전(2024.3 EAP1)을 사용하고 있다면, 편집기에서 바로 나타나는 새로운 핫 리로드 버튼을 시도해 보세요:

편집기의 오른쪽 상단에 파일을 다시 로드하라는 메시지가 나타나는 팝업 편집기의 오른쪽 상단에 파일을 다시 로드하라는 메시지가 나타나는 팝업

포착되지 않은 예외 필터

애플리케이션을 이제 다시 실행하면, 동일한 예외에서 다시 중단됩니다. 이건 왜 발생할까요?

우리는 코드를 수정해서 NumberFormatException 예외를 잡게 했습니다. 이 수정은 애플리케이션의 충돌을 방지하지만, 예외가 발생하는 것을 막지는 않습니다. 그래서 예외가 발생할 때마다, 비록 결국 잡히겠지만, 브레이크포인트는 여전히 트리거됩니다.

포착되지 않은 예외가 발생할 때만 애플리케이션을 중단하고 싶다고 IntelliJ IDEA에 알려봅시다. 이를 위해, 브레이크포인트에 마우스 오른쪽 버튼을 클릭하고 포착된 예외 (Caught exception) 체크박스를 해제하세요:

브레이크포인트 아이콘을 클릭하면 'Exception breakpoint' 대화상자에서 'Caught exception' 박스가 체크 해제되어 있습니다 브레이크포인트 아이콘을 클릭하면 'Exception breakpoint' 대화상자에서 'Caught exception' 박스가 체크 해제되어 있습니다
Tip icon

예외 브레이크포인트 아이콘은 애플리케이션이 예외에서 중단될 때만 겉으로 나타납니다. 또는, 실행 | 중단점 보기 (Run | View Breakpoints) 을 통해 브레이크포인트를 설정할 수 있습니다.

이 설정을 사용하면, 처리되지 않은 NumberFormatException 이 발생하지 않는 한 애플리케이션은 중단 없이 실행됩니다.

다시 시작하고 즐기기

이제 애플리케이션을 다시 시작할 수 있습니다:

디버거 툴바의 'Resume Program' 버튼을 가리키는 화살표 디버거 툴바의 'Resume Program' 버튼을 가리키는 화살표

잘 실행되며, 빠진 데이터에 대해 콘솔에서 알려줍니다. 816.json 파일이 에러 목록에 있다는 점을 주목하세요, 이는 우리가 이 파일을 처리했음을 확인시켜주는 것입니다.

콘솔은 애플리케이션 출력과 함께 에러 목록을 보여줍니다 콘솔은 애플리케이션 출력과 함께 에러 목록을 보여줍니다

실제로, 816.json에 대한 항목이 두 개 있습니다 - 하나는 우리의 표현식 평가 실험에서, 그리고 다른 하나는 프로그램 자체에서 우리가 수정한 후에 입력됩니다.

요약

이 포스트에서, 우리는 추가적인 맥락을 수집함으로써 자바 예외를 효과적으로 디버그하는 방법도 배웠으며, 이는 실패의 원인을 알아내는 데 도움이 되었습니다. 그 후, 디버거의 능력을 활용하여 오류를 취소하고, 디버그 세션을 솔루션을 테스트하는 샌드박스로 바꾸고, 충돌의 중간에 수정 사항을 런타임에 전달할 수 있었습니다, 이 모든 것은 애플리케이션을 재시작하지 않고서 이뤄졌습니다.

이런 기술들, 비록 기본적인 것들이지만, 특정 상황에서 많은 시간을 절약해줄 수 있으며 매우 강력할 수 있습니다. 다가올 게시물에서는 더 많은 디버깅 팁과 트릭에 대해 논의할 것이므로 계속 주목해 주세요!

all posts ->