본문 바로가기
Spring&SpringBoot/<토비의 스프링 3.1 Vol1.1>, 이일민

좋은 예외 처리에 대한 고찰

by 민휘 2023. 3. 21.

예외에 대한 오해와 진실

 

좋은 예외 처리 방법을 살펴보기에 앞서, 체크 예외와 런타임 예외에 대해서 내가 가지고 있던 오해들을 해결해보고자 한다. 보시는 분은 답답할 수 있겠지만.. 이건 나를 위한 공부니까요.. 썰 풀어보겠습니다.

 

복구 불가능한 체크 예외?

 

이건 사실 이전 게시글 내용을 작성하면서 해소한 의문이다. 그래도 정리해봐야지.

분명히 체크 예외는 복구할 가능성이 있는 상황을 가정한다고 했다. 그런데 책을 읽을수록 이게 웬걸? SQLException은 체크 예외인데 복구할 수 있는 방법이 없다네? 그럼 이걸 대체 왜 체크 예외로 만든거야? 물음표 백만개인 상태로 쭉 읽었다. 알고보니 자바 초기에 사용되던 독립형 애플리케이션에서는 상황에 대한 복구 가능성보다는 복구 강제에 초점을 둔 것 같다. 그래서 때로는 이 예외가 프로그램 안에서 복구될 수 없다는 것을 알지만, 프로그램이 중단되는 것을 막기 위해 복구를 강제하는 체크 예외를 던지는 선택을 한 것이다.

처음엔 프로그램 중단을 막는게 목적이라면 체크 예외로 처리하는게 안정성의 측면에서 훨씬 좋은 것 같은데, 개발자들은 왜 불만인지 의문이 들었다. (얼마 전까지 JS의 색다른 API들과 런타임 예외에 황당해하던 참이었다) 그러다 런타임 예외를 더 많이 사용하게 된 결정적인 이유가 두가지 있다.

  1. 서버 환경에서는 중단을 막는 것이 오히려 부자연스러운 처리 흐름이 되었다.
  2. 복구할 수 없는 예외를 메소드 최초 호출자에게 계속 throw하니 호출 스택에 있는 모든 메소드가 구체적인 예외 클래스를 알고 있게 되었다.

이전 게시글에서 체크 예외를 선택한 설계자와 사용자가 동상이몽을 꾸고 있다고 표현했다. 복구 가능한 상황에 대해서 복구를 강제하는 것은 타당하다. 하지만 설계자는 복구 가능한 상황이 아니더라도 복구를 강제하기 위한 수단으로 체크 예외를 선택했다. 그렇기에 사용자가 복구할 수 있는 상황이 아닌 경우 예외를 강제 처리하는 과정에서 OCP 위반이라는 부작용을 만들어냈고, 자바 진영이 서버 환경으로 점점 이전하면서 체크 예외는 도태되었다. 최근에 API를 설계할 때 체크 예외를 선택하는 경우는 별로 없다. 항상 복구 가능한 상황에서만 체크 예외를 선택한다고 한다.

 

체크 예외는 컴파일 시점에 예측 가능해서 예외 처리를 강제하는거야?

 

런타임 예외는 컴파일 시점에 예측이 불가능하기 때문에 복구를 강제할 수 없다는 설명이 있다. 그렇다면 체크 예외는 컴파일 시점에 예측 가능하기 때문에 예외 처리를 강제하는 것인지 궁금했다.

이 가정엔 분명한 모순이 있다. SQLExcpetion은 실제로 실행해보기 전까지는 예외가 발생할지 알 수 없다. FileNotFoundException도 마찬가지다. 컴파일 시점에 파일 이름을 읽어서 FileReader를 만들어오지는 않는다. 런타임 시점이 되어서야 예외가 발생하는지 여부를 확인할 수 있는데, 왜 런타임 예외가 아닌 체크 예외를 사용하는 것일까? 구글링하다가 못 찾겠어서 똑똑 상자에게 물어봤다.

>> hwi min
>> 체크 예외는 컴파일 시점에 예측 가능해서 예외 처리를 강제하는거야?

>> gpt
>> 네, 체크 예외는 컴파일 시점에 예측 가능하기 때문에 예외 처리를 강제합니다.
>> Java에서 체크 예외는 RuntimeException 클래스와 그 하위 클래스를 제외한 모든 예외 클래스를 의미합니다. 
>> 체크 예외를 발생시키는 메서드를 호출할 때는 반드시 try-catch문으로 예외 처리를 하거나 throws 키워드를 이용하여 예외를 호출한 메서드로 전달해야 합니다.
>> 이러한 체크 예외의 처리 방식은 예외 발생 시점이 컴파일 시점에 미리 예측 가능하기 때문에, 
>> 예외 처리를 강제하여 예외 발생에 대한 안정성을 높이고 코드의 안정성을 보장합니다. 
>> 따라서, 체크 예외를 발생시키는 메서드를 사용하는 경우, 예외 처리를 반드시 해주어야 합니다.

