우아한 테크코스 4주차

MINJU·2022년 11월 22일
1

이번주 미션은 다리 건너기 미션 이었다.



🖥 실행 결과 예시

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

다리의 길이를 입력해주세요.
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. 객체를 객체스럽게 설계하기

지난 주 미션 피드백과 코수타에서

객체를 객체스럽게 설계해라

는 피드백을 받을 수 있었다.

이는 객체에 접근해서 직접 확인하기보단 객체에 메세지를 보내 물어보는 방식으로 설계하라는 의미라고 말씀해주셨는데, 이는 내가 지난 주 다른 분들의 회고록을 보며 "이번 주에 꼭 적용하리라" 생각했던 부분과 어느정도 일맥상통했었기 때문에 더 기억에 남았었다.

참고했던 회고록은 바로 이 블로그이다. 해당 블로그에서 디미터 법칙과 묻지 말고 시켜라라는 법칙(?)을 알게 되었는데, 이러한 법칙을 마음 속에 새기고 미션을 진행한다면 객체간의 결합도를 더 낮게 유지 시키고 이로 인해 원활한 유지보수가 가능한 프로젝트가 완성될 수 있을 것 같아 이번주 미션 내내 해당 법칙을 생각하면서 코드를 구현하고자 노력했다.

코드에서도 그 노력이 보였으면 좋겠다 🙃



2. MVC 패턴 도입하기

MVC는 Spring Boot를 공부할 때 접한 개념인데, 이를 스프링이 아닌 그냥 자바 프로젝트에는 어떻게 적용해야하는지 개념이 잡히지 않았다. 그래서 지난 미션에서 처음으로 controller를 도입할 때도 많이 혼란스러웠던 것 같다.

그러던 중, 아래와 같은 그림을 찾아내게 됐고

최종적으로

  1. 컨트롤러모델(도메인)를 알지만
  2. 모델컨트롤러를 모른다.

는 개념을 가지고 미션에 들어갔다.

이를 기반으로 MVC의 동작 원리를 이해하고 나름의 방법대로 적용할 수 있었던 것 같아 만족스럽다 :)



3. 테스트도 관리 대상임을 인지하기

개인적으로 가장 와닿았던 피드백이었다 😮

프리코스 과정 동안 테스트 코드를 열심히 작성하긴 했지만, 테스트인만큼 로직이 중복되어도 크게 영향이 없다는 생각이 들어 코드 길이는 별로 신경을 쓰지 않고 구현했었다.

테스트코드가 아무리 길어져도 신경쓰지 않고 모든 테스트 케이스를 커버하는데만 집중했던 것이다.

그래서 테스트 코드도 관리 대상이다., 테스트 도구를 활용해서 효과적으로 짤 수 있는 방법을 고민해보라는 코치님의 말씀에 적잖은 충격을 받았었다.
내가 너무 안일하게 테스트 코드에 대해 생각했나... 라는 고민을 하게 되었고, 이러한 충격을 기반으로 이번 미션에서는 테스트 코드에 더 힘을 쏟아보고자 노력할 수 있었다.



4. 주어진 조건에 충실하기

사실 가장 집중한 부분이 아닌가싶다.

이것만 지켰어도 충분하다고 생각이 될만큼 지난주 피드백과 이번주 구현 조건은 상세하고 또 다양했다. 여기에 지난 주 피드백까지 합치면 상당한 양의 고려 요소가 나온다.

사실 위에서 말한 부분도 이미 구현 조건에 명시된 부분이다.

테스트 코드와 관련된 부분도,
MVC와 관련된 부분도 (BridgeGame에서 UI 로직을 사용하지 말라는 조건에서 확인할 수 있다!)
그리고 객체스럽게 설계하는 부분도

구현조건만 충분히 지켰어도 성공한 미션이지 않았나..라는 생각이 든다 💧

나름 지키려고 많이 노력한 것 같은데 ! 내 고민의 과정이 코드에 잘 녹아있었으면 좋겠다 ~~







🖥 프로젝트 진행

🦴 구조 설계

수도 코드

구조 설계를 위해 먼저 수도 코드를 작성했다.


main(){
	// BridgeGame 시작
	다리 건너기 게임을 시작합니다.();
    
    다리 길이 입력 받기();
    다리 생성하기();
	
    // 게임 시작
	do(1){
    	// 라운드 시작
    	do(2){
        	if(마지막 라운드면){
            	성공이라고 알려주고;
            	break;
            }
        	이동할 칸 입력 받기();
            결과 출력하기()
            라운드(=총 시도한 횟수) 높이기;
            } while(정답이면)
  		
 게임 재시작 여부 입력받기();
 }while(재시작);
       
결과 출력(실패 or 성공 여부)
}

while이 나오는 부분에서 어느정도 숫자 야구 미션 수도 코드 와 비슷한 느낌을 보이는 수도코드가 완성됐다.




이를 기반으로 작성한 초반 skeleton은 다음과 같다.

🦴 스켈레톤
- controller
  - BridgeGameController (전반적인 app 시작)
  - GameController (만들어진 다리에 대한 게임 시작)
  - RoundController (정답을 맞추는 한 라운드 시작)
