관리 메뉴

거니의 velog

(17) 상품 API 서버 구성하기 2 본문

SpringBoot_React 풀스택 프로젝트

(17) 상품 API 서버 구성하기 2

Unlimited00 2024. 2. 29. 14:56

3. 엔티티 처리

* 파일 업로드의 특징은 파일 자체가 부수적인 요소라는 점이다. 예를 들어, 상품 등록의 경우 핵심은 상품 자체이고 파일들은 이를 설명하는 부수적인 데이터이다. 이럴 때 상품은 고유한 PK를 가지는 하나의 온전한 엔티티로 봐야 하고 파일들은 엔티티에 속해 있는 데이터로 봐야 한다.
* JPA에서는 '값 타입 객체'라는 표현을 쓰는데 컬렉션으로 처리할 때는 @ElementCollection을 활용한다. 값 타입 객체는 엔티티와 달리 PK가 없는 데이터이다. 예제에서 하나의 상품 데이터는 여러 개의 상품 이미지를 갖는 1:N 관계이다.
* domain 패키지에 Product 엔티티 클래스와 ProductImage 클래스를 추가한다.

* ProductImage는 @Embeddable 어노테이션을 이용해서 해당 클래스의 인스턴스가 값 타입 객체임을 명시한다. ProductImage는 특이하게 순서(ord)라는 속성을 가지도록 만드는데 이것은 나중에 목록에서 각 이미지마다 번호를 지정하고 상품 목록을 출력할 때 ord 값이 0번인 이미지들만 화면에서 볼 수 있도록 하기 위함이다(즉, 대표 이미지로 출력하고자 하는 경우에 사용한다).

package com.unlimited.mallapi.domain;

import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

// Embeddable 어노테이션을 사용하여 임베디드 클래스로 선언된 ProductImage 클래스입니다.
@Embeddable
@Getter
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ProductImage {

    // 이미지 파일명을 저장하는 변수
    private String fileName;

    // 이미지 정렬 순서를 저장하는 변수
    private int ord;

    // 이미지 정렬 순서를 설정하는 메서드
    public void setOrd(int ord) {
        this.ord = ord;
    }

}

* 상품을 의미하는 Product는 일반 엔티티와 비슷하지만, ProductImage의 목록을 가지고 이를 관리하는 기능이 있게 작성한다.

package com.unlimited.mallapi.domain;

import java.util.*;

import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

// 상품 정보를 담는 엔터티 클래스인 Product 클래스입니다.
@Entity
@Table(name = "tbl_product") // 테이블 이름을 지정합니다.
@Getter
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long pno; // 상품 번호를 나타내는 식별자

    private String pname; // 상품명

    private int price; // 상품 가격

    private String pdesc; // 상품 설명

    private boolean delFlag; // 상품 삭제 여부를 나타내는 플래그

    @ElementCollection
    @Builder.Default
    private List<ProductImage> imageList = new ArrayList<>(); // 상품 이미지 목록을 담는 리스트

    // 상품 가격을 변경하는 메서드
    public void changePrice(int price) {
        this.price = price;
    }

    // 상품 설명을 변경하는 메서드
    public void changeDesc(String desc) {
        this.pdesc = desc;
    }

    // 상품명을 변경하는 메서드
    public void changeName(String name) {
        this.pname = name;
    }

    // 이미지를 리스트에 추가하는 메서드
    public void addImage(ProductImage image) {
        image.setOrd(this.imageList.size());
        imageList.add(image);
    }

    // 파일명을 이용해 이미지를 리스트에 추가하는 메서드
    public void addImageString(String fileName) {
        ProductImage productImage = ProductImage.builder()
                .fileName(fileName)
                .build();
        addImage(productImage);
    }

    // 이미지 리스트를 초기화하는 메서드
    public void clearList() {
        this.imageList.clear();
    }

}

* Product에는 문자열로 파일을 추가하거나 ProductImage 타입으로 이미지를 추가할 수 있도록 구성한다. @Embeddable을 사용하는 경우 PK가 생성되지 않기 때문에 모든 작업은 PK를 가지는 엔티티로 구성한다는 특징이 있다.


(1) 레퍼지토리 처리

* 상품에 대한 CRUD 기능은 JpaRepository를 사용해서 처리한다.

package com.unlimited.mallapi.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import com.unlimited.mallapi.domain.Product;

public interface ProductRepository extends JpaRepository<Product, Long> {

}

* test 폴더에는 repository 패키지 내 ProductRepositoryTests 클래스를 생성한다.


