# 레시피 저장 교차 오염 — 활성 레시피 ↔ 선택 레시피 상태 불일치 원인 분석
- 작성일: 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장 상단의 작업지시서 참조.