Skip to content

Firo 설정 가이드

Firo 설정은 두 층으로 나뉩니다.

위치담당
선언 설정config/firo.yml저장소 접속 정보, 기본값, 도메인/카테고리 선언
코드 설정FiroRegistrar필터 체인, 접근 제어 등 자바 코드가 필요한 것

아무 설정도 하지 않으면

firo.enabled 는 기본 true, default-storelocal, 저장 경로는 java.io.tmpdir 입니다. 개발 중에는 그대로 동작하지만 재부팅 시 파일이 사라질 수 있으므로firo.directory.base-dir 만큼은 반드시 지정하세요.

firo.yml 전체 구조

yaml
firo:
  enabled: true                       # false 면 Firo 전체 비활성 (REST 엔드포인트·aspect 모두 미등록)
  default-store: local                # store 를 지정하지 않은 도메인/카테고리가 사용할 store

  directory:                          # 글로벌 기본 디렉토리 + 필터링용 로컬 스테이징 위치
    base-dir: /data/attach
    tmp-dir: /data/attach/tmp

  cdn-url:                            # 글로벌 기본 CDN (선택)

  stores:                             # ── 이름 있는 저장소 선언 (N개) ──
    local:
      type: local                     # directory 미지정 → firo.directory 상속

    s3-main:
      type: s3
      bucket: my-bucket
      region: ap-northeast-2
      access-key: ...                 # 생략 시 AWS 기본 자격증명 체인 사용
      secret-key: ...
      role-arn: ...                   # 지정 시 STS AssumeRole
      cdn-url: https://cdn.example.com/

    s3-docs:                          # 같은 타입을 여러 개 선언할 수 있습니다
      type: s3
      bucket: my-docs
      region: ap-northeast-2

    az-media:
      type: azure
      connection-string: ...
      container: media

    backup:
      type: sftp                      # host / port(기본 22) / username / password
      host: files.example.com
      username: firo
      password: ...

  domains:                            # ── 선언적 도메인/카테고리 (선택) ──
    product:
      store: s3-main                  # 미지정 → default-store
      categories:
        main: {}                      # 미지정 → 도메인 설정 상속
        list: { store: s3-docs }
    event:
      store: az-media

store 타입별 필수값

type필수값비고
localdirectory 미지정 시 firo.directory 상속
s3bucket, region자격증명 생략 시 AWS 기본 체인(IAM Role 등)
azureconnection-string, container
ftphostport 기본 21
sftphostport 기본 22

오타는 부팅 시 즉시 실패합니다

domains.*.storedefault-storestores 에 없는 이름을 가리키면 선언되지 않은 firo store: 'xxx'애플리케이션이 기동되지 않습니다. 런타임에 조용히 잘못 저장되는 것보다 낫다는 판단입니다.

상속 규칙 요약

  • 카테고리에 없는 설정은 도메인 → 글로벌 기본값 순으로 조회합니다(런타임 해석 — 설정 변경 즉시 반영).
  • cdnUrl 만 순서가 다릅니다: 카테고리 명시값해석된 store 의 cdn-url도메인 명시값 → 글로벌 cdn-url. 파일이 실제 저장된 store 의 CDN 이 우선해야 URL 과 물리 위치가 어긋나지 않기 때문입니다.
  • 선언하지 않은 도메인/카테고리도 첫 사용 시 기본값으로 자동 생성됩니다. 다만 명시 선언을 권장합니다.

구 스키마 호환

firo.s3.*, firo.local.* 처럼 타입당 블록 하나였던 구 스키마는 부팅 시 같은 이름("s3" 등)의 store 로 자동 병합됩니다(WARN 로그). 신규 프로젝트는 stores 를 사용하세요.

파일명은 설정 대상이 아닙니다

저장 파일명은 항상 {uuid}.{확장자} 로 고정입니다. 과거의 secret(파일명 해시 salt), keep-ext(확장자 유지 여부) 설정은 폐기되었습니다 — 확장자는 언제나 유지되고, 파일명이 예측 불가능한 uuid 라 별도 salt 가 필요 없습니다.

환경별 설정

Spring 프로필로 분리합니다. iflex 템플릿의 config/firo.yml 이 이미 이 형태입니다.

