본문 바로가기

함수형 자바

[JAVA] 함수형 예외 처리 - Chapter 10

예외와 에러의 유형

자바에서는 세 종류의 제어 흐름 방해 요소가 있다.

 

 

1. 체크 예외 (Checked Exception)

 

체크 예외는 정상적인 제어 흐름에서는 벗어나지만,

외부에서 예측할 수 있으며 가능한 경우 복구할 수 있는 예외를 의미한다. (FileNotFoundException, MalformedURLException)

이를 처리하기 위해서는 catch-or-specify 요구 사항을 준수해야 한다.

 

*catch-or-specify

1) catch

현재 컨텍스트에서 예외를 catch 해야 한다. (예외를 처리하는 코드 블록을 작성하여 예외를 잡고 처리)

public void readFile(String filePath) {
    try {
        BufferedReader reader = new BufferedReader(new FileReader(filePath));
    } catch (IOException e) {
        e.printStackTrace();
    }
}

 

2) throw

메서드 시그니처에서 발생하는 예외를 명시해야 한다. (throws 키워드를 사용하여 예외 유형과 체크 예외 목록을 명시)

public void readFile(String filePath) throws IOException {
    BufferedReader reader = new BufferedReader(new FileReader(filePath));
}

 

둘 중 하나를 충족해야 한다.

 

2. 언체크 예외 (Unchecked Exception)

체크 예외와 다르게 예상되지 않으며 대부분 복구할 수 없는 경우에 발생한다. (ArithmeticException, NullPointerException)

RuntimeException을 상속받는 예외들이다.

주로 가정된 계약의 전제 조건이 위반되었을 때 발생한다.

catch-or-specify 요구 사항에 포함되지 않는다.

thorws 키워드로 명시하지 않는다.

 

3. 에러 (Error)

일반적으로 처리하거나 예외를 잡아낼 수 없는 심각한 문제를 나타낸다. (OutOfMemoryError, StacOverflowError)

예상하기 어렵고 복구하기 힘들다.

catch-or-specify 요구 사항에 포함되지 않는다.

 

람다에서의 체크 예외

함수 인터페이스 중에서는 체크 예외를 발생시키지 않는다.

따라서 체크 예외를 발생시키는 메서드와 호환되지 않는다. (예외를 throws하는 메서드는 메서드 참조나 단순한 람다에서 사용 할 수 없다.)

 

이를 해결하는 명백한 방법은 람다를 블록으러 변환하여 try-catch 블럭을 사용하는 것이다.

다만 간결함과 직관성이 약화된다.

 

간결함과 직관성을 지키면서 예외를 처리할 수 있는 방법 세가지가 소개된다.

다만 이들은 함수형 코드에서 예외 처리를 줄이기 위한 불완전한 해결 방법이다. (람다와 자바의 예외 처리 메커니즘이 잘 동작하도록 강제로 다루는 방법들이다.)

 

1.  안전한 메서드 추출

적절한 로컬 예외 처리를 통해 안전한 메서드를 만든다. (예외를 발생시키는 메서드를 예외가 발생하지 않는 메서드로 감싼다.)

 

안전한 메서드는 예외를 잡고 처리하며,

스트림에서는 체크 예외를 걱정하지 않고 사용할 수 있다.

//기존 체크 예외를 명시하는 메서드 시그니처
public static String readString(Path path) throws IOException {
    // ...
}

//안전한 메서드
public String safeReadString(Path path) {
	try {
    	return File.readString(path);
    } catch (IOException e) {
    	return null;
    }
}

Stream.of(path1, path2)
	.map(this::safeReadString)
    	.filter(Objects::nonNull)
        .forEach(System.out::println);

 

2.  언체크 예외

catch-or-specify 요구 사항을 우회한다.

기존의 예외를 캐치하여 언체크 예외인 RuntimeException 또는 관련된 다른 예외를 발생시킨다.

다만 이는 예외 상태를 위장할 뿐, 가장 마지막 옵션으로 고려되어야 한다고 한다.

 

