관리 메뉴

거니의 velog

(16) 상품 API 서버 구성하기 1 본문

SpringBoot_React 풀스택 프로젝트

(16) 상품 API 서버 구성하기 1

Unlimited00 2024. 2. 29. 12:26

* 간단한 Todo 예제로 프로젝트의 기본 구성을 경험했다면 이제는 조금 주의해야 하는 데이터를 다룰 것이다. 이번 장에서는 첨부파일이 여러 개 포함되는 상품 데이터를 예제로 다룬다. 상품 데이터는 첨부파일을 같이 처리하기 때문에 썸네일 이미지의 생성이나 데이터의 구조가 기존과 달라지게 된다.

* 이번 장의 개발 목표는 다음과 같다.

(1) 파일 업로드와 다운로드 처리

(2) 하나의 상품에 여러 이미지를 가지는 데이터 처리

(3) 첨부파일이 있는 API 개발과 테스트

1. 파일 업로드를 위한 설정

* 파일 업로드에 필요한 모든 기능은 이미 스프링 Web 관련 라이브러리에 존재하므로 프로젝트의 application.properties에는 이와 관련된 설정을 추가한다.

* 예제에서는 프로젝트의 실행 폴더에 upload 폴더를 생성해서 파일들을 보관하도록 한다.

# 데이터베이스 관련 설정
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/malldb
spring.datasource.username=malldbuser
spring.datasource.password=malldbuser

# JPA에서 생성하는 SQL 설정
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true

# 파일 업로드 관련 설정
spring.servlet.multipart.max-request-size=30MB
spring.servlet.multipart.max-file-size=10MB

com.unlimited.upload.path=upload

* 추가된 설정은 파일 하나의 최대 크기를 10MB로 제한하고 한 번에 전송하는 데이터는 30MB로 제한하는 설정이다. 마지막 라인은 작성하는 코드 내에서 변수로 사용하기 위한 설정이다.


(1) 상품 정보 처리를 위한 DTO

* 브라우저에서 첨부파일을 포함해서 전송되는 데이터를 하나의 DTO 타입으로 처리하기 위해서 ProductDTO라는 클래스를 추가한다.

* ProductDTO는 상품의 이름이나 설명 등과 같은 문자열과 함께 여러 개의 첨부파일을 의미하는 MultipartFile의 리스트를 가지도록 설계한다.

package com.unlimited.mallapi.dto;

import java.util.*;

import org.springframework.web.multipart.MultipartFile;

import lombok.*;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTO {

    // 상품 번호
    private Long pno;
    // 상품 이름
    private String pname;
    // 상품 가격
    private int price;
    // 상품 설명
    private String pdesc;
    // 삭제 여부를 나타내는 플래그
    private boolean delFlag;

    // 상품에 첨부된 파일 목록
    @Builder.Default
    private List<MultipartFile> files = new ArrayList<>();

    // 업로드된 파일명 목록
    @Builder.Default
    private List<String> uploadFileNames = new ArrayList<>();

}

* ProductDTO의 내부에 있는 MulripartFile의 리스트인 files는 새로운 상품의 등록과 수정 작업 시에 사용자가 새로운 파일을 업로드할 때 사용한다. ProductDTO에는 파일과 관련해서 2가지 형태의 데이터를 보관할 수 있도록 설정되어 있다.

* 멤버 필드 중에서 uploadFileNames는 업로드가 완료된 파일의 이름만 문자열로 보관한 리스트이다. uploadFileNames 문자열로 업로드된 결과만을 가지고 있기 때문에 이를 이용해서 데이터베이스에 파일 이름들을 처리하는 용도로 사용한다. 반면에 files는 새롭게 서버에 보내지는 실제 파일 데이터를 의미한다.


2. 컨트롤러에서의 파일 처리

* controller 패키지에는 ProductController를 생성해서 상품 데이터를 처리한다. ProductController에서는 ProductDTO의 files를 활용해서 전송하는 첨부파일들을 처리하고, 저장된 파일들의 이름은 나중에 데이터베이스에 보관하는 방식으로 사용한다.

* ProductController의 개발 전 단계로 실제 파일을 저장하는 역할은 util 패키지를 구성하고 CustomFileUtil 클래스를 추가해서 처리한다.

* CustomFileUtil은 파일 데이터의 입출력을 담당할 것이다. 프로그램이 시작되면 upload라는 이름의 폴더를 체크해서 자동으로 생성하도록 @PostConstruct를 이용하고, 파일 업로드 작업은 saveFiles()로 작성한다.