yaml
# 기본 (local 개발)
firo:
  enabled: true
  default-store: local
  directory:
    base-dir: /Applications/data/attach/iflex-dev
    tmp-dir: /Applications/data/attach/iflex-dev/tmp
  stores:
    local: { type: local }

---
spring.config.activate.on-profile: dev

firo:
  directory:
    base-dir: /data/attach
    tmp-dir: /data/attach/tmp

---
spring.config.activate.on-profile: prod

firo:
  default-store: s3-main
  stores:
    s3-main:
      type: s3
      bucket: ${S3_BUCKET}
      region: ${AWS_REGION}
      cdn-url: ${CDN_URL}

운영 환경에서는 자격증명을 yml 에 쓰지 마세요

access-key/secret-key 를 생략하면 AWS 기본 자격증명 체인(EC2/ECS IAM Role, 환경변수 등)을 사용합니다. 컨테이너 환경이라면 IAM Role 사용이 가장 안전합니다.

업로드 용량 관련 Spring 설정

Firo 이전에 서블릿 컨테이너가 먼저 거부할 수 있습니다. 큰 파일을 받을 계획이면 함께 올리세요.

yaml
spring:
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB

초과 시 클라이언트는 413 을 받습니다(디렉티브는 error.http.413 메시지로 통지).

자바 코드 등록 — FiroRegistrar

필터 체인, 접근 제어처럼 코드가 필요한 설정은 FiroRegistrar 빈으로 등록합니다. 부팅이 store 초기화를 마친 뒤 실행하므로 FiroRegistry.getStore(...) 를 안전하게 쓸 수 있습니다.

java
import com.unvus.iflex.core.platform.firo.config.FiroRegistrar;
import com.unvus.iflex.core.platform.firo.module.filter.FiroFilterChain;
import com.unvus.iflex.core.platform.firo.module.filter.impl.AutoFixOrientationImageFilter;
import com.unvus.iflex.core.platform.firo.module.filter.impl.ResizeImageFilter;
import com.unvus.iflex.core.platform.firo.module.service.FiroRegistry;
import com.unvus.iflex.core.platform.firo.module.service.domain.FiroCategory;
import com.unvus.iflex.core.platform.firo.module.service.domain.FiroDomain;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
public class MyFiroConfig {

    @Bean
    public FiroRegistrar productFiroRegistrar() {
        return () -> {
            try {
                // 이미지 처리 체인 — EXIF 회전 보정 후 1920x1080 이내로 축소
                FiroFilterChain imageChain = new FiroFilterChain();
                imageChain.addFilter(new AutoFixOrientationImageFilter());   // 파라미터 없는 필터
                imageChain.addFilter(new ResizeImageFilter(), Map.of(        // 파라미터 있는 필터
                    ResizeImageFilter.PARAM_MAX_WIDTH, 1920,
                    ResizeImageFilter.PARAM_MAX_HEIGHT, 1080
                ));

                FiroDomain product = FiroDomain.builder("product")
                    .store("s3-main")                       // 이름으로 참조 (lazy 해석)
                    .build();

                product.addCategory(FiroCategory.builder(product, "main")
                    .filterChain(imageChain)
                    .build());

                FiroRegistry.add(product);
            } catch (Exception e) {
                throw new IllegalStateException("firo product 도메인 등록 실패", e);
            }
        };
    }
}

try/catch 가 필요한 이유

FiroRegistrar.register() 는 예외를 선언하지 않는데 addFilter(filter, config) 는 checked 예외를 던집니다. 위처럼 감싸거나, 등록 로직을 별도 메서드로 빼고 return this::registerProduct; 형태로 쓰세요. 파라미터가 없는 addFilter(filter) 만 쓴다면 try/catch 는 필요 없습니다.

@PostConstruct 에서 등록하지 마세요

@PostConstruct 는 Firo 부팅 초기화보다 먼저 실행될 수 있어 Firo 가 아직 초기화되지 않았습니다 (FiroBootstrap 이전) 예외가 납니다. 등록 코드는 FiroRegistrar 빈에 넣으세요. (빌더의 .store("이름") 은 lazy 참조라 어느 시점에 호출해도 안전합니다.)

yml 과 자바가 겹치면

