조용진의 개발 블로그
← 글 목록

삭제는 숨김이 아니었다: 수업 소프트 삭제를 조회·신청·승인까지 연결한 과정

소프트 삭제데이터 모델링ReactNode.js정합성

참조 이력을 보존하면서 삭제 수업을 신규 선택에서 제외하고, 오래된 신청의 저장·승인을 API와 UI 양쪽에서 차단한 과정을 정리합니다.

수업을 삭제하는 기능은 처음에는 한 행을 지우는 문제처럼 보였습니다. 하지만 그 수업을 참조하는 과거 신청서, 승인 대기 건, 청구 이력, 출결 기록이 남아 있었습니다. 물리 삭제를 계속하면 참조 무결성이 깨지고, 삭제를 막기만 하면 운영자는 더 이상 사용하지 않는 수업을 정리할 수 없었습니다.

이번 작업에서는 수업을 소프트 삭제로 전환하면서 “일반 화면에서는 숨기되, 과거 이력에서는 보이고, 새로운 신청과 승인에는 사용할 수 없다”는 정책을 API와 UI 전체에 반영했습니다.

소프트 삭제는 컬럼 하나를 추가하는 작업이 아니었다

소프트 삭제는 레코드를 실제로 제거하지 않고 삭제 상태를 표시하는 방식입니다. 수업 테이블에 archived를 추가하고 기본값을 활성 상태인 0으로 두었습니다.

아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.

archived: {
  type: DataTypes.TINYINT,
  allowNull: true,
  defaultValue: 0,
  comment: "수업 삭제 여부 (0: 사용, 1: 삭제)",
}

ORM 모델에는 기본 스코프를 적용해 평범한 조회가 삭제 수업을 자동으로 제외하도록 했습니다.

defaultScope: {
  where: {
    archived: 0,
  },
}

이 변경만 보면 문제가 해결된 것처럼 보입니다. 하지만 실제로는 삭제 데이터가 필요한 조회와 숨겨야 하는 조회를 구분하는 작업이 더 중요했습니다.

삭제 데이터는 화면에 따라 숨겨야 할 때와 보여야 할 때가 다르다

수업 데이터를 사용하는 경로를 세 종류로 나눴습니다.

경로삭제 수업 처리이유
신규 수업 선택·공개 신청제외더 이상 선택되면 안 됨
학생·키오스크의 현재 수업제외운영 중인 데이터만 필요
과거 신청서·결제·이력유지당시의 참조와 설명을 보존해야 함

일반 조회에는 기본 스코프가 안전한 기본값을 제공합니다. 반면 과거 신청서를 검증할 때는 unscoped()로 삭제 수업까지 읽었습니다.

const selectedRecords = await ScheduleModel.unscoped().findAll({
  where: {
    id: { [Op.in]: selectedItemIds },
    organizationId,
  },
  attributes: ["id", "name", "category", "archived"],
  transaction,
});

삭제 수업을 무조건 필터링하면 “존재하지 않는 수업”과 “삭제됐지만 이력은 남은 수업”을 구분할 수 없습니다. 이력 화면에서는 이름을 보여주고, 승인 같은 상태 변경은 차단하려면 삭제 상태를 읽을 수 있어야 합니다.

신청폼 저장 단계에서 활성 수업을 최소 한 개 보장했다

기존 신청폼이 삭제된 수업만 참조하게 되면 공개 페이지에 선택 가능한 항목이 없는 폼이 남을 수 있습니다. 폼 생성과 수정 시 다음 조건을 검증했습니다.

이 검증은 프런트와 API 양쪽에 적용했습니다. UI는 빠른 피드백을 제공하고, API는 다른 클라이언트나 오래 열린 화면에서 들어오는 요청까지 최종적으로 막습니다.

승인 시점에 다시 검사해야 했다

신청서를 제출할 때는 수업이 정상이었더라도 관리자가 승인하기 전에 수업이 삭제될 수 있습니다. 제출 시점 검증만으로는 이 경쟁 조건을 막지 못합니다.

승인 트랜잭션에서는 신청 항목이 참조하는 수업을 삭제 상태까지 포함해 다시 읽었습니다. 삭제 수업이 하나라도 포함되면 학생 등록, 수업 배정, 청구서 생성을 시작하지 않고 승인을 차단했습니다.

