파일 첨부 과정

양성준·2025년 3월 20일

스프링

목록 보기
16/49

첨부파일 업로드 흐름

1. 클라이언트에서 첨부파일 선택 → 파일 업로드 API 호출

  • Post API 호출
  • 요청 본문에 실제 파일(바이너리 데이터)가 포함됨
  • 파일을 multipart/form-data 또는 바이트 스트림 형태로 서버에 전송

2. 서버에서 파일을 Storage(S3 등)에 저장

  • 서버는 Storage와 네트워크로 연결되어 있음
  • 실제 파일 자체를 Storage에 바이너리 데이터 형태로 저장
  • Storage에서 저장 성공 응답(path나 key)을 보내줌

3. 서버가 Storage에서 받은 응답을 처리

  • Storage에서 보내준 Path(Key)와 저장된 파일에서 Size, Type(이미지/동영상) 등의 메타데이터를 추출하고 UUID를 만들어,
    (이 때, UUID를 겹치지 않게 만들어서 충돌이 일어나지 않게 하는게 중요!)
  • 이 정보를 BinaryContent 엔티티로 만들어 DB에 저장
  • 이때 파일 자체는 저장하지 않고, 메타데이터만 저장
  • 메시지를 보내지 않아도 파일 업로드 선택만 해도 API가 호출되어 BinaryContent 엔티티는 DB에 저장됨

4. 클라이언트에게 응답 반환

  • BinaryContent 정보(또는 ID)를 포함한 응답을 클라이언트에게 전달
  • 보통 파일 접근 URL(S3 URL 등, Storage에서 제공)도 포함
  • 클라이언트는 이 URL을 사용해서 Storage에서 파일을 직접 접근 가능

메시지에 파일을 첨부하여 전송

5. 클라이언트에서 메시지 전송 API 호출

  • 메시지를 입력하고, 업로드한 파일 ID(BinaryContent ID)를 함께 전송
  • POST /messages API 호출 (Body에 message + attachmentIds나 BinaryContent 객체 포함)

6. 서버가 메시지를 DB에 저장

  • Message 엔티티를 생성하여 DB에 저장
  • 이때, 첨부파일이 있으면 attachmentIds를 이용해 BinaryContent와 연결

7. 클라이언트에게 메시지 전송 성공 응답 반환

  • 메시지 ID와 함께 응답

메시지 조회할 때 파일 불러오는 과정

8. 클라이언트가 메시지 목록 조회 요청 (GET /messages)

  • 메시지에 첨부파일이 있는 경우, BinaryContent의 Path를 함께 반환

9. 클라이언트는 Path(S3 URL)를 이용해 Storage에서 직접 파일 접근

  • 서버를 거치지 않고 Storage에서 직접 다운로드 가능

왜 Storage에 파일을 저장해야 할까?

  • DB에 바이너리 데이터를 저장하면 비효율적!!
    • 데이터베이스는 텍스트 기반의 메타데이터를 저장하는 데 최적화됨.
    • byte[] 같은 바이너리 데이터를 직접 저장하면 성능이 저하될 수 있음.
    • 특히, 대용량 파일이 많아지면 DB 부하가 커지고 관리가 어려워짐.
      => DB에는 파일의 메타데이터만 저장!
  • Storage(S3, 파일 서버 등)는 대용량 파일 관리에 최적화됨.
    • S3, Google Cloud Storage, Azure Blob Storage, 로컬 파일 시스템 같은 Storage는 파일 저장을 전문적으로 처리함.
    • CDN(Content Delivery Network)과 연동하면 빠르게 제공 가능.

파일 업로드와 메시지 전송을 분리하는 이유

=> 첨부 파일 사전 업로드 방식은 사용자 경험, 시스템 안정성, 서버 자원 관리 측면에서 명확한 이점을 제공한다. 구현 시 여러 기술적 고려사항이 있지만, 이러한 복잡성을 잘 관리한다면 사용자 만족도와 시스템 효율성을 크게 향상시킬 수 있다. 대부분의 현대적인 웹 서비스가 사전 업로드 방식을 선택하는 것은 단순한 기술적 선택이 아닌, 더 나은 사용자 경험을 위한 의도적인 설계 결정이다.

