관리 메뉴

거니의 velog

(21) 상품 API 서버 구성하기 6 본문

SpringBoot_React 풀스택 프로젝트

(21) 상품 API 서버 구성하기 6

Unlimited00 2024. 3. 4. 21:50

(4) 수정 기능의 처리

* 수정 기능의 처리에서 첨부파일의 처리를 주의해야 한다. ProductDTO에서는 List<MultipartFile> 타입으로 선언된 files가 존재하고, List<String> 타입은 uploadFIleNames가 존재하는데 uploadFIleNames는 기존에 업로드된 파일들의 이름을 의미하고, files는 처리가 필요한 새로운 파일들이다. 실제 데이터베이스에 추가되는 것은 문자열로 된 uploadFIleNames 이므로 업로드 작업이 완료된 후에는 이미 업로드된 uploadFIleNames에 업로드된 파일의 이름들을 추가해서 구성해 주어야 한다.

* 데이터베이스에 관련된 엔티티에서는 uploadFIleNames의 내용이 첨부파일의 이름들이기 때문에 기존의 Product 객체가 가진 모든 파일을 지우고, ProductDTO가 가진 uploadFIleNames 내용들로 새롭게 추가해서 저장하는 과정으로 처리된다.


[서비스 수정 기능의 처리]

* 수정 기능을 서비스 계층에 추가한다.

package com.unlimited.mallapi.service;

import org.springframework.transaction.annotation.Transactional;

import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.ProductDTO;

/**
 * 
 * @Transactional 어노테이션을 서비스 계층의 인터페이스 레벨에 추가하는 것은 특정 메서드가 아닌 해당 인터페이스에 대해 트랜잭션을
 *                적용하려는 의도를 나타냅니다. 이는 일반적으로 특별한 상황에서만 사용되며, 보통은 메서드
 *                레벨에서 @Transactional 어노테이션을 사용하는 것이 일반적입니다.
 * 
 *                일반적으로 서비스 인터페이스 레벨에 @Transactional 어노테이션을 추가하는 것은 다음과 같은 상황에서
 *                고려될 수 있습니다.
 * 
 *                인터페이스의 모든 메서드가 트랜잭션에 참여하는 경우: 인터페이스 내의 모든 메서드가 트랜잭션 내에서 실행되어야
 *                할 경우에 사용될 수 있습니다. 이는 특히 서비스 인터페이스가 여러 메서드를 정의하고 이를 구현하는 클래스에서
 *                트랜잭션을 관리하기 어려운 경우에 유용할 수 있습니다.
 * 
 *                모든 메서드가 동일한 트랜잭션 속성을 가져야 하는 경우: 인터페이스 내의 모든 메서드가 동일한 트랜잭션 속성을
 *                가져야 하는 경우에 해당됩니다. 이는 특별한 경우에만 적용되어야 하며, 대부분의 경우 메서드 레벨에서 트랜잭션
 *                속성을 명시하는 것이 더 유연하고 명시적입니다.
 * 
 *                서비스 레이어는 일반적으로 비즈니스 로직을 처리하고 트랜잭션 경계를 설정하는 역할을 합니다. 메서드
 *                레벨에서 @Transactional 어노테이션을 사용하는 것이 더 흔하며, 메서드마다 다른 트랜잭션 속성을
 *                지정할 수 있어서 더 유연한 구현이 가능합니다. 인터페이스 레벨에서 @Transactional 어노테이션을
 *                사용하는 것은 특별한 경우에만 필요하며, 주의해서 사용해야 합니다.
 */
@Transactional(rollbackFor = Exception.class)
public interface ProductService {

    PageResponseDTO<ProductDTO> getList(PageRequestDTO pageRequestDTO);

    Long register(ProductDTO productDTO);

    ProductDTO get(Long pno);

    void modify(ProductDTO productDTO);

}

