[우테코] 프리코스 4주차 회고

dldbdud314·2022년 11월 23일
1
post-thumbnail

🌉 4주차 미션: 다리 건너기

길이 n만큼의 다리를 생성해서, 위, 아래 선택을 해서 끝까지 건너는 게임이다.
(오징어게임 클립을 올려주셔서 감삼다 굽신굽신.. 생각지 못한 잔인함에 입틀막하면서 흥미진진하게 봄🫣)

📌 미션 구경하러 가기!

실행창 예시

다리 건너기 게임을 시작합니다.

다리의 길이를 입력해주세요.
3

이동할 칸을 선택해주세요. (위: U, 아래: D)
U
[ O ]
[   ]

이동할 칸을 선택해주세요. (위: U, 아래: D)
U
[ O | X ]
[   |   ]

게임을 다시 시도할지 여부를 입력해주세요. (재시도: R, 종료: Q)
R
이동할 칸을 선택해주세요. (위: U, 아래: D)
U
[ O ]
[   ]

이동할 칸을 선택해주세요. (위: U, 아래: D)
D
[ O |   ]
[   | O ]

이동할 칸을 선택해주세요. (위: U, 아래: D)
D
[ O |   |   ]
[   | O | O ]

최종 게임 결과
[ O |   |   ]
[   | O | O ]

게임 성공 여부: 성공
총 시도한 횟수: 2

전체 큰 흐름

1. 다리 생성

  • 다리 길이 n 입력 받기
  • 길이 n인 다리 생성

2. 다리 건너기 게임 프로세스

  • 선택 횟수 (최소)n번 만큼:
  • 'U'/'D' 입력하고 일치/불일치 여부 출력

3. 게임 종료

  • 게임 결과 출력하기 : 최종 게임 결과와, 게임 성공 여부, 총 시도 횟수

✅ 들어가기 전에..

✨ 이건 새롭다 !

객체는 객체스럽게 사용하라?

3주차 피드백에는 유익한 내용들이 많아서 따로 공부하는 것도 재미있었다. 특히, “객체는 객체스럽게 사용하라”는 피드백이 기억에 남는다.

늘 Java 클래스를 짤 때 아무 생각없이 POJO 형식으로 getter와 setter를 두고 설계했었다.

그런데 이번 피드백에서, 단순히 getter로 데이터를 꺼내가기보다는 객체가 직접 메시지를 던지도록 설계하라고 했다. 단순히 수동적인 data carrier로 취급하지 말고, 메시지 던질 줄 아는 능동적인 주체로 설계하라는 말로 해석했다. 이전에는 단순히 내 전체적인 로직을 보조하기 위해 데이터를 담는 통으로 객체를 생각했다면, 이번 피드백은 왠지 객체에 생명력을 불어넣는 기분이라, 개인적으로 신선하고 흥미로웠다!👀✨

(그나저나 이번 미션에서 이걸 잘 수행했는지는 의문이다🤔 앞으로도 스스로 많이 고민하고 훈련이 필요한 부분일 거 같다.)

👀 이번주에 신경 쓴 부분은 ?!

1. MVC 구조로 설계하기

