[우아한테크코스] 프리코스 1주 차: 숫자야구 회고


우테코 프리코스 과정이 시작됐고, 첫 미션이 공개됐다. 1주차 미션은 숫자야구 게임. 어릴적에 시골 가는 기차에서 가족끼리 종이 한 장과 펜 하나면 즐길 수 있는 간단한 게임이었다. 하지만 막상 이걸 구현하라고 하니 어디부터..? 라는 생각부터 들었다 😂 우리가 알고 있는 숫자야구 게임을 코드로 적어야 한다. 랜덤하게 세 자리 수를 고르는 것부터, 스트라이크/볼 판정 등 구현해야 할 게 많았다.

사실 구현할 게 많았다는 말보다, 모두 구현해야 한다는 말이 정확한 것 같다. 해당 깃허브의 소스코드에는 아무것도 없었다. 정말 아무것도 없었다.

네? 이게 전부라고요?

이제 우리는 던져졌다. 직접 어떤 방식으로 구현해야 할 지 생각해야 했다. 생각의 방향을 알려주는 몇 가지 글들이 미션에 적혀 있었기에, 우선 그것부터 읽어 보았다.

미션은 기능, 프로그래밍, 과제진행 세 가지로 구성돼 있었고, 세 가지 모두 중요했다. 기능에서는 내가 구현해야 하는 기능의 목록이 추상적으로 나열돼 있었고, 프로그래밍에서는 기본적인 포매팅, JDK 버전 등이 명시돼 있었다. 과제 진행은 제일 간단했지만 가야할 길을 희미하게나마 알려주고 있었다. 이 부분을 읽고 나서 어떤 일부터 해야할 지 정리할 수 있었다.

코드를 직접 손으로 타이핑해보기 전에, 주어진 구현 기능을 목록으로 적어야 할 필요가 있었다. 이 기능 목록을 작성하는 것이 나의 첫 미션, 첫 번째 커밋이 되었다.

기능 문서를 작성하는 데에만 꼬박 몇 시간이 걸렸다. 전체적인 게임 흐름을 주욱 늘어놓는 것부터가 시작이었다. ‘객체지향이라고 생각한다면?’ 에서 시작해서 작성해보고자 했었지만, 객체지향에 대한 뚜렷한 이해가 부족해 갈피를 잡기 어려웠다. 기능 문서를 기반으로 모든 구현이 시작되기에, 최대한 수정되지 않도록 간결하게 문서를 작성해야 했다. 결국 ‘게임의 관점’에 대해서 늘어놓기로 했다. 사용자와 컴퓨터가 해야하는 일, 그리고 중간 단계에서 게임 진행을 해주는 진행자가 있다고 가정하고 문서를 작성하니 이전보다는 수월하게 작성됐다.

기능 요구 사항에 기재되지 않은 내용은 스스로 판단하여 구현한다.

미션 안내문구 중, 제일 마음에 걸렸던 문장이었다. 기능 요구사항에서 모호한 부분이 있는지 꼼꼼히 확인하고, 모호한 부분을 문서로 정리해야 했다. 처음에는 아무것도 구현하지 않았던 상태라, 결정해야하는 것이 생길 때마다 문서를 수정해나가기로 했다.

고민했던 구현 사항

구현을 막상 해 두고 내 코드를 보니, 몰라서 건드리지 못 하는 부분이 꽤나 되는 것 같았다. 다른 방법으로도 코드를 작성할 수 있을 것 같은데, 그 방법이 옳은지 알지 못하니 더 망설여졌다. 아래 질문들은 1주차 미션을 진행하면서 스스로에게 던진 질문과 답변이다.

0. 어떻게 나누어서 구현할까?

사실 제일 먼저 떠오르고, 코드가 완성되고 리팩터링이 진행될 때까지 집중해야 하는 질문이다. 우선 앞서 이야기한 ‘관점’에 초점을 맞춰서 클래스를 나누었다. 어떻게 보면 행동을 기준으로 나누었다고 해도 되겠다. 사용자는 입력을 통해 값을 주니까 따로 프로그램이 할 일은 없다. 컴퓨터가 정답을 결정하고 주어진 추측과 자신의 답을 비교해 계산하는 것, 사용자의 입력을 받아내는 것, 입력들을 검증하는 것 등을 모두 나누기로 했다.

