듀라블오브젝트 샘플
This commit is contained in:
parent
a9e6c9333d
commit
da2918bd88
92
CHANGELOG.md
Normal file
92
CHANGELOG.md
Normal 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
165
DEPLOYMENT.md
Normal 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
180
DICE_FEATURE.md
Normal 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
209
PROJECT_STRUCTURE.md
Normal 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
118
QUICKSTART.md
Normal 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
186
README.md
@ -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
|
||||
npx sv create my-app
|
||||
- **SvelteKit 5** - 풀스택 웹 프레임워크
|
||||
- **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
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Building
|
||||
### 2. 로컬 개발 서버 실행
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
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
218
TROUBLESHOOTING.md
Normal 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에서 증가하지 않음.
|
||||
### 문제 설명
|
||||
|
||||
## 실시간 접속자 수가 증가하지 않는 문제
|
||||
|
||||
|
||||
15
package.json
15
package.json
@ -5,15 +5,19 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"build": "vite build && node scripts/patch-worker.js",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"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": {
|
||||
"@cloudflare/workers-types": "^4.20251113.0",
|
||||
"@sveltejs/adapter-cloudflare": "^7.2.4",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
@ -25,12 +29,13 @@
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.10"
|
||||
"vite": "^7.1.10",
|
||||
"wrangler": "^4.48.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"@tailwindcss/oxide"
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@ -9,6 +9,9 @@ onlyBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20251113.0
|
||||
version: 4.20251113.0
|
||||
'@sveltejs/adapter-cloudflare':
|
||||
specifier: ^7.2.4
|
||||
version: 7.2.4(@sveltejs/kit@2.48.5)(wrangler@4.48.0)
|
||||
@ -45,6 +48,9 @@ devDependencies:
|
||||
vite:
|
||||
specifier: ^7.1.10
|
||||
version: 7.2.2
|
||||
wrangler:
|
||||
specifier: ^4.48.0
|
||||
version: 4.48.0(@cloudflare/workers-types@4.20251113.0)
|
||||
|
||||
packages:
|
||||
|
||||
@ -938,7 +944,7 @@ packages:
|
||||
'@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)
|
||||
worktop: 0.8.0-next.18
|
||||
wrangler: 4.48.0
|
||||
wrangler: 4.48.0(@cloudflare/workers-types@4.20251113.0)
|
||||
dev: true
|
||||
|
||||
/@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
|
||||
dev: true
|
||||
|
||||
/wrangler@4.48.0:
|
||||
/wrangler@4.48.0(@cloudflare/workers-types@4.20251113.0):
|
||||
resolution: {integrity: sha512-qkcwysx96XNDWXl4w/5VjAZjqWatxAq9chMXVeqv/etL9e06ouPaZ+Hwwbe5XYV2GYf/XhZVZ3fHJcTBrq60gQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
@ -1963,6 +1969,7 @@ packages:
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.4.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
|
||||
esbuild: 0.25.4
|
||||
miniflare: 4.20251109.1
|
||||
|
||||
46
scripts/patch-worker.js
Normal file
46
scripts/patch-worker.js
Normal 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
19
src/app.d.ts
vendored
@ -1,13 +1,18 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { DurableObjectNamespace } from '@cloudflare/workers-types';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
interface Platform {
|
||||
env: {
|
||||
COUNTER: DurableObjectNamespace;
|
||||
};
|
||||
context: {
|
||||
waitUntil(promise: Promise<any>): void;
|
||||
};
|
||||
caches: CacheStorage & { default: Cache };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
||||
115
src/durable-objects/counter.ts
Normal file
115
src/durable-objects/counter.ts
Normal 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
4
src/hooks.server.ts
Normal 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
181
src/lib/counter-do.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,295 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
|
||||
|
||||
23
src/routes/api/counter/+server.ts
Normal file
23
src/routes/api/counter/+server.ts
Normal 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);
|
||||
};
|
||||
|
||||
@ -3,10 +3,16 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
kit: { adapter: adapter() }
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
routes: {
|
||||
include: ['/*'],
|
||||
exclude: ['<all>']
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
}
|
||||
// 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
|
||||
|
||||
@ -3,5 +3,14 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
ssr: {
|
||||
external: ['cloudflare:workers'],
|
||||
noExternal: []
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['cloudflare:workers']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
25
wrangler.jsonc
Normal file
25
wrangler.jsonc
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user