- domain
  - generator
    - BridgeNumberGenerator 
    - BridgeRandomNumberGenerator
  - BridgeGame
  - BridgeMaker
  - Bridge 
  - Game
  - Round
- view
  - InputView (입력 UI)
  - OutputView (출력 UI)
- util
  - Validator (검증 로직)
  - Constants (상수)

위에서도 볼 수 있듯 domain에 어마어마한 양의(..) 클래스들이 위치하고 있음을 알 수 있다.
숫자 야구 미션과 같이 게임 시작 -> 라운드 시작 -> .. 처럼 게임 내부의 로직을 클래스로서 분리하고자 시도한 것이다.




고민 (1) : BridgeGame 클래스?

이번 미션에서는 이미 구현 된 BridgeGame 클래스를 사용해야만했다.

해당 클래스 내부에는 move()retry()가 구현되어 있었는데,
일단 돌아가는 로직을 미리 구현해놓은 나는 왜 BridgeGame 클래스에서 이동도 하고 재시도도 하는지 이해가 잘 되지 않았다ㅠㅠ

아무리 생각해도 클래스 구조를 고려하지 않고 내 방식대로 먼저 돌아가는 코드를 작성한게 문제였던 것 같다. 전반적인 로직을 짜놓은 상태에서 해당 클래스의 역할에 대해 생각하고자하니, 머리가 잘 돌아가지 않는 기분이 들었다 😶


크게 크게 생각하기 보단, 각 메소드가 해야하는 기능을 작게 작게 분리하는 것이 우선이라는 생각이 들어 일단 이동한다재시도한다의 과정에서 해야하는 일을 아래와 같이 정의했다.


이렇게 글로 쓰고 고민하는 과정에서 깨달았다.

아! 나는 domain(모델)이 해야하는 일을 컨트롤러에서 같이 해내고 있었구나.

이전 정보를 저장하고, 해당 정보를 파라미터로 넘겨주는 것이 아니라, 필요한 정보를 모두 알고 있는 BridgeGame 객체를 만들면 되겠구나!

나는 위와 같은 생각을 기반으로 이번 미션을 해결해나갈 수 있었다.


게임의 전반적인 로직을 담당하는 BridgeGameController만 남기고, 다른 필요 없는 컨트롤러는 삭제했고

BridgeGame 자체에서 "재시도"도 담당해야했으므로, 재시도 때 사용하고자 만들었던 GameRound 도메인도 삭제했다.



이게 맞는 설계인지 확신하지는 못하지만..
그래도 최소한 위와 같은 고민의 구렁텅이에서 빠져나왔음에 의미가 있다고 생각한다. (머리가 돌아가지 않는 경험은 정말이지 끔찍했다🤣)

그리고 이러한 구조 덕분에 UI와 관련된 로직BridgeGameController 한 군데에서만 사용하면 됐으므로 UI 로직을 static으로 구현할 필요도 없어졌다!

나름 의미있었다고 생각한다 :-)



고민 (2) : Enum 클래스로 구현할 로직은?

지난 주 미션에서도 참조한 해당 블로그의 글을 기반으로 어떤 로직을 Enum으로 구현해야할지 결정할 수 있었다.

A라는 상황에서 "a"와 B라는 상황에서 "a"는 똑같은 문자열 "a"지만 전혀 다른 의미입니다.


즉, 위의 다리를 건너라~ 라는 의미의 U,
그리고 아래의 다리를 건너라~는 의미의 D
다른 곳에서 사용되는 U, D와 다르기 때문에 꼭 Enum으로 구현하여 의미를 명시해야겠다는 생각이 든 것이다.
(+ input을 1, 0으로 받아서 U와 D로 변환하는 과정도 거쳐야했기 때문에 더욱더 해당 로직을 Enum으로 구현해야겠다는 생각이 들었다.)


하지만 재시작을 입력받는 R과 Q는 고민 끝에 Enum화 하지 않았다.
재시작 한다, 안한다 딱 두 가지의 경우만 존재하는 "재시작"의 경우엔 상수로 관리하는게 맞겠다는 생각이 들었기 때문이다.




고민 (3) : Enum 클래스의 위치?

사실 매 주차 고민했던 부분인데, Enum 클래스를 패키징하는 enum용 패키지를 생성할까?에 대한 고민이 있었다.

근데 ! 해당 레퍼런스를 보고 답을 얻게 되었다.

Enum도 일종의 클래스이므로, 로직적으로 관련있는 클래스와 가까이 두는 것이 좋다는 것이다!
그래서 따로 패키징 하진 않고, MovingType과 연관된 BridgeGame과 가까운 곳에 위치 시켰다.




고민 (4) : GameResult를 어떻게 관리할 것인가?

사용자의 input을 가지고 OutputView에서 출력 양식에 맞게 이동 결과를 만들어서 출력해도 됐지만,
그렇게 된다면 OutputView의 이동 결과 출력 메소드가 호출 될 때마다 새로 String을 조합해야했으므로 이는 비효율적이라는 생각이 들었다.

그래서 결과를 관리하는 BridgeGameResult를 새로 생성했다!

관련해서는 아래 구현 과정에서 더 자세하게 다뤄보고자 한다 :)



🦴 변경된 최종 구조

위의 고민을 기반으로 변경한 최종 구조는 아래와 같다!





