Skip to content

Firo 예제 코드 모음

실전 예제 코드 모음입니다. 복사해서 바로 사용할 수 있도록 완성된 코드를 제공합니다.

📁 프로젝트 구조 예시

src/
├── main/
│   ├── java/
│   │   └── com/example/demo/
│   │       ├── DemoApplication.java
│   │       ├── config/
│   │       │   └── FiroConfiguration.java
│   │       ├── controller/
│   │       │   ├── FileController.java
│   │       │   └── UserController.java
│   │       ├── service/
│   │       │   ├── FileService.java
│   │       │   └── UserService.java
│   │       └── entity/
│   │           └── User.java
│   └── resources/
│       └── application.yml
└── test/
    └── java/
        └── com/example/demo/
            └── FileServiceTest.java

🔧 기본 설정

application.yml

yaml
# 기본 개발 환경 설정
spring:
  application:
    name: demo-firo-app
  
  # 데이터베이스 설정 (H2 개발용)
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: ""
  
  # JPA 설정
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  
  # H2 콘솔 활성화
  h2:
    console:
      enabled: true
  
  # 파일 업로드 설정
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
  
  # 프로필 설정
  profiles:
    active: local

# Firo 설정
firo:
  enabled: true
  db:
    type: jpa
  directory:
    base-dir: ./uploads/
    tmp-dir: ./uploads/temp/
  cdn-url: http://localhost:8080/
  api-prefix: /api/firo

# 로깅 설정
logging:
  level:
    com.unvus.firo: DEBUG
    com.example.demo: DEBUG

🏗️ 핵심 컴포넌트

1. Firo 설정 클래스

java
package com.example.demo.config;

import com.unvus.firo.core.module.service.FiroRegistry;
import com.unvus.firo.core.module.service.domain.FiroDomain;
import com.unvus.firo.core.module.service.domain.FiroCategory;
import com.unvus.firo.core.module.filter.FiroFilterChain;
import com.unvus.firo.core.module.filter.impl.*;
import org.springframework.context.annotation.Configuration;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.PostConstruct;
import java.util.Set;

@Slf4j
@Configuration
public class FiroConfiguration {

    @PostConstruct
    public void setupFiroDomains() {
        // 1. 사용자 프로필 도메인 설정
        setupUserDomain();
        
        // 2. 게시글 도메인 설정
        setupPostDomain();
        
        // 3. 제품 도메인 설정
        setupProductDomain();
        
        log.info("✅ Firo 도메인 설정이 완료되었습니다.");
    }
    
    private void setupUserDomain() {
        FiroDomain userDomain = FiroDomain.builder("user").build();
        
        // 프로필 이미지 카테고리 (리사이징 + 회전 보정)
        userDomain.addCategory("profile");
        userDomain.addCategory("avatar");
        userDomain.addCategory("document");
        
        FiroRegistry.add(userDomain);
    }
    
    private void setupPostDomain() {
        FiroDomain postDomain = FiroDomain.builder("post").build();
        
        // 게시글 이미지 카테고리
        FiroCategory imageCategory = FiroCategory.builder(postDomain, "image")
            .filterChain(FiroFilterChain.builder()
                .add(new FileSizeExceptionFilter(10 * 1024 * 1024)) // 10MB 제한
                .add(new FileExtensionExceptionFilter(Set.of("jpg", "jpeg", "png", "gif", "webp")))
                .add(new AutoFixOrientationImageFilter())
                .add(new ResizeImageFilter(1200, 800)) // 최대 1200x800
                .build())
            .keepExt(true)
            .build();
        
        // 첨부파일 카테고리
        FiroCategory attachmentCategory = FiroCategory.builder(postDomain, "attachment")
            .filterChain(FiroFilterChain.builder()
                .add(new FileSizeExceptionFilter(50 * 1024 * 1024)) // 50MB 제한
                .add(new FileExtensionExceptionFilter(Set.of("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx")))
                .build())
            .keepExt(true)
            .build();
        
        postDomain.addCategory(imageCategory);
        postDomain.addCategory(attachmentCategory);
        FiroRegistry.add(postDomain);
    }
    
    private void setupProductDomain() {
        FiroDomain productDomain = FiroDomain.builder("product").build();
        
        // 제품 이미지 카테고리 (다양한 크기로 리사이징)
        FiroCategory productImageCategory = FiroCategory.builder(productDomain, "image")
            .filterChain(FiroFilterChain.builder()
                .add(new FileSizeExceptionFilter(15 * 1024 * 1024)) // 15MB 제한
                .add(new FileExtensionExceptionFilter(Set.of("jpg", "jpeg", "png", "webp")))
                .add(new AutoFixOrientationImageFilter())
                .add(new ResizeImageFilter(800, 600)) // 상품 이미지 표준 크기
                .build())
            .keepExt(true)
            .build();
        
        productDomain.addCategory(productImageCategory);
        FiroRegistry.add(productDomain);
    }
}

2. 파일 서비스 클래스

java
package com.example.demo.service;

import com.unvus.firo.core.module.service.FiroService;
import com.unvus.firo.core.module.service.FiroRegistry;
import com.unvus.firo.core.module.service.domain.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Slf4j
@Service
public class FileService {

    @Autowired
    private FiroService firoService;

