Skip to content

TopologyChart

MSA (Microservices Architecture) 분석을 위한 Force 기반 노드-링크 다이어그램 컴포넌트입니다.

개요

TopologyChart는 마이크로서비스 간의 관계와 호출 패턴을 자유로운 형태의 네트워크 그래프로 시각화합니다. Force 시뮬레이션을 통해 자연스러운 노드 배치가 가능하며, 사용자가 직접 노드를 드래그하여 레이아웃을 조정할 수 있습니다.

주요 특징

  • 인터랙티브: 노드 드래그, 확대/축소, 팬 기능 지원
  • Force 시뮬레이션: 물리 기반 시뮬레이션으로 자연스러운 노드 배치
  • 자동 스케일: 데이터에 맞는 자동 스케일 조정
  • 노드 필터링: 고립된 노드 숨기기 기능
  • 시각적 피드백: 호버/클릭 시 연결된 노드/엣지 하이라이팅
  • 실시간 편집: 드래그로 노드 위치 조정 가능

Props Interface

typescript
interface TopologyChartProps {
    /** 토폴로지 데이터 */
    data: TopologyData;
    /** 차트 너비 (기본값: 800) */
    width?: number;
    /** 차트 높이 (기본값: 600) */
    height?: number;
    /** 연결점이 없는 고립된 노드 숨김 여부 (기본값: false) */
    hideIsolatedNodes?: boolean;
    /** Force 시뮬레이션 사용 여부 (기본값: false) */
    enableForceSimulation?: boolean;
    /** 엣지 애니메이션 활성화 여부 (기본값: true) */
    enableEdgeAnimation?: boolean;
    /** 노드 스케일 컨트롤 표시 여부 (기본값: true) */
    showScaleControls?: boolean;
    /** 자동 스케일 조정 활성화 여부 (기본값: true) */
    enableAutoScale?: boolean;
    /** 노드 라벨 숨김 여부 (기본값: false) */
    hideNodeLabels?: boolean;
}

Events

typescript
interface TopologyChartEmits {
    /** 노드 호버 시 발생 */
    'node-hover': [node: TopologyNode];
    /** 노드 클릭 시 발생 */
    'node-click': [node: TopologyNode];
    /** 엣지 호버 시 발생 */
    'edge-hover': [edge: TopologyEdge];
    /** 엣지 클릭 시 발생 */
    'edge-click': [edge: TopologyEdge];
    /** 호버 해제 시 발생 */
    'hover-out': [];
    /** 차트 렌더링 완료 시 발생 */
    'chart-ready': [];
}

기본 사용법

vue
<template>
    <TopologyChart
        :data="topologyData"
        :width="800"
        :height="600"
        :hide-isolated-nodes="false"
        :enable-force-simulation="false"
        :enable-auto-scale="true"
        @node-click="handleNodeClick"
        @edge-click="handleEdgeClick"
    />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { TopologyChart } from '@jennifersoft/apm-components';
import type {
    TopologyData,
    TopologyNode,
    TopologyEdge,
} from '@jennifersoft/apm-apis';

const topologyData = ref<TopologyData>(sampleTopologyData);

const handleNodeClick = (node: TopologyNode) => {
    console.log('Node clicked:', node);
};

const handleEdgeClick = (edge: TopologyEdge) => {
    console.log('Edge clicked:', edge);
};
</script>

인터랙티브 데모

토폴로지 통계

총 노드:8개
연결된 노드:6개
고립된 노드:2개
총 엣지:8개
정상 엣지:5개
실패 엣지:3개
TopologyChart 컴포넌트 로딩 중...
💡 사용법: 노드 드래그/클릭, 마우스 휠 확대/축소, 배경 드래그 이동, Force 시뮬레이션을 통한 물리 기반 자동 배치 등의 기능을 사용할 수 있습니다.

동적 크기 조절