1. 검증 메서드를 static으로 만들어 바깥으로 빼야 할까, 클래스 내부에 두어야 할까?

이번 미션 중에서는 다양한 검증이 필요했다. 사용자가 입력하는 수가 1부터 9 사이의 숫자인지, 각 숫자가 겹치지는 않는지, 메뉴를 입력한다면 1 또는 2만 주어졌는지 확인할 필요가 있었다. 처음 문제를 해결하려고 할 때에는 PlayerInput 에서 직접 검증을 도맡았다. 구현을 끝내고 보니, 이런 의문이 들었다.

무분별한 static 사용은 지양해야 하지만, 지금의 경우에는 사용해도 되지 않을까?

static으로 메서드를 끄집어내는 것은 객체지향과는 거리가 멀기도 하고, 지양하는 방법 중 하나이다. 이 글에서 더자세한 내용을 확인할 수 있다. 사용을 지양해야 하는 여러 이유가 있지만서도, 궁극적으로 static을 사용해도 되겠다! 라고 생각했던 데에는 다음과 같은 생각이 있었다.

  • 입력과 검증은 서로 다른 클래스로 떼어낼 수 있다. 입력에서는 단순히 입력받는 데 집중할 수 있게 된다.
  • static 메서드는 객체의 상태와 상관없어야 한다. 검증 메서드는 어떤 객체의 상태를 검증하는 것이 아니라, 주어진 값이 올바른 값인지, 아닌지를 검증해내야 하므로 객체의 상태와는 무관하다.

이러한 이유로 검증 메서드를 담당하는 메서드를 묶어 클래스로 나타냈다. 코드의 일부는 아래와 같다.

public class NumbersInputValidator {
    ...
    public static boolean validatePlayerNumbers(List<Integer> playerNumbers) {
        return validateNumberCount(playerNumbers) &&
                validateDistinctNumbers(playerNumbers) &&
                validateNumbersInRange(playerNumbers);
    }
    ...
}
2. 예외를 던지는 것은 누가 담당할까? 검증 메서드가 직접 예외를 던져도 될까, 검증 성공/실패 여부를 나타내고 이를 받아내서 예외를 던져야 할까?

이 부분은 아직 고민중인 문제라서 리뷰를 받아보면서 함께 고민해보고 싶은 문제 중 하나이다. 현재 검증 메서드들은 모두 검증 성공/실패 여부를 다루는 boolean 메서드로 이루어져 있다. 검증을 위해 메서드를 부르게 되는 객체가 직접 검증 결과를 수합해 예외를 던질지, 던지지 않을지를 판단하는 식이다.

하지만 이 경우 추가적인 제약사항이 생긴다면 위 메서드에 많은 검증 메서드가 모두 AND 연산자로 모두 묶여야 하지 않을까.. 라는 생각을 했다. 제약사항이 많아질수록 더 유지보수하기에 어려워질 코드는 확실해 보인다. 그래서 생각했던 것이 각 검증 메서드에서 예외를 던지는 방식이었다. 각각의 검증 메서드에서 검증에 통과하지 않는다면 예외를 던지는 식으로, 각각의 예외에 대한 커스텀도 할 수 있겠다는 생각을 했다.

하지만 결국 자체적으로 검증하고, 나아가 성공/실패 여부를 판단하는 객체는 입력을 받는 객체이다. 요구사항이 이미 정해져있기도 하고, 입력을 받는 입장에서 예외를 발생해주는 것이 더 객체지향과 어울린다고 생각해 위와 같은 코드를 작성하게 되었다.

구현 중 발견한 문제점

1. 숫자야구 게임의 수 범위, 자릿수가 하드코딩돼 있었다

