사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
일반▾
인터페이스▾
관측성▾
성능▾
네이티브▾
개발▾

무엇을 만드나요

파일 기능은 업로드 하나를 둘로 나눕니다. 바이트는 스토리지로 가고, DB에는 그 위치를 알려 주는 File 레코드만 남깁니다.
file.constant.tsfile.document.ts
File 모델. 이름, url, 크기, 상태, 진행률을 담은 File 레코드를 저장합니다.
file.signal.ts
업로드 엔드포인트. 클라이언트가 보낸 Upload 파일을 받아 서비스에 넘깁니다.
file.service.ts
File 서비스. 레코드를 만들고, 바이트를 스토리지로 보내고, 최종 URL을 저장합니다.
StorageAdaptorRole
스토리지 어댑터. 바이트가 실제로 놓이는 곳으로, 기본값인 BlobStorage는 로컬 디스크에 씁니다.
file.store.tsFile.Util.tsx
스토어와 UI. 파일 입력으로 업로드하고, active가 되면 결과를 보여 줍니다.
localFile.getBlob
파일 제공 엔드포인트. @libs/util에 들어 있으며, 개발 중에 로컬 파일을 스트림으로 돌려줍니다.
모든 File은 uploading으로 시작해, 스토리지가 URL을 돌려주면 active가 됩니다. 아래 섹션에서 이 순서대로 하나씩 만듭니다.
  • libs/shared가 이미 있나요? 그 File 모듈(libs/shared/lib/file)이 이 레시피의 완성판입니다. 이미지 크기, blur 미리보기, origin 기준 중복 제거, Field.Img / Field.File 컨트롤까지 들어 있습니다.

최소 File 모델

처음에는 UI에 필요한 필드만 넣으세요. 이미지 크기, blur 미리보기, 원본 URL은 나중에 더해도 됩니다.
filename
사용자가 고른 파일 이름으로, 내려받을 때 이 이름을 돌려줍니다.
mimetype
브라우저가 알려 준 파일 타입입니다(예: image/png).
url
스토리지가 바이트를 내주는 주소로, 업로드가 끝날 때까지는 비어 있습니다.
size
바이트 단위 파일 크기입니다.
status
바이트가 옮겨지는 동안은 uploading, 스토리지가 URL을 돌려주면 active입니다.
progress
0부터 100까지의 업로드 진행률입니다.
FilePurpose
필드가 아닌 enum으로, 파일이 들어갈 폴더이자 스토리지 경로의 일부입니다.
constant 파일에서 모든 모델이 갖는 다섯 클래스로 선언합니다:
apps/myapp/lib/file/file.constant.ts
서비스에는 모델 쓰기 두 가지가 필요합니다. 진행률이 바뀔 때 한 번, 스토리지가 답할 때 한 번입니다:
apps/myapp/lib/file/file.document.ts
  • updateById는 한 번의 직접 쓰기입니다. 훅은 타지 않지만 진행률 갱신에는 충분합니다.
  • finishUpload가 uploading → active 전환입니다. URL 저장과 progress 100 설정을 한 번에 씁니다.

업로드 엔드포인트

엔드포인트는 단순하게 둡니다. 파일과 용도를 받아서, 실제 작업은 서비스에 넘깁니다:
apps/myapp/lib/file/file.signal.ts
  • Upload 인자가 있으면 요청이 multipart 폼 데이터가 됩니다. fetch.uploadFiles는 FileList나 File[]를 받아 폼을 알아서 만듭니다.
  • purpose는 enum이라 다른 값은 서버가 거절합니다. 폴더 이름이 되는 값이라, 자유 문자열이면 호출자가 폴더를 고를 수 있게 됩니다.
  • get: User가 있어야 UI에 fetch.file(id)가 생깁니다. 업로드 중인 레코드를 UI가 다시 읽을 때 씁니다.
  • 에이전트에게는 보이지 않습니다. Upload를 받는 엔드포인트는 MCP에 공개되지 않습니다.

File 서비스

