티스토리 뷰
작성일: 2026년 1월 9일
카테고리: ETL, RDF, Data Pipeline
키워드: 회계 ERP, JSON to RDF, ETL, 데이터 변환, 지식그래프
시리즈: 온톨로지 + AI 에이전트: 세무 컨설팅 시스템 아키텍처 (14부/총 20부)
대상 독자: 온톨로지에 입문하는 시니어 개발자
요약
Part D의 시작이다. Part B에서 온톨로지 스키마(TBox)를, Part C에서 AI 에이전트 프레임워크를 구축했다. 이제 실제 회계 ERP 데이터를 지식그래프로 변환하는 ETL(Extract-Transform-Load) 파이프라인을 구축한다. 이 글에서는 계정과목, 거래처, 전표, 재무제표 등 다양한 JSON 데이터를 RDF 트리플로 변환하는 설계 원리와 핵심 매핑 전략을 다룬다.
핵심 질문: JSON에서 RDF로 어떻게 변환하는가?
ERP 시스템에서 추출한 JSON 데이터를 RDF 트리플로 변환하는 과정은 단순한 포맷 변환이 아니다. 관계형 사고방식에서 그래프 사고방식으로의 전환이 필요하다.
| 관계형 (JSON/RDB) | 그래프 (RDF) |
|---|---|
| 테이블, 컬럼 | 클래스, 프로퍼티 |
| 레코드 (행) | 리소스 (URI) |
| 외래키 | 객체 프로퍼티 (관계) |
| 값 | 리터럴 또는 URI |
핵심은 URI 생성 전략과 필드-프로퍼티 매핑 규칙이다.
실제 ERP 데이터 구조 분석
1. 회사 정보 (05.기초정보관리-회사등록.json)
회사 등록 데이터는 사업자등록번호, 법인등록번호, 업종코드 등 법인의 기본 정보를 포함한다.
{
"no_biz": "5808702034",
"no_corpor": "1101118012611",
"nm_krcom": "(주)에스제이엘이노베이션",
"nm_ceo": "송충식",
"cd_biztyp": "452106",
"nm_bizcond": "건 설 업",
"nm_item": "크린룸,실내건축공사업",
"da_build": "20210902",
"da_accbegin": "20240101",
"da_accend": "20241231",
"prd_accounts": 4,
"add_com1": "서울특별시 강북구 삼양로124길 11",
"zip_com": "01038",
"tel_com1": "02",
"tel_com2": "6949",
"tel_com3": "6145"
}
주요 필드 의미:
no_biz: 사업자등록번호 (10자리, 하이픈 없음)no_corpor: 법인등록번호 (13자리)cd_biztyp: 업종코드 (6자리)prd_accounts: 회계기수 (4 = 제4기)da_accbegin/da_accend: 회계연도 시작/종료일
2. 계정과목 (04.기초정보관리-계정과목및적요등록.json)
계정과목은 회계 데이터의 분류 체계다. 현금, 매출, 급여 등 모든 거래는 계정과목으로 분류된다.
{
"cd_acctit": "10100",
"nm_acctit": "현금",
"nm_ename": "Cash",
"cd_stacctit": "100030",
"nm_stacctit": "현금및현금성자산",
"ty_crdr": 1,
"ty_gj": 3,
"nm_gj": "일 반",
"key_acctit": "200701011101010100"
}
주요 필드 의미:
cd_acctit: 계정코드 (5자리)nm_acctit: 계정명 (한글)cd_stacctit: 표준계정코드 (재무제표 분류)ty_crdr: 차대구분 (1=차변, 2=대변)key_acctit: 시스템 고유키
3. 거래처 (01.기초정보관리-거래처등록_일반.json)
거래처는 매출처, 매입처, 금융기관 등 거래 상대방 정보다.
{
"cd_trade": "0000000102",
"nm_trade": "농협은행",
"no_biz": "1048639742",
"nm_krname": "권준학",
"ty_tr1": 1,
"ty_tr2": 2
}
주요 필드 의미:
cd_trade: 거래처코드 (10자리)ty_tr1: 거래처구분1 (1=일반)ty_tr2: 거래처구분2 (0=일반, 2=금융기관)
4. 전표/분개장 (12.전표관리-일반전표, 14.주요장부-분개장)
전표는 회계 거래의 기본 단위다. 복식부기 원칙에 따라 차변과 대변이 일치해야 한다.
{
"da_date": "20241201",
"no_acct": "00001",
"sq_bungae": 1,
"cd_acctit": "25300",
"nm_acctit": "미지급금",
"ty_gubn": 4,
"nm_gubn": "대변",
"mn_bungae1": 0.0,
"mn_bungae2": 5140.0,
"nm_remark": "2945 통행료",
"cd_trade": "099609",
"nm_trade": "농협카드/6056"
}
주요 필드 의미:
da_date: 전표일자 (YYYYMMDD)no_acct: 전표번호sq_bungae: 분개순번 (같은 전표 내 행 번호)ty_gubn: 차대구분 (3=차변, 4=대변)mn_bungae1: 차변금액mn_bungae2: 대변금액
5. 재무상태표 (23.결산재무제표-재무상태표.json)
재무상태표는 특정 시점의 자산, 부채, 자본 현황을 보여준다.
{
"cd_acctit": "10300",
"nm_acctit": "보통예금",
"mn_bungae": "580,355,783",
"mn_bungae_right": 580355783.0,
"jun_mn_bungae": "274,408,769",
"diff_rate": 111.49,
"depth": 4,
"cd_group": "111",
"nm_acctitpr": "보통예금",
"cd_gcode": "10300"
}
주요 필드 의미:
mn_bungae_right: 당기 금액 (숫자형)jun_mn_bungae: 전기 금액 (문자열, 콤마 포함)diff_rate: 전년 대비 증감률depth: 계층 깊이 (1=대분류, 4=세부계정)cd_group: 재무제표 분류코드
6. 손익계산서 (21.결산재무제표-손익계산서.json)
손익계산서는 일정 기간의 수익과 비용을 보여준다.
{
"cd_acctit": "40700",
"nm_acctit": "공사수입금",
"mn_total1": 5090216000.0,
"mn_btotal2": 7914990000.0,
"variation_percent": "-35.68",
"ord_lcate": "010",
"cd_gr": "61000"
}
ERD: ERP 데이터 엔티티 관계
실제 ERP 데이터의 관계를 ERD로 표현하면 다음과 같다.
erDiagram
COMPANY ||--o{ FISCAL_YEAR : has
COMPANY ||--o{ PARTNER : trades_with
COMPANY ||--o{ ACCOUNT : uses
FISCAL_YEAR ||--o{ JOURNAL : contains
FISCAL_YEAR ||--o{ FINANCIAL_STMT : produces
JOURNAL ||--o{ JOURNAL_LINE : has
JOURNAL_LINE }o--|| ACCOUNT : references
JOURNAL_LINE }o--o| PARTNER : involves
FINANCIAL_STMT ||--o{ STMT_LINE : contains
STMT_LINE }o--|| ACCOUNT : maps_to
COMPANY {
string no_biz PK "사업자등록번호"
string no_corpor "법인등록번호"
string nm_krcom "회사명"
string nm_ceo "대표자"
string cd_biztyp "업종코드"
date da_build "설립일"
}
FISCAL_YEAR {
string fy_id PK "회계연도ID"
int prd_accounts "회계기수"
date da_accbegin "시작일"
date da_accend "종료일"
}
ACCOUNT {
string cd_acctit PK "계정코드"
string nm_acctit "계정명"
string cd_stacctit "표준계정코드"
int ty_crdr "차대구분"
string key_acctit "시스템키"
}
PARTNER {
string cd_trade PK "거래처코드"
string nm_trade "거래처명"
string no_biz "사업자번호"
int ty_tr1 "거래처구분"
}
JOURNAL {
string journal_id PK "전표ID"
date da_date "전표일자"
string no_acct "전표번호"
int ty_bungae "전표유형"
}
JOURNAL_LINE {
string line_id PK "분개행ID"
int sq_bungae "분개순번"
decimal mn_bungae1 "차변금액"
decimal mn_bungae2 "대변금액"
string nm_remark "적요"
}
FINANCIAL_STMT {
string stmt_id PK "재무제표ID"
string stmt_type "BS/IS"
date stmt_date "기준일"
}
STMT_LINE {
string line_id PK "항목ID"
decimal mn_current "당기금액"
decimal mn_previous "전기금액"
int depth "계층깊이"
}
JSON에서 RDF로: 매핑 전략
URI 생성 규칙
URI는 RDF 세계의 "주민등록번호"다. 고유하고, 예측 가능하며, 의미 있어야 한다.
| 엔티티 | URI 패턴 | 예시 |
|---|---|---|
| 회사 | data:company/{사업자번호} |
data:company/5808702034 |
| 회계연도 | data:fy/{사업자번호}/{기수} |
data:fy/5808702034/4 |
| 계정과목 | data:account/{계정코드} |
data:account/10100 |
| 거래처 | data:partner/{거래처코드} |
data:partner/0000000102 |
| 전표 | data:journal/{사업자번호}/{일자}/{전표번호} |
data:journal/5808702034/20241201/00001 |
| 재무상태표 | data:bs/{사업자번호}/{기수} |
data:bs/5808702034/4 |
필드-프로퍼티 매핑
회사 정보 매핑
| ERP 필드 | RDF 프로퍼티 | 데이터타입 |
|---|---|---|
no_biz |
tax:businessNumber |
xsd:string |
no_corpor |
tax:corporateNumber |
xsd:string |
nm_krcom |
tax:companyName |
xsd:string (lang:ko) |
nm_ceo |
tax:representativeName |
xsd:string |
cd_biztyp |
tax:industryCode |
xsd:string |
da_build |
tax:foundedDate |
xsd:date |
prd_accounts |
tax:fiscalPeriodNumber |
xsd:integer |
계정과목 매핑
| ERP 필드 | RDF 프로퍼티 | 데이터타입 |
|---|---|---|
cd_acctit |
acc:accountCode |
xsd:string |
nm_acctit |
acc:accountName |
xsd:string (lang:ko) |
nm_ename |
acc:accountNameEn |
xsd:string (lang:en) |
cd_stacctit |
acc:standardCode |
xsd:string |
ty_crdr |
acc:debitCreditType |
xsd:integer |
전표/분개 매핑
| ERP 필드 | RDF 프로퍼티 | 데이터타입 |
|---|---|---|
da_date |
txn:transactionDate |
xsd:date |
no_acct |
txn:voucherNumber |
xsd:string |
mn_bungae1 |
txn:debitAmount |
xsd:decimal |
mn_bungae2 |
txn:creditAmount |
xsd:decimal |
nm_remark |
txn:description |
xsd:string |
재무제표 매핑
| ERP 필드 | RDF 프로퍼티 | 데이터타입 |
|---|---|---|
mn_bungae_right |
fin:currentAmount |
xsd:decimal |
jun_mn_bungae |
fin:previousAmount |
xsd:decimal |
diff_rate |
fin:changeRate |
xsd:decimal |
depth |
fin:hierarchyLevel |
xsd:integer |
ETL 파이프라인 아키텍처
전체 흐름
graph LR
subgraph Extract
E1["회사등록.json"]
E2["계정과목.json"]
E3["거래처.json"]
E4["전표.json"]
E5["재무제표.json"]
end
subgraph Transform
T1["JSON 파싱"]
T2["데이터 정제"]
T3["URI 생성"]
T4["RDF 매핑"]
end
subgraph Load
L1["Turtle 파일"]
L2["Triple Store"]
end
E1 --> T1
E2 --> T1
E3 --> T1
E4 --> T1
E5 --> T1
T1 --> T2
T2 --> T3
T3 --> T4
T4 --> L1
L1 --> L2
style Extract stroke:#2563eb,stroke-width:2px
style Transform stroke:#16a34a,stroke-width:2px
style Load stroke:#ea580c,stroke-width:2px
데이터 정제 규칙
ERP 데이터는 그대로 사용할 수 없다. 정제 과정이 필요하다.
1. 날짜 변환
ERP: "20241201" (YYYYMMDD 문자열)
RDF: "2024-12-01"^^xsd:date2. 금액 정규화
ERP: "580,355,783" (콤마 포함 문자열)
RDF: 580355783^^xsd:decimal3. 사업자번호 포맷팅
ERP: "5808702034" (10자리 숫자)
RDF: "580-87-02034"^^xsd:string (표준 형식)4. 빈 값 처리
ERP: "" 또는 null
RDF: 해당 트리플 생략 (optional property)핵심 변환 로직
회사 정보 변환
def transform_company(data: dict) -> Graph:
"""회사 정보를 RDF로 변환"""
g = Graph()
biz_no = data.get('no_biz')
if not biz_no:
return g
company_uri = DATA[f"company/{biz_no}"]
# 타입 선언
g.add((company_uri, RDF.type, TAX.Corporation))
# 기본 정보
g.add((company_uri, TAX.businessNumber,
Literal(format_biz_number(biz_no))))
if data.get('no_corpor'):
g.add((company_uri, TAX.corporateNumber,
Literal(data['no_corpor'])))
if data.get('nm_krcom'):
g.add((company_uri, TAX.companyName,
Literal(data['nm_krcom'], lang='ko')))
if data.get('nm_ceo'):
g.add((company_uri, TAX.representativeName,
Literal(data['nm_ceo'])))
# 업종 정보
if data.get('cd_biztyp'):
g.add((company_uri, TAX.industryCode,
Literal(data['cd_biztyp'])))
# 설립일
if data.get('da_build'):
g.add((company_uri, TAX.foundedDate,
Literal(parse_date(data['da_build']), datatype=XSD.date)))
return g
전표 변환
전표는 복식부기의 핵심이다. 차변 합계와 대변 합계가 반드시 일치해야 한다.
def transform_journal(data: dict, company_biz_no: str) -> Graph:
"""전표 데이터를 RDF로 변환"""
g = Graph()
# 전표 URI 생성
date_str = data.get('da_date')
voucher_no = data.get('no_acct')
journal_uri = DATA[f"journal/{company_biz_no}/{date_str}/{voucher_no}"]
g.add((journal_uri, RDF.type, TXN.JournalEntry))
g.add((journal_uri, TXN.transactionDate,
Literal(parse_date(date_str), datatype=XSD.date)))
# 회사 연결
company_uri = DATA[f"company/{company_biz_no}"]
g.add((journal_uri, TXN.belongsTo, company_uri))
# 분개 행 처리
line_no = data.get('sq_bungae', 1)
line_uri = DATA[f"journal/{company_biz_no}/{date_str}/{voucher_no}/line/{line_no}"]
g.add((line_uri, RDF.type, TXN.JournalLine))
g.add((journal_uri, TXN.hasLine, line_uri))
# 계정과목 연결
account_code = data.get('cd_acctit')
if account_code:
account_uri = DATA[f"account/{account_code}"]
g.add((line_uri, TXN.usesAccount, account_uri))
# 금액
debit = data.get('mn_bungae1', 0) or 0
credit = data.get('mn_bungae2', 0) or 0
if debit > 0:
g.add((line_uri, TXN.debitAmount,
Literal(debit, datatype=XSD.decimal)))
if credit > 0:
g.add((line_uri, TXN.creditAmount,
Literal(credit, datatype=XSD.decimal)))
# 적요
if data.get('nm_remark'):
g.add((line_uri, TXN.description,
Literal(data['nm_remark'])))
# 거래처 연결
trade_code = data.get('cd_trade')
if trade_code:
partner_uri = DATA[f"partner/{trade_code}"]
g.add((line_uri, TXN.involvesPartner, partner_uri))
return g
재무상태표 변환
재무상태표는 계층 구조를 가진다. depth 필드를 활용해 상위-하위 관계를 표현한다.
def transform_balance_sheet(items: list, company_biz_no: str, fiscal_period: int) -> Graph:
"""재무상태표를 RDF로 변환"""
g = Graph()
bs_uri = DATA[f"bs/{company_biz_no}/{fiscal_period}"]
company_uri = DATA[f"company/{company_biz_no}"]
g.add((bs_uri, RDF.type, FIN.BalanceSheet))
g.add((bs_uri, FIN.belongsTo, company_uri))
g.add((bs_uri, FIN.fiscalPeriod, Literal(fiscal_period, datatype=XSD.integer)))
for item in items:
account_code = item.get('cd_acctit')
if not account_code:
continue
line_uri = DATA[f"bs/{company_biz_no}/{fiscal_period}/line/{account_code}"]
g.add((line_uri, RDF.type, FIN.BalanceSheetLine))
g.add((bs_uri, FIN.hasLine, line_uri))
# 계정 연결
account_uri = DATA[f"account/{account_code}"]
g.add((line_uri, FIN.forAccount, account_uri))
# 금액 (당기/전기)
current_amount = item.get('mn_bungae_right', 0) or 0
if current_amount != 0:
g.add((line_uri, FIN.currentAmount,
Literal(current_amount, datatype=XSD.decimal)))
prev_str = item.get('jun_mn_bungae', '')
if prev_str:
prev_amount = parse_amount(prev_str)
g.add((line_uri, FIN.previousAmount,
Literal(prev_amount, datatype=XSD.decimal)))
# 증감률
diff_rate = item.get('diff_rate')
if diff_rate is not None:
g.add((line_uri, FIN.changeRate,
Literal(diff_rate, datatype=XSD.decimal)))
# 계층 수준
depth = item.get('depth', 0)
g.add((line_uri, FIN.hierarchyLevel,
Literal(depth, datatype=XSD.integer)))
return g
생성된 RDF 예시
위 변환 로직을 실행하면 다음과 같은 RDF(Turtle)가 생성된다.
@prefix tax: <http://example.org/tax/> .
@prefix fin: <http://example.org/financial/> .
@prefix acc: <http://example.org/account/> .
@prefix txn: <http://example.org/transaction/> .
@prefix data: <http://example.org/data/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
# 회사
data:company/5808702034 a tax:Corporation ;
tax:businessNumber "580-87-02034" ;
tax:corporateNumber "1101118012611" ;
tax:companyName "(주)에스제이엘이노베이션"@ko ;
tax:representativeName "송충식" ;
tax:industryCode "452106" ;
tax:foundedDate "2021-09-02"^^xsd:date .
# 계정과목
data:account/10100 a acc:Account ;
acc:accountCode "10100" ;
acc:accountName "현금"@ko ;
acc:accountNameEn "Cash"@en ;
acc:standardCode "100030" ;
acc:debitCreditType 1 .
data:account/10300 a acc:Account ;
acc:accountCode "10300" ;
acc:accountName "보통예금"@ko ;
acc:accountNameEn "Bank deposits"@en ;
acc:standardCode "100030" ;
acc:debitCreditType 1 .
# 전표
data:journal/5808702034/20241201/00001 a txn:JournalEntry ;
txn:transactionDate "2024-12-01"^^xsd:date ;
txn:belongsTo data:company/5808702034 ;
txn:hasLine data:journal/5808702034/20241201/00001/line/1 ,
data:journal/5808702034/20241201/00001/line/2 .
data:journal/5808702034/20241201/00001/line/1 a txn:JournalLine ;
txn:usesAccount data:account/25300 ;
txn:creditAmount 5140^^xsd:decimal ;
txn:description "2945 통행료" ;
txn:involvesPartner data:partner/099609 .
data:journal/5808702034/20241201/00001/line/2 a txn:JournalLine ;
txn:usesAccount data:account/82200 ;
txn:debitAmount 5140^^xsd:decimal ;
txn:description "2945 통행료 [00000102] 165호2945 제네시스/GV70 (7.기타)" .
# 재무상태표
data:bs/5808702034/4 a fin:BalanceSheet ;
fin:belongsTo data:company/5808702034 ;
fin:fiscalPeriod 4 ;
fin:hasLine data:bs/5808702034/4/line/10100 ,
data:bs/5808702034/4/line/10300 .
data:bs/5808702034/4/line/10300 a fin:BalanceSheetLine ;
fin:forAccount data:account/10300 ;
fin:currentAmount 580355783^^xsd:decimal ;
fin:previousAmount 274408769^^xsd:decimal ;
fin:changeRate 111.49^^xsd:decimal ;
fin:hierarchyLevel 4 .
데이터 품질 검증
ETL 파이프라인에서 데이터 품질은 생명이다. 다음 검증을 수행해야 한다.
1. 필수 필드 검증
REQUIRED_FIELDS = {
'company': ['no_biz', 'nm_krcom'],
'account': ['cd_acctit', 'nm_acctit'],
'journal': ['da_date', 'cd_acctit'],
}
def validate_required(data: dict, entity_type: str) -> list:
"""필수 필드 검증"""
errors = []
for field in REQUIRED_FIELDS.get(entity_type, []):
if not data.get(field):
errors.append(f"Missing required field: {field}")
return errors
2. 복식부기 원칙 검증
전표의 차변 합계와 대변 합계가 일치해야 한다.
def validate_journal_balance(journal_lines: list) -> bool:
"""차대 균형 검증"""
total_debit = sum(line.get('mn_bungae1', 0) or 0 for line in journal_lines)
total_credit = sum(line.get('mn_bungae2', 0) or 0 for line in journal_lines)
return abs(total_debit - total_credit) < 0.01 # 1원 미만 오차 허용
3. 재무상태표 등식 검증
자산 = 부채 + 자본 등식이 성립해야 한다.
def validate_balance_sheet_equation(bs_data: dict) -> tuple:
"""대차평균 검증"""
assets = bs_data.get('total_assets', 0)
liabilities = bs_data.get('total_liabilities', 0)
equity = bs_data.get('total_equity', 0)
diff = abs(assets - liabilities - equity)
return diff < 1, diff # 1원 이내 오차 허용
핵심 정리
ETL 파이프라인 설계 원칙
| 원칙 | 설명 |
|---|---|
| URI 일관성 | 동일 엔티티는 항상 동일한 URI로 식별 |
| 멱등성 | 같은 입력에 대해 항상 같은 결과 |
| 증분 처리 | 변경된 데이터만 처리 |
| 검증 우선 | 변환 전 데이터 품질 검증 |
JSON-RDF 매핑 핵심
| 개념 | JSON | RDF |
|---|---|---|
| 식별자 | no_biz, cd_acctit |
URI |
| 속성 | 필드값 | Literal |
| 관계 | 외래키 | Object Property |
| 타입 | 암묵적 | rdf:type |
다음 단계 미리보기
15부: 세무 분석 규칙 SHACL로 정의하기
ETL로 생성한 지식그래프에 비즈니스 규칙을 적용한다:
- 부채비율 경고 임계값 (업종별 차등)
- 인건비 비중 분석 규칙
- 연도별 변동 이상 탐지
참고 자료
ETL 패턴
RDFLib
관련 시리즈
'실제 경험과 인사이트를 AI와 함께 정리한 글' 카테고리의 다른 글
| GraphRAG: 지식그래프와 LLM의 시너지 (0) | 2026.01.09 |
|---|---|
| 세무 분석 규칙 SHACL로 정의하기 (0) | 2026.01.09 |
| 에이전트 도구 설계: LLM이 사용하는 도구를 어떻게 만드는가 (1) | 2026.01.09 |
| LangGraph 상태 기반 워크플로우: 그래프로 복잡한 추론 표현하기 (0) | 2026.01.09 |
| RAG와 GraphRAG: 검색 증강 생성의 아키텍처 (1) | 2026.01.09 |
- Total
- Today
- Yesterday
- architecture
- imprun.dev
- AGENTS.md
- Go
- claude code
- authorization
- security
- Kubernetes
- frontend
- Claude Opus 4.5
- AI
- authentication
- SHACL
- LLM
- EnvironmentAgnostic
- Tailwind CSS
- CLAUDE.md
- GPT-5.1
- AI agent
- Next.js
- Rag
- react
- backend
- troubleshooting
- Tax Analysis
- api gateway
- Development Tools
- Developer Tools
- Ontology
- knowledge graph
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
