데이터베이스의 숨은 암살자 - 논리적 삭제가 도움보다 해로운 이유
Emily Parker
Product Engineer · Leapcell

서론
데이터베이스 관리의 세계에서 데이터를 "삭제"하는 행위는 기본적인 작업입니다. 그러나 정보를 제거하는 겉보기에는 간단한 작업이 처음에는 편리해 보이는 설계 결정으로 이어지는 경우가 많으며, 이는 장기적으로 상당한 단점을 초래할 수 있습니다. 이러한 보편적인 관행 중 하나는 레코드를 물리적으로 제거하는 대신 "논리적으로 삭제된" 것으로 표시하기 위해 일반적으로 is_deleted 또는 deleted라고 명명된 부울 플래그를 사용하는 것입니다. 이 접근 방식은 종종 기록 보존, 데이터 복구 활성화 또는 감사 단순화를 포함한 최선의 의도로 채택됩니다. 그러나 우리가 살펴보겠지만, 이 겉보기에는 무해한 패턴은 조용히 데이터베이스 성능을 침식하고 애플리케이션 로직을 복잡하게 만들며 궁극적으로 유지보수성을 저해하는 숨은 암살자로 발전할 수 있습니다. is_deleted = true가 안티 패턴인 이유와 "삭제된" 데이터를 올바르게 처리하는 방법을 이해하는 것은 강력하고 확장 가능하며 효율적인 데이터베이스 시스템을 구축하는 데 중요합니다.
논리적 삭제의 함정
is_deleted = true가 문제가 되는 구체적인 이유를 자세히 살펴보기 전에, 논의의 중심이 될 몇 가지 핵심 용어를 정의해 보겠습니다.
- 물리적 삭제: 데이터베이스 테이블에서 레코드를 영구적으로 제거하는 것입니다. 물리적으로 삭제된 데이터는 복구하거나 백업 없이는 쉽게 복구할 수 없습니다.
 - 논리적 삭제 (소프트 삭제): 물리적으로 제거하는 대신 플래그(예: 
is_deleted = TRUE,status = 'deleted')를 사용하여 레코드를 "삭제된"으로 표시하는 관행입니다. 레코드는 테이블에 유지되지만 일반적으로 활성 애플리케이션 쿼에서 제외됩니다. - 보관(Archiving): 기록적이거나 비활성 데이터를 기본 운영 테이블에서 별도의, 일반적으로 덜 성능이 좋지만 더 비용 효율적인 저장 위치로 이동시키는 것입니다. 이 데이터는 일반적으로 감사, 규정 준수 또는 기록 분석을 위해 보존됩니다.
 - 완전 삭제(Purging): 보관 또는 보존 기간이 만료된 후 종종 영구적으로 덮어쓰기된 오래된 불필요한 데이터를 모든 시스템에서 영구적으로 제거하는 것입니다.
 
is_deleted = TRUE 접근 방식은 간단해 보이지만 수많은 문제를 야기합니다.
성능 저하
논리적 삭제의 가장 즉각적인 영향 중 하나는 데이터베이스 성능입니다. 활성 데이터를 검색하는 모든 쿼는 이제 WHERE is_deleted = FALSE 절을 포함해야 합니다. 이는 사소해 보일 수 있지만, 테이블이 커지고 논리적으로 삭제된 레코드 수가 증가함에 따라 몇 가지 문제가 발생합니다.
- 인덱스 비효율성: 
WHERE절에 사용되는 열의 인덱스는 논리적으로 삭제된 레코드를 계속 포함합니다. 이는 데이터베이스 엔진이 불필요하게 더 많은 인덱스 항목을 스캔해야 함을 의미하며, I/O를 증가시키고 쿼 실행 속도를 늦춥니다. 부분 인덱스(적용 가능한 경우)가 특정 쿼에 대한 완화를 제공할 수는 있지만, 복잡성을 더하고 보편적인 해결책은 아닙니다. - 테이블 스캔: 쿼리에 올바르게 인덱싱되지 않은 경우 데이터베이스는 "삭제된"으로 표시된 레코드를 포함하여 모든 레코드를 처리하는 전체 테이블 스캔으로 전환해야 할 수 있습니다.
 - 테이블 크기 증가: 논리적으로 삭제된 레코드는 테이블에 남아 크기를 늘립니다. 더 큰 테이블은 더 많은 디스크 공간을 차지하고, 백업 및 복구에 더 오래 걸리며, 데이터베이스 버퍼 풀에서 더 많은 메모리를 소비하여 활성 데이터를 밀어내고 잠재적으로 더 많은 디스크 읽기를 유발할 수 있습니다.
 
