우아한테크코스 사다리 타기 회고

홍혁준·2023년 3월 12일
0

미션이 끝날 때마다 쓰는 회고다. 이번엔 레벨1의 두 번째 미션인 사다리 타기 회고이다.

미션 진행 내역

사다리 타기 Repository
https://github.com/hong-sile/java-ladder

1단계 PR
https://github.com/woowacourse/java-ladder/pull/71

2단계 PR
https://github.com/woowacourse/java-ladder/pull/169

우테코의 미션에는 각각 부제가 붙어있습니다.

자동차 경주 - 단위 테스트, 사다리 타기 - TDD와 같이요.

이번 미션 같은 경우는 TDD가 메인이었기에 많은 시도를 해보았습니다. TDD에 대한 생각은 여기에 있습니다.

그래서 이번 글에서는 사다리 타기를 어떤 단계를 거쳐서 구현하였는지를 적어보려고 합니다. 먼저 요구사항은 아래와 같습니다

요구사항

기능 요구사항

  • 사다리 게임에 참여하는 사람에 이름을 최대5글자까지 부여할 수 있다. 사다리를 출력할 때 사람 이름도 같이 출력한다.
  • 사람 이름은 쉼표(,)를 기준으로 구분한다.
  • 사람 이름을 5자 기준으로 출력하기 때문에 사다리 폭도 넓어져야 한다.
  • 사다리는 랜덤으로 생성된다.
  • 사다리 타기가 정상적으로 동작하려면 라인이 겹치지 않도록 해야 한다.
    • |-----|-----| 모양과 같이 가로 라인이 겹치는 경우 어느 방향으로 이동할지 결정할 수 없다.
  • 사다리 실행 결과를 출력해야 한다.
  • 개인별 이름을 입력하면 개인별 결과를 출력하고, "all"을 입력하면 전체 참여자의 실행 결과를 출력한다.

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

  • 모든 기능을 TDD로 구현해 단위 테스트가 존재해야 한다. 단, UI(System.out, System.in) 로직은 제외
    • 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.
    • UI 로직을 InputView, ResultView와 같은 클래스를 추가해 분리한다.
  • 함수(또는 메서드)의 길이가 10라인을 넘어가지 않도록 구현한다.
    • 함수(또는 메서드)가 한 가지 일만 하도록 최대한 작게 만들어라.
  • 배열 대신 컬렉션을 사용한다.
  • Java Enum을 적용한다.
  • 모든 원시 값과 문자열을 포장한다
  • 줄여 쓰지 않는다(축약 금지).
  • 일급 컬렉션을 쓴다.

실제 클래스 의존관계

최대한 객체의 메시지를 넘기는 방식으로 프로그래밍 하다보니, Service Layer가 필요 없어져서 위와 같이 클래스 의존관계가 형성되었습니다.

구현

사다리

처음에 페어와 어떻게 사다리를 표현해야 될까 고민을 많이 했다. 맨 처음 나온 아이디어는 다음과 같았습니다.

사다리는 줄(Line)의 집합이고, 줄(Line)은 하나의 점(빨간 동그라미)의 집합이라고 생각했습니다. 각 점은 왼쪽 또는 오른쪽을 연결한다는 의미를 부여하면 될것이라 생각하였습니다.

그리고, 각각의 점에서 양쪽을 연결할 수 없으니 한 점에서 왼쪽 또는 오른쪽을 갖는게 좋은 상태라 생각했습니다.

하지만 위와 같은 아이디어는 얼마 안가 폐기되었습니다. n-1번째의 점에서 오른쪽을 연결했다는 상태를 저장하고, n번째 점에서 왼쪽을 연결했다는 상태를 지닙니다. 똑같은 정보를 중복 저장하고 있는 것입니다. 그래서 아래와 같은 아이디어를 떠올렸습니다.

연결 여부(Link)를 List형태로 저장한 것입니다. 이러면 중복적인 정보 저장 없이 사다리를 표현할 수 있다고 생각하여 위와 같이 진행하였습니다.

이번 미션 또한 UI를 제외한 모든 단위테스트를 작성해야 했습니다.

다른 로직을 테스트하는 건 쉬웠으나, 랜덤으로 생성되는 사다리 생성 로직을 테스트하는 것이 상당히 어려웠습니다.

