지금까지 XxxRepository에 정의한 것은 둘뿐이다.

public interface OrderRepository {
    OrderDomain save(OrderDomain order);
    Optional<OrderDomain> findById(Long orderId);
}

delete가 없다. CRUD의 D가 통째로 비어 있는 것처럼 보였다. 그런데 무엇이 없는지 세어보니 생각보다 훨씬 좁은 자리가 비어 있었다.


먼저: “삭제”가 네 가지를 가리키고 있었다

무엇 규칙이 있나
상태 전이 — 주문 취소, 회원 탈퇴 있다. order.cancel()이 새 Domain을 반환하고 저장한다
자식 제거 — 주문 항목 빼기 있다. applyTo가 id로 대조하고 orphanRemoval이 지운다
대량 삭제 — 만료 데이터 정리 있다. {도메인}/bulk의 영역이다
애그리거트 루트 제거 없다 ← 여기만 비어 있다

배치 실행 표준에서 “배치”가 두 가지를 가리키고 있던 것과 같은 형태다. 한 단어가 여러 개를 가리키는데 일부만 정의돼 있었다.

그리고 남은 하나는 자주 필요하지도 않다. 주문은 지우는 게 아니라 취소하고, 회원은 지우는 게 아니라 탈퇴한다. 진짜로 행을 없애는 것은 장바구니 항목, 임시저장, 첨부파일 정도다.

왜 그런지가 이 표준의 출발점이 됐다.


원칙 1. 지울 수 있는 것은 아무도 참조하지 않는 것뿐이다

패키지 구조 표준은 도메인 간 @ManyToOne을 금지하고 ID 컬럼으로만 참조하게 했다.

// payment/infra/PaymentEntity.java
@Column(name = "order_id")
private Long orderId;          // 연관관계가 아니라 그냥 숫자다

주문을 지워도 JPA는 결제를 모른다. payment.order_id는 그대로 남고, 나중에 결제 내역을 조회하면 존재하지 않는 주문을 가리키는 행이 나온다.

연쇄 삭제로는 못 푼다

// ❌ order가 자기를 참조하는 도메인을 찾아다닌다
orderRepository.delete(order);
paymentProvider.deleteByOrderId(order.getId());
shipmentProvider.deleteByOrderId(order.getId());

의존 방향이 뒤집힌다. 주문이 자기를 참조하는 모든 도메인을 알아야 하고, 새 도메인이 주문을 참조할 때마다 주문 쪽을 고쳐야 한다. UseCase 인터페이스 표준“호출하는 도메인이 인터페이스를 선언하고 제공 도메인이 구현하는 방식을 쓰지 않는다 — 제공 도메인이 모든 소비자를 알게 된다” 고 막아둔 것과 정확히 같은 형태다.

그래서 규칙은 이렇게 된다

다른 도메인이 참조하는 애그리거트는 지우지 않는다. 상태 전이로 다룬다.

여기서 앞의 관찰이 우연이 아니었다는 걸 알았다. “주문은 취소, 회원은 탈퇴, 지우는 건 장바구니 항목” 이 관습이 아니라 구조가 강제하는 결론이었다. 남이 참조하는 것은 애초에 지울 수가 없다.


원칙 2. deleteDomain을 받고, 없는 곳에는 만들지 않는다

// order/domain/OrderRepository.java
public interface OrderRepository {
    OrderDomain save(OrderDomain order);
    Optional<OrderDomain> findById(Long orderId);
    // delete 없음 — 결제가 참조하므로 지울 수 없다
}

// cart/domain/CartItemRepository.java
public interface CartItemRepository {
    CartItemDomain save(CartItemDomain item);
    Optional<CartItemDomain> findById(Long id);
    void delete(CartItemDomain item);        // 아무도 참조하지 않으므로 지울 수 있다
}

지울 수 없는 애그리거트에는 메서드 자체가 없다. 지우려는 시도가 컴파일 에러가 된다. OrderItemRepository를 만들지 않아 “항목만 따로 저장하는 길”을 막은 것과 같은 방식이다 — 규율이 아니라 구조가 강제한다.

deleteById를 쓰지 않는 이유

deleteById(id)가 나아 보이는 유일한 이유는 “조회를 아낀다”인데, 그게 사실이 아니다.

// SimpleJpaRepository
public void deleteById(ID id) {
    findById(id).ifPresent(this::delete);   // 결국 조회한다
}

JPA는 영속 컨텍스트에 올라온 엔티티만 지울 수 있다. 게다가 cascadeorphanRemoval이 동작하려면 자식까지 로딩돼 있어야 한다. 조회를 건너뛰면 자식이 안 지워지고 FK 제약에 걸린다.

쿼리를 아껴주지 않으면서 규칙 검증 자리만 없앤다. 그리고 조회 없이 지우고 싶다면 그건 대량 삭제이고 {도메인}/bulk의 영역이다.

규칙은 Domain 안에 둔다

// ❌ 삭제 규칙이 Service로 샌다
if (item.getStatus() == ORDERED) {
    throw BusinessException.of(ErrorCode.CART_ITEM_ALREADY_ORDERED);
}
cartItemRepository.delete(item);

// ✅ 규칙은 루트 안에
item.validateDeletable();
cartItemRepository.delete(item);

애그리거트 표준이 세운 “애그리거트 규칙이 루트 안에 있으므로 외부가 우회할 방법이 없다” 가 삭제 경로에서만 깨지면 안 된다.


원칙 3. 자식은 cascade가 지운다

// cart/infra/CartJpaRepository.java
@Override
public void delete(CartDomain cart) {
    CartEntity entity = jpaEntityRepository.findWithItemsById(cart.getId())   // 자식까지 로딩
            .orElseThrow(() -> BusinessException.of(ErrorCode.CART_NOT_FOUND));

    jpaEntityRepository.delete(entity);     // cascade = REMOVE가 자식을 따라 지운다
}
DELETE FROM cart_items WHERE id = 1;
DELETE FROM cart_items WHERE id = 2;
DELETE FROM carts WHERE id = 77;

Domain 영속화 표준에서 저장할 때 자식을 로딩해야 했던 것과 같은 이유다. JPA가 모르는 자식은 지워주지 않는다.

어제 걸어둔 cascade = CascadeType.ALLorphanRemoval = true가 삭제까지 함께 처리한다. 새로 걸 것이 없다.

자식 행마다 DELETE가 하나씩 나간다. 수십 개면 문제없고, 수백 개를 넘어가면 그건 애그리거트 크기를 다시 볼 신호이거나 bulk로 옮길 일이다.


원칙 4. FK를 걸어 DB가 막게 한다

원칙 1은 사람이 지켜야 하는 규칙이다. “이 애그리거트를 누가 참조하는가”를 컴파일러가 알려주지 않는다.

그래서 도메인 간 ID 참조에 DB FK 제약을 건다.

ALTER TABLE payments
    ADD CONSTRAINT fk_payments_order FOREIGN KEY (order_id) REFERENCES orders(id);

참조하는 쪽 테이블에 건다. 이제 주문을 지우려는 시도는 DB가 막는다.

FK 위반은 409가 아니라 500이다

delete가 있다는 것은 이미 “아무도 참조하지 않는다”고 판단했다는 뜻이다. 그런데도 FK에 걸렸다면 사용자 잘못이 아니라 설계가 어긋난 것이다 — 나중에 누가 이 애그리거트를 참조하기 시작했는데 delete를 걷어내지 않았다는 신호다.

예외의 종류 표준DataIntegrityViolationException을 Repository 구현체에서 번역하라고 정했는데, 삭제 경로에서는 409로 번역하지 않는다. 중복 이메일처럼 사용자가 만들 수 있는 실패와 성격이 다르다. assert 표준이 말한 “있을 수 없는 일” 에 해당하므로 500으로 올리고 로그로 잡는다.

치르는 값

