@vibexnpm/talkx 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +327 -0
- package/dist/index.d.ts +1771 -0
- package/dist/talkflow-sdk.esm.js +2 -0
- package/dist/talkflow-sdk.esm.js.map +1 -0
- package/dist/talkflow-sdk.standalone.js +2 -0
- package/dist/talkflow-sdk.standalone.js.map +1 -0
- package/dist/talkflow-sdk.umd.js +2 -0
- package/dist/talkflow-sdk.umd.js.map +1 -0
- package/package.json +51 -0
- package/src/TalkFlowClient.js +481 -0
- package/src/chat/ChatClient.js +2221 -0
- package/src/constants.js +411 -0
- package/src/core/ConnectionManager.js +517 -0
- package/src/index.js +97 -0
- package/src/push/PushManager.js +893 -0
- package/src/talkflow/delegates.js +112 -0
- package/src/talkflow/eventForwarding.js +93 -0
- package/src/talkflow/session.js +355 -0
- package/src/utils/ApiClient.js +305 -0
- package/src/utils/EventEmitter.js +113 -0
- package/src/utils/Logger.js +88 -0
- package/src/utils/jwtUtils.js +213 -0
- package/src/webrtc/MediaStreamManager.js +478 -0
- package/src/webrtc/PeerConnectionManager.js +467 -0
- package/src/webrtc/WebRTCClient.js +1041 -0
- package/types/index.d.ts +1771 -0
package/README.md
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# TalkFlow SDK
|
|
2
|
+
|
|
3
|
+
채팅 + WebRTC + 웹 푸시 통합 JavaScript SDK.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @vibexnpm/talkflow
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 빠른 시작
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
import TalkFlowClient from '@vibexnpm/talkflow';
|
|
15
|
+
|
|
16
|
+
// 1. SDK 초기화 (Client Key 사용)
|
|
17
|
+
const client = new TalkFlowClient({
|
|
18
|
+
apiKey: 'ck-your-client-key', // 브라우저 노출 안전
|
|
19
|
+
projectId: 'your-project-id'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// 2. 고객사 backend 에서 JWT 발급받아 연결
|
|
23
|
+
const jwtToken = await fetchJwtFromYourBackend();
|
|
24
|
+
await client.connect(jwtToken);
|
|
25
|
+
|
|
26
|
+
// 3. 채팅 사용
|
|
27
|
+
const detail = await client.enterRoom('room-id');
|
|
28
|
+
await client.sendTextMessage('room-id', '안녕하세요!');
|
|
29
|
+
|
|
30
|
+
// 4. 푸시 알림 — 사용자 버튼 클릭 안에서 활성화 권장
|
|
31
|
+
pushButton.addEventListener('click', async () => {
|
|
32
|
+
const result = await client.enablePushNotifications();
|
|
33
|
+
if (!result.ok && result.reason === 'PERMISSION_DENIED') {
|
|
34
|
+
showSettingsGuide();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> 처음 사용한다면 [docs/getting-started.md](docs/getting-started.md) 부터 읽어주세요. 설치 → 인증 → 첫 연결까지 안내합니다.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 권장 초기 설정 순서
|
|
44
|
+
|
|
45
|
+
고객사에서 가장 많이 헷갈리는 부분은 **JWT 발급**, **서버 연결**, **푸시 권한 요청**의 순서입니다. 초기 연동은 아래 순서를 권장합니다:
|
|
46
|
+
|
|
47
|
+
1. 고객사 backend 가 TalkFlow JWT 를 발급합니다.
|
|
48
|
+
2. 브라우저에서 `TalkFlowClient` 를 생성합니다.
|
|
49
|
+
3. `await client.connect(jwtToken)` 으로 연결합니다.
|
|
50
|
+
4. 연결이 끝난 뒤, **사용자 버튼 클릭**으로 `client.enablePushNotifications()` 를 호출합니다.
|
|
51
|
+
|
|
52
|
+
### 푸시 권한 팝업 주의사항
|
|
53
|
+
|
|
54
|
+
- `Notification.requestPermission()` 은 브라우저 정책상 **사용자 제스처 (클릭 / 탭)** 안에서 호출하는 것이 가장 안전합니다.
|
|
55
|
+
- Chrome 계열에서는 비동기 연결 작업이 끝난 뒤 자동으로 권한 요청을 띄우면, 팝업이 바로 안 뜨거나 새로고침 후에만 뜨는 것처럼 보일 수 있습니다.
|
|
56
|
+
- 그래서 초기 온보딩에서는 `connect(jwt, { enablePush: true })` 보다, **연결 완료 후 버튼 클릭으로 `enablePushNotifications()` 호출**을 권장합니다.
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
// ✅ 권장
|
|
60
|
+
await client.connect(jwtToken);
|
|
61
|
+
enablePushButton.onclick = () => client.enablePushNotifications();
|
|
62
|
+
|
|
63
|
+
// ⚠️ 초기 온보딩에서는 비권장
|
|
64
|
+
await client.connect(jwtToken, { enablePush: true });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 문서
|
|
70
|
+
|
|
71
|
+
| 문서 | 내용 |
|
|
72
|
+
|---|---|
|
|
73
|
+
| [getting-started.md](docs/getting-started.md) | **처음 사용한다면 여기부터.** 설치, API Key, 빠른 시작, 프로덕션 인증 플로우 |
|
|
74
|
+
| [connection.md](docs/connection.md) | 연결 관리, 설정 옵션, 재연결 정책, JWT 토큰 lifecycle, 환경 설정, 상태 확인 |
|
|
75
|
+
| [chat.md](docs/chat.md) | 채팅방 관리, 메시지 전송, 파일 업로드, 답글, 리액션, 핀, 타이핑, 채팅방 리스트 실시간 갱신, **AI 어시스턴트 페르소나 (멘션 응답)** |
|
|
76
|
+
| [push.md](docs/push.md) | **웹 푸시 알림.** 권한 lifecycle, 활성화 패턴 (자동 vs 사용자 액션), 디바이스 토글, 알림 클릭 라우팅 |
|
|
77
|
+
| [webrtc.md](docs/webrtc.md) | 1:1 / 그룹 통화, 미디어 제어, 디바이스 관리, TURN 서버 |
|
|
78
|
+
| [events.md](docs/events.md) | 모든 이벤트 레퍼런스. 채팅 이벤트 처리 패턴 (`eventType` 분기 vs 상태 필드 렌더) |
|
|
79
|
+
| [errors.md](docs/errors.md) | 에러 분류 (`PushErrorReason`, `ErrorTypes`), HTTP 상태 코드, 입력 제약 |
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## API 사용 패턴
|
|
84
|
+
|
|
85
|
+
TalkFlow SDK 는 **단일 `client` 인스턴스**로 모든 기능에 접근합니다. 메서드와 이벤트 모두 `client.xxx()` / `client.on(...)` 형태로 통일되어 있습니다.
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
// ✅ 권장 — 모든 메서드는 client 에 직접 호출
|
|
89
|
+
const detail = await client.enterRoom(roomId);
|
|
90
|
+
await client.sendMessage(roomId, request);
|
|
91
|
+
const inCall = client.isInCall();
|
|
92
|
+
|
|
93
|
+
// ✅ 권장 — 모든 이벤트는 client 에 직접 구독
|
|
94
|
+
client.on('chatMessage', handler);
|
|
95
|
+
client.on('messageRead', handler);
|
|
96
|
+
client.on('callStarted', handler);
|
|
97
|
+
client.on('pushNotification', handler);
|
|
98
|
+
client.on('pushFailed', handler);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
내부적으로 채팅 (ChatClient), WebRTC (WebRTCClient), 푸시 (PushManager) 서브 모듈로 구성되어 있지만 외부에서는 `client` 만 사용하면 됩니다.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## AI 어시스턴트
|
|
106
|
+
|
|
107
|
+
채팅방에 AI 어시스턴트를 추가하면 사용자 메시지에 자동으로 답변합니다.
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
// 1) 사용 가능한 어시스턴트 리스트 조회 → UI 에 표시
|
|
111
|
+
const assistants = await client.getAssistants();
|
|
112
|
+
// 각 항목: { id, displayName, mentionKey, role }
|
|
113
|
+
|
|
114
|
+
// 2) 사용자가 UI 에서 선택한 어시스턴트 id 들을 방 생성에 전달
|
|
115
|
+
const room = await client.createGroupRoom({
|
|
116
|
+
roomName: '신제품 기획방',
|
|
117
|
+
invitedUserIds: ['user-1', 'user-2'],
|
|
118
|
+
invitedAssistantPersonaIds: [/* 사용자가 선택한 id 배열 */],
|
|
119
|
+
assistantMode: 'GENERAL', // GENERAL | PEOPLE_ONLY | CALL_ONLY
|
|
120
|
+
roomAiType: 'PERSONA_MULTI' // 선택 — 생략 시 자동 파생. 'NONE' 이면 AI 없는 사람 전용 방 (docs/chat.md)
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 3) 채팅창에서 사용 — @멘션 또는 일반 메시지
|
|
124
|
+
// - @법률자문관 처럼 직접 멘션하면 해당 어시스턴트가 답변
|
|
125
|
+
// - 멘션 없이 일반 메시지를 보내면 메시지 내용에 가장 적합한 어시스턴트가 자동으로 답변 (없으면 침묵)
|
|
126
|
+
await client.sendTextMessage(room.data.id, '@법률자문관 계약서 검토 부탁');
|
|
127
|
+
await client.sendTextMessage(room.data.id, '신제품 출시 일정 어떻게 잡을지 의견 부탁드립니다');
|
|
128
|
+
|
|
129
|
+
// 방장 설정 변경: 사람만 / 호출 시만 모드
|
|
130
|
+
await client.updateGroupRoom(room.data.id, { assistantMode: 'CALL_ONLY' });
|
|
131
|
+
|
|
132
|
+
// 4) 어시스턴트 응답은 일반 chatMessage 이벤트로 수신 (senderType 으로 구분)
|
|
133
|
+
client.on('chatMessage', ({ message }) => {
|
|
134
|
+
if (message.senderType === 'ASSISTANT') {
|
|
135
|
+
renderAssistantMessage(message);
|
|
136
|
+
} else {
|
|
137
|
+
renderUserMessage(message);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 5) "AI 응답 준비 중" 표시 (SDK v2.2.x+) — 어시 응답이 5~13초 걸리므로 사용자가 무반응으로 느끼지 않도록
|
|
142
|
+
// typing 이벤트에 senderType 필드가 추가됨. 'ASSISTANT' 분기로 별도 UI 표시 권장.
|
|
143
|
+
client.on('typing', e => {
|
|
144
|
+
if (e.senderType === 'ASSISTANT') {
|
|
145
|
+
if (e.typing) showAssistantThinking(e.roomId); // 예: 메시지 영역에 "AI 응답 중..." 표시
|
|
146
|
+
else hideAssistantThinking(e.roomId);
|
|
147
|
+
} else {
|
|
148
|
+
// 기존 사용자 typing UI (입력 영역 등)
|
|
149
|
+
if (e.typing) showUserTyping(e.userName);
|
|
150
|
+
else hideUserTyping(e.userName);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**어시 typing 동작**:
|
|
156
|
+
- **서버가 `chat.assistant.typing.enabled=true` 인 경우에만** `typing=true` 1회 발행 (멘션 매칭 또는 자동 라우팅 결정 시점). 어시 메시지가 도착하면 SDK 가 즉시 `typing=false` 자동 emit
|
|
157
|
+
- 어시 응답 없이 30초 경과 시 SDK 가 자동으로 `typing=false` emit — UI 가 영구 "응답 중" 상태에 갇히지 않음
|
|
158
|
+
- 30초 timeout 조정: `client.chat._assistantTypingTimeoutMs = 45000`
|
|
159
|
+
|
|
160
|
+
**운영 활성화**: 본 기능은 서버측 feature flag (`chat.assistant.typing.enabled`) 에 의해 제어. default `false` (운영 안전 — 구버전 SDK 영구 표시 리스크 회피). dev/stg = `true`, prod 는 SDK v2.2.x+ 모든 고객사 롤아웃 확인 후 서버 운영자가 명시적 활성화.
|
|
161
|
+
|
|
162
|
+
**호환성**: 기존 `chat.on('typing', ...)` handler 가 `senderType` 무시해도 동작은 함. 다만 어시 typing 이 `userName="AI"` 로 사용자 typing 처럼 표시될 수 있으니 분기 권장.
|
|
163
|
+
|
|
164
|
+
운영 중 어시스턴트 추가 / 제거 (방장 권한):
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
// 어시스턴트 추가 — 회원 초대와 동일한 방식
|
|
168
|
+
await client.inviteToGroupRoom(roomId, {
|
|
169
|
+
assistantPersonaIds: [/* 어시스턴트 id 배열 */]
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// 어시스턴트 제거 — 회원 kick 과 동일한 방식
|
|
173
|
+
await client.kickMember(roomId, /* 어시스턴트 id */);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 어시스턴트 도구 — 평점 / 요약 / 번역
|
|
177
|
+
|
|
178
|
+
자동 응답 외에, UI 버튼으로 어시스턴트 도구를 직접 실행할 수 있습니다.
|
|
179
|
+
|
|
180
|
+
```javascript
|
|
181
|
+
import { SummarizeFormat } from '@vibexnpm/talkflow';
|
|
182
|
+
|
|
183
|
+
// 1) 평점 — 어시스턴트 메시지에 1~5점 (어시 메시지에만 노출 권장)
|
|
184
|
+
client.on('chatMessage', ({ message }) => {
|
|
185
|
+
if (message.senderType === 'ASSISTANT' && !message.deletedStatus) {
|
|
186
|
+
showRatingButtons(message); // 사용자가 별점 선택 시 ↓
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
await client.rateAssistantMessage(roomId, message.messageId, 5, '정확했어요'); // comment 선택
|
|
190
|
+
|
|
191
|
+
// 2) 요약 — 최근 대화를 지정 포맷으로 요약 (방에 어시 메시지로 broadcast)
|
|
192
|
+
const summary = await client.summarizeWithAssistant(roomId, {
|
|
193
|
+
personaId, // getAssistants() 의 id
|
|
194
|
+
format: SummarizeFormat.MINUTES, // MINUTES|SHORT|TIMELINE|ACTIONS|OPTIONS (기본 SHORT)
|
|
195
|
+
messageCount: 30 // 최근 N개 (1~50, 선택)
|
|
196
|
+
});
|
|
197
|
+
if (summary.outcome !== 'EMITTED') console.warn(summary.detail); // SKIPPED/FAILED 사유
|
|
198
|
+
|
|
199
|
+
// 3) 번역 — 특정 메시지를 목표 언어로 번역
|
|
200
|
+
const translated = await client.translateWithAssistant(roomId, {
|
|
201
|
+
personaId,
|
|
202
|
+
sourceMessageId: message.messageId,
|
|
203
|
+
targetLang: 'en' // 선택 (미지정 시 서버 정책)
|
|
204
|
+
});
|
|
205
|
+
// 결과 메시지는 일반 chatMessage 이벤트로도 수신됨 (outcome === 'EMITTED' 시 messageId 존재)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
요약/번역은 동기 호출이라 LLM 처리(수 초) 후 반환되며, 생성된 어시스턴트 메시지는 일반 `chatMessage` 이벤트로도 도착합니다.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## CDN 사용
|
|
213
|
+
|
|
214
|
+
```html
|
|
215
|
+
<!-- 최신 버전 -->
|
|
216
|
+
<script src="https://cdn.jsdelivr.net/npm/@vibexnpm/talkflow"></script>
|
|
217
|
+
|
|
218
|
+
<!-- 버전 지정 (운영 환경 권장) -->
|
|
219
|
+
<script src="https://cdn.jsdelivr.net/npm/@vibexnpm/talkflow@1.0.0"></script>
|
|
220
|
+
|
|
221
|
+
<script>
|
|
222
|
+
const client = new TalkFlowSDK.default({
|
|
223
|
+
apiKey: 'CLIENT_KEY',
|
|
224
|
+
projectId: 'your-project-id',
|
|
225
|
+
jwtToken: '<고객사 backend 에서 받은 JWT>'
|
|
226
|
+
});
|
|
227
|
+
</script>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
CDN 사용 시 웹 푸시 활성화는 [docs/push.md](docs/push.md#cdn-사용-시) 참고.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## TypeScript 지원
|
|
235
|
+
|
|
236
|
+
TypeScript 타입 정의는 `types/index.d.ts` 에 포함됩니다. `package.json` 의 `"types"` 필드가 이 파일을 가리키므로 별도 설정 없이 자동 지원됩니다.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
import TalkFlowClient, {
|
|
240
|
+
ConnectionState,
|
|
241
|
+
ChatMessageType,
|
|
242
|
+
ChatRoomType,
|
|
243
|
+
RoomListEventType,
|
|
244
|
+
LogLevel,
|
|
245
|
+
PushErrorCode,
|
|
246
|
+
type TalkFlowClientOptions,
|
|
247
|
+
type EnablePushResult,
|
|
248
|
+
type PushPermissionState,
|
|
249
|
+
type PushFailedPayload,
|
|
250
|
+
type ChatMessageReceivedPayload,
|
|
251
|
+
type RoomListUpdateEvent,
|
|
252
|
+
type PushNotificationPayload,
|
|
253
|
+
} from '@vibexnpm/talkflow';
|
|
254
|
+
|
|
255
|
+
const client = new TalkFlowClient({
|
|
256
|
+
apiKey: 'CLIENT_KEY',
|
|
257
|
+
projectId: 'project-id',
|
|
258
|
+
jwtToken: jwt,
|
|
259
|
+
env: 'production',
|
|
260
|
+
logLevel: LogLevel.WARN,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// 푸시 결과 처리
|
|
264
|
+
const result: EnablePushResult = await client.enablePushNotifications();
|
|
265
|
+
if (!result.ok && result.reason === PushErrorCode.PERMISSION_DENIED) {
|
|
266
|
+
showSettingsGuide();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 권한 상태 분기
|
|
270
|
+
const state: PushPermissionState = client.getPushPermissionState();
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### 서브클라이언트 프로퍼티는 nullable
|
|
274
|
+
|
|
275
|
+
`client.chat` / `client.webrtc` / `client.pushManager` 는 **JWT 토큰이 설정되기 전까지 `null`** 입니다.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const client = new TalkFlowClient({ apiKey, projectId });
|
|
279
|
+
// client.chat 은 이 시점에 null
|
|
280
|
+
await client.connect(jwt);
|
|
281
|
+
// 이제 client.chat 은 non-null
|
|
282
|
+
client.chat!.sendTextMessage('room-id', 'hi');
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
> 단, `client.getPushPermissionState()` 는 pushManager 가 null 이어도 호출 가능합니다 (브라우저 표준 API 만 조회).
|
|
286
|
+
|
|
287
|
+
### 제공되는 주요 타입
|
|
288
|
+
|
|
289
|
+
| 카테고리 | 타입/상수 |
|
|
290
|
+
|---|---|
|
|
291
|
+
| 상수 | `ConnectionState`, `ErrorTypes`, `SignalTypes`, `ChatMessageType`, `ChatRoomType`, `SenderType`, `PersonaRole`, `RoomAiType`, `EngagementIntensity`, `RoomListEventType`, `LogLevel`, `Environment`, `WebSocketPaths`, `DefaultConfig`, `Endpoints`, `PushErrorCode` |
|
|
292
|
+
| 도메인 | `ChatMessage`, `ChatRoomResponse`, `ParticipantResponse`, `LastMessageResponse`, `FileMetaData`, `ReactionResponse`, `ReplyToSnapshot`, `CursorPageResponse<T>`, `PushDeviceInfo`, `AssistantPersonaResponse`, `RoomAiMetaResponse` |
|
|
293
|
+
| 옵션 | `TalkFlowClientOptions`, `ConnectOptions`, `EnablePushOptions`, `CursorPageParams`, `CreateGroupRoomData`, `UpdateGroupRoomData`, `SendMessageData`, `SendMessageResult`, `FileUploadOptions`, `SendFileMessageOptions`, `DeleteType` |
|
|
294
|
+
| 이벤트 맵 | `TalkFlowClientEvents`, `ChatClientEvents`, `WebRTCClientEvents` |
|
|
295
|
+
| 이벤트 payload | `ChatMessageReceivedPayload`, `MemberChangePayload`, `MessageReadEventPayload`, `RetentionCleanupPayload`, `RoomListUpdateEvent`, `PushNotificationPayload`, `PushFailedPayload`, `TypingEvent`, `UploadProgressEvent`, `SendFileMessageProgressEvent` |
|
|
296
|
+
| 푸시 (신규) | `PushPermissionState`, `PushErrorReason`, `EnablePushResult`, `PushError` |
|
|
297
|
+
| 클래스 | `TalkFlowClient` (default), `ChatClient`, `WebRTCClient`, `PushManager`, `EventEmitter`, `Logger` |
|
|
298
|
+
|
|
299
|
+
상세 사용은 [docs/](docs/) 디렉토리의 각 문서 참고.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## 브라우저 지원
|
|
304
|
+
|
|
305
|
+
- Chrome 70+
|
|
306
|
+
- Firefox 65+
|
|
307
|
+
- Safari 13+
|
|
308
|
+
- Edge 79+
|
|
309
|
+
|
|
310
|
+
> Safari iOS 등 일부 환경은 웹 푸시 (Notification + Service Worker) 를 지원하지 않습니다. `client.getPushPermissionState() === 'unsupported'` 로 사전 분기 가능합니다.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## 리소스 정리
|
|
315
|
+
|
|
316
|
+
```javascript
|
|
317
|
+
await client.destroy();
|
|
318
|
+
// disconnect → 서브 클라이언트 destroy → 모든 리스너 제거
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
페이지 unmount 시 호출하여 메모리 누수 방지.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 라이선스
|
|
326
|
+
|
|
327
|
+
MIT
|