Files
sam-hotfix/e2e/results/hotfix/BUG-SALES-PAGE-CRASH-ROOT-CAUSE_2026-02-15.md
김보곤 0ef699016a test: E2E 테스트 결과 리포트 2869개 추가 (2026-02-13 ~ 02-19)
- 184/184 전체 PASS (100%) 최종 결과 포함
- 버그 분석 리포트 5건 (매출관리 크래시, 페이지네이션 등)
- OK-/Fail- 시나리오별 상세 리포트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:37:51 +09:00

7.5 KiB

BUG: 매출관리 페이지 크래시 - 근본 원인 분석 및 수정

작성일: 2026-02-15 10:27:39 우선순위: 🔴 Critical (10+ E2E 시나리오 전면 실패) 상태: Frontend 수정 완료, Backend 데이터 정리 필요


1. 증상

매출관리 페이지(/ko/accounting/sales) 접근 시 즉시 크래시:

Error: A <Select.Item /> must have a value prop that is not an empty string.
  This is a bug in your application.

React Error Boundary가 에러를 잡아 "일시적인 오류가 발생했습니다" 화면 표시.

영향 범위

  • 매출관리 페이지 완전 사용 불가
  • 10+ E2E 시나리오 전면 FAIL:
    • accounting-sales, full-crud-acc-sales, detail-verify-acc-sales
    • edge-boundary-acc-sales, edge-rapid-click-acc-sales, multi-item-acc-sales
    • perf-acc-sales, reload-persist-acc-sales, search-filter-acc-sales
    • batch-update-account-sales, a11y-acc-sales

2. 근본 원인: 삭제된 거래처 참조 (Orphaned Foreign Keys)

API 응답 분석

지표
전체 매출 레코드 81건
문제 레코드 (client: null) 12건 (14.8%)
영향받는 client_id 31, 32

원인 체인

1. 거래처 31, 32가 DB에서 삭제됨 (hard delete 또는 soft delete + API 제외)
2. 해당 거래처를 참조하는 12개 매출 레코드는 정리되지 않음
3. Sales API가 with(['client:id,name'])로 eager-load하면 client: null 반환
4. transformApiToFrontend에서 vendorName에 빈 문자열이 들어감
5. vendorOptions에 빈 문자열이 포함됨
6. Radix UI Select.Item에 value="" 전달 → 크래시

문제 레코드 상세

sale_id sale_number client_id client status
78 SAL-202512-0005 32 null draft
77 SAL-202512-0004 31 null draft
65 SAL-202510-0007 32 null confirmed
64 SAL-202510-0006 31 null confirmed
52 SAL-202509-0001 32 null invoiced
51 SAL-202508-0006 31 null confirmed
39 SAL-202507-0001 32 null invoiced
38 SAL-202506-0006 31 null confirmed
26 SAL-202505-0001 32 null confirmed
25 SAL-202504-0006 31 null confirmed
13 SAL-202503-0001 32 null confirmed
12 SAL-202502-0006 31 null confirmed

거래처 API 확인

  • GET /api/v1/clients/31404 (존재하지 않음)
  • GET /api/v1/clients/32404 (존재하지 않음)
  • 전체 클라이언트 목록에서 ID 30→35 사이에 공백 (31, 32, 33, 34 없음)

3. 방어 레이어 분석

기존 코드에는 3개의 방어 레이어가 있었으나 모두 우회됨:

Layer 1: transformApiToFrontend (types.ts)

// 기존 코드
vendorName: apiData.client?.name || '(거래처 미지정)',
  • null?.nameundefined'(거래처 미지정)'
  • 이론적으로 정상 동작해야 하나, 첫 렌더 사이클에서 빈 값이 도달

Layer 2: vendorOptions (index.tsx)

// 기존 코드
const uniqueVendors = [...new Set(salesData.map(d => d.vendorName))].filter(Boolean);
  • .filter(Boolean) : ''false → 제거됨
  • 이론적으로 정상 동작해야 함

Layer 3: renderAutoFilters (IntegratedListTemplateV2.tsx)

// 기존 코드
{field.options.filter(opt => opt.value !== '').map(option => (...))}
  • .filter(opt => opt.value !== '') : 빈 값 제거
  • 이론적으로 정상 동작해야 함

왜 3개 레이어가 모두 실패하는가?

