36 KiB
작업지시서 — 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 (한글 주석 포함)
아래 전문을 그대로 새 파일로 저장하십시오.
// ─────────────────────────────────────────────────────────────────────────────
// [진단 전용 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행]
<Compile Include="Utils\RecipeHelper.cs" />
<Compile Include="Utils\RegistrySerializer.cs" />
[변경안]
<Compile Include="Utils\RecipeHelper.cs" />
<Compile Include="Utils\RecipeTrace.cs" />
<Compile Include="Utils\RegistrySerializer.cs" />
작업 3. SelectedRecipe 필드 → 프로퍼티
파일: WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs
위치: 18행
[현재 코드]
public static (int id, string name) SelectedRecipe;
[변경안]
// [진단 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행
[현재 코드]
public static void SaveRecipe(string recipeName, bool includeModel = true)
{
CommonHelper.GetMainPage().SaveRecipe(GetTeachingEditorFullPath(recipeName), includeModel);
}
[변경안]
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행
[현재 코드]
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));
}
[변경안]
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 필드 추가
[현재 코드]
void SaveAndLoadFieldScanFromLasaDb(string inputStr = null)
[변경안]
// ═══ [진단계측 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. 기록 호출 추가
[현재 코드]
string newpath = fullpath.Replace(Path.GetFileName(fullpath), "FieldHelper.json");
DB.RecipeFieldScan.Instance.SaveFieldHelper(newpath);
[변경안]
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행)
[현재 코드]
public static void SetMainPage(MainPage mainPage)
{
MainPage = mainPage;
//If Using WeldingManager, This Method Not Necessary.
}
[변경안]
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에 포함되어 있습니다. 별도 추가 불필요.
빌드
ILSOS_All.sln을 Visual Studio 2022 로 열고 Release | x64 빌드.- 빌드 실패 시 진행 중단. 특히 아래를 확인:
RecipeTrace.cs가 csproj 에 등록됐는지 (작업 2)SelectedRecipe프로퍼티 전환 관련 에러 (작업 3 확인 사항)
현장 확인 절차
_config_recipe폴더 백업 완료 확인.- 기동 후 Manual 상태에서 레시피 A 선택 → 파라미터 수정 → Save.
- 정상이면 팝업이 뜨지 않습니다.
D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt에SAVE 직전/SAVE 직후줄이 남았는지 확인.
- 레시피 전환(A→B→A)을 10회 반복하며 팝업이 뜨는지 관찰.
- AutoRun 기동 → PLC 레시피 전환 발생 → Manual 복귀 → 레시피 선택 후 Save.
- 팝업이 뜨면 화면 전체를 촬영하고
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 직전만 남습니다 — 그 자체가 저장 실패의 증거입니다.
원복 방법
RecipeTrace.cs삭제- csproj 의
<Compile Include="Utils\RecipeTrace.cs" />삭제 RecipeHelper.cs의 작업 3·4·5 를 원래 코드로 되돌림 (본 문서의[현재 코드]블록)D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt삭제
이번 작업에서 하지 않는 것
- 저장 차단 가드 (선행문서 1단계) — 원인 데이터 수집 후 판단
OnActiveReloaded/OnChanged/Reloaded의 순서 교정 (2단계)OnActivatedRecipe의SaveRecipe제거 (3단계)RMSHandlerForPTOP수정 — 죽은 코드 (선행문서 §7)EquipmentSetup.cs:282RollbackRecipe주석 해제 — 의미 없는 동작 (선행문서 §7)