    /**
     * 사용자 프로필 이미지 업로드
     * 
     * @param userId 사용자 ID
     * @param file 업로드할 파일
     * @return 업로드된 파일의 URL
     */
    public String uploadUserProfile(Long userId, MultipartFile file) {
        try {
            log.info("사용자 {}의 프로필 이미지 업로드 시작: {}", userId, file.getOriginalFilename());
            
            // 1. 임시 업로드
            FiroCategory category = FiroRegistry.get("user", "profile");
            String tempUuid = firoService.uploadTemp(file, category, null, true);
            
            // 2. AttachBag 생성 및 저장
            AttachBag bag = new AttachBag("user");
            FiroFile firoFile = createFiroFile(file, tempUuid);
            bag.add("profile", firoFile);
            
            List<FiroFile> savedFiles = firoService.save(
                userId, bag, LocalDateTime.now(), true, false // cleanDomain=true로 기존 프로필 삭제
            );
            
            String fileUrl = savedFiles.get(0).getCdnUrl();
            log.info("사용자 {}의 프로필 이미지 업로드 완료: {}", userId, fileUrl);
            
            return fileUrl;
        } catch (Exception e) {
            log.error("사용자 {}의 프로필 이미지 업로드 실패", userId, e);
            throw new RuntimeException("프로필 이미지 업로드에 실패했습니다: " + e.getMessage(), e);
        }
    }

    /**
     * 사용자 아바타 업로드 (128x128 고정 크기)
     */
    public String uploadUserAvatar(Long userId, MultipartFile file) {
        try {
            log.info("사용자 {}의 아바타 업로드 시작: {}", userId, file.getOriginalFilename());
            
            FiroCategory category = FiroRegistry.get("user", "avatar");
            String tempUuid = firoService.uploadTemp(file, category, null, true);
            
            AttachBag bag = new AttachBag("user");
            FiroFile firoFile = createFiroFile(file, tempUuid);
            bag.add("avatar", firoFile);
            
            List<FiroFile> savedFiles = firoService.save(
                userId, bag, LocalDateTime.now(), false, false
            );
            
            String fileUrl = savedFiles.get(0).getCdnUrl();
            log.info("사용자 {}의 아바타 업로드 완료: {}", userId, fileUrl);
            
            return fileUrl;
        } catch (Exception e) {
            log.error("사용자 {}의 아바타 업로드 실패", userId, e);
            throw new RuntimeException("아바타 업로드에 실패했습니다: " + e.getMessage(), e);
        }
    }

    /**
     * 게시글 다중 파일 업로드
     * 
     * @param postId 게시글 ID
     * @param images 이미지 파일들
     * @param attachments 첨부파일들
     * @return 업로드 결과 맵
     */
    public Map<String, List<String>> uploadPostFiles(Long postId, 
                                                    List<MultipartFile> images, 
                                                    List<MultipartFile> attachments) {
        try {
            log.info("게시글 {}의 파일 업로드 시작 - 이미지: {}개, 첨부파일: {}개", 
                    postId, 
                    images != null ? images.size() : 0, 
                    attachments != null ? attachments.size() : 0);
            
            AttachBag bag = new AttachBag("post");
            
            // 이미지 파일 처리
            if (images != null && !images.isEmpty()) {
                FiroCategory imageCategory = FiroRegistry.get("post", "image");
                for (MultipartFile image : images) {
                    String tempUuid = firoService.uploadTemp(image, imageCategory, null, true);
                    bag.add("image", createFiroFile(image, tempUuid));
                }
            }
            
            // 첨부파일 처리
            if (attachments != null && !attachments.isEmpty()) {
                FiroCategory attachCategory = FiroRegistry.get("post", "attachment");
                for (MultipartFile attachment : attachments) {
                    String tempUuid = firoService.uploadTemp(attachment, attachCategory, null, true);
                    bag.add("attachment", createFiroFile(attachment, tempUuid));
                }
            }
            
            // 저장
            List<FiroFile> savedFiles = firoService.save(
                postId, bag, LocalDateTime.now(), false, false
            );
            
            // 결과 정리
            Map<String, List<String>> result = savedFiles.stream()
                .collect(Collectors.groupingBy(
                    FiroFile::getRefCategory,
                    Collectors.mapping(FiroFile::getCdnUrl, Collectors.toList())
                ));
            
            log.info("게시글 {}의 파일 업로드 완료: {}", postId, result);
            return result;
            
        } catch (Exception e) {
            log.error("게시글 {}의 파일 업로드 실패", postId, e);
            throw new RuntimeException("게시글 파일 업로드에 실패했습니다: " + e.getMessage(), e);
        }
    }

    /**
     * 제품 이미지 업로드 (여러 개 가능)
     */
    public List<String> uploadProductImages(Long productId, List<MultipartFile> images) {
        try {
            log.info("제품 {}의 이미지 업로드 시작: {}개", productId, images.size());
            
            AttachBag bag = new AttachBag("product");
            FiroCategory category = FiroRegistry.get("product", "image");
            
            for (MultipartFile image : images) {
                String tempUuid = firoService.uploadTemp(image, category, null, true);
                bag.add("image", createFiroFile(image, tempUuid));
            }
            
            List<FiroFile> savedFiles = firoService.save(
                productId, bag, LocalDateTime.now(), false, false
            );
            
            List<String> imageUrls = savedFiles.stream()
                .map(FiroFile::getCdnUrl)
                .collect(Collectors.toList());
            
            log.info("제품 {}의 이미지 업로드 완료: {}개", productId, imageUrls.size());
            return imageUrls;
            
        } catch (Exception e) {
            log.error("제품 {}의 이미지 업로드 실패", productId, e);
            throw new RuntimeException("제품 이미지 업로드에 실패했습니다: " + e.getMessage(), e);
        }
    }

