장르: 구축 기록 + 발견 기록. 문서를 만들려고 코드를 읽었더니 문서가 아니라 제품을 고쳐야 할 곳이 나왔다. 가이드 자체보다 그 과정에서 드러난 것이 더 값졌다.

앞선 글에서 RAG 파이프라인의 각 단계를 사람이 검토하고 평가하는 관리자 콘솔을 만들었다. 문서, 청킹 프로필, 검색 실험, 평가, 벤치마크까지 화면이 여섯 개가 됐다.

화면이 늘어난 만큼 잘못 쓸 방법도 늘었다. 각 화면은 자기 일을 정확히 하지만, 여섯 개를 어떤 순서로 어떻게 엮어야 의미 있는 측정이 되는지는 어디에도 적혀 있지 않았다. 그래서 콘솔 안에 운영 가이드 화면을 하나 붙이기로 했다.

1. 왜 README가 아니라 화면인가

저장소에 문서가 없는 것은 아니었다. 있었고, 꽤 길었다. 문제는 읽어야 할 사람이 그 문서를 열지 않는다는 것이었다.

가이드가 필요해지는 순간은 정해져 있다. 지표가 이상하게 나왔을 때, 방금 만든 프로필이 계속 0문서로 보일 때, 어제 눌러둔 평가가 사라졌을 때다. 그 순간 사람은 콘솔 화면 앞에 있지 저장소 앞에 있지 않다.

그래서 화면으로 만들되 두 가지를 정했다.

백엔드를 호출하지 않는다. 이 화면이 가장 필요한 순간은 무언가 어긋났을 때이고, 그때는 대개 백엔드도 성치 않다. 가이드가 RAG 연결에 의존하면 정작 읽어야 할 때 오류 배너로 덮인다. 같은 이유로 백엔드 capability에 따라 내용을 감추지도 않았다. 지원하지 않는 기능의 설명을 숨기면 "왜 이 버튼이 없지"에 답하지 못한다.

본문을 데이터로 분리했다. 렌더 컴포넌트와 별개로 단계 배열을 타입 있는 데이터로 둔다. 이 화면은 앞으로 계속 고쳐 쓸 문서인데, 문구 수정이 JSX 편집이 되면 레이아웃을 깰 위험을 매번 지고 간다. 데이터로 두면 문구 수정은 문자열 교체이고, 링크가 실제 라우트를 가리키는지 같은 것을 테스트로 잠글 수 있다.

2. 순서와 함정을 같은 자리에 둔다

가이드는 실제로 밟는 순서를 따른다.

연결 → 문서 → 청킹 프로필 → 검색 실험 → 평가 → 벤치마크

각 단계마다 네 가지를 적었다. 무엇을 하는 단계인가, 왜 이 순서인가, 어떻게 하는가, 여기까지 됐는지 어떻게 아는가.

그리고 다섯 번째로 그 단계에서 실제로 저지르게 되는 정합성 파손을 붙였다. 처음에는 주의사항을 맨 뒤에 모을까 했지만, 읽는 시점과 저지르는 시점이 같아야 예방이 된다. 단계 안에 인라인으로 두는 편이 맞다.

함정은 전부 같은 세 칸으로 적는다.

내용
증상 사용자가 실제로 겪는 현상
왜 생기나 시스템이 그렇게 동작하는 이유
이렇게 피한다 규칙

"증상"을 첫 칸에 둔 것이 나중에 쓸모가 있었다. 뒤에 나온다.

3. 함정을 두 축으로 분류하다

25건을 모으고 나니 나열만으로는 읽히지 않았다. 긴 주의사항 목록은 아무도 끝까지 읽지 않는다. 두 축으로 분류했다.

첫 번째 축은 출처다. 백엔드 상태에서 오는가, 콘솔 조작에서 오는가, 두 시스템 사이의 시차에서 오는가. 어디를 고쳐야 하는지가 달라진다.

두 번째 축이 더 중요했다. 콘솔이 이 파손을 알려줄 수 있는가.

등급
화면이 알려줌 배너나 표시로 드러난다
흔적 있음 숫자를 해석하면 알 수 있다. 그냥 지나치기 쉽다
탐지 불가 화면상 정상으로 보인다. 규칙으로 막는 수밖에 없다

이 등급을 매기려면 코드를 실제로 확인해야 했다. "이럴 때 이런 일이 생길 것 같다"로는 등급을 매길 수 없다. 화면이 무엇을 보여주고 무엇을 안 보여주는지 알아야 한다.

그 확인 과정이 이 작업에서 가장 값진 부분이 됐다.

4. 문서를 쓰다 코드에서 잡은 것 세 개

등급을 매기려고 벤치마크 서비스와 평가 서비스를 읽었다. 세 가지가 나왔고, 셋 다 문서로 덮을 문제가 아니었다.

4-1. 골든셋은 구성을 가리지 않는다

이 콘솔의 골든셋은 저작하지 않고 평가 이력에서 파생한다. 사람이 검색 결과에 남긴 👍/👎가 그대로 정답 데이터가 되고, 벤치마크는 그 질의를 다시 돌려 채점한다.

케이스를 뽑는 쿼리를 보니 조건이 두 개뿐이었다.

stage in (retrieval, rerank)  AND  queryRunId is not null

구성 해시로 거르지 않는다. 즉 실험용 청킹 프로필이나 다른 검색 전략을 켜고 남긴 👍도 전부, 모든 구성의 벤치마크에 기대값으로 들어간다.

문서 초안에 나는 정반대로 적어뒀었다. "오버라이드를 켠 채 남긴 평가는 기본 구성 골든셋에 안 들어간다"고. 코드를 안 봤으면 틀린 가이드를 배포할 뻔했다.

더 나쁜 것이 하나 더 있었다. 케이스는 질의 문자열을 키로 병합된다. 같은 질문을 두 구성에서 평가하면 서로 다른 구성의 기대 청크가 한 케이스로 합쳐진다. 기대값 개수만 늘고, 어느 한 구성에서도 전부 맞힐 수 없다. Recall이 구조적으로 떨어지는데 화면에는 그냥 "성능이 나쁘다"로 보인다.

당연히 탐지 불가 등급이다.

4-2. 실행을 지우면 골든셋이 조용히 줄어든다

검색 실험의 실행 이력은 삭제할 수 있다. 실행에 달려 있던 평가는 어떻게 되는지 스키마를 봤다.

queryRunId  onDelete: SetNull

평가 자체는 남는다. 실행과의 연결만 끊긴다. 그런데 골든셋을 뽑는 쿼리는 queryRunId is not null을 요구한다. 질의 문장이 실행에서 오기 때문이다.

결과적으로 평가 목록에는 그대로 보이는데 골든셋에서만 빠진다. 평가 건수는 그대로인데 벤치마크 케이스 수가 줄어드는 것이 유일한 흔적이고, 그 둘을 나란히 보는 화면은 없다.

이것도 탐지 불가다.

4-3. 벤치마크 top_k는 8로 고정돼 있었다

검색 실험 화면에서는 top_k를 1부터 50까지 조절할 수 있다. 벤치마크는 어떤 값으로 도는지 확인하려고 클라이언트를 봤더니, 요청 본문에 topK를 아예 넣지 않고 있었다. 서버는 받은 값이 없으면 기본값 8을 쓴다. 화면에는 조절 컨트롤도 없다.

그래서 이런 일이 생긴다. 검색 실험에서 top_k를 20으로 넓혀 보다가 12위에 있던 좋은 청크에 👍를 남긴다. 그 기대값은 상위 8건만 채점하는 벤치마크에서 원리적으로 맞힐 수 없는 대상이 되어 Recall을 영구히 깎는다.

가이드에는 "골든셋으로 쓸 판정은 상위 8건 안에서 남긴다"고 적었지만, 이건 문서로 덮을 문제가 아니라 제품 쪽 결함에 가깝다. 벤치마크에 숫자 입력을 붙이거나, 최소한 "상위 8건 채점"을 화면에 명시해야 한다. 남은 일로 적어뒀다.

5. 증상으로 되짚는 표 — 저작하지 않고 파생시킨다

가이드는 순서대로 읽는 문서지만, 실제로 다시 여는 순간은 "지표가 이상한데 왜지?" 일 때다. 그때의 진입점은 흐름이 아니라 증상이다.

그래서 하단에 역추적 표를 뒀다. 왼쪽에 증상, 가운데에 의심할 지점, 오른쪽에 등급.

핵심은 이 표를 손으로 쓰지 않는다는 것이다. 단계별 함정 데이터에서 파생시킨다. 같은 내용을 두 곳에 쓰면 한쪽만 고쳐지는 순간 정합성 가이드가 스스로 정합성을 잃는다. 그건 곤란하다.

파생 규칙은 두 가지다.

  • 화면이 알려줌 등급은 싣지 않는다. 화면이 이미 말해주는 것을 증상으로 되짚을 일은 없다.
  • 탐지 불가를 먼저 놓는다. 원인일 확률이 높은 순서가 아니라, 놓쳤을 확률이 높은 순서다.

표를 만들고 나서야 눈에 띈 것이 있다. 증상 칸이 "없다. 평가는 정상적으로 저장된다"인 행이 셋 있다. 조회용 표에서 검색이 안 되는 행이다. 그런데 그 셋이 바로 최악의 케이스라 빼면 색인에서 사라진다. "증상으로는 못 잡는 것이 있다"를 표가 직접 보여주는 편이 낫다고 판단해 그대로 뒀다.

6. 가독성을 세 번 틀리다

여기서부터는 부끄러운 기록이다.

화면을 만들고 나서 "머리말과 본문이 구분되지 않는다"는 피드백을 받았다. 세 번 고쳤고 세 번 다 빗나갔다.

  1. 구분선을 넣었다. 그대로였다.
  2. 제목을 한 단 위 헤딩으로 올리고 요약을 아랫줄로 내렸다. 그대로였다.
  3. 아코디언 항목 사이 여백을 키웠다. 그대로였다.

네 번째에야 브라우저를 열어 실제로 쟀다. 원인은 머리말 하단과 본문 패널 상단이 4px 떨어져 있는 것이었다.

서브타이틀 하단  y = 400
본문 패널 상단   y = 404

맞닿은 두 면은 색을 아무리 갈라도 한 덩어리로 읽힌다. 내가 "면으로 나눴다"고 말한 것이 실제로는 면이 닿아 있었던 것이다. 여백을 20px로 벌리고 본문을 음영진 패널에 넣자 한 번에 해결됐다.

순서가 있다. 여백 → 면 → 선 → 타이포. 나는 거꾸로 갔다.

6-1. 위계가 셋인데 등급이 하나였다

다음 요청은 "요소 하나하나가 무엇을 의미하는지 검토해서 전체를 개선해달라"였다. 이번에는 추측하지 않고 페이지의 모든 텍스트를 스타일별로 묶어 세어봤다.

14px w500 #171717 하나에 134개 요소가 몰려 있었다. 그 안에 이 셋이 전부 들어 있었다.

실제 의미 당시 스타일
단계 내 섹션 헤더 (카드 4장을 담음) 14px w500 #171717
함정 카드 제목 14px w500 #171717
카드 안의 필드 라벨 (증상, 왜 생기나) 14px w500 #171717
본문 문장 14px w400 #171717

네 장의 카드를 담는 섹션 헤더와, 카드 하나 안의 한 칸짜리 라벨이 똑같이 생겼다. 본문과는 굵기 하나 차이였다.

네 등급으로 갈랐다. 특히 라벨을 본문보다 작고 흐리게 내린 것이 컸다. 라벨은 안내판이고 내용이 주인공인데, 그전에는 라벨이 내용보다 진했다.

6-2. 회색 위에 회색을 얹어 칩을 지웠다

가장 뼈아픈 건 이거다.

본문 패널 대비를 올리려고 카드 배경을 #f1f1f1에서 #e5e5e5로 바꿨다. 흰색 대비 14단계에서 26단계로. 대비는 확실히 좋아졌다.

그런데 그 패널 위에는 순서 번호를 담은 회색 칩이 있었다. 회색 칩의 배경이 정확히 #e5e5e5였다. 테두리도 없었다.

칩은 계속 렌더되고 있었다. 다만 완전히 투명했다. 대비를 얻는 대가로 그 위의 요소를 지운 것이고, 나는 알아차리지 못했다. 바로 다음 턴에 "전체 가독성 검토"를 하면서도 못 봤다. 텍스트 등급만 조사하고 배경 충돌은 안 봤기 때문이다.

사용자가 스크린샷을 보내며 "여기 숫자는 테두리가 있는 거야 없는 거야?"라고 물어서야 찾았다.

고칠 때는 칩을 되살리지 않고 걷어냈다. 이 화면에서 칩은 탐지 등급 전용 어휘로 쓰기로 했기 때문이다. 빨강은 탐지 불가, 노랑은 흔적 있음, 회색은 화면이 알려줌. 순서 번호까지 칩으로 찍으면 등급이 아닌 값이 등급처럼 읽힌다. 같은 이유로 함정의 출처 표시도 칩에서 일반 텍스트로 내렸다. 기본색 칩이 회색 칩보다 진해서 분류에 불과한 값이 심각도보다 무겁게 읽히는 위계 역전이 실제로 있었다.

같은 실수가 또 있는지 페이지 전체를 훑는 검사를 한 번 돌렸다. 배경이 부모와 같으면서 테두리가 없는 요소를 찾는 것이다. 결과는 0건이었고, 그 칩이 유일했다.

7. 테스트 두 건이 아무것도 검증하지 않고 있었다

접기 동작을 확인하다 더 큰 것이 나왔다.

쓰고 있던 디자인 시스템의 접기 컴포넌트는 접힌 콘텐츠를 DOM에서 지우지 않는다. CSS로만 감춘다. hiddenaria-hiddendata-state도 붙지 않는다. 조상 체인을 전부 찍어보고 확인했다.

그 말은 이 테스트가 무의미하다는 뜻이다.

it("첫 단계는 펼친 채로 시작한다", () => {
  render(<GuideClient />);
  expect(screen.getByText(STEPS[0].why)).toBeTruthy();  // 접혀 있어도 통과한다
});

접혀 있든 펼쳐져 있든 텍스트는 항상 DOM에 있다. 아무것도 검증하지 않은 채 초록불이 켜지고 있었다. 통과하는 테스트가 통과하는 이유를 확인하지 않으면 이런 것이 남는다.

펼침의 유일한 신호는 트리거의 aria-expanded였다. 전부 그 기준으로 다시 썼다.

const trigger = screen.getAllByRole("button")
  .find(b => b.textContent?.startsWith(제목));
expect(trigger.getAttribute("aria-expanded")).toBe("false");

같은 계열로 하나 더 걸렸다. 트리거 안에 넣은 스택의 width="100%"가 먹지 않는다. 트리거 래퍼가 콘텐츠 폭에 맞춰 줄어드는 블록이라, 100%가 줄어든 부모(412px) 기준으로 계산된다. 우측 정렬이 필요한 배치는 트리거에 두면 안 된다는 뜻이고, 컴포넌트 내부 구조라 우회할 방법이 없어 그대로 기록만 남겼다.

8. 함정 목록은 기본으로 접는다

마지막 조정은 단순하다. 한 단계에 함정이 4~5건이고 각 건이 세 문단이다. 펼친 채로 두면 "무엇을 하는 단계인가"가 함정 더미에 파묻힌다.

순서를 따라 읽을 때는 접혀 있고, 문제를 만났을 때 펼치는 것이 실제 사용 흐름이다. 기본 접힘으로 두고, 접힌 트리거에 건수와 탐지 불가 수를 얹어 열지 말지를 거기서 판단하게 했다.

이 섹션만 다른 요소들과 다르게 보여야 했다. 앞의 세 항목은 읽는 글이고 이것은 여는 서랍인데, 같은 헤딩으로 똑같이 생겨 있었다. 흰 카드로 감싸 회색 패널 위에 뜨는 유일한 컨트롤로 만들고, 경고 아이콘을 앞에 붙였다.

부수 효과로 접기 화살표 문제도 풀렸다. 화살표가 제목에서 820px 떨어진 오른쪽 끝에 있어 둘이 한 컨트롤로 읽히지 않았는데, 행에 카드 경계가 생기니 아코디언 행의 관습적 배치가 됐다. 세로 정렬은 재보니 어긋난 적이 없었다. 제목 중앙과 화살표 중앙이 정확히 같은 y값이었다.

9. 남은 것

  • 벤치마크 top_k 노출. 4-3의 결함. 숫자 입력을 붙이거나 최소한 "상위 8건 채점"을 화면에 명시해야 한다.
  • 골든셋의 구성 격리. 실험용 오버라이드로 남긴 평가가 기준 구성의 골든셋을 오염시키는 것을 지금은 규칙으로만 막는다. 평가 저장 시점의 구성 해시로 케이스를 거르는 것이 맞는지, 아니면 사람의 판정은 구성과 무관하다고 보는 것이 맞는지 결정하지 못했다.
  • 평가 건수와 골든셋 케이스 수를 나란히 보여주기. 4-2가 눈에 띄는 유일한 경로다.
  • 보이지 않는 요소 검사를 일회성으로 돌렸을 뿐 자동화하지 않았다. jsdom은 색을 계산하지 않아 단위 테스트로는 못 잡는다.

10. 교훈

문서를 쓰려면 코드를 읽어야 하고, 코드를 읽으면 문서가 아니라 제품을 고치게 된다. 등급을 매기겠다고 결정한 것이 결국 코드를 읽게 만들었다. "이럴 것 같다"로 적었으면 결함 셋 다 그대로 남았을 것이고, 틀린 문장 하나는 배포까지 갔을 것이다.

틀린 가이드는 없는 가이드보다 나쁘다. 이 콘솔의 평가 라벨에 대해 이미 같은 말을 적어둔 적이 있다. 라벨이 없으면 판단을 보류하지만 틀린 라벨은 확신을 갖고 틀린 방향으로 민다. 문서도 똑같다.

시각 문제는 화면을 보지 않고 고칠 수 없다. 세 번의 추측이 전부 빗나갔고, 브라우저에서 좌표 두 개를 재자 4px이 나왔다. 색과 좌표는 단위 테스트가 계산해주지 않는다. 실제 화면에서 getComputedStylegetBoundingClientRect를 읽는 것 말고 방법이 없다.

대비를 얻으면 그 위에 있던 것을 확인한다. 배경 하나를 바꾸는 것은 그 면 위의 모든 요소에 영향을 준다. 회색 위의 회색은 아무것도 아니다.

통과하는 테스트가 왜 통과하는지 확인한다. 초록불은 검증됐다는 뜻이 아니라 실패하지 않았다는 뜻이다. 그 둘은 다르다.

관련 문서