[DCommerce] 3편 - 상품 API + 주문 API 구현하기

Do Hyun ·2026년 4월 29일

commerce-project

목록 보기
3/6

[DCommerce] 3편 - 상품 API + 주문 API 구현하기

요구사항 정의

상품 API

1. 상품 등록 — 상품명, 가격, 재고수량 입력받아 등록
2. 상품 목록 조회 — 전체 상품 목록 조회
3. 상품 단건 조회 — 상품 ID로 특정 상품 조회, 없으면 예외

주문 API

1. 주문 생성 — 로그인한 회원만 가능 (JWT 필요)
              상품 ID + 수량으로 주문 생성
              재고 부족 시 예외
              주문 생성 시 재고 차감
2. 내 주문 목록 조회 — 로그인한 회원의 주문 목록 조회

정적 팩토리 메서드 (Static Factory Method)

new 키워드 대신 static 메서드로 객체를 생성하는 패턴.

// 일반 생성자
ProductListResponseDTO dto = new ProductListResponseDTO(product.getId(), product.getName(), ...);

// 정적 팩토리 메서드
ProductListResponseDTO dto = ProductListResponseDTO.from(product);

장점

첫째, 이름을 줄 수 있다

// 이게 뭘 만드는지 한눈에 알아?
new ProductListResponseDTO(1L, "상품명", 1000, 10);

// 이건?
ProductListResponseDTO.from(product);  // Product로부터 DTO 만든다

둘째, Entity 변경이 한 곳에 집중된다

Product 필드가 추가되거나 바뀌면 from() 메서드 하나만 고치면 된다.

셋째, Setter가 필요 없어진다

Setter 없이 객체를 불변으로 만들 수 있다.

"정적 팩토리 메서드는 객체 생성의 의도를 명확히 하고, 변환 로직을 한 곳에 모아 유지보수성을 높이기 위해 사용합니다."


상품 API 구현

ProductService

@RequiredArgsConstructor
@Service
public class ProductServiceImpl implements ProductService {

    private final ProductRepository productRepository;

    @Override
    public void createProduct(ProductCreateRequestDTO dto) {
        if (dto.getPrice() < 0) {
            throw new IllegalArgumentException("가격이 음수입니다.");
        }
        if (dto.getStockQuantity() < 0) {
            throw new IllegalArgumentException("재고가 0보다 작습니다.");
        }

        Product product = Product.builder()
                .name(dto.getName())
                .price(dto.getPrice())
                .stockQuantity(dto.getStockQuantity())
                .build();

        productRepository.save(product);
    }

    @Override
    public List<ProductListResponseDTO> getProductList() {
        List<Product> all = productRepository.findAll();
        List<ProductListResponseDTO> result = new ArrayList<>();
        for (Product product : all) {
            result.add(ProductListResponseDTO.from(product));
        }
        return result;
    }

    @Override
    public ProductDetailResponseDTO getProductDetail(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상품입니다."));
        return ProductDetailResponseDTO.from(product);
    }
}

포인트: orElseThrow() 활용
Optional.get()을 바로 쓰면 NPE 위험이 있다.
orElseThrow()로 값이 없을 때 명확한 예외를 던져야 한다.

ProductController

@RequiredArgsConstructor
@RestController
@RequestMapping("/product")
public class ProductController {

    private final ProductService productService;

    @PostMapping
    public ResponseEntity<Void> createProduct(@RequestBody ProductCreateRequestDTO dto) {
        productService.createProduct(dto);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @GetMapping
    public ResponseEntity<List<ProductListResponseDTO>> getProductList() {
        return ResponseEntity.ok(productService.getProductList());
    }

    @GetMapping("/{productId}")
    public ResponseEntity<ProductDetailResponseDTO> getProductDetail(@PathVariable Long productId) {
        return ResponseEntity.ok(productService.getProductDetail(productId));
    }
}

포인트: 등록 API는 201 Created 반환

"등록 API는 자원이 생성됐음을 명확히 하기 위해 200 OK 대신 201 Created를 반환했습니다."


주문 API 구현

재고 차감 설계 — 도메인 메서드

재고 차감 로직을 Product Entity 안에 메서드로 만들었다.

재고 차감 로직 → Product 도메인 메서드
재고 차감 호출 → OrderService

Entity가 자기 상태를 스스로 변경하는 방식 — Setter 없이 안전하게 처리한다.

// Product.java
public void decreaseStockQuantity(int quantity) {
    if (this.stockQuantity < quantity) {
        throw new IllegalArgumentException("재고가 부족합니다.");
    }
    this.stockQuantity -= quantity;
}

OrderService

@RequiredArgsConstructor
@Service
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;

