Firo 설정 가이드
Firo 설정은 두 층으로 나뉩니다.
| 층 | 위치 | 담당 |
|---|---|---|
| 선언 설정 | config/firo.yml | 저장소 접속 정보, 기본값, 도메인/카테고리 선언 |
| 코드 설정 | FiroRegistrar 빈 | 필터 체인, 접근 제어 등 자바 코드가 필요한 것 |
아무 설정도 하지 않으면
firo.enabled 는 기본 true, default-store 는 local, 저장 경로는 java.io.tmpdir 입니다. 개발 중에는 그대로 동작하지만 재부팅 시 파일이 사라질 수 있으므로firo.directory.base-dir 만큼은 반드시 지정하세요.
firo.yml 전체 구조
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-mediastore 타입별 필수값
| type | 필수값 | 비고 |
|---|---|---|
local | — | directory 미지정 시 firo.directory 상속 |
s3 | bucket, region | 자격증명 생략 시 AWS 기본 체인(IAM Role 등) |
azure | connection-string, container | |
ftp | host | port 기본 21 |
sftp | host | port 기본 22 |
오타는 부팅 시 즉시 실패합니다
domains.*.store 나 default-store 가 stores 에 없는 이름을 가리키면 선언되지 않은 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 이 이미 이 형태입니다.
# 기본 (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 이전에 서블릿 컨테이너가 먼저 거부할 수 있습니다. 큰 파일을 받을 계획이면 함께 올리세요.
spring:
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB초과 시 클라이언트는 413 을 받습니다(디렉티브는 error.http.413 메시지로 통지).
자바 코드 등록 — FiroRegistrar
필터 체인, 접근 제어처럼 코드가 필요한 설정은 FiroRegistrar 빈으로 등록합니다. 부팅이 store 초기화를 마친 뒤 실행하므로 FiroRegistry.getStore(...) 를 안전하게 쓸 수 있습니다.
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 업로드 시점(= 사용자가 파일을 고른 즉시) 실행되므로, 거부되면 사용자가 바로 알 수 있습니다.
내장 필터
| 필터 | 파라미터 | 동작 |
|---|---|---|
FileSizeExceptionFilter | maxSize (MB) | 초과 시 업로드 거부 |
FileExtensionExceptionFilter | whitelist, blacklist_type | 확장자 화이트리스트 / 실제 타입(Tika 판별) 블랙리스트 |
FixedDimensionExceptionFilter | width, height | 이미지 크기가 정확히 일치하지 않으면 거부 |
ResizeImageFilter | maxWidth, maxHeight, engine(IR4J/SCALR) | 초과 시 비율 유지 축소 |
OptimizeImageFilter | maxWidth, maxHeight, maxBytes | 목표 용량 이하가 될 때까지 품질·크기 단계 축소 |
AutoFixOrientationImageFilter | — | EXIF 회전 정보 보정 (세로로 찍은 사진이 눕는 문제) |
ResizeImageFilter 의 maxWith
과거 오타 키인 maxWith 도 하위호환으로 계속 인식됩니다. 신규 코드는 maxWidth 를 쓰세요.
적용 방법 ① 카테고리에 고정 (권장)
위 FiroRegistrar 예제처럼 FiroCategory.builder(...).filterChain(chain) 으로 겁니다. 해당 카테고리로 올라오는 모든 업로드에 적용되므로, 화면에서 빠뜨릴 위험이 없습니다.
// 계약서 카테고리 — 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_type | Tika 가 판별한 실제 MIME 타입 | ["application/x-msdownload", "application/x-sh"] |
virus.exe 를 photo.jpg 로 바꿔 올리는 경우는 확장자만으로는 못 막으므로 blacklist_type 을 함께 지정하세요. 단, blacklist_type 만 단독으로 지정하면 안 됩니다 — 구현상 whitelist 가 비어 있으면 참조 오류가 발생합니다. 항상 둘을 함께 주세요.
적용 방법 ② 업로드 요청별 지정
프론트에서 filters 파라미터로 지정합니다.
<nv-file-upload v-model="model" ref-domain="product" :filters="{ size: { maxSize: 5 } }" />미리 등록된 키만 동작합니다
요청의 filters 키는 FiroFilterRegistry 에 등록된 이름이어야 합니다. 등록되지 않은 키는 등록되지 않은 필터 키를 무시합니다 경고만 남기고 무시됩니다.
iflex 가 기본 등록하는 키(core/config/FiroConfig.java)는 다음 둘뿐입니다.
| 키 | 필터 |
|---|---|
size | FileSizeExceptionFilter |
dimension | FixedDimensionExceptionFilter |
다른 필터를 요청별로 쓰려면 먼저 키를 등록하세요.
@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 을 아는 사람은 누구나 파일을 받을 수 있습니다.
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 → 접근 거부가 됩니다.
.secureAccessFunc((request, firoFile) -> {
Long owner = firoFile.getCreatedBy(); // temp/direct 뷰에서는 null
if (owner == null) {
return SecurityUtils.isAuthenticated(); // 업로드 직후 미리보기 허용
}
return owner.equals(SecurityUtils.getCurrentUserId());
})설정 확인하기
현재 적용된 도메인/카테고리와 CDN URL 은 API 로 확인할 수 있습니다.
curl http://localhost:8080/api/firo/config{
"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).
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시간 보정이 필요합니다.