다차원 검색을 수행하는 데이터베이스 (4) SvelteKit 프론트엔드

Pt J·2026년 5월 27일
post-thumbnail

다차원 검색을 수행하는 데이터베이스 (4) SvelteKit 프론트엔드

이번 실습은 백엔드로 끝내지 않고 프론트엔드 부분도 살펴 보겠다.

설정 파일

프로젝트 루트에서 프론트엔드 프로젝트를 초기화한다.

~/workspace/emotion-dict$ npx sv create frontend
~/workspace/emotion-dict$ # Which template would you like?
~/workspace/emotion-dict$ # -> SvelteKit minimal 선택
~/workspace/emotion-dict$ # Add type checking with TypeScript?
~/workspace/emotion-dict$ # -> Yes, using TypeScript syntax 선택
~/workspace/emotion-dict$ # What would you like to add to your project?
~/workspace/emotion-dict$ # -> 필요에 따라 선호하는 도구 방향키, 스페이스바로 선택 후 엔터
~/workspace/emotion-dict$ # -> vitest 사용 시 unit, component 모두 포함하는 게 좋다
~/workspace/emotion-dict$ # Which package manager do you want to install dependencies with?
~/workspace/emotion-dict$ # -> pnpm 선택

pnpm이 설치되어 있지 않을 경우 설치 먼저 하고 선택한다.

선택하기 전에 새 터미널을 열고 다음과 같이 설치 및 확인

~$ brew install pnpm
~$ pnpm --version
🤖 AI AGENT | SvelteKit 초기화 필수 추천 도구
  • Prettier (코드 포맷터)
    추천 이유: 협업과 유지보수의 제1원칙은 '일관된 코드 스타일'입니다. Prettier를 적용하면 탭 간격, 따옴표 스타일 등을 신경 쓸 필요 없이 저장할 때마다 코드가 깔끔하게 정렬됩니다. 특히 Svelte 파일(.svelte) 내부의 HTML, CSS, TS 혼합 구조를 가장 완벽하게 포맷팅해 주는 표준 도구입니다.
  • ESLint (정적 분석 도구)
    추천 이유: TypeScript가 타입(Type)을 검증한다면, ESLint는 코드의 논리적 오류나 안티 패턴(예: 사용하지 않는 변수, 무한 루프 위험이 있는 코드 등)을 런타임 이전에 잡아냅니다. 실무 환경에서는 ESLint 통과 없이는 CI/CD 파이프라인에서 빌드가 거부되도록 설정하는 것이 기본입니다.
  • Vitest (유닛/통합 테스트 프레임워크)
    추천 이유: SvelteKit은 내부적으로 Vite를 번들러로 사용합니다. Vitest는 이 Vite 생태계와 100% 호환되는 가장 현대적이고 빠른 테스트 도구입니다. 향후 백엔드에서 받아온 계층형(ltree) 데이터를 Vis.js가 읽을 수 있는 노드(Node)와 엣지(Edge) 형태로 변환하는 파싱 로직을 작성할 텐데, 이 로직이 정확히 동작하는지 검증하는 데 필수적입니다.
  • Playwright (E2E 테스트 도구) - 선택 사항이나 강력 추천
    추천 이유: 사용자가 브라우저를 열고 감정 노드를 클릭했을 때 화면이 정상적으로 전환되는지 등, 실제 브라우저 환경에서의 시나리오를 자동화하여 테스트합니다. 포트폴리오를 구성할 때 Playwright 테스트 코드가 포함되어 있으면 "이 지원자는 사용자 관점의 품질 보증(QA)까지 고려할 줄 아는구나"라는 강력한 인상을 줄 수 있습니다.

💡 요약 가이드: 프롬프트에서 스페이스바로 Prettier, ESLint, Vitest (그리고 여력이 되신다면 Playwright까지) 선택하신 후 엔터를 눌러 설치를 마무리해 주세요.

🤖 AI AGENT | pnpm을 추천하는 이유
  • 압도적인 설치 속도와 디스크 용량 절약:
    npm 은 프로젝트마다 node_modules 폴더에 패키지를 중복해서 다운로드합니다. 반면 pnpm 은 글로벌 저장소(Store)에 단 한 번만 패키지를 저장하고, 각 프로젝트에는 하드 링크(Hard Link)를 생성하여 연결합니다. M4 Pro의 빠른 디스크 I/O와 결합되면 패키지 설치 속도가 비약적으로 상승하며, 용량 낭비를 원천 차단합니다.
  • 엄격한 의존성 관리 (유령 의존성 방지):
    npm 은 의존성 트리를 평탄화(Hoisting)하여, package.json 에 명시하지 않은 하위 패키지에도 코드에서 접근할 수 있는 치명적인 설계 결함(유령 의존성)이 있습니다. pnpm 은 이를 엄격하게 격리하여, 명시되지 않은 패키지 사용 시 컴파일 타임에 에러를 발생시킵니다. 이는 실무에서 '내 PC에서는 되는데 서버에서는 안 도는' 문제를 완벽히 방지합니다.
  • 안정성 (bun과의 비교):
    속도만 본다면 최근 떠오르는 bun 도 훌륭하지만, SvelteKit의 특정 플러그인이나 복잡한 SSR 환경에서는 아직 엣지 케이스 버그가 보고되곤 합니다. 안정성을 타협하지 않는다는 우리의 원칙에 따라, 성능과 생태계 안정성이 완벽히 검증된 pnpm 이 최적의 선택입니다.

초기화가 끝나면 frontend 디렉토리로 이동하여
물리 엔진 시각화 라이브러리인 Vis.js를 설치한다.

~/workspace/emotion-dict$ cd frontend
~/workspace/emotion-dict/frontend$ pnpm add vis-network vis-data

데이터 변환 코드

웹 브라우저는 gRPC를 사용하여 통신할 수 없으므로 통신은 REST/JSON으로 하되,
타입을 지정할 때 *.proto 파일을 읽어 TypeScript 인터페이스를 자동 생성하도록 한다.

이를 위해 먼저 개발 의존성을 추가한다.

~/workspace/emotion-dict/frontend$ pnpm add -D ts-proto

그리고 *.proto 파일이 변경될 때마다 긴 명령어를 입력하기 번거로우니
frontend/package.json 파일의 "scripts"
다음 내용을 추가하여 단축 명령어를 생성한다.

