Quick Start — 엔터티에 첨부 붙이기
product(상품) 엔터티에 대표 이미지 첨부를 붙이는 전 과정입니다. 기본 설정(서버 로컬 디스크) 그대로면 yml 을 건드릴 필요가 없습니다 — 1 · 2 · 4 단계만 하면 동작합니다.
소요 시간
백엔드 5분 + 프론트엔드 5분. 이미 만들어져 있는 CRUD 화면에 첨부만 추가하는 기준입니다.
전체 그림
[백엔드] [프론트엔드]
1. 엔터티에 @FiroRef("product") 4. <nv-file-upload ref-domain="product" ... />
2. 저장 API 에 @FiroUpload └ 파일 선택 → 즉시 temp 업로드
└ 폼 저장 시 첨부 확정 └ 폼 저장 시 attachContainer 함께 전송
3. (선택) 저장소 · 필터 설정 5. 화면에 표시 → /assets/firo/attach/view/...1. 엔터티에 @FiroRef 붙이기
"이 엔터티는 첨부의 참조 대상이다" 라고 표시합니다. 어노테이션 값이 refDomain 이 됩니다.
import com.unvus.iflex.core.platform.firo.annotation.FiroRef;
import lombok.Data;
import java.time.Instant;
@Data
@FiroRef("product") // ← 이 값이 refDomain
public class Product {
private Long id; // ← 기본 컨벤션: PK 는 id 필드
private String name;
private Instant createdDt; // ← 기본 컨벤션: 저장 경로의 yyyy/MM 산출에 사용 (null 이면 현재 시각)
}Firo 는 이 두 필드를 봅니다.
| 필요한 값 | 기본으로 보는 필드 | 용도 |
|---|---|---|
| refKey | id | 첨부와 엔터티를 연결하는 키 |
| 기준 일시 | createdDt | 저장 디렉토리의 yyyy/MM 결정 |
필드명이 다르다면
id/createdDt 가 아닌 이름을 쓴다면 두 가지 방법 중 하나로 알려줍니다.
// 방법 A — 어노테이션 인자로 지정
@FiroRef(value = "product", keyFieldName = "productId", dateFieldName = "regDt")
// 방법 B — 필드에 직접 표시 (이쪽이 우선)
@FiroRef("product")
public class Product {
@FiroRefKey private Long productId;
@FiroRefDate private Instant regDt;
}기준 일시가 실제로 쓰이는 시점
@FiroUpload aspect 가 저장 직전에 이 필드를 읽어(FiroUtil.getFiroRefDt) 저장 디렉토리의 yyyy/MM 을 정합니다. 값이 null 이거나 필드를 읽을 수 없으면 현재 시각으로 대체되므로 신규 등록 시 비어 있어도 문제없습니다.
실제로 신규 등록에서는 auditing 이 같은 요청 안에서 createdDt 를 현재 시각으로 채우므로 결과적으로 "지금" 이 됩니다. 값이 의미를 갖는 것은 수정할 때 — 오래된 엔터티에 파일을 추가하면 그 엔터티가 처음 만들어진 달의 디렉토리로 들어갑니다(한 엔터티의 첨부를 한곳에 모으려는 의도).
기준 일시로 "수정 일시" 를 쓰지 마세요
dateFieldName 에 modifiedDt 를 지정하면 수정할 때마다 기준이 바뀌어, 한 엔터티의 첨부가 여러 달 디렉토리로 흩어집니다. 반드시 생성 일시를 쓰세요.
다만 파일을 못 찾게 되지는 않습니다 — 저장 경로는 nv_attach.attach_saved_dir 에 기록되고, 조회·삭제는 항상 그 값을 사용합니다(경로를 날짜로 다시 계산하는 코드는 없습니다).
왜 날짜로 디렉토리를 나누나
한 디렉토리에 파일 수십만 개가 쌓이면 파일시스템 조회가 급격히 느려지고 백업·이관도 어려워집니다. Firo 는 도메인/년/월/PK 로 자동 분산합니다.
기준 일시를 쓰지 않는 경로도 있습니다
엔터티의 일시가 반영되는 것은 @FiroUpload aspect 경로뿐입니다. 아래 경로들은 엔터티와 무관하게 호출 시점의 현재 시각으로 디렉토리를 정합니다.
| 경로 | 기준 일시 |
|---|---|
@FiroUpload aspect (본체 저장 API) | 엔터티의 createdDt / @FiroRefDate |
POST /api/firo/attach/{refDomain}/{refKey} (REST 확정 저장) | Instant.now() |
POST /api/firo/attach/direct (direct 업로드) | Instant.now() |
| 에디터 이미지 업로드 | Instant.now() |
2. 저장 API 에 @FiroUpload 붙이기
컨트롤러 메서드가 정상 종료되면, aspect 가 요청 본문의 attachContainer 를 읽어 첨부를 확정합니다.
import com.unvus.iflex.core.platform.firo.annotation.FiroUpload;
@RestController
public class ProductResource {
@FiroUpload // ← 추가
@PostMapping("/api/product")
public ResponseEntity<Void> addProduct(@RequestBody Product product) {
productService.saveProduct(product); // 본체 저장 (여기서 id 채번)
return ResponseEntity.ok().build();
}
@FiroUpload // ← 수정 API 에도 필요
@PutMapping("/api/product/{id}")
public ResponseEntity<Void> updateProduct(@PathVariable Long id, @RequestBody Product product) {
productService.saveProduct(product);
return ResponseEntity.ok().build();
}
}동작 순서는 다음과 같습니다.
- 컨트롤러 메서드 실행 → 상품 저장 →
product.id채번 - aspect 가 캐시된 요청 본문에서
attachContainer파싱 @FiroRef가 붙은 대상(파라미터 자신 · 중첩 필드 · 컬렉션 요소까지 탐색)을 찾아refKey/기준 일시를 꺼냄- temp 파일을 최종 경로로 확정 +
nv_attach레코드 생성
전제 조건 — FiroServletFilter
@FiroUpload 는 요청 본문을 두 번 읽어야 합니다(컨트롤러가 한 번, aspect 가 한 번). 이를 위해 모듈의 WebConfigurer 에 FiroServletFilter 가 등록돼 있어야 합니다. cms 모듈은 기본 등록되어 있으므로 추가 작업이 필요 없습니다.
등록되지 않으면 예외 대신 조용히 첨부 커밋만 생략되고 다음 경고가 남습니다. firo 첨부 본문을 읽을 수 없어 업로드 커밋을 생략합니다
첨부 저장 실패는 예외로 전파됩니다
본체는 이미 저장된 뒤 첨부만 실패한 경우, Firo 는 오류를 삼키지 않고 전파합니다. 성공한 첨부는 그대로 남으므로 클라이언트는 실패한 첨부만 재시도하면 됩니다.
3. (선택) 저장소 · 카테고리 설정
기본 설정(로컬 디스크 · default 카테고리)으로 충분하면 이 단계는 건너뜁니다. 등록하지 않은 도메인/카테고리도 첫 사용 시 기본값으로 자동 생성됩니다.
S3 에 저장하거나 이미지 리사이즈를 걸고 싶을 때만 설정합니다.
# config/firo.yml
firo:
default-store: local
directory:
base-dir: /data/attach
tmp-dir: /data/attach/tmp
stores:
local: { type: local }
s3-main: { type: s3, bucket: my-bucket, region: ap-northeast-2,
cdn-url: https://cdn.example.com/ }
domains:
product:
store: s3-main # 상품 첨부는 S3 에
categories:
main: {} # 미지정 → 도메인 설정 상속자세한 내용(필터 체인, 접근 제어, Azure/FTP 등)은 설정 가이드 를 보세요.
4. 프론트엔드 — 화면에 업로드 UI 붙이기
두 가지 방식을 혼동하지 마세요
Firo 프론트엔드는 서로 다른 두 가지 도구를 제공합니다. 섞어 쓰면 동작하지 않습니다.
<nv-file-upload> | v-firo-upload | |
|---|---|---|
| 종류 | Vue 컴포넌트 | Vue 디렉티브 |
| 붙이는 대상 | 태그 자체를 배치 | <input type="file"> 에 부착 |
| UI | 목록 표 · 썸네일 · 드래그&드롭 · 삭제/정렬 버튼 내장 | 없음 (직접 구현) |
| 옵션 전달 | ref-domain="product" 같은 props | v-firo-upload="{ domain: 'product' }" 바인딩 객체 |
| 옵션 이름 | refDomain / refCategory / refKey | domain / category / key |
| 언제 | 대부분의 경우 이쪽 | 디자인이 완전히 다른 커스텀 업로더가 필요할 때 |
방법 A — <nv-file-upload> (권장)
<template>
<nv-file-upload v-model="model"
ref-domain="product"
ref-category="main"
:ref-key="model.id"
:max-count="1"
accept="image/*" />
</template>
<script setup lang="ts">
import {ref} from 'vue';
const model = ref<Record<string, any>>({}); // 폼 모델 — 저장 API 에 그대로 전송할 객체
</script>이게 전부입니다. 별도 import 도 필요 없습니다(컴포넌트 자동 등록).
| Prop | 의미 |
|---|---|
v-model (필수) | 폼 모델 객체. 컴포넌트가 여기에 attachContainer 속성을 자동으로 만들어 넣습니다 |
ref-domain (필수) | @FiroRef("product") 의 값과 동일하게 |
ref-category | 미지정 시 default |
ref-key | 있으면 수정 모드 — 서버에서 기존 첨부를 불러와 목록에 표시합니다. 신규 등록이면 null |
max-count | 최대 개수 (0 = 무제한) |
accept | <input accept> 값 |
그리고 폼을 저장할 때 모델을 통째로 보내면 됩니다.
// model 안에 attachContainer 가 들어 있으므로 별도 처리 불필요
await api.post('/api/product', model.value);신규 등록 화면인데 ref-key 는?
model.id 가 null 이면 컴포넌트는 기존 첨부를 조회하지 않고 빈 목록으로 시작합니다. 저장 후 화면을 다시 로드하면 채번된 id 로 첨부가 조회됩니다.
방법 B — v-firo-upload 디렉티브
업로드 UI 를 직접 만들어야 할 때 사용합니다. 디렉티브는 파일 선택 → temp 업로드 → attachContainer 적재까지만 담당하고, 미리보기·목록·삭제 UI 는 직접 그립니다.
<template>
<input type="file"
accept="image/*"
v-firo-upload="{
model: form, // (필수) attachContainer 를 붙일 모델
domain: 'product', // (필수) refDomain
category: 'main', // 기본 'default'
key: form.id, // 있으면 기존 첨부 로드 (수정 모드)
maxFileSizeMb: 10,
}"
@onFileAdded="onAdded"
@onUploadError="onError" />
<img v-if="previewUrl" :src="previewUrl" style="max-width: 200px" />
</template>
<script setup lang="ts">
import {ref} from 'vue';
const form = ref<Record<string, any>>({});
const previewUrl = ref<string>();
// 디렉티브는 CustomEvent 로 알려줍니다 — detail 에 파일 정보가 들어 있습니다
const onAdded = (e: CustomEvent) => { previewUrl.value = e.detail.url; };
const onError = (e: CustomEvent) => { alert(e.detail.message); };
</script>전체 옵션·이벤트 목록은 프론트엔드 연동 을 보세요.
5. 저장된 파일 표시하기
첨부가 확정된 뒤에는 다음 방법으로 표시합니다.
<!-- product 35번의 main 카테고리 첫 번째 첨부 -->
<img src="/assets/firo/attach/view/product/35/main" />
<!-- 두 번째 이미지 (인덱스는 0부터) -->
<img src="/assets/firo/attach/view/product/35/main/1" />
<!-- 서버 리사이즈 (가로 300px, 비율 유지) -->
<img src="/assets/firo/attach/view/product/35/main?w=300" /><!-- 목록 API 로 받은 첨부의 id 를 아는 경우 -->
<img src="/assets/firo/attach/view/1024?w=300" />
<!-- 다운로드 (원본 파일명으로 저장됨) -->
<a href="/assets/firo/attach/download/1024">내려받기</a><!-- CDN direct URL 을 조회해 사용하고, 없으면 서버 뷰 URL 로 폴백 -->
<firo-img ref-domain="product"
ref-category="main"
:firo-model="row"
css-style="width: 200px; object-fit: contain;" />어느 것을 쓸까
단순 표시라면 <img> URL 방식이 가장 가볍습니다 — 추가 API 호출이 없습니다. firo-img 는 마운트할 때마다 첨부 목록 API + direct-url API 를 호출하는 대신, CDN URL 사용·라이트박스 뷰어·여러 장 렌더링을 제공합니다.
firo-img 를 쓸 때는 ref-key 또는 firo-model 중 하나는 반드시 지정하세요.
동작 확인
여기까지 왔다면 다음을 확인하세요.
① 파일 선택 직후 — 브라우저 개발자도구 Network 탭
POST /api/firo/attach/tmp → 200
응답: { "files": [ { "name": "6f1c0f2a-....jpg", "displayName": "photo.jpg", "size": 20481, "type": "image/jpeg" } ] }name 이 저장 파일명(savedName)입니다. 이 시점엔 아직 DB 레코드가 없습니다.
② 폼 저장 후 — DB
SELECT attach_id, attach_ref_domain, attach_ref_key, attach_ref_category,
attach_display_name, attach_saved_name, attach_saved_dir, attach_sort
FROM nv_attach
WHERE attach_ref_domain = 'product' AND attach_ref_key = 35;③ 물리 파일
# firo.directory.base-dir 아래
ls -l /data/attach/product/2026/07/35/
# 6f1c0f2a-... .jpg④ 브라우저에서 보기
http://localhost:8080/assets/firo/attach/view/product/35/main자주 하는 실수
파일을 골랐는데 저장 후 아무것도 없어요
폼을 저장할 때 attachContainer 가 서버로 전달되지 않았을 가능성이 큽니다.
v-model(또는 디렉티브의model)에 준 객체가 실제로 저장 API 에 보내는 객체와 같은지 확인 —api.post('/api/product', { name: form.name })처럼 필드를 골라 보내면attachContainer가 빠집니다.- 저장 API 에
@FiroUpload가 붙어 있는지 확인 - 서버 로그에
firo 첨부 본문을 읽을 수 없어…경고가 있는지 확인 →FiroServletFilter미등록
수정 화면에서 기존 첨부가 안 보여요
ref-key(디렉티브는 key)에 엔터티 PK 를 넘겼는지 확인하세요. 비어 있으면 신규 등록으로 간주해 기존 첨부를 조회하지 않습니다. 화면 로딩 순서상 model.id 가 나중에 채워진다면, 데이터 로드 완료 후 컴포넌트를 렌더링하도록 v-if 를 걸어주세요.
카테고리를 여러 개 쓰고 싶어요
<nv-file-upload> 를 카테고리 수만큼 배치하되 v-model 은 같은 모델을 공유하면 됩니다. attachContainer 안에서 카테고리별로 분리 저장되며, 저장 시 한 번에 전송됩니다.
<nv-file-upload v-model="model" ref-domain="product" ref-category="main"
:ref-key="model.id" :max-count="1" accept="image/*" />
<nv-file-upload v-model="model" ref-domain="product" ref-category="list"
:ref-key="model.id" multiple sortable accept="image/*" />