๐ŸŽฏ g5se ํƒ€์ดํ•‘ ์Šคํ”ผ๋“œ ํ…Œ์ŠคํŠธ - ๋ถ„๋‹น ํƒ€์ˆ˜(WPM) ์ธก์ • & ์ˆœ์œ„ ๊ธฐ๋ก - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ž…๋ฌธ PHP g5se AI: Claude

๐ŸŽฏ g5se ํƒ€์ดํ•‘ ์Šคํ”ผ๋“œ ํ…Œ์ŠคํŠธ - ๋ถ„๋‹น ํƒ€์ˆ˜(WPM) ์ธก์ • & ์ˆœ์œ„ ๊ธฐ๋ก

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… g5se-typing-speed-test
์กฐํšŒ์ˆ˜ 48 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐ŸŽฏ ํ”„๋กœ์ ํŠธ ๊ฐœ์š”

๋ธŒ๋ผ์šฐ์ €์—์„œ ์ฆ‰์‹œ ํƒ€์ดํ•‘ ์†๋„๋ฅผ ์ธก์ •ํ•˜๊ณ , ์ž์‹ ์˜ ๊ธฐ๋ก์„ ์ €์žฅํ•˜๋ฉฐ, ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋“ค๊ณผ ์ˆœ์œ„๋ฅผ ๋น„๊ตํ•  ์ˆ˜ ์žˆ๋Š” ํƒ€์ดํ•‘ ์Šคํ”ผ๋“œ ํ…Œ์ŠคํŠธ ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๊ทธ๋ˆ„๋ณด๋“œ5 SE์˜ modern shell์„ ํ™œ์šฉํ•˜์—ฌ ์‚ฌ์ดํŠธ ๋””์ž์ธ๊ณผ ํ†ต์ผ๊ฐ์„ ์œ ์ง€ํ•˜๋ฉด์„œ, ์ˆœ์ˆ˜ PHP + SQLite๋กœ ๊ฐ„๋‹จํ•œ ์ˆœ์œ„ ์‹œ์Šคํ…œ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

โœจ ํ•ต์‹ฌ ๊ธฐ๋Šฅ

ํ•„์ˆ˜ ๊ธฐ๋Šฅ

  1. ํƒ€์ดํ•‘ ํ…Œ์ŠคํŠธ UI

    • ์ œ์‹œ๋œ ๋ฌธ์žฅ(ํ•œ๊ธ€ ๋˜๋Š” ์˜๋ฌธ)์„ ์ •ํ™•ํžˆ ์ž…๋ ฅํ•˜๋Š” ํ…Œ์ŠคํŠธ
    • ์‹ค์‹œ๊ฐ„ ํƒ€์ด๋จธ (60์ดˆ ์นด์šดํŠธ๋‹ค์šด)
    • ์ž…๋ ฅ ์ค‘ ์ •ํ™•๋„ ์‹ค์‹œ๊ฐ„ ํ‘œ์‹œ (๋งž์€ ๊ธ€์ž / ํ‹€๋ฆฐ ๊ธ€์ž)
    • ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ์‹œ WPM(๋ถ„๋‹น ํƒ€์ˆ˜), ์ •ํ™•๋„(%) ๊ณ„์‚ฐ
  2. ๊ธฐ๋ก ์ €์žฅ ์‹œ์Šคํ…œ

    • ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ํ›„ ๋‹‰๋„ค์ž„ ์ž…๋ ฅํ•˜์—ฌ ๊ธฐ๋ก ์ €์žฅ (SQLite DB)
    • ์ €์žฅ ํ•ญ๋ชฉ: ๋‹‰๋„ค์ž„, WPM, ์ •ํ™•๋„, ํ…Œ์ŠคํŠธ ๋‚ ์งœ
    • ์ค‘๋ณต ๋‹‰๋„ค์ž„ ํ—ˆ์šฉ (๊ฐ™์€ ์‚ฌ๋žŒ์ด ์—ฌ๋Ÿฌ ๋ฒˆ ๋„์ „ ๊ฐ€๋Šฅ)
  3. ์‹ค์‹œ๊ฐ„ ์ˆœ์œ„ํŒ

    • WPM ๊ธฐ์ค€ ์ƒ์œ„ 10๋ช… ์ˆœ์œ„ ํ‘œ์‹œ
    • ์ˆœ์œ„, ๋‹‰๋„ค์ž„, WPM, ์ •ํ™•๋„, ๋‚ ์งœ ํ‘œ์‹œ
    • ๋‚ด ์ตœ๊ณ  ๊ธฐ๋ก ํ•˜์ด๋ผ์ดํŠธ (๋‹‰๋„ค์ž„ ๋งค์นญ)

