본문 바로가기

백엔드 엔지니어링 일지

마켓 백엔드 엔진 5 : product DB 구체화, 대용량 더미 데이터 생성, 검색 기능 개발

운영 및 배포 테스트를 위해서 Docker에 모든 환경을 컨테이너에 올렸으나, Next.js를 통한 빠른 프론트엔드 및 API 개발을 위해서는 로컬 개발환경에서 코드를 빠르게 수정하고 결과물을 보는 것이 효율적입니다.

개발환경 설정을 Docker postgre로 하였기 때문에, 백과 프론트 서버는 로컬 개발환경에서 구동하고 도커 컨테이너에서는 postgres만 구동하면 됩니다.

 

통합 운영 테스트는 컨테이너에 올려서 진행:
도커 컨테이너 실행
docker compose up --build -d

빠른 개발을 위해 로컬 개발환경에서 진행:
로컬 개발환경 실행
cd c:\Users\chlwl\workspace\backend-engineering
docker compose up -d postgres

cd c:\Users\chlwl\workspace\backend-engineering\backend
.\gradlew.bat bootRun

cd c:\Users\chlwl\workspace\backend-engineering\frontend
npm run dev

 

product DB 구체화

product를 의류로 구체화하고

검색 및 정렬 기능을 위해 product에 다음과 같은 컬럼을 추가했습니다.

ALTER TABLE products
    ADD COLUMN description TEXT NOT NULL DEFAULT '',
    ADD COLUMN category VARCHAR(100) NOT NULL DEFAULT 'T_SHIRT',
    ADD COLUMN brand VARCHAR(100) NOT NULL DEFAULT 'NIKE',
    ADD COLUMN color VARCHAR(50) NOT NULL DEFAULT 'BLACK',
    ADD COLUMN gender VARCHAR(20) NOT NULL DEFAULT 'UNISEX',
    ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
    ADD COLUMN popularity_score INT NOT NULL DEFAULT 0,
    ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP;

 

category로 품목을 분류합니다.

 

정해진 category만 product 생성시 입력받을 수 있게 productDto에 enum class (productCategory)를 적용했습니다.

status에 따라서 판매 가능한 품목을 선별합니다.

priceAmount, brand, color, gender로 필터를 적용하고

popularity_score와 updated_at 에 따라서 최신순, 혹은 인기도순으로 품목을 정렬할 수 있습니다.

 

프론트도 DB 변경사항에 맞게 update 했습니다.

 

 

이제 검색 기능을 최적화 하기 위해서 의미 있는 대용량 데이터를 쌓아야 합니다.

 

이미지는 placehold.co 서비스를 사용해서 일단 text로 입력하고

브랜드 종류, 가격, 이름 등을 좀 더 다양하고 현실적으로 만든 후 천만개의 데이터를 쌓았습니다.

 

 

 

상품 상세 페이지에서 주문을 할 수 있습니다.

 

 

ProductRepository에 JpaSpecificationExecutor을 적용했습니다.

package com.marketengine.backend.product.domain;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}

JpaSpecificationExecutor는 동적 조건 검색을 쉽게 하기 위한 Spring Data JPA 인터페이스입니다.

  • 기본 JpaRepository는 정적인 메서드(findByBrandAndColor...)에 강함
  • 검색 조건이 많아지면 메서드가 폭발적으로 늘어남
  • JpaSpecificationExecutor를 붙이면 Specification 조합으로 조건을 필요할 때만 붙일 수 있음

지금 코드에서:

  • ProductRepository가 JpaSpecificationExecutor<Product>를 구현
  • productRepository.findAll(spec, pageable) 호출 가능
  • 즉, 필터 + 정렬 + 페이징을 한 번에 처리

ProductService.list 

