[React+Spring] 파일 CRUD 처리

HJ·2024년 1월 24일
0

React+Spring

목록 보기
4/11

아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.

강의 코드를 그대로 따라 친 것이 아닌 제 나름대로 작성한 코드들이 있기 때문에 강의 코드와 동일하진 않습니다.


FileUtils


[ 경로, 디렉토리 설정 ]

@Component
@RequiredArgsConstructor
public class FileUtils {

    @Value("${ghkwhd.upload.path}")
    private String uploadPath;

    @PostConstruct
    public void init() {
        File tempFolder = new File(uploadPath);
        if (!tempFolder.exists()) {
            tempFolder.mkdir();
        }
        uploadPath = tempFolder.getAbsolutePath();
        log.info("uploadPath = {}", uploadPath);
    }
    ...
}

@Value 는 properties 나 yml 과 같은 설정파일에 설정한 내용을 가져와 변수를 초기화해주는 역할을 합니다. 개발 시에는 로컬 경로가 지정되어 있어 서버에 올릴 때는 경로가 수정될 수 밖에 없기에 관리를 용이하게 하기 위해 사용합니다.

@Component는 직접 개발한 클래스를 스프링 Bean 으로 등록하고자 하는 경우에 사용합니다. 스프링 빈으로 등록되고 해당 객체가 생성되었을 때 실행할 메서드를 지정하는 어노테이션은 @PostConstruct 입니다.

위의 예시는 properties 에 지정한 업로드 경로를 가져와 변수에 할당하고, 객체가 생성된 이후에 업로드 된 파일을 저장할 디렉터리가 있는지, 없는지 검사하여 없다면 해당 디렉터리를 만들어줍니다.



[ 파일 업로드 ]

public class FileUtils {
    ...
    // 업로드 된 파일명 반환
    public List<String> saveFiles(List<MultipartFile> files) throws RuntimeException {
        ...
        List<String> uploadNames = new ArrayList<>();

        for (MultipartFile file : files) {
            // 저장 이름 = 랜덤_원래 파일 이름
            String saveName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();
            Path savePath = Paths.get(uploadPath, saveName);    // 저장할 경로와 이름을 지정
            // 파일 저장
            try {
                Files.copy(file.getInputStream(), savePath);
                uploadNames.add(saveName);  // 업로드된 파일명 추가
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            
        }

        return uploadNames;
    }
}

파일을 저장하는 이름은 UUID 를 통해 겹치지 않도록 생성하고, 뒤에 원본 이름을 붙여줍니다. Path 를 통해 어떤 경로에 어떤 파일을 저장할지를 지정하고, File 를 통해 이를 저장합니다.

Path 클래스는 파일 및 디렉토리 경로를 나타내는 데 사용되며, toFile() 메서드를 이용하여 PathFile 로 변환할 수 있습니다.



[ 파일 조회 ]

public class FileUtils {
    ...
    // 파일 조회
    public ResponseEntity<Resource> getFile(String fileName) {
        Resource resource = new FileSystemResource(uploadPath + File.separator + fileName);

        // 이미지가 없는 경우에 디폴트 이미지를 반환
        if (!resource.isReadable()) {
            resource = new FileSystemResource(uploadPath + File.separator + "default.PNG");
        }

        HttpHeaders headers = new HttpHeaders();
        try {
            headers.add("Content-Type", Files.probeContentType(resource.getFile().toPath()));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return ResponseEntity.ok().headers(headers).body(resource);
    }
}

위의 코드는 파일을 조회하고, 해당 파일의 내용을 ResponseEntity<Resource> 형태로 반환하는 코드입니다. 조회된 Resource 를 HTTP body 에 담으면 해당 이미지를 확인할 수 있습니다.

Resource 는 스프링에서 제공하는 인터페이스로, 파일, 클래스패스, URL 등의 리소스를 추상화하는 역할을 합니다. 다양한 종류의 리소스에 일관된 방식으로 접근할 수 있도록 표준화된 메서드를 제공합니다.

FileSystemResourceResource 인터페이스를 구현한 구체적인 클래스 중 하나입니다. 파일 시스템에 있는 파일을 나타내며, 파일 시스템 경로를 기반으로 Resource 객체를 생성하며 파일 시스템 상의 파일을 읽거나 쓸 수 있도록 도와줍니다.




Entity 처리


[ @Embeddable, @Embedded ]

@Embeddable 은 JPA에서 엔티티 클래스에 내장(embedded) 타입을 사용하기 위해 지정하는 어노테이션입니다. 내장 타입은 여러 개의 속성으로 구성되어 있고, 이를 다른의 엔티티에 포함시켜 사용할 수 있도록 해줍니다.

내장 타입을 정의할 때는 @Embeddable 을 사용하여 해당 클래스가 내장 타입임을 명시하고, 실제로 내장 타입을 사용하는 엔티티 클래스에서는 @Embedded 를 사용하여 내장 타입을 포함시킵니다.


@Embeddable
public class Address {
    private String street;
    private String city;
    ...
}
@Entity
public class Employee {
    @Id
    private Long id;

