DART Open API를 Rails 백엔드에 연동하면서 겪은 과정을 정리한다. 공시 모니터링, 감사의견, 지배구조, 재무지표, 지분공시 5개 영역을 구현했고 각 단계마다 삽질이 있었다.


구현 구조

각 데이터 유형마다 모델과 ActiveJob을 하나씩 만들었다. Job은 DART API를 호출해서 upsert_all로 DB에 넣는 단순한 구조다.

DartCorpCodeSyncJob     → dart_companies      (기업 마스터)
DartDisclosureSyncJob   → dart_disclosures    (공시 목록)
DartMajorEventSyncJob   → dart_major_events   (상장폐지 트리거 이벤트 — DS001)
DartAuditOpinionSyncJob → dart_audit_opinions (감사의견 — DS002/DS003)
DartGovernanceSyncJob   → dart_executives / dart_major_shareholders (DS004/DS005)
DartFinancialIndexSyncJob → dart_financial_indexes (fnlttSinglAcntAll)
DartEquityReportSyncJob → dart_equity_reports (지분공시)

삽질 1: upsert_all + update_only + updated_at 중복

가장 먼저 터진 오류.

PG::SyntaxError: ERROR: multiple assignments to same column "updated_at"

Rails 8의 upsert_allupdate_only:에 명시한 컬럼 외에 updated_at을 ON CONFLICT DO UPDATE 절에 자동으로 추가한다. update_only:updated_at을 같이 넣으면 같은 컬럼이 두 번 할당되어 PostgreSQL이 문법 오류로 뻗는다.

# ❌ 오류 발생
upsert_all rows, unique_by: :corp_code, update_only: [
  :corp_name, :stock_code, :updated_at   # ← 여기
]

# ✅ updated_at은 Rails가 자동 처리, 명시하지 않아야 함
upsert_all rows, unique_by: :corp_code, update_only: [
  :corp_name, :stock_code
]

이 실수가 4개 Job에 동일하게 있었다. 한 곳에서 발견하면 나머지도 반드시 전수 확인해야 한다.


삽질 2: API 응답 필드가 문서와 다름

DART API 문서에 적힌 필드명/형식과 실제 응답이 다른 경우가 있었다.

임원 정보 — 날짜·구분 필드

문서에는 짧은 코드값처럼 나와 있지만 실제 응답은 한글 텍스트.

필드예상실제 API 응답
birth_ym"196203" (6자)"1962년 03월" (10자)
rgit_exctv_at1자 코드"사내이사"
fte_at1자 코드"상근" / "비상근"

DB 컬럼을 limit: 6으로 잡았다가 PG::StringDataRightTruncation이 터졌다.

# 마이그레이션으로 컬럼 크기 수정
change_column :dart_executives, :birth_ym,      :string, limit: 20
change_column :dart_executives, :rgit_exctv_at, :string, limit: 20
change_column :dart_executives, :fte_at,        :string, limit: 10

Job 코드에 키 이름 오타도 있었다.

# ❌ 오타
item["rgit_exctv_at"]

# ✅ 실제 API 키
item["rgist_exctv_at"]

지분공시 — 필드명 전면 불일치

지분공시 API는 내가 가정한 키와 실제 응답 키가 세 개 다 틀렸다.

내가 쓴 키실제 API 키
repror_nmrepror
stkqy_irds_rtstkrt_irds
posesn_stock_qota_rtstkrt

rcept_dt 컬럼도 "20240101" 형식(8자)으로 생각하고 limit: 8로 잡았는데, 실제로는 "2024-03-22" 형식(10자)이 온다. limit: 12로 늘리고 컬럼명도 rename_column으로 정정했다.

교훈: DART API는 실제 응답을 curl로 먼저 찍어보고 필드명·데이터 길이를 확인한 뒤 스키마를 설계해야 한다.


삽질 3: 테스트 기업 선택

데이터가 풍부해야 오류를 찾기 좋아서 삼성전자(corp_code: 00126380)로 테스트했다.

dart_major_events       →    6건
dart_audit_opinions     →   32건
dart_major_shareholders →  117건
dart_executives         →  134건
dart_financial_indexes  →  704건
dart_equity_reports     → 2713건

DART API는 일 1만 건 제한이 있어서 건수가 많은 테스트는 Rate Limit을 고려해야 한다.


권한 구조 설계 — 어떤 역할이 볼 수 있어야 하나

처음엔 관리자 네임스페이스에 넣었다. 운영하면서 일반 심사역도 공시를 일상적으로 확인해야 한다는 걸 깨달아서 구조를 바꿨다.

변경 전

# Admin 네임스페이스 안에서 admin만 접근
namespace :admin do
  resources :dart_monitoring, only: [:index, :show] do
    collection { post :sync }
  end
end

변경 후

