특정 데이터 삭제 시 참조 무결성 에 대한 내용을 정리하고자 합니다.
JPA를 사용해 부모-자식 관계가 있는 엔티티를 다룰 때, 부모 엔티티를 삭제하면서 발생했던 문제에 대해 다루고자 합니다.
진행중인 프로젝트에서 Product
(상품) 엔티티가 있고, 이 상품을 Order
(주문)와 DropEvent
(이벤트) 엔티티가 참조하는 구조였습니다.
관리자 페이지에서 상품을 삭제하는 기능은 아래와 같이 간단하게 구현되어 있었습니다.
최초 코드
public void deleteProduct(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));
// 문제의 지점
productRepository.delete(product);
}
productRepository.delete(product)
를 호출하면 JPA가 해당 상품 데이터를 DB에서 물리적으로 삭제(DELETE FROM products WHERE id = ?
)합니다.
상품이 하나 이상의 주문(Order
)이나 이벤트(DropEvent
)에서 참조하고 있다면 데이터베이스는 Order
테이블의 product_id
가 더 이상 존재하지 않는 Product
를 가리키게 되는 것을 막기 위해 외래 키 제약 조건 위반 오류를 발생시킵니다.
// Product.java
@OneToMany(mappedBy = "product")
private List<DropEvent> dropEvents = new ArrayList<>();
@OneToMany(mappedBy = "product")
private List<Order> orders = new ArrayList<>();
결국 트랜잭션은 롤백되고, 서버는 500 에러를 뿜어내며 멈추게 됩니다. 데이터의 일관성이 깨지는 것을 막기 위한 DB의 마지막 방어선입니다.
참조 무결성 (Referential Integrity) 이란?
관계형 데이터베이스에서 두 테이블 간의 관계가 항상 일관된 상태를 유지하도록 보장하는 규칙. 자식 테이블의 외래 키는 항상 부모 테이블의 기본 키를 참조해야 하며, 존재하지 않는 부모 레코드를 참조할 수 없음.
이 문제를 해결하기 위해 가장 직관적인 방법은 "삭제하기 전에, 해당 상품을 참조하는 데이터가 있는지 먼저 확인하는 것" 입니다.
각 Repository에 참조 존재 여부를 확인하는 메서드를 추가하고, deleteProduct
서비스를 수정했습니다.
1. Repository 수정 (existsBy...
메서드 추가)
OrderRepository
와 DropEventRepository
에 특정 productId
를 가진 데이터가 존재하는지 확인하는 간단한 쿼리 메서드를 추가했습니다.
// OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
boolean existsByProductId(Long productId);
// ...
}
// DropEventRepository.java
public interface DropEventRepository extends JpaRepository<DropEvent, Long> {
boolean existsByProductId(Long productId);
}
2. Service 로직 수정
deleteProduct
메서드에서 productRepository.delete()
를 호출하기 전에, 위에서 만든 existsBy...
메서드를 이용해 참조하는 데이터가 있는지 검사하는 로직을 추가했습니다.
// ProductAdminService.java
public void deleteProduct(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));
// 검증 로직 추가
if (orderRepository.existsByProductId(productId) || dropEventRepository.existsByProductId(productId)) {
// 하나라도 참조하는 곳이 있다면 예외 발생
throw new CustomException(ErrorCode.PRODUCT_IN_USE);
}
// 안전하게 삭제
productRepository.delete(product);
}
이제 상품을 삭제하려고 할 때, 해당 상품과 연결된 주문이나 이벤트가 있다면 PRODUCT_IN_USE
예외를 발생시켜 참조 무결성 위반을 사전에 방지할 수 있게 되었습니다.
첫 번째 해결책으로 당장의 오류는 막았지만, 한 가지 중요한 점을 놓치고 있었습니다. 과거의 주문 내역이나 이벤트 기록은 비즈니스적으로 매우 중요한 데이터라는 사실입니다.
물리적 삭제는 상품 정보를 영원히 지워버리기 때문에, 나중에 "A 고객이 1년 전에 주문했던 상품의 정보가 뭐였지?" 와 같은 질문에 답할 수 없게 됩니다.
여기서 더 실용적이고 안전한 대안이 바로 논리적 삭제 (Soft Delete) 입니다.
논리적 삭제란?
데이터베이스에서 레코드를 실제로 지우는 대신, "삭제된 상태"임을 나타내는 플래그를 추가하여 관리하는 방식입니다.
예를 들어, Product
엔티티에 deletedAt
(삭제된 시간)이나 isDeleted
(삭제 여부) 같은 필드를 추가합니다.
// Product.java
@Entity
public class Product {
// ... 기존 필드
private LocalDateTime deletedAt; // 삭제 시각을 저장할 필드
}
그리고 삭제 요청이 오면 DELETE
쿼리를 날리는 대신, 이 필드의 값을 업데이트하는 것입니다.
// ProductAdminService.java (Soft Delete 적용 시)
public void deleteProduct(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));
// 실제 삭제 대신, 삭제 상태로 변경
product.setDeletedAt(LocalDateTime.now());
// productRepository.save(product)는 @Transactional에 의해 자동 처리
}
마지막으로, 사용자에게 데이터를 보여주는 모든 조회 쿼리에는 WHERE deletedAt IS NULL
과 같은 조건을 추가하여 "삭제된" 상품은 보이지 않도록 필터링하는 것 입니다.
JPA의 @Where
나 @SQLDelete
같은 애노테이션을 사용하면 이 과정을 더 편리하게 관리가 가능합니다.
논리적 삭제의 장점
deletedAt
필드를 NULL
로 바꾸기만 하면 손쉽게 복구할 수 있습니다.구분 | 물리적 삭제 (Hard Delete) | 논리적 삭제 (Soft Delete) |
---|---|---|
방법 | DELETE 쿼리로 데이터 완전 제거 | 상태 값 변경 (isDeleted , deletedAt ) |
장점 | 구현이 간단하고, 저장 공간 확보 | 데이터 보존, 복구 용이, 안정성 |
단점 | 데이터 유실, 복구 불가, 참조 무결성 위험 | 조회 쿼리가 복잡해지고, 데이터는 계속 남음 |
추천 | 로그처럼 중요도가 낮은 임시 데이터 | 주문, 회원 등 핵심 비즈니스 데이터 |