우테코 8기 3주차 미션 회고

자바·2025년 11월 4일

우테코

목록 보기
5/10
post-thumbnail
@Override
public void start() {
	System.out.println("8기 3주차 미션");
}

계층 분리와 새로운 패턴에 도전하여 성장한 일주일


💰3주차 미션 - 로또


3주차 미션은 로또이었습니다. 특히 이번 로또 미션은 2주차보다 요구사항도 많고, 구현해야 할 클래스도 훨씬 복잡해 보였습니다. 하지만 우테코 8기의 핵심 키워드인 '도전'을 떠올리며, 이 두려움을 성장의 기회로 바꾸기로 결심했습니다.

간단한 로또 발매기를 구현한다.

  • 로또 번호의 숫자 범위는 1~45까지이다.
  • 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다.
  • 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다.
  • 당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다.
    • 1등: 6개 번호 일치 / 2,000,000,000원
    • 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원
    • 3등: 5개 번호 일치 / 1,500,000원
    • 4등: 4개 번호 일치 / 50,000원
    • 5등: 3개 번호 일치 / 5,000원
  • 로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다.
  • 로또 1장의 가격은 1,000원이다.
  • 당첨 번호와 보너스 번호를 입력받는다.
  • 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다.
  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.
    • Exception이 아닌 IllegalArgumentExceptionIllegalStateException 등과 같은 명확한 유형을 처리한다.

고민 내용

UML 다이어그램

PR 링크입니다 !
개발 내용을 확인하시려면 이 부분을 클릭해 주세요 !


💡 가장 오래 고민했던 부분들


1. 검증 로직의 위치 - 계층 간 책임 분리

문제 상황

당첨 번호를 입력받을 때 발생하는 예외를 어디서 처리해야 할까? 가장 오래 고민한 문제였습니다.

첫 번째 시도: InputHandler에서 모든 검증

// 초기 버전 - InputHandler가 너무 많은 책임
public List<Integer> readWinningNumbers() {
    String input = inputView.readWinningLottoNumbers().strip();
    
    // 형식 검증
    validateWhiteSpace(input);
    validateWinningNumberFormat(input);
    
    // 변환
    String[] numbers = input.split(",");
    List<Integer> result = new ArrayList<>();
    for (String num : numbers) {
        result.add(Integer.parseInt(num));
    }
    
    // 비즈니스 규칙 검증
    if (result.size() != 6) {
        throw new IllegalArgumentException("6개여야 합니다");
    }
    // 중복 검증, 범위 검증...
    
    return result;
}

문제점:

  • InputHandler가 형식, 변환, 비즈니스 규칙까지 모두 처리
  • 테스트가 복잡하고 책임이 불명확
  • 단일 책임 원칙 위반

두 번째 시도: Parser 분리

// InputHandler.java
public List<Integer> readWinningNumbers() {
    String input = inputView.readWinningLottoNumbers().strip();
    validateWhiteSpace(input);
    validateWinningNumberFormat(input);
    return InputParser.parseWinningNumbers(input);  // 변환 책임 분리
}

// InputParser.java*
public static List<Integer> parseWinningNumbers(String input) {
    return Arrays.stream(input.split(","))
            .map(String::strip)
            .map(Integer::parseInt)
            .toList();
}

조금 나아졌지만, 여전히 6개인지, 1~45 범위인지 같은 비즈니스 규칙 검증은 어디서 해야 할까?

최종 해결: 계층별 책임 분리

// 1. InputHandler: 형식 검증만
public class InputHandler {
    private static final String WINNING_NUMBERS_FORMAT_REGEX = "^(\\d+)(\\s*,\\s*\\d+)*$";
    
    public List<Integer> readWinningNumbers() {
        String winningNumbers = inputview.readWinningLottoNumbers().strip();
        validateWhiteSpace(winningNumbers);
        validateWinningNumberFormat(winningNumbers);
        return InputParser.parseWinningNumbers(winningNumbers);
    }
    
    private void validateWinningNumberFormat(String input) {
        if (!input.matches(WINNING_NUMBERS_FORMAT_REGEX)) {
            throw new IllegalArgumentException(INVALID_WINNING_NUMBER_FORMAT.toString());
        }
    }
}

// 2. InputParser: 타입 변환만
public class InputParser {
    public static List<Integer> parseWinningNumbers(String input) {
        return Arrays.stream(input.split(","))
                .map(String::strip)
                .map(Integer::parseInt)
                .toList();
    }
    