package com.unlimited.mallapi.util;

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.ArrayList;
import java.util.List;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Component
@Log4j2
@RequiredArgsConstructor
public class CustomFileUtil {

    // 파일 업로드 경로를 설정하는 프로퍼티
    @Value("${com.unlimited.upload.path}")
    private String uploadPath;

    // 초기화 메서드
    @PostConstruct
    public void init() {
        // 업로드 경로의 폴더를 생성하고 절대 경로를 설정
        File tempFolder = new File(uploadPath);
        if (!tempFolder.exists()) {
            tempFolder.mkdirs();
        }
        uploadPath = tempFolder.getAbsolutePath();
        log.info("---------------------------------------------");
        log.info(uploadPath);
    }

    // 다중 파일 업로드를 처리하는 메서드
    public List<String> saveFiles(List<MultipartFile> files) throws RuntimeException {
        // 파일이 없으면 빈 리스트 반환
        if (files == null || files.size() == 0) {
            return List.of();
        }

        // 업로드된 파일명을 저장할 리스트
        List<String> uploadNames = new ArrayList<>();

        // 각 파일에 대해 처리
        for (MultipartFile multipartFile : files) {
            // 파일명에 UUID를 추가하여 중복을 방지하고, 저장할 경로 설정
            String savedName = UUID.randomUUID().toString() + "_" + multipartFile.getOriginalFilename();
            Path savePath = Paths.get(uploadPath, savedName);

            // 파일 복사를 통해 업로드 수행
            try {
                Files.copy(multipartFile.getInputStream(), savePath);
                uploadNames.add(savedName);
            } catch (IOException e) {
                // 예외 발생 시 런타임 예외로 감싸서 전파
                throw new RuntimeException(e.getMessage());
            }
        } // end for

        // 업로드된 파일명 리스트 반환
        return uploadNames;
    }

}

* CustomFileUtil의 saveFiles()는 파일 저장 시에 중복된 이름의 파일이 저장되는 것을 막기 위해서 UUID로 중복이 발생하지 않도록 파일 이름을 구성한다. 업로드하는 파일의 이름은 'UUID값_파일명'의 형태로 구성될 것이다.

* saveFiles()의 활용은 controller 패키지를 구성하고 ProductController를 추가해서 사용한다.

package com.unlimited.mallapi.controller;

import java.util.List;
import java.util.Map;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.unlimited.mallapi.dto.ProductDTO;
import com.unlimited.mallapi.util.CustomFileUtil;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@RequiredArgsConstructor
@Log4j2
@RequestMapping("/api/products")
public class ProductController {

    private final CustomFileUtil fileUtil;

    // 상품 등록 요청을 처리하는 메서드
    @PostMapping("/")
    public Map<String, String> register(ProductDTO productDTO) {
        log.info("register : {}", productDTO);

        // 요청으로 전달된 파일 목록을 받아온다.
        List<MultipartFile> files = productDTO.getFiles();

        // 파일을 저장하고 저장된 파일명 목록을 반환한다.
        List<String> uploadFileNames = fileUtil.saveFiles(files);

        // ProductDTO에 업로드된 파일명 목록을 설정한다.
        productDTO.setUploadFileNames(uploadFileNames);
        log.info(uploadFileNames);

        // 결과를 성공으로 응답한다.
        return Map.of("RESULT", "SUCCESS");
    }

}

* ProductController의 register() 는 등록 기능을 구현하기 위해서 사용하고 POST 방식으로 처리한다. 업로드가 처리되면 업로드된 파일의 숫자만큼 새로운 파일을 upload 폴더에 저장하게 된다.

* Postman으로 '/api/products/' 경로의 테스트를 진행한다(마지막에 / 로 끝나는 점을 주의). Body 탭으로 form-data 항목을 지정하면 text 혹은 file 을 선택할 수 있으므로 첨부파일의 테스트가 가능하다.

* 테스트를 진행하면 서버 쪽에는 전송된 pname, pdesc, files에 대한 로그가 남고 프로젝트가 실행되는 폴더 내부에 만들어진 upload 폴더에 파일이 생성되는 것을 확인할 수 있다.


(1) 썸네일 이미지 처리

* 업로드된 이미지 파일의 용량이 크다면 나중에 사용자들이 이미지를 보는데 많은 자원과 시간을 소비해야 하므로 이미지는 썸네일을 만들어서 처리한다(특히 모바일 환경에서는 가능하면 적은 양의 데이터로 파일을 보여주어야 성능이 향상된다). 썸네일로 마나들기 위해서 build.gradle에 Thumbnailator 라이브러리를 추가한다(라이브러리를 추가한 후에는 'Clean Java Server Workspace'를 실행해 주는 것이 좋다).

