자바생
article thumbnail
728x90

이번 미션은 자동차 경주입니다. 많은 요구사항이 존재하진 않았지만 리뷰어님 덕분에 많이 배울 수 있었던 미션이었습니다.

정말 자바를 자바스럽게 사용하며, 객체의 "책임"과 "역할"에 대해서 많은 고민을 했습니다. 그래서 이번 Level 1 때는 제대로 완독 해보지 못한 "객체지향의 사실과 오해"를 완독 해보려고 합니다.

 

1차 리뷰

 

도메인 규칙은 어느 곳에 있어야할까?(Feat. 원시 값 포장)

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

요구사항에서 자동차 이름(name)은 5자 이하만 가능했습니다.

기존 Car 생성자 코드는 위와 같고, name에 대한 validation은 InputView에서 처리해 주었습니다.

 

하지만 위 방법의 문제점으로 생성자를 통해서 자동차의 이름이 5자를 넘을 수 있다는 것이었습니다. 즉, 리뷰어님이 "도메인에 도메인 규칙이 적용되어 있지 않다"라고 하셨습니다.

 

리뷰어님 말씀대로 name에 대한 제약 조건은 도메인 규칙이고, 도메인 규칙은 도메인에 맞다고 생각했습니다.

하지만 한편으로는 생성자는 객체를 생성하는 아주 무거운 책임을 가지고 있어서 생성자에서는 객체 생성을 하는 로직만을 가지고 있는 게 좋지 않을까?라는 생각을 가지고 있었습니다.

 

또한, Car를 생성하기 위해서는 Input에서 name을 입력받아야 하기 때문에 InputView에서 처리해 주면 되지 않을까?라고 생각했습니다.

 

원시 값 포장,, 정체가?

리뷰어님이 자동차 이름에 대한 검증이라면 "자동차 이름"이라는 객체를 만들어 책임을 할당해 보는 게 어떠냐는 제안을 주셨습니다. 즉, Car에서 모든 책임을 갖기보다는 각 책임이 적절한 객체를 만들어서 책임을 분산시키는 방법이었습니다.

그래서 "원시 값 포장"이라는 것을 알게 됐습니다. name이라는 값을 Name이라는 객체 안에 넣어서, Car 클래스 안에 Name을 인스턴스 변수로 가지고 있습니다. 그러면 Name이라는 클래스 안에서 name에 관한 validation을 처리하여, Car에서 name에 관한 validation을 처리하지 않아도 됩니다. 책임을 분산시킬 수 있는 것이지요.

 

일급 컬렉션

일급 컬렉션에 관한 설명은 이 두 블로그를 통해서 이해했습니다. 그래서 두 글을 읽어보고 나서 직접 사용해 보며 몸으로 느끼는 것이 중요하다고 생각합니다. (동욱님, 테코블)

 

그래서 저는 제가 느낀 바만 적어보려고 합니다.

 

이번 자동차 미션에서 처음으로 일급 컬렉션을 사용해 보았습니다.

일급 컬렉션의 큰 장점 중 하나는 "상태와 행위"를 한 곳에서 관리할 수 있습니다. 그래서 해당 도메인에 관한 일급 컬렉션의 코드를 변경하면 되기 때문에 유지 보수성이 좋다는 생각이 들었습니다.

 

예를 들어 자동차 관련한 기능들(자동차 중 최대 움직인 거리, 우승자, 현재 상태 등)을 모두 일급 컬렉션인 Cars에서 관리하고 있기 때문에 추후에 "자동차와 관련한 코드는 어딨지?"라는 것에 대해 일급 컬렉션을 보게 될 테고, 이는 유지 보수성에서의 장점을 가져다줄 수 있습니다.

 

파일 끝에 개행을 추가해야 하는 이유

파일 끝에 개행을 추가해야 하는 이유와 같은 이유로 개행을 추가하는 것이 나중에 이유 모를 에러가 발생하는 것을 미리 방지할 수 있지 않을까라는 생각이 듭니다,, 그렇게 어려운 일이 아니기 때문에!!

그리고 intellij 가 파일을 저장할 때 자동으로 마지막 개행을 추가해 주는 기능도 제공하기 때문에 사전에 방지하면 좋아 보입니다~~

 

테스트 검사 -> try-catch vs assertJ

assertThrows를 사용하여 에러 테스트를 진행했었는데 사진과 같이 validateLength, validateName, validateDuplicatedCarName은 private 메서드이고, validation이 지켜지지 않을 경우에 모두 같은 에러를 반환하기 때문에 테스트하기 어려웠습니다. 그래서 try-catch를 통해 직접 error를 핸들링하고, exception message로 검증을 했습니다.

 

