[우아한테크코스 4기] 프리코스 2주차 회고 - 자동차 경주 게임

알린의 개발노트·2021년 12월 7일
2
post-thumbnail

우아한테크코스 4기 프리코스 2주차 미션 자동차 경주 게임을 진행하면서 고민했던 것을 기록으로 남깁니다.


목차


미션 돌아보기

우아한테크코스 - 자동차 경주 게임

2주차 미션은 자동차 경주 게임이다. 사용자가 자동차의 이름과 시도 횟수를 입력하면 경주 결과와 최종 우승자를 출력하는 간단한 경주 게임이다.

미션에서 지켜야 하는 요구사항

이번 미션도 지켜야 할 요구사항은 기능 요구사항, 프로그래밍 요구사항, 과제 진행 요구사항 세 가지로 구성되어있었다.

기능 요구사항

  • 주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다.

  • 각 자동차에 이름을 부여할 수 있다. 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다.

  • 자동차 이름은 쉼표(,)를 기준으로 구분하며 이름은 5자 이하만 가능하다.

  • 사용자는 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다.

  • 전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상일 경우이다.

  • 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한 명 이상일 수 있다.

  • 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다.

  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, [ERROR]로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

  • 아래의 프로그래밍 실행 결과 예시와 동일하게 입력과 출력이 이루어져야 한다.

  • 입/출력 예시

    경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)
    pobi,woni,jun
    시도할 회수는 몇회인가요?
    5

    실행 결과
    pobi : -
    woni :
    jun : -

    pobi : --
    woni : -
    jun : --

    pobi : ---
    woni : --
    jun : ---

    pobi : ----
    woni : ---
    jun : ----

    pobi : -----
    woni : ----
    jun : -----

    최종 우승자 : pobi, jun

프로그래밍 요구사항

  • 프로그램을 실행하는 시작점은 Applicationmain()이다.
  • JDK 8 버전에서 실행 가능해야 한다. JDK 8에서 정상 동작하지 않을 경우 0점 처리한다.
  • 자바 코드 컨벤션을 지키면서 프로그래밍한다.
  • indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다.
    • 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
    • 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메소드)를 분리하면 된다.
  • 3항 연산자를 쓰지 않는다.
  • 함수(또는 메소드)가 한 가지 일만 하도록 최대한 작게 만들어라.
  • 프로그래밍 요구사항에서 별도로 변경 불가 안내가 없는 경우 파일 수정과 패키지 이동을 자유롭게 할 수 있다.

추가된 프로그래밍 요구사항

  • 함수(또는 메소드)의 길이가 15라인을 넘어가지 않도록 구현한다.
    • 함수(또는 메소드)가 한 가지 일만 잘 하도록 구현한다.
  • else 예약어를 쓰지 않는다.
    • 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다.
    • else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다.

프로그래밍 요구사항 - Car 객체

  • 다음 Car 객체를 활용해 구현해야 한다.
  • Car 기본 생성자를 추가할 수 없다.
  • name, position 변수의 접근 제어자인 private을 변경할 수 없다.
  • 가능하면 setPosition(int position) 메소드를 추가하지 않고 구현한다.
public class Car {
    private final String name;
    private int position = 0;

    public Car(String name) {
        this.name = name;
    }

    // 추가 기능 구현
}

프로그래밍 요구사항 - Randoms, Console

  • JDK에서 기본 제공하는 Random, Scanner API 대신 camp.nextstep.edu.missionutils에서 제공하는 Randoms, Console API를 활용해 구현해야 한다.
    • Random 값 추출은 camp.nextstep.edu.missionutils.RandomspickNumberInRange()를 활용한다.
    • 사용자가 입력하는 값은 camp.nextstep.edu.missionutils.ConsolereadLine()을 활용한다.
  • 프로그램 구현을 완료했을 때 src/test/java 디렉터리의 ApplicationTest에 있는 모든 테스트 케이스가 성공해야 한다. 테스트가 실패할 경우 0점 처리한다.

과제 진행 요구사항

  • 기능을 구현하기 전 /docs/README.md 파일에 구현할 기능 목록을 정리해 추가한다.
  • Git의 커밋 단위는 README.md 파일에 정리한 기능 목록 단위로 추가한다.
    AngularJS Commit Message Conventions를 지켜 커밋 로그를 남긴다.

