GitHub

두더지게임 모듈화하는 최적의 방법 feat 함수형 코딩

hojun lee · 02/26/2025
커버이미지

클래스형 vs 함수형

특정 모듈을 구현할 때 클래스 형으로 OOP의 일환으로 클래스를 정의하고 이를 기반으로 객체를 생성하여 모듈을 구성하는 방식이 일반적(?)이다.

클래스는 속성(데이터)과 메서드(동작)을 포함하여, 다형성, 상속, 캡슐화 등 개념을 활용할 수 있기 때문이다.

하지만 난 함수형 프로그래밍으로 짤 것이다.

순수 함수 와 불변성을 기반으로 하는 프로그래밍 패러다임이다. 상태와 데이터를 변경하지 않고 함수를 조합하여 원하는 동작을 구현한다.

함수형의 장점

  1. 불변성(Immutability) 데이터가 변경되지 않고 새로운 데이터를 생성하여 사용하는 방식
const originalArray = [1, 2, 3];
const newArray = [...originalArray, 4]; // originalArray는 변경되지 않음
  1. 순수함수(Pure Functions)
const add = (a, b) => a + b; // 순수 함수
  1. 함수 조합 작은 함수를 조합하여 더 복잡한 기능을 만드는 기법

createMoleGame

두더지 게임을 만들어보겠다. ( 포켓몬 세대로서 digda: 디그다게임으로 명명하겠다. )

어떻게 작동하나

const createMoleGame = () => {
  // 여기서 게임을 만듭니다.
      /**
     * 기본 게임 설정을 반환합니다.
     *
     * @returns {Settings} 기본 설정 객체
     */
    const getDefaultSettings = () => ({
        moleCount: 9, // 두더지 수
        gameDuration: 10000, // 게임 전체 시간 (ms)
        moleDurationMin: 200, // 최소 나타나는 시간 (ms)
        moleDurationMax: 300 // 최대 나타나는 시간 (ms)
    })

    /**
     * 게임 설정을 업데이트하고 초기화합니다.
     *
     * @param {Settings} customSettings - 사용자 정의 설정
     * @param {Settings} currentSettings - 현재 설정
     * @returns {Settings} 업데이트된 설정
     */
    const updateSettings = (customSettings, currentSettings) => ({
        ...currentSettings,
        ...customSettings
    })
  
      /**
     * @typedef {Object} Mole
     * @property {boolean} isVisible - 두더지의 표시 여부
     * @property {NodeJS.Timeout | null} timeoutId - 두더지 타이머 ID
     */

    /** @type {Mole[]} */
    let moles = [] // 두더지 상태 배열

    let settings = getDefaultSettings() // 게임 설정

    /** @type {NodeJS.Timeout | null} */
    let gameTimer = null

    /** @type {boolean} */
    let isPlaying = false // 게임 진행 여부

    /** @type {number} */
    let score = 0 // 점수
  
}

위의 기본 세팅을 가지고 시작한다.

무엇을 돌려받나

return {
  startGame,    // 게임을 시작하는 마법의 단추
  endGame,      // 게임을 끝내는 마법의 단추
  whackMole,    // 두더지를 잡는 망치
  getScore,     // 점수를 확인하는 점수판
  getState,     // 게임 상태를 알려주는 안내판
 subscribe: subscribersManager.subscribe.bind(subscribersManager) // 메서드 바인딩
}

이 규칙을 가지고 디그다 게임을 만들어 볼겁니다.

게임을 시작하지

const startGame = (customSettings = {}) => {
    // 여기서 게임 준비
}

게임을 시작하는 버튼이다.

if (isPlaying) {
    console.warn('게임이 이미 진행 중입니다.')
    return
}

error guard 게임이 진행 중이면 "이미 게임 진행중이다!"라고 알려주고 return ( 종료 ) 이건 마치 이미 달리고 있는 레이싱에서 "출발"이라고 다시 외치지 않는 것과 같은 이치

게임판 준비

isPlaying = true
score = 0
settings = updateSettings(customSettings, settings)
moles = initMoles(settings)

isPlaying = true: "게임 시작!" 깃발을 올린다. score = 0: 점수판을 0으로 초기화한다. settings = updateSettings(...): 원하는 설정으로 게임을 세팅한다 (두더지 수, 게임 시간 등). moles = initMoles(settings): 두더지 구멍을 모두 준비한다.