테스트가 제대로 동작했습니다.(실제로는 제대로 동작 X) 하지만 리뷰어님이 assertJ를 통해 더욱 명확한 테스트를 제안하셨습니다.

 

처음에는 리뷰어님이 말씀하신 "명확한 테스트"가 assertJ를 사용하는 것이면 assertEquals를 사용한 거니까 제대로 한거 아닌가?라는 생각을 했었습니다. 하지만 찾아보니 exception에 관한 assertJ가 제공하는 메서드인 hasMessage 메서드가 존재했습니다.

 

적용하고 나서 "왜 굳이 assertThatThrownBy를 사용하지? 내가 작성해 놓은 테스트도 명확한 테스트를 할 수 있지 않을까?"에 대해 의문을 가졌고, 이 의문은 아래의 테스트에서 문제가 발생하면서 해결됐습니다.(역시 몸으로 느껴야 깨닫습니다,,)

 

@Test
@DisplayName("validationCar() : 자동차가 한 대 미만일 경우에 IllegalArgumentException 발생")
void test_ValidateLength_IllegalArgumentException() {
    //given
    String input = "";
    String expectedMessage = "자동차를 한 대 이상 작성해주세요.";

    //when & then
    try {
        CarInfoValidation.validateCar(input);
    } catch (IllegalArgumentException exception) {
        final String errorMessage = exception.getMessage();

        assertEquals(expectedMessage, errorMessage);
    }
}

exception이 터지지 않아서 catch 문에서 assert문이 실행되지 않은 채로 테스트가 완료돼서 성공한 것처럼 보였습니다,,

그래서 왜 리뷰어님이 말씀하신 assertJ를 통한 명확한 테스트를 작성하는지 몸으로 느낄 수 있었습니다.

@ParameterizedTest
@ValueSource(strings = {" ", "  ", ""})
@NullSource
@DisplayName("Car() : 차 이름이 1글자 미만 및 빈칸일 경우 IllegalArgumentException 발생")
void test_carConstructor_IllegalArgumentException(String name) throws Exception {
    //given
    String expectedMessage = "자동차 이름은 빈칸일 수 없습니다.";

    //when & then
    assertThatThrownBy(() -> {
        new Car(Name.fromName(name));
    }).isInstanceOf(IllegalArgumentException.class)
      .hasMessage(expectedMessage);
}

 

값 객체? 원시 값 포장?

리뷰어님이 값 객체라는 표현을 사용하셔서 값 객체가 무엇인지 찾아보았습니다.

그러다 보니 값 객체와 원시 값 포장 두 개념은 무슨 차이가 있는 거지? 무엇이 다른 거지?라는 의문을 가졌습니다.

 

이러한 정답이 정해져 있지 않는 개념들에 대해서는 항상 의식의 흐름대로 먼저 저의 생각을 작성하면서 정리하는 편입니다,,

 

원시값 포장이 뭘까?

  • 찾아보니 원시값 포장에서는 책임 분리를 하기 위한 것
  • 직접 코드를 작성해 보니 원시값 포장이 왜 필요한지 몸으로 느꼈습니다.

VO는 뭘까? & 원시값 포장과 다른 점

  • 값 표현을 위한 객체라고 함. 즉, 값 표현을 위한 포장
  • 그렇다면 VO와 원시값 포장은 무엇이 다른 걸까?
  • 원시값 포장은 책임 분리, 동등성 보장일 수도 아닐 수도 있고, 불변일수도 아닐수도 있다
  • VO는 값 표현을 위한 포장으로 동등성, 불변이 무조건 보장되야 한다고 함
  • 둘의 차이는 명확하게 그냥 동등성, 불변 보장으로 나뉘는 걸까? 용도로 보면 둘은 비슷하면서 다른 것 같은데, 어떻게 이해하면 좋을까?
  • 그냥 역할이 다르다고 생각하면 될까? 가끔 다시 생각해 보면 VO가 원시값 포장 안에 부분 집합 느낌이 나기도 하고,,
  • 차이를 명확하게 나누는 게 의미 있는 걸까?
  • 없는 것 같기도 하고, 있는 것 같기도 하고,,

결국엔 뭘까?

  • 그냥 원시 값 포장은 말 그대로 “원시 값(primitive type)”을 포장하는 거고, VO는 포장의 대상이 원시 값이 아닐 수도 있으며 불변하는 값을 위한 “포장”이다,,?

리뷰어님은 위 내용이 전반적으로 다 맞지만, 객체를 정의할 때 원시 값 포장과 VO로 명확히 나누지 말고, 한 객체가 둘 다 할 수 있다고 생각하신다고 했다. 처음에는 무슨 말씀인지 이해가 되지 않았지만 예시를 보니 한 번에 이해가 됐다,, 예시 들어주신 게 기가 막혀서 박수 치면서 이해했다,, 

 