    /**
     * 파일 목록 조회
     */
    public AttachBag getFilesByRef(String domain, Long refKey) {
        try {
            return firoService.getAttachBagByRef(domain, refKey, null);
        } catch (Exception e) {
            log.error("파일 목록 조회 실패: domain={}, refKey={}", domain, refKey, e);
            return new AttachBag(domain); // 빈 AttachBag 반환
        }
    }

    /**
     * 파일 삭제
     */
    public void deleteFile(Long fileId) {
        try {
            FiroFile file = firoService.getAttach(fileId);
            if (file != null) {
                firoService.deleteAttach(List.of(file));
                log.info("파일 삭제 완료: {}", fileId);
            }
        } catch (Exception e) {
            log.error("파일 삭제 실패: {}", fileId, e);
            throw new RuntimeException("파일 삭제에 실패했습니다: " + e.getMessage(), e);
        }
    }

    /**
     * 도메인별 모든 파일 삭제
     */
    public void deleteAllFilesByDomain(String domain, Long refKey) {
        try {
            firoService.clearAttachByDomain(domain, refKey);
            log.info("도메인 파일 전체 삭제 완료: domain={}, refKey={}", domain, refKey);
        } catch (Exception e) {
            log.error("도메인 파일 전체 삭제 실패: domain={}, refKey={}", domain, refKey, e);
            throw new RuntimeException("파일 삭제에 실패했습니다: " + e.getMessage(), e);
        }
    }

    /**
     * FiroFile 생성 헬퍼 메소드
     */
    private FiroFile createFiroFile(MultipartFile file, String tempUuid) {
        FiroFile firoFile = new FiroFile();
        firoFile.setDisplayName(file.getOriginalFilename());
        firoFile.setSavedName(tempUuid);
        firoFile.setFileType(file.getContentType());
        firoFile.setFileSize(file.getSize());
        return firoFile;
    }
}

3. 컨트롤러 클래스

java
package com.example.demo.controller;

import com.example.demo.service.FileService;
import com.unvus.firo.core.module.service.domain.AttachBag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/files")
@CrossOrigin(origins = "*") // CORS 허용 (개발용)
public class FileController {

    @Autowired
    private FileService fileService;

    /**
     * 사용자 프로필 이미지 업로드
     */
    @PostMapping("/user/{userId}/profile")
    public ResponseEntity<?> uploadProfile(
            @PathVariable Long userId,
            @RequestParam("file") MultipartFile file) {
        
        try {
            String imageUrl = fileService.uploadUserProfile(userId, file);
            
            Map<String, Object> response = new HashMap<>();
            response.put("success", true);
            response.put("imageUrl", imageUrl);
            response.put("message", "프로필 이미지가 성공적으로 업로드되었습니다.");
            
            return ResponseEntity.ok(response);
            
        } catch (Exception e) {
            log.error("프로필 이미지 업로드 API 오류", e);
            
            Map<String, Object> errorResponse = new HashMap<>();
            errorResponse.put("success", false);
            errorResponse.put("message", e.getMessage());
            
            return ResponseEntity.badRequest().body(errorResponse);
        }
    }

