# 레시피 저장 교차오염 트리거 차단 (2026-09-04) > **목적** : 액티브 전환 시 엉뚱한 레시피 파일에 다른 레시피 내용이 덮어써지던 건의 > 조사 결과와 적용한 차단 조치를 남긴다. > > - 대상 설비 : PtoP 각형화 단동 용접기 (유니셀) > - 이슈번호 : `ISSUE-260904-02` (원인 이슈 `ISSUE-260826-04` 의 조치) > - 커밋 : `bd6a7cd` > - 선행 분석 : `docs/분석/2026/08/26/레시피_Drawing_덮어쓰기_원인분석_260826.md`, > `docs/분석/2026/08/27/레시피_파일IO_동작별_순서맵_260827.md` **표기 규칙** (기존 인수인계 문서와 동일) | 표기 | 의미 | |---|---| | 🟩 **소스확인** | PC 소스/설정 파일에서 직접 대조해 확인한 사실 | | 🟦 **현장기록** | 현장 로그·시험 기록에 남아 있는 값 | | 🟨 **미확보** | 아직 확인되지 않음. 이어받는 사람이 채워야 할 항목 | --- ## 1. 한 줄 요약 **저장 "대상"과 저장 "내용"이 묶여 있지 않다.** LASA 는 레시피를 메모리에 1개만 유지하므로 활성 레시피가 바뀌는 순간 메모리 내용이 통째로 바뀌는데, 저장 함수는 목적지를 **이름 문자열로만** 만들고 내용은 **그 시점 메모리**를 쓴다. 둘이 같은 레시피인지 검증하는 코드가 없다. 이번 조치는 **트리거 차단**이다. 구조적 원인은 그대로 남아 있다. --- ## 2. 현상 - 오창 선행라인 CCW 설비에서 발생. PTOP 에서도 동일 계열 이슈 있었음. - 레시피 10번을 액티브로 쓰던 중 갑자기 2번으로 액티브가 바뀌고, **원래 쓰던 10번 파일 안에 2번 내용이 복사되어 덮어써짐.** - 간헐적. Auto↔Manual 전환 시 특히 잦음. ### 2.1 방향이 범인을 특정한다 🟩 "**10번 파일에 2번 내용**" — 목적지가 10(선택), 내용이 2(활성)다. | 저장 기준 | 만들어지는 결과 | |---|---| | **활성** 기준 저장 | 2번 파일에 10번 내용 ← 보고와 **반대** | | **선택** 기준 저장 | **10번 파일에 2번 내용** ← 보고와 일치 | → 선택(`SelectedRecipe`) 기준으로 저장하는 경로가 범인이다. --- ## 3. ★ 사수 지시와 PTOP 소스가 다르다 🟩 CCW 기준으로 받은 지시 두 가지는 **PTOP 소스에 그 형태로 존재하지 않는다.** 그대로 주석 처리하면 아무것도 바뀌지 않는다. 이어받는 사람이 반드시 알아야 할 부분이다. | 지시 | PTOP 실제 | |---|---| | ① `OnSaveRecipeForEditing()` 호출부 전부 주석 처리 | **호출부 0건.** `RecipeHelper.cs:71` 에 정의만 있는 **사문**. AZ 소스처럼 이미 제거된 상태 | | ② AutoRun 시작 시 "선택 레시피 선저장" 로직 차단 | **선저장이 없다.** `RunSequenceAutoRun:344` → `OnUpdateCurrentActivatedRecipe` → `OnActiveReloaded()` + `UpdateAutorunRecipe()` 이며 **둘 다 읽기 전용** | Auto↔Manual 전환 경로에도 저장이 없다. | 경로 | 코드 | 동작 | |---|---|---| | Auto 진입 | `EquipmentSetup.cs:286` `OnActiveReloaded()` | 읽기 | | Manual 진입 | `EquipmentSetup.cs:289` `//RollbackRecipe()` | 이미 주석 | | AutoRun 정지 | `UCMainAutoRunViewModel.cs:462` `RollbackRecipe()` | 읽기 | > 사수님의 **구조 진단(선택 기준 저장이 범인)은 정확하다.** 다만 PTOP 에서 그 역할을 하는 함수 이름이 > 다를 뿐이다. --- ## 4. 저장이 실제로 일어나는 경로 5개 (전수) 🟩 | # | 위치 | 저장 목적지 | 트리거 | 이번 조치 | |---|---|---|---|---| | **S1** | `UCScannerObjectTeachingViewModel.cs` `OnActivatedRecipe` | **활성** | `SYS_RECIPE_ACTIVATED` 마다 **무조건** | ✅ 조건부화 | | **S2** | 같은 파일 `OnSaved` | **선택** | Aurora 저장 버튼 | ✅ 가드 | | S3 | 같은 파일 `OnRecipeEntryChanged` | 신규 레시피 | 레시피 New | ❌ 미경유 (의도적) | | S4 | `RMSHandlerForPTOP.cs:146` | recipe.Name | RMS 레시피 체인지 | ❌ 미경유 | | S5 | `RecipeHelper.cs:77` `OnSaveRecipeForEditing` | 선택 | **호출 없음** | 사문 | **S1 이 사용자 조작 없이 발화한다.** 260825 로그 실측 🟦 — 저장 조작이 0건인 16분 구간에 `from [OnActivatedRecipe]` 로그가 8줄, 즉 **자동 저장 8회**. **S4(RMS 읽기)만 순서가 올바르다** — `Reload → 수정 → Save`(`RMSHandlerForPTOP.cs:132~147`). 수정 시 참조 모델로 쓸 것. --- ## 5. 저장 주체는 Aurora 가 아니다 🟩 "SaveRecipe" 라는 이름이 3개라 헷갈린다. | 계층 | 위치 | 소스 | 우리가 고칠 수 있나 | |---|---|---|---| | ① Aurora 내부 저장 | `Aurora.exe` | ❌ 없음 | ❌ | | ② `RecipeHelper.SaveRecipe(name, includeModel)` | `WeldingCommon/Utils/RecipeHelper.cs:38` | ✅ | ✅ | | ③ `MainPage.SaveRecipe(targetPath, includeModel)` | `LASA/GUI/Page/MainPage.cs` | ✅ | ✅ | ②가 ③을 호출한다(`RecipeHelper.cs:57`). **①은 우리 TeachingEditor/FieldHelper 파일 저장에 관여하지 않는다.** ### 5.1 Aurora 는 콜백만 준다 — 그런데 정답을 이미 넘겨주고 있다 `IRecipeViewModel` (`Aurora.Api`, `Aurora.xml:3989~4030`) — *"커스텀 레시피 뷰어를 구현할 때 상속받는 인터페이스"*. `UCScannerObjectTeachingViewModel : NotifyPropertyChangedBase, IRecipeViewModel`. | 멤버 | 시점 | |---|---| | `PreSaved(int id, string recipeName)` | Save 되기 **전** 호출. **`false` 반환 시 저장 중단** 🟦 | | `OnSaved(int id, string recipeName)` | Save 된 **후** 호출 | | `OnChanged` / `Reloaded` / `OnModelSelectChanged` | 뷰어 레시피 변경·리로드·모델 변경 | 저장소 어디에도 `PreSaved`/`OnSaved` 호출부가 없다. Aurora 가 부른다. **★ `OnSaved` 가 Aurora 인자를 버린다.** ```csharp public void OnSaved(int id, string recipeName) // ← Aurora 가 알려준 "진짜 저장 대상" { ... RecipeHelper.SaveRecipe(RecipeHelper.SelectedRecipe.name); // ← 인자 무시, 전역값 사용 ``` `RecipeTrace.cs:104` 주석에도 *"OnSaved 가 인자를 버리는 경로"* 라고 이미 적혀 있었다. **어긋남을 판정할 기준값이 이미 손에 들어와 있다.** --- ## 6. 이벤트 체인 — 어긋남을 처리하는 곳은 없다 🟩 **`SYS_RECIPE_ACTIVATED`** (Aurora 발행) 구독 5곳 | 구독 위치 | 하는 일 | 저장 | |---|---|---| | `UCScannerObjectTeachingViewModel.cs:64` | ActivateModel + **SaveRecipe(활성)** + 이벤트 재발행 | ✅ S1 | | `UCScannerObjectTeachingControlBoxViewModel.cs:224` | LaserInterlock 로드 (Background 지연) | ❌ | | `CommonEquipmentSetup.cs:114` | Merge → **로그 전용** | ❌ | | `VisionCommon/VisionEventId.cs:47,62,74,84` | 비전 Merge | ❌ | | `AuroraUIComponent/UCRecipeSelectViewModel.cs:74` | SelectRecipe — **PTOP 생성 0건, 사문** | ❌ | **`EVENT_CHANGE_RECIPE_INFO`** (우리 발행 — `OnActivatedRecipe`, `OnSaved`) 구독 2곳 | 구독 위치 | 하는 일 | 저장 | |---|---|---| | `UCMainAutoRunViewModel.cs:142` | `OnActiveReloaded()` + `UpdateAutorunRecipe()` | ❌ | | `RMSHandlerForPTOP.cs:56` | `ReloadRecipe()` + PLC 파라미터 업로드 | ❌ | **결론: 발행·구독 어느 지점에도 신원 검증이 없다.** 전부 넘어온 값을 그대로 따라간다. --- ## 7. 적용한 조치 — 파일 1개, 블록 3개 `WeldingComponents/WeldingCommon/Views/Recipes/UCScannerObjectTeachingViewModel.cs` 마커 `[레시피 덮어쓰기 이슈]`. ### ① `OnActivatedRecipe` — 조건부 저장 + 스킵 로그 ```csharp if (string.Equals(activeRecipe.Name, selectedNameOnActivate, StringComparison.OrdinalIgnoreCase)) { RecipeHelper.SaveRecipe(activeRecipe.Name); } else { Log.W($"[레시피 덮어쓰기 차단] OnActivatedRecipe 자동저장 스킵. ..."); } ``` > **왜 완전 삭제가 아닌가** — `OnRecipeEntryChanged` 의 **Copy/Rename 분기가 비어 있다**(`:118-121`). > 이 저장이 새 레시피 폴더의 TeachingEditor 파일 생성을 겸했을 가능성이 있고, 파일이 없으면 > `ReloadRecipe` 의 `throw;`(`RecipeHelper.cs:116`)가 Rx `Subscribe` 안에서 터져 > **이후 `SYS_RECIPE_ACTIVATED` 를 영구히 못 받는다.** 레시피 전환이 통째로 죽는 모드다. ### ② `PreSaved` — 활성≠선택이면 저장 차단 + 모달 `return false` 로 저장을 막고, 사유와 조치 방법을 담은 확인 모달을 띄운다. ### ③ `OnSaved` — 동일 검사 2중 방어 + 모달 `PreSaved` 를 뚫고 온 경우를 대비. **조용히 스킵하면 사용자가 "저장됐다"고 오인**하므로 반드시 알린다. **`RecipeHelper`·`MessageUtil` 무수정, 신규 파일 없음, 컴파일 영향 없음.** --- ## 8. 현장 검증 (2026-09-04) | 항목 | 결과 | |---|---| | 활성≠선택 상태에서 저장 → 모달 + 저장 차단 | 🟦 **정상 동작 확인** | | 모달에 선택/활성 값 표시 | 🟦 정상 (`FindStrToken` 통과 확인) | | **`PreSaved`=false 시 Aurora 가 실제로 저장 중단** | 🟦 **확증** — 소스 부재로 추정만 하던 사항 | | 평소 저장(활성=선택)이 막히지 않는지 | 🟨 미확인 | | 레시피 복사·이름변경 → 활성화 | 🟨 미확인 (§9-2 재평가로 **우선순위 하향**) | | 자동 사이클 페이지 전환 | 🟨 미확인 | > `PreSaved` 가 `false` 를 반환하면 Aurora 가 **`Save Failed` 모달을 추가로 띄운다** > (`AuroraUIComponent.ShowSaveFailedDialog`). 모달이 2개 뜨지만, 우리 것은 *사유와 조치*를 > Aurora 것은 *실패 사실*을 알리므로 역할이 겹치지 않아 현행 유지하기로 했다. --- ## 9. 남아 있는 위험 🟨 1. **오탐** — `IRecipeViewModel.OnChanged`(`:163`)는 `SelectedRecipe` 만 바꾸고 활성은 건드리지 않는다. Aurora 가 **활성화 없이 뷰어만 전환**하는 흐름을 가졌다면, 정상 상황인데도 저장이 막힌다. 막히면 판정 조건을 **로드 파일 신원**(`DB.Recipe.Recipe.SelectedRecipeFile`) 기준으로 바꾸면 된다. 그 비교 코드는 `RecipeHelper.cs:48-55` 에 `[관측]` 로그로 **이미 들어가 있다**(차단만 안 할 뿐). 2. **Copy/Rename 분기 공백** — 위 §7 ① 참조. 아래 2.1 에서 재평가했다. ### 9.1 파일 부재 시 예외는 확실히 던져진다 🟩 `DB.Recipe.Recipe.Load(string)` (`LASA/GUI/DB/Recipe.cs:42-73`) 는 `.json` 이면 `File.ReadAllText`, 아니면 `new StreamReader` 로 읽고 **`catch` 에서 `throw ex;` 로 재전파**한다. `MainPage.ReloadRecipe` 는 잡지 않고 `RecipeHelper.ReloadRecipe:116` 도 `throw;` 로 다시 던진다. `OnActiveReloaded` 호출부가 전부 Rx `Subscribe` 안이므로(`UCMainAutoRunViewModel:174`, `EquipmentSetup:286`, `RMSHandlerForPTOP:64`) **구독이 종료된다.** ### 9.2 종전 코드는 파일 존재를 "보증"하고 있었다 🟩 `OnActivatedRecipe` 의 순서가 이렇다. ``` ① SaveRecipe(활성) ← 파일 생성 (GetTeachingEditorFullPath 가 폴더도 만듦) ② EVENT_CHANGE_RECIPE_INFO 발행 ③ 구독자가 ReloadRecipe 수행 ``` **저장이 reload 보다 먼저다.** 즉 무조건 저장은 교차오염의 원인이면서 동시에 **파일 생성 보증**을 겸하고 있었고, 이번 조건부 변경이 그 보증을 걷어냈다. ### 9.3 그럼에도 위험도를 낮게 본 이유 🟨 남은 미지수는 **"Aurora 가 Copy/Rename 시 레시피 폴더를 통째로 복사하는가"** 하나뿐이다. 복사한다면 파일이 이미 있으므로 문제가 성립하지 않는다. - **Aurora 는 레시피를 폴더 단위로 관리하므로 폴더째 복사하는 것이 정상 동작이라고 판단**했다 (2026-09-04 협의). 다만 **Aurora 소스가 없어 코드로 확인한 것은 아니다 — 합리적 추정이다.** - 설령 예외가 나더라도 **재기동하면 구독이 다시 걸려 복구된다.** 데이터 손상이 아니라 그 세션 동안 레시피 전환만 막히는 상태다. - 위 두 가지로 "발생 시 즉시 중단" 에서 **일반 검증 항목으로 하향**했다. > 선제 방어(조건에 "대상 파일 없으면 저장 허용" 을 OR 로 추가)는 **넣지 않기로 했다.** > `GetTeachingEditorFullPath` 가 `private` 이라 공용 `RecipeHelper` 를 손대야 하는데, > 비용 대비 이득이 크지 않다고 판단했다. 필요해지면 이 방법이 가장 작은 조치다. 3. **근본 원인 미해결** — `SaveRecipe` 가 목적지와 내용을 묶어 검증하지 않는다. 가드를 안 거치는 S3(신규생성)·S4(RMS)에는 같은 구멍이 남는다. 근본 대응은 `SaveRecipe` 에 불변식을 넣는 것인데, S3 가 **의도적으로** 신원을 위반하므로 (`includeModel:false` 로 현재 메모리를 새 파일에 씀) `allowCrossIdentity` 같은 의도 인자 분리가 필요하다. 4. **진단계측이 미연결** — `WeldingCommon/Utils/RecipeTrace.cs` 의 `BeforeSave`/`AfterSave` 가 저장 전후 **파일 MD5** 로 교차오염을 판정하도록 완성돼 있으나 **호출이 0건**이다. 2줄이면 살아난다. --- ## 10. 로그 grep 키 | 문자열 | 의미 | |---|---| | `[레시피 덮어쓰기 차단] OnActivatedRecipe 자동저장 스킵` | 정상. 활성 전환마다 나온다 | | `[레시피 덮어쓰기 차단] PreSaved 저장 차단` | 평소 저장에서 나오면 **오탐** | | `[레시피 덮어쓰기 차단] OnSaved 저장 스킵` | Aurora 가 PreSaved 를 무시함. 이상 상황 | | `[관측] 메모리/대상 불일치` | 기존 계측. **가드 미경유 경로**(S3·S4)의 어긋남 | | `*** RELOAD 실패 ***` | §9-2 위험이 실재. 이후 레시피 전환 불가 가능 | --- ## 11. 운영 지침 - **레시피는 항상 "액티브된 레시피 = 선택된 레시피" 상태로 두고 사용한다.** (사수 지시) - **AutoRun 중 Recipe 화면의 레시피 목록을 클릭하지 말 것.** 조회가 아니라 활성 변경이며 저장을 유발한다. - drawing 객체는 변경 이력에 남지 않는다(`RchHandler` 는 ScannerParam 6필드만 기록). **덮어써지면 복구 경로가 없다** — OS 이전 버전(VSS) 또는 수동 백업뿐이다. --- ## 변경 이력 | 일자 | 내용 | 작성 | |---|---|---| | 2026-09-04 | 최초 작성. 조사 결과 + 3블록 차단 조치 적용(`bd6a7cd`), 현장 차단 동작 검증 | 노도연 |