VO & 원시 값 포장 설명 flow

자동차 번호라는 변수가 있을 때, 번호라는 도메인 특징을 살리기 위해 CarNumber라는 객체를 생성했습니다.

class CarNumber {
    private int number;
}

현재 CarNumber는 number라는 primitive type을 감싸고 있는 원시 값 포장 객체입니다.

여기서 자동차 번호는 같으면 “같은 자동차”입니다. 그래서 equals & hashCode를 오버라이딩하여 “동등성”을 보장합니다.

class CarNumber {
    private int number;

	// equals & hashCode ...
}

그리고 자동차 번호는 차를 바꾸지 않는 이상 변하지 않게 됩니다. 그래서 final을 통해 불변성을 보장해 줄 수 있습니다.

class CarNumber {
    private final int number;

	// equals & hashCode ...
}

그러면 CarNumber는 VO일까요? 원시 값 포장을 한 객체일까요?? 둘 다 아닐까요?

저는 이러한 부분들이 헷갈린다고 하니 리뷰어님이 무조건 모든 개념을 A 아니면 B다라고 생각하다 보면 오히려 혼란스러워서 그냥 이름 그대로 이해하는 게 어떠냐고 말씀해 주셨다,,!!

 

항상 이런 정답이 없는 것에 대해서 생각할 때, 차이점을 명확하게 찾으려는 행동들 때문에 엄청 오랫동안 고민하고, 공부했던 적이 많다. 그리고 결국엔 정답을 찾지 못해서 해결하지 못한 것들도 많이 있다..,, 앞으로는 무조건 A 아니면 B 다라는 것보다 이해하는 데에도 유연성을 가져야 한다고 느꼈다,,,,

 

테스트는 경곗값을 통해 더욱 견고한 테스트 코드를 작성하자

@Test
@DisplayName("validateTryCount() 성공 경우")
void test_validateTryCount_success() {
    // given
    final int input = 3;

		// when, then
    TryCountValidation.validateTryCount(input);
}

이 부분에서 특정 숫자보다는 경계값을 테스트하면 더 견고한 테스트 코드를 작성할 수 있다고 말씀해 주셨습니다.

경곗값이라 함은 이제 값이 변경되는 boundary입니다. 그래서 3이 아닌 -1이 들어가면 exception, 0은 가능하므로 0과 -1을 통해 견고한 테스트를 한다면 더욱 견고한 테스트가 될 수 있다고 생각합니다~~

 

ParameterizedTest + @ValueSource/@CsvSource

여러 개의 input 값에 대해서 테스트를 진행할 때, 미리 input 값들을 설정해 줄 수 있는 ParametrizedTest를 알게 됐습니다. 그래서 @ValueSource를 통해 input 들을 설정할 수 있습니다.

@CsvSource는 input값과 결과를 예상하는 값을 미리 넣어놓을 수 있습니다. 그래서 서로 다른 input에 대해 결과 값이 다른 것을 테스트할 때 유용하게 사용할 수 있습니다.

@ParameterizedTest
@ValueSource(strings = {"1", "2", "3"})
void isContainsSet(int input) {
    // when, then
    assertTrue(numbers.contains(input));
}

@ParameterizedTest
@CsvSource(value = {"1,true", "2,true", "3,true", "4,false", "5,false"}, delimiter = ',')
void isContainsSet(int input, boolean check) {
    // when, then
    assertEquals(numbers.contains(input), check);
}

 

도대체 Car 움직임 판단은 어디서 해야 하는 것일까?

해당 리뷰

 

이번 미션에서 제일 고민한 점이 아마 이 부분이 아닌가 싶다,, "객체의 책임과 역할"

 

// CarEngine

public void moveCar() {
    for (final Car car : cars) {
        doRace(car);
    }
}

private void doRace(final Car car) {
    if (canMove()) {
        car.move();
    }
}

private boolean canMove() {
    return RandomNumberGenerator.generateRandomNumber() >= 4;
}

 

자동차 경주 미션에는 "랜덤 숫자가 4 이상이 나오면 자동차를 움직여"라는 요구 사항이 존재했습니다.

처음에는 Cars에서 위 행동들을 하고 있었지만, 리뷰를 통해 아래와 같은 판단으로 CarEngine으로 책임을 위임했습니다.

 

말씀대로 cars는 car에 대한 일급 컬렉션으로 자동차들의 행위를 모아서 관리(?) 하는 객체입니다. 그런데 여기서 자동차를 움직이게 하거나, 움직일 수 있도록 하는 조건들은 다른 곳에서 처리해야 한다고 생각해요. 그래서 별도의 CarEngine이라는 클래스를 생성하여 자동차를 움직이게 하는 doRace, canMove, moveCar 메서드를 분리했습니다!