🏃‍♀️ 구현 과정


고민 (5) : BridgeGame이 알고 있어야 하는 정보

이동하고 재시작하는데 필요한 정보를 알고 있는 BridgeGame을 만들었으므로, 이 클래스가 알고 있어야하는 정보를 구체적으로 정의하는 과정이 필요했다.


  1. BridgeGame이 재시작되어도, 기존에 생성된 실제 Bridge는 바뀌지 않기 때문에 실제 Bridge를 알고 있어야하고

  2. 이동하기 위해서는 현재 사용자가 어디까지 건너왔는지를 알고 있어야한다.

  3. 그리고 최종 결과를 도출하기 위해 지금이 몇 번째 시도인지도 알고 있어야하며

  4. 해당 게임의 진행 상황에 해당하는 결과를 알고 있어야한다.


이렇게 정의한 내용을 기반으로, 아래와 같이 BridgeGame의 필드를 구성했다.

여기서 가장 고민했던 부분이 바로 BridgeGameResult이다.



고민 (5-1) : BridgeGameResult?

해당 게임은 사용자에게 사용자의 이동 결과를 이동할 때마다 계속해서 보여줘야한다.

실제 Bridge가 UUD인데, 사용자가 첫 번째 이동으로 U, 두 번째 이동으로 D를 입력했을 경우,
각각

U
[ O ]
[   ]
D
[ O |   ]
[   | X ]

와 같이 이동 결과를 보여줘야하는 것이다.


원래는 BridgeGame이 사용자의 input인 UD를 알고 있고, 마지막 입력 이전의 값들은 이미 검증된 정답이기 때문에 마지막 입력의 정답 여부만 알고 있으면 위와 같이 구성하기 쉬울 것 같다는 생각을 했다

하지만 이렇게 사용자의 input을 넘겨주고 이동 결과를 출력하고자 한다면, 위, 아래 다리 모양을 결과 출력시마다 (OutputView에서) 매번 다시 만들어줘야하므로 비효율적이라는 생각이 들었다.


따라서, 현재까지의 이동 결과를 저장하고 있을 BridgeGameResult가 필요했다.

출력된 이동 결과 String을 계속 보관하고 있다가 그 String을 수정하는 방식으로 진행할까도 고민했는데, 이렇게 되면 UI 로직이 도메인 로직과 결합되는 것 같아서 List<String> upperBridge, List<String> downBridge를 구현했다.


사용자의 input에 맞게 이동 결과를 BridgeResult내부 uppperBridge, downBridge에 업데이트하고, 이동 결과 출력시엔, 이 List를 받아 OutputView에서 String으로 출력하도록 만든 것이다.

위와 같이 현재까지의 이동 결과를 담은 upperBridge, downBridge를 만들고, 이를 OutputView에 넘겨줘서 결과를 출력하도록 하려면 해당 필드를 넘겨주는 getter가 필수적이다. 심지어 다른 클래스에서 사용하므로 getter를 private으로 선언하기도 애매해진다.😂


따라서 지난주 미션 시에도 참고했던 해당 글의 내용을 기반으로 Unmodifiable 객체를 생성해서 넘겨주는 방식으로 구현하였다.

넘겨 준 객체는 OutputView에서 이와 같이 구현 된다.



회고록을 쓰면서 생각난건데, BridgeGameResult에서 사용자의 Input을 String으로 바꾸는 것이 아니라 True, False와 같은 bool 값으로 가지고 있다가, 이를 OutputView에 넘겨줘서 OutputView 자체에서 출력 형식에 맞게 변환하여 출력하는 것이 더 좋았을 것 같다는 생각이 든다. . 😂 다음부턴 UI 로직 분리에 더더더 신경써야할 것 같다.



고민 (6-1) : Enum의 조회

Enum을 구현한 이유 중 하나가, 랜덤으로 뽑힌 숫자 (0,1)String으로 변환(D,U) 해주는 로직이 필요하기 때문이었다.

그러면 이제 0과 1을 기반으로 D와 U로 변환해주는 로직이 필요한데, 보통 어떤 식으로 구현하는지 확인하기 위해 검색을 진행했다.

해당 글을 확인하면 Enum의 필드 값으로, 다른 필드 값을 조회하는 방법을 총 세 가지를 나열하고 있는데,
글에서도 확인할 수 있듯 미리 map을 만들어놓고 조회하는 방법으로 구현하는 것이 가장 속도가 빠른 것을 확인할 수 있다! (나머지 방법은 조회할 때마다 매번 스트림을 열게 되므로 속도 저하가 필연적으로 발생한다.)

이렇게 MovingType에 맵을 구현해놓으면

위와 같이, 숫자로 그에 해당하는 MovingType을 가져오고

BridgeMaker에서 해당 MovingType을 활용하도록 구현했다.



고민 (6-2) : Enum과 관련된 검증 로직

사용자가 이동 옵션(U,D)을 입력하면, 그 옵션이 유효한 입력인지 검증하는 로직이 필요했다.

원래는

  
    public static void validateMovingType(String input){
        String upperInput = input.toUpperCase();
        if(!upperInput.equals("U") && !upperInput.equals("D")){
            throw ..
        }
    }

이와 같은 방식으로 구현하고자 했는데, 이는 Enum을 잘 활용하지 못하는 방식이라는 생각이 들었다.

