결론부터
- 1서식은 본문 속이 아니라 엔티티(종류·위치·길이) 목록으로 붙는다.
- 2위치·길이는 UTF-16 단위 — 이모지는 2칸 이상이라 밀리기 쉽다.
- 3봇은 parse_mode 나 엔티티 직접 지정 — 사용자 입력은 이스케이프.
서식은 글자 안이 아니라 옆에 붙는다
텔레그램 메시지의 굵게·기울임·링크 같은 서식은 본문 글자 속에 ** 나 태그로 남지 않습니다. 본문은 맨 글자 그대로이고, 서식은 엔티티(entity)라는 목록으로 따로 붙습니다. 엔티티 하나는 '종류 · 시작 위치(offset) · 길이(length)' 세 칸입니다.
본문: 안녕하세요 반갑습니다
엔티티: [{종류: 굵게, offset: 0, length: 5}] → '안녕하세요' 만 굵게
| 갈래 | 엔티티 종류(예) |
|---|---|
| 글자 모양 | 굵게 · 기울임 · 밑줄 · 취소선 · 스포일러(가림) |
| 코드 | 한 줄 코드 · 코드 블록(언어 이름 지정 가능) |
| 링크·사람 | 글자에 링크 걸기 · 사용자 언급 · 이름으로 언급(사용자명 없는 사람) |
| 그 밖 | 사용자 지정 이모지 · 인용 · 접히는 인용 |
주소·해시태그·@사용자명·/명령처럼 알아볼 수 있는 것은 서버가 알아서 엔티티를 붙여 줍니다. 엔티티는 겹쳐 쓸 수 있지만(굵은 기울임 등) 코드 블록처럼 안에 다른 서식을 넣을 수 없는 종류도 있습니다.
위치는 UTF-16 단위로 센다
가장 흔한 실수입니다. offset·length 는 '글자 수'가 아니라 UTF-16 단위로 셉니다.
| 글자 | UTF-16 단위 |
|---|---|
| 한글 한 글자 · 영문 한 글자 | 1 |
| 대부분의 이모지(기본 다국어 평면 밖 글자) | 2 |
| 피부색·가족처럼 여러 글자가 합쳐진 이모지 | 합친 만큼 — 4, 7, 11 … |
파이썬의 len() 처럼 글자(코드 포인트) 수를 세는 언어로 위치를 계산하면, 이모지 뒤부터 서식이 한두 칸씩 밀립니다. 문자열을 UTF-16 으로 바꾼 길이(바이트 수 ÷ 2)로 계산하거나, 자바스크립트처럼 원래 UTF-16 으로 세는 언어의 길이를 씁니다.
def u16(s): # UTF-16 단위 길이
return len(s.encode("utf-16-le")) // 2
마크다운·HTML 은 어디서 바뀌나
- 사람 계정 API: 사용자가 친 마크다운·HTML 을 엔티티로 바꾸는 일은 앱(클라이언트)이 합니다. 서버에는 맨 글자 + 엔티티 목록을 보냅니다.
- 봇 API:
parse_mode에 MarkdownV2 나 HTML 을 주면 텔레그램 쪽이 바꿔 줍니다. 엔티티 목록(entities)을 직접 넘길 수도 있습니다 — 이때도 위치는 UTF-16 단위입니다(봇 API 첫 요청).
메시지 하나에 넣을 수 있는 글자 수 한도는 텔레그램 글자 수 제한에 있습니다.
자주 묻는 질문
텔레그램 서식이 한두 칸씩 밀려요.
offset·length 를 글자 수로 계산해서입니다. UTF-16 단위로 세면 이모지 뒤에서도 맞습니다.
UTF-16 길이는 어떻게 계산하나요?
문자열을 UTF-16(리틀 엔디언)으로 바꾼 바이트 수를 2 로 나눕니다.
봇 메시지에 굵은 글씨는 어떻게 넣나요?
parse_mode 를 MarkdownV2 나 HTML 로 주거나 entities 목록을 직접 넘깁니다.
MarkdownV2 로 보냈더니 오류가 나요.
사용자 입력 속 _ * 같은 기호를 이스케이프하지 않아서일 수 있습니다. 이스케이프하거나 엔티티를 직접 만듭니다.
참고한 공식 문서
한눈에 정리
- 1엔티티
종류 · offset · length.
- 2세기
UTF-16 단위.
- 3봇
parse_mode · 이스케이프.
최종 수정 2026-10-06