# 작업지시서 — 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행 뒤 | `` 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 _trail = new List(); 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 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 ``` **[변경안]** ```xml ``` --- # 작업 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 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 의 `` 삭제 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)