fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화

Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다.
잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고
사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로
폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서
pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다.

이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위
보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를
서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로
결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로
바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
This commit is contained in:
HeuJung committed 2026-08-12 10:10:01 +09:00
1 parent 5ae34002f3
commit c77ddee03b
39 files changed
+1886 -138

No files matched your search

+12 -2
View File
@@ -359,12 +359,22 @@ public static function getBundledPath(string $basePath, string $identifier): str
| 실패 시점 | 결과 |
| --------- | ---- |
| 1단계 실패 (복사) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시 디렉토리 정리 후 예외 |
| 2단계 실패 (rename) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시만 정리 |
| 3단계 실패 (rename) | `_pending/` 내 _old를 원래 위치로 **롤백**, 예외 |
| 2단계 실패 (rename) | **파일 단위 제자리 동기화로 폴백** — 새 버전 파일을 활성 디렉토리에 직접 덮어쓰고, 새 버전에 없는 잔존 파일 제거 |
| 3단계 실패 (rename) | **파일 단위 복사로 폴백** — 스테이징 내용을 활성 경로에 파일별 복사. 복사까지 실패하면 _old 를 원래 위치로 **롤백**, 예외 |
> **Windows 참고**: `deleteDirectory` 직후 같은 이름으로 `rename`이 실패하는 타이밍 이슈가 있어, delete→copy 대신 rename→rename→delete 패턴을 사용합니다.
> **오토로드 안전성**: 임시 디렉토리가 `_pending/` 하위에 생성되므로, IDE 잠금 등으로 잔존하더라도 `str_starts_with($name, '_')` 필터에 의해 오토로드에서 자동 제외됩니다.
#### Windows 파일 잠금 폴백 (rename 차단 시)
Windows 에서 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의 디렉토리 핸들, 편집기·개발 도구가 열어 둔 파일 등)이 하나라도 있으면 실패한다. 잠금 프로세스의 식별·종료는 신뢰할 수 없고(디렉토리 핸들은 Restart Manager API 로 감지되지 않는다) 사용자의 도구를 예고 없이 종료시키는 부작용이 있으므로, **rename 이 차단되면 프로세스를 종료하는 대신 파일 단위 연산으로 폴백**한다:
- 파일 생성·덮어쓰기·읽기는 디렉토리 핸들 잠금의 영향을 받지 않는다 (Windows 의 일반적인 파일 열기 모드는 읽기/쓰기 공유를 허용).
- 덮어쓰기가 막힌 개별 파일은 삭제 후 재생성 → 옆으로 치우기(rename) 순으로 단계적 재시도한다.
- 새 버전 파일을 심지 못하면 예외로 전파해 호출자(매니저)가 백업 복원으로 수습하고, 잔존 파일 삭제 실패는 로그만 남기고 진행한다 (업데이트 전체 실패보다 잔존이 낫다).
- 폴백 경로는 원자적이지 않다 — 파일별로 순차 교체되므로 교체 도중의 요청은 신·구 파일이 섞인 상태를 볼 수 있다. 프로덕션(Linux)에서는 rename 이 차단되지 않아 항상 원자적 fast path 를 탄다.
- 잠금으로 삭제하지 못한 `_updating_*`/`_old_*` 임시 디렉토리는 다음 교체 시작 시 자동으로 재정리된다.
이 메서드는 다음 위치에서 공통으로 사용됩니다:
- `ExtensionBackupHelper::restoreFromBackup()` — 백업 복원
+30 -2
View File
@@ -15,7 +15,8 @@
7. [레이아웃 연동](#7-레이아웃-연동)
8. [백엔드에서 설정 조회](#8-백엔드에서-설정-조회)
9. [카탈로그 병합 설정의 공개 응답](#9-카탈로그-병합-설정의-공개-응답)
10. [관련 문서](#10-관련-문서)
10. [항목 단위 보충 병합과 삭제 의도](#10-항목-단위-보충-병합과-삭제-의도)
11. [관련 문서](#11-관련-문서)
---
@@ -593,7 +594,34 @@ GET /api/modules/{id}/settings/payment # 고아 제외 (정상)
---
## 10. 관련 문서
## 10. 항목 단위 보충 병합과 삭제 의도
`defaults.json` 의 목록 설정(정수키 리스트)은 저장 시 `array_merge` 가 병합이 아니라 통째 교체를 수행한다. 그래서 관리자가 목록에서 일부 항목을 빼고 저장하면 defaults 항목이 저장본에서 사라지고, 그 상태가 그대로 유지된다. 이 통째 교체가 "관리자가 편집한 목록이 그대로 남는다" 는 뜻이라 대부분의 목록에는 옳은 동작이다.
문제는 여기에 **항목 단위 보충 병합**(저장본에 없는 defaults 항목을 code/key 기준으로 다시 채워 넣는 처리)을 얹을 때다. 보충은 보통 "의도치 않은 소실 복구" 를 목적으로 도입되는데, 소실과 삭제는 저장본에서 똑같이 "그 항목이 없다" 로 보이기 때문에 구분할 근거가 없다. 그 결과 관리자의 의도적 삭제까지 되돌아간다.
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| 보충 병합이 저장본에 없는 defaults 항목을 무조건 다시 채움 | 저장 시점에 삭제 의도를 도출해 저장본에 기록하고, 병합이 그 기록을 존중 |
| 삭제 기록을 클라이언트 제출값에서 받음 | 서버가 `defaults 항목 − 제출 항목 − 삭제 불가 항목` 으로 재계산 (제출된 같은 키는 버린다) |
| 그 목록 키가 제출되지 않은 저장에서 기록만 이월 | 목록 값과 기록을 **함께** 이월 — 저장이 파일 통째 교체라 목록 키가 사라지면 defaults 가 다시 들어와 삭제가 전부 부활한다 |
| 항상 살아 있어야 하는 항목(기본 통화 등)까지 기록 대상에 포함 | 그 항목은 기록에서 제외해 보충 경로로 항상 생존시킨다 |
| 삭제 기록을 공개 응답에 그대로 노출 | `frontend_schema` 의 필드 화이트리스트로 차단 (관리자 응답에는 남겨도 무해) |
| 기록 배열을 비연속 키로 저장 | `array_values()` 로 재정렬 — 비연속 키는 JSON 객체로 직렬화되어 화면 반복이 깨진다 |
이 결함은 예외도 경고도 로그도 남기지 않는다. 저장 요청은 200 을 반환하고, 그 응답 본문에 이미 삭제한 항목이 되살아나 있다.
보충 병합과 통째 교체를 혼동하지 않도록, 목록 설정을 다룰 때는 다음 세 가지를 구분해 판정한다.
| 모델 | 조회 시 처리 | 화면 | 삭제 의미 |
| --- | --- | --- | --- |
| 통째 교체 | 저장본을 그대로 사용 | 항목 추가·삭제 | 삭제가 그대로 유지된다 |
| 항목 단위 보충 | 저장본에 없는 defaults 항목을 채움 | 항목 추가·삭제 | **삭제 기록이 없으면 되돌아간다** |
| 카탈로그 병합 | 확장이 등록한 카탈로그와 저장값을 합침 | `is_active` 토글 | 삭제 개념이 없다 (9장 참조) |
---
## 11. 관련 문서
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
- [모듈 라우트](module-routing.md) - API 라우트 규칙