Search

인수증 발행 시스템 설계

오류 1건이 전체 발송을 멈추던 구조를 뜯어고친 이야기 - 인수증 발행 시스템 이관

배경: 구글 시트 + 앱스크립트로 돌던 인수증 발행

인수증 발행은 구글 시트와 앱스크립트(Apps Script)로 돌아가고 있었다. 처음엔 잘 동작했다. 문제는 구조 자체가 단일 실행 흐름이라는 거였다.
전체 업체의 인수증을 한 번에 쭉 돌면서 발송하는데, 중간에 한 업체에서 오류가 나면 거기서 전체가 멈춘다. 그러면 사람이 붙어서 어디서 멈췄는지 찾고, 손으로 나머지를 다시 돌려야 했다.
시간으로 보면 이랬다.
정상: 전처리 + 발송에 최소 35분
오류 발생 시: 수기 대응까지 최대 75분
발송 실패: 월 10회 정도
"기능이 되긴 되는데, 한 명이 계속 지켜봐야 하는" 전형적인 구조였다.

문제 정의: 왜 하나가 터지면 전부가 멈추나?

원인은 명확했다. 모든 업체가 하나의 실행 흐름 안에 묶여 있어서다. A업체 처리 중 예외가 던져지면 그 뒤 B, C, D는 실행조차 안 된다. 장애의 전파범위가 전체였다.
그래서 목표를 이렇게 잡았다.
한 업체의 실패가 다른 업체로 전파되지 않게 격리하고,
실패한 그 건만 독립적으로 다시 처리할 수 있게 만든다.

해결 1: 업체별 SQS 독립 처리로 장애 격리

핵심은 "한 덩어리로 순회하는 루프"를 업체 단위 메시지로 쪼개는 것이었다. 각 업체의 인수증 발행을 독립된 SQS 메시지로 분리했다.
// 발행 트리거: 업체별로 메시지를 흩뿌린다 async enqueueReceiptJobs(companies: Company[]) { await Promise.all( companies.map((company) => this.sqs.send({ queueUrl: RECEIPT_QUEUE_URL, messageBody: { companyId: company.id, date: today() }, // 업체별로 그룹을 나눠 서로 영향 안 주게 messageGroupId: `company-${company.id}`, }), ), ); }
TypeScript
복사
이제 A업체 메시지 처리가 실패해도, B·C·D 메시지는 각자 독립적으로 처리된다. 하나가 터져도 나머지는 멀쩡하다. 장애 범위가 "전체"에서 "그 업체 하나"로 줄었다.

해결 2: 단건 재발행 API — 수기 대응을 자동화로

예전엔 오류가 나면 처음부터 다시 돌리거나 사람이 손으로 메꿨다. 대신 업체별 단건 재발행 API를 만들었다.
@Post('receipts/:companyId/reissue') async reissue(@Param('companyId') companyId: string) { // 실패한 그 건만 다시 큐에 태운다 await this.sqs.send({ queueUrl: RECEIPT_QUEUE_URL, messageBody: { companyId, date: today(), retry: true }, messageGroupId: `company-${companyId}`, }); return { enqueued: true }; }
TypeScript
복사
전체를 다시 돌릴 필요 없이, 문제가 난 건만 콕 집어 재처리한다. 수기로 붙어야 하던 재시도 파이프라인이 자동화됐다.
특정 업체 실패 시 유관부서에서 재실행 API를 호출하고 슬랙연동으로 모든 사람이 볼 수 있게 구현했다.

해결 3: PDF 생성을 Lambda로 분리 — 메인 서버 부하 제거

또 하나의 문제는 PDF 생성이 메모리를 많이 먹는다는 거였다. 인수증을 PDF로 만드는 작업이 메인 서버에서 돌면, 발행이 몰릴 때 메인 서버 메모리가 출렁였다.
이건 "메인 서버가 할 일"이 아니라고 봤다. 무겁고, 간헐적으로 몰리고, 독립적으로 실행 가능한 작업 — Lambda로 분리하기 딱 좋은 특성이었다.
// 메인 서버는 'PDF 만들어줘'라고 요청만 하고 빠진다 const pdfUrl = await this.lambda.invoke('receipt-pdf-generator', { companyId, items, });
TypeScript
복사
메인 서버의 메모리 부담이 사라졌다. 그리고 여기서 하나 더 챙긴 게 있다. 생성 방식을 최적화하면서 인수증 파일 크기를 100KB → 10KB로 90% 줄였다. (불필요한 리소스 임베딩을 걷어내고 폰트/이미지 처리를 정리했다.)

트러블슈팅: "그냥 바꾸면" 안 됐다 — 1주 병행 운영

기술보다 어려웠던 건 전환 그 자체였다. 인수증은 돈과 직결된 문서라, "새 시스템으로 바꿨는데 데이터가 하나라도 틀리면" 사고다. 또한 수많은 업체가 인수증을 보고 배송온 식기가 제대로 왔는지 확인하고 인수증이 오지 않으면 바로 인입이 오는 중요한 기능이였다.
그래서 운영팀과 협업해서 기존 시스템과 1주간 병행 운영했다. 같은 날 같은 인수증을 양쪽에서 발행해서, 결과가 일치하는지를 계속 대조했다. 데이터 무결성이 검증된 뒤에야 기존 시스템을 내렸다.
이때 배운 것: 이관 프로젝트의 리스크는 코드가 아니라 "전환 순간"에 몰려 있다. 무중단으로, 되돌릴 수 있는 방식으로 넘기는 설계가 새 기능을 만드는 것만큼 중요하다.

회고

지표
Before
After
발송 실패
월 10회
0건
전체 소요시간
최대 75분
8분
오류 대응
수기 필수
완전 자동화
파일 크기
100KB
10KB
가장 크게 바뀐 건 "장애를 어떻게 다루느냐"에 대한 관점이었다. 예전 구조는 장애가 나면 안 되는 구조였고, 새 구조는 장애가 나도 번지지 않고 그 건만 다시 돌리면 되는 구조다. 실패를 없애려 하기보다, 실패를 격리하고 회수 가능하게 만드는 게 더 현실적이라는 걸 배웠다.