vue
<template>
    <div class="chart-container">
        <!-- 크기 선택 컨트롤 -->
        <div class="size-controls">
            <label>차트 크기:</label>
            <select v-model="selectedSize" @change="updateChartSize">
                <option value="small">Small (600×400)</option>
                <option value="medium">Medium (800×600)</option>
                <option value="large">Large (1200×800)</option>
                <option value="custom">Custom</option>
            </select>

            <div v-if="selectedSize === 'custom'" class="custom-size">
                <input type="number" v-model="customWidth" placeholder="너비" />
                <span>×</span>
                <input
                    type="number"
                    v-model="customHeight"
                    placeholder="높이"
                />
            </div>
        </div>

        <TopologyChart
            :data="topologyData"
            :width="chartWidth"
            :height="chartHeight"
            v-bind="chartOptions"
        />
    </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';

const selectedSize = ref('medium');
const customWidth = ref(800);
const customHeight = ref(600);

const chartWidth = computed(() => {
    switch (selectedSize.value) {
        case 'small':
            return 600;
        case 'large':
            return 1200;
        case 'custom':
            return customWidth.value;
        default:
            return 800; // medium
    }
});

const chartHeight = computed(() => {
    switch (selectedSize.value) {
        case 'small':
            return 400;
        case 'large':
            return 800;
        case 'custom':
            return customHeight.value;
        default:
            return 600; // medium
    }
});
</script>

이벤트 로깅 시스템

사용자 인터랙션을 추적하고 디버깅에 활용할 수 있는 이벤트 로깅 시스템:

vue
<template>
    <div class="topology-with-logs">
        <TopologyChart
            :data="topologyData"
            @node-click="logNodeClick"
            @edge-click="logEdgeClick"
            @node-hover="logNodeHover"
            @edge-hover="logEdgeHover"
            @chart-ready="logChartReady"
        />

        <!-- 이벤트 로그 패널 -->
        <div v-if="events.length > 0" class="event-logs">
            <h4>이벤트 로그</h4>
            <div class="event-list">
                <div
                    v-for="event in recentEvents"
                    :key="event.id"
                    class="event-item"
                >
                    <span class="event-time">{{ event.time }}</span>
                    <span class="event-type" :class="event.type">{{
                        event.type
                    }}</span>
                    <span class="event-detail">{{ event.detail }}</span>
                </div>
            </div>
            <button @click="clearEvents" class="clear-button">
                로그 지우기
            </button>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';

interface EventLog {
    id: number;
    time: string;
    type: string;
    detail: string;
}

const events = ref<EventLog[]>([]);
let eventIdCounter = 0;

const recentEvents = computed(() => events.value.slice(-10).reverse());

const addEvent = (type: string, detail: string) => {
    events.value.push({
        id: ++eventIdCounter,
        time: new Date().toLocaleTimeString(),
        type,
        detail,
    });
};

const logNodeClick = (node: TopologyNode) => {
    addEvent('node-click', `${getNodeDisplayName(node)} 클릭`);
};

const logEdgeClick = (edge: TopologyEdge) => {
    const source = getNodeDisplayName(edge.relation.source);
    const target = getNodeDisplayName(edge.relation.target);
    addEvent('edge-click', `${source} → ${target} 엣지 클릭`);
};

const logNodeHover = (node: TopologyNode) => {
    addEvent('node-hover', `${getNodeDisplayName(node)} 호버`);
};

const logEdgeHover = (edge: TopologyEdge) => {
    const source = getNodeDisplayName(edge.relation.source);
    const target = getNodeDisplayName(edge.relation.target);
    addEvent('edge-hover', `${source} → ${target} 엣지 호버`);
};

const logChartReady = () => {
    addEvent('chart-ready', '차트 렌더링 완료');
};

const clearEvents = () => {
    events.value = [];
};

const getNodeDisplayName = (node: TopologyNode): string => {
    return node.props.shortName || node.props.longName || 'Unknown';
};
</script>