이전 미션보다 프로그래밍 요구사항이 더 많아졌다. 특히 특정 클래스를 사용하여 구현한다는 것은 객체 활용이 많아진다는 의미 같았다. 다른 추가 요구사항도 적용하기 어렵지는 않았다. 하나의 메소드의 기능을 줄일수록 코드가 짧아졌기 때문에 대부분 15라인 이하로 구현이 되었고, else를 사용하지 않고 바로바로 리턴하는 방식은 저번 미션부터 연습을 해봤기 때문에 어렵지는 않았다.


내가 구현한 방식

내가 구현한 자동차 경주 게임

미션 시작과 함께 받은 메일에서 1주차 미션의 목표가 메소드를 분리하는 것이라고 한 것을 봤다. 1주차 미션에서 indent depth를 2 이하로 제한하고 메소드의 기능을 최대한 줄이라는 것이 힌트였나? 하고 생각했다. 이번 미션의 가장 목표는 클래스를 분리하며 개발하기다. 그렇다면 클래스를 분리하는 것은 무엇일까...? 라는 생각으로 미션을 시작한 것 같다.

최종 구현 결과 클래스

racingcar
├ controller
│  └ RacingCarGameController
├ domain
│  ├ Car
│  ├ CarName
│  ├ CarNames
│  ├ Cars
│  └ RacingRound
├ enums
│  └ DomainConditions
├ utils
│  └ CustomNsTest
├ view
│  ├ InputView
│  └ OutputView
└ Application

각 클래스에서 하는 일

  • Application
    RacingCarGameController 객체를 통해 프로그램을 시작
  • RacingCarGameController
    입/출력 기능 조작
    경주 게임 설정 및 실행
  • InputView
    입력 받기
  • OutputView
    출력문 출력
  • Car
    자동차의 이동/위치 비교 기능
  • Cars
    List<Car> 에 대한 일급 컬렉션
    여러 대의 자동차를 조작
    우승자 찾기
  • CarName
    자동차 이름의 유효성을 검증하는 클래스
  • CarNames
    여러대의 자동차 이름 중 중복을 확인하는 클래스
  • RacingRound
    경주 시도 횟수의 유효성을 검증하는 클래스
  • DomainConditions
    자동차 이름의 최대 길이, 최소 랜덤 값, 최대 랜덤 값 등 조건과 관련된 Enum 클래스
  • CustomNsTest
    NsTest에서 직접 입력값을 보내는 command 기능을 추가한 util 클래스

이번 미션은 최대한 목표인 클래스 분리에 신경 쓰며 구현했다. 이번 미션은 여러번 초기화를 하면서 새로 구현했었다. 같은 것을 구현하면서 점점 더 코드가 나아졌지만 그만큼 부족한 점도 많다고 느껴서 많이 최종 테스트까지 많이 준비해야 할 것 같다.


진행하며 한 고민과 느낀점

❗ 잘못된 정보가 혹시라도 있다면 알려주세요 !!

이번 미션도 기능 요구사항만을 만족하도록 구현한다면 난이도는 크게 어렵지 않았다고 생각했다. 중요한 것은 이번 미션을 빨리 끝내는 것이 아닌 어느 것을 배우고 느껴서 최종 테스트에서 좋은 결과를 낼 것인가라고 생각했다. 이번 미션은 구현을 완료한 뒤 초기화하고 다시 구현하는 과정을 3번 반복하여 구현했다. 단순하게 구현하는 게 아닌 클래스를 분리하여 개발하는 것에 대해 이해하고, 평소에 해보고 싶었던 것들을 이 미션으로 실험해보고 싶었기 때문이다. 이번 미션에서 평소에 해보지 못한 것들을 시도해보는 데 집중했던 것 같다.

클래스를 분리하자

클래스를 분리한다는 것이 뭘까? 말 그대로 클래스를 나눈다는 말이겠지만 처음 들었을 때 감이 오지는 않았다. 이번 미션에서 제시한 요구사항에서도 직접적인 힌트는 없어 보였다. 클래스의 분리에 대해 검색하고 공부하며 내가 찾은 키워드는 "단일 책임 원칙", "일급 컬렉션" 두 가지다.

객체지향 설계 5원칙 - 단일 책임 원칙