[상품 등록 테스트]

* 테스트 코드로 Product 엔티티 객체를 생성해서 어떻게 저장되는지를 확인해 보자. @ElementCollection 과 같이 하나의 엔티티가 여러 개의 객체를 추가적으로 담고 있을 때는 자동으로 이에 해당하는 테이블이 생성되고 외래키(FK)가 생성된다.

package com.unlimited.mallapi.repository;

import java.util.UUID;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import com.unlimited.mallapi.domain.Product;

import lombok.extern.log4j.Log4j2;

@SpringBootTest
@Log4j2
public class ProductRepositoryTests {

    @Autowired
    ProductRepository productRepository;

    @Test
    public void testInsert() {
        // 10개의 상품을 생성하고 데이터베이스에 저장하는 테스트 메서드입니다.
        for (int i = 0; i < 10; i++) {
            // 상품 객체 생성
            Product product = Product.builder()
                    .pname("상품 " + i)
                    .price(100 * i)
                    .pdesc("상품설명 " + i)
                    .build();

            // 상품에 2개의 이미지 파일 추가
            product.addImageString(UUID.randomUUID().toString() + "_" + "IMAGE1.jpg");
            product.addImageString(UUID.randomUUID().toString() + "_" + "IMAGE2.jpg");

            // 상품을 데이터베이스에 저장
            productRepository.save(product);

            log.info("---------------------------");
        }
    }

}

* testInsert()는 하나의 Product에 2개의 첨부파일이 있는 상태로 엔티티를 생성한다. 테스트 코드를 실행하면 데이터베이스 내에 테이블의 생성과 데이터가 추가되었는지 확인할 수 있다. 데이터베이스 내에는 tbl_product 테이블과 product_image_list 테이블이 생성되는데 tbl_product는 아래와 같이 1개의 상품마다 2개의 이미지 파일 데이터를 가지게 된다.


(2) 상품 조회와 Lazy Loading

* 엔티티로는 Product라는 하나의 엔티티 객체지만 테이블에서는 2개의 테이블로 구성되기 때문에 JPA에서 이를 처리할 때 한 번에 모든 테이블을 같이 로딩해서 처리할 것인지(eager loading), 필요한 테이블만 먼저 조회할 것인지(lazy loading)를 결정할 필요가 있다.
* Product 엔티티 구성에 사용한 @ElementCollection은 기본적으로 lazy loading 방식으로 동작하기 때문에 우선은 tbl_product 테이블만 접근해서 데이터를 처리하고 첨부파일이 필요할 때 product_image_list 테이블에 접근하게 된다. 이처럼 데이터베이스에 두 번 접근해서 처리(트랜잭션이 2번 이상 일어난다는 말)해야 하므로 테스트 코드에는 @Transational을 적용해야만 한다(트랜잭션이 2번 이상 처리 되므로 1번째 트랜잭션이 실패하면 다음 트랜잭션은 자동으로 rollback을 해야 데이터 정합성이 유지된다). 테스트 코드를 통해 이러한 상황을 확인해 보도록 한다.

package com.unlimited.mallapi.repository;

import java.util.UUID;
import java.util.Optional;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import com.unlimited.mallapi.domain.Product;

import lombok.extern.log4j.Log4j2;

@SpringBootTest
@Log4j2
public class ProductRepositoryTests {

    @Autowired
    ProductRepository productRepository;

    @Test
    public void testInsert() {
        // 10개의 상품을 생성하고 데이터베이스에 저장하는 테스트 메서드입니다.
        for (int i = 0; i < 10; i++) {
            // 상품 객체 생성
            Product product = Product.builder()
                    .pname("상품 " + i)
                    .price(100 * i)
                    .pdesc("상품설명 " + i)
                    .build();

            // 상품에 2개의 이미지 파일 추가
            product.addImageString(UUID.randomUUID().toString() + "_" + "IMAGE1.jpg");
            product.addImageString(UUID.randomUUID().toString() + "_" + "IMAGE2.jpg");

            // 상품을 데이터베이스에 저장
            productRepository.save(product);

            log.info("---------------------------");
        }
    }

    @Transactional(rollbackFor = Exception.class)
    @Test
    public void testRead() {
        // 상품 번호 1번을 조회하는 테스트 메서드입니다.
        Long pno = 1L;

        // 데이터베이스에서 상품을 조회
        Optional<Product> result = productRepository.findById(pno);

        // 조회한 상품이 존재하면 해당 상품을 가져오고, 없으면 예외를 던집니다.
        Product product = result.orElseThrow();

        log.info(product); // ------------- 1번째 트랜잭션
        log.info(product.getImageList()); // --------------- 2번째 트랜잭션
    }

}