const deletedItemIds = selectedItems
  .filter((selectedItem) => {
    const schedule = recordById.get(Number(selectedItem.itemId));
    return schedule && Number(schedule.archived) === 1;
  })
  .map((selectedItem) => Number(selectedItem.itemId));

if (deletedItemIds.length > 0) {
  throw new Error("신청한 수업이 삭제되어 승인할 수 없습니다.");
}

상태가 바뀔 수 있는 대상은 읽을 때가 아니라 쓰기 직전에 다시 검증해야 합니다. 승인 과정은 학생 생성과 청구서 발행까지 이어지므로, 오래된 화면 상태를 믿고 진행했을 때의 영향이 더 큽니다.

UI는 삭제된 연결을 숨기지 않고 정리할 수 있게 했다

관리 화면에서 삭제 수업을 그냥 제거해 버리면 운영자는 왜 저장이나 승인이 막혔는지 알기 어렵습니다. 기존 연결은 경고 카드로 남기고 “삭제 필요” 상태를 표시했습니다.

<SelectedRow key={item.itemId} $inactive={item.archived}>
  <ItemName>
    {item.itemName}
    {item.archived && <InactiveBadge>삭제 필요</InactiveBadge>}
  </ItemName>

  <LabelInput disabled={item.archived} />

  {item.archived && (
    <UnlinkButton onClick={() => handleRemove(item.itemId)}>
      연결 삭제
    </UnlinkButton>
  )}
</SelectedRow>

삭제된 항목은 순서 변경, 표시명 수정, 학년 필터 편집을 막고 연결 제거만 허용했습니다. 사용자가 잘못된 데이터를 수정해 계속 사용하는 대신, 정상 수업으로 교체하도록 동선을 제한한 것입니다.

ORM 기본 스코프 밖의 raw SQL도 찾아야 했다

소프트 삭제 전환 후 학생 앱과 키오스크 일부 조회에서 삭제 수업이 계속 노출됐습니다. 원인은 ORM 기본 스코프가 적용되지 않는 raw SQL과 직접 조인 쿼리였습니다.

후속 변경에서는 학생 수업 목록, 앱·웹 키오스크, 수업 카테고리 조회에 활성 수업 조건을 명시적으로 추가했습니다. 반대로 결제와 이력처럼 삭제 수업 참조가 필요한 경로는 필터하지 않았습니다.

이 경험은 ORM의 기본 스코프가 시스템 전체의 삭제 정책을 자동으로 보장하지 않는다는 점을 보여줬습니다. 모델 API, raw SQL, 통계 쿼리, 캐시 생성 코드를 모두 검색해야 합니다.

변경 흐름은 경계를 따라 이어졌다

삭제 요청부터 승인까지의 방어 흐름은 다음과 같습니다.

flowchart LR
  A[수업 삭제 요청] --> B[삭제 상태로 전환]
  B --> C[신규 선택에서 제외]
  B --> D[기존 신청 이력은 유지]
  D --> E[관리 화면에 경고]
  E --> F[연결 정리 또는 승인 차단]

한 계층에서 데이터를 숨기는 것보다, 삭제 뒤 허용되는 행동과 금지되는 행동을 각 경계에 명시하는 것이 핵심입니다.

확인된 결과와 한계

API와 관리자 웹의 삭제 정책을 함께 바꾸고 운영 서비스에 반영했습니다.

소프트 삭제 레코드는 계속 저장되므로 보존 기간, 개인정보 삭제 요구, 인덱스 크기 관리는 별도 정책이 필요합니다.

삭제 정책은 데이터가 아니라 행동을 정의해야 한다

이번 작업에서 남은 기준은 다음과 같습니다.

  1. 삭제 데이터를 숨길 조회와 유지할 이력을 먼저 분류합니다.
  2. 기본 스코프는 안전한 기본값으로 사용하되 raw SQL을 별도로 점검합니다.
  3. 오래 걸리는 업무 흐름은 최종 상태 변경 직전에 다시 검증합니다.
  4. 관리 화면에는 삭제된 참조를 보여주고 정리할 방법을 제공합니다.

소프트 삭제의 완성은 archived를 저장하는 순간이 아니라, 삭제된 데이터로 무엇을 할 수 있는지 모든 경계에서 합의하는 순간입니다.