서비스가 파일 기능의 중심입니다. 파일마다 세 가지를 합니다:
  1. uploading 상태로 레코드를 만듭니다.
  2. 파일 이름이 겹치지 않도록 스토리지 경로에 레코드 id를 씁니다.
  3. 업로드가 끝나면 돌아온 URL을 저장하고 상태를 active로 바꿉니다.
apps/myapp/lib/file/file.service.ts
  • 업로드를 기다리지 않습니다. uploadFile은 uploading 레코드를 바로 돌려주고, 진행률과 URL은 두 콜백이 나중에 채웁니다.
  • 경로는 <purpose>/<record id>입니다. 두 사용자가 같은 photo.png를 올려도 겹치지 않고, 클라이언트가 보낸 파일 이름은 디스크 경로에 들어가지 않습니다.
  • BlobStorage는 진행률을 보고하지 않습니다. 쓰기가 끝나면 uploadSuccess만 부르므로, 로컬 디스크에서는 progress가 0에서 바로 100이 됩니다.
  • plug(StorageAdaptorRole)은 벤더가 아니라 역할을 가리킵니다. 나중에 스토리지를 바꿔도 이 파일은 그대로입니다.

UI에서 사용하기

엔드포인트는 스토리지 쓰기가 끝나기 전에 답하므로, 레코드는 uploading 상태에 빈 url로 돌아옵니다. 스토어에 담아 두고 active가 될 때까지 다시 읽은 뒤 url을 보여 줍니다.
1. 스토어에서 올리고 다시 읽기
액션 하나는 업로드하고 레코드를 담아 두고, 다른 하나는 업로드 중인 레코드를 새로 읽습니다:
apps/myapp/lib/file/file.store.ts
2. 컴포넌트에서 보여 주기
이미지는 미리보기로 보여 주고, 모든 파일에 다운로드 링크를 붙입니다:
apps/myapp/lib/file/File.Util.tsx
  • 컴포넌트는 fetch가 아니라 st.do를 부릅니다. 요청은 스토어 액션이 맡고, 결과를 state에 씁니다.
  • useInterval은 업로드 중일 때만 실제로 다시 읽습니다. active인 파일이면 refreshUploadedFile이 바로 돌아옵니다.
  • 이미지는 url을 보여 주고, 모든 파일은 그 주소로 내려받습니다. 저장 경로에는 id만 있으므로 download={filename}로 사용자가 고른 이름을 돌려줍니다.
  • 링크는 resolveServerUrl을 거칩니다. 저장된 url은 서버 기준 상대 경로이고, 네이티브 셸이나 데스크톱 앱이 띄운 페이지는 origin이 다릅니다. Image는 이미 이렇게 풀어 씁니다.

모델 필드에 자동 연결

모델마다 이런 코드를 쓰면 같은 일이 반복됩니다. 업로드 mutation 하나에 { fileUpload: true }를 달면, 모든 모델이 자기 File 필드로 업로드하는 헬퍼를 얻습니다:
fetch.add<Model>Files
(fileList, parentId?)를 받아 표시한 mutation으로 파일을 보냅니다. type에는 모델 이름이 들어갑니다.
st.do.upload<Field>On<Model>
(fileList, index?)를 받아 폼의 File 필드를 채우고, 3초마다 다시 읽습니다.
Field.ImgField.ImgsField.FileField.Files
@libs/shared/ui의 폼 컨트롤입니다. 넘긴 slice의 add<Model>Files를 부릅니다.
표시한 mutation은 고정된 body 필드 네 개를 이 모양으로 받습니다. 여기서 부르는 서비스 메서드는 libs/shared/lib/file의 FileService.addFiles를 참고하세요:
apps/myapp/lib/file/file.signal.ts
네 가지 필드
files[Upload]
고른 순서대로 담긴 파일들입니다.
metasString
파일마다 { lastModifiedAt, size } 하나씩 담은 JSON 배열입니다.
typeString
파일을 가질 모델의 이름입니다(예: user).
parentIdIDnullable
편집 중인 폼의 id입니다. 없으면 보내지 않습니다.
  • 딱 하나만 표시합니다. 두 개에 붙어 있으면 먼저 찾은 쪽을 쓰고 경고를 출력합니다.
  • File 모듈의 시그널에 둡니다. 스토어 액션은 플래그를 가진 시그널의 모델 타입인 필드에만 생깁니다.
  • 다른 mutation처럼 가드를 겁니다. 배너 편집 같은 관리자 폼도 업로드하므로, 사용자와 관리자를 모두 통과시키는 Every를 씁니다.
  • 플래그가 없으면 헬퍼도 동작하지 않습니다. 이때 add<Model>Files는 "File upload is not configured" 오류를 던집니다.