Storage는 인터넷에 노출되지만, 서버(DB)는 내부망에서 관리해야 함

  • Storage (S3, GCS 등) → 인터넷에서 직접 접근 가능 (CDN 활용)
  • 서버(DB)는 내부망에서 관리 → 중요한 메타데이터는 외부 노출 ❌

🔹 만약 파일 업로드와 메시지 전송을 하나의 API로 합쳐서 처리하면?

  • 파일 업로드 시, 메시지와 함께 내부망(DB)의 데이터도 외부 API 요청으로 노출될 위험이 있음
  • 특히 DB 구조, 사용자 정보, 첨부파일의 연관 관계 등의 민감한 정보가 유출될 가능성이 증가

파일 업로드는 Storage에서 직접 접근 가능해야 함 (서버를 거치지 않도록)

  • Storage는 CDN을 활용해서 빠르게 파일을 제공할 수 있어야 함
  • 파일을 Storage에 저장한 후, 파일 접근은 클라이언트가 직접 해야 성능 최적화

🔹 만약 메시지 전송 API와 통합하면?

  • 파일을 업로드할 때마다 서버를 경유해서 메시지까지 함께 저장해야 함 → 불필요한 부하 발생
  • 메시지 전송 API가 불필요하게 Storage 접근까지 처리해야 해서 보안 및 성능 문제가 발생

실패 처리(트랜잭션 관리)가 어려워짐

  • 파일 업로드가 성공했는데, 메시지 전송이 실패하면?
    • 만약 하나의 API에서 파일 업로드와 메시지 전송을 동시에 처리하면,
      메시지 저장이 실패했을 때 이미 Storage에 저장된 파일을 어떻게 처리할 것인지 문제 발생
    • API를 분리하면 파일 업로드 실패 시 다시 업로드할 수 있고, 메시지 전송 실패 시 파일을 유지할 수 있음

파일 검증 프로세스

  • 사전 업로드 방식에서는 파일 크기 제한, 형식 검사, 바이러스 검사, 이미지 최적화 등의 처리를 미리 수행할 수 있음.
  • 문제가 있을 경우 사용자에게 즉시 알려 대응할 수 있다.

멀티파트 업로드 지원

  • 대용량 파일의 경우, 사전 업로드 방식은 파일을 작은 청크로 나누어 전송하고 서버에서 재조립하는 방식을 적용하기 쉽다.
  • 이는 네트워크 문제에 더 견고하며 재시도 메커니즘을 효율적으로 구현할 수 있습니다.

게시글 내 파일 수정 시 처리

 @Override
    public Post update(UUID id, UpdatePostRequestDTO updateRequestPostDTO) {
        Post post = findById(id);
        post.update(updateRequestPostDTO.title(), updateRequestPostDTO.content());
        String newContent = updateRequestPostDTO.content();

        // 기존에 사용된 이미지 ID 목록
        List<UUID> oldImageIds = postImageService.findByPostId(id)
                .stream()
                .map(PostImage::getImageId)
                .toList();

        // 새로운 Content에서 이미지 ID 목록 추출
        List<UUID> newImageIds = extractImageIdsFromContent(newContent);

        // 추가된 이미지 처리
        newImageIds.stream()
                .filter(imageId -> !oldImageIds.contains(imageId)) // 기존에 없던 이미지만 추가
                .forEach(imageId -> {
                    try {
                        imageRepository.findById(imageId)
                                .orElseThrow(() -> {
                                    String errMessage = imageId + "번 image가 존재하지 않습니다.";
                                    logger.error(errMessage);
                                    return new ImageNotFound(errMessage);
                                });
                        postImageService.create(new CreatePostImageRequestDTO(post.getId(), imageId));
                    } catch (ImageNotFound e) {
                        logger.warn("PostImage가 업로드되지 않았습니다.");
                    }
                });

        // 삭제된 이미지 처리
        oldImageIds.stream()
                .filter(imageId -> !newImageIds.contains(imageId)) // 새로운 content에 없으면 삭제
                .forEach(imageId -> {
                    postImageService.deleteByPostIdAndImageId(id, imageId);
                    imageRepository.deleteById(imageId);
                });

        // 게시물 업데이트 후 저장
        post.update(updateRequestPostDTO.title(), newContent);
        return postRepository.save(post);
    }
    
    
    
     private List<UUID> extractImageIdsFromContent(String content) {
        List<UUID> imageUUIDs = new ArrayList<>();

        String uuidRegex = "\\{\\{([0-9a-fA-F-]+)\\|";
        Pattern pattern = Pattern.compile(uuidRegex);
        Matcher matcher = pattern.matcher(content);

        while (matcher.find()) {
            imageUUIDs.add(UUID.fromString(matcher.group(1)));
        }

        return imageUUIDs;
    }
  • 기존에 사용된 이미지 목록을 업데이트 전에 뽑아내고,
  • 새로운 이미지 ID를 newContent에서 추출해, 기존에 있던 이미지와 비교해 없다면 추가해주고,
  • newContent에 기존 이미지가 존재하지 않다면 삭제처리를 해주면 된다.

