매일 조금씩

Spring boot와 JPA로 파일 업로드 API 구현(서버/DB에 저장, PDF를 이미지, 이미지를 썸네일로 변환) 본문

Spring Framework

Spring boot와 JPA로 파일 업로드 API 구현(서버/DB에 저장, PDF를 이미지, 이미지를 썸네일로 변환)

mezo 2021. 12. 13. 14:35
728x90
반응형

파일은 pdf 파일만 받는 것으로 프론트에서 처리한다.

pk인 'document_id'는 auto_increment이다. 

대략적으로 클래스는 다음과 같이 구성하였다. 

 

 

 

 

 

로직은 다음과 같다.

  1. 프론트에서 pdf 파일(MultipartFile)과 roomId(@RequestParam)를 받는다.
  2. 파일 메타를 DB 테이블에 insert 한다. (pk 가 auto_increment 이기 때문에 id 값 가져오기위해 먼저 실행)
  3. pdf 파일을 이미지와 썸네일로 변환하여 원본 pdf파일과 함께 서버에 저장한다.
  4. DB의 파일 메타를 update 한다. (파일 변환여부 등) 

 

pdfbox와 imgscalr 라이브러리를 사용하기 때문에 다음 dependencies를 추가해주어야한다.

> build.gradle

implementation group: 'org.apache.pdfbox', name: 'pdfbox', version: '2.0.24'
implementation group: 'org.imgscalr', name: 'imgscalr-lib', version: '4.2'

 

1. Repository

> repository / DocumentRepository.java

package com.tmax.meeting.document.repository;

import com.tmax.meeting.document.model.Document;
import java.util.List;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface DocumentRepository extends JpaRepository<Document, Long>{
  Document save(Document document);
}

JPA 에선 save가 insert, update 역할을 하므로 save 하나로 위에 명시해둔 로직의 2,4 번이 가능하다.

(id값을 set하고 save 하면 insert 되지 않고, 알아서 DB 테이블에서 해당 id를 찾아 update한다)

 

 

2. Model

> dta / ddl.sql

-- auto-generated definition
create table document
(
    document_id      bigint auto_increment
        primary key,
    created_at       datetime(6)      null,
    current_page_num int default 0    not null,
    file_name        varchar(255)     null,
    file_size        bigint           null,
    is_converted     bit default b'0' not null,
    is_shared        bit default b'0' not null,
    page_num         int              not null,
    room_id          varchar(255)     null,
    updated_at       datetime(6)      null
);

> model / Document.java

package com.tmax.meeting.document.model;

import java.sql.Timestamp;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

@Entity
@Data
@NoArgsConstructor
@Table(name="DOCUMENT")
public class Document {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  Long documentId;
  String roomId;
  String fileName;
  @CreationTimestamp
  @Column(updatable = false)
  Timestamp createdAt;
  @UpdateTimestamp
  Timestamp updatedAt;
  Long fileSize;
  int pageNum;
  @ColumnDefault("0")
  int currentPageNum;
  @ColumnDefault("false")
  boolean isShared;
  @ColumnDefault("false")
  boolean isConverted;

  @Override
  public String toString() {
    StringBuilder sb = new StringBuilder();
    sb.append(this.documentId + "\n");
    sb.append(this.roomId + "\n");
    sb.append(this.fileName);
    return sb.toString();
  }
}

위 코드는 실제 DB의 document 테이블과 같게 작성되어야한다.

이를 위해 각종 annotation을 사용하고 있다.

 

@Entity : 객체 매핑. 이게 붙은 클래스는 JPA가 관리하는 것으로 엔티티라고 불림
@Data : @Getter/@Setter,@ToString,@EqualsAndHashCode, @RequiredArgsConstructor를 합쳐놓은 종합 선물 세트@NoArgsConstructor : 파라미터가 없는 생성자 자동 생성
@Table(name="DOCUMENT") : 테이블 매핑. 엔티티와 매핑할 테이블을 지정.

@Id : 기본키 매핑

@GeneratedValue(strategy = GenerationType.IDENTITY) : 기본키 생성을 DB에 위임 (=AUTO_INCREMENT)

@CreationTimestamp : INSERT 쿼리가 발생할 때, 현재 시간을 값으로 채워서 쿼리 생성

@Column(updatable = false) : UPDATE시 값이 안바뀌도록 컬럼을 명시

@UpdateTimestamp : UPDATE 쿼리가 발생할 때, 현재 시간을 값으로 채워서 쿼리를 생성

