접두사·접미사 시스템 설계: 무작위 아이템 옵션과 데이터 구조

무작위 아이템 옵션을 예측 가능하고 확장 가능하게 만드는 접두사·접미사 시스템의 규칙을 정리한다. 풀 구성, 가중치, 충돌 제한, 데이터 모델과 구현 예제를 함께 살펴본다.

✓ 복사 완료!
챕터수치 공식 & 밸런싱
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
접두사·접미사 시스템 설계: 무작위 아이템 옵션과 데이터 구조

아이템의 무작위 옵션은 반복 플레이의 동기가 되지만 규칙이 불투명하거나 데이터가 뒤엉키면 밸런싱과 디버깅 비용이 빠르게 커진다. 접두사와 접미사 슬롯을 분리하면 옵션의 역할과 조합 규칙을 명확하게 관리할 수 있다.

접두사와 접미사를 왜 나눌까

접두사(prefix)는 보통 공격력, 방어력, 최대 체력처럼 아이템의 핵심 성능을 강화하는 옵션에 어울린다. 접미사(suffix)는 상태 이상 저항, 처치 보상, 이동 관련 효과처럼 플레이 방식에 변화를 주는 보조 옵션에 배치하기 좋다.

중요한 점은 이름이 아니라 슬롯 규칙이다. 예를 들어 희귀 장비에 접두사 2개와 접미사 2개를 허용하면 콘텐츠 제작자는 옵션의 총량을 예측할 수 있고 플레이어는 아이템의 잠재력을 빠르게 판단할 수 있다.

flowchart TD
    A[기본 아이템 생성] --> B[등급별 접두사·접미사 슬롯 결정]
    B --> C[아이템 조건에 맞는 접두사 후보 필터링]
    C --> D[가중치 추첨 및 충돌 검사]
    D --> E[접두사 확정]
    E --> F[접미사 후보 필터링]
    F --> G[가중치 추첨 및 충돌 검사]
    G --> H[최종 옵션 계산 및 아이템 생성]

이 흐름에서 후보 추출과 충돌 검사를 분리해야 한다. 하나의 함수에 조건 검사, 난수 추첨, 수치 계산을 모두 넣으면 규칙 변경이 어려워진다.

접두사와 접미사 슬롯, 후보 풀, 최종 옵션이 연결된 아이템 생성 화면 예시

먼저 정할 생성 규칙

아이템 등급은 슬롯 수를 정한다

등급은 단순히 수치 배율이 아니라 부여 가능한 옵션 수를 정하는 기준이 될 수 있다. 다음처럼 상한을 분명히 두면 조합 폭발을 막을 수 있다.

등급접두사 최대 수접미사 최대 수
일반00
마법11
희귀22
전설33

실제 생성에서는 최대 슬롯을 항상 채울 필요가 없다. 예를 들어 희귀 아이템이 1~2개의 접두사와 1~2개의 접미사를 갖도록 하면 같은 등급 안에서도 변주가 생긴다. 단, 빈 슬롯 확률이 너무 높으면 높은 등급 아이템이 기대보다 약하게 느껴질 수 있으므로 등급별 최소 옵션 수를 별도로 관리하는 편이 안전하다.

후보 풀은 아이템 문맥으로 좁힌다

모든 옵션을 모든 장비에 넣으면 원거리 무기에 방패 전용 옵션이 붙는 문제가 생긴다. 후보는 최소한 다음 조건으로 필터링한다.

  • 장비 종류: 검, 활, 갑옷, 반지 등
  • 요구 레벨 또는 아이템 레벨
  • 희귀도
  • 슬롯 종류: 접두사 또는 접미사
  • 태그: melee, fire, heavy 같은 게임 규칙 태그
  • 배타 그룹: 같은 계열에서 하나만 허용하는 규칙