도메인을 별도 DB나 서비스로 떼어낼 때 FK를 먼저 끊어야 한다. 패키지 구조 표준이 “폴더 하나만 옮기면 된다”고 했던 것이 DB 레벨에서는 한 단계 더 필요해진다.

지금 얻는 안전과 나중에 치를 비용을 맞바꾼 것이다. 분리가 실제로 예정돼 있다면 다시 볼 결정이다.


원칙 5. 정말 지워야 하면 익명화한다

개인정보 파기처럼 법적으로 값을 없애야 하는 경우가 있다. 그런데 회원은 주문이 참조하므로 원칙 1에 따라 지울 수 없다.

// ❌ 행을 지운다 — 주문 이력이 고아가 된다
userRepository.delete(user);

// ✅ 참조는 남기고 개인정보만 비운다
UserDomain anonymized = user.anonymize();   // 이름·이메일·연락처를 익명값으로
userRepository.save(anonymized);

삭제가 아니라 상태 전이 + 필드 갱신이다. order.userId는 그대로 유효하고 주문 이력도 살아 있다. anonymize()cancel()이나 confirm()과 같은 의도 기반 메서드이므로 이미 있는 규칙 위에 있다.


판단 기준 정리

질문 결론
흔적이 남아야 하나? 그렇다 삭제가 아니다. 상태 전이로 다룬다
애그리거트 자식을 빼는 건가? 그렇다 삭제가 아니다. 루트 저장으로 처리된다
대량인가? 그렇다 {도메인}/bulk의 영역이다
다른 도메인이 ID로 참조하나? 그렇다 지우지 않는다. Repository에 delete를 두지 않는다
  아니다 void delete(XxxDomain)을 둔다
deleteById는? 만들지 않는다 규칙 자리가 없고 쿼리도 안 준다
삭제 규칙은 어디에? DomainvalidateDeletable() Service에 두지 않는다
자식은? cascade = REMOVE 조회로 로딩한 뒤 루트를 remove
도메인 간 FK는? 건다 참조하는 쪽 테이블에
삭제 중 FK 위반은? 500 사용자 잘못이 아니라 설계 위반이다
개인정보를 없애야 하면? 익명화 행을 지우지 않고 필드만 비운다

이 표준을 정하기까지

시작은 “CRUD의 D가 없다”였다. Repository에 savefindById만 있으니 삭제를 정해야겠다고 생각했다.

그런데 세어보니 셋은 이미 있었다. 상태 전이, 자식 제거, 대량 삭제. “삭제”라는 한 단어가 네 가지를 가리키는데 그중 하나만 비어 있었다. 배치 표준을 정할 때와 같은 형태라 금방 알아봤다 — 단어가 여러 개를 가리키면 일부만 정의된 채로 남는다.

남은 하나가 왜 드문지를 따져보다 진짜 규칙이 나왔다. 주문은 취소하고 회원은 탈퇴한다. 처음에는 그게 도메인 관습이라고 생각했는데, 다른 도메인이 ID로 참조하기 때문에 지울 수가 없는 것이었다. 관습이 아니라 구조적 결론이었다.

연쇄 삭제를 검토하고 바로 버렸다. 주문을 지울 때 결제도 지우면 되지 않느냐 싶었는데, 그러면 주문이 자기를 참조하는 모든 도메인을 알아야 한다. UseCase 표준에서 “제공 도메인이 모든 소비자를 알게 된다” 는 이유로 이미 한 번 막은 형태였다. 같은 판단을 두 번째로 하는 셈이라 오래 걸리지 않았다.

deleteById는 성능을 근거로 골랐다가 근거가 틀렸다는 걸 확인하고 버렸다. 조회를 아낀다고 생각했는데 SimpleJpaRepository.deleteById가 내부에서 findById를 부른다. 쿼리를 아껴주지 않으면서 규칙 검증 자리만 없애는 선택이었다.

표시 방법에서 한 번 더 갈렸다. 모든 Repository에 delete를 두고 validateDeletable()이 런타임에 막는 안과, 지울 수 있는 것에만 메서드를 두는 안. 후자를 골랐다 — 이 저장소가 계속 써온 “규율이 아니라 구조가 강제한다”에 맞고, 막는 시점이 실행이 아니라 컴파일이다.