확장성을 고려하면서 발견한 문제점 아닌 문제점. 숫자야구는 세 자리로 진행하지만, 종이로 숫자야구를 할 때에는 네 자리, 다섯 자리로도 확장해서 다른 친구들과 즐겼던 기억이 있다. 이를 대비하기 위해서 비교할 때마다 3으로 하드코딩 되어 있던 것을 상수로 바꿔두었다. 마찬가지로 수의 범위 또한 제한할 수 있도록 설정하고, 항상 출력하는 메시지 등도 상수로 바꿔두었다.

상수의 위치가 또 하나의 고민거리였는데, 상수의 종류별로 그에 해당하는 클래스에 두어야할 것 같았다. 우선적으로 숫자의 범위, 자릿수는 게임의 설정config에 해당하므로 GameClient에서 담당하도록 하고, 그 외의 상수는 각각의 클래스가 가지도록 했다.

2. HashSet을 사용해 랜덤하게 정답을 만들었다

평소에 알고리즘 문제풀이를 좋아해서 생긴 실수였다… 컴퓨터가 정답을 만들어내는 로직을 처음에는 아래와 같이 작성했다. 중복이 포함될 수 없다는 점을 이용해 HashSet으로 만들었다.

private List<Integer> createAnswer() {
    HashSet<Integer> answerNumbers = new HashSet<>();
    while (answerNumbers.size() < BALL_LENGTH) {
        int pickedNumber = Randoms.pickNumberInRange(RANGE_MINIMUM_VALUE, RANGE_MAXIMUM_VALUE);
        answerNumbers.add(pickedNumber);
    }
    return new ArrayList<>(answerNumbers);
}

문제점이 보이는가? 사실 나도 관련된 문서를 읽고, 직접 확인하는 코드를 작성하기 전까지는 아무것도 모르고 있었다. 실제로 몇 번 플레이해보면서 컴퓨터가 생성하는 랜덤한 숫자들이 항상 정렬돼있다는 점이 이상하다는 낌새를 알아차리기 전까지는 ‘잘 짰네 ㅎㅎ’라고 다시 돌아보지 않은 코드였다.

HashSet은 Bitwise AND 연산을 사용해 버킷을 특정하고, 해당 버킷으로 들어가 LinkedList 형태로 연결되기에 들어간 순서와 순차방문하는 순서가 같음이 보장되지 않는다. 우연히 삽입된 원소가 숫자 순서대로 들어갔었기에 들어가는 순서대로 ArrayList에 반영될 것이라고 생각했던 게 좋지 않은 판단이었다. 결국 선택된 숫자들을 관리하는 것을 HashSet으로, 선택할 때마다 정답 배열에 추가하는 방식으로 구현했다.

private List<Integer> createAnswer() {
    List<Integer> answerNumbers = new ArrayList<>();
    HashSet<Integer> chosenNumbers = new HashSet<>();
    while (chosenNumbers.size() < BALL_LENGTH) {
        int pickedNumber = Randoms.pickNumberInRange(RANGE_MINIMUM_VALUE, RANGE_MAXIMUM_VALUE);
        if (!chosenNumbers.contains(pickedNumber)) {
            chosenNumbers.add(pickedNumber);
            answerNumbers.add(pickedNumber);
        }
    }
    return answerNumbers;
}

3. 검증하는 역할을 가지는 클래스가 new를 사용해 객체가 될 수 있었다

앞서 설명했던 static 메서드로 이루어진 검증 클래스에는 별도의 조치가 없었다. 바깥에서 해당 클래스를 객체로 만들 수 있다는 의미가 된다. 우테코에서 제공해주는 Console은 private 생성자를 통해 바깥에서 객체가 생성되는 것을 막아두었다. 나도 해당 부분을 반영해 비어 있는 private 생성자를 추가했다.

4. 스트라이크/볼의 정보를 담는 객체가 최종 상태까지 도달하기 위해 addStrike(), addBall()을 호출해야 했다

기존에 스트라이크/볼 여부를 알려주도록 하는 클래스는 아래와 같이 작성했다. 이 때에는 생성자에서 필드를 초기화해야 좋을지, 클래스 위쪽에서 초기화하는 것이 좋은지에 대한 고민도 함께했다. 고민 끝에 내린 결론은 다른 생성자로 인해서 변하지 않는 값이라면, 필드 초기화 방법도 좋은 방법이라는 것. 어차피 해당 객체는 무조건 만들어질 때 0/0으로 시작하므로 추가적인 생성자 작성이 불필요하다고 느꼈다. 하지만 이 구현은 오래 가지 못했는데…

