ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 재직중인 회사에서 법인카드 정산·영수증 처리 자동화하기
    개발 이야기 2026. 6. 19. 09:17

    재직 중인 회사에서 공용법인카드를 쓰고 있습니다. 매월 초가 되면 이전 달 사용 내역을 정리하는 게 루틴이죠. 카드사에서 받은 엑셀을 열고, 거래마다 사용목적과 사용인원을 채우고, 영수증을 모아 PDF로 붙인 뒤 정해진 양식에 맞춰 제출합니다.
    기존에는 경영관리부가 카드별 사용내역 파일을 뽑아, 담당자마다 메일로 보내는 방식이었습니다. 부서마다 받는 파일이 다르고, 메일함에서 찾고, 엑셀 설치·열기·양식 맞추기부터 시작하니까요. 익숙해지면 금방인데, 매번 손으로 하다 보면 같은 실수가 반복됩니다. 파일 버전 꼬임, 이름 오타, 영수증 빠뜨림, 금액 안 맞는 행.
    개발자 입장에서는 이게 꽤 아깝다고 느껴졌습니다. "파일을 사람에게 보내는" 게 아니라 "한곳에 올리면 각자 화면에 뜨게" 하면 되지 않을까요.
    그래서 정산시스템을 만들었습니다. 경영관리부가 카드사 엑셀을 시스템에 첨부하면, 각 부서 계정에 해당 카드 내역이 바로 렌더링됩니다. 엑셀 파일을 받아서 PC에 깔고, 시트 열고, 셀 복붙할 필요 없이 웹에서 바로 사용목적·사용인원·비고를 작성하고, 완료되면 제출용 엑셀을 다운로드하면 됩니다.
    생각보다 단순하지 않았습니다. 카드사마다 엑셀 형식이 다르고, 해외 결제는 달러·환율 처리가 필요하고, 부서별로 볼 수 있는 카드가 달랐습니다. ERP까지 붙일 여유도 없었고요. 이번 글은 그 과정에서 막혔던 지점들을 정리한 것입니다.


    목차

    1. 기존 방식 vs 정산시스템
    2. 전체 구조
    3. 카드사 엑셀 파싱
    4. Strapi 쓴 이유와 저장 방식
    5. 권한·카드 분리
    6. 영수증 PDF 합치기
    7. 엑셀 내보내기·중간 저장
    8. 배포할 때
    9. 테스트
    10. 이슈 모음

    기존 방식 vs 정산시스템

    내역 전달경영관리부 → 담당자별 메일 발송경영관리부가 시스템에 한 번 업로드
    담당자 작업메일 첨부 엑셀 다운 → PC에서 편집로그인하면 본인 카드 내역이 화면에 표시
    작성엑셀 셀에 직접 입력·복붙웹 테이블에서 사용목적·사용인원·비고 입력
    제출편집한 엑셀 + 영수증 PDF시스템에서 양식 맞춘 엑셀 다운 + 영수증 PDF
    중간 저장파일 따로 보관서버·브라우저에 작성 중 내용 자동 저장
    버전 관리최종_v3_진짜최종.xlsx동일 파일 중복 업로드 차단

    핵심은 전달 경로를 메일에서 시스템으로 옮긴 것입니다. 경영관리부는 "누구에게 어떤 파일 보낼지" 신경 쓸 필요 없이, 카드사에서 받은 파일을 업로드하기만 하면 됩니다. 담당자는 메일함을 뒤지지 않고, 로그인하면 자기 카드 내역만 보입니다.
    엑셀을 "작업 도구"로 쓰지 않고 "결과물 포맷"으로만 씁니다. 입력·검증·저장은 웹에서, 최종 제출용 파일만 엑셀로 받는 구조입니다.


    전체 구조

    카드사 엑셀 (KB / BC 등)
        ↓
    경영관리부 계정 → 정산시스템 업로드 (파싱·검증·월별 저장)
        ↓
    부서 계정 로그인 → 본인 카드 내역 자동 렌더링
        ↓
    웹에서 작성·저장 → 제출용 엑셀 다운 + 영수증 PDF 통합

    프론트는 Vite + React + TypeScript, 백엔드는 Strapi, DB는 PostgreSQL입니다. 엑셀 읽기는 SheetJS로 파싱하고, 엑셀 쓰기는 ExcelJS로 제출용 파일만 생성합니다. PDF는 pdf-lib로 브라우저에서 처리하고, Docker Compose로 web / strapi / postgres를 묶어 올립니다.

    경영관리부 계정카드사 엑셀 업로드, 첨부 이력 관리, 사용인원 메타 관리
    부서 계정로그인 → 본인 카드 내역 확인 → 웹에서 작성 → 엑셀·PDF 다운로드

    대상 월은 업로드 파일 승인일 기준 최빈월(YYYY-MM) 입니다. 같은 달에 카드 파일을 여러 번 올려도 기존 카드 데이터는 유지하고, 내용이 똑같은 파일은 서버에서 막습니다.


    카드사 엑셀 파싱

    여기서 시간을 제일 많이 썼습니다. 카드사 API가 있는 것도 아니고, 받는 파일 형식부터 제각각이거든요. 그런데 이 파싱이 되어야 "업로드 한 번 → 전 부서 화면 반영" 이 가능합니다.

    헤더가 1행이 아닐 때가 많습니다

    KB든 BC든 상단에 안내 문구, 빈 줄, 요약 블록이 먼저 나오는 경우가 흔합니다. "1행 = 헤더"라고 가정하면 바로 터집니다.
    시트 전체를 2차원 배열로 읽고, 행마다 필수 헤더 후보가 몇 개 매칭되는지 점수를 매겨 가장 점수 높은 행을 헤더로 잡습니다.

    const HEADER_ALIASES = {
      approvalDate: ['승인일', '승인일자', '거래일', '사용일'],
      merchantName: ['가맹점명', '가맹점', '사용처', '상호'],
      totalAmount: ['실청구금액', '청구금액', '결제예정금액', '금액', ...],
    }

    헤더 문자열은 공백·괄호를 빼고 소문자로 맞춘 다음 부분 일치로 매칭합니다. 경영관리부는 카드사 원본 그대로 올리면 되고, 담당자는 파싱 결과만 화면에서 봅니다.

    금액 열이 여러 개입니다

    실청구금액, 청구금액, 승인금액(원화), 외화승인금액(원화), 승인금액($)… 열 이름만 봐도 정신없습니다. 우선순위를 정해 두었습니다.

    1. 실청구금액, 청구금액, 결제예정금액
    2. 승인금액(원화), 이용금액(원화)
    3. 외화승인금액(원화), 해외승인금액(원화)
    4. 위가 다 비면 승인금액($) × 환율 → 원화 반올림

    외화, 해외, $, usd가 들어간 헤더는 금액 후보에서 따로 구분해 둬야 합니다. 안 그러면 달러 열을 원화로 착각합니다.

    승인취소·정상 쌍

    같은 승인번호에 정상승인취소가 같이 있으면 그 승인번호 전체를 빼야 합계가 맞습니다. 엑셀에서 수동으로 지우던 걸 파싱 단계에서 처리합니다.

    Excel 날짜 직렬값

    승인일이 45231 같은 숫자로 들어올 때가 있습니다. SheetJS SSF.parse_date_code로 먼저 변환하고, 안 되면 YYYY.MM.DD / YY-MM-DD 정규식으로 YYYY-MM-DD에 맞춥니다.

    카드번호 끝자리

    ****-****-****-1234, 1234, 숫자 셀… 형식이 제각각이라 숫자만 뽑아 마지막 4자리를 카드 식별자로 씁니다. 업로드 후 어느 부서 계정에 어떤 내역을 보여줄지도 이 끝자리로 갈립니다.


    Strapi 쓴 이유와 저장 방식

    Express + Prisma로 처음부터 만들 수도 있었는데, 로그인·JWT·역할 관리가 필요해서 Strapi를 택했습니다. 부서별 계정으로 로그인하면 본인 카드만 보여야 하거든요.

    Collection Type 대신 core store

    카드 사용내역은 Collection Type으로 모델링하기 애매합니다. 월별 스냅샷, 업로드 이력, 사용자별 작성 초안… 문서형에 가깝습니다. 그래서 Strapi core store에 key-value로 넣었습니다.

    history:2025-06                              → 해당 월 전체 내역 + uploads[]
    work-draft:{userId}:{month}:{cardTail}       → 담당자별 작성 중 transactions

    경영관리부가 올린 원본 → history에 merge, 담당자가 웹에서 작성한 내용 → work-draft에 저장. 메일로 주고받던 "편집본"이 서버에 남는 구조입니다.

    업로드 API를 커스텀 미들웨어로

    Strapi 기본 upload 플로우만으로는 "엑셀 파싱 → 월 병합 → 중복 검사 → 권한 필터"를 넣기 불편했습니다.

    1. JWT로 업로드 권한 확인 (경영관리부만)
    2. multipart 파일 수신
    3. parseCardUsageExcel() 실행
    4. content signature로 동일 파일 재업로드 차단
    5. 월별 history에 merge → 각 부서 계정 GET 시 본인 카드만 필터링

    bootstrap 계정

    Docker 올릴 때마다 계정을 수동 생성하기 귀찮아서 bootstrap에서 env 기반 계정 자동 생성을 넣었습니다. 프론트 경영관리부 username 상수와 Strapi env가 어긋나면 업로드 탭·권한이 바로 틀어지니, 한쪽만 바꾸지 않는 게 좋습니다.


    권한·카드 분리

    공용법인카드가 여러 장이면, 한 계정이 모든 내역을 보면 안 됩니다. 예전에는 경영관리부가 담당자별로 다른 파일을 메일로 보냈는데, 이제는 username → 허용 카드 끝자리 매핑으로 자동 분기합니다.

    • 일반 부서 계정: 배정된 카드 1~2장만 화면에 렌더링
    • 경영관리부 계정: 전체 카드 + 업로드·이력 관리 탭

    API 응답 단계에서 필터링합니다. 프론트만 막으면 URL 조작으로 다른 카드 내역이 보일 수 있거든요.

    사용인원 자동완성

    웹 테이블에서 사용인원을 칠 때, 계정별로 보이는 인원 목록을 제한했습니다. 경영관리부는 전체, 부서 계정은 해당 부서만. 엑셀에서 이름 복붙하다 틀리던 걸 줄이려고요.

    사용목적·카테고리 추론

    사용목적 드롭다운(중식, SW구독, 교통비 등)이 있고, 비어 있으면 가맹점명·업종·비고 키워드로 식비/교통/소프트웨어를 추정합니다. GitHub, Cursor, AWS 같은 SaaS 가맹점은 소프트웨어로 분류하는 키워드 테이블을 따로 두었습니다. 엑셀에서 매번 타이핑하던 초안값을 미리 채워 주는 쪽에 가깝습니다.


    영수증 PDF 합치기

    정산 제출할 때 거래별로 어떤 영수증인지 한눈에 보여야 합니다. 웹에서 영수증 파일을 올리고, 브라우저에서 JPG/PNG/PDF를 단일 PDF로 합칩니다. 엑셀 작업과 별도로 파일 탐색기에서 PDF 도구 돌릴 필요가 없습니다.

    한글 라벨

    pdf-lib 기본 폰트로는 한글이 깨집니다. Canvas에 Noto Sans KR / Apple SD Gothic Neo / Malgun Gothic으로 승인일 / 사용인원 / 사용목적 라벨을 그린 다음 PNG embed하는 방식을 썼습니다.

    context.font = `700 ${fontSize}px "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif`

    텍스트가 길면 measureText로 fontSize를 줄이고, 그래도 넘치면 ... 처리합니다. 웹에서 작성한 사용목적·사용인원이 라벨에 그대로 반영됩니다.

    이미지·PDF 혼합

    • PNG/JPG → embed
    • HEIC/WebP → Canvas 경유 PNG 변환
    • PDF 영수증 → PDFDocument.load → 페이지 복사 → 각 페이지에 라벨

    거래 행과 영수증 파일은 금액·날짜·가맹점 기준으로 매핑하고, 못 찾으면 매핑없음을 붙입니다.


    엑셀 내보내기·중간 저장

    엑셀은 "입력"이 아니라 "출력"

    이 시스템에서 엑셀의 역할이 바뀌었습니다.

    • 입력: 웹 테이블 (사용목적, 사용인원, 비고)
    • 출력: ExcelJS로 생성한 제출용 파일

    담당자는 카드사 원본 엑셀을 PC에 저장·편집할 필요가 없습니다. 경영관리부가 올린 파일이 파싱되어 화면에 뜨고, 작성이 끝나면 "현재 양식 엑셀 다운로드" 한 번이면 됩니다. 헤더 색, 금액 포맷, 컬럼 순서까지 제출 양식에 맞춰져 있습니다.

    중간 저장

    정산은 한 번에 끝나지 않습니다. 월 초에 이전 달 내역을 정리하다 보면 며칠에 걸쳐 작성하는 경우도 흔하거든요. 예전에는 "작성 중인 엑셀"을 로컬에 따로 두거나 메일로 보냈는데, 이제는 아래처럼 처리합니다.

    저장소 용도
    localStorage 탭 전환·새로고침 대비 로컬 draft
    Strapi core store POST /settlement-work/save — 사용자·월·카드별 서버 저장

    키는 사용자 + 대상월 + 카드끝자리 입니다. 같은 월·카드를 다시 열면 작성하던 내용이 그대로 복원됩니다.


    배포할 때

    Docker Compose 3-tier로 올립니다.

    web (nginx + Vite build)  :80
    strapi                    :1337
    postgres                  :9999

    프론트 빌드할 때 VITE_STRAPI_BASE_URL을 실제 접속 URL로 bake-in 합니다. LAN이나 다른 PC에서 접속하면 Strapi CORS_ORIGIN에 프론트 origin을 넣어야 합니다. .env 바꾼 뒤에는 프론트·백엔드 둘 다 재시작해야 반영됩니다.


    테스트

    # 프론트
    npm install && cp .env.example .env && npm run dev
    
    # 백엔드
    cd strapi-backend && npm install && cp .env.example .env && npm run develop

    손으로 확인할 항목입니다.

    • 경영관리부 계정으로 KB·BC 샘플 엑셀 업로드
    • 부서 계정 로그인 → 본인 카드 내역만 화면에 뜨는지
    • 웹 테이블에서 사용목적·사용인원 입력 후 저장
    • 브라우저 새로고침 → 작성 내용 복원되는지
    • 제출용 엑셀 다운로드 — 카드사 원본과 별개로 양식이 맞는지
    • JPG/PDF 섞어서 통합 PDF
    • 같은 파일 재업로드 → 중복 차단
    • 승인취소 포함 파일 → 쌍 거래 제거

    이슈 모음

    이슈 어떻게 했는지
    담당자별 메일 발송·파일 분기 업로드 1회 + 카드끝자리 allowlist
    엑셀에서 직접 편집 웹 테이블 입력, 엑셀은 출력만
    카드사마다 헤더·행 위치 다름 alias + 헤더 행 점수 매칭
    금액 열 원화/외화/달러 분산 우선순위 + USD×환율
    승인취소·정상 중복 승인번호 void set
    로그인·역할 Strapi Users & Permissions
    월별 데이터 구조 Core store key-value
    PDF 한글 깨짐 Canvas → PNG embed
    작성 중단 localStorage + 서버 save
    동일 파일 재업로드 content signature
    LAN CORS CORS_ORIGIN + 프론트 API URL

    마무리

    정산시스템의 실질적인 이득은 "메일 + 엑셀 편집" 루프를 끊은 것입니다. 경영관리부는 카드사 파일을 한곳에 올리기만 하고, 담당자는 로그인해서 작성·저장·다운로드하면 됩니다. 엑셀 설치하고 시트 열어서 셀 복붙하는 단계가 사라졌습니다.

Designed by Tistory.