"generate:proto": "mkdir -p src/lib/generated && protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./src/lib/generated --ts_proto_opt=outputServices=false,outputEncodeMethods=false,outputJsonMethods=false,esModuleInterop=true -I../proto ../proto/emotion_search.proto"

다음 명령어를 사용하면 *.proto 파일로부터 인터페이스를 불러와
frontend/src/lib/generated/emotion_search.ts 에 저장한다.

~/workspace/emotion-dict/frontend$ pnpm run generate:proto

lib/api.ts

Python 게이트웨이와 통신하여 데이터를 가져오는 모듈을 작성하기에 앞서
API 베이스 주소를 담은 .env 파일을 생성한다.
일반적으로 프론트엔드와 백엔드는 배포 환경이 다르기 때문에
프론트엔드 전용 .env 를 따로 관리하는 것이 좋다.

frontend/.env

PUBLIC_API_BASE_URL=http://127.0.0.1:8000/api/v1/search

frontend/src/lib/api.ts

import type { Emotion } from './generated/emotion_search';
import { PUBLIC_API_BASE_URL } from '$env/static/public';

export interface SearchResponse {
    emotions: Emotion[];
}

/**
 * 백엔드의 SnakeCase JSON 응답을 프론트엔드의 CamelCase 타입으로 변환
 */
function mapEmotion(data: any): Emotion {
    return {
        ...data,
        taxonomyPath: data.taxonomy_path,
        vaVector: data.va_vector
    } as Emotion;
}


/**
 * 2차원 벡터(VA) 기반 K-NN 검색을 수행
 * @param v 정서가 (Valence, -1.0 ~ 1.0)
 * @param a 각성가 (Arousal, -1.0 ~ 1.0)
 * @param limit 반환할 결과 수
 */
export async function searchByVector(v: number, a: number, limit: number = 500): Promise<Emotion[]> {
    const response = await fetch(`${PUBLIC_API_BASE_URL}/vector`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ target_vector: [v, a], limit })
    });

    if (!response.ok) 
        throw new Error('벡터 검색 중 오류가 발생했습니다.');

    const data: SearchResponse = await response.json();
    return data.emotions.map(mapEmotion);
}

/**
 * 계층 분류(ltree) 기반 검색을 수행합니다.
 * @param pathQuery ltree 쿼리 문자열 (예: "negative.low_arousal.*")
 */
export async function searchByTaxonomy(pathQuery: string, limit: number = 500): Promise<Emotion[]> {
    const response = await fetch(`${PUBLIC_API_BASE_URL}/taxonomy`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ path_query: pathQuery, limit })
    });

    if (!response.ok)
        throw new Error('분류 검색 중 오류가 발생했습니다.');

    const data: SearchResponse = await response.json();
    return data.emotions.map(mapEmotion);
}

/**
 * 텍스트(FTS) 역인덱스 기반 검색을 수행합니다.
 * @param query 검색어 쿼리 문자열
 */
export async function searchByText(query: string, limit: number = 500): Promise<Emotion[]> {
    const response = await fetch(`${PUBLIC_API_BASE_URL}/text`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, limit })
    });

    if (!response.ok)
        throw new Error('텍스트 검색 중 오류가 발생했습니다.');

    const data: SearchResponse = await response.json();
    return data.emotions.map(mapEmotion);
}

lib/utils/graph.ts

백엔드에서 받은 계층형 데이터를 파싱하여 Vis.js에서 사용할 수 있는 형태로 변환한다.
Vis.js는 { id, label } 형태의 nodes 배열과
{ from, to } 형태의 edges 배열을 필요로 한다.

frontend/src/lib/utils/graph.ts

import { slide } from 'svelte/transition';
import type { Emotion } from '../generated/emotion_search';
import type { Node, Edge } from 'vis-network';

export interface GraphData {
    nodes: Node[];
    edges: Edge[];
}

// 두 감정의 VA 벡터 간 유클리드 거리 계산
function getDistance(v1: number[], v2: number[]) {
    if (v1.length < 2 || v2.length < 2)
        return 0;

    return Math.sqrt(Math.pow(v1[0] - v2[0], 2) + Math.pow(v1[1] - v2[1], 2));
}