메시지를 안 보낸 파일이 계속 남아 있다면?

  • 메시지를 보내지 않고 업로드한 파일이 계속 DB에 남아 있으면, 불필요한 파일 데이터가 Storage와 DB에 쌓이게 됨.

  • 해결책 : 일정 시간이 지나면 사용되지 않은 파일을 정리해야 함.

    • 서버에서 일정 시간이 지난 후에 사용하지 않는 파일 자동 삭제 (스케줄러)
    • 클라이언트에서 업로드 후 일정 시간이 지나면 자동 삭제 요청
      (예를 들어, 파일 업로드 후 10분 이내 메시지 전송이 없으면 자동 삭제 요청)

스케쥴러로 처리

@Component
@RequiredArgsConstructor
public class ImageCleanUpScheduler {
    private final ImageRepository imageRepository;
    private final PostImageRepository postImageRepository;
    private static final Logger logger = LoggerFactory.getLogger(ImageCleanUpScheduler.class);

    @Scheduled(fixedRate = 60000)
    public void cleanUnusedImages() {
        logger.info("미사용 이미지 정리 시작...");

        // 사용 중인 imageId 목록 조회 (PostImage에서 관리하는 이미지)
        Set<UUID> usedImageIds = postImageRepository.findAll().stream()
                .map(postImage -> postImage.getImageId())
                .collect(Collectors.toSet());

        // 모든 이미지 중에서 사용되지 않은 이미지 찾기 (PostImage에서 관리되지 않는 이미지)
        List<Image> unusedImages = imageRepository.findAll().stream()
                .filter(image -> !usedImageIds.contains(image.getId()))
                .toList();

        if (unusedImages.isEmpty()) {
            logger.info("정리할 미사용 이미지가 없습니다.");
            return;
        }

        // 미사용 이미지 삭제
        unusedImages.forEach(image -> {
            try {
                // 실제 이미지 파일 삭제 (data/imageFiles/{imageFileName})
                Path imageFilePath = Paths.get(image.getPath());
                if (Files.exists(imageFilePath)) {
                    Files.delete(imageFilePath);
                    logger.info("삭제된 이미지 파일: {}", imageFilePath);
                } else {
                    logger.warn("️이미지 파일을 찾을 수 없음: {}", imageFilePath);
                }

                // 이미지 엔티티 직렬화 파일 + Map 메모리에서 삭제
                imageRepository.deleteById(image.getId());
                logger.info("삭제된 이미지 메타데이터: {}", image.getOriginalName());

            } catch (Exception e) {
                logger.error("이미지 삭제 중 오류 발생: {}", image.getOriginalName(), e);
            }
        });

        logger.info("미사용 이미지 정리 완료");
    }
}

@SpringBootApplication
@EnableScheduling
public class BlogPracticeApplication {

    public static void main(String[] args) {
        SpringApplication.run(BlogPracticeApplication.class, args);
    }

}
  • 1분에 한번씩 실행되는 스케쥴러이며, 어플리케이션에 @EnablScheduling을 달아줘야 스케쥴 기능이 동작함
  • postImage는 현재 포스팅된 이미지이므로, 정상적으로 관리되고있는 이미지 -> imageId를 뽑아냄 (현재 사용중인 이미지)
  • 모든 이미지 중에서 사용되지 않는 이미지의 Id를 뽑아내서, 실제 이미지 파일과 이미지 엔티티 파일 삭제
profile
백엔드 개발자를 꿈꿉니다.

0개의 댓글