MovingType을 Enum으로 구현했기 때문에 다리가 하나 더 추가 되어서 다리가 세 개가 되더라도, 그래서 MovingType이 추가 되더라도 Enum을 활용해 더 쉽게 변경이 가능해야하는데, 위와 같은 방식으로 검증을 진행한다면 if문을 추가해서 새로운 타입에 대한 검증 조건을 만드는 과정이 불가피했기 때문이다.

따라서 해당 블로그를 기반으로, Enum 내 필드의 리스트를 만들고, 그 리스트에 포함된 값이면 "유효한 값" 아니면 "유효하지 않은 값"으로 구현하도록 로직을 변경했다.



MovingType내 Enum의 EngNotation의 리스트는

    private static final List<String> movingTypeEngNotations =
            Collections.unmodifiableList(Stream.of(MovingType.values())
                    .map(MovingType::getEngNotation)
                    .collect(Collectors.toList()));

이와 같고

이를 활용하는 방법은 아래와 같다.

    public static void validateMovingType(String input){
        String upperInput = input.toUpperCase();
        if(MovingType.isContains(input)){
            throw new IllegalArgumentException("[ERROR] 입력된 이동 옵션 값이 유효한 옵션이 아닙니다.");
        }
    }

위와 같이, Enum 클래스인 MovingType에게 이 input 값이 포함되어있나고 물어보면 (.isContains())

    public static boolean isContains(String engNotation) {
        return movingTypeEngNotations.contains(engNotation);
    }

이렇게 MovintType 내부에서 위에서 선언한 리스트를 기반으로 포함되어있는지 검증을 해주는 것이다!



Enum 리스트를 활용하기 때문에 에러 메세지도 "U나 D가 아닙니다"가 아닌 "[ERROR] 입력된 이동 옵션 값이 유효한 옵션이 아닙니다." 라고 변경하였다.
이 과정 덕분에 변경에 보다 더 유용한 코드가 만들어졌다고 나는 생각한다.. 😄....



고민 (7) : retry()의 역할

재시도를 한다는 것 자체가 어떤 역할을 해야하나?에 대해 고민했다.
내가 생각한 재시도는 이전 라운드가 틀렸을 때, -> 라운드를 새로 다시 시작한다!였는데, 이것이 move랑 어떤 부분이 제일 다른지에 대해 많이 고민했던 것 같다.

고민한 결과, retry때 필요한 부분은
이동한 사용자의 위치를 제 자리로 돌려놓기, BridgGameResult를 초기화하기, 총 시도 횟수를 증가시키기 였다.


총 시도 횟수를 증가시키기 말고는

처음 게임을 시작할 때도 필요한 로직인 것 같아서 초기 세팅에 필요한 행위들을 initSettings()에 묶어놓고, 그 다음 총 시도횟수를 증가시키는 로직으로 구현했다.


    public void retry() {
        initSettings();
        gameRoundCount++;
    }

실제로 initSettings는 아래와 같이 구현되어 있고

    private void initSettings() {
        this.userLocation = USER_LOCATION_INIT_VALUE;
        this.bridgeGameResult = new BridgeGameResult();
    }

처음 BridgeGame 생성시에도 생성자에서 다음과 같이 initSettings가 진행된다.

    public BridgeGame(Bridge bridge) {
        this.initSettings();
        this.gameRoundCount = ROUND_COUNT_INIT_VALUE;
        this.realBridge = bridge;
    }

개인적으로 조금 더 직관적인 코드가 탄생한 것 같아서 나름 만족스럽다!



고민 (8) : 더 명시적인 메인 (start) 로직

위의 retry를 구현하고 활용하고자 작성한 처음 start 로직은 아래와 같다.

     public void start(){
         outputView.printStartGuide();
         bridge  = createBridge();

         bridgeGame = new BridgeGame(bridge);

         boolean isSuccess = gameStart(bridge);
         boolean restart = getRestart(gameResult);

         while(restart){
             bridgeGame.retry();
             isSuccess = gameStart(bridge);
             restart = getRestart(gameResult);
         }
         outputView.printResult(bridgeGame.getGameRoundCount(), gameResult);
     }

근데 이 코드는

             isSuccess = gameStart(bridge);
             restart = getRestart(gameResult);

이 부분이 중복되고,
retry()가 있지만 이게 재시도라는 것이 눈에 잘 보이지 않는 것 같아ㅠㅠ 아쉬움이 남았다.


그래서 아래와 같이 로직을 분리했다!

새로운 라운드를 시작한다는 개념의 newRoundStart() 메서드를 만들고
해당 메서드 내부에서 라운드 성공 여부를 반환받고 (메서드 네이밍에서도 확인할 수 있듯, 이동 결과 출력도 진행했다), 재시작 여부를 반환 받았다.


그리고 retry()가 필요하지 않은 첫 번째 시도 때 불러오는 firstRoundStart() 메서드를 아래와 같이 구현해, BridgeGame을 초기화하고 새로운 라운드를 시작하도록 구현했고

    private void firstRoundStart() {
        bridgeGame = new BridgeGame(bridge);
        newRoundStart();
    }

