[우아한테크코스] 프리코스 3주 차: 로또 회고


2주 차에 진행했었던 자동차 경주에 이어서, 이번 주 차 미션은 로또였다. 생각보다 간단한 문제였는데 요구사항은 신경써야 할 것이 많았다. 저번 주 자동차 경주에서 많은 사람들에게 내 코드를 보여드리고 리뷰를 받았는데, 다 됐다! 라고 생각해서 제출한 부분도 다시 보니 개선할 부분이 있었고… 그런 부분이 너무나도 많았고… 결국에는 다 벗겨진 나의 코드가 되었다. 멋쟁이 코드를 작성하기 위해서는 좋은 동료들이 꼭 필요하다는 것을 다시금 느꼈다.

소중한 사람들

이번 주에는 지난 2주보다 프리코스에 쏟을 시간이 많지 않았다. 학교에서 프로그래밍 대회를 개최하는 게 토요일에 예정돼 있었기 때문에, 그 전까지는 이런저런 준비로 바빴다. 잠을 줄여가며 완성해 냈으니 성공한 걸까.. 4주 차는 얼마나 더 어렵게 나올까 걱정이 되기도 했다. 지금까지의 3주 프리코스 과정과 다르게 더 많은 사람들이 4주동안 같은 미션을 돌파해나가는 것을 보면서 서로에게 동기부여가 된 것 같다. 코드리뷰도 받으면서 같은 실수를 하지 않기 위해서 의식하기도 한다.

2주 차 피드백으로 주어졌던 문서도 한 번 훑었는데, 이미 알고 있지만 의식하지 않으면 쉽게 놓칠 수 있는 사항이었다. 특히나 변수 이름에 자료형을 사용하지 않는다라는 부분은.. 저번 주에 실수했던 부분이기도 했다. 어떻게 보면 많은 사람들이 하는 실수를 이곳에서 바로잡아 주는 것 같아서 하나의 동아줄 같은 느낌이랄까.. 이번 주에는 살아있는 문서를 만들기, 구현 컨벤션과 관련된 내용이 많았다. 문서를 만들고 먼지쌓이게 두지 말고, 구현을 해나가면서 문서를 수정하기도 하고, 기능 구현을 계획했던 것을 변경해나가기도 하면서 숨을 불어넣으라는 의미가 있었다.

이번에는 제공해주는 코드를 토대로 살을 붙여나가는 것이었는데, 제공해주는 코드도 사실은 아무것도 없는 것과 다름없었다(…) 처음 코드를 보고 나서 느꼈던 것 중 하나는, Lotto 안에 Integer 타입이 들어있었던 것이었다. 로또 공에 대한 검증(공의 수는 1부터 45)은 로또의 책임이 아니라, 공 하나하나의 책임이라고 생각했기에, 이를 클래스로 감싸서 클래스에서 열어주는 메서드를 사용해서 접근하는 게 더 낫다고 생각했다. 사실 미션에는 아래와 같은 요구사항이 있었다.

  • 제공된 Lotto 클래스를 활용해 구현해야 한다.
  • numbers의 접근 제어자인 private을 변경할 수 없다.
  • Lotto에 필드(인스턴스 변수)를 추가할 수 없다.
  • Lotto의 패키지 변경은 가능하다.

해당 요구사항에는 필드를 추가할 수 없지만, 필드의 타입에 대한 명시는 없었기에 numbersLottoBall이라는 클래스를 만들어 진행했다. 나아가 인스턴스 변수가 아닌, 클래스 변수를 추가해서 전체 검증을 위해 사용하고자 했다.

public class Lotto {
    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        validate(numbers);
        this.numbers = numbers;
    }
...
}
public class Lotto {
    public static final int LOTTO_BALL_COUNT = 6;

    private final List<LottoBall> numbers;

    public Lotto(List<LottoBall> numbers) {
        validate(numbers);
        this.numbers = numbers;
    }
}

