Develop

[Spring Boot] 게시판 - 09. 게시글 이미지 업로드 구현하기 (feat. 이미지 여러개 업로드 & 순서 관리) 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 09. 게시글 이미지 업로드 구현하기 (feat. 이미지 여러개 업로드 & 순서 관리)

230801 2025. 11. 30. 23:56

안녕하세요 .ᐟ

지난 시간에 파일 업로드 기본 구조를 만들었다면, 오늘은 게시글에 여러 이미지를 첨부하는 기능을 구현해보겠습니다.

실제 서비스처럼 게시글당 최대 10개의 이미지를 업로드하고, 순서를 관리하며, 삭제까지 할 수 있도록 만들어 보겠습니다.

 

구현할 기능

  • 게시글 복수 이미지 업로드 (최대 10개)
  • 이미지 순서 관리 (displayOrder)
  • 이미지 목록 조회
  • 개별 이미지 삭제
  • 이미지 파일 브라우저에서 보기

 


 

왜 별도 Entity가 필요한가?

처음엔 Post Entity에 String images 필드를 하나 만들어서 쉼표로 구분하면 되지 않을까? 라고 생각했습니다.

 

// 안 좋은 설계
private String images; // "image1.jpg,image2.jpg,image3.jpg"

하지만 이렇게 하면 아래 문제가 있기 때문에 별도 테이블로 분리했습니다.

  • 이미지 순서를 바꾸기 어려움
  • 특정 이미지만 삭제하기 복잡
  • 이미지별 메타데이터(원본 파일명, 크기) 저장 불가
  • 검색/필터링이 비효율적

 

 

PostImage Entity

게시글과 이미지는 1:N 관계입니다. 하나의 게시글에 여러 이미지가 달릴 수 있습니다.

  • post: 어느 게시글의 이미지인가? (@ManyToOne)
  • filePath: 실제 저장된 파일명 (UUID)
  • displayOrder: 이미지 표시 순서 (프론트에서 정렬에 사용)
  • originalFilename: 사용자가 업로드한 원본 파일명 (다운로드 시 사용)
  • fileSize: 용량 표시 및 통계에 활용

 

 

Post Entity에 연관관계 추가

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostImage> images = new ArrayList<>();

 

 

Cascade 옵션의 의미

  • CascadeType.ALL: Post 저장/삭제 시 PostImage도 함께 처리
  • orphanRemoval = true: Post에서 제거된 이미지는 DB에서도 자동 삭제
post.removeImage(image); // 이것만으로 DB에서도 DELETE!
  • orphanRemoval = true 덕분에 Post에서 제거한 이미지가 자동으로 DB에서도 삭제되기 때문에 일일이 repository.delete() 호출할 필요가 없습니다.

 

 

PostImageRepository

public interface PostImageRepository extends JpaRepository<PostImage, Long> {
    
    // 게시글의 모든 이미지 조회 (순서대로)
    List<PostImage> findByPostIdOrderByDisplayOrder(Long postId);

    // 게시글의 이미지 개수 카운트
    long countByPostId(Long postId);
}

 

 

 

Spring Data JPA의 메서드 이름 규칙을 따르면 쿼리를 자동 생성해줍니다.

SELECT * FROM post_images
WHERE post_id = ?
ORDER BY display_order;

 

 

 


 

FileService

복수 이미지 업로드

  • 게시글이 존재하는지 확인
  • 현재 이미지 개수 + 새로 올릴 개수 체크 (최대 10개)
    • 서버 용량 보호, 사용자 경험, 로딩시간 관리를 위해 최대 10개로 제한
  • displayOrder 자동 증가시키며 저장
long currentImageCount = postImageRepository.countByPostId(postId);
if (currentImageCount + files.size() > 10) {
    throw new FileStorageException("게시글당 최대 10개의 이미지만 업로드할 수 있습니다.");
}

int startOrder = (int) currentImageCount;
for (int i = 0; i < files.size(); i++) {
    // displayOrder는 startOrder + i로 자동 증가
}



 

이미지 삭제

  • 해당 게시글의 이미지만 삭제하도록 구현
// 이미지가 정말 이 게시글의 것인지 확인!
if (!image.getPost().getId().equals(postId)) {
    throw new UnauthorizedException("해당 게시글의 이미지가 아닙니다.");
}

 

이미지 파일보기

return ResponseEntity.ok()
    .contentType(MediaType.parseMediaType(contentType))
    .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"...\"")
    .body(resource);

 

Content-Disposition:

  • inline: 브라우저에서 바로 보기
  • attachment: 다운로드 강제

 

 


 

DTO 전략 (목록 vs 상세)

PostListResponse

  • 목록 조회에서 모든 이미지를 보내게 되면 네트워크 트래픽, 로딩시간이 증가되므로 썸네일과 이미지 개수만 보냅니다.
private String thumbnailPath;  // 첫 번째 이미지만
private Integer imageCount;    // 개수만 표시

 

 

PostDetailResponse

private List<PostImageResponse> images;  // 모든 이미지 정보

 

 

 

느낀 점

1. Cascade의 편리함

2. 에러 메시지 분기처리하는게 손이 많이 필요하긴해도 마음이 편하다.

3. DTO를 목록과 상세로 나눠서 쓰면서 성능을 고민하는 계기가 되었다.