Firo — 파일 업로드 플랫폼
Firo 는 iflex 에 내장된 파일 첨부 플랫폼입니다. 파일이 실제로 어디에 저장되든 (서버 디스크 · AWS S3 · Azure Blob · FTP · SFTP) 개발자는 항상 같은 코드로 첨부 기능을 만듭니다.
이 문서를 처음 읽는다면
핵심 개념 을 훑고 바로 Quick Start 로 가세요. 30분이면 새 화면에 첨부 기능을 붙일 수 있습니다.
왜 Firo 를 쓰나
첨부 기능을 직접 만들면 보통 이런 코드를 매번 다시 씁니다.
- 업로드 API, 저장 디렉토리 정하기, 파일명 충돌 방지
- 첨부 테이블 만들기, 엔터티와 연결하기, 수정 화면에서 기존 파일 불러오기
- 이미지 리사이즈 · EXIF 회전 보정 · 확장자 검증 · 용량 제한
- 나중에 "S3 로 옮기자" 가 되면 전부 다시 작성
Firo 는 이 전부를 대신합니다. 개발자가 하는 일은 다음 세 가지뿐입니다.
| 하는 일 | 코드 |
|---|---|
| ① 엔터티에 "나는 첨부를 가진다" 고 표시 | @FiroRef("product") |
| ② 저장 API 에 "첨부도 같이 확정해줘" 라고 표시 | @FiroUpload |
| ③ 화면에 업로드 컴포넌트 배치 | <nv-file-upload ref-domain="product" ... /> |
나머지(임시 저장, 필터링, 물리 저장, DB 레코드, 조회, 삭제, 뷰 URL)는 Firo 가 처리합니다.
왜 별도 SQL 없이 첨부를 가져올 수 있나
첨부 정보는 nv_attach 테이블 한 곳에 (refDomain, refKey, refCategory) 키로 모입니다. 업무 테이블에 image_path 같은 컬럼을 만들 필요가 없고, 업무 쿼리에 조인을 추가하지 않아도 /assets/firo/attach/view/{도메인}/{키}/{카테고리} URL 만으로 이미지를 바로 표시할 수 있습니다.
핵심 개념 (3분)
Domain(도메인)과 Category(카테고리)
Firo 는 첨부를 2단계로 분류합니다.
refDomain "product" ← 업무 엔터티 단위 (보통 테이블 1개 = 도메인 1개)
├── refCategory "main" ← 용도별 구분 (대표 이미지)
├── refCategory "list" ← 목록용 이미지 (여러 장)
└── refCategory "manual"← 사용설명서 PDF여기에 refKey(엔터티의 PK)가 더해져 첨부 하나가 유일하게 식별됩니다.
product도메인의35번 상품의main카테고리 첨부 →product / 35 / main
카테고리를 생략하면
카테고리를 지정하지 않으면 default 라는 이름의 카테고리로 들어갑니다. 첨부 용도가 한 가지뿐이면 카테고리를 신경 쓰지 않아도 됩니다.
예시 — 이 문서 전체에서 사용할 가상의 애플리케이션입니다.
| refDomain | refCategory | 설명 |
|---|---|---|
board | default | 게시판 첨부파일. 용도가 하나뿐이라 기본 카테고리만 사용 |
product | main | 상품 대표 이미지. 1장만 |
list | 상품 상세 이미지. 여러 장, 순서 있음 |
Store(저장소)
Store 는 "이름 붙인 저장소" 입니다. 접속 정보 + 디렉토리 정책 + CDN 주소를 하나로 묶은 것으로, 도메인/카테고리는 store 를 이름으로 참조합니다.
firo:
default-store: local
stores:
local: { type: local }
s3-main: { type: s3, bucket: my-images, region: ap-northeast-2 }
s3-docs: { type: s3, bucket: my-documents, region: ap-northeast-2 }
domains:
product: { store: s3-main } # 상품 이미지는 이미지 버킷에
contract: { store: s3-docs } # 계약서는 문서 버킷에같은 타입(S3)을 여러 개 선언할 수 있는 것이 핵심입니다 — "S3 어댑터는 하나뿐" 같은 제약이 없습니다.
첨부의 일생 (temp 2단계 커밋)
Firo 의 가장 중요한 동작 원리입니다. 파일 선택 시점과 폼 저장 시점이 다르다는 현실을 다룹니다.
① 사용자가 파일 선택
│
├─► POST /api/firo/attach/tmp (즉시 서버로 전송)
│ · 로컬 스테이징에 저장
│ · 필터 체인 실행 (리사이즈 · 용량검증 · EXIF 보정 …)
│ · 원격 store 면 store 의 temp 영역에도 복사
│
◄── uuid.ext 반환 → 화면의 model.attachContainer 에 보관
(아직 DB 레코드 없음 · 미리보기만 가능)
② 사용자가 [저장] 버튼 클릭
│
├─► POST /api/products (본문에 attachContainer 가 함께 실려감)
│ · 컨트롤러가 상품 저장 → id 채번
│ · @FiroUpload aspect 가 attachContainer 를 읽음
│ · temp → 최종 경로로 copy + nv_attach 레코드 생성
│
◄── 저장 완료. 이제 첨부에 id 가 생기고 조회 · 뷰 URL 사용 가능왜 2단계인가
① 만 하고 폼을 저장하지 않으면 그 파일은 확정되지 않습니다. 즉 사용자가 파일을 골랐다가 저장하지 않고 화면을 떠나도 DB 에는 아무 흔적이 남지 않습니다. 반대로 말하면, 폼을 저장해야 첨부가 실제로 남습니다.
저장 파일명과 경로
저장되는 물리 파일명은 {uuid}.{확장자} 로, 한 번 정해지면 절대 바뀌지 않습니다.
{base-dir}/{refDomain}/{yyyy}/{MM}/{refKey}/{uuid}.{ext}
예) /data/attach/product/2026/07/35/6f1c0f2a-....jpg| 특성 | 의미 |
|---|---|
| 확장자 항상 유지 | 브라우저가 Content-Type 을 올바르게 인식 (.jpg 는 이미지로 인라인 표시) |
| 파일명 불변 | 파일을 교체하면 항상 새 이름 → CDN 캐시 무효화가 필요 없음 |
| 순서는 파일명이 아님 | 표시 순서는 nv_attach.attach_sort 컬럼이 담당. 재정렬해도 물리 파일은 그대로 |
direct 업로드
에디터 본문 이미지처럼 "폼 저장" 개념이 없는 첨부는 temp 단계 없이 즉시 영구 저장합니다. 경로는 {base-dir}/{refDomain}/{yyyy}/{MM}/_/{refCategory}/{uuid}.{ext} 로, _ 디렉토리가 direct 업로드 표식입니다.
용어 정리
| 용어 | 뜻 |
|---|---|
| refDomain | 업무 엔터티 단위 코드. @FiroRef("product") 의 값 |
| refKey | 엔터티의 PK. 기본적으로 id 필드를 봅니다 |
| refCategory | 도메인 안의 용도 구분. 미지정 시 default |
| AttachBag | 카테고리 → 파일 목록 Map. { "main": [파일…], "list": [파일…] } |
| AttachContainer | 도메인 → AttachBag Map. 한 요청에 여러 도메인 첨부가 있을 때 사용 |
| attachContainer (프론트) | 위 구조를 그대로 담는 모델 속성. 업로드 컴포넌트가 자동 생성 |
| savedName | 실제 저장 파일명 (uuid.ext). temp 업로드 응답의 name 이 이 값 |
| displayName | 사용자가 올린 원본 파일명. 다운로드 시 이 이름으로 내려갑니다 |
| store | 이름 붙인 저장소 (접속정보 + 경로정책 + CDN) |
설정 상속 규칙
설정은 명시한 값만 저장하고, 빠진 값은 조회 시점에 상위에서 가져옵니다. 그래서 도메인 설정을 나중에 바꿔도 하위 카테고리에 바로 반영됩니다.
| 항목 | 상속 순서 |
|---|---|
| store(어댑터) / 경로 정책 / 접근 제어 | category → domain → 글로벌 기본값(default-store) |
| cdnUrl | category → 해석된 store → domain → 글로벌 기본값 |
cdnUrl 만 순서가 다른 이유
파일이 실제로 저장된 store 의 CDN 이 도메인 상속값보다 우선해야 합니다. 그렇지 않으면 "S3 버킷 A 에 저장했는데 URL 은 버킷 B 의 CDN" 같은 불일치가 생깁니다.
문서 안내
| 문서 | 언제 읽나 |
|---|---|
| Quick Start | 처음 시작. 새 엔터티에 첨부를 붙이는 전 과정 |
| 실전 예제 모음 | 다중 파일 · 순서 변경 · 이미지 리사이즈 · 서버에서 직접 다루기 등 시나리오별 코드 |
| 설정 가이드 | S3/Azure 연결, 필터, 접근 제어, 환경별 설정 |
| 프론트엔드 연동 | nv-file-upload / v-firo-upload / firo-img 상세 레퍼런스 |
| REST API 레퍼런스 | 직접 API 를 호출할 때 (모바일 앱, 외부 연동 등) |
| 트러블슈팅 | 안 될 때 |
내부 구조가 궁금하다면 (기여자용)
저장소 어댑터 SPI, 부팅 조립 순서, 해석 규칙 등 내부 설계는 리포지토리 문서를 참고하세요.
docs/backend/firo-architecture.md— 레이어 구성 · SPI · 불변 계약docs/backend/firo-attach.md— 백엔드 사용 패턴 · 운영 주의점
구버전 문서 주의
과거 별도 라이브러리(com.unvus.firo) 및 멀티모듈(firo-core/firo-jpa/firo-mybatis) 시절의 문서·블로그 글은 현재 코드와 맞지 않습니다. 현재 Firo 는 iflex core 에 내장되어 있고 (core/platform/firo), DB 계층은 MyBatis 전용이며, 별도 의존성 추가가 필요 없습니다.