
사전캠프 기간 동안 다음 내용으로 미니 프로젝트를 구현하게 되었다.
💡상품을 등록하고 → 사용자가 주문하고 → 주문 정보를 조회한다
- 프로젝트 세팅 (DB 연결 포함)
- 상품 CRUD 동작
- 주문 생성 동작
- 주문 조회 동작
- (도전) 주문 목록 조회 구현
- (도전) 주문 목록 조회시 N+1문제 해결
- (도전) 상품 재고 차감 구현
- (도전) 상품 재고 원자적 차감 구현
이번 주말까지 필수 과제 기능을 완성 한 후, 다음 주부터 도전과제를 시작 할 것 같다.
필수 과제까지는 이미 해본 적이 있어서 어렵지 않을 것 같지만,
도전과제부터는 처음 해보는 것이라 조금 걱정되긴 한다. 그래도 과제를 해내길 기대한다!
Spring Boot 기반 백엔드 프로젝트
데이터베이스: MySQL
JPA 사용
프로젝트 구조 - Spring MVC
IDE : IntelliJ 사용

와, 너무 오랜만이라 의존성 뭘 추가해야하는지 기억이 안난다 이게 다가 맞았나?!

# application.properties
spring.application.name=crud
spring.datasource.url=jdbc:mysql://localhost:3306/crud
spring.datasource.username=root
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update

Cloud ERD로 작성해봤다.
ERD 작성 중 식별/비식별 관계가 뭔지 헷갈렸다.
식별관계 - 아파트 3동-102호 처럼 동, 호수가 합쳐져 하나의 의미가 되는것
비식별 관계 - 상품 - 주문 처럼 한쪽의 정보가 다른 한쪽에 포함 되는 것 (별개로 존재)

❓타입을 적는데 이전에 해 온 프로젝트처럼 의미만 맞췄지만, 문자열 길이를 어느정도로 둘지, INT 타입 외의 다른 Number 타입을 쓸 수 있는지, DATE타입을 DATETIME으로 적어야 할지 TIMESTAMP로 적어야 할지 공부가 더 필요하다고 느껴졌다. 다음에 여쭤봐야겠다.
package com.sparta.crud.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Setter
@Getter
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "product_id")
private Long id;
private String name;
private int price; // 제품 현재 판매 가격
private int stock;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "product")
private List<Order> orders;
}
package com.sparta.crud.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Setter
@Getter
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id")
private Long id;
@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
private int quantity;
private int price;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@OneToMany(mappedBy = "product")
// 이 컬렉션은 Order 엔티티의 product 필드가 이미 관계를 관리하고 있다” 라는 뜻
private List<Order> order; // 읽기 전용
구현된 Entity에 맞게 필드를 채우고 Repository 인터페이스의 save() 메서드를 통해 저장한다.
// 상품 등록
public ProductResponseDto createProduct(ProductRequestDto requestDto) {
Product product = new Product();
product.setName(requestDto.getName());
product.setPrice(requestDto.getPrice());
product.setStock(requestDto.getStock());
product.setStatus(requestDto.getStatusOrDefault()); // ACTIVE / DISABLED ...
product.setCreatedAt(LocalDateTime.now());
product.setUpdatedAt(LocalDateTime.now());
return new ProductResponseDto(productRepository.save(product));
}
getStatusOrDefault()으로 status값이 전달되지 않으면 자동으로 "ACTIVE" 상태로 저장하게 했다.
public String getStatusOrDefault() {
return (status == null || status.isBlank()) ? "ACTIVE" : status;
}
POST 요청, Body로 생성할 제품 정보를 받는다.
@PostMapping
public OrderResponseDto createOrder(@RequestBody OrderRequestDto requestDto) {
return orderService.createOrder(requestDto);
}