    /**
     * 사용자 아바타 업로드
     */
    @PostMapping("/user/{userId}/avatar")
    public ResponseEntity<?> uploadAvatar(
            @PathVariable Long userId,
            @RequestParam("file") MultipartFile file) {
        
        try {
            String avatarUrl = fileService.uploadUserAvatar(userId, file);
            
            return ResponseEntity.ok(Map.of(
                "success", true,
                "avatarUrl", avatarUrl,
                "message", "아바타가 성공적으로 업로드되었습니다."
            ));
            
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", e.getMessage()
            ));
        }
    }

    /**
     * 게시글 파일 업로드 (이미지 + 첨부파일)
     */
    @PostMapping("/post/{postId}")
    public ResponseEntity<?> uploadPostFiles(
            @PathVariable Long postId,
            @RequestParam(value = "images", required = false) List<MultipartFile> images,
            @RequestParam(value = "attachments", required = false) List<MultipartFile> attachments) {
        
        try {
            Map<String, List<String>> uploadedFiles = fileService.uploadPostFiles(postId, images, attachments);
            
            return ResponseEntity.ok(Map.of(
                "success", true,
                "files", uploadedFiles,
                "message", "파일이 성공적으로 업로드되었습니다."
            ));
            
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", e.getMessage()
            ));
        }
    }

    /**
     * 제품 이미지 업로드
     */
    @PostMapping("/product/{productId}/images")
    public ResponseEntity<?> uploadProductImages(
            @PathVariable Long productId,
            @RequestParam("images") List<MultipartFile> images) {
        
        try {
            List<String> imageUrls = fileService.uploadProductImages(productId, images);
            
            return ResponseEntity.ok(Map.of(
                "success", true,
                "imageUrls", imageUrls,
                "count", imageUrls.size(),
                "message", "제품 이미지가 성공적으로 업로드되었습니다."
            ));
            
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", e.getMessage()
            ));
        }
    }

    /**
     * 파일 목록 조회
     */
    @GetMapping("/{domain}/{refKey}")
    public ResponseEntity<?> getFiles(
            @PathVariable String domain,
            @PathVariable Long refKey) {
        
        try {
            AttachBag files = fileService.getFilesByRef(domain, refKey);
            
            return ResponseEntity.ok(Map.of(
                "success", true,
                "files", files,
                "message", "파일 목록을 성공적으로 조회했습니다."
            ));
            
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", e.getMessage()
            ));
        }
    }

    /**
     * 파일 삭제
     */
    @DeleteMapping("/{fileId}")
    public ResponseEntity<?> deleteFile(@PathVariable Long fileId) {
        try {
            fileService.deleteFile(fileId);
            
            return ResponseEntity.ok(Map.of(
                "success", true,
                "message", "파일이 성공적으로 삭제되었습니다."
            ));
            
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", e.getMessage()
            ));
        }
    }

    /**
     * 도메인별 모든 파일 삭제
     */
    @DeleteMapping("/{domain}/{refKey}")
    public ResponseEntity<?> deleteAllFiles(
            @PathVariable String domain,
            @PathVariable Long refKey) {
        
        try {
            fileService.deleteAllFilesByDomain(domain, refKey);
            
            return ResponseEntity.ok(Map.of(
                "success", true,
                "message", "모든 파일이 성공적으로 삭제되었습니다."
            ));
            
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", e.getMessage()
            ));
        }
    }

    /**
     * 업로드 상태 확인 (헬스체크)
     */
    @GetMapping("/health")
    public ResponseEntity<?> healthCheck() {
        return ResponseEntity.ok(Map.of(
            "success", true,
            "service", "File Upload Service",
            "status", "healthy",
            "timestamp", System.currentTimeMillis()
        ));
    }
}

4. 엔터티 예시 (JPA 어노테이션 사용)

java
package com.example.demo.entity;

import com.unvus.firo.core.annotation.FiroDomain;
import com.unvus.firo.core.annotation.FiroDomainKey;
import com.unvus.firo.core.module.service.domain.AttachBag;

import javax.persistence.*;
import java.util.HashMap;
import java.util.Map;

@Entity
@Table(name = "users")
@FiroDomain("user") // Firo 도메인 지정
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @FiroDomainKey // Firo 도메인 키 지정
    private Long id;
    
    @Column(nullable = false)
    private String username;
    
    @Column(nullable = false)
    private String email;
    
    // AttachBag이 자동으로 주입될 메타 필드
    @Transient
    private Map<String, Object> _meta = new HashMap<>();
    
    // Constructors
    public User() {}
    
    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }
    
    // Getters and Setters
    public Long getId() {
        return id;
    }
    
    public void setId(Long id) {
        this.id = id;
    }
    
    public String getUsername() {
        return username;
    }
    
    public void setUsername(String username) {
        this.username = username;
    }
    
    public String getEmail() {
        return email;
    }
    
    public void setEmail(String email) {
        this.email = email;
    }
    
    public Map<String, Object> get_meta() {
        return _meta;
    }
    
    public void set_meta(Map<String, Object> _meta) {
        this._meta = _meta;
    }
    
    /**
     * 자동 주입된 AttachBag 조회
     */
    public AttachBag getAttachBag() {
        return (AttachBag) _meta.get("attachBag");
    }
    
    /**
     * 프로필 이미지 URL 조회 (편의 메소드)
     */
    public String getProfileImageUrl() {
        AttachBag bag = getAttachBag();
        if (bag != null && bag.get("profile") != null && !bag.get("profile").isEmpty()) {
            return bag.get("profile").get(0).getCdnUrl();
        }
        return null;
    }
    
    /**
     * 아바타 URL 조회 (편의 메소드)
     */
    public String getAvatarUrl() {
        AttachBag bag = getAttachBag();
        if (bag != null && bag.get("avatar") != null && !bag.get("avatar").isEmpty()) {
            return bag.get("avatar").get(0).getCdnUrl();
        }
        return null;
    }
}

5. 사용자 서비스 클래스 (AttachBag 자동 주입 예시)

java
package com.example.demo.service;

import com.example.demo.entity.User;
import com.unvus.firo.core.module.service.FiroService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserService {
    
    @Autowired
    private FiroService firoService;
    
    /**
     * 사용자 목록 조회 (AttachBag 자동 주입)
     */
    public List<User> getAllUsersWithFiles() {
        // 1. 사용자 목록 조회 (실제로는 UserRepository에서 조회)
        List<User> users = getUsersFromDatabase();
        
        // 2. AttachBag 자동 주입
        firoService.injectAttachBag(users, User.class);
        
        return users;
    }
    
    /**
     * 단일 사용자 조회 (AttachBag 자동 주입)
     */
    public User getUserWithFiles(Long userId) {
        // 1. 사용자 조회 (실제로는 UserRepository에서 조회)
        User user = getUserFromDatabase(userId);
        
        // 2. AttachBag 자동 주입
        firoService.injectAttachBag(List.of(user), User.class);
        
        return user;
    }
    
    // Mock 메소드들 (실제로는 JPA Repository 사용)
    private List<User> getUsersFromDatabase() {
        return List.of(
            new User("john", "john@example.com"),
            new User("jane", "jane@example.com")
        );
    }
    
    private User getUserFromDatabase(Long userId) {
        User user = new User("john", "john@example.com");
        user.setId(userId);
        return user;
    }
}

