0%

Frontend

영수증 사진 한 장으로 경비 처리 끝내기 — 사내 경비 관리 서비스 Spendly 구축기

전*진··수정됨 2026.07.20

영수증 사진 한 장으로 경비 처리 끝내기 — 사내 경비 관리 서비스 Spendly 구축기

들어가며

회사에서 경비 처리는 늘 비슷한 풍경이에요. 법인카드로 결제하고, 영수증을 모아뒀다가, 월말에 엑셀에 하나씩 옮겨 적고, 재무팀은 그걸 다시 대조하고요. 누구도 좋아하지 않지만 누구나 하고 있는 일이죠.

이 흐름을 "영수증 찍기 → 자동 인식 → 승인 → 회계 처리" 한 번에 끝나는 파이프라인으로 바꾸는 것이 Spendly의 목표였어요.

  • 직원은 결제 후 영수증을 사진으로 찍어 올리면 OCR이 금액·가맹점·날짜를 읽어줘요. 휴가 신청도 같은 앱에서 해요.

  • 재무팀은 올라온 경비를 검토·승인하고, 승인 건으로 회계 전표를 만들고, 영수증 묶음을 PDF로 내보내요. 법인카드 승인 내역은 외부 서비스를 통해 자동 수집돼요.

  • 관리자는 사실상 할 일이 없어요. 사내 메신저의 조직도가 매일 새벽 자동 동기화되어 팀과 계정이 만들어지고, 신규 직원은 메신저 봇 DM으로 초대 링크를 받아요.

이 글에서는 완성된 기능 자랑보다는, 만드는 과정에서 실제로 밟은 지뢰들을 위주로 이야기해 보려고 해요. 개발 DB를 통째로 날린 사고, CI가 60분 동안 침묵하다 죽은 미스터리, 그리고 만든 기능을 통째로 들어낸 결정까지요.

전체 그림 — 앱은 3개, 저장소는 1개

기술 스택은 검증된 것들로 보수적으로 골랐어요. TypeScript 모노레포(pnpm) 안에 NestJS API 서버, React 18 + Vite 프론트 2개(직원용 PWA, 재무 관리자 웹), 그리고 PostgreSQL + Prisma 조합이에요.

ocr/                        (모노레포 루트)
├── apps/
│   ├── api/                ← 백엔드 API 서버 (NestJS)
│   ├── employee-pwa/       ← 직원용 앱 (영수증 제출, 휴가 신청)
│   ├── finance-admin-web/  ← 재무 관리자용 웹 (검수, 승인, 설정)
│   └── e2e/                ← 브라우저 통합 테스트 (Playwright)
├── packages/
│   ├── shared/             ← 세 앱이 공유하는 타입·계약(contract)
│   └── ui/                 ← 공용 UI 컴포넌트
└── docker-compose.yml

프론트와 백엔드가 주고받는 요청·응답 타입은 전부 packages/shared에 모았어요. API 응답 형태를 바꾸면 프론트 빌드가 그 자리에서 깨지기 때문에, "서버는 바꿨는데 클라이언트는 몰랐다"류의 사고가 원천적으로 안 나요. 모노레포를 선택한 가장 큰 이유였고, 3주 내내 이 결정 덕을 봤어요.

인증을 위해 오리진을 하나로 접었어요

인증은 httpOnly + SameSite=Strict 쿠키로 하고 싶었어요. 토큰을 localStorage에 두는 순간 XSS 한 방에 세션이 털리니까요. 문제는 SameSite=Strict 쿠키는 크로스 오리진 요청에 실리지 않는다는 점이에요. 프론트가 :5173, API가 :3000이면 그 자체로 크로스 오리진이죠.

CORS 설정과 SameSite 완화로 뚫는 대신, 오리진 자체를 하나로 접기로 했어요. Caddy 리버스 프록시가 세 앱을 포트 하나로 묶어요.