- 리뷰어님 질문에 대한 나의 답

 

그리고 이러한 고민도 했었다,,

  • CarEngine이 움직이는 조건을 판단하고 Car에게 move라는 메시지를 통해서, Car는 move라는 메시지를 수신하여 1칸 움직인다.
  • Car가 특정 숫자를 받으면 Car가 직접 판단하여 해당 숫자가 넘으면 혼자서 판단하고 움직인다.
    • 여기서 Car 입장에서 random number에 대해서 알 필요가 있을까?
    • 그냥 "움직여"라는 메시지를 수신하여 move 하면 주체적으로 판단할 수 있다고 말할 수 없는 걸까?

두 방법 중 어느 방법이 더 객체지향적인가?라는 고민을 했었다.

 

첫 번째가 저의 의견, 두 번째가 제가 리뷰어님의 답을 듣고 생각한 의견이었습니다.

 

하지만 리뷰어님의 의견은 이것이 아니었고,,

해당 리뷰를 통해 매번 도전하지만 항상 완독 하지 못하는 객사오 책을 읽어보려고 한다,,(이것 때문에 읽는다)

처음에는 자동차는 "움직여"라는 메시지를 통해서 그냥 움직이면 된다고 생각했습니다. 하지만 객사오 책에서는 객체란 자율적인 존재이며, 자율적이란 객체 스스로 판단하고 행동하는 것이라고 했습니다. 또한, 자동차 내부에서는 어떻게 움직이든지 외부에서는 상관이 없고, 알 필요가 없으므로 "자율적"이라고 말할 수 있다고 했습니다.

 

그렇다면 현재 미션에 비유해 보자면 CarEngine은 Car에게 움직이라는 메시지를 전송하고, Car는 자율적인 객체이기 때문에 "스스로 정한 원칙(4 이상)"에 움직임을 판단하고 움직이면 됩니다.

 

하지만 여기서 제일 찜찜한 점은 자동차가 굳이 random number를 알아야 할까에 대한 의문이었습니다.

 

이 부분에 대해서 리뷰어님이 randomNumber를 모르더라도, 이동에 대한 전략을 외부에서 넣어주고, Car는 이 전략을 통해 움직이면 어떨까 라는 의견을 주셨습니다.

//Car

public void move(NumberGenerator numberGenerator) {
    if (numberGenerator.generateNumber() >= 4) {
        distance++;
    }
}

//CarEngine

public void moveCar(Cars cars) {
    for (Car car : cars.getCars()) {
        car.move(numberGenerator);
    }
}

처음에는 이러한 느낌의 코드인 줄 알았는데, NumberGenerator로 인해 테스트가 어려울 수 있습니다. 그래서 우리가 제어할 수 있도록 이동과 관련된 전략을 통해 움직일 수 있지 않을까요?

public interface MoveState {

    int move();
}

public class CarEngine {

    private final NumberGenerator numberGenerator;
    private final MoveState moveState;

    public CarEngine(NumberGenerator numberGenerator, MoveState moveState) {
        this.numberGenerator = numberGenerator;
        this.moveState = moveState;
    }

    public void moveCar(Cars cars) {
        for (Car car : cars.getCars()) {
            car.move(moveState);
        }
    }
}

// Car

public void move(MoveState moveState) {
    distance += moveState.move();
}

그러면 이제 Car에 대한 move를 테스트할 때, 전략을 주입해 주면 되기 때문에(제어 가능) 이전 코드보다 테스트하기 더욱 편하다는 장점을 가지고 있습니다. 이렇게 되면 Car는 random number에 대한 의존성을 가지지 않게 되고, 테스트하기도 편해집니다.

 

2차 리뷰

처음에 리뷰 프로세스를 제대로 이해하지 못해서 마지막 리뷰 요청기간이 지난 후에 pr을 보냈다.. 그래서 소중한 리뷰어님의 2차 리뷰를 받지 못하고 머지됐다,,

비록 머지됐지만 1차 리뷰에서 충분히 많은 인사이트를 얻어서 공부할 것들이 많아져서 괜히 괜찮은 척해본다,, 다음부터는 완벽히 안되더라도 일단 pr을 날려야겠다는 생각을 하게 된 계기가 됐다,,!! 너무 아까운 내 2차 리뷰,,

 

REFERENCES

1단계 미션 1차 리뷰

1단계 미션 repository

 

728x90
profile

자바생

@자바생

틀린 부분이 있다면 댓글 부탁드립니다~😀

검색 태그