Skip to content

실전 예제 모음

자주 나오는 요구사항별로 그대로 복사해 쓸 수 있는 코드입니다. 기본 개념은 Quick Start 를 먼저 읽으세요.


1. 프로필 사진 — 이미지 1장

가장 흔한 형태입니다. 기존 사진이 있으면 교체됩니다.

java
@Data
@FiroRef("admin")
public class Admin {
    private Long id;
    private String loginId;
    private Instant createdDt;
}
java
@FiroUpload
@PutMapping("/api/admin/{id}")
public ResponseEntity<Void> updateAdmin(@PathVariable Long id, @RequestBody Admin admin) {
    adminService.saveAdmin(admin);
    return ResponseEntity.ok().build();
}
vue
<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>

표시할 때:

html
<img src="/assets/firo/attach/view/admin/12/image?w=80" alt="프로필" />

2. 다중 파일 + 순서 변경

상품 상세 이미지처럼 여러 장을 올리고 순서를 정하는 경우입니다.

vue
<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 캐시에 영향이 없습니다.

조회할 때도 정렬 순서 그대로 내려옵니다.

html
<!-- 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 에 같은 모델을 주는 것이 핵심입니다 — 저장 시 한 번에 전송됩니다.

vue
<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>

모델 안에는 이런 구조가 만들어집니다.

json
{
  "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 로 바로 표시합니다. 행이 많아도 추가 요청이 없습니다.

vue
<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 로 기본 이미지를 지정하세요.

html
<img :src="url" onerror="this.src='/img/no-image.png'" />

방법 B — 서버에서 한 번에 붙여 내려주기

첨부 정보(파일명·크기 등)까지 필요하면 목록 조회 시 일괄로 채워 보냅니다. 행마다 API 를 호출하는 N+1 을 피할 수 있습니다.

java
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 에 직접 채워 넣습니다.

java
firoService.injectAttachBag(list, Product.class);   // @FiroRef 에서 도메인·키 필드를 읽음
// → 각 항목의 _meta.attachBag 에 AttachBag 이 들어감

5. 서버에서 첨부 직접 다루기

@FiroUpload 로 해결되지 않는 경우(배치 작업, 다른 시스템에서 받은 파일 등)는 FiroService 를 직접 씁니다.

조회

java
// 단건 카테고리 목록 (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 설정 유무를 알아서 처리합니다.

java
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 는 파일을 다른 곳에서 공유 중일 때만 쓰세요.

java
// 엔터티 삭제 시 첨부도 완전 삭제
firoService.clearAttachByDomain("product", 35L);

첨부 복사 (레코드 복제)

"이 상품 복사하기" 같은 기능에서, 물리 파일은 공유하고 DB 레코드만 복제합니다.

java
Product copied = productService.copy(original);          // 새 id 채번
firoService.copyAttach("product", original.getId(), copied.getId());

물리 파일을 공유합니다

원본을 clearAttachByDomain 으로 지우면 복사본의 파일도 함께 사라집니다. 독립된 사본이 필요하면 파일을 새로 업로드하세요.

부가 정보(ext) 갱신

java
firoService.updateExt(1024L, "대표 이미지");   // nv_attach.attach_ext

6. 이미지 자동 리사이즈 · 회전 보정

스마트폰 사진은 용량이 크고 EXIF 회전 정보 때문에 눕는 경우가 많습니다. 카테고리에 필터 체인을 걸어두면 화면 코드를 건드리지 않고 전부 해결됩니다.

java
@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 를 쓰세요. 품질과 크기를 단계적으로 낮춰 목표 바이트에 맞춥니다.

java
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 를 겁니다.

java
@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 는 직접 그립니다.

vue
<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 으로 내려받기

vue
<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" 같은 표시를 할 때.

ts
const {data: count} = await api.get(`/api/firo/attach/board/${id}/default/_count`);

행이 많다면

행마다 호출하지 말고 목록 조회 쿼리에서 nv_attach 를 집계해 함께 내려주는 편이 낫습니다.

sql
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_id

11. 이미 있는 파일을 다른 엔터티에 붙이기

파일을 다시 업로드하지 않고 물리 파일을 공유하려면 savedDirsavedName 을 함께 넘깁니다.

json
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 업로드를 씁니다 — 별도 작업이 필요 없습니다.

vue
<nv-ckeditor v-model="model.content" />

폼을 저장하지 않아도 파일은 남습니다

에디터 이미지는 업로드 즉시 영구 저장됩니다(ckupload 도메인, _ 경로). 본문에서 이미지를 지워도 파일 자체는 남으므로, 장기 운영 시 미참조 파일 정리 배치를 고려하세요.


관련 문서