랜덤으로 생성되는 사다리, 그러나 조건을 곁들인…


사다리는 랜덤으로 생성됩니다. 하지만 특수한 조건이 있죠

|-----|-----| 모양이 나오면 안 된다.

그래서 저희는 다음과 같이 로직을 구성했습니다.

public Line generate(final PersonCount personCount) {
    final Deque<Link> line = new LinkedList<>();
    if (personCount.getValue() != 1) {
        line.add(linkGenerator.generate());
    }
    for (int index = 1; index < personCount.getValue() - 1; index++) {
        addValidatedLink(line);
    }
    return new Line(List.copyOf(line));
}

private void addValidatedLink(final Deque<Link> line) {
    if (line.getLast() == Link.LINKED) {
        line.add(Link.UNLINKED);
        return;
    }
    line.add(linkGenerator.generate());
}

Line을 생성하는 로직이 복잡하므로 LineGenerator라는 클래스로 분리를 하였고, 아래와 같은 역할을 수행합니다.

line에 랜덤으로 값을 추가하기 전에, 이전 원소가 연결되어 있는 상태(Linked)이면 바로 다음 상태는 연결되지 않는 상태(Unlinked)를 추가하였고,

이전 상태가 Linked가 아니라면 랜덤으로 값을 추가하였죠.

line이 deque형태인 이유는 마지막 원소를 확인하는 메서드인 getLast가 있었기에, 생성하는 Line은 deque 형태로 만들었습니다.

테스트

다른 로직은 크게 어렵지 않았습니다만, 조건이 있는 랜덤 사다리 생성 이라는 기능을 테스트하기가 젤 난감했습니다.

랜덤이라는 조건만 있었으면 테스트하기 쉬웠을 겁니다. 전략패턴을 이용해서, 랜덤성을 제어해서 테스트할 수가 있고, 이전 미션에서도 그런 식으로 테스트를 했으니까요.

하지만 이번엔 달랐습니다. 조건이 있는 랜덤 이었습니다.

심지어 이번엔 TDD로 개발을 했습니다. 그래서, 기능을 구현하기 전 테스트를 작성해야 했는데, 기능을 만들기 전 어떤식으로 테스트할 지 떠올리지 못하여 아래와 같은 방식으로 테스트를 하였습니다.

  1. 조건에 맞는 사다리를 랜덤으로 생성하는 기능을 테스트하면 어떻게 해야할까?
  2. 랜덤은 제어할 수 있어도, 조건에 맞는 랜덤은 제어하기 힘들다.
  3. 그렇다면, 아예 제어하기 힘든 부분에선 손을 놓자.
  4. 우리가 원하는건 랜덤으로 생성된 사다리가 조건에 맞는지 이고, 이것이 기능의 전부이다.
  5. 그렇다면, 생성된 사다리를 조건에 맞게 테스트하는 validation을 test class에서 짜고, 이를 repeatedTest로 100번 정도 돌리면 괜찮지 않을까…?

@RepeatedTest(100)
@DisplayName("랜덤으로 생성된 Line이 유효한지 테스트")
void randomLineValidateTest() {
    int personCount = 4;
    final Line generatedLine = lineGenerator.generate(personCount);
    Assertions.assertDoesNotThrow(() -> validateLine(generatedLine));
}

private void validateLine(final Line line) {
    Link pastLink = Link.UNLINKED;
    for (final Link link : line.getLinks()) {
        pastLink = comparePastPointAndPresentPoint(pastLink, link);
    }
}

private Link comparePastPointAndPresentPoint(Link pastLink, final Link link) {
    if (link.isLink() && pastLink.isLink()) {
        throw new IllegalArgumentException();
    }
    pastLink = link;
    return pastLink;
}

음 다시봐도 정말 바보 같은 생각입니다. 하지만 그 당시에 떠올린 다른 마땅한 아이디어는 없었기에 처음에 위처럼 진행하였습니다.

뭐 어찌어찌 잘 동작하기는 했습니다. 실제로 사다리를 생성하는 로직을 변경하고 테스트를 돌릴 때, 잘못 생성된 케이스를 잡아주기도 하였습니다. 제대로 된 아이디어를 떠올리기 까지 임시방편정도의 역할은 충분히 해주었습니다.

