@vircle/sdk-web 0.2.0 → 0.2.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/dist/index.js CHANGED
@@ -422,6 +422,12 @@ class LocalStorageAdapter {
422
422
  getKey(key) {
423
423
  return `${this.prefix}${key}`;
424
424
  }
425
+ /**
426
+ * 전체 키 반환 (접두사 포함)
427
+ */
428
+ getFullKey(key) {
429
+ return this.getKey(key);
430
+ }
425
431
  /**
426
432
  * 값 저장
427
433
  *
@@ -497,15 +503,22 @@ class LocalStorageAdapter {
497
503
  if (data === null) {
498
504
  return null;
499
505
  }
500
- const parsed = JSON.parse(data);
501
- // 구조가 올바른지 확인 (이전 버전 호환성)
502
- if (parsed && typeof parsed === 'object' && 'value' in parsed && 'timestamp' in parsed) {
503
- return parsed.value;
506
+ try {
507
+ const parsed = JSON.parse(data);
508
+ // 구조가 올바른지 확인 (이전 버전 호환성)
509
+ if (parsed && typeof parsed === 'object' && 'value' in parsed && 'timestamp' in parsed) {
510
+ return parsed.value;
511
+ }
512
+ // 이전 버전 데이터 처리
513
+ return parsed;
514
+ }
515
+ catch (parseError) {
516
+ console.warn(`[Vircle] Failed to parse stored value for key "${key}":`, parseError);
517
+ return null;
504
518
  }
505
- // 이전 버전 데이터 처리
506
- return parsed;
507
519
  }
508
- catch {
520
+ catch (error) {
521
+ console.warn(`[Vircle] Failed to get value from localStorage for key "${key}":`, error);
509
522
  return null;
510
523
  }
511
524
  }
@@ -564,10 +577,17 @@ class LocalStorageAdapter {
564
577
  // localStorage의 모든 키를 한번에 가져와서 필터링 (성능 최적화)
565
578
  const allKeys = Object.keys(localStorage);
566
579
  const keysToRemove = allKeys.filter((key) => key.startsWith(this.prefix));
567
- keysToRemove.forEach((key) => localStorage.removeItem(key));
580
+ keysToRemove.forEach((key) => {
581
+ try {
582
+ localStorage.removeItem(key);
583
+ }
584
+ catch (error) {
585
+ console.warn(`[Vircle] Failed to remove key "${key}" during clear:`, error);
586
+ }
587
+ });
568
588
  }
569
- catch {
570
- // 무시
589
+ catch (error) {
590
+ console.warn('[Vircle] Failed to clear localStorage:', error);
571
591
  }
572
592
  }
573
593
  /**
@@ -715,271 +735,791 @@ class LocalStorageAdapter {
715
735
  }
716
736
 
717
737
  /**
718
- * 성능 측정 추적
738
+ * IndexedDB를 사용하는 비동기 스토리지 어댑터
739
+ *
740
+ * @description
741
+ * 브라우저의 IndexedDB API를 활용하여 대용량 데이터를 비동기적으로 저장합니다.
742
+ * LocalStorage와 달리 메인 스레드를 차단하지 않으며, 더 큰 용량을 지원합니다.
743
+ *
744
+ * @example
745
+ * ```typescript
746
+ * const storage = new IndexedDBAdapter('vircle_')
747
+ * await storage.set('user_id', '12345')
748
+ * const userId = await storage.get('user_id')
749
+ * ```
719
750
  */
720
- class WebPerformanceTracker {
721
- constructor() {
722
- this.marks = new Map();
751
+ class IndexedDBAdapter {
752
+ constructor(prefix = 'vircle_') {
753
+ this.db = null;
754
+ this.storeName = 'vircle_store';
755
+ this.dbVersion = 1;
756
+ this.initPromise = null;
757
+ this.isClosed = false;
758
+ this.prefix = prefix;
759
+ this.dbName = `${prefix}db`;
723
760
  }
724
761
  /**
725
- * 성능 추적 시작
762
+ * IndexedDB 초기화
763
+ *
764
+ * @description
765
+ * IndexedDB 연결을 초기화하고 스토어를 생성합니다.
766
+ * 여러 번 호출되어도 한 번만 초기화됩니다.
767
+ *
768
+ * @private
769
+ * @returns {Promise<void>}
726
770
  */
727
- startTracking() {
728
- if (!this.isSupported()) {
729
- return;
771
+ async init() {
772
+ // 명시적으로 닫힌 경우 에러 발생
773
+ if (this.isClosed) {
774
+ throw new VircleStorageError('IndexedDB adapter가 이미 닫혔습니다');
775
+ }
776
+ // 이미 초기화 중이면 기다림
777
+ if (this.initPromise) {
778
+ return this.initPromise;
730
779
  }
731
- // Long Task 감지
732
- this.observeLongTasks();
733
- // Layout Shift 감지
734
- this.observeLayoutShifts();
735
- // Largest Contentful Paint 감지
736
- this.observeLCP();
737
- // First Input Delay 감지
738
- this.observeFID();
780
+ // 이미 초기화됨
781
+ if (this.db) {
782
+ return Promise.resolve();
783
+ }
784
+ // 동시성 문제 방지를 위해 즉시 Promise 할당
785
+ this.initPromise = this.openDB();
786
+ try {
787
+ await this.initPromise;
788
+ }
789
+ catch (error) {
790
+ // 초기화 실패 시 Promise 초기화하여 재시도 가능하도록 함
791
+ this.initPromise = null;
792
+ throw error;
793
+ }
794
+ return this.initPromise;
739
795
  }
740
796
  /**
741
- * 성능 추적 중지
797
+ * IndexedDB 연결 열기
798
+ *
799
+ * @private
800
+ * @returns {Promise<void>}
742
801
  */
743
- stopTracking() {
744
- if (this.observer) {
745
- this.observer.disconnect();
746
- this.observer = undefined;
747
- }
748
- this.marks.clear();
802
+ async openDB() {
803
+ return new Promise((resolve, reject) => {
804
+ const request = indexedDB.open(this.dbName, this.dbVersion);
805
+ request.onerror = () => {
806
+ reject(new VircleStorageError('IndexedDB 열기 실패', {
807
+ error: request.error?.message,
808
+ }));
809
+ };
810
+ request.onsuccess = () => {
811
+ this.db = request.result;
812
+ resolve();
813
+ };
814
+ request.onupgradeneeded = (event) => {
815
+ const db = event.target.result;
816
+ // 스토어가 없으면 생성
817
+ if (!db.objectStoreNames.contains(this.storeName)) {
818
+ const store = db.createObjectStore(this.storeName, { keyPath: 'key' });
819
+ store.createIndex('timestamp', 'timestamp', { unique: false });
820
+ }
821
+ };
822
+ });
749
823
  }
750
824
  /**
751
- * Performance API 지원 여부 확인
825
+ * 키에 접두사 추가
826
+ *
827
+ * @private
828
+ * @param {string} key - 원본 키
829
+ * @returns {string} 접두사가 추가된 키
752
830
  */
753
- isSupported() {
754
- return (typeof PerformanceObserver !== 'undefined' &&
755
- typeof performance !== 'undefined' &&
756
- typeof performance.mark === 'function');
831
+ getKey(key) {
832
+ return `${this.prefix}${key}`;
757
833
  }
758
834
  /**
759
- * Long Task 감지
835
+ * 저장
836
+ *
837
+ * @description
838
+ * IndexedDB에 값을 비동기적으로 저장합니다.
839
+ * 메인 스레드를 차단하지 않아 성능이 향상됩니다.
840
+ *
841
+ * @template T - 저장할 값의 타입
842
+ * @param {string} key - 키
843
+ * @param {T} value - 저장할 값
844
+ * @returns {Promise<void>}
760
845
  */
761
- observeLongTasks() {
762
- if (!('PerformanceObserver' in window)) {
763
- return;
764
- }
765
- try {
766
- const observer = new PerformanceObserver((list) => {
767
- for (const entry of list.getEntries()) {
768
- if (entry.duration > 50) {
769
- // 50ms 이상
770
- console.warn('[Vircle] Long task detected:', {
771
- duration: entry.duration,
772
- startTime: entry.startTime,
773
- name: entry.name,
774
- });
846
+ async set(key, value) {
847
+ await this.init();
848
+ return new Promise((resolve, reject) => {
849
+ if (!this.db) {
850
+ reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
851
+ return;
852
+ }
853
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
854
+ const store = transaction.objectStore(this.storeName);
855
+ const item = {
856
+ key: this.getKey(key),
857
+ value,
858
+ timestamp: Date.now(),
859
+ };
860
+ const request = store.put(item);
861
+ request.onerror = () => {
862
+ reject(new VircleStorageError('IndexedDB 저장 실패', {
863
+ key,
864
+ error: request.error?.message,
865
+ }));
866
+ };
867
+ request.onsuccess = () => {
868
+ resolve();
869
+ };
870
+ });
871
+ }
872
+ /**
873
+ * 값 가져오기
874
+ *
875
+ * @description
876
+ * IndexedDB에서 값을 비동기적으로 가져옵니다.
877
+ *
878
+ * @template T - 반환할 값의 타입
879
+ * @param {string} key - 키
880
+ * @returns {Promise<T | null>} 저장된 값 또는 null
881
+ */
882
+ async get(key) {
883
+ await this.init();
884
+ return new Promise((resolve, reject) => {
885
+ if (!this.db) {
886
+ reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
887
+ return;
888
+ }
889
+ const transaction = this.db.transaction([this.storeName], 'readonly');
890
+ const store = transaction.objectStore(this.storeName);
891
+ const request = store.get(this.getKey(key));
892
+ request.onerror = () => {
893
+ reject(new VircleStorageError('IndexedDB 읽기 실패', {
894
+ key,
895
+ error: request.error?.message,
896
+ }));
897
+ };
898
+ request.onsuccess = () => {
899
+ const result = request.result;
900
+ if (result && 'value' in result) {
901
+ resolve(result.value);
902
+ }
903
+ else {
904
+ resolve(null);
905
+ }
906
+ };
907
+ });
908
+ }
909
+ /**
910
+ * 값 삭제
911
+ *
912
+ * @description
913
+ * IndexedDB에서 특정 키의 값을 삭제합니다.
914
+ *
915
+ * @param {string} key - 키
916
+ * @returns {Promise<boolean>} 삭제 성공 여부
917
+ */
918
+ async remove(key) {
919
+ await this.init();
920
+ return new Promise((resolve, reject) => {
921
+ if (!this.db) {
922
+ reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
923
+ return;
924
+ }
925
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
926
+ const store = transaction.objectStore(this.storeName);
927
+ const request = store.delete(this.getKey(key));
928
+ request.onerror = () => {
929
+ reject(new VircleStorageError('IndexedDB 삭제 실패', {
930
+ key,
931
+ error: request.error?.message,
932
+ }));
933
+ };
934
+ request.onsuccess = () => {
935
+ resolve(true);
936
+ };
937
+ });
938
+ }
939
+ /**
940
+ * 모든 값 삭제
941
+ *
942
+ * @description
943
+ * 현재 접두사로 시작하는 모든 키의 값을 삭제합니다.
944
+ *
945
+ * @returns {Promise<void>}
946
+ */
947
+ async clear() {
948
+ await this.init();
949
+ return new Promise((resolve, reject) => {
950
+ if (!this.db) {
951
+ reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
952
+ return;
953
+ }
954
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
955
+ const store = transaction.objectStore(this.storeName);
956
+ // 모든 키를 가져와서 prefix로 필터링
957
+ const request = store.openCursor();
958
+ const keysToDelete = [];
959
+ request.onsuccess = () => {
960
+ const cursor = request.result;
961
+ if (cursor) {
962
+ if (cursor.key.toString().startsWith(this.prefix)) {
963
+ keysToDelete.push(cursor.key);
775
964
  }
965
+ cursor.continue();
776
966
  }
777
- });
778
- observer.observe({ entryTypes: ['longtask'] });
779
- }
780
- catch {
781
- // Long Task API를 지원하지 않는 브라우저
782
- }
967
+ else {
968
+ // 모든 순회 완료, 삭제 시작
969
+ const deletePromises = keysToDelete.map(key => {
970
+ return new Promise((resolveDelete, rejectDelete) => {
971
+ const deleteRequest = store.delete(key);
972
+ deleteRequest.onsuccess = () => resolveDelete();
973
+ deleteRequest.onerror = () => rejectDelete(deleteRequest.error);
974
+ });
975
+ });
976
+ Promise.all(deletePromises)
977
+ .then(() => resolve())
978
+ .catch(error => reject(new VircleStorageError('일부 항목 삭제 실패', { error })));
979
+ }
980
+ };
981
+ request.onerror = () => {
982
+ reject(new VircleStorageError('IndexedDB 커서 열기 실패', {
983
+ error: request.error?.message,
984
+ }));
985
+ };
986
+ });
783
987
  }
784
988
  /**
785
- * Layout Shift 감지
989
+ * 존재 여부 확인
990
+ *
991
+ * @param {string} key - 키
992
+ * @returns {Promise<boolean>} 존재 여부
786
993
  */
787
- observeLayoutShifts() {
788
- if (!('PerformanceObserver' in window)) {
789
- return;
790
- }
791
- try {
792
- const observer = new PerformanceObserver((list) => {
793
- for (const entry of list.getEntries()) {
794
- const layoutShiftEntry = entry;
795
- if (!layoutShiftEntry.hadRecentInput) {
796
- // CLS would be tracked here: layoutShiftEntry.value
994
+ async has(key) {
995
+ const value = await this.get(key);
996
+ return value !== null;
997
+ }
998
+ /**
999
+ * 저장된 항목
1000
+ *
1001
+ * @description
1002
+ * 현재 접두사로 저장된 모든 항목의 개수를 반환합니다.
1003
+ *
1004
+ * @returns {Promise<number>} 항목 수
1005
+ */
1006
+ async size() {
1007
+ await this.init();
1008
+ return new Promise((resolve, reject) => {
1009
+ if (!this.db) {
1010
+ reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
1011
+ return;
1012
+ }
1013
+ const transaction = this.db.transaction([this.storeName], 'readonly');
1014
+ const store = transaction.objectStore(this.storeName);
1015
+ const request = store.openCursor();
1016
+ let count = 0;
1017
+ request.onsuccess = () => {
1018
+ const cursor = request.result;
1019
+ if (cursor) {
1020
+ if (cursor.key.toString().startsWith(this.prefix)) {
1021
+ count++;
797
1022
  }
1023
+ cursor.continue();
798
1024
  }
799
- });
800
- observer.observe({ entryTypes: ['layout-shift'] });
801
- }
802
- catch {
803
- // Layout Shift API를 지원하지 않는 브라우저
804
- }
1025
+ else {
1026
+ resolve(count);
1027
+ }
1028
+ };
1029
+ request.onerror = () => {
1030
+ reject(new VircleStorageError('IndexedDB 카운트 실패', {
1031
+ error: request.error?.message,
1032
+ }));
1033
+ };
1034
+ });
805
1035
  }
806
1036
  /**
807
- * Largest Contentful Paint 감지
1037
+ * 모든 반환
1038
+ *
1039
+ * @description
1040
+ * 현재 접두사로 저장된 모든 키를 배열로 반환합니다.
1041
+ *
1042
+ * @returns {Promise<string[]>} 키 배열
808
1043
  */
809
- observeLCP() {
810
- if (!('PerformanceObserver' in window)) {
811
- return;
812
- }
813
- try {
814
- const observer = new PerformanceObserver((list) => {
815
- const entries = list.getEntries();
816
- if (entries.length > 0) {
817
- // LCP tracked: entries[entries.length - 1].startTime
1044
+ async keys() {
1045
+ await this.init();
1046
+ return new Promise((resolve, reject) => {
1047
+ if (!this.db) {
1048
+ reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
1049
+ return;
1050
+ }
1051
+ const transaction = this.db.transaction([this.storeName], 'readonly');
1052
+ const store = transaction.objectStore(this.storeName);
1053
+ const request = store.openCursor();
1054
+ const keys = [];
1055
+ request.onsuccess = () => {
1056
+ const cursor = request.result;
1057
+ if (cursor) {
1058
+ const key = cursor.key.toString();
1059
+ if (key.startsWith(this.prefix)) {
1060
+ keys.push(key.substring(this.prefix.length));
1061
+ }
1062
+ cursor.continue();
818
1063
  }
819
- });
820
- observer.observe({ entryTypes: ['largest-contentful-paint'] });
821
- }
822
- catch {
823
- // LCP API를 지원하지 않는 브라우저
824
- }
1064
+ else {
1065
+ resolve(keys);
1066
+ }
1067
+ };
1068
+ request.onerror = () => {
1069
+ reject(new VircleStorageError('IndexedDB 키 조회 실패', {
1070
+ error: request.error?.message,
1071
+ }));
1072
+ };
1073
+ });
825
1074
  }
826
1075
  /**
827
- * First Input Delay 감지
1076
+ * 오래된 항목 정리 (LRU 방식)
1077
+ *
1078
+ * @description
1079
+ * 용량이 부족할 때 타임스탬프 기준으로 오래된 항목을 삭제합니다.
1080
+ *
1081
+ * @param {number} percentage - 삭제할 비율 (0-1)
1082
+ * @returns {Promise<void>}
828
1083
  */
829
- observeFID() {
830
- if (!('PerformanceObserver' in window)) {
831
- return;
832
- }
833
- try {
834
- const observer = new PerformanceObserver((list) => {
835
- for (const entry of list.getEntries()) {
836
- const fidEntry = entry;
837
- if (fidEntry.processingStart && fidEntry.startTime) {
838
- // FID tracked: fidEntry.processingStart - fidEntry.startTime
1084
+ async clearOldItems(percentage = 0.2) {
1085
+ await this.init();
1086
+ return new Promise((resolve, reject) => {
1087
+ if (!this.db) {
1088
+ reject(new VircleStorageError('IndexedDB가 초기화되지 않음'));
1089
+ return;
1090
+ }
1091
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
1092
+ const store = transaction.objectStore(this.storeName);
1093
+ const index = store.index('timestamp');
1094
+ const request = index.openCursor();
1095
+ const items = [];
1096
+ request.onsuccess = async () => {
1097
+ const cursor = request.result;
1098
+ if (cursor) {
1099
+ const primaryKey = cursor.primaryKey;
1100
+ if (primaryKey.startsWith(this.prefix)) {
1101
+ items.push({
1102
+ key: primaryKey,
1103
+ timestamp: cursor.value.timestamp,
1104
+ });
839
1105
  }
1106
+ cursor.continue();
840
1107
  }
841
- });
842
- observer.observe({ entryTypes: ['first-input'] });
843
- }
844
- catch {
845
- // FID API를 지원하지 않는 브라우저
846
- }
1108
+ else {
1109
+ // 타임스탬프로 정렬
1110
+ items.sort((a, b) => a.timestamp - b.timestamp);
1111
+ // 삭제할 개수 계산
1112
+ const deleteCount = Math.max(1, Math.floor(items.length * percentage));
1113
+ const keysToDelete = items.slice(0, deleteCount).map(item => item.key);
1114
+ // 삭제 실행
1115
+ const deletePromises = keysToDelete.map(key => {
1116
+ return new Promise((resolveDelete, rejectDelete) => {
1117
+ const deleteRequest = store.delete(key);
1118
+ deleteRequest.onsuccess = () => resolveDelete();
1119
+ deleteRequest.onerror = () => rejectDelete(deleteRequest.error);
1120
+ });
1121
+ });
1122
+ Promise.all(deletePromises)
1123
+ .then(() => resolve())
1124
+ .catch(error => reject(new VircleStorageError('일부 항목 삭제 실패', { error })));
1125
+ }
1126
+ };
1127
+ request.onerror = () => {
1128
+ reject(new VircleStorageError('IndexedDB 인덱스 조회 실패', {
1129
+ error: request.error?.message,
1130
+ }));
1131
+ };
1132
+ });
847
1133
  }
848
1134
  /**
849
- * 커스텀 성능 마크 시작
850
- * @param name - 마크 이름
1135
+ * 연결 종료
1136
+ *
1137
+ * @description
1138
+ * IndexedDB 연결을 명시적으로 종료합니다.
1139
+ *
1140
+ * @returns {Promise<void>}
851
1141
  */
852
- mark(name) {
853
- if (!this.isSupported()) {
854
- return;
1142
+ async close() {
1143
+ this.isClosed = true;
1144
+ if (this.db) {
1145
+ this.db.close();
1146
+ this.db = null;
855
1147
  }
856
- performance.mark(`vircle_${name}_start`);
857
- this.marks.set(name, performance.now());
1148
+ this.initPromise = null;
858
1149
  }
1150
+ }
1151
+
1152
+ /**
1153
+ * 스토리지 어댑터 팩토리
1154
+ *
1155
+ * @description
1156
+ * 환경과 요구사항에 맞는 최적의 스토리지 어댑터를 생성합니다.
1157
+ * IndexedDB를 우선 사용하고, 지원하지 않으면 LocalStorage로 폴백합니다.
1158
+ */
1159
+ class StorageFactory {
859
1160
  /**
860
- * 커스텀 성능 측정
861
- * @param name - 마크 이름
862
- * @returns 측정된 시간 (ms)
1161
+ * 스토리지 어댑터 생성 (동기)
1162
+ *
1163
+ * @description
1164
+ * 동기적으로 스토리지 어댑터를 생성합니다.
1165
+ * 'auto' 모드에서는 API 존재 여부만 확인하고 IndexedDB를 우선 사용합니다.
1166
+ * Safari Private 모드 등에서 실제 사용 불가 시 런타임 에러가 발생할 수 있습니다.
1167
+ *
1168
+ * @param {string} prefix - 키 접두사
1169
+ * @param {StorageType} type - 스토리지 타입
1170
+ * @returns {StorageAdapter} 생성된 스토리지 어댑터
863
1171
  */
864
- measure(name) {
865
- if (!this.isSupported() || !this.marks.has(name)) {
866
- return null;
1172
+ static create(prefix = 'vircle_', type = 'auto') {
1173
+ switch (type) {
1174
+ case 'localStorage':
1175
+ return new LocalStorageAdapter(prefix);
1176
+ case 'indexedDB':
1177
+ return new IndexedDBAdapter(prefix);
1178
+ case 'auto':
1179
+ default:
1180
+ return this.createAuto(prefix);
867
1181
  }
868
- const startTime = this.marks.get(name);
869
- const duration = performance.now() - startTime;
870
- performance.mark(`vircle_${name}_end`);
871
- performance.measure(`vircle_${name}`, `vircle_${name}_start`, `vircle_${name}_end`);
872
- this.marks.delete(name);
873
- return duration;
874
1182
  }
875
1183
  /**
876
- * 페이지 로드 메트릭 가져오기
1184
+ * 스토리지 어댑터 생성 (비동기, 권장)
1185
+ *
1186
+ * @description
1187
+ * 비동기적으로 스토리지 어댑터를 생성합니다.
1188
+ * 'auto' 모드에서는 실제 IndexedDB 사용 가능 여부를 테스트한 후 결정합니다.
1189
+ * Safari Private 모드 등에서도 안전하게 LocalStorage로 폴백됩니다.
1190
+ *
1191
+ * @param {string} prefix - 키 접두사
1192
+ * @param {StorageType} type - 스토리지 타입
1193
+ * @returns {Promise<StorageAdapter>} 생성된 스토리지 어댑터
877
1194
  */
878
- getLoadMetrics() {
879
- if (!performance || !performance.timing) {
880
- return {};
1195
+ static async createAsync(prefix = 'vircle_', type = 'auto') {
1196
+ switch (type) {
1197
+ case 'localStorage':
1198
+ return new LocalStorageAdapter(prefix);
1199
+ case 'indexedDB':
1200
+ return new IndexedDBAdapter(prefix);
1201
+ case 'auto':
1202
+ default:
1203
+ return this.createAutoAsync(prefix);
881
1204
  }
882
- const timing = performance.timing;
883
- const metrics = {};
884
- // DNS 조회 시간
885
- if (timing.domainLookupEnd && timing.domainLookupStart) {
886
- metrics.dns = timing.domainLookupEnd - timing.domainLookupStart;
887
- }
888
- // TCP 연결 시간
889
- if (timing.connectEnd && timing.connectStart) {
890
- metrics.tcp = timing.connectEnd - timing.connectStart;
1205
+ }
1206
+ /**
1207
+ * 자동으로 최적의 스토리지 선택 (비동기)
1208
+ *
1209
+ * @private
1210
+ * @param {string} prefix - 키 접두사
1211
+ * @returns {Promise<StorageAdapter>} 선택된 스토리지 어댑터
1212
+ */
1213
+ static async createAutoAsync(prefix) {
1214
+ // IndexedDB 실제 사용 가능 여부 확인 (비동기)
1215
+ const isIndexedDBAvailable = await this.isIndexedDBAvailableAsync();
1216
+ if (isIndexedDBAvailable) {
1217
+ return new IndexedDBAdapter(prefix);
891
1218
  }
892
- // 요청 시간
893
- if (timing.responseStart && timing.requestStart) {
894
- metrics.request = timing.responseStart - timing.requestStart;
1219
+ return new LocalStorageAdapter(prefix);
1220
+ }
1221
+ /**
1222
+ * 자동으로 최적의 스토리지 선택
1223
+ *
1224
+ * @private
1225
+ * @param {string} prefix - 키 접두사
1226
+ * @returns {StorageAdapter} 선택된 스토리지 어댑터
1227
+ */
1228
+ static createAuto(prefix) {
1229
+ // IndexedDB 지원 여부 확인
1230
+ if (this.isIndexedDBAvailable()) {
1231
+ try {
1232
+ const adapter = new IndexedDBAdapter(prefix);
1233
+ return adapter;
1234
+ }
1235
+ catch (error) {
1236
+ console.warn('[Vircle] IndexedDB 초기화 실패, LocalStorage로 폴백합니다:', error);
1237
+ }
895
1238
  }
896
- // 응답 시간
897
- if (timing.responseEnd && timing.responseStart) {
898
- metrics.response = timing.responseEnd - timing.responseStart;
1239
+ // LocalStorage로 폴백
1240
+ return new LocalStorageAdapter(prefix);
1241
+ }
1242
+ /**
1243
+ * IndexedDB 사용 가능 여부 확인 (동기)
1244
+ *
1245
+ * @description
1246
+ * API 존재 여부만 동기적으로 확인합니다.
1247
+ * Safari Private 모드 등에서의 실제 사용 가능 여부는 IndexedDBAdapter.init()에서 확인됩니다.
1248
+ *
1249
+ * @private
1250
+ * @returns {boolean} API 존재 여부
1251
+ */
1252
+ static isIndexedDBAvailable() {
1253
+ try {
1254
+ // 기본 API 존재 확인만 수행 (동기)
1255
+ // 실제 사용 가능 여부(Safari Private 모드 등)는
1256
+ // IndexedDBAdapter.init()에서 비동기로 확인됨
1257
+ return typeof window !== 'undefined' && !!window.indexedDB;
899
1258
  }
900
- // DOM 처리 시간
901
- if (timing.domComplete && timing.domLoading) {
902
- metrics.domProcessing = timing.domComplete - timing.domLoading;
1259
+ catch {
1260
+ return false;
903
1261
  }
904
- // 페이지 로드 완료 시간
905
- if (timing.loadEventEnd && timing.navigationStart) {
906
- metrics.pageLoad = timing.loadEventEnd - timing.navigationStart;
1262
+ }
1263
+ /**
1264
+ * IndexedDB 사용 가능 여부 확인 (비동기)
1265
+ *
1266
+ * @description
1267
+ * 실제로 IndexedDB를 열어서 사용 가능 여부를 확인합니다.
1268
+ * Safari Private 모드 등에서의 제한도 감지합니다.
1269
+ *
1270
+ * @returns {Promise<boolean>} 사용 가능 여부
1271
+ */
1272
+ static async isIndexedDBAvailableAsync() {
1273
+ if (typeof window === 'undefined' || !window.indexedDB) {
1274
+ return false;
907
1275
  }
908
- return metrics;
1276
+ return new Promise((resolve) => {
1277
+ try {
1278
+ const request = indexedDB.open('vircle_availability_test');
1279
+ request.onsuccess = () => {
1280
+ request.result.close();
1281
+ indexedDB.deleteDatabase('vircle_availability_test');
1282
+ resolve(true);
1283
+ };
1284
+ request.onerror = () => {
1285
+ resolve(false);
1286
+ };
1287
+ // Safari Private 모드에서 발생할 수 있는 blocked 이벤트
1288
+ request.onblocked = () => {
1289
+ resolve(false);
1290
+ };
1291
+ }
1292
+ catch {
1293
+ resolve(false);
1294
+ }
1295
+ });
909
1296
  }
910
1297
  /**
911
- * 리소스 타이밍 데이터 가져오기
1298
+ * LocalStorage 사용 가능 여부 확인
1299
+ *
1300
+ * @returns {boolean} 사용 가능 여부
912
1301
  */
913
- getResourceTimings() {
914
- if (!performance || !performance.getEntriesByType) {
915
- return [];
1302
+ static isLocalStorageAvailable() {
1303
+ try {
1304
+ const testKey = '__vircle_test__';
1305
+ localStorage.setItem(testKey, 'test');
1306
+ localStorage.removeItem(testKey);
1307
+ return true;
1308
+ }
1309
+ catch {
1310
+ return false;
916
1311
  }
917
- const resources = performance.getEntriesByType('resource');
918
- return resources.map((resource) => ({
919
- name: resource.name,
920
- type: resource.initiatorType,
921
- duration: resource.duration,
922
- size: resource.transferSize || 0,
923
- }));
924
1312
  }
925
1313
  }
1314
+
926
1315
  /**
927
- * 함수 실행 시간 측정 데코레이터
928
- */
929
- function measurePerformance(target, propertyKey, descriptor) {
930
- const originalMethod = descriptor.value;
931
- descriptor.value = async function (...args) {
932
- const start = performance.now();
933
- const result = await originalMethod.apply(this, args);
934
- performance.now() - start;
935
- return result;
936
- };
937
- return descriptor;
938
- }
939
- /**
940
- * 디바운스 유틸리티
941
- * @param func - 디바운스할 함수
942
- * @param wait - 대기 시간 (ms)
943
- * @returns 디바운스된 함수
1316
+ * Web Crypto API 기반 암호화 어댑터
1317
+ *
1318
+ * @description
1319
+ * 브라우저의 Web Crypto API를 사용하여 하이브리드 암호화(AES-256-GCM + RSA-OAEP)를 구현합니다.
1320
+ * 모든 암호화 작업은 브라우저의 네이티브 암호화 엔진을 사용하여 보안성과 성능을 보장합니다.
944
1321
  */
945
- function debounce(func, wait) {
946
- let timeoutId = null;
947
- return function debounced(...args) {
948
- if (timeoutId) {
949
- clearTimeout(timeoutId);
950
- }
951
- timeoutId = setTimeout(() => {
952
- func(...args);
953
- timeoutId = null;
954
- }, wait);
955
- };
956
- }
957
1322
  /**
958
- * 쓰로틀 유틸리티
959
- * @param func - 쓰로틀할 함수
960
- * @param limit - 제한 시간 (ms)
961
- * @returns 쓰로틀된 함수
1323
+ * Web Crypto API를 사용하는 ExtendedCryptoAdapter 구현
962
1324
  */
963
- function throttle(func, limit) {
964
- let inThrottle = false;
965
- let lastArgs = null;
966
- return function throttled(...args) {
967
- if (!inThrottle) {
968
- func(...args);
969
- inThrottle = true;
970
- setTimeout(() => {
971
- inThrottle = false;
972
- if (lastArgs) {
973
- const args = lastArgs;
974
- lastArgs = null;
975
- throttled(...args);
976
- }
977
- }, limit);
1325
+ class WebExtendedCryptoAdapter {
1326
+ constructor() {
1327
+ this.crypto = null;
1328
+ this.subtle = null;
1329
+ this.available = false;
1330
+ this.initCrypto();
1331
+ }
1332
+ /**
1333
+ * Web Crypto API 초기화
1334
+ */
1335
+ initCrypto() {
1336
+ try {
1337
+ if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
1338
+ this.crypto = window.crypto;
1339
+ this.subtle = window.crypto.subtle;
1340
+ this.available = true;
1341
+ }
1342
+ else if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle) {
1343
+ this.crypto = globalThis.crypto;
1344
+ this.subtle = globalThis.crypto.subtle;
1345
+ this.available = true;
1346
+ }
978
1347
  }
979
- else {
980
- lastArgs = args;
1348
+ catch (error) {
1349
+ console.warn('[Vircle] Web Crypto API를 사용할 수 없습니다:', error);
1350
+ this.available = false;
1351
+ }
1352
+ }
1353
+ /**
1354
+ * UUID v4 생성
1355
+ */
1356
+ generateUUID() {
1357
+ if (this.crypto && typeof this.crypto.randomUUID === 'function') {
1358
+ return this.crypto.randomUUID();
1359
+ }
1360
+ // Fallback: crypto.getRandomValues 사용
1361
+ if (this.crypto) {
1362
+ const bytes = new Uint8Array(16);
1363
+ this.crypto.getRandomValues(bytes);
1364
+ // Set version (4) and variant bits
1365
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1366
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1367
+ // Convert to UUID string format
1368
+ const hex = Array.from(bytes)
1369
+ .map((b) => b.toString(16).padStart(2, '0'))
1370
+ .join('');
1371
+ return [
1372
+ hex.substring(0, 8),
1373
+ hex.substring(8, 12),
1374
+ hex.substring(12, 16),
1375
+ hex.substring(16, 20),
1376
+ hex.substring(20, 32),
1377
+ ].join('-');
1378
+ }
1379
+ // Final fallback (less secure)
1380
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1381
+ const r = (Math.random() * 16) | 0;
1382
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
1383
+ return v.toString(16);
1384
+ });
1385
+ }
1386
+ /**
1387
+ * HMAC-SHA256 서명 생성
1388
+ */
1389
+ async createHmacSignature(payload, secret) {
1390
+ if (!this.subtle) {
1391
+ throw new Error('Web Crypto API is required for HMAC operations');
1392
+ }
1393
+ const encoder = new TextEncoder();
1394
+ const data = JSON.stringify(payload);
1395
+ // Import secret key
1396
+ const keyData = encoder.encode(secret);
1397
+ const key = await this.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
1398
+ // Sign data
1399
+ const signature = await this.subtle.sign('HMAC', key, encoder.encode(data));
1400
+ // Convert to hex string
1401
+ return this.arrayBufferToHex(signature);
1402
+ }
1403
+ /**
1404
+ * 하이브리드 암호화 (AES-256-GCM + RSA-OAEP)
1405
+ *
1406
+ * @description
1407
+ * 1. AES-256-GCM으로 데이터 암호화
1408
+ * 2. RSA-OAEP로 AES 키 암호화
1409
+ */
1410
+ async encryptPayload(data, publicKeyBase64) {
1411
+ if (!this.subtle || !this.crypto) {
1412
+ throw new Error('Web Crypto API is required for encryption operations');
1413
+ }
1414
+ let step = 'initialization';
1415
+ try {
1416
+ step = 'JSON serialization';
1417
+ const jsonStr = JSON.stringify(data);
1418
+ const encoder = new TextEncoder();
1419
+ const dataBytes = encoder.encode(jsonStr);
1420
+ // 1. Generate AES-256-GCM key
1421
+ step = 'AES key generation';
1422
+ const aesKey = await this.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
1423
+ // 2. Generate IV (96-bit for GCM)
1424
+ step = 'IV generation';
1425
+ const iv = this.crypto.getRandomValues(new Uint8Array(12));
1426
+ // 3. Encrypt data with AES-GCM
1427
+ step = 'AES-GCM encryption';
1428
+ const encryptedData = await this.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, aesKey, dataBytes);
1429
+ // AES-GCM returns ciphertext + authTag concatenated
1430
+ // authTag is the last 16 bytes (128 bits)
1431
+ const encryptedBytes = new Uint8Array(encryptedData);
1432
+ const ciphertext = encryptedBytes.slice(0, -16);
1433
+ const authTag = encryptedBytes.slice(-16);
1434
+ // 4. Export AES key as raw bytes
1435
+ step = 'AES key export';
1436
+ const rawAesKey = await this.subtle.exportKey('raw', aesKey);
1437
+ // 5. Import RSA public key
1438
+ step = 'RSA public key import';
1439
+ const rsaPublicKey = await this.importRsaPublicKey(publicKeyBase64);
1440
+ // 6. Encrypt AES key with RSA-OAEP
1441
+ step = 'RSA-OAEP encryption';
1442
+ const encryptedKey = await this.subtle.encrypt({ name: 'RSA-OAEP' }, rsaPublicKey, rawAesKey);
1443
+ // 7. Convert to base64 for transport
1444
+ step = 'Base64 encoding';
1445
+ return {
1446
+ data: this.arrayBufferToBase64(ciphertext),
1447
+ key: this.arrayBufferToBase64(encryptedKey),
1448
+ iv: this.arrayBufferToBase64(iv),
1449
+ authTag: this.arrayBufferToBase64(authTag),
1450
+ metadata: {
1451
+ algorithm: 'AES-256-GCM',
1452
+ keyAlgorithm: 'RSA-OAEP',
1453
+ timestamp: new Date().toISOString(),
1454
+ },
1455
+ };
1456
+ }
1457
+ catch (error) {
1458
+ const errorMessage = error instanceof Error ? error.message : String(error);
1459
+ throw new Error(`[WebExtendedCryptoAdapter] Encryption failed at step '${step}': ${errorMessage}`);
981
1460
  }
982
- };
1461
+ }
1462
+ /**
1463
+ * Web Crypto API 사용 가능 여부
1464
+ */
1465
+ isAvailable() {
1466
+ return this.available;
1467
+ }
1468
+ /**
1469
+ * RSA 공개키 가져오기 (PEM/Base64 → CryptoKey)
1470
+ */
1471
+ async importRsaPublicKey(publicKeyBase64) {
1472
+ if (!this.subtle) {
1473
+ throw new Error('Web Crypto API is not available');
1474
+ }
1475
+ // Clean PEM headers and whitespace
1476
+ const cleanedKey = publicKeyBase64
1477
+ .replace(/-----BEGIN PUBLIC KEY-----/g, '')
1478
+ .replace(/-----END PUBLIC KEY-----/g, '')
1479
+ .replace(/-----BEGIN RSA PUBLIC KEY-----/g, '')
1480
+ .replace(/-----END RSA PUBLIC KEY-----/g, '')
1481
+ .replace(/\s/g, '');
1482
+ // Decode base64 to ArrayBuffer
1483
+ const keyData = this.base64ToArrayBuffer(cleanedKey);
1484
+ // Import as SPKI format
1485
+ return await this.subtle.importKey('spki', keyData, {
1486
+ name: 'RSA-OAEP',
1487
+ hash: 'SHA-256',
1488
+ }, false, ['encrypt']);
1489
+ }
1490
+ /**
1491
+ * ArrayBuffer를 Base64 문자열로 변환
1492
+ */
1493
+ arrayBufferToBase64(buffer) {
1494
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
1495
+ let binary = '';
1496
+ const chunkSize = 0x8000; // 32KB chunks
1497
+ for (let i = 0; i < bytes.length; i += chunkSize) {
1498
+ const chunk = bytes.slice(i, Math.min(i + chunkSize, bytes.length));
1499
+ binary += String.fromCharCode.apply(null, Array.from(chunk));
1500
+ }
1501
+ return btoa(binary);
1502
+ }
1503
+ /**
1504
+ * Base64 문자열을 ArrayBuffer로 변환
1505
+ */
1506
+ base64ToArrayBuffer(base64) {
1507
+ const binary = atob(base64);
1508
+ const bytes = new Uint8Array(binary.length);
1509
+ for (let i = 0; i < binary.length; i++) {
1510
+ bytes[i] = binary.charCodeAt(i);
1511
+ }
1512
+ return bytes.buffer;
1513
+ }
1514
+ /**
1515
+ * ArrayBuffer를 Hex 문자열로 변환
1516
+ */
1517
+ arrayBufferToHex(buffer) {
1518
+ const bytes = new Uint8Array(buffer);
1519
+ return Array.from(bytes)
1520
+ .map((b) => b.toString(16).padStart(2, '0'))
1521
+ .join('');
1522
+ }
983
1523
  }
984
1524
 
985
1525
  /**
@@ -998,27 +1538,38 @@ function throttle(func, limit) {
998
1538
  */
999
1539
  class VircleWeb extends sdkCoreTs.VircleCore {
1000
1540
  constructor(config, options = {}) {
1001
- // 웹 환경용 스토리지 어댑터 설정
1002
- const storageAdapter = new LocalStorageAdapter(config.storagePrefix);
1541
+ // 웹 환경용 스토리지 어댑터 설정 - 성능 최적화를 위해 StorageFactory 사용
1542
+ const storageAdapter = StorageFactory.create(config.storagePrefix || 'vircle_', config.storageType || 'auto');
1543
+ // 암호화 어댑터 초기화
1544
+ let cryptoAdapter;
1545
+ if (config.enableEncryption || options.enableEncryption) {
1546
+ cryptoAdapter = new WebExtendedCryptoAdapter();
1547
+ if (cryptoAdapter.isAvailable()) ;
1548
+ else {
1549
+ console.warn('[Vircle] Web Crypto API를 사용할 수 없습니다. 암호화를 비활성화합니다.');
1550
+ cryptoAdapter = undefined;
1551
+ }
1552
+ }
1003
1553
  const coreOptions = {
1004
1554
  ...options,
1005
1555
  storageAdapter,
1556
+ cryptoAdapter,
1557
+ enableEncryption: cryptoAdapter?.isAvailable() ?? false,
1006
1558
  };
1007
1559
  super(config, coreOptions);
1008
1560
  this.pageViewTracked = false;
1561
+ this.lastActivityTime = Date.now();
1009
1562
  this.webConfig = config;
1010
1563
  this.webOptions = {
1011
1564
  flushOnUnload: true,
1012
1565
  contextCacheTime: 300000, // 5분
1013
1566
  idleScheduler: new sdkCoreTs.WebIdleScheduler(),
1014
- enablePerformance: true,
1015
1567
  ...options,
1016
1568
  };
1017
1569
  // 웹 환경 전용 컴포넌트 초기화
1018
1570
  this.contextCollector = new WebContextCollector();
1019
- if (this.webOptions.enablePerformance) {
1020
- this.performanceTracker = new WebPerformanceTracker();
1021
- }
1571
+ // 세션 타임아웃 설정 (기본 30분)
1572
+ this.sessionTimeout = config.sessionTimeout ?? 30 * 60 * 1000;
1022
1573
  }
1023
1574
  /**
1024
1575
  * SDK 초기화 및 자동 추적 설정
@@ -1054,16 +1605,22 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1054
1605
  await this.trackPageView();
1055
1606
  this.pageViewTracked = true;
1056
1607
  }
1057
- // 성능 메트릭 수집 시작
1058
- if (this.performanceTracker) {
1059
- this.performanceTracker.startTracking();
1060
- }
1061
1608
  }
1062
1609
  catch (error) {
1063
1610
  console.error('[Vircle] 초기화 실패:', error);
1064
1611
  throw error;
1065
1612
  }
1066
1613
  }
1614
+ /**
1615
+ * 사용자 식별 (컨텍스트 캐시 무효화 포함)
1616
+ *
1617
+ * @description
1618
+ * 사용자를 식별하고 컨텍스트 캐시를 무효화하여 이후 이벤트에 정확한 컨텍스트가 포함되도록 합니다.
1619
+ */
1620
+ async identify(userId, traits) {
1621
+ this.contextCache.invalidate();
1622
+ return super.identify(userId, traits);
1623
+ }
1067
1624
  /**
1068
1625
  * 페이지뷰 추적
1069
1626
  *
@@ -1095,10 +1652,11 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1095
1652
  }
1096
1653
  /**
1097
1654
  * 웹 환경에 최적화된 이벤트 추적
1098
- * AIDEV-NOTE: 이제 코어의 TaskScheduler를 사용하여 requestIdleCallback 기반의
1655
+ * 이제 코어의 TaskScheduler를 사용하여 requestIdleCallback 기반의
1099
1656
  * 일관된 성능 최적화 구현
1100
1657
  */
1101
1658
  async track(name, properties, context) {
1659
+ this.lastActivityTime = Date.now();
1102
1660
  return new Promise((resolve) => {
1103
1661
  this.taskScheduler.enqueue(async () => {
1104
1662
  try {
@@ -1127,7 +1685,7 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1127
1685
  * @private
1128
1686
  * @returns {Promise<EventContext>} 이벤트 컨텍스트
1129
1687
  *
1130
- * AIDEV-NOTE: 컨텍스트 수집은 DOM 접근이 많아 비용이 높으므로
1688
+ * 컨텍스트 수집은 DOM 접근이 많아 비용이 높으므로
1131
1689
  * 캐싱을 통해 성능을 최적화. 중복 수집 방지를 위한 Promise 재사용 구현
1132
1690
  */
1133
1691
  async getCachedContext() {
@@ -1143,29 +1701,22 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1143
1701
  * @private
1144
1702
  */
1145
1703
  setupAutoTracking() {
1146
- // 페이지 언로드 시 플러시
1704
+ // 페이지 언로드 시 sendBeacon으로 전송
1147
1705
  if (this.webOptions.flushOnUnload) {
1148
- this.unloadHandler = async () => {
1149
- try {
1150
- // 대기 중인 idle 작업들 먼저 처리
1151
- // Flush any pending tasks
1152
- await this.flush();
1153
- // 이벤트 플러시
1154
- await this.flush();
1155
- }
1156
- catch (error) {
1157
- console.warn('[Vircle] 언로드 시 플러시 실패:', error);
1158
- }
1706
+ this.unloadHandler = () => {
1707
+ this.transportService.flushViaBeacon();
1159
1708
  };
1160
1709
  window.addEventListener('beforeunload', this.unloadHandler);
1161
- // visibilitychange 이벤트도 처리 (모바일 브라우저 대응)
1162
- document.addEventListener('visibilitychange', () => {
1710
+ // visibilitychange 이벤트 처리 (sendBeacon + 세션 타임아웃)
1711
+ this.visibilityChangeHandler = () => {
1163
1712
  if (document.visibilityState === 'hidden') {
1164
- this.flush().catch((error) => {
1165
- console.warn('[Vircle] 백그라운드 전환 시 플러시 실패:', error);
1166
- });
1713
+ this.transportService.flushViaBeacon();
1167
1714
  }
1168
- });
1715
+ else if (document.visibilityState === 'visible') {
1716
+ this.checkSessionTimeout();
1717
+ }
1718
+ };
1719
+ document.addEventListener('visibilitychange', this.visibilityChangeHandler);
1169
1720
  }
1170
1721
  // 에러 추적
1171
1722
  if (this.webConfig.trackErrors) {
@@ -1275,16 +1826,23 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1275
1826
  * @private
1276
1827
  */
1277
1828
  setupSPATracking() {
1829
+ // 원래 함수 저장
1830
+ this.originalPushState = history.pushState;
1831
+ this.originalReplaceState = history.replaceState;
1278
1832
  // pushState/replaceState 래핑
1279
- const originalPushState = history.pushState;
1280
- const originalReplaceState = history.replaceState;
1281
1833
  history.pushState = (...args) => {
1282
- originalPushState.apply(history, args);
1283
- this.handleRouteChange();
1834
+ this.originalPushState.apply(history, args);
1835
+ try {
1836
+ this.handleRouteChange();
1837
+ }
1838
+ catch (_) { /* SDK 오류가 호스트 앱 라우팅에 영향 주지 않도록 */ }
1284
1839
  };
1285
1840
  history.replaceState = (...args) => {
1286
- originalReplaceState.apply(history, args);
1287
- this.handleRouteChange();
1841
+ this.originalReplaceState.apply(history, args);
1842
+ try {
1843
+ this.handleRouteChange();
1844
+ }
1845
+ catch (_) { /* SDK 오류가 호스트 앱 라우팅에 영향 주지 않도록 */ }
1288
1846
  };
1289
1847
  // popstate 이벤트 리스너
1290
1848
  this.popstateHandler = () => {
@@ -1311,6 +1869,34 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1311
1869
  });
1312
1870
  }
1313
1871
  }
1872
+ /**
1873
+ * 세션 타임아웃 체크
1874
+ *
1875
+ * @description
1876
+ * 마지막 활동 시간으로부터 세션 타임아웃이 경과했는지 확인합니다.
1877
+ * 만료 시 새 세션을 생성하고 session_start 이벤트를 추적합니다.
1878
+ *
1879
+ * @private
1880
+ */
1881
+ checkSessionTimeout() {
1882
+ const now = Date.now();
1883
+ const elapsed = now - this.lastActivityTime;
1884
+ if (elapsed >= this.sessionTimeout) {
1885
+ const newSessionId = `sess_${now}_${Math.random().toString(36).substr(2, 9)}`;
1886
+ this.setSessionId(newSessionId);
1887
+ this.lastActivityTime = now;
1888
+ this.contextCache.invalidate();
1889
+ this.track('session_start', {
1890
+ reason: 'timeout',
1891
+ previous_session_duration: elapsed,
1892
+ }).catch((error) => {
1893
+ console.warn('[Vircle] 세션 시작 추적 실패:', error);
1894
+ });
1895
+ }
1896
+ else {
1897
+ this.lastActivityTime = now;
1898
+ }
1899
+ }
1314
1900
  /**
1315
1901
  * 요소가 추적 대상인지 확인
1316
1902
  *
@@ -1372,8 +1958,8 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1372
1958
  this.track('element_data_collected', {
1373
1959
  ...properties,
1374
1960
  data_attributes: dataAttributes,
1375
- }).catch((error) => {
1376
- console.debug('[Vircle] Data attributes collection failed:', error);
1961
+ }).catch(() => {
1962
+ // data attributes 수집 실패는 무시
1377
1963
  });
1378
1964
  }
1379
1965
  });
@@ -1415,12 +2001,20 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1415
2001
  if (this.submitHandler) {
1416
2002
  document.removeEventListener('submit', this.submitHandler, true);
1417
2003
  }
2004
+ if (this.visibilityChangeHandler) {
2005
+ document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
2006
+ }
1418
2007
  if (this.popstateHandler) {
1419
2008
  window.removeEventListener('popstate', this.popstateHandler);
1420
2009
  }
1421
- // 성능 추적 정지
1422
- if (this.performanceTracker) {
1423
- this.performanceTracker.stopTracking();
2010
+ // History API 원본 복원
2011
+ if (this.originalPushState) {
2012
+ history.pushState = this.originalPushState;
2013
+ this.originalPushState = undefined;
2014
+ }
2015
+ if (this.originalReplaceState) {
2016
+ history.replaceState = this.originalReplaceState;
2017
+ this.originalReplaceState = undefined;
1424
2018
  }
1425
2019
  // Idle 작업 정리
1426
2020
  // Flush pending tasks
@@ -1472,7 +2066,9 @@ class VircleWeb extends sdkCoreTs.VircleCore {
1472
2066
  }
1473
2067
  }
1474
2068
 
2069
+ exports.IndexedDBAdapter = IndexedDBAdapter;
1475
2070
  exports.LocalStorageAdapter = LocalStorageAdapter;
2071
+ exports.StorageFactory = StorageFactory;
1476
2072
  exports.VircleBrowserCompatibilityError = VircleBrowserCompatibilityError;
1477
2073
  exports.VircleConfigError = VircleConfigError;
1478
2074
  exports.VircleInitializationError = VircleInitializationError;
@@ -1480,9 +2076,6 @@ exports.VircleStorageError = VircleStorageError;
1480
2076
  exports.VircleWeb = VircleWeb;
1481
2077
  exports.VircleWebError = VircleWebError;
1482
2078
  exports.WebContextCollector = WebContextCollector;
1483
- exports.WebPerformanceTracker = WebPerformanceTracker;
1484
- exports.debounce = debounce;
2079
+ exports.WebExtendedCryptoAdapter = WebExtendedCryptoAdapter;
1485
2080
  exports.default = VircleWeb;
1486
- exports.measurePerformance = measurePerformance;
1487
- exports.throttle = throttle;
1488
2081
  //# sourceMappingURL=index.js.map