[Spring CRUD 구현 과제] -1

dev_joo·2026년 1월 29일
post-thumbnail

Spring CRUD 구현 과제

사전캠프 기간 동안 다음 내용으로 미니 프로젝트를 구현하게 되었다.

💡상품을 등록하고 → 사용자가 주문하고 → 주문 정보를 조회한다

  • 프로젝트 세팅 (DB 연결 포함)
  • 상품 CRUD 동작
  • 주문 생성 동작
  • 주문 조회 동작
  • (도전) 주문 목록 조회 구현
  • (도전) 주문 목록 조회시 N+1문제 해결
  • (도전) 상품 재고 차감 구현
  • (도전) 상품 재고 원자적 차감 구현

이번 주말까지 필수 과제 기능을 완성 한 후, 다음 주부터 도전과제를 시작 할 것 같다.
필수 과제까지는 이미 해본 적이 있어서 어렵지 않을 것 같지만,
도전과제부터는 처음 해보는 것이라 조금 걱정되긴 한다. 그래도 과제를 해내길 기대한다!


프로젝트 개발 환경 세팅

Spring Boot 기반 백엔드 프로젝트
데이터베이스: MySQL
JPA 사용
프로젝트 구조 - Spring MVC
IDE : IntelliJ 사용


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


DB 연결

# 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

엔티티 설계(Product, Order)

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

❓타입을 적는데 이전에 해 온 프로젝트처럼 의미만 맞췄지만, 문자열 길이를 어느정도로 둘지, INT 타입 외의 다른 Number 타입을 쓸 수 있는지, DATE타입을 DATETIME으로 적어야 할지 TIMESTAMP로 적어야 할지 공부가 더 필요하다고 느껴졌다. 다음에 여쭤봐야겠다.

Product

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;
}

Order

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; // 읽기 전용

상품 CRUD 동작

상품 등록 구현 (CREATE)

Service

구현된 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));
}

productRequestDto

getStatusOrDefault()으로 status값이 전달되지 않으면 자동으로 "ACTIVE" 상태로 저장하게 했다.

public String getStatusOrDefault() {
	return (status == null || status.isBlank()) ? "ACTIVE" : status;
}

Controller

POST 요청, Body로 생성할 제품 정보를 받는다.


@PostMapping
public OrderResponseDto createOrder(@RequestBody OrderRequestDto requestDto) {
	return orderService.createOrder(requestDto);
}

상품 단건·목록 조회 구현 (READ)

조회는 별 거 없다!😅

Service

// 단일 상품 조회
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("상품을 찾을 수 없습니다."));
}

Controller

@GetMapping("/{productId}")
    public ProductResponseDto getProduct(@PathVariable Long productId) {
        return productService.getProduct(productId);
}

@GetMapping
public List<ProductResponseDto> getProducts() {
    return productService.getProducts();
}



상품 수정 구현 (UPDATE)

상품 수정의 경우 판매 일시중지, 품절 등 상태도 함께 변경하도록 했다.

❓ 상태 변경만 할 수 있게 메서드를 따로 빼야할까?

Service

// 상품 수정
@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);
}

productRequestDto

Controller

// 상품 수정 / 상태 변경
    @PutMapping("{productId}")
    public ProductResponseDto updateProduct(@PathVariable Long productId, @RequestBody ProductRequestDto requestDto) {
        return productService.updateProduct(productId, requestDto);
    }


상품 삭제 구현 (DELETE)

Repository 인터페이스의 delete()메서드의 경우 반환값이 void라서 HTTP STATE 값 외에 클라이언트가 삭제 성공 여부를 알 수 있는 정보를 service에서 boolean으로 반환하게 했다.

❓실제 서비스에서는 delete가 클라이언트에게 뭘 반환?

Service

 // 상품 DB에서 삭제
 public boolean deleteProduct(Long productId) {
 	try { //??: 클라이언트 오류 전달
    		Product product = findProduct(productId);
        	productRepository.delete(product);
        	return true;
        } catch (Exception e) {
        	return false;
        }
}

Controller

// 상품 삭제
@DeleteMapping("/{productId}")
public boolean deleteProduct(@PathVariable Long productId) {
	return productService.deleteProduct(productId);
}


❓ Order의 외래키로 등록된 product를 지우는 방법?

주문 생성 동작 (재고 차감)

Service


    // 주문 생성
    @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);
    }

Controller

@PostMapping
public OrderResponseDto createOrder(@RequestBody OrderRequestDto requestDto) {
	return orderService.createOrder(requestDto);
}

주문 조회 동작

Service

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

Controller

@GetMapping("{orderId}")
    public OrderResponseDto getOrder(@PathVariable Long orderId) {
        return orderService.getOrder(orderId);
}

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글