[Spring CRUD 구현 과제] -2

dev_joo·2026년 2월 3일

Spring CRUD 구현과제

사전캠프 기간 미니 프로젝트를 마저 구현하기로 했다.
현재 구현 상황은 다음과 같다.

💡상품을 등록하고 → 사용자가 주문하고 → 주문 정보를 조회한다
구현 완료 : ✅
구현 완료했으나 수정이 필요 : ☑️

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

상품 삭제 구현 수정

먼저 주문이 한 개의 상품을 포함하기 때문에 Products와 Orders 는 연관 테이블이다.

그래서 주문이 상품을 포함하고 있는 상태에서 해당 상품을 삭제하면 다음과 같은 에러가 생겼다.
자식 테이블이 부모 테이블의 row를 포함하고 있을 때, 부모테이블 row를 삭제하거나 자식이 참조 중인 PK 값을 수정할 수 없다.

Cannot delete or update a parent row: a foreign key constraint fails
(`crud`.`orders`, CONSTRAINT `FKkp5k52qtiygd8jkag4hayd0qg` FOREIGN KEY (`product_id`) REFERENCES `products` (`product_id`))

여기서 FKkp5k52qtiygd8jkag4hayd0qg는 MySQL(InnoDB) 가 외래키 제약조건 이름을 직접 지정하지 않았을 때 자동 생성한 이름이다.
@JoinColumn의 foreiginKey 속성에서 이름을 지정 할 수 있다.

@Entity
public class Order {

    @ManyToOne
    @JoinColumn(
        name = "product_id",
        foreignKey = @ForeignKey(name = "fk_orders_product")
    )
    private Product product;
}

상품 삭제 구현 방법

외래키로 사용되는 부모 테이블의 row를 삭제하는 방법에는 다음 3가지 방법이 있다.

1. ON DELETE CASCADE

외래키 제약조건에 ON DELETE CASCADE를 설정하면 부모테이블을 참조하는 자식테이블의 row까지 한 번에 삭제한다.
즉, 상품을 삭제하면 그 상품을 주문했던 주문 정보가 같이 날아간다.
이러면 고객에게 결제를 받아두고 상품도 주문도 없었던걸로 하는 악덕 사기 업주가 될 수 있다.

JPA서는 이렇게 구현한다. 혹시 악덕 업주가 되고 싶은 사람을 위해 남긴다.

@ManyToOne
@JoinColumn(
    name = "product_id",
    foreignKey = @ForeignKey(
        name = "fk_orders_product",
        foreignKeyDefinition = "FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE"
    )
)

2. 외래키를 Nullable로 설정하고 스냅샷을 저장

@ManyToOne(optional = true)
@JoinColumn(
    name = "product_id",
    foreignKey = @ForeignKey(name = "fk_orders_product")
)
private Product product;

// 주문 시점 상품 정보 스냅샷
private String productName;
private int productPrice;
UPDATE orders SET product_id = NULL WHERE product_id = 1;

이 경우 상품 삭제가 가능하지만, 삭제 이전에 null로 관계를 끊는 쿼리와
연결된 상태에서 상품 정보가 바뀔 때마다 주문 정보를 수정하는 쿼리를 해야한다는 단점이 있다.

3. 소프트 삭제 (DB에서 삭제 x)

DB에서 상품을 삭제하지 않고, 삭제 여부에 대한 상태값을 부여한다.
이 경우엔 고객에게 상품 전체 목록 응답시 필터링해 조회할 필요가 있다.

  product.setStatus("DELETED")

소프트 삭제 구현

기존 수정 메서드를 고쳐서 만들었다.

// 상품 목록 조회 (판매중/품절)
public List<ProductResponseDto> getProductsNotDisabled() {
	return productRepository.findAllByStatusNot("DISABLED").stream().map(ProductResponseDto::new).toList();
 }
@Transactional
    public ProductResponseDto softDeleteProduct(Long productId) {
        Product product = this.findProduct(productId);

        product.setStatus("DELETE");
        product.setUpdatedAt(LocalDateTime.now());

        return new ProductResponseDto(product);
    }

중간 구현단계에서 멘토링

도전과제까진 더 많은 공부가 필요한 것 같아서 어제 중간 점검지에 있는 질문에 답하고 바로 제출을 했었다.
그랬더니 이번 기수에서 1번째로 튜터님께 멘토링을 받게 되었다.잠시만요 아직 준비가 안됐다구요

