์ค์๊ฐ ๋ค์๊ฐ ์์ฑ ํ ๋ก ๋ฐ AI ์ค๋ฆฌ ๋๋ ๋ง ํ์ ํ์ต ํ๋ซํผ ํ๋ก ํธ์๋
ํ๋ก๋์
URL: https://dilemmai-idl.com/
ํ๋ก์ ํธ ๊ธฐ๊ฐ: 2024.09 - 2025.02 (6๊ฐ์)
- ํ๋ก์ ํธ ๊ฐ์
- ํต์ฌ ๊ธฐ์ ์คํ
- ์์คํ ์ํคํ ์ฒ
- ์ฃผ์ ๊ธฐ๋ฅ ๋ฐ ๊ธฐ์ ์ ๋์
- ์ค์๊ฐ ํต์ ๊ตฌํ
- WebRTC ์์ฑ ํต์
- ์ํ ๊ด๋ฆฌ ๋ฐ ๋๊ธฐํ
- ์ฑ๋ฅ ์ต์ ํ
- ๋ธ๋ผ์ฐ์ ํธํ์ฑ
- ํธ๋ฌ๋ธ์ํ
- ํฅํ ๊ฐ์ ์ฌํญ
AI ์ค๋ฆฌ ๊ต์ก์ ์ํ ์ค์๊ฐ ํ์ ํ์ต ํ๋ซํผ์ผ๋ก, 3๋ช ์ ์ฐธ๊ฐ์๊ฐ ๊ฐ๊ธฐ ๋ค๋ฅธ ์ดํด๊ด๊ณ์ ์ญํ ์ ๋งก์ AI ์ค๋ฆฌ ๋๋ ๋ง ์ํฉ์์ ์ค์๊ฐ ์์ฑ ํ ๋ก ์ ํตํด ํฉ์์ ์ ๋์ถํ๋ ์์คํ ์ ๋๋ค.
- โ ์ค์๊ฐ ์์ฑ ํต์ : WebRTC P2P ๊ธฐ๋ฐ ๋๊น ์๋ 3์ ์์ฑ ์ฐ๊ฒฐ
- โ ์์ ์ ์ธ ์ฐ๊ฒฐ ๊ด๋ฆฌ: WebSocket ์ฌ์ฐ๊ฒฐ, ์๋ก๊ณ ์นจ ๋ณต๊ตฌ, ๋คํธ์ํฌ ์ฅ์ ๋์
- โ ์ง๊ด์ ์ธ UX: ๋ณต์กํ ๊ธฐ์ ์ ์ฌ์ฉ์๊ฐ ์ธ์งํ์ง ๋ชปํ๋๋ก ์ฌํํ ์ธํฐํ์ด์ค ๊ตฌํ
- โ ํฌ๋ก์ค ๋ธ๋ผ์ฐ์ : Chrome, Safari, Firefox ๋ฑ ์ฃผ์ ๋ธ๋ผ์ฐ์ ์ง์
- ์ด ํ์ด์ง: 40+ ํ์ด์ง (๊ฒ์ ํ๋ก์ฐ, ๋ฐฉ ์์ฑ/์ ์ฅ, ๋ง์ดํฌ ํ ์คํธ ๋ฑ)
- ์ปดํฌ๋ํธ: 60+ ๊ฐ์ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์ปดํฌ๋ํธ
- ์ค์๊ฐ ์ฌ์ฉ์: ๋์ ์ ์ 50+ ์ธ์ ์ง์
- ์์ฑ ๋ น์: ํ๊ท 30-60๋ถ ๋ถ๋์ ๊ณ ํ์ง ์์ฑ ๋ฐ์ดํฐ ์์ง
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)
WebSocket: Native WebSocket API
WebRTC: P2P Mesh Topology
TURN/STUN: Twilio Network Traversal
Audio Processing: Web Audio API, MediaRecorder APIHTTP: Axios 1.9.0 (Interceptor ๊ธฐ๋ฐ ์ธ์ฆ)
State: React Context API + useReducer
Storage: localStorage + sessionStorage (์ฌ์ฐ๊ฒฐ ๋ณต๊ตฌ)Styling: CSS Modules + Tailwind CSS 4.1.6
Assets: 595๊ฐ์ ์ด๋ฏธ์ง ๋ฆฌ์์ค (์บ๋ฆญํฐ, ๋ฐฐ๊ฒฝ, ์์ด์ฝ)
Responsive: ๋ชจ๋ฐ์ผ/ํ๋ธ๋ฆฟ ๋์ (์ต์ 768px)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 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) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
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 ์ปดํฌ๋ํธ
- 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, ์คํ ๋ ์ค (๋ธ๋ผ์ฐ์ ์ง์ ์)
- ๋คํธ์ํฌ ๋ถ์์ : ๋ชจ๋ฐ์ผ ํ๊ฒฝ์์ ์ฆ์ ์ฐ๊ฒฐ ๋๊น
- ํ์ด์ง ์๋ก๊ณ ์นจ: ์ฌ์ฉ์๊ฐ 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๊ฑด (ํธ๋ค๋ฌ ๋ผ์ฐํ )
- ๋ น์ ๋๋ฝ: ์ด๊ธฐํ ์คํจ๋ก "๋ง์ง๋ง 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๋ช ์ฐธ๊ฐ์ ์์ฑ ๋ฐ์ดํฐ
- ์๋์ฌ์ ์ฐจ๋จ: ํนํ ๋ชจ๋ฐ์ผ 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 ํฌํจ ์ ์ฒด ๋ธ๋ผ์ฐ์
// ๋ง์ดํฌ 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
}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) โโโโโโโโโโโ>โ
// 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;
}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
}));
}// 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.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);
}// 595๊ฐ์ ์ด๋ฏธ์ง ๋ฆฌ์์ค โ Lazy Loading
import { lazy, Suspense } from 'react';
const CharacterImage = lazy(() => import('./assets/characters/character1.jpg'));
<Suspense fallback={<div>๋ก๋ฉ ์ค...</div>}>
<CharacterImage />
</Suspense>import { memo, useMemo } from 'react';
const RoomCard = memo(({ room }) => {
const participantCount = useMemo(() => {
return room.participants?.length || 0;
}, [room.participants]);
return <div>...</div>;
});// ๋ถํ์ํ ๋ฉ์์ง๋ ์กฐ๊ธฐ ๋ฆฌํด
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+ | โ | ์๋์ฌ์ ์ฃผ์ | |
| 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';
}์ฆ์: ๋ น์ ๋ฐ์ดํฐ๊ฐ ๊ทนํ ์งง์ (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);
}, []);์ฆ์: ๊ฐ์ ์ธ์ ์ ์ฌ๋ฌ 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;
}
};์ฆ์: MediaRecorder๊ฐ ์ค๊ฐ์ ๋๊ฒจ์ 1์ด์ง๋ฆฌ ํ์ผ๋ง ์์ฑ๋จ
์์ธ:
- ๋
น์ ์ค์
recordingStream์ ๊ต์ฒดํ๋ฉด ๊ธฐ์กด MediaRecorder๊ฐ ๋ฌดํจํ๋จ - ํธ๋์ stop()ํ๋ฉด ๋ น์์ด ์ฆ์ ์ข ๋ฃ๋จ
ํด๊ฒฐ:
setRecordingStream(stream) {
// โ
๋
น์ ์ค์๋ ์คํธ๋ฆผ ๊ต์ฒด ๊ธ์ง
if (this.isRecording && this.recordingStream && this.recordingStream !== stream) {
console.warn('โ ๏ธ ๋
น์ ์ค์๋ ์คํธ๋ฆผ ๊ต์ฒด ๊ธ์ง');
return false;
}
this.recordingStream = stream;
return true;
}์ฆ์: ์์ ์ ๋ง์ดํฌ๋ ๋๋๋ฐ ์๋๋ฐฉ ๋ชฉ์๋ฆฌ๊ฐ ์ ๋ค๋ฆผ
์์ธ:
- 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 });์ฆ์: ์ฌ์ฉ์๊ฐ 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();
}
}- WebRTC SFU (Selective Forwarding Unit) ๋์ (4์ธ ์ด์ ํ์ฅ ์)
- Virtual List (react-window) ์ ์ฉ (๋ฐฉ ๋ชฉ๋ก 1000๊ฐ ์ด์ ์)
- Service Worker (์คํ๋ผ์ธ ์ง์, ํธ์ ์๋ฆผ)
- Progressive Web App (PWA) ๋ณํ
- ํ๋ฉด ๊ณต์ (WebRTC getDisplayMedia)
- ์ค์๊ฐ ์๋ง (Web Speech API)
- ์ฑํ ๊ธฐ๋ฅ (WebSocket ๊ธฐ๋ฐ)
- ๋ฆฌํ๋ ์ด ๊ธฐ๋ฅ (๋ น์ ์ฌ์)
- ๋คํฌ ๋ชจ๋ ์ง์
- ์ ๋๋ฉ์ด์ ๊ฐํ (Framer Motion)
- ํค๋ณด๋ ๋จ์ถํค (๋ฐฉํฅํค๋ก ์ ํ ๋ฑ)
- ์ ๊ทผ์ฑ (ARIA ๋ผ๋ฒจ, ์คํฌ๋ฆฐ ๋ฆฌ๋)
- 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.
ํ๋ก์ ํธ URL: https://dilemmai-idl.com/
GitHub: (Private Repository)