그 이후 재시작시에 불러오는 restartRoundStart()을 아래와 같이 구현했다.

    private void restartRoundStart() {
        bridgeGame.retry();
        newRoundStart();
    }

이렇게 로직을 분리해서 가장 main이 되는 BridgeGameController의 start() 로직이 더 간단하고 직관적으로 구현되었다!

<public void start() {
        outputView.printStartGuide();
        bridge = createBridge();

        firstRoundStart();

        while (restart) {
            restartRoundStart();
        }

        outputView.printResult(bridgeGame, isSuccess);
    }

나름 직관적인 것 같아서 약간 마음에 든다......... ㅎㅎ.. 너무 로직이 분리되었나 싶기도하지만..



고민 (9) : BridgeNumberGenerator 생성자 주입

BridgeNumberGenerator가 구현되어 있는 만큼 이를 적절히 활용할 수 있는 코드를 만들고 싶었다.

그래서 이를 생성자 주입으로 초기화 할 수 있도록 구현해서 자유롭게 클라이언트가? 선택할 수 있도록 만들었다.

 public static void main(String[] args) {
        BridgeGameController bridgeGameController
                = new BridgeGameController(new BridgeRandomNumberGenerator());

        try {
            bridgeGameController.start();
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }

위의 코드에서도 볼 수 있듯, 게임 메인 로직인 BridgeGameController를 생성할 때 생성자 파라미터로서 BridgeNumberGenerator가 들어감을 확인할 수 있다!

이렇게 된다면 게임마다 각각 다른 Generator 전략을 사용할 수 있게 된다 :)



고민 (10) : 움직일 수 있는지 검증하는 로직 단순화

    private boolean getRoundResult(Bridge bridge) {
        boolean movingResult;

        do{
				...
                
        } while(movingResult && bridgeGame.getUserLocation() < bridge.getSize()-1);

        return movingResult;
    }

사용자가 움직일 수 있는지 확인하는 로직 bridgeGame.getUserLocation() < bridge.getSize()-1
이 너무 복잡하다는 생각이 들었다.
그리고 BridgeGame내에 userLocation도 있지만 실제 다리를 의미하는 realBridge도 있기 때문에, 이를 활용하는 로직으로 변경하는 것이 맞다는 생각이 들어 아래와 같이 코드를 구현하였다. BridgeGame 내에 사용자가 움직일 수 있는지 판단하는 로직을 추가한 것이다.


public class BridgeGame {
    Bridge realBridge; // 실제 다리
    private int userLocation; // 현재 사용자가 어디 서있는지
	...
    

    public boolean isUserCanMove(){
        return userLocation < realBridge.getSize() -1;
    }
}

근데 Result에서 해결하기 보단, realBridge에게 직접 물어보는 것이 더 나을 것 같다!는 생각에 코드를 아래와 같이 한 번 더 변경했다.

public class Bridge {
    List<String> bridge;
	
    ...

   public boolean canMove(int bridgeIndex){
        return bridgeIndex < bridge.size() - 1;
   }

위와 같이 Bridge 클래스 내부에 canMove() 메소드를 만들어서, 해당 인덱스가 아직 움직일 수 있는 인덱스인지 물어볼 수 있도록 만들었고


    public boolean isUserCanMove() {
        return realBridge.canMove(userLocation);
    }

BridgeGame 내부의 코드를 위와 같이 변경했다 :)



고민 (11) : 잘못된 입력시 재시도 로직

잘못된 입력시 예외를 발생시키고 그 부분부터 재입력을 받으라는 요구조건이 있었다!
해당 로직을 어디에 적용하는 것이 가장 좋을지에 대한 고민이 있었는데,

직접적으로 입력을 받는 부분인 InputView 보단, 해당 로직을 사용하고 있는 BridgeGameController에서 사용하는 것이 보다 더 적합하다는 생각이 들었다.


따라서 아래와 같이 로직을 변경하였다.

우선 Bridge 길이를 입력 받는 부분을 예시로 들어보면

