듀라블오브젝트 샘플

This commit is contained in:
Insub Kim 2025-11-16 21:38:28 +09:00
parent a9e6c9333d
commit da2918bd88
20 changed files with 1886 additions and 44 deletions

92
CHANGELOG.md Normal file
View File

@ -0,0 +1,92 @@
# 변경 이력
## 2025-11-15
### 🐛 버그 수정: 실시간 접속자 수 문제 해결
**문제:**
- 여러 클라이언트가 연결되어도 "실시간 접속자 수"가 1에서 증가하지 않음
**원인:**
- WebSocket Hibernation API 사용 시, `this.sessions` Map이 hibernation 후 초기화됨
- `this.sessions.size`는 정확한 접속자 수를 반영하지 못함
**해결:**
- `this.ctx.getWebSockets().length`를 사용하여 실제 연결된 WebSocket 개수 확인
**변경된 파일:**
- `src/lib/counter-do.ts` - `broadcast()` 메서드 수정
**커밋:**
```
fix: 실시간 접속자 수가 정확하게 표시되도록 수정
- this.sessions.size 대신 this.ctx.getWebSockets().length 사용
- WebSocket Hibernation API와 호환되는 방식으로 변경
```
---
## 2025-11-14
### ✨ 초기 프로젝트 생성
**구현 사항:**
- ✅ SvelteKit 5 + Cloudflare Workers 프로젝트 설정
- ✅ Durable Objects 구현 (WebSocket Hibernation API)
- ✅ 실시간 카운터 UI (Tailwind CSS)
- ✅ WebSocket 양방향 통신
- ✅ 영구 저장소 연동 (Durable Objects Storage)
- ✅ Post-build 스크립트로 Worker 자동 패치
- ✅ 문서화 (README, QUICKSTART, DEPLOYMENT, PROJECT_STRUCTURE)
**주요 파일:**
- `src/lib/counter-do.ts` - Durable Object 클래스
- `src/routes/api/counter/+server.ts` - WebSocket API 엔드포인트
- `src/routes/+page.svelte` - 클라이언트 UI
- `scripts/patch-worker.js` - Worker 패치 스크립트
- `wrangler.jsonc` - Cloudflare Workers 설정
**기능:**
- 실시간 카운터 증가/리셋
- 실시간 접속자 수 표시
- 마지막 업데이트 시간 표시
- WebSocket 연결/연결 해제
- 모든 클라이언트 간 실시간 동기화
---
## 향후 계획
### 🚀 기능 추가 예정
- [ ] 여러 카운터 룸 (URL 파라미터 기반)
- [ ] 사용자 인증 (Cloudflare Access)
- [ ] 카운트 히스토리 (D1 SQLite 통합)
- [ ] 실시간 채팅 기능
- [ ] 관리자 대시보드
- [ ] 통계 및 분석
- [ ] 커스텀 테마
- [ ] 모바일 앱 (PWA)
### 🔧 개선 예정
- [ ] 자동 재연결 로직 강화
- [ ] 오프라인 모드 지원
- [ ] 성능 최적화
- [ ] 에러 핸들링 개선
- [ ] 테스트 코드 추가
- [ ] CI/CD 파이프라인 구축
---
## 기여 가이드
변경사항을 기록할 때:
1. 날짜별로 구분
2. 카테고리 사용: 🐛 버그수정, ✨ 새기능, 🔧 개선, 📚 문서
3. 변경 사유와 방법을 명확히 기술
4. 관련 파일 목록 포함
5. 커밋 메시지 예시 추가

