본문 바로가기

[우아한테크코스] 8기 프리코스

[우아한테크코스] 8기 프리코스 - 3차 미션 회고록

로또 발매기 과제를 하며 느낀 점

이번 로또 발매기 과제를 진행하면서, 항상 과제를 대할 때처럼 절차지향적으로 기능을 먼저 구현하고 이후에 객체지향적으로 리팩토링하는 습관에 대해 다시 한번 고민하게 되었습니다. 기능을 하나씩 구현해 나가는 과정은 익숙하지만, “이 방식이 정말 좋은 접근일까?”라는 의문이 생겼습니다.

기능 구현 중심의 사고에서 구조 중심의 사고로

과제를 시작할 때는 늘 그렇듯, 먼저 필요한 기능을 나열했습니다. 금액 입력, 예외 처리, 로또 번호 생성, 당첨 결과 판별, 수익률 계산 등, 각 단계를 세분화하여 절차적으로 하나씩 완성해 나갔습니다. 이 과정에서 Set, List, Map 같은 다양한 자료구조를 활용하며 데이터의 성격에 맞는 자료형 선택의 중요성을 절감했습니다.

특히 다음과 같은 방식으로 자료구조와 객체를 적극적으로 활용했습니다.

1. 로또 번호 생성 시 중복 처리

초기에는 Set의 중복을 허용하지 않는 특성을 활용하여 while(set.size() < 6) 반복문 내에서 무작위 숫자를 계속 추가하는 방식을 고려했습니다. 이는 Set의 핵심 원리를 활용하는 좋은 시도였으나, 로직 구현상 무한 루프 가능성 등 불안정한 요소가 있었습니다. 후에 우아한테크코스에서 제공하는 Randoms.pickUniqueNumbersInRange() 메서드를 사용하여, 범위 내에서 중복 없이 유니크한 난수들을 쉽게 얻는 효율적인 방법으로 개선했습니다. 또한 Stream의 distinct() 메서드를 통해 List 내 중복을 쉽게 확인할 수 있다는 점도 알게 되었습니다.

 

// 우아한테크코스에서 제공하는 메서드 활용
List<Integer> lottoNumbers = Randoms.pickUniqueNumbersInRange(1, 45, 6);

// List에서 중복을 확인하는 Stream distinct() 메서드
if (list.stream().distinct().count() != list.size()) {
    // 중복이 존재함
}

2. Lotto 객체의 불변성과 유효성 검증

Lotto 클래스는 6개의 로또 번호와 보너스 번호를 캡슐화하고 있습니다. 생성자에서 validate()와 distinct() 메서드를 호출하여 로또 번호의 개수가 6개인지, 중복된 번호가 없는지 엄격하게 검증하여 객체의 유효성을 보장했습니다. 또한 Collections.unmodifiableList()를 사용하여 numbers 리스트의 불변성을 확보함으로써, 외부에서의 데이터 변경을 막아 예측 불가능한 버그를 방지할 수 있었습니다.

 

public class Lotto {
    private final List<Integer> numbers; // 로또 번호 리스트 (불변)
    private final int bonus;             // 보너스 번호

    public Lotto(List<Integer> numbers, int bonus) {
        validate(numbers); // 숫자 개수(6개) 검증
        distinct(numbers); // 중복 여부 검증
        // 외부에서 리스트를 수정할 수 없도록 불변 리스트로 저장
        this.numbers = Collections.unmodifiableList(numbers);
        this.bonus = bonus;
    }

    private void validate(List<Integer> numbers) {
        if (numbers.size() != 6) {
            throw new IllegalArgumentException(ERROR_PREFIX + LOTT0_NUMBER_SIZE_MUST_BE_SIX_ERROR.getMessage());
        }
    }

    private void distinct(List<Integer> numbers) {
        // Stream의 distinct()를 활용하여 중복된 요소를 제거한 후 개수 비교
        if (numbers.stream().distinct().count() != 6) {
            throw new IllegalArgumentException(ERROR_PREFIX + LOTTO_NUMBER_DUPLICATED_ERROR.getMessage());
        }
    }
    // ... (이하 관련 메서드 생략)
}

3. 당첨 등수 판별 및 개수 저장 (with LottoRank Enum)

LottoRank enum은 각 등수의 일치 개수, 당첨금, 설명을 명확하게 정의하여 코드의 가독성과 유지보수성을 크게 높였습니다. 특히 from(int matchCount) 정적 팩토리 메서드를 통해 매치 개수만으로 해당 등수의 LottoRank 인스턴스를 얻을 수 있어, 당첨 등수 판별 로직을 간결하게 구현할 수 있었습니다.

 