    public static int parseToInt(String input) {
        try {
            return Integer.parseInt(input);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(INVALID_INT_RANGE.toString());
        }
    }
}

// 3. Domain (Lotto): 비즈니스 규칙 검증
public class Lotto {
    private static final int LOTTO_DEFAULT_SIZE = 6;
    private static final int MIN_RANGE = 1;
    private static final int MAX_RANGE = 45;
    
    public Lotto(List<Integer> numbers) {
        validate(numbers);
        this.numbers = sortAscending(numbers);
    }
    
    private void validate(List<Integer> numbers) {
        validateNumberCount(numbers);      // 6개인지
        validateDuplicateNumber(numbers);  // 중복 없는지
        validateNumberRange(numbers);      // 1~45 범위인지
    }
}

깨달음

좋은 설계는 처음부터 완벽하게 나오는 게 아니라, 반복된 실패와 리팩토링을 거쳐 만들어진다는 것을 느꼈습니다.

핵심 원칙:

  • InputHandler: 입력 형식 검증 (공백, 쉼표 형식)
  • InputParser: 타입 변환 (String → Integer, 범위 초과)
  • Domain: 비즈니스 규칙 (개수, 중복, 범위)

2. Controller의 인스턴스 변수 3개 제한

문제 상황

우테코 클린코드 체크리스트: "3개 이상의 인스턴스 변수를 가진 클래스를 구현하지 않았는가?"

기존 Controller는 4개의 의존성을 가지고 있었습니다.

// 초기 버전 - 인스턴스 변수 4개
public class LottoController {
    private final InputHandler inputHandler;
    private final OutputHandler outputHandler;
    private final LottoPurchaseService purchaseService;  
    private final LottoResultService resultService;      
    
    public LottoController(
            InputHandler inputHandler,
            OutputHandler outputHandler,
            LottoPurchaseService purchaseService,
            LottoResultService resultService
    ) {
        this.inputHandler = inputHandler;
        this.outputHandler = outputHandler;
        this.purchaseService = purchaseService;
        this.resultService = resultService;
    }
}

해결: Facade 패턴 도입

원래는 밤 11시에 자는 약속을 혼자 지키고 있었지만, "이 문제를 그냥 넘기고 싶지 않다"는 생각에 계속 고민했습니다.

여러 패턴들을 공부하는 중에 Facade 패턴을 적용시키면 좋을 것 같다고 생각이 들어 적용시켰습니다.

// LottoGameService.java - Facade 역할
public class LottoGameService {
    private final LottoPurchaseService purchaseService;
    private final LottoResultService resultService;
    private final OutputMapper outputMapper;
    
    public LottoPurchaseResult purchaseLottos(int amount) {
        return purchaseService.purchase(amount);
    }
    
    public Map<Rank, Integer> calculateStatistics(WinningLotto winningLotto, Lottos lottos) {
        return resultService.getStatistics(winningLotto, lottos);
    }
    
    public List<WinningStatistics> getWinningStatistics(WinningLotto winningLotto, Lottos lottos) {
        Map<Rank, Integer> statistics = resultService.getStatistics(winningLotto, lottos);
        return outputMapper.mapToWinningStatistics(statistics);
    }
    
    public double calculateProfitRate(Map<Rank, Integer> statistics, Money money) {
        return resultService.calculateProfitRate(statistics, money);
    }
}
// 개선된 Controller - 인스턴스 변수 3개
public class LottoController {
    private final InputHandler inputHandler;
    private final OutputHandler outputHandler;
    private final LottoGameService gameService;
    
    public void run() {
        try {
            LottoPurchaseResult result = purchaseLottosWithRetry();
            outputHandler.showMyLottos(result);
            
            WinningLotto winningLotto = createWinningLottoSafely();
            showGameResult(result, winningLotto);
        } finally {
            Console.close();
        }
    }
    
    private void showGameResult(LottoPurchaseResult result, WinningLotto winningLotto) {
        List<WinningStatistics> results = gameService.getWinningStatistics(
                winningLotto, result.getLottos()
        );
        double profitRate = gameService.calculateProfitRate(
                gameService.calculateStatistics(winningLotto, result.getLottos()),
                result.getMoney()
        );
        outputHandler.showResult(results, profitRate);
    }
}

의존성 주입 설정

