왜 만들었나
시작은 단순했다. 주가를 좀 더 잘 이해하고 싶었다.
주가는 결국 뉴스를 통해 움직인다. A 기업 실적이 나빠지면 B 기업 주가가 흔들린다. 공급망이 엮여 있거나, 같은 그룹 계열사거나, 경쟁 관계이거나. 이 맥락을 빠르게 파악할 수 있으면 뉴스 해석이 달라진다고 생각했다.
그래서 기업 관계를 시각화하는 프로젝트를 만들어보기로 했다. 다만 기업 데이터는 범위가 너무 넓다. 전 세계 기업을 다 다루는 건 현실적이지 않으니까, 이름이 친근한 국내 대기업부터 시작했다.
그런데 막상 데이터를 채우려니 복잡했다. 삼성그룹 계열사만 해도 수십 개다. 모회사 선택 → 자회사 선택 → 지분율 입력 → 등록을 반복하다 보면 이 작업 자체가 병목이 된다. DART에 이미 공시 데이터가 있는데 손으로 입력하는 건 낭비다.
그래서 DART API를 붙였다. 처음엔 주주 현황(hyslrSttus) 하나였는데, 수집 방향 문제를 발견하면서 계열회사 현황(affiSttus)까지 추가됐다. 하다 보니 임원 수집, 뉴스 크롤링, 지주사 등급 계산까지 붙었다. 단순하게 시작한 게 꽤 복잡한 시스템이 됐다.
기술 스택
| 항목 | 선택 | 이유 |
|---|---|---|
| DART API | hyslrSttus + affiSttus |
대주주 현황(역방향) + 계열회사 현황(순방향) |
| 공정위 지주회사 API | holdingProgCompStusListApi |
공식 등록 지주회사 → 자회사 → 손자회사 계층 (175개, 2,516건) |
| 공정위 주주현황 API | stockholderCompSttusListApi |
대규모기업집단 소속회사 간 지분보유 관계 (삼성·현대차 포함, 7,500건) |
| DB | Supabase PostgreSQL | ilike 퍼지 매칭으로 이름 변형 처리 |
| 관계 검증 | verified boolean + source |
자동 생성 데이터와 수동 등록 데이터 구분, 출처 추적 |
| 프레임워크 | Next.js 16 App Router | API 라우트 + 어드민 페이지 통합 |
아키텍처
DART API
├─ hyslrSttus → crawlAndSaveMajorShareholders() // "X의 주주가 누구인가"
│ ├─ 개인 주주 → company_people 저장
│ └─ 법인 주주 → company_relations 후보 생성 (from=주주, to=X)
│
└─ affiSttus → crawlAndSaveAffiliates() // "이 회사가 보유한 계열사"
└─ 계열사 목록 → company_relations 후보 생성 (from=본사, to=계열사)
↓
어드민 /admin/relations → 후보 검토 → 승인/거절
공정위 API는 DART와 별개 레이어다. DART는 각 기업의 주주·계열사 데이터를 기업별로 크롤하는 방식인 반면, 공정위는 전체 데이터를 한 번에 내려준다. 두 가지 API를 활용한다.
holdingProgCompStusListApi: 공정거래법상 등록된 지주회사의 자회사·손자회사 계층 구조. LG·SK·CJ·GS·롯데 등 175개 지주사 커버. 삼성·현대차는 순환출자 구조라 미등록.
stockholderCompSttusListApi: 삼성·현대차를 포함한 대규모기업집단 소속회사 간 지분보유 현황. shrholdrSe == '소속회사'인 레코드만 필터링하면 계열사 간 관계를 추출할 수 있다. 지주회사 API가 커버하지 못하는 그룹의 관계를 보완한다.
두 DART API를 병행해야 하는 이유는 수집 방향이 다르기 때문이다. hyslrSttus는 역방향(주주→기업), affiSttus는 순방향(기업→계열사). 지주회사가 100% 자회사를 가진 경우, 자회사 주주 공시에 지주회사가 나타나지만 지주회사의 계열회사 현황에도 자회사가 나타난다. 서로 보완 관계다.
자동 감지는 상장사 간 관계에만 동작한다. 비상장사는 DART 공시 의무가 없어 주주 데이터가 없다.
핵심 구현
1. 법인 주주 감지 및 관계 생성 — lib/crawlers/dart.ts
기존 코드는 법인 주주를 만나면 continue로 건너뛰었다.
// Before
const isCompany = ['법인', '은행', '펀드', '투자', '자산', '회사'].some(
(k) => name.includes(k)
)
if (isCompany) continue
이 조건 대신 법인 주주를 DB에서 찾아 관계 후보를 생성하도록 바꿨다.
// After
if (isCompany) {
// 이름 정제: "삼성물산(주)" → "삼성물산", "주식회사 LG" → "LG"
const cleanName = name
.replace(/주식회사\s*/g, '')
.replace(/\s*주식회사/g, '')
.replace(/\(주\)/g, '')
.replace(/\(유\)/g, '')
.trim()
if (!cleanName) continue
// DB 퍼지 매칭
const { data: matched } = await supabaseAdmin
.from('companies')
.select('id, name')
.ilike('name', `%${cleanName}%`)
.limit(1)
.single()
if (!matched) continue
const equity = parseFloat(item.trmend_posesn_stock_qota_rt)
// 지분율 기반 관계 유형 결정
// 0.7% 주주를 자회사로 표시하는 건 데이터 왜곡이다
let relationType: string
if (equity >= 50) relationType = 'subsidiary' // 자회사
else if (equity >= 20) relationType = 'affiliated' // 관계회사
else if (equity >= 5) relationType = 'investment' // 단순투자
else continue // 5% 미만 스킵
// 중복 체크 — 이미 있으면 관계 유형 업데이트
const { data: existing } = await supabaseAdmin
.from('company_relations')
.select('id, relation_type')
.eq('from_company', matched.id)
.eq('to_company', companyId)
.single()
if (existing) {
if (existing.relation_type !== relationType) {
await supabaseAdmin
.from('company_relations')
.update({ relation_type: relationType, equity_pct: isNaN(equity) ? null : equity })
.eq('id', existing.id)
}
continue
}
// 관계 후보 생성 (verified: false)
await supabaseAdmin.from('company_relations').insert({
from_company: matched.id,
to_company: companyId,
relation_type: relationType,
equity_pct: isNaN(equity) ? null : equity,
verified: false,
})
autoSuggestedCount++
continue
}
2. 관계 후보 검토 UI — app/admin/relations/page.tsx
verified: false 관계를 별도 섹션으로 분리해 노란 박스에 표시했다. 승인은 PATCH로 verified: true, 거절은 DELETE다.
// 관계 후보 조회
const unverified = relations.filter(r => r.verified === false)
// 승인
async function handleApprove(id: string) {
await fetch(`/api/admin/relations/${id}`, {
method: 'PATCH',
body: JSON.stringify({ verified: true }),
})
loadRelations()
}
// 거절
async function handleReject(id: string) {
await fetch(`/api/admin/relations/${id}`, { method: 'DELETE' })
loadRelations()
}
{unverified.length > 0 && (
<div className="border border-yellow-500/30 bg-yellow-500/5 rounded-xl p-6">
<h2>관계 후보 ({unverified.length}건)</h2>
<p>DART 주주 데이터에서 자동 감지 — 검토 후 승인 또는 거절하세요</p>
{unverified.map(r => (
<div key={r.id} className="flex items-center justify-between">
<span>{r.from?.name} → {r.to?.name}</span>
<span>{r.equity_pct}%</span>
<button onClick={() => handleApprove(r.id)}>승인</button>
<button onClick={() => handleReject(r.id)}>거절</button>
</div>
))}
</div>
)}
3. 일괄 관계 등록 — app/api/admin/relations/bulk/route.ts
모회사 1개 + 자회사 N개를 한 번에 등록하는 API다. 중복은 자동으로 스킵한다.
export async function POST(req: Request) {
const { from_company, relation_type, to_companies, equity_pct, since, until }
= await req.json()
// 기존 관계 조회
const { data: existing } = await supabaseAdmin
.from('company_relations')
.select('to_company')
.eq('from_company', from_company)
.in('to_company', to_companies)
const existingSet = new Set(existing?.map(r => r.to_company) ?? [])
const targets = to_companies.filter(
(id: string) => id !== from_company && !existingSet.has(id)
)
if (targets.length === 0) {
return Response.json({ success: 0, skipped: to_companies.length, skippedNames: [] })
}
const rows = targets.map((to: string) => ({
from_company,
to_company: to,
relation_type,
equity_pct: equity_pct != null ? equity_pct : null,
since: since || null,
until: until || null,
verified: true,
}))
await supabaseAdmin.from('company_relations').insert(rows)
return Response.json({
success: targets.length,
skipped: to_companies.length - targets.length,
})
}
equity_pct || null 대신 equity_pct != null ? equity_pct : null을 쓴다. 0%가 입력됐을 때 falsy 처리로 null이 저장되는 버그를 막기 위해서다.
4. 계열회사 현황 수집 — crawlAndSaveAffiliates()
hyslrSttus만으로는 지주회사 → 계열사 방향 관계가 누락된다. 예를 들어 (주)LG경영개발원이 LG전자를 35% 보유하고 있어도, LG전자의 hyslrSttus에 (주)LG가 33%로 기록되면 (주)LG경영개발원은 5% 미만 또는 별도 표기 없이 묻힐 수 있다. 반면 (주)LG경영개발원의 affiSttus에는 LG전자가 계열사로 명시된다.
async function crawlAndSaveAffiliates(dartCode: string, companyId: string) {
const apiKey = process.env.DART_API_KEY!
// affiSttus: 기본 API 키는 status 101로 막힐 수 있음 → 조용히 스킵
const list = await fetchDartReport('affiSttus.json', dartCode, apiKey)
if (list.length === 0) return 0
let saved = 0
for (const item of list) {
const name: string = item.afi_corp_name?.trim()
if (!name) continue
const cleanedName = cleanCorpName(name)
const { data: matched } = await supabaseAdmin
.from('companies')
.select('id, name')
.ilike('name', `%${cleanedName}%`)
.limit(1)
.maybeSingle()
if (!matched || matched.id === companyId) continue
// 기존 관계 확인 — from_company=지주사, to_company=계열사
const { data: existingRel } = await supabaseAdmin
.from('company_relations')
.select('id')
.eq('from_company', companyId)
.eq('to_company', matched.id)
.in('relation_type', ['subsidiary', 'affiliated', 'investment'])
.maybeSingle()
if (!existingRel) {
await supabaseAdmin.from('company_relations').insert({
from_company: companyId,
to_company: matched.id,
relation_type: 'affiliated',
verified: false,
source: 'dart_affil',
})
saved++
}
}
return saved
}
기본 DART API 키로는 affiSttus가 status 101(부적절한 접근)을 반환할 수 있다. fetchDartReport는 000 외의 상태코드에서 []를 반환하므로 오류 없이 조용히 스킵된다. API 키 등급을 올리면 코드 변경 없이 자동으로 활성화된다.
crawlAndSaveDARTCompany에서 세 크롤러를 병렬로 호출한다.
await Promise.allSettled([
crawlAndSaveExecutives(dartCode, companyId),
crawlAndSaveMajorShareholders(dartCode, companyId),
crawlAndSaveAffiliates(dartCode, companyId), // 추가
])
5. 지주사 등급 자동 계산 — top / sub
지주회사가 많아지면 단순히 is_holding: true로는 부족하다. (주)LG와 (주)LG경영개발원이 둘 다 지주사인데 어느 쪽이 상위인지 구분이 안 되기 때문이다.
DB 컬럼을 추가하지 않고 런타임에 계산하는 방식을 선택했다. 판정 기준은 단순하다. 어떤 지주사 A가 다른 지주사 B의 자회사 포지션에 있으면 B는 계열 지주사(sub), 그렇지 않으면 최상위 지주사(top)다.
// CompanyGraph.tsx 내부
const { holdingIds, subHoldingIds } = useMemo(() => {
const holdingIds = new Set(
visibleCompanies.filter((c) => c.is_holding).map((c) => c.id)
)
const subHoldingIds = new Set(
visibleRelations
.filter((r) => holdingIds.has(r.to_company) && holdingIds.has(r.from_company))
.map((r) => r.to_company)
)
return { holdingIds, subHoldingIds }
}, [visibleCompanies, visibleRelations])
// 각 노드에 주입
holdingTier: !c.is_holding ? null : subHoldingIds.has(c.id) ? 'sub' : 'top',
어드민용으로는 별도 API(/api/admin/companies/holding-tiers)를 만들어 기업 목록 페이지에서 같이 불러온다. 지주사 컬럼 버튼에 "최상위 지주사" / "계열 지주사" 텍스트와 색상을 분기했다.
그래프 레이아웃에서는 top 지주사 노드를 post-process로 최상단 y 위치에 고정했다.
const topHoldingNodeIds = new Set(
rawNodes.filter((n) => (n.data as any).holdingTier === 'top').map((n) => n.id)
)
const minY = Math.min(...laidNodes.map((n) => n.position.y))
finalLaidNodes = laidNodes.map((n) =>
topHoldingNodeIds.has(n.id) ? { ...n, position: { ...n.position, y: minY } } : n
)
6. 공정위 지주회사 수집 — lib/crawlers/ftc-holding.ts
DART 크롤러가 기업별로 순차 수집하는 방식이라면, 공정위 API는 전체 지주회사 계층을 한 번에 내려준다. 175개 지주회사, 2,516건. 일일 트래픽 한도 10,000건 대비 API 호출은 약 6회(500건 페이지 × 6)로 충분히 여유 있다.
// 공정위 XML 응답 필드
// unityGrupNm - 지주회사명
// cdpnyNm - 자회사/손자회사명
// cdpnyQotaRate - 지분율
// hldcpSeNm - "자회사" | "손자회사"
// parentJurirno - 손자회사의 직속 모회사 법인번호
for (const item of items) {
if (item.hldcpSeNm === '자회사') {
// 지주회사 → 자회사 관계 생성
const holdId = await matchCompany(item.unityGrupNm)
const subId = await matchCompany(item.cdpnyNm)
await upsertRelation(holdId, subId, equity, 'ftc_sub')
} else if (item.hldcpSeNm === '손자회사') {
// 자회사 → 손자회사 관계 생성
// parentJurirno로 중간 자회사를 역조회해서 from_company 특정
const parentName = jurirnoToName.get(item.parentJurirno)
const parentId = await matchCompany(parentName)
const grandId = await matchCompany(item.cdpnyNm)
await upsertRelation(parentId, grandId, equity, 'ftc_grandsub')
}
}
한 가지 함정이 있었다. 공정위는 영문 브랜드를 한글 발음으로 등록한다. (주)엘지, 에스케이(주), 씨제이(주). 우리 DB엔 (주)LG, SK, CJ로 저장돼 있어서 ilike 매칭이 실패한다. 매핑 테이블을 기업명 정제 함수 안에 넣었다.
const FTC_NAME_MAP: Record<string, string> = {
'엘지': 'LG', '에스케이': 'SK', '씨제이': 'CJ', '지에스': 'GS',
'에이치디현대': 'HD현대', '오씨아이': 'OCI', '케이비': 'KB', // ...
}
function cleanName(name: string): string {
let clean = name.replace(/주식회사|\(주\)|\(유\)/g, '').trim()
for (const [kor, eng] of Object.entries(FTC_NAME_MAP)) {
if (clean.includes(kor)) { clean = clean.replace(kor, eng); break }
}
return clean.trim()
}
공정위 출처 관계(source: 'ftc_sub', 'ftc_grandsub')는 DART 재크롤 시 덮어쓰지 않도록 dart.ts에도 체크를 추가했다.
// dart.ts — 주주 관계 수집 시
if (existingRel?.source?.startsWith('ftc_')) {
saved++
continue // 공정위 공식 데이터 우선, DART 추론으로 교체하지 않음
}
7. 대규모기업집단 주주현황 수집 — lib/crawlers/ftc-shareholder.ts
공정위 지주회사 API가 175개 지주사만 커버하는 반면, 주주현황 API는 삼성·현대차·롯데 등 순환출자 그룹을 포함한 대규모기업집단 전체를 다룬다. 전체 7,500건 응답 중 shrholdrSe == '소속회사'인 레코드만 걸러내면 계열사가 다른 계열사에 투자한 관계만 추출된다.
// 응답 예시
// [BGF] (주)비지에프 → (주)비지에프리테일 30%
// [DB] (주)디비하이텍 → (주)디비글로벌칩 100%
// [삼성] 삼성생명보험(주) → 삼성전자(주) N%
const crossItems = allItems.filter(item => item.shrholdrSe === '소속회사')
for (const item of crossItems) {
// item.shrholdrNm = 지분 보유 계열회사 (from)
// item.entrprsNm = 지분 보유당하는 소속회사 (to)
const fromId = await matchCompany(item.shrholdrNm)
const toId = await matchCompany(item.entrprsNm)
await upsertRelation(fromId, toId, equityPct) // source: 'ftc_shareholder'
}
startsWith('ftc_') 체크로 ftc_shareholder 출처도 자동으로 보호된다. DART 재크롤이 공정위 데이터를 덮어쓰는 일이 없다.
처음에 shrholdrSe == '계열회사'로 필터링했다가 결과가 0건이었다. 실제 값을 확인해보니 '소속회사'가 맞는 표기였다. 필드 값을 직접 확인하지 않고 이름을 추측한 실수였다.
9. 전체 재크롤 엔드포인트 — app/api/admin/crawl/all/route.ts
크롤러 로직이 바뀌면 기존 데이터도 다시 수집해야 한다. 단건 재크롤은 있었지만 전체 일괄 재크롤 기능이 없었다.
export async function POST(request: NextRequest) {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: '프로덕션에서는 사용 불가' }, { status: 403 })
}
const { limit = 200, offset = 0, delay = 400 } = await request.json().catch(() => ({}))
const { data: companies } = await supabaseAdmin
.from('companies')
.select('id, name, dart_code')
.not('dart_code', 'is', null)
.order('name')
.range(offset, offset + limit - 1)
for (const company of companies ?? []) {
await crawlAndSaveDARTCompany(company.dart_code!)
await new Promise((r) => setTimeout(r, delay))
}
// ...
}
프로덕션 배포 시에는 NODE_ENV === 'production' 체크로 403을 반환하므로 로컬 개발 전용으로 안전하게 열어둘 수 있다. 어드민 크롤 페이지에 "전체 재크롤" 섹션을 붙여 진행률 바와 배치 로그를 표시했다.
10. 수집 현황 Stats API — app/api/admin/stats/route.ts
어드민 기업 관리 페이지 상단에 전체 수집 현황을 카드로 보여주는 API를 따로 만들었다.
export async function GET() {
const [
{ count: totalCompanies },
{ count: dartCompanies },
{ count: totalRelations },
{ count: verifiedRelations },
{ count: candidateRelations },
] = await Promise.all([
supabaseAdmin.from('companies').select('*', { count: 'exact', head: true }),
supabaseAdmin.from('companies').select('*', { count: 'exact', head: true }).not('dart_code', 'is', null),
supabaseAdmin.from('company_relations').select('*', { count: 'exact', head: true }),
supabaseAdmin.from('company_relations').select('*', { count: 'exact', head: true }).eq('verified', true),
supabaseAdmin.from('company_relations').select('*', { count: 'exact', head: true }).eq('verified', false),
])
return NextResponse.json({
companies: {
total: totalCompanies ?? 0,
dart_crawled: dartCompanies ?? 0, // dart_code가 있는 기업 = DART 수집 기업
manual: (totalCompanies ?? 0) - (dartCompanies ?? 0),
},
relations: {
total: totalRelations ?? 0,
verified: verifiedRelations ?? 0,
candidates: candidateRelations ?? 0, // verified: false = 자동 감지 후보
},
})
}
모두 count: 'exact'만 쓰므로 실제 데이터를 전송하지 않는다. 응답 속도가 빠르고 max_rows 제한과 완전히 무관하다. 어드민 페이지 진입 시 기업 목록과 함께 병렬로 호출한다.
5. 의견 제출 위젯 — components/FeedbackWidget.tsx
그래프 페이지 하단에 플로팅 피드백 버튼을 붙였다. 현재 경로를 자동으로 첨부해서 어느 페이지에서 제출했는지 어드민이 확인할 수 있다.
'use client'
import { usePathname } from 'next/navigation'
type Phase = 'idle' | 'open' | 'submitting' | 'done'
export default function FeedbackWidget() {
const pathname = usePathname()
const [phase, setPhase] = useState<Phase>('idle')
async function handleSubmit() {
setPhase('submitting')
await fetch('/api/feedback', {
method: 'POST',
body: JSON.stringify({ message, email, page_path: pathname }),
})
setPhase('done')
setTimeout(() => setPhase('idle'), 2000)
}
if (phase === 'idle') {
return (
<button onClick={() => setPhase('open')}
className="fixed bottom-6 left-1/2 -translate-x-1/2 ...">
의견 보내기
</button>
)
}
// ...
}
비교 테이블
등록 방식
| 항목 | 수동 등록 | DART 자동 감지 |
|---|---|---|
| 대상 | 모든 기업 | 상장사 (코스피/코스닥) |
| 정확도 | 100% (직접 입력) | 매칭 후 어드민 검토 |
| 속도 | 기업당 30초 ~ 1분 | 크롤링 시 자동 |
| 커버리지 | 무제한 | ~2,600개사 범위 |
| 비상장 자회사 | 가능 | 불가 (수동 필요) |
지분율 기반 관계 유형 분류
| 지분율 | 관계 유형 | 그래프 표시 |
|---|---|---|
| 50% 이상 | 자회사 (subsidiary) | 파란 실선 + 애니메이션 |
| 20 ~ 49% | 관계회사 (affiliated) | 틸 점선 (#2dd4bf) |
| 5 ~ 19% | 단순투자 (investment) | 회색 반투명 점선 |
| 5% 미만 | 스킵 | — |
5%는 한국 자본시장법상 대량보유 공시 기준선이다. 이 이하는 경영 영향력이 없는 재무적 투자로 보고 관계 데이터에서 제외했다.
삽질 기록
equity_pct = 0이 null로 저장됨
// 문제 코드
equity_pct: equity_pct || null // 0 → null
// 수정
equity_pct: equity_pct != null ? equity_pct : null
지분율 0%는 유효한 값이다. || 연산자는 0을 falsy로 처리해서 null로 덮어쓴다.
CompanySearch 드롭다운이 재선택 후 닫힘
// 문제: 선택 후 드롭다운이 사라짐
{open && !selected && <Dropdown />}
// 수정: 선택 상태와 무관하게 open일 때 표시
{open && <Dropdown />}
multiple 모드에서는 선택 후에도 드롭다운이 유지돼야 한다.
contains() 타입 에러
// 문제
if (!ref.current.contains(e.target as HTMLElement)) { ... }
// 수정
if (!ref.current.contains(e.target as Node)) { ... }
contains()는 Node | null을 받는다. HTMLElement는 Node의 서브타입이지만 TypeScript 4.x 이상에서 이 캐스팅은 오류로 처리된다.
relation_type FK 오류 — DB에 없는 값 삽입 시도
company_relations.relation_type은 relation_types 테이블을 참조하는 외래키다. affiliated, investment를 코드에서 쓰기 전에 DB에 먼저 INSERT해야 한다.
INSERT INTO relation_types (key, label, color) VALUES
('affiliated', '관계회사', '#2dd4bf'),
('investment', '단순투자', '#94a3b8')
ON CONFLICT (key) DO NOTHING;
코드 배포 전에 마이그레이션이 선행돼야 하는 케이스다. 순서가 바뀌면 FK constraint violation이 발생한다.
Supabase max_rows 1000 제한 — .limit(10000)이 먹히지 않음
기업 수가 1000개를 넘어서자 사용자 화면에서 계속 1000개만 표시됐다. 코드에서 .limit(10000)을 걸었는데도 변화가 없었다.
원인은 Supabase 대시보드의 PostgREST 설정이었다. Project Settings → API → Max Rows가 기본값 1000으로 설정되어 있으면, 클라이언트에서 아무리 큰 .limit()을 넣어도 서버에서 1000개로 잘려 반환된다.
대시보드 설정을 바꾸는 것이 가장 단순한 해결책이지만, 코드 레벨에서도 완전히 해결하는 방법이 있다. count: 'exact'로 총 개수를 먼저 확인한 뒤 .range()로 1000개씩 분할 요청해서 합치는 방식이다.
// 전체 카운트 확인 — HEAD 요청이라 max_rows 적용 안 됨
const { count } = await supabaseAdmin
.from('companies')
.select('*', { count: 'exact', head: true })
.eq('country', country)
const total = count ?? 0
const CHUNK = 1000
const all: any[] = []
// 1000개씩 청크 분할 → max_rows 우회
for (let from = 0; from < total; from += CHUNK) {
const { data } = await supabaseAdmin
.from('companies')
.select('*')
.eq('country', country)
.order('name')
.range(from, from + CHUNK - 1)
all.push(...(data ?? []))
}
count: 'exact'는 SELECT COUNT(*)만 실행하는 HEAD 요청이라 max_rows 제한이 적용되지 않는다. 그래서 실제 총 개수를 정확히 알 수 있고, 이걸 기준으로 청크를 나눠 요청하면 어떤 설정에서도 전체 데이터를 가져올 수 있다.
/api/companies, /api/relations, /api/admin/companies/list 세 곳 모두에 동일하게 적용했다.
관계회사 엣지가 인수합병과 같은 색으로 보임
배포 전에 프로덕션에서 확인하면 RELATION_COLORS에 affiliated가 없어서 fallback #6366f1(인디고)로 렌더링된다. 인수합병 색상 #8b5cf6(보라)과 육안으로 구분이 안 된다. 배포 후 해결되는 문제지만 테스트 시 혼동할 수 있다.
hyslrSttus만으로는 지주사 → 계열사 방향이 안 잡힘
hyslrSttus는 "X를 보유한 주주는 누구인가"를 반환한다. LG전자를 크롤하면 (주)LG가 주주로 나오고 (주)LG → LG전자 관계가 생긴다. 그러면 (주)LG경영개발원을 크롤할 때는? hyslrSttus가 반환하는 건 (주)LG경영개발원의 주주들(구씨 일가)이지, (주)LG경영개발원이 보유한 기업 목록이 아니다.
결과적으로 아무리 크롤해도 (주)LG경영개발원 → LG전자 관계는 생성되지 않는다.
해결은 affiSttus(계열회사 현황)를 추가로 크롤하는 것이다. 이 API는 해당 기업이 "보유한 계열사 목록"을 반환하므로 from_company = 본사, to_company = 계열사 방향을 직접 생성할 수 있다. 현재 기본 API 키로는 status 101 응답이지만 코드는 이미 준비해뒀다.
# 수집 방향 차이
hyslrSttus(LG전자) → (주)LG가 LG전자를 33% 보유 → (주)LG → LG전자 ✓
hyslrSttus(LG경영개발원) → 구씨 일가가 LG경영개발원 보유 → [구씨] → LG경영개발원 ✓
affiSttus(LG경영개발원) → LG전자, LG화학 등이 계열사 → LG경영개발원 → LG전자 ✓
공정위 주주현황 API — 계열회사가 아니라 소속회사
대규모기업집단 주주현황 API에서 계열사 간 관계를 필터링하려고 shrholdrSe == '계열회사'로 조건을 걸었는데 결과가 0건이었다.
실제 필드 값을 확인해보니 공정위가 쓰는 표기는 소속회사였다. 즉 shrholdrSe의 유니크 값은:
동일인 | 친족 | 소속회사 | 비영리법인 | 임원 | 자기주식 | 기타
이름을 추측하지 말고 실제 응답에서 직접 확인하는 게 맞다. Set으로 유니크 값을 먼저 뽑아보고 나서 필터를 짜는 게 순서다.
지주사 구분 — 현대차그룹 사례
"현대그룹의 지주사는 무엇인가"에 대한 답이 생각보다 복잡하다.
현대차그룹은 공정거래법상 지주회사 체제가 아니다. 순환출자 구조다.
오너일가 → 현대모비스 → 현대자동차 → 기아 → 현대모비스 (순환)
실질적 지배 기점이 현대모비스이므로 is_holding = true를 현대모비스에 부여하고, 그래프에서 보라색 배지로 표시하는 방식을 택했다. 삼성그룹도 동일 — 삼성물산이 사실상 지주 역할.
마무리
DART 자동 감지를 추가하면 상장사 간 지배구조 데이터 수집 속도가 크게 올라간다. 대기업 그룹 핵심 계열사들은 대부분 상장사이므로 주요 관계의 상당수를 자동화할 수 있다.
핵심은 verified 플래그다. 자동 감지 데이터를 즉시 신뢰하지 않고 어드민 검토 단계를 두는 것이 데이터 품질을 지키는 방법이다. DART 이름 표기와 DB 이름이 완전히 일치하는 경우가 드물기 때문에 퍼지 매칭 결과는 반드시 검토가 필요하다.
지분율 분류도 단순히 "주주 = 자회사"로 처리하지 않고 의미 있는 기준을 두는 게 중요했다. 0.7% 지분으로 자회사 표시가 되면 데이터 신뢰도가 떨어진다. 5% 기준선을 두고 그 이하는 버리는 것이 더 정확한 그래프를 만든다.
이 프로젝트는 계속 업데이트 중이다. 현재 예정된 작업:
affiSttus활성화 — DART API 키 등급 업그레이드 시 지주사 → 계열사 방향 관계 자동 수집- 삼성·현대차 그룹 — 공정위 주주현황 API로 1차 수집, DART
hyslrSttus로 보완 - DART 주요사항보고서 파싱 — 인수합병, 합작법인 부분 자동화
- 뉴스 수집 페이지에서 키워드 프리셋 필터 — 파트너/경쟁사/하청 감지
비상장사, 외국 기업, 파트너·경쟁사 관계는 여전히 수동이다. 완전 자동화보다는 "어드민 작업량을 최소화하는 반자동화"가 현실적인 방향이라고 생각한다.
기술 스택: Next.js 16 · TypeScript · Supabase · DART API · Tailwind CSS
'프로젝트 > 풀스택' 카테고리의 다른 글
| Trip Planner - 지도 기반 여행계획 풀스택 앱 (0) | 2026.02.25 |
|---|