기존에는 이렇게 input을 바로 받았던 것을

    private Bridge createBridge(){
        outputView.printInputBridgeLengthGuide();
        int bridgeLength = inputView.readBridgeLength();
        
        return New Bridge(bridgeMaker.makeBridge(bridgeLength));

readBridgeLength()라는 새로운 메소드를 만들어 입력 받는 메소드를 만들었고

    private Bridge createBridge(){
        outputView.printInputBridgeLengthGuide();
        int bridgeLength = readBridgeLength();
        
        return New Bridge(bridgeMaker.makeBridge(bridgeLength));

해당 메소드를 아래와 같이 구현했다.

private int readBridgeLength(){
	for(int tryCnt = 1; tryCnt <= MAX_TRY_FOR_READ_BRIDGE_LENGTH; tryCnt ++ ){
    	try{
        	return inputView.readBridgeLength();
          }
		catch(IllegalArgumentException e){
        	callRetryGuide(tryCnt, MAX_TRY_FOR_READ_BRIDGE_LENGTH, e);
            }
            }
            throw new IllegalArugumentException(OVER_MAX_TRY_ERROR);
            }

여기서 언급할 만한 부분은 아래와 같다.


우선, 최대 시도 횟수를 지정했다. 해당 링크를 보면 무한 루프를 방지하기 위해, try-catch-retry 로직 구현시 최대 시도 횟수를 명시하고 있음을 알 수 있다.

😶 내가 생각했을 땐! 이 부분이 더 합리적인 것 같아서 처음엔 최대 시도 횟수를 지정하는 방식으로 구현했었다.

그리고 사용자의 입력이 필요한 다리 길이 입력, 이동 옵션 입력, 재시도 여부 입력 각각 최대 시도 횟수를 다르게 지정할 수도 있을 것 같아, 각각의 최대 시도 횟수를 매직넘버로 만들어 관리하도록 만들었고,



에러 로그가 출력될 때

💻 다리 길이 입력 :
🙂 25 
💻 [ERROR] 다리 길이는 3 ~ 20 사이여야합니다.
💻 [ERROR] 다시 입력해주십시오 (최대 20번의 기회 제공, 현재 1번 시도)

...

🙂 25
💻 [ERROR] 다리 길이는 3 ~ 20 사이여야합니다.
💻 [ERROR] 최대 입력 횟수 초과


와 같은 방식으로 출력되길 원해서 callRetryGuide() 함수를 만들고 이에 맞는 로직을 OutputView에 구현했었다. PR링크

    private String readRestartOption() {
        for (int tryCnt = 1; tryCnt <= MAX_TRY_OF_READ_RESTART_OPTION; tryCnt++) {
            try {
                return inputView.readRestartOption();
            } catch (IllegalArgumentException e) {
                callRetryGuide(tryCnt, MAX_TRY_OF_READ_RESTART_OPTION, e);
            }
        }
        throw new IllegalArgumentException(EXCEED_THE_NUMBER_OF_TRY);
    }

하지만 결국 마지막 제출 시, 예상치 못한 오류 발생 을 해결하기 위해 조건에 명시되지 않은 로직을 제거하는 과정에서 해당 로직은 삭제하게 되었다! (이것이 정확한 오류의 원인은 아니었지만)


명시되지 않은 부분이기 때문에, 지금 생각해보면 최대 시도 횟수 로직 제거는 정말 잘 한 일이라는 생각이 드는데, 사용자의 편의를 생각하고 사람들이 자주 쓰는 방식을 검색해서 그 이유를 찾아보고 합당한 이유라고 판단해 직접 프로젝트에 적용해 본 경험은 그 자체로 나름 의미있다는 생각이 들어 작성하게 되었다ㅎㅎ



고민 (12) : 사용자 자유도를 위한 결정

무한 루프에 빠지지 않기 위한 재시도 횟수 제한도 이 카테고리에 포함되는 내용이다!

위의 고민 말고도, 사용자 자유도를 위해 결정했다가 예상치 못한 오류 해결을 위해 마지막에 제거한 로직이 바로 사용자 입력의 대소문자 구분과 관련된 내용이었다.


해당 프로젝트에선 위의 다리를 건넌다 = U, 아래 다리를 건넌다 = D, 재시도한다 = R, 재시도하지 않고 나간다 = Q 와 같이, 영어 대문자 하나로 구성된 명령어를 사용하여 게임을 진행한다.


그래서 로직을 구현할 때, 사용자가 대문자가 아닌 소문자 d를 입력했을 때도 예외처리를 해야하나..? 는 고민이 들었다.

명령어 자체가 재시도하려면 R, 나가려면 Q를 입력해주세요와 같은 내용이었으므로 그 이후에 입력되는 소문자 r,q 또한 비슷한 의미일 것이 당연하고 이것도 예외처리를 한다면 사용자의 불편함이 증가할 것이라 생각했기 때문이다.



그래서 처음 로직은

    public static void validateRestartOption(String input){
        String upperInput = input.toUpperCase();
        if(!upperInput.equals("Q") && !upperInput.equals("R")){
            throw new IllegalArgumentException("[ERROR] 입력된 재시작 옵션 값이 유효한 옵션이 아닙니다.");
        }
    }

요렇게 구성했었다!


입력이 들어오면 -> 우선 그 입력이 영어인지 확인하고 -> 위에 작성한 validateRestartOption 함수에서 이것이 R, r, Q, q인지 upperCase() 를 활용하여 검증하는 로직을 구현한 것이다.

나름의 이유를 가지고 구현한 것이기에 그대로 유지하려고했지만 이도 마찬가지로 마지막 오류를 확인할 때 제거하게 되었다. 지금 생각해보면 삭제한 것이 더 잘한 것 같긴하지만 ㅎㅎ 사용자를 생각하고, 구체적인 이유를 갖고 구현해 본 과정이었으므로 기록하는 것이 더 좋을 것 같아서 기록으로 남기게 되었다😄



🧾 테스트 코드

노력 (1) : @ParameterizedTest 적용

테스트 코드를 보다 더 최적화하기 위해, 지난 주 미션 피드백에서 볼 수 있었던 @ParameterizedTest를 적용하기로 했다.

입력값만 바뀌거나(@ValueSource) 하나의 입력에 따른 출력 값만 바뀌는(@CsvSource) 테스트 코드를 모두 해당 어노테이션을 활용하는 테스트 코드로 변경하였다!

따라서 이와 같이 중복 코드가 많았던 테스트 코드가

이렇게 단순하게! 바뀔 수 있었다
:)



회고를 작성하면서 List가 input으로 들어가는 경우엔 Stream<Arugument>를 활용하면 된다는 사실을 해당 글을 통해 알게 되었다. 추후에 해당 방법을 사용하는 방식으로 테스트 코드를 수정하는 과정을 시도해봐야겠다.🏃‍♀️



노력 (1-1) : @ParameterizedTest의 네이밍

근데 해당 어노테이션을 도입하니

이와 같이 명시적으로 테스트코드 네이밍이 되지 않았다.

해당 글을 통해 @ParameterizedTest의 네이밍에 대해 알게 되었고
따라서 아래와 같이! 중복 코드를 줄이면서도 명시적으로 네이밍을 할 수 있게 되었다 :)

