Skip to content

프론트엔드 연동

cms 프론트엔드가 제공하는 Firo 도구의 전체 레퍼런스입니다.

시작 전에 — 컴포넌트와 디렉티브는 다릅니다

가장 흔한 실수가 둘을 섞어 쓰는 것입니다. 하나만 고르세요.

vue
<!-- ✅ 컴포넌트: 태그를 그대로 배치. props 이름은 ref-* -->
<nv-file-upload v-model="model" ref-domain="product" ref-category="main" :ref-key="model.id" />

<!-- ✅ 디렉티브: input[type=file] 에 부착. 바인딩 객체의 키는 domain/category/key -->
<input type="file" v-firo-upload="{ model: form, domain: 'product', category: 'main', key: form.id }" />

<!-- ❌ 둘을 겹쳐 쓰면 동작하지 않습니다 -->
<div v-firo-upload="{ ... }"><nv-file-upload /></div>

어느 것을 쓸까

상황선택
일반적인 관리자 첨부 화면<nv-file-upload>
파일 목록·썸네일·삭제 버튼이 필요하다<nv-file-upload>
순서 변경(정렬) UI 가 필요하다<nv-file-upload> (sortable)
디자인이 완전히 다른 커스텀 업로더를 직접 만든다v-firo-upload
이미 만들어둔 마크업의 <input type="file"> 에 업로드만 얹고 싶다v-firo-upload
저장된 이미지를 화면에 표시만 한다<img src="/assets/firo/attach/view/..."> 또는 <firo-img>

제공 파일

cms/frontend/src/
├── components/plugins/firo/src/
│   ├── nv-file-upload.vue          업로드 컴포넌트 (표 UI 내장)
│   ├── nv-editor-image-upload.vue  에디터 본문 이미지 업로더
│   ├── firo-img.vue                이미지 표시
│   └── firo-video.vue              동영상 표시
└── boot/firo-upload-directive.ts   v-firo-upload 디렉티브

import 불필요

컴포넌트는 unplugin-vue-components 로 자동 등록됩니다. 파일명 그대로 <nv-file-upload>, <firo-img>, <firo-video>, <nv-editor-image-upload> 를 바로 쓰면 됩니다. 디렉티브도 boot 파일에서 전역 등록되므로 별도 준비가 필요 없습니다.

외부 업로드 라이브러리 의존 없음

업로더는 전부 자체 구현입니다(네이티브 input + drag & drop). 서버 호출은 프로젝트의 api 인스턴스를 쓰므로 인증 헤더가 자동으로 붙습니다.


<nv-file-upload> — 업로드 컴포넌트

파일 선택 버튼 + 목록 표(썸네일 · 파일명 · 설명 · 크기 · 삭제/정렬 버튼) + 드래그&드롭이 모두 들어 있습니다.

기본 사용

vue
<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>>({});
</script>

Props

필수 · 참조 지정

Prop타입기본값설명
v-modelobject(필수) 폼 모델. 여기에 attachContainer 속성을 자동 생성해 첨부 정보를 담습니다
ref-domainstring(필수) refDomain. 엔터티의 @FiroRef("...")
ref-categorystring'default'도메인 안의 용도 구분
ref-keystring | number엔터티 PK. 값이 있으면 수정 모드 — 서버에서 기존 첨부를 불러옵니다. 없으면 신규(빈 목록)

개수 · 형식 · 크기 제한