통계 정보 표시

토폴로지 데이터의 통계 정보를 실시간으로 계산하고 표시:

vue
<template>
    <div class="topology-with-stats">
        <TopologyChart :data="topologyData" />

        <!-- 통계 정보 패널 -->
        <div class="stats-panel">
            <h4>토폴로지 통계</h4>
            <div class="stats-grid">
                <div class="stat-item">
                    <span class="stat-label">총 노드 수:</span>
                    <span class="stat-value">{{ nodeStats.total }}개</span>
                </div>
                <div class="stat-item">
                    <span class="stat-label">연결된 노드:</span>
                    <span class="stat-value">{{ nodeStats.connected }}개</span>
                </div>
                <div class="stat-item">
                    <span class="stat-label">고립된 노드:</span>
                    <span class="stat-value">{{ nodeStats.isolated }}개</span>
                </div>
                <div class="stat-item">
                    <span class="stat-label">총 엣지 수:</span>
                    <span class="stat-value">{{ edgeStats.total }}개</span>
                </div>
                <div class="stat-item">
                    <span class="stat-label">정상 엣지:</span>
                    <span class="stat-value">{{ edgeStats.normal }}개</span>
                </div>
                <div class="stat-item">
                    <span class="stat-label">실패 엣지:</span>
                    <span class="stat-value error"
                        >{{ edgeStats.failed }}개</span
                    >
                </div>
                <div class="stat-item">
                    <span class="stat-label">총 실패 건수:</span>
                    <span class="stat-value error"
                        >{{ edgeStats.totalFailures }}건</span
                    >
                </div>
                <div class="stat-item">
                    <span class="stat-label">최고 실패 엣지:</span>
                    <span class="stat-value">{{
                        edgeStats.topFailureEdge
                    }}</span>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const nodeStats = computed(() => {
    const total = topologyData.value.nodes.length;
    const connectedNodeIds = new Set<string>();

    topologyData.value.edges.forEach((edge) => {
        connectedNodeIds.add(getNodeId(edge.relation.source));
        connectedNodeIds.add(getNodeId(edge.relation.target));
    });

    const connected = connectedNodeIds.size;
    const isolated = total - connected;

    return { total, connected, isolated };
});

const edgeStats = computed(() => {
    const total = topologyData.value.edges.length;
    const failed = topologyData.value.edges.filter(
        (edge) => (edge.statistic.failureCount || 0) > 0
    ).length;
    const normal = total - failed;

    const totalFailures = topologyData.value.edges.reduce(
        (sum, edge) => sum + (edge.statistic.failureCount || 0),
        0
    );

    // 최고 실패 엣지 찾기
    const topFailureEdge = topologyData.value.edges
        .filter((edge) => (edge.statistic.failureCount || 0) > 0)
        .sort(
            (a, b) =>
                (b.statistic.failureCount || 0) -
                (a.statistic.failureCount || 0)
        )[0];

    const topFailureEdgeName = topFailureEdge
        ? `${getNodeDisplayName(
              topFailureEdge.relation.source
          )} → ${getNodeDisplayName(topFailureEdge.relation.target)} (${
              topFailureEdge.statistic.failureCount
          }건)`
        : '없음';

    return {
        total,
        normal,
        failed,
        totalFailures,
        topFailureEdge: topFailureEdgeName,
    };
});
</script>

<style scoped>
.stats-panel {
    margin-top: 20px;
    padding: 16px;
    border: 1px solid #e1e5e9;
    border-radius: 8px;
    background: #fafbfc;
}

.stats-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 12px;
    margin-top: 12px;
}

.stat-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 8px 12px;
    background: white;
    border-radius: 4px;
    border: 1px solid #e5e7eb;
}

.stat-label {
    font-size: 14px;
    color: #6b7280;
}

.stat-value {
    font-weight: 600;
    color: #1f2937;
}

