우아한테크코스 프리코스와 본 과정 미션에서는 다양한 요구사항을 만족하는 프로그램을 작성해야 한다. 이번 프리코스에서도 고민을 많이 했었던 부분이 예외 발생 시 재시도하는 것이다. 대부분 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라고도 볼 수 있겠다. 하지만 저울이 너무 기울지 않았는가? 코드를 조금 간결하게 만들기 위해서 서비스가 먹통이 날 위기에 처한 것이다.

방법 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
는 최초 값과 이를 변형하는 함수가 있으면, 값에 계속해서 함수를 씌우는 형태의 무한한 원소의 스트림을 만들어낸다. 최초 값인 seed
는 Generic
타입이기 때문에 어떤 것이든 들어갈 수 있고, 두 번째 파라미터인 함수 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
을 최초 값으로 받아서, hasReturnValue
가 true
인 LoopFunction
를 계속해서 찾아야 한다. Stream
에는 생각보다 유용한 함수가 많다!
LoopFunction<T> loopFunction = new LoopFunction<>(function); Stream.iterate(loopFunction, this::generateNextFunction) .filter(LoopFunction::hasReturnValue) // 메서드 참조 .findFirst() .orElseThrow(IllegalArgumentException::new) .getReturnValue();
뭔가 되어가는 것 같았지만… 이 스트림을 멈출 방법이 없다. returnValue
필드를 바꿔줄 로직이 존재하지 않았기 때문이다. 결국 어느 시점에서
가 returnValue
null
이 아닌 값이 되어야 하고, 그 때의 리턴값을 전달해줘야 했다. 이제 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; }
전체적인 흐름은 이와 같다.
- 함수형 인터페이스를 변환하는 과정에서, 예외가 언제 발생하고, 그 리턴값이 무엇인지 알지 못한다.
- 리턴값을 포함하는 클래스를 만든다.
- 해당 클래스 객체를 변형시키는 함수를 만들어,
Stream.iterate
를 통해 무한 스트림을 생성한다. filter
와findFirst
를 통해, 처음으로 리턴값이 존재하는 함수 객체를 찾는다.- 예외가 발생하는 경우, 리턴값이
null
이므로 내부에서try-catch
를 통해 같은 함수가 리턴된다.
사실.. 아직 수정할 곳이 더 많은 것 같다. 처음에는 상속으로 구현했다가, 상속 없이도 setter
를 통해 바꿀 수 있어 상속을 없앴다. 나아가, 실제로 null
을 리턴하는 함수가 인자로 들어오는 경우 이를 체크할 수 없으니 Optional
로 감싸줘야 하나? 라는 생각도 들고, 그렇게 되면 stream
함수를 사용하는 곳에서 너무 길어지는 것 같기도 하다. 콘솔 위에서만 사용하니까 항상 리턴이 있다고 생각해도 되는 걸까? 확장성을 어느 정도까지 생각해도 되는 지 감이 안 오는 듯하다 🤔
이참에 무한 스트림에 대해서도 학습하고, 드디어 재귀 없이 인덴트 1을 지키면서 요구사항을 만족할 수 있게 된 게 제일 크게 다가왔다. 이것을 실제 미션에 적용하는 것은 좀 더 고민해봐야겠지만, 우선 프리코스 때부터 간지러웠던 부분을 긁을 수 있게 돼서 좋았다 😎😎
References
Java 8로 꼬리재귀 함수 만들기: https://loosie.tistory.com/790