티스토리 뷰

Backend

try-catch 지옥 벗어나기

지마켓 선현상 2021. 5. 21. 12:00

실패한 작업

가끔 일련의 작업을 하나의 트랜잭션 마냥 수행해야 하는 경우가 있습니다. 분산처리나 분산 트랜잭션 등의 주제라면 또 다른 얘기가 되지만, 좀 더 단순하게 코드 레벨의 문제들을 마주할 경우가 있죠. 각 작업은 실패할 수 있고 하나라도 실패하는 경우 지금까지 수행한 작업들에 실패 처리를 하는 코드를 어떻게 짜면 좋을까요? 일단 하나의 작업에 대해서 실패를 대비하고 실패 시 처리방안을 마련하는 방법은 바로 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

먼저 CompensatoryTaskMiddlewareTask 에 대한 컨테이너로 시작합니다. MiddlewareTaskMiddleware 패턴과 코드 수준에서의 활용 을 참고 부탁드립니다.

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 메서드를 통해 actioncompensation 을 하나의 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
댓글