회계 통합 대시보드 개발 기록 (1) 왜 만들었나와 전체 구조

매달 반복되던 취합 작업 회사의 숫자는 한곳에 모여 있지 않았다. 재고, 입고, 발주, 출고, 품목(SKU) 정보는 물류 시스템(WMS)의 Oracle DB에 있었고, 자사몰 쪽 데이터는 MariaDB에, 쿠팡 매출은 쿠팡 공급사 사이트에, 마케팅 비용과 손익은 또 다른 시스템에 있었다. 팀장회의 자료를 만들 때마다 각 팀이 이 원천들을 따로 열어 엑셀로 내려받고, 붙여 넣고, 합계를 다시 맞췄다. 문제는 손이 많이 간다는 것만이 아니었다. 원천마다 갱신 시점이 다르다 보니 같은 "7월 판매량"이 누가 언제 뽑았느냐에 따라 달랐다. 회의 자리에서 숫자의 출처를 따지는 데 시간이 쓰였다. 그래서 목표를 이렇게 잡았다. 원천 데이터를 매일 정해진 시간에 한곳(PostgreSQL)으로 모으고, 모든 화면과 보고서는 그 한곳만 보게 한다. 그러면 적어도 "어디서 뽑은 숫자냐"는 질문은 사라진다. 2026년 8월 초에 배치 스크립트 묶음과 Flask 웹앱을 함께 올리면서 시작했고, 이후 두 달 동안 팀별 요구가 들어올 때마다 화면과 배치를 늘려 갔다. 이 글은 그 첫 번째 기록으로, 왜 이런 구조를 골랐는지와 전체 흐름을 정리한다. 요구사항과 제약 원천은 읽기만 한다. WMS Oracle은 실제 물류 업무가 돌아가는 DB라 쓰기는 물론이고 무거운 조회도 조심해야 했다. 조회는 SELECT만, 그것도 날짜 단위로 잘라서 했다. 당일 숫자와 마감 숫자가 다르다. 매출이나 입고는 당일에 잡혔다가 나중에 수정되거나 취소된다. 매일 쌓기만 하면 월말에 실제 마감 수치와 어긋난다. 과거 구간을 다시 채울 수 있어야 한다. 테이블 구조를 바꾸거나 조회 조건을 고치면 2024~2025년 데이터를 다시 적재해야 했다. 팀마다 보는 단위가 다르다. 영업은 거래처·채널별, 생산물류는 재고와 발주, 개발팀은 신제품, 경영지원은 품목별 원가 배분을 본다. 같은 원천을 다른 축으로 잘라 보여 줘야 했다. 외부 시스템도 같은 데이터를 쓰고 싶어 했다. 수요 예측(Forecast) 쪽에서 재고·입고·쿠팡 데이터를 API로 받아 가길 원했다. 구조: 도메인별 배치 + PostgreSQL + Flask 구성은 단순하게 세 층으로 나눴다. 수집 배치(Python) : 재고, 물류재고, 입고, 발주, 발주잔량, 출고, 매출, 해외매출, 해외 견적, SKU, 쿠팡을 도메인별 폴더로 나눴다. 각 폴더에는 실제 조회·적재 로직을 가진 *_batch.py 하나와, 이를 감싸는 얇은 실행 파일( daily , month_end , month_start , manual )이 있다. PostgreSQL : 모든 배치의 도착지. 원천 형식을 크게 바꾸지 않고 tb_items_total_* 테이블에 날짜·조건 단위로 적재한다. Flask 웹앱(Gunicorn, systemd) : 보고서 화면, 엑셀 내보내기, RAW 데이터 JSON API, 사용자·권한 관리, 배치 스케줄 관리를 담당한다. 배치 실행 파일을 셋으로 나눈 이유는 "당일 숫자와 마감 숫자가 다르다"는 제약 때문이다. daily 는 실행일 하루치만 적재하고, month_end 는 그달 1일부터 말일까지를 같은 로직으로 다시 적재한다. 월말 22시쯤 한 번 더 돌리면 한 달 동안 생긴 수정과 취소가 반영된다. manual 은 시작일·종료일을 인자로 받아 과거 구간을 채우는 용도이고, 2024~2025년 백필은 이걸 셸 스크립트로 묶어 돌렸다. 물류재고와 출고처럼 "전달 말일 기준"이 중요한 데이터는 매월 1일 새벽에 도는 month_start 를 따로 뒀다. 세 실행 파일이 같은 run() 을 부르게 하려면 적재가 몇 번을 돌려도 같은 결과를 내야 했다. 그래서 upsert 대신 "같은 조건의 기존 행을 지우고 새로 넣는" 방식을 택했다. def insert_to_pg(connection, rows, start_date, end_date, pcust, recstate1, recstate2): seller = seller_from_pcust_filter(pcust) delete_query = f""" DELETE FROM {TABLE_NAME} WHERE salesindate_start = %s AND salesindate_end = %s AND pcust_filter = %s AND seller IS NOT DISTINCT FROM %s AND recstate1 = %s AND recstate2 = %s """ # ... values 구성 생략 with connection.cursor() as cursor: cursor.execute(delete_query, (start_date, end_date, pcust, seller, recstate1, recstate2)) if values: cursor.executemany(insert_query, values) connection.commit() 조회는 날짜 하나, 판매사 필터 하나 단위로 잘라 반복한다. 원천 DB에 한 번에 큰 쿼리를 던지지 않고, 중간에 실패해도 그 날짜만 다시 돌리면 된다. 삭제 조건에 조회 조건(기간, 판매사, 상태 범위)을 전부 넣었기 때문에 같은 날짜를 다른 조건으로 적재한 행은 건드리지 않는다. 시행착오 몇 가지 오늘 재고가 아직 없을 때 물류재고 일배치는 처음에 실행일 날짜 하나로만 조회했다. 그런데 WMS의 당일 재고 스냅샷이 배치 시각에 아직 준비되지 않으면 조회 결과가 0건이 된다. 오류가 아니라 0건으로 끝나기 때문에, 그대로 두면 화면의 "당월 재고"가 비어도 배치는 성공으로 남는다. 그래서 후보 날짜를 둘로 늘렸다. 오늘을 먼저 시도하고, 0건이거나 오류가 나면 어제(같은 달인 경우만)를 시도한다. 둘 다 안 되면 그때 실패 코드로 종료한다. 달이 바뀐 첫날에 전달 말일 재고를 당월 재고로 쓰면 안 되기 때문에 "같은 달" 조건을 붙였다. def inventory_date_candidates(now): candidates = [now.strftime("%Y%m%d")] yesterday = now - timedelta(days=1) if yesterday.year == now.year and yesterday.month == now.month: candidates.append(yesterday.strftime("%Y%m%d")) return candidates for intot_date in inventory_date_candidates(now): try: inserted_count = run(start_date=intot_date, end_date=intot_date, ...) except Exception as exc: last_error = exc continue if inserted_count: return sys.exit(1) 이 경험 이후로 "0건 적재"를 성공으로 치지 않는 쪽으로 기준을 바꿨다. 같은 문제가 쿠팡 배치에서 더 까다로운 형태로 다시 나왔는데, 그 이야기는 3편에 적었다. 상태가 바뀌는 데이터는 증분이 안 된다 실재재고 화면에 "해외 확정출고"를 넣어야 했다. 해외 출고는 먼저 견적으로 잡히고, 그 견적이 신규 → 완료 또는 취소로 상태가 바뀐다. 처음엔 다른 배치처럼 견적일자 기준으로 그날치만 적재하려 했는데, 이렇게 하면 지난주에 만든 견적이 오늘 취소돼도 우리 테이블에는 여전히 "신규"로 남는다. 견적일자는 그대로이고 상태만 바뀌기 때문이다. 해외 견적은 건수가 많지 않았다. 그래서 증분을 포기하고 매일 02:40에 2020년 1월부터 오늘까지 해외 견적 라인 전체를 지우고 다시 넣도록 했다. 실재재고의 해외 확정출고는 이 테이블에서 상태가 "신규(미출고)"인 견적의 출고예정수량을 합한 값이다. 계산 기준이 헷갈린다는 의견이 있어서 화면에 산출 기준 안내 박스를 달고, 엑셀에도 "산출기준 / 품목별 합계 / 견적 명세" 시트를 따로 넣었다. 견적은 통화가 섞여 있어서 통화와 환율 컬럼도 함께 적재하고, 공급가액이 견적 통화 기준이라는 점을 명시했다. 배치 스케줄을 화면에서 관리하기 배치가 스무 개 가까이 늘자 서버에 들어가 crontab을 직접 고치는 일이 잦아졌다. 시간을 잘못 적거나 다른 작업(백업 등)의 줄을 실수로 지울 위험도 있었다. 그래서 웹앱의 배치 관리 화면에서 배치별 켜기/끄기와 실행 시각을 저장하면 crontab에 반영되게 했다. 핵심은 앱이 crontab 전체를 덮어쓰지 않는 것이다. 시작·끝 표시 주석 사이의 블록만 앱이 관리하고, 그 바깥 줄은 그대로 둔다. 화면에서도 "앱이 관리하는 내용"과 "앱 관리 외 내용"을 나눠 보여 주고, 반영 전에 확인 창을 띄운다. 각 배치에는 "매일 02시 40분 전체 기간 재적재(상태 변경 반영)"처럼 언제 왜 도는지 설명을 붙여 두어서, 나중에 보는 사람이 crontab 줄만 보고 추측하지 않아도 되게 했다. 팀장회의 자료를 PDF 한 부로 팀장회의 자료는 팀별 탭으로 화면에 들어갔지만, 회의 전에는 결국 한 부의 PDF가 필요했다. 각 팀이 올린 발표자료(PDF로 변환된 파일)와 시스템이 만든 보고서 화면을 순서대로 묶어야 했다. 서버에서 headless Chrome으로 보고서의 인쇄용 화면을 PDF로 찍고, pdfunite 로 발표자료와 합치는 방식을 택했다. 문제는 headless Chrome에는 로그인 세션이 없다는 점이었다. 인쇄용 화면을 로그인 없이 열어 두면 누구나 볼 수 있게 된다. 그래서 해당 연월과 만료 시각을 앱의 비밀키로 HMAC 서명한 10분짜리 토큰을 만들어 URL에 붙이고, 인쇄용 화면은 이 토큰만 검증하도록 했다. 만료 시각이 지나치게 먼 토큰도 거부한다. 팀 탭마다 Chrome을 한 번씩 띄우기 때문에 생성에 시간이 걸린다. 그래서 버튼에 "PDF 취합 중" 표시를 넣고 작성 중 모달을 띄워, 기다리는 동안 다시 누르지 않게 했다. 생성 결과가 1KB 미만이면 실패로 보고 어느 팀 탭에서 깨졌는지 메시지에 남기게 했다. RAW 데이터 API 수요 예측 쪽에서 재고·물류재고·입고·쿠팡 데이터를 직접 받아 가고 싶다는 요청이 왔다. 이미 화면에 RAW 데이터 메뉴가 있었기 때문에, 화면과 같은 테이블·검색·컬럼 필터·정렬 로직을 그대로 쓰는 JSON API를 붙였다. 화면과 API가 같은 함수를 타야 "화면에서 본 숫자와 API 숫자가 다르다"는 문의가 생기지 않는다. 인증은 브라우저 세션 또는 서버 환경변수에 둔 토큰( Authorization: Bearer 또는 X-API-Token 헤더) 중 하나로 받는다. 페이지 크기는 기본 100건, 최대 1,000건으로 막았고, 날짜는 YYYY-MM-DD , YYYYMMDD , YYYY-MM , YYYYMM 을 모두 받게 했다. 호출하는 쪽마다 날짜 형식이 달랐기 때문이다. 결과 재고, 물류재고, 입고, 발주, 발주잔량, 출고, 매출, 해외매출, 해외 견적, SKU, 쿠팡 데이터가 매일 정해진 시각에 PostgreSQL로 모인다. 팀장회의 자료는 영업·생산물류·개발·플랫폼 등 팀별 탭으로 화면에서 바로 보고, 전체 PDF 한 부로 내려받는다. 외부 시스템은 같은 데이터를 토큰 인증 API로 가져간다. 무엇보다 회의에서 "그 숫자 어디서 뽑았냐"를 묻는 대신 대시보드의 같은 화면을 함께 보게 됐다. 회고 "지우고 다시 넣기"는 생각보다 오래 버텼다. 일배치·월말 재적재·과거 백필이 같은 함수를 공유할 수 있었던 건 적재를 조건 단위로 멱등하게 만든 덕분이다. 대신 삭제 조건과 조회 조건이 어긋나면 데이터가 중복되거나 사라지므로, 두 조건을 한 함수 안에서 같은 인자로 만들도록 묶어 둔 것이 중요했다. 0건은 성공이 아니다. 원천이 아직 준비되지 않았을 때 배치는 오류 없이 빈 결과를 낸다. 처음부터 도메인마다 "0건이면 어떻게 할지"를 정해 뒀어야 했다. 웹앱을 한 파일로 키운 건 다시 하면 바꿀 부분이다. 화면을 빠르게 붙이느라 Flask 앱이 2만 줄이 넘는 app.py 하나가 됐다. 템플릿까지 문자열로 들어가 있어 diff를 읽기 어렵다. 다시 한다면 처음부터 보고서 도메인별 Blueprint와 템플릿 파일로 나누겠다. 스케줄 설명을 데이터로 둔 것은 잘한 일이다. 배치마다 실행 시각과 이유를 코드 안의 정의로 들고 있으니, crontab을 바꾸는 사람과 화면을 보는 사람이 같은