나의 독학은

[프리코스 2주차] 자동차 경주 게임 회고 본문

회고/우아한테크코스 6기 프리코스 회고

[프리코스 2주차] 자동차 경주 게임 회고

안종혁 2023. 11. 1. 19:07

😊 2주차 목표

  • 요구사항에 충실하고, 2주차 목표인 함수 분리하기와 함수별로 테스트 작성에 익숙해지기!
  • 1주차 공통 피드백 적용하기!
  • 코드를 한 줄 한 줄, 고민하며 프로그램을 구현하기!
  • 오류를 무서워하지 말고 성장의 과정이라 믿기!
  • 천천히 그리고 꼼꼼히 문서 읽기!

😊 자동차 경주 게임 구현

☆ 코드의 전문입니다.

✅ 좋은 프로그램이란?

2주차는 요구사항 마다 "~할 수 있다." 로 끝나서 참 애매했다.

 

어떻게 예외처리를 해야할지 씻을 때도, 자기 전에도, 구현 도중에도 진짜 계속 고민했다. 

그 과정에서 Console.readLine() 내부 안을 들어가 보기도 했고, escape 문자는 출력과 다르게 그대로 입력이 되는지 알게 되고, BigDecimal와 BigInteger 자료형의 함수들도 찾고, 중국어와 일본어도 포함해야 하나? 란 생각에 String문자열의 입력 범위를 알아봐서 String타입은 유니코드의 문자를 받을 수 있는 것도 알게 되었다.

 

그러다, 출력문의 요구사항 중 하나인 "-" 를 통해 감을 잡기 시작했다.

만약, int형 자료범위인 약 20억을 넘어가는 것까지 입력을 받을 수 있게 입력을 처리한다면 사용자의 콘솔에는 20억번마다 20억개의 "-"를 볼 수도 있다는 것이다.

 

예를 들어, 

pobi : -----------------------------------------------------------------------...

pobi : -----------------------------------------------------------------------..------...

pobi : -----------------------------------------------------------------------... ...-----.. 이렇게 계속 도배될 것이다.

 

이런 출력문이 반복되는 것은 사용자가 자동차 경주 게임의 진행 상황을 인지하기 어려워질 것이다.

그렇기 때문에 "이런 출력문을 사용자가 반복적으로 보는 것이 과연 도움이 되는가?"를 생각할 수 있었다.

 

프로그래머는 비즈니스 문제를 코드로 해결해서 사회(사용자)에 이바지 하는 사람이라 생각한다.

 

나의 역할(프로그래머)은 사용자들이 자동차 경주 게임을 즐길 수 있도록 해야지, 사용자가 엣지케이스는 뭐가 있지? 하며 자동차 경주 게임을 즐기는 데에 혼란을 야기하게 게임을 만들어야 하는 것은 아니라고 생각했다.

 

그렇게 요구사항의 기준을 "사용자가 자동차 경주 게임을 직관적으로 바라봤을 때, 즐길 수 있어야 한다."로 세웠다.

 

먼저, "자동차 경주 게임" 란 이름에 걸맞게 단어들을 정의했다.

"자동차" → 자동차는 이름을 가져야 한다

"경주"  → 자동차끼리 겨뤄서 우승자를 가려내야 한다.

 

마침내, 예외사항을 만들어갔다.

 

1. 자동차에 이름을 부여할 수 있다

  • 자동차는 이름은 1~5사이여야 한다
  • 이름은 최소 2개 이상 입력 받기 → 이름이 0개나 1개면 "경주"란 의미가 없음
  • 입력에 숫자와 특수기호를 포함하게 할 것인가?
    • 1번 : 입력에 제한두지 않기
      • car! 란 단어도 이름이 되는 대신 !@#$%도 이름이 될 수 있다. 또한, 입력에 제한을 두지 않으면 String이 유니코드의 모든 문자를 지원한다 하더라도 얘기치 못한 오류가 생겨 프로그램에 버그가 생길 수 있다고 생각한다. 이로 인해 버그가 생겨 사용자 컴퓨터에게 악영햘을 끼칠 수 도 있는 가능성 생각하기
    • 2번 : 이름에 제한 두기
      • 사용자는 "이름은 쉼표(,) 기준으로 구분" 이란 게임 시작 문구 때문에 "특수 기호도 포함 되는구나" 라고 생각할 것이다. 그러나, 특수기호를 포함한 이름을 정의하면 사용자는 오류를 맞이한다. car! 라고 입력하면 오류 메시지로 [영문자,한글로만 입력해 주세요] 를 표현하고, 사용자에게 "뭐야, 미리 말을 해 주던가" 라는 반응을 얻을 수 있다. 
        • GPT를 이용해 영문자와 한글로만 입력을 받는 정규식을 알게 되었다. 정규식 학습!
    • 결론 : 1번이 사용자를 위한 프로그램이라 생각해서 영문자,한글로만 이름을 입력할 수 있게 해놨다. 다만, 기획자와 소통한다면 변경될 수 있기 때문에 함수를 잘 분리해놓기! (프리코스에서는 기획자는 우테코라 아쉽게도 소통할수 없다..)
  • 끝이 쉼표(,) 로 끝나면 예외처리! 
  • 이름이 중복되면 예외처리! → 우승자를 출력할 때 헷갈리기 때문