객체지향의 설계 방법의 수많은 글에서 늘 보던 하나의 클래스는 하나의 책임을 가진다는 그 단일 책임 원칙이다. 즉 책임에 따라 클래스를 분리하면 될 것이다. 말은 쉽고 간단하지만 실제로는 그렇지 않았었다. 공부하면서 내가 정리한 단일 책임 원칙은 다음과 같다.

  • 하나의 클래스는 하나의 책임을 가진다.
  • 코드가 변경되야 할 때 왜 변경되는지가 다르면 다른 책임으로 볼 수 있다.
  • 코드가 변경될 때 영향을 받으면 같은 책임으로 볼 수 있다.
  • 단일 책임 원칙은 책임을 완전히 캡슐화 해야한다.
  • 하나의 클래스는 같은 책임의 코드들이 묶여있으라는 의미이다.

난 이 부분을 이전 미션인 숫자 야구 게임을 구현하면서 했던 고민을 통해 이해할 수 있었다.

1

서로 다른 클래스 C1과 C2에서 Valid의 메소드 V를 통해 검증을 하는 상황에서 C1에 의해 V가 수정되야 한다면 C2에 영향을 미칠 수 있다. 그렇기 때문에 유효성 검증하는 클래스를 삭제하고 두 개의 클래스에서 따로따로 검증하는 메소드를 만들 것이라고 했었다. 여기서 C1에 새로 만든 V1이라는 메소드는 서로 같은 책임이고, C2에 새로 만든 V2라는 메소드는 같은 책임을 가질것이다. 사실 이게 상황에 따라 틀릴 수도 있겠지만 어느 정도는 변경으로 영향을 받는 것들을 같은 책임으로 본다고 이해했다.

일급 컬렉션

일급 컬렉션은 기억보다 기록을 을 참고했습니다. 감사합니다.

일급 컬렉션은 컬렉션 변수를 Wrapping 하면서 다른 멤버 변수가 없는 클래스를 의미한다. 일급 컬렉션은 다음과 같은 장점이 있다고 한다.

  1. 비지니스에 종속적인 자료구조
  2. Collection의 불변성을 보장
  3. 상태와 행위를 한 곳에서 관리
  4. 이름이 있는 컬렉션

네 가지 모두가 일급 컬렉션을 사용해야 할 이유이다. 여기서 비지니스에 종속적인 자료구조는 클래스를 분리하여 개발하는 이번 미션를 위해 필요한 장점이다. 모든 비즈니스 로직이 일급 컬렉션 클래스 안에서 이루어진다는 말이다. 이 말이 처음엔 조금 어려웠는데, 한 클래스에 해당 콜렉션을 사용하는 메소드를 모아놨다고 생각하니 조금 이해하기 쉬웠다. 일단 일급 컬렉션 사용 에시를 보자.

이번 미션에서 자동차는 여러 대 설정할 수 있다. 그리고 이 자동차들을 이용해 경주를 해야 하고, 우승자를 찾아내야 한다. 그렇다면 예를 들어 다음과 같이 구현할 수 있을 것이다.

class Game {
// ...
    public void play() {
        String[] names = readLine().split(",");
        List<Car> cars = new ArrayList<>();
        for (String name : names) {
            cars.add(new Car(name));
        }
        racing(cars);
        List<Car> winners = findWinners(cars);
    }

    private void racing(List<Car> cars) {
        // 경주하는 기능
    }
    
    private List<Car> findWinners(List<Car> cars) {
        // 우승자를 찾는 기능
  }
// ...
}

여기서 List<Car> cars를 일급 컬렉션으로 만든다면 다음처럼 만들 수 있다.

class Game {
// ...
    public void play() {
        String[] names = readLine().split(",");
        Cars cars = new Cars(names);

        cars.racing();
        List<Car> winners = cars.findWinners();
    }
// ...
}

class Cars {
    private final List<Car> cars = new ArrayList<>();

    public Cars(String[] names) {
        for (String name : names) {
            cars.add(new Car(name));
        }
    }

    public void racing() {
        // 경주하는 기능
    }

    public List<Car> findWinners() {
        // 우승자를 찾는 기능
    }
}

그럼 이렇게 되는 것이 왜 장점인지 알아야 한다. 비즈니스에 종속적인 자료구조라는 관점에서 내가 생각한 일급 컬렉션의 장점은 다음과 같다.

  • 유지 보수를 하기 좋을 것 같다.
  • 가독성이 좋아진다.
  • 단일 책임 원칙을 만족한다.

일급 컬렉션에서 모든 로직이 있다 보니 다른 사람이 코드를 보거나 수정할 일이 있어도 변경할 부분을 더 빠르게 찾고 더 명확하게 변경할 수 있다. 또 일급 컬렉션을 사용하는 상위 클래스의 코드가 더 깔끔해진다. 그리고 컬렉션이 해야 하는 일을 일급 컬렉션으로 분리되기 때문에 책임이 나뉘어 단일 책임 원칙을 만족할 수 있다. 즉 깔끔하고 이해하기 쉬운 객체지향적 코드가 된다! 고 생각한다.

