Java Lambda (6) 예외 처리

🗓 ⏱ 소요시간 10 분

예외처리

람다 예외처리에서 주의할 점을 람다 작성 및 호출하는 입장으로 나눠서 살펴보겠습니다.

  • 람다를 작성해서 넘겨주는 입장 : 해당 람다가 어떤 환경에서 실행될 지 알 수 없다.
  • 람다를 받아서 호출(실행)하는 입장 : 람다에서 어떤 예외가 던져질 지 알 수 없다.

예제

다음 예제는 Runnable 을 순서대로 실행하는 메소드입니다.

1
2
3
4
public static void runInSequence(Runnable first, Runnable second) {
first.run();
second.run();
}

만약 첫 번째 run 호출에서 예외가 발생할 경우 두 번째 메소드는 호출되지 않을 겁니다. 이 메소드를 호출하는 쪽에서 예외를 처리해야 합니다.

다음은 runInSequence 메소드를 이용해서 입금 및 출금을 하는 코드입니다. 출금하고 입금하는 작업을 차례대로 실행하고, 이 때 예외처리하고 있습니다.

1
2
3
4
5
6
7
8
9
10
public void transfer(BankAccount a, BankAccount b, Integer amount) {
Runnable debit = () -> a.debit(amount); // 출금
Runnable credit = () -> b.credit(amount); // 입금

try {
runInSequence(debit, credit);
} catch (Exception e) {
// 계좌 잔액을 확인하고 롤백
}
}

한 가지 가정을 해보겠습니다. 만약 runInSequence 메소드가 외부 라이브러리에서 가져 온 메소드라서 내부가 어떻게 구현되었는지 알지 못하는 상황이라고 해봅시다. 그래서 위처럼 예외 처리 코드를 넣었지만 runInSequence 메소드가 다음과 같이 쓰레드를 이용해서 구현되어 있다면, 예외가 쓰레드를 종료시키기 때문에 해당 지점까지 예외가 throws 되지 않습니다. 따라서 작성해놓은 예외 처리는 제대로 동작하지 않게 됩니다.

1
2
3
4
5
6
public static void runInSequence(Runnable first, Runnable second) {
new Thread(() -> {
first.run();
second.run();
}).start();
}

1. 람다를 작성해서 넘겨주는 입장

먼저 람다를 작성하는 입장에서 예외 처리를 생각해보겠습니다. 앞의 예에서 runInSequence 는 다른 라이브러리의 메소드라 내부 구현을 알 수 없고 수정할 수 없는 상황입니다. Checked exception 을 발생시키도록 수정하고, 이를 Runtime exception 으로 변환시켜서 처리하는 과정을 살펴보겠습니다.

BankAccount 클래스에 checked exception 인 InsufficientFundsException 을 throw 하도록 수정합니다.

1
2
3
4
5
6
7
8
9
10
11
class BankAccount {
public void debit(Integer amount) throws InsufficientFundsException {
// ...
}

public void credit(Integer amount) throws InsufficientFundsException {
// ...
}
}

class InsufficientFundsException extends Exception {}

Checked exception 을 추가했기 때문에 컴파일 단계에서 예외 처리를 해야 합니다. 람다 내부에서 예외가 나는 메소드(debit, credit)를 호출하고, 람다 내부에서 발생한 예외는 호출하는 곳으로 전파되기 때문에 transfer 메소드에서 다음과 같이 예외 처리를 해야 합니다.

1
2
3
4
5
public void transfer(BankAccount a, BankAccount b, Integer amount) {
Runnable debit = () -> a.debit(amount); // 컴파일 에러
Runnable credit = () -> b.credit(amount); // 컴파일 에러
runInSequence(debit, credit);
}

이 컴파일 에러를 없애는 가장 단순한 방법은 checked exception 을 runtime exception 으로 감싸는 겁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void transfer(BankAccount a, BankAccount b, Integer amount) {
Runnable debit = () -> {
try {
a.debit(amount); // 출금
} catch (InsufficientFundsException e) {
throw new RuntimeException(e);
}
};
Runnable credit = () -> {
try {
b.credit(amount); // 입금
} catch (InsufficientFundsException e) {
throw new RuntimeException(e);
}
};
runInSequence(debit, credit);
}