dependencies {
	(...)
	implementation 'net.coobird:thumbnailator:0.4.19'
}

* CustomFileUtil에서 saveFiles()는 파일을 저장할 때 이미지 파일이라면 썸네일을 생성하도록 코드를 수정한다. 썸네일은 원본 파일과 혼동하지 않게 파일의 맨 앞으로 's_'로 시작하고 나머지는 UUID와 파일 이름을 이용한다.

package com.unlimited.mallapi.util;

(...)
import net.coobird.thumbnailator.Thumbnails;

@Component
@Log4j2
@RequiredArgsConstructor
public class CustomFileUtil {

    (...)

    // 다중 파일 업로드를 처리하는 메서드
    public List<String> saveFiles(List<MultipartFile> files) throws RuntimeException {
        // 파일이 없으면 빈 리스트 반환
        if (files == null || files.size() == 0) {
            return List.of();
        }

        // 업로드된 파일명을 저장할 리스트
        List<String> uploadNames = new ArrayList<>();

        // 각 파일에 대해 처리
        for (MultipartFile multipartFile : files) {
            // 파일명에 UUID를 추가하여 중복을 방지하고, 저장할 경로 설정
            String savedName = UUID.randomUUID().toString() + "_" + multipartFile.getOriginalFilename();
            Path savePath = Paths.get(uploadPath, savedName);

            // 파일 복사를 통해 업로드 수행
            try {
                Files.copy(multipartFile.getInputStream(), savePath);

                // 여기에 썸네일 처리 코드 추가
                String contentType = multipartFile.getContentType();
                if (contentType != null && contentType.startsWith("image")) { // 이미지 여부 확인
                    Path thumbnailPath = Paths.get(uploadPath, "s_" + savedName);
                    Thumbnails.of(savePath.toFile())
                            .size(200, 200)
                            .toFile(thumbnailPath.toFile());
                }

                uploadNames.add(savedName);
            } catch (IOException e) {
                // 예외 발생 시 런타임 예외로 감싸서 전파
                throw new RuntimeException(e.getMessage());
            }
        } // end for

        // 업로드된 파일명 리스트 반환
        return uploadNames;
    }

}

* 썸네일 이미지 파일은 200px의 넓이나 높이를 가지게 된다(자동으로 가로나 세로 길이를 조정하기 때문에 이미지의 종횡비가 무시되는 일은 없다). 썸네일이 적용되면 이미지 파일이 업로드될 때는 원본 파일과 's_'로 시작되는 썸네일 파일이 생성되는 것을 확인할 수 있다.


(2) 업로드 파일 보여주기

* 업로드된 파일은 GET 방식으로 호출해서 브라우저에서 볼 수 있어야 한다. 브라우저는 'api/products/view/파일이름' 경로에서 파일 데이터를 볼 수 있게 구성한다. 현재 업로드된 파일들은 UUID 값이 적용된 파일의 이름인데 UUID가 포함되어 상당히 긴 문자열이기 때문에 간단히 테스트할 수 있도록 파일 하나를 업로드 폴더에 추가한다.

* CustomFileUtil에는 파일 데이터를 읽어서 스프링에서 제공하는 Resource 타입으로 반환하는 getFile()을 추가한다. getFile()은 스프링 프레임워크에서 제공하는 ResponseEntity와 Resource를 사용해서 응답 데이터를 생성한다.

package com.unlimited.mallapi.util;

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.ArrayList;
import java.util.List;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import net.coobird.thumbnailator.Thumbnails;

@Component
@Log4j2
@RequiredArgsConstructor
public class CustomFileUtil {

    // 파일 업로드 경로를 설정하는 프로퍼티
    @Value("${com.unlimited.upload.path}")
    private String uploadPath;

    // 초기화 메서드
    @PostConstruct
    public void init() {
        // 업로드 경로의 폴더를 생성하고 절대 경로를 설정
        File tempFolder = new File(uploadPath);
        if (!tempFolder.exists()) {
            tempFolder.mkdirs();
        }
        uploadPath = tempFolder.getAbsolutePath();
        log.info("---------------------------------------------");
        log.info(uploadPath);
    }