2. 사용자는 자동차의 이동할 횟수를 입력할 수 있어야 한다

  • 이동 횟수를 int로 제한해서 숫자가 아니면 예외처리! 
  • int 중에서 0과 음수면 예외처리 (0이면 경주란 뜻에 위반되기 때문)
  • 예외처리 메시지를 "1이상의 자연수로 입력하세요"로 한다면 50억도 1이상 자연수이기 때문에 조건에 부합하지만 int형의 범위를 벗어나서 오류를 일으키고, 이는 사용자에겐 혼란을 야기할 수 있다 생각함. 그래서, 20억 이상의 입력 이상의 숫자를 입력하면 "20억 이하의 숫자를 입력하세요"란 오류 메시지 출력.(자동차 2대가 30번만 움직여도 20억의 경우의 수가 생기기 때문에 확률과 경우의 수를 다 계산해서 입력받을 수를 제한할까 생각도 했다..ㅎ)
  • 결론 : 사용자가 1~20억 사이의 숫자를 입력받게 하자

그렇게 예외사항을 만들었다.

consistOfEnglishKoreanComma(input); // 입력은 영문자,한글,쉼표만 가능
endWithsNotComma(input);            // 입력의 끝은 쉼표가 올 수 없다.
checkCarNameLength(carNames);       // 자동차 이름은 1~5자이다.
checkCarNameCounting(carNames);     // 자동차 이름은 최소 2개여야 한다.
checkCarNameDuplicated(carNames);   // 자동차 이름은 중복되면 안된다
validateMovingNumber                // 이동횟수로 숫자만 올 수 있다.
checkMinMovingNumber(movingNumber); // 경주에 알맞게 최소 이동횟수는 1번이다.
checkMaxMovingNumber(movingNumber); // int형 범위는 넘어가지 않도록 최대 이동횟수는 20억이다.

 

그리고, 예외사항이 많은 만큼 사용자에게 혼동을 주지 않기 위해 정확한 오류 메시지가 출력될 수 있도록 노력했다.

결국 "자동차 경주 게임의 목적", "사용자의 입장", "요구사항의 변경", "시스템의 안정성"을 종합적으로 충분히 고려하고자 했다.

이 경험을 통해 좋은 프로그램이기 위한 최소 조건은 "다양한 시각에서 바라보기" 라고 조심스레 생각해본다. 

 

1주차에는 이러한 시각들로 요구사항을 바라보지 못한게 너무 아쉽다. (이렇게까지 고민을 하니 1주차에 놓친 예외사항들이 있는 것도 알게됨..ㅠ)

그래도 이제 프리코스 과제를 한다고 생각하지 않고, 작은 프로그램이지만 사용자를 위해 좋은 프로그램을 만들고자 노력하기! 를 생각하며 프리코스에 임하겠다.😁

✅ 테스트 코드 작성을 위한 AssertJ 학습

일단 테스트 코드 작성하는 것이 익숙해 지고자 AssertJ 메서드를 학습했다.

 

학습하는 과정에서 공식문서를 이용하려고 했지만 메서드이름 자체를 몰라서 메서드를 찾는데 어려움이 많았다.

물론, 공식문서가 다 영어로 되어있어서 한참을 구경하고 헤맸다..ㅎ

 

더 이상 시간을 지체할 수 없어서 3가지 방법으로 학습했다.

1. 프로그램에 내장되어 있는 [External Libraries]의 assertJ 패키지를 이용

2. 인텔리제이에서 .을 찍으면 나오는 자동완성기능으로 하나씩 살펴보기

3. 구글링하기

 