    @Embedded
    private Address address;
    ...
}

Address@Embeddable 어노테이션을 사용하여 내장 타입임을 나타냅니다. Employee@Embedded를 사용하여 Address 내장 타입을 포함하고 있습니다. 이를 통해 Employee 는 Address의 속성을 포함하게 되며, Employee 테이블에 street, city 컬럼이 포함되어 저장됩니다.



[ @ElementCollection ]

@ElementCollection 은 JPA에서 사용되는 어노테이션으로, 엔티티 클래스에 컬렉션(리스트, 셋, 맵 등)을 내장 타입으로 저장하고자 할 때 사용됩니다. 이 어노테이션을 사용하면 컬렉션의 각 요소가 별도의 테이블에 저장되며, 이 테이블은 부모 엔티티와의 관계를 통해 매핑됩니다.

일대다 관계를 표현하기 위해 별도의 엔티티 클래스를 생성하는 것보다 @ElementCollection 을 사용하면 코드의 복잡성을 감소시킬 수 있습니다. 특히 간단한 데이터 모델을 갖는 경우에 불필요한 엔티티 클래스의 생성을 피할 수 있습니다.

또한 해당 컬렉션의 라이프사이클을 관리하기 용이합니다. 엔티티가 삭제되면 해당 컬렉션도 함께 삭제되며, 특정 엔티티의 컬렉션만을 선택적으로 로딩할 수 있습니다.

@Entity
public class Book {
    ...
    // @ElementCollection을 사용하여 컬렉션을 내장 타입으로 저장
    @ElementCollection
    private List<String> authors;
    ...
}

위의 코드에서 Book 엔티티 클래스는 @ElementCollection 를 사용하여 authors 필드를 내장 타입으로 저장하고 있습니다. 이는 authors가 일반적인 엔티티가 아니라 컬렉션의 각 요소가 별도의 테이블에 저장되어야 함을 나타냅니다.

이 예제에서는 BookBook_authors 라는 테이블이 생성됩니다. Book_authors 테이블은 Book 의 id와 authors의 각 요소를 저장하는 열로 구성됩니다.



[ 실제 코드 ]

[ ItemImage ]

@Embeddable
public class ItemImage {
    private String fileName;
    private int imageOrder;

    public void setOrder(int order) {
        this.imageOrder = order;
    }
}

@Embeddable 을 통해 내장 타입으로 선언하였습니다.


[ Item ]

@Entity
@Table(name = "tbl_item")
@ToString(exclude = "imageList")
public class Item {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long itemId;
    ...

    @ElementCollection
    @Builder.Default
    private List<ItemImage> imageList = new ArrayList<>();

    public void addImage(ItemImage itemImage) {
        itemImage.setOrder(imageList.size());
        imageList.add(itemImage);
    }

    public void addImageString(String fileName) {
        ItemImage itemImage = ItemImage.builder()
                                        .fileName(fileName)
                                        .build();
        addImage(itemImage);
    }
    ...
}

Item Entity 의 경우, @Table 에 지정한 이름대로 테이블이 생성됩니다. 그리고 위에서 언급한대로 @ElementCollection 으로 지정된 ItemImage 의 경우, item_id, file_name, image_order 의 필드를 가진 별도의 테이블이 추가로 생성됩니다.



[ 테스트 코드 ]

@SpringBootTest
class ItemRepositoryTest {
    @Autowired
    private ItemRepository itemRepository;  // Spring Data JPA

