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>