๋ณด๋„ˆ์Šค ๊ธฐ๋Šฅ (์„ ํƒ)

  • ๋‚œ์ด๋„ ์„ ํƒ (์งง์€ ๋ฌธ์žฅ / ๊ธด ๋ฌธ์žฅ / ์˜๋ฌธ / ํ•œ๊ธ€)
  • ์˜คํƒ€ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ (ํ‹€๋ฆฐ ๊ธ€์ž ๋นจ๊ฐ„์ƒ‰ ํ‘œ์‹œ)
  • ํ…Œ์ŠคํŠธ ์ค‘ ESC ํ‚ค๋กœ ์žฌ์‹œ์ž‘
  • ๊ฐœ์ธ ๊ธฐ๋ก ๊ทธ๋ž˜ํ”„ (์ตœ๊ทผ 5ํšŒ WPM ์ถ”์ด)
  • ์†Œ์…œ ๊ณต์œ  ๋ฒ„ํŠผ ("๋‚˜๋Š” WPM 120! ๋„์ „ํ•ด๋ณด์„ธ์š”!")

๐ŸŽจ ๋””์ž์ธ ์š”๊ตฌ์‚ฌํ•ญ

  • modern shell ์ ์šฉ: ์‚ฌ์ดํŠธ ๋„ค๋น„๊ฒŒ์ด์…˜, ํ‘ธํ„ฐ, ๋‹คํฌ๋ชจ๋“œ ์ž๋™ ์ง€์›
  • ๋””์ž์ธ ํ† ํฐ ์‚ฌ์šฉ: var(--m-surface), var(--m-text), var(--m-shadow) ๋“ฑ์œผ๋กœ ๋‹คํฌ๋ชจ๋“œ ๋Œ€์‘
  • ํƒ€์ดํ•‘ ์˜์—ญ: ํฐ ํฐํŠธ(24px), ๊ณ ๋Œ€๋น„ ์ƒ‰์ƒ, ์ž…๋ ฅ ์ค‘์ธ ๊ธ€์ž ํ•˜์ด๋ผ์ดํŠธ
  • ์ˆœ์œ„ํŒ: ์นด๋“œ ํ˜•ํƒœ, 1~3์œ„๋Š” ๊ธˆ/์€/๋™ ๋ฐฐ์ง€ ์•„์ด์ฝ˜
  • ์• ๋‹ˆ๋ฉ”์ด์…˜: ์นด์šดํŠธ๋‹ค์šด ์ˆซ์ž ํŽ„์Šค ํšจ๊ณผ, ๊ฒฐ๊ณผ ํ™”๋ฉด ํŽ˜์ด๋“œ์ธ

๐Ÿ›  ๊ธฐ์ˆ  ์Šคํƒ

  • ํ”„๋ ˆ์ž„์›Œํฌ: ๊ทธ๋ˆ„๋ณด๋“œ5 SE (PHP ๊ธฐ๋ฐ˜ CMS)
  • DB: SQLite (๋ชจ๋“ˆ ์ „์šฉ ํŒŒ์ผ, G5_DATA_PATH/modules/typing-speed.sqlite)
  • ํ”„๋ก ํŠธ: HTML5 + CSS3 + Vanilla JavaScript
  • ๋ผ์šฐํŒ…: ํด๋” ๊ธฐ๋ฐ˜ ์ž๋™ ๋งคํ•‘ (app/modules/typing-speed/index.php โ†’ /typing-speed)

