트러블슈팅
증상에서 시작해 원인을 찾아가는 순서로 정리했습니다.
먼저 확인할 것
로그 레벨을 올리면 대부분의 원인이 바로 보입니다.
logging:
level:
com.unvus.iflex.core.platform.firo: DEBUG애플리케이션이 기동되지 않음
Firo 는 설정 오류를 부팅 시점에 즉시 알립니다. 런타임에 조용히 잘못 저장되는 것을 막기 위해서입니다.
선언되지 않은 firo store: 'xxx' (선언된 store: [...])
domains.*.store 또는 default-store 가 stores 에 없는 이름을 가리킵니다. 괄호 안에 실제 선언된 이름이 나오니 오타를 대조하세요.
firo:
default-store: s3-main # ← 여기와
stores:
s3main: { type: s3, ... } # ← 여기의 이름이 다름 (하이픈 누락)firo.stores.xxx 에 type 이 없습니다 (local|ftp|sftp|s3|azure)
store 선언에 type 이 빠졌습니다. 모든 store 는 타입을 명시해야 합니다.
store 'x' 의 type S3 을 지원하는 어댑터 팩토리가 없습니다 (SDK 의존성 확인)
해당 저장소 SDK 가 클래스패스에 없습니다.
| type | 필요한 의존성 |
|---|---|
s3 | software.amazon.awssdk:s3 |
azure | com.azure:azure-storage-blob |
core 모듈에 기본 포함돼 있으므로, 이 오류가 난다면 의존성을 제외(exclude)했는지 확인하세요.
s3 store 'x' 에는 bucket 과 region 이 필수입니다
store 타입별 필수값 누락입니다.
| type | 필수값 |
|---|---|
s3 | bucket, region |
azure | connection-string, container |
ftp / sftp | host |
Firo 가 아직 초기화되지 않았습니다 (FiroBootstrap 이전)
@PostConstruct 안에서 FiroRegistry.getStore() / getAdapter() 를 호출했습니다. Firo 부팅보다 먼저 실행될 수 있는 시점입니다.
해결 — 등록 코드를 FiroRegistrar 빈으로 옮기세요.
// ❌ 초기화 순서 보장 안 됨
@PostConstruct
public void setup() { FiroRegistry.add(...); }
// ✅ store 초기화 완료 후 실행됨
@Bean
public FiroRegistrar myFiroRegistrar() {
return () -> { FiroRegistry.add(...); };
}빌더의 .store("이름") 은 lazy 참조라 어느 시점에 호출해도 안전합니다.
업로드가 안 됨
파일을 골라도 아무 반응이 없음
- Network 탭에
POST /api/firo/attach/tmp요청이 있는지 확인- 요청 자체가 없다 → 컴포넌트/디렉티브가 배선되지 않음. 아래 "혼동" 항목 참고
401→ 인증 문제.api인스턴스가 아닌 순수axios로 호출하면 인증 헤더가 붙지 않습니다
- 브라우저 콘솔에 에러가 있는지 확인
컴포넌트와 디렉티브를 섞어 씀
가장 흔한 원인입니다. 둘은 완전히 다른 도구입니다.
<!-- ❌ 동작하지 않음 — 디렉티브는 input[type=file] 용입니다 -->
<div v-firo-upload="{ model: form, domain: 'product' }">
<nv-file-upload />
</div>
<!-- ✅ 컴포넌트만 쓰기 (props 이름은 ref-*) -->
<nv-file-upload v-model="form" ref-domain="product" ref-category="main" :ref-key="form.id" />
<!-- ✅ 디렉티브만 쓰기 (바인딩 키는 domain/category/key) -->
<input type="file" v-firo-upload="{ model: form, domain: 'product', category: 'main', key: form.id }" />옵션 이름도 다릅니다 — 컴포넌트는 ref-domain/ref-category/ref-key, 디렉티브는 domain/category/key 입니다. 자세한 비교는 프론트엔드 연동 을 보세요.
413 오류
요청 크기가 서블릿 한도를 넘었습니다. Firo 이전에 거부된 것입니다.
spring:
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MBnginx 등 앞단 프록시가 있다면 client_max_body_size 도 함께 올려야 합니다.
필터가 거부함
HTTP 는 200 이지만 응답의 파일 항목에 error 가 들어옵니다.
{ "files": [ { "error": { "code": "size", "message": "File size exceeds ..." } } ] }<nv-file-upload> 는 이를 @error 이벤트로 넘겨주므로, 핸들러를 붙여 사용자에게 알리세요.
<nv-file-upload ... @error="err => ElMessage.error(err?.message)" />브라우저 단 검사와 서버 단 검사
max-file-size-mb prop 은 브라우저에서 미리 걸러 서버 왕복을 아낍니다(code: 'max-file-size'). 서버 필터는 그와 별개로 항상 동작합니다.
filters 로 지정한 필터가 적용되지 않음
로그에 등록되지 않은 필터 키를 무시합니다: xxx 가 있는지 확인하세요.
요청의 filters 키는 미리 등록된 이름이어야 합니다. iflex 기본 등록 키는 size, dimension둘 뿐입니다. resize 등 다른 필터를 요청별로 쓰려면 먼저 등록하세요.
@Bean
public FiroRegistrar myFilterRegistrar() {
return () -> FiroFilterRegistry.add("resize", new ResizeImageFilter());
}리사이즈처럼 항상 적용해야 하는 처리는 요청 파라미터보다 카테고리 필터 체인으로 고정하는 것이 안전합니다 (→ 설정 가이드).
업로드는 됐는데 저장 후 첨부가 없음
가장 자주 나오는 문제입니다. temp 업로드는 성공했지만 확정 단계가 실행되지 않은 상태입니다.
① 저장 API 에 attachContainer 가 전달되지 않음
업로드 컴포넌트는 v-model 로 받은 객체에 attachContainer 를 심습니다. 그 객체를 그대로 전송해야 합니다.
// ❌ 필드를 골라 보내면 attachContainer 가 빠집니다
await api.post('/api/product', { name: model.value.name, price: model.value.price });
// ✅ 모델을 통째로
await api.post('/api/product', model.value);Network 탭에서 요청 본문에 attachContainer 가 있는지 직접 확인하세요.
② 저장 API 에 @FiroUpload 가 없음
@FiroUpload // ← 이게 없으면 첨부는 확정되지 않습니다
@PostMapping("/api/product")
public ResponseEntity<Void> addProduct(@RequestBody Product product) { ... }등록 API 에만 붙이고 수정 API 에 빠뜨리는 경우가 흔합니다. 둘 다 필요합니다.
③ firo 첨부 본문을 읽을 수 없어 업로드 커밋을 생략합니다 경고
@FiroUpload aspect 는 요청 본문을 다시 읽어야 하는데, 이를 가능하게 하는 FiroServletFilter 가 해당 모듈에 등록되지 않았습니다.
// {module}/config/WebConfigurer.java
@Bean
public FilterRegistrationBean firoServletFilter() {
FilterRegistrationBean bean = new FilterRegistrationBean();
bean.setFilter(new FiroServletFilter());
bean.setUrlPatterns(List.of("", "/", "/api/*", "/pages/*"));
bean.setOrder(1000);
bean.setAsyncSupported(true);
return bean;
}cms 모듈은 기본 등록되어 있습니다. 새 모듈을 만들었다면 추가하세요.
URL 패턴 확인
저장 API 경로가 필터의 urlPatterns 에 포함되어야 합니다. /api/* 밖의 경로에 저장 API 를 두었다면 패턴을 추가하세요.
④ 엔터티에 @FiroRef 가 없음
첨부를 붙일 대상을 못 찾습니다. @RequestBody 로 받는 클래스에 어노테이션이 있는지 확인하세요.
저장은 됐지만 일부만 실패
응답이 500 이고 본문이 이렇다면 부분 실패입니다.
{
"success": false,
"code": "attach-save-failure",
"message": "첨부 저장 실패 1건 (성공 2건)",
"failures": [ { "refCategory": "main", "index": 2, "displayName": "broken.png", "reason": "..." } ]
}- 성공한 첨부는 이미 저장돼 있습니다. 전체를 재전송하면 중복 저장됩니다.
failures에 나온 항목만 다시 업로드하세요.reason에 실제 원인(디스크 공간, 권한, 원격 저장소 오류 등)이 들어 있습니다.
수정 화면에서 기존 첨부가 안 보임
ref-key 가 비어 있음
ref-key(디렉티브는 key)가 null/undefined 면 신규 등록으로 간주해 조회하지 않습니다.
데이터 로드보다 컴포넌트가 먼저 마운트됨
<nv-file-upload> 는 onMounted 시점의 ref-key 로 조회합니다. 비동기로 상세 데이터를 받아온다면 로드 완료 후 렌더링하세요.
<nv-file-upload v-if="loaded" v-model="model" ref-domain="product" :ref-key="model.id" />디렉티브는 updated 훅이 있습니다
v-firo-upload 는 바인딩 값이 바뀌면 자동 반영되지만, 기존 첨부 로드는 mount 시 1회입니다. 마운트 후에 key 가 채워지는 구조라면 컴포넌트와 마찬가지로 v-if 를 쓰세요.
도메인/카테고리 이름이 다름
업로드할 때와 조회할 때의 refDomain/refCategory 철자가 정확히 같아야 합니다. DB 로 직접 확인해 보세요.
SELECT attach_ref_domain, attach_ref_category, COUNT(*)
FROM nv_attach
WHERE attach_ref_key = 35
GROUP BY 1, 2;표시가 안 됨
이미지가 깨져 보임 (404)
- 해당 참조에 첨부가 실제로 있는지 DB 확인
- URL 의 인덱스 확인 — 참조 기준 URL 의 인덱스는 0부터 입니다 (
/attach/view/product/35/main/0이 첫 번째) - 첨부가 없을 수 있는 화면이면 대체 이미지를 지정하세요
<img :src="url" onerror="this.src='/img/no-image.png'" />403
카테고리의 secureAccessFunc 가 거부했습니다.
null 참조로 인한 오거부
temp / direct 뷰는 DB 레코드가 없어 refDomain·refCategory·savedName 만 채운 합성 객체가 전달됩니다. 함수에서 getCreatedBy() 같은 필드를 그대로 쓰면 NPE 가 나고, 예외는 접근 거부로 처리되어 정상 파일까지 막힙니다. null-safe 하게 작성하세요.
.secureAccessFunc((request, firoFile) -> {
Long owner = firoFile.getCreatedBy();
if (owner == null) { // temp/direct 미리보기
return SecurityUtils.isAuthenticated();
}
return owner.equals(SecurityUtils.getCurrentUserId());
})POST /api/firo/direct-url 이 404
이 API 는 조회형입니다 — 저장된 첨부만 URL 이 존재합니다.
| 확인할 것 |
|---|
해당 refDomain/refKey/refCategory 에 첨부가 실제로 저장돼 있는가 |
index 가 첨부 개수 범위 안인가 (0부터) |
| 아직 폼을 저장하지 않은(temp) 파일은 아닌가 |
업로드 직후에는 temp 미리보기 URL(/assets/firo/attach-temp/view/...)을 쓰고, 저장 후 재조회 시점에 direct URL 이 부여됩니다. "프론트 URL 복사" 버튼도 저장 후에 나타납니다.
firo-img / firo-video 가 아무것도 표시하지 않음
참조 키를 못 찾았을 가능성이 큽니다. ref-key 또는 firo-model 중 하나는 필수이며, 둘 다 없으면 콘솔에 다음이 찍히고 조회를 건너뜁니다.
firo-img: refKey 또는 firoModel 중 하나는 반드시 지정해야 합니다<!-- ✅ 엔터티 객체를 넘기거나 -->
<firo-img ref-domain="bannerItem" ref-category="image" :firo-model="item" />
<!-- ✅ 키를 직접 넘기거나 -->
<firo-img ref-domain="bannerItem" ref-category="image" :ref-key="item.id" />단순 표시라면 <img src="/assets/firo/attach/view/..."> 가 더 간단하고 가볍습니다.
예전 버전에서 목록이 항상 비어 있던 문제
Options API → Composition API 전환 과정에서 조회 결과를 반응형 bag 에 대입하는 코드가 누락돼, firo-img 가 항상 빈 목록을 렌더링하던 시기가 있었습니다(로컬 변수가 ref 를 가림). 현재는 수정되었습니다. 화면이 계속 비어 있다면 프론트엔드 빌드가 최신인지 확인하세요.
이미지가 인라인 표시되지 않고 다운로드로 열림
Content-Type 문제입니다. Firo 의 표준 흐름(업로드 시 지정, 확정 copy 시 재지정)은 자동 처리하지만, 외부에서 저장소에 직접 넣은 객체는 메타데이터를 직접 지정해야 합니다.
- S3: 객체의
Content-Type메타데이터 확인 - Azure: Blob 의 Content-Type 헤더 확인
?w= / ?h= 리사이즈가 동작하지 않음
| 상황 | 이유 |
|---|---|
temp / direct 뷰 (attach-temp, attach-direct) | attach 메타(id·savedDir)가 없어 캐시본을 만들 수 없음 → 원본 반환 |
| 원본이 요청 크기보다 작음 | 확대하지 않음 (원본 반환) |
| 이미지가 아님 | 그대로 반환 |
정식 저장 후 /assets/firo/attach/view/{id}?w=300 을 사용하세요.
순서가 의도와 다름
- 표시 순서는
nv_attach.attach_sort가 결정하며, 폼 저장 시 배열 순서로 확정됩니다. - 화면에서 순서만 바꾸고 저장하지 않으면 반영되지 않습니다.
<nv-file-upload>의reverse-order는 화면 표시만 뒤집습니다. 저장 순서는 그대로입니다.- 정렬 UI 가 필요하면
sortable을 켜세요.
파일이 사라짐 / 예상과 다른 위치에 있음
저장 경로 규칙
일반 업로드: {base-dir}/{refDomain}/{yyyy}/{MM}/{refKey}/{uuid}.{ext}
direct 업로드: {base-dir}/{refDomain}/{yyyy}/{MM}/_/{refCategory}/{uuid}.{ext}예상한 달(月) 디렉토리에 없음
yyyy/MM 이 무엇으로 정해지는지는 어느 경로로 저장했느냐에 따라 다릅니다.
| 저장 경로 | yyyy/MM 기준 |
|---|---|
@FiroUpload aspect (본체 저장 API) | 엔터티의 createdDt (또는 @FiroRefDate / dateFieldName 필드) |
POST /api/firo/attach/{refDomain}/{refKey} | Instant.now() — 엔터티 일시 무시 |
POST /api/firo/attach/direct · 에디터 이미지 | Instant.now() |
따라서 오래된 엔터티를 수정하며 파일을 추가하면, aspect 경로에서는 그 엔터티가 처음 만들어진 달의 디렉토리로 들어갑니다(현재 달이 아님). 정상 동작입니다.
값이 없으면 현재 시각
createdDt 가 null 이거나 필드를 읽을 수 없으면 Instant.now() 로 대체됩니다. 신규 등록에서는 auditing 이 같은 요청 안에서 createdDt 를 현재 시각으로 채우므로 결과적으로 "지금" 이 됩니다 — 차이는 수정 시에만 드러납니다.
기준 일시로 수정 일시를 쓰지 마세요
dateFieldName 에 modifiedDt 를 지정하면 수정할 때마다 기준이 바뀌어 한 엔터티의 첨부가 여러 달 디렉토리로 흩어집니다. 생성 일시를 사용하세요.
단, 파일을 못 찾게 되지는 않습니다 — 저장 경로는 nv_attach.attach_saved_dir 에 기록되고 조회·삭제는 항상 그 값을 씁니다(경로를 날짜로 재계산하는 코드는 없습니다).
기본 설정 그대로 두면 임시 디렉토리에 저장됩니다
firo.directory.base-dir 을 지정하지 않으면 java.io.tmpdir 이 기본값입니다. OS 나 컨테이너 재시작 시 삭제될 수 있으니 반드시 명시하세요.
삭제 API 를 잘못 호출
| 메서드/엔드포인트 | DB | 물리 파일 |
|---|---|---|
FiroService.deleteByRef(...) | 삭제 | 남음 |
FiroService.clearAttachByDomain(...) | 삭제 | 삭제 |
POST /api/firo/attach (body: 목록) | 삭제 | 삭제 |
확정 저장 body 의 _deleted | 삭제 | 삭제 |
copyAttach 는 파일을 공유합니다
copyAttach 로 복제한 레코드는 같은 물리 파일을 가리킵니다. 원본을 물리 삭제하면 복사본도 깨집니다.
날짜/시간이 9시간 어긋남
Firo 는 프로젝트 표준에 따라 UTC 로 저장·전송합니다(Instant, 와이어는 ISO-8601 ...Z).
nv_attach.attach_created_dt는 무타임존 컬럼이지만 내용물은 UTC 입니다.- 표준 이관 이전에 KST 벽시계로 저장된 기존 배포 데이터는 보정이 필요합니다.
UPDATE nv_attach SET attach_created_dt = attach_created_dt - INTERVAL 9 HOUR;템플릿 개발 DB
iflex 는 템플릿 프로젝트이므로, 개발 DB 는 보정보다 재생성(seed) 을 권장합니다.
저장 디렉토리의 yyyy/MM 만은 사이트 업무 타임존 기준 업무일입니다 (기존 KST 배포 동작을 유지하기 위한 의도된 예외).
그래도 해결되지 않으면
수집해두면 원인 파악이 빨라지는 정보입니다.
logging.level.com.unvus.iflex.core.platform.firo: DEBUG로그- Network 탭 —
POST /api/firo/attach/tmp응답, 본체 저장 API 의 요청 본문 GET /api/firo/config결과 (도메인/카테고리/CDN 설정 실제 적용값)SELECT * FROM nv_attach WHERE attach_ref_domain = '...' AND attach_ref_key = ...- 물리 파일 존재 여부 (
ls {base-dir}/{domain}/{yyyy}/{MM}/{refKey}/)