들여쓰기 단계를 지키면서 예외 발생 시 재시도하는 방법


우아한테크코스 프리코스와 본 과정 미션에서는 다양한 요구사항을 만족하는 프로그램을 작성해야 한다. 이번 프리코스에서도 고민을 많이 했었던 부분이 예외 발생 시 재시도하는 것이다. 대부분 while을 통해 입력받는 부분을 작성하거나, 재귀로 작성하기도 한다.

우테코 미션의 기본 프로그래밍 요구사항은 들여쓰기를 한 번만 하는 조건이 기본적으로 붙어 있다. 유연한 코드를 의식적으로 만들어내게 하기 위한 하나의 장치라고 생각한다. 메서드 내에서의 두 번 들여쓰기는 이 요구사항을 위반하므로 메서드를 분리하거나, Stream 등을 활용해 줄여볼 생각을 하는 것이 좋다.

우테코 미션의 실제 기능 요구사항에는 오류 발생 시 재입력을 하도록 하는 사항이 들어있지는 않았다. 프리코스 때 코드는 내가 짜지만 사용자는 내 프로그램을 쓴다는 생각에 보다 친절한(?) 개발자가 되는 편이 낫겠다 싶었다. 앞으로 진행할 미션들에서는 콘솔에서 진행할 때, 예외 발생 시 재입력을 받도록 하려고 한다.

방법 1: while-try-catch (Indent 2)

가장 간단하게 생각해볼 수 있는 방법으로는, 오류가 날 수 있는 모든 부분에 while-try-catch를 붙이는 것이다. 몇 번 시도해보면 알겠지만 코드가 매번 반복되는 게 보기 힘들다는 단점이 있다.

private int convertToInteger() {
    while (true) {
        try {
            String input = scanner.nextLine();
            return Integer.parseInt(input);
        } catch (IllegalArgumentException e) {            
            // 에러 메시지 출력 등
        }
    }
}

프리코스 3주 차 회고에서 설명했던 내용인데, Java8부터 새로 도입된 함수형 인터페이스, 제너릭을 활용하면 코드의 재사용성을 최소화하면서 같은 동작을 하도록 할 수 있다. 이제는 코드를 파라미터로 넘길 수 있게 되었으니, 이 부분을 신경써서 보면 좋겠다. 이 변화로 많은 코드를 보기 쉽게 작성할 수 있었고, 다른 코드와의 시너지도 느껴볼 수 있다!

private <T> T repeatUntilValid(Supplier<T> function) {
    while (true) {
        try {
            return function.get();
        } catch (IllegalArgumentException e) {
            // 에러 메시지 출력 등
        } 
    }
}

private int convertToInteger() {
    String input = scanner.nextLine();
    Integer.parseInt(input);
}

repeatUntilValid(this::convertToInteger); // 메서드 참조 !

위와 같이 작성하면, 인덴트 2를 지키면서, 메서드를 참조하도록 하면서 보다 나은 코드를 작성할 수 있게 된다! 실제 코드를 작성하는 곳에서는 메서드 참조만을 사용하게 되니 메서드 안쪽이 훨씬 깔끔해진다.

방법 2: 재귀함수 (Indent 1)

상황을 조금 더 제한해 보자. 만약 이 상태에서 인덴트 1이라는 요구사항이 추가된다면 어떻게 구현할 것인가? 사실 요구사항을 듣자마자 보기 어려운 코드도 아닌데 왜 굳이 바꿔야 할까… 라는 생각이 먼저 들었지만, 제한된 상황에서 나의 창의력을 끌어내는 것도 좋은 경험이 될 것이라고 생각한다.

보통 while 루프의 인덴트를 줄이기 위해서는 아래와 같은 재귀함수를 사용한다. 예외가 나지 않으면 값을 리턴하도록 하면 된다. 예외가 난다면? 다시 해당 함수를 들어가 줄 때까지 파고들면 된다.

private <T> T repeatUntilValid(Supplier<T> function) {
    try {
        return function.get();
    } catch (IllegalArgumentException e) {
        return repeatUntilValid(function);
    } 
}

다만 이 경우, 계속해서 잘못된 값을 사용자가 입력한다면 어떻게 될까? Java의 경우 메서드가 실행될 때마다 스택 영역에 Frame이 추가되는 형식이다. 메서드가 계속해서 실행되므로 어느 순간 스택이 가득 차게 되고, StackOverflowError가 발생하게 된다.

이제는 사용자가 마음만 먹으면 나의 프로그램을 엉망으로 만들 수 있게 되었다. 어떻게 보면 인덴트를 줄이는 것과, 프로그램 보안과의 Trade-off라고도 볼 수 있겠다. 하지만 저울이 너무 기울지 않았는가? 코드를 조금 간결하게 만들기 위해서 서비스가 먹통이 날 위기에 처한 것이다.

인덴트 2만 됐어도 마음이 편했을 텐데요

방법 3: Stream으로 해결하기 (Indent 1, StackOverflowError 🙅‍♂️)

역시 이번 미션을 진행하면서도 재귀를 넣었고, 내가 걱정했던 부분에 리뷰가 달려 고민을 많이 했다.

조금 더 고민해보며 다른 방법이 없을까 생각했다. 이미 try-catch로도 하나의 들여쓰기를 먹는데 추가 분기를 어떻게 진행해야 할까? 메서드를 어떻게 빼야 코드가 동작하면서 재입력까지 받아낼 수 있을까? 나아가, 이를 만들어내는 데 들인 시간과 노력, 얻어낸 결과의 trade-off가 만족할 만 한가? 등 많은 의문이 머리를 스쳤다.

하지만 예외가 발생했을 때의 재입력은 우아한테크코스 프리코스 때에도 매번 등장하는 토픽이고, 이를 구현하기 위해서 등장하는 들여쓰기 문제와 요구사항이 부딪히는 상황이 빈번했다. 제한된 상황에서 코드에 들이는 정성과의 trade-off를 생각하지 않고, 우선 가능한지부터 찾아보고 싶었다. 그리고 코드가 조금 길어지긴 했지만, 결국 성공해냈다 😁

구현할 때의 가장 중요했던 개념은 Stream과 위에서 설명했던 함수형 인터페이스이다.

아래 문단부터는 어떤 식으로 접근해야하는지를 설명합니다.
혹여 방법에 대한 힌트만을 원하신다면 여기까지만 보시는 것을 추천드립니다 🙌

Stream의 iterate: 무한 스트림

무한 스트림을 생성하는 Stream.iterate에 집중해 보자. 아래는 메서드에 작성된 Javadoc이다.

Returns an infinite sequential ordered Stream produced by iterative application of a function f to an initial element seed, producing a Stream consisting of seed, f(seed), f(f(seed)), etc.
...
Params:
seed – the initial element
f – a function to be applied to the previous element to produce a new element

Stream.iterate는 최초 값과 이를 변형하는 함수가 있으면, 값에 계속해서 함수를 씌우는 형태의 무한한 원소의 스트림을 만들어낸다. 최초 값인 seedGeneric 타입이기 때문에 어떤 것이든 들어갈 수 있고, 두 번째 파라미터인 함수 f는 함수형 인터페이스: UnaryOperator이다. UnaryOperator는 단항 연산을 의미한다. 단순하게 생각하면 아래와 같이 사용할 수 있다.

List<Integer> numbers = Stream.iterate(0, i -> i + 1)
        .limit(10)
        .toList();
for (int number : numbers) {
    System.out.print(number + " ");
}
// 출력 결과
// 0 1 2 3 4 5 6 7 8 9

간단한 예제를 통해서 감을 잡아 보았다. 최초 값은 0이고, i가 주어졌을 때 i+1을 반환하는 함수를 사용해서 무한한 길이의 스트림을 만들었다. 이 중 맨 앞의 열 개만을 limit 리스트로 반환했다 toList. 간단한 함수를 통해서 iterate를 이해했다. 그렇다면 이 것을 사용해서 어떻게 무한히 특정 코드를 반복하게 하고, 예외가 나지 않는다면 반환하도록 할 수 있을까?

함수형 인터페이스와의 조합

우선 함수형 인터페이스를 생각해 보자. 함수형 인터페이스와 메서드 참조를 통해, 우리는 코드 조각을 파라미터로 넣을 수 있게 되었다. 동작하는 방법을 메서드에서 받을 수 있다. 그렇다면 하고자하는 동작을 받고, 예외가 난다면 다시 실행하고 그렇지 않다면 리턴해줘야 할 것이다.

그렇다면, 우리가 실행하고자 하는 메서드를 전해야하는 것은 분명하다. 인자로는 Generic 타입 메서드가 들어와야 한다. 인자가 없고, 리턴 타입이 Generic인 함수형 인터페이스 Supplier<T>를 사용할 수 있겠다. 하지만 문제는 언제 함수가 끝나는가? 예외가 발생했다는 것을 어떻게 알 수 있는가? 라는 점이다. 결국 어느 지점이라는 게 필요했고, 그 상태를 위쪽에 다시 알리는 레이어가 필요했다. 메서드를 담고 있는 클래스를 만들어, 상태를 알리도록 했다.