* testRead()의 결과는 1에서는 상품 테이블만을 접근하지만 2를 실행하기 위해서 상품 이미지 테이블에 접근하는 두 번의 쿼리가 실행된다. 현재 Product 엔티티 클래스에는 @ToString(exclude = "imageList")가 적용되어 있지 않기 때문에 1을 실행하는 상황에서는 상품 이미지 데이터가 필요하지 않은 상황이다(@ToString의 exclude 속성이 없다면 1을 실행할 때 두 번의 쿼리가 실행된다).


[@EntityGraph]

* 애플리케이션 개발에서 가능하면 데이터베이스의 접근은 항상 많은 리소스와 시간을 잡아먹기 때문에 쿼리 실행의 횟수는 가능하면 줄여주는 것이 좋다. JPA에서는 쿼리를 작성할 때 @EntityGraph를 이용해서 해당 속성을 조인 처리하도록 설정해 줄 수 있다. ProductRepository 내에 @Query로 메서드를 추가한다.

package com.unlimited.mallapi.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.unlimited.mallapi.domain.Product;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 상품 번호로 상품을 조회하는 메서드입니다.
    // @EntityGraph 어노테이션을 사용하여 "imageList" 속성을 로딩합니다.
    // :pno 파라미터를 사용하여 조회할 상품 번호를 전달합니다.
    @EntityGraph(attributePaths = "imageList")
    @Query("select p from Product p where p.pno = :pno")
    Optional<Product> selectOne(@Param("pno") Long pno);

}

* ProductRepositoryTests 에는 새로운 testRead2() 를 작성해서 확인한다.

    @Test
    public void testRead2() {
        // 상품 번호를 지정합니다.
        Long pno = 1L;

        // 상품 레포지토리에서 상품을 조회합니다.
        Optional<Product> result = productRepository.selectOne(pno);

        // 조회된 상품이 존재하지 않으면 예외를 던집니다.
        Product product = result.orElseThrow();

        // 조회된 상품의 정보를 로그에 출력합니다.
        log.info(product);

        // 조회된 상품의 이미지 목록을 로그에 출력합니다.
        log.info(product.getImageList());
    }

* testRead2()는 @Transactional이 없어도 테이블들을 조인 처리해서 한 번에 로딩한다. 아래 실행 결과를 보면 이전과 달리 조인 처리가 된 것을 알 수 있고, imageList를 출력하기 위해서 별도의 쿼리가 실행되지 않은 것을 확인할 수 있다.


(3) 상품의 삭제

* 실제 데이터베이스 내에서 상품의 삭제는 나중에 구매 기록과 이어지기 때문에 주의해야 한다. 예를 들어 특정 상품이 데이터베이스에서 삭제되면 해당 상품 데이터를 사용한 모든 구매나 상품 문의 등의 데이터들이 같이 삭제되어야 한다(PK와 FK의 관계로 인한 참조무결성 제약). 지금과 같이 단순한 예제는 문제가 되지 않지만, 실제 서비스 중이라면 통계 데이터나 고객의 리뷰 데이터와 같은 데이터들이 모두 삭제되어야 하기 때문에 심각한 문제가 될 수 있다.
* 이에 대한 대안으로 실제 물리적인 삭제(delete) 대신에 특정한 컬럼의 값을 기준으로 해당 상품이 삭제되었는지 아닌지를 구분하고 delete 대신에 update를 이용해서 처리한다(이러한 논리적인 삭제를 Soft Delete 라고 한다).
* Product 클래스에는 이러한 처리를 위해서 delFlag 값을 선언해 두었고, changeDel()을 추가해서 삭제된 상품으로 표시하도록 한다.

package com.unlimited.mallapi.domain;

import java.util.*;

import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

// 상품 정보를 담는 엔터티 클래스인 Product 클래스입니다.
@Entity
@Table(name = "tbl_product") // 테이블 이름을 지정합니다.
@Getter
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    (...)

    private boolean delFlag; // 상품 삭제 여부를 나타내는 플래그

    // 상품 삭제 여부를 변경하는 메서드
    public void changeDel(boolean delFlag) {
        this.delFlag = delFlag;
    }

    (...)

}

