데이터베이스 팀프로젝트 3주차 작업로그

박서영·2026년 5월 26일

데이터베이스 수업

목록 보기
12/13

3주차 작업로그

1. 주요 기능 SQL 쿼리 작성하기

동작 시나리오: 상품

  • 회원은 상품을 전체 조회할 수 있다. ✔️
  • 회원은 키워드 검색을 통해 상품을 전체 조회할 수 있다. ✔️
    • 카테고리를 설정하여 상품을 조회할 수 있다. ✔️
    • 가격순(오름/내림차순)을 설정하여 상품을 조회할 수 있다. ✔️
  • 회원은 상품 내에서 옵션별 선택지를 조회하고 선택할 수 있다. ✔️

상품 목록 전체 조회

SELECT *
FROM product
ORDER BY created_at DESC;

상품 키워드 조회

SELECT *
FROM product 
WHERE product_name LIKE CONCAT('%', **{검색어}**, '%')
ORDER BY product_name;

상품 카테고리별 조회

SELECT *
FROM product
WHERE category_id = **{카테고리 ID}**
ORDER BY product_name;

상품 가격순별(오름차순/내림차순) 조회

SELECT *
FROM product
ORDER BY price ASC;
SELECT *
FROM product
ORDER BY price DESC;

개별 상품별 상세보기 (존재하는 옵션 확인하기)

SELECT pd.product_detail_id, od.option_type_id, od.option_detail_id
FROM product AS p JOIN 
		 product_detail pd ON p.product_id = pd.product_id JOIN 
		 product_option op ON pd.product_detail_id = op.product_detail_id JOIN 
		 option_detail od ON op.option_detail_id = od.option_detail_id
WHERE p.product_id = **{클릭한 상품ID}**;

개별 상품별 옵션 선택하기

SELECT pd.product_detail_id
FROM product_detail pd 
JOIN product_option op ON pd.product_detail_id = op.product_detail_id
WHERE pd.product_id = **{상품ID}**
		  AND op.option_detail_id IN (**{선택한 옵션상세ID(1)}**, **{선택한 옵션상세ID(2)}**)
GROUP BY pd.product_detail_id
HAVING COUNT(DISTINCT op.option_detail_id) = **{선택한 옵션 개수}**;

동작 시나리오: 장바구니

  • 회원은 물건(옵션 상세까지 지정 후) 장바구니에 물건을 담을 수 있다.
    • 옵션 지정하지 않을 수 못 담도록 설정해야함
  • 회원은 장바구니의 물건의 수량을 증가 및 감소시킬 수 있다.
  • 회원은 장바구니의 물건을 삭제할 수 있다.

장바구니에 물건 담기

INSERT INTO cart_item("member_id", "product_detail_id", "quantity")
VALUES (?, ?, ?);

장바구니 물건 수량 증가/감소

UPDATE cart_item
SET quantity = quantity + 1
WHERE member_id = ? AND product_detail_id = ?;
UPDATE cart_item
SET quantity = quantity - 1
WHERE member_id = ? AND product_detail_id = ?;

장바구니 물건 삭제

DELETE FROM cart_item
WHERE member_id = ? AND product_detail_id = ?;

2. 통계 관련 SQL 쿼리 작성하기

상품별 판매량 조회

SELECT p.product_id, SUM(pd.sales) AS total_sales
FROM product p JOIN
	 product_detail pd ON p.product_id = pd.product_id
GROUP BY p.product_id;

상품상세별 판매량 조회

SELECT pd.product_detail_id,
		MAX(CASE WHEN od.option_type_id=1 THEN od.option_value END) AS size_option,
		MAX(CASE WHEN od.option_type_id=2 THEN od.option_value END) AS color_option,
        SUM(pd.sales) AS total_sales
FROM product_detail pd JOIN
	 product_option op ON pd.product_detail_id = op.product_detail_id JOIN
     option_detail od ON op.option_detail_id = od.option_detail_id
GROUP BY pd.product_detail_id;
  • 작성시 AI 도움 받음 질문: product_detail_id마다의 옵션이 행으로 분리되어 여러 개의 옵션이 붙으면 2-3행이 나오는 것을 어떻게 처리할지?
    • 행으로 분리되어있는것을 → 열로 피벗 이동
    • CASE WHEN을 사용해서 옵션 타입별로 우선 새로운 새로 열로 만듦.
      • 이때 앞에 MAX()를 사용해서 GROUP BY를 사용할 때, 널과 값이 존재하는 경우에는 존재하는 값을 선택하도록 설정.

  • product 테이블까지 조인해주면 어떤 상품의 어떤 옵션들이 얼마나 팔렸는지 조회 가능
