[우아한테크코스] 프리코스 2주 차: 자동차 경주 회고


1주 차가 끝나기 무섭게 두 번째 과제가 던져졌다. 이번에는 과제와 함께 1주 차에서 사람들이 많이 헤맸던 부분을 피드백해주는 공통 피드백 또한 올라왔다. 하나씩 읽어보면서 나에게 해당되는 것은 없었는지, 공부할 것이 더 있었는지 살펴봤다.

가장 중요한 항목이라 짐작됐던 것은 요구사항을 제대로 분석한다는 것이었다. 주어진 과제에서 하라는 대로 하고, 그렇지 않은 항목에 대해서는 1주 차 회고에서도 적어두었지만 애매한 부분이나 적혀있지 않은 사항에 대해서는 ‘마음대로 해라‘가 아니라, ‘적당한 조치를 한 뒤 문서에 작성하라‘라고 이해해야 한다. 문서를 통해서 애매한 부분을 해결했다고 적어둬야 나도, 요구자도 오해 없이 프로젝트가 마무리될 것이다. 또, 적당한 조치는 물론 여러 부분에서 일관성있게 작성해야 할 것이다. 요구사항이 있기에 우리는 코드를 작성한다. 요구사항대로 돌아가지 않는 프로그램은 (극단적인 표현이긴 하지만) 필요없는 프로그램이다.

이번 요구사항은 간단한 자동차 경주 게임을 만드는 것이었는데, 자동차라고 주어지다보니 머릿속으로 객체를 바로 떠올릴 수 있었다. 개인적으로는 1주 차 과제보다 체감 난이도가 낮았는데, 내가 성장했다고 해야 할지 모르겠다 🏃‍♀️

저번 주와 매한가지로 요구사항에 대한 기능 분석서부터 작성했다. 기능 분석서에는 앞 문단에서 설명한 것처럼 애매하거나 정해야 하는 부분에 대해서는 🔍 이모지를 붙여 분리했다. 내가 추가로 적어넣은 요구사항을 정하는 조건은, 프로그램을 사용하는 사람이 해당 부분을 고려하지 않을 경우 프로그램 진행에 방해가 되는 곳에 대해서 스스로 판단해 구현했다. 아래 다섯 가지는 내가 추가로 정해 구현한 것들이다.

  • 이름으로 빈 문자열을 가지는 것은 허용하지 않는다.
  • 자동차의 최초 위치는 0이다.
  • 자동차의 개수는 10개 이하여야 한다.
  • 자동차의 이름들 중, 중복되는 이름이 없어야 한다.
  • 사용자가 입력하는 자동차 총 이동 횟수는 1 이상 1,000 이하의 정수여야 한다.
구현할 기능 목록이 꽤 많았다. 1주 차보다는 필요한 요구사항이 많아지긴 한 듯.

몇 가지 가지를 쳐내니 확실히 요구사항이 명료하게 보이기 시작했다. 곧바로 구현에 들어가기에 앞서, 이번 주 차부터는 과제 진행 요구사항에 몇 문장이 더 추가돼 있었다. 작성한 기능 목록 단위로 커밋 단위를 맞추라는 것. 저번 주에 놓친 사항이기도 했는데, 이곳저곳 동시에 개발하다 보니 체크리스트를 올바르게 채우지도 못했고 어찌저찌 완성만 한 꼴이 됐었다. 이번에는 해당 요구사항을 최대한 지키기 위해 노력했다.

또, 깃 커밋 메시지 컨벤션을 지켜서 커밋 메시지를 작성하라고 한다. 지금까지 나는 커밋 메시지를 컨벤션에 맞게 작성한 줄 알고 있었는데, 해당 사이트에 들어가보니 보다 많은 부분들이 있었다. Subject뿐만 아니라 커밋의 Body 부분까지 꼼꼼하게 채우기 위해서 노력했다. Breaking change가 포함되는 경우에는 footer에 작성하라는 것까지 지키기 위해 노력했다.

커밋은 길지만, 어떤 일을 하고 싶은지, 내가 어떤 코드를 만들어나갈 것인지 정확하게 파악할 수 있다!