저번 미션의 코드에서 MVC 설계가 아쉽다는 리뷰를 받고, MVC 구조를 더 찾아봤다. (참고: https://tecoble.techcourse.co.kr/post/2021-04-26-mvc/)

3주차의 코드는 Service의 책임이 막대했다. Service에서 비즈니스 로직뿐만 아니라 UI 로직까지 다뤄서, 생각해보면 Controller를 백분 활용하지 못한 설계였다.

이번 미션은 Controller를 controller답게, UI와 큰 단위의 비즈니스 로직을 중심으로 설계하려고 노력했다.

2. 클래스 분리와 리팩토링

이번 주차의 목표가 클래스 분리인만큼, 각 클래스의 책임과 역할을 고려하며 코딩하려고 했다.

점차적으로 리팩토링하면서 클래스의 역할에 대해 고민을 많이 할 수 있어서 재밌었다 !

🐞 이번 주의 bug fix : 예외 발생 후 정상 입력 했을 때, 최종 결과가 바로 출력되는 오류

빠뜨린 예외 상황에 대한 검증 로직을 넣고 실행시켜서 테스트해보다가 우연히 문제 상황을 마주쳤다.

다리를 잘못 입력해서 예외가 발생한 뒤, 다시 입력하면 다리 사이즈 0인 결과가 바로 출력된다는 것이였다. 내 예상대로라면 정상적인 입력 사이즈만큼의 다리가 생성되어야 했기에, 물음표 500개 정도 떴다.

아래는 수정 전, 기존 코드다:

private Bridge makeBridge() {
    outputView.printBrideSizeOpening();
	int bridgeSize = 0;
	try{
			bridgeSize = inputView.readBridgeSize(inputView.userInput());
	}catch(IllegalArgumentException exception) {
		  outputView.printErrorMessage(exception.getMessage());
				makeBridge(); // 문제가 되는 부분
	}
	outputView.printEmptyLine();
	return new Bridge(bridgeSize);
}

재귀적인 구조로, 예외 발생 시 자기 자신을 다시 호출하게끔 로직을 작성했다.

대체 어디서 잘못된거지..? 왜 사이즈가 0인 다리가 생성된건가 싶어서 여기저기 로그 찍어보며 상황을 파악했다.

분명 정상 입력으로 3을 입력하면, 사이즈가 3인 다리가 생성되는 건 확인했다. 아래 로그를 확인해보면, bridgeSize가 3으로 만들어진 뒤, 다음에 사이즈가 0으로 설정됐다는 걸 확인할 수 있다.

🤓: 자, 그럼 여기서 문제!! 여러분은 눈치 채셨나요..?! 제가 재귀 호출 스택을 놓쳤다는 사실을요..?!😱

다시 한번 코드를 뜯어보자.

처음 bridgeSize = 0으로 초기화한 뒤, 예외가 발생하면 makeBridge()가 호출될 것이다. 그리고 만약 3으로 정상 입력되면, 크기가 3인 Bridge가 생성될 것이다. 크기가 3인 Bridge의 행방은 어떻게 될까..? 호출만 했고, 반환은 안했기 때문에 공중분해 된다. 결국, 기존에 초기화된 bridgeSize = 0으로 bridge가 생성될 것이다.

catch문에서 return makeBridge();로 return을 해야 의도대로 동작한다.

그림으로 그려보면 다음과 같다:

(파란색이 첫번째 makeBridge의 실행 스택, 분홍색이 두번째 makeBridge의 실행 스택)

다음은 문제가 되는 지점을 수정한 결과물이다.

private Bridge makeBridge() {
    outputView.printBrideSizeOpening();
	int bridgeSize;
	try{
		    bridgeSize = inputView.readBridgeSize(inputView.userInput());
		}catch(IllegalArgumentException exception) {
		    outputView.printErrorMessage(exception.getMessage());
					return makeBridge(); 
	}
	outputView.printEmptyLine();
	return new Bridge(bridgeSize);
}

다시금 back to the basic해서 함수 호출 스택에 대해 고찰할 수 있는 좋은 기회였다.

🍝 코드는 다 작성했는데.. whyrano..

1차로 동작하는 코드를 다 작성하고 나서 컨트롤러 코드를 볼 때 기분 한장 요약:

사실 더 적절한 짤은 환하게 웃으면서 피자 여러판 들고 오는데 난장판이 된 불 타는 거실을 황망하게 바라보는 그 움짤이다 (뭔지 아시나요?)
왜냐하면 난장판이 된 컨트롤러. . . 내가 쓴 코드지만 외면하고픈 심정 . . .🧔‍♀️

문제는 뭐였을까?

초기 컨트롤러 코드의 문제점은 게임 진행 상태에 따른 로직 처리가 복잡하다는 것이다.

Q나 R의 입력에 따라 Controller 내에서 움직이도록 코드를 작성했기 때문에, 코드 동작을 따라가기 힘들었고 로직이 꼬여있었다.

사용자로부터 재시도/종료 입력을 받아서 동작하는 부분의 로직:

  • RETRY의 경우
  1. context에서 관리하는 tryCount 증가
  2. bridgeGame의 retry() 호출
  3. 컨트롤러의 crossToOtherSide() 호출
  • QUIT의 경우
  1. context quit() 호출
  2. 최종 결과 출력

그리고 Controller의 메인 코드는 다음과 같았다:

public void executeGame() {
    outputView.printOpening();
    bridgeGame = newBridgeGame(makeBridge());
    crossToOtherSide();
	if (!context.hasQuit()){
        printFinalResult();
    }
}

즉, controller에서 신경써야 하는 상황이 너무 많았다

현 상태에 따라 이 로직을 하고, 저 로직으로 건너뛰고, 이건 하지 말고.. 이렇게 구구절절 조건이 추가되는 느낌이라, 작성하고 나서 이게 맞나.. 라는 생각이 들었다.

코드로 보면 대충 난장판이 더 잘 들어올 것이다.

// 최근 move가 틀린 경우, R이나 Q를 입력받아 다음 행동을 결정하는 메소드
private void retryOrQuitIfFailed(boolean success) {
    if (!success) {
        outputView.printGameContinueOpening();
        try {
            String cmd = inputView.readGameCommand(inputView.userInput());
            decideAction(cmd); // 입력값에 따라 다음 액션 결정
        } catch (IllegalArgumentException exception) {
            outputView.printErrorMessage(exception.getMessage());
            retryOrQuitIfFailed(success);
        }

    }
}

// 입력값에 따른 다음 행동 결정
private void decideAction(String cmd) {
    if (cmd.equals(RETRY)) {
        context.increaseTryCount();
        bridgeGame.retry();
        crossToOtherSide();
    }
    if (cmd.equals(QUIT)) {
        context.quit();
        printFinalResult();
    }
}

그래 이번 주 목표가 리팩토링인 이유가 있구나!😃 빠르게 납득하고 뚱땅뚱땅 리팩토링에 들어갔다

👷 리팩토링 과정

0. 문제가 되는 상황 정의와 방향 설정하기

1. Controller의 책임 범위는?

Controller에서 게임의 진행 상황 관련해서 알고 있어야 할 게 너어어어무 많다. 중간에 그만뒀는지, 아니면 계속 하는지, 그때 retry 카운트 늘리기, 뭘 선택했는지에 따라 다른 흐름으로 직접 변환하는 등, 구구절절 신경 쓰고 챙길 게 많다.

우선적으로 게임의 진행 상황은, 게임을 담당하는 BridgeGame에서 다루는 게 자연스럽다고 생각했다. 그래서 Controller에 있던 전체 게임의 상황을 담고 있는 GameContext를 BridgeGame에서 담당하도록 필드를 옮겼다. 게임 진행 상황의 책임이 Controller에서 BridgeGame으로 넘어간 것이다.

결국, Controller는 최종적으로 InputView와 OutputView, BridgeGame을 필드로 가지게 되었다.

2. 현재 프로세스에서 핵심은?

상태값에 따른 흐름의 변화가 급격히 전환된다는 것이다.

전체 흐름을 살펴보면 사용자의 선택에 따라 기존의 프로세스로 돌아가거나, 바로 결과를 출력하는 걸 알 수 있다. 결국 여기서 뽑을 수 있는 가장 큰 두가지 상태는 : 1) 중간에 그만두지 않고 끝까지 PLAY, 2) 중간에 그만둠 이라고 정의할 수 있었다.