이렇게 하는 게 맞는 것 같아서 진작 소신있게 질렀어야 했는데, 한 번 프리코스 문의사항으로 물어봐서 하남자(?)식으로 진행하게 된 것 같아 아쉽다. 어쨌든 내가 보다 나은 코드를 위해서 나아가는 거면 과감하게 진행했어야 했다. 1, 2주 차에서도 요구사항에 적히지 않은 내용에 대해서는 알아서 판단하라는 말을 그렇게 잘 들어놓고, 왜 여기에서 많이 고민했을까..

로또 공 객체, 싱글톤으로 관리하면 어떨까?

미션을 진행하다 문득 재미있는 생각이 들었다. LottoBall은 변하지 않는 친구이고, 1부터 45까지의 번호가 부여되면 다른 일 없이 그냥 생성되었다가 사라지기를 반복한다. 로또를 많이많이 사면 그만큼 공 객체가 많이 생성되고, 오버헤드가 늘어날 것 같았다. 그래서 생각했던 것이 싱글톤으로 로또 공 객체를 관리하는 것이었다. HashMap을 통해 이미 번호에 해당하는 공이 존재한다면 가져다 쓰고, 그렇지 않으면 새로 만들어 로또 공 풀에 추가하는 방식이었다.

커밋 링크

public class LottoBallFactory {
    private static final Map<Integer, LottoBall> lottoBallPool = new HashMap<>();

    private LottoBallFactory() {
    }

    public static LottoBall grabFromPool(int number) {
        return lottoBallPool.get(number);
    }

    public static boolean isExists(int number) {
        return lottoBallPool.containsKey(number);
    }

    public static void addToPool(int number, LottoBall lottoBall) {
        lottoBallPool.put(number, lottoBall);
    }
}

이렇게 하면 하나의 번호에 대해서 하나의 객체만이 생성되므로 메모리 걱정을 할 필요가 없었고, 중복 공을 비교할 때에도 distinct를 통해 쉽게 해결할 수 있었다. 하나의 객체를 비교하는 것은 == 연산으로도 충분했기 때문이었다.
…만, 몇 가지 이유로 해당 커밋을 다시 돌려놓을 수밖에 없었다. 그 때의 나는 이런 생각을 가지고 있었다.

우선, 로또 공은 로직을 수행하는 친구가 아니었다. 전반적인 기둥이 되는 것이 싱글톤으로 구성되는 것은 납득이 되지만, 45개나 되는 공들을 하나하나 싱글톤으로 관리하는 것이 괜찮은 방법같지 않았다.
두 번째로는 확장성을 고려할 때, 객체 간 메시지를 올바르게 던질 수 없다는 점이었다. 하나의 객체는 한 번만 생성되므로, 서로 다른 로또에서의 같은 번호는 같은 객체를 공유하고 있다. 로또라는 특수성을 고려한다면 도입해도 됐겠지만, 다양한 요구사항이 넘치는 프리코스에서는 이 요구사항에 대해서도 확장성을 고려해야 하겠다, 싶어 잠깐 내려놓게 되었다.

고마워요 문어!

해당 사항에 대한 코드리뷰를 받으면서, Flyweight 패턴을 알게 됐다. 온라인 체스 서비스가 있을 때, 각 말을 모두 객체로 관리한다면 엄청난 부하가 될 것이다! 이를 각 말을 한 번만 생성하고 풀에서 꺼내는 방식으로 진행하면서 메모리 부하를 낮추는 패턴이다. 패턴을 알지도 못하는데 이런 생각을 했다는 게 신기했다. 다익스트라 알고리즘을 모르고 다익스트라를 짠 것 같은 기분… 그래도 도입하지 않았다는 것에 대한 이유를 (그때는) 어느정도 확립해두고 다른 사람에게 설명할 수 있을 정도의 논리가 잡혀 있어서, 도입하지 않은 것에 대한 후회는 접어두기로 했다.

예외 발생 시 다시 입력받기, 어떻게 해야할까?

이번 미션에서는 예외가 발생할 때, 해당 부분부터 입력을 다시 받으라는 요구사항이 있었다. 잘못된 입력이 들어오면 뻗어버리는 것이 아니라, 적절한 예외처리를 하라는 뜻이었다. 예외사항이 일어날 수 있는 곳은 다양했다. 돈을 적절히 입력하지 않거나, 중복된 번호를 입력하는 등 모든 예외 사항에 대해서 다시 입력하는 것을 진행해야 했다.

처음으로 떠오른 것은 당연히 while문을 사용한 반복문이었다. try-catch를 사용하면서, 예외가 발생했을 때 whlie문을 다시 돌도록 해주는 것이었다. 이게 당연하면서도 자연스럽지만, 문제가 있었다. 다양한 예외 상황에 대해서 모두 while-try-catch를 작성해야 했다. 한두 개의 상황이 아니라 다섯 개, 여섯 개, 나아가 수십 개의 try-catch를 일일히 작성하는 것은 꽤나 머리아픈 일일 것이다. 다른 요구사항으로 “이제는 그냥 프로그램을 종료하게 해 주세요”라고 한다면 뒷목잡고 쓰러질 것이다.

중복 코드, 모든 곳에 중복 코드

Java8부터는 함수형 인터페이스가 도입됐다. 추상화의 대명사인 Java에서는 이제 메서드까지 추상화하기에 나섰었다. 이렇게 나선 이유는 무엇일까. 지금까지 Java가 여러 가지를 추상화하면서 얻었던 이점은 무엇이었을까. 생각해보면 이번 요구사항에서 바로 알 수 있다. 모든 try-catch 구문을 하나의 함수로 만들고, 그 함수의 매개변수로 또 함수를 받으면 된다 (???)

Supplier라는 함수형 인터페이스와 Lambda를 지원하면서, 아래와 같은 우아한 코드를 작성할 수 있었다.

private <T> T repeatUntilValid(Supplier<T> function) {
    try {
        return function.get();
    } catch (LottoIllegalArgumentException e) {
        System.out.println(e.getMessage());
        return repeatUntilValid(function);
    } 
}

한 번의 try-catch로 모든 함수를 감쌀 수 있게 되었다. 이 함수를 메서드 선언형으로 작성하면 아래와 같이 깔끔하게 입력을 받을 수 있다.

public void startLottery() {
    LottoReceipt lottoReceipt = repeatUntilValid(this::purchaseLotto);
    lottoOutput.printLottoReceipt(lottoReceipt);

    WinningNumbers winningNumbers = repeatUntilValid(this::constructWinningNumbers);
    Map<Rank, Integer> results = lottoReceipt.getResults(winningNumbers);
    lottoOutput.printResults(results);

    double profitRate = profitCalculator.calculateProfitRateInPercentage(results);
    lottoOutput.printProfitAsPercentage(profitRate);
}

어플리케이션의 전체적인 로직이 확 줄어들었고, repeatUntilValid라는 메서드명을 통해서 잘못된 입력이 들어오는 경우에는 계속해서 입력을 받는다는 가독성까지 챙길 수 있다 🧐

사실 위쪽의 재귀함수 형태를 while로 감싸려다가, 이번 주 차 개인적인 목표로 코드 전체 인덴트 1을 챙기기 위해서 저렇게 두었었다. 처음에는 별다른 생각을 하지 못 했었는데, 지속적으로 사용자가 잘못된 입력을 하는 경우에 대응하기 어렵다는 단점이 있었다. Java의 경우 깊은 재귀에 대해서 좋지 않은 성능을 보여주기에, 되려 while로 작성하는 것이 메모리 측면에서도 훨씬 좋았겠다는 생각을 했다 (이것도 코드리뷰를 통해서 알게 된 사실이다.. 모두에게서 배울 점이 있는 우테코 최고야)

로또 번호를 맞춰나가는 로직은 누가 담당해야 할까?
누구인가?

로또 두 개의 번호 사이에서 맞은 공의 개수를 판별하는 것은 누구의 책임일까? 하나의 로또가 다른 로또와 비교한다는 점에서 Lotto에 책임이 있을까? 아니면 로또의 정답과 보너스 볼의 정보를 담고 있는 WinningNumbers의 책임일까? 고민을 하는 동안 몇 번이고 이쪽에 적었다가, 저쪽에 옮겼다가를 반복했다.

이 부분은 정답이 없다고 봤다. ‘로또파’도 자신의 생각이 있었을 것이고, ‘당첨파’도 나름 주장이 있었을 것. 나는 ‘당첨파‘를 대변하게 되었는데, 그 이유는 간단했다. 컴퓨터로 실제 로또를 구매하고, 그 당첨 내역을 확인할 때, 내가 로또 번호를 적으면 컴퓨터 쪽에서는 나의 번호를 가져다가 컴퓨터의 당첨 번호와 비교한 뒤 나의 (낙첨) 결과를 알려준다. 로또 도메인 그 자체에 비교 연산까지 존재한다면 너무 많은 책임을 가진다는 생각이 들기도 했다. 로또 도메인은 그저 로또 번호를 담고 있는 하나의 일급 컬렉션으로 작동되기를 원했기 때문이다.

그러한 이유로 로또의 번호를 매칭하는 부분은 당첨 번호 클래스에 작성했다. 물론 contains라는 메서드를 통해서 직접 접근하지 않고, 메서드를 여는 것까지 진행해 주었다!

// WinningLotto.java

public boolean contains(LottoBall number) {
    return numbers.stream()
            .anyMatch(lottoBall -> lottoBall.equals(number));
}

public int getMatchedCount(Lotto lotto) {
    return (int) numbers.stream()
            .filter(lotto::contains)
            .count();
}
많은 테스트를 진행해도 보이는 요구사항을 지키지 못했던 한 가지

모든 것을 마치고 코드리뷰를 통해 알게 된 사항이다. 소수 점 둘째 자리에서 반올림하라는 이야기만 듣고, 세 자리 단위로 콤마를 찍지 않았다. 이런 건 요구사항을 제시하는 사람과의 소통이 있으면 훨씬 원활했을 텐데.. 하는 아쉬움이 있지만, 그 안에서 세부적인 것을 꺼내지 못한 나의 잘못이었으리라. 코드리뷰를 진행하지 않았더라면 코드에 허점을 발견하지도 못했을 것이라, 오히려 감사하게 이 상황을 받아들이고자 했다. 이미 지난 일, 다시 할 수도 없고 나중에 연습하게 된다면 이 부분을 더욱 신경쓰겠지!

예시 부분도 꼭 꼼꼼하게 확인하자

이번 주는요

여담이지만, 교내에서 진행하는 알고리즘 경진대회를 좋은 기회로 열게 되었고, 많은 사람들이 함께해서 성공적으로 마무리할 수 있었다! 이번 주동안 머릿속을 휘저은 게 한두 가지가 아니라서 집중하기 어려운 환경이었지만, 열심히 완성해서 제출한 내 자신에게 고생했다고 한 마디 건넨다..! 🤯

코드를 짜는 속도는 빠른 편이라고 생각했는데, 요구사항을 코드에 반영하는 것은 쉬운 일이 아닌 것 같다. 코드 용병은 구하기 쉬워도 사고력과 문제해결력이 필요한 것은 별개의 문제같달까..? 알고리즘 문제를 풀듯이 빈 틈을 찾아서 내가 스스로 메꿔야 하는 점이 참 매력적인 것 같다. 이런 문제 형식을 어디에서 다시 볼 수 있겠냐 싶지만, 객체지향에 대해서 끊임없이 탐구할 수 있었던 시간이라서 너무 만족한다! 🤗

학교에서 요즘 분산 시스템을 배우고 있는데.. 도커의 특징 중 하나가 Union file system이다. 중복된 정보를 효율적으로 저장하기 위해서 Read-only layer 위에 Container가 read-write layer를 덧붙이는 방식인데, 프리코스가 딱 이 비유와 알맞은 것 같다. 지금까지의 피드백을 모두 생각하면서 코드를 의식적으로 짜야 하고, 앞으로 새로 들어오는 피드백들도 이전 피드백을 잊지 않은 채로 작성해나가야지… 이번 주도 정말 고생 많았다! 🦾


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

Categories