* 데이터베이스의 테이블에는 boolean 타입의 값이 0 혹은 1로 표시된다(간혹 프로그램에 따라 true/false로 출력되기도 하지만, 데이터베이스는 기본적으로 boolean 타입이 존재하지 않는 경우가 더 많다).

* ProductRepository에는 @Query와 @Modifying 어노테이션을 이용해서 update, delete 등의 JPQL 을 처리할 수 있다.

package com.unlimited.mallapi.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.unlimited.mallapi.domain.Product;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 상품 번호로 상품을 조회하는 메서드입니다.
    // @EntityGraph 어노테이션을 사용하여 "imageList" 속성을 로딩합니다.
    // :pno 파라미터를 사용하여 조회할 상품 번호를 전달합니다.
    @EntityGraph(attributePaths = "imageList")
    @Query("select p from Product p where p.pno = :pno")
    Optional<Product> selectOne(@Param("pno") Long pno);

    // 상품 삭제 플래그를 업데이트하는 메서드입니다.
    // @Modifying 어노테이션을 통해 업데이트 쿼리임을 명시하고,
    // :pno 파라미터로 상품 번호, :flag 파라미터로 변경할 플래그 값을 전달합니다.
    @Modifying
    @Query("update Product p set p.delFlag = :flag where p.pno = :pno")
    void updateToDelete(@Param("pno") Long pno, @Param("flag") boolean flag);

}

* 테스트 코드를 실행하면 단순한 update 문이 실행되는 것을 확인할 수 있고 데이터베이스를 통해 del_flag 컬럼의 값이 변경된 것을 확인할 수 있다.

@Commit
@Transactional(rollbackFor = Exception.class)
@Test
public void testRemove() {
    Long pno = 2L;

    log.info("pno가 {}인 상품을 삭제합니다.", pno);

    // 상품 삭제 플래그를 업데이트합니다.
    productRepository.updateToDelete(pno, true);

    // 업데이트 후 상품을 다시 조회합니다.
    Optional<Product> removedProductResult = productRepository.findById(pno);
    Product delProduct = removedProductResult.orElseThrow();

    log.info("updateToDelete 후 상품 정보: {}", delProduct);

    // 상품이 성공적으로 삭제되었는지 확인
    if (delProduct.isDelFlag()) {
        log.info("상품이 성공적으로 삭제되었습니다.");
    } else {
        log.error("상품 삭제 실패: DelFlag가 업데이트되지 않았습니다.");
    }
}

* 테스트 코드를 실행하며 단순한 update 문이 실행되는 것을 확인할 수 있고 데이터베이스를 통해 del_flag 컬럼의 값이 변경된 것을 확인할 수 있다.


(4) 상품의 수정

* 상품의 수정 부분은 Product의 changeXXX()을 활용한다. 다만 상품에 포함된 이미지는 Product의 clearList()를 이용해서 첨부파일 데이터들을 비우고 다시 ProductImage들을 추가하는 방식으로 구성한다.
* 작성하는 testUpdate()는 실행 과정에서 데이터를 조회하기 위한 select와 기존 이미지 삭제를 위한 delete 그리고, 새로운 이미지를 위한 insert, 상품정보 갱신을 위한 update가 모두 실행된다.

    @Test
    public void testUpdate() {
        // 업데이트할 상품 번호를 지정합니다.
        Long pno = 10L;

        // 상품 레포지토리에서 상품을 조회합니다.
        Product product = productRepository.selectOne(pno).get();

        // 상품 정보를 수정합니다.
        product.changeName("10번 상품");
        product.changeDesc("10번 상품 설명입니다.");
        product.changePrice(5000);

        // 첨부파일을 수정합니다.
        product.clearList();

        // 새로운 이미지 파일을 추가합니다.
        product.addImageString(UUID.randomUUID().toString() + "_" + "NEWIMAGE1.jpg");
        product.addImageString(UUID.randomUUID().toString() + "_" + "NEWIMAGE2.jpg");
        product.addImageString(UUID.randomUUID().toString() + "_" + "NEWIMAGE3.jpg");

        // 수정된 상품 정보를 데이터베이스에 저장합니다.
        productRepository.save(product);
    }

* 테스트 코드를 실행하면 여러 종류의 쿼리가 한 번에 실행되는 것을 확인할 수 있다.

* 데이터베이스에서 최종적으로 수정된 결과를 확인한다. 상품의 설명이나 가격이 변경된 것을 확인하고 상품 이미지도 3개로 변경된 것을 확인한다.


(5) 이미지가 포함된 목록 처리