initMoles의 기능 함수를 알아봐야겠지

    /**
     * 두더지 상태를 초기화합니다.
     *
     * 함수 인자 도입: settings를 함수 인자로 받아 외부 상태에 의존하지 않도록 합니다. 이는 함수의 순수성을 높이고, 테스트를 용이하게 합니다. 반환값 명확화:
     * 초기화된 두더지 배열을 반환하여, 함수의 결과를 명확하게 알 수 있습니다.함수 인자 도입: settings를 함수 인자로 받아 외부 상태에 의존하지 않도록 합니다. 반환값
     * 명확화: 초기화된 두더지 배열을 반환하여, 함수의 결과를 명확하게 알 수 있습니다.
     *
     * @param {Settings} settings - 현재 게임 설정
     * @returns {Mole[]} 초기화된 두더지 배열
     */
    const initMoles = (settings) =>
        Array.from({ length: settings.moleCount }, () => ({
            isVisible: false,
            timeoutId: null
        }))

moles 변수에 settings.moleCount 만큼 디그다를 초기 세팅한다. 두더지를 9마리 / 3 마리 등 세팅한 만큼 넣어놓는다.

게임 시작을 알린다.

console.log('게임이 시작되었습니다!')
notifySubscribers()

그것도 모두에게 알린다. ( 모두란 1인 게임 후 socket을 통한 2인게임을 위한 준비)

사용자 구독은 마지막에 다루도록 하겠습니다.

게임을 시작한다.

showMole()



        // 게임 종료 예약
        gameTimer = setTimeout(endGame, settings.gameDuration)

끝날시간도 알려준다.

나타나라 디그다!

const showMole = () => {
    if (!isPlaying) return

    const randomIndex = Math.floor(Math.random() * settings.moleCount)

    if (!moles[randomIndex].isVisible) {
        revealMole(randomIndex, settings)
    } else {
        // 이미 보이는 두더지면 다시 시도
        showMole()
    }
}

동작원리

// 랜덤으로 구멍을 선택한다.
const randomIndex = Math.floor(Math.random() * settings.moleCount)

여러 구멍 중에서 두더지가 튀어나올 구멍을 무작위로 고른다. index로 처리하면 됨

다만 두더지가 이미 담겨있는 경우도 있으니 이를 제외한다.

if (!moles[randomIndex].isVisible) {
    revealMole(randomIndex, settings)
} else {
    // 이미 보이는 두더지면 다시 시도
    showMole()
}

이 함수는 마치 디그다 교통 정리 요원같다.

  1. 게임 열렸는지 확인
  2. 제비뽑기로 어느 두더지를 내보낼지 정한다.
  3. 그 두더지가 이미 영업중인지 확인
  4. 아직 영업전이면 나오라고한다.
  5. 이미 영업중이면 다른 두더지 호출한다.

이 함수는 재귀 호출을 사용한단 점을 주목하자. 첫 번째로 선택한 구멍에 이미 두더지가 있으면 자기 자신을 다시 호출해서 새로운 구멍을 찾는다. 이렇게 하면 반드시 비어있는 구멍을 찾을 때까지 계속 시도하게 된다.

두더지 보이기

    // 두더지 나오기 함수
    const revealMole = (index, settings) => {
        // digda 등장
        moles[index].isVisible = true
        console.log(`${index}번째 두더지가 나타났다!`)
        notifySubscribers() // 상태 변경 알림

        // 랜덤한 지속 시간 계산
        const randomDuration = Math.floor(
            Math.random() * (settings.moleDurationMax - settings.moleDurationMin + 1) +
                settings.moleDurationMin
        )

        moles[index].timeoutId = setTimeout(() => hideMole(index), randomDuration)
    }

두더지의 상태를 변경하고 화면에 보이게 한다. 그리고 이 변화를 구독자들에게 알린다. 마치 "자, 이제 두더지가 나타났으니 망치 준비하세요!"라고 외치는 것과 같다.

  1. 두더지를 보이게 만든다.
moles[index].isVisible = true
console.log(`${index}번째 두더지가 나타났다!`)
notifySubscribers()

두더지 상태를 변경하고 화면에 보이게 한다. 그리고 이 변화를 구독자들에게 알린다. 망치를 준비하세요!

  1. 얼마나 두더지가 노출될지 정한다.
const randomDuration = Math.floor(
    Math.random() * (settings.moleDurationMax - settings.moleDurationMin + 1) +
        settings.moleDurationMin
)

두더지가 얼마나 오래 밖에 있을지 200-300ms 사이 설정한 만큼 랜덤하게 결정한다.

  1. 사라질 시간을 예약한다.
moles[index].timeoutId = setTimeout(() => hideMole(index), randomDuration)