public ProductPageResponse list(
            String keyword,
            ProductCategory category,
            String brand,
            String gender,
            String color,
            Integer minPrice,
            Integer maxPrice,
            String sortBy,
            int page,
            int size
    ) {
        Sort sort = "POPULARITY".equalsIgnoreCase(sortBy)
                ? Sort.by(Sort.Direction.DESC, "popularityScore").and(Sort.by(Sort.Direction.DESC, "id"))
                : Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id"));
        Pageable pageable = PageRequest.of(page, size, sort);

        Specification<Product> spec = (root, query, cb) -> cb.conjunction();
        if (hasText(keyword)) {
            String normalizedKeyword = "%" + keyword.trim().toLowerCase() + "%";
            spec = spec.and((root, query, cb) -> cb.or(
                    cb.like(cb.lower(root.get("name")), normalizedKeyword),
                    cb.like(cb.lower(root.get("brand")), normalizedKeyword)
            ));
        }
        if (category != null) {
            spec = spec.and((root, query, cb) -> cb.equal(root.get("category"), category));
        }
        if (hasText(brand)) {
            spec = spec.and((root, query, cb) -> cb.equal(root.get("brand"), brand.trim()));
        }
        if (hasText(gender)) {
            spec = spec.and((root, query, cb) -> cb.equal(root.get("gender"), gender.trim()));
        }
        if (hasText(color)) {
            spec = spec.and((root, query, cb) -> cb.equal(root.get("color"), color.trim()));
        }
        if (minPrice != null) {
            spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("priceAmount"), java.math.BigDecimal.valueOf(minPrice)));
        }
        if (maxPrice != null) {
            spec = spec.and((root, query, cb) -> cb.lessThanOrEqualTo(root.get("priceAmount"), java.math.BigDecimal.valueOf(maxPrice)));
        }

        Page<ProductSummaryResponse> pageResult = productRepository.findAll(spec, pageable).map(ProductSummaryResponse::from);
        return ProductPageResponse.from(pageResult);
    }

 

ProductService.list(...)는 검색 API의 핵심 로직입니다.

역할 순서:

  1. 정렬 기준 결정
    • POPULARITY면 popularityScore desc, id desc
    • 아니면 createdAt desc, id desc
  2. 페이지 객체 생성
    • PageRequest.of(page, size, sort)
  3. 동적 필터 조건 조립 (Specification)
    • keyword 있으면 name/brand like
    • category, brand, gender, color는 equal
    • minPrice, maxPrice는 가격 범위 조건
  4. DB 조회
    • findAll(spec, pageable)
  5. DTO 변환 + 페이지 응답 포맷 변환
    • Page<ProductSummaryResponse>로 매핑
    • ProductPageResponse.from(...)으로 반환

핵심 포인트:

  • 조건이 없으면 전체조회(페이징 포함)
  • 조건이 있으면 해당 조건만 SQL where에 반영
  • 프론트는 필요한 페이지 데이터만 받음

ProductController.list

public ResponseEntity<ApiResponse<ProductPageResponse>> list(
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) ProductCategory category,
            @RequestParam(required = false) String brand,
            @RequestParam(required = false) String gender,
            @RequestParam(required = false) String color,
            @RequestParam(required = false) Integer minPrice,
            @RequestParam(required = false) Integer maxPrice,
            @RequestParam(required = false) String sortBy,
            @RequestParam(required = false) Integer page,
            @RequestParam(required = false) Integer size
    ) {
        int normalizedPage = page == null ? 0 : Math.max(page, 0);
        int normalizedSize = size == null ? 12 : Math.min(Math.max(size, 1), 100);
        String normalizedSort = sortBy == null ? "VIEW" : sortBy;
        return ResponseEntity.ok(ApiResponse.ok(productService.list(
                keyword,
                category,
                brand,
                gender,
                color,
                minPrice,
                maxPrice,
                normalizedSort,
                normalizedPage,
                normalizedSize
        )));
    }

ProductController.list(...)는 HTTP 요청을 서비스에 전달하는 입구입니다.

역할:

  1. 쿼리 파라미터 수신 (keyword, category, brand, page, size 등)
  2. 기본값/범위 보정
    • page 기본 0, 음수 방지
    • size 기본 12, 최소 1 / 최대 100
    • sortBy 기본 VIEW
  3. 서비스 호출
  4. ApiResponse.ok(...)로 응답 래핑

즉 Controller는:

  • “입력 정리/검증 + 서비스 호출” Service는:
  • “실제 검색 로직/쿼리 구성/결과 생성”

이렇게 역할이 분리되어 있어서 유지보수와 테스트가 쉬워집니다.

 

 

 

프론트엔드에서 상품 조회시 총 로딩속도를 측정했습니다.

최초 조회시 2375ms, 이후 필터 변경에 따라서 800-2000ms의 성능을 보입니다.