public class BallCount {
    ...
    private int strike = 0;
    private int ball = 0;

    public void addStrike() {
        this.strike++;
    }

    public void addBall() {
        this.ball++;
    }
    ...
}

밖에서 스트라이크, 볼을 addStrike(), addBall()을 통해 조작할 수 있다. 단순히 스트라이크와 볼의 개수를 늘리는 메서드니, 어떻게 보면 setter 메서드와 다를 게 없어 보였다. 바깥에서 충분히 조작될 가능성이 높았고, 메서드가 메시지의 역할을 한다기보다는, 최종 상태를 나타내기 위한 하나의 과정이었다. 이 중간 상황이 객체의 상태에 반영되면 안 된다고 생각했다. 이런 이유로 스트라이크/볼 판정은 외부에서 진행하고, 해당 객체는 생성될 때 초기화되도록 했다. 단순히 해당 스트라이크/볼 카운트가 아웃을 의미하는지, 낫싱을 의미하는 지 등의 메서드만 존재하도록 간소화했다.

public class BallCount {
    ...
    private final int strike;
    private final int ball;

    public BallCount(int strike, int ball) {
        this.strike = strike;
        this.ball = ball;
    }
    ...
}

해치웠나..?

… 여기까지가 구현의 전부인 줄 알았지만, 한숨 자고 일어나서 코드를 봤을 때는 또 만족스럽지 못한 부분이 있었다. 아래 코드는 GameClient에서 스트라이크/볼을 가져오도록 컴퓨터에게 명령하는 코드의 일부이다.

List<Integer> playerNumbers = playerInput.getPlayerNumbers();
ballCount = computer.calculateBallCount(playerNumbers);
System.out.println(ballCount);

게임 흐름을 생각해보면, 컴퓨터 자체가 계산을 진행하는 것이 올바르게 보이기도 하다. 하지만 두 가지 이유로 해당 코드가 문제라고 생각했고, 리팩터링으로 코드를 크게 뜯어고쳤다.

첫 번째로는 게임의 흐름을 담당하는 곳에서 플레이어의 숫자들을 List 형태로 가져오기에 외부에서 변형될 우려가 있었던 점이었다. 외부에서 값을 바꾸지 못하도록, 그리고 추상화한 객체로 이를 바라볼 수 있도록 BaseballNumberSlot 클래스를 만들었다. 이 클래스에서는 단순히 리스트를 가지고 있고, getcontains 메서드를 열어 둬 리스트에 접근해서 확인하는 것은 진행할 수 있게 했다.

두 번째로는 게임의 흐름이 정말 코드에 잘 반영되었는가? 에 대한 고민이었다. 게임을 진행하는 진행자는 두 사람의 수를 모두 알고 있고, 두 수에 기반해 스트라이크/볼을 판정한 뒤 힌트를 제공하는 것이다. 하지만 위 코드에서 볼 수 있듯이, 플레이어에게서 값을 가져온 뒤 컴퓨터에게 계산을 하라고 시킨다. 컴퓨터와 나는 서로 동등한 위치인데, 컴퓨터에게 짊어질 책임이 하나 더 늘어난 셈이다.

이를 해결하기 위해 우선 PlayerComputer의 권한을 같게 맞춰줄 필요가 있었다. 둘 다 게임의 참가자이므로 Participant라는 상위 클래스를 두었고, 각각 BaseballNumberSlot을 가지도록 했다. 이렇게 하면 각각의 플레이어가 종이 한 장을 들고 있는 것과 같은 느낌이 된다.

public abstract class Participant {
    protected final BaseballNumbersSlot numbersSlot;
    ...
}

이제 둘은 서로 자신의 종이에 생각한 수를 적는 행위밖에 하지 못한다. 나머지는 밖에 있는 게임 진행자에게 맡겨야 한다. 게임 진행자의 도움을 위해 심판의 개념을 추가해보기로 했다. Referee가 스트라이크/볼 판정을 맡고, 위에서 설명한 BallCount 객체를 반환토록 했었다.

