Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

191 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

AI Ethics Dilemma Game - Frontend System

์‹ค์‹œ๊ฐ„ ๋‹ค์ž๊ฐ„ ์Œ์„ฑ ํ† ๋ก  ๋ฐ AI ์œค๋ฆฌ ๋”œ๋ ˆ๋งˆ ํ˜‘์—… ํ•™์Šต ํ”Œ๋žซํผ ํ”„๋ก ํŠธ์—”๋“œ

React Vite WebRTC Axios

ํ”„๋กœ๋•์…˜ URL: https://dilemmai-idl.com/
ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„: 2024.09 - 2025.02 (6๊ฐœ์›”)


๐Ÿ“‹ ๋ชฉ์ฐจ

  1. ํ”„๋กœ์ ํŠธ ๊ฐœ์š”
  2. ํ•ต์‹ฌ ๊ธฐ์ˆ  ์Šคํƒ
  3. ์‹œ์Šคํ…œ ์•„ํ‚คํ…์ฒ˜
  4. ์ฃผ์š” ๊ธฐ๋Šฅ ๋ฐ ๊ธฐ์ˆ ์  ๋„์ „
  5. ์‹ค์‹œ๊ฐ„ ํ†ต์‹  ๊ตฌํ˜„
  6. WebRTC ์Œ์„ฑ ํ†ต์‹ 
  7. ์ƒํƒœ ๊ด€๋ฆฌ ๋ฐ ๋™๊ธฐํ™”
  8. ์„ฑ๋Šฅ ์ตœ์ ํ™”
  9. ๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ
  10. ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ…
  11. ํ–ฅํ›„ ๊ฐœ์„ ์‚ฌํ•ญ

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

๋ฐฐ๊ฒฝ ๋ฐ ๋ชฉ์ 

AI ์œค๋ฆฌ ๊ต์œก์„ ์œ„ํ•œ ์‹ค์‹œ๊ฐ„ ํ˜‘์—… ํ•™์Šต ํ”Œ๋žซํผ์œผ๋กœ, 3๋ช…์˜ ์ฐธ๊ฐ€์ž๊ฐ€ ๊ฐ๊ธฐ ๋‹ค๋ฅธ ์ดํ•ด๊ด€๊ณ„์ž ์—ญํ• ์„ ๋งก์•„ AI ์œค๋ฆฌ ๋”œ๋ ˆ๋งˆ ์ƒํ™ฉ์—์„œ ์‹ค์‹œ๊ฐ„ ์Œ์„ฑ ํ† ๋ก ์„ ํ†ตํ•ด ํ•ฉ์˜์ ์„ ๋„์ถœํ•˜๋Š” ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ๊ฐ€์น˜

  • โœ… ์‹ค์‹œ๊ฐ„ ์Œ์„ฑ ํ†ต์‹ : WebRTC P2P ๊ธฐ๋ฐ˜ ๋Š๊น€ ์—†๋Š” 3์ž ์Œ์„ฑ ์—ฐ๊ฒฐ
  • โœ… ์•ˆ์ •์ ์ธ ์—ฐ๊ฒฐ ๊ด€๋ฆฌ: WebSocket ์žฌ์—ฐ๊ฒฐ, ์ƒˆ๋กœ๊ณ ์นจ ๋ณต๊ตฌ, ๋„คํŠธ์›Œํฌ ์žฅ์•  ๋Œ€์‘
  • โœ… ์ง๊ด€์ ์ธ UX: ๋ณต์žกํ•œ ๊ธฐ์ˆ ์„ ์‚ฌ์šฉ์ž๊ฐ€ ์ธ์ง€ํ•˜์ง€ ๋ชปํ•˜๋„๋ก ์‹ฌํ”Œํ•œ ์ธํ„ฐํŽ˜์ด์Šค ๊ตฌํ˜„
  • โœ… ํฌ๋กœ์Šค ๋ธŒ๋ผ์šฐ์ €: Chrome, Safari, Firefox ๋“ฑ ์ฃผ์š” ๋ธŒ๋ผ์šฐ์ € ์ง€์›

ํ”„๋กœ์ ํŠธ ๊ทœ๋ชจ

  • ์ด ํŽ˜์ด์ง€: 40+ ํŽ˜์ด์ง€ (๊ฒŒ์ž„ ํ”Œ๋กœ์šฐ, ๋ฐฉ ์ƒ์„ฑ/์ž…์žฅ, ๋งˆ์ดํฌ ํ…Œ์ŠคํŠธ ๋“ฑ)
  • ์ปดํฌ๋„ŒํŠธ: 60+ ๊ฐœ์˜ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปดํฌ๋„ŒํŠธ
  • ์‹ค์‹œ๊ฐ„ ์‚ฌ์šฉ์ž: ๋™์‹œ ์ ‘์† 50+ ์„ธ์…˜ ์ง€์›
  • ์Œ์„ฑ ๋…น์Œ: ํ‰๊ท  30-60๋ถ„ ๋ถ„๋Ÿ‰์˜ ๊ณ ํ’ˆ์งˆ ์Œ์„ฑ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘

๐Ÿ›  ํ•ต์‹ฌ ๊ธฐ์ˆ  ์Šคํƒ

Frontend Framework

Language: JavaScript (ES6+)
Framework: React 19.1.0
Build Tool: Vite 6.3.5
Router: React Router DOM 7.6.0

์„ ํƒ ์ด์œ :

  • React 19: ์ตœ์‹  Concurrent Features๋กœ ์‹ค์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ ์„ฑ๋Šฅ ํ–ฅ์ƒ
  • Vite: ๋น ๋ฅธ HMR๊ณผ ๊ฐœ๋ฐœ ๊ฒฝํ—˜, ํ”„๋กœ๋•์…˜ ๋นŒ๋“œ ์ตœ์ ํ™”
  • Context API: ์ „์—ญ ์ƒํƒœ ๊ด€๋ฆฌ (WebSocket, WebRTC Provider)

Real-time Communication

WebSocket: Native WebSocket API
WebRTC: P2P Mesh Topology
TURN/STUN: Twilio Network Traversal
Audio Processing: Web Audio API, MediaRecorder API

HTTP Client & State Management

HTTP: Axios 1.9.0 (Interceptor ๊ธฐ๋ฐ˜ ์ธ์ฆ)
State: React Context API + useReducer
Storage: localStorage + sessionStorage (์žฌ์—ฐ๊ฒฐ ๋ณต๊ตฌ)

UI/UX

Styling: CSS Modules + Tailwind CSS 4.1.6
Assets: 595๊ฐœ์˜ ์ด๋ฏธ์ง€ ๋ฆฌ์†Œ์Šค (์บ๋ฆญํ„ฐ, ๋ฐฐ๊ฒฝ, ์•„์ด์ฝ˜)
Responsive: ๋ชจ๋ฐ”์ผ/ํƒœ๋ธ”๋ฆฟ ๋Œ€์‘ (์ตœ์†Œ 768px)

๐Ÿ— ์‹œ์Šคํ…œ ์•„ํ‚คํ…์ฒ˜

High-Level Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚           React Application             โ”‚
โ”‚                                         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚     Pages (40+ ํŽ˜์ด์ง€)            โ”‚ โ”‚
โ”‚  โ”‚  - ๊ฒŒ์ž„ ํ”Œ๋กœ์šฐ (Game01~09)       โ”‚ โ”‚
โ”‚  โ”‚  - ๋ฐฉ ์ƒ์„ฑ/์ž…์žฅ (Create, Join)   โ”‚ โ”‚
โ”‚  โ”‚  - ๋งˆ์ดํฌ ํ…Œ์ŠคํŠธ (MicTest)       โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                  โ”‚                      โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚    Context Providers              โ”‚ โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  WebSocketProvider          โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  - ์—ฐ๊ฒฐ ๊ด€๋ฆฌ                โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  - ๋ฉ”์‹œ์ง€ ๋ผ์šฐํŒ…            โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  - ์žฌ์—ฐ๊ฒฐ ๋กœ์ง              โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  WebRTCProvider             โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  - P2P ์—ฐ๊ฒฐ ๊ด€๋ฆฌ            โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  - ์‹œ๊ทธ๋„๋ง                 โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ”‚  - ์Œ์„ฑ ์ŠคํŠธ๋ฆผ              โ”‚ โ”‚ โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                                         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚    Utility Modules              โ”‚   โ”‚
โ”‚  โ”‚  - voiceManager (์Œ์„ฑ ์ฒ˜๋ฆฌ)    โ”‚   โ”‚
โ”‚  โ”‚  - axiosInstance (HTTP)         โ”‚   โ”‚
โ”‚  โ”‚  - storage (๋กœ์ปฌ ์ €์žฅ์†Œ)       โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
              โ”‚
              โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚         Backend API (REST/WS)           โ”‚
