1. 프론트엔드 데이터 수집(Analytics) SDK 아키텍처 설계
안정적이고 확장 가능한 프론트엔드 분석 SDK를 구축하기 위해서는 수집해야 할 핵심 지표와 데이터 전송 방식에 대한 깊은 고민이 필요합니다.
수집 대상 및 전략
- 페이지 뷰(PV) 및 세션: 라우터 변경 또는 페이지 로드시 자동으로 히스토리를 기록하여 트래픽 흐름을 파악합니다.
- 커스텀 인터랙션: 버튼 클릭, 폼 전송 등 특정 사용자 행동을 추적하기 위해
trackCustomEvent와 같은 공개 API를 제공합니다. - 웹 성능 지표:
PerformanceObserver를 활용하여 LCP, FCP, CLS 등 현대적인 웹 바이탈 및 로드 시간을 측정합니다. - 예외 및 오류 모니터링:
window.onerror를 통한 동기적 JS 오류와unhandledrejection이벤트를 통한 비동기(Promise) 예외를 포착합니다.
데이터 전송 최적화
메인 스레드 블로킹을 방지하고 페이지 이탈 시 데이터 유실을 막기 위해 navigator.sendBeacon을 최우선으로 사용합니다. 해당 API를 지원하지 않는 레거시 환경에서는 Image 객체의 src 속성을 변경하는方式为 폴백(Fallback) 처리합니다.
class AnalyticsTracker {
constructor(config) {
this.endpoint = config.endpoint;
this.appId = config.appId;
this._initErrorListeners();
this._initPerformanceObserver();
}
trackPageView(pageName) {
this._transmit({ type: 'pv', page: pageName, timestamp: Date.now() });
}
trackCustomEvent(action, properties = {}) {
this._transmit({ type: 'event', action, properties, timestamp: Date.now() });
}
_transmit(payload) {
const data = JSON.stringify({ appId: this.appId, ...payload });
if (navigator.sendBeacon) {
const blob = new Blob([data], { type: 'application/json' });
navigator.sendBeacon(this.endpoint, blob);
} else {
// Image 픽셀 폴백 방식
const img = new Image();
img.src = `${this.endpoint}?data=${encodeURIComponent(data)}`;
}
}
_initErrorListeners() {
window.addEventListener('error', (event) => {
this._transmit({ type: 'error', message: event.message, stack: event.error?.stack });
});
window.addEventListener('unhandledrejection', (event) => {
this._transmit({ type: 'promise_error', reason: String(event.reason) });
});
}
_initPerformanceObserver() {
if (typeof PerformanceObserver === 'undefined') return;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this._transmit({ type: 'perf', name: entry.name, duration: entry.duration });
}
});
observer.observe({ entryTypes: ['resource', 'paint'] });
}
}
2. 이커머스 관리자 시스템: RBAC 및 네트워크 최적화
RBAC 모델 기반 동적 라우팅 및 메뉴 생성
사용자-역할-권한(User-Role-Permission) 구조를 기반으로 접근을 제어합니다. 로그인, 404 페이지와 같은 정적 라우트를 제외한 비즈니스 라우트는 서버로부터 권한 데이터를 받아 동적으로 주입합니다.
// router/guards.js (Vue 3 & Vue Router 4 환경)
import { useUserStore } from '@/stores/user';
export function setupRouterGuards(router) {
router.beforeEach(async (to, from) => {
const userStore = useUserStore();
if (!userStore.accessToken) {
return { path: '/login', query: { redirect: to.fullPath } };
}
if (!userStore.isPermissionsLoaded) {
try {
const { roles, dynamicMenus } = await userStore.fetchUserInfo();
const asyncRoutes = generateRoutesFromMenus(dynamicMenus);
asyncRoutes.forEach(route => router.addRoute(route));
userStore.setPermissionsLoaded(true);
return { ...to, replace: true };
} catch (error) {
userStore.logout();
return '/login';
}
}
});
}
function generateRoutesFromMenus(menus) {
return menus.map(menu => {
const route = {
path: menu.path,
name: menu.name,
component: () => import(`@/views/${menu.componentPath}.vue`),
meta: menu.meta
};
if (menu.children?.length) {
route.children = generateRoutesFromMenus(menu.children);
}
return route;
});
}
버튼 레벨 권한 제어 (커스텀 디렉티브)
템플릿 내에서 v-if를 남발하는 것을 방지하고 재사용성을 높이기 위해 Vue 3 커스텀 디렉티브를 활용하여 DOM 레벨에서 요소를 제거하거나 숨깁니다.
// directives/permission.js
import { useUserStore } from '@/stores/user';
export const vPermission = {
mounted(el, binding) {
const userStore = useUserStore();
const requiredRoles = binding.value;
if (!Array.isArray(requiredRoles) || requiredRoles.length === 0) {
throw new Error('v-permission requires an array of roles.');
}
const hasAccess = requiredRoles.some(role => userStore.roles.includes(role));
if (!hasAccess) {
el.parentNode?.removeChild(el);
}
}
};
// main.js 등록
// app.directive('permission', vPermission);
Axios 인터셉터 및 토큰 자동 갱신 큐
액세스 토큰 만료(401) 시 다수의 동시 요청이 발생하더라도 토큰 갱신 API는 한 번만 호출하고, 나머지 요청은 큐에 담아두었다가 갱신 완료 후 재시도하도록 설계합니다.
import axios from 'axios';
import { useUserStore } from '@/stores/user';
import router from '@/router';
const httpClient = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 30000 });
let isTokenRefreshing = false;
let retryQueue = [];
httpClient.interceptors.response.use(
response => response.data,
async error => {
const { config, response } = error;
if (response?.status === 401 && !config._isRetry) {
if (!isTokenRefreshing) {
isTokenRefreshing = true;
try {
const userStore = useUserStore();
const newToken = await userStore.refreshAccessToken();
retryQueue.forEach(cb => cb(newToken));
retryQueue = [];
return retryRequest(config, newToken);
} catch (refreshError) {
useUserStore().logout();
router.push('/login');
return Promise.reject(refreshError);
} finally {
isTokenRefreshing = false;
}
}
return new Promise(resolve => {
retryQueue.push((token) => resolve(retryRequest(config, token)));
});
}
return Promise.reject(error);
}
);
function retryRequest(config, token) {
config.headers.Authorization = `Bearer ${token}`;
config._isRetry = true;
return httpClient(config);
}
대용량 파일 처리: 청크 분할, 해시 추출 및 동시성 제어
대용량 파일 업로드 시 네트워크 끊김에 대비한 단편화(Chunking)와断点续传(Resume from breakpoint)을 구현합니다. SparkMD5 대신 네이티브 Web Crypto API를 사용하여 파일 해시를 추출하고, 커스텀 동시성 풀을 통해 네트워크 대역폭을 제어합니다.
class ChunkUploader {
constructor(file, options = {}) {
this.file = file;
this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 5MB
this.concurrency = options.concurrency || 3;
}
async computeFileHash() {
const buffer = await this.file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
async startUpload(progressCallback) {
const fileHash = await this.computeFileHash();
const totalChunks = Math.ceil(this.file.size / this.chunkSize);
// 서버에 이미 업로드된 청크 확인 (秒传 및 단속 전송 로직)
const uploadedChunks = await this.checkUploadedChunks(fileHash);
const tasks = this._buildUploadTasks(fileHash, totalChunks, uploadedChunks);
await this._executeWithConcurrency(tasks, progressCallback);
}
_buildUploadTasks(fileHash, totalChunks, uploadedChunks) {
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
if (uploadedChunks.includes(i)) continue;
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunkBlob = this.file.slice(start, end);
tasks.push(() => this._uploadChunk(chunkBlob, fileHash, i));
}
return tasks;
}
async _uploadChunk(chunkBlob, fileHash, index) {
const formData = new FormData();
formData.append('fileChunk', chunkBlob);
formData.append('hash', fileHash);
formData.append('chunkIndex', index);
await httpClient.post('/upload/chunk', formData);
}
async _executeWithConcurrency(tasks, progressCallback) {
let completed = 0;
const executing = new Set();
for (const task of tasks) {
const p = task().then(() => {
executing.delete(p);
completed++;
progressCallback?.(completed / tasks.length);
});
executing.add(p);
if (executing.size >= this.concurrency) {
await Promise.race(executing);
}
}
await Promise.all(executing);
}
async checkUploadedChunks(hash) {
const { data } = await httpClient.get(`/upload/status?hash=${hash}`);
return data.uploadedIndexes || [];
}
}
3. 데이터 시각화 대시보드: 반응형 화면 적응 전략
데이터 시각화大屏(대형 스크린) 프로젝트는 다양한 해상도의 모니터에 배포됩니다. CSS transform: scale을 활용하여 디자인 시안(예: 1920x1080, 16:9)의 비율을 유지하면서 화면을 확장하거나 축소합니다.
화면 비율에 따른 스케일링 로직
- 실제 화면 비율 > 디자인 비율 (가로가 넓은 경우): 세로 길이를 기준으로 스케일을 조정하고, 좌우 여백(Margin)을 자동으로 계산하여 중앙 정렬합니다.
- 실제 화면 비율 < 디자인 비율 (세로가 긴 경우): 가로 길이를 기준으로 스케일을 조정하고, 상하 여백을 계산합니다.
window.resize 이벤트 대신 ResizeObserver를 사용하여 DOM 요소의 크기 변화를 더 정밀하고 성능 친화적으로 감지합니다.
<template>
<div class="dashboard-viewport">
<div ref="scaleContainer" class="dashboard-canvas">
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const scaleContainer = ref(null);
let observer = null;
const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;
const DESIGN_RATIO = DESIGN_WIDTH / DESIGN_HEIGHT;
function applyScaling() {
if (!scaleContainer.value) return;
const { clientWidth: viewW, clientHeight: viewH } = document.documentElement;
const viewRatio = viewW / viewH;
let scale, offsetX, offsetY;
if (viewRatio > DESIGN_RATIO) {
// 가로가 더 넓음 -> 세로 기준 스케일
scale = viewH / DESIGN_HEIGHT;
offsetX = (viewW - DESIGN_WIDTH * scale) / 2;
offsetY = 0;
} else {
// 세로가 더 김 -> 가로 기준 스케일
scale = viewW / DESIGN_WIDTH;
offsetX = 0;
offsetY = (viewH - DESIGN_HEIGHT * scale) / 2;
}
Object.assign(scaleContainer.value.style, {
transform: `scale(${scale})`,
transformOrigin: 'top left',
marginLeft: `${offsetX}px`,
marginTop: `${offsetY}px`
});
}
onMounted(() => {
applyScaling();
observer = new ResizeObserver(applyScaling);
observer.observe(document.documentElement);
});
onBeforeUnmount(() => {
observer?.disconnect();
});
</script>
<style scoped>
.dashboard-viewport {
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #050a15;
}
.dashboard-canvas {
width: 1920px;
height: 1080px;
position: relative;
}
</style>