Skip to content

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 이 됩니다.

java
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 는 이 두 필드를 봅니다.

필요한 값기본으로 보는 필드용도
refKeyid첨부와 엔터티를 연결하는 키
기준 일시createdDt저장 디렉토리의 yyyy/MM 결정

필드명이 다르다면

id/createdDt 가 아닌 이름을 쓴다면 두 가지 방법 중 하나로 알려줍니다.

java
// 방법 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 를 현재 시각으로 채우므로 결과적으로 "지금" 이 됩니다. 값이 의미를 갖는 것은 수정할 때 — 오래된 엔터티에 파일을 추가하면 그 엔터티가 처음 만들어진 달의 디렉토리로 들어갑니다(한 엔터티의 첨부를 한곳에 모으려는 의도).

기준 일시로 "수정 일시" 를 쓰지 마세요

dateFieldNamemodifiedDt 를 지정하면 수정할 때마다 기준이 바뀌어, 한 엔터티의 첨부가 여러 달 디렉토리로 흩어집니다. 반드시 생성 일시를 쓰세요.

다만 파일을 못 찾게 되지는 않습니다 — 저장 경로는 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 를 읽어 첨부를 확정합니다.

java
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();
    }
}

동작 순서는 다음과 같습니다.

  1. 컨트롤러 메서드 실행 → 상품 저장 → product.id 채번
  2. aspect 가 캐시된 요청 본문에서 attachContainer 파싱
  3. @FiroRef 가 붙은 대상(파라미터 자신 · 중첩 필드 · 컬렉션 요소까지 탐색)을 찾아 refKey/기준 일시 를 꺼냄
  4. temp 파일을 최종 경로로 확정 + nv_attach 레코드 생성

전제 조건 — FiroServletFilter

@FiroUpload요청 본문을 두 번 읽어야 합니다(컨트롤러가 한 번, aspect 가 한 번). 이를 위해 모듈의 WebConfigurerFiroServletFilter 가 등록돼 있어야 합니다. cms 모듈은 기본 등록되어 있으므로 추가 작업이 필요 없습니다.

등록되지 않으면 예외 대신 조용히 첨부 커밋만 생략되고 다음 경고가 남습니다. firo 첨부 본문을 읽을 수 없어 업로드 커밋을 생략합니다

첨부 저장 실패는 예외로 전파됩니다

본체는 이미 저장된 뒤 첨부만 실패한 경우, Firo 는 오류를 삼키지 않고 전파합니다. 성공한 첨부는 그대로 남으므로 클라이언트는 실패한 첨부만 재시도하면 됩니다.

3. (선택) 저장소 · 카테고리 설정

기본 설정(로컬 디스크 · default 카테고리)으로 충분하면 이 단계는 건너뜁니다. 등록하지 않은 도메인/카테고리도 첫 사용 시 기본값으로 자동 생성됩니다.

S3 에 저장하거나 이미지 리사이즈를 걸고 싶을 때만 설정합니다.

yaml
# 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" 같은 propsv-firo-upload="{ domain: 'product' }" 바인딩 객체
옵션 이름refDomain / refCategory / refKeydomain / category / key
언제대부분의 경우 이쪽디자인이 완전히 다른 커스텀 업로더가 필요할 때

방법 A — <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>>({});   // 폼 모델 — 저장 API 에 그대로 전송할 객체
</script>

이게 전부입니다. 별도 import 도 필요 없습니다(컴포넌트 자동 등록).

Prop의미
v-model (필수)폼 모델 객체. 컴포넌트가 여기에 attachContainer 속성을 자동으로 만들어 넣습니다
ref-domain (필수)@FiroRef("product") 의 값과 동일하게
ref-category미지정 시 default
ref-key있으면 수정 모드 — 서버에서 기존 첨부를 불러와 목록에 표시합니다. 신규 등록이면 null
max-count최대 개수 (0 = 무제한)
accept<input accept>

그리고 폼을 저장할 때 모델을 통째로 보내면 됩니다.

ts
// model 안에 attachContainer 가 들어 있으므로 별도 처리 불필요
await api.post('/api/product', model.value);

신규 등록 화면인데 ref-key 는?

model.idnull 이면 컴포넌트는 기존 첨부를 조회하지 않고 빈 목록으로 시작합니다. 저장 후 화면을 다시 로드하면 채번된 id 로 첨부가 조회됩니다.

방법 B — v-firo-upload 디렉티브

업로드 UI 를 직접 만들어야 할 때 사용합니다. 디렉티브는 파일 선택 → temp 업로드 → attachContainer 적재까지만 담당하고, 미리보기·목록·삭제 UI 는 직접 그립니다.

vue
<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. 저장된 파일 표시하기

첨부가 확정된 뒤에는 다음 방법으로 표시합니다.

html
<!-- 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" />
html
<!-- 목록 API 로 받은 첨부의 id 를 아는 경우 -->
<img src="/assets/firo/attach/view/1024?w=300" />

<!-- 다운로드 (원본 파일명으로 저장됨) -->
<a href="/assets/firo/attach/download/1024">내려받기</a>
vue
<!-- 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

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

③ 물리 파일

bash
# 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 가 서버로 전달되지 않았을 가능성이 큽니다.

  1. v-model(또는 디렉티브의 model)에 준 객체가 실제로 저장 API 에 보내는 객체와 같은지 확인 — api.post('/api/product', { name: form.name }) 처럼 필드를 골라 보내면 attachContainer 가 빠집니다.
  2. 저장 API 에 @FiroUpload 가 붙어 있는지 확인
  3. 서버 로그에 firo 첨부 본문을 읽을 수 없어… 경고가 있는지 확인 → FiroServletFilter 미등록
수정 화면에서 기존 첨부가 안 보여요

ref-key(디렉티브는 key)에 엔터티 PK 를 넘겼는지 확인하세요. 비어 있으면 신규 등록으로 간주해 기존 첨부를 조회하지 않습니다. 화면 로딩 순서상 model.id 가 나중에 채워진다면, 데이터 로드 완료 후 컴포넌트를 렌더링하도록 v-if 를 걸어주세요.

카테고리를 여러 개 쓰고 싶어요

<nv-file-upload> 를 카테고리 수만큼 배치하되 v-model 은 같은 모델을 공유하면 됩니다. attachContainer 안에서 카테고리별로 분리 저장되며, 저장 시 한 번에 전송됩니다.

vue
<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/*" />

다음 단계