Swift

숫자 야구게임을 프로토콜지향 프로그래밍으로 리팩토링 해보기

Jenikeju2552 2025. 3. 12. 19:47

숫자 야구게임을 만들어 보며.. 늘 같은 구조로 설계하는 느낌이 들어서 새로운 방법을 시도해보기 위해 최근에 공부했던 protocol을 적극 사용해서 Protocol Oriented Programming 방식으로 리팩토링 해보려고 합니다.

 

리팩토링하기 전엔 각 기능들을 메서드로 구현하여 클래스 안에 담았습니다.

코드는 아래와 같습니다!

더보기
class NumberBaseballGame {
    
    private var answer: [Int] = []
    private var records: [(game: Int, tryCount: Int)] = []
    private var currentGameIndex = 0
    private var currentState: GameState = .none {
        didSet {
            switch currentState {
            case .play:
                playBaseball()
            case .record:
                openRecord()
            case .exit:
                exitGame()
            default:
                print("올바른 숫자를 입력해주세요!")
            }
        }
    }
   
    enum GameState: Int {
        case play = 1
        case record = 2
        case exit = 3
        case none
    }
    
    init() { }
    
    func play() {
        while currentState != .exit {
            selectMode()
        }
    }
    
    private func selectMode() {
        print("환영합니다! 원하시는 번호를 입력해주세요\n1. 게임 시작하기  2. 게임 기록 보기  3. 종료하기")
        guard let input = readLine(),
        let inputInteger = Int(input),
        let state = GameState(rawValue: inputInteger) else { return }
        currentState = state
    }
    
    private func exitGame() {
        records.removeAll()
        currentGameIndex = 0
    }
    
    private func openRecord() {
        records.forEach { (game, count) in
            print("\(game)번째 게임 : 시도 횟수 - \(count)\n")
        }
    }
    
    private func playBaseball() {
        var currentTryCount = 0
        var isFindAnswer = false
        currentGameIndex += 1
        initAnswer()
        print("<게임을 시작합니다.>")
        while !isFindAnswer {
            print("숫자를 입력하세요")
            guard let input = readLine() else { continue }
            if !isCorrectInput(input: input) {
                print("올바르지 않은 입력값입니다.\n")
                continue
            }
            
            currentTryCount += 1
            let inputIntegerArray = input.map{Int(String($0))!}
            let result = compareAnswer(with: inputIntegerArray)
            
            if result.equalPoint == 3 {
                print("정답입니다!")
                isFindAnswer = true
                records.append((game: currentGameIndex, tryCount: currentTryCount))
            } else if result.equalPoint == 0 && result.containsPoint == 0 {
                print("Nothing")
            } else {
                print("\(result.equalPoint) Strike  \(result.containsPoint) Ball")
            }
        }
    }
    
    private func isCorrectInput(input: String) -> Bool {
        if input.prefix(1) == "0" { return false }
        if Int(input) == nil || Set(input.map{$0}).count != 3 {
            return false
        }
        
        return true
    }
    
    private func initAnswer() {
        var answerArray = [Int]()
        
        func randomNumber(range: Range<Int>) -> Int {
            return Int.random(in: range)
        }
        
        while answerArray.count < 3 {
            let num = randomNumber(range: 1..<10)
            if !answerArray.contains(num) { answerArray.append(num)}
        }
        
        answer = answerArray
    }
    
    private func compareAnswer(with numArray: [Int]) -> (equalPoint: Int, containsPoint: Int) {
        var equalPoint = 0
        var containsPoint = 0
        
        for i in 0..<numArray.count {
            if answer[i] == numArray[i] {
                equalPoint += 1
            } else if answer.contains(numArray[i]) {
                containsPoint += 1
            }
        }
        return (equalPoint: equalPoint, containsPoint: containsPoint)
    }
    
}

 

기획

프로토콜을 사용하기 전에 재사용성, 확장 가능성을 고려하기 위해 공통된 목적의 프로퍼티, 메서드를 묶기로 했습니다.

그 전에 숫자 야구게임이 어떻게 진행되는지 먼저 살펴보았습니다.

 