SELECT p.product_id, pd.product_detail_id,
			MAX(CASE WHEN od.option_type_id=1 THEN od.option_value END) AS size_option,
			MAX(CASE WHEN od.option_type_id=2 THEN od.option_value END) AS color_option,
      SUM(pd.sales) AS total_sales
FROM product p JOIN
		 product_detail pd ON p.product_id = pd.product_id JOIN
		 product_option op ON pd.product_detail_id = op.product_detail_id JOIN
     option_detail od ON op.option_detail_id = od.option_detail_id
GROUP BY pd.product_detail_id;

월별 매출 조회

SELECT MONTH(order_date) AS month, SUM(total_price) AS total_income
FROM orders
WHERE YEAR(order_date) = 2026
GROUP BY MONTH(order_date)
ORDER BY month;

전월 대비 매출

WITH month_income AS (
	SELECT MONTH(order_date) AS month, SUM(total_price) AS total_income
	FROM orders
	WHERE YEAR(order_date) = 2026
	GROUP BY MONTH(order_date)
	ORDER BY month
)

SELECT month, total_income AS "월 매출",
	   LAG(total_income, 1, 0) OVER (ORDER BY month) AS "전월매출",
       total_income - LAG(total_income, 1, 0) OVER (ORDER BY month) AS "전월 대비 증감"
FROM month_income;
  • CTE를 통해 우선 위에서 사용한 월별 매출을 임시 테이블처럼 사용할 수 있도록 구성
  • 윈도우 함수 LAG를 통해 직전 행을 가져올 수 있도록 구성

3. 더미 데이터 넣어서 테스트하기

  • 더미 데이터 생성
  • INSERT (삽입)
  • SELECT (조회)
  • UPDATE (수정)
  • DELETE (삭제)

0. 더미 데이터 (data.sql)

-- [제조업체]
INSERT INTO manufacturer (manufacturer_id, company_name, owner)
VALUES (1, 'ZARA', '자라 소유주');
INSERT INTO manufacturer (manufacturer_id, company_name, owner)
VALUES (2, 'H&M', 'H&M 소유주');
INSERT INTO manufacturer (manufacturer_id, company_name, owner)
VALUES (3, 'UNIQLO', 'UNIQLO 소유주');
INSERT INTO manufacturer (manufacturer_id, company_name, owner)
VALUES (4, 'Nike', 'Nike 소유주');
INSERT INTO manufacturer (manufacturer_id, company_name, owner)
VALUES (5, 'Adidas', 'Adidas 소유주');

-- [카테고리]
INSERT INTO category (category_id, category_name)
VALUES (1, '자켓');
INSERT INTO category (category_id, category_name)
VALUES (2, '티셔츠');
INSERT INTO category (category_id, category_name)
VALUES (3, '바지');
INSERT INTO category (category_id, category_name)
VALUES (4, '스커트');
INSERT INTO category (category_id, category_name)
VALUES (5, '아우터');
INSERT INTO category (category_id, category_name)
VALUES (6, '니트');

-- [상품]
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (1, 1, '트위드 자켓', 150000, 1, 'http://img/zara-tweed-jacket');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (2, 1, '플리츠 미디 스커트', 89000, 4, 'http://img/zara-pleats-skirt');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (3, 1, '리넨 블라우스', 65000, 2, 'http://img/zara-linen-blouse');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (4, 2, '오버사이즈 티셔츠', 29900, 2, 'http://img/hm-oversized-tee');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (5, 2, '슬림핏 청바지', 49900, 3, 'http://img/hm-slim-jeans');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (6, 3, '히트텍 롱슬리브', 19900, 2, 'http://img/uniqlo-heattech');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (7, 3, '후리스 집업', 59900, 5, 'http://img/uniqlo-fleece-zip');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (8, 4, '드라이핏 반팔', 45000, 2, 'http://img/nike-dri-fit');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (9, 5, '트랙 재킷', 89000, 1, 'http://img/adidas-track-jacket');
INSERT INTO product (product_id, manufacturer_id, product_name, price, category_id, image_url)
VALUES (10, 5, '조거 팬츠', 69000, 3, 'http://img/adidas-jogger-pants');
-- [상품상세]
-- 트위드 자켓 (product_id=1)
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (1, 1, 30, 0, 10, 'http://img/zara-tweed-jacket-white-s');
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (2, 1, 25, 0, 20, 'http://img/zara-tweed-jacket-white-m');
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (3, 1, 20, 0, 15, 'http://img/zara-tweed-jacket-black-m');
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (4, 1, 15, 0, 5, 'http://img/zara-tweed-jacket-black-l');
-- 플리츠 미디 스커트 (product_id=2)
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (5, 2, 40, 0, 30, 'http://img/zara-pleats-skirt-beige-s');
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (6, 2, 35, 0, 25, 'http://img/zara-pleats-skirt-beige-m');
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (7, 2, 30, 0, 20, 'http://img/zara-pleats-skirt-black-m');
INSERT INTO product_detail (product_detail_id, product_id, stock_quantity, surcharge, sales, image_url)
VALUES (8, 2, 20, 0, 10, 'http://img/zara-pleats-skirt-navy-l');
-- 등...

