4편까지는 카드 내역을 불러오고 태그를 붙이는 흐름을 만들었다. 그런데 쓰다 보니 급여 쪽이 계속 걸렸다. 매달 기본급, 식대, 교통비 같은 고정 항목은 그냥 숫자만 넣으면 됐는데, 문제는 그 외에 가끔 들어오는 금액들이었다.
명절 상여, 프로젝트 인센티브, 분기 성과급 같은 것들. 이걸 그냥 “기타”로 뭉뚱그려 넣으면 나중에 명세서를 다시 볼 때 뭔지 알 수가 없다. 그래서 수시급여에 직접 이름을 붙일 수 있게 구조를 바꿨다.
기존 구조의 한계
처음에 salary_records 테이블을 만들 때는 항목 종류를 고정 컬럼으로 관리했다. base_pay, meal_allowance, transport… 이런 식으로. 정기급여는 이걸로 충분했다.
근데 수시급여는 달마다 항목 이름이 달라진다. 컬럼을 미리 만들어놓을 수가 없다. 그렇다고 매번 테이블 구조를 바꿀 수도 없고.
결국 선택한 방식은 label 컬럼을 추가해서 항목 이름을 자유롭게 입력받는 것이었다. 수시급여 한 건 = 테이블 한 행, 그 행에 이름과 금액을 같이 저장하는 구조.
테이블 변경
기존 테이블에 컬럼 하나를 추가했다.
ALTER TABLE salary_records ADD COLUMN label VARCHAR(100) DEFAULT NULL;
이미 들어가 있던 정기급여 행들은 label이 NULL이 된다. 수시급여 행만 이 컬럼에 값이 들어간다. 정기/수시를 구분하는 플래그 컬럼도 함께 추가해서 조회할 때 구분하기 쉽게 했다.
API 수정 — payroll.php
화면에서 수시급여를 저장하거나 불러올 때 쓰는 API 파일이 payroll.php다. 여기서 수정한 부분은 크게 두 가지였다.
저장할 때
기존엔 정기급여 항목들을 고정 키로 받아서 INSERT했다. 수시급여는 배열 형태로 받아야 하니까 처리 방식이 달랐다.
// 요청 예시 (JSON)
{
"year": 2025,
"month": 6,
"type": "extra",
"label": "프로젝트 인센티브",
"amount": 0
}
실제 금액은 예시에서 0으로 처리했다. 중요한 건 label 필드를 받아서 DB에 그대로 저장한다는 흐름이다.
PHP 쪽에서는 type이 extra일 때만 label을 읽어서 INSERT하도록 분기했다. 정기급여 경로에서는 label을 아예 무시한다.
불러올 때
월별 명세서를 GET으로 조회하면 정기급여 행과 수시급여 행을 같이 돌려준다. 프론트에서는 type 값으로 구분해서 각각 다른 영역에 그린다.
SELECT id, type, label, amount, year, month
FROM salary_records
WHERE year = ? AND month = ?
ORDER BY type, id
쿼리 자체는 단순하다. 복잡한 건 이걸 화면에 어떻게 뿌리느냐였다.
화면 수정 — payroll.js
JS 파일에서 바꾼 부분이 제일 손이 많이 갔다.
수시급여 입력 행 추가/삭제
수시급여는 몇 건이 들어올지 모르니까 행을 동적으로 추가할 수 있게 했다. “항목 추가” 버튼을 누르면 이름 입력칸과 금액 입력칸이 한 쌍으로 생긴다. 삭제 버튼도 각 행마다 붙어 있다.
처음엔 이걸 그냥 innerHTML로 때웠는데, 행이 많아지면 이벤트 바인딩이 꼬였다. 결국 이벤트 위임 방식으로 바꿨다. 부모 컨테이너 하나에만 이벤트를 걸고, 클릭된 요소가 삭제 버튼인지 확인하는 방식.
container.addEventListener('click', (e) => {
if (e.target.classList.contains('btn-remove-extra')) {
e.target.closest('.extra-row').remove();
}
});
이 패턴은 이번 작업에서 여러 곳에 썼다. 동적으로 생기는 요소에 이벤트를 붙일 때는 위임 방식이 훨씬 안정적이었다.
저장 시 수시급여 수집
저장 버튼을 누르면 화면에 있는 수시급여 행들을 순회해서 배열로 만든다. 이름이 비어 있는 행은 건너뛴다.
const extraItems = [];
document.querySelectorAll('.extra-row').forEach(row => {
const label = row.querySelector('.input-label').value.trim();
const amount = parseInt(row.querySelector('.input-amount').value) || 0;
if (label) extraItems.push({ label, amount });
});
이걸 API에 넘길 때 정기급여 데이터와 합쳐서 한 번에 보냈다. 서버에서 type으로 분리해서 처리하니까 프론트는 그냥 묶어서 보내면 됐다.
이 작업 하면서 생긴 문제들
기존 데이터 처리
컬럼을 추가하고 나서 기존에 넣어뒀던 정기급여 데이터가 조회 쿼리에서 이상하게 보이는 경우가 있었다. label이 NULL인 행을 화면에서 수시급여 영역에 잘못 그리는 버그였다.
type 컬럼 값으로 구분하는 게 맞는데, 초기 마이그레이션 때 기존 행들에 type = 'regular'를 제대로 못 넣어줬던 거였다. UPDATE 한 번으로 해결했지만, 처음부터 마이그레이션 스크립트를 꼼꼼히 짰어야 했다.
같은 달에 수시급여를 여러 번 저장할 때
처음엔 저장할 때 해당 월의 수시급여 행을 전부 지우고 다시 INSERT했다. 그러다 보니 수정할 때마다 ID가 바뀌었고, 나중에 참조할 일이 생기면 문제가 될 것 같았다.
그래서 저장 로직을 바꿨다. 기존 행과 새 입력을 비교해서 없어진 건 DELETE, 새로 생긴 건 INSERT, 금액만 바뀐 건 UPDATE하는 방식으로. 코드가 좀 길어졌지만 ID 안정성은 훨씬 나아졌다.
연도·월 이동 버튼 안정화
이번 작업이랑 같이 대시보드, 월급, 카드 화면의 연도·월 이동 버튼도 손봤다. 이전 달, 다음 달 버튼이 가끔 두 번 이동하거나 아무 반응이 없는 경우가 있었다.
원인은 JS 모듈이 초기화되기 전에 이벤트가 붙는 타이밍 문제였다. 각 모듈마다 버튼에 직접 이벤트를 붙이다 보니 중복 바인딩이 생기기도 했다.
해결은 간단했다. 페이지 최상위 컨테이너 하나에 이벤트를 위임하고, 어떤 버튼이 눌렸는지 data-action 속성으로 구분했다. 모듈 초기화 순서에 영향을 덜 받게 됐다.
지금 상태 정리
월급명세서 화면에서 할 수 있는 게 이렇게 됐다.
- 정기급여 항목은 고정 입력칸에 금액만 넣는다.
- 수시급여는 항목 이름을 직접 입력하고 행을 추가/삭제할 수 있다.
- 저장하면
salary_records테이블에label과 함께 기록된다. - 조회할 때 정기/수시 구분이 화면에 명확하게 나뉜다.
생각보다 손이 많이 간 작업이었다. DB 컬럼 하나 추가하는 건 5분이지만, 기존 데이터 마이그레이션, API 분기 처리, 화면 동적 행 관리까지 연결되면 꽤 길어진다. 그래도 이제 명세서를 다시 봤을 때 “이게 뭐였더라” 하는 일은 없어졌다.
다음엔 대시보드 상세 모달 작업이랑 카드 내역 일괄 수정 기능 쪽을 정리할 예정이다. 그쪽도 손댄 게 꽤 있어서 따로 쓸 것 같다.
시리즈 글 모아보기
- 이전 편: 4. 아이폰 홈 화면에서 앱처럼 보이게 만든 시행착오
- 다음 편: 6. 카드 내역을 편하게 관리하려고 추가한 기능들
- 전체 목차: 바이브코딩 NAS 가계부 시리즈 전체 목차
- 참고: Claude Code 공식 문서