0배경
슬랙 답장 기능 스레드(6/17)에서 첨부파일 관련 미해결 어젠다가 도출됨. 핵심 질문 두 가지.
- 대용량 첨부 처리 — "용량이 크면 첨부가 안 된다". 현재 한계 ~22MB. 큰 파일을 어떻게 보낼 것인가.
- 자동 다운로드 링크 — 김규동: "너무 클 때 자동으로 다운로드 링크를 제공하는 API는 없을까요?" / 조서현: browser→S3 직통 + 한 달 후 자동삭제 Lifecycle 필요, 범위가 커서 다음 어젠다로 제안.
이 문서는 그 결정을 내리기 위한 근거를 정리한다 — 우리 코드 현황, 업계 표준, 메일 서비스들의 실제 방식, S3 모범사례, 그리고 각 결정 포인트별 판단 기준.
1린다 현재 구현 (코드 전수조사)
결론: 모든 첨부가 서버(앱린다) 메모리를 경유하며, 용량 한계가 프론트·백엔드 불일치, 자동삭제(Lifecycle) 없음. presigned 직통 패턴은 녹음·CSV에만 존재.
업로드 경로 — 완전한 서버 경유
| 단계 | 동작 | 위치 |
|---|---|---|
| 1. 프론트 | 파일 선택 → FormData(multipart)로 이메일 데이터와 함께 전송 (드래그앤드롭 미구현, 파일 input만) | admin/src/components/FileAttachment.tsx |
| 2. 서버 수신 | Elysia t.Files()로 메모리 버퍼 수신 → file.arrayBuffer() 전체 로드 | schemas/email.schemas.ts:57 |
| 3. 변환 | 메모리에서 base64 변환 (이중 메모리 점유) | utils/file.util.ts:7-11 |
| 4. S3 업로드 | PutObjectCommand로 서버→S3 전송 | services/email-send.service.ts:501-522 |
용량 제한 — 불일치 (SSOT 위반)
| 위치 | 한계 | 비고 |
|---|---|---|
| FileAttachment 기본 | 30MB | FileAttachment.tsx:15 |
| 답장(Inline/New) 컴포저 | 20MB | 프론트 검증만 |
| 시퀀스 단계 | 30MB | — |
| 백엔드 검증 | 30MB | email-send.service.ts:494 하드코딩 |
저장·다운로드
다운로드는 인증 라우트 GET /api/v1/emails/:id/attachments/:idx 경유. Lifecycle/TTL 없음 — 첨부가 영구 잔존(CacheControl: max-age=31536000, 1년 캐시). 단 CSV export에는 "24h presigned + lifecycle 정리 예정" 주석 존재.
이미 존재하는 직통 패턴 (재사용 가능)
- 녹음 파일: presigned PUT 직통 업로드 —
s3.service.ts:816-838 getPresignedRecordingUploadUrl() - CSV export: presigned GET —
s3.service.ts:688-705
2이메일 첨부 기술 표준 — "왜 25MB인가"
한계는 임의 숫자가 아니라 base64 인코딩과 수신측 편차의 합산 결과. 이것이 임계값 결정의 1차 근거.
| 사실 | 수치 | 함의 |
|---|---|---|
| base64 인코딩 증가 | +33% (실측 ~36~37%) | 20MB 원본 → ~27MB 전송. "25MB 한계"는 인코딩 후 기준 |
| 안전 원본 크기 | 25MB cap 환경에서 ~18MB 이하 | 24MB 원본은 인코딩 후 ~32MB로 반송됨 |
| 수신측 서버 한계 편차 | 10MB ~ 50MB 제각각 | 발신측이 작게 보내도 경로상 한 서버라도 초과하면 message too large 반송 |
| deliverability 안전선 | 전체 메시지 ~10MB 이하 | 본문+인라인+인코딩 포함 전체에 적용 |
| 원본 크기 | 업계 권장 |
|---|---|
| < 1MB | 첨부 |
| 1–5MB | 1:1 발송이면 첨부 |
| 5–10MB | 신중히 첨부, 테스트 필수 |
| > 10MB | 항상 다운로드 링크 |
3메일 프로바이더 벤치마크 (2026)
거의 모든 서비스가 ~25MB에서 클라우드 스토리지 다운로드 링크로 전환하는 동일 패턴. 차이는 만료 정책.
| 프로바이더 | 인라인 한계 | 초과 시 | 대용량 상한 | 보관·만료 |
|---|---|---|---|---|
| Gmail (개인) | 25MB 발송 | 자동 Google Drive 링크 | Drive 한도 | 영속(사용자 삭제까지) |
| Gmail Workspace(Ent+) | 50MB 발송/70MB 수신 | 자동 Drive 링크 | Drive 한도 | 영속 |
| Microsoft 365 | 150MB(관리자) | OneDrive 클라우드 링크 | 파일당 250GB | 영속 / 링크 만료·비번 설정 가능 |
| 네이버 메일 | 10MB | 자동 대용량 첨부 | 파일당 2GB | 30일 / 100회 후 자동삭제 |
| 다음(카카오) | 25MB | 자동 대용량 첨부 | 파일당 4GB | 30일 / 100회 후 자동삭제 |
| Apple iCloud Mail Drop | — | 자동 업로드 링크 | 메일당 5GB | 30일 후 만료 |
| Proton Mail | 25MB 발송/50MB 수신 | Proton Drive 링크(수동) | Drive 한도 | Drive 정책(E2E) |
4S3 활용 베스트프랙티스 (AWS 공식, 2026)
바이트는 클라이언트→S3 직행, 서버는 presign(작은 JSON)만. 서버 프록시는 대역폭 2배·OOM·게이트웨이 페이로드 한계(API GW 10MB)로 디폴트가 아님.
업로드 3가지 방식
| 방식 | 최대 | 용량 서버강제 | 재개 | 용도 |
|---|---|---|---|---|
| Presigned PUT | 5GB | 불가 | 불가 | 소형, 강제 불필요 |
| Presigned POST | 5GB | 가능 (content-length-range) | 불가 | 용량 상한 필요한 사용자 업로드 ← 첨부 |
| Multipart | 5TB | 파트별 | 가능(병렬) | 5GB 초과·초대용량·재개 |
// 용량을 서버가 강제하는 유일한 방법 (POST policy)
createPresignedPost(s3, {
Bucket, Key,
Conditions: [["content-length-range", 0, 10*1024*1024]], // 0~10MB 강제
Fields: { "Content-Type": "application/pdf" },
Expires: 600,
});
다운로드 제공
| 방식 | CDN 캐시 | origin 은닉 | 적합 |
|---|---|---|---|
| Presigned GET | ✕ | ✕ | 일회성·저빈도·단일 파일 ← 첨부 다운로드 |
| CloudFront + OAC signed URL | ○ | ○ | 반복 다운로드·캐시 이득 |
| CloudFront signed cookie | ○ | ○ | 스트리밍·다수 파일 |
운영 필수 3종
- Lifecycle:
AbortIncompleteMultipartUpload 7일(미완료 파트 과금 누수 차단) +Expiration N일(임시 첨부 자동삭제) - CORS:
ExposeHeaders에 ETag 필수(누락 시 multipart 완료 실패), AllowedOrigins는 명시 origin - 보안: key는 서버 결정(테넌트 prefix 격리) · Content-Type 1차 + magic number 재검증 · GuardDuty Malware Protection for S3($0.09/GB)
5의사결정 기준 ★
각 결정 포인트 — 무엇을 정해야 하나 / 옵션 / 권장 / 근거. 린다(한국 사용자·서울 리전·B2B 이메일) 맥락 반영.
quarantine/ prefix 업로드 → 스캔 통과만 다운로드 허용 패턴. ClamAV는 운영부담 커 비권장.62026 최적 제안 — 린다 하이브리드 첨부 파이프라인
소형은 그대로 인라인 첨부, 임계값 초과는 browser→S3 직통 + 자동 다운로드 링크 + 30일 자동삭제. 한국 메일 서비스 모델 + AWS 모범사례 결합.
흐름
[발송 시 파일 크기 분기]
원본 ≤ 10MB → 인라인 첨부 (기존 경로 유지, base64 MIME)
원본 > 10MB → 대용량 모드
1) FE가 BE에 presigned POST 요청 (key=서버결정: ws/{wsId}/attachments/{uuid}/{file})
2) BE: content-length-range[0, MAX] 조건으로 presigned POST 발급
3) 브라우저 → S3 직통 업로드 (서버 메모리 미경유)
4) (선택) GuardDuty 스캔 통과 확인
5) 발송: 본문에 presigned GET 다운로드 링크 삽입
"📎 ○○.pdf (24MB) — 다운로드 (30일간 유효)"
[S3 Lifecycle] email-attachments/ → 30일 Expiration
+ AbortIncompleteMultipartUpload 7일
업로드
Presigned POST 직통 · content-length-range 용량강제 · key 서버결정 · workspace prefix 격리다운로드
Presigned GET (만료 24h, 만료 시 재발급) · 인증 라우트 경유 유지 가능보관
S3 Lifecycle 30일 자동삭제 · 미완료 업로드 7일 정리 · 본문에 유효기간 명시보안
magic number 재검증 · (선택)GuardDuty 스캔 · HTTPS · 테넌트 격리7구현 로드맵 (범위·우선순위)
8부록 — 답장 UX 피드백 (코드 현황)
스레드의 나머지 피드백도 코드 기준 현황 정리. (별도 분석 영역)
| 피드백 | 현황 | gap |
|---|---|---|
| 답장 본문 수정 버튼 | 있음 AiDraftReplyCard.tsx:437 "수정"/"저장" 토글 | 구현됨 |
| 연필 아이콘 | 없음 텍스트만 | 아이콘 추가 가능 |
| 호버 커서 클릭가능 표시 | 있음 본문 cursor-pointer | 단 하이라이트는 cursor-help(i) — 혼선 가능 |
| "수정 내용 사라짐" | 버그 의심 다시생성/스레드전환 시 useEffect[draft.bodyText]가 localBody 덮어씀 (:117-122) | 미저장 편집값 보호 로직 필요 |
| AI 초안 언어 선택 | UI 없음 BE는 language/locale 파라미터 지원, FE는 앱 UI 언어 고정 전송 | 모달에 언어 드롭다운 + 선택값 전달 |
| 답장에 서명 삽입 | 부분 시퀀스·AI답장은 자동 주입, 수동 답장(FloatingReplyPopup)은 없음 | 답장 컴포저에 서명 선택/삽입 |