@FunctionalInterface
public interface ThrowingFunction<T, R> extends Function<T, R> {
	
    R applyThrows(T elem) throws Exception;
    
    @Override
    default U apply(T t) {
    	try {
        	return applyThrows(t);
        } catch (Exception e) {
        	throw new RuntimeException(e);
        }
    }
    
    //catch-or-specify 요구사항을 우회를 위한 헬퍼 메서드 (Function<T, R>에서 발생하는 어떠한 예외도 검사 X
    public static <T, R> Function<T, R> uncheck(ThrowingFunction<T, R> fn) {
    	return fn::apply;
   	}
  
  }
  
  //명시적 사용
  ThrowingFunction<Path, String> throwingFn = files::readString;
  
  //암시적 사용
  Stream.of(path1, path2)
  	.map(ThrowingFunction.uncheck(Files::readString))
    	.filter(Objects::nonNull)
        .forEach(System.out::println);

 

3. 몰래 던지기 (sneaky throw)

메서드 시그니처에서 throws 키워드를 사용해 명시적으로 선언하지 않아도 체크 예외를 발생시킬 수 있는 방법니다.

메서드 바디에서 throw 키워드를 사용하여 체크 예외를 직접 발생시키지 않으며, 실제 예외는 다른 메서드에서 발생된다.

 

String sneakyRead(File input) {

	//...
    
    if (fileNotFound) {
    	sneakyThrow(new IOException());
    }
 }
 
 //예외 발생을 위임
 <E extends Throwable> void sneakyThrow(Throwable e) throws E {
 	throw (E) e;
 }

 

다만 이 경우에는 catch-or-specify 요구사항을 지키지 않아도 되는데,

이는 throws E를 포함하는 제네릭 메서드 시그니처에 상한이나 하한이 없다면  컴파일러는 E 타입을 Runti meExcepti on 으로 간주하기 때문이다. (컴파일러가 체크 예외를 인식하지 못하게 한다.)

언체크 예외와 마찬가지로 신중하게 사용해야 한다.

 

함수형으로 예외 다루기

1.  예외를 발생시키지 않기

예외를 발생시키는 대신에 Optional을 사용한다.

다만 이는 원래의 예외 발생 원인은 알 수 없다.

 

Optional<String> safeReadString(Path path) {
	try {
    	String content = Files.readString(path);
        return Optional.of(content);
    } catch (IOException e) {
    	return Optional.empty();
    }
 }

 

2.  값으로써의 에러

 

래퍼 객체를 이용하여 성공한 경우에는 값을 포함하고, 실패 경우에는 실패 원인을 나타내도록 한다.

public record Result<V, E extends Throwable> (V value, E throwable, bollean isSuccess) {


	public Optional<R> mapSuccess(Function<V, R> fn) {
		return this.isSuceess ? Optional.ofNullable(this.value).map(fn)
			: Optional.empty();
	}

	public Optional<R> mapFailure(Function<E, R> fn {
		return this.isSuccess ? Optional.empty()
			: Optional.ofNullable(this.throwable).map(fn);
	}

	public R map(Function<V, R> successFn, Function<E, R> failureFn) {
		return this.isSuccess ? successFn.apply(this.value)
			: failureFn.apply(this.throwable);
	}

	public V orElse(V other) {
		return this.isSuccess ? this.value
				: other;
	}

	public V orElseGet(Supplier<? extends V> otherSupplier) {
		return this.isSuccess ? this.value
				: otherSupplier.get();
	}

	private <E extends Throwable> void sneakyThrow(Throwable e) throws E {
		throw (E) e;
	}

	public V orElseThrow() {
		if (!this.isSuccess) {
			sneakyThrow(this.throwable);
			return null;
		}

		return this.value;
	}

}

 

orElseThrow에서는 SneakyThrow 형태를 사용해서 catch-or-specify 요구사항을 우회하였다.

 

3.  Try/Succes/Failure 패턴

try는 성공한 경우 success 상태, 실패한 경우 failure 상태가 될 수 있으며,

실패한 경우에는 Throwable을 포함한다.