public class Referee {
    public BallCount calculateBallCount(BaseballNumbersSlot playerNumbers, BaseballNumbersSlot answerNumbers) {
        ...
    }
}

하지만 다시 생각해 보면, 이 행동은 단순히 심판이 두 개를 보면서 BallCount를 던져주는 역할이다. 심판 객체가 가지는 정체성이 없는 듯했다. 심판만이 할 수 있는 일이 아니었고, 돌아오는 값 또한 0스트라이크/볼의 상태를 나타내는 것에 불과했기 때문이다. 이 메서드는 충분히 BallCount 클래스에서 생성자 대신 사용돼도 충분할 것 같았다. 따라서 불필요한 심판을 없애고, BallCount 클래스에서 두 개의 종이를 받아 볼 상태를 나타내는 정적 메서드를 만들어 두었다. 객체의 상태와 무관한 클래스이기 때문에, 고민 끝에 static을 사용해도 좋겠다고 판단했다.

public class BallCount {
    ...
    public static BallCount calculateFrom(BaseballNumbersSlot playerNumbers, BaseballNumbersSlot answerNumbers) {
        ...
        return new BallCount(strikeCount, ballCount);
    }
}
리팩터링, 리팩터링, 리팩터링, 리팩터링

그렇게 완성된 코드는 미션 제출로 향했다! 예제 테스트 결과 두 개뿐이었기 때문에, 다음에는 꼭 다른 테스트도 넣어서 코너케이스를 확인해봐야겠다 싶었다. JUnit5가 기본적으로 포함돼 있었기 때문에, 이를 이용하면 좋을 것 같다!

시험공부 좀 하겠습니다 🏃🏻‍♂️

그래서, 지금까지 어땠냐고요?

한 주동안 온전히 프리코스에 몰입했다고 한다면 거짓말이다. 학교 공부와 ICPC 예선 준비가 겹치기도 했고, 주최하고 있는 교내 대회를 준비하느라 매일같이 코드를 작성하거나 리팩터링에 몰두하지는 못했다. 다만…

너무 재미있다! 한 주동안 몰입하지는 못했어도, 프리코스를 진행하는 동안만큼은 온전히 집중했다! 아직 다른 사람들은 코드를 어떻게 짰는지 아직 알 수는 없지만, 1주차 마감이 된 뒤에 여러 사람들에게 나의 코드를 리뷰해달라고 요청하기도 하고, 다른 사람들의 PR에 리뷰를 달아서 생각을 물어보고 싶은 마음이 가득하다..!

아마 몇천 명의 사람들이 동시에 진행하니만큼 각각 인원에 대한 세부적인 판단이 어려울 수 있을 것 같다고 생각하는데, 그런 부담에도 프리코스를 모든 인원에 대해 오픈해준 우테코에 무한한 감사를…! 외적 동기이긴 하지만, 덕분에 관련된 책도 사서 읽고, 레퍼런스 문서도 찾아서 읽어보는 좋은 시간을 가졌다!

테스트케이스가 작성돼 있어서 한번 확인해보긴 했다. 우테코 쪽에서 직접 작성한 ConsoleRandoms 패키지를 구경하면서 이런 식으로 테스트에 용이하게 과제를 만들 수도 있구나, 놀랐다. 이번 과제에서는 테스트 쪽을 만지면 안 되는 줄 알고 구현에만 집중했는데, 다른 분들의 PR을 보니 테스트 코드도 정성스럽게 작성해두신 것 같았다. 돌아오는 미션에서는 테스트까지 집중적으로 잘 짜봐야지~!


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

References

https://stackoverflow.com/questions/4916735/default-constructor-vs-inline-field-initialization
https://stackoverflow.com/questions/2671496/when-to-use-static-methods
https://mangkyu.tistory.com/147
https://stackoverflow.com/questions/1504302/is-it-a-good-or-bad-idea-throwing-exceptions-when-validating-data
https://tecoble.techcourse.co.kr/post/2020-07-16-static-method/

Categories