1. 조회 (SELECT)

(1) 상품별 옵션 조회하기

각 상품별로 어떤 옵션들이 있는지 조회

  • 상품상세(product_detai)과 옵션 상세(option_detail)을 둘을 연결하는 테이블인 상품옵션 조합(product_option) 테이블과 함께 조인해서 연관 있는 상품상세-옵션상세가 연결되도록 우선 설정.
  • 그 후에 WHERE에 조건을 설정하여 찾으려는 상품의 id를 조회
  • 해당 상품(id)와 연관된 옵션들의 목록을 볼 수 있음.
  • 재고가 남아있는 상품(상세)들만 조회되도록 WHERE 절에 조건 추가.
SELECT *
FROM product_detail as pd JOIN
		 product_option as pop ON pd.product_detail_id = pop.product_detail_id JOIN
     option_detail as od ON pop.option_detail_id = od.option_detail_id
WHERE pd.product_id = **{선택한 상품ID}**
			AND **pd.stock > 0**;

(2) 상품 옵션 타입별(사이즈/색상) 조회하기

상품 안에서 색상/사이즈 내역을 조회해서 출력하기 위한 쿼리문

SELECT *
FROM product_detail as pd JOIN
		 product_option as pop ON pd.product_detail_id = pop.product_detail_id JOIN
     option_detail as od ON pop.option_detail_id = od.option_detail_id
WHERE pd.product_id = **{선택한 상품ID}** 
			AND option_type_id = **{선택한 옵션종류ID}**;

(3) 카테고리별 상품 조회

SELECT *
FROM product as p JOIN
	 category as c ON p.category_id = c.category_id
WHERE c.category_name = '자켓';

3. 업데이트 (UPDATE)

(1) 상품 변경

상품가격 업데이트

UPDATE product
SET price = **{새로운 가격}**
WHERE product_id = **{변경하려는 상품}**;

(2) 상품상세 판매수량 업데이트

UPDATE product_detail
SET sales = {업데이트되는 판매량}
WHERE product_detail_id = {업데이트되는 상품상세ID};

(3) 상품 옵션 변경

UPDATE product
SET category_id = **{새로운 옵션ID}**
WHERE product_id = **{바꾸려는 상품ID}**;


  • 존재하지 않는 카테고리의 아이디로 설정해 업데이트하려고 하면 불가능. 에러를 일으킴.

(4) 상품-옵션 테이블에서의 행 삭제

상품-옵션 테이블의 행을 삭제하는 것은 “해당 상품의 옵션 종류 중 하나를 삭제하는 것을 의미”

DELETE FROM product_option
WHERE product_detail_id = **{상품 상세ID}** 
			AND option_detail_id = **{삭제하려는 옵션상세ID}**;

4. 삭제 (DELETE)

(1) 상품의 삭제 & 상품 삭제의 삭제

상품을 삭제했을 때, 상품을 외래키로 갖는 상품 상세 등이 어떻게 동작하는지를 확인.

  • product_detail 테이블의 외래키에 ON DELTE CASCADE를 설정해놨기 때문에 상품이 삭제되면 관련된 옵션 등의 데이터를 가지고 있던 상품 상세 데이터들도 삭제

  • 뿐만 아니라 이렇게 product_detail의 데이터가 삭제될 경우 해당 데이터를 참조하던 상품-옵션 테이블에서 해당 데이터를 참조하던 행들 역시 ON DELETE CASCADE를 적용해 삭제되도록 구성.

