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