들어가며
현대의 웹 애플리케이션에서는 이미지, 동영상, 문서 등 다양한 형태의 파일을 처리해야 합니다. 이러한 파일들을 애플리케이션 서버나 데이터베이스에 직접 저장하면 성능 저하와 확장성 문제가 발생할 수 있습니다. 이번 포스트에서는 파일 저장소를 분리하고 CDN을 활용하여 효율적으로 파일을 관리하는 방법을 알아보겠습니다.
파일 저장소 분리의 필요성
기존 방식의 문제점
1. 데이터베이스에 파일 저장
-- 문제가 되는 구조
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
content TEXT,
image_data LONGBLOB -- 이미지를 직접 저장
);
문제점:
- 데이터베이스 크기 급속 증가
- 백업 및 복원 시간 증가
- 메모리 사용량 증가
- 동시성 처리 성능 저하
2. 애플리케이션 서버에 파일 저장
// 로컬 파일 시스템에 저장
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
String uploadDir = "/app/uploads/";
String fileName = file.getOriginalFilename();
Path filePath = Paths.get(uploadDir + fileName);
Files.copy(file.getInputStream(), filePath);
return ResponseEntity.ok("파일 업로드 성공");
}
문제점:
- 서버 디스크 용량 부족
- 로드 밸런싱 시 파일 공유 문제
- 서버 장애 시 파일 손실 위험
- 확장성 제한
클라우드 저장소 활용
AWS S3를 활용한 파일 저장
의존성 추가:
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
<version>1.12.400</version>
</dependency>
S3 설정:
@Configuration
public class S3Config {
@Value("${aws.access.key}")
private String accessKey;
@Value("${aws.secret.key}")
private String secretKey;
@Value("${aws.region}")
private String region;
@Bean
public AmazonS3 amazonS3() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
파일 업로드 서비스:
@Service
public class FileUploadService {
@Autowired
private AmazonS3 amazonS3;
@Value("${aws.s3.bucket}")
private String bucketName;
public String uploadFile(MultipartFile file) {
try {
String fileName = generateFileName(file.getOriginalFilename());
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
amazonS3.putObject(new PutObjectRequest(
bucketName,
fileName,
file.getInputStream(),
metadata
).withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3.getUrl(bucketName, fileName).toString();
} catch (IOException e) {
throw new RuntimeException("파일 업로드 실패", e);
}
}
private String generateFileName(String originalFileName) {
String extension = originalFileName.substring(originalFileName.lastIndexOf("."));
return System.currentTimeMillis() + "_" + UUID.randomUUID().toString() + extension;
}
public void deleteFile(String fileName) {
amazonS3.deleteObject(bucketName, fileName);
}
public String generatePresignedUrl(String fileName, int expirationMinutes) {
Date expiration = new Date();
expiration.setTime(expiration.getTime() + (expirationMinutes * 60 * 1000));
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
bucketName, fileName, HttpMethod.GET
).withExpiration(expiration);
return amazonS3.generatePresignedUrl(request).toString();
}
}
데이터베이스 구조 개선
-- 개선된 구조: 파일 정보만 저장
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
content TEXT,
created_at TIMESTAMP
);
CREATE TABLE post_files (
id BIGINT PRIMARY KEY,
post_id BIGINT,
file_name VARCHAR(255),
file_url VARCHAR(500),
file_size BIGINT,
file_type VARCHAR(100),
created_at TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id)
);
엔티티 설계:
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<PostFile> files = new ArrayList<>();
// getters, setters
}
@Entity
public class PostFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "post_id")
private Post post;
private String fileName;
private String fileUrl;
private Long fileSize;
private String fileType;
private LocalDateTime createdAt;
// getters, setters
}
CDN(Content Delivery Network) 활용
CDN의 장점
- 성능 향상: 사용자와 가까운 위치의 서버에서 파일 제공
- 트래픽 분산: 원본 서버의 부하 감소
- 가용성 향상: 글로벌 분산으로 장애 대응력 증가
- 비용 절감: 대역폭 사용량 감소
CloudFront 설정
CloudFront 배포 생성:
@Configuration
public class CloudFrontConfig {
@Value("${aws.cloudfront.domain}")
private String cloudFrontDomain;
@Bean
public AmazonCloudFront amazonCloudFront() {
return AmazonCloudFrontClientBuilder.defaultClient();
}
public String getCloudFrontUrl(String s3Key) {
return "https://" + cloudFrontDomain + "/" + s3Key;
}
}
CDN을 활용한 파일 서비스:
@Service
public class FileService {
@Autowired
private FileUploadService fileUploadService;
@Autowired
private CloudFrontConfig cloudFrontConfig;
public FileUploadResponse uploadFile(MultipartFile file) {
// S3에 파일 업로드
String s3Url = fileUploadService.uploadFile(file);
// S3 키 추출
String s3Key = extractS3Key(s3Url);
// CDN URL 생성
String cdnUrl = cloudFrontConfig.getCloudFrontUrl(s3Key);
return FileUploadResponse.builder()
.originalUrl(s3Url)
.cdnUrl(cdnUrl)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.build();
}
private String extractS3Key(String s3Url) {
return s3Url.substring(s3Url.lastIndexOf("/") + 1);
}
}
이미지 최적화
썸네일 생성
@Service
public class ImageProcessingService {
@Autowired
private FileUploadService fileUploadService;
public String createThumbnail(MultipartFile originalFile) {
try {
BufferedImage originalImage = ImageIO.read(originalFile.getInputStream());
// 썸네일 크기 계산
int thumbnailWidth = 200;
int thumbnailHeight = (int) ((double) originalImage.getHeight() * thumbnailWidth / originalImage.getWidth());
// 썸네일 생성
BufferedImage thumbnail = new BufferedImage(thumbnailWidth, thumbnailHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = thumbnail.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.drawImage(originalImage, 0, 0, thumbnailWidth, thumbnailHeight, null);
g2d.dispose();
// 썸네일을 바이트 배열로 변환
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(thumbnail, "jpg", baos);
byte[] thumbnailBytes = baos.toByteArray();
// MultipartFile로 변환하여 업로드
MultipartFile thumbnailFile = new MockMultipartFile(
"thumbnail_" + originalFile.getOriginalFilename(),
"thumbnail_" + originalFile.getOriginalFilename(),
"image/jpeg",
thumbnailBytes
);
return fileUploadService.uploadFile(thumbnailFile);
} catch (IOException e) {
throw new RuntimeException("썸네일 생성 실패", e);
}
}
}
이미지 포맷 최적화
@Service
public class ImageOptimizationService {
public MultipartFile optimizeImage(MultipartFile originalFile) {
try {
BufferedImage originalImage = ImageIO.read(originalFile.getInputStream());
// WebP 형식으로 변환 (더 작은 파일 크기)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 품질 설정 (0.8f = 80% 품질)
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
ImageWriter writer = writers.next();
ImageOutputStream ios = ImageIO.createImageOutputStream(baos);
writer.setOutput(ios);
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.8f);
writer.write(null, new IIOImage(originalImage, null, null), param);
writer.dispose();
ios.close();
return new MockMultipartFile(
originalFile.getName(),
originalFile.getOriginalFilename(),
"image/jpeg",
baos.toByteArray()
);
} catch (IOException e) {
throw new RuntimeException("이미지 최적화 실패", e);
}
}
}
파일 업로드 컨트롤러
@RestController
@RequestMapping("/api/files")
public class FileController {
@Autowired
private FileService fileService;
@Autowired
private ImageProcessingService imageProcessingService;
@PostMapping("/upload")
public ResponseEntity<FileUploadResponse> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "createThumbnail", defaultValue = "false") boolean createThumbnail) {
// 파일 크기 검증
if (file.getSize() > 10 * 1024 * 1024) { // 10MB 제한
return ResponseEntity.badRequest()
.body(FileUploadResponse.error("파일 크기는 10MB를 초과할 수 없습니다"));
}
// 파일 타입 검증
if (!isValidFileType(file.getContentType())) {
return ResponseEntity.badRequest()
.body(FileUploadResponse.error("지원하지 않는 파일 형식입니다"));
}
try {
FileUploadResponse response = fileService.uploadFile(file);
// 이미지 파일인 경우 썸네일 생성
if (createThumbnail && file.getContentType().startsWith("image/")) {
String thumbnailUrl = imageProcessingService.createThumbnail(file);
response.setThumbnailUrl(thumbnailUrl);
}
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(FileUploadResponse.error("파일 업로드 중 오류가 발생했습니다"));
}
}
@DeleteMapping("/{fileId}")
public ResponseEntity<Void> deleteFile(@PathVariable Long fileId) {
fileService.deleteFile(fileId);
return ResponseEntity.noContent().build();
}
private boolean isValidFileType(String contentType) {
List<String> allowedTypes = Arrays.asList(
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
return allowedTypes.contains(contentType);
}
}
보안 고려사항
1. 파일 업로드 보안
@Component
public class FileSecurityValidator {
private static final List<String> DANGEROUS_EXTENSIONS = Arrays.asList(
"exe", "bat", "cmd", "scr", "pif", "vbs", "js"
);
public boolean isSecureFile(MultipartFile file) {
String fileName = file.getOriginalFilename();
if (fileName == null) return false;
// 확장자 검증
String extension = getFileExtension(fileName).toLowerCase();
if (DANGEROUS_EXTENSIONS.contains(extension)) {
return false;
}
// 파일 시그니처 검증
return validateFileSignature(file);
}
private boolean validateFileSignature(MultipartFile file) {
try {
byte[] header = new byte[8];
file.getInputStream().read(header);
// JPEG 시그니처 확인
if (file.getContentType().equals("image/jpeg")) {
return header[0] == (byte) 0xFF && header[1] == (byte) 0xD8;
}
// PNG 시그니처 확인
if (file.getContentType().equals("image/png")) {
return header[0] == (byte) 0x89 && header[1] == 0x50 &&
header[2] == 0x4E && header[3] == 0x47;
}
return true;
} catch (IOException e) {
return false;
}
}
}
2. 접근 권한 관리
@Service
public class FileAccessService {
@Autowired
private FileUploadService fileUploadService;
public String getSecureFileUrl(Long fileId, Long userId) {
PostFile file = fileRepository.findById(fileId)
.orElseThrow(() -> new FileNotFoundException("파일을 찾을 수 없습니다"));
// 파일 소유자 확인
if (!file.getPost().getUserId().equals(userId)) {
throw new AccessDeniedException("파일에 접근할 권한이 없습니다");
}
// 임시 접근 URL 생성 (30분 유효)
return fileUploadService.generatePresignedUrl(file.getFileName(), 30);
}
}
마무리
파일 저장소 분리와 CDN 활용은 현대 웹 애플리케이션의 필수 요소입니다. 클라우드 저장소를 활용하면 확장성과 가용성을 크게 향상시킬 수 있으며, CDN을 통해 전 세계 사용자에게 빠른 파일 서비스를 제공할 수 있습니다. 적절한 이미지 최적화와 보안 검증을 함께 구현하면 안전하고 효율적인 파일 관리 시스템을 구축할 수 있습니다.
'Spring > 백엔드 실무 지식' 카테고리의 다른 글
| 데이터베이스 커넥션 풀 최적화 - 성능과 안정성 향상 (0) | 2025.07.14 |
|---|---|
| 백엔드 성능 최적화 - Redis를 활용한 리모트 캐싱 (0) | 2025.07.14 |