    @Test
    void saveTest() {
        Item item = Item.builder().itemName("색연필").itemDesc("상품 설명").price(25000).build();
        item.addImageString(UUID.randomUUID() + "_" + "Image1.jpg");
        item.addImageString(UUID.randomUUID() + "_" + "Image2.jpg");

        itemRepository.save(item);
    }
}

Item 객체를 생성한 후, 정상적으로 저장이 이루어지는지 확인하는 테스트 코드입니다.

해당 테스트를 실행하면 @ElementCollection 에 의해 별도의 테이블과 함께 Item 테이블이 생성되며, 데이터는 별도의 테이블에는 item_id 필드가 추가로 생성되어, Item 테이블에 저장된 데이터의 item_id 와 동일한 것을 알 수 있습니다.




조회 문제 시 주의점


[ 테스트 코드 ]

@Test
public void readTest() {
    Long id = 3L;
    Item item = itemRepository.findById(id).orElseThrow();
    log.info("item = {}", item);
    log.info("item.getImageList() = {}", item.getImageList());
}

위의 테스트 코드를 실행하면 다음과 같은 에러가 발생합니다. failed to lazily initialize a collection of role: ghkwhd.apiServer.item.entity.Item.imageList: could not initialize proxy - no Session

해당 에러는 엔티티 또는 컬렉션을 지연 로딩으로 설정하고, 해당 엔티티를 조회한 세션이 종료된 후에 연관된 엔티티나 컬렉션을 접근하려고 할 때 발생합니다. 지연 로딩이란 연관된 엔티티 또는 컬렉션을 실제로 사용할 때까지 데이터베이스에서 로딩을 미루는 전략입니다.

이전에 보았듯이 ItemImageList 필드는 @ElementCollection 을 통해 매핑되었으며, 기본적으로는 지연 로딩 전략이 적용됩니다. 지연 로딩은 해당 필드를 실제로 사용할 때까지 로딩을 미루는 전략이기 때문에 세션이 종료된 이후에 접근하면 문제가 발생할 수 있습니다.

테스트 코드에서 세션은 트랜잭션 내에서 열린 후에 닫히게 되므로, findById() 로 조회한 Item 엔티티를 반환한 시점에서는 이미 세션이 종료되었을 것입니다. 그리고 이후에 item.getImageList() 를 호출하면서 연관된 imageList 필드를 로딩하려고 할 때, 세션이 종료된 상태에서는 더 이상 필드를 로딩할 수 없어서 에러가 발생한 것입니다.



[ 해결 ]

public interface ItemRepository extends JpaRepository<Item, Long> {
    @EntityGraph(attributePaths = "imageList")
    @Query("select i from Item i where i.itemId = :itemId")
    Optional<Item> selectItemById(@Param("itemId") Long itemId);
}

위의 코드는 특정 Item 엔티티를 조회할 때 연관된 imageList 필드를 즉시 로딩하도록 설정하고 있습니다. 사용된 어노테이션들을 하나씩 확인해보도록 하겠습니다.

@EntityGraph

@EntityGraph 는 JPA Entity Graph 를 사용하여 엔터티를 조회할 때 특정 연관 속성을 로딩하는 데 사용됩니다. attributePaths 속성은 로딩할 연관 속성의 경로를 나타내며 이 어노테이션을 사용함으로써 selectItemById() 를 호출할 때, 연관된 imageList 필드가 즉시 로딩되어 반환됩니다.

@EntityGraph 를 사용하면 특정 메서드에 대한 로딩 전략을 지정할 수 있습니다. 이것은 @ElementCollection(fetch = FetchType.EAGER) imageList 필드에 지정하는 것과 유사하지만, 메서드 수준에서 제어할 수 있습니다.


@Query, @Param

@Query 어노테이션은 JPQL(Querydsl) 또는 네이티브 SQL 쿼리를 사용하여 데이터베이스에서 데이터를 조회하는 데 사용됩니다. @Param 어노테이션은 메서드의 파라미터를 JPQL 쿼리의 바인딩 변수와 매핑하기 위해 사용됩니다.


그 후 findById 를 selectItemById 로 변경하고 다시 테스트를 수행했을 때 정상적으로 통과되며, 수행되는 쿼리를 확인했을 때 LEFT JOIN 을 통해 한 번에 select 하는 것을 확인할 수 있습니다.

사실 테스트 코드에서 @Transactional 을 사용하면 테스트는 성공적으로 수행됩니다. 그렇기 때문에 Service 계층에서 @Transactional 을 사용한다면 findById()selectItemById() 둘 중 어느 것을 사용해도 문제가 되지 않습니다.




목록 데이터 처리


[ ItemRepository ]

public interface ItemRepository extends JpaRepository<Item, Long> {
    ...
    @Query("select i, ii from Item i left join i.imageList ii where (ii IS NULL OR ii.imageOrder = 0) and i.delFlag = false")
    Page<Object[]> selectList(Pageable pageable);
}

조회 시에 필요한 것이 Item 뿐만 아니라 Item 이 가진 이미지도 있기 때문에 ItemItemImage 를 둘 다 Select 해서 Object[] 형태로 받아오게 됩니다. 이때, 대표 이미지만을 가져오기 위해 ItemImage 에 지정한 순서가 0 인 것들만 가져오도록 하였습니다.

이때, 이미지를 첨부하지 않고 저장하게 되면 아무 이미지도 저장되지 않기 때문에 NULL 이거나 순서가 0인 것들이라는 조건을 함께 작성합니다. 만약 순서가 0인 조건만 추가한다면 이미지가 없는 데이터는 조회되지 않습니다.



[ ItemService ]

public class ItemServiceImpl implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public PageResponseDTO<ItemDTO> getList(PageRequestDTO requestDTO) {