페어 프로그래밍 때는 위와 같이 마무리했지만, 너무 너무 찝찝한 코드였습니다. 실제로, 위와 같은 방식으로 작성한 코드의 신뢰성이 떨어져 저희는 코드를 변경하고 나서도 여러번 테스트를 실행하였습니다.

그렇게 찝찝한 상태로 페어 프로그래밍이 끝나고, 리뷰를 받고나서 생각이 떠올랐습니다.

테스트에서 랜덤으로 생성된 사다리가 조건에 맞는지 를 테스트하는 것이 아니라,
라인을 생성하는 로직이 내가 원하는 방식대로 동작하는지를 테스트하는 것이었습니다.

@Test
@DisplayName("generate 메서드가 조건에 맞는 라인을 반환하는지 테스트")
void generateTest() {
    //given
    final List<Link> input = List.of(Link.LINKED, Link.UNLINKED, Link.LINKED);
    final LineGenerator lineGenerator = new LineGenerator(new TestLinkGenerator(input));
    //when
    final Line line = lineGenerator.generate(new PersonCount(5));
    //then
    Assertions.assertThat(line.getLinks())
            .containsExactly(Link.LINKED, Link.UNLINKED, Link.UNLINKED, Link.LINKED);
}
//랜덤으로 값을 추가할 때 연결, 비연결, 연결이 반환되면 실 로직에서는
//연결, 비연결, 비연결, 연결이 반환되어야 한다.

테스트하는 대상을 추상화된 목적(랜덤으로 생성된 사다리가 조건에 맞는지)이 아닌, 딱 라인을 생성하는 로직이 내가 원하는 대로 동작하는지를 테스트 하는 것이었습니다.

여기서 TDD의 단점을 느낄 수 있었습니다. expected 값이 내가 제어할 수 없는 값일 때, 우리는 기능을 작성하기전 Test 코드를 작성하는 것이 굉장히 어렵습니다.

나중에 더 나은 방법으로 테스트할 수 있는 것은 제가 조건에 맞는 라인을 생성하는 로직(LineGenerator.generate)을 구현했고 ,해당 로직이 어떻게 동작했는지 알 수 있었기 때문입니다.

TDD 단계에서는 절대로 나중과 같은 로직을 작성할 수 없죠. 작성했다면, 이미 구현 방안이 머릿속에 다 있었던 것과 마찬가지일 것입니다.

하지만 TDD에도 장점은 있습니다. 생성하는 로직이 원하는 방식으로 동작하는지 로 테스트했을 때의 단점은 다음과 같습니다.

만약 구현되는 로직(LineGenerator.generate)의 내부가 달라진다면? 로직을 테스트하는 것은 정상적으로 동작하지 않을 것입니다.

하지만, 해당 로직의 목적인 랜덤으로 생성된 사다리가 **조건에 맞는지** 를 테스트한 로직은 내부 구현이 바뀌어도 정확도는 떨어질 지언정 이전과 비슷하게 정상적으로 동작했겠죠.

확실히 TDD는 기능을 추상화하고, 이를 검증하는 로직을 먼저 구현한다는 점에서 내부구현이 변경되는 것에서 자유롭지만,

제어할수 없는 값이 오는 경우는 기능을 추상화 하기 어렵기 때문에, 이 떄는 사용하기 힘든 것 같습니다.

각각의 상황에 맞춰서 이용해야 할 것 같습니다.

유저와 상품들

유저와 상품들은 크게 어렵지 않았습니다.

유저와 상품은 각각 이름을 가지고 있고, Users와 Prizes라는 일급 컬렉션에서 List형태로 관리하였습니다.

사다리 타기 게임 실행

이 부분이 이제, 2단계의 메인 입니다. 생성된 사다리를 가지고 사다리 게임을 실행하게 하는 것이죠.

처음 떠올렸던 아이디어는 다음과 같습니다.

  1. User와 Prize는 Position이라는 상태를 갖는다.
  2. Ladder에서 한 Line씩 순회한다.
    1. Line에서 한 Link씩 순회한다.
      1. Link가 연결되어있다면 인접한 Position에 있는 두 User의 Position을 swap한다.
      2. 연결되어 있지 않다면 swap하지 않는다.
  3. Ladder에서 순회가 끝나면, User의 Position과 일치하는 Position을 가진 Prize를 반환한다.

