만들면서 배운 것

엑셀 하나 업로드했더니, 시트 8개가 통째로 사라졌습니다 🧾

답답해하는 초록이 아빠

제가 만드는 자산관리 앱 돈독에, 제 실제 자산 엑셀 파일을 직접 넣어본 날이었습니다.

대차대조표·현금흐름·세액까지 정리된 파일 하나, 아내가 쓰던 부자공식 가계부 파일 하나. 시트를 다 합치면 8개였어요.

업로드 버튼을 눌렀습니다. 결과: 8개 시트 전부 0건.

파서가 기대한 모양과, 실제 파일의 모양이 달랐다

에러 메시지는 없었습니다. 그냥 아무것도 안 들어왔어요. 코드를 열어보니 이유가 명확했습니다.

그때까지 만든 파서는 “은행에서 내려받는 거래내역”을 기준으로 짜여 있었습니다. 날짜·내용·금액이 한 줄씩 쌓인, 위에서 아래로 읽으면 되는 표 구조예요.

그런데 제가 넣은 파일은 전혀 다른 모양이었습니다. 대차대조표는 자산이 왼쪽, 부채가 오른쪽에 있는 2차원 표였고, 부자공식 가계부는 순자산·투자·지출까지 표 여러 개가 한 시트 안에 섞여 있었습니다. “날짜별 한 줄”을 기대하는 파서한테는 이 둘 다 읽을 방법이 없는 파일이었던 거예요.

돌아보니 당연한 결과였습니다. 제품 소개 문구에는 “어떤 양식이든 소화한다”고 써놨는데, 실제로 검증된 건 은행 export 형태 하나뿐이었으니까요.

진짜 무서운 건 에러가 아니라 조용한 오탐이었다

삽질에 멘붕한 초록이 아빠

구조 문제를 고치려고 양식별 파서를 새로 만들다가, 더 성가신 걸 하나 더 만났습니다.

부자공식 가계부 파일에는 “가계부”라는 이름의 시트가 있었습니다. 기존 코드에는 시트 이름에 “가계부”만 들어 있으면 헤더 형태를 안 보고도 은행 앱(뱅크샐러드) export로 간주해버리는 로직이 있었어요. 그 로직이 먼저 이 시트를 붙잡아버렸고, 뱅크샐러드 파서 기준으로는 읽을 게 없으니 결과는 또 0건이었습니다.

문제는 이게 “못 읽었다”는 신호조차 안 준다는 점이었습니다. 그냥 조용히 다른 파서가 가로채서 빈손으로 끝나는 거예요.


에러가 나면 오히려 다행입니다. 조용히 다른 로직이 가로채는 쪽이 훨씬 무섭습니다.

그래서 양식별로 담당자를 나누고, 통과 규칙을 하나 넣었다

해결 방향은 두 갈래였습니다.

첫째, 양식마다 감지(detect)와 추출(parse)을 한 쌍으로 묶은 담당자를 따로 두는 구조로 바꿨습니다. 새 양식이 나오면 기존 코드를 건드리지 않고 담당자 하나만 추가하면 되게요.

둘째, 오탐 문제는 통과 규칙 하나로 잡았습니다. 어떤 파서든 결과가 0건이면 “이건 내 담당이 아니었다”로 보고 다음 담당자에게 넘기도록 바꿨습니다. 뱅크샐러드 파서가 이름만 보고 집어갔더라도, 실제로 아무것도 못 뽑았으면 바로 다음 순서(부자공식 담당자)로 넘어가게 한 거예요.

이 두 가지를 반영해서 부자공식·대차대조표 두 양식을 실제 파일로 다시 돌려봤습니다. 부자공식은 항목이 빠짐없이 다 들어왔고, 대차대조표도 앱이 계산한 순자산이 원본 파일의 합계와 정확히 맞아떨어졌습니다. “숫자가 나온다”가 아니라 “숫자가 맞다”까지 확인하고 나서야 마음이 놓였습니다.

그래도 못 읽는 양식은 어떻게 하나

양식별 담당자를 아무리 늘려도 세상 모든 엑셀을 다 커버할 수는 없습니다. 그래서 위 두 담당자가 다 실패하면, AI가 시트를 읽고 표준 형태로 바꿔주는 폴백 단계를 하나 더 뒀습니다.

다만 이 결과는 절대 자동으로 저장하지 않습니다. 실제로 테스트해보니 AI가 표 오른쪽에 있던 부채 칸을 통째로 놓친 적이 있었어요. 그래서 AI가 읽은 결과는 항상 “확인 후 등록” 화면을 한 번 거치게 만들었습니다. 결정형 담당자보다 정확도가 낮다는 걸 알고 있으니, 그만큼 사람 확인 단계를 건너뛰지 않는 거예요.

따라 하기

같은 문제(사용자가 올리는 파일 양식이 제각각)를 겪고 있다면, 제가 정리한 순서는 이렇습니다.

  1. 표준 출력 타입부터 정한다. 입력 양식이 몇 개로 늘어나든, 그 뒤 로직은 이 타입 하나만 알면 되게 하기 위해서입니다.
// utils/asset-templates/types.ts
export interface AssetRow {
  name: string
  balance: number   // 항상 양수, 부채도 magnitude로 표현
  type: 'CASH' | 'INVESTMENT' | 'PENSION' | 'REAL_ESTATE' | 'DEBT'
  sourceCategory: string
  uncertain: boolean
}
  1. 입력 양식 하나 = 담당자(어댑터) 하나로 표현한다. 감지(detect)와 추출(parse)을 한 쌍으로 묶어야, 새 양식이 늘 때 기존 코드를 안 건드리고 담당자만 추가할 수 있습니다.
export interface AssetTemplateAdapter {
  id: string
  detect(wb: XLSX.WorkBook): boolean
  parse(wb: XLSX.WorkBook): { rows: AssetRow[] }
}
  1. 레지스트리는 순서대로 물어보고, 결과가 0건이면 다음 담당자로 넘긴다. 오탐이 나도 여기서 걸러지게 하기 위해서입니다.
export const ASSET_TEMPLATES: AssetTemplateAdapter[] = [bujaGongsikAdapter, balanceSheetAdapter]

export function detectAssetTemplate(wb: XLSX.WorkBook) {
  for (const adapter of ASSET_TEMPLATES) {
    if (!adapter.detect(wb)) continue
    const { rows } = adapter.parse(wb)
    if (rows.length === 0) continue   // 0건이면 내 담당이 아니었던 것으로 보고 다음으로
    return { id: adapter.id, rows }
  }
  return null
}
  1. “지원한다”는 말은 실제 파일로 검증한 양식에만 붙인다. 검증한 케이스는 테스트로 고정해서, 나중에 다른 걸 고치다 조용히 깨지지 않게 합니다.
npm test   # vitest run — 양식별 실파일 기반 케이스 포함
  1. 그래도 못 읽는 양식이 나오면, 자동 저장하지 않는 폴백(사람 확인용 프리뷰 화면)으로 넘긴다.

지금 남은 것

업로드 기능에서 이상한 결과를 보면, 이제는 제일 먼저 이걸 나눠서 묻습니다. 파서가 애초에 이 모양을 모르는 건지, 아니면 다른 로직이 먼저 붙잡고 조용히 빈손으로 놔버린 건지. 둘은 원인도 고치는 방법도 다르거든요.

이번 일로 배운 건 “테스트를 잘 짜자” 같은 말이 아니었습니다. “지원한다”고 말하려면, 그 말을 실제 파일로 검증했는지부터 스스로 물어야 한다는 거였어요. 코드가 초록불이어도 실제로 눌러보지 않으면 안 보이는 문제가 있었습니다.

뿌듯해하는 초록이 아빠

지금 제 화면에 뜨는 자산 목록은, 몇 번 조용히 사라졌다 다시 채워진 끝에 남은 숫자들입니다. 오늘도 한 걸음. 🐴

이 글은 산업 구조와 만드는 과정을 정리한 개인 기록입니다.
특정 종목의 매수·매도 추천이 아니며, 투자 판단과 그 결과는 독자 본인에게 있습니다.

← 글 목록으로