간단한 users 테이블을 생각해 보겠습니다.
CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(255), email VARCHAR(255), is_deleted BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 활성 사용자 예제 쿼리 SELECT id, name, email FROM users WHERE is_deleted = FALSE;
is_deleted = TRUE 레코드 수가 증가함에 따라 데이터베이스가 활성 레코드를 식별하기 전에 많은 "삭제된" 행을 읽어야 하므로 WHERE is_deleted = FALSE 필터가 덜 효과적이게 됩니다.
애플리케이션 로직 복잡성
is_deleted 플래그는 애플리케이션 코드베이스 전체에 전파됩니다. 거의 모든 쿼, 거의 모든 데이터 검색 작업은 이제 명시적으로 is_deleted = FALSE를 확인해야 합니다. 이 조건을 단 한 곳이라도 잊으면 애플리케이션 버그가 발생할 수 있으며, 잠재적으로 "삭제된" 데이터가 노출되거나 잘못된 계산이 발생할 수 있습니다.
# Django ORM 예제 # 잘못된 경우: 삭제된 사용자를 검색할 수 있음 users = User.objects.all() # 올바른 경우: 명시적 필터링 필요 active_users = User.objects.filter(is_deleted=False) # 애플리케이션 전체에 걸쳐 이 필터를 반복적으로 기억하는 것은 오류가 발생하기 쉽습니다.
또한 비즈니스 로직이 is_deleted의 상태와 얽히게 되어 복잡한 조건부 문장이 만들어질 수 있습니다. 예를 들어, 사용자가 "삭제"되었는데 다시 로그인하려고 하면 어떻게 될까요? 시스템은 사용자를 비활성화할지, 로그인을 방지할지, 또는 새 레코드를 만들지를 결정해야 하며, 이는 복잡성을 더합니다.
데이터 무결성 및 고유성 제약 조건
논리적 삭제는 고유성 제약 조건을 심각하게 복잡하게 만들 수 있습니다. 예를 들어 email에 UNIQUE 제약 조건이 있고 user@example.com을 가진 사용자가 논리적으로 삭제되면, 동일한 이메일로 새 사용자가 등록할 수 있을까요?
대부분의 데이터베이스 시스템은 고유성을 적용할 때 테이블의 모든 행을 활성 상태로 처리합니다. 이는 UNIQUE (email, is_deleted)와 같이 is_deleted가 활성 이메일에 대해 FALSE로 제한되는 경우를 제외하고는 동일한 고유 이메일을 가진 두 명의 사용자(한 명은 활성, 한 명은 논리적 삭제)를 가질 수 없다는 것을 의미합니다. 이는 종종 개발자들이 필수적인 고유성 제약 조건을 완화하거나 복잡한 해결책을 고안하도록 강요하여 데이터 무결성 위험을 초래합니다.
데이터 노출 및 규정 준수 위험
논리적으로 삭제된 데이터를 숨기려는 의도에도 불구하고, 데이터는 기본 데이터베이스에 그대로 존재합니다. 이는 우발적인 쿼리, 잘못된 구성 또는 보안 침해를 통해 데이터가 노출될 위험을 증가시킵니다. GDPR, CCPA, HIPAA와 같이 종종 "잊혀질 권리" 또는 엄격한 데이터 보존 정책을 의무화하는 규정 준수 요구 사항의 경우, 논리적 삭제는 악몽이 될 수 있습니다. 단순히 데이터를 삭제로 표시하는 것만으로는 실제 데이터 삭제에 대한 법적 요구 사항을 충족하지 못할 수 있으며, 이는 상당한 벌금과 평판 손상을 초래할 수 있습니다.
유지보수 오버헤드
시간이 지남에 따라 논리적으로 삭제된 데이터의 축적은 관리하기 어려워집니다. 팀은 오래된 논리적으로 삭제된 레코드를 주기적으로 "완전 삭제"하기 위한 사용자 정의 스크립트를 개발해야 할 수 있으며, 이는 나중에 물리적 삭제를 효과적으로 수행합니다. 이는 초기의 is_deleted의 단순성을 상쇄하는 운영 복잡성과 유지보수 오버헤드의 또 다른 계층을 추가합니다.
"삭제된" 데이터의 올바른 처리
단일 is_deleted 플래그에 의존하는 대신, "삭제"의 의도를 이해하고 적절한 전략을 적용하는 보다 강력하고 지속 가능한 접근 방식이 필요합니다.
1. 진정으로 일시적인 데이터에 대한 실제 물리적 삭제
진정으로 제거되어야 하며 기록, 감사 또는 복구 요구 사항이 없는 데이터의 경우, 물리적 삭제가 가장 간단하고 효율적인 방법입니다. 레코드가 진정으로 사라졌다면 데이터베이스에서 물리적으로 제거해야 합니다.
예시:
- 만료 후의 임시 세션 토큰.
 - 저장하지 않고 사용자가 명시적으로 폐기한 사용자 초안 게시물.
 - 즉각적인 서비스 약관을 위반하고 즉시 제거해야 하는 데이터.
 