Webpack 런타임 분석(Radix SelectItem monkey-patching)으로 확인한 결과:

  • 첫 번째 렌더 사이클에서만 빈 값이 도달
  • React의 동시 렌더링 또는 상태 초기화 타이밍에서 방어 레이어가 적용되기 전 렌더가 발생하는 것으로 추정
  • Production 빌드의 최적화(코드 분할, Suspense 등)가 관여할 가능성

4. 수정 내용

Frontend 수정 (완료)

4-1. transformApiToFrontend 강화 (types.ts)

// 수정 전
vendorName: apiData.client?.name || '(거래처 미지정)',

// 수정 후 - explicit trim + empty check
vendorName: (apiData.client?.name && apiData.client.name.trim() !== '')
  ? apiData.client.name
  : '(거래처 미지정)',
  • || 연산자만으로는 whitespace 문자열을 잡지 못함
  • 명시적 trim() !== '' 체크로 edge case 완전 차단

4-2. vendorOptions 필터 강화 (index.tsx)

// 수정 전
.filter(Boolean)

// 수정 후 - explicit empty & whitespace check
.filter(v => v && v.trim() !== '')
  • Boolean('') = false (기존에도 동작)
  • trim() 추가로 whitespace-only 문자열도 차단

Backend 수정 필요 (미완료)

4-3. 데이터 정리 (DB)

-- 옵션 A: 삭제된 거래처 참조 매출 레코드의 client_id를 NULL로 설정
UPDATE sales SET client_id = NULL WHERE client_id IN (31, 32);

-- 옵션 B: 해당 매출 레코드 자체 삭제 (테스트 데이터인 경우)
DELETE FROM sales WHERE client_id IN (31, 32);

-- 옵션 C: 거래처 복구
-- INSERT INTO clients (id, ...) VALUES (31, ...), (32, ...);

4-4. API 방어 코드 추가 (권장)

// SaleService.php - index() 메서드에 추가
// client 관계가 null인 경우 기본값 제공
$query = Sale::query()
    ->where('tenant_id', $tenantId)
    ->with(['client:id,name']);  // 현재 코드

// Sales Resource에서 방어적 직렬화:
// 'client' => $this->client ? ['id' => $this->client->id, 'name' => $this->client->name] : null

4-5. 참조 무결성 강화 (장기)

// Migration: client_id에 ON DELETE SET NULL 추가
Schema::table('sales', function (Blueprint $table) {
    $table->foreign('client_id')
        ->references('id')
        ->on('clients')
        ->onDelete('set null');
});

5. 검증 방법

Frontend 수정 검증

# 매출관리 페이지 접근 → 크래시 없이 테이블 표시 확인
# 거래처 필터 → '(거래처 미지정)' 옵션 정상 표시 확인

E2E 테스트 재실행

node C:/Users/codeb/sam/e2e/runner/run-all.js --filter acc-sales

데이터 검증 (Backend)

# 삭제된 거래처를 참조하는 매출 레코드 확인
# SELECT s.id, s.sale_number, s.client_id, c.id as client_exists
# FROM sales s LEFT JOIN clients c ON s.client_id = c.id
# WHERE c.id IS NULL;

6. 재발 방지

조치 레벨 설명
Frontend transform 강화 즉시 client.name 빈값 방어
vendorOptions 필터 강화 즉시 빈 문자열 이중 차단
⚠️ DB 데이터 정리 단기 orphaned 레코드 처리
⚠️ Foreign key constraint 중기 ON DELETE SET NULL 적용
⚠️ API 직렬화 방어 중기 null client 시 기본값 반환
⚠️ 거래처 삭제 시 참조 확인 장기 삭제 전 사용 중인 매출 레코드 경고

7. 조사 과정 요약

6+ 세션에 걸친 심층 조사:

  1. 정적 코드 분석 (모든 SelectItem 값 추적) - 빈 값 미발견
  2. Webpack 런타임 monkey-patching으로 Radix SelectItem 래핑
  3. React Fiber 트리 워킹으로 salesData 내 빈 vendorName 확인
  4. API 직접 호출로 client: null 레코드 12건 확인
  5. Backend SaleService 분석 → with(['client:id,name']) eager-load 확인
  6. 거래처 API 확인 → client_id 31, 32가 삭제된 것 확인