야구게임 흐름

 - readLine() 메서드를 통해 입력받는다.

 - 1: 게임실행, 2: 기록보기, 3: 게임종료 로 선택할 수 있고, 1,2,3을 제외한 다른 입력값은 즉시 종료한다.

 - 게임실행을 선택한 경우 정답을 맞추기위해 3자리 문자열을 입력받는다. 이때 문자열은 모두 중복없는 숫자로 이루어져야 하고, 0으로 시작해선 안된다.

 - 정답을 맞출 때 까지 입력받고 정답인 경우 기록에 저장후 처음으로 돌아온다.

 - 기록보기를 선택한 경우 이전까지의 기록들을 모두 출력하고 처음으로 돌아온다.

 - 게임종료를 선택한 경우 기록을 모두 삭제하고 게임이 종료된다.

 

여기서 저는 아래와 같이 기능적 공통분모를 설정하였습니다.

 

이를 코드로 구현하면 다음과 같습니다.

// 게임모드 (시작, 기록보기, 종료)
protocol GameContentable: AnyObject {
    func gameStart(currentGameIndex: Int) -> (game: Int, tryCount: Int)
    func openRecord(records: [(game: Int, tryCount: Int)])
    func gameExit()
}

// 게임의 전반적인 로직 (정답생성, 입력값 확인, 숫자 비교)
protocol GameLogicProtocol: AnyObject {
    func initAnswerIntegerArray() -> [Int]
    func isCorrectInput(in input: String) -> Bool
    func compare(input: [Int], withAnswer answer: [Int]) -> (equalPoint: Int, containsPoint: Int)
}

// GameManager
final class BaseBallGameManager {
    
    // 현재 게임순서
    private var currentGameIndex = 1
    // 게임모드 Protocol (게임 시작, 기록보기, 종료)
    weak var gameContent: GameContentable?
    // 기록들
    private var records: [(game: Int, tryCount: Int)] = []
    
    enum Gamemode: String {
        case start = "1"
        case record = "2"
        case exit = "3"
    }
    
    // 게임 실행
    func start() {
        while true {
            print("환영합니다! 원하시는 번호를 입력해주세요\n1. 게임 시작하기  2. 게임 기록 보기  3. 종료하기")
            let input = readLine()!
            
            if input == Gamemode.start.rawValue {
                guard let result = gameContent?.gameStart(currentGameIndex: currentGameIndex) else { return }
                currentGameIndex += 1
                records.append(result)
            } else if input == Gamemode.record.rawValue {
                gameContent?.openRecord(records: records)
            } else if input == Gamemode.exit.rawValue {
                gameContent?.gameExit()
                break
            } else {
                print("올바르지 않은 입력값입니다.")
            }
        }
    }
}

// 게임 컨텐츠 클래스
final class GameContent: GameContentable {
    
    // 게임 로직 Protocol
    weak var delegate: GameLogicProtocol?
    
    // 기록 보기
    func openRecord(records: [(game: Int, tryCount: Int)]) {
        if records.isEmpty { print("게임 기록이 없습니다.")}
        records.forEach { (game, count) in
            print("\(game)번째 게임 : 시도 횟수 - \(count)\n")
        }
    }
    
    // 게임 실행
    func gameStart(currentGameIndex: Int) -> (game: Int, tryCount: Int) {
        var currentTryCount = 0
        guard let answer = delegate?.initAnswerIntegerArray() else { return (game: 0, tryCount: 0) }
        
        while true {
            print("숫자를 입력하세요")
            guard let input = readLine() else { continue }
            
            guard let isCorrect = delegate?.isCorrectInput(in: input),
                  isCorrect
            else {
                print("올바르지 않은 입력값입니다.\n")
                continue
            }
            currentTryCount += 1
            let inputIntegerArray = input.map{Int(String($0))!}
            guard let result = delegate?.compare(input: inputIntegerArray, withAnswer: answer) else { break }
            
            if result.equalPoint == 3 {
                print("정답입니다!")
                return (game: currentGameIndex, tryCount: currentTryCount)
            } else if result.equalPoint == 0 && result.containsPoint == 0 {
                print("Nothing")
            } else {
                print("\(result.equalPoint) Strike  \(result.containsPoint) Ball")
            }
        }
        return (game: 0, tryCount: 0)
    }
    
    // 게임 종료
    func gameExit() {
        print("게임 종료")
    }
}