코드 리뷰

서면 요청이 있었지만 빠른 피드백을 받기 위해 대면 상담을 요청했기 때문에 실시간으로 github에 올린 내 코드를 보면서 코드 리뷰를 해주셨다.
같은 팀원이 아닌 누군가 내 코드를 보면서 리뷰를 해 준것은 처음이라 낯설고 부끄럽고 막 그랬다. 발가벗겨진 기분...!🫥
근데 뭐 어때? 난 지금 배우는 중인데! 하며 앞으로 배우는 자세가 더 중요하다 생각하고 용기를 냈다.

튜너님은 내 코드의 문제점에 대해 다음과 같이 차근차근 피드백해주셨다.

1.REST API에서는 Response Entity 사용해서 응답하기

HTTP status, header, body를 명확히 표현

2. 데이터를 수정할 때 setter 남발해 사용하지 않기

setter를 통해 구현하면 객체 수정 시점을 파악하기 어렵다.
Service 에서 builder 사용하거나 Entity 자체에 update/delete 의미 있는 행위 단위로 메서드를 추가해 캡슐화해 사용한다.

product.setName(name);
product.setPrice(price);
product.setStatus(status);
product.changePrice(newPrice);
product.disable();

3. 레이어드 아키텍처에서 service는 비지니스 로직만 수행하기

Entity의 값은 Entity 가 직접 수정해야 한다.
예를 들어 Product의 status를 DELETE로 바꿀 때 위에선 service 코드에서 아래와 같이 직접 값을 주어 수정했다.

그러나 이건 DDD 스타일에 어긋난 것으로, Service가 Entity가 담당하는 도메인 책임을 가져온 것이다.

 product.setStatus("DELETE");
  • status가 왜 "DELETE"로 바뀌는지 -> setter를 호출하는 메서드의 이름만 보고 유추해야한다.
  • 어떤 상태들이 있는지 -> Enum 코드도 아니라 알 수 없다.
  • 어떤 규칙이 있는지 -> 예: DISABLED는 삭제되었기 때문에 ACVIVE 상태로 되돌릴 수 없다. 등

의미와 규칙이 Service 곳곳에 흩어져버리는 문제가 있다.

그래서 다음과 같이 엔티티가 직접 값을 수정하도록 해야한다.

product.disable();
  • Service: 언제 (행위의 시점)
  • Entity : 무엇을 (행위)

4. dto 생성시 정적 팩토리 메서드 사용하기

정적 팩토리 메서드(Static Factory Method) 패턴은 개발자가 구성한 Static Method를 통해 간접적으로 생성자를 호출하는 객체를 생성하는 디자인 패턴이다.

class Book {
    private String title;
    
    // 생성자를 private화 하여 외부에서 생성자 호출 차단
    private Book(String title) { this.title = title; }
    
    // 정적 팩토리 메서드
    public static Book titleOf(String title) {
        return new Book(title); // 메서드에서 생성자를 호출하고 리턴함
    }
}

생성 목적에 대한 이름 표현이 가능
반환 객체에 대한 분기 가능
인스턴스에 대해 통제 및 관리가 가능 (Singleton 가능)

from : 하나의 매개 변수를 받아서 객체를 생성
of : 여러개의 매개 변수를 받아서 객체를 생성
getInstance | instance : 인스턴스를 생성. 이전에 반환했던 것과 같을 수 있음
newInstance | create : 항상 새로운 인스턴스를 생성
get[OrderType] : 다른 타입의 인스턴스를 생성. 이전에 반환했던 것과 같을 수 있음
new[OrderType] : 항상 다른 타입의 새로운 인스턴스를 생성

출처: https://inpa.tistory.com/entry/GOF-💠-정적-팩토리-메서드-생성자-대신-사용하자 [Inpa Dev 👨‍💻:티스토리]

정적 팩토리 메서드는 개발자가 임의로 만든 메서드이기 때문에 네이밍 컨밴션을 잘 지켜 만드는것이 좋다.

5. 두개의 요청이 동시에 들어오면?

현재 코드로썬 재고가 1개밖에 없는데 주문이 두개 생기는 일이 발생한다.
다음 키워드들을 바탕으로 공부를 해보라 하셨다.
동시성 처리
비관적lock
낙관적lock
분산lock

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

0개의 댓글