배포 준비 — 자격증명 분리, 산출물 축소, 로깅·전역 예외

[자격증명] 접속 문자열 우선순위를 환경변수 > appsettings.Development.json > appsettings.json >
ServerInfo ini 로 세웠다. 병원 설치본은 설정 없이 [000]bin 의 MSYSTECH_ServerInfo.ini 를
자동으로 찾아 붙는다(레거시와 동일한 MD5 → 3DES-ECB 복호).
- ServerInfoReader 를 [200]SheetMe 에서 복사 이식(vendoring — 두 저장소가 분리돼 있어
  ProjectReference 불가, 레거시 암복호 규약은 변할 이유가 없는 고정 자산). 헤더에 동기화 의무 명시.
- 실측 검증: ini 566라인/병원 엔트리 76개, CurrentServer=029.BSGH 복호 후 실제 접속 성공.
- SaveMode 기본값을 File 로 되돌렸다. ini 의 CurrentServer 가 운영 병원 DB 를 가리키므로
  DB 쓰기는 명시적으로 켠 단말에서만 활성화되어야 한다.
- ConfigService 로 단일 소유화 — ConfigLoader.Load() 11회 호출이 ini 파싱 + 3DES 복호를
  매번 반복하던 것을 1회로. 죽어 있던 Designer:GridSize/SnapThreshold 를 SnapEngine 에 배선하고,
  쓰이지 않던 His:Provider 와 DefaultPaperWidth/Height 는 제거했다(후자는 설정이 아니라
  레거시 패리티 상수다).

[산출물] 8.6MB/34파일 → 7.2MB/26파일.
- EmrDataContext 제거 — M.Framework.DBAccess/TableFramework/M.MW.Data.EMR 의 유일한 소비처였는데
  그 클래스가 어디서도 인스턴스화되지 않았다. DB 접근은 전부 raw Oracle 클라이언트를 쓴다.
- Microsoft.Web.WebView2 는 ExcludeAssets="runtime" — M.Framework.WPF 전이 의존일 뿐
  소스 참조 0건. [000]bin 과의 이름 충돌 3건도 함께 사라진다.
- Production.pubxml(win-x64, FDD, SatelliteResourceLanguages=ko). RID 를 csproj 가 아니라
  pubxml 에 둔 이유는 csproj 에 넣으면 dotnet build/test 까지 RID 별 복원을 타기 때문이다.
- tools/publish.ps1 — 비밀값 하드 게이트 + [000]bin 충돌 경고 + SHA256 매니페스트 + zip.
  게이트는 역방향으로 검증했다(appsettings.json 에 실접속 정보를 넣고 실행 → 정상 차단).
- nuget.config 신설 — 사내 피드가 개발자 개인 OneDrive 경로라 다른 머신에서 복원이 불가능했다.
  %MSYS_NUGET_FEED% 환경변수로 받게 해 최소한 실패 원인이 드러나게 했다.

[배포 규약] docs/DEPLOYMENT.md.
[000]bin 최상위 평면 복사를 금지한다 — 실측 결과 Oracle.ManagedDataAccess.dll 이 겹치고
(신규 .NET Core 5,434KB ↔ 기존 .NET FW 4,602KB), 덮으면 그 폴더의 레거시 EXE 187개가
전부 Oracle 접속 불능이 된다. [000]bin\SheetMe\ 하위 폴더에 둔다 — Information/Log/
OCR서식생성기/SpreadDesign 등 기존 앱들과 같은 방식이다.
FDD 로 배포한다: [000]bin\OCR서식생성기 가 이미 net10.0 + WindowsDesktop.App 10.0.0 을
요구하며 운영 중이라 런타임 존재가 확인된다. 없는 단말이 나오면 -SelfContained 한 번이면 된다.

[로깅] AppLog — LogManager 배선. 모든 호출을 try/catch 로 감싸 로깅 실패가 업무를 막지 않게 했다.
- Redact 필수 — 접속 문자열을 값으로 들고 다니므로 예외 메시지에 자격증명이 섞일 수 있다.
  기록 직전 1회 통과시킨다.