VO(Value Object)

나는 이번 미션에서 자동차를 모아둔 일급 컬렉션 Cars를 만들었는데 결국 List<Car>가 사용하는 메소드는 너가 다 가져가서 알아서 처리해! 하고 클래스를 만들어준 것이었다. 여기서 내가 든 생각은 이게 꼭 컬렉션이어야 하나? 그냥 저렇게 책임이 여러 개면 다 분리하면 안 되나? 라는 것이었다. 자동차 이름과 시도 횟수가 그 예시다. 그 이전에는 입력받는 클래스에서 이름과 시도 횟수의 유효성을 검증하는 메소드가 있었다. 아래와 같이 한 클래스에서 여러 책임을 가지고 있는 경우의 상황이다.

class InputView {
// 상수

// 생성자

// 입력에 관련된 메소드

...

자동차_이름을_받는_메소드() {
    String input = readLine();
    String[] names = input.split(",");
    자동차_이름을_검증하는_메소드(names);
}

자동차_이름을_검증하는_메소드(String[] names) {
    // 검증
}

시도_횟수를_받는_메소드() {...}

시도_횟수를_검증하는_메소드() {...}

...
}

여기서 InputView는 입력받는 역할만 하면 될 것이다. 입력값을 검증하는 것은 InputView에 있을 필요가 없다. InputView는 입력만 받고, 자동차 이름은 (CarName, CarNames) 객체를 만들어 검증만 하고 돌려주고, 시도 횟수도 RacingRound에서 검증만 하고 돌려주는 방식으로 구현한다면 클래스를 분리할 수 있을 것 같았다.

class InputView {
...

자동차_이름을_받는_메소드() {
    String input = readLine();
    String[] names = input.split(",");
    names = new CarNames(names).get();
}

시도_횟수를_받는_메소드() {...}

...
}

class CarNames {
    private String[] carNames;

    public CarNames(String[] carNames) {
        validate(carNames);
        this.carNames = carNames;
    }

    public String[] get() {
        return carNames;
    }

    private void validate(String[] carNames) {
        // 검증
    }
}

class CarName {...}

class RacingRound {...}

이것은 단순히 클래스를 분리한 것이 아닌 각자의 역할을 나누어 분리했다는 게 의미가 있는 것 같다. 지금은 간단한 이름, 시도 횟수를 검증하는 기능만 있지만 이후 기능이 추가되는 등 다른 기능이 필요할 때 수정이 더 쉬울 것이다.

객체지향적인 코드

이번 미션에서는 setter를 쓰지 말라는 요구사항이 있다. 내가 대학생 때 java를 배울 때 캡슐화를 위해서 우리는 getter와 setter를 써야 한다고 배웠다. 내부 속성을 알거나 직접 접근하지 못하게 하기 위함이다. setter를 쓰지 말라는 요구사항을 보자마자 그럼 getter는 써도 되나? 라는 생각을 했다. getter와 setter도 결국 그 값을 그대로 넣어주고 받아오는 것이기 때문이다. 찾다 보니 getter도 되도록 사용하지 말라는 것을 알게 되었다. 그럼 은닉화와 캡슐화를 지키면서도 내가 원하는 작업을 하려면 어떻게 해야 할까?

캡슐화에 대해 공부하면서 묻지말고 시켜라라는 말을 봤고, 이 모든 궁금증을 해결해 주었다. 처음에는 조금 어려웠다. 나는 getter로 값을 받아와서 메소드에 파라미터로 보내고 원하는 정보를 받아온 뒤 setter로 저장하는 방식에 익숙해져 있었기 때문이다. 그러나 이런 방식은 객체지향이 아닌 절차지향에 가깝다고 한다. 객체지향에서는 객체의 상태를 묻지 않고 시킨다고 한다. 예를 들면 이런 것이다. 자동차라는 객체가 있다. 이 자동차가 앞으로 한 칸 이동하고 싶다면 방법은 두 가지다.

  • getter로 현재 위치를 받아온 뒤 1을 더하여 setter를 이용해 저장한다.
  • 그냥 앞으로 가라는 명령어를 실행한다.

