Skip to content

트러블슈팅

증상에서 시작해 원인을 찾아가는 순서로 정리했습니다.

먼저 확인할 것

로그 레벨을 올리면 대부분의 원인이 바로 보입니다.

yaml
logging:
  level:
    com.unvus.iflex.core.platform.firo: DEBUG

애플리케이션이 기동되지 않음

Firo 는 설정 오류를 부팅 시점에 즉시 알립니다. 런타임에 조용히 잘못 저장되는 것을 막기 위해서입니다.

선언되지 않은 firo store: 'xxx' (선언된 store: [...])

domains.*.store 또는 default-storestores 에 없는 이름을 가리킵니다. 괄호 안에 실제 선언된 이름이 나오니 오타를 대조하세요.

yaml
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필요한 의존성
s3software.amazon.awssdk:s3
azurecom.azure:azure-storage-blob

core 모듈에 기본 포함돼 있으므로, 이 오류가 난다면 의존성을 제외(exclude)했는지 확인하세요.

s3 store 'x' 에는 bucket 과 region 이 필수입니다

store 타입별 필수값 누락입니다.

type필수값
s3bucket, region
azureconnection-string, container
ftp / sftphost

Firo 가 아직 초기화되지 않았습니다 (FiroBootstrap 이전)

@PostConstruct 안에서 FiroRegistry.getStore() / getAdapter() 를 호출했습니다. Firo 부팅보다 먼저 실행될 수 있는 시점입니다.

해결 — 등록 코드를 FiroRegistrar 빈으로 옮기세요.

java
// ❌ 초기화 순서 보장 안 됨
@PostConstruct
public void setup() { FiroRegistry.add(...); }

// ✅ store 초기화 완료 후 실행됨
@Bean
public FiroRegistrar myFiroRegistrar() {
    return () -> { FiroRegistry.add(...); };
}

빌더의 .store("이름") 은 lazy 참조라 어느 시점에 호출해도 안전합니다.


업로드가 안 됨

파일을 골라도 아무 반응이 없음

  1. Network 탭에 POST /api/firo/attach/tmp 요청이 있는지 확인
    • 요청 자체가 없다 → 컴포넌트/디렉티브가 배선되지 않음. 아래 "혼동" 항목 참고
    • 401 → 인증 문제. api 인스턴스가 아닌 순수 axios 로 호출하면 인증 헤더가 붙지 않습니다
  2. 브라우저 콘솔에 에러가 있는지 확인

컴포넌트와 디렉티브를 섞어 씀

가장 흔한 원인입니다. 둘은 완전히 다른 도구입니다.

vue
<!-- ❌ 동작하지 않음 — 디렉티브는 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 이전에 거부된 것입니다.

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

nginx 등 앞단 프록시가 있다면 client_max_body_size 도 함께 올려야 합니다.

필터가 거부함

HTTP 는 200 이지만 응답의 파일 항목에 error 가 들어옵니다.

json
{ "files": [ { "error": { "code": "size", "message": "File size exceeds ..." } } ] }

<nv-file-upload> 는 이를 @error 이벤트로 넘겨주므로, 핸들러를 붙여 사용자에게 알리세요.

vue
<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 등 다른 필터를 요청별로 쓰려면 먼저 등록하세요.

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

리사이즈처럼 항상 적용해야 하는 처리는 요청 파라미터보다 카테고리 필터 체인으로 고정하는 것이 안전합니다 (→ 설정 가이드).


업로드는 됐는데 저장 후 첨부가 없음

가장 자주 나오는 문제입니다. temp 업로드는 성공했지만 확정 단계가 실행되지 않은 상태입니다.

① 저장 API 에 attachContainer 가 전달되지 않음

업로드 컴포넌트는 v-model 로 받은 객체에 attachContainer 를 심습니다. 그 객체를 그대로 전송해야 합니다.

ts
// ❌ 필드를 골라 보내면 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 가 없음

java
@FiroUpload                       // ← 이게 없으면 첨부는 확정되지 않습니다
@PostMapping("/api/product")
public ResponseEntity<Void> addProduct(@RequestBody Product product) { ... }

