블로그로 돌아가기
Frontend

랜덤 편성 기능을 만들면서 고민한 것들

2026.03.13·8분 소요·1회 조회
ReactTypeScriptGSAPNext.js

왜 랜덤 편성인가

Limbus Company의 덱 빌더는 12명의 수감자에게 각각 인격과 EGO를 배정하는 구조다. 메타 편성은 이미 커뮤니티에서 충분히 공유되고 있지만, "랜덤으로 굴려서 플레이한다"는 수요가 생각보다 많았다. 랜덤 챌린지, 축제 컨텐츠, 또는 단순히 매너리즘을 깨고 싶은 유저들을 위한 기능이다.

단순히 Math.random()으로 12개를 뽑으면 끝나는 것 같지만, 실제로 만들어보면 고려할 게 많다.

랜덤 편성 — 고스트 카드 상태 랜덤을 돌리기 전, 12명의 수감자가 고스트 카드로 대기하고 있다.

랜덤 엔진 설계

기본 구조

엔진의 입출력은 명확하다:

typescripttypescript
type RandomOptions = {
  identityEnabled: boolean
  egoEnabled: boolean
  keywords: KeywordCondition[]
  ownedIds: Set<string> | null   // null = 전체
  rarityFilter: Set<number> | null
}

type RandomResult = {
  identities: Record<string, Identity | null>  // 수감자별 인격
  egos: Record<string, EGO[]>                  // 수감자별 EGO 목록
}

12명의 수감자 각각에게 인격 1개와 EGO 여러 개를 배정한다. ownedIds가 있으면 보유한 것만, rarityFilter가 있으면 해당 등급만 풀에 넣는다.

키워드 AND 로직

핵심은 키워드 필터다. "출혈 인격 5명 이상 + 진동 인격 3명 이상" 같은 조건을 걸 수 있는데, 여기서 AND 로직을 선택했다. 즉 선택한 키워드를 모두 가진 인격만 조건에 카운트된다.

typescripttypescript
const matchesAll = (identity: Identity) =>
  allKeywords.every(kw => identity.keywords?.includes(kw))

OR 로직이 더 관대하지만, AND가 더 재미있다. "출혈+진동을 동시에 쓰는 인격"을 조건으로 거는 게 실제 게임 플레이에서도 의미가 있기 때문이다.

조건 검증

랜덤을 돌리기 전에 조건이 만족 가능한지 먼저 검증한다. 보유 현황 연동 시 풀이 좁아지면 "출혈 5명" 조건을 채울 수 없을 수 있다.

typescripttypescript
export function validateConditions(
  sinners: Sinner[],
  identitiesBySinner: Record<string, Identity[]>,
  conditions: KeywordCondition[],
): string | null {
  // 각 수감자에게 조건을 만족하는 인격이 있는지 확인
  let count = 0
  for (const sinner of sinners) {
    const ids = identitiesBySinner[sinner.id] ?? []
    if (ids.some(matchesAll)) count++
  }

  if (count < maxMinCount) {
    return `보유 인격으로는 ${kwLabel} ${maxMinCount}명 조건을 만족할 수 없습니다`
  }
  return null
}

불가능한 조건이면 Toast로 사유를 알려주고 롤을 막는다. 이게 없으면 무한 루프에 빠지거나 의미 없는 결과가 나온다.

키워드 우선 배정

키워드 조건이 있으면 조건을 만족하는 수감자부터 먼저 배정하고, 나머지를 채운다:

typescripttypescript
function keywordAwareAssign(sinners, idsBySinner, conditions) {
  // 1. 조건 만족 가능한 수감자들을 셔플
  const canProvide = sinners.filter(s =>
    idsBySinner[s.id].some(matchesAll)
  )
  const shuffled = shuffle(canProvide)

  // 2. 필요한 수만큼 키워드 인격 배정
  for (let i = 0; i < maxMinCount; i++) {
    const pool = idsBySinner[shuffled[i].id].filter(matchesAll)
    result[shuffled[i].id] = pool[Math.floor(Math.random() * pool.length)]
  }

  // 3. 나머지 수감자는 자유롭게
  for (const s of sinners) {
    if (!usedSinners.has(s.id)) {
      result[s.id] = randomPick(idsBySinner[s.id])
    }
  }
}

Fisher-Yates 셔플로 매번 다른 수감자가 키워드 슬롯에 들어간다.

EGO 등급별 확률