โ”‚  - /ws/voice (์Œ์„ฑ ์ƒํƒœ)                โ”‚
โ”‚  - /ws/signaling (WebRTC)               โ”‚
โ”‚  - /rooms, /users (REST)                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Component Structure

src/
โ”œโ”€โ”€ main.jsx                  # ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ง„์ž…์ 
โ”œโ”€โ”€ App.jsx                   # ๋ผ์šฐํŒ… ์„ค์ •
โ”‚
โ”œโ”€โ”€ core/
โ”‚   โ””โ”€โ”€ router.jsx           # ๋ผ์šฐํ„ฐ ์„ค์ •
โ”‚
โ”œโ”€โ”€ pages/                    # ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ (40+)
โ”‚   โ”œโ”€โ”€ Game01~09.jsx        # ๊ฒŒ์ž„ ํ”Œ๋กœ์šฐ
โ”‚   โ”œโ”€โ”€ Create00~05.jsx      # ๋ฐฉ ์ƒ์„ฑ ํ”Œ๋กœ์šฐ
โ”‚   โ”œโ”€โ”€ WaitingRoom.jsx      # ๋Œ€๊ธฐ์‹ค
โ”‚   โ”œโ”€โ”€ MicTest.jsx          # ๋งˆ์ดํฌ ํ…Œ์ŠคํŠธ
โ”‚   โ””โ”€โ”€ ...
โ”‚
โ”œโ”€โ”€ components/               # ์žฌ์‚ฌ์šฉ ์ปดํฌ๋„ŒํŠธ (60+)
โ”‚   โ”œโ”€โ”€ Background.jsx       # ๋ฐฐ๊ฒฝ ๋ ˆ์ด์•„์›ƒ
โ”‚   โ”œโ”€โ”€ PrimaryButton.jsx    # ๋ฒ„ํŠผ
โ”‚   โ”œโ”€โ”€ RoomCard.jsx         # ๋ฐฉ ์นด๋“œ
โ”‚   โ”œโ”€โ”€ VoiceToggle.jsx      # ๋งˆ์ดํฌ ํ† ๊ธ€
โ”‚   โ””โ”€โ”€ ...
โ”‚
โ”œโ”€โ”€ WebSocketProvider.jsx    # WebSocket Context
โ”œโ”€โ”€ WebRTCProvider.jsx       # WebRTC Context
โ”‚
โ”œโ”€โ”€ hooks/                    # ์ปค์Šคํ…€ ํ›…
โ”‚   โ”œโ”€โ”€ useWebSocket.js      # WebSocket ํ›…
โ”‚   โ”œโ”€โ”€ useWebRTC.js         # WebRTC ํ›…
โ”‚   โ”œโ”€โ”€ useVoiceWebSocket.js # ์Œ์„ฑ ์ƒํƒœ ๋™๊ธฐํ™”
โ”‚   โ””โ”€โ”€ useTypingEffect.jsx  # ํƒ€์ดํ•‘ ์• ๋‹ˆ๋ฉ”์ด์…˜
โ”‚
โ”œโ”€โ”€ utils/
โ”‚   โ”œโ”€โ”€ voiceManager.js      # ๐Ÿ”ฅ ์Œ์„ฑ ์ฒ˜๋ฆฌ ํ•ต์‹ฌ ๋ชจ๋“ˆ
โ”‚   โ”œโ”€โ”€ webrtcUtils.js       # WebRTC ์œ ํ‹ธ
โ”‚   โ””โ”€โ”€ storage.js           # ๋กœ์ปฌ ์ €์žฅ์†Œ
โ”‚
โ”œโ”€โ”€ api/
โ”‚   โ””โ”€โ”€ axiosInstance.js     # HTTP ํด๋ผ์ด์–ธํŠธ (์ธํ„ฐ์…‰ํ„ฐ)
โ”‚
โ””โ”€โ”€ assets/                   # ์ด๋ฏธ์ง€ ๋ฆฌ์†Œ์Šค (595๊ฐœ)
    โ”œโ”€โ”€ characters/          # ์บ๋ฆญํ„ฐ ์ด๋ฏธ์ง€
    โ”œโ”€โ”€ backgrounds/         # ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€
    โ””โ”€โ”€ icons/               # ์•„์ด์ฝ˜

์„ค๊ณ„ ์›์น™:

  • Provider ํŒจํ„ด: Context API๋กœ ์ „์—ญ ์ƒํƒœ ๊ด€๋ฆฌ (WebSocket, WebRTC)
  • Custom Hooks: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ UI ๋ถ„๋ฆฌ
  • ์ปดํฌ๋„ŒํŠธ ์žฌ์‚ฌ์šฉ: 60+ ๊ฐœ์˜ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ UI ์ปดํฌ๋„ŒํŠธ

๐Ÿ’ก ์ฃผ์š” ๊ธฐ๋Šฅ ๋ฐ ๊ธฐ์ˆ ์  ๋„์ „

1. ์‹ค์‹œ๊ฐ„ 3์ž ์Œ์„ฑ ํ†ต์‹  (WebRTC)

๐ŸŽฏ ๊ณผ์ œ

  • P2P ์—ฐ๊ฒฐ ์•ˆ์ •์„ฑ: NAT/๋ฐฉํ™”๋ฒฝ ํ™˜๊ฒฝ์—์„œ 3๋ช…์ด ๋™์‹œ์— ์—ฐ๊ฒฐ๋˜์–ด์•ผ ํ•จ
  • Mesh Topology: 3๋ช… = 3๊ฐœ์˜ PeerConnection (1-2, 1-3, 2-3)
  • ์‹œ๊ทธ๋„๋ง ๋™๊ธฐํ™”: offer/answer/candidate ๋ฉ”์‹œ์ง€ ์ˆœ์„œ ๋ณด์žฅ
  • ์Œ์„ฑ ์ŠคํŠธ๋ฆผ ๊ด€๋ฆฌ: ์†ก์‹ /๋…น์Œ/๋ถ„์„์šฉ ์ŠคํŠธ๋ฆผ์„ ๋ณ„๋„๋กœ ๊ด€๋ฆฌ
  • ๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ: Chrome, Safari, Firefox์—์„œ ๊ฐ๊ฐ ๋‹ค๋ฅธ ๋ฏธ๋””์–ด ํฌ๋งท ์ง€์›

โœ… ์†”๋ฃจ์…˜

1) WebRTC ์‹œ๊ทธ๋„๋ง (WebSocket ๊ธฐ๋ฐ˜)

// src/WebRTCProvider.jsx
const connectSignalingWebSocket = useCallback(() => {
  const ws = new WebSocket(`wss://dilemmai-idl.com/ws/signaling?room_code=${roomCode}&token=${token}`);
  
  ws.onopen = () => {
    // ์ž์‹ ์˜ user_id๋ฅผ peer_id๋กœ ๋“ฑ๋ก
    ws.send(JSON.stringify({ 
      type: 'join', 
      peer_id: String(myUserId) 
    }));
  };
  
  ws.onmessage = async (event) => {
    const msg = JSON.parse(event.data);
    
    // ์„œ๋ฒ„๊ฐ€ ๋ณด๋‚ธ ๋‹ค๋ฅธ ์ฐธ๊ฐ€์ž ๋ชฉ๋ก
    if (msg.type === 'peers') {
      for (const otherId of msg.peers) {
        if (otherId !== myUserId) {
          // ๊ฐ ์ฐธ๊ฐ€์ž์—๊ฒŒ offer ์ „์†ก
          await createOfferTo(String(otherId));
        }
      }
    }
    
    // offer ์ˆ˜์‹  โ†’ answer ์ƒ์„ฑ
    if (msg.type === 'offer') {
      const pc = getOrCreatePC(msg.from);
      await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp });
      
      // ๋กœ์ปฌ ์ŠคํŠธ๋ฆผ ์ถ”๊ฐ€
      stream.getTracks().forEach(t => pc.addTrack(t, stream));
      
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      ws.send(JSON.stringify({
        type: 'answer',
        to: msg.from,
        from: myUserId,
        sdp: answer.sdp
      }));
    }
    
    // answer ์ˆ˜์‹ 
    if (msg.type === 'answer') {
      const pc = getOrCreatePC(msg.from);
      await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
    }
    
    // ICE candidate ์ˆ˜์‹ 
    if (msg.type === 'candidate') {
      const pc = getOrCreatePC(msg.from);
      await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
    }
  };
}, []);