테스트코드를 효율적으로 작성하는 방법을 습득했음에 의미가 있었다 :)



노력 (2) : 모든 기능을 테스트할 수 있도록 노력

사실 조금 부가적인 부분이고 직관적으로 내가 어떻게 노력을 했는지 보여줄 수 없는 부분이지만, 나름 많이 신경쓴 부분이라 꼭 작성하고 싶었다!

지난 주까지는 테스트코드 자체에 신경을 많이는 쏟지 못했다면, 이번 주 미션에는 피드백에서도 강조되었던 만큼 작은 기능들을 테스트코드로 다 커버하는데 나름 힘을 많이 쏟았다.

이 과정에서 가장 마음에 새기고 있었던 부분이 바로 피드백에서 말씀해주셨던 단순히 가시성을 위해 분리한 메소드가 아니라면, 그 기능을 하는 객체를 하나 더 만들어서 테스트하는 것이 맞을 수 있다. 라는 느낌의 말이었다.

그래서 가시성을 위한 것이 아닌, 특별한 기능을 하는 로직들은 다 테스트 할 수 있도록 구현하고자 노력했다.
private 메소드들의 역할을 하나하나 따져보며 이 메소드가 가시성 이상의 역할을 하고 있는지 판단하는 과정을 거친 것이다.

열심히 했다고는 자신할 수 있는데, 코드를 읽는 다른 분들에게도 와닿으실지는 잘 모르겠다 💧 계속해서 연습이 필요할 것 같다. 소스 링크




🤦‍♀️ 에러 상황

에러 (1) : @TestInstance(Lifecycle.PER_CLASS)

테스트 코드를 수정하면서 테스트 코드 내부에 @BeforeAll @BeforeEach를 사용하게 되었다.