hideMole 함수를 시간 후 실행해서 두더지를 집에가게한다.

    // 두더지를 숨기는 함수
    const hideMole = (index) => {
        if (moles[index].isVisible) {
            moles[index].isVisible = false
            console.log(`${index}번째 두더지가 사라졌다!`)
            notifySubscribers()

            // 새로운 두더지 표시
            if (isPlaying) showMole()
        }
    }
  • 이 함수가 주는 재미 요소 예측 불가능함: 언제 사라질지 모르니 긴장감이 있다. 반응 속도 테스트: 빠르게 나타났다 사라지므로 반사신경을 시험한다. 게임의 난이도 조절: 시간을 조정하여 게임을 더 어렵게 또는 쉽게 만들 수 있다.

여기까진 게임을 진행하는 설정 요소에 가깝다. 이제부턴 user가 두더지를 때리는 등 특별한 액션에 대한 이야기를 해보자 게임만시작하고 때려도 아무일이 안 일어나면 슬프다.

whackMole 디그다 때리기

이 함수는 두더지 게임의 핵심! user가 두더지를 망치로 내려쳤을 때 실행되는 함수

    // 두더지를 잡는 함수
    const whackMole = (index) => {
        if (!isPlaying || !moles[index].isVisible) return false

        console.log(`${index}번째 두더지를 잡았다!`)
        score += 1
        moles[index].isVisible = false

        // 기존 타이머 제거
        clearTimeout(moles[index].timeoutId)
        moles[index].timeoutId = null

        notifySubscribers()

        // 새로운 두더지 표시
        if (isPlaying) showMole()
        return true
    }

1단계: 두더지 확인하기 먼저 게임이 진행 중인지, 그리고 잡으려는 두더지가 정말 밖에 나와 있는지 확인한다. 만약 게임이 끝났거나 두더지가 없으면 아무 일도 일어나지 않는다. 마치 "헛 것"을 본 것처럼!

if (!isPlaying || !moles[index].isVisible) return false

2단계: 점수 올리기 두더지를 성공적으로 잡았으니 점수를 1점 올린다. "짜잔! 점수 +1!"

score는 createMoleGame에서 전역으로 관리되고 있고 클로저로 변수은닉되어 사용자에게 보여진다.

console.log(`${index}번째 두더지를 잡았다!`)
score += 1

3단계: 두더지 숨기기 두더지를 잡았으니까 화면에서 사라지게 한다. "쿵! 두더지 퇴장~"

moles[index].isVisible = false

4단계: 타이머 정리하기 원래는 두더지가 일정 시간 후에 자동으로 사라지도록 타이머가 설정되어 있다. 하지만 우리가 이미 잡았으니까 그 타이머는 필요 없다네! "타이머야, 너의 임무는 끝났다!"

clearTimeout(moles[index].timeoutId)
moles[index].timeoutId = null

5단계: 변경사항 알리기 모든 친구들에게 "나 두더지 잡았어!" 라고 소식을 전한다. 이렇게 하면 화면이 업데이트된다. notifySubscribers()

6단계: 새 두더지 부르기 게임이 아직 진행 중이라면 다른 두더지를 불러내. "다음 두더지 출동!" if (isPlaying) showMole()

7단계: 성공 신호 보내기 모든 과정이 성공적으로 끝났다는 신호를 보낸다. "미션 완료!" return true

디그다 게임 종료하기

    /** 게임을 종료합니다. */
    const endGame = () => {
        if (!isPlaying) {
            console.warn('게임이 진행 중이지 않습니다.')
            return
        }

        isPlaying = false
        clearTimeout(gameTimer) // 게임 종료 타이머 제거

        // 모든 두더지 타이머 제거 및 숨김
        moles.forEach((mole, index) => {
            if (mole.timeoutId) {
                clearTimeout(mole.timeoutId)
                mole.timeoutId = null
            }
            if (mole.isVisible) {
                mole.isVisible = false
                console.log(`${index}번째 두더지가 사라졌습니다!`)
            }
        })

        console.log(`게임 종료! 최종 점수: ${score}`)

        // 상태 변경을 구독자에게 알림
        notifySubscribers()
    }

이거면 얼추 게임이 완성되었다.

추가 필요사항 구현

getScore, // 점수 가져오기
getState, // 현재 상태 가져오기
subscribe: subscribersManager.subscribe.bind(subscribersManager) // 메서드 바인딩
  1. getScore
    // 점수 가져오기
    const getScore = () => score

점수를 직접 접근하지 않고 함수화하여 제공한다. get 함수이다.

  1. getState
    /**
     * 현재 게임 상태를 반환합니다.
     *
     * @returns {GameState} 현재 상태
     */
    const getState = () => ({
        isPlaying,
        score,
        moles: moles.map((mole) => mole.isVisible)
    })

cilent에게 현재 상황을 반환한다.

  1. subscribe 구독 반환 ( 게임 참여자 )

게임 정보를 어떻게 객관적으로 균질하게 공유할지 고민해보았다. 특히 socket기반 게임을 마련하려고 했을 때 이는 무엇보다 중요했다.

