우아한테크코스 6기 백엔드 프리코스 - 자동차 경주 게임

이전 미션과의 유사함

2주차 미션을 처음 시작할 때 이전 미션과 이번 미션의 구조가 유사하다고 생각했다. 일단 TDD를 통해 애플리케이션을 완성했고 이제 추상적인 관점에서 리팩토링을 해보자. 잘 추상화 하면 이전 미션과 이번 미션의 중복을 많이 제거하고 최대한 일관성 있는 코드로 두 애플리케이션을 표현할 수 있을 것 같다.

두 미션 비교

두 미션 사이 구조적 중복을 제거하기 위해 두 프로그램의 로직을 아래와 같이 정리하고 비교했다.

공통점 숫자 야구 자동차 경주
프롬프트 출력 “숫자 야구 게임을 …” “경주할 자동차 이름을 …”
사용자로부터 입력을 받는다 서로 다른 3자리 수를 입력한다 경주할 자동차들의 이름을 입력한다
    시도할 횟수를 입력한다
유효성 검사 적절하지 않은 입력 -> 프로그램 종료 적절하지 않은 입력 -> 프로그램 종료
게임 로직 실행 스트라이크, 볼 판정 자동차 이동
입력에 대한 결과를 출력한다 볼과 스트라이크 개수 출력 매 회차마다 진행 상황을 출력한다
게임 종료 메세지 출력 정답을 맞췄음을 알린다 최종 우승자를 출력한다
  새 게임을 시작, 프로그램 종료 여부를 물어본다  

중복 제거

이제 찾아낸 중복을 리팩토링을 통해 제거하면 된다. 처음에는 바로 인터페이스를 만들고, 클래스를 작성하려고 했지만(실제로 시도했다가 감당하지 못하고 전부 git restore해버렸다) 리팩토링을 TDD 방식으로 작은 작업으로 나누어서 여러 번 빠르게 진행하면 안정적으로 진행할 수 있겠다고 생각했다. 이미 작성한 코드를 최대한 활용하는 것이 비용 측면에서도 이득이다.

사용자 입력 로직

숫자 야구 게임에서의 유효하지 않은 입력을 받는 테스트이다.

숫자야구 입력테스트

자동차 경주 게임에서의 유효하지 않은 입력(자동차 이름)을 받는 테스트이다.

자동차 경주 이름 입력테스트

자동차 경주 게임에서의 유효하지 않은 입력(이동 횟수)을 받는 테스트이다.

자동차 경주 횟수 입력테스트

사용자 입력을 받기 위해 UserInputReader 클래스와 메서드 readUserInput이 필요하다. 그럼 이 메서드가 반환하는 타입은 뭘까?
지금까지의 미션에서 사용자 입력은 아래와 같은 클래스로 변환되었다.

  • 미션1 - BaseballNumber
  • 미션2 - CarRecord NumberOfRepetitions

각각의 미션에서 UserInputReader클래스는 아래와 같이 생겼다.

UserInputReader-baseball

UserInputReader-racing

두 메서드 readNumberOfRepetitionsreadUserguess숫자를 읽어 게임에 필요한 객체를 반환한다는 공통점이 있다. 이 두 함수를 인터페이스로 뽑아내보자.

IOTest-racing-init

UserInputReader-racing-pureNumber

UserInputReader-interface

변경할 내용

  • UserInputReader 클래스를 -> ConsoleReader 클래스
  • 공통 로직을 추상화 한 UserInputReader 인터페이스 정의
  • NumberOfRepetitions BaseballNumber CarRecordGameObject 를 상속받도록 구현

인터페이스 vs 추상 클래스

공통된 로직을 뽑아내서 중복을 줄이고 싶은 경우 언제 인터페이스를 정의하고 상위 클래스는 어떤 경우에 정의하는게 좋을까?

Baeldung에서는 아래와 같은 경우 인터페이스를 사용하는 것이 좋다고 한다.

  • 문제 해결을 통해 다중 상속이 필요한 경우
  • 클래스 A가 이런 기능을 해야 한다라고 표현하고 싶은 경우.
    (예를 들어 Comparable 인터페이스를 구현하는 클래스는 compareTo 기능을 제공해야 한다.)