.stat-value.error {
    color: #dc2626;
}
</style>

## 데이터 구조 ### TopologyData ```typescript interface TopologyData { nodes:
TopologyNode[]; edges: TopologyEdge[]; }

TopologyNode

노드는 세 가지 타입으로 구분됩니다:

typescript
// 인스턴스 노드
interface TopologyInstanceNode {
    type: 'INSTANCE';
    props: {
        domainId: number;
        instId: number;
        oid: number;
        shortName: string;
        longName: string;
    };
}

// 도메인 노드
interface TopologyDomainNode {
    type: 'DOMAIN';
    props: {
        domainId: number;
        shortName: string;
        longName: string;
    };
}

// 원격 호출 노드
interface TopologyRemoteCallNode {
    type: 'REMOTE_CALL';
    props: {
        remoteCallType: RemoteCallTypeDef;
        languageTypeOrZero: number;
        customMethodDescHashOrZero: number;
        customMethodDescOrEmpty: string;
        ipAddressOrEmpty: string;
        portOrZero: number;
    };
}

type TopologyNode =
    | TopologyInstanceNode
    | TopologyDomainNode
    | TopologyRemoteCallNode;

TopologyEdge

typescript
interface TopologyEdge {
    relation: {
        source: TopologyNode;
        target: TopologyNode;
    };
    statistic: {
        count: number; // 호출 횟수
        timeSum: number; // 총 응답 시간 (ms)
        failureCount: number; // 실패 횟수
    };
}

샘플 데이터

자세한 샘플 데이터는 별도 파일에서 관리됩니다:

typescript
// j5-components/data/sample-topology-data.ts 에서 import
import {
    getCompleteTopologyData,
    sampleTopologyData,
} from './data/sample-topology-data';

// 완전한 샘플 데이터 사용
const topologyData = getCompleteTopologyData();

// 또는 기본 노드만 사용하고 엣지는 직접 생성
const basicData = sampleTopologyData;

샘플 데이터에는 다음 요소들이 포함되어 있습니다:

  • 8개 노드: 웹서버, API게이트웨이, 마이크로서비스들, 데이터베이스, 캐시, 고립된 서비스
  • 8개 엣지: 다양한 호출량과 에러 발생 시나리오
  • 실제적인 메트릭: count, timeSum, failureCount 값들
  • Force 시뮬레이션 테스트: 고립된 노드를 포함한 다양한 네트워크 구조

고급 기능

Force 시뮬레이션

enableForceSimulation을 활성화하면 D3.js의 force 시뮬레이션을 사용하여 노드들이 물리 법칙에 따라 자동으로 배치됩니다:

  • Link Force: 연결된 노드들을 적절한 거리에 배치
  • Charge Force: 노드들 간의 반발력으로 겹치지 않게 배치
  • Center Force: 노드들을 중앙으로 끌어당김
  • Collision Force: 노드들이 겹치지 않도록 충돌 검사

시각적 상태

  • 정상 노드: 초록색 배경, 연결된 상태
  • 에러 노드: 빨간색 배경, 실패한 엣지가 있는 경우
  • 고립 노드: 회색 배경, 연결이 없는 경우
  • 선택/호버: 관련된 노드와 엣지가 하이라이팅됨

스케일 컨트롤

  • 확대/축소: 마우스 휠 또는 버튼으로 차트 확대/축소
  • 자동 스케일: 데이터에 맞는 최적의 스케일 자동 계산
  • 뷰포트 이동: 배경 드래그로 차트 이동 가능

활용 사례

  1. 마이크로서비스 아키텍처 시각화: 서비스 간 의존성 파악
  2. 성능 병목 지점 탐지: 높은 응답 시간을 가진 연결 식별
  3. 장애 전파 경로 추적: 실패 카운트 기반 장애 영향 범위 파악
  4. 시스템 구조 분석: 복잡한 서비스 관계를 직관적으로 이해