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

낙관적인 예외 처리

by 민휘 2023. 3. 21.

이번주에 토비 4장 예외를 읽으면서 정말 많은 생각을 했다. 머리속에 안개가 낀 것처럼 막막했는데 조금씩 풀어보려고 한다. 지금까지 예외에 대해서 깊이 고민해본 적 없이, 프로그래머의 의도와 다르게 동작하는 모든 상황에 대해 무조건 예외를 던졌는데 반성한다.

첫번째 절에서는 예외의 종류와 처리 방법, 예외 처리 전략을 다루었다. 추가적으로 예외에 대한 조사를 하면서 어떻게 하면 예외를 잘 사용할 수 있을지 고민한 내용을 공유해보도록 하겠습니다. (이 내용은 다음 게시물에 이어집니다)

 

Error vs Exception

에러와 예외의 차이는 프로그램에서의 처리 가능 여부다.

에러는 프로그램에서 처리할 방법이 없지만, 예외는 프로그램에서 처리가 가능하다.

 

Error

java.lang.Error 클래스의 서브클래스.

에러는 시스템에 비정상적인 상황이 발생했을 경우 사용된다.

예를 들어 OutOfMemoryError나 ThreadDeath 등이 있다.

시스템의 에러는 애플리케이션 코드에서 catch로 잡아봤자 대응 방법이 없기 때문에

애플리케이션에서는 이 에러에 대한 처리는 신경 쓰지 않아도 된다.

 

Exception

java.lang.Exception의 서브클래스.

에러가 복구 불가능한 시스템 에러인 것과 달리,

예외는 실행 중에 예상되는 상황에서 발생할 수 있으며

프로그램에서 처리 가능한 문제를 말한다.

예를 들어 숫자를 입력받는 프로그램에서 사용자가 문자열을 입력했다고 생각해보자.

프로그램에서 기대한 정상적인 상황은 아니지만, 이 정도는 충분히 예상 가능하며 프로그램에서 처리가 가능하다.

입력 받는 부분이 NumberFormatException을 던지면 프로그램에서 사용자에게 다시 입력받도록 요청하는 식으로 복구 가능하다.

예외 처리는 프로그램의 실행 도중 예상하지 못한 문제가 발생할 때 이를 감지하고,

해당 예외를 처리하기 위한 코드를 실행하는 것이다.

 

Exception의 종류

체크 예외와 언체크 예외가 있다. 복구를 강제하는지 여부가 다르다.

체크 예외는 복구를 강제하고, 언체크 예외(런타임 예외)는 복구를 강제하지 않는다.

 

Check Exception

Exception 클래스의 서브클래스이면서 RuntimeException의 클래스를 상속하지 않은 클래스이다.

public class SQLException extends java.lang.Exception	implements Iterable<Throwable> 

 

체크 예외가 가정하는 예외 상황

체크 예외는 복구될 가능성이 조금이라도 있는 예외 상황을 가정한다.

그래서 예외를 복구하도록 강제한다.

다음과 같이 체크 예외를 던지는 메소드를 사용했는데 예외 처리를 하지 않으면 컴파일 에러를 발생시킨다.

catch로 예외를 잡아서 처리할 때까지 컴파일 에러가 발생한다.

public void add(User user) throws SQLException