지금 앞에 장애물이 막고 있는지, 연료가 있는지, 엔진이 고장났는지 그건 내가 모르겠고 그냥 넌 앞으로 가! 문제있으면 니가 예외 처리 해! 이게 객체지향적인 방식이라는 것이다. 그렇기 때문에 메소드의 이름을 통해 이 작업의 의도를 나타내는 것이 중요한 것이었다. 이것을 이번 미션에서 최대한 적용하면서 구현해보려 노력했었다. 그런데 의외로 적용이 어렵지는 않았던것 같다. 나도 모르게 이런식으로 구현한 적도 있었던것 같고 무엇보다 코드가 깔끔해지다 보니 자연스럽게 적용하게 되었다.

1주차 과제를 하면서 아직도 객체지향적인 코드가 뭔지 막연하다고 했었던게 기억난다. 그러나 지금은 적어도 그때보다는 더 나아진 것 같다. 객체지향이 뭔지에 대해 검색하면 가장 먼저 나오는 것들이 객체지향의 설계 5원칙, 객체지향의 4가지 특징과 같은 것들이 나온다. 이 개념은 객체지향언어를 공부한 사람들이라면 대부분이 알고 있을 것이다. 나도 이런 것들을 들어봤고 알고 있지만, 이해하고 적용하지 못하고 있던 것 같다. 이런 것들을 일기 위해 이런 미션을 진행해보는 것은 너무나 큰 경험인 것 같다. 내가 숫자 야구 게임에서 검증 클래스를 만들어보지 않았다면, 일급 컬렉션의 존재를 몰랐다면 나는 아직도 책임에 대해서 몰랐을 것이고 하나의 클래스에 여러 메소드를 넣어두고 있을 것이다. 이런식으로 이론이 아닌 실제로 무언가 해보는 것이 큰 도움이 된다는 점에서 현장 중심의 교육이 얼마나 중요할지 기대도 된다.

TDD를 적용해보자

TDD(Test Driven Development)는 테스트 주도 개발이라고도 한다. TDD라는 것은 이전에 토이 프로젝트를 하면서 처음 알게 되었다. 처음 테스트 코드를 보고 내가 든 생각은 이제 더 이상 코드를 바꿀 때마다 확인을 위해 System.out.println()를 찍어보지 않아도 되는것인가??? 와 이거 대박이다! 라는 생각을 했었다. 그러나 실제 적용이 참 어려웠다. 구현 초기 단계에서 요구사항이 계속 변하다 보니 그때마다 테스트 코드를 함께 변경해줬어야 했다. 또 시간이 추가로 걸리다보니 결국 구현이 다 마무리된 후 테스트 코드를 작성하게 되었고 내가 작성한 코드에 테스트를 맞추려는 것 같은 느낌을 받기도 했기 때문이다. 이전 프로젝트에서 테스트 코드를 작성하기 어려운 가장 큰 원인은 요구사항과 구조가 자꾸 변했기 때문이다.

이번 미션은 요구사항과 기능이 명확하기 때문에 TDD를 실습해보기 쉬울 것 같다고 생각했다. 그리고 적용을 해보면서 역시 쉽지는 않다고 느꼈다. 개발 속도가 느려진다는 것이 가장 불편한 점이었다. 그리고 테스트 코드를 작성하는것을 자주 잊기도 했다. 하지만 기능이 잘 동작하는지 확인할 수 있다는 점, 내가 변경한 코드가 시스템 전체에 의도하지 않은 영향을 주는지 확인할 수 있다는 점은 좋다고 느꼈다. 짧게 보면 개발 속도가 느려지는 것이 너무 불편하고 손해 같다고 생각한다. 하지만 더 길게 본다면 코드가 쌓여갈수록 장점이 더 커지지 않을까 생각한다. TDD는 앞으로도 더 적용해보면서 공부해봐야 할 것 같다.

모든 과정을 노트북으로 진행하자

나는 대부분의 개발을 데스크톱을 이용했다. 두 개의 모니터를 사용하는 게 너무 익숙했기 때문이다. 하지만 우아한테크코스에 합격하게 된다면 매일 노트북을 사용하게 될 것이다. 당장 최종 테스트도 오프라인으로 본다면 노트북을 사용해야 한다. 그래서 노트북과 친해지기 위해 이번 미션은 모든 과정을 노트북으로 진행했다.