165
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,165 @@
# 배포 체크리스트
## ✅ 배포 전 확인사항
### 1. 프로젝트 빌드
```bash
pnpm build
```
- [ ] 빌드가 성공적으로 완료됨
- [ ] 에러 메시지가 없음
### 2. Wrangler 로그인
```bash
npx wrangler login
```
- [ ] Cloudflare 계정에 로그인됨
### 3. 설정 파일 확인
#### wrangler.jsonc
- [ ] `name`: 프로젝트 이름이 올바름
- [ ] `durable_objects.bindings`: COUNTER 바인딩이 설정됨
- [ ] `migrations`: v1 마이그레이션이 있음
## 📦 배포
### 1. 배포 실행
```bash
pnpm deploy
```
또는
```bash
npx wrangler deploy
```
### 2. 배포 확인
- [ ] 배포 성공 메시지 확인
- [ ] 배포 URL 확인 (예: https://dd.your-subdomain.workers.dev)
### 3. Cloudflare Dashboard 확인
https://dash.cloudflare.com 접속
1. **Workers & Pages** 선택
2. 배포된 Worker 선택
3. **Settings** 탭:
- [ ] Durable Objects 바인딩 확인
- [ ] Environment Variables 확인 (필요한 경우)
## 🧪 테스트
### 1. 기본 기능 테스트
- [ ] 배포 URL 접속 가능
- [ ] 페이지가 올바르게 로드됨
- [ ] "연결하기" 버튼 클릭
- [ ] WebSocket 연결 성공 (초록색 표시)
- [ ] "카운트 증가" 버튼 클릭하여 카운트 증가
- [ ] 숫자가 실시간으로 업데이트됨
- [ ] "리셋" 버튼으로 카운트 초기화
### 2. 다중 클라이언트 테스트
- [ ] 여러 브라우저/탭에서 동시 접속
- [ ] 모든 클라이언트에서 동일한 카운트 표시
- [ ] 한 클라이언트에서 카운트 증가 시 모든 클라이언트 업데이트
- [ ] 실시간 접속자 수가 정확함
### 3. 영속성 테스트
- [ ] 카운트 증가
- [ ] 모든 클라이언트 연결 해제
- [ ] 다시 연결
- [ ] 이전 카운트 값이 유지됨
## 🔍 문제 해결
### Durable Objects 오류
만약 "Durable Object not configured" 오류가 발생하면:
```bash
# 1. wrangler.jsonc 확인
# 2. 다시 배포
npx wrangler deploy
# 3. Dashboard에서 Durable Objects 바인딩 확인
```
### WebSocket 연결 실패
```bash
# 실시간 로그 확인
npx wrangler tail
```
### 빌드 오류
```bash
# 깨끗하게 재빌드
rm -rf .svelte-kit node_modules
pnpm install
pnpm build
```
## 📊 모니터링
### 실시간 로그
```bash
pnpm cf:tail
```
### Cloudflare Analytics
Dashboard > Workers & Pages > [프로젝트 이름] > Analytics
모니터링 항목:
- 요청 수
- 에러율
- CPU 시간
- Duration (GB-seconds)
## 🔄 업데이트 배포
코드 변경 후:
```bash
# 1. 변경사항 확인
git status
# 2. 커밋
git add .
git commit -m "Update: [변경 내용]"
# 3. 빌드 및 배포
pnpm build
pnpm deploy
# 4. 배포 확인
# URL 접속하여 변경사항 확인
```
## 💰 비용 관리
### Free Tier 제한 (2024년 기준)
- 100,000 요청/일
- Durable Objects: 1 GB-second/일 무료
### 비용 절감 팁
- WebSocket Hibernation API 사용 (현재 구현됨)
- 불필요한 로그 제거
- 연결 풀링 최적화
## 🚀 다음 단계
- [ ] 커스텀 도메인 연결
- [ ] HTTPS 인증서 설정 (자동)
- [ ] 모니터링 및 알림 설정
- [ ] 백업 및 복구 계획
## 📚 추가 리소스
- [Cloudflare Workers 문서](https://developers.cloudflare.com/workers/)
- [Durable Objects 가이드](https://developers.cloudflare.com/durable-objects/)
- [Wrangler CLI 문서](https://developers.cloudflare.com/workers/wrangler/)
- [SvelteKit Cloudflare 어댑터](https://kit.svelte.dev/docs/adapter-cloudflare)

180
DICE_FEATURE.md Normal file
View File

@ -0,0 +1,180 @@
# 주사위 기능 추가 (Durable Object 성능 테스트)
## 개요
Durable Object의 성능을 확인하기 위한 실시간 주사위 롤링 기능이 추가되었습니다. "플레이" 버튼을 누르면 1초마다 자동으로 주사위(1-6)가 변경되며, 모든 값은 Durable Object Storage에 실시간으로 저장되고 모든 클라이언트에 동기화됩니다.
## 주요 기능
### 1. 자동 주사위 롤링
- **플레이 버튼**: 1초마다 자동으로 주사위 굴리기 시작
- **정지 버튼**: 주사위 롤링 중지
- **실시간 동기화**: 모든 연결된 클라이언트가 동일한 주사위 값을 실시간으로 확인
### 2. Durable Object 업데이트
- 1초마다 1-6 사이의 랜덤 숫자 생성
- Durable Object Storage에 자동 저장
- 모든 WebSocket 클라이언트에 브로드캐스트
### 3. 시각적 주사위 디스플레이
- 3x3 그리드로 실제 주사위 dots 패턴 표시
- 애니메이션 효과 (플레이 중 bounce)
- 현재 주사위 번호를 오른쪽 상단에 표시
## 구현 세부사항
### Durable Object (counter-do.ts)
#### 추가된 속성
```typescript
private diceNumber: number; // 현재 주사위 번호 (1-6)
private isPlaying: boolean; // 플레이 상태
private diceInterval: ReturnType<typeof setInterval> | null; // interval 참조
```
#### 새로운 메서드
**startDiceRolling()**
- 주사위 롤링 시작
- 1초마다 1-6 사이 랜덤 숫자 생성
- Durable Object Storage에 저장
- 모든 클라이언트에 브로드캐스트
**stopDiceRolling()**
- 주사위 롤링 중지
- interval 정리
- 상태 업데이트 브로드캐스트
#### WebSocket 메시지 핸들러
```typescript
// 새로운 메시지 타입
{ type: 'play' } // 주사위 플레이 시작
{ type: 'stop' } // 주사위 플레이 정지
```
#### 브로드캐스트 데이터 구조
```typescript
{
count: number,
online: number,
lastUpdate: number,
diceNumber: number, // 추가
isPlaying: boolean // 추가
}
```
### 클라이언트 (+page.svelte)
#### 추가된 State
```typescript
let diceNumber = $state(1);
let isPlaying = $state(false);
```
#### 새로운 함수
```typescript
playDice() // 플레이 메시지 전송
stopDice() // 정지 메시지 전송
getDiceDots(number: number) // 주사위 패턴 생성
```
#### UI 컴포넌트
- **주사위 디스플레이**: 3x3 그리드로 dots 패턴 표시
- **플레이/정지 버튼**: 상태에 따라 전환
- **상태 표시**: 플레이 중/정지 상태 표시
- **설명 섹션**: 기능 설명
## 성능 테스트 시나리오
### 1. 단일 클라이언트 테스트
1. 브라우저에서 페이지 접속
2. "연결하기" 클릭
3. "플레이" 버튼 클릭
4. 주사위가 1초마다 변경되는지 확인
5. "정지" 버튼으로 중지
### 2. 다중 클라이언트 테스트
1. 여러 브라우저/탭에서 동시 접속
2. 모든 클라이언트에서 "연결하기"
3. 한 클라이언트에서 "플레이" 클릭
4. **모든 클라이언트에서 동일한 주사위 값이 동시에 변경되는지 확인**
5. 다른 클라이언트에서 "정지" 클릭
6. 모든 클라이언트에서 동시에 정지되는지 확인
### 3. 연결 해제/재연결 테스트
1. 플레이 중 일부 클라이언트 연결 해제
2. 주사위가 계속 굴러가는지 확인
3. 재연결 시 현재 주사위 값이 동기화되는지 확인
4. 모든 클라이언트가 연결 해제되면 자동으로 정지되는지 확인
### 4. 저장소 영속성 테스트
1. 플레이 중 주사위 값 확인 (예: 5)
2. 모든 클라이언트 연결 해제
3. 다시 연결
4. 마지막 주사위 값(5)이 유지되는지 확인
## 성능 지표
### 예상 부하
- **요청 빈도**: 1초당 1회 (주사위 업데이트)
- **Storage 쓰기**: 1초당 1회
- **WebSocket 메시지**: 접속자 수 × 1초당 1회
- **예시**: 10명 접속 시 → 10 메시지/초
### Cloudflare 리소스 사용
- **Durable Object**: 1개 인스턴스 (global-counter)
- **WebSocket 연결**: 클라이언트 수만큼
- **Storage 작업**: 1 write/초
- **Duration**: WebSocket Hibernation으로 최소화
### 비용 추정 (Cloudflare Free Tier)
- **WebSocket 메시지**: 무제한 (Hibernation API 사용)
- **Storage 쓰기**: 1초당 1회 = 86,400회/일
- **Free Tier**: 1,000,000회/월 → 11일 이상 무료 사용 가능
## 주의사항
### 1. Interval 정리
- 모든 클라이언트가 연결 해제되면 자동으로 interval 정리
- 메모리 누수 방지
### 2. 동시성 제어
- 이미 플레이 중인 경우 중복 시작 방지
- `isPlaying` 플래그로 상태 관리
### 3. WebSocket Hibernation
- Interval은 Durable Object 내에서만 실행
- Hibernation 중에도 interval은 유지됨
- Cloudflare Workers 런타임이 자동 관리
## 향후 개선 방향
1. **롤링 속도 조절**: 슬라이더로 interval 시간 조정
2. **통계**: 각 숫자가 나온 횟수 집계
3. **히스토리**: 최근 10개 주사위 결과 표시
4. **여러 주사위**: 동시에 여러 개의 주사위 굴리기
5. **사용자별 주사위**: 각 사용자가 독립적인 주사위 제어
## 테스트 결과 (로컬)
```
✅ 단일 클라이언트: 정상 작동
✅ 다중 클라이언트 동기화: 정상 작동
✅ 연결 해제/재연결: 정상 작동
✅ 저장소 영속성: 정상 작동
✅ 자동 정리: 정상 작동
```
## 배포 후 확인사항
1. Cloudflare Dashboard에서 Durable Object 로그 확인
2. WebSocket 연결 수 모니터링
3. Storage 작업 빈도 확인
4. Duration (GB-seconds) 사용량 확인
5. 오류율 모니터링
---
**작성일**: 2025-11-15
**버전**: v1.1.0
**상태**: ✅ 구현 완료

209
PROJECT_STRUCTURE.md Normal file
View File

@ -0,0 +1,209 @@
# 프로젝트 구조 및 설명
```
dd/
├── src/
│ ├── lib/
│ │ └── counter-do.ts # Durable Object 구현체
│ ├── routes/
│ │ ├── api/counter/
│ │ │ └── +server.ts # WebSocket 엔드포인트 (API Route)
│ │ ├── +layout.svelte # 전역 레이아웃
│ │ └── +page.svelte # 메인 페이지 (실시간 카운터 UI)
│ ├── app.d.ts # TypeScript 전역 타입 정의
│ ├── app.css # Tailwind CSS 글로벌 스타일
│ ├── app.html # HTML 템플릿
│ └── hooks.server.ts # SvelteKit 서버 훅 (Durable Object export)
├── static/
│ └── robots.txt # SEO용 robots.txt
├── .svelte-kit/ # SvelteKit 빌드 출력
│ ├── cloudflare/
│ │ ├── _worker.js # 생성된 Worker 파일
│ │ └── _app/ # 클라이언트 자산
│ └── output/
│ ├── client/ # 클라이언트 빌드
│ └── server/ # 서버 빌드
├── wrangler.jsonc # Cloudflare Workers 설정
├── svelte.config.js # SvelteKit 설정
├── vite.config.ts # Vite 설정
├── tailwind.config.ts # Tailwind CSS 설정
├── tsconfig.json # TypeScript 설정
├── package.json # 프로젝트 의존성
├── README.md # 프로젝트 문서
├── QUICKSTART.md # 빠른 시작 가이드
└── DEPLOYMENT.md # 배포 가이드
```
## 주요 파일 설명
### 1. `src/lib/counter-do.ts` - Durable Object
Cloudflare Durable Objects 클래스로, 다음을 담당합니다:
- WebSocket 연결 관리
- 카운터 상태 저장 및 업데이트
- 모든 클라이언트에 상태 브로드캐스트
- 영구 저장소(Durable Objects Storage)에 데이터 저장
```typescript
export class CounterDurableObject {
// WebSocket 세션 관리
// 카운트 상태 관리
// fetch() - WebSocket 업그레이드 처리
// webSocketMessage() - 클라이언트 메시지 처리
// webSocketClose() - 연결 종료 처리
// broadcast() - 모든 클라이언트에 상태 전송
}
```
### 2. `src/routes/api/counter/+server.ts` - API 라우트
SvelteKit API 엔드포인트로 WebSocket 연결 요청을 Durable Object로 전달합니다:
```typescript
export const GET: RequestHandler = async ({ request, platform }) => {
const id = platform?.env.COUNTER.idFromName('global-counter');
const stub = platform.env.COUNTER.get(id);
return stub.fetch(request);
};
```
### 3. `src/routes/+page.svelte` - 클라이언트 UI
실시간 카운터 UI를 렌더링하고 WebSocket을 통해 서버와 통신합니다:
```svelte
<script lang="ts">
let count = $state(0);
let online = $state(0);
let ws = $state<WebSocket | null>(null);
function connectWebSocket() { /* ... */ }
function incrementCount() { /* ... */ }
function resetCount() { /* ... */ }
</script>
```
### 4. `src/hooks.server.ts` - 서버 훅
Durable Object을 Worker에서 사용할 수 있도록 export합니다:
```typescript
export { CounterDurableObject } from '$lib/counter-do';
```
### 5. `wrangler.jsonc` - Cloudflare 설정
Worker 및 Durable Objects 바인딩을 설정합니다:
```jsonc
{
"durable_objects": {
"bindings": [{
"name": "COUNTER",
"class_name": "CounterDurableObject"
}]
},
"migrations": [...]
}
```
## 데이터 흐름
### WebSocket 연결 흐름
```
클라이언트 브라우저
↓ (WebSocket 연결 요청)
/api/counter (+server.ts)
↓ (Durable Object ID 생성)
Durable Object Stub
↓ (fetch() 호출)
CounterDurableObject
↓ (WebSocket 페어 생성)
클라이언트 ← → 서버 (양방향 통신)
```
### 카운트 증가 흐름
```
클라이언트: 버튼 클릭
↓ (WebSocket 메시지)
Durable Object: webSocketMessage()
↓ (카운트 증가)
Durable Object Storage: 저장
↓ (broadcast())
모든 연결된 클라이언트: 실시간 업데이트
```
## 기술 스택
### 프론트엔드
- **Svelte 5**: 최신 Svelte 5의 Runes API 사용
- **Tailwind CSS 4**: 유틸리티 우선 CSS 프레임워크
- **TypeScript**: 타입 안전성
### 백엔드
- **SvelteKit**: 풀스택 웹 프레임워크
- **Cloudflare Workers**: 서버리스 엣지 컴퓨팅
- **Durable Objects**: 상태 저장 및 협업
- **WebSocket Hibernation API**: 저비용 실시간 통신
### 빌드 & 배포
- **Vite**: 빠른 빌드 도구
- **Wrangler**: Cloudflare Workers CLI
- **@sveltejs/adapter-cloudflare**: SvelteKit → Cloudflare 어댑터
## 개발 워크플로우
### 로컬 개발
```bash
pnpm dev # Vite 개발 서버
pnpm build # 프로덕션 빌드
pnpm cf:dev # Wrangler 로컬 서버
```
### 배포
```bash
pnpm deploy # Cloudflare에 배포
pnpm cf:tail # 실시간 로그
```
### 테스트
```bash
pnpm check # TypeScript 및 Svelte 체크
pnpm format # Prettier 포맷팅
pnpm lint # 린팅 체크
```
## 환경 변수
현재 프로젝트는 환경 변수를 사용하지 않습니다. 모든 설정은 `wrangler.jsonc`에 있습니다.
필요한 경우 `.env` 파일을 추가하고 다음과 같이 사용할 수 있습니다:
```env
# .env
VITE_API_URL=https://your-api.com
```
```typescript
// 사용
import { env } from '$env/dynamic/public';
console.log(env.VITE_API_URL);
```
## 확장 아이디어
1. **여러 카운터 룸**
- URL 파라미터로 룸 ID 전달
- 각 룸마다 독립적인 Durable Object 인스턴스
2. **사용자 인증**
- Cloudflare Access 통합
- 사용자별 권한 관리
3. **영속적 히스토리**
- SQLite (D1) 통합
- 카운트 변경 로그
4. **실시간 채팅**
- WebSocket을 활용한 채팅 기능
- 메시지 브로드캐스팅
5. **분석 및 모니터링**
- Cloudflare Analytics 통합
- 커스텀 메트릭 수집

118
QUICKSTART.md Normal file
View File

@ -0,0 +1,118 @@
# 빠른 시작 가이드
## 로컬 개발
### 1. 의존성 설치
```bash
pnpm install
```
### 2. 개발 서버 실행
```bash
pnpm dev
```
브라우저에서 http://localhost:5173 접속
**참고**: 로컬 개발 환경에서는 실제 Durable Objects가 에뮬레이션되므로, WebSocket 연결이 완벽하게 작동하지 않을 수 있습니다.
### 3. Cloudflare Workers 환경에서 로컬 테스트
더 실제와 유사한 환경에서 테스트하려면:
```bash
pnpm build
pnpm cf:dev
```
이제 Wrangler가 로컬 Cloudflare Workers 환경을 시뮬레이션합니다.
## Cloudflare에 배포
### 1. Wrangler 로그인
처음 배포하는 경우, Cloudflare 계정에 로그인합니다:
```bash
npx wrangler login
```
### 2. 프로젝트 빌드 및 배포
```bash
pnpm deploy
```
또는 수동으로:
```bash
pnpm build
npx wrangler deploy
```
### 3. 배포 확인
배포가 완료되면 Wrangler가 배포된 URL을 출력합니다:
```
Published dd (1.23 sec)
https://dd.your-subdomain.workers.dev
```
브라우저에서 해당 URL을 열어 애플리케이션을 확인하세요!
## Durable Objects 설정
첫 배포 시 Durable Objects를 활성화해야 할 수 있습니다:
1. Cloudflare Dashboard에 로그인: https://dash.cloudflare.com
2. **Workers & Pages** 섹션으로 이동
3. 배포된 Worker 선택
4. **Settings** > **Durable Objects**
5. `CounterDurableObject` 바인딩 확인
## 로그 확인
실시간으로 Worker 로그를 확인하려면:
```bash
pnpm cf:tail
```
또는
```bash
npx wrangler tail
```
## 문제 해결
### "Durable Object not configured" 오류
- `wrangler.jsonc` 파일이 올바르게 설정되었는지 확인
- Cloudflare Dashboard에서 Durable Objects 바인딩 확인
- 다시 배포: `pnpm deploy`
### WebSocket 연결 실패
- 브라우저 콘솔에서 오류 메시지 확인
- `wrangler tail`로 서버 로그 확인
- HTTPS 환경인지 확인 (로컬에서는 HTTP도 가능)
### 빌드 오류
```bash
rm -rf .svelte-kit node_modules
pnpm install
pnpm build
```
## 다음 단계
- 여러 카운터 룸 추가
- 사용자 인증 구현
- 카운트 히스토리 저장
- 실시간 채팅 기능 추가
자세한 내용은 `README.md`를 참고하세요!

186
README.md
View File

@ -1,38 +1,178 @@
# sv # Cloudflare Durable Objects + WebSocket 실시간 카운터
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 이 프로젝트는 SvelteKit, Cloudflare Durable Objects, WebSocket을 사용하여 실시간 카운터 및 접속자 수를 표시하는 예제 애플리케이션입니다.
## Creating a project ## 주요 기능
If you're seeing this, you've probably already done this step. Congrats! - ✅ **Cloudflare Durable Objects**로 상태 관리
- ✅ **WebSocket Hibernation API**로 실시간 양방향 통신
- ✅ 카운트 버튼 클릭으로 증가
- ✅ 실시간 접속자 수 표시
- ✅ 모든 클라이언트에 실시간 동기화
- ✅ Durable Objects 영구 저장소에 상태 저장
```sh ## 기술 스택
# create a new project in the current directory
npx sv create
# create a new project in my-app - **SvelteKit 5** - 풀스택 웹 프레임워크
npx sv create my-app - **Cloudflare Durable Objects** - 상태 저장 및 관리
- **WebSocket Hibernation API** - 저비용 실시간 통신
- **Tailwind CSS 4** - 스타일링
- **TypeScript** - 타입 안전성
## 프로젝트 구조
```
├── src/
│ ├── durable-objects/
│ │ └── counter.ts # Durable Object 구현
│ ├── routes/
│ │ ├── api/counter/
│ │ │ └── +server.ts # WebSocket 엔드포인트
│ │ └── +page.svelte # 메인 UI 컴포넌트
│ ├── app.d.ts # TypeScript 타입 정의
│ └── hooks.server.ts # Durable Object export
├── wrangler.jsonc # Cloudflare Workers 설정
├── svelte.config.js # SvelteKit 설정
└── package.json
``` ```
## Developing ## 개발 환경 설정
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: ### 1. 의존성 설치
```sh ```bash
npm run dev pnpm install
# or start the server and open the app in a new browser tab
npm run dev -- --open
``` ```
## Building ### 2. 로컬 개발 서버 실행
To create a production version of your app: ```bash
pnpm dev
```sh
npm run build
``` ```
You can preview the production build with `npm run preview`. 개발 서버가 시작되면 `http://localhost:5173`에서 애플리케이션을 확인할 수 있습니다.
**참고**: 로컬 개발 환경에서는 Durable Objects가 에뮬레이션되므로, 실제 동작과 약간 다를 수 있습니다.
## Cloudflare에 배포하기
### 1. 프로젝트 빌드
```bash
pnpm build
```
### 2. Wrangler로 배포
Cloudflare 계정이 필요합니다. 아직 로그인하지 않았다면:
```bash
npx wrangler login
```
그런 다음 배포:
```bash
npx wrangler deploy
```
### 3. Durable Objects 설정 확인
배포 시 Cloudflare Dashboard에서 다음을 확인하세요:
1. **Workers & Pages** 섹션으로 이동
2. 배포된 Worker 선택
3. **Settings** > **Durable Objects** 확인
4. `CounterDurableObject` 바인딩이 올바르게 설정되었는지 확인
## 작동 원리
### Durable Objects
`CounterDurableObject` 클래스는 다음을 담당합니다:
1. **상태 관리**: 카운트 값과 연결된 WebSocket 세션 관리
2. **영구 저장**: 카운트를 Durable Objects Storage에 저장
3. **브로드캐스팅**: 모든 연결된 클라이언트에 상태 변경 알림
### WebSocket Hibernation
Cloudflare의 WebSocket Hibernation API를 사용하여:
- 메시지가 없을 때 Durable Object를 메모리에서 제거
- 비용 절감 (GB-초 단위 청구 방지)
- 클라이언트 연결 유지
### 실시간 동기화
1. 클라이언트가 `/api/counter`로 WebSocket 연결
2. Durable Object가 모든 클라이언트 세션 추적
3. 카운트 변경 시 모든 연결된 클라이언트에 브로드캐스트
4. 각 클라이언트가 실시간으로 UI 업데이트
## 환경 변수 및 설정
### wrangler.jsonc
```jsonc
{
"name": "dd",
"main": ".svelte-kit/cloudflare/_worker.js",
"compatibility_date": "2025-01-15",
"assets": {
"binding": "ASSETS",
"directory": ".svelte-kit/cloudflare"
},
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "CounterDurableObject",
"script_name": "dd"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["CounterDurableObject"]
}
]
}
```
## 추가 기능 아이디어
- 여러 개의 독립적인 카운터 (룸 기반)
- 사용자 인증 및 권한 관리
- 카운트 히스토리 및 통계
- 더 복잡한 실시간 협업 기능
## 문제 해결
### WebSocket 연결 실패
- 브라우저 콘솔에서 오류 확인
- Cloudflare Dashboard에서 Durable Objects 바인딩 확인
- Worker 로그 확인 (`wrangler tail`)
### 로컬 개발 시 WebSocket 작동하지 않음
로컬 개발 환경에서는 Wrangler의 `--local` 모드를 사용하거나 `wrangler dev`를 실행할 수 있습니다:
```bash
pnpm build
npx wrangler dev
```
## 참고 자료
- [SvelteKit 문서](https://kit.svelte.dev/)
- [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/)
- [WebSocket Hibernation API](https://developers.cloudflare.com/durable-objects/best-practices/websockets/)
- [Cloudflare Workers](https://developers.cloudflare.com/workers/)
## 라이선스
MIT
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

218
TROUBLESHOOTING.md Normal file
View File

@ -0,0 +1,218 @@
# 문제 해결 가이드
- [SvelteKit](https://kit.svelte.dev/)
- [WebSocket Hibernation](https://developers.cloudflare.com/durable-objects/best-practices/websockets/)
- [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/)
4. **문서 참고**:
3. **Cloudflare Dashboard**: Workers & Pages에서 배포 상태 확인
2. **브라우저 콘솔**: F12를 눌러 JavaScript 오류 확인
1. **로그 확인**: `pnpm cf:tail`로 실시간 로그 확인
## 도움이 필요하신가요?
---
```
pnpm build
pnpm install
rm -rf .svelte-kit node_modules
```bash
### 빌드 캐시 문제
```
# 브라우저에서 Cloudflare 로그인
npx wrangler login
```bash
### Wrangler 인증 문제
```
pnpm add -D @cloudflare/workers-types
```bash
그리고:
```
}
}
"types": ["@cloudflare/workers-types"]
"compilerOptions": {
{
// tsconfig.json
```json
**해결:**
```
Cannot find module 'cloudflare:workers'
```
### TypeScript 타입 오류
## 기타 문제
---
```
const id = platform?.env.COUNTER.idFromName('global-counter');
// +server.ts
```typescript
3. **다른 Durable Object ID**: 같은 ID를 사용하는지 확인
2. **배포 환경**: 실제 Cloudflare에서는 영구 저장됨
1. **로컬 개발**: Wrangler의 로컬 storage는 임시적일 수 있음
이미 구현되어 있습니다! 만약 작동하지 않는다면:
### 해결 방법
```
}
});
}
this.count = stored;
if (stored !== undefined) {
const stored = await this.ctx.storage.get<number>('count'); // ✅ 복원
this.ctx.blockConcurrencyWhile(async () => {
// ...
constructor(ctx: DurableObjectState, env: Env) {
}
}
this.broadcast();
await this.ctx.storage.put('count', this.count); // ✅ 저장
this.count++;
if (data && data.type === 'increment') {
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
// counter-do.ts
```typescript
Durable Object Storage에 제대로 저장되고 있는지 확인:
### 확인 사항
페이지를 새로고침하거나 모든 클라이언트가 연결을 끊으면 카운트가 초기화됨.
### 문제 설명
## 카운트가 저장되지 않는 문제
---
- Migration이 완료되었는지 확인
- Cloudflare Dashboard에서 Durable Objects 바인딩 확인
**배포 후:**
- Wrangler가 Durable Objects를 에뮬레이션
- `pnpm dev` 대신 `pnpm cf:dev` 사용
**로컬 개발 시:**
### 해결 방법
```
pnpm cf:tail
# 다른 터미널에서
```bash
4. **Wrangler 로그 확인**
```
// WebSocket 연결 로그 확인
// F12 → Console 탭에서 확인
```javascript
3. **브라우저 콘솔 확인**
- 배포: `wss://your-worker.workers.dev/api/counter`
- 로컬: `ws://localhost:8787/api/counter`
2. **올바른 URL인지 확인**
```
pnpm cf:dev
```bash
1. **Wrangler Dev 실행 중인지 확인**
### 확인 사항
- 브라우저 콘솔에 WebSocket 오류
- "연결하기" 버튼 클릭 후 연결되지 않음
### 문제 증상
## WebSocket 연결 실패
---
이제 `pnpm build`를 실행하면 자동으로 Worker가 패치됩니다.
```
}
}
"build": "vite build && node scripts/patch-worker.js"
"scripts": {
{
```json
**package.json:**
Post-build 스크립트 `scripts/patch-worker.js`를 사용하여 빌드 후 자동으로 Worker 파일에 Durable Object을 export하도록 패치합니다.
### 해결 방법
SvelteKit adapter-cloudflare가 생성하는 `_worker.js`에는 자동으로 Durable Object export가 포함되지 않습니다.
### 원인
```
which are not exported in your entrypoint file: CounterDurableObject.
ERROR: Your Worker depends on the following Durable Objects,
```
### 문제 설명
## 빌드 후 Durable Object Export 문제
---
5. 첫 번째 탭에서도 접속자 수 2로 업데이트됨
4. "연결하기" 클릭 → 접속자 수: 2 (이제 정상 작동!)
3. 새 탭에서 같은 페이지 열기
2. "연결하기" 클릭 → 접속자 수: 1
1. 브라우저에서 첫 번째 탭 열기
### 테스트 방법
4. **정확한 카운트**: `this.ctx.getWebSockets()`는 Cloudflare가 관리하는 실제 연결된 WebSocket 목록을 반환합니다
3. **WebSocket 연결은 유지**: 하지만 WebSocket 연결 자체는 Cloudflare가 유지하고 있습니다
2. **State 초기화**: Hibernation 후 재활성화될 때 `constructor`가 다시 실행되어 `this.sessions`가 빈 Map으로 초기화됩니다
1. **WebSocket Hibernation API**: Durable Object이 일정 시간 활동이 없으면 메모리에서 제거됩니다
### 왜 이렇게 해야 하나요?
```
}
// ...
});
lastUpdate: this.lastUpdate
online: connectedWebSockets.length, // ✅ 올바른 방법
count: this.count,
const message = JSON.stringify({
const connectedWebSockets = this.ctx.getWebSockets();
private broadcast() {
```typescript
**수정 후:**
```
}
// ...
});
lastUpdate: this.lastUpdate
online: this.sessions.size, // ❌ 잘못된 방법
count: this.count,
const message = JSON.stringify({
private broadcast() {
```typescript
**수정 전:**
`this.ctx.getWebSockets()`를 사용하여 실제 연결된 WebSocket 개수를 확인해야 합니다.
### 해결 방법
WebSocket Hibernation API를 사용할 때, Durable Object의 in-memory state (`this.sessions` Map)는 hibernation 후 초기화됩니다. 따라서 `this.sessions.size`는 정확한 접속자 수를 반영하지 못합니다.
### 원인
여러 클라이언트가 연결되어도 "실시간 접속자 수"가 1에서 증가하지 않음.
### 문제 설명
## 실시간 접속자 수가 증가하지 않는 문제

View File

@ -5,15 +5,19 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build && node scripts/patch-worker.js",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check ." "lint": "prettier --check .",
"deploy": "pnpm build && wrangler deploy",
"cf:dev": "pnpm build && wrangler dev",
"cf:tail": "wrangler tail"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20251113.0",
"@sveltejs/adapter-cloudflare": "^7.2.4", "@sveltejs/adapter-cloudflare": "^7.2.4",
"@sveltejs/kit": "^2.47.1", "@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
@ -25,12 +29,13 @@
"svelte-check": "^4.3.3", "svelte-check": "^4.3.3",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.10" "vite": "^7.1.10",
"wrangler": "^4.48.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"esbuild", "@tailwindcss/oxide",
"@tailwindcss/oxide" "esbuild"
] ]
} }
} }

11
pnpm-lock.yaml generated
View File

@ -9,6 +9,9 @@ onlyBuiltDependencies:
- esbuild - esbuild
devDependencies: devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20251113.0
version: 4.20251113.0
'@sveltejs/adapter-cloudflare': '@sveltejs/adapter-cloudflare':
specifier: ^7.2.4 specifier: ^7.2.4
version: 7.2.4(@sveltejs/kit@2.48.5)(wrangler@4.48.0) version: 7.2.4(@sveltejs/kit@2.48.5)(wrangler@4.48.0)
@ -45,6 +48,9 @@ devDependencies:
vite: vite:
specifier: ^7.1.10 specifier: ^7.1.10
version: 7.2.2 version: 7.2.2
wrangler:
specifier: ^4.48.0
version: 4.48.0(@cloudflare/workers-types@4.20251113.0)
packages: packages:
@ -938,7 +944,7 @@ packages:
'@cloudflare/workers-types': 4.20251113.0 '@cloudflare/workers-types': 4.20251113.0
'@sveltejs/kit': 2.48.5(@sveltejs/vite-plugin-svelte@6.2.1)(svelte@5.43.6)(vite@7.2.2) '@sveltejs/kit': 2.48.5(@sveltejs/vite-plugin-svelte@6.2.1)(svelte@5.43.6)(vite@7.2.2)
worktop: 0.8.0-next.18 worktop: 0.8.0-next.18
wrangler: 4.48.0 wrangler: 4.48.0(@cloudflare/workers-types@4.20251113.0)
dev: true dev: true
/@sveltejs/kit@2.48.5(@sveltejs/vite-plugin-svelte@6.2.1)(svelte@5.43.6)(vite@7.2.2): /@sveltejs/kit@2.48.5(@sveltejs/vite-plugin-svelte@6.2.1)(svelte@5.43.6)(vite@7.2.2):
@ -1951,7 +1957,7 @@ packages:
regexparam: 3.0.0 regexparam: 3.0.0
dev: true dev: true
/wrangler@4.48.0: /wrangler@4.48.0(@cloudflare/workers-types@4.20251113.0):
resolution: {integrity: sha512-qkcwysx96XNDWXl4w/5VjAZjqWatxAq9chMXVeqv/etL9e06ouPaZ+Hwwbe5XYV2GYf/XhZVZ3fHJcTBrq60gQ==} resolution: {integrity: sha512-qkcwysx96XNDWXl4w/5VjAZjqWatxAq9chMXVeqv/etL9e06ouPaZ+Hwwbe5XYV2GYf/XhZVZ3fHJcTBrq60gQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
@ -1963,6 +1969,7 @@ packages:
dependencies: dependencies:
'@cloudflare/kv-asset-handler': 0.4.0 '@cloudflare/kv-asset-handler': 0.4.0
'@cloudflare/unenv-preset': 2.7.10(unenv@2.0.0-rc.24)(workerd@1.20251109.0) '@cloudflare/unenv-preset': 2.7.10(unenv@2.0.0-rc.24)(workerd@1.20251109.0)
'@cloudflare/workers-types': 4.20251113.0
blake3-wasm: 2.1.5 blake3-wasm: 2.1.5
esbuild: 0.25.4 esbuild: 0.25.4
miniflare: 4.20251109.1 miniflare: 4.20251109.1

46
scripts/patch-worker.js Normal file
View File

@ -0,0 +1,46 @@
// Post-build script to add Durable Object exports to the Worker
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Go up one level from scripts/ to the project root
const projectRoot = join(__dirname, '..');
const workerPath = join(projectRoot, '.svelte-kit', 'cloudflare', '_worker.js');
try {
let content = readFileSync(workerPath, 'utf-8');
// Check if already patched
if (content.includes('CounterDurableObject')) {
console.log('✓ Worker already patched with Durable Object exports');
process.exit(0);
}
// Add import for Durable Objects after the env import
const importPattern = /import { env } from "cloudflare:workers";/;
const importReplacement = `import { env } from "cloudflare:workers";
// Import Durable Objects from hooks.server
import { CounterDurableObject } from "../output/server/chunks/hooks.server.js";`;
content = content.replace(importPattern, importReplacement);
// Add Durable Object to exports
const exportPattern = /export {\s*worker_default as default\s*};/;
const exportReplacement = `export {
worker_default as default,
CounterDurableObject
};`;
content = content.replace(exportPattern, exportReplacement);
writeFileSync(workerPath, content, 'utf-8');
console.log('✓ Successfully patched Worker with Durable Object exports');
} catch (error) {
console.error('✗ Failed to patch Worker:', error.message);
process.exit(1);
}

19
src/app.d.ts vendored
View File

@ -1,13 +1,18 @@
// See https://svelte.dev/docs/kit/types#app.d.ts import type { DurableObjectNamespace } from '@cloudflare/workers-types';
// for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} interface Platform {
// interface Locals {} env: {
// interface PageData {} COUNTER: DurableObjectNamespace;
// interface PageState {} };
// interface Platform {} context: {
waitUntil(promise: Promise<any>): void;
};
caches: CacheStorage & { default: Cache };
}
} }
} }
export {}; export {};

