프로젝트 개요
프로젝트명: 콘솔기반 숫자 야구 게임
개발 기간: 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
주요 코드 설명
랜덤 숫자 생성 코드
생성에 사용할 숫자의 범위와 개수를 받음.
첫 번째 숫자로 0이 오는 것을 방지하기 위해 1 ~ 9 사이의 숫자에서 한 개를 뽑음.
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 판단 코드
enumerated()
를 사용하여 인덱스와 값이 같을 때 Strike를 증가시킴.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)
}
발생 이슈
게임 기록 데이터 유실
문제 상황: 게임 기록을 저장했지만, 조회할 떄 데이터가 유실되어 빈 배열(
[]
)이 출력됨.원인 추론:
기록 저장 메소드
saveHistory()
가 정상적으로 호출되지 않았을 가능성→
HistoryRepository
에 값이 저장되는 것을 확인함.기록 조회 메소드
fetchHistory()
가 데이터를 반환하지 않는 문제일 가능성→
fetchHistory()
를GameViewModel
에서 호출하면 데이터가 존재하지만,HistoryViewModel
에서 호출하면 빈 배열이 출력됨.
즉, 저장된 데이터가 다른 View에서 보이지 않는 문제 발생.
단순히fetchHistory()
의 구현 문제가 아님.HistoryRepository
인스턴스가 여러 개 생성되었을 가능성→
GameViewModel
과HistoryViewModel
에서 각각HistoryRepository
의 인스턴스를 생성하고 있어, 저장과 조회가 서로 다른 인스턴스를 참조하는 문제 발생.
해결 방안: 싱글톤(Singleton) 패턴을 적용하여,
HistoryRepository
가 하나의 인스턴스를 공유하도록 개선.결과 확인: 게임 기록을 저장하고, 기록 조회 시 정상적으로 데이터가 유지됨.
싱글톤 패턴의 클린 아키텍처 원칙 위반
문제 상황: 싱글톤 패턴이 클린 아키텍처의 '계층 간의 명확한 의존성 분리 원칙'을 위반함.
원인 추론: 싱글톤 패턴을 사용하면, Repository가 글로벌 상태를 가지게 되며, 암묵적으로 어디서든 접근할 수 있기 때문에 명확한 의존성 흐름을 해칠 수 있음.
해결 방안: DI(Dependency Injection) Container를 활용하여 싱글 인스턴스 주입.
결과 확인: 의존성 분리가 명확해짐.
배운 점
UseCase를 활용하여 비즈니스 로직을 ViewModel과 분리하는 방법
Coordinator 패턴을 적용하여 ViewController 간 강한 결합을 제거하는 방법
Dependency Injection을 활용하여 의존성 분리 원칙을 지키는 방법
향후 목표
Unit Test 추가
비즈니스 로직을 UseCase로 분리했으므로, UseCase 단위 테스트 작성해보고 싶음.
MockRepository를 만들어 UseCase 테스트를 수행하는 방법 공부.
클린 아키텍처 Deep Dive
프로젝트 코드는 GitHub에서 확인할 수 있습니다!