조회는 별 거 없다!😅
// 단일 상품 조회
public ProductResponseDto getProduct(Long productId) {
Product product = this.findProduct(productId);
return new ProductResponseDto(product);
}
// 상품 목록 조회
public List<ProductResponseDto> getProducts() {
return productRepository.findAll().stream().map(ProductResponseDto::new).toList();
}
삭제, 수정 전에 DB에 해당 데이터가 존재하는지 확인해야 하는 로직이 겹쳐서 findProduct() 메서드를 따로 뺀다.
다른 도메인의 Service 클래스에서 해당 메서드를 사용하는 경우
(예: OrderService에서 ProductRepository 호출) QueryService 클래스 등으로 따로 빼기도 한다.
// TODO: -> ProductQueryService
private Product findProduct(Long productId) {
return productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다."));
}
@GetMapping("/{productId}")
public ProductResponseDto getProduct(@PathVariable Long productId) {
return productService.getProduct(productId);
}
@GetMapping
public List<ProductResponseDto> getProducts() {
return productService.getProducts();
}



상품 수정의 경우 판매 일시중지, 품절 등 상태도 함께 변경하도록 했다.
❓ 상태 변경만 할 수 있게 메서드를 따로 빼야할까?
// 상품 수정
@Transactional
public ProductResponseDto updateProduct(Long productId, ProductRequestDto requestDto) {
Product product = this.findProduct(productId);
product.setName(requestDto.getName());
product.setPrice(requestDto.getPrice());
product.setStock(requestDto.getStock());
product.setStatus(requestDto.getStatusOrDefault()); // ACTIVE / DISABLED ...
product.setUpdatedAt(LocalDateTime.now());
return new ProductResponseDto(product);
}
// 상품 수정 / 상태 변경
@PutMapping("{productId}")
public ProductResponseDto updateProduct(@PathVariable Long productId, @RequestBody ProductRequestDto requestDto) {
return productService.updateProduct(productId, requestDto);
}


Repository 인터페이스의 delete()메서드의 경우 반환값이 void라서 HTTP STATE 값 외에 클라이언트가 삭제 성공 여부를 알 수 있는 정보를 service에서 boolean으로 반환하게 했다.
❓실제 서비스에서는 delete가 클라이언트에게 뭘 반환?
// 상품 DB에서 삭제
public boolean deleteProduct(Long productId) {
try { //??: 클라이언트 오류 전달
Product product = findProduct(productId);
productRepository.delete(product);
return true;
} catch (Exception e) {
return false;
}
}
// 상품 삭제
@DeleteMapping("/{productId}")
public boolean deleteProduct(@PathVariable Long productId) {
return productService.deleteProduct(productId);
}


❓ Order의 외래키로 등록된 product를 지우는 방법?
// 주문 생성
@Transactional
public OrderResponseDto createOrder(OrderRequestDto requestDto) {
// TODO:
Product product = productRepository.findById(requestDto.getProductId())
.orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다."));
if (product.getStock() < requestDto.getQuantity()) {
throw new IllegalArgumentException("요청한 수량이 재고를 초과했습니다.");
}
// 상품 재고 차감
product.setStock(product.getStock() - requestDto.getQuantity());
Order order = new Order();
order.setProduct(product);
order.setQuantity(requestDto.getQuantity());
order.setPrice(product.getPrice() * requestDto.getQuantity());
order.setStatus("ORDERED"); // ORDERED / INDELIVERY /
order.setCreatedAt(LocalDateTime.now());
order.setUpdatedAt(LocalDateTime.now());
orderRepository.save(order);
return new OrderResponseDto(order);
}
@PostMapping
public OrderResponseDto createOrder(@RequestBody OrderRequestDto requestDto) {
return orderService.createOrder(requestDto);
}



// 주문 조회
public OrderResponseDto getOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다."));
return new OrderResponseDto(order);
}
@GetMapping("{orderId}")
public OrderResponseDto getOrder(@PathVariable Long orderId) {
return orderService.getOrder(orderId);
}
