
1. 클라이언트에서 첨부파일 선택 → 파일 업로드 API 호출
2. 서버에서 파일을 Storage(S3 등)에 저장
3. 서버가 Storage에서 받은 응답을 처리
4. 클라이언트에게 응답 반환
5. 클라이언트에서 메시지 전송 API 호출
6. 서버가 메시지를 DB에 저장
7. 클라이언트에게 메시지 전송 성공 응답 반환
8. 클라이언트가 메시지 목록 조회 요청 (GET /messages)
9. 클라이언트는 Path(S3 URL)를 이용해 Storage에서 직접 파일 접근
=> 첨부 파일 사전 업로드 방식은 사용자 경험, 시스템 안정성, 서버 자원 관리 측면에서 명확한 이점을 제공한다. 구현 시 여러 기술적 고려사항이 있지만, 이러한 복잡성을 잘 관리한다면 사용자 만족도와 시스템 효율성을 크게 향상시킬 수 있다. 대부분의 현대적인 웹 서비스가 사전 업로드 방식을 선택하는 것은 단순한 기술적 선택이 아닌, 더 나은 사용자 경험을 위한 의도적인 설계 결정이다.
Storage는 인터넷에 노출되지만, 서버(DB)는 내부망에서 관리해야 함
🔹 만약 파일 업로드와 메시지 전송을 하나의 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;
}
메시지를 보내지 않고 업로드한 파일이 계속 DB에 남아 있으면, 불필요한 파일 데이터가 Storage와 DB에 쌓이게 됨.
해결책 : 일정 시간이 지나면 사용되지 않은 파일을 정리해야 함.
스케쥴러로 처리
@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);
}
}