@ColumnDefault() : 컬럼의 DEFAULT 값을 지정

 

 

3. Dto

dto는 프론트와 통신에서

프론트의 request와 그에 대한 response로 보내는 데이터를 담기 위한 객체이다. 

 

> dto/ RequestModel.java

package com.tmax.meeting.document.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

@AllArgsConstructor
@Builder
@Getter
@Setter
public class RequestModel {
  private Long documentId;
  private String roomId;
  private Long fileSize;
  private int pageNum;
  private boolean isShared;
  private boolean converted;
  private String fileName;

}

 

> dto/ ResponseModel.java

package com.tmax.meeting.document.dto;

import java.io.File;
import java.util.List;
import lombok.Data;

@Data
public class ResponseModel {
  private Long documentId;
  private String roomId;
  private String fileName;
  private Long fileSize;
  private int pageNum;
  private int currentPageNum;
  private boolean isShared;
  private boolean converted;
  private List<File> thumbnailList;
  private File image;
  private String createdAt;
}

 

 

4. Service

> service / DocService.java

package com.tmax.meeting.document.service;

import com.tmax.meeting.document.dto.ResponseModel;
import com.tmax.meeting.document.model.Document;
import java.io.IOException;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;

public interface DocService {
  ResponseModel addDocument(MultipartFile file, String roomId);
  ResponseModel updateDocument(Document document);
}

인터페이스이다.

 

> service / DocServiceImpl.java

package com.tmax.meeting.document.service;

import com.tmax.meeting.document.dto.ResponseModel;
import com.tmax.meeting.document.model.Document;
import com.tmax.meeting.document.repository.DocumentRepository;
import com.tmax.meeting.document.util.DirectoryUtil;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;
import javax.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
@Slf4j
public class DocServiceImpl implements DocService {

  @Autowired
  private DocumentRepository documentRepository;
  private Document newDocument = new Document();

  private ModelMapper modelMapper = new ModelMapper();

  public ResponseModel addDocument(MultipartFile file, String roomId) {
    String fileName = file.getOriginalFilename();
    Long fileSize = file.getSize();

    newDocument.setDocumentId(0l);
    newDocument.setRoomId(roomId);
    newDocument.setFileName(fileName);
    newDocument.setFileSize(fileSize);
    newDocument.setPageNum(0);
    newDocument.setShared(false);
    newDocument.setConverted(false);

    newDocument = documentRepository.save(newDocument);

    return modelMapper.map(newDocument, ResponseModel.class);
  }

  @Override
  public ResponseModel updateDocument(Document document) {
    newDocument = documentRepository.save(document);

    return modelMapper.map(newDocument, ResponseModel.class);
  }
}

addDocument 메서드는 메타를 insert 하고, updateDocument 메서드는 메타를 update 한다.

 

 

5. Util

util은 서비스에서 이루어지는 공통되는 작업을 묶어 캡슐화한 것이다.

 

여기선 프론트로부터 확장자가 pdf인 파일만 받아오는데

아래의 ConverterUtil 클래스는

pdf 파일의 각 페이지를 이미지로, 이미지는 썸네일로 변환한다. 

> util / ConverterUtil.java

package com.tmax.meeting.document.util;

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.imgscalr.Scalr;

public class ConverterUtil {

  private File pdfFile;
  private DirectoryUtil directory;

  public ConverterUtil(File pdfFile, DirectoryUtil directory) {
    this.pdfFile = pdfFile;
    this.directory = directory;
  }

  public int getNumberOfPages() throws IOException {
    PDDocument pdDocument = PDDocument.load(pdfFile);
    int count = pdDocument.getNumberOfPages();
    pdDocument.close();
    return count;
  }

  public void pdfToPng() throws IOException {
    PDDocument pdDocument = PDDocument.load(pdfFile);

    PDFRenderer pdfRenderer = new PDFRenderer(pdDocument);

    for (int i = 0; i < pdDocument.getNumberOfPages(); i++) {
      BufferedImage bufferedImage = pdfRenderer.renderImage(i, 2.0f);
      ImageIO.write(bufferedImage, "png",
          new File(Paths.get(directory.getImageDirectoryPath(), Integer.toString(i + 1) + ".png")
              .toString()));
    }
    pdDocument.close();
  }

  public static final int THUMB_WIDTH_SIZE = 250;