* ProductServiceImpl 클래스에서는 modify()의 내용을 아래와 같이 구현한다.

    @Override
    public void modify(ProductDTO productDTO) {
        // 상품 ID로 상품을 찾습니다.
        Optional<Product> result = productRepository.findById(productDTO.getPno());

        // 상품이 존재하지 않으면 예외를 발생시킵니다.
        Product product = result.orElseThrow();

        // 상품 정보를 업데이트합니다.
        product.changeName(productDTO.getPname());
        product.changeDesc(productDTO.getPdesc());
        product.changePrice(productDTO.getPrice());

        // 기존 이미지 목록을 초기화합니다.
        product.clearList();

        // 업로드된 파일명이 존재하면 이미지 목록을 업데이트합니다.
        List<String> uploadFileNames = productDTO.getUploadFileNames();

        if (uploadFileNames != null && uploadFileNames.size() > 0) {
            uploadFileNames.stream().forEach(uploadName -> {
                product.addImageString(uploadName);
            });
        }

        // 수정된 상품 정보를 저장합니다.
        productRepository.save(product);
    }

[컨트롤러와 연동 확인]

* 컨트롤러는 상품의 수정 과정에서 기존에 업로드된 파일의 처리와 새롭게 업로드된 파일의 처리를 주의해야 한다. 상품의 수정 과정을 정리해 보면 다음과 같다.

- 기존의 상품정보를 얻어오고 상품 이미지 정보들을 미리 파악해 두고 나중에 삭제해야 하는 파일들을 파악할 때 사용

- ProductDTO의 files는 새롭게 업로드해야 하는 파일들이므로 저장하고 업로드된 파일의 이름들을 파악해 두어야 함

- ProductDTO의 uploadFileNames의 내용물은 기존에 업로드된 파일들의 이름들이므로 새로 업로드된 파일의 이름들을 추가

- 서비스 계층에 파일 관련 처리가 완료된 ProductDTO를 전달하고 처리

- 기존의 파일 중에서 더 이상 사용되지 않는 파일들을 찾아서 삭제

    @PutMapping("/{pno}")
    public Map<String, String> modify(@PathVariable(name = "pno") Long pno, ProductDTO productDTO) {

        productDTO.setPno(pno);

        ProductDTO oldProductDTO = productService.get(pno);

        // 기존의 파일들 (데이터베이스에 존재하는 파일들 - 수정 과정에서 삭제되었을 수 있음)
        List<String> oldFileNames = oldProductDTO.getUploadFileNames();

        // 새로 업로드 해야 하는 파일들
        List<MultipartFile> files = productDTO.getFiles();

        // 새로 업로드되어서 만들어진 파일 이름들
        List<String> currentUploadFileNames = fileUtil.saveFiles(files);

        // 화면에서 변화 없이 계속 유지된 파일들
        List<String> uploadedFileNames = productDTO.getUploadFileNames();

        // 새로 업로드된 파일들만 추가
        if (currentUploadFileNames != null && currentUploadFileNames.size() > 0) {
            uploadedFileNames.addAll(currentUploadFileNames);
        }

        // 수정 작업
        productService.modify(productDTO);

        // 파일 삭제 작업 수행 (기존 파일 중에서 유지되는 파일 목록을 제외한 파일 삭제)
        if (oldFileNames != null && oldFileNames.size() > 0) {
            // 지워야 하는 파일 목록 찾기
            List<String> removeFiles = oldFileNames
                    .stream()
                    .filter(fileName -> !uploadedFileNames.contains(fileName)).collect(Collectors.toList());
            // 실제 파일 삭제
            fileUtil.deleteFiles(removeFiles);
        }

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

    }

* 컨트롤러까지 연동된 결과를 확인하기 위해서 Postman을 이용해서 먼저 두 개의 이미지를 가지는 새로운 상품 하나를 추가한다.

* 새롭게 등록된 상품은 115번으로 등록되었고, 첨부 이미지 파일이 2개였으므로 썸네일을 포함해서 4개의 파일이 생성된다.

* 데이터베이스에도 정상적으로 모든 내용과 첨부파일의 정보가 처리된 것을 볼 수 있다.

* 상품의 수정 과정에서는 기존 파일에 대한 다음과 같은 경우의 수가 발생한다.

1. 기존의 파일들을 모두 삭제하고 새로운 파일들이 추가되는 경우

2. 기존 파일의 일부만 삭제하고 새로운 파일들이 추가되는 경우

3. 기존 파일들을 그대로 유지하는 경우