function getRgba(hex: string, alpha: number) {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

function getGradientRgba(vValue: number, alpha: number) {
    const v = Math.max(-1.0, Math.min(1.0, vValue)); // -1.0 ~ 1.0 제한

    // 중립(0.0) = 회색 (148, 163, 184)
    // 긍정(1.0) = 파랑 (74, 144, 226)
    // 부정(-1.0) = 빨강 (226, 74, 74)
    let r, g, b;
    if (v >= 0) {
        r = Math.round(148 + v * (74 - 148));
        g = Math.round(163 + v * (144 - 163));
        b = Math.round(184 + v * (226 - 184));
    } else {
        const factor = Math.abs(v);
        r = Math.round(148 + factor * (226 - 148));
        g = Math.round(163 + factor * (74 - 163));
        b = Math.round(184 + factor * (74 - 184));
    }
    return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

/**
 * 감정 노드를 생성하고 VA 유사도에 따라 관걔선 생성
 * @param emotions 434개의 전체 감정 리스트
 * @param focusedWords 현재 선택되었거나 검색된 단어들의 배열
 */
export function buildEmotionNetwork(emotions: Emotion[], focusedWords: string[] = []): GraphData {
    const nodes: Node[] = [];
    const edges: Edge[] = [];

    const adjList = new Map<string, Set<string>>();
    emotions.forEach(e => adjList.set(e.word, new Set()));

    const baseEdges: { id: string, from: string, to: string }[] = [];
    const edgeSet = new Set<string>();

    // 각 노드에 기본 간선과 인접 리스트 생성
    emotions.forEach((emotion) => {
        if (emotion.vaVector && emotion.vaVector.length >= 2) {
            const neighbors = emotions
                .filter(e => e.word !== emotion.word && e.vaVector && e.vaVector.length >= 2)
                .map(e => ({
                    word: e.word,
                    dist: getDistance(emotion.vaVector!, e.vaVector!)
                }))
                .sort((a, b) => a.dist - b.dist)
                .slice(0, 2);

            neighbors.forEach(n => {
                const edgeId = [emotion.word, n.word].sort().join('-');
                if (!edgeSet.has(edgeId)) {
                    edgeSet.add(edgeId);
                    baseEdges.push({
                        id: edgeId,
                        from: emotion.word,
                        to: n.word
                    });
                    adjList.get(emotion.word)?.add(n.word);
                    adjList.get(n.word)?.add(emotion.word);
                }
            });
        }
    });

    // 포커스된 단어의 직접적인 이웃 탐색
    const neighborWords = new Set<string>();
    const isIdle = focusedWords.length === 0;

    if (!isIdle) {
        focusedWords.forEach(fw => {
            const neighbors = adjList.get(fw);
            if (neighbors) {
                neighbors.forEach(n => {
                    if (!focusedWords.includes(n)) {
                        neighborWords.add(n);
                    }
                });
            }
        });
    }

    // 계산된 관계성을 바탕으로 노드 시각화
    emotions.forEach((emotion) => {
        const isFocused = focusedWords.includes(emotion.word);
        const isNeighbor = neighborWords.has(emotion.word);

        // 기본값
        let alpha = 0.3;
        let size = 10;
        let fontSize = 10;
        let fontColor = 'rgba(51, 51, 51, 0.3)';

        if (isIdle) {
            // 전체 조명
            alpha = 0.6;
            size = 12;
            fontSize = 12;
            fontColor = 'rgba(51, 51, 51, 0.6)';
        } else if (isFocused) {
            // 최대 강조
            alpha = 1.0;
            size = 24;
            fontSize = 20;
            fontColor = '#333333';
        } else if (isNeighbor) {
            // 중간 강조
            alpha = 0.8;
            size = 16;
            fontSize = 16;
            fontColor = 'rgba(51, 51, 51, 0.8)';
        }

        const vValue = emotion.vaVector && emotion.vaVector.length > 0 ?
            emotion.vaVector[0] : 0;
        const nodeBgColor = getGradientRgba(vValue, alpha);
        const nodeBorderColor = getRgba('#FFFFFF', isFocused ? 1.0 : alpha);

        nodes.push({
            id: emotion.word,
            label: emotion.word,
            shape: 'dot',
            size: size,
            color: {
                background: getGradientRgba(vValue, alpha),
                border: getRgba('#ffffff', alpha),
                highlight: {
                    background: nodeBgColor,
                    border: getRgba('#ffffff', 1.0),
                },
                hover: {
                    background: nodeBgColor,
                    border: nodeBorderColor,
                }
            },
            font: {
                color: fontColor,
                size: fontSize,
                strokeWidth: isFocused ? 2 : (isNeighbor || isIdle ? 1 : 0),
                strokeColor: '#ffffff',
            },
            title: emotion.definition
        });
    });

    // 계산된 관계성을 바탕으로 간선 시각화
    baseEdges.forEach(edge => {
        const fromFocused = focusedWords.includes(edge.from);
        const toFocused = focusedWords.includes(edge.to);
        const fromNeighbor = neighborWords.has(edge.from);
        const toNeighbor = neighborWords.has(edge.to);

        // 기본값
        let edgeAlpha = 0.1;
        let edgeWidth = 1;

        if (isIdle) {
            // 전체 조명
            edgeAlpha = 0.3;
        } else if ((fromFocused && toNeighbor) || (toFocused && fromNeighbor) || (fromFocused && toFocused)) {
            // 최대 강조
            edgeAlpha = 0.8;
            edgeWidth = 2;
        } else if (fromNeighbor && toNeighbor) {
            // 중간 강조
            edgeAlpha = 0.6;
        }

        edges.push({
            id: edge.id,
            from: edge.from,
            to: edge.to,
            color: {
                color: '#94A3B8',
                opacity: edgeAlpha,
            },
            width: edgeWidth,
            smooth: {
                enabled: true,
                type: 'continuous',
                roundness: 0.5,
            }
        });
    });

    return { nodes, edges };
}
🤖 AI AGENT | 코드 해설

434개의 점을 우주처럼 펼치고 연결하는 로직의 핵심은 유클리드 거리그라데이션 계산입니다.

  • 이웃 찾기 (K-NN 알고리즘의 프론트엔드 구현)
    단순히 선을 긋는 것이 아닙니다. 각 감정의 V(정서가), A(각성가) 값을 좌표(x, y)로 삼아 피타고라스의 정리(유클리드 거리, getDistance)를 이용해 거리를 구합니다. 자신과 좌표가 가장 가까운 2개의 감정을 찾아 선(edge)으로 연결합니다.
  • 수학적 색상 맵핑 (getGradientRgba)
    긍정(파랑)과 부정(빨강)을 0과 1로 단순히 나누지 않고, V값(-1.0 ~ 1.0)에 따라 중간값인 회색을 거쳐가도록 수학적으로 R, G, B 값을 보간(Interpolation)합니다.
  • 3단계 시각적 하이라이트
    사용자가 단어를 선택하면, 1) 선택된 단어 자체(isFocused), 2) 그 단어와 선으로 연결된 1촌 이웃 단어들(isNeighbor), 3) 나머지 우주의 먼지들(isIdle)로 그룹을 나누어 투명도(alpha)와 크기(size)를 다르게 렌더링합니다.

UI 컴포넌트 코드

app.css

UI 컴포넌트에서 공통으로 사용될 색상 변수와 기본 UI 설정을 작성한다.

frontend/src/app.css

:root {
    --bg-main: #f8fafc;
    --bg-panel: #ffffff;
    --bg-muted: #f1f5f9;
    --text-primary: #1e293b;
    --text-secondary: #64748b;
    --border-light: #e2e8f0;
    --accent-blue: #4a90e2;
    --accent-hover: #357abd;
    --shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
    --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1);
}

body {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    background-color: var(--bg-main);
    color: var(--text-primary);
}

.vis-tooltip {
    position: absolute;
    visibility: hidden;
    background-color: rgba(0, 0, 0, 0.1);
    backdrop-filter: blur(2px);
    -webkit-backdrop-filter: blur(2px);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    box-shadow: var(--shadow-md);
    z-index: 9999;
    pointer-events: none;
    color: var(--text-primary);
    padding: 0.2rem;
}