-- 진정으로 일시적인 데이터의 경우 DELETE FROM temporary_sessions WHERE expires_at < NOW();
이 접근 방식은 테이블을 간결하게 유지하고, 쿼 성능을 유지하며, is_deleted와 관련된 모든 복잡성을 피합니다.
2. 기록 또는 규정 준수 데이터에 대한 보관
기록 분석, 감사 또는 규정 준수 이유로 데이터를 보존해야 하지만 코어 애플리케이션에서 더 이상 적극적으로 사용되지 않는 경우, 보관이 이상적인 솔루션입니다. 이는 데이터를 기본 운영 테이블에서 별도의 보관 테이블 또는 다른 저장 시스템(예: 데이터 웨어하우스, Amazon S3 또는 Google Cloud Storage와 같은 콜드 스토리지 서비스)으로 이동시키는 것을 포함합니다.
구현:
- 
별도의 보관 테이블(동일한 데이터베이스 내): 동일하거나 유사한 스키마를 별도의 테이블에 생성하며, 종종
archive_로 접두사가 붙거나_archive로 접미사가 붙습니다. 배치 작업을 사용하여 오래된 비활성 레코드를 기본 테이블에서 보관 테이블로 주기적으로 이동합니다.-- 보관 테이블 생성 CREATE TABLE users_archive LIKE users; ALTER TABLE users_archive DROP COLUMN is_deleted; -- 보관에는 이 플래그가 필요하지 않음 -- 오래된, "삭제된" 사용자를 보관으로 이동하는 ETL 프로세스 INSERT INTO users_archive (id, name, email, created_at, updated_at) SELECT id, name, email, created_at, updated_at FROM users WHERE is_deleted = TRUE AND updated_at < date_sub(NOW(), INTERVAL 1 YEAR); -- 보존 정책 정의 -- 보관이 성공적으로 완료된 후 기본 테이블에서 물리적으로 삭제 DELETE FROM users WHERE is_deleted = TRUE AND updated_at < date_sub(NOW(), INTERVAL 1 YEAR); - 
전용 보관 시스템: 매우 큰 데이터 세트 또는 장기 보존의 경우 전문 보관 솔루션을 고려하십시오. 이러한 솔루션은 액세스가 빈번하지 않은 데이터의 비용 효율적인 저장 및 검색에 최적화되어 있습니다.
 