노트북을 사용하면서 가장 큰 불편함은 마우스가 없다는 것이었다. 터치패드가 있지만 수년간 사용한 마우스보다 불편했다. 하지만 오히려 이 불편함 때문에 수많은 단축키를 사용하게 되었다. 단축키는 개발 속도를 향상시켜줬고 익숙해지고 나니 마우스를 쓰는 것 보다 편해졌다. 특히 IntelliJ에서는 적어도 이번 미션을 진행하는 동안 마우스가 없이도 모든 작업을 할 수 있었다. 다음은 IntelliJ에서 내가 가장 유용하게 사용했던 단축키들이다. 진짜 너무 편하다. (윈도우 기준이다.)

  • Ctrl + ↑, ↓ : 코드에서 커서를 그대로 두고 위아래로 스크롤하는 것
  • Alt + ↑, ↓ : 메소드 단위로 커서를 이동시키는 것
  • Alt + →, ← : 작업중인 탭을 좌우로 이동하는것
  • Alt + 1 : 프로젝트 탐색창 올리기/내리기
  • Alt + 4 : 실행창 올리기/내리기
  • Alt + 9 : git log 창 올리기/내리기
  • Alt + 0 : git commit 창 올리기/내리기
  • Alt + F12 : 콘솔창 올리기/내리기
  • Ctrl + Alt + s : 설정창 켜기
  • Ctrl + Alt + z : 코드 수정사항 취소하기
  • Ctrl + F2 : 프로그램 종료

진짜 하나같이 너무 소중한 단축키들이다. 지금은 오히려 하나의 화면으로 개발에만 집중할 수 있어서 노트북이 더 편해지는 것 같다. 합격과 별개로 앞으로 노트북을 많이 사용하게 될 텐데 잘 맞는 것 같아서 다행이다.

구현은 기능 위주로 쉽고 간단하게, 기록은 꼼꼼하고 자세하게

1주차 미션이 끝난 뒤 받은 공통 피드백에서 처음부터 완벽하게 기능 목록을 정리하고 클래스, 메소드 등을 설계하지 말고 README.md를 활용하여 구현해야 할 기능을 정리하는데 집중하라는 것이 가장 기억에 남는다. 왜냐하면 이것은 평소의 나의 코딩 스타일과 정반대였기 때문이다. 이렇게 구현하기 위해 평소와 전혀 다른 방식으로 구현을 시도했다. 평소에 나는 코드를 작성하기 전 전체적인 구조를 그리고 클래스나 메소드를 간단하게 정리한 뒤 구현을 시작했었다. 하지만 이번 과제에서는 구현할 기능만을 정리하고 구현을 시작했다. 생각해보면 계획을 완벽하게 세워도 어차피 구현하면서 수정된다. 오히려 기능만을 정리하고 그것에 집중해 구현하니 더 빨랐다. 또 커밋 단위를 기능 단위로 하라는 요구사항이 있었는데 어떤 기준에서 커밋을 할지가 명확해지다보니 이것을 만족하기에도 좋았다. 큰 것을 한 번에 구현하려 하기보다 작은 것부터 구현해나가는 것이 더 쉽고 간단하게 구현할 수 있는 방법이라고 느꼈다.

그리고 난 원래 구현한 것에 대해 기록을 잘 하지 않았다. 구현하면서 느낀점이나 새로 알게 된 것들은 노션에 메모하면서 남기지만 내가 구현한 코드에 대해 기록하지 않는다는 의미이다. 늘 혼자 개발하다보니 필요성을 느끼지 못했었다. 하지만 다른 사람들과 함께 일하게 된다면 기록은 중요하다고 생각하게 되었다. 나는 기록을 하기위해 README.md에 프로젝트에 대해 나름대로 상세하게 기록했다. 어떤 프로젝트이고 어떻게 동작하고 구현 환경, 요구사항 등을 정리했다. 하지만 코드에 대한 주석은 추가하지 않았다. 코드에서는 주석을 통한 소통보다 최대한 이름으로 의도를 나타내기 위해 노력했다.

그 외 새로 해본 것 간단 정리

마크다운에서 체크박스의 존재

미션을 하다보면 README.md에서 기능을 구현해나가면서 구현한 기능을 체크를 해야 한다. 처음엔 완성된 기능 앞이나 뒤에 ✔️를 추가했었다. 하지만 통일된 느낌이 없는 게 아쉬웠었다. 찾아보니 마크다운에서 체크박스가 있었다. - [ ]를 하면 빈 체크박스가 되고 - [x]를 하면 찬 체크박스가 된다. 이것은 github에서도 많이 사용되기 때문에 README.md에서도 많이 사용된다고 한다. 그리고 PR 메시지에 체크박스를 적어서 보냈더니 다음처럼 tasks done이라고 나오면서 체크박스가 적용되기도 했다. 잘 활용하면 앞으로 유용하게 사용할 수 있을 것 같다.

