실전 예제 모음
자주 나오는 요구사항별로 그대로 복사해 쓸 수 있는 코드입니다. 기본 개념은 Quick Start 를 먼저 읽으세요.
1. 프로필 사진 — 이미지 1장
가장 흔한 형태입니다. 기존 사진이 있으면 교체됩니다.
@Data
@FiroRef("admin")
public class Admin {
private Long id;
private String loginId;
private Instant createdDt;
}@FiroUpload
@PutMapping("/api/admin/{id}")
public ResponseEntity<Void> updateAdmin(@PathVariable Long id, @RequestBody Admin admin) {
adminService.saveAdmin(admin);
return ResponseEntity.ok().build();
}<template>
<nv-file-upload v-model="model"
ref-domain="admin"
ref-category="image"
:ref-key="model.id"
:max-count="1"
accept="image/*"
:cols="['THUMB', 'ACTION']" />
</template>
<script setup lang="ts">
import {ref} from 'vue';
const model = ref<Record<string, any>>({});
</script>표시할 때:
<img src="/assets/firo/attach/view/admin/12/image?w=80" alt="프로필" />2. 다중 파일 + 순서 변경
상품 상세 이미지처럼 여러 장을 올리고 순서를 정하는 경우입니다.
<nv-file-upload v-model="model"
ref-domain="product"
ref-category="list"
:ref-key="model.id"
multiple
sortable
:max-count="10"
:max-file-size-mb="20"
accept="image/*" />sortable이 위/아래 이동 버튼을 띄웁니다.- 저장 시 화면에 보이는 순서대로
attach_sort가 확정됩니다. - 순서만 바꾸면 물리 파일은 그대로 두고 sort 값만 갱신되므로 CDN 캐시에 영향이 없습니다.
조회할 때도 정렬 순서 그대로 내려옵니다.
<!-- 0 → 첫 번째, 1 → 두 번째 … -->
<img src="/assets/firo/attach/view/product/35/list/0?w=400" />
<img src="/assets/firo/attach/view/product/35/list/1?w=400" />3. 한 화면에서 카테고리 여러 개
대표 이미지 · 상세 이미지 · 설명서를 한 폼에서 다룹니다. v-model 에 같은 모델을 주는 것이 핵심입니다 — 저장 시 한 번에 전송됩니다.
<template>
<nv-input-wrap label="대표 이미지">
<nv-file-upload v-model="model" ref-domain="product" ref-category="main"
:ref-key="model.id" :max-count="1" accept="image/*" />
</nv-input-wrap>
<nv-input-wrap label="상세 이미지">
<nv-file-upload v-model="model" ref-domain="product" ref-category="list"
:ref-key="model.id" multiple sortable accept="image/*" />
</nv-input-wrap>
<nv-input-wrap label="사용설명서">
<nv-file-upload v-model="model" ref-domain="product" ref-category="manual"
:ref-key="model.id" :max-count="3" accept=".pdf"
:cols="['NAME', 'SIZE', 'ACTION']" />
</nv-input-wrap>
</template>모델 안에는 이런 구조가 만들어집니다.
{
"id": 35,
"name": "상품명",
"attachContainer": {
"product": {
"main": [ { "savedName": "...", "displayName": "cover.jpg", ... } ],
"list": [ { ... }, { ... } ],
"manual": [ { ... } ],
"_deleted": [ { "id": 10 } ]
}
}
}도메인이 여러 개여도 됩니다
한 화면에서 상품(product)과 옵션(productOption) 첨부를 함께 다룰 수도 있습니다. attachContainer 는 도메인별로 분리 보관하며, @FiroUpload aspect 가 중첩 필드·컬렉션까지 탐색해 각각의 @FiroRef 대상에 맞춰 저장합니다.
4. 목록 화면에서 썸네일 보여주기
방법 A — URL 만 쓰기 (가장 가볍고 권장)
첨부 조회 API 를 호출하지 않고 URL 로 바로 표시합니다. 행이 많아도 추가 요청이 없습니다.
<el-table :data="rows">
<el-table-column label="이미지" width="120">
<template #default="{ row }">
<img :src="`/assets/firo/attach/view/product/${row.id}/main?w=100`"
style="max-width: 100px" />
</template>
</el-table-column>
</el-table>첨부가 없는 행
첨부가 없으면 404 가 나고 깨진 이미지 아이콘이 보입니다. onerror 로 기본 이미지를 지정하세요.
<img :src="url" onerror="this.src='/img/no-image.png'" />방법 B — 서버에서 한 번에 붙여 내려주기
첨부 정보(파일명·크기 등)까지 필요하면 목록 조회 시 일괄로 채워 보냅니다. 행마다 API 를 호출하는 N+1 을 피할 수 있습니다.
import com.unvus.iflex.core.platform.firo.module.service.FiroService;
import com.unvus.iflex.core.platform.firo.module.service.domain.AttachBag;
@Service
@RequiredArgsConstructor
public class ProductService {
private final FiroService firoService;
public List<ProductResponse> listProduct(ProductSearchForm form) {
List<ProductResponse> list = productRepository.list(form);
List<Long> ids = list.stream().map(ProductResponse::getId).toList();
Map<Long, AttachBag> bagMap = firoService.getAttachBagMapByRef("product", ids); // 쿼리 1회
list.forEach(row -> row.setAttachBag(bagMap.get(row.getId())));
return list;
}
}injectAttachBag 로 _meta 에 담기
엔터티에 _meta 맵이 있으면(iflex 엔터티 표준) 이 메서드가 _meta.attachBag 에 직접 채워 넣습니다.
firoService.injectAttachBag(list, Product.class); // @FiroRef 에서 도메인·키 필드를 읽음
// → 각 항목의 _meta.attachBag 에 AttachBag 이 들어감5. 서버에서 첨부 직접 다루기
@FiroUpload 로 해결되지 않는 경우(배치 작업, 다른 시스템에서 받은 파일 등)는 FiroService 를 직접 씁니다.
조회
// 단건 카테고리 목록 (attach_sort 순)
List<? extends FiroFile> images = firoService.listAttachByRef("product", 35L, "main");
// 도메인 전체를 카테고리별로
AttachBag bag = firoService.getAttachBagByRef("product", 35L, null);
FiroFile cover = bag.one("main"); // main 카테고리 첫 번째
List<FiroFile> details = bag.get("list");
// 첨부 ID 로 단건
FiroFile attach = firoService.getAttach(1024L);
// 건수
long count = firoService.listAttachCntByRef("product", 35L, "manual");프론트 URL 만들기
DB 를 직접 조인해 첨부를 가져온 경우에도 URL 조립은 이 메서드를 쓰세요. CDN 설정 유무를 알아서 처리합니다.
import com.unvus.iflex.core.platform.firo.util.FiroUtil;
String url = FiroUtil.directUrl(attach);
// CDN 있음 → https://cdn.example.com/product/2026/07/35/6f1c....jpg
// CDN 없음 → /assets/firo/attach/view/1024삭제
두 메서드는 동작이 다릅니다
| 메서드 | DB 레코드 | 물리 파일 |
|---|---|---|
deleteByRef(domain, key, category) | 삭제 | 남음 |
clearAttachByDomain(domain, key) | 삭제 | 삭제 |
deleteAttach(List<FiroFile>) | 삭제 | 삭제 |
물리 파일을 남기는 deleteByRef 는 파일을 다른 곳에서 공유 중일 때만 쓰세요.
// 엔터티 삭제 시 첨부도 완전 삭제
firoService.clearAttachByDomain("product", 35L);첨부 복사 (레코드 복제)
"이 상품 복사하기" 같은 기능에서, 물리 파일은 공유하고 DB 레코드만 복제합니다.
Product copied = productService.copy(original); // 새 id 채번
firoService.copyAttach("product", original.getId(), copied.getId());물리 파일을 공유합니다
원본을 clearAttachByDomain 으로 지우면 복사본의 파일도 함께 사라집니다. 독립된 사본이 필요하면 파일을 새로 업로드하세요.
부가 정보(ext) 갱신
firoService.updateExt(1024L, "대표 이미지"); // nv_attach.attach_ext6. 이미지 자동 리사이즈 · 회전 보정
스마트폰 사진은 용량이 크고 EXIF 회전 정보 때문에 눕는 경우가 많습니다. 카테고리에 필터 체인을 걸어두면 화면 코드를 건드리지 않고 전부 해결됩니다.
@Configuration
public class ProductFiroConfig {
@Bean
public FiroRegistrar productFiroRegistrar() {
return () -> {
try {
FiroFilterChain imageChain = new FiroFilterChain();
imageChain.addFilter(new AutoFixOrientationImageFilter()); // EXIF 회전 보정
imageChain.addFilter(new ResizeImageFilter(), Map.of( // 1920x1080 이내로 축소
ResizeImageFilter.PARAM_MAX_WIDTH, 1920,
ResizeImageFilter.PARAM_MAX_HEIGHT, 1080
));
imageChain.addFilter(new FileExtensionExceptionFilter(), Map.of( // 이미지 확장자만
FileExtensionExceptionFilter.PARAM_WHITELIST,
List.of("jpg", "jpeg", "png", "gif", "webp")
));
FiroDomain product = FiroDomain.builder("product").build();
product.addCategory(FiroCategory.builder(product, "main")
.filterChain(imageChain).build());
product.addCategory(FiroCategory.builder(product, "list")
.filterChain(imageChain).build()); // 체인 공유 — 동시 업로드에 안전
FiroRegistry.add(product);
} catch (Exception e) {
throw new IllegalStateException("firo product 등록 실패", e);
}
};
}
}목표 용량까지 줄이기
"무조건 500KB 이하" 같은 요구에는 OptimizeImageFilter 를 쓰세요. 품질과 크기를 단계적으로 낮춰 목표 바이트에 맞춥니다.
imageChain.addFilter(new OptimizeImageFilter(), Map.of(
OptimizeImageFilter.PARAM_MAX_WIDTH, 1600,
OptimizeImageFilter.PARAM_MAX_BYTES, 500 * 1024
));썸네일은 필터가 아니라 URL 로
목록용 작은 이미지는 원본을 저장해두고 ?w=200 으로 요청하세요. 서버가 만들어 캐시하므로 크기별로 여러 벌 저장할 필요가 없습니다.
7. 비공개 첨부 — 접근 제어
/assets/firo/** 는 인증 필터 밖이므로, URL 만 알면 누구나 받을 수 있습니다. 비공개 파일이라면 반드시 secureAccessFunc 를 겁니다.
@Bean
public FiroRegistrar contractFiroRegistrar() {
return () -> {
FiroDomain contract = FiroDomain.builder("contract")
.secureAccessFunc((request, firoFile) -> {
if (!SecurityUtils.isAuthenticated()) {
return false;
}
Long owner = firoFile.getCreatedBy();
if (owner == null) {
return true; // temp/direct 미리보기 — 인증만 확인
}
return owner.equals(SecurityUtils.getCurrentUserId())
|| SecurityUtils.hasAnyAuthority("ROLE_ADMIN");
})
.build();
FiroRegistry.add(contract);
};
}null 체크를 빠뜨리지 마세요
temp / direct 뷰에서는 DB 레코드가 없어 refDomain, refCategory, savedName 만 채워진 합성 객체가 전달됩니다. getCreatedBy() 등을 그대로 쓰면 NPE 가 나고, 예외는 접근 거부(403)로 처리되어 정상 파일까지 안 보이게 됩니다.
8. 커스텀 업로더 만들기 (디렉티브)
기본 표 UI 대신 직접 디자인해야 할 때. 디렉티브가 업로드/적재를 맡고 UI 는 직접 그립니다.
<template>
<div class="uploader">
<label class="drop-zone">
<span v-if="!items.length">클릭하거나 파일을 끌어다 놓으세요</span>
<input ref="fileInput" type="file" multiple accept="image/*" hidden
v-firo-upload="binding"
@onLoaded="onLoaded"
@onFileAdded="onFileAdded"
@onFileRemoved="onFileRemoved"
@onUploadError="onError" />
</label>
<ul class="thumbs">
<li v-for="(item, idx) in items" :key="idx">
<img :src="item.url" />
<button @click="remove(idx)">×</button>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useTemplateRef} from 'vue';
import {ElMessage} from 'element-plus';
const props = defineProps<{ modelValue: Record<string, any> }>();
const fileInput = useTemplateRef<HTMLInputElement>('fileInput');
const items = ref<any[]>([]);
// key 가 나중에 채워져도 디렉티브의 updated 훅이 반영하므로 computed 로 둡니다
const binding = computed(() => ({
model: props.modelValue,
domain: 'product',
category: 'list',
key: props.modelValue.id,
maxCount: 10,
maxFileSizeMb: 20,
}));
const onLoaded = (e: CustomEvent) => { items.value = e.detail.bag; };
const onFileAdded = (e: CustomEvent) => { items.value.push(e.detail); };
const onFileRemoved= (e: CustomEvent) => { items.value.splice(e.detail.index, 1); };
const onError = (e: CustomEvent) => { ElMessage.error(e.detail.message); };
// 제거는 디렉티브에 이벤트로 요청합니다 (저장된 첨부면 _deleted 에 자동 적재)
const remove = (index: number) => {
fileInput.value?.dispatchEvent(new CustomEvent('removeFileFromBag', { detail: { index } }));
};
</script>업로드 전 커스텀 검증
change 처리 전에 el.dataset.validationFailed = 'true' 를 세팅하면 그 업로드를 건너뜁니다. "필수 항목을 먼저 입력해야 업로드 가능" 같은 규칙에 쓸 수 있습니다.
9. 첨부 전체를 ZIP 으로 내려받기
<template>
<el-button @click="downloadAll">전체 다운로드</el-button>
</template>
<script setup lang="ts">
import {api} from 'src/boot/axios';
const props = defineProps<{ productId: number }>();
const downloadAll = async () => {
// 1) 첨부 목록에서 id 수집
const {data: bag} = await api.get(`/api/firo/attach/product/${props.productId}/list`);
const ids = (bag.list ?? []).map((f: any) => f.id);
if (!ids.length) {
return;
}
// 2) ZIP 요청
const {data} = await api.post('/assets/firo/attach/download',
{ title: `상품${props.productId}_이미지`, ids },
{ responseType: 'blob' });
// 3) 저장
const url = URL.createObjectURL(data);
const a = Object.assign(document.createElement('a'), { href: url, download: '이미지.zip' });
a.click();
URL.revokeObjectURL(url);
};
</script>10. 첨부 개수 뱃지
목록에서 "첨부 3" 같은 표시를 할 때.
const {data: count} = await api.get(`/api/firo/attach/board/${id}/default/_count`);행이 많다면
행마다 호출하지 말고 목록 조회 쿼리에서 nv_attach 를 집계해 함께 내려주는 편이 낫습니다.
LEFT JOIN (SELECT attach_ref_key, COUNT(*) cnt
FROM nv_attach
WHERE attach_ref_domain = 'board'
GROUP BY attach_ref_key) a ON a.attach_ref_key = b.board_id11. 이미 있는 파일을 다른 엔터티에 붙이기
파일을 다시 업로드하지 않고 물리 파일을 공유하려면 savedDir 과 savedName 을 함께 넘깁니다.
POST /api/firo/attach/product/99
{
"refDomain": "product",
"main": [
{ "savedName": "6f1c0f2a-....jpg", "savedDir": "/product/2026/07/35/",
"displayName": "photo.jpg", "fileSize": 204812, "fileType": "image/jpeg" }
]
}id 는 없고 savedDir 이 있으면 → 파일 복사 없이 DB 레코드만 새로 생성됩니다. 서버 코드에서는 firoService.copyAttach(...) 가 같은 일을 합니다.
12. 에디터 본문 이미지
nv-ckeditor / nv-suneditor 는 내부적으로 direct 업로드를 씁니다 — 별도 작업이 필요 없습니다.
<nv-ckeditor v-model="model.content" />폼을 저장하지 않아도 파일은 남습니다
에디터 이미지는 업로드 즉시 영구 저장됩니다(ckupload 도메인, _ 경로). 본문에서 이미지를 지워도 파일 자체는 남으므로, 장기 운영 시 미참조 파일 정리 배치를 고려하세요.
관련 문서
- Quick Start — 처음부터 따라하기
- 프론트엔드 연동 — 컴포넌트/디렉티브 전체 옵션
- 설정 가이드 — 저장소·필터·접근 제어
- REST API — 직접 호출할 때
- 트러블슈팅 — 안 될 때