Prop타입기본값설명
multiplebooleanfalse파일 선택 창에서 다중 선택 허용
max-countnumber0최대 첨부 개수 (0 = 무제한). 초과 시 업로드 버튼이 숨겨지고 @error 발생
acceptstring<input accept> 속성 (예: image/*, .pdf,.docx)
image-onlybooleanfalsetrueacceptimage/* 로 강제
max-file-size-mbnumber10파일당 최대 크기(MB). 브라우저에서 먼저 검사하고 초과 시 @error. 0 = 무제한(서버 필터에 위임)
filtersobject | nullnull요청별 서버 필터 지정 → 필터 사용법

표시 · UI

Prop타입기본값설명
colsstring[]['THUMB','NAME','DESCRIPTION','SIZE','ACTION']표시할 컬럼 → 컬럼 종류
preview-max-sizenumber100썸네일 최대 변 길이(px)
sortablebooleanfalse위/아래 이동 버튼 표시. 저장 시 배열 순서가 attach_sort 로 확정됩니다(물리 파일은 불변)
reverse-orderbooleanfalse화면 표시 순서만 반전 (실제 저장 순서는 그대로)
modify-disabledbooleanfalse업로드/삭제/정렬 비활성화 (읽기 전용 화면)
upload-btn-textstring'Upload'업로드 버튼 문구
upload-btn-classstring'btn btn-default btn-file'업로드 버튼 CSS 클래스
delete-btn-textstring'삭제'삭제 버튼 문구
show-upload-iconbooleantrue업로드 버튼 아이콘 표시(업로드 중엔 스피너)

CDN URL 관련

Prop타입기본값설명
use-direct-urlbooleanfalse기존 첨부 로드 시 CDN direct URL 을 조회해 url 로 사용 (미존재 시 서버 뷰 URL 폴백)
enable-copy-direct-urlbooleanfalse파일명 옆에 "프론트 URL 복사" 버튼 표시
disable-url-copy-messagestring'저장 후에 URL을 복사 할 수 있습니다.'ref-key 가 없을 때(신규) 표시할 안내 태그

고급

Prop타입기본값설명
interceptor(bag) => voidnull기존 첨부 로드 직후 목록을 가공하는 훅 (필터링·가공 등)

표 컬럼 (cols)

내용
THUMB썸네일. 이미지는 미리보기(클릭 시 원본 뷰어), 동영상/오디오는 플레이어
NAME파일명 + 다운로드 링크 (+ enable-copy-direct-url 시 URL 복사 버튼)
DESCRIPTION편집 가능한 설명 입력칸. 입력값은 첨부의 ext 필드(= nv_attach.attach_ext)로 저장됩니다
SIZE파일 크기(MB)
UPLOADER업로더(createdBy) — 기본 미포함
UPLOAD_DATE업로드 일시 — 기본 미포함
ACTION삭제 버튼
vue
<!-- 썸네일과 삭제 버튼만 -->
<nv-file-upload v-model="model" ref-domain="product" :cols="['THUMB', 'ACTION']" />

<!-- 업로더 · 업로드일시 추가 -->
<nv-file-upload v-model="model" ref-domain="board"
                :cols="['NAME', 'SIZE', 'UPLOADER', 'UPLOAD_DATE', 'ACTION']" />

이벤트

이벤트페이로드발생 시점
@change카테고리 첨부 배열목록이 바뀔 때마다
@uploaded성공 항목 배열temp 업로드가 끝났을 때 (1회 선택당 1번)
@error{ code, message, file? } 또는 서버 오류 객체개수/용량 초과, 서버 필터 거부, 통신 실패

@errorcode 값:

code
max-countmax-count 초과
max-file-sizemax-file-size-mb 초과 (file 에 파일명 포함)
(그 외)서버 필터가 거부했거나 통신 오류 — 서버가 준 객체가 그대로 전달됩니다
vue
<template>
  <nv-file-upload v-model="model" ref-domain="product" :ref-key="model.id"
                  :max-count="5" :max-file-size-mb="20"
                  @error="onError" @uploaded="onUploaded" />
</template>

<script setup lang="ts">
import {ElMessage} from 'element-plus';

const onError = (err: any) => {
  ElMessage.error(err?.message ?? '업로드에 실패했습니다.');
};

const onUploaded = (items: any[]) => {
  console.log(`${items.length}개 업로드 완료`);
};
</script>

요청별 서버 필터 (filters)

카테고리에 고정 필터를 걸지 않고, 이 화면에서만 서버 필터를 적용하고 싶을 때 사용합니다.

vue
<nv-file-upload v-model="model" ref-domain="product"
                :filters="{ size: { maxSize: 5 } }" />

등록된 키만 동작합니다

filters 의 키는 서버에 미리 등록된 필터 키여야 합니다. 등록되지 않은 키는 등록되지 않은 필터 키를 무시합니다 경고만 남기고 조용히 무시됩니다.

iflex 기본 등록 키는 다음 두 개뿐입니다.

필터파라미터
size용량 검증maxSize (MB)
dimension이미지 크기 정확 일치 검증width, height

리사이즈처럼 다른 필터가 필요하면 카테고리 필터 체인으로 거는 것이 정석입니다 (→ 설정 가이드). 굳이 요청별로 쓰려면 FiroRegistrar 에서 FiroFilterRegistry.add("resize", new ResizeImageFilter()) 처럼 키를 먼저 등록하세요.

실제 사용 예 (프로젝트 내)

vue
<!-- cms/frontend/src/views/admin/admin-detail.vue — 관리자 프로필 사진 1장 -->
<nv-file-upload v-model="model" ref-domain="admin" ref-category="image"
                :ref-key="model.id" :max-count="1" accept="image/*" />
vue
<!-- cms/frontend/src/views/banner/banner-item-detail.modal.vue — 이미지 또는 동영상 -->
<nv-file-upload v-model="model" ref-domain="bannerItem" ref-category="image"
                :ref-key="model.id" :max-count="1" :multiple="false" accept="image/*" />
<nv-file-upload v-model="model" ref-domain="bannerItem" ref-category="video"
                :ref-key="model.id" :max-count="1" :multiple="false" accept="video/mp4" />

v-firo-upload — 디렉티브

<input type="file">업로드 배선만 얹습니다. UI 는 직접 만듭니다.

바인딩 옵션

vue
<input type="file" multiple v-firo-upload="{
  model: form,            // (필수) attachContainer 를 붙일 모델 객체
  domain: 'product',      // (필수) refDomain
  category: 'main',       // 기본 'default'
  key: form.id,           // 있으면 기존 첨부 로드 (수정 모드)
  multiple: true,         // input 의 multiple 속성으로도 판단됩니다
  maxCount: 5,            // 0 = 무제한
  maxFileSizeMb: 10,      // 0 = 무제한
  filters: null,          // 요청별 서버 필터 (컴포넌트와 동일 — 등록된 키만)
  extra: null,            // 항목의 ext 필드에 담을 부가정보 (문자열 또는 객체)
}" />

옵션 이름이 컴포넌트와 다릅니다

디렉티브는 domain / category / key 입니다 (ref-domain 아님).

발생 이벤트

디렉티브는 엘리먼트에 CustomEvent 를 dispatch 합니다. 값은 event.detail 에 있습니다.

이벤트detail발생 시점
onLoaded{ bag }기존 첨부 로드 완료 (수정 모드)
onFileAdded{ displayName, url, fileType, savedName, fileSize }temp 업로드 성공 항목마다
onFileUpdated위와 동일하위호환용 — onFileAdded 와 함께, 그리고 로드 시 첫 항목에도 발생
onFileRemoved{ index, item }항목 제거 후
onUploadError{ message, file? }업로드/필터 실패
vue
<input type="file" v-firo-upload="binding"
       @onLoaded="e => bag = e.detail.bag"
       @onFileAdded="e => preview = e.detail.url"
       @onUploadError="e => alert(e.detail.message)" />

항목 제거하기

목록에서 파일을 지우는 UI 는 직접 만들되, 제거 자체는 엘리먼트에 이벤트를 보내 요청합니다. (저장된 첨부라면 _deleted 에 자동 적재되어 폼 저장 시 영구 삭제됩니다.)

vue
<template>
  <input ref="fileInput" type="file" v-firo-upload="binding" @onFileRemoved="onRemoved" />

  <ul>
    <li v-for="(item, idx) in bag" :key="idx">
      {{ item.displayName }}
      <button @click="remove(idx)">삭제</button>
    </li>
  </ul>
</template>

<script setup lang="ts">
import {ref, useTemplateRef} from 'vue';

const fileInput = useTemplateRef<HTMLInputElement>('fileInput');
const form = ref<Record<string, any>>({});
const bag = ref<any[]>([]);
const binding = ref({ model: form.value, domain: 'product', category: 'main' });

const remove = (index: number) => {
  fileInput.value?.dispatchEvent(new CustomEvent('removeFileFromBag', { detail: { index } }));
};
const onRemoved = () => { /* 목록 갱신 */ };
</script>