// 게임 로직
final class GameLogic: GameLogicProtocol {
    
    // 정답 생성
    func initAnswerIntegerArray() -> [Int] {
        var answerArray = [Int]()
        func randomNumber(range: Range<Int>) -> Int {
            return Int.random(in: range)
        }
        while answerArray.count < 3 {
            let num = randomNumber(range: 1..<10)
            if !answerArray.contains(num) { answerArray.append(num)}
        }
        return answerArray
    }
    
    // 입력값 검사
    func isCorrectInput(in input: String) -> Bool {
        if input.prefix(1) == "0" { return false }
        if Int(input) == nil || Set(input.map{$0}).count != 3 {
            return false
        }
        return true
    }
    
    // 숫자 비교
    func compare(input: [Int], withAnswer answer: [Int]) -> (equalPoint: Int, containsPoint: Int) {
        var equalPoint = 0
        var containsPoint = 0
        
        for i in 0..<input.count {
            if answer[i] == input[i] {
                equalPoint += 1
            } else if answer.contains(input[i]) {
                containsPoint += 1
            }
        }
        return (equalPoint: equalPoint, containsPoint: containsPoint)
    }
    
}

 

 

위 코드를 작성하고 나서....

프로토콜을 사용하여 기능적 공통분모를 묶어 사용했지만, POP의 장점을 쉽게 찾을 수 없었습니다.

POP의 장점중 하나인 확장성과 유연성을 이 코드에서 찾을 수 있을까? 생각을 하게되었습니다.

만약 1, 2, 3번 입력받고 게임을 실행하고, 게임로직과 게임 컨텐츠를 새로운 게임의 로직과 컨텐츠로 구현하고 매니저에 주입하면 아예 새로운 게임을 할 수 있을까? 생각을 했지만, 대답은 No였습니다.

 

protocol에는 확장 가능성을 열어두기 위해 어느정도의 추상화가 필요하지만 위 코드는 BaseballGame에 구체화 되어있습니다.

위 코드의 프로토콜을 예시로 농구게임에 사용하기 위해 채택한다면, 이미 BaseballGame에 구체화 되었기 때문에 코드구현은 어려울 것으로 보입니다.

결과적으로 위의 코드는 POP형식을 따른게 아닌 프로토콜 기능을 사용하기만 한 숫자야구게임이 되버렸습니다.

 

추상화는 기획단계에서 여러 경우의수에 따른 확장가능성을 열어두고 추상화를 하지만, 숫자야구게임 구현에 목적을 두고 코딩을 하다보니 구체화 했던것 같습니다.

 

 

그러면 확장가능성을 염려해두고 추상화를 해보자

// 게임 컨텐츠
protocol GameStartable {
    func gameStart(currentGameIndex: Int) -> (game: Int, tryCount: Int)
}

protocol RecordOpenable {
    func openRecord(records: [(game: Int, tryCount: Int)])
}

protocol GameExitable {
    func gameExit()
}

// 숫자 야구게임의 게임컨텐츠
protocol BaseBallGameContentable: GameStartable, RecordOpenable, GameExitable { }

// 게임 로직 프로토콜
protocol InitRandomAnswerArrayProtocol {
    func initAnswerIntegerArray() -> [Int]
}

protocol CompareArrayProtocol {
    func compare(input: [Int], withAnswer answer: [Int]) -> (equalPoint: Int, containsPoint: Int)
}

protocol CorrectInputProtocol {
    func isCorrectInput(in input: String) -> Bool
}

// 숫자 야구게임 로직 프로토콜
protocol BaseBallGameLogicProtocol: InitRandomAnswerArrayProtocol, CorrectInputProtocol, CompareArrayProtocol { }

 

프로토콜의 기능중 다중 채택기능을 사용하여 확장 가능성을 열어두었습니다.

만약 농구게임을 개발할 때, 숫자야구게임과 로직이 같다면 똑같이 채택해도 되지만, 몇개의 기능만 같다면 해당 프로토콜을 다중 채택하여 새로운 프로토콜을 만들도록 설계하였습니다.

여기서 추가로 protocol의 요구사항에 있는 메서드가 제네릭 형식으로 주어지면 더 많은 타입과 리턴값에 대한 확장 가능성을 열어둘 수 있을 것으로 느꼈습니다.