부가세 관리 시스템을 하루 저녁에 밀어붙인 기록
목차
6시에 Google Play 수익 수집봇을 붙이는 것부터 시작했는데, 자정이 되기 전에 부가세 전용 구글 시트 탭까지 완성해 있었다. 중간에 심링크 사고, 다운로드 버튼 버그, 구글 시트 OOM, 첫 탭 덮어쓰기 사고까지 연속으로 터지면서 "이거 오늘 다 될까" 싶었는데 결국 다 됐다. 이 세션을 회고해두지 않으면 나중에 왜 이런 구조가 됐는지 전혀 못 찾을 것 같아서 남긴다.
Google Play 수익봇 - 작은 조각, 하지만 먼저 끝내야 했던 이유
세션 초반에 Google Play 수익 리포트 수집봇을 먼저 완성했다. 어제부터 Apple 매출은 수집이 됐는데 Play 쪽이 비어있으니 대시보드 "앱 매출" 카드가 반쪽짜리였다. 수집봇은 _lib/playstore-sales-check.py + cron 셀로 분리했고, 어드민 쪽 앱 매출 페이지를 Apple+Google 통합 뷰로 바꾸고 대시보드 카드에 Google Play 수치를 올렸다. 작업 자체는 크지 않았는데 이걸 먼저 끝내야 부가세 쪽에 집중할 수 있었다. 쌓인 TODO를 하나라도 줄이지 않으면 머릿속이 분산되는 타입이라 어쩔 수 없다.
부가세 시스템 본체 - 스키마부터 내보내기까지 한 판에
본게임은 부가세 자료 정리 기능이었다. 이게 왜 이 시간에 터졌냐면, 다음 달 초에 세무사한테 1기 확정 자료를 넘겨야 하는데 지금까지 스프레드시트로 수작업 정리하던 게 한계에 달했기 때문이다. 그냥 어드민에 붙여버리자고 결정했다.
Prisma 스키마에 vat_transactions, vat_documents 테이블을 추가하고 마이그레이션 돌렸다. 거래 분류(매출/매입/카드/기타), 인보이스 PDF 업로드, Excel(.xlsx) + 인보이스 묶음 zip 내보내기까지 한 세트로 넣었다.
prisma/migrations/20260701190000_add_vat_tables/migration.sql
src/lib/vat-fs.ts - 파일 경로·저장 유틸
src/lib/vat-sheets.ts - 구글 시트 연동 (이후에 계속 추가됨)
src/app/api/admin/vat/ - upload / transactions / export / sheet 엔드포인트
초기 seed 데이터(vat_seed_2026_1.json)도 심어서 개발 중 실제 데이터처럼 확인할 수 있게 했다. 내보내기는 exceljs로 xlsx 생성 후 zip에 인보이스 파일들을 같이 묶어 내려주는 방식이다.
삽질 1번 - 심링크 + 경로 참조 사고
업로드한 인보이스 파일 서빙을 처음에 심링크(public/uploads → /var/uploads)로 처리했다. 그런데 배포 스크립트가 public/ 하위를 날리는 케이스가 있어서 심링크가 날아가는 사고가 발생할 수 있다는 걸 로컬 테스트 중에 발견했다. 실제로 터지기 전에 잡은 거라 다행이었지만, 코드 전체를 canonical 경로 직접 참조 방식으로 바꿨다.
| 이전 | 이후 |
|---|---|
public/uploads 심링크 → 외부 볼륨 |
/var/data/vat/uploads/{id} 절대경로 직접 읽기 |
| 심링크 rm 시 전체 다운 | 경로만 맞으면 독립적으로 동작 |
vat-fs.ts, upload/export/document 엔드포인트 4개를 모두 수정했다. 이게 좀 귀찮았는데 안 하면 언젠가 분명히 사고난다.
삽질 2번 - 다운로드 버튼 a>button 중첩
Excel/zip 다운로드 버튼을 처음에 <a href="..."><button>...</button></a> 형태로 만들었다. HTML 스펙상 인라인 요소 안에 블록 요소 못 들어가고, React가 hydration 시 경고 + 일부 브라우저에서 클릭 이벤트가 씹힌다. fetch로 blob 받아서 임시 <a> 만들어 클릭하는 방식으로 바꿨다. 이 김에 Content-Disposition 헤더의 한글 파일명도 RFC 5987 인코딩(filename*=UTF-8''...)으로 제대로 처리했다. 크롬은 그냥 돼도 Safari가 한글 파일명 깨지는 케이스가 있어서.
캘린더 뷰도 이 타이밍에 넣었다. 세무 마감일(예정신고, 확정신고 등)과 거래 일정을 월별 캘린더로 보여주고, 날짜 드래그앤드롭으로 거래 일자를 옮길 수 있게 했다. 거래 수정 모달도 붙였다. vat-deadlines.ts에 기수별 마감일 계산 로직을 따로 분리했다.
구글 시트 연동 - 가장 오래 걸린 파트
여기서 진짜 시간이 많이 들었다. 서비스 계정(SA) 키 파일(gsa.json)로 스프레드시트에 쓰는 방식을 먼저 구현했는데, 조직 정책에서 SA 키 발급이 막혀있다는 걸 googleapis 패키지 붙이고 나서야 확인했다. 이미 googleapis 관련 코드는 다 짜놨는데.
해결책은 ADC(Application Default Credentials) + 임퍼스네이션이었다. 서버에 ADC가 세팅된 환경에서 SA를 직접 임퍼스네이션(impersonation)해 Sheets API를 호출하는 방식이다.
// SA 키 없이, ADC + 임퍼스네이션
const client = await auth.getClient();
const impersonated = new Impersonated({
sourceClient: client,
targetPrincipal: process.env.SHEETS_SA_EMAIL,
lifetime: 300,
delegates: [],
targetScopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
그리고 googleapis 패키지가 무거워서 빌드 시 OOM이 났다. Node.js 기본 메모리가 약 1.7GB인데 타입 체킹 + googleapis 번들링이 겹치면 넘어간다. package.json의 build 스크립트에 NODE_OPTIONS=--max-old-space-size=4096을 추가해서 해결했다.
삽질 3번 - 첫 탭 덮어쓰기 사고
구글 시트 연동이 동작하기 시작했는데, 테스트 중에 스프레드시트 첫 번째 탭(원래 수동으로 관리하던 시트)이 데이터로 덮어써지는 사고가 났다. 코드가 sheetId: 0 (첫 번째 시트) 기준으로 작동하고 있었던 것이다.
즉각 수정했다. 싱크 대상을 항상 부가세_admin이라는 전용 탭으로 격리하고, 해당 탭이 없으면 새로 만들게 했다. 기수별로는 부가세_2026_1기, 부가세_2026_2기 형태의 탭을 자동 생성한다. 이 사고 내용은 CLAUDE.md에도 기록해뒀다 - 나중에 "왜 첫 탭에 안 쓰냐"고 헷갈릴 미래의 나를 위해서.
마지막으로 전용 탭 서식을 갖췄다. 헤더 행 굵게, 배경색, 첫 행 고정, 열 너비 자동조정, 표 테두리까지. Sheets API의 repeatCell + updateSheetProperties + autoResizeDimensions 요청을 batchUpdate로 한 번에 보내는 방식이다. API 호출 횟수를 줄이려면 batchUpdate 하나로 묶는 게 거의 필수다.
한 세션에 이 정도 범위를 밀어붙인 건 드문 일인데, 세무 마감이라는 실제 압박이 있었기 때문에 가능했다. 구글 시트 연동이 없었으면 2~3시간 빠르게 끝났겠지만, 세무사와 공유할 때 스프레드시트가 자동으로 채워지는 게 훨씬 실용적이라 포기할 수 없었다. SA 임퍼스네이션 방식이 org 정책에 따라 언젠가 또 막힐 수도 있다는 생각은 있는데, 그건 그때 가서 ADC Workload Identity로 전환하면 된다.
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.