- LogLevel 은 열거형이 아니라 문자열 속성이라 오타를 컴파일러가 못 잡고, 잘못된 값이면
  FIXED 만 남고 나머지가 조용히 사라진다. LogType 열거값의 이름으로만 지정하게 했다.
- 문서에 있는 HandleShutdown 은 6.0.0 DLL 에 실제로는 없어(XML 문서가 앞서 있음) 쓰지 않는다.
  대신 기록이 비동기 배치라 스모크에서 짧게 폴링해 확인한다.
- 로그 경로는 실행 폴더\logs\Designer, 쓰기 불가 시 %LocalAppData% 폴백(쓰기 프로브까지 확인).

[전역 예외] Dispatcher/AppDomain/TaskScheduler 3종을 진단 분기보다 앞에 등록했다.
UI 예외는 기록 후 계속 진행한다(편집 중 문서를 예외 하나로 잃지 않게) — 단 10초 내 5회면
무한 팝업 루프이므로 강제 종료한다.
DialogService.ShowError 도입 — 우리가 던진 안내성 예외는 메시지를 그대로 보여주고, 그 외는
일반화 문구 + 오류 코드만 노출한다(코드가 로그 줄머리와 같아 전화 한 통으로 특정된다).
ex.ToString() 전문을 그대로 띄우던 2곳을 정리했다.
로그인/권한거부/DB저장은 감사 이벤트(FIXED)로 남긴다.