사다리 게임을 하나의 라인을 여러번 반복하는 것으로 쪼개고, 하나의 라인을 각각의 Linked에 따른 연산으로 분리하였습니다. 이렇게 하면 한 번만 순회하면 모든 결과를 얻을 수 있었죠.

그렇게 위와 같은 방식으로 구현을 진행하다가 하나를 깨달았습니다.

아 어차피 Users에서 List로 들고 있고, List는 순서가 포함되어있는 Collection이니, Position의 역할을 List가 대체해줄 수 있겠구나.

심지어 Collections.swap이라는 위치를 바꿔주는 아주 좋은 API도 있었습니다. Prize도 List형태로 저장하고 있었으니, 순서가 내포되어있는 건 동일해서 Prize에서도 Position을 빼주었습니다.

그래서 초기에는 User와 Prize가 Position을 들고 있었지만, 순서라는 책임을 List에게 위임하여, 구현하였습니다.

그러다보니 1단계에서 2단계로 넘어갈 때 클래스가 단순하게 상품을 나타내는 Prize와 Prizes만 추가하고, 구현하였습니다.

미션하다 생긴 궁금증 및 나의 생각

TDD로 미션을 진행할때의 커밋 단위

우테코에서는 git commit convention을 지키는 것이 기본 요구사항으로 들어가 있습니다.

git commit convention에는 기능을 추가해을 때 붙이는 feat와 test를 추가했을 때 붙이는 test가 있습니다.

TDD로 진행할 때는, test를 작성하고나서, 기능을 작성하고, 이를 리팩터링 하기에, 각각의 단계마다 한 번씩 커밋을 해야 하나? 라는 의문이 들어 페어와 논의를 해보았습니다.

페어와 제가 내린 결론은 TDD 사이클이 끝나고 기능 구현이 완료 된 상태를 feat로 커밋하는 것이었습니다.

테스트로 만든 기능이 완성되는 것 까지가 기능 구현 단계라고 보았기에 위와 같이 결정하였습니다.

또, 어떤 커밋 단계로 돌아가도, 기능이 정상적으로 작동해야 한다고 생각하여, TDD 사이클이 끝난 후 feat로 커밋하였습니다.

오버엔지니어링

현재 필요한 것 보다 더 과하게 제품을 디자인 하는 것이다. 즉, 제품을 더 견고하게 만들거나, 더 복잡하게 만드는 것이다. 핵심개념만을 담아 최대한 단순하게 만들자는 최소주의와는 대비되는 개념이다.
보통 오버엔지니어링 되어 있으면 이후 제품을 운영 할 때 어려움을 줄 때도 많다. 단순한 구조에서는 간단히 할 수 있는 일을 더 복잡하게 해야 하는 등이 일이 발생하기 때문이다.

위 궁금증은 자동차 미션에서 부터 있었던 궁금증이었습니다.

저는 확장성이 좋은 코드가 무조건 좋은 코드라 생각했습니다. 하지만, 오버 엔지니어링에 대해 알고 나서 그렇지 않다는 것을 깨달았습니다. 어떠한 변화에도 대응할 수 있으니까요.

오버엔지니어링이란 개념을 알게되고 저는 다음과 같은 새로운 궁금증이 생겼습니다. 

어디까지가 오버 엔지니어링이고, 어디까지가 확장성을 고려한 개발일까?

도저희 답이 안나와서 리뷰어께 여쭤봤습니다.

위 답을 듣고 YAGNI 원칙에 대해 알아봤습니다.

실제로 필요할 때 무조건 구현하되, 그저 필요할 것이라고 예상할 때에는 절대 구현하지 말라.
-론 제이프스

법칙 자체는 단순합니다. 필요할 것이라 예상만 되는 상황에선 만들지 말아라.

크루들은 미션을 진행할 때 다양한 것을 고려합니다. 만약에 룰이 바뀐다면? 만약에 유저가 엄청나게 많아진다면? 등 다양한 것을 고려하여, 캐싱과 같은 여러 기술들을 적용하죠. 적용한 기술 중엔 현 상황에선 필요가 없는 기술들도 몇몇 있습니다.