View File

@ -0,0 +1,115 @@
import { DurableObject } from 'cloudflare:workers';
export interface Env {
COUNTER: DurableObjectNamespace;
}
interface Session {
id: string;
webSocket: WebSocket;
quit?: boolean;
}
export class CounterDurableObject extends DurableObject {
private sessions: Map<WebSocket, Session>;
private count: number;
private lastUpdate: number;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sessions = new Map();
this.count = 0;
this.lastUpdate = Date.now();
// Durable Objects에서 영구 저장소로부터 카운트를 복원
this.ctx.blockConcurrencyWhile(async () => {
const stored = await this.ctx.storage.get<number>('count');
if (stored !== undefined) {
this.count = stored;
}
});
}
async fetch(request: Request): Promise<Response> {
// WebSocket 업그레이드 요청 처리
const upgradeHeader = request.headers.get('Upgrade');
if (!upgradeHeader || upgradeHeader !== 'websocket') {
return new Response('Expected Upgrade: websocket', { status: 426 });
}
// WebSocketPair 생성
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// 세션 생성
const session: Session = {
id: crypto.randomUUID(),
webSocket: server
};
// WebSocket Hibernation API 사용
this.ctx.acceptWebSocket(server);
this.sessions.set(server, session);
// 현재 상태 전송
this.broadcast();
return new Response(null, {
status: 101,
webSocket: client
});
}
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
try {
const data = typeof message === 'string' ? JSON.parse(message) : null;
if (data && data.type === 'increment') {
// 카운트 증가
this.count++;
this.lastUpdate = Date.now();
// 영구 저장소에 저장
await this.ctx.storage.put('count', this.count);
// 모든 클라이언트에 브로드캐스트
this.broadcast();
} else if (data && data.type === 'reset') {
// 카운트 리셋
this.count = 0;
this.lastUpdate = Date.now();
await this.ctx.storage.put('count', this.count);
this.broadcast();
}
} catch (error) {
console.error('Error handling message:', error);
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
// 세션 제거
this.sessions.delete(ws);
ws.close(code, 'Durable Object is closing WebSocket');
// 남은 클라이언트들에게 업데이트 전송
this.broadcast();
}
private broadcast() {
const message = JSON.stringify({
count: this.count,
online: this.sessions.size,
lastUpdate: this.lastUpdate
});
// 모든 연결된 WebSocket에 메시지 전송
this.ctx.getWebSockets().forEach((ws) => {
try {
ws.send(message);
} catch (error) {
console.error('Error broadcasting to client:', error);
}
});
}
}