어떻게 된 건지 내가 저번 주에 아쉬웠다고 생각한 것들이 피드백으로 날아와 줘서 더 집중할 수 있었다. 특히 테스트 관련해서도 어떤 테스트까지 진행할 수 있을까.. 생각을 많이 했다. 결국 전체적으로 100% 테스트 커버리지를 만족하게 테스트를 작성했다!

저번 주보다 더 확실하게

3항 연산자, indent는 두 번까지만, 함수는 작게, 테스트 작성. 이번 주 차에 추가된 요구사항이다. 어떻게 보면 “깔끔하게 코드를 짠다”의 표본이라고 할 수 있겠는데, 인덴트 정도는 깊게 안 들어가는 걸 원래 선호하기도 해서 지키는 것이 어렵지는 않았다. 다만 함수를 작게, 테스트를 작성하는 곳에서 꽤나 애를 먹었다.

이번 주차에는 입력을 검증하는 것과 더불어 각각의 객체의 필드를 검증하는 것도 추가했다. 자동차의 이름 길이와 같은 요구사항은 static으로 두면 안 될 것 같았다. 객체와 관련된 항목이고, 이를 static으로 열면 객체의 필드와 관련된 사항을 바깥에서 접근할 수 있기 때문이다.

따라서 이런 검증 사항들은 클래스 내부에서 메서드를 생성해 두었다. 생성자가 불릴 때 검증 메서드를 호출하고, 검증 메서드는 검증에 실패하면 적절한 예외를 처리하는 방식이었다. 검증은 내부에서만 진행해서는 안 된다. 외부에서 사용자가 입력하는 것에 대한 검증은 자동차와 전혀 무관한 검증 (쉼표와 같은) 것이고, 실제로 해당 문자열을 이름으로 가지는 자동차를 생성하고자 할 때에는 비로소 해당 객체와 연관이 생기므로 검증이 진행된다. 객체 내부에서의 검증, 바깥 static으로서의 검증 모두 해 보면서 이해를 키워 나갔다.

저번 주 다른 사람들의 코드를 보니 Stream을 정말 잘 활용하는 분들이 많았다. Stream은 주어진 배열이나 정보들을 한 번 더 추상화하는 것이지 않은가..! 내부 값은 모르겠고, 밖에서 이렇게 해줘~ 라고 하니 그렇게 만들어지는 것이 참 객체지향과 어울린다고 생각했다. 이번에는 저번 주에 적용하지 못했던 랜덤 숫자를 뽑는 과정을 IntStream을 활용해서 구현했다. 실제 filter와 같은 것이 뽑힐 때 적용되는 건지, Stream에 진작 적용되는 것인지 확인해봐야겠다.

public class RandomNumberGenerator {
    public static final int RANGE_MIN_VALUE = 0;
    public static final int RANGE_MAX_VALUE = 9;

    public static List<Integer> generateRandomNumbers(int size) {
        return IntStream.generate(() -> Randoms.pickNumberInRange(RANGE_MIN_VALUE, RANGE_MAX_VALUE))
                .limit(size)
                .boxed()
                .toList();
    }
}

추상적인 것은 곧 인간 언어에 가깝고, 연산 과정을 숨겨 코드를 보면 어떤 결과가 나올 지 쉽게 유추할 수 있다는 것이었다. 이를 기반으로 리팩터링도 더 원활하게 할 수 있겠지!

저번 주 차의 숫자야구 정답을 보관하는 Slot처럼, 이번에도 게임 클라이언트가 자동차에 대한 관리를 해줘야 했다. List<RacingCar>로 만들면 게임 클라이언트에서 예상치 못한 조작이 발생할 수 있기에, 이를 다시 클래스로 감싸 추상화했다. 이로써 클래스에서 “움직여!” 라고 신호를 주면 모든 자동차가 하나하나 움직일 기회를 가졌다. 클라이언트에서 반복문을 돌면서 자동차에게 접근하라는 것보다 훨씬 일리있다고 생각했다.

public void moveRacingCarsBasedOnGivenNumbers(List<Integer> randomNumbers) {
    for (int i = 0; i < racingCars.size(); i++) {
        int chosenNumber = randomNumbers.get(i);
        racingCars.get(i).checkAndMoveForward(chosenNumber);
    }
}