* 다양한 상황에서 가장 주의해야 하는 것은 '기존의 파일 중 일부만 유지하고 새로운 파일이 추가된 경우' 이다. Postman에서는 PUT 방식으로 '/api/products/상품 번호'를 지정하고, uploadFIleNames 항목에는 기존에 업로드된 파일의 이름을 넣어주고 files 항목에는 새로운 이미지들을 추가해 본다.

* 변경 전의 파일 목록에는 

- XXX_logo.png

- XXX _react.png

파일이 존재한다.

* PUT 방식으로 변경된 데이터를 살펴보면 

- XXX_logo.png

파일은 남아 있고 새로운 파일( XXX_css.png )이 추가된 것을 확인할 수 있다.

* 업로드 폴더를 살펴보면 XXX_logo.png 파일과 썸네일 파일은 삭제되고 기존의 XXX _react.png 파일과 새로운 파일이 추가된 것을 확인할 수 있다.


(5) 삭제 기능의 처리

* 상품의 삭제는 실제 데이터를 삭제하는 delete 대신에 상품의 delFlag 값을 true로 변경해 주는 soft delete 작업이지만 이미 업로드된 파일들은 모두 삭제해 주는 작업을 처리해 주어야 한다.

소프트 삭제(Soft Delete)는 데이터베이스에서 행을 실제로 삭제하는 대신, 해당 행을 표시 상태를 변경하여 데이터를 유지하는 방식입니다. 
일반적으로 행에 "삭제 여부"를 나타내는 컬럼(예: deleted_at)을 추가하고, 행을 삭제할 때 이 컬럼에 삭제 일자를 기록합니다. 
그런 다음 데이터를 조회할 때는 삭제되지 않은 행만을 반환하도록 쿼리를 작성합니다.

소프트 삭제의 주요 이점은 다음과 같습니다:

1. 데이터 보존 : 소프트 삭제는 데이터를 유지하면서도 삭제 여부를 추적할 수 있기 때문에 필요한 경우 데이터를 복구하거나 관리할 수 있습니다.

2. 이력 추적 : 삭제된 데이터의 이력을 추적하기 용이합니다. 언제 어떤 데이터가 삭제되었는지를 알 수 있어 데이터 감사 및 복구 작업이 쉬워집니다.

3. 외래 키 제약 해결 : 소프트 삭제를 사용하면 관계형 데이터베이스에서 외래 키 제약을 유지할 수 있습니다. 특히 부모 테이블의 레코드가 삭제되지 않으면서 자식 테이블의 외래 키 제약을 해결하는 데 유용합니다.

4. 유연성 : 데이터를 완전히 삭제하지 않기 때문에 시스템이나 사용자의 요구에 따라 데이터를 유연하게 관리할 수 있습니다.

소프트 삭제를 구현하려면, 테이블에 삭제 여부를 나타내는 컬럼을 추가하고, 삭제할 때 해당 컬럼을 업데이트하는 방식으로 처리합니다. 
일반적으로는 이러한 컬럼의 값이 NULL이면 해당 행이 삭제되지 않은 상태, 그렇지 않으면 삭제된 상태로 간주됩니다. 
데이터를 조회할 때는 삭제되지 않은 행만을 쿼리에 포함시키는 조건을 추가하여 원하는 결과를 얻을 수 있습니다.

[서비스 계층의 삭제 처리]

* ProductService 인터페이스에 삭제와 관련한 remove()를 선언한다.

package com.unlimited.mallapi.service;

import org.springframework.transaction.annotation.Transactional;

import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.ProductDTO;