EGO는 인격과 다르게 등급별로 하나씩 뽑되, 등급마다 등장 확률을 다르게 했다:

  • ZAYIN: 항상 1개
  • TETH, HE, WAW, ALEPH: 각각 50% 확률
typescripttypescript
// ZAYIN: always pick one
if (byGrade['ZAYIN']?.length) {
  picked.push(randomPick(byGrade['ZAYIN']))
}
// Other grades: 50% chance
for (const grade of ['TETH', 'HE', 'WAW', 'ALEPH']) {
  if (byGrade[grade]?.length && Math.random() < 0.5) {
    picked.push(randomPick(byGrade[grade]))
  }
}

모든 등급을 무조건 뽑으면 너무 많고, 아예 안 뽑으면 허전하다. ZAYIN은 기본 장비 느낌으로 항상 포함하고, 나머지는 확률로 굴려서 매번 다른 조합이 나오게 했다.

카드 UI: 고스트 카드에서 시네마틱 리빌까지

고스트 카드

결과가 없을 때 빈 슬롯을 보여주는데, 단순히 빈 박스를 두면 밋밋하다. 수감자 로고를 워터마크로 깔고, 코너 장식과 이름 힌트를 넣어 "아직 뽑히지 않은 카드" 느낌을 줬다.

tsxtsx
<div className="animate-ghost-pulse" style={{
  aspectRatio: '2/3',
  background: 'linear-gradient(160deg, #1a1510 0%, #0d0a06 60%, #100c08 100%)',
  border: '1.5px dashed rgba(42,32,21,0.4)',
}}>
  {/* 수감자 로고 워터마크 */}
  <Image src={getSinnerLogoUrl(sinner.id)} alt=""
    style={{ opacity: 0.08, filter: 'grayscale(1)' }} />
  {/* 코너 장식 */}
  <div style={{ borderTop: '1px solid rgba(138,96,16,0.12)', ... }} />
</div>

ghost-pulse 애니메이션으로 미세하게 밝기가 변하면서 "대기 중" 느낌을 강화했다.

GSAP 스태거 리빌

12장의 카드가 한꺼번에 나타나면 임팩트가 없다. GSAP의 stagger로 카드마다 55ms 간격을 두고, back.out 이징으로 약간 튀어나오는 효과를 줬다.

typescripttypescript
gsap.fromTo(cards,
  { opacity: 0, y: 40, scale: 0.88, rotateX: 8 },
  {
    opacity: 1, y: 0, scale: 1, rotateX: 0,
    duration: 0.55,
    stagger: 0.055,
    ease: 'back.out(1.6)',
  },
)

12장 × 55ms = 약 660ms. 전체 애니메이션이 1.2초 안에 끝나면서도 카드가 순서대로 펼쳐지는 느낌을 준다.

랜덤 편성 — 인격 결과 12명의 수감자에게 랜덤 인격이 배정된 결과. 등급별 테두리 색상과 시네마틱 비네팅이 적용되어 있다.

개별 리롤

전체를 다시 굴리는 것뿐 아니라 카드를 클릭하면 해당 수감자만 리롤할 수 있다. 이때 Y축 90도 회전 → 새 이미지 로드 → 역회전으로 카드가 뒤집히는 효과를 줬다.

typescripttypescript
// 즉시 숨기기
card.style.transform = 'scale(0.9) rotateY(90deg)'

// 새 이미지 로드 완료 후 등장
gsap.fromTo(card,
  { opacity: 0, scale: 0.9, rotateY: -90 },
  { opacity: 1, scale: 1, rotateY: 0, duration: 0.4, ease: 'back.out(1.7)' },
)

핵심은 onLoad 콜백이다. 이미지가 로드되기 전에 카드를 보여주면 깨진 이미지가 순간적으로 보인다. <Image key={identity.id}>로 key를 바꿔서 React가 이미지를 리마운트하게 하고, onLoad에서 애니메이션을 시작한다.

카드 디자인: 시네마틱 비네팅

결과 카드는 인격 일러스트를 전면에 깔고, 여러 레이어로 정보를 쌓는다:

  1. 상단 등급 바: 등급 색상의 그라디언트 라인
  2. 좌상단: 수감자 로고 (소속 표시)
  3. 우상단: 등급 아이콘 (1성/2성/3성)
  4. 비네팅: inset box-shadow로 가장자리를 어둡게
  5. 하단 그라디언트: 55% 높이의 검정 그라디언트 → 이름 가독성 확보
  6. 이름: 금색, text-shadow 3중 → 어떤 배경에서도 읽힘
csscss
box-shadow: inset 0 0 2rem rgba(0,0,0,0.4),
            inset 0 -3rem 3rem -1.5rem rgba(0,0,0,0.7);

이 비네팅이 카드에 "영화 포스터" 같은 깊이감을 준다. 추가로 hover 시 scale(1.03) 확대와 리롤 아이콘 오버레이가 뜬다.

EGO 탭: 덱 빌더 스타일 슬롯

EGO는 인격과 다르게 등급(ZAYIN~ALEPH)별로 표시해야 한다. 카드 이미지를 12×5=60장 보여주면 과하므로, 덱 빌더와 같은 수감자 카드 + 등급 슬롯 리스트 형태로 디자인했다.

랜덤 편성 — EGO 탭 EGO 탭. 수감자별로 등급(ZAYIN~ALEPH) 슬롯에 랜덤 EGO가 배정된다. 활성 슬롯은 해당 죄악 색상으로 강조.

tsxtsx
{GRADE_ORDER.map(grade => {
  const ego = egoList.find(e => e.grade === grade)
  return (
    <div style={{
      borderLeft: isActive ? `2px solid ${sinColor}88` : '2px solid transparent',
      background: isActive ? `${sinColor}15` : 'rgba(10,8,5,0.5)',
    }}>
      <Image src={`icons/grade/${grade}.webp`} ... />
      {isActive ? <span style={{ color: sinColor }}>{ego.name_kr}</span> : '—'}
    </div>
  )
})}

활성 슬롯은 해당 EGO의 죄악 색상으로 강조되고, 빈 슬롯은 대시로 표시된다. 수감자별로 다른 죄악 색상이 자연스럽게 12개 카드의 다양성을 만든다.

컬렉션 연동과 덱 코드

가장 까다로웠던 부분이다. 랜덤 결과를 기존 덱 빌더의 덱 코드 형식으로 변환해서, 결과를 복사하면 곧바로 덱 빌더에서 열 수 있게 했다.

typescripttypescript
export async function encodeRandomResult(
  sinners, allIdentities, allEgos, result
): Promise<string> {
  // RandomResult → 덱 빌더 포맷 변환
  const deckSlots = mapIdentitiesToSlots(result)
  const egoSlots = mapEgosToGradeSlots(result)
  const formationOrder = sortByOrderNum(sinners, deckSlots)

  return encodeDeckCode(sinners, allIdentities, allEgos,
    deckSlots, egoSlots, formationOrder)
}

컬렉션 코드도 URL 파라미터, localStorage, 로그인 프로필 순으로 자동 로드된다. ?code=xxx로 공유하면 상대방의 보유 현황 기준으로 랜덤을 돌릴 수도 있다.

등급 필터

2성 이하 인격만으로 랜덤 챌린지를 하고 싶은 수요가 있어서 등급 필터를 추가했다. 토글 칩 UI로, 최소 1개는 선택되어야 한다:

typescripttypescript
if (props.rarityFilter.size <= 1) return // 마지막 하나는 해제 불가

필터는 엔진 단계에서 풀을 좁히기 때문에, 키워드 조건 검증에도 자동으로 반영된다. 2성 출혈 인격이 3명밖에 없으면 "출혈 5명" 조건이 불가능하다는 것도 정확히 알려준다.

되돌아보며

단순해 보이는 "랜덤 뽑기"도 막상 만들면 고려할 게 많다:

  1. 조건 검증을 엔진 밖에서 먼저 하자 — 불가능한 조건으로 무한 루프 도는 건 UX 재앙이다
  2. 애니메이션은 정보 전달이다 — 스태거 리빌은 "12명이 순서대로 결정됐다"는 내러티브를 만든다
  3. 기존 시스템과의 호환 — 랜덤 결과를 덱 코드로 변환해서 다른 기능과 연결하면 가치가 배가된다
  4. 빈 상태도 디자인하자 — 고스트 카드가 "아직 뽑히지 않았다"는 기대감을 만든다

최애의 관리자에서 직접 랜덤 편성을 돌려볼 수 있다.

이전 글

React에서 Apple Liquid Glass 효과를 구현하고 최적화한 과정

다음 글

CodeQuest — GitHub 코드 기반 AI 코딩 퀴즈 플랫폼

관련 글