아이템 레벨은 착용 요구 레벨과 구분하는 것이 좋다. 아이템 레벨은 드롭 지역이나 보상 난이도를 반영하고 옵션의 티어를 해금하는 내부 기준으로 사용한다. 그러면 저레벨 캐릭터가 얻은 고급 아이템에도 적절한 옵션 풀을 적용할 수 있다.

중복과 충돌을 데이터로 표현한다

화염 피해 증가와 화염 피해 대폭 증가를 동시에 허용할지 결정해야 한다. 허용하지 않는다면 두 옵션에 같은 exclusiveGroup을 지정한다. 체력 증가와 체력 재생처럼 함께 나와도 되는 조합은 서로 다른 그룹으로 둔다.

동일한 옵션 ID가 중복으로 뽑히는 것도 기본적으로 막는 편이 이해하기 쉽다. 다만 스택형 옵션을 의도했다면 maxStacks처럼 명시적인 규칙을 추가해야 한다.

프리픽스·서픽스 데이터 모델

옵션 정의는 표시 이름보다 게임 규칙을 우선해 구성한다. 아래 TypeScript 예시는 런타임에서 쓰기 쉬운 형태다.

type AffixSlot = "prefix" | "suffix";
type ModifierKind = "flat" | "percent";

type StatModifier = {
  stat: "attack" | "armor" | "maxHealth" | "critChance" | "fireResist";
  kind: ModifierKind;
  min: number;
  max: number;
};

type AffixDefinition = {
  id: string;
  slot: AffixSlot;
  name: string;
  weight: number;
  minItemLevel: number;
  allowedItemTags: string[];
  exclusiveGroup?: string;
  modifiers: StatModifier[];
};

const affixes: AffixDefinition[] = [
  {
    id: "prefix-sharp",
    slot: "prefix",
    name: "예리한",
    weight: 60,
    minItemLevel: 1,
    allowedItemTags: ["weapon", "melee", "ranged"],
    exclusiveGroup: "attack-flat",
    modifiers: [{ stat: "attack", kind: "flat", min: 3, max: 6 }]
  },
  {
    id: "suffix-of-embers",
    slot: "suffix",
    name: "불씨의",
    weight: 25,
    minItemLevel: 8,
    allowedItemTags: ["weapon"],
    exclusiveGroup: "elemental-damage",
    modifiers: [{ stat: "fireResist", kind: "percent", min: 4, max: 8 }]
  }
];

allowedItemTags를 문자열 배열로 두면 초기 개발에서는 빠르게 콘텐츠를 만들 수 있다. 규모가 커지면 태그 오탈자를 막기 위해 문자열 리터럴 유니온, 열거형 또는 별도 태그 레지스트리로 옮기는 편이 좋다.

최종 아이템에는 정의 전체를 복사하지 말고 선택된 옵션 ID와 굴린 수치만 저장한다. 정의가 바뀌었을 때 저장 데이터가 불필요하게 비대해지는 일을 막을 수 있다.

type RolledModifier = {
  stat: StatModifier["stat"];
  kind: ModifierKind;
  value: number;
};

type RolledAffix = {
  affixId: string;
  modifiers: RolledModifier[];
};

type ItemInstance = {
  baseItemId: string;
  itemLevel: number;
  prefixes: RolledAffix[];
  suffixes: RolledAffix[];
};

후보 추출과 가중치 추첨

가중치가 높을수록 자주 등장하게 하되 가중치는 확률 자체가 아니라 상대값이다. 후보들의 가중치 합이 100일 필요는 없다.

function getCandidates(
  definitions: AffixDefinition[],
  slot: AffixSlot,
  itemLevel: number,
  itemTags: Set<string>,
  usedIds: Set<string>,
  usedGroups: Set<string>
) {
  return definitions.filter((affix) => {
    const matchesTag = affix.allowedItemTags.some((tag) => itemTags.has(tag));
    const conflicts = affix.exclusiveGroup && usedGroups.has(affix.exclusiveGroup);

    return (
      affix.slot === slot &&
      affix.minItemLevel <= itemLevel &&
      matchesTag &&
      !usedIds.has(affix.id) &&
      !conflicts
    );
  });
}

