Skip to content

feat(media): 목록용 축소본 생성 - #124

Merged
SD-gif merged 2 commits into
mainfrom
feat/media-thumbnail
Sep 19, 2026
Merged

SD-gif merged 2 commits into
mainfrom
feat/media-thumbnail

Conversation

@SD-gif

@SD-gif SD-gif commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

커뮤니티 목록이 원본 사진을 그대로 받던 것을 고쳤음.

왜

  • 목록의 thumbnailUrl 이 첫 사진의 원본 주소였음. 실제 사진이 장당 0.7MB
  • 목록은 한 번에 20건을 부름 → 첫 화면에서만 10MB 가까이 내려받음
  • 목록에 그려지는 크기는 한 변 200px 안팎

변경 사항

  • ThumbnailGenerator 추가. 긴 변 480px JPEG, JDK 내장 ImageIO 만 사용(새 의존성 없음)
  • 업로드가 원본 옆에 ..._thumb.jpg 로 사본을 올리고 응답에 thumbnailUrl 추가
  • post_media.thumbnail_url 추가 (V22). 게시물 작성·수정이 업로드 응답 값을 그대로 저장
  • 목록 응답은 사본을 쓰고, 없으면 원본으로 돌아감. 상세 mediaList[] 에도 thumbnailUrl 추가
  • 정리 배치와 완전 삭제가 사본 주소까지 봄. 원본만 보면 쓰이는 사본을 고아로 지워 목록 사진이 전부 깨짐
  • API_SPEC(C-2, C-17), ERD 16절, api-change-log 갱신

검증

  • 테스트 568개 통과 (축소 4, 업로드 키 3, 목록 대체 2, 사본 보존 1 추가)
  • V22 로컬 PostgreSQL 적용 확인, 피드·상세 응답 실측 확인

남은 것

  • EXIF 회전 미처리. 세로로 찍은 사진의 사본만 누울 수 있음. 처리 추가 여부 논의 필요
  • 기존 사진은 사본 없음. 계속 원본을 씀. 일회성 보정 작업 별도
  • 실제 S3 업로드는 로컬에서 검증 불가(로컬은 S3 꺼짐). dev 배포 후 확인 필요
  • 클라이언트는 feat/media-thumbnail 브랜치. 서버 먼저 머지

🤖 Generated with Claude Code

SD-gif and others added 2 commits September 18, 2026 02:15
휴대폰 사진은 장당 수백 KB~수 MB 라, 스무 건을 한 번에 부르는 커뮤니티 목록이
원본을 쓰면 첫 화면에서만 10MB 가까이 내려받는다. 목록에 그려지는 크기는 한 변
200px 안팎이라 그만한 화질이 필요 없다.

