이번에 주어진 구체적인 요구사항들에 대하여, 마냥 따르기 보다는 해당 사항이 무엇을 의미하는지 어떤 걸 요구하는지 파악하고 미션을 진행하고 싶었습니다.
특히 클래스에 대한 제약 사항에 대해서는, 이전부터 계속 요구되어 왔던 도메인 로직과 UI 로직의 분리를 클래스 간의 관계에서 잘 적용시켜야 한다는 것으로 이해했습니다.
그동안 MVC 패턴에 대해 제대로 이해하기보다는 대충 흉내만 내 왔다는 생각이 들어서, 이번 기회에 이를 좀 더 알아보는 시간을 가졌습니다.
그 중 “10분 테코톡”에서 제리님이 설명해주시는 MVC 패턴 영상에서 설명하는 “MVC를 지키면서 코딩하는 법” 목록을 참고하며 이번 미션을 진행했습니다.
💡 MVC 패턴 원칙을 지키며 코딩하기
1. Model은 Controller와 View에 의존하지 않아야 한다.
- Model 내부에 Controller와 View에 관련된 코드가 있으면 안된다.
2. View는 Model에만 의존해야 하고, Controller에는 의존하면 안된다.
3. View가 Model로부터 데이터를 받을 때는 사용자마다 다르게 보여줘야 하는 데이터만 받아야 한다.
- 예를 들어 “주문하기”, “배달정보” 와 같이 일반적인 데이터 vs 사용자의 주소, 전화번호 데이터
4. Controller는 Model과 View에 의존해도 된다. (중개자 역할)
5. View가 Model로부터 데이터를 받을 때, 반드시 Controller에서 받아야 한다.
리팩토링 과정에서도 위 목록을 염두에 두며 고민하니 도움이 많이 되었습니다.
이번에 가장 많은 시간을 투자해 많은 변화 과정을 거쳤던 부분으로는 크게 두 가지가 있었습니다.
- 사용자로부터 입력값을 받고, 잘못된 입력값일 시 다시 입력을 받는 기능 에서
- 도메인과 UI 로직 분리에 대한 고민을 하고,
- 중복되는 메소드를 재사용성 높은 공통 메소드로 바꿔보고,
- View에서 Model을 판단하지 않도록 리팩토링하는 경험을 했습니다.
- 사용자가 현재 이동한 다리의 현황을 보여주는 기능 에서
- Model에서 View를 위한 행위를 제거하고,
- View를 위한 템플릿 역할을 하는 클래스를 만들어보면서
- 기존의 비효율적인 설계 방식을 전면 리팩토링하는 경험을 했습니다.
이전부터 어려움을 겪었던 부분으로, 지난 미션에서는 검증하는 영역을 도메인과 UI 간 완전히 분리하여 해결했습니다. 하지만 이번 미션에서는 잘못된 입력값에 대해 다시 입력을 받아야 한다는 점이 달랐습니다. UI에서 입력값을 완전히 검증하기 위하여 도메인에 대한 조건을 알고 있어야 하는 상황이었습니다.
그러다보니 구현을 시작하고 나서도 어떤 인자값들을 검증해주어야 할지 명확하지 않아 진행이 어려웠습니다.
만약 저번 미션과 같은 기준으로 검증 영역을 분리한다면, 특히 입력 기능에서 아래 코드와 같은 상황이 되기 때문에 비효율적이라고 생각했습니다.
// ... inputView
public int readBridgesSize() {
String value = Console.readLine();
// 데이터 타입 검증, 정수로 변환
return size;
}
// ... GameController
public void initializeGame() {
try {
outputView.printMessage(Message.INPUT_BRIDGES_SIZE.getValue());
int size = inputView.readBridgesSize();
**bridgeGame.initializeBridges(size); // 다리 길이 검증을 위해 도메인 로직을 실행해야 함**
} catch (IllegalArgumentException exception) {
outputView.printMessage(Format.Error, exception.getMessage());
initializeGame();
}
}
사실 로직과 동일하게 검증 영역을 분리하기 보다는, UI와 도메인 로직 양 측에서 모두 검증하는 것이 가장 이상적일 것입니다. 하지만 지금은 그보다 각 로직의 책임을 명확히 하고 싶었습니다.
그래서 이번에는 아래와 같은 기준을 세우고 나니 구현을 진행하기 보다 수월해졌습니다.
💡 로직 분리에 따른 예외 발생 기준
UI 로직
에서는 도메인 로직을 위한 조건을 모두 검증한 입력값을 받아 전달한다. (잘못된 입력값일 경우, 예외를 발생시킨 뒤 다시 입력값을 받는다.)도메인 로직
에서는 입력으로 전달받는 값은 모두 검증된 것으로 간주한다. 그러나 개발자의 실수 및 잘못된 메소드 사용에 대해 예외를 발생시킨다.- 예외 발생 시 적합한 표준예외를 사용한다.
- 입력값 오류와 코딩 오류를 구분하기 위해 에러메시지에
[ERROR] 게임 기능 오류:
,[ERROR] 사용자 입력 오류:
와 같이 표기한다.
// ... GameController
private int askBridgeSize() {
try {
return inputView.readBridgeSize();
} catch (IllegalArgumentException exception) {
outputView.printErrorMessage(exception.getMessage());
return askBridgeSize();
}
}
하지만 다리의 길이
, 이동할 칸 위치
, 재시도/종료 여부
를 물어보는 로직이 모두 같은 구조의 코드에서 inputView
의 각기 다른 메소드만 호출하고 있었습니다. 이러한 중복을 줄여보기 위해 고민하면서 여러 단계의 과정을 거쳤습니다.
같은 구조에서 서로 다른 “함수”만 전달해주면 되는 것이었기 때문에 처음에는 이렇게 내부 인터페이스를 정의해보았습니다. 그리고 각 메소드의 반환 타입이 다르기 때문에, 이를 Object
로 정의했습니다.
private interface Readable {
Object read();
}
private Object ask(Readable ask) {
try {
return ask.read();
} catch (IllegalArgumentException exception) {
outputView.printErrorMessage(exception.getMessage());
return ask();
}
}
/// 사용 예시
ask(inputView::readMoving).toString()
그런데 Enum 클래스나 테스트 코드에 대해 공부하면서 접하게 된 여러 인터페이스들이 생각났습니다. 직접 인터페이스를 정의할 필요 없이, Runnable
, Callable
, 그리고 util.function
라이브러리에 다양한 인터페이스가 이미 제공되고 있었습니다.
그리고 반환 타입의 경우도 콜렉션을 사용하면서 접하기만 하고 사용해보지 않은 제너릭 타입을 적용해보았습니다. Object를 반환 타입으로 선언했을 때는, 그 값을 받은 곳에서 이를 다시 원하는 타입으로 변환해주어야 하는 문제점이 있었기 때문입니다.
/**
* 사용자에게 입력값을 받는다.
* 잘못된 입력값으로 인한 예외 발생 시 다시 입력값을 받는다.
*/
private <T> T askUntilGetLegalAnswer(Supplier<T> readInput) {
try {
return readInput.get();
} catch (IllegalArgumentException exception) {
outputView.printUserInputErrorMessage(exception.getMessage());
return askUntilGetLegalAnswer(readInput);
}
}
/// 사용 예시
askUntilGetLegalAnswer(inputView::readMoving)
결국 여러 번의 과정을 거쳐 같은 구조의 세 개의 메소드가 하나의 재사용성 높은 공통 메소드로 바뀌었습니다.
이전에는 직접 사용해 볼 엄두가 안났던 자바의 여러 API를 하나씩 적용해가며 발전시킬 수 있어서
작업 중에도 재미있고 인상 깊은 경험이 된 것 같습니다.
(Callable
과 Supplier
의 차이를 찾아보니, 전자는 멀티스레드를 사용하는 상황에 더욱 적합하고 후자가 더 일반적인 인터페이스라고 이해하여 후자를 사용했습니다.)
그런데 InputView
의 readBridgeSize
메소드를 보면 다음과 같았습니다.
public int readBridgeSize() throws IllegalArgumentException {
int size = ConsoleReader.readLineAsInteger();
if (size < BridgeSize.MINIMUM.getValue() || size > BridgeSize.MAXIMUM.getValue()) {
throw new IllegalArgumentException("입력 오류: 다리의 길이는 3 이상 20 이하만 허용됩니다.");
}
return size;
}
UI 로직에서 도메인 로직을 위한 조건을 모두 검증한다고 해도, 가능하면 도메인의 정보인 다리 길이에 대한 조건을 직접 판단하지 않게 하고 싶었습니다.
이를 위해 View와 Model을 중재하는 Controller에서, 해당 입력 기능에서 알아야 할 조건을 주입하는 방식으로 바꿔보았습니다.
불필요한 과정이 아닐까 고민도 되었지만, 다리 길이의 조건이 바뀌는 등 Model의 변경 사항을 View가 바로 알아차릴 수 있다는 점에서 MVC 패턴에 적합한 방법이라고 생각했습니다.
askUntilGetLegalAnswer
는 위에서 만든 메소드를 오버로딩)// ... InputView
/**
* 다리의 길이를 입력받는다.
*/
public int readBridgeSize(int minimum, int maximum) throws IllegalArgumentException {
return consoleReader.readLineAsIntegerInRange(minimum, maximum);
}
// ... GameController
/**
* 정해진 다리 번호 생성기를 이용해 다리 게임의 기본 값을 설정한다.
*/
private void initializeBridgeGame(BridgeNumberGenerator generator) {
outputView.printMessage(OutputMessage.ASK_BRIDGE_SIZE);
**int bridgeSize = askUntilGetLegalAnswer(inputView::readBridgeSize,
BridgeSize.MINIMUM.getValue(), BridgeSize.MAXIMUM.getValue());**
bridgeGame = new BridgeGame(bridgeSize, new BridgeMaker(generator));
movingMap = new MovingMap();
}
// ...
/**
* 사용자에게 정해진 범위 내의 입력값을 받는다.
* 잘못된 입력값으로 인한 예외 발생 시 다시 입력값을 받는다.
*/
private <R> R askUntilGetLegalAnswer(BiFunction<Integer, Integer, R> readInput,
int minimum, int maximum) {
try {
return readInput.apply(minimum, maximum);
} catch (IllegalArgumentException exception) {
outputView.printUserInputErrorMessage(exception.getMessage());
return askUntilGetLegalAnswer(readInput, minimum, maximum);
}
}
기능 목록을 작성하면서 초기에 정리해 본 클래스 구조는 다음과 같았습니다.
도메인 로직에서 사용자가 이동할 때에는 U/D
와 같은 키워드를 사용하지만, 이를 OutputView
에서 보여줄 때는 다른 형식으로 바꾸어주어야 했습니다.
그리고 이동 현황에는 이전까지 이동한 내역이 모두 표현되어야 했습니다.
그래서 Player
가 한 칸씩 이동할 때마다 그 내역인 movingHistory
를 도메인 로직의 표현 방식([”U”, “U”, “D”, ...]
)으로 계속 저장하고,
이를 GameController
을 통해 View 계층에 전달해 원하는 방식으로 표현하게 하고자 했습니다.
Bridge
로 정의했던 클래스는, 이후 Player
의 멤버 변수로 사용되는 RemainingSteps
로 바뀌었습니다. “다리” 정보는 의인화할 필요가 없고, Player
가 건너야 할 다리가 얼마나 남았는지 알고 있어야 한다고 생각했습니다.)하지만 라운드마다 한 개씩만 더 추가되는 movingHistory
의 원소값을 매번 새로 변환해주고, 새로 표현하는 일이 비효율적이라는 생각이 들었습니다.
실제로 코드의 양 또한 많아져서 MovingMapGenerator
클래스로 분리했지만, 이 클래스에서는 같은 리스트에 대한 작업을 하면서 메소드를 작게 분리하기 위해서는 꼭 movingHistory
를 필드로 사용해야만 했습니다.
MovingMapGenerator
를 적용한 클래스 다이어그램public class MovingMapGenerator {
private final List<String> movingHistory;
private final boolean isFailed;
// ... 생략
private List<String> assembleDisplaysForMap(StringJoiner up, StringJoiner down) {
String mapPrefix = MovingMapFormat.PREFIX.getValue();
String mapSuffix = MovingMapFormat.SUFFIX.getValue();
List<String> map = new ArrayList<>();
map.add(mapPrefix + up + mapSuffix);
map.add(mapPrefix + down + mapSuffix);
return map;
}
private void addDisplayByMoving(StringJoiner up, StringJoiner down) {
for (int index = 0; index < movingHistory.size(); index++) {
String moving = movingHistory.get(index);
boolean isFailedMoving = isFailedMoving(index);
up.add(MovingMapDisplay.convertMovingToDisplay(MovingKeyword.UP.isSameStepMoving(moving), isFailedMoving));
down.add(MovingMapDisplay.convertMovingToDisplay(MovingKeyword.DOWN.isSameStepMoving(moving), isFailedMoving));
}
}
private boolean isFailedMoving(int index) {
if (index == movingHistory.size() - 1) {
return isFailed;
}
return false;
}
}
// MovingMap 클래스에서 toString 메소드에서 출력 방식 구현
그래서 고민하던 중 이는 메소드 차원을 넘어서 클래스 설계 관점에서의 변경이 필요하다는 걸 알게 되었습니다. 그리고 다시 살펴보니, Player
가 movingHistory
를 저장하는 일 자체가 오직 View를 위해서 수행되고 있었기 때문입니다. 그리고 View에 movingHistory
를 제공하지 않아도
BridgeGame
이 GameController
에 반환하고 있는 Player
의 현재 게임 생존 상태이 두 가지만 알아도 직접 이동 현황 지도를 만들 수 있기 때문에, View 계층에서 직접 매 라운드 마다 새로운 정보를 추가해주기만 하면 되는 것이었습니다.
“삽질을 했다”고, 진작 이렇게 생각을 해서 만들면 더 좋았을 거라고 생각할 수도 있습니다.
하지만 복잡한 생각에 갇힌 상황에서 MVC 패턴의 원칙을 참고함으로써 더 나은 길을 다시 찾는 경험을 했다고 생각합니다.
위 과정을 통해서 MovingMapGenerator
를 없애고, 아래와 같은 구조로 단순화시켰습니다.
같은 객체에 대하여 매 라운드마다 정보만 갱신해주고, 게임 재시도 시에는 이 객체를 초기화하기 위해서는 한 클래스에서 이 객체를 필드로써 관리해주어야 했습니다.
GameController
와 OutputView
중 어느 클래스에서 이 역할을 하는 것이 맞는지 고민했습니다.
결과적으로,
이 객체는 GameController
에서 관리하고, OutputView
는 이를 전달받아 출력만 하도록 만들었습니다.
이 클래스는 View에 필요한 형식을 제공하는 일종의 “템플릿” 역할을 하는 것이라고 생각했기 때문입니다. 때문에 OutputView
는 이 객체를 이용해 정보를 원하는 형식으로 출력할 수 있고, 이 객체를 관리하는 기능은 GameController
의 책임이라고 생각했습니다.
/**
* 이동 현황 정보를 출력을 위해 정해진 지도 형식으로 저장하는 템플릿 클래스
*/
public class MovingMap {
private final StringJoiner up = new StringJoiner(MovingMapFormat.DELIMITER.getValue());
private final StringJoiner down = new StringJoiner(MovingMapFormat.DELIMITER.getValue());
public void addOneRound(boolean hasFailed, String moving) {
String upDisplay = MovingMapDisplay.convert(hasFailed, MovingKeyword.UP.isSameStepMoving(moving));
String downDisplay = MovingMapDisplay.convert(hasFailed, MovingKeyword.DOWN.isSameStepMoving(moving));
up.add(upDisplay);
down.add(downDisplay);
}
@Override
public String toString() {
return MovingMapFormat.PREFIX.getValue() + up + MovingMapFormat.SUFFIX.getValue() + "\n"
+ MovingMapFormat.PREFIX.getValue() + down + MovingMapFormat.SUFFIX.getValue();
}
}
이것이 가장 좋은 방법이라고 확언할 수는 없겠지만, 이런 과정을 통해서 클래스 간의 관계를 더 많이 고민해볼 수 있었습니다. 그리고 무엇보다 리팩토링 과정에서 메소드 분리, 클래스 분리 뿐만 아니라 설계 관점의 고민도 중요하다는 것을 알게 되었습니다.
이로써 프리코스의 모든 미션을 완료했는데, 전체 과정에 대한 회고는 별도 작성 예정입니다.