동작 세부

  • 단일 모드(multiple 이 아님)는 교체 방식 — 새 파일을 고르면 기존 항목을 먼저 비웁니다.
  • 같은 파일을 다시 선택해도 change 가 발생하도록 input.value 를 매번 초기화합니다.
  • 외부 검증과 조합: change 처리 전에 el.dataset.validationFailed = 'true' 로 세팅해 두면 그 업로드를 건너뜁니다(플래그는 소비 후 자동 해제).
  • key 가 나중에 채워져도(저장 후 id 부여) 디렉티브의 updated 훅이 자동 반영합니다.
  • 미리보기 URL 은 아직 저장 전이므로 temp 뷰 URL(/assets/firo/attach-temp/view/...)입니다.

<firo-img> — 이미지 표시

해당 참조의 첨부를 조회해 CDN direct URL 로 표시하고, 없으면 서버 뷰 URL 로 폴백합니다. 클릭하면 원본 뷰어(라이트박스)가 열립니다.

vue
<firo-img ref-domain="bannerItem"
          ref-category="image"
          :firo-model="item"
          :refresh="true"
          css-style="width: 200px; height: 120px; object-fit: contain;" />
Prop타입기본값설명
ref-domainstring(필수)
ref-categorystring'default'
ref-keystring | number참조 키. ref-key 또는 firo-model 중 하나는 필수
firo-modelobject엔터티 객체. ref-key 미지정 시 id 를, cache-value 미지정 시 modifiedDt 를 사용
cache-valuestring캐시버스터 값. 없으면 firo-model.modifiedDt
refreshbooleanfalsetrue 면 URL 에 ?cache= 쿼리를 붙임
css-style / css-class<img> 에 적용할 스타일/클래스
sort-descbooleanfalse첨부 등록일시 내림차순으로 표시 (기본은 서버의 attach_sort 순)