이곳에 작성했다고 반영되는 건 아니고
전역으로 적용하기 위해 routes/+layout.svelte 에 추가한다.

frontend/src/routes/+layout.svelte

<script lang="ts">
	import favicon from '$lib/assets/favicon.svg';
	import '../app.css';

	let { children } = $props();
</script>

<svelte:head>
	<link rel="icon" href={favicon} />
</svelte:head>

{@render children()}

lib/components/Network.svelte

백엔드의 데이터를 받아 실제 물리 엔진 캔버스로 그려내는
핵심 UI 컴포넌트를 작성한다.

frontend/src/lib/components/Network.svelte

<script lang="ts">
    import { Network } from 'vis-network';
    import { DataSet } from 'vis-data';
    import type { GraphData } from '$lib/utils/graph';

    let { graphData, focusedWords=[], onNodeClick } = $props<{
        graphData: GraphData;
        focusedWords?: string[];
        onNodeClick?: (nodeId: string) => void;
    }>();

    // 캔버스가 마운트 될 DOM element 참조
    let container: HTMLDivElement;
    let networkInstance: Network | undefined;

    let nodesData = new DataSet<any>();
    let edgesData = new DataSet<any>();

    let minZoomScale = 0.1;

    // 화면 마운트 시 캔버스 초기화
    $effect(() => {
        if (!container)
            return;

        const options = {
            physics: {
                solver: 'barnesHut',
                barnesHut: {
                    gravitationalConstant: -2000, // 노드 간의 기본 척력 (음수: 밀어냄)
                    centralGravity: 0.1, // 화면 중앙으로 당기는 힘 (흩어짐 방지)
                    springLength: 120, // 엣지 기본 길이
                },
                stabilization: {
                    iterations: 150
                }
            },
            interaction: {
                hover: true,
                zoomView: true,
                tooltipDelay: 10
            }
        };

        // Vis.js 네트워크 인스턴스 생성 및 렌더링
        networkInstance = new Network(container, {nodes: nodesData, edges: edgesData}, options);

        networkInstance.once("stabilizationIterationsDone", () => {
            networkInstance?.setOptions({
                physics: {enable: false}
            });

            networkInstance?.fit();
            minZoomScale = (networkInstance?.getScale() || 0.1);
        });

        // 최소 크기 지정
        networkInstance.on("zoom", (params) => {
            if (networkInstance && params.scale < minZoomScale) {
                networkInstance.moveTo({ scale: minZoomScale });
            }
        });

        // 화면을 벗어나지 않게 조정
        networkInstance.on("dragEnd", () => {
            if (!networkInstance) return;

            const positions = networkInstance.getPositions();
            const xVals = Object.values(positions).map((p: any) => p.x);
            const yVals = Object.values(positions).map((p: any) => p.y);
            if (xVals.length === 0) return;

            const minX = Math.min(...xVals);
            const maxX = Math.max(...xVals);
            const minY = Math.min(...yVals);
            const maxY = Math.max(...yVals);

            const pos = networkInstance.getViewPosition();
            let snappedX = pos.x;
            let snappedY = pos.y;
            let needsSnap = false;

            const padding = 300; 

            if (pos.x > maxX + padding) {
                snappedX = maxX + padding;
                needsSnap = true;
            } else if (pos.x < minX - padding) {
                snappedX = minX - padding;
                needsSnap = true;
            }

            if (pos.y > maxY + padding) {
                snappedY = maxY + padding;
                needsSnap = true;
            } else if (pos.y < minY - padding) {
                snappedY = minY - padding;
                needsSnap = true;
            }

            if (needsSnap) {
                networkInstance.moveTo({
                position: { x: snappedX, y: snappedY },
                animation: { duration: 300, easingFunction: 'easeOutQuart' as const }
                });
            }
        });

        if (onNodeClick) {
            networkInstance.on('click', (params: any) => {
                onNodeClick(params.nodes.length > 0 ? params.nodes[0] : '');
            });
        }

        return () => {
            if (networkInstance) {
                networkInstance.destroy();
                networkInstance = undefined;
            }
        };
    });

    // graphData가 들어오거나 변경될 때마다 실행
    $effect(() => {
        if (!networkInstance || !graphData)
            return;

        nodesData.update(graphData.nodes);
        edgesData.update(graphData.edges);
    });

    // 검색 시 카메라 이동
    $effect(() => {
        if (!networkInstance) return;

        const targetWords = focusedWords;

        const animationOptions = {
            duration: 800,
            easingFunction: 'easeInOutQuad' as const
        };

        if (targetWords.length === 1) {
            networkInstance.focus(targetWords[0], {
                scale: 1.2,
                animation: animationOptions
            });
        } else if (targetWords.length > 1) {
            networkInstance.fit({
                nodes: targetWords,
                animation: animationOptions
            });
        }
    });

</script>

<div bind:this={container} class="network-container"></div>

<style>
    .network-container {
        width: 100%;
        height: 80vh;
        border: 1px solid var(--border-light);
        border-radius: 12px;
        background-color: var(--bg-main);
        box-shadow: var(--shadow-md);
    }
</style>
🤖 AI AGENT | 코드 해설