4

Enum 적용

이전 미션에서 상수 클래스를 만든 지원자들이 많았었다. 나는 상수를 모아둔 클래스를 만들 필요가 없다고 생각해서 만들지 않았다. 이럴경우 private 접근자를 사용하기 때문에 외부에서 사용하지 못한다. 여기서 한가지 문제가 발생했다.

private static final String LONG_LENGTH_ERROR_MESSAGE = "[ERROR] 5자 이하로 입력해주세요.";

위 상수는 자동차 이름의 길이가 6자 이상이 되면 출력하는 상수이다. 매직넘버를 사용하지 않기 위해 상수를 썼는데 상수에 매직 넘버가 있다. 이 경우 아래와 같이 수정을 할 수 있다. 이번 경우는 문제가 발생하지 않지만 MAX_CAR_NAME_LENGTH를 다른 곳에서 사용한다면 그곳에 또 상수를 생성해야 한다.

private static final int MAX_CAR_NAME_LENGTH = 5;
private static final String LONG_LENGTH_ERROR_MESSAGE = "[ERROR] "
	+ MAX_CAR_NAME_LENGTH
	+ "자 이하로 입력해주세요.";

물론 책임을 잘 분리해서 클래스를 나누고 사용하면 문제가 없을 수도 있다. 하지만 하나의 상수를 여러 곳에서 사용하는 경우가 있을 것이다. 예를들어 지금의 코드에서 문제가 발생했을 때 에러 코드의 시작을 [ERROR]가 아닌 [WARNING]으로 바꾼다면 모든 곳의 상수에서 값을 바꿔줘야 한다. 이럴 경우 [WARNING]을 한곳에서 가지고 있고 모든 에러 메시지에서 그 값을 가져와서 사용하면 문제를 해결할 수 있다. 이럴 때 Enum을 사용할 수 있을 것 같았다. 실제 코드에서는 여러가지 조건에 대한 부분을 Enum으로 만들어 사용해보았다.

public enum DomainConditions {
	MAX_CAR_NAME_LENGTH(5),
	EMPTY_CAR_NAME_LENGTH(0),
	EMPTY_RACING_ROUND(0),
	RANDOM_START_NUMBER(0),
	RANDOM_END_NUMBER(9),
	MOVING_POINT_NUMBER(4);

	private final int value;

	DomainConditions(int value) {
		this.value = value;
	}

	public int get() {
		return value;
	}
}
////
private static final int MAX_CAR_NAME_LENGTH = DomainConditions.MAX_CAR_NAME_LENGTH.get();
private static final String LONG_LENGTH_ERROR_MESSAGE = "[ERROR] "
	+ MAX_CAR_NAME_LENGTH
	+ "자 이하로 입력해주세요.";
...
private void validateRange(String name) {
		int nameLength = name.length();
		if (nameLength > MAX_CAR_NAME_LENGTH) {
			throw new IllegalArgumentException(LONG_LENGTH_ERROR_MESSAGE);
		}
	}

그 결과 이런 식으로 코드를 수정하였다. 이 코드를 예로들면 현재 자동차의 최대는 5이지만 최대 10자의 자동차 이름을 받고 싶다면 Enum의 값만 수정해주면 된다. 자동으로 유효성 검증 메소드와 에러 메시지가 수정된다. Enum을 처음 사용해봤는데 분명 좋아 보이고 편해 보이지만 아직은 좀 어설프게 적용해본 것 같아서 앞으로도 연습을 해봐야 할 것 같다.

다양한 람다식

람다식은 익명 함수를 말하는 것이라고 한다. 쉽게 함수지만 내가 따로 메소드를 만들어 사용하는 것이 아닌 그자리에서 슥 만들어서 쓰는 것이다. 나는 원래 람다식을 잘 쓰지 않았다. 알고리즘 문제를 풀다보면 람다식으로 문제를 풀면 속도가 더 느려지기 때문에 안좋은 인식이 있었기 때문이다. 람다식은 실제로 stream을 사용하면 느리다는 단점이 있긴 하지만 코드가 간결해지고 개발자의 의도를 나타내기 쉽다는 장점이 있다고 한다. 오히려 병렬처리를 하면 속도가 더 빠르기도 하다고 한다. 이번 미션에서는 단순하게 느리다는 단점보다 코드의 가독성을 향상한다는 장점만을 보고 적용해보기로 했다.