아래 코드처럼, 사용자가 이동을 하고 (birdgeGameResult.update)
이동을 한 결과에 해당하는 "상위 다리 상태", "하위 다리 상태"를 각각의 테스트 코드로 뽑아내기 위해서는 해당 어노테이션이 필수적이었기 때문이다.

 		@Nested
        @DisplayName("사용자가 U 이동 옵션을 선택했는데 정답인 경우")
        class U {

            @BeforeAll()
            void moveU() {
                bridgeGameResult.update("U", true);
            }

            @Test
            @DisplayName("상위 다리의 상태는 [O]")
            void 상위() {
                //given
                // when
                List<String> result = bridgeGameResult.getUpperBridge();
                // then
                assertThat(result).isEqualTo(List.of("O"));
            }

            @Test
            @DisplayName("하위 다리의 상태는 [ ]")
            void 하위() {
                //given
                // when
                List<String> result = bridgeGameResult.getDownBridge();
                // then
                assertThat(result).isEqualTo(List.of(" "));
            }

하지만 맨 처음에 코드를 돌렸을 땐

이와 같이 에러가 발생했었다.


해당 에러로그를 기반으로 검색을 해보니 해당 글해당 글을 발견하게 되었다.

간략히 말하자면, JUnit은 테스트 인스턴스 생성 기본 단위가 메소드라서, non-static 메소드에서 @BeforeAll을 활용하려면 @BeforeAll 메소드가 포함된 테스트 클래스 위에 @TestInstance(Lifecycle.PER_CLASS)를 선언하여 테스트 인스턴스의 생성 단위를 클래스로 변경해야한다는 것이었다!

간단하게 생각했던 테스트 코드도 보다 효율적으로 작성하기 위해선 더 많은 공부가 필요하구나..깨달을 수 있었던 시간이었다. 세상엔 정말 공부할게 많은 것 같다 🤧



에러 (2) : 예기치 못한 오류로 인해 실행에 실패하였습니다.


마지막 최종 제출시에 이와 같이... 오류가 터져서 정말 내 멘탈도 같이 터지는 것만 같았다 왜냐면 아 이제 회고록 써야겠다~는 마음으로 제출을 한 시점이었기 때문에 😂

제시 된 구현 조건을 잘 읽었다고 생각했는데, 이동 조건 관련해서는 안일하게 생각했던 것 같다.
사전에 미리 알고 대비했으면 좋았을 텐데, 그래도 이러한 식겁한(?) 상황을 기반으로 구현 조건 숙지에 더욱 더 주의를 기울이게 된 것 같다




👏 마치며

너무너무 길었던 회고록이 드디어 끝났다.
미션이 복잡하고 어려웠던 만큼 기록하고 싶은 부분이 많았던 이번 미션이었던 것 같다.

하지만 여전히 아쉬움은 존재한다ㅠㅠ 내가 느끼는 이번 미션의 아쉬운 점은 다음과 같다.

👀 부족했던 부분


1. 여전히 어려운 깔끔한 커밋 로그

2. 설계 구조 변경 노하우 부족

초기 스켈레톤을 작성하고 -> 구현할 때 까지만 해도 커밋로그를 깔끔하게(?) 유지할 수 있었다.

근데, 이제 위에서 작성했던 것 처럼 BridgeGame을 활용하는 방안으로 코드를 수정하다보니 전반적인 skeleton을 다 변경하게 되었고

이를 해내는 과정에서 솔직히 정신이 하나도 없었어서ㅠㅠ 그 과정을 잘 커밋하지 못했었다ㅠㅠ

(내가 구현하고 있는 패턴을 유지할 것이라는 확신이 들지 않았고, 일단 구현하고 보자!!! 라는 마음이 있었기 때문이다ㅠㅠㅠ 구조 변경과정에서는 "기능 단위"까지는 생각하지 못한 것이다.)

결국 전반적인 skeleton을 다 변경하고 난 뒤, 내가 생각하는 기능 단위 별로 커밋을 하고자 했는데
순서가 잘못됐던 것인지 충돌이 나서, 그 충돌을 해결해야만했고

그 과정이 꼬여서 결국

이렇게 기능 단위 커밋이라고는 보기 어려운 코드가 완성되었다.


이는 노하우 부족이라고밖에 할 수 없을 것 같다ㅠㅠ
원활한 유지 보수를 위해 커밋 로그를 관리하는 것인데 이렇게 되면 의미가 너무 없지 않나..라는 생각이 든다.

깃 커밋에 대해서는 4주 내내 아쉬움이 남았기 때문에 이후의 시간 동안에는 이 과정에 대해 조금 더 연습해봐야할 것 같다 😂



3. 전략 패턴 도입의 아쉬움

BridgeRandomNumberGenerator가 기본적으로 제공되었었다.
정확하지는 않지만 이는 나름의 '전략패턴'이라는 생각이 들었고, 이번 미션에서도 내가 이 전략이 필요한 부분에서 이것을 적용하면 좋겠다는 생각이 들었다.

하지만 명확히 어떤 로직에서 전략 패턴을 도입해야하는지 확신이 서지 않았고 따라서 이번 미션에서는 해당 부분을 구현하지 못했다. (BrigeMaker를 생성자 주입 받도록 하여 있는 것을 활용하고자 노력하긴했다..)

각 기능에 맞는 적절한 전략 패턴을 설계하고 적용해보는 것 또한 매우 좋은 경험일 것 같다는 생각이 들었다 !

그래서 다음에는 그런 부분을 도입해보고자 노력해보고 싶다



🎉 소감

최종 제출을 한지 이틀이나 지났지만, 프리코스가 끝났다는게 아직도 믿겨지지 않는다 !!!!

평소의 나는 좀 틀리는 것을 무서워하고 그래서 피드백 과정을 매우 두려워하는 편이었는데,

온보딩과 그 다음 미션까지만해도 피드백에 내가 틀린 부분이 있으면 속상해하고 우울해했던 내가 이번주차 피드백에서는 내가 적용하지 못한 부분(테스트코드)에 대한 피드백을 보며 또 새로운 것을 배울 수 있겠구나 적용해 볼 수 있겠구나 기대하는 모습을 보며 내가 그래도 정말 많이 성장했구나를 느낄 수 있었다 :)


코수타에서 사람은 "머리를 쥐어짜내며 고민하는 과정에서 성장한다"는 말씀을 해주셨는데,
(지난 주 미션에 힘을 쏟은 만큼 이번 주 미션은 수월하게 하겠다....생각했지만 ..) 이번주 미션도 머리를 잔뜩 쥐어짜내며 구현해낸 내 모습을 생각해보면 나는 정말 프리코스 4주간 계속해서 성장해나갔던 것 같다. 🐳

아직도 아쉬운 부분은 정말 많다

구조를 설계하고 이를 구현하고 그에 맞게 리팩토링하고 그 과정을 예쁘게 커밋 히스토리로 남기는 것은 정말 기초적인 일이지만 정말 너무 어렵다

이러한 아쉬운 부분을 ! 본 과정에서 더 배워나갔으면 좋겠다 ㅠㅠ🤍

프리코스가 우테코에 대한 내 맘에 더 불을 지른 것 같아 끝나는게 아쉽기도 하고 무섭기도하면서 뭐 복잡미묘한 마음이 드는 것 같다.

그래도 이렇게 내 열정과 노력을 글로 남겼으니, 이 열정을 끊임없이 꺼내보며 자극받고 이 경험을 기반으로 더 고민하고 고민하는 개발자로서 성장해나가고 싶다.


최선을 다해 노력하고, 부족한 부분은 인정하고 빠르게 개선해나가는 것이 내 비약적인 성장의 지름길임을 알게 되었으니!
터득한 태도를 기반으로 더 노력해보고자한다.


다들 정말 수고 많으셨다는 말씀을 드리며!!
이번 회고록도 마무리

아자아자 🏃‍♀️🍀🍀

다 같이 잘됩시다🍀🍀

0개의 댓글