환자 검색·내원 조회를 재 보고 고친다 — 5초 두 곳이 각각 441ms·1ms 가 됐다

"검색 및 선택하면 시간이 너무 오래걸려" — 체감이 아니라 숫자였다.
진단에 시간을 재는 줄을 먼저 넣었다. 어디가 느린지 숫자 없이 고치면 엉뚱한 곳을 만진다.

## 실측 (--db-patient)

  성명 검색     5,025ms → 441ms
  ListVisits    5,000ms 내외 → 1~45ms
  문맥 읽기     557ms (그대로 — 병목이 아니었다)

## 검색 — 보이지도 않는 값에 5초를 쓰고 있었다

최종내원일시와 재원여부를 파생표 두 개로 붙였는데 둘 다 P_ComInf 를 <b>통째로 GROUP BY</b>
한 뒤에야 조인된다. 조건에 맞는 환자가 몇 명이든 내원 테이블 전체를 집계했다.

그리고 최종내원일시는 <b>목록 열에 없다</b> — 차트번호·성명·주민번호·휴대전화·비고뿐이다.
아무도 보지 않는 값을 위해 5초를 쓰고 있었다. 뺐다.

재원여부는 비고 배지에 쓰므로 남기되, 행 상한을 먼저 걸고 <b>돌아온 환자에 대해서만</b>
따로 읽는다(FillInpatient). 왕복이 하나 늘지만 집계 범위가 전체 → 최대 200명으로 줄어든다.
차트번호는 바인드로 넘긴다 — 이어 붙이면 레거시의 주입 구멍을 되살린다.

## 내원 조회 — 원인을 한 번 잘못 짚었다

마스터 셋(M_DepMst·M_UidMst·M_InsMst)을 ROW_NUMBER 파생표로 접은 것이 문제라고 보고
상관 스칼라(MAX ... KEEP DENSE_RANK FIRST)로 바꿨다. 결과는 같고 정렬이 사라지는데
<b>시간은 그대로 5초였다.</b>

진짜 원인은 WHERE 였다 — <c>TRIM(A.ComChtNum) = :c</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 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-19 11:23:49 +09:00
co-authored by Claude Opus 5
parent 4bdd583adb
commit 222d1987d5
2 changed files with 96 additions and 32 deletions
+84 -30
View File
@@ -116,26 +116,22 @@ public sealed class PatientVisitStore
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
// 처음에는 최종내원일시와 재원여부를 <b>파생표 두 개</b>로 붙였다.
// 둘 다 P_ComInf 를 통째로 GROUP BY 한 뒤에야 조인되므로,
// 조건에 맞는 환자가 몇 명이든 <b>내원 테이블 전체</b>를 집계했다 — 실측 5,025ms.
//
// 최종내원일시는 <b>화면에 쓰지도 않았다</b>(목록 열은 차트번호·성명·주민번호·휴대전화·비고).
// 보이지도 않는 값을 위해 5초를 쓰고 있었다. 빼면 그 집계가 사라진다.
//
// 재원여부는 비고 배지에 쓰므로 남기되, 여기서 계산하지 않는다 —
// 행 상한을 먼저 걸고 <b>돌아온 환자에 대해서만</b> 따로 읽는다(아래 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);
}
/// <summary>
/// 돌아온 환자들의 <b>재원 여부만</b> 한 번 더 읽는다 — 비고 배지에 쓴다.
///
/// 검색 SQL 안에서 계산하면 조건에 맞는 환자가 몇 명이든 P_ComInf 전체를 집계한다.
/// 여기서는 행 상한이 이미 걸린 뒤라 <b>차트번호 목록으로 한정</b>해서 읽는다.
/// 왕복이 하나 늘지만 집계 범위가 전체 → 최대 200명으로 줄어든다.
///
/// 차트번호는 <b>바인드로</b> 넘긴다 — 값을 SQL 에 이어 붙이면 레거시의 주입 구멍을 되살린다.
/// </summary>
private static List<PatientSummary> FillInpatient(
OracleConnection connection, List<PatientSummary> patients)
{
if (patients.Count == 0)
{
return patients;
}
using var command = connection.CreateCommand();
command.BindByName = true;
var names = new List<string>();
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<string>(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;
}
/// <summary>
@@ -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 중복은 해결되지만
// <b>마스터 전체를 정렬</b>한 뒤에야 조인되므로 내원이 한 건인 환자도 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 을 씌우면 <b>인덱스를 못 탄다</b> — P_ComInf 전건 스캔이 되어
// 내원이 한 건인 환자도 5초가 걸렸다(마스터 조인을 고친 뒤에도 그대로였다).
// LIKE 접두로 인덱스 범위 스캔을 살리고, 그것만으로는 '123' 이 '1234' 까지 잡으므로
// TRIM 등호를 <b>함께</b> 둬서 넘친 행을 걸러 낸다.
+ " 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;