랜덤 번호를 뽑는 주체가 자동차여야 할까, 클라이언트여야 할까?

구현을 진행할 때 요구사항에는 0에서 9 사이의 무작위 수를 구한 뒤, 4 이상이면 전진한다고만 되어 있지, 누가 무작위 수를 뽑아주는지에 대한 언급은 돼있지 않았다. 자동차가 무작위 수를 골라야 할지, 클라이언트가 자동차에게 무작위 수를 뽑아서 줄지 고민을 많이 했다.

자동차가 무작위 수를 뽑는다면, 자동차에게 무작위 수를 뽑는다는 책임이 하나 추가된다. 게임을 진행하는 입장에서 주사위를 굴리는 것이 해당 객체여야 할 것도 같지만, 순전히 자동차라는 의미를 잃고 싶지 않아서 추가하지 않고, 특정 수를 주었을 때 판단한 다음에 앞으로 나아가는 기능을 구현했다.

랜덤 번호에 대한 테스트는 어떻게 진행해야 할까?

랜덤에 의존하는 것은 테스트하기가 어렵다. 비단 랜덤이 아니더라도, 이는 외부에서 이미 만들어진 API를 활용하는 경우 더더욱 그렇다. static 키워드가 붙으니 더욱 테스트하기 어려워진다.

공부한 결과 두 가지 방법이 있었다. 하나는 Mocking을 활용한 테스트, 나머지 하나는 Interface를 활용한 방법이다.

첫 번째 방법은 프리코스 소스를 뜯어봤다면 알아챌 수 있다. 예제 테스트코드들이 모두 Mock을 이용한 방법이기 때문이다. 랜덤한 값을 고정해준 뒤, API의 리턴값이 항상 이 값을 뱉어줄 것이다~ 라고 한다면, 해당 부분을 배제하고 내가 원하는 기능을 테스트할 수 있게 된다. 나는 이 방법을 이용해서 테스트를 진행했다.

두 번째 방법은 유튜브를 보다가 알게 된 방법이었는데, 백기선님이 올려주신 스프링 제대로 공부했는지 5분만에 확인하는 방법이다. 스프링을 잠깐 찍어보고, 객체지향에 대해서 공부하던 나에게 신선한 충격을 가져다줬다. 객체지향의 개념을 정확하게 알고 있어야 한다. 주어진 클래스에서 외부 API를 사용하는 경우, 해당 API를 인터페이스로 뽑아낸 뒤, 인터페이스 구현체에서 해당 API를 부르는 방식이다. 이렇게 한 뒤 바깥에서 의존성을 주입해준다면(Dependency Injection) 테스트를 진행할 때 테스트 구현체를 넣어줌으로써 원활하게 테스트할 수 있게 된다. 아래 예시를 보자!

public interface RandomNumberGenerator {
    List<Integer> generateNumbers(int size);
}

public class RandomNumberGeneratorImpl implements RandomNumberGenerator {
    public static final int RANGE_MIN_VALUE = 0;
    public static final int RANGE_MAX_VALUE = 9;

    @Override
    public List<Integer> generateNumbers(int size) {
        return IntStream.generate(() -> Randoms.pickNumberInRange(RANGE_MIN_VALUE, RANGE_MAX_VALUE))
                .limit(size)
                .boxed()
                .toList();
    }
}

// 테스트 할 때에는 아래처럼

@Test
public void clientRandomTest() {
    GameClient gameClient = new GameClient(new RandomNumberGenerator() {
        @Override
        public List<Integer> generateNumbers(int size) {
            return IntStream.rangeClosed(1, 10)
                    .limit(size)
                    .boxed()
                    .collect(Collectors.toList());
        }
    });
    // ...
}
// 항상 Generate 하는 값은 1, 2, 3, ... 꼴

비즈니스 로직과 입출력의 분리

요구사항이 많아지다 보니, 각각의 객체가 해야하는 행동이 많아지기 시작했다. 필연적으로 코드도 길어지기 시작했고, 메서드를 나누면서 하나의 클래스가 여러 가지 책임을 지고 있었다. 이러면 안 됐다. 코드를 짜면서 등골이 오싹해지는 기분을 느꼈는데, 더 무서운 건 고쳐야하는 것을 알면서도 어떻게 고쳐야할 지 떠올리기가 어렵다는 점이었다.