최신 Svelte 5의 룬(Runes) 문법을 활용하여 반응성과 메모리 관리를 극대화합니다.

  • Svelte 5 반응성($props, $effect)의 결합:
    과거 버전의 export let이나 생명주기 훅(onMount) 대신 Svelte 5의 룬을 사용했습니다. $effect는 내부에서 참조하고 있는 상태(graphData)가 변경될 때마다 스스로를 재실행합니다. 백엔드에서 검색 결과가 달라져 graphData가 교체되면, 알아서 새로운 그래프를 화면에 그립니다.
  • destroy()를 통한 철저한 메모리 관리:
    Single Page Application(SPA)에서 무거운 WebGL/Canvas 라이브러리를 다룰 때 가장 빈번하게 발생하는 장애가 메모리 누수입니다. $effect가 반환하는 클린업(Cleanup) 함수에서 networkInstance.destroy()를 호출하여, 브라우저가 차지하고 있던 이벤트 리스너와 렌더링 자원을 확실하게 반환하도록 설계했습니다.
  • 물리 엔진 튜닝 (barnesHut)
    노드들이 겹치지 않게 밀어내는 힘(gravitationalConstant: -2000)과 흩어지지 않게 중앙으로 당기는 힘(centralGravity: 0.1)을 조율하여 은하수 같은 구형(Sphere) 네트워크를 만들어냅니다.
  • 드래그 이탈 방지 (Snap-back Algorithm)
    사용자가 화면을 허공으로 던져버리지 못하게 막는 로직입니다.
    networkInstance.getPositions()로 모든 노드의 실제 좌표를 구해 상하좌우 한계선(maxX, minX 등)을 계산합니다. 유저의 카메라 중심점(pos)이 노드 구역보다 300px(여백) 이상 벗어나면, 고무줄처럼 부드럽게 한계선 안쪽으로 카메라를 강제 이동(moveTo)시킵니다.
  • 자동 줌인 (Focus & Fit)
    $effect를 이용해 focusedWords 상태를 감시합니다.
    단어가 1개 선택되면 해당 노드로 카메라가 날아가 줌인(focus, scale: 1.2)하고, 여러 개(계층 검색)가 선택되면 그 노드들이 모두 화면에 들어오도록 줌아웃(fit)합니다.

lib/components/SearchBar.svelte

사용자의 입력을 받아 검색을 트리거하는 컴포넌트를 작성한다.
검색 방식을 선택함에 따라 검색어 또는 드롭박스로 검색을 할 수 있게 구현한다.
Svelte 논리 블록을 사용하면 선택에 따라 다른 UI를 보여줄 수 있다.

frontend/src/lib/components/SearchBar.svelte

<script lang="ts">
    import type { Emotion } from '$lib/generated/emotion_search';

    let { onSearch, allEmotions = [] } = $props<{
        onSearch: (query: string, type: 'text' | 'taxonomy') => void;
        allEmotions: Emotion[];
    }>();

    let searchType = $state<'text' | 'taxonomy'>('text');

    // 텍스트 검색 시 사용
    let textQuery = $state('');

    // 계층 검색 시 사용
    let taxonomyLevel1 = $state('*');
    let taxonomyLevel2 = $state('*');

    let showSuggestions = $state(false);
    let suggestions = $derived(
        textQuery.trim().length > 0
        ? allEmotions.filter((e: Emotion) => e.word.includes(textQuery.trim())).slice(0, 5)
        : []
    );

    let focusedIndex = $state(-1);
    $effect(() => {
        if (textQuery) {
            focusedIndex = -1;
        }
    });

    function handleSubmit(e?: Event) {
        if (e) e.preventDefault();
        showSuggestions = false;

        if (searchType == 'text') {
            if (textQuery.trim()) {
                onSearch(textQuery, 'text');
            }
        } else {
            const pathQuery = `${taxonomyLevel1}.${taxonomyLevel2}.*`;
            console.log("계층 검색:", pathQuery);
            onSearch(pathQuery, 'taxonomy');
        }
    }

    function selectSuggestion(word: string) {
        textQuery = word;
        showSuggestions = false;
        handleSubmit();
    }

    // 추천 검색어 키보드 제어
    function handleKeydown(e: KeyboardEvent) {
        if (searchType !== 'text' || !showSuggestions || suggestions.length === 0) return;

        if (e.key === 'ArrowDown') {
            e.preventDefault();
            focusedIndex = focusedIndex + 1 >= suggestions.length ? 0 : focusedIndex + 1;
        } else if (e.key === 'ArrowUp') {
            e.preventDefault();
            focusedIndex = focusedIndex - 1 < 0 ? suggestions.length - 1 : focusedIndex - 1;
        } else if (e.key === 'Enter') {
            if (e.isComposing) return; 

            e.preventDefault(); // 기본 폼 제출 방지

            if (focusedIndex >= 0) {
                selectSuggestion(suggestions[focusedIndex].word);
            } else {
                selectSuggestion(suggestions[0].word);
            }
        } else if (e.key === 'Escape') {
            showSuggestions = false;
        }
    }
</script>

