LLM이 뱉은 마크다운 예절 고치는 라이브러리 제작기

Halu·2026년 8월 18일

오픈소스

목록 보기
1/3
post-thumbnail

들어가며

모릭은 문서를 업로드하면 AI가 분석해서 요약/설명을 정리노트 형식의 마크다운 텍스트로 돌려주는 서비스입니다.

서비스를 운영하는 과정에서 AI가 뱉은 마크다운 응답을 프론트에 그대로 내려주다 보니, 렌더링이 종종 깨지는 경우를 자주 마주쳤습니다.

처음엔 프론트 쪽에서 방어 코드를 하나씩 추가하는 식으로 땜질했는데, 어느 순간 이 로직이 꽤 커졌다는 걸 깨달았습니다.

백엔드에서 응답을 내려주기 직전에 한 번 정리하는 게 맞겠다 싶어서 별도 모듈로 분리해뒀던 코드였는데, 이걸 다른 프로젝트에서도 쓸 수 있게 범용화해서 오픈소스로 내보면 어떨까 하는 생각이 문득 들었습니다.

문제 정의: LLM이 마크다운을 망가뜨리는 패턴들

실제로 겪은 패턴을 정리하면 이렇습니다.

  • **bold**text처럼 볼드/이탤릭 마커가 다음 단어에 그대로 붙어버림
  • 답변 전체가 ```markdown 코드펜스로 한 번 더 감싸져 있거나, 반대로 펜스가 끝까지 안 닫힘
  • 표가 한 줄로 뭉개지거나, 구분선(---)이 빠진 채로 옴
  • 셀 안에 |가 있으면 컬럼 수 계산이 틀어져서 표 자체가 깨짐
  • 리스트 들여쓰기가 응답 안에서도 일관성이 없음

사이드 프로젝트 코드를 라이브러리로 분리하기로 한 이유

모릭 코드 안에 묻혀 있던 마크다운 정리 함수는 순수하게 문자열을 입력받아 문자열을 반환하는 형태라, 모릭 고유의 도메인 로직(위키 문법 등)만 걷어내면 그대로 독립된 라이브러리가 될 수 있는 구조였습니다.

레딧 등 커뮤니티에서 기존에 비슷한 문제를 해결해주는 라이브러리가 있는지 찾아봤는데, LLM이 뱉은 마크다운이 깨지는 경우를 후처리로 고쳐주는 라이브러리는 딱히 없더군요..

그래서 사이드 프로젝트 코드에서 도메인 의존성을 제거하고 범용 라이브러리로 분리하는 작업을 시작했습니다.

라이브러리를 만들며 세운 원칙들

라이브러리로 분리하기로 마음먹고 나서 나중에 코드가 커지고 나서 정하면 이미 늦을 것 같아 어떤 형태로 만들지를 두고 몇 가지 원칙을 고민한 결과입니다.

  • 의존성 0개:
    • 표준 라이브러리만 사용 (Python re, Java java.util.regex).
      • 이 라이브러리는 다른 사람의 백엔드 요청-응답 경로 한가운데에 들어갈 물건인데, 거기에 제가 고른 서드파티 의존성을 끼워넣는 건 도입하는 입장에서 부담일 수밖에 없다고 생각했습니다.
      • 의존성 충돌 걱정 없이 그냥 설치해서 바로 쓸 수 있어야 한다는 게 기준이었습니다.
  • 순수 함수 하나:
    • clean_markdown() / MarkdownSanitizer.clean() 함수/메서드 하나만 공개 API로 노출.
    • 내부 모듈(볼드 처리, 표 처리, 리스트 처리 등)을 전부 공개해버리면 나중에 로직을 리팩터링할 때마다 하위 호환을 신경 써야 합니다.
    • 진입점 하나만 계약으로 걸어두면 내부는 마음껏 갈아엎을 수 있다는 게 편했습니다.
  • Python 먼저, Java 포팅:
    • 모릭 백엔드가 Python(FastAPI)과 Java(Spring) 양쪽에서 LLM 응답을 다루고 있어서, 한쪽 언어로만 만들면 결국 다른 쪽에서 똑같은 로직을 또 짜게 될 게 뻔했습니다.
    • 처음부터 두 언어를 같은 동작으로 유지하는 걸 목표로 잡았습니다.
  • 모노레포 구조:
    • python/, java/ 두 디렉터리를 한 저장소 안에 두고, 같은 테스트 픽스처 기준으로 동작을 맞추는 방식을 택했습니다.
    • 저장소를 따로 두면 한쪽만 고치고 다른 쪽은 깜빡하는 일이 반드시 생길 것 같았고, 실제로 두 언어의 동작이 조금이라도 어긋나면 "어느 쪽이 맞는 동작이냐"는 질문 자체가 성립 안 되는 라이브러리가 되어버리니, 한 곳에서 같이 관리하는 게 맞다고 판단했습니다.

실제로 겪은 어려움: 경우의 수와의 싸움

솔직히 배포 파이프라인(PyPI, JitPack)을 세팅하는 과정에는 큰 어려움이 없었습니다. 진짜 시간이 많이 든 부분은 따로 있었는데, 마크다운이 깨지는 경우의 수가 생각보다 훨씬 많았다는 점입니다.

예를 들어 볼드 마커 뒤에 공백을 넣어주는 규칙 하나만 봐도, 코드 블록 안에 있는 kwargs는 건드리면 안 되고, 수식 안의 ...도 건드리면 안 되고, 표 셀 안에서는 또 다르게 처리해야 하고... 이런식으로 예외가 계속 늘어났습니다.

하나를 고치면 다른 케이스가 깨지는 걸 반복하면서, 결국 "코드펜스 내부인지", "수식 범위인지" 같은 상태를 문서 전체를 순회하며 추적하는 구조로 다시 짜야 했습니다.

_MATH_SPAN_RE = re.compile(r"\${1,2}[\s\S]*?\${1,2}")
# `$...$`는 CommonMark/GFM 스펙에 없는 관례라, "진짜 수식"과 "같은 줄에
# 우연히 나온 두 개의 금액"을 구분할 표준 문법이 없음 (예: "costs $5 and
# $10"을 그대로 두면 두 금액 사이 전체가 하나의 수식 스팬으로 잡혀버림).
# 진짜 LaTeX 수식에는 공백만으로 떨어진 평범한 단어 두 개가 나란히 오는
# 경우가 거의 없어서(LaTeX 명령어는 `\text{...}`로 감싸거나 연산자/중괄호로
# 구분됨), 그런 패턴이 보이면 수식이 아니라 우연히 달러 기호가 낀 일반
# 문장이라고 보고 보호 대상에서 제외함 — 완벽한 파서는 아니지만, 모든
# "$" 쌍을 무조건 수식으로 취급하는 것보다는 낫다는 판단.

def _normalize_emphasis_tokens(text: str) -> str:
    ...
    close_index = text.find(marker, index + len(marker))
    if close_index == -1:
        # 이 줄에는 짝이 맞는 닫는 마커가 없음 -- 이건 강조 문법이 아닐
        # 수도 있음 (Python의 `**kwargs`, 딕셔너리 언패킹 `**`, 혹은 그냥
        # 오타일 수도 있음). 그래서 조용히 지우지 않고 원본 문자 그대로
        # 통과시킴 -- 짝이 완전히 맞는 marker...marker 쌍만 정규화 대상.
        output.append(marker)
        index += len(marker)
        continue

볼드 뒤에 공백 하나 넣는 규칙 하나에도 "이게 수식 안인가", "짝이 맞는 마커인가", "Python 코드의 **kwargs는 아닌가" 같은 조건이 줄줄이 따라붙습니다. 처음엔 간단한 정규식 치환이면 될 줄 알았는데, 실제로는 이런 예외를 하나씩 발견할 때마다 로직을 다시 설계해야 했습니다.

기술적으로 어려운 로직은 아니었지만, "이 정도면 다 커버했겠지" 싶은 지점마다 새로운 예외가 나오는 경험을 반복하면서, 이런 종류의 텍스트 처리 문제는 복잡도가 로직 자체가 아니라 케이스의 개수에서 온다는 걸 체감했습니다.

앞으로 어떻게 풀어갈 생각인지

이 문제를 미리 다 예측해서 한 번에 완결된 규칙을 짜는 건 애초에 불가능하다고 생각합니다. 그래서 방향을 "완벽한 규칙 찾기"에서 "실제로 깨진 사례를 계속 모아서 테스트 케이스로 쌓기"로 바꿨습니다.

  • 지금도 규칙이 애매해서 잘못 고치면 오히려 정상적인 내용을 훼손할 수 있는 케이스(예: 서로 다른 길이로 중첩된 코드펜스가 충돌하는 경우)는 무리해서 자동으로 고치기보다, 이슈로 남겨두고 "고치지 않는 게 맞는 선택"으로 정리해두는 중입니다.
  • 이번 글을 쓰면서 만든 데모 예시에서도, 한글 조사(**이차전지**는**이차전지** 는)에 공백을 붙이면 오히려 부자연스러워지는 케이스를 새로 발견했습니다. 영어 기준으로 짠 규칙이 한국어에는 다르게 적용돼야 한다는 걸 이번에 체감해서, 이 부분을 다음 개선 대상으로 잡아두고 있습니다.
  • 커뮤니티에 제보되는 실제 사례를 계속 모아서 반영하는 걸 기본 원칙으로 하고 있고, 혼자 다 찾아내는 것보다 이 방식이 훨씬 안전하다고 생각합니다.

배포 과정

Python은 PyPI Trusted Publisher(OIDC)로 배포했고, GitHub Environment에 필수 리뷰어를 걸어서 태그를 푸시해도 제가 직접 승인해야 실제로 올라가게 해뒀습니다.

Java는 JitPack으로 배포했습니다. 모노레포라 저장소 루트에 빌드 파일이 없다 보니 처음엔 빌드를 못 찾겠다고 계속 실패했고, 루트에 jitpack.yml을 두고 cd java && ./gradlew build를 직접 지정해서 해결했습니다.

결과: 버전 히스토리

Java 쪽 버전 번호는 Python과 완전히 같이 가지 않습니다. 0.2.2는 Python만 문서가 바뀐 버전이라 Java는 그대로 뒀고, 표 공백 버그 수정(Python 기준 0.2.3)은 Java에도 실제 코드 수정이 있어서 java-v0.2.2로 같이 배포했습니다.

그래서 지금 기준 최신은 Python 0.2.3, Java java-v0.2.2이고, 둘 다 실제 동작은 같은 상태입니다.

마무리

텍스트 처리 문제의 난이도는 로직이 아니라 케이스의 개수에서 온다는 걸 배웠습니다.

볼드 공백 하나 넣는 규칙도 코드펜스인지, 수식인지, **kwargs인지 따져야 했던 것처럼, 처음부터 완결된 규칙을 짜려고 하기보다 실제로 깨진 사례를 하나씩 테스트 케이스로 쌓아가는 방식이 맞다는 걸 확인했습니다.


사이드 프로젝트 안에 묻혀 있던 코드도, 도메인 의존성만 걷어내면 생각보다 쉽게 독립된 도구가 될 수 있다는 걸 확인했습니다.

모릭을 위해 짠 코드였지 처음부터 라이브러리로 계획하고 만든 게 아니었는데도, 순수 함수 형태로 짜뒀던 덕분에 분리 자체는 어렵지 않았습니다. 앞으로 뭘 만들든 이렇게 순수하게 짜두면 나중에 선택지가 하나 더 생긴다는 걸 배웠습니다.


배포는 "코드를 올리는 일"이 아니라 "약속을 지키는 일"이라는 걸 체감했습니다.

PyPI에 한 번 배포한 버전의 설명은 되돌릴 수 없다는 걸 몰라서 README 링크 하나 때문에 패치 버전을 새로 냈던 것처럼, 배포 전에 한 번 더 확인하는 습관과 승인 게이트 같은 장치가 왜 필요한지 이번에 직접 겪고 나서야 체감했습니다.

요약

  • 사이드 프로젝트로 운영 중인 AI 서비스 모릭에서 반복적으로 마주친 "LLM이 생성한 마크다운이 렌더링 시 깨지는 문제"를 범용 라이브러리로 뽑아내 Python/Java 두 언어로 오픈소스 배포
  • 가장 어려웠던 건 기술적으로 복잡한 부분이 아니라, 마크다운이 깨지는 경우의 수 자체가 생각보다 많아서 그걸 하나하나 찾아내 예외 처리하는 과정
  • 향후 커뮤니티 등에서 예외 케이스를 찾아 지속적으로 업데이트 할 예정

궁금하신 분은 아래 링크에서 구경하시거나, 써보시고 이상한 점 있으면 이슈로 편하게 남겨주세요 🙌

0개의 댓글