    @Transactional
    @Override
    public void createOrder(Long memberId, CreateOrderRequestDTO dto) {
        // 1. 회원 조회
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new IllegalArgumentException("등록된 회원이 아닙니다."));

        // 2. 상품 조회
        Product product = productRepository.findById(dto.getProductId())
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상품입니다."));

        // 3. 주문 생성
        Order order = Order.builder()
                .member(member)
                .orderPrice((long) (dto.getQuantity() * product.getPrice()))
                .orderStatus(OrderStatus.ORDERED)
                .orderedAt(LocalDateTime.now())
                .build();

        // 4. 주문아이템 생성 (주문 당시 가격 스냅샷)
        OrderItem orderItem = OrderItem.builder()
                .order(order)
                .product(product)
                .quantity(dto.getQuantity())
                .orderPrice(product.getPrice())
                .build();

        // 5. 재고 차감
        product.decreaseStockQuantity(dto.getQuantity());

        // 6. 저장
        orderRepository.save(order);
        orderItemRepository.save(orderItem);
    }
}

포인트 1: @Transactional

재고 차감, 주문 저장, 주문아이템 저장이 하나의 트랜잭션으로 묶여야 한다.
중간에 하나라도 실패하면 전부 롤백된다.

// Spring 거로 임포트해야 함
import org.springframework.transaction.annotation.Transactional;
// Jakarta 거 아님!

포인트 2: OrderItem에 가격 스냅샷 저장

상품 가격이 나중에 바뀌어도 주문 당시 가격을 유지하기 위해
OrderItemorderPrice를 따로 저장한다.

OrderController

@RequiredArgsConstructor
@RequestMapping("/order")
@RestController
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<Void> createOrder(
            HttpServletRequest request,
            @RequestBody CreateOrderRequestDTO dto) {
        Long memberId = (Long) request.getAttribute("memberId");
        orderService.createOrder(memberId, dto);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

포인트: memberId는 JWT에서 꺼낸다

JwtInterceptor에서 토큰 검증 후 request.setAttribute("memberId", memberId)로 저장해뒀기 때문에
Controller에서 request.getAttribute("memberId")로 꺼내 쓸 수 있다.


API 테스트

상품 등록

POST http://localhost:8080/product
Content-Type: application/json

{
    "name": "맥북 프로",
    "price": 3000000,
    "stockQuantity": 10,
    "description": "M3 Pro 칩셋"
}

주문 생성

POST http://localhost:8080/order
Authorization: Bearer {JWT토큰}
Content-Type: application/json

{
    "productId": 1,
    "quantity": 2
}

@Builder.Default 주의사항

@Builder와 컬렉션 초기화를 함께 쓸 때 경고가 발생한다:

// 경고 발생
private List<OrderItem> orderItemList = new ArrayList<>();

// 해결
@Builder.Default
private List<OrderItem> orderItemList = new ArrayList<>();

@Builder는 기본값 초기화를 무시하기 때문에 @Builder.Default를 붙여야
빌더로 생성할 때도 빈 리스트가 초기화된다.


오늘 배운 것

  • 정적 팩토리 메서드 패턴과 장점
  • orElseThrow()로 안전한 Optional 처리
  • 도메인 메서드로 Entity 상태 변경 (Setter 없이)
  • @Transactional로 원자적 트랜잭션 처리
  • JWT에서 memberId 꺼내는 방법
  • 201 Created vs 200 OK 차이
  • @Builder.Default 사용법

profile
우당탕탕

0개의 댓글