그렇다면 상태에 따라 전혀 다른 흐름을 가져가려면 어떻게 해야할까?
를 중점적으로 고민해봐야 할 것이다.

1. 상태 정보를 설정하도록 수정하기

GameContext 클래스 (게임의 상태와 재시도 횟수 관리)

  • 두가지 상태 상수를 두고
  • 상태를 변환시키는 함수와
  • 현재 상태를 감지할 수 있는 함수를 뒀다
private static final int QUIT = 1;
private static final int PLAYING = 2;

...

public void transition(){
    state = QUIT;
}

public boolean isPlaying() {
    return state == PLAYING;
}

Controller의 decideAction

Controller에서 Q를 입력하면 bridgeGame.quit();로 QUIT으로 상태를 설정하고, R를 입력 받으면 bridgeGame.retry();를 호출하도록 수정했다. 그리하여 Controller 내에서 복잡하게 얽혀 있던 로직을 제거하고, BridgeGame의 context에 따라 흐름이 결정되도록 BridgeGame의 책임으로 넘겼다.

private void decideAction(String cmd) {
    if (cmd.equals(RETRY)) {
        bridgeGame.retry();
    }
    if (cmd.equals(QUIT)) {
        bridgeGame.quit();
    }
}

하지만 여기서 더 개선의 여지가 있어 보인다. Controller에서 입력에 따른 상태가 어떻게 전환되는지 까지 모르게 할 순 없을까? 다음 스텝으로 넘어가보자!