    // 다중 파일 업로드를 처리하는 메서드
    public List<String> saveFiles(List<MultipartFile> files) throws RuntimeException {
        // 파일이 없으면 빈 리스트 반환
        if (files == null || files.size() == 0) {
            return List.of();
        }

        // 업로드된 파일명을 저장할 리스트
        List<String> uploadNames = new ArrayList<>();

        // 각 파일에 대해 처리
        for (MultipartFile multipartFile : files) {
            // 파일명에 UUID를 추가하여 중복을 방지하고, 저장할 경로 설정
            String savedName = UUID.randomUUID().toString() + "_" + multipartFile.getOriginalFilename();
            Path savePath = Paths.get(uploadPath, savedName);

            // 파일 복사를 통해 업로드 수행
            try {
                Files.copy(multipartFile.getInputStream(), savePath);

                // 여기에 썸네일 처리 코드 추가
                String contentType = multipartFile.getContentType();
                if (contentType != null && contentType.startsWith("image")) { // 이미지 여부 확인
                    Path thumbnailPath = Paths.get(uploadPath, "s_" + savedName);
                    Thumbnails.of(savePath.toFile())
                            .size(200, 200)
                            .toFile(thumbnailPath.toFile());
                }

                uploadNames.add(savedName);
            } catch (IOException e) {
                // 예외 발생 시 런타임 예외로 감싸서 전파
                throw new RuntimeException(e.getMessage());
            }
        } // end for

        // 업로드된 파일명 리스트 반환
        return uploadNames;
    }

    // 파일 다운로드를 처리하는 메서드
    public ResponseEntity<Resource> getFile(String fileName) {
        // 파일 리소스 생성
        Resource resource = new FileSystemResource(uploadPath + File.separator + fileName);

        // 파일이 읽을 수 없는 경우, 기본 이미지로 대체
        if (!resource.isReadable()) {
            resource = new FileSystemResource(uploadPath + File.separator + "default.jpeg");
        }

        HttpHeaders headers = new HttpHeaders();
        try {
            // 파일의 MIME 타입을 가져와서 HTTP 헤더에 설정
            headers.add("Content-Type", Files.probeContentType(resource.getFile().toPath()));
        } catch (Exception e) {
            // 예외 발생 시 서버 내부 오류 응답
            return ResponseEntity.internalServerError().build();
        }

        // 파일 리소스를 응답에 담아서 반환
        return ResponseEntity.ok().headers(headers).body(resource);
    }

}

* getFile()은 파일의 종류마다 다르게 HTTP 헤더 'Content-Type' 값을 생성해야 하기 때문에 Files.probeContentType() 으로 헤더 메시지를 생성한다.

* CustomFileUtil의 getFile()은 ProductController에서 특정한 파일을 조회할 때 사용한다.

package com.unlimited.mallapi.controller;

import java.util.List;
import java.util.Map;

import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.unlimited.mallapi.dto.ProductDTO;
import com.unlimited.mallapi.util.CustomFileUtil;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@RequiredArgsConstructor
@Log4j2
@RequestMapping("/api/products")
public class ProductController {

    private final CustomFileUtil fileUtil;

    // 상품 등록 요청을 처리하는 메서드
    @PostMapping("/")
    public Map<String, String> register(ProductDTO productDTO) {
        log.info("register : {}", productDTO);

        // 요청으로 전달된 파일 목록을 받아온다.
        List<MultipartFile> files = productDTO.getFiles();

        // 파일을 저장하고 저장된 파일명 목록을 반환한다.
        List<String> uploadFileNames = fileUtil.saveFiles(files);

        // ProductDTO에 업로드된 파일명 목록을 설정한다.
        productDTO.setUploadFileNames(uploadFileNames);
        log.info(uploadFileNames);

        // 결과를 성공으로 응답한다.
        return Map.of("RESULT", "SUCCESS");
    }

    // 파일 조회 요청을 처리하는 메서드
    @GetMapping("/view/{fileName}")
    public ResponseEntity<Resource> viewFileGET(@PathVariable String fileName) {
        // 파일 다운로드를 처리하는 메서드 호출
        return fileUtil.getFile(fileName);
    }

}

* 코드를 적용하고 브라우저에서 실행해 보면 '/api/products/view/파일명'을 이용해서 원하는 이미지 파일을 브라우저에서 볼 수 있다. 간단히 확인하려면 upload 폴더에 aaa.jpg와 같은 파일을 올려서 확인하면 편하다(테스트 시에는 한글이나 공백이 있는 파일이름은 사용하지 않도록 주의한다).

- http://localhost:8080/api/products/view/html.png

- http://localhost:8080/api/products/view/css.png


(3) 서버 내부에서 파일 삭제

