미국 경비관리 SaaS(ClaimBook) 연재의 마지막 편이다. 시작 · 감사 통제 설계 · AI 매칭 정확도.
실사용에서 나온 가장 현실적인 불만은 화려한 기능이 아니었다. “영수증을 한 장씩만 올릴 수 있어서 불편하다.” 출장 다녀오면 영수증이 열 장씩 쌓이는데 하나 올리고 기다리고 또 하나 올리고를 반복해야 했다. 이걸 여러 장 한 번에 올리는 벌크 업로드로 풀었다.
요청을 다 구현하지 않는 것도 설계다
현장 담당자에게서 개선 요청이 여러 건 왔다. 전부 구현하지 않았다. 비판적으로 검토해 절반만 채택했다. 벌크 업로드처럼 병목을 직접 없애는 건 넣고, 화면을 복잡하게 만들거나 기존 흐름과 충돌하는 건 뺐다.
“고객 요청 = 즉시 구현”으로 가면 제품이 금방 누더기가 된다. 요청 뒤의 진짜 병목만 골라내는 게 오히려 서비스라고 봤다. 벌크 업로드는 그 필터를 통과한 요청이었다.
설계 1. 단건 흐름을 절대 안 깬다
이미 잘 돌던 단건 업로드가 있었다. 벌크를 얹되 기존 단건은 100% 그대로 동작해야 했다. 그래서 파라미터를 복수·단일 둘 다 받게 했다.
# receipt_images[] (복수) 와 receipt_image (단일) 를 모두 수용, 빈 파일 제거
raw = params[:receipt_images].presence || params[:receipt_image]
새 기능이 옛 경로를 건드리지 않으니, 벌크에 문제가 생겨도 단건 업로드는 안전하다.
설계 2. 한 장이 실패해도 나머지는 살린다
이게 벌크의 핵심이다. 처음엔 전체를 한 트랜잭션으로 묶을까 했는데, 그러면 20장 중 1장이 검증에 걸리는 순간 19장이 통째로 날아간다. 벌크에서 최악의 경험이다.

그래서 파일별로 트랜잭션을 끊고, 성공/실패를 따로 모아 요약했다.
created, failed = [], []
files.first(MAX_BATCH).each do |file|
expense = build_expense_from_capture
begin
ActiveRecord::Base.transaction do # 파일 단위 트랜잭션
expense.save!
expense.create_receipt!(organization: Current.organization, image: file)
end
ReceiptParseJob.perform_later(expense.id) # 파싱은 비동기로
created << expense
rescue ActiveRecord::RecordInvalid => e
failed << [ file.original_filename, e.record.errors.full_messages.to_sentence ]
end
end
한 장이 무료 한도를 넘거나 검증에 걸려도 그 한 장만 failed로 빠지고 나머지는 저장된다. 사용자에겐 “N장 저장, M장 실패(사유)”로 요약해 보여준다. 명세서(statement)는 배치당 한 번만 find_or_create로 묶어 멱등하게 유지했고, 배치 상한은 20장으로 뒀다.
설계 3. 진행률은 있으면 좋고, 없어도 된다
여러 장을 올리면 진행률이 보여야 한다. JS 업로더가 파일별로 따로 POST해서 도착하는 대로 진행 막대를 그리고, 서버는 그때 JSON으로 결과를 준다. 그런데 JS가 없거나 꺼져도 동작해야 하므로, 폼을 그냥 제출하면 기존처럼 한 요청으로 받아 리다이렉트한다(점진적 향상).
파싱은 무거우니 업로드와 분리해 백그라운드 잡으로 돌렸고, 그래서 검토 화면은 파싱이 끝나는 대로 행이 하나씩 열리고 저장은 마지막에 한 번하는 형태가 됐다.
배운 것
- 요청을 다 구현하는 게 서비스가 아니다. 요청 뒤의 진짜 병목만 골라 넣고 나머지는 과설계로 기각한다. 절반만 채택하는 판단이 제품을 지킨다.
- 벌크의 본질은 “부분 실패를 어떻게 다루느냐”다. all-or-nothing 트랜잭션은 최악의 UX다. 파일별로 끊고 성공/실패를 요약한다.
- 새 기능은 옛 경로를 안 건드리게 얹는다. 복수·단일 파라미터를 모두 수용하니 단건 흐름이 100% 살아 있다.
- 점진적 향상. JS 있으면 진행률, 없으면 폼 제출. 무거운 파싱은 업로드와 분리해 비동기로.
한계 · 다음 계획
- 배치 상한(20장)은 경험값이다. 더 큰 배치는 청크 업로드나 재개(resume) 설계가 필요하다.
- 실패한 파일의 재시도 UX는 아직 “다시 올리기” 수준이다. 실패분만 모아 원클릭 재시도를 붙일 계획이다.
- 파싱 비동기라 검토 화면 도착 순서가 업로드 순서와 다를 수 있다. 정렬·그룹핑을 다듬는 중이다.
FAQ
Q. 왜 전체를 한 트랜잭션으로 묶지 않았나요?
한 장이라도 실패하면 전부 롤백되기 때문이다. 20장 올렸다 1장 때문에 19장을 다시 올리는 건 최악의 경험이다. 파일별로 트랜잭션을 끊어 부분 성공을 허용했다.
Q. 고객 요청을 다 안 들어주면 불만이 생기지 않나요?
요청의 표면이 아니라 그 뒤의 병목을 본다. 병목을 직접 없애면(벌크 업로드) 만족도가 오르고, 화면을 복잡하게 만드는 요청은 오히려 빼는 게 전체 경험에 낫다.
Q. 진행률 때문에 파일별로 따로 POST하면 서버 부하가 늘지 않나요?
파싱을 업로드에서 분리해 백그라운드로 돌리기 때문에 요청 자체는 가볍다. 그리고 JS가 없으면 한 요청으로 처리하는 경로도 함께 둬서 선택적이다.
Q. 단건 업로드는 계속 유지하나요?
그렇다. 복수·단일 파라미터를 모두 수용해 단건 흐름은 100% 하위호환이다. 벌크는 그 위에 얹은 것이라 서로 간섭하지 않는다.