*공식문서로 모르는 메서드를 어떻게 찾는지 아시는분 댓글 부탁드림니다..

✅ 기능마다 커밋하기, 테스트 코드 작성을 위한 나만의 루틴 

구현하면서  기능마다 커밋하고, 테스트 코드 작성을 처음하기 때문에 나만의 루틴을 먼저 세웠다.

나의 루틴은 1주차에 느낀대로 " 구현 → 커밋 → 테스트 코드 → 커밋"  순으로 코드를 작성하고자 했다.

 

그러나, 테스트 코드가 실패하거나 테스트 코드를 짜는데 어려움이 있을 때면, 커밋했던 구현 로직을 수정하고, 다시 커밋해야했다.

이로 인해 커밋 순서도 엉키고, 추가 커밋에 시간이 많이 소요되서 2주차를 진행하면서 순서를 바꿨다.

"구현 → 테스트 코드 → 커밋 → 커밋" 이다. 이 순서로 앞으로 3주차에 임해보겠다!

✅ public 은 직접 테스트, private 은 간접 테스트 

테스트 코드를 짜다보면 public 메서드와 private 메서드들을 모두 테스트 해야 할까? 라는 고민이 생기는데 쉽게 결론을 내렸다.

나는 public 메서드가 객체에게 부여된 책임을 처리하는 것이고, private 메서드는 public 메서드의 로직에 도움을 주는 메서드라 생각한다.

즉, private 메서드는 public 메서드를 위해 존재한다고 생각한다.

 

그래서, 나는 public 메서드만을 직접 테스트 했다.

private 키워드를 쓰는 이유는 캡슐화하기 위함인데, 테스트 코드를 통해 노출시키는 것은 아니라 생각한다.

 

그러나, private 함수를 테스트해야 할 상황이 온다.

아래처럼 예외처리가 잘 되었는지 테스트 할 때가 private 테스트를 해야 할 상황이다.

 

나는 private을 간접적으로 테스트 했다.

한 개의 public메서드 안에 서로 다른 예외 처리를 하는 3개의 private메서드가 있다. 

public void validateCarNames(List<String> carNames) {
    checkCarNameLength(carNames);     // private함수
    checkCarNameCounting(carNames);   // private함수
    checkCarNameDuplicated(carNames); // private함수
}

public메서드만 테스트한다면 이 3개 중의 어떤 함수에서 예외 처리를 했는지 알 수 없기 때문에 나는 예외 처리 메시지를 함께 테스트 코드에 붙여주었다.

public 메서드인 validateCarNames 를 직접 호출하고, private 메서드들은 호출하지 않고 hasMessage 로만 확인하여 간접 테스트를 진행했다.

@Test
void validateCarNames_자동차이름이_1자부터_5자인지_확인1() {
    List<String> invalidList = Arrays.asList("pobi", "", "안종혁");

    assertThatThrownBy(() -> validator.validateCarNames(invalidList))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(CAR_NAME_LENGTH_ERROR);
}
    
@Test
void validateCarNames_자동차이름이_2개이상인지_확인1() {
    List<String> invalidList = Arrays.asList("pobi");

    assertThatThrownBy(() -> validator.validateCarNames(invalidList))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(CAR_NAME_COUNT_ERROR);
}
    
@Test
void validateCarNames_자동차이름이_중복인지_확인1() {
    List<String> invalidList = Arrays.asList("pobi", "pobi", "종혁");

    assertThatThrownBy(() -> validator.validateCarNames(invalidList))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(CAR_NAME_DUPLICATE_ERROR);
}

 

이렇게 hasMessage를 이용하면 private 을 public 으로 고치지 않고도 private를 간접적으로 테스트 할 수 있었다.

✅ 2주차 목표 : 테스트 코드와 함수 분리

아래는 2주차 목표인 테스트 코드와 함수 분리를 진행하며 했던 생각들이다.

 

[테스트 코드의 장점]

public 메서드의 내부를 분리하면서 "옳게 함수를 분리했는지?", "객체의 값이 전달되고 있는지?"란 두려움이 있었다.

그러나, public 메서드의 테스트 코드를 작성해 놓고 함수분리를 할 때마다 테스트 코드를 "딸깍" 해서 초록불이 들어오는 것만 확인하면 함수분리가 잘 진행되고 있는지를 파악할 수 있었다.

테스트 코드는 구현한 로직의 검증뿐만 아니라 함수분리 같은 리팩토링 하는데에도 중요하단 것을 알 수 있었다.

 