// AppConfig.java - DI 컨테이너
public class AppConfig {
    public LottoGameService lottoGameService() {
        return new LottoGameService(
                lottoPurchaseService(),
                lottoResultService(),
                outputMapper()
        );
    }
    
    public LottoController lottoController() {
        return new LottoController(
                inputHandler(),
                outputHandler(),
                lottoGameService()
        );
    }
}

깨달음

"디자인 패턴이 이런 이유로 있구나!"

단순히 개수를 맞추기 위한 것이 아니라, 실제로 코드 구조가 더 명확해지고 가독성이 높아졌다고 생각이 듭니다.

Facade 패턴의 장점:

  • 복잡한 서비스 로직을 하나의 인터페이스로 제공
  • Controller는 순수하게 흐름 제어에만 집중
  • 테스트 시 Facade만 Mocking하면 됨

3. 랜덤 값 테스트

2주차의 문제

자동차 경주 미션에서 Randoms.pickNumberInRange(0, 9)를 직접 호출

  • 매번 다른 값이 나와 테스트 결과 예측 불가
  • 특정 시나리오 테스트 불가능

결국 그냥 범위의 값만 넣어서 테스트 진행함.

해결: 전략 패턴 + 의존성 주입

코드 리뷰에서 본 인터페이스 패턴을 적용해봤습니다!

// 1. 인터페이스 정의
public interface NumberGenerator {
    List<Integer> generate();
}

// 2. 실제 환경용 구현체
public class LottoNumberGenerator implements NumberGenerator {
    private static final int LOTTO_NUMBER_COUNT = 6;
    private static final int MIN_RANDOM_RANGE = 1;
    private static final int MAX_RANDOM_RANGE = 45;
    
    @Override
    public List<Integer> generate() {
        return Randoms.pickUniqueNumbersInRange(
                MIN_RANDOM_RANGE,
                MAX_RANDOM_RANGE,
                LOTTO_NUMBER_COUNT
        );
    }
}

// 3. 테스트용 구현체
public class FixedNumberGenerator implements NumberGenerator {
    private final List<Integer> numbers;
    
    public FixedNumberGenerator(List<Integer> numbers) {
        this.numbers = numbers;
    }
    
    @Override
    public List<Integer> generate() {
        return numbers;  // 고정된 값 반환!
    }
}

서비스에서 사용

public class LottoPurchaseService {
    private final NumberGenerator numberGenerator;  
    
    public LottoPurchaseService(NumberGenerator numberGenerator) {
        this.numberGenerator = numberGenerator;
    }
    
    private Lotto generateLotto() {
        List<Integer> lottoNumbers = numberGenerator.generate();
        return new Lotto(lottoNumbers);
    }
}

설정 분리

// AppConfig.java
public class AppConfig {
    public NumberGenerator numberGenerator() {
        return new LottoNumberGenerator();
    }
    
    public LottoPurchaseService lottoPurchaseService() {
        return new LottoPurchaseService(numberGenerator());
    }
}

깨달음

실패는 끝이 아니라 다음 도전을 위한 가장 확실한 발판이 된다는 것을, 그리고 코드 리뷰를 통해서 배우는 것이 얼마나 큰 힘이 되는지 깨달았습니다.

전략 패턴의 장점:

  • 테스트 용이성 대폭 향상
  • 실제 코드 수정 없이 동작 변경 가능

시도했지만 완벽하지 않았던 도전들

1. 리팩토링 타이밍 고민

방식 1: 기능 완성 후 일괄 리팩토링

장점:

  • 빠른 기능 개발
  • 테스트 코드 집중 작성 가능

단점:

  • 후반부에 대규모 리팩토링 필요
  • "이건 어디로 분리?" 끊임없는 고민
  • 시간 소모가 오히려 더 큼

방식 2: 기능마다 즉시 리팩토링

장점:

  • 역할과 책임이 명확
  • 검증 로직 위치가 한눈에 보임
  • 유지보수성 향상

단점:

  • 초기 개발 속도 느림
  • 설계 고민 시간 증가
// 방식 1의 결과물
public class LottoService {
    public void run() {
        // 입력, 검증, 로직, 출력 모두 한 곳에
        String input = Console.readLine();
        int money = Integer.parseInt(input);
        // 각종 기능들..
    }
}

// 방식 2의 결과물 (최종)
public class LottoController {
    public void run() {
        LottoPurchaseResult result = purchaseLottosWithRetry();
        WinningLotto winningLotto = createWinningLottoSafely();
        showGameResult(result, winningLotto);
    }
    // 각 메서드는 10줄 이내, 역할 명확
}

깨달음

전자는 빠른 기능 개발에 좋지만, 후자는 안정성과 유지보수성이 좋다고 느껴졌습니다.
저는 조금 느리더라도 처음부터 제대로 설계하는 방식을 선택할 것 같습니다.


2. View-Domain 의존성 제거

문제 상황

MVC 패턴에서 View와 Domain이 직접 상호작용하면 안 된다는 건 알고 있었지만, 어떻게 분리해야 할지 막막했습니다.

해결 과정

1단계: 아이디어
"외부에서 필요한 값만 전달받으면 View가 Domain을 몰라도 되지 않을까?"

2단계: 커뮤니티 학습
디스코드에서 다른 크루분이 공유해주신 디자인 패턴 사이트 이용
알려주신 디자인 패턴 학습 사이트
→ Mapper 패턴 학습

3단계: 적용

// WinningStatistics.java - DTO
public class WinningStatistics {
    private final String rankMessage;
    private final int count;
    
    public WinningStatistics(String rankMessage, int count) {
        this.rankMessage = rankMessage;
        this.count = count;
    }
    
    public String getRankMessage() {
        return rankMessage;
    }
    
    public int getCount() {
        return count;
    }
}

// OutputMapper.java - 변환 담당
public class OutputMapper {
    public List<WinningStatistics> mapToWinningStatistics(Map<Rank, Integer> statistics) {
        return statistics.entrySet()
                .stream()
                .filter(entry -> entry.getKey() != Rank.NONE)
                .map(entry -> new WinningStatistics(
                        entry.getKey().getMessage(),
                        entry.getValue()
                ))
                .sorted(Comparator.comparing(WinningStatistics::getRankMessage))
                .toList();
    }
}

// 개선된 OutputView - Domain 모름!
public class OutputView {
    public void printResult(List<WinningStatistics> results, double profitRate) {
        System.out.println();
        System.out.println("당첨 통계");
        System.out.println("---");
        
        results.forEach(result ->
                System.out.println(result.getRankMessage() + " - " + result.getCount() + "개")
        );
        
        System.out.printf("총 수익률은 %.1f%%입니다.%n", profitRate);
    }
}

흐름 정리

  1. Service가 Domain으로 통계 계산
  2. Mapper가 DTO로 변환
  3. View는 DTO만 받아서 출력

💡 깨달음

문제를 인식하는 것도 중요하지만, 그 해결책을 찾기 위해 적극적으로 배우고 커뮤니티를 활용하는 것이 더 중요하다는 걸 깨달았습니다.

DTO 패턴의 장점:

  • 계층 간 의존성 제거
  • View는 순수하게 출력만 담당
  • Domain 변경이 View에 영향 없음

📊 도메인 설계

Rank Enum

가장 공들인 부분 중 하나입니다.
단순히 if-else로 등수를 판정하는 게 아니라, Enum이 스스로 판정하도록 했습니다.

public enum Rank {
    FIRST(null, 6, false, 2_000_000_000L, "6개 일치 (%s원)"),
    SECOND(FIRST, 5, true, 30_000_000L, "5개 일치, 보너스 볼 일치 (%s원)"),
    THIRD(SECOND, 5, false, 1_500_000L, "5개 일치 (%s원)"),
    FOURTH(THIRD, 4, false, 50_000L, "4개 일치 (%s원)"),
    FIFTH(FOURTH, 3, false, 5_000L, "3개 일치 (%s원)"),
    NONE(FIFTH, 0, false, 0L, "꽝");

    private static final int BONUS_AVAILABLE_FROM_MATCH = 2;
    private Rank upperRank;
    private final int matchCount;
    private final boolean hasBonusNumber;
    private final long prize;
    private final String messageFormat;
    
    // 핵심 메서드: 일치 개수와 보너스 여부로 등수 판정
    public static Rank of(int matchCounts, boolean hasBonusNumber) {
        Rank baseRank = fromMatchCount(matchCounts, hasBonusNumber);
        if (hasBonusNumber && matchCounts >= BONUS_AVAILABLE_FROM_MATCH) {
            return baseRank.upgrade();
        }
        return baseRank;
    }
    
    private static Rank fromMatchCount(int matchCount, boolean hasBonusNumber) {
        return Arrays.stream(values())
                .filter(rank -> rank.matchCount == matchCount)
                .filter(rank -> !rank.hasBonusNumber || hasBonusNumber)
                .findFirst()
                .orElse(NONE);
    }
    
    private Rank upgrade() {
        if (this == SECOND) {
            return this;
        }
        return upperRank;
    }
    
    public String getMessage() {
        if (this == NONE) return messageFormat;
        String formattedPrize = String.format("%,d", prize);
        return String.format(messageFormat, formattedPrize);
    }
}

사용 예시

// WinningLotto.java
public class WinningLotto {
    private final Lotto winningNumbers;
    private final BonusNumber bonusNumber;
    
    public Rank determineRank(Lotto lotto) {
        int matchCount = countMatches(lotto);
        boolean hasBonus = hasBonusNumber(lotto);
        return Rank.of(matchCount, hasBonus);  // Enum이 판정!
    }
}

Lottos 일급 컬렉션

여러 로또를 관리하고 통계를 계산하는 책임을 가진 일급 컬렉션입니다.

public class Lottos {
    private static final int EMPTY_SIZE = 0;
    private final List<Lotto> lottos;
    
    public Lottos(List<Lotto> lottos) {
        validateEmptyLotto(lottos);
        this.lottos = List.copyOf(lottos);  *// 불변성 보장*
    }
    
    *// 핵심: 자기 자신의 데이터로 통계 계산 (Tell, Don't Ask)*
    public Map<Rank, Integer> calculateStatistics(WinningLotto winningLotto) {
        Map<Rank, Integer> statistics = initializeStatistics();
        for (Lotto lotto : this.lottos) {
            Rank rank = winningLotto.determineRank(lotto);
            statistics.computeIfPresent(rank, (key, value) -> value + 1);
        }
        return statistics;
    }
    
    private Map<Rank, Integer> initializeStatistics() {
        Map<Rank, Integer> statistics = new EnumMap<>(Rank.class);
        for (Rank rank : Rank.values()) {
            if (rank != Rank.NONE) {
                statistics.put(rank, 0);
            }
        }
        return statistics;
    }
}

🎓 배운 디자인 패턴들


1. 전략 패턴 (Strategy Pattern)

  • 적용: NumberGenerator 인터페이스
  • 효과: 런타임에 랜덤 생성 전략 교체, 테스트 용이성

2. Facade 패턴

  • 적용: LottoGameService
  • 효과: 복잡한 서비스 로직 통합, Controller 간소화

3. DTO 패턴

  • 적용: LottoPurchaseResult, WinningStatistics
  • 효과: 계층 간 의존성 제거

4. Mapper 패턴

  • 적용: OutputMapper
  • 효과: Domain-DTO 변환 책임 분리

💭 회고


이 정도면 충분할까?

매번 리팩토링을 하면서 "이 정도면 됐나?", "더 할 수 있지 않을까?" 사이에서 고민했습니다.
결국 깨달은 건, 지금의 최선이 곧 충분하다는 것입니다.
오늘 내가 할 수 있는 최선을 다했다면, 그것으로 충분합니다.

다른 사람과의 비교

디스코드에서 다른 분들의 질문을 보면 "나는 아직 이것도 모르는데..." 하는 생각이 들었습니다.
하지만 중요한 건 어제의 나와 비교하는 것입니다.
1주차, 2주차, 그리고 지금의 나를 비교하면 분명히 성장했습니다.

몰입의 즐거움

밤늦게까지 코드를 붙잡고 있다가 문제가 해결되는 순간의 쾌감이 너무 좋았습니다.
처음에는 "과제를 해야 한다"는 부담감이었다면, 이제는 "문제를 풀고 싶다"는 도전 정신으로 바뀌었습니다.


🎯 마치며

처음 로또 미션을 봤을 때의 두려움이 아직도 생생합니다. "이걸 정말 내가 할 수 있을까?"
하지만 3주를 돌아보니, 두려움을 넘어선 도전의 순간들이 가장 값진 배움을 주었습니다.
완벽한 코드를 만들지는 못했습니다.

하지만 15번의 리팩토링, 수십 번의 시행착오, 수백 번의 "왜?"라는 질문 속에서 저는 분명히 성장했습니다.
이번 주도 완벽하지 않았지만, 그 모든 과정이 제 성장의 발자국입니다.
도전은 계속됩니다. 그리고 저는 그 도전이 기다려집니다.

profile
열띤 토론을 좋아하는 개발자입니다.

0개의 댓글