Articles

PDF와 영상에서 썸네일을 만들고, 실패해도 분석은 멈추지 않게

올린 자료를 목록에서 알아보기 쉽게 PDF는 첫 페이지, 영상은 중간 장면을 썸네일로 만들어요. 파일 형식마다 다르게 처리한 방법과, 네이티브 라이브러리 오류까지 막아 낸 과정을 정리했어요.

DropThePitch의 사이드바에는 그동안 올린 자료가 목록으로 나와요. 제목만 있으면 어떤 자료였는지 떠올리기 어려워서, 자료마다 작은 썸네일을 붙였어요. 사용자는 PDF, 이미지, 영상, 마크다운 파일을 올릴 수 있는데, 형식마다 썸네일을 만드는 방법이 달라요.

형식마다 다르게

형식 썸네일
JPG, PNG, WEBP 원본 이미지를 그대로 써요
PDF 첫 페이지를 그림으로 만들어요
MP4 영상 중간의 한 장면을 잘라 내요
마크다운 만들지 않아요

이미지는 이미 그림이라 새로 만들지 않아요. 썸네일 주소가 원본 파일을 가리키게만 해요. 저장 공간도 아끼고 시간도 들지 않아요.

마크다운은 글자뿐이라 그림으로 만들 만한 것이 없어요. 글자를 이미지로 찍어 봐야 작은 사각형 안에서는 읽을 수 없어요. 썸네일이 없으면 화면에서 문서 아이콘을 보여줘요.

private byte[] extract(InputType type, byte[] bytes) throws Exception {
    return switch (type) {
        case PDF -> pdfThumbnailExtractor.extract(bytes);
        case MP4 -> videoThumbnailExtractor.extract(bytes);
        default -> null;
    };
}

PDF는 첫 페이지를 96 DPI로

PDF는 Apache PDFBox로 첫 페이지를 그려요. PDFBox는 자바로만 만들어져서 서버에 따로 설치할 프로그램이 없어요.

private static final float RENDER_DPI = 96f;
private static final int FIRST_PAGE = 0;

public byte[] extract(byte[] bytes) throws Exception {
    try (PDDocument document = Loader.loadPDF(bytes)) {
        BufferedImage image = new PDFRenderer(document).renderImageWithDPI(FIRST_PAGE, RENDER_DPI);
        return ThumbnailImageWriter.toThumbnail(image);
    }
}

해상도는 96 DPI로 정했어요. 인쇄용 300 DPI로 그리면 A4 한 장이 2480×3508픽셀이 되는데, 화면의 작은 사각형에 들어갈 그림에는 필요 없는 크기예요. 그리는 시간과 메모리만 늘어나요.

기획서의 첫 페이지는 대부분 표지라서, 제목과 대표 이미지가 들어 있어 썸네일로 알맞았어요.

영상은 첫 장면 대신 중간 장면

영상은 JavaCV로 FFmpeg를 불러서 한 장면을 잘라 내요.

public byte[] extract(byte[] bytes) throws Exception {
    try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(new ByteArrayInputStream(bytes))) {
        grabber.start();
        grabber.setTimestamp(grabber.getLengthInTime() / 2);

        try (Java2DFrameConverter converter = new Java2DFrameConverter()) {
            Frame frame = grabber.grabImage();

            if (frame == null) {
                log.warn("[extract] 영상에서 프레임을 얻지 못했습니다.");
                return null;
            }

            return ThumbnailImageWriter.toThumbnail(converter.getBufferedImage(frame));
        }
    }
}

처음에는 첫 장면을 썼어요. 그런데 소개 영상은 대부분 검은 화면이나 로고, 자막으로 시작해요. 썸네일이 전부 비슷한 검은 사각형이 돼서 구분할 수가 없었어요. 영상 길이의 절반 지점으로 옮기니 실제 화면이 나오는 경우가 훨씬 많았어요.

FFmpeg는 기본으로 자잘한 경고를 많이 출력해요. 서버 로그가 그 경고로 뒤덮이지 않게, 오류 수준의 로그만 남기도록 설정했어요.

@PostConstruct
void configureFfmpegLog() {
    FFmpegLogCallback.set();
    FFmpegLogCallback.setLevel(avutil.AV_LOG_ERROR);
}

파일은 한 번만 내려받아요

올린 파일은 S3에 저장돼요. 분석도 파일 내용이 필요하고, 썸네일도 파일 내용이 필요해요. 각자 내려받으면 같은 파일을 두 번 받아요. 영상은 수백 MB가 되기도 해서 낭비가 커요.

그래서 업로드된 파일을 처리하는 쪽에서 한 번만 내려받고, 그 내용을 분석과 썸네일 생성에 같이 넘겨요.

썸네일이 실패해도 분석은 계속돼요

썸네일은 화면을 보기 좋게 하는 장식이에요. 썸네일을 못 만들었다고 분석과 리포트가 멈추면 안 돼요. 그래서 썸네일 생성 전체를 감싸고, 실패하면 로그만 남기고 넘어가요.

} catch (Exception | LinkageError e) {
    log.error("[generate] 썸네일 생성 실패: projectId={}, type={}", projectId, type, e);
}

여기서 Exception만 잡지 않고 LinkageError까지 잡은 이유가 있어요.

FFmpeg는 자바가 아니라 운영체제용으로 만들어진 네이티브 라이브러리예요. 서버 환경에 맞는 라이브러리 파일을 찾지 못하면 UnsatisfiedLinkError가 나요. 그런데 이것은 Exception이 아니라 Error예요. catch (Exception e)로는 잡히지 않고 그대로 밖으로 빠져나가서, 같은 흐름에 있던 분석까지 멈춰 버려요.

UnsatisfiedLinkError는 LinkageError의 한 종류라서, LinkageError를 같이 잡으면 막을 수 있어요. 반대로 메모리가 부족할 때 나는 OutOfMemoryError는 일부러 잡지 않았어요. 그건 썸네일만의 문제가 아니라 서버 전체가 위험하다는 신호라서, 숨기지 않고 그대로 드러나게 했어요.

Error를 함부로 잡지 말라는 말은 맞아요. 다만 네이티브 라이브러리를 불러오는 곳은 예외예요. 그 기능 하나만 포기하면 되는 상황이라면, 그 범위에서만 잡는 것이 서비스 전체를 지키는 방법이에요.

정리

  • 이미지는 원본을 그대로 쓰고, 그림으로 만들 것이 없는 형식은 아이콘으로 대신해요.
  • PDF 썸네일은 화면용 해상도로 충분해요.
  • 영상은 도입부보다 중간 장면이 내용을 더 잘 보여줘요.
  • 큰 파일은 한 번만 내려받아서 여러 작업이 같이 써요.
  • 장식 기능의 실패가 핵심 기능을 멈추지 않게 하고, 네이티브 라이브러리라면 LinkageError까지 잡아요.
다음 글 팀 프로젝트 기여도를 대화로 풀어 주는 서비스를 만든 이유