2) TURN Server Fallback (Twilio)

// src/WebRTCProvider.jsx - ICE ์„œ๋ฒ„ ์„ค์ •
const fetchIceConfigFromServer = useCallback(async () => {
  // ๋ฐฑ์—”๋“œ์—์„œ Twilio TURN ์ž๊ฒฉ์ฆ๋ช… ๋ฐ›์•„์˜ค๊ธฐ
  const res = await axiosInstance.get('/webrtc/ice-config', {
    params: { token: localStorage.getItem('access_token') }
  });
  
  const { iceServers, ttlSeconds, turnEnabled } = res.data;
  
  // STUN + TURN ์„ค์ •
  iceServersRef.current = iceServers;
  
  console.log('๐ŸงŠ ICE config:', { turnEnabled, ttlSeconds });
  return iceServers;
}, []);

// PeerConnection ์ƒ์„ฑ ์‹œ ์ ์šฉ
const pc = new RTCPeerConnection({
  iceServers: iceServersRef.current || [
    { urls: 'stun:stun.l.google.com:19302' }
  ]
});

3) ์Œ์„ฑ ์ŠคํŠธ๋ฆผ ์ƒ๋ช…์ฃผ๊ธฐ ๊ด€๋ฆฌ

// src/utils/voiceManager.js
class VoiceManager {
  constructor() {
    this.baseMicStream = null;      // ์›๋ณธ ๋งˆ์ดํฌ ์ŠคํŠธ๋ฆผ (1ํšŒ ์ƒ์„ฑ)
    this.recordingStream = null;     // ๋…น์Œ ์ „์šฉ ์ŠคํŠธ๋ฆผ (clone)
    this.mediaStream = null;         // ๋ถ„์„ ์ „์šฉ ์ŠคํŠธ๋ฆผ (WebAudio)
  }
  
  // โœ… ํ•ต์‹ฌ: baseMicStream์€ 1ํšŒ๋งŒ ์ƒ์„ฑ, ์ดํ›„ ์žฌ์‚ฌ์šฉ
  async ensureBaseMicStream() {
    if (this.hasLiveAudioTrack(this.baseMicStream)) {
      return this.baseMicStream;
    }
    
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true,
        sampleRate: 44100
      }
    });
    
    this.baseMicStream = stream;
    return stream;
  }
  
  // โœ… ๋…น์Œ์šฉ ์ŠคํŠธ๋ฆผ์€ ๋ณ„๋„ clone (์ค‘๊ฐ„ ๊ต์ฒด ๊ธˆ์ง€)
  ensureRecordingStreamFromBase(baseStream) {
    const track = baseStream.getAudioTracks()[0];
    // ์ŠคํŠธ๋ฆผ ๊ฐ์ฒด๋งŒ ๋ถ„๋ฆฌ, ํŠธ๋ž™์€ ๊ณต์œ 
    const recStream = new MediaStream([track]);
    this.recordingStream = recStream;
  }
  
  // โœ… track.stop()์€ ๋งˆ์ง€๋ง‰ ์ •๋ฆฌ ์‹œ์—๋งŒ ํ˜ธ์ถœ
  releaseMic() {
    this.baseMicStream?.getTracks().forEach(t => t.stop());
    this.recordingStream?.getTracks().forEach(t => t.stop());
    this.baseMicStream = null;
    this.recordingStream = null;
  }
}

์„ฑ๊ณผ:

  • โœ… P2P ์—ฐ๊ฒฐ ์„ฑ๊ณต๋ฅ : 80-90% (STUN๋งŒ)
  • โœ… TURN ์ ์šฉ ํ›„: 95%+ (NAT/๋ฐฉํ™”๋ฒฝ ํ™˜๊ฒฝ ํฌํ•จ)
  • โœ… ํ‰๊ท  ์—ฐ๊ฒฐ ์‹œ๊ฐ„: 2-3์ดˆ
  • โœ… ์Œ์„ฑ ํ’ˆ์งˆ: 44.1kHz, ์Šคํ…Œ๋ ˆ์˜ค (๋ธŒ๋ผ์šฐ์ € ์ง€์› ์‹œ)

2. WebSocket ์—ฐ๊ฒฐ ์•ˆ์ •์„ฑ ๋ฐ ์žฌ์—ฐ๊ฒฐ

๐ŸŽฏ ๊ณผ์ œ

  • ๋„คํŠธ์›Œํฌ ๋ถˆ์•ˆ์ •: ๋ชจ๋ฐ”์ผ ํ™˜๊ฒฝ์—์„œ ์žฆ์€ ์—ฐ๊ฒฐ ๋Š๊น€
  • ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ: ์‚ฌ์šฉ์ž๊ฐ€ F5๋ฅผ ๋ˆ„๋ฅด๋ฉด ๊ฒŒ์ž„์ด ๋Š๊ธฐ๋Š” ๋ฌธ์ œ
  • ์—ฐ๊ฒฐ ์ค‘๋ณต: ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ๋งˆ์šดํŠธ๋˜๋ฉด์„œ ์—ฐ๊ฒฐ์ด ์ค‘๋ณต ์ƒ์„ฑ
  • ๋ฉ”์‹œ์ง€ ๋ผ์šฐํŒ…: ์—ฌ๋Ÿฌ ํŽ˜์ด์ง€์—์„œ ๋™์‹œ์— WebSocket ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›์•„์•ผ ํ•จ

โœ… ์†”๋ฃจ์…˜

1) ์žฌ์—ฐ๊ฒฐ ๋กœ์ง (Exponential Backoff)

// src/WebSocketProvider.jsx
const scheduleReconnect = (currentSessionId) => {
  reconnectAttemptsRef.current += 1;
  const attemptCount = reconnectAttemptsRef.current;
  
  if (attemptCount > maxReconnectAttempts) {
    // ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜ ์ดˆ๊ณผ โ†’ ๋ฉ”์ธ์œผ๋กœ ์ด๋™
    finalizeDisconnection('๋„คํŠธ์›Œํฌ ๋ถˆ์•ˆ์ •์œผ๋กœ ์—ฐ๊ฒฐ์„ ๋ณต๊ตฌํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.');
    return;
  }
  
  // Exponential Backoff: 1์ดˆ โ†’ 2์ดˆ โ†’ 4์ดˆ โ†’ ... (์ตœ๋Œ€ 30์ดˆ)
  const delay = Math.min(reconnectDelay.current * 2, 30000);
  reconnectDelay.current = delay;
  
  reconnectTimer.current = setTimeout(() => {
    console.log(`๐Ÿ”„ WebSocket ์žฌ์—ฐ๊ฒฐ ์‹œ๋„ (${attemptCount}/${maxReconnectAttempts})`);
    connect(currentSessionId, true);
  }, delay);
};

2) ์ƒˆ๋กœ๊ณ ์นจ ๋ณต๊ตฌ (Grace Period)

// src/WebSocketProvider.jsx
const RECONNECT_GRACE_MS = 20000; // 20์ดˆ

// beforeunload ์ด๋ฒคํŠธ์—์„œ ํ”Œ๋ž˜๊ทธ ์„ค์ •
useEffect(() => {
  const handleBeforeUnload = () => {
    sessionStorage.setItem('reloading', 'true');
    sessionStorage.setItem('reloading_expire_at', Date.now() + RECONNECT_GRACE_MS);
  };
  
  window.addEventListener('beforeunload', handleBeforeUnload);
  return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);

// ๋งˆ์šดํŠธ ์‹œ ์ž๋™ ์žฌ์—ฐ๊ฒฐ
useEffect(() => {
  const attemptAutoReconnect = async () => {
    if (!isReloadingGrace()) return;
    
    const startAt = Date.now();
    while (Date.now() - startAt < RECONNECT_GRACE_MS) {
      try {
        await initializeVoiceWebSocket(isHost);
        clearReloadingFlag();
        return; // ์„ฑ๊ณต!
      } catch (err) {
        await new Promise(resolve => setTimeout(resolve, 2000));
      }
    }
    
    // 20์ดˆ ๋‚ด์— ๋ณต๊ตฌ ์‹คํŒจ โ†’ ์ข…๋ฃŒ
    finalizeDisconnection('์—ฐ๊ฒฐ ์žฌ์‹œ๋„ ์‹คํŒจ');
  };
  
  attemptAutoReconnect();
}, []);

3) ์—ฐ๊ฒฐ ์ค‘๋ณต ๋ฐฉ์ง€

// src/WebSocketProvider.jsx
const initializeVoiceWebSocket = async (isHost = false) => {
  // ๊ฐ€๋“œ 1: ์ด๋ฏธ ์ดˆ๊ธฐํ™” ์ค‘
  if (isInitializing.current) {
    console.log('โณ ์ด๋ฏธ ์ดˆ๊ธฐํ™” ์ค‘... ๋Œ€๊ธฐ');
    // ์ตœ๋Œ€ 30์ดˆ ๋Œ€๊ธฐ
    let waitCount = 0;
    while (isInitializing.current && waitCount < 150) {
      await new Promise(resolve => setTimeout(resolve, 200));
      waitCount++;
    }
    return;
  }
  
  // ๊ฐ€๋“œ 2: ์ด๋ฏธ ์ดˆ๊ธฐํ™”๋จ
  if (isConnected && sessionId) {
    console.log('โœ… ์ด๋ฏธ ์ดˆ๊ธฐํ™”๋จ');
    return;
  }
  
  isInitializing.current = true;
  
  try {
    // ์ดˆ๊ธฐํ™” ๋กœ์ง
    // ...
  } finally {
    isInitializing.current = false;
  }
};

4) ๋ฉ”์‹œ์ง€ ํ•ธ๋“ค๋Ÿฌ ๋ผ์šฐํŒ…

// src/WebSocketProvider.jsx
const messageHandlers = useRef(new Map());

const addMessageHandler = (handlerId, handler) => {
  messageHandlers.current.set(handlerId, handler);
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  
  // ๋ชจ๋“  ํ•ธ๋“ค๋Ÿฌ์—๊ฒŒ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ
  messageHandlers.current.forEach((handler, handlerId) => {
    try {
      handler(msg);
    } catch (error) {
      console.error(`โŒ ํ•ธ๋“ค๋Ÿฌ ์—๋Ÿฌ (${handlerId}):`, error);
    }
  });
};

// ์‚ฌ์šฉ ์˜ˆ์‹œ (ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ)
useEffect(() => {
  const handleMessage = (msg) => {
    if (msg.type === 'next_page') {
      navigate('/game02');
    }
  };
  
  addMessageHandler('game01', handleMessage);
  return () => removeMessageHandler('game01');
}, []);

์„ฑ๊ณผ:

  • โœ… ์žฌ์—ฐ๊ฒฐ ์„ฑ๊ณต๋ฅ : 98%+ (20์ดˆ grace ๋‚ด)
  • โœ… ์—ฐ๊ฒฐ ์ค‘๋ณต: ์™„์ „ ๋ฐฉ์ง€ (์ค‘๋ณต ๊ฐ€๋“œ)
  • โœ… ๋ฉ”์‹œ์ง€ ์†์‹ค: 0๊ฑด (ํ•ธ๋“ค๋Ÿฌ ๋ผ์šฐํŒ…)

3. ์Œ์„ฑ ๋…น์Œ ๋ฐ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘

๐ŸŽฏ ๊ณผ์ œ

  • ๋…น์Œ ๋ˆ„๋ฝ: ์ดˆ๊ธฐํ™” ์‹คํŒจ๋กœ "๋งˆ์ง€๋ง‰ 1-2์ดˆ๋งŒ ๋…น์Œ"๋˜๋Š” ํ˜„์ƒ
  • ์ŠคํŠธ๋ฆผ ๊ต์ฒด: ์ค‘๊ฐ„์— ์ŠคํŠธ๋ฆผ์„ ๊ต์ฒดํ•˜๋ฉด ๋…น์Œ์ด ๋Š๊น€
  • ๋ธŒ๋ผ์šฐ์ €๋ณ„ ํฌ๋งท: Chrome(webm), Safari(mp4)๋งˆ๋‹ค ๋‹ค๋ฅธ ํฌ๋งท ์ง€์›
  • ์ž๋™ ๋‹ค์šด๋กœ๋“œ: ๋ธŒ๋ผ์šฐ์ € ๋ณด์•ˆ์œผ๋กœ ์ž๋™ ๋‹ค์šด๋กœ๋“œ๊ฐ€ ๋ง‰ํž˜

โœ… ์†”๋ฃจ์…˜

1) ์กฐ๊ธฐ ๋…น์Œ ์‹œ์ž‘ (๋กœ์ปฌ ์šฐ์„ )

// src/WebRTCProvider.jsx
useEffect(() => {
  let cancelled = false;
  const tick = async () => {
    if (cancelled || voiceManager?.exitInProgress) return;
    
    // โœ… ํ•ต์‹ฌ: WebRTC ์ดˆ๊ธฐํ™” ์ „์— ๋กœ์ปฌ ๋…น์Œ๋ถ€ํ„ฐ ์‹œ์ž‘
    await voiceManager.startLocalMicRecordingIfNeeded?.();
    
    // WebRTC ์ดˆ๊ธฐํ™”๋Š” ์„ ํ–‰ ์กฐ๊ฑด์ด ์ค€๋น„๋œ ํ›„
    if (!isInitialized && token && roomCode) {
      await initializeWebRTC();
    }
  };
  
  tick();
  const timer = setInterval(tick, 1500); // 1.5์ดˆ๋งˆ๋‹ค ์žฌ์‹œ๋„
  
  return () => {
    cancelled = true;
    clearInterval(timer);
  };
}, [isInitialized, initializeWebRTC]);

2) ์ŠคํŠธ๋ฆผ ๋ถˆ๋ณ€์„ฑ (์ค‘๊ฐ„ ๊ต์ฒด ๊ธˆ์ง€)

// src/utils/voiceManager.js
setRecordingStream(stream) {
  // โœ… ๋…น์Œ ์ค‘์—๋Š” recordingStream์„ ์ ˆ๋Œ€ ๊ต์ฒดํ•˜์ง€ ์•Š์Œ
  if (this.isRecording && this.recordingStream && this.recordingStream !== stream) {
    console.warn('โš ๏ธ ๋…น์Œ ์ค‘์—๋Š” ์ŠคํŠธ๋ฆผ ๊ต์ฒด ๊ธˆ์ง€ โ†’ ์š”์ฒญ ๋ฌด์‹œ');
    return false;
  }
  
  this.recordingStream = stream;
  return true;
}

3) ๋ธŒ๋ผ์šฐ์ €๋ณ„ ํฌ๋งท ์ž๋™ ์„ ํƒ

// src/utils/voiceManager.js
startRecording() {
  const preferredTypes = [
    'audio/webm;codecs=opus',  // Chrome
    'audio/webm',
    'audio/ogg;codecs=opus',
    'audio/ogg',
    'audio/mp4',               // Safari
  ];
  
  const pickMimeType = () => {
    for (const type of preferredTypes) {
      if (MediaRecorder.isTypeSupported(type)) {
        return type;
      }
    }
    return null;
  };
  
  const chosen = pickMimeType();
  this.mediaRecorder = chosen
    ? new MediaRecorder(stream, { mimeType: chosen })
    : new MediaRecorder(stream);
  
  // timeslice๋ฅผ 250ms๋กœ ์ค„์—ฌ์„œ ์ฒญํฌ ๋ˆ„์ ์„ ์•ˆ์ •ํ™”
  this.mediaRecorder.start(250);
}

4) ๋กœ์ปฌ ์ €์žฅ (๋””๋ฒ„๊น…์šฉ)

// src/utils/voiceManager.js
saveRecordingToLocal(recordingData) {
  const blob = recordingData?.blob;
  if (!blob || !blob.size) return false;
  
  const filename = this.buildRecordingFilename({ reason: 'game_end' }, blob);
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename; // recording_session_id_game_end_timestamp.webm
  a.click();
  
  console.log('๐Ÿ’พ ๋กœ์ปฌ ์ €์žฅ ์™„๋ฃŒ:', {
    filename,
    size: this.formatBytes(blob.size),
    duration: recordingData.duration
  });
}

์„ฑ๊ณผ:

  • โœ… ๋…น์Œ ๋ˆ„๋ฝ: 0๊ฑด (์กฐ๊ธฐ ์‹œ์ž‘ ๋กœ์ง)
  • โœ… ํ‰๊ท  ๋…น์Œ ๊ธธ์ด: 5-10๋ถ„ (๊ฒŒ์ž„ ์ „์ฒด)
  • โœ… ๋ฐ์ดํ„ฐ ํ’ˆ์งˆ: 44.1kHz, 256kbps (webm/opus)
  • โœ… ์ˆ˜์ง‘ ๋ฐ์ดํ„ฐ: 1,693๋ช… ์ฐธ๊ฐ€์ž ์Œ์„ฑ ๋ฐ์ดํ„ฐ

4. ๋ธŒ๋ผ์šฐ์ € ์ž๋™์žฌ์ƒ ์ •์ฑ… ๋Œ€์‘

๐ŸŽฏ ๊ณผ์ œ

  • ์ž๋™์žฌ์ƒ ์ฐจ๋‹จ: ํŠนํžˆ ๋ชจ๋ฐ”์ผ Safari์—์„œ audio.play()๊ฐ€ ๋ง‰ํž˜
  • AudioContext ์ผ์‹œ์ค‘์ง€: ์‚ฌ์šฉ์ž ์ œ์Šค์ฒ˜ ์—†์ด๋Š” suspended ์ƒํƒœ
  • ์›๊ฒฉ ์˜ค๋””์˜ค ์žฌ์ƒ ์‹คํŒจ: ์ƒ๋Œ€๋ฐฉ ๋ชฉ์†Œ๋ฆฌ๊ฐ€ ์•ˆ ๋“ค๋ฆผ

โœ… ์†”๋ฃจ์…˜

1) ์‚ฌ์šฉ์ž ์ œ์Šค์ฒ˜ ํŠธ๋ฆฌ๊ฑฐ

// src/WebRTCProvider.jsx
const requestAudioUnlock = useCallback(() => {
  const tryPlayAll = () => {
    // AudioContext resume (๋ชจ๋ฐ”์ผ ์‚ฌํŒŒ๋ฆฌ ๋Œ€์‘)
    if (voiceManager?.audioContext?.state === 'suspended') {
      voiceManager.audioContext.resume();
      console.log('๐Ÿ”Š AudioContext resumed');
    }
    
    // ๋ชจ๋“  ์›๊ฒฉ ์˜ค๋””์˜ค ์žฌ์ƒ ์‹œ๋„
    const audios = document.querySelectorAll('audio[data-user-id]');
    audios.forEach((audio) => {
      audio.play().catch(() => {});
    });
  };
  
  window.addEventListener('click', tryPlayAll, { once: true });
  window.addEventListener('touchstart', tryPlayAll, { once: true });
}, []);

// ontrack์—์„œ ์ž๋™์žฌ์ƒ ์‹œ๋„ โ†’ ์‹คํŒจ ์‹œ unlock ์š”์ฒญ
pc.ontrack = (e) => {
  const audio = document.createElement('audio');
  audio.autoplay = true;
  audio.srcObject = e.streams[0];
  
  audio.play().catch((err) => {
    console.warn('๐Ÿ”‡ ์ž๋™์žฌ์ƒ ์ฐจ๋‹จ๋จ, ์‚ฌ์šฉ์ž ์ œ์Šค์ฒ˜ ํ•„์š”');
    requestAudioUnlock();
  });
};

2) ์Œ์„ฑ ๋ถ„์„ ์ดˆ๊ธฐํ™” ์‹œ AudioContext ํ™•์ธ

// src/utils/voiceManager.js
async setupAudioAnalysisWithWebRTCStream(stream) {
  this.audioContext = new AudioContext();
  this.analyser = this.audioContext.createAnalyser();
  this.micNode = this.audioContext.createMediaStreamSource(stream);
  this.micNode.connect(this.analyser);
  
  // AudioContext๊ฐ€ suspended ์ƒํƒœ๋ฉด resume ์‹œ๋„
  if (this.audioContext.state === 'suspended') {
    console.warn('โš ๏ธ AudioContext suspended โ†’ resume ์‹œ๋„');
    await this.audioContext.resume();
  }
}

์„ฑ๊ณผ:

  • โœ… ์ž๋™์žฌ์ƒ ์„ฑ๊ณต๋ฅ : 99%+ (์ฒซ ํด๋ฆญ ํ›„)
  • โœ… ๋ชจ๋ฐ”์ผ ์ง€์›: iOS Safari ํฌํ•จ ์ „์ฒด ๋ธŒ๋ผ์šฐ์ €

๐Ÿ”„ ์‹ค์‹œ๊ฐ„ ํ†ต์‹  ๊ตฌํ˜„

WebSocket ๋ฉ”์‹œ์ง€ ํ”„๋กœํ† ์ฝœ

์Œ์„ฑ ์ƒํƒœ ๋™๊ธฐํ™”

// ๋งˆ์ดํฌ ON/OFF + ๋ฐœํ™” ์ƒํƒœ ์ „์†ก
const message = {
  type: "voice_status_update",
  data: {
    user_id: participantId,
    is_mic_on: true,
    is_speaking: true,
    session_id: sessionId
  }
};

// ๋‹ค๋ฅธ ์ฐธ๊ฐ€์ž์˜ ์ƒํƒœ ์ˆ˜์‹ 
{
  type: "voice_status",
  user_id: 123,
  is_mic_on: true,
  is_speaking: false
}

ํŽ˜์ด์ง€ ๋™๊ธฐํ™”

// ๋ฐฉ์žฅ์ด ํŽ˜์ด์ง€ ์ „ํ™˜ ํŠธ๋ฆฌ๊ฑฐ
{
  type: "next_page",
  page: "/game02"
}

// ๋ชจ๋“  ์ฐธ๊ฐ€์ž๊ฐ€ ๋™์‹œ์— navigate
useEffect(() => {
  const handleMessage = (msg) => {
    if (msg.type === 'next_page') {
      navigate(msg.page);
    }
  };
  
  addMessageHandler('pageSync', handleMessage);
  return () => removeMessageHandler('pageSync');
}, [navigate]);

์„ ํƒ/ํ•ฉ์˜ ๋™๊ธฐํ™”

// ๊ฐœ์ธ ์„ ํƒ ์™„๋ฃŒ ์ƒํƒœ
{
  type: "choice_submitted",
  user_id: 123,
  round: 2,
  choice: 1
}

// ์ „์ฒด ์ฐธ๊ฐ€์ž ์„ ํƒ ํ˜„ํ™ฉ ์กฐํšŒ
GET /rooms/{room_code}/choice-status
{
  "round_2_choices": [
    { "user_id": 123, "choice": 1, "submitted": true },
    { "user_id": 456, "choice": 2, "submitted": true },
    { "user_id": 789, "choice": null, "submitted": false }
  ],
  "all_submitted": false
}

๐ŸŽค WebRTC ์Œ์„ฑ ํ†ต์‹ 

P2P ์—ฐ๊ฒฐ ํ๋ฆ„

