모릭은 문서를 업로드하면 AI가 분석해서 요약/설명을 정리노트 형식의 마크다운 텍스트로 돌려주는 서비스입니다.
서비스를 운영하는 과정에서 AI가 뱉은 마크다운 응답을 프론트에 그대로 내려주다 보니, 렌더링이 종종 깨지는 경우를 자주 마주쳤습니다.

처음엔 프론트 쪽에서 방어 코드를 하나씩 추가하는 식으로 땜질했는데, 어느 순간 이 로직이 꽤 커졌다는 걸 깨달았습니다.
백엔드에서 응답을 내려주기 직전에 한 번 정리하는 게 맞겠다 싶어서 별도 모듈로 분리해뒀던 코드였는데, 이걸 다른 프로젝트에서도 쓸 수 있게 범용화해서 오픈소스로 내보면 어떨까 하는 생각이 문득 들었습니다.
실제로 겪은 패턴을 정리하면 이렇습니다.
**bold**text처럼 볼드/이탤릭 마커가 다음 단어에 그대로 붙어버림```markdown 코드펜스로 한 번 더 감싸져 있거나, 반대로 펜스가 끝까지 안 닫힘---)이 빠진 채로 옴|가 있으면 컬럼 수 계산이 틀어져서 표 자체가 깨짐
모릭 코드 안에 묻혀 있던 마크다운 정리 함수는 순수하게 문자열을 입력받아 문자열을 반환하는 형태라, 모릭 고유의 도메인 로직(위키 문법 등)만 걷어내면 그대로 독립된 라이브러리가 될 수 있는 구조였습니다.
레딧 등 커뮤니티에서 기존에 비슷한 문제를 해결해주는 라이브러리가 있는지 찾아봤는데, LLM이 뱉은 마크다운이 깨지는 경우를 후처리로 고쳐주는 라이브러리는 딱히 없더군요..
그래서 사이드 프로젝트 코드에서 도메인 의존성을 제거하고 범용 라이브러리로 분리하는 작업을 시작했습니다.
라이브러리로 분리하기로 마음먹고 나서 나중에 코드가 커지고 나서 정하면 이미 늦을 것 같아 어떤 형태로 만들지를 두고 몇 가지 원칙을 고민한 결과입니다.
re, Java java.util.regex). clean_markdown() / MarkdownSanitizer.clean() 함수/메서드 하나만 공개 API로 노출. 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 링크 하나 때문에 패치 버전을 새로 냈던 것처럼, 배포 전에 한 번 더 확인하는 습관과 승인 게이트 같은 장치가 왜 필요한지 이번에 직접 겪고 나서야 체감했습니다.
궁금하신 분은 아래 링크에서 구경하시거나, 써보시고 이상한 점 있으면 이슈로 편하게 남겨주세요 🙌