본문으로 건너뛰기

리더보드

리더보드는 플레이어 순위를 표시하는 기능입니다. Connect Base의 데이터베이스와 함수를 활용하여 구현합니다.

리더보드 데이터 구조

typescript
// JSON 데이터베이스 테이블 구조
interface LeaderboardEntry {
  _id: string          // 레코드 ID
  userId: string       // 플레이어 ID
  nickname: string     // 닉네임
  score: number        // 점수
  rank?: number        // 순위 (계산됨)
  updatedAt: number    // 마지막 업데이트
  metadata?: {
    wins: number
    losses: number
    playTime: number
  }
}

점수 업데이트

typescript
import ConnectBase from 'connectbase-client'

const cb = new ConnectBase({ appId: 'YOUR_APP_ID', publicKey: 'cb_pk_...' })

// 콘솔의 데이터베이스 → 테이블 만들기 후 발급된 ID 사용
const LEADERBOARD_TABLE = 'tbl_leaderboard'

// 점수 업데이트 (게임 종료 시)
async function updateScore(userId: string, newScore: number) {
  // 기존 기록 조회 (queryData 로 where 조건)
  const result = await cb.database.queryData(LEADERBOARD_TABLE, {
    where: { userId },
    limit: 1
  })

  const existing = result.data[0]

  if (existing) {
    // 점수 업데이트
    await cb.database.updateData(LEADERBOARD_TABLE, existing.id, {
      data: {
        score: newScore,
        updatedAt: Date.now()
      }
    })
  } else {
    // 새 기록 생성
    await cb.database.createData(LEADERBOARD_TABLE, {
      data: {
        userId,
        nickname: await getPlayerNickname(userId),
        score: newScore,
        updatedAt: Date.now(),
        metadata: { wins: 0, losses: 0, playTime: 0 }
      }
    })
  }
}

리더보드 조회

typescript
// 상위 N명 조회
async function getTopPlayers(limit = 100) {
  const result = await cb.database.queryData(LEADERBOARD_TABLE, {
    orderBy: 'score',
    orderDirection: 'desc',
    limit
  })

  // 순위 추가
  return result.data.map((row, index) => ({
    id: row.id,
    ...(row.data as LeaderboardEntry),
    rank: index + 1
  }))
}

// 특정 플레이어 주변 순위 조회
async function getPlayerRanking(userId: string) {
  // 먼저 전체 조회 후 위치 계산 (대규모에는 비효율적 — aggregate 사용 권장)
  const result = await cb.database.queryData(LEADERBOARD_TABLE, {
    orderBy: 'score',
    orderDirection: 'desc',
    limit: 1000
  })

  const playerIndex = result.data.findIndex(
    (row) => (row.data as LeaderboardEntry).userId === userId
  )

  if (playerIndex === -1) return null

  // 앞뒤 5명씩 포함
  const start = Math.max(0, playerIndex - 5)
  const end = Math.min(result.data.length, playerIndex + 6)

  return result.data.slice(start, end).map((row, index) => ({
    id: row.id,
    ...(row.data as LeaderboardEntry),
    rank: start + index + 1
  }))
}

시즌 리더보드

typescript
// 시즌별로 별도 테이블을 만들고 해당 ID 를 매핑
const SEASON_TABLES: Record<number, string> = {
  1: 'tbl_leaderboard_season_1',
  2: 'tbl_leaderboard_season_2',
  3: 'tbl_leaderboard_season_3'
}

function getSeasonTableId(season: number): string {
  const id = SEASON_TABLES[season]
  if (!id) throw new Error(`시즌 ${season} 테이블이 등록되지 않았습니다`)
  return id
}

// 현재 시즌 계산
async function getCurrentSeason(): Promise<number> {
  const now = new Date()
  const startOfYear = new Date(now.getFullYear(), 0, 1)
  const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000))
  return Math.floor(days / 90) + 1  // 90일마다 새 시즌
}

// 시즌 리더보드 조회
async function getSeasonLeaderboard(season?: number) {
  const currentSeason = season ?? (await getCurrentSeason())
  const tableId = getSeasonTableId(currentSeason)

  const result = await cb.database.queryData(tableId, {
    orderBy: 'score',
    orderDirection: 'desc',
    limit: 100
  })
  return result.data
}

실시간 리더보드 업데이트

WebSocket을 활용하여 실시간 순위 변동을 표시합니다:

typescript
// 1) 카테고리 구독
await cb.realtime.connect()
const sub = await cb.realtime.subscribe('leaderboard-updates')

// 2) 메시지 수신 핸들러
type LeaderboardUpdate = { entries: LeaderboardEntry[] }
sub.onMessage<LeaderboardUpdate>((msg) => {
  updateLeaderboardUI(msg.data.entries)
})

// 3) 서버리스 함수에서 점수 변경 시 broadcast
//    (실제 broadcast 는 백엔드에서 NATS publish 또는 socket-server REST 호출로 수행)
//    함수 코드 예시:
// export default async function handler(request, context) {
//   const { userId, newScore } = request.body
//   await updateScore(userId, newScore)
//   const top10 = await getTopPlayers(10)
//   await context.realtime.send('leaderboard-updates', { entries: top10 })
//   return { success: true }
// }

리더보드 UI 예시

typescript
function LeaderboardComponent() {
  const [entries, setEntries] = useState<LeaderboardEntry[]>([])
  const [myRank, setMyRank] = useState<number | null>(null)

  useEffect(() => {
    loadLeaderboard()
    subscribeToUpdates()
  }, [])

  async function loadLeaderboard() {
    const top100 = await getTopPlayers(100)
    setEntries(top100)

    // 내 순위 찾기
    const myEntry = top100.find(e => e.userId === myUserId)
    setMyRank(myEntry?.rank ?? null)
  }

  return (
    <div className="leaderboard">
      <h2>리더보드</h2>
      {myRank && <div className="my-rank">내 순위: {myRank}위</div>}
      <ul>
        {entries.map(entry => (
          <li key={entry.userId}>
            <span className="rank">#{entry.rank}</span>
            <span className="nickname">{entry.nickname}</span>
            <span className="score">{entry.score.toLocaleString()}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}