User A (๋ฐฉ์žฅ)          Server (์‹œ๊ทธ๋„๋ง)          User B, C
   โ”‚                        โ”‚                        โ”‚
   โ”œโ”€ join โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚                        โ”‚
   โ”‚<โ”€ peers: [B, C] โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค                        โ”‚
   โ”‚                        โ”‚<โ”€ join โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค B
   โ”œโ”€ offer โ†’ B โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚                        โ”‚
   โ”‚                        โ”œโ”€ offer โ†’ B โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
   โ”‚                        โ”‚<โ”€ answer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค B
   โ”‚<โ”€ answer โ† B โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค                        โ”‚
   โ”‚                        โ”‚                        โ”‚
   โ”œโ”€ offer โ†’ C โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚<โ”€ join โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค C
   โ”‚                        โ”œโ”€ offer โ†’ C โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
   โ”‚                        โ”‚<โ”€ answer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค C
   โ”‚<โ”€ answer โ† C โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค                        โ”‚
   โ”‚                        โ”‚                        โ”‚
   โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ P2P RTC Connection (Mesh) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚

PeerConnection ๊ด€๋ฆฌ

// src/WebRTCProvider.jsx
const pcsRef = useRef(new Map()); // peerId -> RTCPeerConnection

function getOrCreatePC(remotePeerId) {
  const key = String(remotePeerId);
  
  // ์ž๊ธฐ ์ž์‹ ๊ณผ๋Š” ์—ฐ๊ฒฐ ์•ˆ ํ•จ
  if (key === SELF()) return null;
  
  // ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ์žฌ์‚ฌ์šฉ
  if (pcsRef.current.has(key)) {
    return pcsRef.current.get(key);
  }
  
  // ์ƒˆ PeerConnection ์ƒ์„ฑ
  const pc = new RTCPeerConnection({
    iceServers: iceServersRef.current
  });
  
  // ontrack: ์›๊ฒฉ ์˜ค๋””์˜ค ์ˆ˜์‹ 
  pc.ontrack = (e) => {
    const audio = document.createElement('audio');
    audio.autoplay = true;
    audio.srcObject = e.streams[0];
    audio.setAttribute('data-user-id', key);
    document.body.appendChild(audio);
    audio.play().catch(requestAudioUnlock);
  };
  
  // onicecandidate: ICE candidate ์ „์†ก
  pc.onicecandidate = (e) => {
    if (!e.candidate) return;
    signalingWs.send(JSON.stringify({
      type: 'candidate',
      from: SELF(),
      to: key,
      candidate: e.candidate
    }));
  };
  
  // onconnectionstatechange: ์—ฐ๊ฒฐ ์ƒํƒœ ๋ชจ๋‹ˆํ„ฐ๋ง
  pc.onconnectionstatechange = () => {
    console.log(`PC(${key}): ${pc.connectionState}`);
  };
  
  pcsRef.current.set(key, pc);
  return pc;
}

Offer/Answer ๊ตํ™˜

async function createOfferTo(remotePeerId) {
  const pc = getOrCreatePC(remotePeerId);
  if (!pc) return;
  
  // ๋กœ์ปฌ ์ŠคํŠธ๋ฆผ ์ถ”๊ฐ€
  const stream = masterStreamRef.current;
  if (!stream) {
    console.warn('โš ๏ธ ๋กœ์ปฌ ์ŠคํŠธ๋ฆผ์ด ์—†์–ด offer ์ƒ์„ฑ ์Šคํ‚ต');
    return;
  }
  
  stream.getTracks().forEach(track => pc.addTrack(track, stream));
  
  // SDP offer ์ƒ์„ฑ
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  
  // ์‹œ๊ทธ๋„๋ง ์„œ๋ฒ„๋กœ ์ „์†ก
  signalingWs.send(JSON.stringify({
    type: 'offer',
    from: SELF(),
    to: remotePeerId,
    sdp: offer.sdp
  }));
}

๐Ÿ“Š ์ƒํƒœ ๊ด€๋ฆฌ ๋ฐ ๋™๊ธฐํ™”

Context API ๊ตฌ์กฐ

// src/WebSocketProvider.jsx
export const WebSocketProvider = ({ children }) => {
  const [isConnected, setIsConnected] = useState(false);
  const [sessionId, setSessionId] = useState(null);
  const messageHandlers = useRef(new Map());
  
  const value = {
    isConnected,
    sessionId,
    sendMessage,
    addMessageHandler,
    removeMessageHandler,
    initializeVoiceWebSocket
  };
  
  return (
    <WebSocketContext.Provider value={value}>
      {children}
    </WebSocketContext.Provider>
  );
};

// ์‚ฌ์šฉ
const { sendMessage, addMessageHandler } = useWebSocket();

localStorage ํ™œ์šฉ (์„ธ์…˜ ๋ณต๊ตฌ)

// ๊ฒŒ์ž„ ์ง„ํ–‰ ์ƒํƒœ ์ €์žฅ
localStorage.setItem('room_code', 'ABC123');
localStorage.setItem('session_id', 'voice-session-uuid');
localStorage.setItem('myrole_id', '1');
localStorage.setItem('nickname', '์ฐธ๊ฐ€์ž1');

// ์ƒˆ๋กœ๊ณ ์นจ ํ›„ ๋ณต๊ตฌ
const roomCode = localStorage.getItem('room_code');
if (roomCode && isReloadingGrace()) {
  await reconnectToRoom(roomCode);
}

โšก ์„ฑ๋Šฅ ์ตœ์ ํ™”

1. ์ด๋ฏธ์ง€ ์ตœ์ ํ™”

// 595๊ฐœ์˜ ์ด๋ฏธ์ง€ ๋ฆฌ์†Œ์Šค โ†’ Lazy Loading
import { lazy, Suspense } from 'react';

const CharacterImage = lazy(() => import('./assets/characters/character1.jpg'));

<Suspense fallback={<div>๋กœ๋”ฉ ์ค‘...</div>}>
  <CharacterImage />
</Suspense>

2. ์ปดํฌ๋„ŒํŠธ ๋ฉ”๋ชจ์ด์ œ์ด์…˜

import { memo, useMemo } from 'react';

const RoomCard = memo(({ room }) => {
  const participantCount = useMemo(() => {
    return room.participants?.length || 0;
  }, [room.participants]);
  
  return <div>...</div>;
});

3. WebSocket ๋ฉ”์‹œ์ง€ ํ•„ํ„ฐ๋ง

// ๋ถˆํ•„์š”ํ•œ ๋ฉ”์‹œ์ง€๋Š” ์กฐ๊ธฐ ๋ฆฌํ„ด
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  
  // ๋‚˜์—๊ฒŒ ์˜จ ๋ฉ”์‹œ์ง€๊ฐ€ ์•„๋‹ˆ๋ฉด ๋ฌด์‹œ
  if (msg.to && msg.to !== myUserId) return;
  
  // ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰
  messageHandlers.current.forEach(handler => handler(msg));
};

์„ฑ๊ณผ:

  • โœ… ์ดˆ๊ธฐ ๋กœ๋”ฉ ์‹œ๊ฐ„: 1.5์ดˆ ์ดํ•˜
  • โœ… ํŽ˜์ด์ง€ ์ „ํ™˜: 200ms ์ดํ•˜
  • โœ… ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ: 100MB ์ดํ•˜ (3์ž ์—ฐ๊ฒฐ ์‹œ)

๐ŸŒ ๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ

์ง€์› ๋ธŒ๋ผ์šฐ์ €

๋ธŒ๋ผ์šฐ์ € ๋ฒ„์ „ WebRTC MediaRecorder ๋น„๊ณ 
Chrome 90+ โœ… โœ… (webm) ์ตœ์ 
Safari 14+ โœ… โš ๏ธ (mp4) ์ž๋™์žฌ์ƒ ์ฃผ์˜
Firefox 88+ โœ… โœ… (webm) -
Edge 90+ โœ… โœ… (webm) Chrome ๊ธฐ๋ฐ˜

๋ธŒ๋ผ์šฐ์ €๋ณ„ ๋Œ€์‘

// Safari: getUserMedia ๊ถŒํ•œ ์š”์ฒญ ์‹œ HTTPS ํ•„์ˆ˜
if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
  console.log('Safari ๊ฐ์ง€: HTTPS ํ™•์ธ ํ•„์š”');
}

// iOS: AudioContext resume ํ•„์ˆ˜
if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
  await audioContext.resume();
}

// Firefox: MediaRecorder mimeType ํ™•์ธ
if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
  mimeType = 'audio/ogg;codecs=opus';
}

๐Ÿ› ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ…

1. "๋งˆ์ง€๋ง‰ 1-2์ดˆ๋งŒ ๋…น์Œ๋˜๋Š” ํ˜„์ƒ"