[테스트 하기 어렵다면 함수 분리를 해보자]

입력된 String 타입의 자동차이름이 List 로 변환되는 함수이다.

그러나, 이 함수는 입력값을 받고, 나누고, List에 추가하고, 반환하는 기능을 갖고 있다.

 

이 함수의 어떤 기능을 테스트 할 건지 고민을 많이 했었다.

입력값을 들어왔는지? 입력값이 잘 나눠 지는지? List에 잘 추가 되는지? 반환이 잘 되는지?

추가로, 매개변수가 없어서 테스트 코드의 예상값과 기대값조차 작성하기 어려웠다. 

public List<String> inputCarNames() {
    System.out.println("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)");
    String input = Console.readLine();
    String[] carNameArray = input.split(",");
    for (String carName : carNameArray) {
        carNames.add(carName);
    }
    return carNames;
}

 

함수 분리를 통해 코드가 깔끔해졌다. 내부 로직에 맞게 메서드명을 바꿨다.(inputCarNames → createCarNames)

이제 이 함수는 사용자의 input값을 받아서 List로 변환되는지를 확인 하면 된다.

더욱이, 테스트 코드의 예상값과 기대값을 비교하고자 한다면 "isEqualTo"만을 사용해서 테스트를 작성할 수 있었다.

public List<String> createCarNames(String input) {
    System.out.println("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)");
    String[] inputCarNames = split(input);
    return toList(inputCarNames);
}

[함수분리를 잘하면 코드 읽기가 쉬워진다.]

함수분리를 하기 전에는 메서드 이름과 다르게 내부에서 많은 일들이 일어나고 있었다. 즉, 함수의 이름만 봐서는 함수의 기능을 알 수 없었다.

예를 들어, inputCarNames() 는 입력값 받고, 나누고, List 로 변환하는 것과 같이 모든 로직을 읽어야만 함수의 기능을 알 수가 있었고, 이는 메서드 이름과도 어울리지 않았다.

그러나, 함수분리를 진행하자 메서드의 이름만 보고, 내부를 읽지 않고 return값만 봐도 메서드가 무슨 기능인지 쉽게 눈에 들어왔다.

특히, 함수분리가 잘되자 코드가 영어 지문을 읽는 것처럼 잘 읽혔다.

가독성이 엄청 향상됨을 느꼈고, private 은 선택적으로 읽는 메서드란 것을 알게 되었다.

이는 코드 리뷰를 받을 때 다른 사람들에게 도움이 될 것이고, 나도 사람들의 코드들 중 public 만 읽음으로써 흐름을 잡을 수 있을 것이다!!

✅ push한 커밋 되돌리기

기능마다 커밋하고, 테스트 코드 작성에 익숙해지기 위해 2주차 과제를 2번 구현했었다.

 

첫 번째 구현했을 때 기능마다 커밋하는 것이 익숙치 않아서 내 맘대로 커밋을 하곤 했다.

뒤죽박죽으로 보냈던 커밋을 되돌리기 위해, 포크를 새로하고 깃을 클론하기 보다는 새로운 git 명령어인 reset 을 학습했다.

 

reset 은 애초에 커밋을 하지 않은 상태로 만들어주는 특징이 있다.

그래서, reset 명령어는 히스토리를 고쳐쓴다는 점 때문에 다른 사람이 사용하는 remote 브랜치에서는 사용하면 안된다.

reset 명령어는 프리코스 과제와 같이 나 혼자 작업할 때만 사용하기!

 

reset 명령어가 잘 작동하는 지 알기 위해 지금껏 커밋한 기록을 보여주는 명령어인 git log 를 사용하였다.

  • git log --oneline : 지금까지 커밋한 내용을 보여주는 git 명령어

git log 에 의해 최근 커밋한 순으로 feat와 docs가 나오는 모습

git log --oneline 을 통해 feat 와 docs로 커밋이 되어 있음을 확인했다.

 

reset 을 통해 커밋을 삭제해 보자. 

  • git reset HEAD~n : HEAD 기준으로 n개 만큼 되돌리기

HEAD가 있는 커밋에서 1개 삭제하기

그리고, git log --oneline을 통해 커밋 로그를 확인해서 커밋이 잘 지워져 있는지 확인까지!

feat: 자동차가 이동하는 결과를 출력하기 란 커밋이 지워졌다.

 

하지만, 원격 저장소인 깃 허브에는 삭제 커밋이 반영되지 않았다.