function pickWeighted<T extends { weight: number }>(items: T[], random = Math.random): T | undefined {
  const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
  if (totalWeight <= 0) return undefined;

  let roll = random() * totalWeight;
  for (const item of items) {
    roll -= item.weight;
    if (roll < 0) return item;
  }

  return items.at(-1);
}

난수 함수를 인자로 받으면 테스트가 쉬워진다. 라이브 서비스나 재현 가능한 버그 분석이 필요하다면 Math.random() 대신 시드 기반 난수 생성기를 사용하고 생성 시드 또는 결과 옵션을 로그에 남기는 방식을 고려할 수 있다.

수치 범위와 티어를 분리하는 방법

초기에는 하나의 옵션에 min, max만 두어도 충분하다. 콘텐츠가 늘어나면 같은 이름의 옵션에 여러 티어를 붙이고 싶어진다. 이때 예리한 I, 예리한 II처럼 데이터 ID만 다르게 복제하기보다 티어를 명시하는 구조가 관리하기 쉽다.

type AffixTier = {
  minItemLevel: number;
  weight: number;
  modifiers: StatModifier[];
};

type TieredAffixDefinition = Omit<AffixDefinition, "minItemLevel" | "weight" | "modifiers"> & {
  tiers: AffixTier[];
};

아이템 레벨에 맞는 티어만 후보로 만든 뒤 가중치 추첨을 수행한다. 같은 옵션 계열의 상위 티어가 열릴수록 하위 티어를 완전히 제거할지, 낮은 확률로 남길지는 게임의 파밍 감각에 따라 결정한다. 성장 체감이 중요하다면 상위 콘텐츠에서 하위 티어의 비중을 줄이는 쪽이 보통 낫다.

밸런싱에서 자주 생기는 문제

조건부 옵션의 실제 가치를 놓친다

보스에게 주는 피해 증가처럼 조건이 있는 옵션은 훈련장 DPS만으로 평가하기 어렵다. 발동 빈도, 전투 길이, 다른 효과와의 곱연산 여부를 포함해 실제 전투 시나리오에서 확인해야 한다.

희귀 옵션이 반드시 좋은 것은 아니다

낮은 가중치는 희소성을 만들지만 성능을 보장하지 않는다. 특정 빌드에서만 강력한 옵션은 낮은 가중치여도 괜찮지만 모든 플레이어가 기대하는 범용 핵심 옵션까지 지나치게 희귀하면 드롭의 만족도가 떨어진다.

접두사와 접미사의 역할이 겹친다

두 슬롯 모두 공격력과 치명타를 주기 시작하면 분류의 의미가 약해진다. 완전히 엄격할 필요는 없지만 각 슬롯이 주로 담당하는 축을 문서화해 두면 새 옵션을 추가할 때 판단 기준이 생긴다.

테스트 체크리스트

  • 각 장비 태그에 허용되지 않은 옵션이 나오지 않는가
  • 동일 옵션과 배타 그룹 중복이 차단되는가
  • 후보가 부족할 때 생성이 중단되지 않고 가능한 만큼만 부여되는가
  • 가중치 변경 후 대량 시뮬레이션 결과가 의도와 맞는가
  • 같은 시드에서 같은 아이템이 재현되는가
  • 저장된 옵션 ID가 삭제되거나 변경됐을 때 마이그레이션 경로가 있는가

접두사·접미사 시스템의 핵심은 무작위성 자체가 아니라 통제 가능한 무작위성이다. 슬롯, 태그, 티어, 충돌 규칙을 데이터로 분리해 두면 새로운 장비와 옵션을 추가해도 생성 코드의 수정 범위를 작게 유지할 수 있다.