본문으로 건너뛰기

텔레그램 봇 메시지 서식 완벽 가이드: MarkdownV2 이스케이프 18문자와 400 에러 해결

텔레그램 알림봇을 만들다가 400 Bad Request: can't parse entities 에러에 막혀보신 적 있으신가요? 분명 굵게 표시만 하고 싶었을 뿐인데, 메시지에 마침표 하나 들어갔다고 봇이 메시지를 거부합니다. 이게 바로 텔레그램 봇 개발자가 가장 자주 부딪히는 함정인 MarkdownV2 이스케이프 문제예요.

이 글에서는 텔레그램 봇 메시지 서식의 핵심인 parse_mode 3종을 비교하고, 400 에러의 진짜 원인인 이스케이프 18문자 규칙, 그리고 동적 입력을 안전하게 다루는 HTML 모드와 escape 자체를 피하는 entities 배열 대안까지 실무 기준으로 정리합니다. 모든 내용은 텔레그램 공식 문서(Bot API 10.1)를 직접 대조한 사실만 담았어요.

저희 QJC는 AI 자동화 컨설팅 1인 기업이라 매출 알림봇, 모니터링 봇 같은 자동화 봇을 자주 만드는데요, 실무에서 막히는 지점을 그대로 풀어드리겠습니다.

parse_mode 3종 비교: MarkdownV2 vs HTML vs legacy Markdown

텔레그램 봇이 메시지에 서식을 입히는 방법은 sendMessage(그리고 caption 등) 호출 시 parse_mode 필드를 지정하는 거예요. 사용할 수 있는 값은 정확히 3가지입니다.

parse_mode상태권장 여부공식 문구
MarkdownV2현행✅ 신규 개발 권장"To use this mode, pass MarkdownV2 in the parse_mode field."
HTML현행✅ 권장 (특히 동적 입력)"To use this mode, pass HTML in the parse_mode field."
Markdown (legacy)레거시❌ 하위 호환 전용"This is a legacy mode, retained for backward compatibility."

여기서 핵심은 legacy Markdown은 신규 코드에 쓰지 말라는 거예요. 공식 문서가 "backward compatibility" 목적으로만 유지한다고 명시하고 있고, 한계도 분명합니다.

  • entity 중첩 불가 — 공식 문구 그대로 "Entities must not be nested, use parse mode MarkdownV2 instead"
  • underline, strikethrough, spoiler, blockquote, expandable blockquote, custom emoji 등을 표현할 수 없음

그러니 지금 새로 봇을 만든다면 선택지는 사실상 MarkdownV2 또는 HTML 둘 중 하나입니다.

결론부터: 정적인 안내 메시지는 MarkdownV2가 편하고, 사용자 입력이나 DB 값처럼 동적 텍스트를 끼워넣어야 하면 HTML이 escape 규칙이 단순해서 훨씬 안전합니다.

참고로 parse_mode는 Optional이라 생략하면 텍스트가 plain text로 전송됩니다. 이 경우 *_ 같은 마크업 문자가 그대로 노출되지만, plain URL이나 이메일, @멘션, #해시태그는 클라이언트가 알아서 entity로 감지해줘요.

MarkdownV2 문법 전체 정리

MarkdownV2가 지원하는 서식을 표로 정리하면 다음과 같습니다. 텔레그램 공식 문서의 예시를 그대로 대조한 매핑이에요.

서식MarkdownV2 문법
bold (굵게)*굵게*
italic (기울임)_기울임_
underline (밑줄)__밑줄__
strikethrough (취소선)~취소선~
spoiler (스포일러)||스포일러||
inline code`코드`
code block``` ... ``` (언어 지정은 ```python)
inline link[텍스트](URL)
inline mention[텍스트](tg://user?id=123456789)
custom emoji![👍](tg://emoji?id=...) (대체 이모지 alt 필수)
blockquote줄마다 > 접두
expandable blockquote**> 로 시작 + 마지막 줄 끝에 ||

몇 가지 실무에서 꼭 알아둘 포인트가 있어요.

underline와 italic 모호성 함정

MarkdownV2에서 ___...___처럼 밑줄 세 개로 감싸면 텔레그램은 이걸 항상 underline으로 greedy하게 파싱합니다. 그래서 italic과 underline을 중첩하려면 빈 bold(**)를 구분자로 끼워넣어야 해요. 공식 예시에서도 __underline italic bold___처럼 닫을 때 트릭이 필요합니다.

custom emoji 제약

![👍](tg://emoji?id=...) 형태의 커스텀 이모지는 아무 봇이나 쓸 수 있는 게 아니에요. 공식 문구를 보면 Fragment에서 추가 username을 구매한 봇이거나, 봇 소유자가 Telegram Premium 구독 중일 때만 사용할 수 있고, 대체 이모지(alt)를 반드시 넣어야 합니다.

expandable blockquote

펼칠 수 있는 인용구는 **>로 시작하고 마지막 줄 끝에 ||(expandability mark)를 붙입니다. 바로 앞에 일반 blockquote가 있으면 빈 bold entity(**)로 구분해야 해요.

이스케이프 18문자: 400 에러를 부르는 진짜 범인 (CRITICAL)

자, 이제 가장 중요한 부분입니다. 텔레그램 봇 개발에서 400 Bad Request: can't parse entities 에러가 나는 대부분의 원인이 바로 여기예요.

MarkdownV2 모드에서는 entity(서식 영역) 밖의 일반 텍스트에 다음 18개 특수문자가 escape 없이 나오면 텔레그램이 메시지 파싱에 실패합니다.

escape 대상 18문자: _ * [ ] ( ) ~ ` > # + - = | { } . !

공식 문서 원문은 이렇게 말합니다.

"In all other places characters '_', '*', '[', ']', '(', ')', '~', '', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'must be escaped with the preceding character''`."

문제는 이 18문자 안에 한국 개발자가 일상적으로 쓰는 문자들이 잔뜩 들어있다는 점이에요. 마침표(.), 하이픈(-), 느낌표(!), 괄호(( ))는 평범한 안내 문장에 거의 무조건 등장합니다.

예를 들어 이런 안내 문장을 그냥 보내면 바로 400 에러가 납니다.

설치 완료! 3-5분 소요.

마침표, 하이픈, 느낌표가 escape되지 않았기 때문이에요. 올바르게 보내려면 이렇게 백슬래시로 escape해야 합니다.

설치 완료\! 3\-5분 소요\.

Python으로 동적 텍스트를 escape하는 함수를 만들면 다음과 같아요.

import requests

def escape_markdown_v2(text: str) -> str:
    """MarkdownV2 entity 밖 18문자를 백슬래시로 escape한다."""
    special_chars = r"_*[]()~`>#+-=|{}.!"
    return "".join("\\" + ch if ch in special_chars else ch for ch in text)

BOT_TOKEN = "YOUR_BOT_TOKEN"
CHAT_ID = "123456789"

# 동적으로 들어온 안내 문구
status = "설치 완료! 3-5분 소요."
text = f"*배포 알림*\n{escape_markdown_v2(status)}"

resp = requests.post(
    f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
    json={
        "chat_id": CHAT_ID,
        "text": text,
        "parse_mode": "MarkdownV2",
    },
)
print(resp.status_code, resp.json())

추가로 알아둘 점은, 코드 1~126 사이의 문자는 어디서든 \로 escape할 수 있고, 백슬래시(\) 자신도 보통 \\로 escape해야 한다는 거예요.

entity 내부는 escape 규칙이 다르다 (두 번째 함정)

"어디서나 18개를 escape하면 되겠지"라고 생각하면 오히려 다른 함정에 빠집니다. entity 내부는 컨텍스트별로 escape 규칙이 달라요.

  • pre/code 블록 내부: 모든 `\만 escape
  • inline link / custom emoji의 (...) 내부: 모든 )\만 escape
  • 그 외 위치에서만 18문자 전체 escape

즉 URL 안에 들어간 .이나 -을 무턱대고 escape하면 오히려 링크가 깨집니다. escape 로직을 짤 때 "지금 이 문자가 entity 안인지 밖인지"를 구분해야 하는데, 이게 바로 MarkdownV2가 동적 입력에 까다로운 이유예요.

HTML 모드: escape는 단 3문자, 동적 입력에 안전한 선택

MarkdownV2의 escape가 복잡해서 부담스럽다면, HTML 모드가 훨씬 단순합니다. 텔레그램 HTML 모드에서 escape해야 하는 문자는 단 3개뿐이에요.

원문escape
<&lt;
>&gt;
&&amp;

태그나 HTML entity의 일부가 아닌 모든 <, >, &를 위처럼 치환하기만 하면 됩니다. 컨텍스트별로 규칙이 갈리지도 않아요. 그래서 사용자 입력, DB 값, 외부 API 응답처럼 무슨 문자가 들어올지 모르는 동적 텍스트를 메시지에 끼워넣을 때 HTML이 실무에서 훨씬 안전합니다.

HTML 모드가 지원하는 태그는 다음과 같아요. (공식 문서: "Only the tags mentioned above are currently supported")

<b>bold</b>, <strong>bold</strong>
<i>italic</i>, <em>italic</em>
<u>underline</u>, <ins>underline</ins>
<s>strikethrough</s>, <strike>...</strike>, <del>...</del>
<span class="tg-spoiler">spoiler</span>, <tg-spoiler>spoiler</tg-spoiler>
<a href="http://www.example.com/">inline URL</a>
<a href="tg://user?id=123456789">inline mention</a>
<tg-emoji emoji-id="5368324170671202286">👍</tg-emoji>
<code>inline fixed-width code</code>
<pre>pre-formatted fixed-width code block</pre>
<pre><code class="language-python">code in Python</code></pre>
<blockquote>Block quotation</blockquote>
<blockquote expandable>Expandable block quotation</blockquote>

Python에서 HTML 모드로 동적 텍스트를 안전하게 보내는 코드는 이렇게 짧아집니다.

import html
import requests

BOT_TOKEN = "YOUR_BOT_TOKEN"
CHAT_ID = "123456789"

# 사용자 입력처럼 무슨 문자가 올지 모르는 값
user_note = "조건: a < b && c > d"

# HTML escape는 표준 라이브러리 html.escape로 끝
safe_note = html.escape(user_note, quote=False)
text = f"<b>알림</b>\n{safe_note}"

resp = requests.post(
    f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
    json={
        "chat_id": CHAT_ID,
        "text": text,
        "parse_mode": "HTML",
    },
)
print(resp.status_code, resp.json())

표준 라이브러리 html.escape만 호출하면 된다는 점이 MarkdownV2와 가장 큰 차이예요. 코드 블록에 언어를 지정하려면 <pre><code class="language-python">...</code></pre> 형태로 중첩해야 하고, standalone <code>에는 언어를 지정할 수 없다는 점만 기억하세요.

entities 배열: escape 자체를 없애는 대안 (UTF-16 offset 주의)

escape를 아예 피하고 싶다면 parse_mode 대신 entities 배열을 직접 넘기는 방법이 있습니다. 텍스트는 plain text로 그대로 두고, 어느 위치에 어떤 서식을 입힐지를 MessageEntity 객체의 배열로 지정하는 방식이에요. parse 자체를 하지 않으니 escape도 필요 없습니다.

MessageEntity의 주요 필드는 다음과 같아요.

  • type: bold, italic, underline, strikethrough, spoiler, blockquote, expandable_blockquote, code, pre, text_link, text_mention, custom_emoji, date_time
  • offset: 서식 시작 위치 (UTF-16 code unit 기준)
  • length: 서식 길이 (UTF-16 code unit 기준)
  • 옵션 필드: url(text_link), user(text_mention), language(pre), custom_emoji_id

여기서 가장 중요하고 헷갈리는 함정이 offset과 length가 byte도 codepoint도 아닌 UTF-16 code unit 기준이라는 거예요.

  • BMP 영역의 한글 글자는 1 code unit
  • surrogate pair로 표현되는 이모지(예: 👍)는 2 code unit

이걸 잘못 계산하면 서식이 한두 글자씩 밀려서 엉뚱한 부분이 굵게 표시됩니다. Python에서 UTF-16 길이를 정확히 구하는 방법은 다음과 같아요.

import requests

BOT_TOKEN = "YOUR_BOT_TOKEN"
CHAT_ID = "123456789"

def utf16_len(s: str) -> int:
    """UTF-16 code unit 단위 길이 (이모지는 2로 계산)."""
    return len(s.encode("utf-16-le")) // 2

text = "배포 성공 🎉 v1.2.0"
# "v1.2.0"을 굵게 표시하고 싶다고 가정
prefix = "배포 성공 🎉 "          # 이모지가 2 units임에 주의
offset = utf16_len(prefix)
length = utf16_len("v1.2.0")

resp = requests.post(
    f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
    json={
        "chat_id": CHAT_ID,
        "text": text,
        "entities": [
            {"type": "bold", "offset": offset, "length": length},
        ],
    },
)
print(resp.status_code, resp.json())

len(prefix)(파이썬 문자열 길이)로 offset을 잡으면 이모지 때문에 1만큼 어긋나니, 반드시 UTF-16 기준으로 계산해야 합니다. 한글이 섞인 텍스트에서 offset만 정확히 맞추면 entities 배열이 가장 안전한 방법이에요.

언제 MarkdownV2를 쓰고, 언제 HTML을 쓸까

지금까지 살펴본 세 가지 방식을 실무 상황별로 정리하면 다음과 같습니다. 어떤 방식이 절대적으로 우월한 건 아니고, 상황에 따라 선택하는 게 맞아요.

상황권장 방식이유
사용자 입력/DB 값/동적 텍스트를 끼워넣음HTMLescape가 3문자뿐이라 안전하고 단순. MarkdownV2는 18문자 + 컨텍스트별 규칙이라 누락 위험이 큼
정적이고 간단한 봇 안내 메시지MarkdownV2 또는 HTML취향 차이. MarkdownV2가 타이핑이 짧음
escape를 아예 피하고 싶음entities 배열parse 불필요. 단 UTF-16 offset 계산이 정확해야 함
신규 개발 전반MarkdownV2 또는 HTMLlegacy Markdown은 중첩·underline·spoiler 등 미지원이라 금지

저희가 매출 알림봇이나 에러 모니터링 봇을 만들 때의 기본 전략은 이래요. 고정된 템플릿 부분은 MarkdownV2나 HTML로 작성하고, 변수로 들어가는 동적 값은 HTML escape로 감싸거나 entities 배열로 처리하는 거죠. 이렇게 하면 외부에서 어떤 문자가 들어와도 400 에러 없이 안정적으로 동작합니다.

마무리

텔레그램 봇 메시지 서식의 핵심을 다시 정리하면 이렇습니다.

  • parse_modeMarkdownV2, HTML, legacy Markdown 3종이고, 신규 개발은 앞의 두 개만 사용
  • 400 Bad Request: can't parse entities의 주범은 MarkdownV2의 이스케이프 18문자 — 특히 마침표, 하이픈, 느낌표, 괄호 누락
  • 동적 입력이 많다면 escape가 3문자뿐인 HTML 모드가 안전
  • escape를 아예 피하려면 entities 배열을 쓰되 UTF-16 offset 계산에 주의

자동화 봇을 만들다 보면 이런 서식 함정에서 시간을 꽤 잡아먹게 되는데요, 위 패턴만 익혀두면 400 에러 없이 깔끔한 알림봇을 만들 수 있어요. QJC는 AI 자동화 컨설팅 1인 기업으로, 이런 실무 자동화 봇 구축을 도와드리고 있습니다. 비슷한 막힘이 있으시면 언제든 편하게 문의해주세요.

자주 묻는 질문 (FAQ)

Q: 왜 텔레그램 봇에서 400 Bad Request: can't parse entities 에러가 나나요?

대부분 MarkdownV2 모드에서 escape하지 않은 특수문자 때문이에요. MarkdownV2는 entity 밖 일반 텍스트의 18문자(_ * [ ] ( ) ~ \ > # + - = | { } . !)를 백슬래시로 escape해야 하는데, 안내 문장에 흔한 마침표·하이픈·느낌표·괄호를 그대로 보내면 파싱에 실패합니다. 해당 문자들을 `로 escape하면 해결돼요.

Q: MarkdownV2와 HTML 중 뭘 써야 하나요?

사용자 입력이나 DB 값처럼 동적 텍스트를 끼워넣는다면 HTML을 추천합니다. HTML은 escape가 <, >, & 단 3문자뿐이고 컨텍스트별 규칙도 없어서 표준 라이브러리 html.escape만으로 안전하게 처리할 수 있어요. 정적이고 간단한 안내 메시지라면 타이핑이 짧은 MarkdownV2도 괜찮습니다.

Q: 한글이 섞인 텍스트에서 entities 배열의 offset은 어떻게 계산하나요?

offsetlengthUTF-16 code unit 기준입니다. BMP 영역의 한글은 1 unit이지만 이모지(surrogate pair)는 2 unit으로 세야 해요. Python에서는 len(s.encode("utf-16-le")) // 2로 정확한 UTF-16 길이를 구할 수 있습니다. 파이썬 기본 len()은 codepoint 기준이라 이모지가 있으면 어긋납니다.

Q: legacy Markdown 모드는 쓰면 안 되나요?

신규 개발에는 권장하지 않습니다. 공식 문서가 하위 호환(backward compatibility) 목적으로만 유지한다고 명시했고, entity 중첩이 불가능하며 underline·strikethrough·spoiler·blockquote·custom emoji 등을 표현할 수 없어요. MarkdownV2나 HTML을 사용하세요.

Q: 커스텀 이모지(custom emoji)는 모든 봇이 쓸 수 있나요?

아니요. Fragment에서 추가 username을 구매한 봇이거나, 봇 소유자가 Telegram Premium 구독 중일 때만 사용할 수 있습니다. 또한 대체 이모지(alt)를 반드시 함께 지정해야 해요.

참고 자료