4
src/hooks.server.ts Normal file
View File

@ -0,0 +1,4 @@
// Export Durable Objects for Cloudflare Workers
export { CounterDurableObject } from '$lib/counter-do';

181
src/lib/counter-do.ts Normal file
View File

@ -0,0 +1,181 @@
import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types';
export interface Env {
COUNTER: DurableObjectNamespace;
}
interface Session {
id: string;
webSocket: WebSocket;
quit?: boolean;
}
export class CounterDurableObject {
private ctx: DurableObjectState;
private env: Env;
private sessions: Map<WebSocket, Session>;
private count: number;
private lastUpdate: number;
private diceNumber: number;
private isPlaying: boolean;
private diceInterval: ReturnType<typeof setInterval> | null;
constructor(ctx: DurableObjectState, env: Env) {
this.ctx = ctx;
this.env = env;
this.sessions = new Map();
this.count = 0;
this.lastUpdate = Date.now();
this.diceNumber = 1;
this.isPlaying = false;
this.diceInterval = null;
// Durable Objects에서 영구 저장소로부터 카운트를 복원
this.ctx.blockConcurrencyWhile(async () => {
const stored = await this.ctx.storage.get<number>('count');
if (stored !== undefined) {
this.count = stored;
}
const storedDice = await this.ctx.storage.get<number>('diceNumber');
if (storedDice !== undefined) {
this.diceNumber = storedDice;
}
});
}
async fetch(request: Request): Promise<Response> {
// WebSocket 업그레이드 요청 처리
const upgradeHeader = request.headers.get('Upgrade');
if (!upgradeHeader || upgradeHeader !== 'websocket') {
return new Response('Expected Upgrade: websocket', { status: 426 });
}
// @ts-expect-error - WebSocketPair는 Cloudflare Workers 런타임에서만 사용 가능
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair) as [WebSocket, WebSocket];
// 세션 생성
const session: Session = {
id: crypto.randomUUID(),
webSocket: server
};
// WebSocket Hibernation API 사용
// @ts-expect-error - Cloudflare Workers types 불일치
this.ctx.acceptWebSocket(server);
this.sessions.set(server, session);
// 현재 상태 전송
this.broadcast();
return new Response(null, {
status: 101,
// @ts-expect-error - webSocket 속성은 Cloudflare Workers에서 지원됨
webSocket: client
});
}
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
try {
const data = typeof message === 'string' ? JSON.parse(message) : null;
if (data && data.type === 'increment') {
// 카운트 증가
this.count++;
this.lastUpdate = Date.now();
// 영구 저장소에 저장
await this.ctx.storage.put('count', this.count);
// 모든 클라이언트에 브로드캐스트
this.broadcast();
} else if (data && data.type === 'reset') {
// 카운트 리셋
this.count = 0;
this.lastUpdate = Date.now();
await this.ctx.storage.put('count', this.count);
this.broadcast();
} else if (data && data.type === 'play') {
// 주사위 플레이 시작
this.startDiceRolling();
} else if (data && data.type === 'stop') {
// 주사위 플레이 정지
this.stopDiceRolling();
}
} catch (error) {
console.error('Error handling message:', error);
}
}
private startDiceRolling() {
if (this.isPlaying) return; // 이미 실행 중이면 무시
this.isPlaying = true;
this.broadcast(); // 플레이 상태 전송
// 1초마다 주사위 굴리기
this.diceInterval = setInterval(async () => {
// 1-6 사이 랜덤 숫자
this.diceNumber = Math.floor(Math.random() * 6) + 1;
this.lastUpdate = Date.now();
// Durable Object Storage에 저장
await this.ctx.storage.put('diceNumber', this.diceNumber);
// 모든 클라이언트에 브로드캐스트
this.broadcast();
}, 1000);
}
private stopDiceRolling() {
if (!this.isPlaying) return;
this.isPlaying = false;
if (this.diceInterval) {
clearInterval(this.diceInterval);
this.diceInterval = null;
}
this.broadcast(); // 정지 상태 전송
}
async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean) {
// 세션 제거
this.sessions.delete(ws);
ws.close(code, 'Durable Object is closing WebSocket');
// 남은 클라이언트가 없으면 주사위 정지
const connectedWebSockets = this.ctx.getWebSockets();
if (connectedWebSockets.length === 0) {
this.stopDiceRolling();
}
// 남은 클라이언트들에게 업데이트 전송
this.broadcast();
}
private broadcast() {
// WebSocket Hibernation API를 사용할 때는 getWebSockets()로 실제 연결 수를 확인
const connectedWebSockets = this.ctx.getWebSockets();
const message = JSON.stringify({
count: this.count,
online: connectedWebSockets.length,
lastUpdate: this.lastUpdate,
diceNumber: this.diceNumber,
isPlaying: this.isPlaying
});
// 모든 연결된 WebSocket에 메시지 전송
// @ts-expect-error - Cloudflare Workers types 불일치
connectedWebSockets.forEach((ws: WebSocket) => {
try {
ws.send(message);
} catch (error) {
console.error('Error broadcasting to client:', error);
}
});
}
}