public enum LottoRank {
    FIFTH(3, "5000", "3개 일치 (5,000원)"),
    FOURTH(4, "50000", "4개 일치 (50,000원)"),
    THIRD(5, "1500000", "5개 일치 (1,500,000원)"),
    SECOND(5, "30000000", "5개 일치, 보너스 볼 일치 (30,000,000원)"),
    FIRST(6, "2000000000", "6개 일치 (2,000,000,000원)");

    final int count;    // 일치하는 로또 번호 개수
    final String winning; // 당첨금 (문자열로 저장하여 BigDecimal 변환 용이)
    final String label;  // 출력용 설명

    LottoRank(int count, String winning, String label) {
        this.winning = winning;
        this.count = count;
        this.label = label;
    }

    // 일치 개수로 해당 LottoRank 인스턴스를 찾아 반환하는 정적 팩토리 메서드
    public static LottoRank from(int matchCount) {
        return Arrays.stream(LottoRank.values())
                .filter(r -> r.count == matchCount)
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException(ERROR_PREFIX + "Invalid Lotto Rank"));
    }
}

HashMap<LottoRank, Integer>는 등수별 당첨 횟수를 저장하는 데 사용되었으며, initPrizeCounts() 메서드로 초기화되어 모든 등수를 0으로 설정했습니다. 실제로 당첨 결과를 계산하는 getWinningResult 메서드에서는 구매한 로또 번호(여기서는 userLottoSet)와 당첨 로또 번호(this.numbers)를 비교하여 일치 개수를 세고, 이를 통해 등수를 판별했습니다.

특히 2등의 경우 5개 일치와 보너스 번호 일치라는 특수 조건을 다음과 같이 명시적으로 처리하여 등수 로직의 정확성을 높였습니다. 2등을 먼저 처리하고 continue를 사용함으로써, 5개 일치 시 2등과 3등이 중복 계산되는 문제를 방지했습니다.

 

public class Lotto {
    // ... (앞 부분 생략)

    public HashMap<LottoRank, Integer> initPrizeCounts() {
        HashMap<LottoRank, Integer> prizeCounts = new HashMap<>();
        Arrays.stream(LottoRank.values()).forEach(r -> prizeCounts.put(r, 0));
        return prizeCounts;
    }

    // 구매한 로또들과 당첨 번호를 비교하여 당첨 결과를 계산합니다.
    public HashMap<LottoRank, Integer> getWinningResult(
            HashMap<LottoRank, Integer> prizeCounts,
            ArrayList<TreeSet<Integer>> userLottoList) { // randomList 대신 userLottoList로 명확하게 변경

        for (TreeSet<Integer> userLottoSet : userLottoList) {
            // 당첨 번호와 일치하는 개수를 계산 (구매한 로또가 당첨 번호를 얼마나 포함하는지)
            int matchCount = (int) userLottoSet.stream()
                                                .filter(numbers::contains) // this.numbers는 당첨 로또 번호
                                                .count();

            // 3개 미만 일치는 당첨이 아니므로 필터링
            if (matchCount >= 3) {
                // 2등은 5개 일치 + 보너스 볼 일치이므로 가장 먼저 처리
                if (matchCount == 5 && userLottoSet.contains(bonus)) {
                    prizeCounts.put(LottoRank.SECOND, prizeCounts.get(LottoRank.SECOND) + 1);
                    continue; // 2등 처리 후, 현재 로또에 대한 추가 등수 판별 없이 다음 로또로 이동
                }
                // 그 외 등수 처리 (1등, 3등, 4등, 5등)
                LottoRank rank = LottoRank.from(matchCount);
                // LottoRank.from(5)는 기본적으로 THIRD를 반환하도록 enum이 정의되어 있습니다.
                // 2등은 위에서 이미 처리되었으므로, matchCount가 5인 경우 자동으로 THIRD로 매핑됩니다.
                prizeCounts.put(rank, prizeCounts.get(rank) + 1);
            }
        }
        return prizeCounts;
    }
    // ... (이하 관련 메서드 생략)
}

4. 정확성과 안정성을 위한 예외 처리와 BigDecimal 활용

과제 중 가장 많은 시행착오가 있었던 부분은 사용자 입력에 대한 예외 처리였습니다. 금액이 정수 형식이 아니거나 1000원 단위가 아닐 때, 당첨 번호가 1~45 범위를 벗어나거나 중복될 때 등 다양한 상황을 고려하며 사용자 입력의 안정성을 확보하는 방법을 고민했습니다. ERROR_PREFIX와 같은 상수를 활용하여 예외 메시지를 일관성 있게 관리하고, IllegalArgumentException을 적절히 사용하여 유효하지 않은 입력에 대한 방어 로직을 견고하게 구축했습니다.

