프론트엔드 시스템 설계 핵심: 데이터 수집 SDK, RBAC 기반 권한 제어, 대용량 파일 처리 및 대시보드 화면 적응

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>

태그: vue3 RBAC WebSDK axios FileUpload

6월 21일 17:33에 게시됨