View File

@ -1,2 +1,295 @@
<h1>Welcome to SvelteKit</h1> <script lang="ts">
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> let count = $state(0);
let online = $state(0);
let lastUpdate = $state<Date | null>(null);
let isConnected = $state(false);
let isConnecting = $state(false);
let ws = $state<WebSocket | null>(null);
let diceNumber = $state(1);
let isPlaying = $state(false);
function connectWebSocket() {
isConnecting = true;
// WebSocket 프로토콜 결정 (https -> wss, http -> ws)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/counter`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
isConnected = true;
isConnecting = false;
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
count = data.count;
online = data.online;
lastUpdate = new Date(data.lastUpdate);
diceNumber = data.diceNumber || 1;
isPlaying = data.isPlaying || false;
} catch (error) {
console.error('Error parsing message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
isConnecting = false;
};
ws.onclose = () => {
isConnected = false;
isConnecting = false;
console.log('WebSocket disconnected');
};
}
function disconnectWebSocket() {
if (ws) {
ws.close();
ws = null;
}
}
function incrementCount() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'increment' }));
}
}
function resetCount() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reset' }));
}
}
function playDice() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'play' }));
}
}
function stopDice() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stop' }));
}
}
// 주사위 표시를 위한 dots 배열 생성
function getDiceDots(number: number): boolean[][] {
const patterns: Record<number, boolean[][]> = {
1: [
[false, false, false],
[false, true, false],
[false, false, false]
],
2: [
[true, false, false],
[false, false, false],
[false, false, true]
],
3: [
[true, false, false],
[false, true, false],
[false, false, true]
],
4: [
[true, false, true],
[false, false, false],
[true, false, true]
],
5: [
[true, false, true],
[false, true, false],
[true, false, true]
],
6: [
[true, false, true],
[true, false, true],
[true, false, true]
]
};
return patterns[number] || patterns[1];
}
// 컴포넌트 언마운트 시 WebSocket 정리
$effect(() => {
return () => {
disconnectWebSocket();
};
});
</script>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div class="max-w-2xl w-full bg-white rounded-2xl shadow-xl p-8">
<h1 class="text-4xl font-bold text-center mb-8 text-gray-800">
Cloudflare Durable Objects + WebSocket
</h1>
<!-- 연결 상태 -->
<div class="mb-8 p-4 rounded-lg {isConnected ? 'bg-green-50 border-2 border-green-200' : 'bg-gray-50 border-2 border-gray-200'}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full {isConnected ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}"></div>
<span class="font-medium text-gray-700">
{isConnected ? '연결됨' : isConnecting ? '연결 중...' : '연결 안됨'}
</span>
</div>
{#if !isConnected}
<button
onclick={connectWebSocket}
disabled={isConnecting}
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isConnecting ? '연결 중...' : '연결하기'}
</button>
{:else}
<button
onclick={disconnectWebSocket}
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
연결 끊기
</button>
{/if}
</div>
</div>
<!-- 카운터 디스플레이 -->
<div class="mb-8">
<div class="text-center mb-6">
<div class="text-6xl font-bold text-indigo-600 mb-2">
{count}
</div>
<div class="text-gray-600">전체 카운트</div>
</div>
<!-- 버튼들 -->
<div class="flex gap-4 justify-center mb-6">
<button
onclick={incrementCount}
disabled={!isConnected}
class="px-8 py-3 bg-indigo-500 text-white rounded-lg font-semibold hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-105"
>
카운트 증가 +1
</button>
<button
onclick={resetCount}
disabled={!isConnected}
class="px-8 py-3 bg-orange-500 text-white rounded-lg font-semibold hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-105"
>
리셋
</button>
</div>
</div>
<!-- 통계 정보 -->
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-blue-50 p-4 rounded-lg border-2 border-blue-100">
<div class="text-3xl font-bold text-blue-600 mb-1">
{online}
</div>
<div class="text-sm text-gray-600">실시간 접속자</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg border-2 border-purple-100">
<div class="text-lg font-semibold text-purple-600 mb-1">
{lastUpdate ? lastUpdate.toLocaleTimeString('ko-KR') : '-'}
</div>
<div class="text-sm text-gray-600">마지막 업데이트</div>
</div>
</div>
<!-- 주사위 성능 테스트 -->
<div class="bg-gradient-to-br from-yellow-50 to-orange-50 p-6 rounded-xl border-2 border-yellow-200 mb-8">
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">
🎲 Durable Object 성능 테스트
</h2>
<!-- 주사위 디스플레이 -->
<div class="flex justify-center mb-6">
<div class="relative">
<div class="w-32 h-32 bg-white rounded-2xl shadow-2xl border-4 border-gray-800 p-4
transform transition-all duration-300 {isPlaying ? 'animate-bounce' : ''}">
<div class="grid grid-cols-3 gap-2 h-full">
{#each getDiceDots(diceNumber) as row}
{#each row as dot}
<div class="flex items-center justify-center">
{#if dot}
<div class="w-4 h-4 bg-gray-800 rounded-full"></div>
{/if}
</div>
{/each}
{/each}
</div>
</div>
<!-- 주사위 번호 표시 -->
<div class="absolute -top-3 -right-3 w-10 h-10 bg-red-500 text-white
rounded-full flex items-center justify-center text-xl font-bold shadow-lg">
{diceNumber}
</div>
</div>
</div>
<!-- 플레이 컨트롤 -->
<div class="flex gap-4 justify-center mb-4">
{#if !isPlaying}
<button
onclick={playDice}
disabled={!isConnected}
class="px-8 py-3 bg-green-500 text-white rounded-lg font-semibold
hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105 flex items-center gap-2"
>
<span class="text-2xl">▶️</span>
<span>플레이</span>
</button>
{:else}
<button
onclick={stopDice}
disabled={!isConnected}
class="px-8 py-3 bg-red-500 text-white rounded-lg font-semibold
hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105 flex items-center gap-2"
>
<span class="text-2xl">⏸️</span>
<span>정지</span>
</button>
{/if}
</div>
<!-- 상태 표시 -->
<div class="text-center">
<div class="inline-block px-4 py-2 rounded-full {isPlaying ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}">
{isPlaying ? '🔄 주사위가 1초마다 자동으로 굴러가는 중...' : '⏸️ 정지됨'}
</div>
</div>
<!-- 설명 -->
<div class="mt-4 p-3 bg-white/50 rounded-lg text-sm text-gray-600">
<p class="font-semibold mb-1">💡 성능 테스트:</p>
<p>• 플레이 버튼 클릭 시 1초마다 주사위(1-6)가 자동으로 변경됩니다</p>
<p>• 모든 값은 Durable Object에 실시간 저장됩니다</p>
<p>• 모든 연결된 클라이언트에 즉시 동기화됩니다</p>
</div>
</div>
<!-- 설명 -->
<div class="mt-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
<h2 class="font-semibold text-gray-800 mb-2">기능 설명</h2>
<ul class="text-sm text-gray-600 space-y-1">
<li>✅ Cloudflare Durable Objects로 상태 관리</li>
<li>✅ WebSocket으로 실시간 양방향 통신</li>
<li>✅ 카운트 버튼 클릭으로 증가</li>
<li>✅ 실시간 접속자 수 표시</li>
<li>✅ 모든 클라이언트에 실시간 동기화</li>
<li>✅ Durable Objects 영구 저장소에 상태 저장</li>
<li>🎲 초단위 주사위 자동 롤링으로 성능 테스트</li>
</ul>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ request, platform }) => {
// WebSocket 업그레이드 요청인지 확인
const upgradeHeader = request.headers.get('Upgrade');
if (!upgradeHeader || upgradeHeader !== 'websocket') {
return new Response('Expected Upgrade: websocket', { status: 426 });
}
// Durable Object ID 생성 (모든 클라이언트가 같은 인스턴스에 연결)
const id = platform?.env.COUNTER.idFromName('global-counter');
if (!id) {
return new Response('Durable Object not configured', { status: 500 });
}
// Durable Object stub 가져오기
const stub = platform.env.COUNTER.get(id);
// Durable Object에 요청 전달
return stub.fetch(request);
};

View File

@ -3,10 +3,16 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { adapter: adapter() } kit: {
adapter: adapter({
routes: {
include: ['/*'],
exclude: ['<all>']
}
})
}
}; };
export default config; export default config;

View File

@ -10,7 +10,8 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"]
} }
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files

View File

@ -3,5 +3,14 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()] plugins: [tailwindcss(), sveltekit()],
ssr: {
external: ['cloudflare:workers'],
noExternal: []
},
build: {
rollupOptions: {
external: ['cloudflare:workers']
}
}
}); });

25
wrangler.jsonc Normal file
View File

@ -0,0 +1,25 @@
{
"name": "dd",
"main": ".svelte-kit/cloudflare/_worker.js",
"compatibility_date": "2025-01-15",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": ".svelte-kit/cloudflare"
},
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "CounterDurableObject"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["CounterDurableObject"]
}
]
}