public class LoopFunction<T> {

    private final Supplier<T> function;

    private T returnValue = null;

    public LoopFunction(Supplier<T> function) {
        this.function = function;
    }

    public T invoke() {
        return function.get();
    }

    public T getReturnValue() {
        return returnValue;
    }

    public void setReturnValue(T value) {
        this.returnValue = value;
    }

    public boolean hasReturnValue() {
        return returnValue != null;
    }
}

이제 Stream.iterate의 파라미터를 다시 살펴보자. 최초 값, 변형 함수 두 가지가 들어간다. 스트림에서 새로운 값을 만들어내고 싶을 때마다 현재 값에 함수를 씌워 나온 결과를 내보내며, 이 또한 Generic이다 (UnaryOperator이기 때문이다).

그렇다면 LoopFunction을 최초 값으로 받아서, hasReturnValuetrueLoopFunction를 계속해서 찾아야 한다. Stream에는 생각보다 유용한 함수가 많다!

LoopFunction<T> loopFunction = new LoopFunction<>(function);

Stream.iterate(loopFunction, this::generateNextFunction)
        .filter(LoopFunction::hasReturnValue) // 메서드 참조
        .findFirst()
        .orElseThrow(IllegalArgumentException::new)
        .getReturnValue();

뭔가 되어가는 것 같았지만… 이 스트림을 멈출 방법이 없다. returnValue 필드를 바꿔줄 로직이 존재하지 않았기 때문이다. 결국 어느 시점에서 returnValuenull이 아닌 값이 되어야 하고, 그 때의 리턴값을 전달해줘야 했다. 이제 try-catch를 통해서 예외가 일어나지 않는다면 함수의 리턴값을 설정해 주면 된다! 다 왔다 😁😁

public static <T> T retryOnException(Supplier<T> function) {
    LoopFunction<T> loopFunction = new LoopFunction<>(function);

    return Stream.iterate(loopFunction, LoopFunction::setReturnValueOnSuccess)
            .filter(LoopFunction::hasReturnValue)
            .findFirst()
            .orElseThrow(IllegalArgumentException::new)
            .getReturnValue();
}

private static <T> LoopFunction<T> setReturnValueOnSuccess(LoopFunction<T> loopFunction) {
    try {
        T returnValue = loopFunction.invoke();
        loopFunction.setReturnValue(returnValue);
    } catch (Exception e) {
        System.out.println(e.getMessage());
    }
    return loopFunction;
}

전체적인 흐름은 이와 같다.

  1. 함수형 인터페이스를 변환하는 과정에서, 예외가 언제 발생하고, 그 리턴값이 무엇인지 알지 못한다.
  2. 리턴값을 포함하는 클래스를 만든다.
  3. 해당 클래스 객체를 변형시키는 함수를 만들어, Stream.iterate를 통해 무한 스트림을 생성한다.
  4. filterfindFirst를 통해, 처음으로 리턴값이 존재하는 함수 객체를 찾는다.
  5. 예외가 발생하는 경우, 리턴값이 null이므로 내부에서 try-catch를 통해 같은 함수가 리턴된다.

사실.. 아직 수정할 곳이 더 많은 것 같다. 처음에는 상속으로 구현했다가, 상속 없이도 setter를 통해 바꿀 수 있어 상속을 없앴다. 나아가, 실제로 null을 리턴하는 함수가 인자로 들어오는 경우 이를 체크할 수 없으니 Optional로 감싸줘야 하나? 라는 생각도 들고, 그렇게 되면 stream 함수를 사용하는 곳에서 너무 길어지는 것 같기도 하다. 콘솔 위에서만 사용하니까 항상 리턴이 있다고 생각해도 되는 걸까? 확장성을 어느 정도까지 생각해도 되는 지 감이 안 오는 듯하다 🤔

이참에 무한 스트림에 대해서도 학습하고, 드디어 재귀 없이 인덴트 1을 지키면서 요구사항을 만족할 수 있게 된 게 제일 크게 다가왔다. 이것을 실제 미션에 적용하는 것은 좀 더 고민해봐야겠지만, 우선 프리코스 때부터 간지러웠던 부분을 긁을 수 있게 돼서 좋았다 😎😎


References

Java 8로 꼬리재귀 함수 만들기: https://loosie.tistory.com/790

Categories