다음은 추상 클래스가 권장되는 상황이다.

  • 서로 연관된 클래스들이 많은 경우. 중복된 코드를 줄일 수 있다.
  • public 키워드가 아닌 필드나 매서드가 중복되는 경우
  • A는 B이다 라는 의미를 코드로 표현하고 싶은 경우.

UserInputReader 인터페이스

이 인터페이스를 구현하는 클래스는 숫자를 입력받거나 단어를 입력받는 기능을 제공해야 한다. 단, 유효하지 않은 입력에 대해서는 IllegalArgumentException 예외를 던진다.

UserInputReader-interface

RacingGameComponent 추상 클래스

UserInputReader 인터페이스의 메서드가 구체적인 클래스(NumberOfRepetitions)를 반환하는 것은 인터페이스를 정의한 의미가 없기 때문에 반환 타입을 일반화 해야 한다. Java에서 클래스를 일반화 하는 방법은 추상 클래스를 정의하는 것이다.

RacingGameComponent-hierarchy

RacingGameComponent-abstract-class

테스트는 아래와 같이 수정했다.

IOTest 수정 1

RacingGameComponent 인스턴스를 바로 CarRecord 타입으로 캐스팅하는 것이 마음에 걸리지만 일단 테스트는 통과했다.

IOTest 수정 2

이제 추상 클래스의 메서드를 하위 클래스에서 구현할 차례다.

validate 메서드는 해당 RacingGameComponent의 유효성을 검사하고 예외를 던지는 기능을 한다. 리팩토링을 하다가 이상한 점을 발견했다.

validate 문제점

유효성을 검증하는 메서드가 팩토리 클래스에 있었다. 값과 연산이 분리되어 있었다. 아래와 같이 수정하여 값과 연산을 하나로 묶어줬다. 값과 연산이 분리되었다는 것은 값 클래스의 책임이 여러 코드에 흩어져있다는 것이다. 클래스의 책임이 여러 군데에 흩어져있으면 나중에 수정하기 힘들어진다.

wrapper 수정 후 문제점

수정하고 보니 또 다른 문제점이 보였다. 입력에 대한 필터는 게임 규칙의 영역인데, 디테일한 게임 규칙을 CarName이 알고 있었다. 상수를 그대로 사용했다는 점과 모델 코드에 regex api가 그대로 노출된 것도 객체지향과는 거리가 멀다. 게임 규칙을 수정하기 위해 CarName 클래스를 수정해야되기 때문이다.

GameRule 클래스

본격적으로 GameRule 클래스를 리팩토링하기 전에 이 클래스의 인스턴스를 멤버로 가지는 클래스들에서 중복을 제거할 필요가 있다. GameRule 인스턴스를 멤버로 가지는 클래스는 ConsoleReader Race이고 추가적으로 NumberOfRepetitionsCarNameGameRule 인스턴스를 가져야 한다.

GameRule-1

GameRule-2

이 클래스는 게임 규칙과 관련된 값을 관리(설정, 조회)한다.

클래스 다이어그램

지금까지 리팩토링한 내용을 다이어그램으로 나타내면 다음과 같다.

racing-game-hierarchy

그런데 이렇게 리팩토링한 이유는 무엇일까? 코드의 재사용성을 높이는 것이 목적이였다. 이전 미션도 이렇게 구조화 해서 코드를 재사용하지 않으면 의미가 없기 때문에 이전 미션도 똑같이 리팩토링 해줘야 한다.

Static mocking is already registered

테스트를 실행하면 두 번에 한 번은 아래와 같은 오류가 두 군데의 클래스에서 발생했다.

static-mocking-is-already-registered

마무리

리팩토링 위주로 진행하려고 했는데 에러에 붙잡혀서 대부분의 시간을 보내버렸다. 그냥 처음부터 미션이랑 같이 제공된 테스트 코드를 분석해볼걸…

새로 배운 점

  • 인터페이스와 추상 클래스의 차이

elapsed-time

reference

Comments