<form class="search-bar" onsubmit={handleSubmit}>
    <select bind:value={searchType} class="type-select">
        <option value="text">의미 검색</option>
        <option value="taxonomy">계층 검색</option>
    </select>

    <div class="input-area">
        {#if searchType === 'text'}
            <div class="autocomplete-wrapper">
                <input
                    type="text"
                    bind:value={textQuery}
                    onfocus={() => showSuggestions = true}
                    oninput={() => showSuggestions = true}
                    onblur={() => setTimeout(() => showSuggestions = false, 150)}
                    onkeydown={handleKeydown}
                    placeholder="예: 마음이, 슬픔..."
                    class="search-input"
                />
                {#if showSuggestions && suggestions.length > 0}
                    <ul class="suggestions-list">
                        {#each suggestions as emotion, i}
                            <li class={i === focusedIndex ? 'focused' : ''}>
                                <button
                                    type="button"
                                    class="suggestion-btn"
                                    onclick={() => selectSuggestion(emotion.word)}

                                    {@html emotion.word.replace(textQuery.trim(), `<strong>${textQuery.trim()}</strong>`)}
                                </button>
                            </li>
                        {/each}
                    </ul>
                {/if}
            </div>
        {:else}
            <div class="taxonomy-selectors">
                <select bind:value={taxonomyLevel1} class="tax-select">
                    <option value="*">전체</option>
                    <option value="positive">긍정적</option>
                    <option value="neutral">중립적</option>
                    <option value="negative">부정적</option>
                </select>

                <span class="divider"></span>

                <select bind:value={taxonomyLevel2} class="tax-select">
                    <option value="*">전체</option>
                    <option value="high_arousal">높은 에너지</option>
                    <option value="low_arousal">낮은 에너지</option>
                </select>
            </div>
        {/if}
    </div>

    <button type="submit" class="search-btn">검색</button>
</form>

<style>
    .search-bar {
        display: flex;
        gap: 0.75rem;
        background-color: var(--bg-panel);
        padding: 1rem;
        border-radius: 8px;
        box-shadow: var(--shadow-sm);
        margin-bottom: 1rem;
        align-items: center;
    }

    .input-area {
        flex: 1;
        display: flex;
    }

    .type-select, .search-input, .tax-select, .search-btn {
        padding: 0.75rem;
        border: 1px solid var(--border-light);
        border-radius: 6px;
        font-size: 1rem;
    }

    .type-select {
        background-color: var(--bg-muted);
        font-weight: bold;
        color: var(--text-primary);
    }

    .search-input {
        width: 90%;
    }

    .taxonomy-selectors {
        display: flex;
        align-items: center;
        gap: 0.5rem;
        width: 100%;
    }

    .tax-select {
        flex: 1;
        cursor: pointer;
    }

    .divider {
        color: var(--text-secondary);
        font-size: 0.8rem;
    }

    .search-btn {
        background-color: var(--accent-blue);
        color: white;
        font-weight: bold;
        border: none;
        cursor: pointer;
        transition: background-color 0.2s;
        min-width: 80px;
    }

    .search-btn:hover {
        background-color: var(--accent-hover);
    }

    .autocomplete-wrapper { 
        width: 100%;
        position: relative;
    }

    .suggestions-list {
        position: absolute;
        top: 100%;
        left: 0;
        right: 0;
        background: white;
        border: 1px solid var(--border-light);
        border-radius: 6px;
        box-shadow: var(--shadow-md);
        margin: 4px 0 0 0;
        padding: 0;
        list-style: none;
        z-index: 100;
        max-height: 200px;
        overflow-y: auto;
    }

    .suggestions-list li {
        padding: 0.75rem 1rem;
        cursor: pointer;
        border-bottom: 1px solid var(--bg-muted);
        color: var(--text-primary);
    }

    .suggestions-list li:last-child {
        border-bottom: none;
    }

    .suggestion-btn {
        width: 100%; text-align: left; padding: 0.75rem 1rem; background: transparent;
        border: none; font-size: 1rem; color: var(--text-primary); cursor: pointer; font-family: inherit;
    }

    .suggestions-list li.focused .suggestion-btn,
    .suggestion-btn:hover, 
    .suggestion-btn:focus {
        background-color: var(--bg-muted);
        color: var(--accent-blue);
        outline: none;
    }

    :global(.suggestion-btn strong) { color: var(--accent-blue); font-weight: 800; }
</style>
🤖 AI AGENT | 코드 해설

상태 캡슐화와 역방향 데이터 흐름이 핵심입니다. 프론트엔드와 백엔드의 검색 기술이 하이브리드로 섞여 있습니다.

  • $state (로컬 상태 관리):
    searchQuerysearchType 은 이 컴포넌트 내부에서만 유효한 상태입니다. Svelte 5의 $state 는 이 변수들이 변경될 때 화면의 <input><select> 를 즉각적으로 동기화(bind:value)시킵니다.
  • $propsonSearch:
    Svelte는 기본적으로 데이터가 부모에서 자식으로 흐릅니다(Top-down). 자식인 검색창이 부모(메인 페이지)의 데이터를 변경하게 하려면, 부모로부터 onSearch라는 함수를 주입(Inject)받아 실행하는 방식을 취합니다. 이는 리액트(React) 등 현대 프레임워크의 표준 제어 역전(IoC) 패턴입니다.
  • 인메모리(In-memory) 자동완성 (의미 검색)
    의미 검색의 자동완성은 백엔드 서버를 거치지 않습니다. 이미 브라우저 메모리에 있는 434개의 allEmotions 배열을 Svelte의 $derived를 사용해 사용자가 타이핑할 때마다 즉시 필터링(.includes())하여 최대 5개까지 드롭다운에 뿌려줍니다. 이 덕분에 0.01초의 딜레이도 없이 키보드 방향키(ArrowDown/Up)로 추천 단어를 오갈 수 있습니다.
  • ltree 와일드카드 쿼리 조립 (계층 검색)
    PostgreSQL의 ltree 구조에 맞추기 위해 사용자가 선택한 드롭다운 값들을 조합합니다.
    예를 들어, 1단계(정서가)를 *(전체), 2단계(에너지)를 high_arousal로 선택하면, ${taxonomyLevel1}.${taxonomyLevel2}.*라는 템플릿 리터럴을 통해 *.high_arousal.* 이라는 쿼리가 완성되어 백엔드로 전송됩니다.

lib/components/DetailPanel.svelte

네트워크 캔버스에서 특정 감정 노드를 클릭했을 때
그 감정의 정의 및 메타데이터를 보여주는 컴포넌트를 작성한다.

frontend/src/lib/components/DetailPanel.svelte

<script lang="ts">
    import type { Emotion } from '$lib/generated/emotion_search';

    // 선택된 감정 데이터
    let { selectedEmotion } = $props<{ selectedEmotion: Emotion | null}>();

    // 백엔드의 -1.0 ~ 1.0 값을 CSS 위치인 0% ~ 100% 로 변환하는 헬퍼 함수
    function getPos(value: number) {
        return ((value + 1) / 2) * 100;
    }
</script>

<aside class="detail-panel">
    {#if selectedEmotion}
        <h2 class="title">{selectedEmotion.word}</h2>
        <div class="tag">{selectedEmotion.taxonomyPath}</div>

        <div class="section">
            <h3>사전적 정의</h3>
            <p>{selectedEmotion.definition || '등록된 정의가 없습니다.'}</p>
        </div>
        {#if selectedEmotion.vaVector && selectedEmotion.vaVector.length === 2}
            <div class="section">
                <h3>감정 위치 나침반</h3>
                <div class="va-map-container">
                    <div class="va-map">
                        <div class="axis-x"></div>
                        <div class="axis-y"></div>

                        <span class="label top">흥분(고각성)</span>
                        <span class="label bottom">차분(저각성)</span>
                        <span class="label left">불쾌</span>
                        <span class="label right">유쾌</span>

                        <div 
                            class="emotion-dot" 
                            style="left: {getPos(selectedEmotion.vaVector[0])}%; bottom: {getPos(selectedEmotion.vaVector[1])}%;"
</div>
                    </div>
                </div>

                <div class="va-description">
                    이 감정은 
                    <strong>{selectedEmotion.vaVector[0] >= 0 ? '긍정적(유쾌)' : '부정적(불쾌)'}</strong>이며, 
                    에너지 수준이 
                    <strong>{selectedEmotion.vaVector[1] >= 0 ? '높은(흥분)' : '낮은(차분)'}</strong> 상태입니다.
                </div>
            </div>
        {/if}
    {:else}
        <div class="empty-state">
            <p>노드를 클릭하면<br>상세 정보가 표시됩니다.</p>
        </div>
    {/if}
</aside>

<style>
    .detail-panel {
        width: 300px;
        background-color: var(--bg-panel);
        border: 1px solid var(--border-light);
        border-radius: 12px;
        padding: 1.5rem;
        box-shadow: var(--shadow-md);
        overflow-y: auto;
    }

    .title {
        margin: 0 0 0.5rem 0;
        font-size: 1.5rem;
        color: var(--text-primary);
    }

    .tag {
        display: inline-block;
        background-color: var(--border-light);
        color: var(--text-secondary);
        padding: 0.25rem, 0.5rem;
        border-radius: 4px;
        font-size: 0.875rem;
        margin-bottom: 1.5rem;
    }

    .section {
        margin-bottom: 1.5rem;
    }

    .section h3 {
        font-size: 1rem;
        margin-bottom: 0.5rem;
        color: var(--text-secondary);
    }

    .section p {
        line-height: 1.5;
        margin: 0;
    }

    .label {
        display: block;
        font-size: 0.875rem;
        color: var(--text-secondary);
        margin-bottom: 0.5rem;
    }

    .empty-state {
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        text-align: center;
        color: var(--text-secondary);
    }

    .va-map-container {
        width: 100%;
        aspect-ratio: 1 / 1;
        background-color: #f8fafc;
        border: 1px solid #e2e8f0;
        border-radius: 8px;
        position: relative;
        margin-bottom: 1rem;
        overflow: hidden;
    }

    .va-map {
        position: absolute;
        top: 15%; left: 15%; right: 15%; bottom: 15%;
    }

    .axis-x {
        position: absolute;
        top: 50%;
        left: 0;
        width: 100%;
        height: 1px;
        background-color: #cbd5e1;
    }

    .axis-y {
        position: absolute;
        top: 0;
        left: 50%;
        width: 1px;
        height: 100%;
        background-color: #cbd5e1;
    }

    .label {
        position: absolute;
        font-size: 0.7rem; color:
        #94a3b8; font-weight: bold;
    }

    .label.top {
        top: -15%;
        left: 50%;
        transform: translateX(-50%);
    }

    .label.bottom {
        bottom: -15%;
        left: 50%;
        transform: translateX(-50%);
    }

    .label.left {
        left: -15%;
        top: 50%;
        transform: translateY(-50%);
    }

    .label.right {
        right: -15%;
        top: 50%;
        transform: translateY(-50%);
    }

    .emotion-dot {
        position: absolute;
        width: 12px;
        height: 12px;
        background-color: #ffb300;
        border: 2px solid white;
        border-radius: 50%;
        transform: translate(-50%, 50%);
        box-shadow: 0 0 8px rgba(255, 179, 0, 0.6);
        transition: all 0.3s ease-out;
    }

    .va-description {
        font-size: 0.9rem;
        color: var(--text-primary);
        background-color: var(--bg-muted, #f1f5f9);
        padding: 0.75rem;
        border-radius: 6px;
        line-height: 1.5;
    }

    .va-description strong {
        color: var(--accent-blue, #4a90e2);
    }
</style>
🤖 AI AGENT | 코드 해설

안전한 조건부 렌더링으로 클릭된 노드의 정보를 보여주는 패널입니다. 사용자가 심리학의 '-1.0 ~ 1.0'이라는 숫자를 직관적으로 이해할 수 있게 돕는 UI 로직입니다.

  • selectedEmotion: Emotion | null:
    사용자가 아무것도 클릭하지 않은 초기 상태(null)를 명시적으로 허용합니다.
  • {#if selectedEmotion} 블록:
    Svelte의 논리 블록입니다. 데이터가 없을 때는 "노드를 클릭하면..." 이라는 Placeholder(빈 화면)를 보여주고, 데이터가 들어오는 순간 0.1초의 지연도 없이 DOM을 교체하여 상세 정보를 그립니다.
  • 안전한 배열 접근:
    vaVector 값이 없을 수도 있는 엣지 케이스를 방지하기 위해 {#if selectedEmotion.vaVector && selectedEmotion.vaVector.length === 2} 로 엄격하게 검증한 뒤 화면에 렌더링합니다.
  • 2D 나침반 좌표 변환 (getPos)
    ((value + 1) / 2) * 100 이라는 공식을 사용합니다.
    예를 들어 V값이 -1.0(완전 부정)이면 결과는 0%가 되고, 0.0(중립)이면 50%, 1.0(완전 긍정)이면 100%가 됩니다. 이 값을 각각 노란색 점(emotion-dot)의 CSS left(가로축)와 bottom(세로축) 속성에 주입하여, 2차원 미니맵 위에서 감정의 정확한 위치에 불을 켭니다.

메인 화면 코드

routes/+page.ts

서버사이드 렌더링을 해제하고,
마운트 시점에 보여질 초기 데이터를 작성한다.

frontend/src/routes/+page.ts

import { searchByVector } from '$lib/api';
import type { Emotion } from '$lib/generated/emotion_search';

export const ssr = false;

export async function load() {
    let initialEmotions: Emotion[] = [];
    try {
        initialEmotions = await searchByVector(0, 0, 1000);
    } catch (e) {
        console.error("초기 데이터 로딩 실패:", e);
    }

    return {
        initialEmotions
    }
}

routes/+page.svelte

컴포넌트를 사용하여 메인 UI를 작성한다.

frontend/src/routes/+page.svelte

<script lang='ts'>
    import SearchBar from '$lib/components/SearchBar.svelte';
    import Network from '$lib/components/Network.svelte';
    import DetailPanel from '$lib/components/DetailPanel.svelte';
    import { searchByText, searchByTaxonomy, searchByVector } from '$lib/api';
    import { buildEmotionNetwork } from '$lib/utils/graph';
    import type { Emotion } from '$lib/generated/emotion_search';

    // +page.ts 초기 데이터 사용
    let { data } = $props();

    const allEmotions = $derived(data.initialEmotions);

    let focusedWords = $state<string[]>([]);

    let selectedEmotion = $derived(
        focusedWords.length === 1
            ? allEmotions.find(e => e.word === focusedWords[0]) || null
            : null
    );

    let graphData = $derived(buildEmotionNetwork(allEmotions, focusedWords));

    // Search Bar에서 트리거
    async function handleSearch(query: string, type: 'text' | 'taxonomy') {
        try {
            let results: Emotion[] = [];
            if (type === 'text') {
                results = await searchByText(query, 1000);
            } else {
                results = await searchByTaxonomy(query, 1000);
                console.log("🐛 계층 검색 응답 데이터:", results);
            }

            if (results && results.length > 0) {
                focusedWords = results.map(e => e.word);
            } else {
                alert('검색 결과가 없습니다.');
                focusedWords = [];
            }
        } catch (error) {
            console.error(error);
            alert("데이터를 불러오는 중 문제가 발생했습니다.");
        }
    }

    // Network 노드 클릭 시 트리거
    function handleNodeClick(nodeId: string) {
        focusedWords = nodeId ? [nodeId] : [];
    }
</script>

<div class="dashboard">
    <header class="header">
        <div class="title-area">
            <h1>감정 네트워크 탐색기</h1>
            <p>단어의 의미와 심리학적 분류를 시각적으로 탐색하세요.</p>
        </div>
        <div class="search-area">
            <SearchBar onSearch={handleSearch} {allEmotions}/>
        </div>
    </header>

    <main class="content-grid">
        <section class="network-section">
            {#if allEmotions.length === 0}
                <p>데이터를 불러오지 못했거나 결과가 없습니다.</p>
            {:else}
                <Network
                    {graphData}
                    {focusedWords}
                    onNodeClick={handleNodeClick}
                />
            {/if}
        </section>

        <section class="panel-section">
            <DetailPanel {selectedEmotion} />
        </section>
    </main>
</div>
<style>
    .dashboard {
        max-width: 1400px;
        margin: 0 auto;
        padding: 2rem;
        height: 100vh;
        display: flex;
        flex-direction: column;
        box-sizing: border-box;
    }

    .header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 2rem;
        gap: 2rem;
    }

    .title-area h1 {
        margin: 0 0 0.5rem 0;
        font-size: 1.8rem;
        color: var(--text-primary);
    }

    .title-area p {
        margin: 0;
        color: var(--text-secondary);
    }

    .search-area {
        flex: 1;
        max-width: 600px;
    }

    .content-grid {
        display: flex;
        gap: 1.5rem;
        flex: 1;
        min-height: 0;
    }

    .network-section {
        flex: 1;
        min-height: 0;
        min-width: 0;
    }

    .panel-section {
        width: 320px;
        flex-shrink: 0;
    }

    /* 태블릿 이하: 패널을 네트워크 아래로 */
    @media (max-width: 1024px) {
        .content-grid {
            flex-direction: column;
            overflow-y: auto;
        }

        .network-section {
            flex: 0 0 auto;
            width: 100%;
            height: min(52vh, 520px);
            min-height: 280px;
        }

        .network-section :global(.network-container) {
            height: 100%;
            min-height: 280px;
        }

        .panel-section {
            width: 100%;
            max-width: none;
            flex-shrink: 0;
        }

        .panel-section :global(.detail-panel) {
            width: 100%;
            max-width: 100%;
            box-sizing: border-box;
        }
    }

    /* 모바일: 검색창을 제목 아래로 */
    @media (max-width: 768px) {
        .dashboard {
            padding: 1rem;
        }

        .header {
            flex-direction: column;
            align-items: stretch;
            gap: 1.25rem;
            margin-bottom: 1.25rem;
        }

        .title-area h1 {
            font-size: 1.5rem;
        }

        .search-area {
            flex: none;
            max-width: none;
            width: 100%;
        }

        .network-section {
            height: min(68vh, 600px);
            min-height: 360px;
        }

        .network-section :global(.network-container) {
            min-height: 360px;
        }
    }
</style>
🤖 AI AGENT | 코드 해설

이 앱의 모든 데이터는 +page.svelte라는 하나의 '두뇌'에서 통제되며, 폭포수처럼 하위 컴포넌트로 흘러갑니다. (단방향 데이터 플로우)

  • 초기 로딩 (+page.ts+page.svelte)
    서버 로딩 없이 클라이언트 단에서 searchByVector(0, 0, 1000)를 호출해 434개의 전체 감정 데이터를 가져옵니다. 이 데이터는 Svelte 5의 $derived 룬을 통해 allEmotions라는 읽기 전용 불변 상태로 고정됩니다.
  • 상태의 중심축: `focusedWords앱에서 변하는 핵심 상태는 오직focusedWords($state<string[]>`) 하나뿐입니다. 유저가 검색을 하거나 노드를 클릭하면 이 배열에 단어가 담깁니다.
  • 연쇄 반응 (Reactivity)
    focusedWords가 변하는 순간, 연쇄적으로 두 가지 계산이 자동으로 다시 실행됩니다.
  1. selectedEmotion: 포커스된 단어가 1개일 때, allEmotions에서 해당 객체를 찾아 패널에 전달합니다.
  2. graphData: 이 프로젝트의 가장 무거운 연산buildEmotionNetwork() 함수가 다시 실행되어 그래프의 모양, 색, 선 굵기를 재계산합니다.

CORS 처리

프론트엔드와 백엔드를 분리해서 개발할 때 별다른 처리를 하지 않으면
CORS(Cross-Origin Resource Sharing) 정책에 위반되어 작동하지 않는다.
따라서 Python 게이트웨이에 CORS 미들웨어를 추가해야 한다.

python-gateway/main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.server import lifespan
from app.api.emotion import router as emotion_router

app = FastAPI(
    title="다차원 감정 검색 API Gateway",
    description="Python FastAPI ↔ Rust gRPC 엔진",
    lifespan=lifespan
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(emotion_router)

또한 434개의 데이터를 모두 그려놓고 진행하는 만큼
python-gateway/app/schemas/emotion.py 파일의 limit 기본값과
rust-engine/src/service.rs 파일의 req.limit.clamp() 최대값이
434개를 커버하는 충분히 큰 수인지 검토한다.
구현 환경에 따라 성능을 위해 값을 줄일 수는 있지만 이 갯수는 넘겨야 정상 작동한다.

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다

0개의 댓글