재시도 로직을 컨트롤러에서 분리할 수는 없을까?
우아한테크코스의 프리코스 과정에서 검증 로직을 분리할 필요가 있었다.
기존에는 검증을 하나의 클래스에서 처리했다. 하지만 도메인이 점점 풍부해짐에 따라, 같은 flow의 검증이라도 입출력 관점에서의 검증과 도메인 관점에서의 검증이 나뉠 수밖에 없었다. 이 때, 재시도 로직을 컨트롤러에서 분리하려는 노력이 있었는데, 이를 메뉴 주문을 예로 들면서 내 생각의 흐름을 말해보고자 한다.
- 메뉴 주문은
[메뉴명-수량]의 형태여야 한다. 예를 들어,[초코바-3]과 같은 형태이다.- 여러개 주문하고 싶다면 ,로 각 주문을 붙인다. 예를 들어,
[초코바-3],[스프-2]와 같은 형태이다.
- 형태가 잘못되었다면 예외를 발생시켜야 한다.
- 만약 존재하지 않은 메뉴를 주문하면 예외를 발생시켜야 한다.
- 수량이 1개 미만이라면 예외를 발생시켜야 한다.
이때, 첫번째 예외 상황은 입력 과정에서의 예외라고 볼 수 있다. 또한 두세번째 예외는 도메인의 예외라고 볼 수 있다. 이 둘을 나누기 위해서는 다음과 같이 코드가 작성된다.
public class InputValidator {
private static final Pattern ORDERS_PATTERN = Pattern.compile("^(.+-[1-9][0-9]*)(,.+-[1-9][0-9]*)*$");
public void validateOrders(String input) {
if (!ORDERS_PATTERN.matcher(input).matches()) {
throw CustomExceptions.INVALID_ORDER_FORMAT.get();
}
}
}
입력 과정에서는 정규표현식을 통해 [메뉴-수량],[메뉴-수량], ... 의 형태를 검증한다.
public class Order {
private static final int MIN_AMOUNT = 1;
private final Menu menu;
private final int amount;
private Order(OrderCreateDto dto, int amount) {
validateAmount(amount);
this.menu = Menu.from(dto.menuName()), dto.amount();
this.amount = amount;
}
private void validateAmount(int amount) {
if (amount < MIN_AMOUNT) {
throw CustomExceptions.INVALID_ORDER.get();
}
}
}
주문 도메인에서는 주문 수량을 검증하게 된다.
public enum Menu {
양송이스프(6_000, MenuType.APPETIZER),
타파스(5_500, MenuType.APPETIZER),
시저샐러드(8_000, MenuType.APPETIZER),
// . . .
;
private final int cost;
public final MenuType menuType;
Menu(int cost, MenuType menuType) {
this.cost = cost;
this.menuType = menuType;
}
public static Menu from(String name) {
return Arrays.stream(Menu.values())
.filter(menu -> menu.name().equals(name))
.findFirst()
.orElseThrow(CustomExceptions.MENU_NOT_FOUND::get);
}
}
메뉴 도메인에서는 각 메뉴가 존재하는 메뉴인지 검증하게 된다.
여기서 문제는 예외 발생시에 사용자에게 다시 입력을 받는다. 라는 요구사항 때문에 발생하였다. 이런 경우에 도메인에서 예외가 발생하여도 입력 과정부터 다시 재시도해야 하기 때문에, 입력과 도메인은 필연적으로 한 코드 블록으로 묶일 수밖에 없다. (예외가 입력과 도메인 두 위치에서 모두 발생할 수 있기 때문!)
코드가 묶인 상태는 아래와 같이 나타난다.
public class Controller {
// ... 의존성 주입
public void run() {
while(true) {
try {
List<OrderCreateRequest> orderCreateRequests = inputHandler.getOrderRequests();
Orders orders = Orders.from(orderCreateRequests);
} catch (IllegalArgumentException e) {
exceptionHandler.handle(e);
}
}
// ... 추가 코드
}
}
이렇게 작성되니 컨트롤러가 하는 역할이 너무 많다고 느꼈다.
1. 반복문을 돌며 성공할 때가지 계속 시도
2. 예외가 발생하면 해당 예외를 처리하는 컴포넌트에게 전달
3. 입력계층으로부터 요청을 받아옴
4. 도메인에게 요청을 전달하여 도메인 생성
여기서 적어도 재시도 로직만큼은 분리하는 것이 좋을 것 같다고 생각하였다.
public class RetryHandler {
// ... 의존성 주입
public <T> T tryUntilSuccess(final IllegalArgumentExceptionThrower<T> thrower) {
while (true) {
try {
return thrower.run();
} catch (IllegalArgumentException e) {
exceptionHandler.handle(e);
}
}
}
@FunctionalInterface
public interface IllegalArgumentExceptionThrower<T> {
T run() throws IllegalArgumentException;
}
}
public class Controller {
// ... 의존성 주입
public void run() {
Orders orders = retryHandler.tryUntilSuccess(() -> {
List<OrderCreateRequest> orderCreateRequests = inputHandler.getOrderRequests();
return Orders.from(orderCreateRequests);
});
// ... 추가 코드
}
}
이렇게 하면 RetryHandler에게 재시도를 시도하고 싶은 코드블록을 전달해줄 수 있고, 컨트롤러는 덕분에 재시도로직을 빼고 RetryHandler를 가져다 쓰기만 하면 된다.
하지만 나는 이것도 마음에 들지 않았는데, 결국 재시도에 관련된 코드가 한 컨트롤러에 존재하기 때문이다. 상세한 로직은 빠졌지만, 흐름이 그대로 존재하며 컨트롤러를 읽는데 큰 방해가 되고 있었다.
위 컨트롤러는 오로지 입력을 받고, 도메인을 생성하는 로직에만 집중하도록 구성하고 싶었기 때문에, 프록시 패턴을 통해서 이를 해결하였다.
public interface OrderController {
Orders getOrders();
}
public class OrderControllerRetryProxy implements OrderController {
// ... 의존성 주입
@Override
public Orders getOrders() {
return retryHandler.tryUntilSuccess(() -> orderController.getOrders());
}
}
public class DefaultOrderController implements OrderController {
// ... 의존성 주입
@Override
public Orders getOrders() {
List<OrderCreateRequest> orderCreateRequests = inputHandler.getOrderRequests();
return Orders.from(orderCreateRequests);
}
}
이렇게 프록시 패턴을 통해서 예외 처리 로직과 컨트롤러를 완전하게 분리할 수 있었다.
이렇게 작성되니 확실히 코드가 분리되고, 더 좋은 것은 컨트롤러를 어떻게 구성하느냐에 따라 재시도 로직을 넣을 수도 있고 뺄 수도 있다. 이 말은 재시도 로직이 기존의 컨트롤러에서 완전히 분리되었다는 말이 되어서, 관심사가 잘 분리된 것 같다.