* 첨부파일은 수정이라는 개념이 존재하지 않고, 기존 파일들을 삭제하고 새로운 파일로 대체하는 개념이기 때문에 삭제하는 기능 역시 필요하다. 파일 삭제 기능은 파일 이름을 기준으로 한 번에 여러 개의 파일을 삭제하는 기능을 CustomFileUtil 내부에 deleteFiles() 로 구현해 둔다.

package com.unlimited.mallapi.util;

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.ArrayList;
import java.util.List;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import net.coobird.thumbnailator.Thumbnails;

@Component
@Log4j2
@RequiredArgsConstructor
public class CustomFileUtil {

    // 파일 업로드 경로를 설정하는 프로퍼티
    @Value("${com.unlimited.upload.path}")
    private String uploadPath;

    // 초기화 메서드
    @PostConstruct
    public void init() {
        // 업로드 경로의 폴더를 생성하고 절대 경로를 설정
        File tempFolder = new File(uploadPath);
        if (!tempFolder.exists()) {
            tempFolder.mkdirs();
        }
        uploadPath = tempFolder.getAbsolutePath();
        log.info("---------------------------------------------");
        log.info(uploadPath);
    }

    // 다중 파일 업로드를 처리하는 메서드
    public List<String> saveFiles(List<MultipartFile> files) throws RuntimeException {
        // 파일이 없으면 빈 리스트 반환
        if (files == null || files.size() == 0) {
            return List.of();
        }

        // 업로드된 파일명을 저장할 리스트
        List<String> uploadNames = new ArrayList<>();

        // 각 파일에 대해 처리
        for (MultipartFile multipartFile : files) {
            // 파일명에 UUID를 추가하여 중복을 방지하고, 저장할 경로 설정
            String savedName = UUID.randomUUID().toString() + "_" + multipartFile.getOriginalFilename();
            Path savePath = Paths.get(uploadPath, savedName);

            // 파일 복사를 통해 업로드 수행
            try {
                Files.copy(multipartFile.getInputStream(), savePath);

                // 여기에 썸네일 처리 코드 추가
                String contentType = multipartFile.getContentType();
                if (contentType != null && contentType.startsWith("image")) { // 이미지 여부 확인
                    Path thumbnailPath = Paths.get(uploadPath, "s_" + savedName);
                    Thumbnails.of(savePath.toFile())
                            .size(200, 200)
                            .toFile(thumbnailPath.toFile());
                }

                uploadNames.add(savedName);
            } catch (IOException e) {
                // 예외 발생 시 런타임 예외로 감싸서 전파
                throw new RuntimeException(e.getMessage());
            }
        } // end for

        // 업로드된 파일명 리스트 반환
        return uploadNames;
    }

    // 파일 다운로드를 처리하는 메서드
    public ResponseEntity<Resource> getFile(String fileName) {
        // 파일 리소스 생성
        Resource resource = new FileSystemResource(uploadPath + File.separator + fileName);

        // 파일이 읽을 수 없는 경우, 기본 이미지로 대체
        if (!resource.isReadable()) {
            resource = new FileSystemResource(uploadPath + File.separator + "default.jpeg");
        }

        HttpHeaders headers = new HttpHeaders();
        try {
            // 파일의 MIME 타입을 가져와서 HTTP 헤더에 설정
            headers.add("Content-Type", Files.probeContentType(resource.getFile().toPath()));
        } catch (Exception e) {
            // 예외 발생 시 서버 내부 오류 응답
            return ResponseEntity.internalServerError().build();
        }

        // 파일 리소스를 응답에 담아서 반환
        return ResponseEntity.ok().headers(headers).body(resource);
    }

    // 파일 삭제를 처리하는 메서드
    public void deleteFiles(List<String> fileNames) {
        if (fileNames == null || fileNames.size() == 0) {
            return;
        }
        fileNames.forEach(fileName -> {
            // 썸네일이 있는지 확인하고 삭제
            String thumbnailFileName = "s_" + fileName;
            Path thumbnailPath = Paths.get(uploadPath, thumbnailFileName);
            Path filePath = Paths.get(uploadPath, fileName);
            try {
                Files.deleteIfExists(filePath);
                Files.deleteIfExists(thumbnailPath);
            } catch (IOException e) {
                throw new RuntimeException(e.getMessage());
            }
        });
    }

}

* 파일의 삭제는 컨트롤러 계층 혹은 서비스 계층에서 데이터베이스 작업이 완료된 후에 필요 없는 파일들을 삭제하는 용도로 처리할 때 사용한다.