이점:
- 성능 향상: 주요 운영 테이블은 간결하게 유지되어 활성 데이터에 대한 쿼를 최적화합니다.
 - 복잡성 감소: 애플리케이션 로직은 활성 데이터만 처리하고, 보관 액세스는 별도입니다.
 - 비용 효율성: 보관 스토리지 비용은 기본 데이터베이스 스토리지 비용보다 저렴합니다.
 - 규정 준수: 활성 데이터와 기록 데이터를 명확하게 분리하여 규정 준수 감사를 단순화합니다.
 
3. 워크플로 관련 "삭제"의 경우 "상태" 필드
"삭제"가 다단계 워크플로(예: 주문이 pending, shipped, delivered 또는 cancelled 상태인 경우)의 일부인 경우, 부울 is_deleted보다 status 필드가 더 적절합니다. 이를 통해 레코드의 수명 주기에 대한 더 풍부한 표현을 제공할 수 있습니다.
예시:
CREATE TABLE orders ( id INT PRIMARY KEY, customer_id INT, order_date TIMESTAMP, status ENUM('pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded') DEFAULT 'pending', -- ... 기타 주문 세부 정보 ); -- 활성/열린 주문 쿼리 SELECT * FROM orders WHERE status NOT IN ('cancelled', 'delivered', 'refunded');
이는 전통적인 의미의 삭제는 아니지만, 종종 is_deleted가 고려될 수 있는 시나리오를 다룹니다. 개체의 상태에 대한 명확한 의미를 제공합니다.
4. 감사 및 포렌식 복구를 위한 이벤트 소싱
광범위한 감사, 전체 기록 재구성 또는 복잡한 실행 취소/다시 실행 기능이 필요한 시스템의 경우, 이벤트 소싱은 강력한 아키텍처 패턴입니다. 엔티티의 현재 상태를 저장하는 대신, 이벤트 소싱은 현재 상태를 초래한 불변 이벤트 시퀀스를 저장합니다. "삭제"는 UserDeletedEvent 이벤트로 표현됩니다. 현재 상태는 이러한 이벤트를 다시 재생하여 재구성할 수 있습니다.
이점:
- 완전한 감사 추적: "삭제"를 포함한 모든 변경 사항이 기록됩니다.
 - 포렌식 분석: 어떤 시점으로든 쉽게 추적할 수 있습니다.
 - 복구: 논리적 오류에서 복구하기 위해 이벤트를 다시 재생할 수 있습니다.
 - 분리된 읽기 모델: 다양한 쿼에 최적화된 다양한 읽기 모델(보기)을 구축할 수 있습니다.
 
이것은 더 고급 패턴이며 자체적인 복잡성을 야기하지만, 데이터 기록에 대한 비교할 수 없는 기능을 제공합니다.
결론
is_deleted = TRUE 플래그는 겉보기에는 간단하지만, 데이터베이스 성능을 조용히 저하시키고, 애플리케이션 로직을 복잡하게 만들며, 유지보수 오버헤드를 증가시킬 수 있는 안티 패턴입니다. 데이터 제거의 진정한 의도를 이해하고, 일시적인 데이터에 대한 물리적 삭제를 선택하고, 기록 요구 사항에 대한 보관을 활용하고, 워크 플로 관리를 위한 상태 필드를 사용하고, 고급 감사를 위한 이벤트 소싱을 고려함으로써 개발자는 보다 강력하고 성능이 뛰어나며 유지보수 가능한 데이터 시스템을 구축할 수 있습니다. 이를 통해 데이터 관리가 애플리케이션 성장을 진정으로 지원하고 방해하지 않도록 보장합니다. 현명한 데이터베이스 설계는 장기적으로 단순성에 대한 인식보다 명확성과 효율성을 우선시합니다.

