본문 바로가기
Spring/백엔드 실무 지식

파일 저장소 분리와 CDN 활용 - 대용량 파일 처리 최적화

by JuNo_12 2025. 7. 14.

들어가며

현대의 웹 애플리케이션에서는 이미지, 동영상, 문서 등 다양한 형태의 파일을 처리해야 합니다. 이러한 파일들을 애플리케이션 서버나 데이터베이스에 직접 저장하면 성능 저하와 확장성 문제가 발생할 수 있습니다. 이번 포스트에서는 파일 저장소를 분리하고 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의 장점

  1. 성능 향상: 사용자와 가까운 위치의 서버에서 파일 제공
  2. 트래픽 분산: 원본 서버의 부하 감소
  3. 가용성 향상: 글로벌 분산으로 장애 대응력 증가
  4. 비용 절감: 대역폭 사용량 감소

 

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을 통해 전 세계 사용자에게 빠른 파일 서비스를 제공할 수 있습니다. 적절한 이미지 최적화와 보안 검증을 함께 구현하면 안전하고 효율적인 파일 관리 시스템을 구축할 수 있습니다.