마지막으로 FK를 걸기로 하면서 대가를 하나 받아들였다. “누가 참조하는가”를 컴파일러가 알려주지 않으므로 DB에 맡겼다. 대신 도메인을 별도 DB로 떼어낼 때 FK를 먼저 끊어야 한다. 패키지 구조 표준이 자랑하던 “폴더 하나만 옮기면 된다”가 DB 레벨에서는 한 단계 늘어난다. 분리가 실제 계획에 있다면 다시 볼 지점이라 적어둔다.

그리고 FK 위반의 성격을 정했다. delete가 있다는 건 이미 “참조가 없다”고 판단했다는 뜻이므로, 그런데도 걸렸다면 사용자가 아니라 우리가 틀린 것이다. 409가 아니라 500이라는 결론이 여기서 나왔다.


정리

  • “삭제”는 네 가지다. 상태 전이·자식 제거·대량 삭제는 이미 규칙이 있고, 이 표준은 루트 제거만 다룬다
  • 다른 도메인이 참조하는 애그리거트는 지우지 않는다. 주문은 취소, 회원은 탈퇴다 — 관습이 아니라 구조적 결론이다
  • 지울 수 있는 것에만 delete(XxxDomain)을 둔다. 나머지는 메서드가 없어 컴파일러가 막는다
  • deleteById를 만들지 않는다. 쿼리를 아껴주지 않으면서 규칙 자리만 없앤다
  • 자식은 cascade = REMOVE가 지운다. 조회로 로딩한 뒤 루트를 remove한다
  • 도메인 간 FK를 건다. 대가는 나중에 도메인을 떼어낼 때 FK를 먼저 끊는 것이다
  • 삭제 중 FK 위반은 500이다. 사용자 잘못이 아니라 설계가 어긋난 상태다
  • 개인정보 파기는 익명화다. 행을 지우지 않고 필드만 비운다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/aggregate-deletion-standard/SKILL.md

---
name: aggregate-deletion-standard
description: 애그리거트 삭제 규칙. Repository의 delete, cascade 삭제, 도메인 간 FK, 상태 전이와 삭제의 구분을 다룰 때 반드시 적용한다.
---

# 애그리거트 삭제 표준

"삭제"는 네 가지를 가리킨다. 셋은 이미 규칙이 있고, 이 표준은 **애그리거트 루트를 행째로 없애는 경우**만 다룬다.

## 먼저 무엇인지 가른다
- 주문 취소, 회원 탈퇴처럼 흔적이 남아야 하는 것은 삭제가 아니라 **상태 전이**다. `Domain`의 의도 기반 메서드로 새 상태를 만들고 저장한다.
- 애그리거트 자식을 빼는 것은 삭제가 아니라 **루트 저장**이다. `applyTo`가 id로 대조하고 `orphanRemoval`이 지운다. domain-persistence-standard를 따른다.
- 애그리거트 경계를 벗어나는 대량 삭제는 `{도메인}/bulk`의 영역이다. batch-execution-standard를 따른다.
- 위 셋이 아닌 경우만 이 표준이 다룬다.

## 지울 수 있는 것과 없는 것
- **다른 도메인이 ID로 참조하는 애그리거트는 지우지 않는다.** 상태 전이로 다룬다.
- 지울 수 있는 것은 아무도 참조하지 않는 애그리거트뿐이다. 장바구니 항목, 임시저장, 첨부파일 같은 것들이다.
- 연쇄 삭제를 만들지 않는다. 제공 도메인이 자기를 참조하는 모든 도메인을 알게 되어 의존 방향이 뒤집힌다. usecase-interface-standard가 막은 것과 같은 형태다.
- 개인정보 파기처럼 값을 없애야 하면 행을 지우지 않고 **익명화**한다. `user.anonymize()`처럼 상태 전이로 다루고 참조는 살려둔다.

