From 222d1987d54b71433b0d6e1b6f0ae9c2b87cfab0 Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 19 Aug 2026 11:23:49 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=98=EC=9E=90=20=EA=B2=80=EC=83=89=C2=B7?= =?UTF-8?q?=EB=82=B4=EC=9B=90=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9E=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=20=EA=B3=A0=EC=B9=9C=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?5=EC=B4=88=20=EB=91=90=20=EA=B3=B3=EC=9D=B4=20=EA=B0=81?= =?UTF-8?q?=EA=B0=81=20441ms=C2=B71ms=20=EA=B0=80=20=EB=90=90=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "검색 및 선택하면 시간이 너무 오래걸려" — 체감이 아니라 숫자였다. 진단에 시간을 재는 줄을 먼저 넣었다. 어디가 느린지 숫자 없이 고치면 엉뚱한 곳을 만진다. ## 실측 (--db-patient) 성명 검색 5,025ms → 441ms ListVisits 5,000ms 내외 → 1~45ms 문맥 읽기 557ms (그대로 — 병목이 아니었다) ## 검색 — 보이지도 않는 값에 5초를 쓰고 있었다 최종내원일시와 재원여부를 파생표 두 개로 붙였는데 둘 다 P_ComInf 를 통째로 GROUP BY 한 뒤에야 조인된다. 조건에 맞는 환자가 몇 명이든 내원 테이블 전체를 집계했다. 그리고 최종내원일시는 목록 열에 없다 — 차트번호·성명·주민번호·휴대전화·비고뿐이다. 아무도 보지 않는 값을 위해 5초를 쓰고 있었다. 뺐다. 재원여부는 비고 배지에 쓰므로 남기되, 행 상한을 먼저 걸고 돌아온 환자에 대해서만 따로 읽는다(FillInpatient). 왕복이 하나 늘지만 집계 범위가 전체 → 최대 200명으로 줄어든다. 차트번호는 바인드로 넘긴다 — 이어 붙이면 레거시의 주입 구멍을 되살린다. ## 내원 조회 — 원인을 한 번 잘못 짚었다 마스터 셋(M_DepMst·M_UidMst·M_InsMst)을 ROW_NUMBER 파생표로 접은 것이 문제라고 보고 상관 스칼라(MAX ... KEEP DENSE_RANK FIRST)로 바꿨다. 결과는 같고 정렬이 사라지는데 시간은 그대로 5초였다. 진짜 원인은 WHERE 였다 — TRIM(A.ComChtNum) = :c 는 컬럼에 함수를 씌워 인덱스를 못 타고 P_ComInf 전건을 스캔한다. 내원이 한 건인 환자도 5초가 걸린 이유다. LIKE 접두로 인덱스 범위 스캔을 살리고, 그것만으로는 '123' 이 '1234' 까지 잡으므로 TRIM 등호를 함께 둬서 넘친 행을 걸러 낸다. 5,000ms → 1ms. 측정을 안 했으면 마스터 조인만 고치고 "고쳤다"고 보고했을 것이다. 그 변경도 유지한다(정렬이 사라지는 것은 이득이다) — 다만 그것이 원인은 아니었다. ## 게이트 - --db-patient ①~㉑ 전건 통과(판정 23건 유지 — 결과가 달라지지 않았다) - dotnet test 336/336 · --edit-smoke 실패 0 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Opus 5 --- src/SheetMe.Data/Stores/PatientVisitStore.cs | 114 ++++++++++++++----- src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 14 ++- 2 files changed, 96 insertions(+), 32 deletions(-) diff --git a/src/SheetMe.Data/Stores/PatientVisitStore.cs b/src/SheetMe.Data/Stores/PatientVisitStore.cs index 1422989..9c94291 100644 --- a/src/SheetMe.Data/Stores/PatientVisitStore.cs +++ b/src/SheetMe.Data/Stores/PatientVisitStore.cs @@ -116,26 +116,22 @@ public sealed class PatientVisitStore using var connection = Open(); using var command = connection.CreateCommand(); command.BindByName = true; + // 처음에는 최종내원일시와 재원여부를 파생표 두 개로 붙였다. + // 둘 다 P_ComInf 를 통째로 GROUP BY 한 뒤에야 조인되므로, + // 조건에 맞는 환자가 몇 명이든 내원 테이블 전체를 집계했다 — 실측 5,025ms. + // + // 최종내원일시는 화면에 쓰지도 않았다(목록 열은 차트번호·성명·주민번호·휴대전화·비고). + // 보이지도 않는 값을 위해 5초를 쓰고 있었다. 빼면 그 집계가 사라진다. + // + // 재원여부는 비고 배지에 쓰므로 남기되, 여기서 계산하지 않는다 — + // 행 상한을 먼저 걸고 돌아온 환자에 대해서만 따로 읽는다(아래 FillInpatient). command.CommandText = "SELECT * FROM (" + " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam," + " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked," + " NVL(PatMblPhn,' ') Mbl," - + " NVL(B.LastVisit,' ') LastVisit," - + " NVL(I.ComNum, 0) InP," + " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon" - + " FROM P_PatInf A" - // 최종 내원일시 — 레거시와 같은 서브쿼리(취소 내원 제외) - + " LEFT JOIN (SELECT ComChtNum, MAX(ComAcpDtm) LastVisit FROM P_ComInf" - + " WHERE ComAcpStt <> 'OC' GROUP BY ComChtNum) B" - + " ON A.PatChtNum = B.ComChtNum" - // 지금 재원 중인가 — 레거시는 오늘 날짜를 클라이언트에서 넣지만 여기서는 DB 시각을 쓴다 - + " LEFT JOIN (SELECT ComChtNum, MIN(ComNum) ComNum FROM P_ComInf" - + " WHERE ComPatTyp = 'I' AND ComAcpStt NOT IN ('OC','IC')" - + " AND TO_CHAR(SYSDATE,'YYYYMMDD') || '0000' <= ComLevDtm" - + " AND ComAcpDtm <= TO_CHAR(SYSDATE,'YYYYMMDD') || '2359'" - + " GROUP BY ComChtNum) I" - + " ON A.PatChtNum = I.ComChtNum" + + " FROM P_PatInf" + $" WHERE {where}" + " ORDER BY PatNam, PatChtNum" + $") WHERE ROWNUM <= {max}"; @@ -150,12 +146,64 @@ public sealed class PatientVisitStore reader.GetString(1).Trim(), reader.GetString(2).Trim(), reader.GetString(3).Trim(), - reader.GetString(4).Trim(), - reader.GetDecimal(5) > 0, - reader.GetString(6).Trim() == "Y", - reader.GetString(7).Trim() != "N")); + // 최종내원일시는 더 이상 읽지 않는다 — 화면에 없는 값이었고 집계가 5초였다 + string.Empty, + false, + reader.GetString(4).Trim() == "Y", + reader.GetString(5).Trim() != "N")); } - return result; + reader.Close(); + return FillInpatient(connection, result); + } + + /// + /// 돌아온 환자들의 재원 여부만 한 번 더 읽는다 — 비고 배지에 쓴다. + /// + /// 검색 SQL 안에서 계산하면 조건에 맞는 환자가 몇 명이든 P_ComInf 전체를 집계한다. + /// 여기서는 행 상한이 이미 걸린 뒤라 차트번호 목록으로 한정해서 읽는다. + /// 왕복이 하나 늘지만 집계 범위가 전체 → 최대 200명으로 줄어든다. + /// + /// 차트번호는 바인드로 넘긴다 — 값을 SQL 에 이어 붙이면 레거시의 주입 구멍을 되살린다. + /// + private static List FillInpatient( + OracleConnection connection, List patients) + { + if (patients.Count == 0) + { + return patients; + } + using var command = connection.CreateCommand(); + command.BindByName = true; + var names = new List(); + for (var i = 0; i < patients.Count; i++) + { + var name = "c" + i; + names.Add(":" + name); + command.Parameters.Add(new OracleParameter(name, patients[i].ChtNum)); + } + command.CommandText = + "SELECT DISTINCT TRIM(ComChtNum) ChtNum FROM P_ComInf" + + $" WHERE TRIM(ComChtNum) IN ({string.Join(",", names)})" + + " AND ComPatTyp = 'I' AND ComAcpStt NOT IN ('OC','IC')" + + " AND TO_CHAR(SYSDATE,'YYYYMMDD') || '0000' <= ComLevDtm" + + " AND ComAcpDtm <= TO_CHAR(SYSDATE,'YYYYMMDD') || '2359'"; + + var inpatients = new HashSet(StringComparer.Ordinal); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + inpatients.Add(reader.GetString(0).Trim()); + } + } + for (var i = 0; i < patients.Count; i++) + { + if (inpatients.Contains(patients[i].ChtNum)) + { + patients[i] = patients[i] with { Inpatient = true }; + } + } + return patients; } /// @@ -183,24 +231,30 @@ public sealed class PatientVisitStore + " SELECT A.ComNum," + " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind," + " NVL(A.ComAcpDtm,' ') AcceptedAt, NVL(TRIM(A.ComLevDtm),' ') LeftAt," - + " NVL(Dep.Nam,' ') Dep, NVL(Doc.Nam,' ') Dtr, NVL(Ins.Nam,' ') Ins," + + " NVL((SELECT MAX(DepKorNam) KEEP (DENSE_RANK FIRST ORDER BY DepStrDte DESC)" + + " FROM M_DepMst WHERE TRIM(DepCod) = TRIM(B.CodDepCod)),' ') Dep," + + " NVL((SELECT MAX(UidNam) KEEP (DENSE_RANK FIRST ORDER BY UidStrDte DESC)" + + " FROM M_UidMst WHERE TRIM(UidCod) = TRIM(B.CodDtrCod)),' ') Dtr," + + " NVL((SELECT MAX(InsNam) KEEP (DENSE_RANK FIRST ORDER BY InsStrDte DESC)" + + " FROM M_InsMst WHERE TRIM(InsCod) = TRIM(C.CoiInsCod)),' ') Ins," + " NVL(Stt.DtlCodNam,' ') Stt" + " FROM P_ComInf A" + " LEFT JOIN P_CodInf B ON A.ComNum = B.CodComNum AND B.CodMtiSeq = 0" + " LEFT JOIN P_CoiInf C ON A.ComNum = C.CoiComNum AND C.CoiMtiSeq = 0" - + " LEFT JOIN (SELECT TRIM(DepCod) Cod, DepKorNam Nam," - + " ROW_NUMBER() OVER (PARTITION BY TRIM(DepCod) ORDER BY DepStrDte DESC) Rn" - + " FROM M_DepMst) Dep ON Dep.Cod = TRIM(B.CodDepCod) AND Dep.Rn = 1" - + " LEFT JOIN (SELECT TRIM(UidCod) Cod, UidNam Nam," - + " ROW_NUMBER() OVER (PARTITION BY TRIM(UidCod) ORDER BY UidStrDte DESC) Rn" - + " FROM M_UidMst) Doc ON Doc.Cod = TRIM(B.CodDtrCod) AND Doc.Rn = 1" - + " LEFT JOIN (SELECT TRIM(InsCod) Cod, InsNam Nam," - + " ROW_NUMBER() OVER (PARTITION BY TRIM(InsCod) ORDER BY InsStrDte DESC) Rn" - + " FROM M_InsMst) Ins ON Ins.Cod = TRIM(C.CoiInsCod) AND Ins.Rn = 1" + // 처음에는 마스터 셋을 ROW_NUMBER 파생표로 접었다. 기간 PK 중복은 해결되지만 + // 마스터 전체를 정렬한 뒤에야 조인되므로 내원이 한 건인 환자도 5초가 걸렸다(실측). + // 상관 스칼라로 바꾼다 — 내원 행마다 코드 하나를 인덱스로 찾는다. + // MAX(...) KEEP (DENSE_RANK FIRST ORDER BY 시작일 DESC) 가 "가장 최근 것 하나"를 + // 정렬 없이 뽑아 주고, 결과는 파생표 방식과 같다(중복도 생기지 않는다). + " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt" - + " WHERE TRIM(A.ComChtNum) = :c" + // 컬럼에 TRIM 을 씌우면 인덱스를 못 탄다 — P_ComInf 전건 스캔이 되어 + // 내원이 한 건인 환자도 5초가 걸렸다(마스터 조인을 고친 뒤에도 그대로였다). + // LIKE 접두로 인덱스 범위 스캔을 살리고, 그것만으로는 '123' 이 '1234' 까지 잡으므로 + // TRIM 등호를 함께 둬서 넘친 행을 걸러 낸다. + + " WHERE A.ComChtNum LIKE :p AND TRIM(A.ComChtNum) = :c" + " ORDER BY A.ComAcpDtm DESC" + $") WHERE ROWNUM <= {max}"; + command.Parameters.Add(new OracleParameter("p", key + "%")); command.Parameters.Add(new OracleParameter("c", key)); LastSql = command.CommandText; diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index f0fc1dc..18b42d9 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -1893,15 +1893,18 @@ public static class DbSmoke { var store = new PatientVisitStore(ConfigService.Current.ConnectionString); - // 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다 + // 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다. + // 시간을 함께 잰다. "느리다"는 체감이고, 어디가 느린지는 숫자로만 안다. foreach (var kind in new[] { PatientSearchKind.ChartNumber, PatientSearchKind.Name, PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone, }) { + var watch = System.Diagnostics.Stopwatch.StartNew(); var found = store.Search(kind, term); - lines.Add($" {kind,-16} {found.Count}건"); + watch.Stop(); + lines.Add($" {kind,-16} {found.Count,4}건 {watch.ElapsedMilliseconds,6}ms"); } Check("① 검색 네 갈래가 예외 없이 돈다", true); @@ -1928,7 +1931,10 @@ public static class DbSmoke foreach (var candidate in people) { probed++; + var vw = System.Diagnostics.Stopwatch.StartNew(); visits = store.ListVisits(candidate.ChtNum); + vw.Stop(); + lines.Add($" ListVisits {vw.ElapsedMilliseconds}ms → {visits.Count}건"); if (visits.Count > 0) { break; @@ -1964,7 +1970,11 @@ public static class DbSmoke { var target = visits[0].ComNum; var contexts = new PatientContextStore(ConfigService.Current.ConnectionString); + // 환자를 고른 뒤 사람이 기다리는 구간이다 — 문맥 5행 + 시각 + 복호화까지 붙는다 + var loadWatch = System.Diagnostics.Stopwatch.StartNew(); var context = contexts.Load(target); + loadWatch.Stop(); + lines.Add($" 문맥 읽기 {loadWatch.ElapsedMilliseconds}ms (환자를 고른 뒤 기다리는 구간)"); Check("⑧ 문맥 5행 조회가 예외 없이 돈다", context is not null, $"내원 {target:F0} 을 읽지 못했다(목록에는 있는데 다시 읽으면 없다)");