/**
 * 
 * @Transactional 어노테이션을 서비스 계층의 인터페이스 레벨에 추가하는 것은 특정 메서드가 아닌 해당 인터페이스에 대해 트랜잭션을
 *                적용하려는 의도를 나타냅니다. 이는 일반적으로 특별한 상황에서만 사용되며, 보통은 메서드
 *                레벨에서 @Transactional 어노테이션을 사용하는 것이 일반적입니다.
 * 
 *                일반적으로 서비스 인터페이스 레벨에 @Transactional 어노테이션을 추가하는 것은 다음과 같은 상황에서
 *                고려될 수 있습니다.
 * 
 *                인터페이스의 모든 메서드가 트랜잭션에 참여하는 경우: 인터페이스 내의 모든 메서드가 트랜잭션 내에서 실행되어야
 *                할 경우에 사용될 수 있습니다. 이는 특히 서비스 인터페이스가 여러 메서드를 정의하고 이를 구현하는 클래스에서
 *                트랜잭션을 관리하기 어려운 경우에 유용할 수 있습니다.
 * 
 *                모든 메서드가 동일한 트랜잭션 속성을 가져야 하는 경우: 인터페이스 내의 모든 메서드가 동일한 트랜잭션 속성을
 *                가져야 하는 경우에 해당됩니다. 이는 특별한 경우에만 적용되어야 하며, 대부분의 경우 메서드 레벨에서 트랜잭션
 *                속성을 명시하는 것이 더 유연하고 명시적입니다.
 * 
 *                서비스 레이어는 일반적으로 비즈니스 로직을 처리하고 트랜잭션 경계를 설정하는 역할을 합니다. 메서드
 *                레벨에서 @Transactional 어노테이션을 사용하는 것이 더 흔하며, 메서드마다 다른 트랜잭션 속성을
 *                지정할 수 있어서 더 유연한 구현이 가능합니다. 인터페이스 레벨에서 @Transactional 어노테이션을
 *                사용하는 것은 특별한 경우에만 필요하며, 주의해서 사용해야 합니다.
 */
@Transactional(rollbackFor = Exception.class)
public interface ProductService {

    PageResponseDTO<ProductDTO> getList(PageRequestDTO pageRequestDTO);

    Long register(ProductDTO productDTO);

    ProductDTO get(Long pno);

    void modify(ProductDTO productDTO);

    void remove(Long pno);

}

* ProductServiceImpl에서 ProductReposiroty에 구현해 둔 updateToDelete()를 호출한다.

    /**
     * 상품을 삭제 표시합니다.
     *
     * @param pno 삭제할 상품의 식별 번호
     */
    @Override
    public void remove(Long pno) {
        // 상품 삭제 여부를 업데이트하여 삭제 표시를 합니다.
        productRepository.updateToDelete(pno, true);
    }

[컨트롤러의 삭제 처리]

* 컨트롤러에서 상품의 삭제는 먼저 상품의 데이터를 삭제한 후에 상품에 속한 이미지 파일들을 삭제하는 순서로 처리되어야 한다. ProductController에는 DELETE 방식으로 동작하는 remove()를 추가한다.

    /**
     * 상품을 삭제합니다.
     *
     * @param pno 삭제할 상품의 식별 번호
     * @return 삭제 결과를 나타내는 Map ("RESULT": "SUCCESS")
     */
    @DeleteMapping("/{pno}")
    public Map<String, String> remove(@PathVariable("pno") Long pno) {
        // 삭제해야 할 파일들의 목록을 얻어옵니다.
        List<String> oldFileNames = productService.get(pno).getUploadFileNames();

        // 상품 서비스를 통해 상품을 삭제합니다.
        productService.remove(pno);

        // 삭제된 상품에 속한 파일들을 실제로 삭제합니다.
        fileUtil.deleteFiles(oldFileNames);

        // 삭제 결과를 SUCCESS로 반환합니다.
        return Map.of("RESULT", "SUCCESS");
    }

* Postman에서는 특정 번호의 상품을 지정해서 삭제한다. 삭제하기 전에 해당 번호의 파일들의 존재 여부도 같이 확인해 두도록 한다.

* 예를 들어 현재 115번 상품은 2개의 이미지와 2개의 썸네일 파일을 가지고 있는 상태이다.

* Postman을 이용해서 115번 상품을 삭제하고, 업로드 폴더에서 파일들이 삭제되었는지 확인한다(데이터베이스는 업데이트만 진행).

115번 관련 상품 이미지가 잘 삭제되었다.

* 예제를 조금 더 보충하자면 상품이 등록될 때 반드시 이미지 파일이 있는지 확인하도록 검증(Validate)
  하는 기능들을 추가하는 것을 고려할 수 있다.
  컨트롤러에서 @Valid 어노테이션을 이용하고 @RestControllerAdvice를 활용해서 잘못된 결과를 반환할 수 있다.

* 다음 장에서는 완성된 상품 관련 API 기능들을 활용해서 리액트 프로그램을 작성하도록 할 것이다.