1. 상품 등록 — 상품명, 가격, 재고수량 입력받아 등록
2. 상품 목록 조회 — 전체 상품 목록 조회
3. 상품 단건 조회 — 상품 ID로 특정 상품 조회, 없으면 예외
1. 주문 생성 — 로그인한 회원만 가능 (JWT 필요)
상품 ID + 수량으로 주문 생성
재고 부족 시 예외
주문 생성 시 재고 차감
2. 내 주문 목록 조회 — 로그인한 회원의 주문 목록 조회
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 없이 객체를 불변으로 만들 수 있다.
"정적 팩토리 메서드는 객체 생성의 의도를 명확히 하고, 변환 로직을 한 곳에 모아 유지보수성을 높이기 위해 사용합니다."
@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()로 값이 없을 때 명확한 예외를 던져야 한다.
@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를 반환했습니다."
재고 차감 로직을 Product Entity 안에 메서드로 만들었다.
재고 차감 로직 → Product 도메인 메서드
재고 차감 호출 → OrderService
Entity가 자기 상태를 스스로 변경하는 방식 — Setter 없이 안전하게 처리한다.
// Product.java
public void decreaseStockQuantity(int quantity) {
if (this.stockQuantity < quantity) {
throw new IllegalArgumentException("재고가 부족합니다.");
}
this.stockQuantity -= quantity;
}
@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에 가격 스냅샷 저장
상품 가격이 나중에 바뀌어도 주문 당시 가격을 유지하기 위해
OrderItem에 orderPrice를 따로 저장한다.
@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")로 꺼내 쓸 수 있다.
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와 컬렉션 초기화를 함께 쓸 때 경고가 발생한다:
// 경고 발생
private List<OrderItem> orderItemList = new ArrayList<>();
// 해결
@Builder.Default
private List<OrderItem> orderItemList = new ArrayList<>();
@Builder는 기본값 초기화를 무시하기 때문에 @Builder.Default를 붙여야
빌더로 생성할 때도 빈 리스트가 초기화된다.
orElseThrow()로 안전한 Optional 처리@Transactional로 원자적 트랜잭션 처리@Builder.Default 사용법