등록 API 에만 붙이고 수정 API 에 빠뜨리는 경우가 흔합니다. 둘 다 필요합니다.

firo 첨부 본문을 읽을 수 없어 업로드 커밋을 생략합니다 경고

@FiroUpload aspect 는 요청 본문을 다시 읽어야 하는데, 이를 가능하게 하는 FiroServletFilter 가 해당 모듈에 등록되지 않았습니다.

java
// {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 이고 본문이 이렇다면 부분 실패입니다.

json
{
  "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 로 조회합니다. 비동기로 상세 데이터를 받아온다면 로드 완료 후 렌더링하세요.

vue
<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 로 직접 확인해 보세요.

sql
SELECT attach_ref_domain, attach_ref_category, COUNT(*)
  FROM nv_attach
 WHERE attach_ref_key = 35
 GROUP BY 1, 2;

표시가 안 됨

이미지가 깨져 보임 (404)

  1. 해당 참조에 첨부가 실제로 있는지 DB 확인
  2. URL 의 인덱스 확인 — 참조 기준 URL 의 인덱스는 0부터 입니다 (/attach/view/product/35/main/0 이 첫 번째)
  3. 첨부가 없을 수 있는 화면이면 대체 이미지를 지정하세요
html
<img :src="url" onerror="this.src='/img/no-image.png'" />

403

카테고리의 secureAccessFunc 가 거부했습니다.

null 참조로 인한 오거부

temp / direct 뷰는 DB 레코드가 없어 refDomain·refCategory·savedName 만 채운 합성 객체가 전달됩니다. 함수에서 getCreatedBy() 같은 필드를 그대로 쓰면 NPE 가 나고, 예외는 접근 거부로 처리되어 정상 파일까지 막힙니다. null-safe 하게 작성하세요.

java
.secureAccessFunc((request, firoFile) -> {
    Long owner = firoFile.getCreatedBy();
    if (owner == null) {                       // temp/direct 미리보기
        return SecurityUtils.isAuthenticated();
    }
    return owner.equals(SecurityUtils.getCurrentUserId());
})

POST /api/firo/direct-url404

이 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 중 하나는 반드시 지정해야 합니다
vue
<!-- ✅ 엔터티 객체를 넘기거나 -->
<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 경로에서는 그 엔터티가 처음 만들어진 달의 디렉토리로 들어갑니다(현재 달이 아님). 정상 동작입니다.

값이 없으면 현재 시각

createdDtnull 이거나 필드를 읽을 수 없으면 Instant.now() 로 대체됩니다. 신규 등록에서는 auditing 이 같은 요청 안에서 createdDt 를 현재 시각으로 채우므로 결과적으로 "지금" 이 됩니다 — 차이는 수정 시에만 드러납니다.

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

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

단, 파일을 못 찾게 되지는 않습니다 — 저장 경로는 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 벽시계로 저장된 기존 배포 데이터는 보정이 필요합니다.
sql
UPDATE nv_attach SET attach_created_dt = attach_created_dt - INTERVAL 9 HOUR;

템플릿 개발 DB

iflex 는 템플릿 프로젝트이므로, 개발 DB 는 보정보다 재생성(seed) 을 권장합니다.

저장 디렉토리의 yyyy/MM 만은 사이트 업무 타임존 기준 업무일입니다 (기존 KST 배포 동작을 유지하기 위한 의도된 예외).


그래도 해결되지 않으면

수집해두면 원인 파악이 빨라지는 정보입니다.

  1. logging.level.com.unvus.iflex.core.platform.firo: DEBUG 로그
  2. Network 탭 — POST /api/firo/attach/tmp 응답, 본체 저장 API 의 요청 본문
  3. GET /api/firo/config 결과 (도메인/카테고리/CDN 설정 실제 적용값)
  4. SELECT * FROM nv_attach WHERE attach_ref_domain = '...' AND attach_ref_key = ...
  5. 물리 파일 존재 여부 (ls {base-dir}/{domain}/{yyyy}/{MM}/{refKey}/)