같은 refDomain 을 yml 과 자바가 모두 등록하면 자바 등록이 우선합니다(등록 순서와 무관, WARN 로그).

필터 체인

업로드된 파일을 저장 전에 검증·변환합니다. temp 업로드 시점(= 사용자가 파일을 고른 즉시) 실행되므로, 거부되면 사용자가 바로 알 수 있습니다.

내장 필터

필터파라미터동작
FileSizeExceptionFiltermaxSize (MB)초과 시 업로드 거부
FileExtensionExceptionFilterwhitelist, blacklist_type확장자 화이트리스트 / 실제 타입(Tika 판별) 블랙리스트
FixedDimensionExceptionFilterwidth, height이미지 크기가 정확히 일치하지 않으면 거부
ResizeImageFiltermaxWidth, maxHeight, engine(IR4J/SCALR)초과 시 비율 유지 축소
OptimizeImageFiltermaxWidth, maxHeight, maxBytes목표 용량 이하가 될 때까지 품질·크기 단계 축소
AutoFixOrientationImageFilterEXIF 회전 정보 보정 (세로로 찍은 사진이 눕는 문제)

ResizeImageFiltermaxWith

과거 오타 키인 maxWith 도 하위호환으로 계속 인식됩니다. 신규 코드는 maxWidth 를 쓰세요.

적용 방법 ① 카테고리에 고정 (권장)

FiroRegistrar 예제처럼 FiroCategory.builder(...).filterChain(chain) 으로 겁니다. 해당 카테고리로 올라오는 모든 업로드에 적용되므로, 화면에서 빠뜨릴 위험이 없습니다.

java
// 계약서 카테고리 — 50MB 이하, 문서 확장자만 허용
FiroFilterChain docChain = new FiroFilterChain();
docChain.addFilter(new FileSizeExceptionFilter(), Map.of(
    FileSizeExceptionFilter.PARAM_MAX_SIZE, 50));
docChain.addFilter(new FileExtensionExceptionFilter(), Map.of(
    FileExtensionExceptionFilter.PARAM_WHITELIST, List.of("pdf", "doc", "docx", "xlsx")));

FiroDomain contract = FiroDomain.builder("contract").build();
contract.addCategory(FiroCategory.builder(contract, "default").filterChain(docChain).build());
FiroRegistry.add(contract);

확장자 위조 대응

FileExtensionExceptionFilter 의 두 파라미터는 보는 대상이 다릅니다.

파라미터검사 대상값 예시
whitelist파일명의 확장자["pdf", "docx"]
blacklist_typeTika 가 판별한 실제 MIME 타입["application/x-msdownload", "application/x-sh"]

virus.exephoto.jpg 로 바꿔 올리는 경우는 확장자만으로는 못 막으므로 blacklist_type 을 함께 지정하세요. 단, blacklist_type 만 단독으로 지정하면 안 됩니다 — 구현상 whitelist 가 비어 있으면 참조 오류가 발생합니다. 항상 둘을 함께 주세요.

적용 방법 ② 업로드 요청별 지정

프론트에서 filters 파라미터로 지정합니다.

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

미리 등록된 키만 동작합니다

요청의 filters 키는 FiroFilterRegistry 에 등록된 이름이어야 합니다. 등록되지 않은 키는 등록되지 않은 필터 키를 무시합니다 경고만 남기고 무시됩니다.

iflex 가 기본 등록하는 키(core/config/FiroConfig.java)는 다음 둘뿐입니다.

필터
sizeFileSizeExceptionFilter
dimensionFixedDimensionExceptionFilter

다른 필터를 요청별로 쓰려면 먼저 키를 등록하세요.

java
@Bean
public FiroRegistrar myFilterRegistrar() {
    return () -> {
        FiroFilterRegistry.add("resize", new ResizeImageFilter());
        FiroFilterRegistry.add("ext", new FileExtensionExceptionFilter());
    };
}

모든 업로드에 공통 적용

FiroFilterRegistry.addDefaultFilter(filter) 로 등록한 필터는 요청별 filters 지정이 있을 때 항상 함께 적용됩니다(바이러스 검사, 공통 확장자 차단 등에 유용).

접근 제어 — secureAccessFunc