public void addAndGet() {
	dao.add(user1); // Unhandled exception

 

체크 예외의 설계 의도

예외 상황을 처리하지 않으면 프로그램은 중단된다.

프로그램이 중단되지 않고 계속 정상적인 제어 흐름을 유지하기 위해서는 예외 상황을 처리해야한다.

메소드를 사용했을 때 발생할 수 있는 예외 상황에 대해 반드시 예외에 대한 복구를 하도록 만들어서

프로그램이 중단되지 않도록 하는 것이 체크 예외의 의도이다.

그런데 이 맥락에서 SQLException이 어쩌다 발생했는지 알 수 있는가?

public void addAndGet() {
	try {
		dao.add(user1);
	} catch (SQLException e) {
		throw new AnotherError();
	}
}

 

네트워크가 불안해서 DB 접속에 실패할 수도 있고, 원격 DB의 데이터가 꼬여서 삽입문이 제대로 실행되지 않을 수도 있다. 아님 프로그램 로직에서 중복을 처리하지 않아서 발생했을 수도 있다. 원인을 모르는데 어떻게 예외를 복구할 수 있는가? 원격 DB가 문제라면 어떻게 프로그램에서 복구할 수 있는가? 사용자에게 조금 있다 다시 시도해보라고 할 것인가? 문제가 원격 DB에 있는 경우는 그렇다 쳐도, 프로그램 자체가 문제였다면 다시 시도한들 또 실패할 것이다. 괜히 복구하다가 제어 흐름을 부자연스럽게 만들 수도 있다. 이럴 때는 SQLException을 직접 복구할 방법이 딱히 없으므로 다른 예외를 던지거나, SQLException을 던지는 방법을 사용해서 컴파일 에러를 해결해야 한다.

 

설계자와 사용자의 동상이몽

조금 이상하다.

 

분명히 아까 에러와 예외의 차이를 설명할 때 예외는 프로그램에서 처리할 수 있는 문제를 다루고 프로그램에서 처리한다고 했는데, 심지어 체크 예외는 복구할 가능성이 조금이라도 있는 경우에 사용한다고 했는데, SQLException을 복구할 방법이 없다니? 이러면 SQLException은 체크 예외이면 안되는 것 아닌가?

 

뒤에 살펴보겠지만, 복구할 방법이 없는 상황에서 발생하는 예외는 복구를 강제하지 않는 런타임 예외로 전환해서 던지는 것을 권장한다.

Jdbc API의 설계자는 SQL 예외가 발생했는데 예외 처리를 하지 않아서 프로그램이 중단될 바에는 클라이언트가 반드시 예외 처리를 해서 예외 상황이 발생하더라도 정상적인 제어 흐름으로 돌아가도록 의도했을 것이다. 설계자 입장에서는 배려였겠지만, 개발자 입장에서는 복구할 수 없는 예외 상황이 컴파일 가능할 때까지 계속 throw로 예외를 던지거나 의미 없는 복구를 해야한다. throw로 예외를 계속 던지다보면 OCP를 위반하는 문제도 발생한다.

 

Runtime Exception

RuntimeException의 클래스의 서브클래스이다.

public abstract class DataAccessException extends NestedRuntimeException {

런타임 예외가 가정하는 예외 상황

체크 예외와 달리 런타임 예외가 가정하는 예외 상황은

복구가 가능하지 않고, 복구가 반드시 필요하다고 보지 않기 때문에

catch나 throw를 사용하지 않아도 정상적으로 컴파일된다.

(그렇다고 해서 처리를 아예 안해도 된다는 말은 아님)

 

런타임 예외 설계의 의도

런타임 예외는 주로 프로그램의 오류가 있을 때 발생하도록 의도한 것들이다. 프로그램의 오류는 대부분 컴파일 시점까지는 예측할 수 없다가 런타임에 로직 상의 오류가 발생하는 것이므로 처리를 강제할 수 없다. 대표적으로 NPE나 IllegalArgumentException 등이 있다. 이 예외는 코드에서 미리 조건을 체크한다면 충분히 피할 수 있는 상황이다. 프로그래머가 본인이 사용할 api에 대해 충분히 인지하고 있다면 피할 수 있는 예외이므로 처리를 강제하지 않는다는 말이다. 예를 들어 문자열을 정수로 변환하는 Integer.toInteger(String)은 정수로 변환할 수 없는 문자열을 받았을 때 런타임 예외인 NumberFormatException를 던진다. API 입장에서 정수 형태의 문자열이 들어와야하는데 그렇지 않은 상황이므로 예외를 던져야 하는데, 이 문자열이 정수로 변환 가능한지 여부는 런타임에 클라이언트가 문자열을 매개변수로 넘겨주어야만 판단할 수 있다. 그러므로 런타임 예외를 던지는 것이다.

 

예외 처리 방법

예외는 복구 강제 여부에 따라 체크 예외, 런타임 예외로 나뉜다.

예외 상황에 대해 처리할 수 있는 방법을 알아보자.

 

예외 복구

예외 복구는 예외 상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것을 말한다.

기존 흐름으로 문제 해결이 불가능하다면 다른 작업 흐름으로 유도해 정상적인 설계 흐름을 따라 진행하도록 하는 방법을 말한다.

파일 이름을 입력받아 해당 파일 내용을 사용하는 프로그램인데,

사용자가 파일 이름을 잘못 입력해서 존재하지 않는 파일을 찾는다고 해보자.

이때 프로그램을 중단하기보다는 사용자에게 다시 입력받는 편이 좋다고 판단했다.

이럴 때 예외 복구를 사용해 예외를 처리할 수 있다.

import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.Scanner;

public class FileReadLineByLine {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        String fileName = "";

        while (fileName.isEmpty()) {
            System.out.print("파일 이름을 입력하세요: ");
            fileName = scanner.nextLine().trim();
        }

        while (true) {
            try {
                readFile(fileName);
                break;
            } catch (FileNotFoundException e) {
                System.err.println("파일을 찾을 수 없습니다.");
            } catch (IOException e) {
                System.err.println("파일을 읽는 도중 오류가 발생하였습니다.");
                break;
            }

            System.out.print("파일 이름을 다시 입력하세요: ");
            fileName = scanner.nextLine().trim();
        }
    }

    private static void readFile(String fileName) throws IOException {
        try (BufferedReader br = new BufferedReader(new FileReader(fileName))) {
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}

 

예외 처리 회피

예외 처리 회피는 예외 처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다.

throw나 catch로 예외를 던진다.

public void add() throw SQLException;
public void add() throws SQLException {
	try {}
	catch(SQLException e) { throw e; }
}

예외 처리 회피는 해당 예외를 처리하는 책임이 자신의 역할이 아니라고 보기 때문이다. 예외 처리 회피는 그 의도가 명확해야 한다. 즉, 예외 처리를 해줄 오브젝트가 확실하게 있으며 자신은 그 역할을 담당하지 않는다는 확신이 있어야 한다. 예를 들어 템플릿 콜백 패턴에서 콜백은 템플릿이 SQLException을 처리하는 역할임을 알고 있으므로 자신있게 SQLException를 던진다. 만약 의도 없이 무책임하게 예외를 던지다보면 SQLException을 컨트롤러 메소드의 헤더에서 보게될 수도 있다.

 

예외 전환

위에서 체크 예외는 복구를 강제하기 때문에, 예외를 복구할 수 없는 상황에서 클라이언트를 곤혹스럽게 만든다고 하는 것이 기억 나시는가? 이때 쓰면 좋은 방법이 예외 전환이다.

예외 전환은 말 그대로 예외를 잡아서 다른 예외를 던지는 것이다.

  • 체크 예외 → 런타임 예외 : 복구할 수 없는 상황에 대해서 복구 회피
  • 추상적인 예외 → 구체적인 예외 : 발생 원인 구체화
public void addAndGet() {
	try {
		dao.add(user1);
	} catch (SQLException e) {
			if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
				throw new DuplicateUserIdException().initCause(e); // 발생 원인 구체화, 중첩 예외
			else
				throw new RuntimeError(); // runtime error
	}
}

SQLException의 예외코드를 검사해서, 중복 에러이면 구체적인 원인을 드러내는 DuplicateUserIdException를 발생시킨다.

이때 발생 원인인 예외를 담아서 중첩 예외로 만들어주면 디버깅에 도움이 된다.

그외에 원인을 알 수 없는 SQLException은 지금은 복구를 포기한다는 의미에서 런타임 에러를 던진다.

만약 다른 부분에서 예외를 잡고 싶다면 런타임 에러를 잡으면 된다.

 

예외 처리 전략

체크 예외와 런타임 예외, 언제 사용하는 것이 좋을까?

낙관적인 예외 처리

서버 환경에서는 복구하지 않는 것이 더 낫다

체크 예외는 복구를 강제한다. 자바가 처음 만들어질 때 많이 사용되던 독립형 애플리케이션은 통제가 불가능한 시스템 예외더라도 애플리케이션이 중단되지 않게 상황을 복구해야만 했다. 그래서 초기 API 개발자들은 체크 예외를 통해 복구를 강제했고, 그것이 의미가 있었다.

하지만 자바가 점점 서버 환경으로 옮겨오면서 복구를 강제하는 의미가 사라졌다. 사용자의 요청은 독립적인 작업으로 취급되므로 하나의 요청을 처리하는 중에 예외가 발생하면 중단시키고 개발자에게 통보하는 것이 더 좋은 선택이다.

 

서버의 특성 때문에 예외 상황에 대한 복구는 선택 사항이 되었다. 개발자가 보기에 예외 상황에 대한 복구가 꼭 필요한 상황이라면 예외를 잡아서 처리한다. 하지만 복구보다는 중단시키는게 낫거나 복구할 수 없는 상황이라면 그냥 요청 실행을 중단하고 에러를 알리는게 최선이 되었다. 이런 측면에서 복구를 강제하지 않는 런타임 예외를 사용하는 것이 더 일반적이게 되었다.

 

예를 들어 사용자에게 문자열을 입력받아 정수로 변환하는 기능을 콘솔 프로그램과 API 서버로 구현한다고 생각해보자. 콘솔 프로그램에서는 사용자가 정수로 변환 불가능한 문자열을 입력했을 때 예외 처리를 따로 하지 않으면 그대로 프로그램이 중단된다. 하지만 API 서버는 실행을 중단하고 인터랙션으로 사용자와 소통할 수 없다. 그리고 서버에서 발생한 예외는 처리를 하지 않는다고 해서 떠있는 서버가 죽는 것이 아니라 서블릿의 요청으로 실행되던 로직이 중단되는 것이다. 이런 상황에서는 예외를 복구하겠답시고 “00”과 같은 기본값을 사용하기 보다는 API의 클라이언트에게 잘못된 입력값을 줬다고 알려주고, 다시 요청을 보내라고 하는 것이 더 자연스럽다.

 

체크 예외는 OCP를 위반한다

체크 예외를 사용하면 예외 복구가 완료될 때까지 컴파일 에러가 난다. 이렇게 되면 예외를 복구할 수 없거나 예외 복구의 책임이 없는 메소드는 계속 예외 회피를 하게되고, 결국 메소드를 처음 호출한 최상단까지 회피를 하다가 에러를 띄워주며 실행을 중단한다. 문제는 예외 회피를 하는 과정에서 체크 예외 클래스가 직접적으로 노출된다는 점이다. 즉, 컴파일을 성공시키기 위해 메소드 호출을 거쳐야 하는 모든 메소드가 특정 체크 예외를 발생시키는 메소드를 호출합니다라는 구체적인 사실에 의존하게 된다. 이건 뭔가? OCP를 위반하는 상황이다.

 

파일 이름을 문자열로 받아서 파일 내용을 한줄씩 출력하는 프로그램이다. FileReader를 생성할 때 파일을 찾지 못하면 FileNotFoundException를 발생시키는데, readFile에서 이 예외를 처리하지 않고 던졌다. 이 메소드를 사용하는 processFile도 예외를 처리하지 않고 던졌다.

private static void processFile(String fileName) throws FileNotFoundException, IOException {
	readFile(fileName);
	// 파일을 처리하는 로직
}

private static void readFile(String fileName) throws FileNotFoundException, IOException {
	BufferedReader br = new BufferedReader(new FileReader(fileName));
	String line;
	while ((line = br.readLine()) != null) {
		System.out.println(line);
	}
	br.close();
}

 

만약 readFile의 요구사항 변경으로 파일 권한 검사를 추가로 한다고 가정하자. 이때 파일 권한이 없으면 SecurityException을 던진다. 그러면 readFile을 호출하던 모든 메소드는 SecurityException를 추가로 던져야한다. 변경이 발생했을 때 다른 메소드도 변경해야하는, 전형적인 OCP 위반 사례이다.

private static void readFile(String fileName) 
	throws FileNotFoundException, IOException, SecurityException {
	BufferedReader br = new BufferedReader(new FileReader(fileName));
	// ..
}

private static void processFile(String fileName) 
	throws FileNotFoundException, IOException, SecurityException {
	readFile(fileName);
	// ..
}

 

그렇다면 런타임 예외는 어떨까?

런타임 예외는 복구를 강제하지 않는다. 그래서 예외를 복구할 수 없거나 복구하고 싶지 않은 경우에는 예외를 무시할 수 있다. 무시해도 컴파일이 가능하기 때문이다. 하지만 프로그램 정책 상 예외를 복구해야 하는 경우에는 언제든지 잡아서 처리할 수 있다. 체크 예외보다 훨씬 유연하다.

 

결국 낙관적인 예외 전략이라는게…

정리해보면 런타임 예외는 복구를 강제하지 않기 때문에 서버 환경에서 더 자연스러운 대응과 유연한 코드를 유지할 수 있게 도움을 준다. 이처럼 런타임 예외를 주로 사용하는 전략을 낙관적인 예외 전략이라고 부른다. 일단 복구할 수 있는 예외는 없다고 가정한다. 예외가 생겨도 어차피 런타임 예외이므로 시스템 레벨에서 알아서 처리해줄 것이고, 꼭 필요한 경우는 나중에 대응할 수 있으니 문제 될 것 없다는 낙관적인 태도를 기반으로 한다.

 

애플리케이션 예외 처리

애플리케이션 자체의 로직에서 의도적으로 발생시키고, 반드시 catch해서 조치를 취하도록 요구하는 예외를 애플리케이션 예외라고 한다. 포인트는 애플리케이션 예외 상황에서 정상적인 제어 흐름으로의 복귀를 위해 예외 복구를 강제하는 부분이다. 책에서는 잔고가 부족한 사용자의 요청을 예외로 처리하는 예시를 들었다. 나는 똑똑 상자에게 물어봐서, 주문을 처리할 때 수량 잔고가 부족한 경우를 비즈니스 예외 사항으로 가정하여 체크 예외로 처리하는 예제로 연습해보았다.

 

애플리케이션 예외는 정상적이지 않은 상황을 의도적으로 정의하고 꼭 처리하도록 만든다. 사실 예외를 사용하지 않고 정상적이지 않은 상황에 대해 표준값을 정의해서 이 클래스를 사용하는 개발자가 표준값을 검사해서 사용하도록 만드는 방법도 있다. 이 방법은 표준을 어겼을 때 기능이 제대로 동작하지 않을 위험이 있고, 클라이언트가 매번 조건문으로 검사해야하는 문제가 있다. 이것 보다는 비즈니스적인 의미를 띤 예외를 던지는 것이 더 깔끔하다. try catch로 예외가 발생하는 코드와 처리 로직을 깔끔하게 구분할 수 있다. 이때 예외는 비즈니스 로직의 의미에서 반드시 처리되어야 하므로 체크 예외를 사용해야 한다.

 

애플리케이션 로직의 예외 상황은 사실 정의하기 나름이다. 입력 받은 문자열을 정수로 변환하는 프로그램에서, 바로 파싱이 불가능하면 예외라고 판단할 수도 있지만, 요구사항이 변경되어서 0x로 시작하는 16진수를 10진수 정수로 변환하는 기능이 추가되면 위의 상황은 예외 상황이 아니게 된다. 요구사항이 어떻게 정의하는지에 따라서 예외 상황이 달라지니 코드를 유연하게 유지할 수 있도록 하자.

 

이제 주문을 처리할 때 수량 잔고가 부족한 경우를 비즈니스 예외 사항으로 가정하여 체크 예외로 처리하는 예제 코드를 살펴보자. placeOrder에서 주문수량보다 잔고수량이 적으면 주문이 불가능하다는 요구사항이 있었으니, 체크 예외를 발생시킨다. InsufficientStockException는 Exception의 서브클래스인 체크 예외이고, 현재 잔고량을 매개변수로 받아서 함께 출력한다. placeOrder를 사용하는 main에서는 체크 예외를 처리하는 handleOrder을 호출했다. 원래 main에서 placeOrder을 호출하고 try catch를 사용했는데 재사용성 떄문에 리팩터링했다.

public class OrderService {
	private Map<Product, Integer> stock = new HashMap<>();

  public void placeOrder(Order order) throws InsufficientStockException {
    int availableStock = stock.getOrDefault(order.getProduct(), 0);
    if (availableStock < order.getQuantity()) {
      throw new InsufficientStockException( // 체크 예외 던지기
        "Insufficient stock for product " + order.getProduct().getName(),
        availableStock
      );
    }

    // 재고 감소
    stock.put(order.getProduct(), availableStock - order.getQuantity());
}

public class InsufficientStockException extends Exception {
  private int currentStock;

  public InsufficientStockException(String message, int currentStock) {
    super(message);
    this.currentStock = currentStock;
  }
}

public class Main {
  public static void main(String[] args) {
    OrderService orderService = new OrderService();

    Order order1 = new Order(new Product("Product A"), 10);
    handleOrder(orderService, order1);

    Order order2 = new Order(new Product("Product B"), 20);
    handleOrder(orderService, order2);
  }

  private static void handleOrder(OrderService orderService, Order order) {
    try { // 메소드를 호출하는 쪽에서 예외를 반드시 처리해야함
      orderService.placeOrder(order);
      System.out.println("Order placed successfully");
    } catch (InsufficientStockException e) {
      System.out.println("Error placing order: " + e.getMessage());
      e.printStackTrace();
    }
  }
}

이렇게 비즈니스 흐름에서 비정상적인 경우는 체크 예외로 복구를 강제할 수 있다.