๐Ÿ“Š ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ

CREATE TABLE IF NOT EXISTS records (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nickname TEXT NOT NULL,
    wpm INTEGER NOT NULL,
    accuracy REAL NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_wpm ON records(wpm DESC);

๐ŸŽฏ ํ•™์Šต ๋ชฉํ‘œ

  • g5se์˜ ํด๋” ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ… ์ดํ•ด
  • modern shell์„ ํ™œ์šฉํ•œ ์ผ๊ด€๋œ UI ๊ตฌ์„ฑ
  • SQLite๋ฅผ ํ™œ์šฉํ•œ ๊ฐ„๋‹จํ•œ CRUD ๊ตฌํ˜„
  • JavaScript๋กœ ํƒ€์ด๋จธ & ์‹ค์‹œ๊ฐ„ ์ž…๋ ฅ ๊ฒ€์ฆ ๋กœ์ง ์ž‘์„ฑ
  • CSS ๋ณ€์ˆ˜(๋””์ž์ธ ํ† ํฐ)๋ฅผ ํ™œ์šฉํ•œ ๋‹คํฌ๋ชจ๋“œ ๋Œ€์‘

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข ๊ทธ๋ˆ„๋ณด๋“œ5 SE ์„ค์น˜ ํ™˜๊ฒฝ ํ•„์š” (https://github.com/gnuboard/g5se)
  • โ€ข PHP 7.4 ์ด์ƒ + SQLite ํ™•์žฅ ํ™œ์„ฑํ™”
  • โ€ข modern shell ์ ์šฉ ํ•„์ˆ˜ (์‚ฌ์ดํŠธ ๋””์ž์ธ ํ†ต์ผ)
  • โ€ข ์™ธ๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ(npm/CDN) ์‚ฌ์šฉ ๊ธˆ์ง€ - ์ˆœ์ˆ˜ HTML/CSS/JS๋งŒ
  • โ€ข ํด๋” ๊ธฐ๋ฐ˜ ์ž๋™ ๋ผ์šฐํŒ… ์‚ฌ์šฉ (๋ณ„๋„ ๋ผ์šฐํŠธ ๋“ฑ๋ก ๋ถˆํ•„์š”)
  • โ€ข DB๋Š” ๋ชจ๋“ˆ ์ „์šฉ SQLite ํŒŒ์ผ ์‚ฌ์šฉ (g5se ๋ฉ”์ธ DB ๊ฑด๋“œ๋ฆฌ์ง€ ๋ง๊ฒƒ)
  • โ€ข ๋ชจ๋“  ์‚ฌ์šฉ์ž ์ž…๋ ฅ์€ htmlspecialchars()๋กœ ์ด์Šค์ผ€์ดํ”„
  • โ€ข prepared statement๋กœ SQL ์ธ์ ์…˜ ๋ฐฉ์ง€

ํ”„๋กฌํ”„ํŠธ ๋ ˆ์‹œํ”ผ

์•„๋ž˜ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ AI์—๊ฒŒ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๋ณต์‚ฌ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.

1
Step 1
g5se๊ฐ€ ์„ค์น˜๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ(๋ฃจํŠธ์— `index.php`ยท`app/`์ด ์žˆ๋Š” ๊ณณ)์—์„œ ์ž‘์—…์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. (g5se ์„ค์น˜๋Š” https://github.com/gnuboard/g5se ์ฐธ๊ณ )

ํƒ€์ดํ•‘ ์Šคํ”ผ๋“œ ํ…Œ์ŠคํŠธ ํŽ˜์ด์ง€์˜ ๊ธฐ๋ณธ ๊ณจ๊ฒฉ์„ ๋งŒ๋“ค์–ด์ฃผ์„ธ์š”.

**1) ํด๋” & ํŒŒ์ผ ์ƒ์„ฑ**
- `app/modules/typing-speed/index.php` ํŒŒ์ผ ์ƒ์„ฑ
- URL์€ ์ž๋™์œผ๋กœ `/typing-speed`๊ฐ€ ๋ฉ๋‹ˆ๋‹ค (ํด๋” ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ…)

**2) modern shell ๊ณจ๊ฒฉ ์ ์šฉ**
ํŒŒ์ผ ๋‚ด์šฉ:
```php
<?php
include_once('./_common.php');

require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
    <?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
    <main class="m-container" style="padding: 32px 20px 48px;">
        <h1 style="font-size: 32px; font-weight: 700; margin-bottom: 8px;">โŒจ๏ธ ํƒ€์ดํ•‘ ์Šคํ”ผ๋“œ ํ…Œ์ŠคํŠธ</h1>
        <p style="color: var(--m-text-muted); margin-bottom: 32px;">60์ดˆ ์•ˆ์— ์–ผ๋งˆ๋‚˜ ๋น ๋ฅด๊ณ  ์ •ํ™•ํ•˜๊ฒŒ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ๋‚˜์š”?</p>
        
        <div id="app">
            <!-- JavaScript๋กœ ๋™์  ๋ Œ๋”๋ง -->
        </div>
    </main>
    <?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```

**3) ๊ธฐ๋ณธ CSS ์Šคํƒ€์ผ**
`<main>` ํƒœ๊ทธ ์•ˆ์— `<style>` ๋ธ”๋ก ์ถ”๊ฐ€:
- ํƒ€์ดํ•‘ ์˜์—ญ ์นด๋“œ: `background: var(--m-surface); box-shadow: var(--m-shadow); border-radius: var(--m-radius-lg); padding: 24px;`
- ํ…์ŠคํŠธ ์ƒ‰์ƒ: `color: var(--m-text);`
- ๋ฒ„ํŠผ: `background: #3b82f6; color: white; padding: 12px 24px; border-radius: 8px; font-weight: 600; cursor: pointer;`