์ฆ์ƒ: ๋…น์Œ ๋ฐ์ดํ„ฐ๊ฐ€ ๊ทนํžˆ ์งง์Œ (5-10๋ถ„ ์˜ˆ์ƒ โ†’ 1-2์ดˆ ์‹ค์ œ)

์›์ธ:

  • ์ดˆ๊ธฐํ™” ์‹คํŒจ๋กœ ๋…น์Œ ์‹œ์ž‘์ด ๊ฒŒ์ž„ ๋๋ถ€๋ถ„์—๋งŒ ๋จ
  • WebRTC/WebSocket ์ดˆ๊ธฐํ™”๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๋™์•ˆ ๋…น์Œ์ด ์‹œ์ž‘ ์•ˆ ๋จ

ํ•ด๊ฒฐ:

// โœ… ์„ ํ–‰ ์กฐ๊ฑด ์—†์ด ๋กœ์ปฌ ๋…น์Œ๋ถ€ํ„ฐ ์‹œ์ž‘
useEffect(() => {
  const tick = async () => {
    // WebRTC ์ดˆ๊ธฐํ™” ์ „์— ๋กœ์ปฌ ๋…น์Œ๋ถ€ํ„ฐ ์ผœ๊ธฐ
    await voiceManager.startLocalMicRecordingIfNeeded?.();
    
    // WebRTC๋Š” ๋‚˜์ค‘์— ์ดˆ๊ธฐํ™”
    if (!isInitialized && token && roomCode) {
      await initializeWebRTC();
    }
  };
  
  tick();
  const timer = setInterval(tick, 1500);
  return () => clearInterval(timer);
}, []);

2. "WebSocket ์—ฐ๊ฒฐ์ด ์ค‘๋ณต ์ƒ์„ฑ๋˜๋Š” ๋ฌธ์ œ"

์ฆ์ƒ: ๊ฐ™์€ ์„ธ์…˜์— ์—ฌ๋Ÿฌ WebSocket ์—ฐ๊ฒฐ์ด ์ƒ์„ฑ๋จ

์›์ธ:

  • React Strict Mode (๊ฐœ๋ฐœ ํ™˜๊ฒฝ)
  • ์ปดํฌ๋„ŒํŠธ ์žฌ๋งˆ์šดํŠธ
  • ์ค‘๋ณต ๊ฐ€๋“œ ๋ถ€์žฌ

ํ•ด๊ฒฐ:

const initializeVoiceWebSocket = async (isHost) => {
  // ๊ฐ€๋“œ 1: ์ด๋ฏธ ์ดˆ๊ธฐํ™” ์ค‘
  if (isInitializing.current) {
    await waitForInitialization();
    return;
  }
  
  // ๊ฐ€๋“œ 2: ์ด๋ฏธ ์ดˆ๊ธฐํ™”๋จ
  if (isConnected && sessionId) {
    return;
  }
  
  isInitializing.current = true;
  try {
    // ์ดˆ๊ธฐํ™” ๋กœ์ง
  } finally {
    isInitializing.current = false;
  }
};

3. "๋…น์Œ ์ค‘ ์ŠคํŠธ๋ฆผ ๊ต์ฒด๋กœ 1์ดˆ๋งŒ ๋…น์Œ๋˜๋Š” ๋ฌธ์ œ"

์ฆ์ƒ: MediaRecorder๊ฐ€ ์ค‘๊ฐ„์— ๋Š๊ฒจ์„œ 1์ดˆ์งœ๋ฆฌ ํŒŒ์ผ๋งŒ ์ƒ์„ฑ๋จ

์›์ธ:

  • ๋…น์Œ ์ค‘์— recordingStream์„ ๊ต์ฒดํ•˜๋ฉด ๊ธฐ์กด MediaRecorder๊ฐ€ ๋ฌดํšจํ™”๋จ
  • ํŠธ๋ž™์„ stop()ํ•˜๋ฉด ๋…น์Œ์ด ์ฆ‰์‹œ ์ข…๋ฃŒ๋จ

ํ•ด๊ฒฐ:

setRecordingStream(stream) {
  // โœ… ๋…น์Œ ์ค‘์—๋Š” ์ŠคํŠธ๋ฆผ ๊ต์ฒด ๊ธˆ์ง€
  if (this.isRecording && this.recordingStream && this.recordingStream !== stream) {
    console.warn('โš ๏ธ ๋…น์Œ ์ค‘์—๋Š” ์ŠคํŠธ๋ฆผ ๊ต์ฒด ๊ธˆ์ง€');
    return false;
  }
  
  this.recordingStream = stream;
  return true;
}

4. "Safari์—์„œ ์›๊ฒฉ ์˜ค๋””์˜ค๊ฐ€ ์•ˆ ๋“ค๋ฆฌ๋Š” ๋ฌธ์ œ"

์ฆ์ƒ: ์ž์‹ ์˜ ๋งˆ์ดํฌ๋Š” ๋˜๋Š”๋ฐ ์ƒ๋Œ€๋ฐฉ ๋ชฉ์†Œ๋ฆฌ๊ฐ€ ์•ˆ ๋“ค๋ฆผ

์›์ธ:

  • Safari ์ž๋™์žฌ์ƒ ์ •์ฑ…์œผ๋กœ audio.play()๊ฐ€ ์ฐจ๋‹จ๋จ
  • AudioContext๊ฐ€ suspended ์ƒํƒœ

ํ•ด๊ฒฐ:

// 1) ์‚ฌ์šฉ์ž ์ œ์Šค์ฒ˜์—์„œ AudioContext resume
if (audioContext.state === 'suspended') {
  await audioContext.resume();
}

// 2) ์ฒซ ํด๋ฆญ/ํ„ฐ์น˜ ์‹œ ๋ชจ๋“  audio ์žฌ์ƒ ์‹œ๋„
const tryPlayAll = () => {
  document.querySelectorAll('audio[data-user-id]').forEach(a => a.play());
};
window.addEventListener('click', tryPlayAll, { once: true });

5. "์ƒˆ๋กœ๊ณ ์นจ ์‹œ ๊ฒŒ์ž„์ด ๋Š๊ธฐ๋Š” ๋ฌธ์ œ"

์ฆ์ƒ: ์‚ฌ์šฉ์ž๊ฐ€ F5๋ฅผ ๋ˆ„๋ฅด๋ฉด ๊ฒŒ์ž„์—์„œ ํ‡ด์žฅ๋จ

์›์ธ:

  • WebSocket/WebRTC๊ฐ€ ์žฌ์—ฐ๊ฒฐ๋˜์ง€ ์•Š์Œ
  • ๋ฐฑ์—”๋“œ๊ฐ€ ์—ฐ๊ฒฐ ๋Š๊น€์„ ํ‡ด์žฅ์œผ๋กœ ๊ฐ„์ฃผ

ํ•ด๊ฒฐ:

// 1) beforeunload์—์„œ "์ƒˆ๋กœ๊ณ ์นจ ์ค‘" ํ”Œ๋ž˜๊ทธ ์„ค์ •
window.addEventListener('beforeunload', () => {
  sessionStorage.setItem('reloading', 'true');
  sessionStorage.setItem('reloading_expire_at', Date.now() + 20000);
});

// 2) ๋งˆ์šดํŠธ ์‹œ ํ”Œ๋ž˜๊ทธ ํ™•์ธ โ†’ 20์ดˆ ๋‚ด ์žฌ์—ฐ๊ฒฐ ์‹œ๋„
if (sessionStorage.getItem('reloading') === 'true') {
  const expireAt = parseInt(sessionStorage.getItem('reloading_expire_at'));
  if (Date.now() < expireAt) {
    await autoReconnect();
  }
}

๐Ÿ”ฎ ํ–ฅํ›„ ๊ฐœ์„ ์‚ฌํ•ญ

1. ์„ฑ๋Šฅ ๊ฐœ์„ 

  • WebRTC SFU (Selective Forwarding Unit) ๋„์ž… (4์ธ ์ด์ƒ ํ™•์žฅ ์‹œ)
  • Virtual List (react-window) ์ ์šฉ (๋ฐฉ ๋ชฉ๋ก 1000๊ฐœ ์ด์ƒ ์‹œ)
  • Service Worker (์˜คํ”„๋ผ์ธ ์ง€์›, ํ‘ธ์‹œ ์•Œ๋ฆผ)
  • Progressive Web App (PWA) ๋ณ€ํ™˜