>> hwi min
>> 그런데 FileReader를 생성할 때 파일명으로 파일을 찾지 못하면 FileNotFoundException이 발생하잖아. 
>> 파일을 찾는 과정은 런타임에 일어나니까 컴파일 시점에 알 수 없는데, 
>> 왜 체크 예외인 FileNotFoundException을 던지는거야?

>> gpt
>> 맞습니다. FileReader의 생성자는 파일을 찾지 못할 때 FileNotFoundException을 발생시킵니다. 
>> 파일을 찾는 과정은 런타임에 일어나기 때문에, FileNotFoundException은 체크 예외라기 보다는 언체크 예외에 가깝습니다.
>> 그러나 FileNotFoundException은 IOException의 하위 클래스이기 때문에, 예외 처리를 강제하는 체크 예외로 분류됩니다. 
>> 즉, FileNotFoundException을 발생시키는 FileReader 생성자를 사용하는 경우, 예외 처리를 반드시 해주어야 합니다.
>> Java에서 이러한 예외 처리 강제 규칙은 코드 안정성을 높이기 위해 만들어졌습니다. 
>> 파일을 찾는 과정이 런타임에 일어난다는 점에서 FileNotFoundException이 체크 예외로 분류될 필요성이 없다는 주장도 있습니다. 
>> 하지만 Java에서는 FileNotFoundException을 체크 예외로 분류하고 있으며, 이에 따라 해당 예외를 처리하는 코드를 반드시 작성해야 합니다.

 

고맙다 천재 상자야… 저 이제 제법 gpt랑 티키타카 잘해요

결국 첫번째 의문에서 제시했던 설계자와 사용자의 동상이몽이 답인 것 같다. 설계자가 복구 가능한 상황이므로 복구를 강제한 것이 아니라, 복구 가능 여부와는 상관 없이 복구를 강제하기 위해서 체크 예외를 사용한 것이다. 그래서 체크 예외에 대한 설명을 읽었을 때 혼란스러웠던 것 같다. 의문 해결!

 

정상적인 제어 흐름에서는 예외를 사용하면 안된다고 하는데, 그럼 예외 처리를 하면 안되는거야? 그럼 처리를 어디서 하는데?

 

예외에 대해서 추가적으로 조사를 하던 중에 이펙티브 자바 예외 내용을 요약한 포스팅을 많이 보았다. 거기에서부터 의문이 시작된 것 같다. 반복해서 강조된 부분이 예외는 예외 상황에서 사용될 의도로 설계되었다. 와 잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없어야 한다. 이었다. 그 이상하게 생긴 반복문 코드도 봤는데 잘 이해되지 않았다. 만약 매개변수로 넘어오는 값에 null을 허용하기로 했다고 하자. null로 넘어온 값은 정상이다. 그러니 예외 처리를 하지 않아도 된다. 그럼 null 처리 안해도 돼? 런타임 오류 나면 어떡할건데?

 

결론적으로 정말 크나큰 오해였다. 내 문해력이 처참해서 발생한 오해다. 정상적인 제어 흐름은 프로그램에서 정하기 나름이다. 위의 예시에서 null 처리를 안해서 런타임 오류가 날걸 걱정한다면 null 허용은 비정상적인 상황이므로 예외 처리를 해야한다. 나는 바보다.

사용자에게 입력받은 문자열을 정수로 변환하는 예제를 생각해보자. 우리는 문자열이 정수로 변환 가능할 것을 기대하고 Integer.parseInt를 사용했는데, 이게 웬걸. 사용자가 “asdsafs” 이런 문자열을 넘겨줬다. 이건 비정상적인 상황이므로 예외 처리를 해야한다. 바로 이렇게 처리하는게 정상이다. 이 조언의 핵심은 정상적인 상황에 예외를 남용하지 말라는 것이었다.

import java.util.Scanner;

public class StringToInt {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.print("숫자를 입력하세요: ");
        String input = scanner.nextLine();
        try {
            int result = Integer.parseInt(input);
            System.out.println("입력한 숫자는 " + result + "입니다.");
        } catch (NumberFormatException e) {
            System.out.println("입력한 값이 유효한 숫자가 아닙니다.");
            System.out.println("다시 실행하여 유효한 값을 입력해주세요.");
        }
    }
}

 

예외, 어떻게 선택할까?