        Pageable pageable = PageRequest.of(requestDTO.getPage() -1, requestDTO.getSize(), Sort.by("itemId").descending());
        Page<Object[]> objects = itemRepository.selectList(pageable);
        
        List<ItemDTO> dtoList = objects.get().map(arr -> {
            Item item = (Item) arr[0];
            ItemImage itemImage = (ItemImage) arr[1];
            ItemDTO itemDTO = ItemDTO.builder()
                    .itemId(item.getItemId())
                    .itemName(item.getItemName())
                    .itemDesc(item.getItemDesc())
                    .price(item.getPrice())
                    .build();

            if (itemImage != null) {
                String fileName = itemImage.getFileName();
                itemDTO.setUploadedFileNames(List.of(fileName));
            }
            return itemDTO;
        }).toList();

        long totalCount = objects.getTotalElements();

        return PageResponseDTO.<ItemDTO>withAll()
                .dtoList(dtoList)
                .total(totalCount)
                .requestDTO(requestDTO)
                .build();
    }
}

ItemRepositoryselectList() 를 통해 전달받은 ItemItemImage 의 정보를 이용하여 프론트 부분에 전달해줄 ItemDTO 를 생성합니다.

이때 ItemImage 는 DB에 있는 내용을 조회한 것이기 때문에 UUID + 원본파일명 형식이며, ItemDTOuploadedFileNames 역시 업로드된 파일명을 갖는 필드이기 때문에 업로드된 파일명을 List 에 추가합니다.

앞의 쿼리에서 이미지가 없는 경우도 조회되게 하였기 때문에 이미지가 있는 경우에만 파일명을 추가하도록 하고, 이전 ToDo 와 마찬가지로 조회된 데이터들과 함께 페이징 처리에 필요한 정보들을 PageResponseDTO 에 담아서 프론트로 전달해주게 됩니다.




데이터 저장


[ ItemController ]

public class ItemController {
    private final FileUtils fileUtils;
    private final ItemService itemService;