:8080 {
    handle /api/* {
        reverse_proxy api:3000
    }

    redir /finance /finance/ 308

    handle_path /finance/* {
        root * /srv/finance
        try_files {path} /index.html
        file_server
    }

    handle {
        root * /srv/employee
        try_files {path} /index.html
        file_server
    }
}

브라우저 입장에서는 처음부터 끝까지 하나의 사이트예요. CORS 설정이 아예 없고, 쿠키는 아무 예외 없이 흘러요. "CORS 에러 나서 임시로 * 열어둠" 같은 주석이 생길 여지를 구조로 없앤 셈이에요.

작은 함정도 하나 있었는데, /finance(슬래시 없음)로 접근하면 handle_path /finance/*에 매칭되지 않아 직원용 PWA가 떠버렸어요. 308 리다이렉트 한 줄로 정규화해서 해결했고요. 이런 건 문서에서 못 배우고 직접 밟아야 알게 되더라고요.

외부 연동 — OCR, 카드사, 메신저

Spendly의 핵심 가치는 "직원이 타이핑을 안 하는 것"이라서, 외부 연동이 세 갈래예요.

영수증 인식은 네이버 Clova OCR을 써요. 직원이 찍은 사진에서 금액·가맹점·날짜를 뽑고, 직원은 틀린 곳만 고쳐요. OCR이 100% 맞는다는 가정은 처음부터 버리고, 인식 결과를 "제안"으로 보여주고 최종 확정은 사람이 하는 흐름으로 설계했어요.

법인카드 승인 내역은 CODEF로 수집해요. 카드사 사이트를 크롤링해 주는 서비스인데, 직원이 올린 영수증과 카드 승인 내역을 자동 매칭하면 "영수증은 있는데 카드 내역이 없다"거나 그 반대인 미증빙 건이 바로 드러나요.

실배포 프로비저닝에서 배운 것 하나만 공유하면 — 카드사 계정 등록 시 loginType, clientTypeLevel 같은 필드가 문서상 필수로 보이지 않는데, 빠지면 "사용자 계정정보 등록에 실패했습니다"라는 메시지만 돌아와요. 원인을 알 수 없는 에러 메시지와 씨름한 끝에 알아냈고, 곧바로 운영 런북에 성공한 요청 원형을 박제해뒀어요. 외부 API 연동은 "성공한 요청의 원본"이 최고의 문서라는 걸 다시 확인했어요.

조직도는 사내 메신저 Flow에서 매일 새벽 동기화해요. 부서·직원 정보를 읽어 팀과 계정을 만들고, 신규 직원에게는 봇 DM으로 초대 링크를 보내요. 관리자가 조직도를 이중으로 관리하는 순간 반드시 어긋나기 때문에, 메신저 조직도를 단일 원천(source of truth)으로 삼았어요.

사고: e2e 테스트가 개발 DB를 지웠어요

7월 7일, 브라우저로 영수증 업로드를 테스트하던 중 갑자기 제출이 404로 실패하기 시작했어요. "영수증/OCR 결과가 없습니다." 방금 올린 영수증이 없다니요.

원인을 추적해 보니 코드 문제가 아니었어요. 별도 체크아웃에서 돌던 e2e 테스트가 개발 DB를 청소한 것이었어요. e2e 테스트는 시작할 때 시드 데이터를 정리하는데, 그 체크아웃에는 보호 장치가 없어서 DATABASE_URL — 즉 제가 쓰던 개발 DB — 를 그대로 물고 돌았던 거예요. 테스트는 성실하게 자기 할 일을 했고, 제 데이터가 사라졌죠.

"조심하자"는 다짐은 대책이 아니에요. 같은 실수를 할 수 없게 만들어야죠. 그래서 모든 e2e 테스트 앞단에 강제 게이트를 심었어요.

// apps/api/test/helpers/test-env.mjs
const testUrl = process.env.TEST_DATABASE_URL ?? readEnvFile("TEST_DATABASE_URL");
const devUrl  = process.env.DATABASE_URL      ?? readEnvFile("DATABASE_URL");

if (!testUrl) {
  throw new Error(
    "TEST_DATABASE_URL이 설정되지 않았습니다. " +
    "e2e 테스트는 개발 DB를 파괴하므로 전용 테스트 DB가 필요합니다.",
  );
}
if (devUrl && testUrl === devUrl) {
  throw new Error("TEST_DATABASE_URL이 DATABASE_URL과 동일합니다.");
}

process.env.DATABASE_URL = testUrl;   // 이 시점 이후 모든 코드는 테스트 DB만 본다
await prepareTestDatabase(testUrl);   // 없으면 생성 + 마이그레이션까지 자동

포인트는 세 가지예요.

  1. 테스트 전용 DB가 없으면 실행 자체를 거부해요. 경고가 아니라 에러예요.

  2. 테스트 DB가 개발 DB와 같은 주소여도 거부해요. 복붙 실수까지 막아요.

  3. 거부만 하면 불편하니까, 테스트 DB 생성과 마이그레이션은 자동으로 해줘요. 안전장치가 귀찮으면 사람들은 우회하니까요.

이 사고 이후로 같은 유형의 데이터 소실은 한 번도 없었어요. 사고에서 얻을 수 있는 최선은 "다시는 일어날 수 없는 구조"라고 생각해요.

CI/CD — 하루 만에 구축했지만 함정은 8개였어요

기능이 어느 정도 안정되자 GitLab CI/CD를 붙였어요. 전략은 단순하게 갔어요.

  • CI(verify)는 자동, CD(deploy)는 수동 버튼. main 브랜치는 직접 push 금지, 파이프라인 통과 필수.

  • verify가 빌드한 프론트 산출물을 deploy가 그대로 배포해요. 서버에서 재빌드하지 않으니 "CI에서 통과한 것과 배포된 것이 다른" 상황이 없어요.

  • 시크릿은 서버 .env에만 두고, 배포 스크립트가 .env.example과 키를 대조해 누락을 배포 전에 차단해요.

  • 배포 직전 pg_dump 자동 백업, 마이그레이션은 additive만 허용(컬럼 삭제·rename은 2단계 배포).

설계는 반나절이면 끝났는데, 실제로 초록불을 보기까지 함정을 여덟 개 밟았어요. 몇 개만 추리면 —

  • 테스트 자동 발견의 배신: node --testtest/ 하위를 전부, 그것도 플랫폼마다 다른 순서로 실행했어요. 로컬(macOS)과 CI(Linux)에서 실행 순서가 달라 결과도 달랐죠. 테스트 파일 글롭을 명시적으로 고정해서 해결했어요.

  • 브라우저 테스트의 환경 요구: 프론트 테스트가 실제 Chrome을 요구해서, verify 잡 이미지를 mcr.microsoft.com/playwright 공식 이미지로 교체했어요. 이때 정한 규칙 하나 — Playwright 버전을 올리면 이미지 태그도 반드시 함께 올린다. 안 그러면 몇 주 뒤의 내가 고통받아요.

  • CI 부하에서만 죽는 테스트: Promise.all로 동시 요청을 쏘는 테스트가 로컬에선 멀쩡한데 CI에서만 ECONNRESET으로 죽었어요. CI 러너의 리소스가 로컬보다 빈약하다는 당연한 사실을 테스트가 가르쳐줬고, 순차 실행으로 바꿨어요.

  • rsync와 권한의 미묘한 관계: 배포 유저가 소유하지 않은 디렉터리에 rsync하면 mtime 변경이 거부돼요. -a 대신 -rlt -O로 소유권·시간 보존을 포기하니 해결됐어요.

하나하나는 사소하지만, 이런 게 여덟 개 쌓이면 "CI 붙이는 데 왜 이렇게 오래 걸려요?"의 정체가 돼요. 전부 커밋 메시지와 운영 문서에 남겨서, 다음 프로젝트에선 같은 값을 두 번 치르지 않게 했어요.

60분의 침묵 — CI를 죽인 범인은 감사 로그였어요

구축기에서 가장 미스터리했던 버그 이야기를 해볼게요. 인증·세션 하드닝 작업을 하던 7월 15일, CI verify 잡이 60분 타임아웃으로 죽기 시작했어요. 로그의 마지막 줄은 "Interrupted while running: X" — 그런데 X 파일은 이번 작업과 아무 상관 없는 테스트였어요. 로컬에선 전부 통과하고요.

범인을 찾고 보니, 서로 무관해 보이는 세 가지가 결합한 결과였어요.

  1. 감사 로그(AuditEvent)는 DB 트리거로 UPDATE/DELETE가 금지돼 있어요. 감사 기록은 불변이어야 하니까 의도한 설계였죠.

  2. AuditEvent.actor는 optional 관계라서, Prisma의 기본 참조 동작이 SetNull이에요. 즉 사용자를 삭제하면 그 사용자의 감사 이벤트에 UPDATE가 발생해요. → 불변 트리거가 예외를 던져요.

  3. e2e의 after() 정리 훅이 이 예외로 죽으면, 앱 종료 코드가 실행되지 않아요. 자식 프로세스가 HTTP 서버와 DB 커넥션을 쥔 채 살아남고, 순차 실행 중인 테스트 러너는 그 자식이 끝나기를 영원히 기다려요.

각 조각은 전부 합리적이에요. 감사 로그는 불변이어야 하고, optional 관계의 SetNull은 Prisma의 표준 동작이고, 테스트는 뒷정리로 사용자를 지워야 하죠. 그런데 셋이 만나면 "인증 기능을 추가할수록(= 감사 이벤트 기록 지점이 늘어날수록) 사용자를 삭제하는 모든 테스트가 시한폭탄이 되는" 구조가 돼요. 심지어 증상은 엉뚱한 파일의 타임아웃으로 나타나서, 에러 로그가 오히려 수사를 방해했어요.

수습하며 두 가지 규칙을 세웠어요.

// e2e cleanup: 사용자 삭제 전, 그 사용자의 감사 이벤트를 먼저 지운다
await prisma.$executeRawUnsafe(
  'ALTER TABLE "AuditEvent" DISABLE TRIGGER "AuditEvent_immutable_delete"');
await prisma.auditEvent.deleteMany({ where: { actorId: { in: userIds } } });
await prisma.$executeRawUnsafe(
  'ALTER TABLE "AuditEvent" ENABLE TRIGGER "AuditEvent_immutable_delete"');

그리고 더 중요한 쪽 — 모든 after() 훅에서 앱·서버 종료는 try/finally로 보장한다. 정리 실패는 "그 파일의 실패"로 끝나야지, 파이프라인 전체의 행(hang)이 되면 안 돼요. 근본 원인 수정과 별개로, 같은 부류의 버그가 다시 생겨도 피해 반경이 파일 하나로 제한되도록요.

디버깅 관점의 교훈도 하나 있어요. CI가 지목한 파일이 범인이 아닐 수 있어요. "Interrupted while running: X"가 보이면 X를 파기 전에, 직전에 수 밀리초 만에 죽은 스위트가 좀비 프로세스를 남기지 않았는지부터 의심해 보세요.

만든 기능을 들어내는 결정

구축기라고 하면 보통 기능을 쌓아 올린 이야기를 기대하지만, 돌아보면 뺀 결정들이 서비스를 더 좋게 만들었어요.

ERP 연동을 전면 제거했어요. 초기에는 승인된 경비를 외부 ERP로 전송하는 어댑터 계층까지 만들었는데, 실제 운영 요구를 다시 들여다보니 필요한 건 "전표(분개) 생성"까지였어요. 쓰지 않을 연동 계층은 존재만으로 유지비를 발생시켜요 — 테스트해야 하고, 마이그레이션 때 고려해야 하고, 새로 합류한 사람이 읽어야 하죠. 그래서 전표 생성 모듈만 남기고 ERP 전송 기능·설정·DB 모델을 전부 걷어냈어요. DB 컬럼 drop은 2단계로 나눠 안전하게요.

휴가 승인 흐름도 단순화했어요. 자동 승인, 강제 승인, 대리 신청, 중간 상태(APPROVING)까지 만들어뒀지만, 실제 사용 패턴을 보니 "신청 → 승인/반려" 단일 흐름이면 충분했어요. CSV 임포트, 팀별 파일럿 게이트도 같은 이유로 제거했고요.

기능을 지우는 커밋은 심리적으로 어려워요. 이미 쓴 시간이 아까우니까요. 하지만 코드베이스는 물려받을 사람(미래의 나 포함)에 대한 부채이고, 안 쓰는 코드의 가장 좋은 상태는 삭제된 상태더라고요.

마치며

2주간의 구축기에서 남은 것들을 정리하면 이래요.

  • 구조로 막을 수 있는 실수는 구조로 막기. 단일 오리진으로 CORS·쿠키 문제를 설계에서 제거했고, 테스트 DB 게이트로 데이터 소실 사고를 재발 불가능하게 만들었어요.

  • 각자 합리적인 결정도 결합하면 폭탄이 될 수 있어요. 불변 트리거 + SetNull + 보호 없는 정리 훅처럼요. 컴포넌트 단위 리뷰로는 안 보이고, 결합을 시험하는 e2e와 CI가 있어야 드러나요.

  • 외부 연동은 성공한 요청의 원형을 박제하기. 문서보다 정확하고, 미래의 디버깅 시간을 가장 확실하게 줄여줘요.

  • 빼는 결정을 두려워하지 않기. ERP 제거, 승인 흐름 단순화 모두 서비스를 가볍고 정확하게 만들었어요.

  • 함정은 밟은 즉시 기록하기. CI 구축의 함정 8개, CODEF의 필수 필드, Caddy 리다이렉트 — 전부 런북과 온보딩 문서에 남겼어요. 같은 값을 두 번 치르지 않는 것이 속도의 비결이라고 믿어요.

Spendly는 지금 실서버에서 법인카드 내역을 수집하고, 매일 새벽 조직도를 동기화하며 돌아가고 있어요. 다음 편에서는 OCR 인식 결과를 사람이 검수하는 UX 설계와, 재무팀 대시보드를 데이터 기반으로 재설계한 이야기를 다뤄볼게요.

읽기 도구

16분 읽기

이 글이 도움이 되었나요?

다음으로 읽기