🧪 테스트 코드

단위 테스트 예시

java
package com.example.demo;

import com.example.demo.service.FileService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
public class FileServiceTest {

    @Autowired
    private FileService fileService;

    @Test
    public void testUploadUserProfile() {
        // Given
        MockMultipartFile file = new MockMultipartFile(
            "file", 
            "test-profile.jpg", 
            "image/jpeg", 
            "test image content".getBytes()
        );
        Long userId = 1L;

        // When
        String imageUrl = fileService.uploadUserProfile(userId, file);

        // Then
        assertThat(imageUrl).isNotNull();
        assertThat(imageUrl).contains("test-profile.jpg");
    }
}

🎯 실전 사용 예시

HTML 폼 예시

html
<!DOCTYPE html>
<html>
<head>
    <title>Firo 파일 업로드 테스트</title>
</head>
<body>
    <h1>파일 업로드 테스트</h1>
    
    <!-- 프로필 이미지 업로드 -->
    <form action="/api/files/user/1/profile" method="post" enctype="multipart/form-data">
        <h3>프로필 이미지 업로드</h3>
        <input type="file" name="file" accept="image/*" required>
        <button type="submit">프로필 업로드</button>
    </form>
    
    <!-- 아바타 업로드 -->
    <form action="/api/files/user/1/avatar" method="post" enctype="multipart/form-data">
        <h3>아바타 업로드 (128x128)</h3>
        <input type="file" name="file" accept="image/*" required>
        <button type="submit">아바타 업로드</button>
    </form>
    
    <!-- 게시글 파일 업로드 -->
    <form action="/api/files/post/1" method="post" enctype="multipart/form-data">
        <h3>게시글 파일 업로드</h3>
        <label>이미지:</label><br>
        <input type="file" name="images" accept="image/*" multiple><br><br>
        <label>첨부파일:</label><br>
        <input type="file" name="attachments" multiple><br><br>
        <button type="submit">게시글 파일 업로드</button>
    </form>
</body>
</html>

JavaScript (Ajax) 예시

javascript
// 프로필 이미지 업로드
function uploadProfile(userId, file) {
    const formData = new FormData();
    formData.append('file', file);
    
    fetch(`/api/files/user/${userId}/profile`, {
        method: 'POST',
        body: formData
    })
    .then(response => response.json())
    .then(data => {
        if (data.success) {
            console.log('업로드 성공:', data.imageUrl);
            // 이미지 미리보기 업데이트
            document.getElementById('profile-preview').src = data.imageUrl;
        } else {
            alert('업로드 실패: ' + data.message);
        }
    })
    .catch(error => {
        console.error('오류:', error);
        alert('업로드 중 오류가 발생했습니다.');
    });
}

// 파일 목록 조회
function getFiles(domain, refKey) {
    fetch(`/api/files/${domain}/${refKey}`)
    .then(response => response.json())
    .then(data => {
        if (data.success) {
            console.log('파일 목록:', data.files);
            displayFiles(data.files);
        }
    })
    .catch(error => console.error('오류:', error));
}

// 파일 목록 표시
function displayFiles(attachBag) {
    const container = document.getElementById('file-list');
    container.innerHTML = '';
    
    // 각 카테고리별로 파일 표시
    for (const [category, files] of Object.entries(attachBag)) {
        if (category !== 'refDomain' && files.length > 0) {
            const categoryDiv = document.createElement('div');
            categoryDiv.innerHTML = `<h4>${category}</h4>`;
            
            files.forEach(file => {
                const fileDiv = document.createElement('div');
                fileDiv.innerHTML = `
                    <p>${file.displayName} (${file.fileSize} bytes)</p>
                    <img src="${file.cdnUrl}" style="max-width: 200px;" alt="${file.displayName}">
                    <button onclick="deleteFile(${file.id})">삭제</button>
                `;
                categoryDiv.appendChild(fileDiv);
            });
            
            container.appendChild(categoryDiv);
        }
    }
}

🖼️ 프론트엔드 프레임워크 예제

TypeScript 타입 정의

typescript
// types/firo.ts
export interface FiroFile {
  id: number;
  refDomain: string;
  refKey: number;
  refCategory: string;
  displayName: string;
  savedName: string;
  savedDir: string;
  fileType: string;
  fileSize: number;
  deleted: boolean;
  ext?: string;
  createdBy?: number;
  createdDt: string;
  cdnUrl: string;
}

export interface AttachBag {
  refDomain: string;
  [category: string]: FiroFile[] | string;
}

export interface UploadResponse {
  success: boolean;
  imageUrl?: string;
  avatarUrl?: string;
  files?: { [category: string]: string[] };
  imageUrls?: string[];
  message: string;
}

export interface FilesResponse {
  success: boolean;
  files: AttachBag;
  message: string;
}

export interface DeleteResponse {
  success: boolean;
  message: string;
}