public List<Car> findWinners() {
	Car maxPositionCar = cars.stream()
		.max(Car::compareTo)
		.orElseThrow(() -> new IllegalArgumentException(CARS_IS_EMPTY_ERROR_MESSAGE));

	return cars.stream()
		.filter(car -> car.isSamePosition(maxPositionCar))
		.collect(Collectors.toList());
}

이 코드를 일반적인 코드로 수정하면 다음처럼 된다. 딱 봐도 가독성이 안 좋아진 것은 물론이고 당장 이 코드가 뭘 하겠다는 것인지 알기 어려워진다.

public List<Car> findWinners() {
	if (cars.isEmpty()) {
		throw new IllegalArgumentException(CARS_IS_EMPTY_ERROR_MESSAGE);
	}
	Car maxPositionCar = cars.get(0);
	for (Car car : cars) {
		if (maxPositionCar.compareTo(car) < 0) {
			maxPositionCar = car;
		}
	}

	List<Car> winners = new ArrayList<>();
	for (Car car : cars) {
		if (car.isSamePosition(maxPositionCar)) {
			winners.add(car);
		}
	}
	return winners;
}

공부하면서 본 글에서 "컴퓨터의 성능은 올릴 수 있지만, 가독성이 떨어지는 코드는 이후에 모든 개발자가 고통받는다" 이런 느낌의 말을 본 적이 있다. 람다식을 사용하는 이유가 딱 맞을 것 같다. 람다식을 이용하면 좀 더 느려질 수도 있다. 하지만 이후 이 코드를 보는 개발자가 편해질 것이다. 물론 람다식을 남용하면 오히려 가독성이 해칠 수 있고 코드의 재사용성이 떨어질 수 있다. 그렇기 때문에 필요한 부분에 람다식을 적절하게 사용할 수 있도록 연습해야 할 것 같다.

이름에서 의도를 나타내라

사실 이번에 람다식을 적용하면서 검색을 거의 하지 않았다. IntelliJ에서 추천해주는 Ctrl + space를 기능을 많이 사용하는데 이곳에서 추천하는 함수를 보면 이름, 파라미터, 반환형을 보여준다. 이름, 파라미터, 반환형만 봐도 이 함수가 무엇을 하는 함수인지 알 수 있을 만큼 쉽고 명확하다. 아래 예시를 보면 max는 최댓값인 녀석을 찾는구나, orElseThrow는 또는 아니면 throw를 하라는 거구나, filter는 조건에 의해 맞는 것만 리턴하는거구나 하는 식으로 이름만 보고도 람다식을 사용할 수 있었다. 물론 코드를 작성하고 나서 맞는지 확인하기 위해 검색을 해보았지만 전부 내가 생각한 것과 같았다. 이름에서 의도를 나타내라는 것이 얼마나 다른 개발자에게 얼마나 중요한지 알게 되었다. 아 그리고 IntelliJ의 추천 기능은 대박이다!

public List<Car> findWinners() {
	Car maxPositionCar = cars.stream()
		.max(Car::compareTo)
		.orElseThrow(() -> new IllegalArgumentException(CARS_IS_EMPTY_ERROR_MESSAGE));

	return cars.stream()
		.filter(car -> car.isSamePosition(maxPositionCar))
		.collect(Collectors.toList());
}

요구사항을 꼼꼼하게 확인하지 않은 실수

요구사항을 지키는 것은 기본 중 기본일 것이다. 이번 미션을 하면서 내가 실수한 부분이 하나 있다. README.md의 위치를 /docs/README.md로 이동하지 않고 작업한 것이다. 제출 직전 하나하나 확인하면서 이것을 알게 되었고 README.md를 이동시켰다. 이동시키고 나니 이전 히스토리가 다 사라져 버렸다. 나름대로 체크박스를 이용하면서 계속 수정하며 살아있는 문서로 만들기 위한 노력이 다 사라진 느낌이었다. 하지만 나의 실수였기 때문에 할 말이 없다. 다음 미션에서는 요구사항을 더 신경써서 확인해야겠다.


3주차부터는 난이도가 많이 오른다고 하는데 열심히 준비해야겠다.

👍

profile
안녕하세요!

1개의 댓글

comment-user-thumbnail
2022년 4월 14일

정성글 잘봤습니다,, ^^ 항상 노력하시는 모습이 아름다우세요.

답글 달기