# 레시피 저장 교차 오염 — 활성 레시피 ↔ 선택 레시피 상태 불일치 원인 분석 - 작성일: 2026-08-03 - 계기: 2026-08-01 현장. AutoRun 시퀀스 도중, 구(舊) 캘 레시피가 활성인 상태에서 신규 캘 레시피를 복사해 **선택 레시피**로 페이지 생성·Draw·저장했는데, 뷰를 이동했다 돌아오니 **활성 레시피의 페이지가 선택 레시피에 덮어써진** 현상 - 대상 소스: - `WeldingComponents/WeldingCommon/Utils/RecipeHelper.cs` - `WeldingComponents/WeldingCommon/Views/Recipes/UCScannerObjectTeachingViewModel.cs` - `Machines/ILSOS_PrismaticRD_PTOP/Recipes/RMSHandlerForPTOP.cs` - `Machines/ILSOS_PrismaticRD_PTOP/Views/Main/UCMainAutoRunViewModel.cs` - `Machines/ILSOS_PrismaticRD_PTOP/Sequences/SeqAutoRunPTOP.cs` - `Machines/ILSOS_PrismaticRD_PTOP/EquipmentSetup.cs` - `WeldingComponents/LASA/GUI/Page/MainPage.cs`, `.../DB/Recipe.cs`, `.../CoreUnit/.../Canvas/Model.cs` - 상태: **원인 구조 확정 (소스 수정 없음 — 분석만)** - 관련: [FieldHelper 격자배율차이 분석](../01/FieldHelper_격자배율차이_용접위치오차_원인분석.md) — 여기서 말하는 "구 캘 레시피"의 정체(격자 2.406% 차이) --- ## 0. 결론 LASA 티칭 편집기는 **프로세스 전역 단일 캔버스** 하나뿐인데, "지금 캔버스에 무엇이 로드돼 있는가"와 "저장 버튼을 누르면 어디에 쓸 것인가"가 **서로 다른 두 개의 전역 상태**로 관리되고, **둘 중 하나만 갱신하는 코드 경로가 양쪽 다 존재한다.** | 상태 | 의미 | 위치 | |---|---|---| | `DB.Dao.SystemRecipe.WorkRecipeFile` | 캔버스에 **실제 로드된** 레시피 파일 | `MainPage.ReloadRecipe`(MainPage.cs:466)에서만 갱신 | | `RecipeHelper.SelectedRecipe` | 저장 시 **쓸 대상** 레시피 (id, name) | `RecipeHelper.cs:18` — static | `RecipeHelper.SaveRecipe(name)`은 **캔버스 내용이 그 레시피인지 전혀 검증하지 않고** `name` 폴더에 통째로 덮어쓴다 (`RecipeHelper.cs:36-39` → `MainPage.cs:425-434` → `Recipe.cs:79-120`). 즉 **두 상태가 어긋난 순간에 저장이 일어나면 무조건 남의 레시피 파일이 파괴된다.** 핵심 결함 3개: 1. **`SelectedRecipe`를 건드리지 않고 캔버스만 바꾸는 경로** — `RMSHandlerForPTOP.OnUpdateCurrentActivatedRecipe`(RMSHandlerForPTOP.cs:62-66) → 이후 저장하면 **활성 레시피 내용이 선택 레시피 파일로 기록됨**. ← 이번 현장 증상과 방향 일치 2. **캔버스를 건드리지 않고 `SelectedRecipe`만 활성 레시피로 바꿔치기하는 경로** — `RecipeHelper.OnActiveReloaded()` (SeqAutoRunPTOP.cs:425, UCMainAutoRunViewModel.cs:172/247, EquipmentSetup.cs:279, VM 생성자 :71). Aurora 레시피 화면의 콤보박스는 이 변경을 **모른다** → 화면 표시와 저장 대상이 어긋남 3. **레시피 활성화 순간, 편집 중이던 캔버스를 새 활성 레시피 파일에 그대로 써버림** — `UCScannerObjectTeachingViewModel.OnActivatedRecipe`(:156) `RecipeHelper.SaveRecipe(activeRecipe.Name)` — 사용자가 저장을 누른 적이 없는데 파일이 바뀐다 --- ## 1. 구조 전제 — 캔버스는 하나뿐이다 ``` MainPage(WinForms, XAML의 ) └ ScannerCanvasCtrlTeaching.Canvas.Model ← 유일한 편집 대상 ↑ 참조 고정 DB.Dao.WeldingLinesRecipe.Model (MainPage.cs:175) ``` - `CommonHelper.SetMainPage(MainPageView)` (UCScannerObjectTeaching.xaml.cs:35) → 전역 1개 (`CommonHelper.cs:10`) - `SaveRecipe(path)` = **라이브 캔버스를 path에 직렬화** (`Recipe.cs:79`, `RecipeWeldingLines.Save`) - `ReloadRecipe(path)` = **라이브 캔버스를 path 내용으로 전면 교체** (`Model.Load` → `m_layers.Clear()` 후 재구성, Model.cs:681-731 — 병합이 아니라 완전 치환) 따라서 "레시피 A 편집 중"과 "레시피 B 편집 중"은 **동시에 존재할 수 없고**, 전환은 오직 `ReloadRecipe` 호출 하나로만 이뤄진다. 전환을 놓치면 곧바로 오염이다. --- ## 2. 저장/로드 경로 지도 — 어떤 경로가 무엇을 갱신하나 | # | 경로 | 캔버스 | `SelectedRecipe` | 위험 | |---|---|:---:|:---:|---| | 1 | `IRecipeViewModel.OnChanged(recipe)` | ← recipe | ← recipe | 정상 (둘 다 갱신) | | 2 | `IRecipeViewModel.Reloaded(recipe)` | ← recipe | ← recipe | 정상 | | 3 | `RecipeHelper.OnActiveReloaded()` | ← **활성** | ← **활성** | 사용자 UI 선택을 무시하고 활성으로 강제 | | 4 | `RMSHandlerForPTOP.OnUpdateCurrentActivatedRecipe()` | ← **활성** | **그대로** | ★ **불일치 발생** | | 5 | `UCScannerObjectTeachingViewModel.OnActivatedRecipe()` | (아래 참조) | **그대로** | ★ 활성 파일에 캔버스를 써버림 | | 6 | `OnRecipeEntryChanged` New 분기 (:110-115) | 그대로 | ← 신규 | ★ 캔버스를 신규 레시피에 즉시 저장 | | 7 | `OnSaved(id, recipeName)` (:202-238) | 그대로 | 그대로 | ★ **인자 `id`/`recipeName`을 무시**하고 `SelectedRecipe`로 저장 | ### 2-1. 경로 4 — 불일치를 만드는 지점 ```csharp // RMSHandlerForPTOP.cs:56-66 aurora.EventObservable.AsObservable(CommonEventIds.EVENT_CHANGE_RECIPE_INFO) .ObserveOnDispatcher() .Select(param => (RecipeInfo)param.Param) .Subscribe(OnUpdateCurrentActivatedRecipe); private void OnUpdateCurrentActivatedRecipe(RecipeInfo info) { RecipeHelper.ReloadRecipe(info.Name); // 캔버스 = 활성 레시피 UploadActivatedRecipeParameter(); // SelectedRecipe 는 손대지 않음 } ``` `EVENT_CHANGE_RECIPE_INFO`는 `OnActivatedRecipe`(:159)와 `OnSaved`(:234)가 발행한다. 즉 **레시피가 활성화될 때마다** 캔버스는 활성 레시피로 바뀌지만 `SelectedRecipe`는 사용자가 고른 값 그대로 남는다. 이 상태에서 저장 버튼 → `SaveRecipe(SelectedRecipe.name)` → **활성 레시피의 페이지가 선택 레시피 파일에 기록.** ### 2-2. 경로 5 — 저장 버튼 없이 파일이 바뀐다 ```csharp // UCScannerObjectTeachingViewModel.cs:155-160 var activeRecipe = _RecipeManager.ActivatedRecipe(); RecipeHelper.SaveRecipe(activeRecipe.Name); // ← 편집 중이던 캔버스를 활성 레시피에 덮어씀 _Aurora.EventPublisher.Publish(new EventMessage("OnActivatedRecipe", CommonEventIds.EVENT_CHANGE_RECIPE_INFO, activeRecipe)); ``` 레시피 활성화(SYS_RECIPE_ACTIVATED)는 **PLC/AutoRun이 자동으로 일으킨다**(SeqAutoRunPTOP.cs:403). 사용자가 B를 티칭하던 중에 시퀀스가 A를 활성화하면, **B의 작업물이 A 파일에 저장**된다(A 파괴). 그 직후 경로 4가 A를 캔버스로 로드하므로, 화면은 그대로인 듯 보이지만 **파일 두 개가 이미 섞였다.** ### 2-3. 경로 7 — Aurora가 알려준 대상을 버린다 ```csharp public void OnSaved(int id, string recipeName) // ← Aurora가 "지금 저장 중인 레시피"를 넘겨줌 { ... RecipeHelper.SaveRecipe(RecipeHelper.SelectedRecipe.name); // ← 그런데 static 값을 씀 ``` `Aurora.xml:4024`의 계약상 `id`/`recipeName`이 **정답**이다. 이 둘을 무시하고 static을 쓰는 순간, 위 1~6 어느 경로로든 static이 오염되면 저장이 엉뚱한 곳으로 간다. `PreSaved`(:181)도 마찬가지로 인자를 검증에 쓰지 않는다. --- ## 3. 재현 시나리오 (2026-08-01 현장과 일치) 전제: 활성 = **A**(구 캘, MOTORPOS 격자 2.4% 다른 동탄 레시피), 신규 복사본 = **B** | 단계 | 동작 | 캔버스 | `SelectedRecipe` | A 파일 | B 파일 | |---|---|---|---|---|---| | 1 | 사용자가 콤보에서 B 선택 → `OnChanged(B)` | B | B | A | B | | 2 | B에 페이지 추가 + Draw + 저장 → `OnSaved` → `SaveRecipe(B)` | B | B | A | **B(신규 작업물)** | | 3 | AutoRun 시퀀스가 `ActivateRecipe(A)` (SeqAutoRunPTOP.cs:403)
→ `OnActivatedRecipe` → `SaveRecipe(A)` (:156) | B | B | **A ← B 내용으로 오염** | B | | 4 | 같은 핸들러가 `EVENT_CHANGE_RECIPE_INFO` 발행 (:159)
→ 경로 4 `ReloadRecipe(A)` | **A** | **B (미갱신)** | A | B | | 5 | 뷰 이동 후 Recipe 화면 복귀 — 화면에는 **A의 페이지**가 보임
(콤보는 여전히 B) | A | B | A | B | | 6 | 저장 → `SaveRecipe(SelectedRecipe.name = B)` | A | B | A | **B ← A의 페이지로 덮어써짐** | → 6단계가 보고된 증상 그대로다. **"활성화된 레시피의 페이지가 선택된 레시피에 덮어써진다."** ### 3-1. 왜 하필 "뷰 이동 후 복귀"가 트리거인가 캔버스는 뷰를 떠나도 파괴되지 않는 전역 WinForms 컨트롤이다. 따라서 **4단계에서 이미 캔버스가 A로 바뀌어 있었지만**, 사용자는 다른 메뉴에 있어서 보지 못했다. Recipe 화면으로 돌아와 처음 눈으로 확인하는 순간이 5단계이므로, 사용자에겐 "뷰를 이동하고 돌아오니 바뀌어 있더라"로 관측된다. **뷰 이동 자체가 원인은 아니다.** 추가로 뷰가 재생성되는 경우 `UCScannerObjectTeachingViewModel` 생성자(:71)의 `RecipeHelper.OnActiveReloaded()`가 다시 돌면서 **선택/캔버스를 모두 활성 레시피로 리셋**하므로, 복귀 시점에 활성 레시피 화면이 뜨는 결과는 어느 쪽이든 동일하다. --- ## 4. 로그 근거 (`docs/분석/Aurora_20260731.log`) 전날 로그에 동일 구조가 그대로 찍혀 있다. ``` 2471 18:24:06.658 SeqAutoRunPTOP: [CheckRecipeNoComp] ActivateRecipe (12 -> 21) 2475 18:24:06.704 UCScannerObjectTeachingViewModel: SYS_RECIPE_ACTIVATED OnActivatedRecipe 2477 18:24:06.754 UCScannerObjectTeachingViewModel: CommonEventIds.EVENT_CHANGE_RECIPE_INFO from [OnActivatedRecipe] 2566 18:24:13.120 UCScannerObjectTeachingViewModel: SYS_RECIPE_ACTIVATED OnActivatedRecipe 2568 18:24:13.170 ... EVENT_CHANGE_RECIPE_INFO from [OnActivatedRecipe] 2685 18:25:49.144 ... EVENT_CHANGE_RECIPE_INFO from [OnSaved] RecipeName [U-Frame_Pre_Welding] 2686 18:25:49.144 ... Saved Recipe : [U-Frame_Pre_Welding] ``` - 2471–2477: **AutoRun 시퀀스가 자동으로 레시피를 전환** → 3~4단계 경로가 실제로 발동함이 확인된다. 이때 `SaveRecipe(activeRecipe.Name)`(:156)은 로그를 남기지 않으므로 **파일이 조용히 바뀐다.** - 2685: `[OnSaved]`에서 이벤트가 발행됐다는 건 `SelectedRecipe.id == activeRecipe.Id`였다는 뜻(:230). 사용자가 명시적으로 그 레시피를 고른 적이 없어도, 경로 3(`OnActiveReloaded`)이 `SelectedRecipe`를 활성으로 바꿔놓았기 때문에 성립한다. > 2026-08-01 당일 `Aurora_20260801.log`가 현장 데이터에 포함돼 있지 않다. > 위 6단계 중 **어느 저장이 최종적으로 B를 덮었는지**(3단계 자동저장인지 6단계 수동저장인지) 확정하려면 당일 로그의 > `Saved Recipe : [...]` / `SYS_RECIPE_ACTIVATED` 시각 순서가 필요하다. 확보되면 이 절에 덧붙일 것. --- ## 5. 부수 결함 (같은 뿌리, 별건으로 터질 수 있음) ### 5-1. 신규 레시피 생성 시 남의 내용이 즉시 기록됨 ```csharp // UCScannerObjectTeachingViewModel.cs:110-115 if (param.AddedId != -1) { // New RecipeHelper.SelectedRecipe = (recipe.Id, recipe.Name); RecipeHelper.SaveRecipe(recipe.Name, includeModel: false); // 현재 캔버스를 신규 레시피에 기록 } else if (param.CopiedToId != -1 && param.CopiedFromId != -1) { //Copy — 비어 있음 } ``` `includeModel: false`라 도형(Layer)은 안 쓰이고 **용접 파라미터만** 기록되지만, 그 파라미터는 "그 순간 캔버스에 로드돼 있던 레시피"의 것이다. 신규/복사본이 **엉뚱한 파라미터로 출발**한다. 현장 데이터의 `Copy__U-Frame_Welding`이 이 경로를 탔는지 확인 필요. ### 5-2. `RollbackRecipe()`가 의도대로 동작하지 않음 ```csharp // RecipeHelper.cs:102-110 //AutoRun 완료 후 다시 Teching 중인 Recipe로 Rollback 해줘야 함 public static void RollbackRecipe() => Dispatcher.InvokeAsync(() => ReloadRecipe(SelectedRecipe.name)); ``` 주석의 의도는 "티칭하던 레시피로 복귀"인데, AutoRun 진입 시 경로 3이 이미 `SelectedRecipe`를 **활성 레시피로 덮어썼기 때문에** 롤백은 활성 레시피로 되돌아갈 뿐이다. **사실상 no-op.** (호출: UCMainAutoRunViewModel.cs:442. 그리고 Auto→Manual 복귀 경로인 EquipmentSetup.cs:282는 **주석 처리**되어 아예 호출되지 않는다.) ### 5-3. Auto 진입 시 무조건 선택 레시피가 날아감 ```csharp // EquipmentSetup.cs:276-283 if (status == eEqpStatus.Auto) { ... RecipeHelper.OnActiveReloaded(); // SelectedRecipe := 활성 } else { //RecipeHelper.RollbackRecipe(); // ← 복귀 없음 } ``` Auto 한 번 들어갔다 나오면 **사용자가 고른 레시피 컨텍스트는 영구 소실**된다. ### 5-4. `bit15` Resume 경로도 동일 `UCMainAutoRunViewModel.cs:246-247` — 팝업 후 `ActivateRecipe` + `OnActiveReloaded()`. 이 경로는 **작업자가 Manual에서 티칭하도록 유도하는 흐름**이므로, 티칭 시작 직전에 선택 상태를 갈아엎는 셈이라 가장 위험하다. 이번 현장 상황("AutoRun 시퀀스 도중, 활성 레시피가 있음에도 다른 레시피에서 작업")과 정황이 정확히 맞는다. --- ## 6. 수정 방향 (제안 — 미적용) 우선순위 순. ### A. 저장 대상을 인자로 고정 (최소 변경, 효과 큼) ```csharp public void OnSaved(int id, string recipeName) { // Aurora가 알려준 대상만 신뢰 RecipeHelper.SaveRecipe(recipeName); ``` `PreSaved(id, recipeName)`에서 **"캔버스에 로드된 레시피 ≠ 저장 대상"이면 저장 거부**를 추가. 비교 기준은 `DB.Dao.SystemRecipe.WorkRecipeFile` (캔버스의 진짜 출처). ```csharp // PreSaved 안 var loaded = Path.GetFileName(Path.GetDirectoryName(DB.Dao.SystemRecipe.WorkRecipeFile)); if (loaded != recipeName) { MessageUtil.ShowErrorToast($"화면에 로드된 레시피({loaded})와 저장 대상({recipeName})이 다릅니다."); return false; } ``` 이 가드 하나만 넣어도 **이번 사고는 저장 시점에 차단**된다. ### B. 캔버스와 `SelectedRecipe`를 한 함수로만 바꾸도록 통합 `RecipeHelper`에 `SetCurrentRecipe(int id, string name)` 하나만 두고, 내부에서 `SelectedRecipe` 대입 + `ReloadRecipe`를 **항상 같이** 수행. 경로 4(`RMSHandlerForPTOP.OnUpdateCurrentActivatedRecipe`)의 `ReloadRecipe(info.Name)` 단독 호출을 제거. ### C. 자동 저장 제거 `OnActivatedRecipe`의 `RecipeHelper.SaveRecipe(activeRecipe.Name)`(:156) 삭제 또는 **"캔버스가 이미 그 활성 레시피일 때만"** 저장하도록 가드. 사용자가 누르지 않은 저장이 파일을 바꾸는 것은 어떤 경우에도 정당화되지 않는다. ### D. 티칭 중 자동 레시피 전환 차단 `eEqpStatus.Manual` + Recipe 화면 활성 상태에서는 PLC/시퀀스발 `ActivateRecipe`가 캔버스를 갈아엎지 않도록 큐잉하거나, 최소한 **작업자에게 확인 팝업**. --- ## 7. 확인 필요 사항 1. **2026-08-01 `Aurora_20260801.log` 확보** — 3~6단계 중 실제 발동 순서 확정 (§4 참고) 2. **Aurora 내부 `UCRecipe`가 뷰 재진입 시 `Reloaded()`를 호출하는지** — 호출한다면 5단계에서 자동 복구되므로, 실제 오염 시점이 3단계(자동 저장) 쪽으로 좁혀진다. Aurora.dll 소스가 없어 코드로는 확인 불가 → **현장 재현 시 로그로 판별** 3. **`Copy__U-Frame_Welding`의 WeldingParameters 출처** — §5-1 경로를 탔는지 원본 `U-Frame_Welding`과 대조 4. 손상 가능성이 있는 레시피 파일 목록 정리 후 `_config_recipe/backup/` 과 대조 (백업 시점이 사고 이전인지 먼저 확인) --- ## 8. 코드 위치 요약 | 항목 | 위치 | |---|---| | `SelectedRecipe` static | `WeldingCommon/Utils/RecipeHelper.cs:18` | | `SaveRecipe` (검증 없이 덮어씀) | `RecipeHelper.cs:36-39` → `MainPage.cs:425-434` → `Recipe.cs:79-120` | | `ReloadRecipe` (캔버스 전면 교체) | `RecipeHelper.cs:64-69` → `MainPage.cs:460-486` → `Model.cs:681-731` | | `OnActiveReloaded` (선택=활성 강제) | `RecipeHelper.cs:73-79` | | `RollbackRecipe` (사실상 no-op) | `RecipeHelper.cs:105-110` | | 활성화 시 자동 저장 | `UCScannerObjectTeachingViewModel.cs:155-160` | | 신규 생성 시 자동 저장 | `UCScannerObjectTeachingViewModel.cs:110-115` | | `OnSaved` 인자 무시 | `UCScannerObjectTeachingViewModel.cs:202-238` (저장은 :208) | | 캔버스만 교체(불일치 생성) | `RMSHandlerForPTOP.cs:62-66` | | 시퀀스발 레시피 전환 | `SeqAutoRunPTOP.cs:403-426` | | AutoRun 시작/정지 시 갱신 | `UCMainAutoRunViewModel.cs:169-176, 324, 442` | | bit15 Manual 전환 경로 | `UCMainAutoRunViewModel.cs:240-249` | | Auto 진입 시 강제 갱신 | `EquipmentSetup.cs:276-283` | | 캔버스 단일 인스턴스 | `CommonHelper.cs:10-23`, `UCScannerObjectTeaching.xaml.cs:35`, `MainPage.cs:175` | --- ## 9. [별건] AutoRun 정지 시 에코 워드(PC_RECIPE_NO/PC_PAGE_NO) 초기화 여부 - 계기: 2026-08-03 검토 중 제기 — *"`OnSeqActionStop()`에서 워드 초기화를 안 하는데, 레시피 NUM·PAGE NUM은 PLC가 이미 주니까 PC측은 초기화해도 되지 않나"* - 본 문서 본론(레시피 **파일** 오염)과는 **원인이 다른 별건**이나, **bit15 Resume 경로·AutoRun 정지 처리**라는 같은 구간을 공유하므로 여기에 함께 기록한다. - 상태: **분석만 — 소스 수정 없음** - 작업안: [작업지시서 2026-08-03 에코 워드 정지 시 초기화 정리](../../../../작업지시서/2026-08-03_에코워드_정지시_초기화_정리.md) ### 9-1. 현황 — `OnSeqActionStop`은 초기화하지 않는다 (사실) ```csharp // SeqAutoRunPTOP.cs:1209-1212 // 260512 워드 // 260621: 에코 워드(PC_PAGE_NO/PC_RECIPE_NO)는 정지 시 0으로 지우지 않음 — PLC가 정합 확인에 쓰는 // 마지막 핸드셰이크 값을 보존(다음 사이클에 어차피 재에코). 기존 0 초기화 2줄 제거. Log.I("[OnSeqActionStop] PLC IO Output Word Init"); // ← 실제 동작 없이 문구만 남음 ``` 260621에 초기화 2줄이 제거되고 **로그 문구만 남았다.** 로그만 보면 초기화한 것처럼 보이는 함정. ### 9-2. 그러나 실제로는 이미 지워지고 있다 — `SeqStatusMonitor` ```csharp // SeqStatusMonitor.cs:94-109 bool plcAutoOff = ChangeChecker.IsChangedBoolMonitoring("PlcAutoStateOff", _plc.ReadBit(eBitInput.PLC_AUTO_STATUS).data, false); // M70501 하강엣지 bool pcAutoOff = ChangeChecker.IsChangedBoolMonitoring("PcAutoStateOff", _plc.ReadOutBit(eBitOutput.PC_AUTO_STATUS).data, false); // M70001 하강엣지 bool weldReadyAlive = _plc.ReadBit(eBitInput.PLC_WELD_READY_REQ).data; // M70515 if (plcAutoOff || (pcAutoOff && !weldReadyAlive)) { _plc.WriteWord(eWordOutput.PC_RECIPE_NO, 0); _plc.WriteWord(eWordOutput.PC_PAGE_NO, 0); } ``` `OnSeqActionStop:1200`이 `PC_AUTO_STATUS = false`를 쓰므로 → StatusMonitor 루프(3노드 × `NODE_SLEEP` 20ms ≈ 60ms 주기)가 다음 스캔에 `pcAutoOff` 하강엣지를 감지 → **M70515가 안 살아있으면 워드를 0으로 지운다.** | 정지 유형 | M70515(bit15) | 에코 워드 실제 결과 | |---|:---:|---| | 일반 정지 · 알람 정지 · `OnForceError` | OFF | **StatusMonitor가 0으로 초기화** | | bit15 Resume 정지 (`ShowRecipePageConfirmAndSwitchManual`) | ON | **보존** ← 이 케이스만 | → **"PC측은 초기화를 안 한다"는 bit15 구간에만 해당**하고, 나머지 정지 경로는 이미 초기화되고 있다. ### 9-3. "PLC가 이미 주니 PC측은 초기화해도 된다" — 타당함 **① PC는 자기 에코를 되읽지 않는다.** 소스 전체에서 `PC_RECIPE_NO`/`PC_PAGE_NO`는 `WriteWord`만 존재하고 읽는 곳이 없다. 판정은 항상 **PLC 입력 워드**로 한다 — `CheckRecipeNoComp:349`(D70502 `PLC_RECIPE_NO`), `CheckPageNoComp:571`(D70501 `PLC_PAGE_NO`). → **PC 내부 로직은 에코 워드 값에 전혀 의존하지 않는다. 0으로 지워도 PC 동작 불변.** **② 재진입 시 반드시 재에코된다.** - `CheckRecipeNoComp` — 동일 레시피여도 :395에서 에코 유지. :368 주석이 정확히 그 이유를 적어둠 ("정지 시 D70002=0으로 지우므로, 동일 레시피 재진입에서 에코를 건너뛰면 PLC가 0/이전값을 봄") - `CheckPageNoComp` — :563에서 매 페이지 무조건 에코 **③ 260621에 보존으로 바꾼 원래 근거가 현재 코드에서는 성립하지 않는다.** - 당시 전제(주석 :131, :260): *"bit15 Resume은 핸드셰이크를 스킵하니 에코를 보존해야 한다"* - 현재 `ResumeMode`의 시퀀스 내 소비처는 **`CheckCycleStop:872-877` 한 곳뿐**이고, 하는 일은 `PC_WELD_READY_ACK` OFF + 플래그 해제다. **핸드셰이크를 스킵하지 않는다.** - `OnAutoRunResume:283`은 `RunSequenceAutoRun()`으로 **시퀀스를 처음부터 재시작**하며, :284 로그도 `"Auto 재개(정상 핸드셰이크)"`라고 적는다(옵션A). - → **주석(:131, :260 "핸드셰이크 스킵")과 실제 구현이 어긋나 있다.** 옵션A로 바뀌며 스킵은 사라졌는데 보존 로직·주석만 남은 것으로 보인다. ### 9-4. 남은 리스크 — 소비자는 PC가 아니라 PLC 래더 | 가정 | PLC 로직 | 0 초기화 시 영향 | |---|---|---| | **A** | `D70002 == D70502`(에코↔지령)를 **상시 레벨로** 인터락에 사용 | 정지 중 불일치 → 기동 금지/알람 가능 | | **B** | `PC_PAGE_CHANGE_COMPLETE`(M70008) 등 **ACK 비트와 함께 1회만** 확인 | 정지 중 값은 무의미 → **무해** | - 현재 PC 코드는 **B 전제**로 작성돼 있다(`CheckPageNoComp:563→573` — 에코 직후 ACK 비트를 세우는 구조). - 결정적 방증: **일반 정지에서는 이미 StatusMonitor가 0으로 지우고 있고, 현장에서 이로 인한 문제 보고가 없다.** → "0으로 지워도 되는가"는 **대부분의 정지 경로에서 이미 실증되고 있다.** 미검증 구간은 bit15 정지뿐. - 최종 확인 필요: **PLC 담당자에게 "D70001/D70002를 상시 비교하는 인터락이 있는지"** 1줄 확인. ### 9-5. 결론 | # | 선택지 | 동작 변경 | 리스크 | |---|---|:---:|---| | 1 | `OnSeqActionStop`에 0 초기화 2줄 복원 | 있음 | StatusMonitor와 **중복**. `OnSeqActionStop`엔 M70515 예외가 없어 bit15 보존이 무력화 → 실질적으로 3번과 동일해짐 | | **2** | **초기화 책임을 StatusMonitor로 일원화 명시 — 오해 소지 로그·죽은 주석만 정리** | **없음** | **없음** (권장 시작점) | | 3 | bit15 보존 예외 제거 (`StatusMonitor:104`를 `pcAutoOff`로 단순화) | 있음 | 9-3 ③ 근거로 논리적 타당. **실제 동작이 바뀌는 유일한 안** → bit15 정지 중 D70001/D70002=0에서 PLC가 Resume을 수락하는지 현장 검증 필요 | 세 선택지는 **독립적으로 적용 가능**하며, 전부 적용할 필요는 없다. 상세 절차는 9장 상단의 작업지시서 참조.