5. 삽입 (INSERT)

상품 테이블 삽입

  • 정상 삽입
  • 기본키(PK) 중복 불가: 중복된 기본키로 새로운행 삽입 시 에러
  • 가격 널값일 때: 삽입 에러

상품 상세 테이블

  • 정상 삽입
  • 이미지 URL 생략하여 삽입: 이미지URL은 NULL값을 허용하므로 생략해서 삽입 가능

상품-옵션 테이블

  • 정상 삽입
  • 옵션 상세ID/상품상세ID 생략: 두 외래키의 널값을 허용하지 않으므로 삽입 불가 (에러)

  • 외래키 무결성: 존재하지 않는 product_detail_id 또는 option_detail_id를 삽입하는 것은 불가 (에러)

  • 더미 데이터 생성
  • INSERT (삽입)
  • SELECT (조회)
  • UPDATE (수정)
  • DELETE (삭제)

0. 더미 데이터 (data.sql)

-- [옵션 종류]
INSERT INTO option_type (option_type_id, option_type_name)
VALUES (1, '사이즈');
INSERT INTO option_type (option_type_id, option_type_name)
VALUES (2, '색상');

-- [옵션 상세]
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (1, 1, 'XS');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (2, 1, 'S');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (3, 1, 'M');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (4, 1, 'L');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (5, 1, 'XL');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (6, 2, '화이트');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (7, 2, '블랙');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (8, 2, '네이비');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (9, 2, '베이지');
INSERT INTO option_detail (option_detail_id, option_type_id, option_value)
VALUES (10, 2, '그레이');

1. 삽입 (INSERT)

(1) 옵션 종류 테이블

  • 정상 삽입
  • ‼️ 문제점: 옵션 종류의 이름(분류) 역시 중복이면 안되는데 UNIQUE 설정이 되어있지 않아 중복으로 들어가는 상황

⇒ option_type_name을 UNIQUE가 되도록 (option_type_id, option_type_name)을 복합키로 구성하도록 변경

CREATE TABLE option_type
(
    option_type_id   BIGINT      NOT NULL AUTO_INCREMENT,
    option_type_name VARCHAR(30) NOT NULL,

    PRIMARY KEY (option_type_name, option_type_id)
);


(2) 옵션 상세 테이블

  • 정상삽입
  • ‼️ 문제: (option_type, option_value)를 UNIQUE로 지정해야, 중복된 옵션 상세 내용이 들어가지 않음


  • ‼️ 문제: option_type_id에 널값을 허용하여 종류를 구분하지 않은 채로 옵션 상세 값이 들어감 ⇒ 외래키 널값 허용 금지하기

⇒ ❓ 의문: PK(대리키)를 제외하고 두 속성을 합쳐서 UNIQUE인 제약이 있어도 괜찮을지?

2. 조회 (SELECT)

(1) 옵션 종류별 존재하는 옵션 조회

3. 수정 (UPDATE)

(1) 옵션 종류(option type) 테이블 업데이트

  • 정상 업데이트

  • ‼️ 문제: option_type_name을 널값을 금지하긴했지만, 이게 문자열이 공백인거랑은 또 달라서 공백으로도 업데이트가 되는 부분 ⇒ check constraint로 추가하기 (또는 spring에서 @Not Blank 어노테이션 사용하기)

(2) 옵션 상세(option detail) 테이블 업데이트

  • 정상 업데이트

  • ‼️문제: 옵션 종류와 마찬가지로 공백 문자열 허용하는 경우가 존재 ⇒ CHECK + CONSTRAINT 사용해서 수정

4. 삭제 (DELETE)

옵션 종류 테이블

  • 옵션 상세에서 참조하는 행을 삭제 ⇒ 삭제 불가 (금지 RESTRICTED)

옵션 상세 테이블

  • 정상 삭제: 자식 테이블이라 추가적인 제약은 없음.


4. 트리거/프로시저/함수

리뷰 신고 (트랜잭션 + 프로시저)

  • 리뷰 신고 시, review_report 테이블에 추가
  • review 테이블에 신고 횟수를 기록하기 위한 컬럼 추가 (수정사항)
  • 리뷰 신고 테이블에 추가 → 리뷰에 신고 횟수 증가 이 부분을 하나의 트랜잭션으로 생성
  • 트랜잭션 중간에 에러가 생겼을 때, 롤백 시키기 위한 방법을 고민하다 AI한테 물어봤는데 SQLEXCEPTION 발생시 빠져나가 롤백하도록 BEGIN 바로 아래에 구성.