카테고리의 첨부를 전부 렌더링합니다

firo-img 는 해당 카테고리의 모든 첨부를 <img> 로 그립니다. 1장만 보여주려면 카테고리를 1장짜리로 운영하거나 아래 URL 방식을 쓰세요.

created-dt prop 은 제거되었습니다

서버의 direct-url API 가 조회형으로 바뀌면서 createdDt 를 무시하게 됐는데, 프론트에는 "createdDt 가 없으면 CDN URL 조회를 건너뛴다"는 게이트만 남아 있었습니다. 게이트와 함께 prop 도 제거되어, 이제 항상 direct URL 을 조회하고 없으면 뷰 URL 로 폴백합니다.

단순 표시라면 URL 이 더 가볍습니다

firo-img 는 마운트할 때마다 첨부 목록 API + direct-url API 를 호출합니다. 목록 화면처럼 행이 많은 곳에서는 <img> URL 방식이 훨씬 저렴합니다.

html
<img src="/assets/firo/attach/view/product/35/main?w=200" />

<firo-video> — 동영상 표시

카테고리의 첫 번째 첨부를 동영상 플레이어로 재생합니다. video/quicktimevideo/mp4 로 처리하며, 첨부가 없으면 VIDEO 플레이스홀더를 표시합니다.

vue
<firo-video ref-domain="bannerItem" ref-category="video" :firo-model="item" />
Prop타입기본값설명
ref-domainstring(필수)
ref-categorystring'default'
ref-keystring | number참조 키. ref-key 또는 firo-model 중 하나는 필수
firo-modelobject엔터티 객체. ref-key 미지정 시 id 를 사용
css-style / css-class플레이어에 적용할 스타일/클래스

<nv-editor-image-upload> — 에디터 본문 이미지

nv-suneditor 가 내부적으로 사용합니다. 폼 저장과 무관하게 업로드 즉시 영구 저장(direct 업로드)하고, 에디터 본문에 삽입/제거를 관리합니다.

Prop기본값설명
v-model(필수) 모델 객체
ref-domain(필수)
ref-category'default'
ref-key있으면 기존 이미지 목록 로드
multiple / max-countfalse / 0
accept'image/*'
max-file-size-mb10
upload-btn-text'이미지 업로드'
display-namefalse목록에 파일명 표시
show-upload-headerfalse접기/펼치기 헤더 표시
modify-disabledfalse