    @PostMapping("/add")
    public Map<String, Long> save(ItemDTO dto) {
        List<MultipartFile> files = dto.getFiles();

        // 원본 파일을 업로드 하는 파일의 이름으로 문자열을 생성
        // DB 에 저장하기 위한 과정
        List<String> uploadedNames = fileUtils.saveFiles(files);

        dto.setUploadedFileNames(uploadedNames);

        log.info("uploaded file Names = {}", uploadedNames);
        Long savedId = itemService.save(dto);

        return Map.of("RESULT", savedId);
    }
}

ItemDTOfiles 필드는 원본 파일들을 가진 List 입니다. ItemDTO 에서 이를 꺼내어 이전에 만든 FileUtilssaveFiles() 메서드에 전달합니다.

saveFiles() 는 원본 파일의 이름을 UUID + 원본파일명 형식으로 변환하여, DB 에 저장하고 업로드 된 파일명을 반환하는 역할을 합니다. 이를 통해 ItemDTO 가 가진 업로드된 파일명 필드의 값을 지정하고, ItemServicesave() 를 호출하게 됩니다.



[ ItemService ]

public class ItemServiceImpl implements ItemService {
    ...
    @Override
    public Long save(ItemDTO itemDTO) {
        Item item = dtoToEntity(itemDTO);
        return itemRepository.save(item).getItemId();
    }
}

public interface ItemService {
    ...
    default Item dtoToEntity(ItemDTO dto) {
        Item item = Item.builder()
                .itemId(dto.getItemId())
                .itemName(dto.getItemName())
                .itemDesc(dto.getItemDesc())
                .price(dto.getPrice())
                .build();

        // 업로드 처리가 끝난 파일들의 이름 리스트 ( FileUtils 에 의해 save 과정을 거침 )
        List<String> uploadedFileNames = dto.getUploadedFileNames();
        if (uploadedFileNames == null || uploadedFileNames.size() == 0) {
            return item;
        }

        uploadedFileNames.forEach(item::addImageString);
        return item;
    }
}

DB 에 저장하기 위해서는 ItemRepository 에 Entity 를 전달해야 하기 때문에 DTO 를 Entity 로 변환하는 과정을 거칩니다. 이때 Item 객체의 ItemImage 에 ItemImage 객체를 넣어주어야 함께 저장되기 때문에 해당 과정이 필요합니다.

DB에 저장되는 데이터는 UUID_원본파일명 형식이며, Item 클래스의 addImageString() 을 통해 Item 에 ItemImage 를 매핑하는 작업을 수행합니다.



[ 흐름 정리 ]

위에서 작성된 코드의 동작을 요약하면 아래와 같습니다.

1. ItemDTO 내의 파일 업로드 처리

ItemDTO 내의 files 필드에서 업로드된 파일 정보를 가져옵니다. 이 파일 정보들을 FileUtils.saveFiles(files) 메서드에 전달하여 파일을 업로드하고, 업로드된 파일명 리스트를 반환합니다.

2. ItemDTO 에 업로드된 파일명 설정

FileUtils.saveFiles() 에서 반환한 uploadedFileNames 리스트를 사용하여 ItemDTO 내의 업로드된 파일명 필드를 업데이트합니다. 즉, 실제 저장은 repository 에서 이루어지는 것이 아닙니다. repository 에서는 저장된 이미지와 매핑을 하여 DB 에 해당 정보를 저장합니다.

3. ItemService.save() 호출

ItemDTO 는 이제 업로드된 파일명 정보를 포함하고 있으며, ItemService.save(dto) 를 호출합니다. 이 메서드에서 ItemDTOItem 엔티티로 변환하기 위해 dtoToEntity() 를 호출합니다.

4. dtoToEntity() 호출

ItemDTO 의 필드 값을 사용하여 해당 엔터티의 필드들을 설정합니다. ItemDTO 가 가져온 업로드된 파일명(uploadedFileNames) 를 확인하여 비어있지 않다면 각 파일명에 대해 addImageString() 을 호출합니다.

5. addImageString() 호출

업로드된 파일명을 받아서 ItemImage 객체를 생성하고, addImage() 메서드를 호출합니다.

6. addImage() 호출

addImage() 는 전달받은 ItemImage 객체의 순서를 지정하고, Item 의 ImageList 에 ImageImage 객체를 추가합니다.

7. ItemRepository.save() 호출

4번부터 시작된 수행 결과로 ItemImage 객체까지 추가된 Item 를 Repository 에 전달하여 저장을 수행합니다. 그 결과로 Item 테이블과 ItemImageList 테이블에 데이터가 각각 저장되게 됩니다.




데이터 수정


[ ItemController ]

public class ItemController {
    @PutMapping("/modify/{itemId}")
    public Map<String, String> modifyItem(@PathVariable Long itemId, ItemDTO dto) {
        log.info("################### ItemController modifyItem ####################");
        log.info("ItemDTO = {}", dto);
        ItemDTO preItem = itemService.getItem(itemId);
        log.info("preItemDTO = {}", preItem);

        // 새로운 파일 업로드
        List<MultipartFile> files = dto.getFiles();
        List<String> currentUploadFileName = fileUtils.saveFiles(files);
        List<String> uploadedFileNames = dto.getUploadedFileNames();

        log.info("새로 저장된 파일명 : currentUploadFileName = {}", currentUploadFileName); // 새로 저장한 파일명
        log.info("DTO 가 가진 업로디드 파일명 : uploadedFileNames = {}", uploadedFileNames); // 유지하고자 하는 파일의 저장명

        if (currentUploadFileName != null && currentUploadFileName.size() != 0) {
            uploadedFileNames.addAll(currentUploadFileName);
        }
        log.info("service 호출 전 ItemDTO = {}", dto);
        itemService.modify(dto);

        // 기존 파일 삭제
        List<String> preSavedFileNames = preItem.getUploadedFileNames();    // 기존에 저장되어 있던 파일명

        if (preSavedFileNames != null && preSavedFileNames.size() > 0) {
            // 제거해야하는 파일명
            List<String> removeFileName = preSavedFileNames.stream().filter(fileName -> !uploadedFileNames.contains(fileName)).toList();
            fileUtils.deleteFiles(removeFileName);
        }

        return Map.of("RESULT", "SUCCESS");
    }
}

처음 들어온 DTO 에는 새롭게 업로드 할 파일명기존에 업로드 되어 있는데 유지할 파일명이 존재합니다.

preItem 에는 기존에 업로드 되어 있던 모든 파일명이 존재합니다.

FileUtils 를 이용해 파일을 저장하고, 저장된 파일명을 받습니다. 그 후 유지할 파일명들과 합치게 되는데 이렇게 하면 DTO 의 uploadedFileNames 가 모두 사용자가 원하는 파일만 남게 됩니다. Spring Data JPA 는 자동으로 변경을 감지하기 때문에 uploadedFileNames 가 변경되면 자동으로 그에 맞게 저장됩니다.

기존에 저장되어 있던 파일과, 사용자가 원하는 파일을 비교해서 일치하지 않는 항목을 FileUtils 를 이용하여 삭제를 진행합니다.



< 로그 >
: ################### ItemController modifyItem ####################
: ItemDTO = ItemDTO(itemId=59, itemName=수정 상품 이름, price=37000, itemDesc=수정 상품 설명, delFlag=false, files=[org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@10590acd], uploadedFileNames=[67d3eaf1-8c13-4ae8-a151-80407a0f149e_111.jpg])
: preItemDTO = ItemDTO(itemId=59, itemName=색연필, price=17000, itemDesc=색연필, delFlag=false, files=[], uploadedFileNames=[67d3eaf1-8c13-4ae8-a151-80407a0f149e_111.jpg, e6538cab-b27b-49db-ae6a-02b38c90651c_websiteplanet-dummy-540X400.png])
: 새로 저장된 파일명 : currentUploadFileName = [74273af7-6699-4d12-8526-9d82b23e7fac_222.jpg]
: DTO 가 가진 업로디드 파일명 : uploadedFileNames = [67d3eaf1-8c13-4ae8-a151-80407a0f149e_111.jpg]
: service 호출 전 ItemDTO = ItemDTO(itemId=59, itemName=수정 상품 이름, price=37000, itemDesc=수정 상품 설명, delFlag=false, files=[org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@10590acd], uploadedFileNames=[67d3eaf1-8c13-4ae8-a151-80407a0f149e_111.jpg, 74273af7-6699-4d12-8526-9d82b23e7fac_222.jpg])
: ################ FileUtils ###############
: fileNames = [e6538cab-b27b-49db-ae6a-02b38c90651c_websiteplanet-dummy-540X400.png]

0개의 댓글