결국 서재에 처박혀있던 이펙티브 자바 3판을 꺼냈다. 4월에 데일리 이펙티브 자바 스터디 열어볼까..? 🫠 어쨌든 아이템 70, 71번에 예외의 선택 기준을 잘 설명해주고 있어 짧게 정리해봤다. 위에서 체크 예외를 선택한 설계가 실패했던 이유는 정말 복구 가능한 상황이어서 선택한게 아니라 복구를 강제할 의도였기 때문이다. 그렇다면 우리는 정말 복구 가능한 상황에 체크 예외를 선택하면 된다. 복구 가능한지 아닌지 애매하다면 런타임 예외를 사용하자. 체크 예외를 던질 때 구체적인 예외 클래스를 메소드 헤더에 선언해야하기 때문에 OCP를 위반할 여지가 있다.

선택 기준

  1. 복구할 수 있는 상황에서는 체크 예외를 사용해라
  2. 프로그래밍 오류에는 런타임 예외를 사용해라
  3. 애매하다면 런타임 예외를 사용해라

필요하지 않은 체크 예외는 피해라

복구가 꼭 필요한 상황이 아니라면 체크 예외 사용을 피해야 한다. 이유는 다음과 같다.

  1. 서버 환경에서 예외의 복구는 부자연스럽다. 그냥 쓰레드를 죽이고 예외를 통보하는게 낫다.
  2. 체크 예외를 던지는 메소드는 구체적인 코드를 그대로 드러낸다. 캡슐화가 깨진다.
  3. Stream은 예외를 던지는 메소드 호출을 허용하지 않는다.

체크 예외 대안

체크 예외는 캡슐화를 위반한다는 점에서 남용하면 사용하기 고통스러운 API를 낳는다. 체크 예외는 복구를 강제하기 때문에 자칫하면 사용하는 입장에서 부담스러울 수 있다. 예외가 복구 가능한 상황이지만 이런 이유로 체크 예외 사용이 꺼려진다면, 대안이 있다.

  1. 적절한 결과 타입을 담은 옵셔널을 반환한다.
  2. 체크 예외 하나를 런타임 예외 둘로 쪼갠다. 예외가 발생하는지 반환하는 메소드와 실제 동작을 담은 메소드를 호출한다.
import java.util.Optional;

public class Example {
    public static Optional<Integer> parseInteger(String str) {
        try {
            return Optional.of(Integer.parseInt(str));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    }

    public static void main(String[] args) {
        String str1 = "123";
        String str2 = "hello";

        Optional<Integer> int1 = parseInteger(str1);
        Optional<Integer> int2 = parseInteger(str2);

        if (int1.isPresent()) {
            System.out.println(str1 + " parsed to " + int1.get());
        } else {
            System.out.println(str1 + " cannot be parsed to an integer");
        }

        if (int2.isPresent()) {
            System.out.println(str2 + " parsed to " + int2.get());
        } else {
            System.out.println(str2 + " cannot be parsed to an integer");
        }
    }
}

 

Optional을 사용하면 예외 상황에서 빈 값을 반환하므로 부가 정보를 전달할 수 없다. 옵셔널 대신 예외 발생 여부를 판단하는 메서드와 실제 실행하는 메서드를 호출하면 나중에 부가 정보를 추가할 수 있다.

public static void main(String[] args) {
    Scanner scanner = new Scanner(System.in);
    System.out.print("Please enter an integer: ");
    String input = scanner.nextLine();
    if (isParsableToInt(input)) {
        int value = Integer.parseInt(input);
        System.out.println("Parsed integer: " + value);
    } else {
        System.out.println("Invalid input. Please enter a valid integer.");
    }
}

public static boolean isParsableToInt(String input) {
    try {
        Integer.parseInt(input);
        return true;
    } catch (NumberFormatException e) {
        return false;
    }
}

 

예외, 어떻게 처리할까?

예외는 위의 방법대로 선택하면 된다. 그럼 하지 말아야할 예외 처리와 따라야할 예외 처리 방법에는 어떤 것들이 있을까?

안 좋은 예외 처리 방법

토비의 스프링에서 소개된 안 좋은 예외 처리 방법 두가지다.

  1. catch 블록 비워두기 : 디버깅 극악, 필요한 예외 처리 무시
  2. throw Exception 돌려막기 : 의미를 알 수 없어 복구될 수 있는 기회 박탈

예외 문서화하기

메서드가 던지는 예외는 그 메서드를 올바르게 사용하는데 필요한 아주 중요한 정보이므로, 문서화에 시간을 쏟아야 한다.

