새벽에 VAT 폼을 뜯어고치고 구글시트 자동 동기화를 붙인 날
목차
새벽 작업치고는 꽤 무거운 내용이 쌓였다. 시작은 VAT 입력 폼의 드롭다운 UX가 영 불편하다는 데서 출발했고, 거기서 파생돼 구글시트 자동 동기화 파이프라인까지 만들게 됐다. 작업이 끝나고 보니 커밋이 여섯 개, 그 중에 fix가 두 개인데 둘 다 스스로 파놓은 구멍을 막은 것이라 민망하긴 하다.
VAT 폼이 왜 불편했나
VAT 관련 어드민 폼에는 사용처, 용도, 결제자, 내용 같은 필드들이 있다. 이게 처음엔 전부 자유 텍스트 <input> 이었는데, 실제로 쓰다 보면 동일한 값들이 반복해서 입력된다. "법인카드", "교통비", 담당자 이름 같은 것들. 매번 직접 타이핑하면 오타도 나고, 나중에 필터링이나 집계를 할 때 "교통비"와 "교통 비"가 따로 집계되는 문제가 생겼다. 어드민 도구인데 이 정도 불편함은 해결해야 한다 싶었다.
목표는 단순했다. 기존에 입력된 값들을 자동완성 후보로 띄우되, 새로운 값도 자유롭게 입력 가능한 형태. 그리고 "구분" 같은 고정 선택지는 일반 <select>, 자유 입력이 필요한 필드는 기존값 목록이 뜨는 자동완성 형태로 나눠서 처리하기로 했다.
select박스 vs 자동완성 콤보박스
두 가지 패턴을 필드 성격에 따라 구분했다.
| 필드 | 패턴 | 이유 |
|---|---|---|
| 구분 | <select> (고정 옵션) |
값의 종류가 닫혀 있음 |
| 결제자 | 자동완성 (기존값+직접입력) | 담당자가 추가될 수 있음 |
| 사용처 | 자동완성 (기존값+직접입력) | 새로운 거래처 생길 수 있음 |
| 용도 | 자동완성 (기존값+직접입력) | 신규 항목 발생 가능 |
| 내용 | 자동완성 (기존값+직접입력) | 자유도 필요 |
VatClient.tsx 쪽에서 자동완성 드롭다운을 구현하는 건 어렵지 않았다. datalist를 활용하거나 직접 상태 관리로 드롭다운을 띄우는 방식 중 고민하다가, 직접 구현 쪽으로 갔다. datalist는 브라우저마다 스타일링이 제각각이라 어드민 UI 통일성을 맞추기가 까다롭다. 결국 input에 focus/change 이벤트를 붙여서 필터링된 목록을 overlay로 띄우는 패턴을 택했다.
// 기존값 목록을 props로 받아 자동완성 후보로 사용
const [suggestions, setSuggestions] = useState<string[]>([])
const handleChange = (value: string) => {
setValue(value)
const filtered = candidates.filter(c =>
c.toLowerCase().includes(value.toLowerCase())
)
setSuggestions(filtered)
}
처음 feat 커밋에서는 결제자, 사용처만 했다가, 곧바로 다음 커밋에서 용도, 내용도 같은 패턴으로 추가했다. 어차피 컴포넌트가 일반화되어 있으니 추가하는 건 빠르게 됐다.
구글시트 UI 제거와 동기화 자동화
VAT 데이터는 내부 DB뿐 아니라 구글시트에도 올라가게 되어 있었다. 그런데 기존 구현에는 어드민 UI 안에 "구글시트에 올리기" 버튼 같은 게 있었는데, 이게 너무 수동적이고 실수 여지가 있었다. 누군가 버튼을 안 누르면 시트가 구식 상태로 남는다.
그래서 이번에 구글시트 UI 자체를 제거하고, 대신 scripts/vat-sheet-sync.js를 새로 만들어서 주기적으로 자동 동기화되도록 바꿨다. 스크립트 역할은 단순하다.
- DB에서 VAT 데이터를 pull
- 구글시트의 전용 탭에만 push (다른 탭은 건드리지 않음)
- 변경분만 감지해서 덮어쓰는 게 아니라 탭 전체를 교체하는 방식
"전용 탭만" 건드리는 제약을 명시적으로 설정한 이유가 있다. 해당 구글시트에 다른 팀에서도 쓰는 탭이 있어서, 동기화 스크립트가 잘못 건드리면 다른 탭의 데이터가 날아갈 수 있다. 탭 이름을 하드코딩으로 고정해서 그 탭 외에는 절대 접근 안 하도록 막아뒀다.
cron은 10분 간격으로 설정했고, scripts/vat-sync-cron.sh를 래퍼 스크립트로 만들었다.
#!/bin/bash
cd /opt/<repo>
node scripts/vat-sheet-sync.js >> /var/log/vat-sync.log 2>&1
permission denied로 배포가 막혔던 삽질
여기서 첫 번째 삽질이 터졌다. vat-sync-cron.sh 래퍼 스크립트를 작성하고 커밋했는데, 배포 후에 cron에서 permission denied가 떴다. 원인은 git이 파일 실행 비트(+x)를 추적하지 않은 상태로 커밋됐던 것.
로컬에서 chmod +x scripts/vat-sync-cron.sh 해놨으니 당연히 되겠지 했는데, git 저장소에 실행 비트가 저장되지 않으면 배포 후 클론된 파일은 기본 퍼미션으로 복원된다. 특히 CI/CD로 배포하는 환경에서는 로컬 퍼미션이 전혀 의미가 없다.
수정은 간단하다.
git add --chmod=+x scripts/vat-sync-cron.sh
git commit -m "fix(vat): 동기화 래퍼 실행비트(+x) git 추적"
git update-index --chmod=+x 또는 git add --chmod=+x로 git 인덱스에 실행 비트를 명시적으로 기록해줘야 한다. 배포 후 서버에서 ls -la scripts/vat-sync-cron.sh 찍어보면 -rwxr-xr-x로 올바르게 들어온 걸 확인할 수 있다.
이런 실수는 보통 .sh 파일을 처음 추가할 때 놓치기 쉽다. 특히 macOS에서 작업하고 Linux 서버에 배포하는 환경이면, 로컬에서 실행이 잘 된다는 게 배포 환경에서도 된다는 보장이 전혀 아니다. .sh 스크립트를 git에 추가할 때는 --chmod=+x를 세트로 붙이는 걸 습관화해야 한다.
드롭다운 옵션 범위 버그
두 번째 fix는 드롭다운 옵션 데이터 소스 문제였다. 자동완성 후보로 쓸 기존 입력값들을 어디서 가져오느냐가 문제였는데, 처음 구현에서는 현재 필터링된 기간 내 데이터만 기준으로 후보를 뽑고 있었다.
예를 들어, 조회 기간을 2026년 6월로 설정해놓으면 드롭다운 후보에 6월 데이터에 있는 값만 나온다. 그러면 7월에 새로운 VAT 항목을 입력할 때, "이 사용처는 5월에 처음 등록됐는데 6월에는 없었으니 후보에 안 뜬다"는 상황이 생긴다.
// 버그: 현재 조회 기간 내 데이터로만 후보 생성
const candidates = filteredData.map(row => row.사용처)
// 수정: 전체 데이터에서 후보 생성
const candidates = allData.map(row => row.사용처)
fix는 단순하지만 의도 설계가 잘못됐던 것. 자동완성 후보는 "현재 보고 있는 데이터"가 아니라 "전체 히스토리에서 써본 값"이 기준이어야 한다. page.tsx에서 전체 데이터를 별도로 받아서 VatClient에 allData 형태로 내려주는 방식으로 수정했다.
PSY 자동 생성 커밋
자정이 지나면서 PSY 쪽 daily auto-generated 커밋도 올라왔다. 이건 스케줄러가 자동으로 만들어주는 것이라 딱히 손댄 건 없다. 오늘 날짜 기준으로 테스트 콘텐츠 세 개와 커버 이미지가 생성됐다. 자동 파이프라인이 정상적으로 돌고 있다는 확인 정도의 의미다.
새벽 여섯 시간 동안 VAT 어드민 폼 하나를 뜯어고쳤는데, 생각보다 파생 작업이 많았다. 폼 UX 개선이 목표였는데 구글시트 동기화 자동화까지 이어졌고, 그 과정에서 git 실행 비트 추적 문제랑 드롭다운 데이터 범위 설계 실수를 두 번 수습했다.
배운 것 정리하면 이렇다.
.sh파일 추가 시git add --chmod=+x는 기본 세트로 챙기기- 자동완성 후보 데이터는 현재 뷰 필터가 아닌 전체 히스토리 기준으로 설계할 것
- 수동 버튼보다 cron 자동화가 실수 여지를 훨씬 줄인다. 버튼은 사람이 안 누르면 그만이지만 cron은 잊어버릴 수가 없다
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.