// API 클라이언트 인터페이스
export interface FiroApiClient {
  uploadProfile(userId: number, file: File): Promise<UploadResponse>;
  uploadAvatar(userId: number, file: File): Promise<UploadResponse>;
  uploadPostFiles(postId: number, images?: File[], attachments?: File[]): Promise<UploadResponse>;
  uploadProductImages(productId: number, images: File[]): Promise<UploadResponse>;
  getFiles(domain: string, refKey: number): Promise<FilesResponse>;
  deleteFile(fileId: number): Promise<DeleteResponse>;
  deleteAllFiles(domain: string, refKey: number): Promise<DeleteResponse>;
}

TypeScript API 클라이언트

typescript
// services/firoApi.ts
import type { FiroApiClient, UploadResponse, FilesResponse, DeleteResponse } from '../types/firo';

class FiroApiClientImpl implements FiroApiClient {
  private baseUrl = '/api/files';

  private async request<T>(url: string, options: RequestInit = {}): Promise<T> {
    try {
      const response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
        },
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      return await response.json();
    } catch (error) {
      console.error('API 요청 실패:', error);
      throw error;
    }
  }

  async uploadProfile(userId: number, file: File): Promise<UploadResponse> {
    const formData = new FormData();
    formData.append('file', file);

    return this.request<UploadResponse>(`${this.baseUrl}/user/${userId}/profile`, {
      method: 'POST',
      body: formData,
    });
  }

  async uploadAvatar(userId: number, file: File): Promise<UploadResponse> {
    const formData = new FormData();
    formData.append('file', file);

    return this.request<UploadResponse>(`${this.baseUrl}/user/${userId}/avatar`, {
      method: 'POST',
      body: formData,
    });
  }

  async uploadPostFiles(
    postId: number,
    images?: File[],
    attachments?: File[]
  ): Promise<UploadResponse> {
    const formData = new FormData();

    if (images) {
      images.forEach(image => formData.append('images', image));
    }
    if (attachments) {
      attachments.forEach(attachment => formData.append('attachments', attachment));
    }

    return this.request<UploadResponse>(`${this.baseUrl}/post/${postId}`, {
      method: 'POST',
      body: formData,
    });
  }

  async uploadProductImages(productId: number, images: File[]): Promise<UploadResponse> {
    const formData = new FormData();
    images.forEach(image => formData.append('images', image));

    return this.request<UploadResponse>(`${this.baseUrl}/product/${productId}/images`, {
      method: 'POST',
      body: formData,
    });
  }

  async getFiles(domain: string, refKey: number): Promise<FilesResponse> {
    return this.request<FilesResponse>(`${this.baseUrl}/${domain}/${refKey}`);
  }

  async deleteFile(fileId: number): Promise<DeleteResponse> {
    return this.request<DeleteResponse>(`${this.baseUrl}/${fileId}`, {
      method: 'DELETE',
    });
  }

  async deleteAllFiles(domain: string, refKey: number): Promise<DeleteResponse> {
    return this.request<DeleteResponse>(`${this.baseUrl}/${domain}/${refKey}`, {
      method: 'DELETE',
    });
  }
}

export const firoApi = new FiroApiClientImpl();

Vue 3 Composition API 예제