물론 컴파일 에러만 없어졌을 뿐 발생하는 runtime exception 를 처리해야하는 것은 동일한 상황입니다. try/catch 로 예외를 처리합니다.

1
2
3
4
5
try {
runInSequence(debit, credit);
} catch (RuntimeException e) {
// 잔고를 검사하고 롤백
}

하지만 RuntimeException 의 범위가 너무 넓기 때문에 계좌 관련된 경우만 캐치할 수 있도록 예외를 한정시켜는게 좋겠죠. RuntimeException 을 상속하는 InsufficientFundsRuntimeException 을 만듭니다.

1
2
3
4
5
class InsufficientFundsRuntimeException extends RuntimeException {
public InsufficientFundsRuntimeException(InsufficientFundsException cause) {
super(cause);
}
}

람다에서 Checked exception 을 캐치해서 runtime exception 을 발생시키고 람다를 사용할 곳에서 runtime exception 을 핸들링합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void transfer(BankAccount a, BankAccount b, Integer amount) {
Runnable debit = () -> {
try {
a.debit(amount); // 출금
} catch (InsufficientFundsException e) {
throw new InsufficientFundsRuntimeException(e);
}
};
Runnable credit = () -> {
try {
b.credit(amount); // 입금
} catch (InsufficientFundsException e) {
throw new InsufficientFundsRuntimeException(e);
}
};

try {
runInSequence(debit, credit);
} catch (InsufficientFundsRuntimeException e) {
// 잔고를 검사하고 롤백
}
}

일단 완료는 했지만, 단순했던 코드가 예외 처리가 들어가 지저분해졌고 비즈니스 로직보다도 예외 처리 코드가 더 많아진 상태입니다.

이 문제를 해결하기 위해서 checked exception 을 처리하는 로직을 일반화해보겠습니다. 시그니처의 제네릭을 사용해서 예외 타입을 지정할 수 있는 함수형 인터페이스를 만들어보죠. Exception 계열의 타입을 받는 이 함수형 인터페이스를 다음과 같이 만듭니다.

1
2
3
4
@FunctionalInterface
interface ExceptionCallable<E extends Exception> {
void call() throws E;
}

그러면 람다를 다음과 같이 수정할 수 있습니다. debit 이나 credit 메소드에서 checked exception 을 throws 하지만 함수형 인터페이스에서 지정해놓았기 때문에 따로 컴파일 에러가 발생하지 않습니다. 대신 실제로 사용하기 위해서 인자에 넣는다면 checked exception 이기 때문에 에러가 발생합니다.

1
2
3
4
5
6
public void transfer(BankAccount a, BankAccount b, Integer amount) {
ExceptionCallable<InsufficientFundsException> debit = () -> a.debit(amount);
ExceptionCallable<InsufficientFundsException> credit = () -> b.credit(amount);

runInSequence(debit, credit); // compile error!
}

앞서 checked exception 을 runtime exception 으로 변경시켰던 것처럼, 이런 역할을 해줄 수 있는 메소드를 작성합니다.

1
2
3
4
5
6
7
8
9
public static Runnable unchecked(ExceptionCallable<InsufficientFundsException> function) {
return () -> {
try {
function.call();
} catch (InsufficientFundsException e) {
throw new InsufficientFundsRuntimeException(e);
}
};
}

그러면 다음과 같이 checked exception 을 발생시키는 람다를 받아서 runtime exception 으로 변환시켜주는 메소드를 이용함으로써 간단하게 정리가 된다. 남은 것은 예상되는 runtime exception 을 처리하면 된다.

1
2
3
4
5
6
7
8
9
10
11
public void transfer(BankAccount a, BankAccount b, Integer amount) {

Runnable debit = unchecked(() -> a.debit(amount));
Runnable credit = unchecked(() -> b.credit(amount));

try {
runInSequence(debit, credit);
} catch (InsufficientFundsRuntimeException e) {
// 잔고를 검증하고 롤백
}
}

이렇게 번거로운 과정을 거쳐야 하는 것은 람다의 문제라기보다 자바의 checked exception 모델의 문제라고 볼 수 있습니다. 이래서 예외 처리가 필요할 땐 runtime exception 이 선호됩니다.

2. 람다를 받아 사용하는 입장

이번엔 람다를 받아서 호출하는 쪽 입장인 runInSequence 메소드에서 예외 처리를 해보겠습니다. 해당 메소드를 제공하는 라이브러리를 작성하는 입장입니다. 위에서 살펴본것과 같이 runInSequence 메소드를 사용할 때는 메소드 시그니처가 정해져있었기 때문에 방법에 제한적이었지만, 지금은 메소드를 제공하기 때문에 좀 더 다양하게 수정을 해볼 수 있습니다.

먼저 checked exception 을 발생시키는 메소드가 있는 함수형 인터페이스를 만듭니다.

1
2
3
4
@FunctionalInterface
interface FinanceTransfer {
void transfer() throws InsufficientFundsException;
}

이를 이용해서 람다를 정의하면 runInSequence 는 다음과 같이 수정할 수 있습니다.

1
2
3
4
public static void runInSequence(FinanceTransfer first, FinanceTransfer second) throws InsufficientFundsException {
first.transfer();
second.transfer();
}

그럼 클라이언트 쪽에서는 다음과 같이 사용할 수 있을 겁니다. 얼핏 보면 비슷해보이지만 훨씬 심플해졌습니다. runInSequence 메소드는 checked exception 인 InsufficientFundsException 을 던지는 메소드를 받아서 실행하고 있고, runInSequence 메소드를 호출하는 쪽은 그저 예외 처리를 하면 됩니다. 람다 내부에서 try/catch 할 필요도 없습니다. 이전처럼 매개변수 타입을 Runnable 로 고정하지 않았기 때문에 가능한 방법입니다.

1
2
3
4
5
6
7
8
9
10
public void transfer(BankAccount a, BankAccount b, Integer amount) {
FinanceTransfer debit = () -> a.debit(amount);
FinanceTransfer credit = () -> b.debit(amount);

try {
runInSequence(debit, credit);
} catch (InsufficientFundsException e) {
// 예외 처리
}
}

runInSequence 메소드를 비동기로 처리한다면 어떨까요? 다른 스레드에서 예외가 발생한다면 전파되지 않기 때문에 throws 구문은 삭제할 수 있습니다. 그리고 throws 를 하지 않기 때문에 예외 발생에 대한 내용을 작성할 필요가 없고 대신 예외 처리 핸들러를 넘겨주면 됩니다.

1
2
3
4
5
6
7
8
9
10
public static void runInSequence(FinanceTransfer first, FinanceTransfer second, Consumer<InsufficientFundsException> exceptionHandler) {
new Thread(() -> {
try {
first.transfer();
second.transfer();
} catch (InsufficientFundsException e) {
exceptionHandler.accept(e);
}
}).start();
}

이렇게 작성하면 runInSequence 메소드를 호출하는 쪽은 더 명확하게 정리됩니다. 따로 try/catch 를 사용할 필요도 없고 핸들러만 넘겨주면 됩니다. 이제 checed exception 의 문제는 사라진 것 같네요.

1
2
3
4
5
6
7
8
public void transfer(BankAccount a, BankAccount b, Integer amount) {
FinanceTransfer debit = () -> a.debit(amount);
FinanceTransfer credit = () -> b.debit(amount);
Consumer<InsufficientFundsException> handler = (exception) -> {
// 잔고를 확인하고 롤백
};
runInSequence(debit, credit, handler);
}

하지만 비동기로 처리되기 때문에 호출 시점이 불분명합니다. 예외 처리 메소드가 원하는 시점에 호출될 거라고 확신할 수는 없습니다.

참고