검증: 테스트 70/70, edit-smoke 실패 0(마스킹 5건 + 로그 배선 1건 추가), 왕복 1,271건
diff 0/예외 0, db-save-smoke 제자리 갱신 통과. publish.ps1 정방향/역방향 모두 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 19:23:27 +09:00
co-authored by Claude Fable 5
parent 0f77cb717a
commit 71862c2986
20 changed files with 754 additions and 71 deletions
+12 -3
View File
@@ -7,11 +7,20 @@ namespace SheetMe.Data.Config;
public sealed class DataConfig
{
#region Properties
/// <summary>Oracle 접속 문자열 (ConnectionStrings:His)</summary>
/// <summary>Oracle 접속 문자열 — 어느 원천에서도 못 찾으면 빈 문자열(파일 전용 모드)</summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>DB 프로바이더 — 기본 Oracle</summary>
public string Provider { get; set; } = "Oracle";
/// <summary>
/// 접속 정보의 출처 — "appsettings" 또는 "ServerInfo(병원라벨)". 진단·상태 표시용이며
/// <b>자격증명은 담지 않는다</b>(어느 DB 에 붙었는지만 알려준다).
/// </summary>
public string ConnectionSource { get; set; } = "없음";
/// <summary>캔버스 그리드 간격(px)</summary>
public int GridSize { get; set; } = 4;
/// <summary>정렬 가이드 스냅 임계값(px)</summary>
public int SnapThreshold { get; set; } = 6;
/// <summary>저장 모드 — File(기본, .xml 파일) | Db(E_SdgMst/E_SctMst 저장)</summary>
public string SaveMode { get; set; } = "File";
+174
View File
@@ -0,0 +1,174 @@
using System.Security.Cryptography;
using System.Text;
namespace SheetMe.Data.Config;
/// <summary>
/// 병원 설치본의 HIS DB 연결을 레거시 <b>ServerInfo</b>(<c>MSYSTECH_ServerInfo.ini</c>, M.CMM.ServerInfo 가 기록)에서
/// 읽어 Oracle 연결 문자열로 구성한다. 각 병원 설치본은 이 ini 의 <c>CurrentServer</c> 가 가리키는 DB 로 고정된다.
///
/// ini 값은 레거시 <c>bzStringCryptography</c>(MD5 → 2-key 3DES-ECB, 기본키 "Msystech")로 암호화되어 있어 동일 규칙으로 복호한다.
/// 섹션 <c>[MSYSTECHHIS\ServerList\DBServer]</c> 의 CurrentServer → 엔트리
/// <c>[MSYSTECHHIS\ServerList\DBServer\{name}]</c> 의 HOST/PORT/SERVICE_NAME/UserID/Password 를 EZConnect 로 조립한다.
/// 읽기 전용 — 레거시 산출물을 변경하지 않는다.
///
/// <para>
/// <b>출처</b>: <c>[200]SheetMe\src\Data\SheetMe.Data\ServerInfoReader.cs</c> 에서 복사 이식(vendoring).
/// 두 저장소가 물리적으로 분리돼 ProjectReference 가 불가능하고, 레거시 암복호 규약은 변할 이유가 없는 고정 자산이다.
/// 원본이 바뀌면 여기도 함께 갱신해야 한다.
/// </para>
///
/// <para>
/// <b>보안</b>: 복호 결과와 완성된 연결 문자열은 어떤 로그·화면에도 기록하지 않는다.
/// 복호 키가 상수인 것은 레거시와 동일하다 — 이 키는 이미 [000]bin 의 모든 실행 파일에 들어 있어 비밀이 아니며,
/// 설정 필수로 만들면 "ini 는 있는데 못 읽는" 새로운 실패 모드만 생긴다.
/// </para>
/// </summary>
public static class ServerInfoReader
{
#region Member Fields
/// <summary>기본 복호 키 — 레거시 HKLM\SOFTWARE\MSYSTECH\Company\Name 기본값</summary>
public const string DefaultKey = "Msystech";
private const string IniName = "MSYSTECH_ServerInfo.ini";
private const string DbSection = @"MSYSTECHHIS\ServerList\DBServer";
/// <summary>ini 탐색 상한 — 배포 폴더에서 [000]bin 까지 거슬러 올라가기에 충분한 깊이</summary>
private const int MaxParentProbe = 9;
#endregion
#region Methods
/// <summary>ServerInfo ini(명시 경로 또는 BaseDirectory→상위 탐색)에서 현재 서버의 연결 문자열 구성 — 실패 시 null</summary>
public static string? TryBuildConnectionString(string? iniPath = null, string? key = null)
{
var path = ResolveIniPath(iniPath);
if (path is null)
{
return null;
}
try
{
var lines = File.ReadAllLines(path);
var current = ReadValue(lines, DbSection, "CurrentServer");
if (string.IsNullOrWhiteSpace(current))
{
return null;
}
var entry = DbSection + "\\" + current.Trim();
var decryptKey = string.IsNullOrEmpty(key) ? DefaultKey : key;
string? Decode(string param)
{
var raw = ReadValue(lines, entry, param);
return string.IsNullOrEmpty(raw) ? null : Decrypt(raw, decryptKey);
}
var host = Decode("HOST");
var port = Decode("PORT");
var service = Decode("SERVICE_NAME");
var user = Decode("UserID");
var password = Decode("Password");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(service) || string.IsNullOrWhiteSpace(user))
{
return null;
}
var resolvedPort = string.IsNullOrWhiteSpace(port) ? "1521" : port!.Trim();
return $"User Id={user};Password={password};Data Source={host!.Trim()}:{resolvedPort}/{service!.Trim()}";
}
catch
{
// ini 손상·권한 오류 등은 '설정 없음'과 동일하게 다룬다 — 상위가 다음 우선순위로 넘어간다
return null;
}
}
/// <summary>현재 서버 라벨(CurrentServer) — 어느 병원 DB 에 붙었는지 로그·상태 표시용(자격증명 아님)</summary>
public static string? CurrentServerName(string? iniPath = null)
{
var path = ResolveIniPath(iniPath);
if (path is null)
{
return null;
}
try
{
var value = ReadValue(File.ReadAllLines(path), DbSection, "CurrentServer");
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
catch
{
return null;
}
}
/// <summary>ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)</summary>
private static string? ResolveIniPath(string? iniPath)
{
if (!string.IsNullOrWhiteSpace(iniPath))
{
return File.Exists(iniPath) ? iniPath : null;
}
var dir = AppContext.BaseDirectory;
for (var i = 0; i < MaxParentProbe && !string.IsNullOrEmpty(dir); i++)
{
var candidate = Path.Combine(dir, IniName);
if (File.Exists(candidate))
{
return candidate;
}
dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
}
return null;
}
/// <summary>INI 의 [section] 내 key 값(대소문자 무시, 첫 매치). 백슬래시를 포함한 섹션명을 그대로 비교한다</summary>
private static string? ReadValue(string[] lines, string section, string param)
{
var inSection = false;
foreach (var raw in lines)
{
var line = raw.Trim();
if (line.Length == 0 || line[0] == ';')
{
continue;
}
if (line[0] == '[' && line[^1] == ']')
{
inSection = string.Equals(line[1..^1], section, StringComparison.OrdinalIgnoreCase);
continue;
}
if (!inSection)
{
continue;
}
var eq = line.IndexOf('=');
if (eq <= 0)
{
continue;
}
if (string.Equals(line[..eq].Trim(), param, StringComparison.OrdinalIgnoreCase))
{
return line[(eq + 1)..].Trim();
}
}
return null;
}
/// <summary>레거시 bzStringCryptography.Decrypt 복제 — MD5(Unicode(key)) 16바이트 → 3DES-ECB(PKCS7) → Unicode 문자열</summary>
public static string Decrypt(string base64, string key)
{
using var md5 = MD5.Create();
var keyBytes = md5.ComputeHash(Encoding.Unicode.GetBytes(key)); // 16바이트 = 2-key 3DES
using var tripleDes = TripleDES.Create();
tripleDes.Key = keyBytes;
tripleDes.Mode = CipherMode.ECB;
tripleDes.Padding = PaddingMode.PKCS7;
var cipher = Convert.FromBase64String(base64);
using var decryptor = tripleDes.CreateDecryptor();
return Encoding.Unicode.GetString(decryptor.TransformFinalBlock(cipher, 0, cipher.Length));
}
#endregion
}
@@ -1,31 +0,0 @@
using M.Framework.TableFramework;
using M.MW.Data.EMR;
namespace SheetMe.Data.DataContexts;
/// <summary>
/// EMR 서식 마스터 데이터 컨텍스트 — M.MW.Data.EMR 기존 엔티티 재사용(레거시 테이블과 1:1).
/// 목록성 LINQ 조회 기반. CLOB/채번/트랜잭션 저장은 raw ODP.NET 스토어가 담당([200]SheetMe 검증 분업).
/// </summary>
public sealed class EmrDataContext : AbstractDataContext
{
#region Properties
/// <summary>서식 마스터 (E_ShtMst)</summary>
public DataSet<E_ShtMst> SheetMasters { get; }
/// <summary>서식 디자인 버전 (E_SdgMst)</summary>
public DataSet<E_SdgMst> SheetDesigns { get; }
/// <summary>서식 컨트롤 마스터 (E_SctMst)</summary>
public DataSet<E_SctMst> SheetControls { get; }
#endregion
#region Constructors
public EmrDataContext(string provider, string connectionString) : base(provider, connectionString)
{
SheetMasters = new(access);
SheetDesigns = new(access);
SheetControls = new(access);
}
#endregion
}
+6 -4
View File
@@ -11,10 +11,12 @@
</ItemGroup>
<ItemGroup>
<!-- [200]SheetMe에서 검증된 조합과 동일 버전 고정 (M.MW.Data.EMR: E_ShtMst/E_SdgMst/E_SctMst 엔티티) -->
<PackageReference Include="M.Framework.DBAccess" Version="9.3.15.1" />
<PackageReference Include="M.Framework.TableFramework" Version="6.0.13.2" />
<PackageReference Include="M.MW.Data.EMR" Version="3.0.0.3" />
<!--
DB 접근은 전부 raw Oracle.ManagedDataAccess.Client 로 한다.
M.Framework.DBAccess / TableFramework / M.MW.Data.EMR 은 EmrDataContext 하나만 참조했는데
그 클래스가 어디서도 인스턴스화되지 않아 함께 제거했다(DB 프로바이더 5종 + Azure/MSAL 전이 의존 약 9MB 감소).
TableFramework 기반 엔티티 접근이 다시 필요해지면 그때 되살린다.
-->
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.100" />
</ItemGroup>