소유 모델과 함께 삭제

File 관계 필드에 cascade: "removeRef"를 달면, 소유 모델을 지울 때 파일도 함께 지워집니다. 소유 모델의 관계 필드에 표시합니다:
apps/myapp/lib/user/user.constant.ts
캐스케이드는 File 모델이 아니라 File 서비스를 부르므로 FileService._postRemove가 실행됩니다. 스토리지 삭제를 거기에 두면 따로 연결할 것이 없습니다:
apps/myapp/lib/file/file.service.ts
  • 배열 필드에도 되지만, 관계 필드에만 붙습니다. 문자열 id나 내장 스칼라에는 지울 문서가 없습니다.
  • 다른 소유자가 있는지는 검사하지 않습니다. libs/shared의 File은 origin 기준으로 중복을 없애므로 두 문서가 한 파일을 함께 쓸 수 있습니다. removeRef는 이 필드가 파일을 단독으로 소유한다는 선언입니다.
  • 쿼리 단위 삭제는 캐스케이드를 건너뜁니다. removeMany, removeById, 생성된 remove<Filter>는 removedAt을 원자적 업데이트 한 번으로 찍고 훅을 타지 않습니다. 캐스케이드가 걸린 문서는 하나씩 지우세요.

나중에 확장하기

처음에는 로컬 디스크로 시작하세요. 기능이 동작하면 업로드 API를 고치지 말고 스토리지 어댑터만 바꿔 S3, R2, MinIO로 옮깁니다.
로컬 디스크
BlobStorage
기본값이고 디버깅이 가장 쉽습니다. 파일은 local/<app>/backend에 쌓이고, localFile.getBlob이 스트림으로 돌려줍니다.
오브젝트 스토리지
option.applyAdaptor(StorageAdaptorRole, S3Storage)
운영 환경과 여러 서버의 공유 접근에 맞습니다. StorageAdaptor를 구현한 adapt() 클래스를 만들어 lib/option.ts에 적용합니다.
직접 만든 어댑터는 앱의 option 파일에서 한 줄로 적용합니다:
apps/myapp/lib/option.ts
  • 서비스는 그대로입니다. 서비스는 plug(StorageAdaptorRole)이라는 역할만 알기 때문에, S3, R2, MinIO로 옮겨도 업로드 코드는 건드리지 않습니다.
  • @libs/util을 쓰나요? 그 안의 ObjectStorageApi가 이미 S3, R2, MinIO, Naver를 지원합니다. env/env.server.<env>.ts에 objectStorage를 설정하면, libs/shared의 File 서비스가 use<StorageApi>()로 읽어 씁니다.

팁

  • 레코드와 바이트를 분리하세요. DB에는 파일을 찾는 방법만 저장하고, 파일 자체는 넣지 않습니다.
  • 스토리지 경로에 File id를 넣으세요. 그래야 두 사용자가 같은 이름의 파일을 올려도 충돌하지 않습니다.
  • 진행률은 처음엔 선택 사항입니다. 오브젝트 스토리지에 큰 파일을 올릴 때부터 쓸모가 생깁니다.
  • 지울 때는 둘 다 지우세요. 스토리지 객체를 지우는 _postRemove를 두면 File 레코드와 저장된 바이트가 함께 정리됩니다.

MIT 라이선스 하에 배포되었습니다.

내 AI에 이 문서 연결하기

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js 모든 권리 보유.시스템 관리자bassman