**4) JavaScript ์ดˆ๊ธฐ ๊ตฌ์กฐ**
`<script>` ๋ธ”๋ก ์ถ”๊ฐ€:
```javascript
const app = document.getElementById('app');

function renderStart() {
    app.innerHTML = `
        <div class="card">
            <h2>์ค€๋น„๋˜์…จ๋‚˜์š”?</h2>
            <p>์•„๋ž˜ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด 60์ดˆ ํƒ€์ด๋จธ๊ฐ€ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.</p>
            <button id="startBtn">์‹œ์ž‘ํ•˜๊ธฐ</button>
        </div>
    `;
    document.getElementById('startBtn').addEventListener('click', startTest);
}

function startTest() {
    app.innerHTML = '<p>ํ…Œ์ŠคํŠธ ์‹œ์ž‘!</p>';
}

renderStart();
```

์™„๋ฃŒ ํ›„ `/typing-speed`๋กœ ์ ‘์†ํ•˜์—ฌ "์‹œ์ž‘ํ•˜๊ธฐ" ๋ฒ„ํŠผ์ด ๋ณด์ด๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: - `/typing-speed` URL์—์„œ ํŽ˜์ด์ง€ ์ ‘๊ทผ ๊ฐ€๋Šฅ - modern shell์ด ์ ์šฉ๋˜์–ด ์‚ฌ์ดํŠธ ๋„ค๋น„๊ฒŒ์ด์…˜/ํ‘ธํ„ฐ ํ‘œ์‹œ - "โŒจ๏ธ ํƒ€์ดํ•‘ ์Šคํ”ผ๋“œ ํ…Œ์ŠคํŠธ" ์ œ๋ชฉ๊ณผ ์„ค๋ช… ํ‘œ์‹œ - "์‹œ์ž‘ํ•˜๊ธฐ" ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ "ํ…Œ์ŠคํŠธ ์‹œ์ž‘!" ๋ฉ”์‹œ์ง€ ์ถœ๋ ฅ - ๋‹คํฌ๋ชจ๋“œ ์ „ํ™˜ ์‹œ ๋ฐฐ๊ฒฝ/ํ…์ŠคํŠธ ์ƒ‰์ƒ ์ž๋™ ๋ณ€๊ฒฝ

2
Step 2
ํƒ€์ดํ•‘ ํ…Œ์ŠคํŠธ ํ•ต์‹ฌ ๋กœ์ง์„ ๊ตฌํ˜„ํ•ด์ฃผ์„ธ์š”.

**1) ํ…Œ์ŠคํŠธ ๋ฌธ์žฅ ๋ฐ์ดํ„ฐ**
JavaScript ์ƒ๋‹จ์— ์ถ”๊ฐ€:
```javascript
const sentences = [
    '๋น ๋ฅธ ๊ฐˆ์ƒ‰ ์—ฌ์šฐ๊ฐ€ ๊ฒŒ์œผ๋ฅธ ๊ฐœ๋ฅผ ๋›ฐ์–ด๋„˜์Šต๋‹ˆ๋‹ค.',
    '์ฝ”๋”ฉ์€ ์ฐฝ์˜๋ ฅ๊ณผ ๋…ผ๋ฆฌ๋ ฅ์ด ๋งŒ๋‚˜๋Š” ์˜ˆ์ˆ ์ž…๋‹ˆ๋‹ค.',
    '์—ฐ์Šต์€ ์™„๋ฒฝ์„ ๋งŒ๋“ค์ง€ ์•Š์ง€๋งŒ ์˜๊ตฌ์ ์œผ๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค.',
    'ํƒ€์ดํ•‘ ์†๋„๋Š” ์ƒ์‚ฐ์„ฑ์˜ ๊ธฐ๋ณธ์ž…๋‹ˆ๋‹ค.',
    'ํ‚ค๋ณด๋“œ๋Š” ์ƒ๊ฐ์„ ํ˜„์‹ค๋กœ ๋ฐ”๊พธ๋Š” ๋„๊ตฌ์ž…๋‹ˆ๋‹ค.'
];
```

**2) ํ…Œ์ŠคํŠธ ํ™”๋ฉด ๋ Œ๋”๋ง**
`startTest()` ํ•จ์ˆ˜ ์ˆ˜์ •:
```javascript
let timer = 60;
let timerInterval;
let targetSentence = sentences[Math.floor(Math.random() * sentences.length)];
let userInput = '';
let startTime = Date.now();

function startTest() {
    app.innerHTML = `
        <div class="card">
            <div style="display: flex; justify-content: space-between; margin-bottom: 16px;">
                <span style="font-size: 18px; font-weight: 600;">โฑ๏ธ <span id="timer">60</span>์ดˆ</span>
                <span style="font-size: 18px; font-weight: 600;">์ •ํ™•๋„: <span id="accuracy">100</span>%</span>
            </div>
            <div id="sentence" style="font-size: 24px; line-height: 1.6; margin-bottom: 16px; color: var(--m-text-muted);"></div>
            <textarea id="input" placeholder="์—ฌ๊ธฐ์— ์ž…๋ ฅํ•˜์„ธ์š”..." style="width: 100%; height: 120px; font-size: 18px; padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; resize: none;"></textarea>
        </div>
    `;
    
    renderSentence();
    document.getElementById('input').addEventListener('input', handleInput);
    document.getElementById('input').focus();
    
    timerInterval = setInterval(() => {
        timer--;
        document.getElementById('timer').textContent = timer;
        if (timer <= 0) {
            endTest();
        }
    }, 1000);
}
```

**3) ์‹ค์‹œ๊ฐ„ ์ž…๋ ฅ ๊ฒ€์ฆ**
```javascript
function renderSentence() {
    const sentenceDiv = document.getElementById('sentence');
    let html = '';
    for (let i = 0; i < targetSentence.length; i++) {
        const char = targetSentence[i];
        const userChar = userInput[i];
        let color = 'var(--m-text-muted)';
        if (userChar !== undefined) {
            color = userChar === char ? '#10b981' : '#ef4444';
        }
        html += `<span style="color: ${color};">${char}</span>`;
    }
    sentenceDiv.innerHTML = html;
}

function handleInput(e) {
    userInput = e.target.value;
    renderSentence();
    
    const correct = userInput.split('').filter((c, i) => c === targetSentence[i]).length;
    const accuracy = userInput.length > 0 ? Math.round((correct / userInput.length) * 100) : 100;
    document.getElementById('accuracy').textContent = accuracy;
    
    if (userInput === targetSentence) {
        endTest();
    }
}
```

**4) ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ**
```javascript
function endTest() {
    clearInterval(timerInterval);
    const elapsedTime = (Date.now() - startTime) / 1000 / 60; // ๋ถ„ ๋‹จ์œ„
    const correctChars = userInput.split('').filter((c, i) => c === targetSentence[i]).length;
    const wpm = Math.round(correctChars / 5 / elapsedTime); // ๋‹จ์–ด๋‹น 5๊ธ€์ž ๊ฐ€์ •
    const accuracy = Math.round((correctChars / userInput.length) * 100) || 0;
    
    app.innerHTML = `
        <div class="card" style="text-align: center;">
            <h2 style="font-size: 28px; margin-bottom: 16px;">๐ŸŽ‰ ํ…Œ์ŠคํŠธ ์™„๋ฃŒ!</h2>
            <div style="font-size: 48px; font-weight: 700; color: #3b82f6; margin-bottom: 8px;">${wpm} WPM</div>
            <div style="font-size: 20px; color: var(--m-text-muted); margin-bottom: 24px;">์ •ํ™•๋„: ${accuracy}%</div>
            <button onclick="location.reload()">๋‹ค์‹œ ๋„์ „</button>
        </div>
    `;
}
```

์™„๋ฃŒ ํ›„ ํ…Œ์ŠคํŠธํ•ด๋ณด์„ธ์š”:
1. ์‹œ์ž‘ ๋ฒ„ํŠผ ํด๋ฆญ
2. ์ œ์‹œ๋œ ๋ฌธ์žฅ ์ž…๋ ฅ ์ค‘ ๋งž์€ ๊ธ€์ž๋Š” ์ดˆ๋ก์ƒ‰, ํ‹€๋ฆฐ ๊ธ€์ž๋Š” ๋นจ๊ฐ„์ƒ‰์œผ๋กœ ํ‘œ์‹œ
3. 60์ดˆ ๊ฒฝ๊ณผ ๋˜๋Š” ๋ฌธ์žฅ ์™„์„ฑ ์‹œ WPM๊ณผ ์ •ํ™•๋„ ํ‘œ์‹œ

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: - ๋žœ๋ค ๋ฌธ์žฅ์ด ์ œ์‹œ๋จ - ์ž…๋ ฅ ์ค‘ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ •ํ™•๋„ ๊ณ„์‚ฐ - ๋งž์€ ๊ธ€์ž๋Š” ์ดˆ๋ก์ƒ‰, ํ‹€๋ฆฐ ๊ธ€์ž๋Š” ๋นจ๊ฐ„์ƒ‰์œผ๋กœ ํ‘œ์‹œ - 60์ดˆ ํƒ€์ด๋จธ๊ฐ€ 1์ดˆ์”ฉ ๊ฐ์†Œ - ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ์‹œ WPM(๋ถ„๋‹น ํƒ€์ˆ˜)๊ณผ ์ •ํ™•๋„(%) ๊ณ„์‚ฐ - "๋‹ค์‹œ ๋„์ „" ๋ฒ„ํŠผ์œผ๋กœ ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ

3
Step 3
SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์—ฐ๋™ํ•˜์—ฌ ๊ธฐ๋ก ์ €์žฅ ๋ฐ ์ˆœ์œ„ํŒ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.