  public void makeThumbnails() throws IOException {
    File imageDirectory = new File(this.directory.getImageDirectoryPath());
    for (String fileName : imageDirectory.list()) {
      BufferedImage originalImage = ImageIO
          .read(new File(Paths.get(this.directory.getImageDirectoryPath(), fileName).toString()));
      BufferedImage thumbnail = Scalr.resize(originalImage, THUMB_WIDTH_SIZE,
          (THUMB_WIDTH_SIZE * originalImage.getHeight()) / originalImage.getWidth());

      ImageIO.write(thumbnail, "png",
          new File(Paths.get(this.directory.getThumbnailDirectoryPath(), fileName).toString()));
    }
    ;
  }

}

 

아래의 DirectoryUtil 클래스는 File 객체를 만들기 위한 클래스이다. 

각 디렉토리의 서버경로가 여러가지여서 코드가 더러워지는 것을 아래 클래스로 감추었다.

makeDirectories()는 서버에 디렉토리를 만들어주고 나머지 getter 함수들은 경로를 return한다. 

> util / DirectoryUtil.java

package com.tmax.meeting.document.util;

import java.io.File;
import java.nio.file.Paths;

public class DirectoryUtil {

  private Long documentId;

  public DirectoryUtil(Long documentId) {
    this.documentId = documentId;
  }

  public void makeDirectories() {

    File directory = new File(getDirectoryPath());
    File imageDirectory = new File(getImageDirectoryPath());
    File thumbnailDirectory = new File(getThumbnailDirectoryPath());

    try {
      if (!directory.exists()) {
        directory.mkdirs();
      }

      if (!imageDirectory.exists()) {
        imageDirectory.mkdirs();
      }

      if (!thumbnailDirectory.exists()) {
        thumbnailDirectory.mkdirs();
      }
    } catch (Exception e) {
      throw new RuntimeException("Some directories was not exists and created");
    }
  }

  public String getDirectoryPath() {
    return new File(Paths.get("documents", Long.toString(documentId)).toString()).getAbsolutePath();
  }

  public String getImageDirectoryPath() {
    return Paths.get(getDirectoryPath(), "image").toString();
  }

  public String getThumbnailDirectoryPath() {
    return Paths.get(getDirectoryPath(), "thumbnail").toString();
  }


}

 

 

6. Controller

프론트에서 pdf 파일과 roomId를 받아서 로직을 수행후, responseModel을 return한다.

여기서 pdf 파일변환, 서버 저장 등이 수행된 후에 메타를 테이블에 insert하지 않고, 가장 먼저 insert를 하는 이유는..

pk인 documentId가 auto_increment여서 insert해야 값을 볼수 있기 때문이다. 

> controller/ DocRestController.java

package com.tmax.meeting.document.controller;

import com.tmax.meeting.document.dto.ResponseModel;
import com.tmax.meeting.document.model.Document;
import com.tmax.meeting.document.service.DocService;
import com.tmax.meeting.document.util.ConverterUtil;
import com.tmax.meeting.document.util.DirectoryUtil;
import java.io.File;
import java.io.IOException;
import java.util.List;
import javax.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

@Controller
@RequestMapping("/document")
@Slf4j
@CrossOrigin(origins = "*", allowedHeaders = "*")
public class DocRestController {

  @Autowired
  private final DocService docService;

  private ModelMapper modelMapper = new ModelMapper();

  @Autowired
  public DocRestController(DocService docService) {
    this.docService = docService;
  }

  @Transactional
  @ResponseBody
  @PostMapping(value = "/upload")
  public ResponseModel uploadDocument(MultipartFile file,
      @RequestParam(value = "roomId") String roomId)
      throws IOException {

    if (file.isEmpty()) {
      throw new RuntimeException("the file is empty.");
    }

    try{
      ResponseModel newDocument = docService.addDocument(file, roomId);
      DirectoryUtil directoryUtil = new DirectoryUtil(newDocument.getDocumentId());

      directoryUtil.makeDirectories();

      File toWrite = new File(directoryUtil.getDirectoryPath(), file.getOriginalFilename());
      file.transferTo(toWrite);

      ConverterUtil converter = new ConverterUtil(toWrite, directoryUtil);
      converter.pdfToPng();
      converter.makeThumbnails();

      Document toSave = modelMapper.map(newDocument, Document.class);
      toSave.setConverted(true);
      toSave.setPageNum(converter.getNumberOfPages());
      return docService.updateDocument(toSave);
    } catch(Exception e){
      throw new RuntimeException("upload fail");
    }

  }

}

 

728x90
반응형