[iOS] 콘솔기반 숫자 야구 게임

iosSwiftUIKit
avatar
2025.03.12
·
7 min read

프로젝트 개요

  • 프로젝트명: 콘솔기반 숫자 야구 게임

  • 개발 기간: 2025.03.10 ~ 2025.03.13

  • 사용 기술: Swift, Xcode

  • 주요 학습 목표: Swift, 로직 설계, MVVM, Clean Architecture, Cordinator

설계 과정

요구 사항 분석

  • 0~9 사이의 중복되지 않는 랜덤 숫자 3개를 정답으로 설정

  • 사용자가 숫자를 입력하면 Strike / Ball / Nothing을 판단하여 힌트 제공

  • 정답을 맞힐 때까지 반복

  • 게임 기록을 저장하고 조회하는 기능 추가

  • 메뉴 화면을 통해 게임 시작 / 기록 조회 / 종료 기능 제공

프로젝트 구조 설계

유지보수성을 고려하여 실제 앱 개발에서 사용되는 아키텍처를 경험해보기 위해 MVVM + Clean Architecture + Coordinator 패턴을 도입함

  • Domain: UseCase를 통해 게임 로직 수행

  • Data: 데이터 저장소 관리

  • Presentation: View/ViewModel을 분리하여 UI(콘솔) 관련 로직 관리

  • AppCoordinator: ViewController간의 네비게이션

NumberBaseball/
├── Appication/
│   ├── AppCoordinator.swift
│   ├── DependencyContainer.swift
│   └── main.swift
├── Data/
│   ├── Repository/
│   │   └── DefaultHistoryRepository.swift
├── Domain/
│   ├── Model/
│   │   ├── GameConfig.swift
│   │   ├── History.swift
│   │   └── MainMenuOption.swift
│   ├── Repository/
│   │   └── HistoryRepository.swift
│   ├── UseCase/
│   │   ├── Game/
│   │   │   ├── AnswerGenerationUseCase.swift
│   │   │   └── StrikeBallCalculationUseCase.swift
│   │   └── History/
│   │       ├── FetchHistoryUseCase.swift
│   │       └── SaveHistoryUseCase.swift
└── Presentation/
    ├── Game/
    │   ├── View/
    │   │   ├── GameView.swift
    │   │   └── GameViewController.swift
    │   └── ViewModel/
    │       └── GameViewModel.swift
    ├── History/
    │   ├── View/
    │   │   ├── HistoryView.swift
    │   │   └── HistoryViewController.swift
    │   └── ViewModel/
    │       └── HistoryViewModel.swift
    ├── Main/
    │   ├── View/
    │   │   ├── MainView.swift
    │   │   └── MainViewController.swift
    │   └── ViewModel/
    │       └── MainViewModel.swift
    └── Messages.swift

주요 코드 설명

랜덤 숫자 생성 코드

  1. 생성에 사용할 숫자의 범위와 개수를 받음.

  2. 첫 번째 숫자로 0이 오는 것을 방지하기 위해 1 ~ 9 사이의 숫자에서 한 개를 뽑음.

  3. 2에서 선택된 숫자를 제외한 나머지 숫자를 섞은 후 앞에서 count - 1개만 선택.

func generateAnswer(range: ClosedRange<Int>, count: Int) -> [Int] {
    let nonZero = range.filter { $0 != 0 }
    guard let first = nonZero.randomElement() else { return [] }
    let rest = range.filter { $0 != first }.shuffled().prefix(count - 1)

    return [first] + rest
}

Strike / Ball 판단 코드

  1. enumerated()를 사용하여 인덱스와 값이 같을 때 Strike를 증가시킴.

  2. contains()를 사용하여 값이 정답 배열에 있을 경우 Ball을 증가시킴.

func result(numbers: [Int], answer: [Int]) -> (strike: Int, ball: Int) {
    var strike = 0, ball = 0
    for (idx, num) in numbers.enumerated() {
        if num == answer[idx] {
            strike += 1
        } else if answer.contains(num) {
            ball += 1
        }
    }

    return (strike, ball)
}

발생 이슈

게임 기록 데이터 유실

  • 문제 상황: 게임 기록을 저장했지만, 조회할 떄 데이터가 유실되어 빈 배열([])이 출력됨.

  • 원인 추론:

    1. 기록 저장 메소드 saveHistory()가 정상적으로 호출되지 않았을 가능성

      HistoryRepository에 값이 저장되는 것을 확인함.

    2. 기록 조회 메소드 fetchHistory()가 데이터를 반환하지 않는 문제일 가능성

      fetchHistory()GameViewModel에서 호출하면 데이터가 존재하지만, HistoryViewModel에서 호출하면 빈 배열이 출력됨.
      즉, 저장된 데이터가 다른 View에서 보이지 않는 문제 발생.
      단순히 fetchHistory()의 구현 문제가 아님.

    3. HistoryRepository 인스턴스가 여러 개 생성되었을 가능성

      GameViewModelHistoryViewModel에서 각각 HistoryRepository의 인스턴스를 생성하고 있어, 저장과 조회가 서로 다른 인스턴스를 참조하는 문제 발생.

  • 해결 방안: 싱글톤(Singleton) 패턴을 적용하여, HistoryRepository가 하나의 인스턴스를 공유하도록 개선.

  • 결과 확인: 게임 기록을 저장하고, 기록 조회 시 정상적으로 데이터가 유지됨.

싱글톤 패턴의 클린 아키텍처 원칙 위반

  • 문제 상황: 싱글톤 패턴이 클린 아키텍처의 '계층 간의 명확한 의존성 분리 원칙'을 위반함.

  • 원인 추론: 싱글톤 패턴을 사용하면, Repository가 글로벌 상태를 가지게 되며, 암묵적으로 어디서든 접근할 수 있기 때문에 명확한 의존성 흐름을 해칠 수 있음.

  • 해결 방안: DI(Dependency Injection) Container를 활용하여 싱글 인스턴스 주입.

  • 결과 확인: 의존성 분리가 명확해짐.

배운 점

  • UseCase를 활용하여 비즈니스 로직을 ViewModel과 분리하는 방법

  • Coordinator 패턴을 적용하여 ViewController 간 강한 결합을 제거하는 방법

  • Dependency Injection을 활용하여 의존성 분리 원칙을 지키는 방법

향후 목표

  • Unit Test 추가

    • 비즈니스 로직을 UseCase로 분리했으므로, UseCase 단위 테스트 작성해보고 싶음.

    • MockRepository를 만들어 UseCase 테스트를 수행하는 방법 공부.

  • 클린 아키텍처 Deep Dive


프로젝트 코드는 GitHub에서 확인할 수 있습니다!







- 컬렉션 아티클