2. Enum을 활용해서 상태 매핑하기

바로 코드부터 보면, 다음과 같은 Enum을 도입했다.

  • State: 두가지 상태 PLAYING, QUIT_PLAYING
public enum State {
    PLAYING(RETRY),
    QUIT_PLAYING(QUIT);

    private String cmd;

    State(String cmd) {
        this.cmd = cmd;
    }

    // 들어오는 커맨드(RETRY/QUIT)에 맞는 state return
    public State transitionTo(String cmd){
        return Arrays.stream(State.values())
                .filter(state -> state.stateEquals(cmd))
                .findAny()
                .orElse(PLAYING);
    }

    private boolean stateEquals(String cmd){
        return this.cmd.equals(cmd);
    }

}

결론부터 말하자면 외부로부터 상태를 주입받도록 설계를 수정했다.

큰 흐름을 설명하자면 다음과 같다:

Controller -> BridgeGame -> GameContext <-> State

  1. controller에서 cmd (사용자 입력, Q/R) 입력 받아 game으로 넘기기
String cmd = inputView.readGameCommand(inputView.userInput());
bridgeGame.transitionTo(cmd);
  1. BridgeGame에서 GameContext로 상태 설정 넘기기
gameContext.transition(cmd);
  1. GameContext에서 State 입력과 매핑된 상태 설정
public void transition(String cmd){
    state = state.transitionTo(cmd);
}
  1. State enum은 커맨드에 적합한 state를 찾아 반환
// 들어오는 커맨드(RETRY/QUIT)에 맞는 state return
public State transitionTo(String cmd){
    return Arrays.stream(State.values())
            .filter(state -> state.stateEquals(cmd))
            .findAny()
            .orElse(PLAYING);
}

그리고 이전에 Controller에 있던 decideAction(cmd)의 함수는 아예 삭제할 수 있었다. 그저 controller에서는 상태 정보만 넘기고 끝낸다.

결과적으로, GameController는 게임의 상태 정보에 따른 흐름을 전환시키는 책임을 상당히 덜어냈다.

🥹 4주간의 과정을 마무리 지으며..

많은 걸 배우고 얻어간 4주같다. 어쩌면 학교에서 보낸 4년보다 값진.. 읍읍

이전에 신경 쓰지 못했던 코드의 가독성과, 클래스, 메소드의 역할과 책임에 대해서 많이 고민하면서 코드를 다듬고 또 다듬었다. 또, 테스트코드의 중요성을 깨달으면서 앞으로 어떻게 활용할지 감도 잡을 수 있었다.

무엇보다 값진 건 함께 하는 것의 가치가 크게 와닿았다는 점이다.
혼자서는 헤매고 막막했을 길인데, 함께 고민하고 도와주면서 힘든 길 더 즐겁게 나아갈 수 있지 않았나 싶다.

긴 글이였는데, 따라가기 쉬웠을지 모르겠다🥲

아무래도 리팩토링 과정을 설명하다보니, 흐름을 설명하느라 길어졌다. 4주간 함께 한 프리코스 지원자 분들 모두 고생 많았다는 말씀을 드리며, 4주차 회고를 마무리 해야겠다.


📌 소스코드 && PR 링크

최종 결과물은 아래에서 확인할 수 있습니다 !

profile
잡다한 공부 기록장

2개의 댓글

comment-user-thumbnail
2022년 11월 23일

잘보고갑니다!
4주간 프리코스 고생하셨습니다! 앞으로도 화이팅입니다!🔥🔥

1개의 답글