# 루트 레벨로 이동, 동기화만 admin 전용
resources :dart_monitoring, only: [:index, :show] do
  collection { post :sync }
end
class DartMonitoringController < ApplicationController
  before_action :ensure_staff!              # reviewer + admin: 읽기
  before_action :ensure_admin!, only: :sync # admin만: 동기화 실행

  def ensure_staff!
    return if current_user&.admin? || current_user&.reviewer?
    redirect_to root_path
  end
end

Svelte 페이지도 Admin/DartMonitoring/ 디렉토리에서 DartMonitoring/으로 이동했다.

사이드바 역할별 분기

역할에 따라 사이드바 항목이 달라지는 구조라 각 케이스를 명시적으로 처리했다. “전문분야 미지정” 심사역의 경우 상장심사 메뉴 + 상폐심사 메뉴를 합치는 로직이 있는데, 그냥 배열을 concat하면 공통 항목(DART, 캘린더, 알림)이 두 번 나온다.

// ❌ 중복 발생
return [...baseItems, ...listingItems, ...delistingItems, ...commonItems];

// ✅ 공통 항목 변수로 추출 후 명시적으로 1회만 포함
const dartItem = { name: 'DART 모니터링', href: '/dart_monitoring', ... };
const calendarItem = { ... };
const notifItem = { ... };

// listingItems, delistingItems 각각에 dartItem 포함시키되
// 미지정 케이스에서는 직접 조합
return [
  ...baseItems,
  { name: '내 검토 목록', ... },
  { name: '상장폐지 심사', ... },
  { name: '심사내역&통계', ... },
  dartItem,      // 1번만
  calendarItem,  // 1번만
  notifItem,     // 1번만
  ...commonItems,
];

Flutter 앱 반영

웹 컨트롤러가 Inertia 렌더링이라 모바일에서 직접 쓸 수 없다. Api::V1::DartMonitoringController를 별도로 만들고 동일한 데이터를 JSON으로 반환했다.

# routes.rb
namespace :api do
  namespace :v1 do
    resources :dart_monitoring, only: [:index, :show]
  end
end

Flutter 페이지는 BLoC 없이 StatefulWidget + ApiClient(Dio) 패턴으로 단순하게 구성했다. 탭 전환 시 해당 탭 데이터를 다시 fetch하고, 무한 스크롤은 pagination으로 처리했다.

Future<void> _loadTab(String tab, {bool loadMore = false}) async {
  final resp = await _api.get<Map<String, dynamic>>(
    '/api/v1/dart_monitoring',
    queryParameters: {'tab': tab, 'page': loadMore ? _currentPage : 1},
  );
  // ...
}

삽질: 커스텀 위젯 파라미터 확인

프로젝트에서 쓰는 GlassCard 위젯에 margin 파라미터가 없었다. 당연히 있을 거라 생각하고 바로 썼다가 분석 오류가 떴다.

// ❌ 파라미터 없음
GlassCard(
  margin: const EdgeInsets.only(bottom: 8),
  child: ...,
)

// ✅ Padding으로 감싸기
Padding(
  padding: const EdgeInsets.only(bottom: 8),
  child: GlassCard(child: ...),
)

Flutter 최신 버전에서 withOpacity deprecated 경고도 많이 나왔다. withAlpha(38) (= 0.15 * 255) 또는 .withValues(alpha: 0.15)를 써야 한다.


전체 데이터 흐름

[DART Open API]
     ↓ (ActiveJob, 주기적 or 수동 sync)
[PostgreSQL]
  dart_companies, dart_disclosures, dart_financials,
  dart_major_events, dart_audit_opinions,
  dart_major_shareholders, dart_executives, dart_equity_reports
     ↓
[Rails Controllers]
  Web:    DartMonitoringController       → Inertia/Svelte (심사역+관리자)
  Mobile: Api::V1::DartMonitoringController → JSON
     ↓
[Frontend]
  Web:    Index.svelte  (탭 대시보드: 개요/공시/이벤트/감사의견/기업현황/지분공시)
          Show.svelte   (기업 상세: 공시·재무·지배구조 등)
  Mobile: DartMonitoringPage       (탭 목록)
          DartMonitoringDetailPage (기업 상세)

요약

  • upsert_allupdate_only: 배열에 updated_at 절대 넣지 말 것
  • DART API 필드명은 문서보다 실제 응답을 믿을 것 (curl로 먼저 확인)
  • 날짜·구분 컬럼 limit은 넉넉하게 잡을 것 (코드값이 아니라 한글 텍스트가 올 수 있음)
  • 권한 변경 시 라우트·컨트롤러·프론트엔드·사이드바를 전부 찾아서 일괄 수정할 것
  • 커스텀 위젯 쓰기 전에 파라미터 정의를 먼저 확인할 것