**1) DB ์ดˆ๊ธฐํ™” (PHP ์ƒ๋‹จ์— ์ถ”๊ฐ€)**
`<?php` ๋ธ”๋ก ์•„๋ž˜, `require_once` ์ „์—:
```php
// ๋ชจ๋“ˆ DB ๋””๋ ‰ํ„ฐ๋ฆฌ ์ƒ์„ฑ
@mkdir(G5_DATA_PATH.'/modules', 0707, true);

// SQLite ์—ฐ๊ฒฐ
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/typing-speed.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// ํ…Œ์ด๋ธ” ์ƒ์„ฑ
$db->exec("
    CREATE TABLE IF NOT EXISTS records (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        nickname TEXT NOT NULL,
        wpm INTEGER NOT NULL,
        accuracy REAL NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wpm ON records(wpm DESC)");

// AJAX ์ฒ˜๋ฆฌ
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
    header('Content-Type: application/json');
    
    if ($_POST['action'] === 'save') {
        $nickname = trim($_POST['nickname'] ?? '');
        $wpm = intval($_POST['wpm'] ?? 0);
        $accuracy = floatval($_POST['accuracy'] ?? 0);
        
        if ($nickname && $wpm > 0) {
            $stmt = $db->prepare("INSERT INTO records (nickname, wpm, accuracy) VALUES (?, ?, ?)");
            $stmt->execute([$nickname, $wpm, $accuracy]);
            echo json_encode(['success' => true]);
        } else {
            echo json_encode(['success' => false, 'error' => '๋‹‰๋„ค์ž„๊ณผ WPM์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.']);
        }
        exit;
    }
    
    if ($_POST['action'] === 'leaderboard') {
        $stmt = $db->query("SELECT nickname, wpm, accuracy, created_at FROM records ORDER BY wpm DESC LIMIT 10");
        $records = $stmt->fetchAll(PDO::FETCH_ASSOC);
        echo json_encode($records);
        exit;
    }
}
```

**2) ๊ฒฐ๊ณผ ํ™”๋ฉด์— ์ €์žฅ ํผ ์ถ”๊ฐ€**
`endTest()` ํ•จ์ˆ˜ ์ˆ˜์ •:
```javascript
function endTest() {
    clearInterval(timerInterval);
    const elapsedTime = (Date.now() - startTime) / 1000 / 60;
    const correctChars = userInput.split('').filter((c, i) => c === targetSentence[i]).length;
    const wpm = Math.round(correctChars / 5 / elapsedTime);
    const accuracy = Math.round((correctChars / userInput.length) * 100) || 0;
    
    app.innerHTML = `
        <div class="card" style="text-align: center;">
            <h2 style="font-size: 28px; margin-bottom: 16px;">๐ŸŽ‰ ํ…Œ์ŠคํŠธ ์™„๋ฃŒ!</h2>
            <div style="font-size: 48px; font-weight: 700; color: #3b82f6; margin-bottom: 8px;">${wpm} WPM</div>
            <div style="font-size: 20px; color: var(--m-text-muted); margin-bottom: 24px;">์ •ํ™•๋„: ${accuracy}%</div>
            
            <div style="margin-bottom: 16px;">
                <input type="text" id="nickname" placeholder="๋‹‰๋„ค์ž„ ์ž…๋ ฅ" style="padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 16px; width: 200px;">
            </div>
            <button id="saveBtn" style="margin-right: 8px;">๊ธฐ๋ก ์ €์žฅ</button>
            <button onclick="location.reload()">๋‹ค์‹œ ๋„์ „</button>
        </div>
        <div id="leaderboard" style="margin-top: 32px;"></div>
    `;
    
    document.getElementById('saveBtn').addEventListener('click', () => saveRecord(wpm, accuracy));
    loadLeaderboard();
}
```