위와 같은 개념을 공부하다보니 최근에 강의에서 네오가 말해준 “자바는 블루칼라 언어다.” 라는 말이 더 깊게 다가오는 것 같아요. 요구사항을 구현할 때 시간과 비용을 최대한 절약한다는 의미니까요.

아직도, 어디까지가 오버엔지니어링이고 어디까지가 적정엔지니어링인지 명확하진 않습니다. 하지만 YAGNI 원칙을 한번 나 자신에게 물어보면서 오버엔지니어링이 많이 줄었다고 생각해요. 앞으로도 많이 배워보고, 기준을 세워 나갈 것 같습니다.

view의 static 메서드

위 질문은 리뷰어께서 먼저 던진 질문으로 시작하였습니다.

그에 대한 제가 생각한 답은 다음과 같습니다.

static 키워드를 붙인 이유

  1. InputView, OutputView가 상태를 지니고 있지 않기에, static으로 선언해도 문제가 없습니다.
  2. InputView, OutputView의 메서드들이 멱등성을 보장합니다.
    (몇 번을 반복하든 똑같은 값을 입력하면, 동일한 결과를 반환합니다.)
  3. 추후 InputView와 OutputView에 의존하는 객체가 많아진다면(controller가 여러 개가 된다면), 각 객체에서 불필요하게 view를 생성할 필요 없이, 바로 static으로 접근이 가능하기 때문에 static이 더 효율적입니다.
    (이 부분은 오버엔지니어링일수도 있을 것 같습니다. 현재 접근하는 곳이 하나기 때문에...)
  4. 현재 로직에서 유효하지 않은 입력을 하였을 경우, 다시 입력을 받도록 구현하였습니다. 이렇 듯 InputView의 메서드가 반복적으로 호출이 되는데, 메모리 낭비를 줄이기 위해 static 키워드를 붙였습니다.(non-static 메서드는 호출될 때마다 메모리를 할당 받고, 해제하기 때문에)
  5. 편합니다…

그리고, 제가 생각해봤을 때, static 키워드를 붙였을 때 단점은 다음과 같습니다.

메서드에 static 키워드를 붙이면서 발생할 수 있는 단점

  1. static 메서드는 컴파일 할 때 메모리에 올라가고, 프로그램 종료시에 해제되기 때문에, static 메서드를 호출하지 않는다면, 불필요한 메모리를 낭비하는 것입니다.
    → 하지만 view의 메서드는 현 프로그램에선 무조건 호출됩니다.
  2. 추후 inputView에 대한 테스트(System.in)를 진행할 때, mocking을 해야만 테스트를 진행할 수 있다.Scanner는 생성할 때 System.in을 인자로 받아 생성되고, static member로 생성하면 생성 시점은 컴파일 단계이기에, 입력값에 대한 설정(System.setin)을 할 수 없습니다.
    이 부분은 이전 자동차 경주 미션에서 겪고, 결론을 내린 문제입니다.

저는 InputView와 OutputView가 Util처럼 바라봤습니다.

utils와 view가 다른 점이라 한다면, 코드 외부(입출력)에 영향을 주거나 받냐의 차이 인 것 같은데,이로 인해서 발생할 수 있는 문제가 없다고 생각하여, static을 붙였다고 답을 드렸습니다.

그에 대한 리뷰어의 답변:

조금 더 강력하게 Model에서 View를 접근하는 경우를 막을 수 있을 것입니다. InputView하고 점(.)을 눌렀는데 바로 메서드가 접근이 가능하면, 그런 실수도 발생할수도 있을 것 같다는 생각이 드네요.

static을 붙였을 때의 이점을 간단히 정리하면 메모리와 편안함인데, 이를 제거하면, 객체를 생성하는 과정이 생기긴 하지만, 위와 같은 실수가 발생할 여지가 없어지긴 합니다.

지금은 개인으로 코딩하기에 static을 붙여 사용하고 있지만, 확실히 팀으로 가면 제거할 것 같습니다.

총정리

이번 미션은 나름 무난하게 흘러갔습니다. 이전 자동차 경주 미션에서 겪고 떠올린 사항들을 바로 적용했고, 구현도 괜찮았으니까요.

domain의 값들을 어떻게 view에 넘겨줄지에 대한 고민이 있지만, 여러방법을 써보고 더 나은 방법을 적용해볼까 합니다.

profile
끊임없이 의심하고 반증하기

0개의 댓글