우선 간단하게 빼낼 수 있는 것부터 빼내기로 했다. 기존에는 GameClientIO라고 해서 입출력을 모두 담당하게 하려고 했었지만, 입력 검증만 해도 충분히 많은 책임을 지고 있었다. 따라서 출력을 구현하려고 할 때 IOInputOutput으로 나누어 진행했다. @ca62c8b

한두 번 빼내는 과정에 익숙해지다보니 코드를 작성하기 전에 머리를 싸매는 것보다, 일단 코드를 작성한 다음에 기능이나 역할에 따라 나누어나가는 게 더 수월하다고 느꼈다. 머릿속의 코드는 밖으로 나올 때 나의 손을 거치기(…) 때문에, 이상적으로 코드가 작성될 리가 없었다.

예외 상속으로 더 말끔하게 처리하기

1주 차에서는 모든 예외 상황에 대해서 IllegalArgumentExcepion을 던져서 프로그램을 종료시켰다. 요구사항에 부합하도록 작성하기는 했지만, 개발자나 사용자에게 있어서 친절하지는 않았다. 나 죽어~ 하고 벌렁 드러누워버린 것이고, 다잉 메시지도 남기지 않아 원인을 찾기 어려웠다.

Java의 특징은 상속이다. 모든 객체는 Object를 암묵적으로 상속하고, 따라서 아래 문장은 항상 참이다.

(Some instance) instanceof Object

따라서 IllegalArgumentException을 상속받는 커스텀 예외를 만들어서 메시지를 유저나 개발자에게 전달하도록 했다. 이렇게 하면 요구사항을 벗어나지 않게, 또 사용자에게는 친절하게 왜 예외가 발생했는지 알려줄 수 있게 된다.

public class DuplicateNameException extends IllegalArgumentException {
    public DuplicateNameException() {
        super("중복된 이름이 존재합니다.");
    }
}
테스트는 많을수록 좋다

테스트가 요구사항인만큼 최대한 나의 코드가 테스트에 절여지도록(?) 했다. 전체 라인 중에 얼마나 Test를 거쳐갔는지를 알려주는 Coverage가 있었는데, 각각의 코드에 대해서 모든 줄이 테스트코드를 거쳐가게끔 많은 테스트를 작성했다. Coverage가 높은 것과 정확한 테스트를 작성한 것은 별개의 일이지만, 적어도 해당 지표를 통해 내가 까먹고 넣지 않은 테스트를 최소화할 수 있게 되었다.

100퍼센트 커버리지, 모든 라인이 한 번쯤은 테스트를 지나쳤다는 의미이다
개인적으로 추가한 테스트를 모두 합하니 50개의 테스트 케이스가 작성됐다

이번 주는요

주 차별로 고민이 정말 많아지도록 설계한 우테코에게 박수를, 열심히 고민하고 나만의 정답을 만들어내 나에게도 박수를… 모든 사람들이 이런 것을 고민해봤으면 좋겠다고 생각했겠지..! 그만큼 중요한 개념들이 포함돼있기 때문에 나도 즐겁게 고민했던 것 같다. 모호한 요구사항은 실제 세상에서 충분히 일어나는 일이고, 이를 적절히 결정하고 구현하는 것도 개발자의 몫이다. 그런 일이 일어나지 않도록 충분히 소통하는 것이 그만큼 중요하겠지 싶었다.

읽고 있는 객체지향의 사실과 오해 책도 재미있게 읽고 있다. 1주 차에도 기록했지만 객체의 행동에 집중하면 확실히 길이 보이는 듯했다. 나중에 성장했을 때 다시 내 코드를 보면 눈살이 찌푸려지려나 싶다 😅

3주차도 열심히 달리자! 같은 코드를 여러 사람이 작성해둔 것을 보면서도 충분히 성장할 테니, 코드리뷰를 요청해봐야겠다.


제가 구현한 코드는 이곳에서 확인하실 수 있습니다.

Categories