지금까지 XxxRepository에 정의한 것은 둘뿐이다.
public interface OrderRepository {
OrderDomain save(OrderDomain order);
Optional<OrderDomain> findById(Long orderId);
}
한 줄씩 따라가기. XxxRepository의 Repository는 DB에 데이터를 저장하거나 꺼내오는 일만 맡는 창구다 — Service는 “이 조건에 맞는 데이터를 달라”고만 말하고, 실제로 SQL을 날리는 일은 Repository가 한다. 여기서 OrderDomain의 Domain은 주문이라는 개념을 담은 자바 객체를 가리킨다(Domain). Optional<OrderDomain>은 “주문이 없을 수도 있다”는 걸 타입으로 드러낸 것이다(Optional) — findById가 찾는 주문이 없으면 빈 Optional을 돌려준다.
delete가 없다. CRUD의 D가 통째로 비어 있는 것처럼 보였다. 그런데 무엇이 없는지 세어보니 생각보다 훨씬 좁은 자리가 비어 있었다.
먼저: “삭제”가 네 가지를 가리키고 있었다
| 무엇 | 규칙이 있나 |
|---|---|
| 상태 전이 — 주문 취소, 회원 탈퇴 | 있다. order.cancel()이 새 Domain을 반환하고 저장한다 |
| 자식 제거 — 주문 항목 빼기 | 있다. applyTo가 id로 대조하고 orphanRemoval이 지운다 |
| 대량 삭제 — 만료 데이터 정리 | 있다. {도메인}/bulk의 영역이다 |
| 애그리거트 루트 제거 | 없다 ← 여기만 비어 있다 |
여기서 applyTo는 Domain이 아는 필드만 골라 Entity에 덮어쓰는 매퍼 메서드이고, orphanRemoval은 컬렉션에서 빠진 자식을 JPA가 대신 지워주는 설정이다 — 둘 다 Domain 영속화 표준에서 자세히 다룬다.
배치 실행 표준에서 “배치”가 두 가지를 가리키고 있던 것과 같은 형태다. 한 단어가 여러 개를 가리키는데 일부만 정의돼 있었다.
그리고 남은 하나는 자주 필요하지도 않다. 주문은 지우는 게 아니라 취소하고, 회원은 지우는 게 아니라 탈퇴한다. 진짜로 행을 없애는 것은 장바구니 항목, 임시저장, 첨부파일 정도다.
왜 그런지가 이 표준의 출발점이 됐다.
원칙 1. 지울 수 있는 것은 아무도 참조하지 않는 것뿐이다
패키지 구조 표준은 도메인 간 @ManyToOne을 금지하고 ID 컬럼으로만 참조하게 했다. @ManyToOne은 “이 필드 하나가 상대 Entity 하나를 가리킨다”는 JPA 애너테이션이다 — 자세한 설명은 패키지 구조 표준의 해당 절에 있다.
// 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. delete는 Domain을 받고, 없는 곳에는 만들지 않는다
// 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는 영속 컨텍스트에 올라온 엔티티만 지울 수 있다. 게다가 cascade와 orphanRemoval이 동작하려면 자식까지 로딩돼 있어야 한다. 조회를 건너뛰면 자식이 안 지워지고 FK 제약에 걸린다.
쿼리를 아껴주지 않으면서 규칙 검증 자리만 없앤다. 그리고 조회 없이 지우고 싶다면 그건 대량 삭제이고 {도메인}/bulk의 영역이다.
규칙은 Domain 안에 둔다
// ❌ 삭제 규칙이 Service로 샌다
if (item.getStatus() == ORDERED) {
throw ErrorCodeException.of(ErrorCode.CART_ITEM_ALREADY_ORDERED);
}
cartItemRepository.delete(item);
// ✅ 규칙은 루트 안에
item.validateDeletable();
cartItemRepository.delete(item);
위 코드의 Service는 Controller가 넘긴 일을 실제로 처리하는 계층이다 — 재고 확인이나 DB 기록 같은 업무 절차가 여기 들어간다. 애그리거트 표준이 세운 “애그리거트 규칙이 루트 안에 있으므로 외부가 우회할 방법이 없다” 가 삭제 경로에서만 깨지면 안 된다.
원칙 3. 자식은 cascade가 지운다
// cart/infra/CartJpaRepository.java
@Override
public void delete(CartDomain cart) {
CartEntity entity = jpaEntityRepository.findWithItemsById(cart.getId()) // 자식까지 로딩
.orElseThrow(() -> ErrorCodeException.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;
한 줄씩 따라가기. delete()는 먼저 findWithItemsById로 장바구니를 자식(item)까지 함께 읽어 온다 — 조인으로 부모와 자식을 한 번에 가져와야 자식도 영속 컨텍스트에 올라오고, 그래야 바로 아래의 jpaEntityRepository.delete(entity)가 cascade = REMOVE 설정을 보고 자식까지 따라 지운다. findWithItemsById는 결과가 없을 수도 있어 Optional로 돌려주는데, .orElseThrow(() -> ErrorCodeException.of(ErrorCode.CART_NOT_FOUND))의 () -> ErrorCodeException.of(...)는 람다다 — “값이 있으면 그 값을 쓰고, 없으면 이 코드를 실행해 그 결과(예외)를 던져라”는 뜻이라, 장바구니를 못 찾으면 그 자리에서 CART_NOT_FOUND 예외를 던진다. 그 결과 나가는 SQL이 위 블록이다 — DELETE FROM은 테이블에서 조건에 맞는 행을 지우는 문장이고, cart_items에서 자식 행 두 개를 먼저 지운 뒤 carts에서 부모 행 하나를 지운다.
Domain 영속화 표준에서 저장할 때 자식을 로딩해야 했던 것과 같은 이유다. JPA가 모르는 자식은 지워주지 않는다.
어제 걸어둔 cascade = CascadeType.ALL과 orphanRemoval = 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);
한 줄씩 따라가기. ALTER TABLE payments는 이미 있는 payments 테이블의 구조를 바꾸겠다는 뜻이고, ADD CONSTRAINT는 그 테이블에 이름을 붙인 제약을 하나 추가한다. FOREIGN KEY (order_id) REFERENCES orders(id)가 그 제약의 내용이다 — payments.order_id가 반드시 orders.id 중 하나를 가리키게 강제하는 FK 제약이다.
참조하는 쪽 테이블에 건다. 이제 주문을 지우려는 시도는 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는? |
만들지 않는다 | 규칙 자리가 없고 쿼리도 안 준다 |
| 삭제 규칙은 어디에? | Domain의 validateDeletable() |
Service에 두지 않는다 |
| 자식은? | cascade = REMOVE |
조회로 로딩한 뒤 루트를 remove |
| 도메인 간 FK는? | 건다 | 참조하는 쪽 테이블에 |
| 삭제 중 FK 위반은? | 500 | 사용자 잘못이 아니라 설계 위반이다 |
| 개인정보를 없애야 하면? | 익명화 | 행을 지우지 않고 필드만 비운다 |
이 표준을 정하기까지
시작은 “CRUD의 D가 없다”였다. Repository에 save와 findById만 있으니 삭제를 정해야겠다고 생각했다.
그런데 세어보니 셋은 이미 있었다. 상태 전이, 자식 제거, 대량 삭제. “삭제”라는 한 단어가 네 가지를 가리키는데 그중 하나만 비어 있었다. 배치 표준을 정할 때와 같은 형태라 금방 알아봤다 — 단어가 여러 개를 가리키면 일부만 정의된 채로 남는다.
남은 하나가 왜 드문지를 따져보다 진짜 규칙이 나왔다. 주문은 취소하고 회원은 탈퇴한다. 처음에는 그게 도메인 관습이라고 생각했는데, 다른 도메인이 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이다. 사용자 잘못이 아니라 설계가 어긋난 상태다
- 개인정보 파기는 익명화다. 행을 지우지 않고 필드만 비운다
자신만의 철학을 만들어가는 중입니다.
댓글남기기