포트폴리오
프로젝트 목록
에러가 나지 않는 버그 회계 통합 대시보드를 운영하면서 가장 무서웠던 건 배치가 실패하는 경우가 아니었다. 실패하면 로그가 남고 다시 돌리면 된다. 정말 곤란한 건 배치가 "성공"했는데 틀린 값이 들어간 경우였다. 팀장회의 자료에 그 숫자가 그대로 올라가고, 누군가 이상하다고 말하기 전까지는 아무도 모른다. 이 글은 그런 사례 두 가지를 다룬다. 하나는 쿠팡 일별 매출이 0으로 들어오던 문제, 다른 하나는 마케팅 재무 월별 자료가 다른 달 자료로 덮어써지던 문제다. 둘 다 원인은 "데이터를 어떤 키로 식별하느냐"와 "어떤 응답을 정상으로 볼 것이냐"를 처음에 느슨하게 정한 데 있었다. 사례 1: 쿠팡 일별 매출의 0 배경 쿠팡 매출은 쿠팡 공급사 사이트에서 일별 실적을 받아 온다. 배치는 매일 전날 날짜로 돌면서 그날의 총 매출(GMV)과 SKU별 상세(판매 수량, 반품, 주문 수, 조회수 등)를 받아 두 테이블에 넣는다. 일별 합계 테이블은 날짜를 유니크 키로 upsert하고, SKU 상세 테이블은 그 날짜 행을 지우고 다시 넣는다. 증상 어떤 날은 쿠팡 일별 매출이 0으로 찍혔다. 쿠팡 매출이 하루에 0일 리는 없었다. 배치 로그를 보면 오류 없이 성공으로 끝나 있었다. 원인 쿠팡 쪽에서 전날 집계가 아직 끝나지 않았을 때, 응답 자체는 성공 플래그와 함께 정상으로 오되 합계 0, 상세 0건이 내려왔다. 배치 입장에서는 정상 응답이었으니 그대로 저장했다. 일별 합계는 날짜 기준 upsert, 상세는 날짜 단위 삭제 후 삽입이라, 이미 제대로 들어가 있던 날짜를 다시 돌리다 0 응답을 받으면 멀쩡한 데이터까지 0으로 덮어쓸 수 있는 구조였다. 첫 번째 대응: 덮어쓰지 않기 먼저 "합계 0이고 상세도 0건"이면 집계 미완료로 보고 저장을 건너뛰도록 했다. 기존 데이터는 유지된다. 그런데 이것만으로는 부족했다. 건너뛴 날짜는 그냥 비어 있는 채로 남았다. 다음 날 배치는 다시 "어제" 하루만 보기 때문에, 한 번 놓친 날은 누군가 수동으로 채우기 전까지 영영 비어 있었다. 그리고 건너뛰어도 배치는 성공으로 끝났으니 아무도 몰랐다. 두 번째 대응: 실패로 드러내고, 빈 날을 스스로 채우기 두 가지를 바꿨다. 첫째, 0 응답으로 건너뛴 날짜가 있으면 다른 날짜는 모두 저장한 뒤 마지막에 "데이터 미준비" 예외를 던진다. 배치 진입점은 실패 시 일정 시간(기본 600초)을 기다렸다가 같은 조건으로 한 번 더 돌리므로, 쿠팡 집계가 조금 늦은 날은 이 재시도에서 채워진다. 그래도 안 되면 실패 코드로 끝나 로그에 남는다. 둘째, 일배치가 어제 하루만 보지 않고 최근 14일 중 테이블에 없는 날짜를 찾아 함께 수집한다. 오늘 놓친 날은 내일 배치가, 내일도 놓치면 그다음 배치가 다시 시도한다. 기간은 환경변수로 바꿀 수 있게 했다. def get_recent_missing_dates(conn, end_date, lookback_days): if lookback_days <= 1: return [end_date] start_date = end_date - timedelta(days=lookback_days - 1) with conn.cursor() as cur: cur.execute( """ WITH days AS ( SELECT generate_series(%s::date, %s::date, INTERVAL '1 day')::date AS d ) SELECT d FROM days LEFT JOIN tb_coupang_daily c ON c.perf_date = d WHERE c.perf_date IS NULL OR d = %s::date ORDER BY d """, (start_date, end_date, end_date), ) return [row[0] for row in cur.fetchall()] generate_series 로 날짜 달력을 만들고 일별 합계 테이블과 LEFT JOIN해서 빠진 날만 고른다. 어제 날짜는 이미 들어가 있어도 항상 포함한다. 저장 쪽은 이렇게 바뀌었다. incomplete_dates = [] for d in dates: if d in results: total_gmv, rows = results[d] if num(total_gmv) == 0 and not rows: # 전일 집계가 아직 안 끝나면 성공 응답에 0이 내려옴 — 덮어쓰지 않음 incomplete_dates.append(d) continue store_day(conn, d, total_gmv, rows) if incomplete_dates: raise CoupangDataIncomplete( "Coupang daily data is not ready: " + ", ".join(d.strftime("%Y-%m-%d") for d in incomplete_dates) ) 예외를 저장 루프 뒤에서 던지는 것이 중요했다. 14일 중 하루만 미준비여도 나머지 13일은 저장되고, 미준비인 날만 다음 기회로 넘어간다. 결과 0이 저장되는 일은 없어졌고, 집계가 늦은 날은 재시도나 다음 날 배치에서 채워진다. 같은 원리로 쿠팡 일별 합계와 SKU 상세를 RAW 데이터 API로도 열었는데, 빈 날짜가 없다는 전제가 생기니 외부에서 기간 합계를 내도 안심할 수 있었다. 사례 2: 마케팅 재무 자료가 다른 달을 덮어쓰다 배경 마케팅 재무(채널별 매출 실적, 매출원가, 직·간접 광고비, 결제 수수료, 운송비, 판촉비 등)는 다른 시스템에서 월별로 정리된 행을 API로 보내 준다. 행마다 행 번호( Nos ), 연도, 월, 구분(채널) 값이 있다. 처음에는 이 행 번호를 기본키로 삼고, 같은 번호가 오면 갱신하는 upsert로 받았다. 첫 번째 문제: 행 번호가 없는 행 8월 중순, 보내는 쪽에서 행 번호가 비어 있는 행이 섞여 들어왔다. 기본키가 없으니 요청 전체가 400으로 거절됐다. 받는 쪽에서 임시 번호를 만들어 주기로 했다. 번호가 없는 행을 (연, 월, 구분)으로 묶고 정렬한 뒤, -((연*100+월)*1000+순번) 형태의 음수 번호를 붙였다. 음수로 둔 건 원천이 보내는 양수 번호와 절대 겹치지 않게 하려는 것이고, 연월을 번호에 넣은 건 다른 달의 임시 번호끼리 겹치지 않게 하려는 것이었다. 한 달에 999행을 넘으면 거절한다. 두 번째 문제: 7월 자료가 사라지다 9월 중순에 팀장회의 자료를 만들다가 7월 마케팅 재무 자료가 비어 있거나 다른 값으로 바뀌어 있는 걸 발견했다. 임시 번호 문제는 막았지만, 근본적으로 "행 번호"가 자료의 정체성을 나타내지 않는다는 게 원인이었다. 행 번호 기준으로 upsert하니, 다른 달 자료가 같은 행 번호로 들어오면 기존 달의 행을 그대로 덮어썼다. 보내는 쪽에서는 행 번호를 표의 줄 번호 정도로 쓰고 있었고, 우리는 그것을 영구 식별자로 믿고 있었다. 바꾼 방법 이 자료에서 실제로 하나의 행을 구분하는 건 (연, 월, 구분)이다. 그래서 다음 순서로 바꿨다. 대리키 id 를 추가하고 기존의 행 번호 기본키를 제거한 뒤 id 를 기본키로 지정한다. 운영 DB에 이미 테이블이 있으므로, 앱 시작 시 스키마를 보정하는 함수 안에서 pg_constraint 를 조회해 "행 번호 한 컬럼짜리 기본키"가 있을 때만 지우도록 했다. 몇 번을 실행해도 같은 결과가 나와야 했다. (연, 월, 구분)에 유니크 인덱스를 만든다. upsert의 충돌 대상을 이 세 컬럼으로 바꾸고, 갱신 대상 컬럼에서 이 세 컬럼을 뺀다. 행 번호는 이제 일반 컬럼으로 갱신된다. 식별 키가 비면 안 되므로 입력 검증을 추가했다. 연도는 2000~2100, 월은 1~12, 구분은 필수다. identity_columns = {"date_year", "date_month", "separate"} update_columns = [c for c in columns if c not in identity_columns] INSERT INTO tb_marketing_finance ({columns}) VALUES ({placeholders}) ON CONFLICT (date_year, date_month, separate) DO UPDATE SET {", ".join(f"{c} = EXCLUDED.{c}" for c in update_columns)}, updated_at = {KST_TIMESTAMP_SQL} 구조를 고친 뒤 7월 자료를 다시 적재해 복구했다. 이제 같은 달·같은 구분의 자료가 다시 오면 그 행만 갱신되고, 다른 달 자료는 행 번호가 같아도 건드리지 않는다. 함께 한 일 이 일을 겪기 전에 이미 팀장회의 화면에 "마케팅 비용 미적재 월" 표시를 넣어 두었다. 자료가 아직 안 들어온 달을 0으로 보여 주지 않고 미적재로 표시하는 것이다. 덮어쓰기 문제를 발견한 것도 결국 숫자가 이상하다는 걸 사람이 알아챘기 때문이었으니, 이 표시를 더 일찍 넣었다면 좋았을 것이다. 두 사례에서 배운 것 정상 응답과 정상 데이터는 다르다. 쿠팡의 성공 응답 안에 든 0은 "아직 없음"의 다른 표현이었다. 외부 원천마다 "준비 안 됨"이 어떤 모양으로 오는지 먼저 확인하고, 그 모양을 예외로 바꿔 드러내야 한다. 건너뛰기는 반쪽짜리 해결이다. 틀린 값을 저장하지 않는 것만으로는 빈칸이 남는다. 빈칸을 다음 실행이 스스로 찾아 채우는 구조(최근 N일 누락 탐지)까지 있어야 운영이 손을 떠난다. upsert 키는 "보내는 쪽이 주는 번호"가 아니라 "자료의 의미"로 정한다. 행 번호는 편해 보였지만 보내는 쪽에서 그 번호를 어떻게 쓰는지 확인하지 않았다. 처음부터 (연, 월, 구분)을 유니크 키로 두었다면 임시 번호를 만드는 우회도, 7월 복구도 필요 없었다. 운영 중인 테이블의 키를 바꾸는 마이그레이션은 멱등하게. 별도 마이그레이션 도구 없이 앱 시작 시 스키마를 보정하고 있었기 때문에, 제약 조건 이름을 추측하지 않고 카탈로그에서 찾아 지우는 방식으로 썼다. 다시 한다면 이런 변경은 버전이 붙은 마이그레이션 파일로 따로 관리하겠다. 기간: 2026.08 ~ 2026.09 기술: Python, Flask, PostgreSQL 관련 프로젝트: 회계 통합 대시보드
프로젝트 목록
공개된 기록과 소식
프로필과 소개