업로드가 긴 변 480px JPEG 사본을 원본 옆에 올리고, 게시물이 그 주소를 저장한다.
사본이 없는 사진은 원본으로 돌아간다. 저장소 정리와 게시물 완전 삭제가 사본까지
함께 다루도록 질의를 넓혔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main 에 들어온 '게시물 수정으로 빠진 사진 파일 정리'(#123)와 겹친다. 그쪽은 요청의
원본 주소에 없는 딸린 파일을 지우는데, 축소본이 생기면서 딸린 파일 주소에 축소본이
함께 들어온다. 그대로 두는 사진의 축소본까지 지워져 목록 사진이 깨지므로, 남길
목록에 요청의 thumbnailUrl 도 넣는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🤖 Gemini PR Review

분석 결과 실제 버그 및 런타임 오류 가능성이 있는 부분이 발견되어 아래와 같이 리뷰를 작성합니다.


1. [HIGH] JPQL 내 UNION 키워드 사용으로 인한 구문 분석 에러 및 런타임 실패 가능성

  • 파일: src/main/java/com/server/post/repository/PostMediaRepository.java
  • 라인: 29~33, 42~47
  • 문제점:
    JPQL(Java Persistence Query Language)은 표준 사양에서 UNION 키워드를 지원하지 않습니다. Hibernate 6.x 이상 버전부터 JPQL 내 UNION을 제한적으로 지원하기 시작했으나, 프로젝트의 Hibernate 버전이 낮거나 타 JPA 구현체를 사용하는 환경에서는 애플리케이션 구동 시점(Query Validation) 또는 해당 메서드 호출 시점에 QuerySyntaxException이 발생하여 컴파일/런타임 에러가 발생합니다.
  • 개선 제안:
    UNION을 사용하는 대신, 단일 JPQL 쿼리에서 OR 조건을 사용하거나 필요한 컬럼들만 프로젝션(Projection)하여 조회한 뒤 애플리케이션 레이어에서 병합하는 방식으로 수정해야 합니다.
  • 개선 예시:
public interface PostMediaRepository extends JpaRepository<PostMedia, Long> {

    // UNION 대신 OR 조건을 사용하여 단일 쿼리로 해결
    @Query("select m.url from PostMedia m where m.url in :urls or m.thumbnailUrl in :urls")
    List<String> findUrlsIn(@Param("urls") Collection<String> urls);

    // 엔티티 전체를 조회하지 않고 필요한 컬럼만 Object[]로 조회 후 서비스 레이어에서 평탄화(Flatten) 처리
    @Query("select m.url, m.thumbnailUrl from PostMedia m where m.post.id in :postIds")
    List<Object[]> findUrlsAndThumbnailsByPostIdIn(@Param("postIds") Collection<Long> postIds);
}

2. [HIGH] MultipartFile 스트림 중복 소비로 인한 썸네일 생성 실패 가능성

  • 파일: src/main/java/com/server/media/service/MediaService.java
  • 라인: 59~62
  • 문제점:
    MediaService.upload 메서드에서 file.getInputStream()을 호출하여 원본 파일을 S3에 업로드한 후, thumbnailUrl 메서드 내에서 다시 file.getBytes()를 호출하고 있습니다.
    일부 서블릿 컨테이너 환경(예: Tomcat)의 MultipartFile 구현체는 내부 입력 스트림을 한 번만 소비할 수 있도록 제한하거나, 첫 번째 스트림 소비 완료 후 임시 파일을 정리합니다. 이로 인해 thumbnailUrl 내부의 file.getBytes() 호출 시 스트림이 이미 닫혔거나 비어 있어 IOException이 발생하고, 결과적으로 썸네일 생성이 항상 실패하여 null이 반환될 수 있습니다.
  • 개선 제안:
    원본 업로드 전에 파일 데이터를 바이트 배열로 먼저 확보한 뒤, 이를 각각 원본 업로드 스트림과 썸네일 생성 스트림으로 분기하여 사용하도록 구조를 변경해야 합니다.
  • 개선 예시:
    public MediaUploadListResponse upload(Long userId, List<MultipartFile> files) {
        MediaStorage storage = requireStorage();
        List<MediaFormat> formats = formats(files);

        List<MediaUploadResponse> uploaded = new ArrayList<>();
        for (int index = 0; index < files.size(); index++) {
            MultipartFile file = files.get(index);
            MediaFormat format = formats.get(index);
            String key = key(format);
            
            try {
                // 파일 데이터를 메모리에 한 번만 로드
                byte[] fileBytes = file.getBytes();
                
                try (InputStream originalContent = new ByteArrayInputStream(fileBytes)) {
                    String url = storage.upload(key, format.getContentType(), originalContent, fileBytes.length);
                    
                    // 이미 확보된 fileBytes를 재사용하여 스트림 중복 소비 방지
                    String thumbUrl = null;
                    if (format.getMediaType() == MediaType.IMAGE) {
                        thumbUrl = createThumbnail(storage, key, fileBytes);
                    }
                    
                    uploaded.add(new MediaUploadResponse(url, thumbUrl, format.getMediaType()));
                }
            } catch (IOException exception) {
                throw new BusinessException(ErrorCode.INVALID_MEDIA_FILE, exception);
            }
        }
        log.info("미디어 업로드 완료 userId={} count={}", userId, uploaded.size());
        return new MediaUploadListResponse(List.copyOf(uploaded));
    }

    private String createThumbnail(MediaStorage storage, String key, byte[] fileBytes) {
        try {
            Optional<byte[]> thumbnail = thumbnailGenerator.generate(fileBytes);
            if (thumbnail.isEmpty()) {
                return null;
            }
            byte[] bytes = thumbnail.get();
            return storage.upload(
                    thumbnailKey(key), ThumbnailGenerator.CONTENT_TYPE,
                    new ByteArrayInputStream(bytes), bytes.length);
        } catch (RuntimeException exception) {
            log.warn("축소본을 올리지 못했다. 원본만 쓴다. key={}", key, exception);
            return null;
        }
    }

3. [MEDIUM] 대용량 이미지 업로드 시 file.getBytes()로 인한 OutOfMemoryError(OOM) 발생 위험

  • 파일: src/main/java/com/server/media/service/ThumbnailGenerator.java
  • 라인: 54
  • 문제점:
    ThumbnailGenerator.generate(byte[] source)는 이미지 전체 데이터를 JVM 힙 메모리에 바이트 배열로 적재한 상태에서 동작합니다. 고해상도 이미지나 대용량 파일이 동시에 여러 개 업로드되는 고부하 상황에서 JVM 메모리가 고갈되어 OutOfMemoryError가 발생할 수 있습니다. ImageIO.read()는 InputStream을 직접 지원하므로, 굳이 전체 파일을 byte[]로 변환하여 메모리에 유지할 필요가 없습니다.
  • 개선 제안:
    ThumbnailGenerator.generate 메서드가 byte[] 대신 InputStream을 직접 받도록 변경하고, ImageIO.read(InputStream)을 사용하여 스트림 방식으로 이미지를 읽어오도록 개선해야 합니다.
  • 개선 예시:
public class ThumbnailGenerator {
    // ... 기존 상수 생략

    public Optional<byte[]> generate(InputStream inputStream) {
        if (inputStream == null) {
            return Optional.empty();
        }
        try {
            // byte[] 전체를 메모리에 올리지 않고 스트림에서 직접 이미지를 읽음
            BufferedImage original = ImageIO.read(inputStream);
            if (original == null) {
                return Optional.empty();
            }
            int longestEdge = Math.max(original.getWidth(), original.getHeight());
            if (longestEdge <= MAX_EDGE) {
                return Optional.empty();
            }
            return Optional.of(encode(resize(original, (double) MAX_EDGE / longestEdge)));
        } catch (IOException | RuntimeException exception) {
            log.warn("Thumbnail generation failed. Falling back to the original image.", exception);
            return Optional.empty();
        }
    }
}

Model: `gemini-3.5-flash` · API key: `PRIMARY` · Commit: `3841a07`

@SD-gif
SD-gif merged commit 7e381f9 into main Sep 19, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant