
Autowired.ai의 전체 아키텍처: AWS Serverless 기반의 멀티 테넌트 (Multi-Tenant) AI SaaS
요약
AWS Serverless 기반의 멀티 테넌트 AI SaaS인 Autowired.ai의 전체 시스템 아키텍처를 소개합니다. 이벤트 기반 문서 파이프라인, Bedrock 비용 최적화, DynamoDB 단일 테이블 설계 등 주요 설계 결정과 트레이드오프를 다룹니다.
핵심 포인트
- AWS CDK를 활용한 6개의 독립적인 스택 분리로 운영 안정성 확보
- Turborepo 모노레포 구조를 통한 코드베이스 관리 효율화
- 제출 경로와 처리 경로의 디커플링을 통한 시스템 확장성 강화
- AI 추론 및 OCR 비용 최적화를 고려한 아키텍처 설계
지난 4주 동안 저는 Autowired.ai 아키텍처의 개별 요소들, 즉 이벤트 기반 (event-driven) 문서 파이프라인, Bedrock 비용 최적화, 그리고 DynamoDB 단일 테이블 설계 (single-table design)에 대해 작성해 왔습니다. 이 포스트는 전체적인 그림을 보여줍니다. 즉, 모든 것이 어떻게 결합되는지, 왜 시스템이 현재와 같은 방식으로 구성되었는지, 그리고 각 주요 결정 뒤에 숨겨진 구체적인 트레이드오프 (tradeoffs)가 무엇인지에 대해 다룹니다.
Autowired.ai는 문서 추출 SaaS (Software as a Service)입니다. 사용자는 추출 스키마 (extraction schemas)를 정의하고, 문서 배치 (document batches)를 제출하며, 구조화된 데이터를 돌려받습니다. 여러 테넌트 (tenants)가 동일한 인프라를 공유합니다. AI 추론 (inference)과 OCR은 주요 비용 동인 (cost drivers)입니다. 배치는 수백 개의 문서를 포함할 수 있으며 처리하는 데 몇 분이 걸릴 수 있습니다. 모든 설계 결정은 이러한 제약 사항으로부터 비롯됩니다.
코드베이스 구성 방식
이 저장소는 Turborepo 모노레포 (monorepo)입니다. 두 개의 Next.js 앱 (마케팅 사이트, 제품)과 API 핸들러, 데이터베이스 액세스, 테스트를 위한 공유 패키지 레이어로 구성됩니다. 인프라는 전적으로 AWS CDK TypeScript로 정의되며, 목적에 따라 분리된 6개의 스택 (stacks)으로 나뉩니다.
하나가 아닌 6개의 스택을 사용하는 것은 미적인 선택이 아니라 운영상의 선택입니다. 처리 파이프라인을 업데이트해야 할 때, CloudFormation이 데이터베이스나 API Gateway를 건드려서는 안 됩니다. 스택 분리는 격리된 변경 세트 (change sets)를 가진 독립적인 배포 단위를 제공합니다. 그 대가는 스택 간 의존성 관리 (cross-stack dependency management)이며, 이에 대해서는 잠시 후에 더 자세히 설명하겠습니다.
요청 흐름 (Request Flow)
시스템은 두 가지 흐름으로 구성됩니다. 제출 경로 (submission path)와 처리 경로 (processing path)는 의도적으로 디커플링 (decoupled)되어 있습니다.
제출 (Submission) — 동기식 (synchronous), 즉시 반환:
API 핸들러는 처리 파이프라인(processing pipeline)에 절대 관여하지 않습니다. API 핸들러의 역할은 요청을 수락하고, 초기 레코드를 기록한 뒤, 응답을 반환하는 것입니다. 그것으로 끝입니다.
처리 (Processing) — S3에 의해 트리거되는 완전 비동기 방식:
API에서 Step Functions를 직접 호출하는 대신 S3 트리거를 사용하는 것은 의도된 설계입니다. 만약 업로드 시점에 Step Functions에 일시적인 문제가 발생하더라도, S3 이벤트가 큐에 쌓이고 자동으로 재시도됩니다. API는 이미 202 응답을 반환했으므로, 클라이언트는 이 사실을 알 수도 없고 신경 쓸 필요도 없습니다.
아키텍처 다이어그램
6개의 스택(Stacks) — 가장 큰 보상을 가져다준 결정
CDK 스택 분리는 API 레이어에 영향을 주지 않고 파이프라인 변경 사항을 배포해야 하거나, 데이터베이스에 영향을 주지 않고 처리 로직 변경 사항을 롤백(roll back)해야 하는 상황을 맞닥뜨리기 전까지는 과잉 엔지니어링(over-engineering)처럼 보일 수 있습니다.
각 스택과 관리 범위:
- DatabaseStack: DynamoDB 싱글 테이블(single-table), GSI 정의, PITR, TTL
- StorageStack: S3 버킷(buckets), 수명 주기 규칙(lifecycle rules), S3 이벤트 알림(event notifications)
- ProcessingStack: Step Functions 상태 머신(state machine), DocumentProcessorLambda, SQS 큐(queues) + DLQ, ScheduledBatchLambda
- BedrockStack: Bedrock Guardrails (주제 정책, 콘텐츠 필터)
- APIStack: API Gateway, 모든 API Lambda 핸들러, Clerk JWT 인증
- MonitoringStack: CloudWatch 대시보드, DLQ 깊이, Lambda 오류, 상태 머신 실패에 대한 알람
까다로운 부분: _StorageStack_은 S3 이벤트 알림을 연결하기 위해 _ProcessingStack_의 Step Functions ARN이 필요하지만, _ProcessingStack_은 _StorageStack_의 S3 버킷이 필요합니다 — 즉, 순환 참조(circular dependency)가 발생합니다.
해결책: 상태 머신(state machine) ARN을 CDK 크로스 스택 내보내기(cross-stack export)로 가져오는 대신, 명명 규칙(naming convention)을 통해 결정론적(deterministically)으로 계산합니다:
const stateMachineArn = arn:aws:states:${region}:${account}:stateMachine:autowire-batch-processing-${stage};
이는 의도적인 트레이드오프(tradeoff)입니다. 즉, 명명 규칙이 핵심적인 인프라(load-bearing infrastructure)가 됩니다. 상태 머신의 이름을 변경하면 두 스택을 모두 업데이트해야 합니다. 저는 이 내용을 CDK 코드에 명시적으로 기록했습니다. 이는 순환 참조(circular dependency)를 피하기 위한 올바른 트레이드오프이지만, 암시적으로 남겨두지 않고 명확히 관리되어야 합니다.
테넌트 격리(Tenant Isolation): 권고가 아닌 구조적 설계
모든 데이터는 단일 DynamoDB 테이블에 저장됩니다. 테넌트 격리는 애플리케이션 계층이 아닌 키 구조(key structure) 수준에서 강제됩니다.
모든 파티션 키(partition key)에는 _tenantId_가 포함되어 있습니다. DynamoDB 쿼리는 올바른 tenantId 없이는 물리적으로 다른 테넌트의 데이터를 반환할 수 없습니다. 잊어버릴 수 있는 필터도 없고, 잘못 설정할 수 있는 미들웨어 계층도 없습니다. 이 내용은 4주 차에 자세히 다루었습니다.
세 개의 GSI(Global Secondary Index)는 기본 키(primary key)가 처리할 수 없는 액세스 패턴을 처리합니다:
- GSI1: 이메일을 통한 사용자 조회 + 날짜순 워크플로 목록
- GSI2: 처리 파이프라인을 위한 상태 기반 필터링 (희소 인덱스(sparse index) — 활성 상태인 경우에만 GSI 속성을 기록)
- GSI3: _batchId_만으로 직접 배치 조회 — Step Functions를 메인 테이블 키 구조로부터 분리
문서 프로세서(Document Processor): 3단계
Step Functions의 Map 상태 내에 있는 DocumentProcessorLambda는 다음 세 가지 작업을 순차적으로 수행합니다:
- Textract가 문서에서 구조화된 필드(structured fields)를 추출합니다. 표준 송장(invoice) 및 양식(form)의 경우, 대상 필드의 70~80%를 높은 신뢰도로 안정적으로 가져옵니다.
- Bedrock gap-fill이 Textract가 추출하지 못한 필드를 추출합니다. 전체 문서가 아닌, 누락된 필드에 해당하는 OCR 섹션만 Bedrock으로 전송됩니다.
- Bedrock verify가 결합된 출력값(Textract 결과 + gap-fill)을 검증하고, 최종 신뢰도 점수(confidence scores)를 할당하며, 검토가 필요한 필드에 플래그를 지정합니다.
이러한 아키텍처 덕분에 Bedrock 비용이
10개의 동시 워커(concurrent workers)와 문서당 약 15초(Textract + 두 번의 Bedrock 호출)를 기준으로 할 때, 100개 문서 배치(batch)는 약 150초가 소요됩니다. 500개 문서 배치는 약 750초, 즉 12분이 조금 넘게 걸립니다. 이는 백그라운드 배치 처리(background batch processing) 작업으로서 완전히 수용 가능한 수준입니다.
Textract 및 Bedrock 할당량(quotas)을 높이면 이 수치도 함께 높아집니다. 이는 애플리케이션 코드에 숨겨져 있는 것이 아니라, CDK 정의 내의 명명된 상수(named constant)로 관리됩니다.
실패 처리 우선 (Failure Handling First)
파이프라인의 모든 실패 모드(failure mode)는 정상 경로(happy path)를 설계하기 전에 먼저 설계되었습니다.
문서별 실패 (Per-document failures): _addCatch_가 _MarkDocumentFailed_로 라우팅합니다. 하나의 손상된 PDF가 DynamoDB에 오류를 기록하면, Map 상태는 다음 문서로 이동합니다. 배치는 _SUCCEEDED/FAILED_가 섞인 문서 상태로 완료됩니다.
웹훅 실패 (Webhook failures): 별도의 SQS 큐에 격리됩니다. _maxReceiveCount: 5_로 설정되어 있습니다(문서 큐의 3보다 높게 설정 — 외부 엔드포인트는 내부 Lambda보다 더 불안정하기 때문입니다). 컨슈머(consumer)의 batchSize: 1 설정을 통해 각 웹훅 전달은 독립적으로 재시도됩니다.
S3 최소 한 번 전달 (S3 at-least-once delivery): _S3IngestionLambda_는 모든 DynamoDB 쓰기 작업에 _attribute_not_exists(PK)_를 사용합니다. 동일한 S3 이벤트가 두 번째로 전달되면 조건 확인(condition check) 단계에서 조용히 실패합니다.
예약된 배치 트리거 (Scheduled batch triggers): EventBridge의 _retryAttempts: 2_를 사용합니다. 일시적인 Lambda 콜드 스타트(cold start)로 인해 예약된 실행이 조용히 건너뛰어지는 일은 발생하지 않습니다.
상태 머신 타임아웃 (State machine timeout): 24시간입니다. 중단된 실행은 무한히 실행되는 대신 종료됩니다.
두 DLQ(Dead Letter Queues)는 메시지를 14일 동안 보관합니다. DLQ 깊이(depth)가 0보다 크다는 CloudWatch 알람은 메시지가 데드 레터(dead-letter)되는 즉시 발생하며, 이는 조사가 필요하다는 신호가 됩니다.
아키텍처에 내재된 비용 결정 (Cost Decisions Baked Into the Architecture)
- PAY_PER_REQUEST DynamoDB: 배치 제출(Batch submissions)은 버스트(bursty) 특성을 가집니다. 프로비저닝된 용량(Provisioned capacity)을 사용한다면 유휴 처리량(idle throughput)에 대해 지속적으로 비용을 지불해야 합니다.
- 모든 곳에 ARM64 Lambda 적용: Node.js Lambda 워크로드에 대해 기본값으로 x86을 사용할 이유가 없습니다. 모든 호출(invocation)에 대해 GB-초(GB-second)당 비용 차이가 약 20% 발생합니다.
- S3 생명주기 규칙 (S3 lifecycle rules): 배치 문서는 3일 후 Glacier로 전환되며, 6개월 후 만료됩니다. 임시 처리 아티팩트(Temp processing artifacts)는 24시간 후 삭제됩니다. 이 규칙이 없다면 S3 Standard 스토리지는 무한정 축적됩니다.
- 희소 GSI2 (Sparse GSI2): 활성 처리 상태(active processing statuses)에 있는 문서만 GSI 속성을 기록합니다. 완료된 문서(특정 시점에 대다수를 차지함)는 인덱스에 나타나지 않습니다. 이를 통해 GSI 스토리지 및 쓰기 증폭(write amplification) 비용을 낮게 유지합니다.
- Textract 우선 추출 (Textract-first extraction): Bedrock은 Textract가 할 수 없는 작업, 즉 간극 채우기(gap-fill) 및 검증(verification)을 위해서만 호출됩니다. 모든 필드에 대해 전체 OCR 데이터를 파운데이션 모델(foundation model)로 보내는 것에 비해 토큰 소비량을 획기적으로 줄일 수 있습니다.
내가 다르게 했을 것들 (The Things I'd Do Differently)
첫날부터 모든 곳에 DynamoDB에 RETAIN 정책을 적용했을 것입니다. 테이블은 RETAIN 삭제 정책을 사용하므로, 테스트 환경을 제거(teardown)할 때 실수로 데이터를 삭제할 수 없습니다. 초기 개발 단계에서 발생할 뻔한 사고 이후 이 설정을 추가했습니다. 처음부터 기본값이었어야 했습니다.
상태 머신(state machine) 실행 명명 규칙을 문서에 명시적으로 정의했을 것입니다. 순환 참조(circular dependency)를 피하기 위해 사용한 ARN 결정론(determinism) 트릭은 직관적이지 않습니다. 나중에 CDK 코드를 읽는 사람은 왜 ARN이 하드코딩되어 있는지 의아해할 것입니다. 단순히 구현 방식만 적지 말고, 그 트레이드오프(tradeoff)를 설명하는 주석을 남겨두어야 합니다.
첫 주부터 DynamoDB 액세스 패턴을 계측(Instrument)했을 것입니다. 개발 중간에 쿼리 패턴에 대한 CloudWatch 메트릭을 추가했습니다. 더 일찍 수행했다면 GSI 설계 문제를 더 빨리 발견했을 것입니다.
마치며 (Wrapping Up)
이 아키텍처가 완전히 새로운 것은 아닙니다. Step Functions, DynamoDB, SQS, S3, 그리고 Bedrock을 상당히 표준적인 방식으로 사용하고 있습니다. 이것이 실제 운영 환경(production)에서 작동하게 만드는 핵심은 의도성(intentionality)에 있습니다. 왜 10개의 동시 워커(concurrent workers)를 사용하는지, 왜 문서는 3번의 재시도(retries)를 수행하고 웹후크(webhooks)는 5번을 수행하는지, 왜 1GB RAM 환경에서 ARM64를 사용하는지, 왜 상태 머신(state machine) ARN을 내보내기(export)하지 않고 계산하여 사용하는지, 그리고 왜 GSI3가 존재하는지에 대한 이유 말입니다.
이 시리즈를 계속 따라오셨다면, 각 구성 요소를 개별적으로 살펴보셨을 것입니다. 이제 이들이 어떻게 결합되는지 보여드리겠습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기