/assets/firo/** 는 인증 필터를 타지 않습니다

성능상 뷰/다운로드 URL 은 시큐리티 필터 체인 밖에 있습니다. 비공개 첨부를 다루는 카테고리에는 반드시 secureAccessFunc 를 설정하세요. 설정하지 않으면 URL 을 아는 사람은 누구나 파일을 받을 수 있습니다.

java
FiroDomain adminDoc = FiroDomain.builder("admin-doc")
    .secureAccessFunc((request, firoFile) -> SecurityUtils.hasAnyAuthority("ROLE_ADMIN"))
    .build();
FiroRegistry.add(adminDoc);
  • 검사 대상: 모든 뷰 경로 — 단건(view/{id}) · 참조 기준 · temp · direct · ZIP 일괄 다운로드
  • 함수가 예외를 던지면 접근 거부(403) 로 처리됩니다

null-safe 하게 작성하세요

temp / direct 뷰는 DB 레코드가 없으므로 refDomain, refCategory, savedName 만 채운 합성 FiroFile 이 전달됩니다. firoFile.getCreatedBy() 같은 필드를 그대로 참조하면 NullPointerException → 접근 거부가 됩니다.

java
.secureAccessFunc((request, firoFile) -> {
    Long owner = firoFile.getCreatedBy();          // temp/direct 뷰에서는 null
    if (owner == null) {
        return SecurityUtils.isAuthenticated();    // 업로드 직후 미리보기 허용
    }
    return owner.equals(SecurityUtils.getCurrentUserId());
})

설정 확인하기

현재 적용된 도메인/카테고리와 CDN URL 은 API 로 확인할 수 있습니다.

bash
curl http://localhost:8080/api/firo/config
json
{
  "cdnUrl": null,
  "domainMap": {
    "product": {
      "refDomain": "product",
      "cdnUrl": "https://cdn.example.com/",
      "categoryMap": {
        "default": { "refCategory": "default", "cdnUrl": "https://cdn.example.com/" },
        "main":    { "refCategory": "main",    "cdnUrl": "https://cdn.example.com/" }
      }
    }
  }
}

데이터베이스

Firo 는 nv_attach 테이블 하나만 사용합니다. iflex 템플릿에는 이미 포함되어 있습니다 (neo-sql/{dbms}/99.etc.sql).

sql
CREATE TABLE nv_attach (
    attach_id           bigint(20)   NOT NULL AUTO_INCREMENT COMMENT '파일ID',
    attach_ref_domain   varchar(100) NOT NULL                COMMENT '참조구분',
    attach_ref_key      bigint(20)   NOT NULL                COMMENT '참조구분키',
    attach_ref_category varchar(100) DEFAULT NULL            COMMENT '참조타입',
    attach_display_name varchar(300) DEFAULT NULL            COMMENT '표시파일명',
    attach_saved_name   varchar(200) DEFAULT NULL            COMMENT '저장파일명',
    attach_saved_dir    varchar(150) DEFAULT NULL            COMMENT '저장경로',
    attach_file_type    varchar(100) DEFAULT NULL            COMMENT '파일타입',
    attach_file_size    decimal(19,0) DEFAULT NULL           COMMENT '파일사이즈',
    attach_deleted      tinyint(1)   DEFAULT NULL            COMMENT '삭제여부',
    attach_ext          longtext     DEFAULT NULL            COMMENT '확장컬럼',
    attach_created_by   bigint(20)   DEFAULT NULL            COMMENT '등록자',
    attach_created_dt   datetime(6)  DEFAULT NULL            COMMENT '등록일시',
    attach_sort         int          NOT NULL DEFAULT 0      COMMENT '순서',
    CONSTRAINT nv_attach_pk PRIMARY KEY (attach_id)
) COMMENT '첨부파일';

조회 성능

첨부 조회는 항상 (attach_ref_domain, attach_ref_key, attach_ref_category) 로 이뤄집니다. 데이터가 많아지면 이 세 컬럼의 복합 인덱스를 추가하세요.

날짜는 UTC 로 저장됩니다

attach_created_dt 는 무타임존 컬럼이지만 내용물은 UTC 입니다(프로젝트 전체 규약). 과거 KST 벽시계로 저장된 데이터를 이관한다면 -9시간 보정이 필요합니다.