원격 저장소에도 똑같이 반영하기 위해 다음과 같은 git 명령어를 사용하였다.

  • git push -f origin [브랜치이름] : 원격 저장소에 특정 브랜치의 변경 내용을 강제로(push --force) 푸시(push)하는 명령어

jonghyeok-97 브랜치의 변경 내용을 원격 저장소에 강제로 푸시하는 명령어

✅ 아쉬움

[객체지향는 만만하지 않았다]

정말 .. 객체지향은 만만하지 않았다.. 1주차에 깨달음을 얻고 2주차에 잘 적용하자 다짐했지만 어림도 없었다..

 

다음은 내 코드이다.

Game객체가 User객체에게 입력값을 입력하라고 메시지를 보내고 얻은 값을 List 에 보관하고 있다.

public class Game {
    Private User user;
    
    public void start() {
        List<String> carNames = user.InputCarNames();
    }
}

 

...

User 객체의 상태인 private List<String>이 Game객체에 드러났다... 상태를 private 으로 설정만 한다고 캡슐화가 지켜지는 것은 아니었다.

언제든 carNames 는 변경될 위험이 있었다. 그래서, 어떻게 하면 캡슐화를 지킬 수 있을지 많은 고민을 했다.

 

이 중, ImmutableCollections(불변 컬렉션)을 알게 되었다.

 

불변 컬렉션이란 컬렉션의 내부의 데이터를 수정(add, remove, sort ...등)을 하면 UnsupportedOperationException 예외가 일어나게 하는 컬렉션이다.

 static abstract class AbstractImmutableList<E> extends AbstractImmutableCollection<E>
            implements List<E>, RandomAccess {

        // all mutating methods throw UnsupportedOperationException
        @Override public void    add(int index, E element) { throw uoe(); }
        @Override public boolean addAll(int index, Collection<? extends E> c) { throw uoe(); }
        @Override public E       remove(int index) { throw uoe(); }
        @Override public void    replaceAll(UnaryOperator<E> operator) { throw uoe(); }
        @Override public E       set(int index, E element) { throw uoe(); }
        @Override public void    sort(Comparator<? super E> c) { throw uoe(); }
...

그래서, 나의 carNames 의 변경을 막기 위해서 불변 컬렉션으로 설정할 수 있었다.

하지만, 이 불변 컬렉션의 사용 용도는 값을 조회하기 위해 상태를 객체에서 꺼내야 할 때!! 주로 사용한다고 한다.

내가 carNames 란 상태를 API에 드러낸 이유는 값을 전달하기 위해서였기 때문에 내 문제에 불변 컬렉션을 사용하는 것은 단지 임시방편이라 생각해서 사용하지 않았다.

 

근본적인 원인을 찾고 싶었다.

 

그러나,, 많은 시간을 시간을 투자했지만 결국 해결하지 못해서 너무 아쉬웠다.

 

User 가 입력을 받을 책임과 Car를 이동시킬 책임이 있는지, 아니면 나처럼 값을 전달하는데 특정 방식이 따로 있는 건지 고민하며 객체 설계를 잘하기 위해 꾸준히 고민해야겠다.

😊마치며

2주차 중간에는 코드에 진전이 없는 것 같아 스스로에게 실망도 했었지만, 기록을 통해 꽤 많이 노력한걸 알 수 있었다😁😁😁

삽질을 한만큼 다음 번에는 삽질을 안할 테니까!!

 

  • 정규식학습
  • AssertJ함수 학습, 공식문서에서 함수 찾기
  • 함수분리, 테스트 코드 작성
  • 1주차 공동 피드백 학습
  • 불변컬렉션
  • 요구사항을 대하는 자세

다만 2주차가 끝나고는 구현한 기능마다 커밋하고, 테스트 코드 작성과 함수분리에 익숙해지고 싶었지만 이루지 못해 아쉽다.

 

오늘도 3주차에 의식적으로 코드를 작성하기 위해 다짐을 해본다.

"왜 이렇게 코드를 작성하는 거야?" 를 의식적으로 자문하기

 

마지막으로 테스트 코드에 대해 학습하며 읽었던 책 중 인상 깊었던 말을 적으며 2주차도 끝!

코드를 다 작성하고 나서 테스트에 대해 생각하면 안된다.
코드를 작성하면서 '어떻게 테스트를 할 것인가?'를 자문하기
p. 23 中 좋은 코드, 나쁜 코드