addFileToBag(file)defineExpose 로 열려 있어, 에디터가 드래그&드롭으로 올린 이미지를 목록에 주입할 수 있습니다.


백엔드 연동 — @FiroRef + @FiroUpload

프론트가 보낸 attachContainer 를 서버가 어떻게 처리하는지의 요약입니다. (단계별 안내는 Quick Start 참고)

java
@FiroRef("banner")                    // 엔터티 = 첨부 참조 대상 (value = refDomain)
public class Banner {
    private Long id;                  // → refKey
    private Instant createdDt;        // → 저장 경로의 yyyy/MM
}

@FiroUpload                           // 본체 저장 후 aspect 가 첨부 확정
@PostMapping("/api/banner")
public ResponseEntity<Void> addBanner(@RequestBody Banner banner) {
    bannerService.saveBanner(banner);
    return ResponseEntity.ok().build();
}

동작: 컨트롤러가 정상 반환 → aspect 가 캐시된 요청 본문에서 attachContainer 파싱 → @FiroRef 대상(파라미터 자신 · 중첩 필드 · 컬렉션 요소 포함)을 찾아 FiroService.save(refKey, bag, 기준일시) 호출.

  • 키/일시는 @FiroRefkeyFieldName/dateFieldName(기본 id/createdDt) 또는 필드의 @FiroRefKey/@FiroRefDate(이쪽이 우선). 일시가 null 이면 Instant.now() 로 대체
  • 첨부 저장 실패는 예외로 전파됩니다 (본체는 이미 저장됨 — 클라이언트는 첨부만 재시도)
  • 전제: 모듈 WebConfigurerFiroServletFilter 등록 (요청 본문 캐싱). cms 는 기본 등록됨

엔터티 일시가 반영되는 건 이 경로뿐입니다

REST 확정 저장(POST /api/firo/attach/{refDomain}/{refKey})과 direct 업로드는 엔터티와 무관하게 Instant.now() 로 저장 디렉토리를 정합니다.

다른 모듈에서 쓰기

channel-vue (모바일/최신 웹)

boot/firo-upload-directive.ts 만 이식되어 있습니다(cms 디렉티브의 부분집합). firo-img 등 표시 컴포넌트가 필요하면 cms 에서 복사해 사용하세요.

cms 판과 달리 단일 파일 교체 방식이며, 이벤트도 onFileUpdated / onError 만 발생시킵니다.

일시(createdDt/modifiedDt)는 화면이 소유합니다

과거 이 디렉티브는 마운트 시 model.createdDtmodel.modifiedDt현재 시각으로 덮어썼습니다. 수정 화면에서 불러온 엔터티의 실제 일시가 사라져 그대로 서버로 되돌아가는 문제가 있어 제거했습니다.

따라서 수정 폼에서는 화면이 조회한 엔터티의 일시를 그대로 유지해 전송해야 합니다 — @FiroUpload aspect 가 그 createdDt 로 저장 디렉토리를 정하기 때문입니다.

channel (레거시 MVC · jQuery)

templates/fragments/scripts.html 에서 아래 코드로 전역 초기화되어 있습니다.

javascript
// resources/static/assets-fd/js/tool.upload.js
$('input[type=file].firo').firoUpload('/api/firo/attach/tmp');

따라서 마크업에서 할 일은 class="firo" 를 주고 data-* 속성으로 옵션을 지정하는 것뿐입니다.

html
<form>
  <input type="file" class="firo"
         data-domain="product"
         data-category="main"
         data-max="5"
         data-size-limit="10"
         data-done-callback="onUploadDone" />
  <!-- attachContainer hidden input 은 폼에 자동 생성됩니다 -->
</form>
속성설명
data-domainrefDomain
data-categoryrefCategory (미지정 시 default)
data-max최대 개수 (0 = 무제한)
data-size-limit파일당 최대 크기(MB)
data-filters요청별 서버 필터
data-keeptrue 면 기존 항목 유지(교체하지 않음)
data-change-callback / data-done-callback / data-error-callback콜백 함수명

첨부 건수 조회는 tool.firo.jsGET /api/firo/attach/{domain}/{key}/{category}/_count 를 사용합니다.