2. ๊ธฐ๋Šฅ ํ™•์žฅ

  • ํ™”๋ฉด ๊ณต์œ  (WebRTC getDisplayMedia)
  • ์‹ค์‹œ๊ฐ„ ์ž๋ง‰ (Web Speech API)
  • ์ฑ„ํŒ… ๊ธฐ๋Šฅ (WebSocket ๊ธฐ๋ฐ˜)
  • ๋ฆฌํ”Œ๋ ˆ์ด ๊ธฐ๋Šฅ (๋…น์Œ ์žฌ์ƒ)

3. UX ๊ฐœ์„ 

  • ๋‹คํฌ ๋ชจ๋“œ ์ง€์›
  • ์• ๋‹ˆ๋ฉ”์ด์…˜ ๊ฐ•ํ™” (Framer Motion)
  • ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค (๋ฐฉํ–ฅํ‚ค๋กœ ์„ ํƒ ๋“ฑ)
  • ์ ‘๊ทผ์„ฑ (ARIA ๋ผ๋ฒจ, ์Šคํฌ๋ฆฐ ๋ฆฌ๋”)

4. ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ

  • TypeScript ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜
  • Storybook (์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œํ™”)
  • Cypress (E2E ํ…Œ์ŠคํŠธ)
  • Jest + RTL (๋‹จ์œ„ ํ…Œ์ŠคํŠธ)

๐Ÿ“š ์ฐธ๊ณ  ๋ฌธ์„œ

๋‚ด๋ถ€ ๋ฌธ์„œ

์™ธ๋ถ€ ๋ฌธ์„œ


๐ŸŽ“ ๋ฐฐ์šด ์  ๋ฐ ํšŒ๊ณ 

๊ธฐ์ˆ ์  ์„ฑ์žฅ

1. WebRTC์˜ ๋ณต์žก์„ฑ๊ณผ ์•ˆ์ •์„ฑ

  • P2P ์—ฐ๊ฒฐ์€ ๊ฒ‰๋ณด๊ธฐ์—” ๊ฐ„๋‹จํ•˜์ง€๋งŒ, NAT/๋ฐฉํ™”๋ฒฝ ํ™˜๊ฒฝ์—์„œ๋Š” TURN ์„œ๋ฒ„๊ฐ€ ํ•„์ˆ˜
  • ์‹œ๊ทธ๋„๋ง ์„œ๋ฒ„์˜ ๋ฉ”์‹œ์ง€ ์ˆœ์„œ๊ฐ€ ํ‹€์–ด์ง€๋ฉด ์—ฐ๊ฒฐ์ด ์‹คํŒจํ•  ์ˆ˜ ์žˆ์Œ
  • Mesh Topology๋Š” 3-4๋ช…๊นŒ์ง€๋Š” ๊ดœ์ฐฎ์ง€๋งŒ, ๊ทธ ์ด์ƒ์€ SFU ๊ณ ๋ ค ํ•„์š”

2. ์‹ค์‹œ๊ฐ„ ํ†ต์‹ ์˜ ์–ด๋ ค์›€

  • WebSocket ์žฌ์—ฐ๊ฒฐ ๋กœ์ง์€ ๋‹จ์ˆœํžˆ "๋‹ค์‹œ ์—ฐ๊ฒฐ"์ด ์•„๋‹ˆ๋ผ, ์ƒํƒœ ๋ณต๊ตฌ, ๋ฉ”์‹œ์ง€ ์ค‘๋ณต ๋ฐฉ์ง€, ๊ทธ๋ ˆ์ด์Šค ๊ธฐ๊ฐ„ ๋“ฑ ๋งŽ์€ ๊ฒƒ์„ ๊ณ ๋ คํ•ด์•ผ ํ•จ
  • ๋ธŒ๋ผ์šฐ์ € ๋ณด์•ˆ ์ •์ฑ…(์ž๋™์žฌ์ƒ, HTTPS ํ•„์ˆ˜)์€ ๊ฐœ๋ฐœ ์‹œ์—๋Š” ๋ชฐ๋ž๋˜ ๋ถ€๋ถ„์ด ํ”„๋กœ๋•์…˜์—์„œ ๋งŽ์ด ๋ฐœ๊ฒฌ๋จ

3. ์Œ์„ฑ ์ฒ˜๋ฆฌ์˜ ์„ธ๋ฐ€ํ•จ

  • MediaStream/MediaRecorder๋Š” "๊ทธ๋ƒฅ ๋…น์Œ"์ด ์•„๋‹ˆ๋ผ, ์ŠคํŠธ๋ฆผ์˜ ์ƒ๋ช…์ฃผ๊ธฐ, ํŠธ๋ž™์˜ ์ƒํƒœ, ๋ธŒ๋ผ์šฐ์ €๋ณ„ ํฌ๋งท๊นŒ์ง€ ๊ณ ๋ คํ•ด์•ผ ํ•จ
  • "๋งˆ์ง€๋ง‰ 1์ดˆ๋งŒ ๋…น์Œ๋˜๋Š” ๋ฒ„๊ทธ"๋Š” ์ดˆ๊ธฐํ™” ํƒ€์ด๋ฐ ๋ฌธ์ œ์˜€๊ณ , ์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ์„ ํ–‰ ์กฐ๊ฑด ์—†๋Š” ์กฐ๊ธฐ ๋…น์Œ ์‹œ์ž‘์ด ํ•ต์‹ฌ์ด์—ˆ์Œ

ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ

์ž˜ํ•œ ์ :

  • โœ… ์ฒด๊ณ„์ ์ธ ๋กœ๊น…์œผ๋กœ ํ”„๋กœ๋•์…˜ ๋ฒ„๊ทธ๋ฅผ ๋น ๋ฅด๊ฒŒ ์ถ”์ 
  • โœ… Context API๋กœ ์ „์—ญ ์ƒํƒœ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๊ด€๋ฆฌ
  • โœ… ๋””๋ฒ„๊น…์šฉ ์ „์—ญ ํ•จ์ˆ˜ (window.debugWebRTC)๋กœ ์‹ค์‹œ๊ฐ„ ์ƒํƒœ ํ™•์ธ

์•„์‰ฌ์šด ์ :

  • โš ๏ธ TypeScript๋ฅผ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋„์ž…ํ–ˆ์œผ๋ฉด ํƒ€์ž… ์•ˆ์ •์„ฑ์ด ๋” ์ข‹์•˜์„ ๊ฒƒ
  • โš ๏ธ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ๋ถ€์กฑํ•ด์„œ ๋ฆฌํŒฉํ† ๋ง ์‹œ ๋ถˆ์•ˆํ•จ
  • โš ๏ธ ์ปดํฌ๋„ŒํŠธ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋” ๊ณ ๋ คํ–ˆ์œผ๋ฉด ์ค‘๋ณต ์ฝ”๋“œ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์—ˆ์„ ๊ฒƒ

๐Ÿค ๊ธฐ์—ฌ

Frontend Developer

  • ์ „์ฒด UI/UX ์„ค๊ณ„ ๋ฐ ๊ตฌํ˜„
  • WebSocket/WebRTC ์‹ค์‹œ๊ฐ„ ํ†ต์‹  ๊ตฌํ˜„
  • ์Œ์„ฑ ๋…น์Œ ๋ฐ ์ŠคํŠธ๋ฆผ ๊ด€๋ฆฌ
  • ์žฌ์—ฐ๊ฒฐ ๋กœ์ง ๋ฐ ์•ˆ์ •์„ฑ ๊ฐœ์„ 
  • ๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ ๋Œ€์‘
  • ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ ๋ฐ ๋ชจ๋‹ˆํ„ฐ๋ง

๐Ÿ“„ ๋ผ์ด์„ ์Šค

This project is private and proprietary.


๐Ÿ“ž Contact

ํ”„๋กœ์ ํŠธ URL: https://dilemmai-idl.com/
GitHub: (Private Repository)


Built with โค๏ธ using React + WebRTC

React Vite WebRTC JavaScript

About

Front-end Repository

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages