LGES-OC5-PtoP-Welder/docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측.md
2026-08-06 11:29:00 +09:00

727 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 작업지시서 — Laser Recipe Save 교차 오염 **진단 계측** (2026-08-04)
| 항목 | 내용 |
|---|---|
| 대상 | LGES-OC5-PtoP-Welder / ILSOS_PrismaticRD_PTOP |
| 분류 | **진단 전용 계측** (기능 수정 아님) |
| 선행 문서 | `docs/작업지시서/작업지시서_2026-08-03_Laser_Recipe_Save_교차오염.txt` |
| 원본 분석 | `docs/분석/2026/08/03/레시피저장_활성vs선택_교차오염_원인분석.md` |
| 상태 | **적용 완료 + 빌드 성공 (2026-08-04, 브랜치 `feature/ubisam-pptx-integration`, 미커밋)**. 현장 검증 대기. |
| 원복 문서 | `docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측_원복.md` |
> **적용 시 실제 소스에는 원복용 마커가 추가되었습니다.** 본 문서의 `[변경안]` 코드 블록에는 마커가 없으나,
> 소스에는 삽입 구간마다 아래 형태의 시작/끝 주석이 들어가 있습니다. 검색 한 번으로 전부 찾을 수 있습니다.
>
> ```
> [진단계측 2026-08-04]
> ```
>
> 각 블록 머리 주석에 **원본 코드가 그대로 적혀 있어** 그것만 보고도 되돌릴 수 있습니다.
> `RecipeHelper.cs` 에 3쌍, `WeldingCommon.csproj` 에 주석 1줄이 있습니다.
## 이 작업의 목적
**저장을 막지 않습니다.** 레시피 신원이 **언제 어긋나는지**, Save 로 **무엇이 무엇으로 바뀌는지**를 데이터로 확정하기 위한 계측만 넣습니다.
원인 경로가 확정되면 선행 문서의 2~4단계 중 필요한 것만 적용하고, **본 계측은 통째로 제거**합니다.
> [!] 경고 1 — `WeldingComponents` 하위를 수정합니다. 프로젝트 규약상 사전 승인 대상입니다.
> [!] 경고 2 — 한글 포함 파일은 **UTF-8 인코딩을 유지**하고 편집하십시오. 새 인코딩으로 저장 금지.
> [!] 경고 3 — 작업 전 `D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\_config_recipe` 폴더를 통째로 백업하십시오.
> 본 계측은 **저장을 막지 않으므로** 검증 중에도 레시피가 오염될 수 있습니다.
## 수정 요약
| # | 파일 | 위치 | 작업 |
|---|---|---|---|
| 1 | `WeldingComponents\WeldingCommon\Utils\RecipeTrace.cs` | 신규 | 파일 생성 (전문 아래) |
| 2 | `WeldingComponents\WeldingCommon\WeldingCommon.csproj` | 272행 뒤 | `<Compile Include>` 1줄 추가 |
| 3 | `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs` | 18행 | 필드 → 프로퍼티 |
| 4 | `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs` | 36~39행 | `SaveRecipe` 전/후 계측 |
| 5 | `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs` | 64~69행 | `ReloadRecipe` 성공/실패/무동작 계측 |
| 6 | `WeldingComponents\LASA\GUI\Page\MainPage.cs` | 535행 | Page(FieldHelper.json) 기록 훅 (작업 6) |
| 7 | `WeldingComponents\WeldingCommon\Utils\CommonHelper.cs` | 13~17행 | 위 훅 연결 (작업 7) |
`ILSOS_PrismaticRD_PTOP` 소스는 수정하지 않습니다.
배포용 파일 목록은 `docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측_변경파일목록.md` 참조.
> **3~5는 같은 파일입니다.** 위에서부터 고치면 줄 번호가 밀리므로, **줄 번호보다 `[현재 코드]` 문자열을 기준으로** 찾아서 바꾸십시오.
## 왜 이 3곳인가 (근거)
저장소 전체를 조사한 결과, 레시피 신원이 바뀌는 지점은 **정확히 3종류**뿐입니다.
**(가) 저장 "대상"이 바뀌는 곳 — `RecipeHelper.SelectedRecipe` 대입, 전 저장소에 4곳**
| 위치 | 경로 |
|---|---|
| `UCScannerObjectTeachingViewModel.cs:114` | 신규 레시피 생성 |
| `UCScannerObjectTeachingViewModel.cs:165` | `OnChanged` |
| `UCScannerObjectTeachingViewModel.cs:172` | `Reloaded` |
| `RecipeHelper.cs:76` | `OnActiveReloaded` |
나머지는 전부 `.name` / `.id` **읽기**뿐이고, 튜플 멤버에 직접 대입하는 코드는 **없습니다**.
→ **필드를 프로퍼티로 바꿔도 컴파일이 깨지지 않으며**, setter 한 곳에서 4개 지점을 호출자까지 전부 자동으로 잡습니다.
**(나) 저장 "내용"이 바뀌는 곳 — `MainPage.ReloadRecipe` 하나뿐**
`WeldingManager.ReloadWeldingRecipe`(WeldingManager.cs:43)라는 우회 경로가 있으나 호출부가 전부 주석(`CommonHelper.cs:22`, `UCScannerObjectTeaching.xaml.cs:37`)이라 **죽어 있습니다.** 무시해도 됩니다.
**(다) 사고가 디스크에 확정되는 곳 — `RecipeHelper.SaveRecipe`**
`MainPage.SaveRecipe`(MainPage.cs:425-434)는 메모리를 지정 경로에 덤프할 뿐, **이 시점에 바뀌는 것은 파일뿐입니다.** 메모리도 대상도 여기서 바뀌지 않습니다. 즉 사고는 Save 이전에 이미 결정되어 있고, Save 는 그것을 파일에 굳힐 뿐입니다. 그래서 **전(원인)과 후(피해)를 모두** 찍습니다.
## 참조 가능성 사전 확인 (완료)
선행 문서 §3 "구현 시 확인 사항"과 §9 미확정 항목 중 아래는 **확인 완료**입니다. 다시 검증할 필요 없습니다.
| 항목 | 결과 |
|---|---|
| `DB.Dao.SystemRecipe.WorkRecipeFile` 를 WeldingCommon 에서 참조 가능한가 | **가능.** `WeldingCommon.csproj:581` 이 **LASAGUI 만** 참조하므로 `DB.Dao` 는 `MainPage.cs:466` 이 쓰는 바로 그 정적과 동일 인스턴스. 선례 `RecipeLaserInterlock.cs:25` |
| `UtilUnit.logUnit` | WeldingCommon 에서 **사용 선례 0건**. → 로그는 파일 append 로 대체함 |
| `MessageBox` | WeldingCommon 에서 이미 다수 사용 중 (`UCPowerCalibrationViewModel.cs:263` 등) |
| Auto/Manual 판별 | `WeldingCommon.Status.EquipmentStatus` 정적, `WeldingCommon.Utils` 에서 그대로 해석됨 |
| `getRecipeInfo` 호출이 안전한가 | **안전.** `LoadFileReadOnly`(RecipeWeldingLines.cs:232)는 자기 인스턴스에만 로드하고 전역을 건드리지 않음. `OnSaved:209` 가 이미 동일하게 사용 중 |
| `RecipeExIds` | `namespace DB` (LASARecipeHelper.cs:10) |
| `eEqpStatus` | `namespace WeldingCommon` (EquipDefCommon.cs:20) |
**추가 발견 — 신원 마커가 2개입니다.**
| 마커 | 기록 지점 |
|---|---|
| `DB.Dao.SystemRecipe.WorkRecipeFile` | `MainPage.ReloadRecipe` (MainPage.cs:466) |
| `DB.Recipe.Recipe.SelectedRecipeFile` | `Recipe.Load` **내부** (Recipe.cs:75). `Recipe.Save` 는 건드리지 않음 |
정상이면 항상 같습니다. **다르면 MainPage 를 거치지 않은 Load 경로가 있었다**는 뜻이라, 그 자체로 원인을 좁혀 줍니다.
---
# 작업 1. 신규 파일 생성
**경로**: `WeldingComponents\WeldingCommon\Utils\RecipeTrace.cs`
**인코딩**: UTF-8 (한글 주석 포함)
아래 전문을 그대로 새 파일로 저장하십시오.
```csharp
// ─────────────────────────────────────────────────────────────────────────────
// [진단 전용 2026-08-04] Laser Recipe Save 교차 오염 — "언제 어긋나는지" 추적.
// 레시피 신원(대상/내용)이 바뀌는 지점마다 흔적을 남기고,
// Save 전/후를 비교해 무엇이 무엇으로 바뀌었는지 보여준다.
// 저장을 막지 않는다. 원인 확정 후 이 파일과 호출부를 통째로 제거할 것.
// ─────────────────────────────────────────────────────────────────────────────
using Aurora;
using CoreUnit.Control.UserControl.Canvas;
using CoreUnit.Scanner.UserControl.ScannerCanvas;
using DB;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows;
namespace WeldingCommon.Utils
{
public static class RecipeTrace
{
// 흐름을 눈으로 다 보고 싶으면 true (저장할 때마다 팝업).
// 평소엔 false 로 두고 불일치가 감지될 때만 뜨게 한다.
public const bool ALWAYS_SHOW_ON_SAVE = false;
// Auto 중 모달은 시퀀스를 세운다. 기본은 파일에만 기록.
public const bool SHOW_IN_AUTO = false;
const int KEEP = 40;
static readonly List<string> _trail = new List<string>();
static readonly object _lock = new object();
// ── 흔적 남기기 ──────────────────────────────────────────────────────
public static void Mark(string evt, string detail = "")
{
string line;
try {
string actName; int actId;
GetActive(out actName, out actId);
line = $"{DateTime.Now:HH:mm:ss.fff} T{System.Threading.Thread.CurrentThread.ManagedThreadId,-3} "
+ $"{evt,-24} 선택={Sel()} 메모리={Short(Work())} LASA={Short(Lasa())} "
+ $"활성=[{actId}][{actName}] {Status.EquipmentStatus} | {detail} | ← {Caller()}";
} catch (Exception ex) {
line = $"{DateTime.Now:HH:mm:ss.fff} {evt} (기록실패 {ex.Message})";
}
lock (_lock) {
_trail.Add(line);
if (_trail.Count > KEEP) _trail.RemoveAt(0);
}
AppendFile(line);
}
// ── Save 전/후 스냅샷 ────────────────────────────────────────────────
public class SaveSnap
{
public string RecipeName, TargetPath, ActivePath, ActiveName;
public string Selected, Work, Lasa, Eqp;
public string MemFp, TgtFp, ActFp;
public string TgtHash, ActHash, TgtField, ActField;
public bool BadArg, BadPath, BadMarker;
}
// 저장 직전 — "왜 어긋났나"
public static SaveSnap BeforeSave(string recipeName, string targetPath)
{
var s = new SaveSnap { RecipeName = recipeName, TargetPath = targetPath };
try {
string actName; int actId;
GetActive(out actName, out actId);
s.ActiveName = actName;
s.ActivePath = string.IsNullOrWhiteSpace(actName) ? "" : TeachingPath(actName);
s.Selected = Sel();
s.Work = Work();
s.Lasa = Lasa();
s.Eqp = Status.EquipmentStatus.ToString();
s.MemFp = MemFingerprint(); // 메모리 = 저장될 내용
s.TgtFp = FileFingerprint(targetPath); // 저장될 자리에 원래 있던 내용
s.ActFp = FileFingerprint(s.ActivePath); // 활성 레시피 내용
s.TgtHash = Hash(targetPath);
s.ActHash = Hash(s.ActivePath);
s.TgtField = Hash(FieldPath(targetPath)); // Page 데이터(FieldHelper.json)
s.ActField = Hash(FieldPath(s.ActivePath));
// 호출 인자와 전역 대상이 다르다 (OnSaved 가 인자를 버리는 경로)
s.BadArg = !string.Equals(recipeName, RecipeHelper.SelectedRecipe.name,
StringComparison.OrdinalIgnoreCase);
// 저장 대상과 메모리에 로드된 파일이 다르다
s.BadPath = !string.IsNullOrWhiteSpace(s.Work) && !SamePath(s.Work, targetPath);
// 두 신원 마커가 서로 다르다 (MainPage 를 안 거친 Load 존재)
s.BadMarker = !string.IsNullOrWhiteSpace(s.Work) && !string.IsNullOrWhiteSpace(s.Lasa)
&& !SamePath(s.Work, s.Lasa);
Mark("SAVE 직전",
$"요청=[{recipeName}] arg={s.BadArg} path={s.BadPath} marker={s.BadMarker} "
+ $"메모리={s.MemFp} 대상파일={s.TgtFp}");
} catch { }
return s;
}
// 저장 직후 — "무엇이 무엇으로 바뀌었나"
public static void AfterSave(SaveSnap s)
{
try {
if (s == null) return;
string tgtFpAfter = FileFingerprint(s.TargetPath);
string actFpAfter = FileFingerprint(s.ActivePath);
string tgtHashA = Hash(s.TargetPath);
string actHashA = Hash(s.ActivePath);
string tgtFieldA = Hash(FieldPath(s.TargetPath));
string actFieldA = Hash(FieldPath(s.ActivePath));
bool targetIsActive = SamePath(s.TargetPath, s.ActivePath);
bool tgtChanged = s.TgtHash != tgtHashA;
bool actChanged = s.ActHash != actHashA;
bool tgtFieldChg = s.TgtField != tgtFieldA;
bool actFieldChg = s.ActField != actFieldA;
// 핵심 : 대상 파일이 활성 레시피 내용으로 바뀌었다 = 신고된 증상
bool becameActive = !targetIsActive && tgtChanged
&& tgtFpAfter == actFpAfter && s.TgtFp != s.ActFp;
// 역방향 : 엉뚱하게 활성 파일이 바뀌었다 (선행문서 원인 2)
bool activeHit = !targetIsActive && (actChanged || actFieldChg);
bool bad = becameActive || activeHit || s.BadArg || s.BadPath || s.BadMarker;
string conclusion =
becameActive ? $"★ 대상 [{s.RecipeName}] 이 활성 레시피 [{s.ActiveName}] 내용으로 덮어써짐"
: activeHit ? $"★ 대상은 [{s.RecipeName}] 인데 활성 파일 [{s.ActiveName}] 이 함께 변경됨"
: (s.BadArg || s.BadPath || s.BadMarker)
? "△ 저장은 대상 파일에만 됐지만 신원이 어긋난 상태였음"
: "정상 (대상 파일만 변경)";
Mark(bad ? "*** SAVE 직후 (의심) ***" : "SAVE 직후",
$"{conclusion} | 대상변경={tgtChanged} 활성변경={actChanged} "
+ $"페이지변경(대상)={tgtFieldChg} 페이지변경(활성)={actFieldChg}");
if (!bad && !ALWAYS_SHOW_ON_SAVE) return;
if (s.Eqp != eEqpStatus.Manual.ToString() && !SHOW_IN_AUTO) return;
var sb = new StringBuilder();
sb.AppendLine("[Recipe Save 진단] 이 화면을 촬영해 주세요. 저장은 그대로 진행되었습니다.");
sb.AppendLine();
sb.AppendLine($"결론 : {conclusion}");
sb.AppendLine();
sb.AppendLine($"저장 요청 : [{s.RecipeName}]");
sb.AppendLine($"SelectedRecipe : {s.Selected}");
sb.AppendLine($"활성 레시피 : [{s.ActiveName}]");
sb.AppendLine($"설비상태 : {s.Eqp}");
sb.AppendLine($"신원 어긋남 : arg={s.BadArg} path={s.BadPath} marker={s.BadMarker}");
sb.AppendLine($" 메모리 WorkRecipeFile : {s.Work}");
sb.AppendLine($" 메모리 SelectedRecipeFile : {s.Lasa}");
sb.AppendLine($" 저장대상 target : {s.TargetPath}");
sb.AppendLine();
sb.AppendLine("──────── 무엇이 무엇으로 바뀌었나 ────────");
sb.AppendLine($"저장된 내용(메모리) : {s.MemFp}");
sb.AppendLine();
sb.AppendLine($"대상파일 전 : {s.TgtFp}");
sb.AppendLine($"대상파일 후 : {tgtFpAfter} 변경={tgtChanged}");
sb.AppendLine($"활성파일 전 : {s.ActFp}");
sb.AppendLine($"활성파일 후 : {actFpAfter} 변경={actChanged}");
sb.AppendLine();
sb.AppendLine($"Page(FieldHelper) 대상 변경 : {tgtFieldChg} 활성 변경 : {actFieldChg}");
sb.AppendLine();
sb.AppendLine("──────── 직전 이력 (오래된 것 → 최근) ────────");
lock (_lock) { foreach (var l in _trail) sb.AppendLine(l); }
MessageBox.Show(sb.ToString(), "Recipe Save 진단",
MessageBoxButton.OK, MessageBoxImage.Warning);
} catch { }
}
// ── 지문 ────────────────────────────────────────────────────────────
// 메모리의 실제 용접 파라미터. ScannerCanvasUserCtrl.ScannerParams 가 실체다.
public static string MemFingerprint()
{
try { return Fp(ScannerCanvasUserCtrl.ScannerParams); } catch { return "?"; }
}
public static string FileFingerprint(string fullPath)
{
try {
if (string.IsNullOrWhiteSpace(fullPath) || !File.Exists(fullPath)) return "(파일없음)";
return Fp(CommonHelper.GetMainPage().getRecipeInfo(fullPath).ReadOnlyScanParams);
} catch (Exception ex) { return "(읽기실패)" + ex.Message; }
}
// OnSaved 가 비교하는 필드와 동일한 6개, 앞 3개 파라미터
static string Fp(List<ScannerParam> p)
{
if (p == null) return "null";
var sb = new StringBuilder($"n={p.Count}");
for (int i = 0; i < 3 && i < p.Count; i++)
sb.Append($"|{p[i].MarkDelay},{p[i].JumpDelay},{p[i].LaserOnDelay},"
+ $"{p[i].LaserOffDelay},{p[i].LaserFrequency},{p[i].NumberOfCount}");
return sb.ToString();
}
// 파일↔파일 비교는 파싱 없이 해시로 (빠름)
static string Hash(string path)
{
try {
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return "(없음)";
using (var md5 = System.Security.Cryptography.MD5.Create())
using (var fs = File.OpenRead(path)) {
var h = BitConverter.ToString(md5.ComputeHash(fs)).Replace("-", "");
return h.Substring(0, 8) + "/" + new FileInfo(path).Length;
}
} catch { return "(해시실패)"; }
}
// ── 잡동사니 ────────────────────────────────────────────────────────
static string Sel() { var s = RecipeHelper.SelectedRecipe; return $"[{s.id}][{s.name}]"; }
static string Work() { try { return DB.Dao.SystemRecipe.WorkRecipeFile; } catch { return ""; } }
static string Lasa() { try { return DB.Recipe.Recipe.SelectedRecipeFile; } catch { return ""; } }
// Aurora 바이너리 타입명을 쓰지 않기 위해 out 으로 받는다
static void GetActive(out string name, out int id)
{
name = ""; id = -1;
try {
var a = Application.Current.AuroraEnv().RecipeManager.ActivatedRecipe();
if (a != null) { name = a.Name; id = a.Id; }
} catch { }
}
// 같은 폴더의 Page 데이터
static string FieldPath(string recipeFullPath)
{
try {
return string.IsNullOrWhiteSpace(recipeFullPath) ? ""
: recipeFullPath.Replace(Path.GetFileName(recipeFullPath), "FieldHelper.json");
} catch { return ""; }
}
static string TeachingPath(string recipeName)
{
var root = ConfigurationManager.AppSettings.Get("DATA_PATH");
return Path.Combine(root, RecipeExIds.RECIPE_DIR, recipeName, RecipeExIds.TEACHING_EDITOR_RECIPE);
}
// 전체 경로 대신 레시피 폴더명만 (…/_config_recipe/<레시피명>/TeachingEditor…)
static string Short(string p)
{
try {
return string.IsNullOrWhiteSpace(p) ? "(비어있음)"
: Path.GetFileName(Path.GetDirectoryName(p));
} catch { return p; }
}
static bool SamePath(string a, string b)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
try {
return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b),
StringComparison.OrdinalIgnoreCase);
} catch { return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); }
}
// RecipeTrace 프레임을 건너뛰고 실제 호출자 2단계
static string Caller()
{
try {
var st = new StackTrace(true);
var sb = new StringBuilder();
int found = 0;
for (int i = 1; i < st.FrameCount && found < 2; i++) {
var m = st.GetFrame(i).GetMethod();
var t = m.DeclaringType == null ? "?" : m.DeclaringType.Name;
if (t == "RecipeTrace") continue;
if (found > 0) sb.Append(" ← ");
sb.Append($"{t}.{m.Name}:{st.GetFrame(i).GetFileLineNumber()}");
found++;
}
return sb.ToString();
} catch { return "?"; }
}
static void AppendFile(string line)
{
try {
var root = ConfigurationManager.AppSettings.Get("DATA_PATH");
File.AppendAllText(Path.Combine(root, "RecipeTrace.txt"), line + "\r\n", Encoding.UTF8);
} catch { }
}
}
}
```
---
# 작업 2. csproj 에 파일 등록
**파일**: `WeldingComponents\WeldingCommon\WeldingCommon.csproj`
구식 csproj 라 **파일만 만들면 빌드에 포함되지 않습니다.** Visual Studio 에서 [기존 항목 추가]로 넣으면 자동 처리되고, 직접 편집한다면 아래와 같이 합니다.
**[현재 272~273행]**
```xml
<Compile Include="Utils\RecipeHelper.cs" />
<Compile Include="Utils\RegistrySerializer.cs" />
```
**[변경안]**
```xml
<Compile Include="Utils\RecipeHelper.cs" />
<Compile Include="Utils\RecipeTrace.cs" />
<Compile Include="Utils\RegistrySerializer.cs" />
```
---
# 작업 3. `SelectedRecipe` 필드 → 프로퍼티
**파일**: `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs`
**위치**: 18행
**[현재 코드]**
```csharp
public static (int id, string name) SelectedRecipe;
```
**[변경안]**
```csharp
// [진단 2026-08-04] 대상이 바뀌는 순간을 잡기 위해 필드 → 프로퍼티.
// 외부에서의 읽기/쓰기 방식은 동일하다(대입 4곳, 나머지는 .name/.id 읽기뿐).
static (int id, string name) _SelectedRecipe;
public static (int id, string name) SelectedRecipe {
get { return _SelectedRecipe; }
set {
var old = _SelectedRecipe;
_SelectedRecipe = value;
RecipeTrace.Mark("대상변경 SELECTED",
$"[{old.id}][{old.name}] → [{value.id}][{value.name}]");
}
}
```
**확인 사항** — 빌드 시 `SelectedRecipe` 관련 에러가 나면 튜플 멤버에 직접 대입하는 코드(`SelectedRecipe.name = ...`)가 어딘가 있다는 뜻입니다. 조사 시점에는 **0건**이었습니다.
---
# 작업 4. `SaveRecipe` 전/후 계측
**파일**: `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs`
**위치**: 36~39행
**[현재 코드]**
```csharp
public static void SaveRecipe(string recipeName, bool includeModel = true)
{
CommonHelper.GetMainPage().SaveRecipe(GetTeachingEditorFullPath(recipeName), includeModel);
}
```
**[변경안]**
```csharp
public static void SaveRecipe(string recipeName, bool includeModel = true)
{
var targetPath = GetTeachingEditorFullPath(recipeName);
// [진단 2026-08-04] 저장 전/후를 모두 찍어 무엇이 무엇으로 바뀌었는지 확인한다.
// 저장 자체는 막지 않는다.
var snap = RecipeTrace.BeforeSave(recipeName, targetPath);
CommonHelper.GetMainPage().SaveRecipe(targetPath, includeModel);
RecipeTrace.AfterSave(snap);
}
```
---
# 작업 5. `ReloadRecipe` 성공/실패/무동작 계측
**파일**: `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs`
**위치**: 64~69행
**[현재 코드]**
```csharp
public static void ReloadRecipe(string recipeName)
{
var camConfig = VisionEnv.AuroraEnv.DeviceManager.GetDeviceConfig(eCamId.CAM1.ToString()) as CamConfig;
CommonHelper.GetMainPage().UpdateDefaultField(camConfig.ChildImage.ChildBufRowCols);
CommonHelper.GetMainPage().ReloadRecipe(GetTeachingEditorFullPath(recipeName));
}
```
**[변경안]**
```csharp
public static void ReloadRecipe(string recipeName)
{
// [진단 2026-08-04] 내용 전환의 성공/실패/무동작을 구분해 기록. 동작은 그대로.
var before = RecipeTrace.MemFingerprint();
RecipeTrace.Mark("내용변경 RELOAD 진입", $"요청=[{recipeName}] 지문(전)={before}");
try {
var camConfig = VisionEnv.AuroraEnv.DeviceManager.GetDeviceConfig(eCamId.CAM1.ToString()) as CamConfig;
CommonHelper.GetMainPage().UpdateDefaultField(camConfig.ChildImage.ChildBufRowCols);
CommonHelper.GetMainPage().ReloadRecipe(GetTeachingEditorFullPath(recipeName));
var after = RecipeTrace.MemFingerprint();
RecipeTrace.Mark(before == after ? "RELOAD 완료(내용무변화!)" : "RELOAD 완료",
$"요청=[{recipeName}] 지문(후)={after}");
} catch (System.Exception ex) {
RecipeTrace.Mark("*** RELOAD 실패 ***",
$"요청=[{recipeName}] {ex.GetType().Name}: {ex.Message}");
throw; // 기존 동작 보존 — 예외 전파는 그대로
}
}
```
`throw;`(파라미터 없음)이므로 스택 트레이스와 예외 전파 동작이 기존과 동일합니다. `throw ex;` 로 쓰지 마십시오.
**이 두 문구가 원인 확정의 핵심입니다.**
| 문구 | 의미 |
|---|---|
| `*** RELOAD 실패 ***` | 선행문서 **원인 (1)** 확정. 대상만 바뀌고 내용은 직전 레시피에 남음 |
| `RELOAD 완료(내용무변화!)` | `Recipe.cs:64` **무동작 가설** 확정. 경로 비교로는 절대 못 잡는 케이스 |
같은 레시피를 다시 로드하면 정상적으로도 `내용무변화` 가 뜹니다. **요청 레시피명이 직전과 다른데** 무변화면 그때가 사고입니다.
---
---
# 작업 6. Page(FieldHelper.json) 기록 훅 — `MainPage.cs`
**파일**: `WeldingComponents\LASA\GUI\Page\MainPage.cs`
**위치**: `SaveAndLoadFieldScanFromLasaDb` (535행)
## 왜 필요한가
`FieldHelper.json`(Page 데이터)을 실제로 쓰는 건 `SaveAndLoadFieldScanFromLasaDb()`(내부 `:539 SaveFieldHelper`)인데, 호출처 4곳 중 `SaveRecipe` 안에 있는 건 **1곳뿐**입니다.
| 호출처 | Save 경로 안인가 |
|---|---|
| `MainPage.cs:433` — `SaveRecipe` | ✓ |
| `MainPage.cs:448,452` — `ReloadSetting()` | ✗ |
| `MainPage.cs:502,505` — `UpdateScoutField()` (Scout 필드 조작) | ✗ |
| `MainPage.cs:1433` — 콤보박스 `SelectedIndexChanged` (**페이지 바꿀 때마다**) | ✗ |
게다가 경로를 `WorkRecipeFile` 기준으로 정합니다(`:537`). **`WorkRecipeFile` 이 어긋난 상태에서 페이지를 넘기기만 해도** 엉뚱한 레시피 폴더의 `FieldHelper.json` 이 덮어써질 수 있습니다. `_suppressPageFieldSync` 가드(`:1422`)는 `ReloadRecipe` **도중**의 발화만 막고 평상시 페이지 전환은 그대로 씁니다.
함수 한 곳에 훅을 넣으면 **호출처 4곳이 전부** 잡힙니다.
## 6-1. `Tracer` 필드 추가
**[현재 코드]**
```csharp
void SaveAndLoadFieldScanFromLasaDb(string inputStr = null)
```
**[변경안]**
```csharp
// ═══ [진단계측 2026-08-04] 시작 ═══════════════════════════════════════
// WeldingCommon(RecipeTrace)이 LASAGUI 를 참조하므로 역방향 호출이 불가능하다.
// 델리게이트로 우회한다. CommonHelper.SetMainPage 에서 RecipeTrace.Mark 가 꽂힌다.
// 원복 : 이 블록과 SaveAndLoadFieldScanFromLasaDb 안의 Tracer?.Invoke 블록을 삭제.
public static Action<string, string> Tracer;
// ═══ [진단계측 2026-08-04] 끝 ═════════════════════════════════════════
void SaveAndLoadFieldScanFromLasaDb(string inputStr = null)
```
## 6-2. 기록 호출 추가
**[현재 코드]**
```csharp
string newpath = fullpath.Replace(Path.GetFileName(fullpath), "FieldHelper.json");
DB.RecipeFieldScan.Instance.SaveFieldHelper(newpath);
```
**[변경안]**
```csharp
string newpath = fullpath.Replace(Path.GetFileName(fullpath), "FieldHelper.json");
// ═══ [진단계측 2026-08-04] 시작 — 원복 시 이 블록 삭제 ═══
Tracer?.Invoke("PAGE 저장 FieldHelper",
$"경로={newpath} 기준={(inputStr == null ? "WorkRecipeFile" : "인자지정")}");
// ═══ [진단계측 2026-08-04] 끝 ═══
DB.RecipeFieldScan.Instance.SaveFieldHelper(newpath);
```
`기준=WorkRecipeFile` 이면 그 시점 `WorkRecipeFile` 값이 곧 기록 대상이라는 뜻입니다. 어긋나 있으면 그대로 오염입니다.
`Tracer` 는 `Action` 이라 `LASAGUI` → `WeldingCommon` 역참조가 생기지 않습니다. 연결이 안 돼 있으면 `?.` 로 조용히 무시되므로 **계측이 없어도 원래대로 동작합니다.**
---
# 작업 7. 훅 연결 — `CommonHelper.cs`
**파일**: `WeldingComponents\WeldingCommon\Utils\CommonHelper.cs`
**위치**: `SetMainPage()` (13~17행)
**[현재 코드]**
```csharp
public static void SetMainPage(MainPage mainPage)
{
MainPage = mainPage;
//If Using WeldingManager, This Method Not Necessary.
}
```
**[변경안]**
```csharp
public static void SetMainPage(MainPage mainPage)
{
MainPage = mainPage;
//If Using WeldingManager, This Method Not Necessary.
// ═══ [진단계측 2026-08-04] 시작 — 원복 시 이 블록 삭제 ═══
RecipeTrace.Attach(); // MainPage.Tracer 에 RecipeTrace.Mark 를 연결(참조 방향 우회)
// ═══ [진단계측 2026-08-04] 끝 ═══
}
```
`SetMainPage` 는 기동 시 `MainPage` 가 준비되는 시점에 호출되므로, 사용자가 페이지를 조작하기 전에 훅이 걸립니다.
> `RecipeTrace.Attach()` 는 작업 1의 `RecipeTrace.cs` 에 포함되어 있습니다. 별도 추가 불필요.
---
# 빌드
1. `ILSOS_All.sln` 을 Visual Studio 2022 로 열고 **Release | x64** 빌드.
2. 빌드 실패 시 진행 중단. 특히 아래를 확인:
- `RecipeTrace.cs` 가 csproj 에 등록됐는지 (작업 2)
- `SelectedRecipe` 프로퍼티 전환 관련 에러 (작업 3 확인 사항)
# 현장 확인 절차
1. `_config_recipe` 폴더 백업 완료 확인.
2. 기동 후 Manual 상태에서 레시피 A 선택 → 파라미터 수정 → Save.
- 정상이면 **팝업이 뜨지 않습니다.**
- `D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt` 에 `SAVE 직전` / `SAVE 직후` 줄이 남았는지 확인.
3. 레시피 전환(A→B→A)을 10회 반복하며 팝업이 뜨는지 관찰.
4. AutoRun 기동 → PLC 레시피 전환 발생 → Manual 복귀 → 레시피 선택 후 Save.
5. 팝업이 뜨면 **화면 전체를 촬영**하고 `RecipeTrace.txt` 를 함께 회수.
**흐름을 눈으로 직접 보고 싶으면** `RecipeTrace.cs` 의 `ALWAYS_SHOW_ON_SAVE` 를 `true` 로 바꾸면 저장할 때마다 팝업이 뜹니다. 확인이 끝나면 `false` 로 되돌리십시오.
# 결과 읽는 법
팝업 맨 위 `결론` 한 줄이 판정입니다.
```
결론 : ★ 대상 [RCP_B] 이 활성 레시피 [RCP_A] 내용으로 덮어써짐
──────── 무엇이 무엇으로 바뀌었나 ────────
저장된 내용(메모리) : n=128|300,100,50,50,2000,1
대상파일 전 : n=128|500,200,80,80,3000,2
대상파일 후 : n=128|300,100,50,50,2000,1 변경=True ← 활성 것과 같아짐
활성파일 전 : n=128|300,100,50,50,2000,1
활성파일 후 : n=128|300,100,50,50,2000,1 변경=False
Page(FieldHelper) 대상 변경 : True 활성 변경 : False
──────── 직전 이력 (오래된 것 → 최근) ────────
14:22:07.113 T1 대상변경 SELECTED 선택=[3][RCP_B] 메모리=RCP_A … | [1][RCP_A] → [3][RCP_B] | ← UCScannerObjectTeachingViewModel.OnChanged:165
14:22:07.115 T1 내용변경 RELOAD 진입 선택=[3][RCP_B] 메모리=RCP_A … | 요청=[RCP_B] 지문(전)=n=128|300,…
14:22:07.140 T1 *** RELOAD 실패 *** 선택=[3][RCP_B] 메모리=RCP_A … | 요청=[RCP_B] NullReferenceException: …
14:23:51.902 T1 *** SAVE 직후 (의심) *** …
```
- `대상파일 전 → 후` 가 곧 **무엇이 무엇으로 바뀌었나**.
- 그 값이 `활성파일` 과 같아지면 **교차 오염 확정**.
- 그 아래 이력에서 **언제 어긋났는지**(`RELOAD 실패` 또는 `대상변경 SELECTED` 뒤에 대응하는 `RELOAD 완료` 가 없음)가 보입니다.
- `← 호출자` 컬럼으로 **어느 화면 조작이었는지**까지 특정됩니다.
# 원인별 후속 조치 (확정 후)
| 팝업/이력에서 관찰된 것 | 적용할 것 |
|---|---|
| `*** RELOAD 실패 ***` 직후의 Save | 선행문서 **2단계** (대상·내용 갱신 순서 역전) |
| `RELOAD 완료(내용무변화!)` 인데 요청 레시피가 바뀐 경우 | `Recipe.cs:64` 무동작. 별도 분석 필요 (선행문서 §9-5) |
| `SAVE 직전` 의 호출자가 `OnActivatedRecipe:156` | 선행문서 **3단계** (A안 권장) |
| `SAVE 직전` 의 호출자가 `OnRecipeEntryChanged:115` | 선행문서 **4단계** |
| `arg=True` (인자 ≠ SelectedRecipe) | `OnSaved` 의 저장 대상을 `recipeName` 으로 통일 |
| `marker=True` (두 마커 불일치) | MainPage 를 거치지 않은 Load 경로 존재. 재분석 필요 |
# 알아두실 점 / 한계
- **저장을 막지 않습니다.** 오염이 일어나면 팝업은 뜨지만 파일은 이미 덮어써진 뒤입니다. 데이터를 지키는 게 목적이면 선행문서 **1단계 가드**(저장 거부)를 넣어야 하지만, 그러면 원인 경로 데이터를 모을 수 없습니다. **백업을 전제로 진단을 먼저** 도는 순서입니다.
- **Save 가 느려질 수 있습니다.** 저장 1회당 레시피 파일 파싱이 4회(대상·활성 × 전·후) 늘어납니다. `TeachingEditor` 에 Model 까지 들어 있어 레시피가 크면 체감될 수 있습니다. 느리면 `BeforeSave`/`AfterSave` 에서 **활성 파일 지문(`ActFp`/`actFpAfter`) 계산만 제거**하십시오. `becameActive` 판정만 약해지고 나머지는 그대로 동작합니다.
- **Auto 중에는 팝업이 뜨지 않습니다** (`SHOW_IN_AUTO = false`). `OnActivatedRecipe`(VM:156)의 Save 는 PLC 레시피 전환 중 디스패처에서 돌기 때문에, 여기서 모달이 뜨면 확인을 누를 때까지 전환이 멈춥니다. 파일에는 그대로 남으므로 양산 중이면 이대로 두십시오.
- **지문은 ScanParam 앞 3개 기준**입니다. 두 레시피의 파라미터가 완전히 동일하면 `becameActive` 가 구분하지 못합니다. 이 경우 `Page(FieldHelper) 변경` 과 해시로 판단하십시오.
- `MainPage.SaveRecipe` 가 예외를 던지면 `AfterSave` 는 실행되지 않습니다. 이때는 `SAVE 직전` 만 남습니다 — 그 자체가 저장 실패의 증거입니다.
# 원복 방법
1. `RecipeTrace.cs` 삭제
2. csproj 의 `<Compile Include="Utils\RecipeTrace.cs" />` 삭제
3. `RecipeHelper.cs` 의 작업 3·4·5 를 원래 코드로 되돌림 (본 문서의 `[현재 코드]` 블록)
4. `D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt` 삭제
# 이번 작업에서 하지 않는 것
- 저장 차단 가드 (선행문서 1단계) — 원인 데이터 수집 후 판단
- `OnActiveReloaded` / `OnChanged` / `Reloaded` 의 순서 교정 (2단계)
- `OnActivatedRecipe` 의 `SaveRecipe` 제거 (3단계)
- `RMSHandlerForPTOP` 수정 — 죽은 코드 (선행문서 §7)
- `EquipmentSetup.cs:282` `RollbackRecipe` 주석 해제 — 의미 없는 동작 (선행문서 §7)