티스토리 뷰
실패한 작업
가끔 일련의 작업을 하나의 트랜잭션 마냥 수행해야 하는 경우가 있습니다. 분산처리나 분산 트랜잭션 등의 주제라면 또 다른 얘기가 되지만, 좀 더 단순하게 코드 레벨의 문제들을 마주할 경우가 있죠. 각 작업은 실패할 수 있고 하나라도 실패하는 경우 지금까지 수행한 작업들에 실패 처리를 하는 코드를 어떻게 짜면 좋을까요? 일단 하나의 작업에 대해서 실패를 대비하고 실패 시 처리방안을 마련하는 방법은 바로 try-catch
일 것입니다. 그러면 쉽게 생각해서 일련의 작업을 위한 try-catch
를 여러 번 사용하면 되지 않을까요?
중첩 Try Catch
하나의 작업은 다음과 같이 실패를 다룰 수 있습니다.
try {
// 실패할 가능성이 있는 작업
} catch (Exception e) {
// 실패 시 처리방안
}
일련의 작업들을 하나의 트랜잭션처럼 실패를 다루려면 이렇게 작성할 수 있습니다.
try {
// 실패할 가능성이 있는 작업 1
try {
// 실패할 가능성이 있는 작업 2
try {
// 실패할 가능성이 있는 작업 3
} catch (Exception e3) {
// 작업 3 실패 시 처리방안
throw e2; // 작업 2 에 실패를 알림
}
} catch (Exception e2) {
// 작업 2 실패 시 처리방안
throw e1; // 작업 1 에 실패를 알림
}
} catch (Exception e1) {
// 작업 1 실패 시 처리방안
}
멋지게 일련의 작업들에 대한 실패를 다룰 수 있습니다. 각 단계에서의 실패는 지금까지 진행한 작업들에 실패를 알리고 처리하도록 할 수 있습니다.
그런데 이 코드에서는 뭔지 모를 답답함이 느껴지네요. 중복된 try-catch
는 우리를 지옥으로 인도할 것 같습니다. 이 지옥에서 벗어날 수 있을까요?
Middleware
Middleware 패턴과 코드 수준에서의 활용 에서 middleware 패턴에 대해 다뤄보았습니다. 사실 고백하자면 middleware 패턴의 코드 수준의 활용할 방안을 고민한 이유가 바로 이 문제를 정리하기 위해서였습니다. 위의 일련의 작업들을 진행하는 흐름을 다음과 같이 생각해보도록 하겠습니다.
try {
// 실패할 가능성이 있는 작업
return next.apply(/* ... */); // 다음 middleware 를 try 블럭 안에서 수행
} catch (Exception e) {
// 실패 시 처리방안
throw e;
}
이 짧은 코드는 하나의 작업과 그 실패를 다루는 try-catch
에 가장 단순한 형태에 next
가 합성 가능한 middleware 의 pseudo code 입니다. 다음 middleware 를 이번 단계의 try 블럭 안에서 수행하기 때문에 추후 작업의 실패가 이 middleware 에도 보고될 것입니다.
이 기능의 클래스를 CompensatoryTask
라 이름 붙였습니다. 이 클래스의 구현을 살펴봅시다.
CompensatoryTask
먼저 CompensatoryTask
는 MiddlewareTask
에 대한 컨테이너로 시작합니다. MiddlewareTask
는 Middleware 패턴과 코드 수준에서의 활용 을 참고 부탁드립니다.
public class CompensatoryTask<T> {
private final MiddlewareTask<T, T> task;
private CompensatoryTask(MiddlewareTask<T, T> task) {
this.task = task;
}
그리고 of
메서드를 만듭니다.
public static <T> CompensatoryTask<T> of(
UnaryOperator<T> action,
BiFunction<T, RuntimeException, RuntimeException> compensation
) {
return new CompensatoryTask<>(
MiddlewareTask.of(
(value, next) -> {
try {
return next.apply(action.apply(value));
} catch (RuntimeException re) {
throw compensation.apply(value, re);
}
}
)
);
}
of
메서드를 통해 action
과 compensation
을 하나의 middleware 로 만들어 줍니다. action
을 수행한 결과를 다음 middleware 에 전달해 주고 이후의 실행 결과를 그대로 반환합니다. 이미 소개한 바와 같이 이 코드에서 주요한 내용은 next.apply()
를 try 블록 안에서 호출한다는 것입니다.
이제 CompensatoryTask
를 위한 합성 규칙을 고려해봅시다.
public CompensatoryTask<T> andThen(CompensatoryTask<T> compensatoryTask) {
return new CompensatoryTask<>(task.andThen(compensatoryTask.task));
}
사실 고려할 부분은 크게 없습니다. CompensatoryTask
의 합성은 담고 있는 MiddlewareTask
을 합성하는 것이고 이는 이미 그 규칙을 찾아두었기 때문입니다.
CompensatoryTask
의 전체 코드는 다음과 같습니다.
public class CompensatoryTask<T> {
private final MiddlewareTask<T, T> task;
private CompensatoryTask(MiddlewareTask<T, T> task) {
this.task = task;
}
public static <T> CompensatoryTask<T> of(
UnaryOperator<T> action,
BiFunction<T, RuntimeException, RuntimeException> compensation
) {
return new CompensatoryTask<>(
MiddlewareTask.of(
(value, next) -> {
final var result = action.apply(value);
try {
return next.apply(result);
} catch (RuntimeException re) {
throw compensation.apply(value, re);
}
}
)
);
}
public static <T> CompensatoryTask<T> empty() {
return new CompensatoryTask<>(MiddlewareTask.empty());
}
public CompensatoryTask<T> andThen(
UnaryOperator<T> action,
BiFunction<T, RuntimeException, RuntimeException> compensation
) {
return andThen(CompensatoryTask.of(action, compensation));
}
public CompensatoryTask<T> andThen(CompensatoryTask<T> compensatoryTask) {
return new CompensatoryTask<>(task.andThen(compensatoryTask.task));
}
public T run(T init) {
return task.run(init, it -> null);
}
}
run(T)
이나 static 메서드 empty()
도 MiddlewareTask
에 위임하면 충분하고 직관적인 사용을 위해 andThen(UnaryOperator<T>, BiFunction<T, RuntimeException, RuntimeException>)
을 추가 하더라도 길지 않은 코드입니다.
이것으로 이런 사용이 가능합니다.
private CompensatoryTask<Order> placeOrder =
CompensatoryTask.of(this::subtractStock, this::revokeStock) // 재고차감, 실패 시 재고복구
.andThen(this::useCoupon, this::revokeCoupon); // 쿠폰사용, 실패 시 쿠폰복구
public void order(Order order) {
placeOrder.run(order);
}
재고를 차감한 후에 쿠폰을 사용하다 문제가 생기면 재고 역시 복구하는 코드입니다. 이 패턴 또한 변경에 유연합니다. 이제 주문된 장바구니 내용은 장바구니에서 제외하기로 하고 만약 실패한다면 장바구니 내역 역시 복구하도록 변경을 추가해 봅시다.
private CompensatoryTask<Order> placeOrder =
CompensatoryTask.of(this::subtractStock, this::revokeStock)
.andThen(this::useCoupon, this::revokeCoupon)
.andThen(this::removeToCart, this::revokeToCart); // 장바구니 내역 제거, 실패 시 장바구니 내역 복구
public void order(Order order) {
placeOrder.run(order);
}
간단히 추가하였지만 장바구니를 다루는 행위는 이제 재고와 쿠폰을 다루는 행위와 같은 트랜잭션처럼 동작할 것입니다.
정리
실제로 이번 CompensatoryTask
는 Monad 의 규칙을 따라 구현하긴 했지만 MiddlewareTask
의 규칙을 따르면 되기에 구현은 아주 단순했습니다. 그보다 CompensatoryTask
에서는 기존의 Monad 를 재사용하는 과정에서 필요에 따라 Monad 패턴을 다시 사용하여 편의를 높이고 Monad 패턴이 주는 장점을 가져오는 사례입니다. 그렇지만 이 역시도 짧은 합성 방법이 이 사례에 핵심입니다.
public CompensatoryTask<T> andThen(CompensatoryTask<T> compensatoryTask) {
return new CompensatoryTask<>(task.andThen(compensatoryTask.task));
}
Middleware 패턴과 코드 수준에서의 활용 에 이어 Monad 규칙을 활용하여 주어진 문제를 좀 더 변경에 유리한 코드로 작성해 보았습니다. 그리고 그 과정에서 주요한 부분은 제가 Monad 를 이해하는 방법 로 부터 Monad 를 설명해보려는 방식인 우리가 사용한 컨테이너를 합성하는 규칙을 찾는 것입니다. Bartosz Milewski 는 Category Theory For Programmers 에서 Monad 는 모든 것을 합성하기 위해 사용하는 것이라고 합니다. 컨테이너와 합성의 관점에서 살펴보는 방법이 Monad 를 좀 더 이해할 수 있는 방식에 하나가 되었으면 좋겠습니다.
긴 글 읽어주셔서 감사합니다.
'Backend' 카테고리의 다른 글
상품 브랜드 태그 API 의 과거와 현재 (1) | 2022.03.10 |
---|---|
Linger 로 오버헤드 줄이기 (0) | 2021.07.14 |
Rust Memory Management (2) | 2021.05.07 |
Middleware 패턴과 코드 수준에서의 활용 (0) | 2021.04.30 |
Monad 를 이해하는 방법 (2) | 2021.04.16 |