뉴스 한 건이 어떤 종목까지 영향을 미치는지 온톨로지 그래프로 보여주는 데스크탑 웹 서비스. 코스콤 해커톤 2일 프로젝트.
| 항목 | 내용 |
|---|---|
| 인증 | 없음. 로그인, 회원가입, 마이페이지 구현 안 함 |
| 데이터 | 뉴스, 그래프, 분석, 검증 전부 서버 API에서 조회 |
| 클라이언트 저장 | 없음. localStorage 등 영속 저장 사용 안 함 |
| 관심종목 | 사용자가 입력한 종목과 브리핑 결과만 세션 메모리(Jotai) 에 유지. 새로고침하면 사라짐 |
| 플랫폼 | 데스크탑 웹 우선, 태블릿·모바일 반응형 대응 |
| 페이지 | SPA 단일 페이지. 라우터 없이 탭 전환 |
| 언어 | 한국어 전용 |
React 18 + TypeScript + Vite
@xyflow/react (v12) 그래프
Tailwind CSS + shadcn/ui tabs, badge, sheet, tooltip, command, scroll-area, separator
Jotai UI 상태 + 관심종목 메모리
TanStack Query 서버 상태 캐싱
npm i @xyflow/react jotai @tanstack/react-query
npx shadcn@latest add tabs badge sheet tooltip command scroll-area separatorimport '@xyflow/react/dist/style.css' // 필수. 빠뜨리면 노드가 겹쳐 보임3분할 그리드 312px | 1fr | 384px
┌─ GNB ───────────────────────────────────────────────┐
│ KOSLINK [뉴스맵] [예측 검증] │
├──────────┬──────────────────────┬───────────────────┤
│ A 뉴스 │ B 관계 그래프 │ C 영향 분석 │
│ │ [파급경로|전체관계망] │ │
│ 섹터 필터 │ │ 기사 요약 3줄 │
│ 뉴스카드 │ ○ ─── ○ │ 원문 링크 │
│ 뉴스카드 │ │ │ ───────────── │
│ 뉴스카드 │ ○ ○ │ 영향 기점 카드 │
│ │ │ 관련주 리스트 │
│ │ 범례 │ 판단 근거 3줄 │
└──────────┴──────────────────────┴───────────────────┘
[관심종목 브리핑] ← FAB
┌─ 컨텍스트 배너 (뉴스에서 진입했을 때만) ────────────────┐
├─ 요약 카드 ─────────────────────────────────────────┤
│ 지표 3개 (300px) │ 30일 적중률 추이 차트 │
├─────────────────────────────────────────────────────┤
│ 검증 목록 (392px) │ 선택한 뉴스 상세 │
│ 섹터 필터 │ 제목, 적중률 도넛 │
│ 뉴스 행 (스크롤) │ 종목별 예측 vs 실제 표 │
└─────────────────────────────────────────────────────┘
페이지 번호 UI 없음. 목록은 커서 기반 무한 스크롤(뉴스 목록과 동일한 방식), 상세는 우측 고정.
우측 슬라이드 시트 424px. shadcn Sheet.
종목 칩 입력 → 조회 → 결과 카드. 닫아도 상태 유지, FAB에 매칭 건수 배지.
| 폭 | 처리 |
|---|---|
| 1281px+ | 3분할 |
| 1081~1280 | 폭만 축소 |
| ~1080 | 뉴스 리스트를 상단 현재뉴스 바 + 바텀시트로 전환. 좌우 화살표로 이전/다음 이동 |
| ~820 | 1열 스택 |
모바일에서 뉴스 리스트를 가로 스크롤로 만들지 말 것.
type NodeKind = 'COMPANY' | 'PRODUCT' | 'THEME' | 'MATERIAL'
interface OntologyNode {
id: string // 'sk'
name: string // 'SK하이닉스'
kind: NodeKind
ticker?: string // COMPANY만
sector: string // '반도체' | '2차전지' | '방산'
marketCap?: number // 억원, COMPANY만
}
interface OntologyEdge {
id: string
source: string
target: string
relation: string // '장비 공급', '경쟁', '양극재 조달'
polarity: 1 | -1 // 1 동조 / -1 반대(경쟁·대체)
}polarity: -1이 핵심이다. 같은 뉴스에서 상승주와 하락주가 동시에 나오는 근거다.
베이스 /api. 인증 헤더 없음. 전부 TanStack Query로 감싼다.
GET /api/news?limit=20§or=반도체&cursor=n3
cursor는 클라이언트가 값을 해석하지 않는 opaque 문자열이다 — 이전 응답의
nextCursor를 다음 요청의 cursor에 그대로 담아 보낸다. 첫 요청은 cursor를
생략한다. 프론트는 useInfiniteQuery로 페이지를 쌓고, 목록 하단 sentinel이
뷰포트에 들어오면 다음 페이지를 요청한다(lib/queries.ts의 useNewsQuery).
GET /api/news/{id}/analysis
{
"newsId": "n1",
"title": "SK하이닉스, HBM4 양산 위해 청주 M15X 증설 확정",
"sector": "반도체",
"article": {
"summary": ["요약 1", "요약 2", "요약 3"],
"originUrl": "https://www.yna.co.kr/...",
"press": "연합뉴스",
"publishedAt": "2026-07-18T09:12:00Z",
},
"main": {
"nodeId": "sk",
"name": "SK하이닉스",
"ticker": "000660",
"direction": "UP",
"reason": "HBM4 증설 발표로 생산능력이 직접 확대되는 당사자",
},
"related": [
{
"nodeId": "hanmi",
"name": "한미반도체",
"ticker": "042700",
"direction": "UP",
"relation": "장비 공급",
"chain": ["sk", "hanmi"],
},
{
"nodeId": "ss",
"name": "삼성전자",
"direction": "DOWN",
"relation": "경쟁 관계",
"chain": ["sk", "ss"],
},
],
"rationale": {
"event": "SK하이닉스가 HBM4 대응 목적의 청주 M15X 증설을 확정",
"propagation": "장비를 대는 한미반도체·HPSP에는 수주 확대 요인, 경쟁하는 삼성전자에는 압박 요인",
"precedent": "동일 유형 공시 5건 중 4건에서 장비주가 익일 평균 +3.1%",
},
}주의
confidence같은 확률값은 받지도, 표시하지도 않는다- 영향의 크기는
chain.length - 1(hop 수)로만 표현한다 chain은 기점부터 대상까지의 노드 ID 배열. 그래프 배치와 애니메이션 순서가 전부 여기서 나온다rationale.propagation은 서버가 그래프 경로에서 템플릿 생성한다title/sector는 뉴스 목록(§5.1)이 페이지네이션되어 선택된 뉴스가 이미 로드된 페이지에 없을 수 있으므로, 분석 패널이 목록을 다시 훑지 않고도 헤더에 표시할 수 있게 여기 포함한다
GET /api/graph
→ { "nodes": OntologyNode[], "edges": OntologyEdge[] }
POST /api/briefing
{ "tickers": ["005930", "042700"] }
{
"totalNews": 20,
"matched": [
{
"ticker": "042700",
"name": "한미반도체",
"direction": "UP",
"relation": "장비 공급",
"chain": ["sk", "hanmi"],
"newsId": "n1",
"newsTitle": "SK하이닉스, HBM4 양산 위해...",
},
],
"unmatched": [{ "ticker": "005930", "name": "삼성전자" }],
}요청에 담는 tickers는 서버에 저장되지 않는다. 매번 클라이언트 메모리에서 보낸다.
GET /api/verify?days=30§or=반도체&cursor=v10&limit=10
{
"daily": [{ "date": "2026-07-17", "hitRate": 0.71 }], // 섹터/페이지와 무관한 전체 추이, 페이징하지 않음
"news": [
{
"newsId": "v1",
"date": "07-17",
"sector": "반도체",
"title": "SK하이닉스, 엔비디아향 HBM 공급 계약 확대",
"items": [
{
"name": "한미반도체",
"predicted": "UP",
"actualReturn": 3.42,
"hit": true,
"pathLabel": "장비 공급 · 1단계",
},
],
},
],
"nextCursor": "v10", // §5.1과 동일한 opaque 커서 관례. 마지막 페이지면 null
}daily(적중률 추이 차트)는 사전 배치로 계산된 정적 결과이며 페이징 대상이
아니다. news(검증 목록)만 §5.1과 동일한 커서 페이징을 적용해 무한
스크롤로 받는다. 프론트는 읽기만 한다.
React Flow에는 레이아웃 엔진이 없다. 좌표는 우리가 계산해서 position으로 넣는다.
dagre나 elkjs를 붙일 필요 없다. 방사형과 클러스터 배치는 단순 삼각함수로 결정론적으로 계산한다.
// nodeTypes / edgeTypes는 반드시 컴포넌트 바깥에 선언한다.
// 안에 두면 매 렌더마다 새 객체가 되어 그래프 전체가 리마운트된다. (가장 흔한 실수)
const nodeTypes = { stock: StockNode }
const edgeTypes = { rel: RelEdge }
;<ReactFlowProvider>
{' '}
{/* useInternalNode 사용에 필수 */}
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={handleNodeClick}
onNodeMouseEnter={handleEnter}
onNodeMouseLeave={handleLeave}
nodesConnectable={false}
elementsSelectable={false}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={2}
>
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1.2}
color="#E3E0DC"
/>
<Controls showInteractive={false} position="top-right" />
</ReactFlow>
</ReactFlowProvider>proOptions.hideAttribution은 유료 라이선스 기능이다. 어트리뷰션은 그대로 둔다.
type StockNodeData = {
label: string
ticker?: string
abstract: boolean // THEME·PRODUCT·MATERIAL
tier?: 't1' | 't2' | 't3' // 전체 관계망 위계
state:
| 'hidden'
| 'idle'
| 'dim'
| 'via'
| 'via2'
| 'up'
| 'down'
| 'up2'
| 'down2'
| 'origin'
isMain?: boolean
badge?: 'UP' | 'DOWN'
pulse?: boolean
}
function StockNode({ data }: NodeProps<StockNodeData>) {
return (
<div
className={cn(
'nd',
data.abstract && 'abs',
data.tier,
`nd-${data.state}`,
data.isMain && 'main',
data.pulse && 'pulse',
)}
>
<Handle type="target" position={Position.Top} isConnectable={false} />
{data.badge && (
<span className={cn('nbadge', data.badge.toLowerCase())}>
{data.badge === 'UP' ? '▲' : '▼'}
</span>
)}
<div className="nd-name">{data.label}</div>
{data.ticker && <div className="nd-tk">{data.ticker}</div>}
<Handle type="source" position={Position.Bottom} isConnectable={false} />
</div>
)
}Handle은 숨기되 반드시 넣는다. 없으면 React Flow가 엣지 연결 경고를 낸다.
기본 엣지는 Handle 위치에 고정된다. 방사형 배치에서는 선이 카드 옆구리에 어긋나 붙는다. 두 카드의 사각형 테두리 교점을 계산하는 엣지를 만든다.
function RelEdge({ id, source, target, data, markerEnd }: EdgeProps) {
const sn = useInternalNode(source)
const tn = useInternalNode(target)
if (!sn || !tn) return null
const box = (n: InternalNode) => ({
x: n.internals.positionAbsolute.x + (n.measured?.width ?? 150) / 2,
y: n.internals.positionAbsolute.y + (n.measured?.height ?? 58) / 2,
w: n.measured?.width ?? 150,
h: n.measured?.height ?? 58,
})
const A = box(sn),
B = box(tn)
const p1 = rectPoint(A, B),
p2 = rectPoint(B, A)
const [path, lx, ly] = getBezierPath({
sourceX: p1.x,
sourceY: p1.y,
targetX: p2.x,
targetY: p2.y,
sourcePosition: sideOf(p1, p2),
targetPosition: sideOf(p2, p1),
curvature: 0.28,
})
const showLabel = ['up', 'down', 'up2', 'down2', 'near'].includes(data.state)
return (
<>
<path
id={id}
d={path}
markerEnd={markerEnd}
className={cn('ep', data.state, data.draw && 'draw')}
/>
{showLabel && (
<EdgeLabelRenderer>
<div
className={cn('elabel', data.state)}
style={{
position: 'absolute',
transform: `translate(-50%,-50%) translate(${lx}px,${ly}px)`,
}}
>
{data.relation}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
// 카드 테두리 교점
function rectPoint(a: Box, b: Box) {
const dx = b.x - a.x,
dy = b.y - a.y
const hw = a.w / 2 + 6,
hh = a.h / 2 + 6
const t = Math.min(Math.abs(hw / (dx || 1e-6)), Math.abs(hh / (dy || 1e-6)))
return { x: a.x + dx * t, y: a.y + dy * t }
}
function sideOf(from: Pt, to: Pt) {
const dx = to.x - from.x,
dy = to.y - from.y
return Math.abs(dx) > Math.abs(dy)
? dx > 0
? Position.Right
: Position.Left
: dy > 0
? Position.Bottom
: Position.Top
}화살표는 엣지 객체에 지정하면 React Flow가 마커를 자동 생성한다.
markerEnd: { type: MarkerType.ArrowClosed, width: 15, height: 15,
color: polarity === -1 ? '#0E6E96' : '#F26722' }// 파급 경로: 기점 중앙, hop 레벨별 동심원
const RADIUS = [0, 300, 520, 700]
function radialLayout(ids: string[], level: Record<string, number>) {
const byLv: Record<number, string[]> = {}
ids.forEach((id) => (byLv[level[id]] ??= []).push(id))
const pos: Record<string, XYPosition> = {}
Object.entries(byLv).forEach(([lk, arr]) => {
const l = +lk
if (l === 0) {
pos[arr[0]] = { x: 0, y: 0 }
return
}
const step = (2 * Math.PI) / arr.length
const off = -Math.PI / 2 + (l % 2 ? 0 : step / 2)
arr.forEach((id, i) => {
const a = off + step * i,
r = RADIUS[Math.min(l, 3)]
pos[id] = { x: Math.cos(a) * r, y: Math.sin(a) * r * 0.76 } // 타원
})
})
return pos
}| 함수 | 역할 |
|---|---|
focusBuild(analysis) |
related[].chain을 펼쳐 노드·엣지·hop 레벨 산출 |
nodeBuild(nodeId, 2) |
특정 노드 기점 BFS 2단계 |
radialLayout() |
위 결과를 동심원 좌표로 |
fullLayout(graph) |
섹터 3클러스터 고정 좌표. 클러스터 안은 연결 수 내림차순 동심원 |
fullLayout 결과는 한 번 계산해 캐시한다. 뷰를 오갈 때마다 재계산하면 노드가 튄다.
모양 COMPANY = 실선 카드 / THEME·PRODUCT·MATERIAL = 점선 카드
크기 시총 30조↑ 172px · 5~30조 150px · 5조↓ 134px · 개념 노드 auto
위계 전체 관계망에서 연결 수로 3단계 (t1 진한 잉크 / t2 중간 / t3 연한)
색상 상승 주황 · 하락 딥애저 · 경유 흰색+주황테두리 · 무관 dim
라벨 노드는 항상 / 엣지는 활성 상태와 호버 시에만
주황은 "영향받는 것"에만 쓴다. GNB, 탭, 버튼 같은 장식에는 쓰지 않는다. 그래야 그래프에 주황이 켜지는 순간이 화면에서 유일한 사건이 된다.
React Flow의 animated: true를 쓰지 말 것. 점선이 무한 반복돼 정신없다.
CSS 1회 재생으로 구현하고 끝나면 멈춘다.
.ep.draw {
stroke-dasharray: 1400;
stroke-dashoffset: 1400;
animation: draw 0.48s cubic-bezier(0.33, 0, 0.2, 1) forwards; /* forwards = 상태 유지 */
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}뉴스 선택 시퀀스. setTimeout 체인으로 data.state를 갱신하면 CSS 트랜지션이 따라온다.
| 시점 | 동작 | 상단 라벨 |
|---|---|---|
| 0.30s | 기점 카드 점등 + 링 2회 확산 | 영향 기점 확인 |
| 0.80s | hop별 엣지가 순차적으로 그려짐 | 관계 경로 추적 |
| — | 도착 노드가 경유(흰색) 또는 최종(주황/애저)으로 전환 | |
| 1.60s | 관련주가 0.21s 간격으로 확정, 우측 카드도 동시 강조 | 영향 종목 산출 |
타이머는 useRef<number[]>에 모아 두고 언마운트·재선택 시 전부 clearTimeout 한다.
| 현재 뷰 | 노드 클릭 |
|---|---|
| 전체 관계망 | 배치 유지. 그 자리에서 2단계 파급만 강조 (1단계 진하게, 2단계 옅게, 나머지 dim) |
| 파급 경로 | 그 종목을 기점으로 동심원 재배치 |
| 종목 기점 탐색 | 다른 종목으로 계속 이동. 뒤로가기 칩으로 원위치 |
하이라이트가 켜져 있는 동안 호버 강조를 막아야 한다. 안 그러면 마우스를 떼는 순간 지워진다.
const onNodeMouseLeave = () => {
if (highlightedNode) return // 이 가드가 없으면 하이라이트가 사라진다
resetHoverEdges()
}Jotai는 단일 스토어 객체가 아니라 atom 단위로 상태를 쪼갠다. 문서 전체에서
언급하는 슬라이스를 각각 독립 atom으로 선언한다 (lib/atoms.ts):
import { atom } from 'jotai'
export const viewAtom = atom<'map' | 'verify'>('map')
export const selectedNewsIdAtom = atom<string | null>(null)
export const graphModeAtom = atom<'focus' | 'all' | 'node'>('focus')
export const highlightedNodeAtom = atom<string | null>(null) // 전체 관계망 제자리 강조
export const backToAtom = atom<'focus' | 'all' | 'reset'>('focus')
export const sectorFilterAtom = atom<string>('전체') // '전체' | '반도체' | ...
export const verifyContextAtom = atom<{
label: string
names: string[]
} | null>(null) // 뉴스에서 진입 시 종목명 배열
export const newsSheetOpenAtom = atom(false) // 모바일 바텀시트
// 브리핑 — 메모리 전용, 영속 저장 없음
export const briefingTickersAtom = atom<string[]>([])
export const briefingResultAtom = atom<BriefingResponse | null>(null)
export const briefingRanAtAtom = atom<string | null>(null)서버 데이터(뉴스, 그래프, 분석, 검증)는 atom에 넣지 않는다. TanStack Query가
관리한다(백엔드 연결 전까지는 lib/api.ts가 로컬 데이터를 동기적으로
반환한다). Jotai atom은 UI 상태와 관심종목 메모리만 담당한다.
파생 상태가 필요하면(예: 선택된 뉴스 객체, 필터링된 뉴스 목록) 각 atom을
get으로 조합하는 파생 atom(atom((get) => ...))을 추가로 선언해 컴포넌트
안에서 매번 다시 계산하지 않도록 한다.
verifyContext가 있으면 검증 화면은 그 종목이 포함된 과거 검증만 보여주고 상단에 배너를 띄운다.
뉴스 상세의 "이 종목들의 지난 예측 적중률 보기" 버튼이 이 값을 채운다.
:root {
/* Primary — 코스콤 오렌지 */
--o-50: #fff4ed;
--o-100: #ffe4d3;
--o-200: #ffc7a6;
--o-300: #fca271;
--o-500: #f26722;
--o-600: #dd5410;
--o-700: #b0400b;
/* Negative — 주황의 보색 계열 딥애저. 선명한 파랑은 주황과 충돌한다 */
--d-50: #e9f3f8;
--d-100: #cfe5ef;
--d-500: #0e6e96;
--d-600: #0a5878;
--d-700: #08455e;
/* Warm neutrals — 쿨 그레이를 쓰면 주황이 겉돈다 */
--n-0: #ffffff;
--n-25: #fcfcfb;
--n-50: #fafaf9;
--n-100: #f4f3f1;
--n-150: #edebe8;
--n-200: #e3e0dc;
--n-300: #d6d3d1;
--n-400: #b4afaa;
--n-500: #8a847e;
--n-600: #57534e;
--n-700: #44403c;
--n-900: #1c1917;
}- 폰트 Pretendard, 라디우스 카드 16px·요소 10~13px
- 그림자 아주 약하게
0 1px 2px rgba(28,25,23,.04), 0 6px 18px rgba(28,25,23,.06) - 자간 제목류
-0.03em전후 - shadcn 기본 테마를 그대로 쓰면 티가 난다. 설치 직후 위 토큰으로
--primary,--border,--radius를 교체할 것
- 로그인, 회원가입, 마이페이지, 설정 화면
- localStorage 등 클라이언트 영속 저장
- 신뢰도 퍼센트 표시 (검증 화면의 실측 등락률·적중률은 예외)
- React Flow
animated: true또는 반복 재생 애니메이션 nodeTypes/edgeTypes를 컴포넌트 안에서 선언- dagre·elkjs 등 레이아웃 라이브러리 추가
- 모바일 가로 스크롤 뉴스 리스트
- 뉴스 목록·검증 목록에 페이지 번호(1, 2, 3 …) UI 추가 — 둘 다 커서 기반 무한 스크롤만 쓴다
- 기사 본문 전문 저장·표시 (저작권. 요약 3줄 + 원문 링크만)
- 그래프 노드 안에 shadcn
Card사용 (래퍼가 크기 계산을 방해) - 장식 목적의 주황색 사용
src/
├── app/
│ ├── App.tsx
│ └── queryClient.ts
├── components/
│ ├── ui/ # shadcn
│ ├── news/
│ │ ├── NewsList.tsx
│ │ ├── NewsCard.tsx
│ │ └── MobileNewsBar.tsx # 반응형 상단 바 + 바텀시트
│ ├── graph/
│ │ ├── GraphPanel.tsx # ReactFlowProvider, 모드 전환, 범례
│ │ ├── StockNode.tsx
│ │ ├── RelEdge.tsx # 플로팅 엣지
│ │ └── usePropagation.ts # 하이라이트 시퀀스 훅
│ ├── analysis/
│ │ ├── AnalysisPanel.tsx
│ │ ├── MainStockCard.tsx
│ │ ├── RelatedList.tsx
│ │ └── RationaleBox.tsx
│ ├── verify/
│ │ ├── VerifyView.tsx
│ │ ├── VerifyList.tsx
│ │ └── VerifyDetail.tsx
│ └── briefing/
│ └── BriefingSheet.tsx
├── lib/
│ ├── api.ts # fetch 래퍼 + Query 훅
│ ├── atoms.ts # Jotai atom (UI 상태 + 관심종목 메모리)
│ ├── layout.ts # focusBuild, nodeBuild, radialLayout, fullLayout
│ └── format.ts # 방향 라벨, 영향 태그(직접/간접/확산)
└── types/index.ts
발표 당일 네트워크 장애 대비. 우선순위 높음.
- 모든 Query에 로컬 목 데이터 폴백을 둔다 (
placeholderData또는 에러 시 fallback) - 데모용 뉴스 5건은 분석 결과를 번들에 포함해 즉시 응답하게 한다
- 외부 CDN 의존성은 전부 로컬로 내려받아 번들에 포함한다
- 빌드된 정적 파일로 발표한다. 개발 서버로 시연하지 않는다
{ "items": [ { "id": "n1", "title": "SK하이닉스, HBM4 양산 위해 청주 M15X 증설 확정", "press": "연합뉴스", "publishedAt": "2026-07-18T09:12:00Z", "sector": "반도체", }, ], "nextCursor": "n1", // 다음 페이지 요청 시 그대로 실어 보낸다. 마지막 페이지면 null }