**3) ์ €์žฅ ๋ฐ ์ˆœ์œ„ํŒ ๋กœ๋“œ**
```javascript
async function saveRecord(wpm, accuracy) {
    const nickname = document.getElementById('nickname').value.trim();
    if (!nickname) {
        alert('๋‹‰๋„ค์ž„์„ ์ž…๋ ฅํ•˜์„ธ์š”!');
        return;
    }
    
    const formData = new FormData();
    formData.append('action', 'save');
    formData.append('nickname', nickname);
    formData.append('wpm', wpm);
    formData.append('accuracy', accuracy);
    
    const res = await fetch('', { method: 'POST', body: formData });
    const data = await res.json();
    
    if (data.success) {
        alert('๊ธฐ๋ก์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
        loadLeaderboard();
    } else {
        alert('์ €์žฅ ์‹คํŒจ: ' + (data.error || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'));
    }
}

async function loadLeaderboard() {
    const formData = new FormData();
    formData.append('action', 'leaderboard');
    
    const res = await fetch('', { method: 'POST', body: formData });
    const records = await res.json();
    
    const leaderboard = document.getElementById('leaderboard');
    if (records.length === 0) {
        leaderboard.innerHTML = '<p style="text-align: center; color: var(--m-text-muted);">์•„์ง ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๊ธฐ๋ก์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!</p>';
        return;
    }
    
    let html = '<div class="card"><h3 style="margin-bottom: 16px;">๐Ÿ† ์ˆœ์œ„ํŒ</h3><div>';
    records.forEach((r, i) => {
        const medal = i === 0 ? '๐Ÿฅ‡' : i === 1 ? '๐Ÿฅˆ' : i === 2 ? '๐Ÿฅ‰' : `${i + 1}.`;
        html += `
            <div style="display: flex; justify-content: space-between; padding: 12px; border-bottom: 1px solid #e5e7eb;">
                <span style="font-weight: 600;">${medal} ${r.nickname}</span>
                <span><strong>${r.wpm} WPM</strong> (${r.accuracy}%)</span>
            </div>
        `;
    });
    html += '</div></div>';
    leaderboard.innerHTML = html;
}
```

์™„๋ฃŒ ํ›„ ํ…Œ์ŠคํŠธ:
1. ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ํ›„ ๋‹‰๋„ค์ž„ ์ž…๋ ฅ โ†’ "๊ธฐ๋ก ์ €์žฅ" ํด๋ฆญ
2. ์ˆœ์œ„ํŒ์— ๋‚ด ๊ธฐ๋ก์ด ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธ
3. ์—ฌ๋Ÿฌ ๋ฒˆ ํ…Œ์ŠคํŠธํ•˜์—ฌ ์ƒ์œ„ 10๋ช… ์ˆœ์œ„๊ฐ€ WPM ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌ๋˜๋Š”์ง€ ํ™•์ธ

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: - ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ์‹œ ๋‹‰๋„ค์ž„ ์ž…๋ ฅ ํผ ํ‘œ์‹œ - "๊ธฐ๋ก ์ €์žฅ" ํด๋ฆญ ์‹œ SQLite DB์— ์ €์žฅ - ์ €์žฅ ์„ฑ๊ณต ์‹œ "๊ธฐ๋ก์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" ์•Œ๋ฆผ - ์ˆœ์œ„ํŒ์— ์ƒ์œ„ 10๋ช… ํ‘œ์‹œ (1~3์œ„๋Š” ๊ธˆ/์€/๋™ ๋ฉ”๋‹ฌ ์ด๋ชจ์ง€) - ์ˆœ์œ„ํŒ์€ WPM ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ - ๊ฐ™์€ ๋‹‰๋„ค์ž„์œผ๋กœ ์—ฌ๋Ÿฌ ๋ฒˆ ์ €์žฅ ๊ฐ€๋Šฅ (๊ฐ๊ฐ ๋ณ„๋„ ๊ธฐ๋ก)

์ œ์ถœ๋œ ์ž‘ํ’ˆ (0)

๋กœ๊ทธ์ธ ํ›„ ์ œ์ถœ

์•„์ง ์ œ์ถœ๋œ ์ž‘ํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋„์ „์ž๊ฐ€ ๋˜์–ด๋ณด์„ธ์š”!

๋Œ“๊ธ€ (0)

๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!