특히 수익률 계산에서 double 자료형의 부동소수점 오차 문제를 경험했고, 이를 해결하기 위해 BigDecimal 클래스를 적극적으로 사용했습니다.  초기에 작성했던 수익률 계산 코드는 (총 상금 - 총 투자금) 부분을 먼저 계산하여 BigDecimal의 정밀도 문제를 야기할 수 있었습니다.

 

public class Lotto {
    // ... (앞 부분 생략)

    // 총 당첨 상금을 계산하는 메서드
    public BigDecimal calculateTotalProfit(HashMap<LottoRank, Integer> prizeCounts) {
        BigDecimal totalProfit = BigDecimal.ZERO;

        for (LottoRank lottoRank : LottoRank.values()) {
            int counts = prizeCounts.getOrDefault(lottoRank, 0); // null-safe 처리
            BigDecimal winnings = new BigDecimal(lottoRank.winning); // 당첨금을 BigDecimal로 변환
            totalProfit = totalProfit.add(winnings.multiply(new BigDecimal(counts))); // 총 상금 합산
        }
        return totalProfit;
    }

    // 수익률을 계산하는 메서드
    public BigDecimal calculateTotalRate(BigDecimal totalProfit, int money) {
        BigDecimal totalCost = new BigDecimal(String.valueOf(money)); // 총 투자금을 BigDecimal로 변환

        BigDecimal rate = totalProfit
                .divide(totalCost, 4, RoundingMode.HALF_UP) // 정확도를 높이기 위해 정밀도 4로 나누기 연산
                .multiply(new BigDecimal("100")); // 백분율을 위해 100 곱하기

        return rate.setScale(1, RoundingMode.HALF_UP); // 소수점 첫째 자리에서 반올림
    }
}

 

BigDecimal은 정확한 소수점 연산을 위해 double 대신 사용하는 것이 필수적입니다. calculateTotalProfit 메서드에서는 각 등수의 당첨금을 BigDecimal로 변환하고 getOrDefault로 안전하게 횟수를 가져와 총 상금을 정확하게 계산했습니다. calculateTotalRate 메서드에서는 총 상금을 총 투자금으로 나눈 후 100을 곱하는 방식으로 연산의 순서를 조정하여 정밀도 이슈를 해결하고, RoundingMode.HALF_UP으로 반올림 규칙까지 명시하여 결과의 신뢰도를 높였습니다.

이러한 과정은 단순히 기능 구현을 넘어 정확한 비즈니스 로직을 위한 자료형 선택의 중요성과 예측 불가능한 오류를 방지하는 방어적인 프로그래밍의 중요성을 다시 한번 깨닫게 했습니다.

객체지향 리팩토링에 대한 고민

기능을 모두 완성한 후, 가장 큰 고민은 “이제 어떻게 객체지향적으로 바꿀까?”였습니다. 그때 스스로에게 아래의 기준을 세웠습니다.

  • 하나의 클래스는 한 가지 책임(Single Responsibility Principle) 만 가진다.
  • 메서드가 길거나 if-else가 많은 경우, 다형성(Polymorphism) 으로 단순화할 수 있는가?
  • 상태(state)를 가진 객체가 데이터를 잘 캡슐화(Encapsulation) 하고 있는가?

이 기준을 적용하면서 리팩토링의 목적이 단순히 “클래스로 나누는 것”이 아니라, **“변경에 강한 구조로 만드는 것”**임을 확실히 느꼈습니다.

마무리하며

이번 로또 발매기 미션은 단순한 기능 구현을 넘어, 자료구조의 적합성 판단금융 계산의 정확성을 위한 BigDecimal 활용예외 처리의 세밀함, 그리고 객체지향 설계 원칙을 동시에 요구하는 프로젝트였습니다. 주어진 API를 사용하는 것을 넘어 그 내부 원리와 한계를 이해하고, 상황에 맞는 최적의 자료구조와 로직을 선택하는 것이 좋은 개발로 이어진다는 것을 깊이 있게 체감할 수 있었습니다. 특히 처음부터 완벽한 객체지향 설계가 어렵더라도, 절차지향적으로 구현한 후 객체지향 원칙을 기준으로 점진적으로 리팩토링해 나가는 것이 성장을 위한 의미 있는 과정임을 깨달았습니다.