vue
<!-- components/FileUpload.vue -->
<template>
  <div class="file-upload">
    <!-- 프로필 이미지 업로드 -->
    <div class="upload-section">
      <h3>프로필 이미지 업로드</h3>
      <div class="file-input-wrapper">
        <input
          ref="profileInput"
          type="file"
          accept="image/*"
          @change="handleProfileUpload"
          :disabled="uploading"
        />
        <button @click="() => profileInput?.click()" :disabled="uploading">
          {{ uploading ? '업로드 중...' : '프로필 선택' }}
        </button>
      </div>
      
      <!-- 프로필 이미지 미리보기 -->
      <div v-if="profileImageUrl" class="image-preview">
        <img :src="profileImageUrl" alt="프로필 이미지" />
        <button @click="deleteProfileImage" class="delete-btn">
          삭제
        </button>
      </div>
    </div>

    <!-- 아바타 업로드 (128x128 고정) -->
    <div class="upload-section">
      <h3>아바타 업로드 (128x128)</h3>
      <div class="file-input-wrapper">
        <input
          ref="avatarInput"
          type="file"
          accept="image/*"
          @change="handleAvatarUpload"
          :disabled="uploading"
        />
        <button @click="() => avatarInput?.click()" :disabled="uploading">
          {{ uploading ? '업로드 중...' : '아바타 선택' }}
        </button>
      </div>
      
      <div v-if="avatarImageUrl" class="avatar-preview">
        <img :src="avatarImageUrl" alt="아바타" class="avatar" />
        <button @click="deleteAvatar" class="delete-btn">
          삭제
        </button>
      </div>
    </div>

    <!-- 게시글 다중 파일 업로드 -->
    <div class="upload-section">
      <h3>게시글 파일 업로드</h3>
      <div class="multi-file-upload">
        <div>
          <label>이미지:</label>
          <input
            ref="imagesInput"
            type="file"
            accept="image/*"
            multiple
            @change="handleImagesSelect"
            :disabled="uploading"
          />
        </div>
        <div>
          <label>첨부파일:</label>
          <input
            ref="attachmentsInput"
            type="file"
            multiple
            @change="handleAttachmentsSelect"
            :disabled="uploading"
          />
        </div>
        <button 
          @click="uploadPostFiles" 
          :disabled="uploading || (!selectedImages.length && !selectedAttachments.length)"
        >
          {{ uploading ? '업로드 중...' : '게시글 파일 업로드' }}
        </button>
      </div>
      
      <!-- 선택된 파일 미리보기 -->
      <div v-if="selectedImages.length > 0" class="selected-files">
        <h4>선택된 이미지 ({{ selectedImages.length }}개)</h4>
        <div class="file-list">
          <div v-for="(file, index) in selectedImages" :key="index" class="file-item">
            {{ file.name }} ({{ formatFileSize(file.size) }})
          </div>
        </div>
      </div>
      
      <div v-if="selectedAttachments.length > 0" class="selected-files">
        <h4>선택된 첨부파일 ({{ selectedAttachments.length }}개)</h4>
        <div class="file-list">
          <div v-for="(file, index) in selectedAttachments" :key="index" class="file-item">
            {{ file.name }} ({{ formatFileSize(file.size) }})
          </div>
        </div>
      </div>
    </div>

    <!-- 파일 목록 표시 -->
    <div class="files-section">
      <h3>업로드된 파일 목록</h3>
      <button @click="loadFiles" :disabled="loading">
        {{ loading ? '로딩 중...' : '파일 목록 새로고침' }}
      </button>
      
      <div v-if="files" class="files-display">
        <div v-for="[category, categoryFiles] in Object.entries(files)" :key="category">
          <div v-if="category !== 'refDomain' && categoryFiles.length > 0">
            <h4>{{ category }} ({{ categoryFiles.length }}개)</h4>
            <div class="file-grid">
              <div 
                v-for="file in categoryFiles" 
                :key="file.id" 
                class="file-card"
              >
                <img v-if="isImage(file.fileType)" :src="file.cdnUrl" :alt="file.displayName" />
                <div class="file-info">
                  <p class="file-name">{{ file.displayName }}</p>
                  <p class="file-size">{{ formatFileSize(file.fileSize) }}</p>
                  <button @click="deleteFile(file.id)" class="delete-btn">
                    삭제
                  </button>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- 에러/성공 메시지 -->
    <div v-if="message" :class="['message', messageType]">
      {{ message }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { firoApi } from '../services/firoApi';
import type { FiroFile, AttachBag } from '../types/firo';

// Props
interface Props {
  userId: number;
  postId: number;
}

const props = defineProps<Props>();

// Reactive references
const profileInput = ref<HTMLInputElement>();
const avatarInput = ref<HTMLInputElement>();
const imagesInput = ref<HTMLInputElement>();
const attachmentsInput = ref<HTMLInputElement>();

const uploading = ref(false);
const loading = ref(false);
const message = ref('');
const messageType = ref<'success' | 'error'>('success');

const profileImageUrl = ref<string | null>(null);
const avatarImageUrl = ref<string | null>(null);
const files = ref<AttachBag | null>(null);

const selectedImages = ref<File[]>([]);
const selectedAttachments = ref<File[]>([]);

// 파일 크기 포맷팅
const formatFileSize = (bytes: number): string => {
  if (bytes === 0) return '0 Bytes';
  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

// 이미지 파일 확인
const isImage = (fileType: string): boolean => {
  return fileType.startsWith('image/');
};

// 메시지 표시
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
  message.value = msg;
  messageType.value = type;
  setTimeout(() => {
    message.value = '';
  }, 5000);
};

// 프로필 이미지 업로드
const handleProfileUpload = async (event: Event) => {
  const target = event.target as HTMLInputElement;
  const file = target.files?.[0];
  if (!file) return;

  try {
    uploading.value = true;
    const response = await firoApi.uploadProfile(props.userId, file);
    
    if (response.success && response.imageUrl) {
      profileImageUrl.value = response.imageUrl;
      showMessage('프로필 이미지가 성공적으로 업로드되었습니다.');
      await loadFiles(); // 파일 목록 새로고침
    } else {
      showMessage(response.message, 'error');
    }
  } catch (error) {
    console.error('프로필 업로드 실패:', error);
    showMessage('프로필 이미지 업로드에 실패했습니다.', 'error');
  } finally {
    uploading.value = false;
    if (profileInput.value) {
      profileInput.value.value = '';
    }
  }
};

// 아바타 업로드
const handleAvatarUpload = async (event: Event) => {
  const target = event.target as HTMLInputElement;
  const file = target.files?.[0];
  if (!file) return;

  try {
    uploading.value = true;
    const response = await firoApi.uploadAvatar(props.userId, file);
    
    if (response.success && response.avatarUrl) {
      avatarImageUrl.value = response.avatarUrl;
      showMessage('아바타가 성공적으로 업로드되었습니다.');
      await loadFiles();
    } else {
      showMessage(response.message, 'error');
    }
  } catch (error) {
    console.error('아바타 업로드 실패:', error);
    showMessage('아바타 업로드에 실패했습니다.', 'error');
  } finally {
    uploading.value = false;
    if (avatarInput.value) {
      avatarInput.value.value = '';
    }
  }
};

// 이미지 선택
const handleImagesSelect = (event: Event) => {
  const target = event.target as HTMLInputElement;
  selectedImages.value = Array.from(target.files || []);
};

// 첨부파일 선택
const handleAttachmentsSelect = (event: Event) => {
  const target = event.target as HTMLInputElement;
  selectedAttachments.value = Array.from(target.files || []);
};

// 게시글 파일 업로드
const uploadPostFiles = async () => {
  if (selectedImages.value.length === 0 && selectedAttachments.value.length === 0) {
    showMessage('업로드할 파일을 선택해주세요.', 'error');
    return;
  }

  try {
    uploading.value = true;
    const response = await firoApi.uploadPostFiles(
      props.postId,
      selectedImages.value.length > 0 ? selectedImages.value : undefined,
      selectedAttachments.value.length > 0 ? selectedAttachments.value : undefined
    );
    
    if (response.success) {
      showMessage('게시글 파일이 성공적으로 업로드되었습니다.');
      selectedImages.value = [];
      selectedAttachments.value = [];
      if (imagesInput.value) imagesInput.value.value = '';
      if (attachmentsInput.value) attachmentsInput.value.value = '';
      await loadFiles();
    } else {
      showMessage(response.message, 'error');
    }
  } catch (error) {
    console.error('게시글 파일 업로드 실패:', error);
    showMessage('게시글 파일 업로드에 실패했습니다.', 'error');
  } finally {
    uploading.value = false;
  }
};

// 파일 목록 로드
const loadFiles = async () => {
  try {
    loading.value = true;
    const response = await firoApi.getFiles('user', props.userId);
    
    if (response.success) {
      files.value = response.files;
      
      // 프로필과 아바타 URL 업데이트
      const profile = (response.files.profile as FiroFile[])?.[0];
      const avatar = (response.files.avatar as FiroFile[])?.[0];
      
      profileImageUrl.value = profile?.cdnUrl || null;
      avatarImageUrl.value = avatar?.cdnUrl || null;
    }
  } catch (error) {
    console.error('파일 목록 로드 실패:', error);
    showMessage('파일 목록을 불러오는데 실패했습니다.', 'error');
  } finally {
    loading.value = false;
  }
};

// 파일 삭제
const deleteFile = async (fileId: number) => {
  if (!confirm('정말로 이 파일을 삭제하시겠습니까?')) {
    return;
  }

  try {
    const response = await firoApi.deleteFile(fileId);
    
    if (response.success) {
      showMessage('파일이 성공적으로 삭제되었습니다.');
      await loadFiles();
    } else {
      showMessage(response.message, 'error');
    }
  } catch (error) {
    console.error('파일 삭제 실패:', error);
    showMessage('파일 삭제에 실패했습니다.', 'error');
  }
};

// 프로필 이미지 삭제
const deleteProfileImage = () => {
  const profile = (files.value?.profile as FiroFile[])?.[0];
  if (profile) {
    deleteFile(profile.id);
  }
};

// 아바타 삭제
const deleteAvatar = () => {
  const avatar = (files.value?.avatar as FiroFile[])?.[0];
  if (avatar) {
    deleteFile(avatar.id);
  }
};

// 컴포넌트 마운트 시 파일 목록 로드
onMounted(() => {
  loadFiles();
});
</script>

<style scoped>
.file-upload {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.upload-section {
  margin-bottom: 40px;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.file-input-wrapper {
  margin-bottom: 15px;
}

.file-input-wrapper input[type="file"] {
  display: none;
}

.file-input-wrapper button {
  padding: 10px 20px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.file-input-wrapper button:disabled {
  background: #6c757d;
  cursor: not-allowed;
}

.image-preview {
  position: relative;
  display: inline-block;
}

.image-preview img {
  max-width: 200px;
  max-height: 200px;
  border-radius: 8px;
}

.avatar-preview {
  position: relative;
  display: inline-block;
}

.avatar {
  width: 128px;
  height: 128px;
  border-radius: 50%;
  object-fit: cover;
}

.delete-btn {
  position: absolute;
  top: 5px;
  right: 5px;
  background: #dc3545;
  color: white;
  border: none;
  border-radius: 50%;
  width: 25px;
  height: 25px;
  cursor: pointer;
  font-size: 12px;
}

.multi-file-upload > div {
  margin-bottom: 15px;
}

.selected-files {
  margin-top: 15px;
}

.file-list {
  background: #f8f9fa;
  padding: 10px;
  border-radius: 4px;
}

.file-item {
  padding: 5px;
  border-bottom: 1px solid #dee2e6;
}

.file-item:last-child {
  border-bottom: none;
}

.files-section {
  margin-top: 40px;
}

.file-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 15px;
  margin-top: 15px;
}

.file-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 10px;
  text-align: center;
  position: relative;
}

.file-card img {
  max-width: 100%;
  max-height: 150px;
  object-fit: cover;
  border-radius: 4px;
}

.file-info {
  margin-top: 10px;
}

.file-name {
  font-weight: bold;
  margin-bottom: 5px;
  word-break: break-all;
}

.file-size {
  color: #6c757d;
  font-size: 0.9em;
  margin-bottom: 10px;
}

.message {
  padding: 10px;
  border-radius: 4px;
  margin-top: 20px;
}

.message.success {
  background: #d4edda;
  color: #155724;
  border: 1px solid #c3e6cb;
}

.message.error {
  background: #f8d7da;
  color: #721c24;
  border: 1px solid #f5c6cb;
}
</style>