๋กœ์ง ๋ฆฌํŒฉํ† ๋ง (์ธ์นด์šดํ„ฐ)

์ž‘์„ฑ์ผ : 10/10/2025

์ตœ๊ทผ์— ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์— ๋Œ€ํ•ด์„œ ๊ณต๋ถ€ํ•ด๋ณด๋ฉด์„œ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์ข‹์€ ์ฝ”๋“œ๊ฐ€ ์ž˜ ์„ค๊ณ„๋œ ์ฝ”๋“œ๋ผ๋Š”๊ฑธ ๋ฐฐ์› ๋‹ค.
๊ทธ๋ž˜์„œ ์ง€๊ธˆ๊นŒ์ง€ ์ค‘๊ตฌ๋‚œ๋ฐฉ ์ž‘์„ฑ๋œ ์ฝ”๋“œ๋“ค์„ ์ ๊ฒ€ํ•˜๊ณ  ๋ฆฌํŒฉํ† ๋ง(ํด๋กœ๋“œ๊ฐ€ใ…Ž) ํ•ด๋ดค๋‹ค.

๋ฆฌํŒฉํ† ๋ง ๋‚ด์šฉ

์š”์•ฝ

  • ์ „ํˆฌ ๋กœ์ง ๋ถ„๋ฆฌ : BattleField.tsx -> battle.ts (๋‹ค๋ฅธ ๊ธ€์—์„œ ๋‹ค๋ฃฌ๋‹ค)
  • ๋ ˆ๋ฒจ์—… ๋กœ์ง ๋ถ„๋ฆฌ : useGameStore.ts -> battle.ts (๋‹ค๋ฅธ ๊ธ€์—์„œ ๋‹ค๋ฃฌ๋‹ค)
  • storage ์ถ”์ƒํ™” ๊ณ„์ธต ์ถ”๊ฐ€ : storage.ts ํŒŒ์ผ ์ถ”๊ฐ€
  • encounter ์˜์กด์„ฑ ์ฃผ์ž… : encounter.ts

encounter ์˜์กด์„ฑ ์ฃผ์ž…

// before
export function checkEncounter(): boolean {
  const { incrementStepCount, incrementTotalEncounters, setEncounteredPkmon } =
    useGameStore.getState(); // ํƒ€์ดํŠธ ์ปคํ”Œ๋ง, Store์— ์ง์ ‘ ์˜์กด์ค‘

  incrementStepCount();

  const shouldEncounter = Math.random() < 1 / ENCOUNTER_RATE; // ํƒ€์ดํŠธ ์ปคํ”Œ๋ง

  if (shouldEncounter) {
    incrementTotalEncounters();
    const wildPkmon = generateRandomPkmon();
    setEncounteredPkmon(wildPkmon);
    console.log("[Encounter] Wild Pkmon appeared!", wildPkmon);
  }

  return shouldEncounter;
}

// after
export function checkEncounter(
  actions?: EncounterActions,
  encounterRate: number = ENCOUNTER_RATE,
  randomFn: () => number = Math.random
): boolean {
  // actions๊ฐ€ ์—†์œผ๋ฉด ์‹ค์ œ store ์‚ฌ์šฉ (๊ธฐ๋ณธ ๋™์ž‘)
  const { incrementStepCount, incrementTotalEncounters, setEncounteredPkmon } =
    actions || useGameStore.getState(); // ํ…Œ์ŠคํŠธ์‹œ์—๋Š” action ์ธ์ž๋ฅผ ๋„ฃ์–ด ๊ฒ€์ฆ ๊ฐ€๋Šฅ 

  incrementStepCount();

  const shouldEncounter = shouldTriggerEncounter(encounterRate, randomFn);

  if (shouldEncounter) {
    incrementTotalEncounters();
    const wildPkmon = generateRandomPkmon(PKMON_SPECIES, randomFn);
    setEncounteredPkmon(wildPkmon);
    console.log("[Encounter] Wild Pkmon appeared!", wildPkmon);
  }

  return shouldEncounter;
}

ํ•จ์ˆ˜ ๋‚ด๋ถ€์—์„œ useGameStore์™€ Math.random() < 1์„ ์ง์ ‘ ์‚ฌ์šฉํ•˜๋Š”๋ฐ, (๋‚ด๋ถ€์—์„œ ์ง์ ‘ ์˜์กด์„ฑ ์ƒ์„ฑ)
์ด๋ฅผ ๋Œ€์‹ ํ•ด ์™ธ๋ถ€๋กœ๋ถ€ํ„ฐ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌ์„ ๋ฐ›๋„๋ก ํ•ด์„œ ์˜์กด์„ฑ ์ฃผ์ž…์ด๋ผ๊ณ  ํ•œ๋‹ค.

ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ

// encounter.test.ts
const mockActions: EncounterActions = {
        incrementStepCount: vi.fn(),
        incrementTotalEncounters: vi.fn(),
        setEncounteredPkmon: vi.fn(),
      };

const mockRandom = vi
	.fn()
	.mockReturnValue(0.05) // encounter ๋ฐœ์ƒ, ๊ธฐ๋ณธ๊ฐ’
	.mockReturnValueOnce(0.05)
	.mockReturnValueOnce(0.0) // ์ข… ์„ ํƒ
	.mockReturnValueOnce(0.0); // ๋ ˆ๋ฒจ ์„ ํƒ
	
const result = checkEncounter(mockActions, 10, mockRandom);

expect(result).toBe(true);
expect(mockActions.incrementStepCount).toHaveBeenCalledTimes(1);
expect(mockActions.incrementTotalEncounters).toHaveBeenCalledTimes(1);
expect(mockActions.setEncounteredPkmon).toHaveBeenCalledTimes(1);

// EncounterControls.tsx
  <button
	onClick={() => checkEncounter()}
	disabled={!encounterEnabled}
	className="w-full bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded
	disabled:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
>

ํ…Œ์ŠคํŠธ์‹œ์—๋Š” ๊ฐ ์ธ์ž๋“ค(mockActions, mockRandom) ์„ ๋„ฃ์–ด์„œ ํ˜ธ์ถœํ•œ๋‹ค.
mockActions : ์ŠคํŒŒ์ด ํ•จ์ˆ˜๋“ค์ด ๋“ค์–ด์žˆ์–ด์„œ expect ๋งค์ฒ˜๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.
mockRandom : Stub์ด๋ผ๊ณ  ๋ถ€๋ฅด๋Š”, ์‹ค์ œ ํ•จ์ˆ˜๋ฅผ ๋Œ€์ฒดํ•˜๋Š” ์—ญํ• .

Stub ํ•จ์ˆ˜

์‹ค์ œ ํ•จ์ˆ˜(์—ฌ๊ธฐ์„œ๋Š” randomFn = Math.random()) ๋ฅผ ๋Œ€์‹ ํ•œ๋‹ค.
๋ฏธ๋ฆฌ ์ •ํ•ด์ง„ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์ฆ‰ ์œ„์˜ mockRandom์„ ๋ณด๋ฉด 0.05 -> 0.0 -> 0.0์„ ๋ฐ˜ํ™˜ํ•˜๋Š”๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. (mockReturnValue๋Š” ๊ธฐ๋ณธ๊ฐ’)

์ด์ œ checkEncounter()์—์„œ mockRandom์ด ์–ธ์ œ ์–ด๋””์„œ ํ˜ธ์ถœ๋˜๋Š”์ง€ ํ™•์ธํ•ด๋ณด์ž.


// shouldTriggerEncounter(encounterRate, randomFn) ๋‚ด๋ถ€์—์„œ
// 1
return randomFn() < 1 / encounterRate;

// generateRandomPkmon(PKMON_SPECIES, randomFn); ๋‚ด๋ถ€์—์„œ
// 2
  const randomSpecies =
    commonPkmons[Math.floor(randomFn() * commonPkmons.length)];
// 3
  const randomLevel = Math.floor(randomFn() * 5) + 1;

์œ„์—์„œ๋ถ€ํ„ฐ ๋ณธ๋‹ค๋ฉด
๋จผ์ € 0.05 < 1 / encounterRate === true : ์ธ์นด์šดํ„ฐ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค๋Š”๋ฐ ์‚ฌ์šฉํ•˜๊ณ 
๊ทธ๋ฆฌ๊ณ  Math.floor(0.0*commonPkmons.length) === 0 : ๋ฆฌ์ŠคํŠธ ๋งจ ์•ž์˜ ํŒจํ‚ท๋ชฌ์„ ์„ ํƒํ•˜๊ณ 
๋งˆ์ง€๋ง‰์œผ๋กœ Math.floor(0*5)+1 === 1 : ํŒจํ‚ท๋ชฌ์˜ ๋ ˆ๋ฒจ์„ 1๋กœ ์„ค์ •ํ•œ๋‹ค.

storage ์ถ”์ƒํ™” ๊ณ„์ธต ์ถ”๊ฐ€

// before
// utils.ts
localStorage.setItem("pkmon-storage", JSON.stringify({ state: gameData }));

// after
// utils.ts
import { storage } from "./storage";

storage.setItem("pkmon-storage", JSON.stringify({ state: gameData }));

// storage.ts
export interface StorageAdapter {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
  clear(): void;
}

// ํ”„๋กœ๋•์…˜: LocalStorageAdapter
export class LocalStorageAdapter implements StorageAdapter {...}
// ํ…Œ์ŠคํŠธ: InMemoryStorageAdapter
export class InMemoryStorageAdapter implements StorageAdapter {...}

export let storage: StorageAdapter = new LocalStorageAdapter();

์ด๋ ‡๊ฒŒ ํ”„๋กœ๊ทธ๋žจ์ด ๋ธŒ๋ผ์šฐ์ €์˜ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์— ์ง์ ‘ ์ ‘๊ทผํ•˜์ง€ ์•Š๊ณ 
localStorage / InMemoryStorage -> storageAdapter -> useGameStore ๊ฐ€ ๋œ๋‹ค.

์›๋ž˜๋Š” zustand์˜ persist ๋ฏธ๋“ค์›จ์–ด๋ฅผ ๋ณด๋ฉด ๊ธฐ๋ณธ์ ์œผ๋กœ createJSONStorage(()=>localStorage)๋ฅผ ํ†ตํ•ด ๊ธฐ๋ณธ์ ์œผ๋กœ localStorage์™€ ์—ฐ๊ฒฐํ•˜๋Š”๋ฐ, ๋‚˜๋Š” storage ํ•„๋“œ๋ฅผ ์ž…๋ ฅํ–ˆ๋‹ค.

export const useGameStore = create<GameState>()(
	persist(
		(set)=>({
		
		}),
		{
		  name: "pkmon-storage",
		  version: 1,
		  storage: createJSONStorage(() => storage), // <-----์—ฌ๊ธฐ
);

์ปค์Šคํ…€ ์–ด๋Œ‘ํ„ฐ๋ฅผ ๋งŒ๋“ค์–ด์„œ zustand๊ฐ€ ๊ทธ๊ฒƒ์„ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ์ƒํƒœ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค.

๊ตฌํ˜„

๊ทธ๋ ‡๋‹ค๋ฉด ์ƒ๊ฐํ•ด๋ณด์•„์•ผ ํ•˜๋Š”๊ฒŒ "์–ด๋Œ‘ํ„ฐ๋Š” ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ป๊ฒŒ ๊ตฌํ˜„ํ•ด์•ผ ํ• ๊นŒ?"

persist ๋„ํ๋จผํŠธ๋ฅผ ๋ณด๋ฉด ์ด๋ ‡๋‹ค.

export declare const persist : Persist;

type Persist = <
  T,
  Mps extends [StoreMutatorIdentifier, unknown][] = [],
  Mcs extends [StoreMutatorIdentifier, unknown][] = [],
  U = T
>(
  initializer: StateCreator<T, [...Mps, ["zustand/persist", unknown]], Mcs>,
  options: PersistOptions<T, U>
) => StateCreator<T, Mps, [["zustand/persist", U], ...Mcs]>;
declare module "../vanilla.mjs" {
  interface StoreMutators<S, A> {
    "zustand/persist": WithPersist<S, A>;
  }
}

initializer๊ณผ options ๋‘ ์ธ์ž๋ฅผ ํ•„์š”๋กœ ํ•˜๊ณ  (useGameStore.tsx์— ๊ตฌํ˜„๋˜์–ด์žˆ์Œ)

options์˜ ํƒ€์ž…์— ๋Œ€ํ•ด์„œ ์ž์„ธํžˆ ๋ณด๋ฉด ์ด๋ ‡๋‹ค.

export interface PersistOptions<S, PersistedState = S, PersistReturn = unknown> {
    name: string;
    storage?: PersistStorage<PersistedState, PersistReturn> | undefined;
    partialize?: (state: S) => PersistedState;
    onRehydrateStorage?: (state: S) => ((state?: S, error?: unknown) => void) | void;
    version?: number;
    migrate?: (persistedState: unknown, version: number) => PersistedState | Promise<PersistedState>;
    merge?: (persistedState: unknown, currentState: S) => S;
    skipHydration?: boolean;
}

๋‚ด๊ฐ€ ์•Œ์•„์•ผ ํ•  storage ์˜ ํƒ€์ž…์— ๋Œ€ํ•ด ๋˜ ์ฐพ์•„๊ฐ€๋ณด๋ฉด ์ด๋ ‡๋‹ค.

export interface PersistStorage<S, R = unknown> {
    getItem: (name: string) => StorageValue<S> | null | Promise<StorageValue<S> | null>;
    setItem: (name: string, value: StorageValue<S>) => R;
    removeItem: (name: string) => R;
}

์ฆ‰ ์ด ์„ธ ํ•จ์ˆ˜๋Š” ๋ฐ˜๋“œ์‹œ ๊ตฌํ˜„์„ ํ•ด์•ผํ•œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค.