본문으로 건너뛰기

[Open WebUI] KaTeX 유니코드 정규식 사전 컴파일로 마크다운 렌더링 87% 병목 제거

PR 링크: open-webui/open-webui#22196 상태: Merged | 변경: +25 / -36

들어가며

Open WebUI의 마크다운 렌더링에서 수학 수식을 감지하는 katexStart 함수가 렌더링 시간의 약 87%를 차지하고 있었습니다. 원인은 유니코드 속성 이스케이프(\p{Script=Han}, \p{Script=Hangul} 등)를 포함한 정규식을 매 호출마다 새로 컴파일하고 있었기 때문입니다. 유니코드 정규식 컴파일은 일반 정규식 대비 훨씬 비용이 큽니다.

핵심 코드 분석

정규식 사전 컴파일

Before:

const f = index === 0 ||
  indexSrc.charAt(index - 1).match(
    new RegExp(`[${ALLOWED_SURROUNDING_CHARS}]`, 'u')
  );

After:

// Pre-compile the surrounding character regex once at module load time.
const ALLOWED_SURROUNDING_CHARS_REGEX = new RegExp(
  `[${ALLOWED_SURROUNDING_CHARS}]`, 'u'
);

// 함수 내에서는 사전 컴파일된 정규식 사용
if (i === 0 || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i - 1))) {
  return i;
}

katexStart 함수 재작성

기존에는 구분자 목록을 반복하면서 indexOf와 정규식 매칭을 중첩 수행했지만, 변경 후에는 문자 단위로 순회하면서 $\ 문자만 확인하는 방식으로 변경되었습니다.

Before:

function katexStart(src, displayMode: boolean) {
  let indexSrc = src;
  while (indexSrc) {
    // 각 구분자에 대해 indexOf 반복
    for (const delimiter of DELIMITER_LIST) { ... }
    // 매번 new RegExp 컴파일
    indexSrc.charAt(index - 1).match(new RegExp(`[${...}]`, 'u'));
    // 문자열 잘라내기 반복
    indexSrc = indexSrc.substring(...);
  }
}

After:

function katexStart(src, displayMode: boolean) {
  for (let i = 0; i < src.length; i++) {
    const ch = src.charCodeAt(i);
    if (ch === 36 /* $ */) {
      if (displayMode && src.charAt(i + 1) !== '$') continue;
      if (i === 0 || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i - 1))) {
        return i;
      }
    } else if (ch === 92 /* \ */) {
      // 빠른 문자 검사로 불필요한 정규식 매칭 건너뛰기
      const next = src.charAt(i + 1);
      if (displayMode) {
        if (next !== '[' && next !== 'b') continue;
      } else {
        if (next !== '(' && next !== 'c' && next !== 'p') continue;
      }
      if (i === 0 || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i - 1))) {
        return i;
      }
    }
  }
}

왜 이게 좋은가

  1. 정규식 컴파일 비용 제거: \p{Script=Han} 등 유니코드 속성 이스케이프를 포함한 정규식은 컴파일 비용이 매우 높습니다. 모듈 로드 시 한 번만 컴파일하여 이 비용을 완전히 제거했습니다.
  2. 알고리즘 개선: 구분자 목록 순회와 문자열 잘라내기를 제거하고, 단일 루프의 문자 코드 비교로 교체하여 불필요한 메모리 할당과 검색을 줄였습니다.
  3. charCodeAt 활용: 문자열 비교 대신 정수 비교(ch === 36)를 사용하여 미세하지만 일관된 성능 이득을 얻습니다.
  4. 마크다운 렌더링 전체 성능 향상: 수식이 포함된 LLM 응답의 스트리밍 렌더링이 체감될 정도로 빨라집니다.

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글