게임상태를 구독하는 행위이다.

옵저버 패턴으로 게임 구독하기 옵저버 패턴: 객체의 상태 변화가 있을 때, 이를 의존하는 여러 객체들에게 자동으로 통지하고 업데이트할 수 있도록 하는 디자인 패턴입니다. 주로 이벤트 핸들링이나 데이터 바인딩에 활용됩니다.

set을 사용해 구독자들으 저장하고 관리한다. set의 구조의 경우에 중복을 방지하는 역할을 할 수 있다.


const createSubscribers = () => {
    const subscribers = new Set()

    return {
        /**
         * 모든 구독자에게 상태를 알립니다.
         *
         * @param {GameState} state - 현재 게임 상태
         */
        notify(state) {
            subscribers.forEach((callback) => {
                try {
                    callback(state)
                } catch (error) {
                    console.error('구독자 알림 중 오류:', error)
                }
            })
        },

        /**
         * 구독자를 추가합니다.
         *
         * @param {SubscriberCallback} callback - 상태 변경 시 호출될 콜백 함수
         * @returns {Function} 구독 해제 함수
         */
        subscribe(callback) {
            console.log('새로운 구독자 추가')
            subscribers.add(callback)
            // 구독 해제 함수 반환
            return () => {
                console.log('구독자 제거 시도')
                const removed = subscribers.delete(callback)
                console.log('구독자 제거 성공:', removed)
                console.log('남은 구독자 수:', subscribers.size)
            }
        }
    }
}

notify - 소식 전달

notify(state) {
    subscribers.forEach((callback) => {
        try {
            callback(state)
        } catch (error) {
            console.error('구독자 알림 중 오류:', error)
        }
    })
}

모든 구독자에게 현재 게임 상태를 알린다. 호출 시점 : 게임 상태가 변경될 때마다 (revealMole, hideMole, whackMole, startGame, endGame 등)

subscribe - 구독 신청부

subscribe(callback) {
    console.log('새로운 구독자 추가')
    subscribers.add(callback)
    return () => {
        console.log('구독자 제거 시도')
      const removed = subscribers.delete(callback)
        // 구독 해제 로직
    }
}

새 구독자 등록 및 구독 해제 함수 반환 return 에 구독제거 함수를 박아 놓는다. (클린업 함수)

게임 구독 흐름

구독 관리자 생성: const subscribersManager = createSubscribers()

게임 상태 변경 통지 함수:

const notifySubscribers = () => {
    const state = getState()
    subscribersManager.notify(state)
}

변경 시점 추적:

두더지 등장: revealMole → notifySubscribers() 두더지 사라짐: hideMole → notifySubscribers() 두더지 잡기: whackMole → notifySubscribers() 게임 시작: startGame → notifySubscribers() 게임 종료: endGame → notifySubscribers() 외부 연결점 (client) :

return {
    // ...
    subscribe: subscribersManager.subscribe.bind(subscribersManager)
}

🔗 UI와의 연결 🌟 중요한 기술적 포인트 bind 사용 이유: subscribersManager의 this 컨텍스트 유지 Set 자료구조: 중복 구독 방지 및 효율적인 추가/삭제 클린업 함수: 메모리 누수 방지를 위한 구독 해제 매커니즘 오류 격리: 개별 구독자 오류가 전체 시스템에 영향 없음

게임 인스턴스 생성

  • 기본 인스턴스 제공 ( singletone )
import gameInstance from './digda.js';
// 바로 사용 가능
gameInstance.startGame();

편의성: 대부분의 경우 한 개의 게임만 필요하므로 바로 사용 가능 간편한 사용: 추가 코드 없이 바로 게임 기능 사용 가능 상태 공유: 앱 전체에서 동일한 게임 상태 공유

싱글톤 패턴: 애플리케이션 전체에서 단 하나의 인스턴스만 생성되고, 이에 대한 전역 접근점을 제공하는 디자인 패턴입니다. 주로 설정 관리나 로그 관리 등에 사용됩니다.

  • 팩토리 함수 제공

커스터마이징: 필요하면 여러 개의 게임 인스턴스 생성 가능 테스트 용이성: 테스트마다 격리된 새 인스턴스 사용 가능 다중 게임: 필요하면 여러 게임을 동시에 운영 가능

import { createMoleGame } from './digda.js';
// 새로운 게임 인스턴스 생성
const myCustomGame = createMoleGame();

게임을 실행하는 기본적인 규칙을 만들어보았다.

디그다를 실행해본다.

또 무리하게 함수형으로 모듈을 만들어보았다.

복잡한 듯 여러 기능이 구현된 디그다 게임을 만드는데 앗 정리해보니 좀 낫다.

전체코드공유