## Repository
- **지울 수 있는 애그리거트의 Repository에만 `void delete(XxxDomain domain)`을 둔다.** 나머지에는 메서드를 만들지 않아 지우려는 시도가 컴파일 에러가 되게 한다.
- `deleteById(Long id)`를 만들지 않는다. 규칙을 검증할 자리가 없고, Spring Data의 `deleteById`도 내부에서 조회하므로 쿼리가 줄지 않는다.
- 구현체는 `findById`로 자식까지 로딩한 뒤 영속 Entity를 `remove`한다. 자식이 로딩되어 있지 않으면 `cascade`가 동작하지 않는다.
- `findById`가 비면 `BusinessException`으로 `XXX_NOT_FOUND`를 던진다.
- 삭제에 규칙이 있으면 `Domain``validateDeletable()`을 두고 Service가 삭제 전에 호출한다. 규칙을 Service에 두지 않는다.

## 자식 처리
- Entity의 자식 컬렉션에 이미 걸어둔 `cascade = CascadeType.ALL`, `orphanRemoval = true`가 삭제도 함께 처리한다.
- 자식 행마다 DELETE가 나간다. 수백 건을 넘어가면 애그리거트 크기를 다시 보거나 `{도메인}/bulk`로 옮긴다.

## FK 제약
- 도메인 간 ID 참조에는 DB FK 제약을 건다. 잘못된 삭제를 사람이 아니라 DB가 막게 한다.
- FK는 참조하는 쪽 테이블에 건다. 예: `payment.order_id → orders.id`.
- 삭제 경로에서 `DataIntegrityViolationException`이 나면 사용자 잘못이 아니라 **설계가 어긋난 것**이다. 참조가 생겼는데 `delete`를 걷어내지 않은 상태다. 409가 아니라 500으로 다루고 로그로 잡는다. exception-taxonomy-standard를 따른다.
- 도메인을 별도 DB나 서비스로 분리할 때는 FK를 먼저 끊어야 한다. 이 표준이 치르는 값이다.

GitHub Copilot — .github/instructions/aggregate-deletion-standard.instructions.md

---
description: 애그리거트 삭제와 도메인 간 FK 규칙
applyTo: "**/domain/**/*.java, **/infra/**/*.java"
---

- 주문 취소나 회원 탈퇴처럼 흔적이 남아야 하는 것은 삭제가 아니라 `Domain`의 의도 기반 메서드로 상태를 전이시키고 저장한다.
- 애그리거트 자식을 빼는 것은 삭제가 아니라 루트 저장이다. `applyTo``orphanRemoval`이 처리한다.
- 애그리거트 경계를 벗어나는 대량 삭제는 `{도메인}/bulk`에 둔다.
- 다른 도메인이 ID로 참조하는 애그리거트는 지우지 않는다. 상태 전이로 다룬다.
- 연쇄 삭제를 만들지 않는다. 제공 도메인이 소비 도메인을 알게 되어 의존 방향이 뒤집힌다.
- 개인정보를 없애야 하면 행을 지우지 않고 `anonymize()`처럼 필드만 비우는 상태 전이로 처리한다.
- 지울 수 있는 애그리거트의 Repository에만 `void delete(XxxDomain domain)`을 둔다.
- `deleteById(Long id)`를 만들지 않는다. 규칙 검증 자리가 없고 쿼리도 줄지 않는다.
- `delete` 구현체는 `findById`로 자식까지 로딩한 뒤 영속 Entity를 `remove`한다.
- `findById`가 비면 `BusinessException`으로 `XXX_NOT_FOUND`를 던진다.
- 삭제 규칙은 `Domain``validateDeletable()`에 두고 Service가 삭제 전에 호출한다.
- 도메인 간 ID 참조에는 DB FK 제약을 걸고, 참조하는 쪽 테이블에 건다.
- 삭제 경로의 `DataIntegrityViolationException`은 409가 아니라 500으로 다룬다. 설계가 어긋난 상태이지 사용자 잘못이 아니다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기