DELIMITER $$

CREATE PROCEDURE report_review (
	IN r_user_id BIGINT,
    IN r_review_id BIGINT,
    IN r_reason VARCHAR(255)
) 
BEGIN
	DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
		ROLLBACK;
	END;
    
	START TRANSACTION;
    
    INSERT INTO review_report (review_id, reporter_member_id, report_reason, report_status)
	VALUES(r_review_id, r_user_id, r_reason, 'WAITING');
    
    UPDATE review
    SET report_count = report_count+1
    WHERE review_id = r_review_id;
    
    COMMIT;
END $$
DELIMITER ;

장바구니 아이템 추가 (프로시저)

DELIMITER $$

CREATE PROCEDURE add_cart (
	IN p_member_id BIGINT,
    IN p_product_id BIGINT,
    IN p_quantity INT
) 
BEGIN
	DECLARE exist INT;
    
	DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
		ROLLBACK;
	END;
    
	START TRANSACTION;
    
    SELECT COUNT(*) INTO exist
    FROM cart_item
    WHERE member_id = p_member_id AND product_detail_id = p_product_id;
    
    IF exist > 0 THEN
		UPDATE cart_item
        SET quantity = quantity + p_quantity
        WHERE member_id = p_member_id AND product_detail_id = p_product_id;
	ELSE
		INSERT INTO cart_item (member_id, product_detail_id, quantity)
        VALUES (p_member_id, p_product_id, p_quantity);
	END IF;
    
    COMMIT;
END $$
DELIMITER ;

장바구니 재고 확인 (트리거)

DELIMITER $$

CREATE TRIGGER check_stock_insert
BEFORE INSERT ON cart_item
FOR EACH ROW
BEGIN
	DECLARE stock INT;
    
    SELECT stock_quantity INTO stock
    FROM product_detail
    WHERE product_detail_id = NEW.product_detail_id;
    
    IF NEW.quantity > stock
    THEN SIGNAL SQLSTATE '45000'
		 SET MESSAGE_TEXT = '상품 재고가 부족합니다.';
	END IF;
END $$

CREATE TRIGGER check_stock_update
BEFORE UPDATE ON cart_item
FOR EACH ROW
BEGIN
	DECLARE stock INT;
    
    SELECT stock_quantity INTO stock
    FROM product_detail
    WHERE product_detail_id = NEW.product_detail_id;
    
    IF NEW.quantity > stock
    THEN SIGNAL SQLSTATE '45000'
		 SET MESSAGE_TEXT = '상품 재고가 부족합니다.';
	END IF;
END $$
DELIMITER ;

장바구니 총 결제 금액 계산 (함수)

DELIMITER $$

CREATE FUNCTION get_cart_price (
	p_member_id BIGINT
) 
RETURNS INT
DETERMINISTIC
BEGIN
	DECLARE total_price INT;
    
    SELECT IFNULL(SUM(price*quantity),0) INTO total_price
    FROM cart_item
    WHERE member_id = p_member_id;
    
    RETURN total_price;
    
END $$
DELIMITER ;

SELECT get_cart_price(5) AS total_price;

5. 상품 CRUD 작성하기

코드

db-project-e-commerce/backend/src/main/java/db/project/ecommerce/product at main · 26-1-db-course-project/db-project-e-commerce

1. 폴더 분리

  • 폴더는 controller, domain(entity), dto(response/request), service, repository로 분리하여 진행하였습니다.
  • SQL 쿼리 공유

2. Controller

  • 상품 생성 (POST)
  • 상품 조회 (GET)
    • 상품 목록 조회 (일반)
    • 상품 카테고리별 조회
    • 상품 가격순 조회
    • 상품 개별 조회
  • 상품 가격 수정 (PATCH)
  • 상품 삭제 (DELETE)

코드

@Controller
@RequiredArgsConstructor
@RequestMapping("/products")
public class ProductController {
    private final ProductService productService;

