- 184/184 전체 PASS (100%) 최종 결과 포함 - 버그 분석 리포트 5건 (매출관리 크래시, 페이지네이션 등) - OK-/Fail- 시나리오별 상세 리포트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.5 KiB
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-salesedge-boundary-acc-sales,edge-rapid-click-acc-sales,multi-item-acc-salesperf-acc-sales,reload-persist-acc-sales,search-filter-acc-salesbatch-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/31→ 404 (존재하지 않음)GET /api/v1/clients/32→ 404 (존재하지 않음)- 전체 클라이언트 목록에서 ID 30→35 사이에 공백 (31, 32, 33, 34 없음)
3. 방어 레이어 분석
기존 코드에는 3개의 방어 레이어가 있었으나 모두 우회됨:
Layer 1: transformApiToFrontend (types.ts)
// 기존 코드
vendorName: apiData.client?.name || '(거래처 미지정)',
null?.name→undefined→'(거래처 미지정)'- 이론적으로 정상 동작해야 하나, 첫 렌더 사이클에서 빈 값이 도달
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+ 세션에 걸친 심층 조사:
- 정적 코드 분석 (모든 SelectItem 값 추적) - 빈 값 미발견
- Webpack 런타임 monkey-patching으로 Radix SelectItem 래핑
- React Fiber 트리 워킹으로 salesData 내 빈 vendorName 확인
- API 직접 호출로
client: null레코드 12건 확인 - Backend SaleService 분석 →
with(['client:id,name'])eager-load 확인 - 거래처 API 확인 → client_id 31, 32가 삭제된 것 확인