  1. 체크 예외는 항상 따로 선언하고, 예외가 발생하는 상황을 자바독의 @throws를 사용해 문서화해라. 맥락에 따라 재사용하지 말라!
  2. 런타임 예외는 메서드 선언의 throws 목록에 넣지 말자. 체크 예외와 구분해야 한다.
  3. 클래스에 정의된 많은 메서드가 같은 이유로 던진다면 그 예외를 클래스 설명에 추가해보자.

실패 원자적으로 만들기

호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다. DB의 all or nothing 개념과 동일하다. 다음과 같은 방법을 시도해볼 수 있다.

  1. 불변 객체로 만들기
  2. 가변 객체는 작업 수행 전에 잠재적 예외의 가능성을 걸러내기
  3. 실패할 가능성이 있는 모든 코드를 객체 상태 변경 전에 배치하기
  4. 객체의 임시 복사본에서 작업을 수행하고 작업이 성공하면 원래 객체와 교체하기
  5. 작업 도중 발생한 실패를 가로채는 복구 코드를 작성해 작업 전 상태로 되돌리기

한편 비용이 아주 큰 연산은 실패 원자적으로 만들지 않는게 효율적일 수 있다. 또 실패 원자성은 항상 달성할 수 있는 것은 아닌데, 스레드 동기화 없이 객체를 동시에 수정하면 어차피 변경되므로 실패 원자적으로 만드는게 의미가 없을 수 있다.

 

 

예외 캡슐화

예외도 클래스다. 예외 처리도 코드이다. 요구사항이 바뀌면 예외 코드도 같이 바뀐다. 객체지향 패러다임 안에서 구현은 캡슐화되어야 하는 존재이다. 예외도 마찬가지다.

예외 번역

(토비에 나온 예외 전화노가 동일한 방법입니다)

예외도 추상화할 수 있다. 예외를 계속 던지다보면 메소드의 호출자에 예외 이름이 계속 노출된다. 아무리 코드를 캡슐화하고 역할로 숨겨도, 예외가 노출된다면 캡슐화는 깨지기 마련이다. 예외는 윗 레벨 API를 오염시키고 OCP를 위반하는 잠재적 폭탄이다.

  1. 상위 계층에서 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던진다.
  2. 저수준 예외가 디버깅에 도움이 된다면 예외 연쇄를 사용해 원인 클래스를 고수준 예외에 실어보낸다.

특수 사례 패턴

(이 내용은 클린코드에 나옵니다. 마틴 파울러 님의 글을 참고해보삼)

특수 사례 패턴은 특정 상황에 대한 처리를 캡슐화하는 패턴이다. 변수가 null일 가능성이 있는 경우에는 null 테스트 코드로 둘러싸야 한다. 그래야 null이 존재할 경우 올바른 작업을 수행할 수 있다. null 반환값 처리는 많은 맥락에서 사용되기 때문에 코드 중복이 일어날 수 있다. 이럴 때 null을 바로 반환하지 말고 Optional 타입으로 한번 감싸거나, 호출자가 예상하는 인터페이스와 동일한 null 객체를 반환할 수 있다.

특수 사례 패턴을 사용해서 예외 처리를 추상화하는 방법을 알아보자. 입력된 문자열을 분석하여 숫자로 변환하는 프로그램을 생각해보자. 입력된 문자열이 숫자가 아닌 경우에는 메서드에서 예외를 발생시켜야한다. 이러한 예외 처리는 코드의 가독성을 해치고, 중복 코드를 유발할 수 있다. 이런 경우에는 특수 사례 패턴을 적용하여 예외 처리를 캡슐화할 수 있다. 이 예제에서는 try catch 문을 parse 메소드로 추출하여 재사용 가능하게 했다. 그리고 INVALID_NUMBER라는 특수한 값이 들어왔을 때 예외 상황이라고 간주하여 예외를 처리하게 했다.

public class NumberParser {
	private static final Integer INVALID_NUMBER = -999999;

	public int parse(String input) {
		if (isValidNumber(input)) {
			return Integer.parseInt(input);
		} else {
			return INVALID_NUMBER;
		}
	}
		
  private boolean isValidNumber(String input) {
		// 입력된 문자열이 숫자인지 확인하는 로직을 작성합니다.
		// 숫자이면 true, 아니면 false를 반환합니다.
	}
}

 

후기

꼬박 하루를 써서 예외에 대한 의문을 낱낱이 헤쳐보았다. 되게 오랫동안 고민한 것 같은데 풀어놓고 보니 별거 아니어서 좀 민망하다. 그래도 이제 예외 자신있게 쓸 수 있을 것 같다. 이 공로를 예제 만들어준 챗지피티에게 돌립니다.. 천재 상자 넌 짱이야!!