    //TODO: 상품 생성
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(@RequestBody CreateProductRequest request) {
        ProductResponse response = productService.createProduct(request);

        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    //TODO: 상품 목록 조회 (가격순 정렬)
    @GetMapping
    public ResponseEntity<ProductListResponse> getProductList(@RequestParam(defaultValue = "productName,desc") String sortBy) {
        ProductListResponse response = productService.getProductList(sortBy);

        return ResponseEntity.ok(response);
    }

    //TODO: 카테고리별 상품 목록 조회 (가격순 정렬)
    @GetMapping("/category/{categoryId}")
    public ResponseEntity<ProductListResponse> getProductListByCategory(@PathVariable("categoryId") Long categoryId,
                                                                        @RequestParam(defaultValue = "productName,desc") String sortBy) {
        ProductListResponse response = productService.getProductListByCategory(categoryId, sortBy);

        return ResponseEntity.ok(response);
    }

    //TODO: 상품 개별 조회
    @GetMapping("/{productId}")
    public ResponseEntity<ProductResponse> getProductDetail(@PathVariable("productId") Long productId) {
        ProductResponse response = productService.getProductDetail(productId);

        return ResponseEntity.ok(response);
    }

    //TODO: 상품 검색
    @GetMapping("/search")
    public ResponseEntity<ProductListResponse> searchProduct(@RequestBody SearchProduct request,
                                                         @RequestParam(defaultValue = "productName,desc") String sortBy) {
        ProductListResponse response = productService.searchProduct(request, sortBy);

        return ResponseEntity.ok(response);

    }

    //TODO: 상품 가격 업데이트
    @PatchMapping("/{productId}")
    public ResponseEntity<Void> updateProductPrice(@PathVariable("productId") Long productId,
                                                   @RequestBody UpdateProductPrice request) {
        productService.updateProductPrice(productId, request);

        return ResponseEntity.ok().build();
    }

    //TODO: 상품 삭제
    @DeleteMapping("/{productId}")
    public ResponseEntity<Void> deleteProduct(@PathVariable("productId") Long productId) {
        productService.deleteProduct(productId);

        return ResponseEntity.ok().build();
    }
}

3. Domain(Entity)

Category

@Entity
@Getter
@NoArgsConstructor
public class Category {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "category_id")
    private Long id;

    @Column(name = "category_name")
    private String category;

    @Builder
    public Category(String category) {
        this.category = category;
    }
}

Manufacturer

@Entity
@Getter
@NoArgsConstructor
public class Manufacturer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "manufacturer_id")
    private Long id;

    @Column (name = "company_name")
    private String company;

    @Column (name = "owner")
    private String owner;

    @Builder
    public Manufacturer(String company, String owner) {
        this.company = company;
        this.owner = owner;
    }
}

Product

@Entity
@Getter
@NoArgsConstructor
public class Product {
    @Id
    @GeneratedValue (strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    private Long id;

    @ManyToOne (fetch = FetchType.LAZY)
    @JoinColumn(name = "manufacturer_id")
    private Manufacturer manufacturer;

    @Column(name = "product_name")
    private String productName;

    @Column(name = "price")
    private int price;

    @OneToOne
    @JoinColumn(name = "category_id")
    private Category category;

    @Column (name = "image_url")
    private String imageUrl;

    @Builder
    public Product(Manufacturer manufacturer, String name, int price, Category category, String imageUrl) {
        this.manufacturer = manufacturer;
        this.productName = name;
        this.category = category;
        this.price = price;
        this.imageUrl = imageUrl;
    }

    public void updatePrice(int price) {
        this.price = price;
    }

}

4. Dto

CreateProductRequest

@Getter
@AllArgsConstructor
public class CreateProductRequest {
    private Long manufacturerId;
    private String productName;
    private int price;
    private Long categoryId;
    private String imageUrl;
}

SearchProduct

@Getter
@AllArgsConstructor
public class SearchProduct {
    private String keyword;
}

UpdateProductPrice

@Getter
@AllArgsConstructor
public class UpdateProductPrice {
    private int price;
}

ProductListResponse

@Getter
@Builder
@AllArgsConstructor
public class ProductListResponse {
    private List<ProductResponse> productResponseList;
    private Long productCount;

    public static ProductListResponse of (List<Product> products) {
        return ProductListResponse.builder()
                .productResponseList(products.stream().map(ProductResponse::of).toList())
                .productCount((long) products.size())
                .build();
    }
}

ProductResponse

@Getter
@Builder
@AllArgsConstructor
public class ProductResponse {
    private Long productId;
    private String productName;
    private int price;
    private String manufacturer;
    private String category;
    private String imageUrl;

    public static ProductResponse of (Product product) {
        return ProductResponse.builder()
                .productId(product.getId())
                .productName(product.getProductName())
                .price(product.getPrice())
                .category(product.getCategory().getCategory())
                .manufacturer(product.getManufacturer().getCompany())
                .imageUrl(product.getImageUrl())
                .build();
    }
}

5. Repository

  • 검색 관련 쿼리만 JPQL을 사용하여 작성하였습니다.
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findAllByCategory(Category category, Sort sort);
    @Query("select p from Product p where p.productName LIKE %:keyword%" )
    List<Product> searchByName(@Param("keyword")String keyword, Sort sort);
}

6. Service

코드

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ManufacturerRepository manufacturerRepository;
    private final CategoryRepository categoryRepository;
    private final ProductRepository productRepository;

    //TODO: 상품 생성
    @Transactional
    public ProductResponse createProduct(CreateProductRequest request) {
        Manufacturer manufacturer = getManufacturer(request.getManufacturerId());
        Category category = getCategory(request.getCategoryId());

        Product newProduct = Product.builder()
                .manufacturer(manufacturer)
                .name(request.getProductName())
                .price(request.getPrice())
                .category(category)
                .imageUrl(request.getImageUrl())
                .build();

        productRepository.save(newProduct);

        return ProductResponse.of(newProduct);
    }

    //TODO: 상품 목록조회 (일반)
    @Transactional(readOnly = true)
    public ProductListResponse getProductList(String sortBy) {
        Sort sort = createSort(sortBy);
        List<Product> productList = productRepository.findAll(sort);

        return ProductListResponse.of(productList);
    }

    //TODO: 상품 목록조회 (카테고리)
    @Transactional(readOnly = true)
    public ProductListResponse getProductListByCategory(Long categoryId, String sortBy) {
        Category category = getCategory(categoryId);
        Sort sort = createSort(sortBy);
        List<Product> productList = productRepository.findAllByCategory(category, sort);

        return ProductListResponse.of(productList);
    }

    //TODO: 상품 개별조회
    @Transactional(readOnly = true)
    public ProductResponse getProductDetail(Long productId) {
        Product product = getProduct(productId);

        return ProductResponse.of(product);
    }

    //TODO: 상품 가격 수정
    @Transactional
    public void updateProductPrice(Long productId, UpdateProductPrice request) {
        Product product = getProduct(productId);
        product.updatePrice(request.getPrice());
    }

    //TODO: 상품 삭제
    @Transactional
    public void deleteProduct(Long productId) {
        Product product = getProduct(productId);
        productRepository.delete(product);
    }

    //TODO: 상품 검색
    @Transactional(readOnly = true)
    public ProductListResponse searchProduct(SearchProduct request, String sortBy) {
        Sort sort = createSort(sortBy);
        List<Product> productList = productRepository.searchByName(request.getKeyword(),sort);

        return ProductListResponse.of(productList);
    }

    private Product getProduct(Long productId) {
        return productRepository.findById(productId)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));
    }
    private Manufacturer getManufacturer(Long manufacturerId) {
        return manufacturerRepository.findById(manufacturerId)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));
    }

    private Category getCategory (Long categoryId) {
        return categoryRepository.findById(categoryId)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));
    }

    //AI 도움.
    private Sort createSort (String sortBy) {
        try {
            String[] sortParams = sortBy.split(",");
            String property = sortParams[0];
            String direction = sortParams[1];

            if (direction.equalsIgnoreCase("asc")) {
                return Sort.by(Sort.Direction.ASC, property);
            } else {
                return Sort.by(Sort.Direction.DESC, property);
            }
        } catch (Exception e) {
            return Sort.by(Sort.Direction.DESC, "productName");
        }
    }

}

7. 테스트 화면

(1) 상품 목록 조회: 일반 (GET)

URL: /products


(2) 상품 목록 조회: 카테고리별 (GET)

URL: /product/category/{categoryId}


(3) 상품 목록 조회 (가격순: 내림차순) (GET)

URL: /products?sortBy=price,desc


(4) 상품 목록 조회 (가격순: 오름차순) (GET)

URL: /products?sortBy=price,asc


(5) 상품 개별 조회 (GET)

URL: /products/{productId}


(6) 상품 생성 (POST)

URL: /products


(7) 상품 가격 수정

URL: /products/{productId}


(8) 상품 삭제

URL: /products/{productId}


(9) 상품 키워드 검색

URL: /products/search

profile
이불 밖은 위험해.

0개의 댓글