* 목록 화면은 상품당 하나의 이미지가 포함되는 형태로 처리되어야 하기 때문에 @Query로 조인 처리해서 구성해야 한다. 상품 하나당 여러 개의 이미지 파일이 존재할 수 있기 때문에 상품 이미지의 ord 값이 0인 상품의 대표 이미지들만 처리해서 출력하도록 구성한다.
* ProductRepository에 목록처를 위한 selectList()를 추가한다.

package com.unlimited.mallapi.repository;

import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.unlimited.mallapi.domain.Product;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 상품 번호로 상품을 조회하는 메서드입니다.
    // @EntityGraph 어노테이션을 사용하여 "imageList" 속성을 로딩합니다.
    // :pno 파라미터를 사용하여 조회할 상품 번호를 전달합니다.
    @EntityGraph(attributePaths = "imageList")
    @Query("select p from Product p where p.pno = :pno")
    Optional<Product> selectOne(@Param("pno") Long pno);

    // 상품 삭제 플래그를 업데이트하는 메서드입니다.
    // @Modifying 어노테이션을 통해 업데이트 쿼리임을 명시하고,
    // :pno 파라미터로 상품 번호, :flag 파라미터로 변경할 플래그 값을 전달합니다.
    @Modifying
    @Query("update Product p set p.delFlag = :flag where p.pno = :pno")
    void updateToDelete(@Param("pno") Long pno, @Param("flag") boolean flag);

    // 상품 목록을 조회하는 메서드입니다.
    // pi.ord = 0은 첫 번째 순서의 이미지만 가져오도록 필터링합니다.
    // p.delFlag = false는 삭제되지 않은 상품만 가져오도록 필터링합니다.
    @Query("select p, pi from Product p left join p.imageList pi where pi.ord = 0 and p.delFlag = false")
    Page<Object[]> selectList(Pageable pageable);

}

* ProductRepository에 추가된 selectList()는 JPA에서 사용하는 JPQL을 이용해서 쿼리를 작성하는데 위와 같이 조인 처리가 가능하다. ProductRepositoryTests 에는 테스트 코드를 추가한다.

    /**
     * org.hibernate.LazyInitializationException은 Hibernate에서 지연 로딩된 엔터티나 컬렉션을 초기화할
     * 때 발생하는 일반적인 예외입니다. 이 예외는 지연 로딩된 엔터티나 컬렉션을 사용하는 도중에 Hibernate 세션이 닫혔을 때 발생합니다.
     * 
     * @Transactional 어노테이션을 사용하는 이유 중 하나는 트랜잭션 내에서 지연 로딩된 엔터티나 컬렉션을 사용할 때 발생하는 이러한
     *                문제를 해결하기 위함입니다.
     * 
     *                예를 들어, 당신의 selectList 메서드에서 Product 엔터티와 연관된 imageList를 지연
     *                로딩으로 가져오고 있다고 가정해봅시다. 그리고 트랜잭션이 시작되지 않은 상태에서
     *                result.getContent()를 호출하면 Hibernate는 세션이 없어서 지연 로딩된 imageList를
     *                초기화할 수 없게 됩니다.
     * 
     * @Transactional 어노테이션을 사용하면 트랜잭션 내에서 메서드가 실행되기 때문에 Hibernate는 세션을 유지하게 됩니다. 이로
     *                인해 지연 로딩된 엔터티나 컬렉션을 초기화할 때 문제가 발생하지 않습니다.
     * 
     *                따라서 해당 테스트 메서드에 @Transactional 어노테이션을 추가하면 지연 로딩과 관련된 문제를 해결할
     *                수 있습니다.
     */
    @Test
    @Transactional(rollbackFor = Exception.class)
    public void testList() {
        // 페이징 및 정렬 조건을 설정합니다.
        // import org.springframework.data.domain.Pageable
        // import org.springframework.data.domain.PageRequest
        // import org.springframework.data.domain.Sort
        Pageable pageable = PageRequest.of(0, 10, Sort.by("pno").descending());

        // 상품 목록을 조회합니다.
        // import org.springframework.data.domain.Page
        Page<Object[]> result = productRepository.selectList(pageable);

        // 조회 결과를 출력합니다.
        // import java.util.Arrays
        result.getContent().forEach(arr -> log.info(Arrays.toString(arr)));
    }

* testList()를 실행하면 left join 처리가 된 쿼리가 실행되는 것을 확인할 수 있고, Product와 ProductImage가 배열로 만들어진 것을 확인할 수 있다.