티스토리 뷰

텔레그램으로 개발하기 — Claude Code를 모바일에서 부리기

이틀 동안 텔레그램 봇을 만들었다. 텔레그램 메시지를 받아 로컬 claude CLI로 넘기고, 결과를 다시 텔레그램으로 보내는 구조다. 단순해 보이지만 막상 만들어 보니 자잘한 함정이 많아서, 정리해두면 비슷한 걸 만들 사람에게 도움이 될 것 같아 기록한다.

왜 텔레그램으로 개발하는가

이미 Claude Code CLI를 매일 쓰고 있다. 노트북 앞에 앉아서. 그런데 출근길이나 카페나 침대에서 갑자기 "그 코드 한 줄만 고쳐두자"가 떠오를 때가 있다. SSH 띄우는 건 과하고, GitHub Codespaces는 무겁고, 그냥 텔레그램으로 명령 한 줄 던져서 처리할 수 있으면 이상적이다.

그리고 무엇보다 — 이미 인증된 Claude Pro/Max 구독을 그대로 쓸 수 있다. API 토큰을 새로 발급해서 미터링 과금에 노출시키는 게 아니라, 로컬에 깔린 claude CLI(이미 OAuth 로그인 되어 있는 것)를 그대로 활용한다.

아키텍처

Telegram → Python 봇 → claude CLI (subprocess) → Telegram
            ↑               ↓
       chat_id별         stream-json
       session_id        실시간 이벤트
  • Python 봇: urllib로 텔레그램 getUpdates long-polling, asyncio.create_subprocess_exec로 claude 띄움
  • 세션 관리: chat_id → session_id 매핑을 data/sessions.json에 저장. 새 메시지가 오면 --resume <session_id>로 이어감
  • 실행 모드: claude -p --output-format stream-json --verbose --permission-mode bypassPermissions
  • launchd: 백그라운드 상시 실행 + 죽으면 자동 재시작
  • 인증: ANTHROPIC_API_KEY 환경변수를 의도적으로 제거 → Pro 구독(OAuth)으로 폴백

API 과금 0: 핵심 트릭

claude CLI는 인증 우선순위가 이렇다.

  1. ANTHROPIC_API_KEY 환경변수 (메터링 과금)
  2. claude /login 으로 받은 OAuth 토큰 (Pro/Max 구독 과금)
  3. Bedrock / Vertex 자격증명

봇에서 자식 프로세스를 띄울 때 ANTHROPIC_API_KEY만 빼고 나머지 환경변수를 넘기면, 자동으로 OAuth로 폴백된다.

def _pro_oauth_env() -> dict:
    return {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}

proc = await asyncio.create_subprocess_exec(
    *cmd,
    stdin=asyncio.subprocess.PIPE,
    stdout=asyncio.subprocess.PIPE,
    stderr=asyncio.subprocess.PIPE,
    env=_pro_oauth_env(),
)

이 한 줄 덕분에 봇이 처리하는 모든 요청이 본인의 Pro 구독에서 빠져나간다. API 비용은 정확히 0원.

다회차 대화 — --resume + chat_id 매핑

Claude CLI는 --session-id <uuid> 또는 --resume <session-id>로 이전 세션을 이어갈 수 있다. 텔레그램 봇은 사용자가 여럿 붙을 수 있으니, chat_id마다 별도의 Claude 세션을 유지해야 컨텍스트가 안 섞인다.

session_id = _get_session_id(chat_id)        # data/sessions.json 조회
new_sid = None if session_id else str(uuid.uuid4())

cmd = [self.bin, "-p", "--output-format", "stream-json", ...]
if session_id:
    cmd += ["--resume", session_id]
else:
    cmd += ["--session-id", new_sid]

처음 대화 시작할 때만 UUID 생성, 이후엔 stream에서 받은 session_id를 다음 턴에 그대로 넘긴다. /new 커맨드로 사용자가 컨텍스트를 명시적으로 리셋할 수 있게 했다.

stream-json으로 사고 과정 로깅

처음엔 --output-format json(단일 응답)으로 만들었다. 그런데 막상 봇이 도중에 뭔가 잘못 동작하면, 로그엔 최종 응답만 남아있어서 어디서 꼬였는지 알 길이 없었다.

--output-format stream-json --verbose로 바꾸면 다음 같은 이벤트가 한 줄씩 stdout으로 흘러나온다.

  • system.init — 세션 시작, 사용 가능한 도구 목록
  • assistant 메시지 — 텍스트 블록 / tool_use 블록 / thinking 블록
  • user 메시지 — tool_result (도구 실행 결과)
  • result — 최종 응답 + 토큰 사용량

각 이벤트를 받자마자 logger에 찍으면, claude가 무슨 도구를 어떤 입력으로 호출했고 결과가 무엇이었는지가 시간순으로 다 남는다.

claude run [resume] sid=d5e2... prompt=서흥 5개년 손익계산서 만들어줘
claude system.init sid=d5e2... model=claude-sonnet-4-6 tools=30
claude text[1] +18ch | 서흥 재무 데이터부터 조회할게요.
claude tool_use[1] WebSearch input={"query": "서흥 capsule 재무제표..."}
claude tool_result[1] err=False | Web search results...
claude tool_use[2] WebFetch input={"url": "https://comp.fnguide.com/..."}
...
claude result sid=d5e2... err=False dur=287.9s in=17 out=13454 cache_read=846874
claude run done rc=0 elapsed=290.7s text=6 tool_use=17 tool_result=17

디버깅하다가 "어 이 봇이 어디서 막힌 거지?"가 들면 로그만 보면 된다. claude의 매 결정이 다 박혀있다.

editMessageText 점진 업데이트

복잡한 요청은 5분이 넘게 걸린다. 그동안 텔레그램에 아무것도 안 뜨면 사용자는 봇이 죽었나 싶어한다.

해결: 응답 시작할 때 "💭 생각 중..." placeholder 메시지를 보내고 message_id를 잡는다. 그다음 stream에서 텍스트 블록이 도착할 때마다 editMessageText로 그 메시지를 갱신한다.

async def on_text(running: str) -> None:
    nonlocal last_edit_at
    now = time.monotonic()
    if now - last_edit_at < 1.1:   # 텔레그램 rate limit
        return
    last_edit_at = now
    await asyncio.to_thread(
        _tg.edit_message_text,
        placeholder_id,
        markdown_to_telegram_html(running),
        chat_id, "HTML", True,
    )

result = await runner.run(prompt, ..., on_text=on_text)

텔레그램 editMessageText는 초당 1회 정도가 안전한 한도라 1.1초 디바운스를 뒀다. 사용자 입장에선 답변이 한꺼번에 뚝 떨어지는 게 아니라 점진적으로 차오르는 게 보인다.

Markdown → HTML — 텔레그램의 함정

Claude는 기본적으로 Markdown으로 답한다. 텔레그램은 Markdown을 자체적으로 지원하긴 하는데, MarkdownV2 모드는 escape 규칙이 너무 까다로워서 한 글자만 빠져도 통째로 reject 한다. HTML 모드가 안전하다.

직접 변환기를 짰다.

_RE_CODE_BLOCK = re.compile(r"```(\w*)\n?(.*?)```", re.DOTALL)
_RE_BOLD = re.compile(r"\*\*([^*\n]+)\*\*")
_RE_LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
# ...

def markdown_to_telegram_html(text: str) -> str:
    # 1. 코드 블록을 placeholder로 stash (내부엔 escape 적용)
    # 2. 나머지 텍스트는 html.escape (quote=False)
    # 3. 굵게/기울임/링크/헤딩 정규식 치환
    # 4. placeholder 복원

함정 두 개:

  • 텔레그램 HTML은 &quot;, &#x27;를 디코드하지 않는다 → html.escape(text, quote=False)로 따옴표는 그대로 둔다
  • # 헤딩은 텔레그램 HTML에 없다 → <b>...</b>로 fallback

그래도 가끔 변환 실패가 나는 경우가 있어서, _send_one에서 parse_mode로 보냈다가 실패하면 태그를 모두 벗겨내고 plain text로 한 번 더 보내는 폴백을 깔았다.

result = self._post_json("sendMessage", payload)
if not result.get("ok") and parse_mode:
    payload.pop("parse_mode", None)
    payload["text"] = _strip_html(text)
    result = self._post_json("sendMessage", payload)

권한 묻기 — bypassPermissions

Claude Code는 기본적으로 도구 사용 시 사용자에게 권한을 묻는다. CLI에선 1/2/3 골라서 답한다. 텔레그렘에선 불편할 것 같았다.

선택지가 셋이었다.

  1. 권한 버튼: 텔레그램 inline 키보드로 [✅ 허용][♾ 항상 허용][❌ 거부] 띄우고 callback_query 받아서 처리. SDK의 canUseTool 콜백으로 가능
  2. MCP 권한 도구: --permission-prompt-tool <mcp_tool>로 MCP 서버에서 권한 처리
  3. auto 모드: --permission-mode bypassPermissions — 그냥 다 허용

어차피 화이트리스트로 본인 chat_id만 막아둔 토이 봇이라 3번을 선택했다. 외부에 공개되는 봇이라면 1번이 맞다.

화이트리스트 — ALLOWED_CHAT_IDS

봇 토큰만 알면 누구나 메시지를 보낼 수 있다. 봇 핸들러 첫 줄에서 chat_id(또는 user_id)가 화이트리스트에 있는지 검사하고, 아니면 묵묵히 무시 + WARNING 로그.

if ALLOWED_CHAT_IDS and chat_id not in ALLOWED_CHAT_IDS:
    logger.warning("DENIED chat=%s | %s", chat_id, _preview(text))
    return

본인 chat_id는 @userinfobot에 메시지 한 번 보내면 알 수 있다.

launchd로 백그라운드 상시 실행

macOS라서 launchd. plist 하나.

<key>ProgramArguments</key>
<array>
    <string>/usr/bin/python3</string>
    <string>/Users/biseo/only_gh/telegram_bot.py</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>10</integer>

KeepAlive.SuccessfulExit=false는 "정상 종료된 게 아니면 다시 띄워라"라는 뜻. 봇이 크래시하면 launchd가 10초 간격으로 재시작한다. RunAtLoad로 로그인 시 자동 시작.

launchctl kickstart -k gui/$(id -u)/com.biseo.onlygh.bot로 수동 재시작 가능.


만들면서 만난 진짜 문제들

여기부터가 본론. 단순히 봇이 동작하게 만드는 건 어렵지 않은데, 실전에서 깨지는 케이스들이 있었다.

문제 1: 다중 사용자 메시지 라우팅 사고

화이트리스트에 두 명을 등록한 상태에서 한 명이 "5개년 손익계산서 만들어달라"고 요청했더니, 응답이 양쪽 사용자에게 모두 전송됐다. 더 이상한 건, 요청자에겐 텍스트 표만 갔고 요청 안 한 사람에겐 더 예쁜 이미지 차트가 갔다.

원인: 봇이 Claude한테 "텔레그램 API로 직접 메시지 보내라"고 시켰던 것. Claude가 사용자 정보를 알지 못한 상태에서 환경변수에서 본 토큰으로 임의의 chat_id에 보내거나 이전 메시지의 chat_id를 잘못 참조했다.

해결: 봇이 사용자 정보를 Claude한테 절대 안 알려준다. Claude는 어디로 보낼지 모르고, 그냥 응답 텍스트에 마커만 박는다.

[IMAGE: /Users/biseo/only_jy/charts/abicom_pl.png]

봇이 stream에서 마커를 정규식으로 파싱해서, 현재 요청을 보낸 본인 사용자에게만 첨부 파일을 보낸다.

게다가 Claude가 우회 시도(인코딩, 변수 분리 등)를 못 하도록 PreToolUse hook(.claude/hooks/restrict_secrets.py)으로 Bash 명령에서 TELEGRAM_BOT_TOKEN, api.telegram.org, sendPhoto/sendMessage 패턴이 보이면 차단했다. 시스템 프롬프트에 "봇이 너에게 사용자 정보를 알려주지 않는다, Bash로 Telegram API 직접 호출은 막혀있다"고 명시적으로 적어두는 것도 중요했다.

문제 2: 자기 자신 재시작하기

"코드 고쳐서 다시 시작해줘"를 봇에게 시키면, Claude가 코드를 고치고 launchd로 재시작 명령을 친다. 그러면 봇이 자기 자신을 죽이는 셈이라, 응답을 텔레그램에 보내기 전에 죽으면 사용자는 영원히 답을 못 받는다.

이중 안전장치를 깔았다.

(a) 시스템 프롬프트로 절차 강제

1. 사용자에게 보낼 최종 응답 텍스트를 모두 작성한 뒤
2. 마지막에 단 한 번 Bash로:

   nohup bash -c 'sleep 5 && launchctl kickstart -k gui/$(id -u)/com.biseo.onlygh.bot' \
     </dev/null >/dev/null 2>&1 &

   - nohup + </dev/null >/dev/null 2>&1: 부모 fd 완전 분리
   - sleep 5: 응답 송신 시간 확보
   - &: 백그라운드 분리

nohup + 모든 fd를 /dev/null로 리다이렉트하는 게 핵심이다. sleep 5만으론 부족하다 (다음 문제 참고).

(b) 스트리밍 송신

editMessageText로 텍스트가 점진적으로 텔레그램에 박히기 때문에, 마지막에 봇이 죽어도 그 시점까지 본 텍스트는 남는다.

문제 3: proc.wait() 무한 대기

위에서 ( ... ) &로 백그라운드 분리했는데도 lock이 안 풀리는 현상이 발생했다. 로그를 보니:

16:15:56 [INFO] claude result sid=... err=False dur=97.2s in=19 out=3714
16:17:19 [INFO] ⇐ chat=... mid=41 | 응 너가 정리하고 배포해
16:17:19 [INFO] chat=... busy — queued
16:21:53 [INFO] ⇐ chat=... mid=42 | 왜 답이 안와? 흠 내부적으로 꼬인거같은뎅
16:21:53 [INFO] chat=... busy — queued

claude result 이벤트는 도착했는데 claude run done이 안 찍힌다. 즉 await proc.wait()가 무한 대기 중. 새 메시지들은 chat_lock이 풀릴 때까지 영원히 큐에 박힘.

원인: Claude가 Bash 도구로 띄운 백그라운드 자식 프로세스(( sleep 5 && ... ) &)가 부모(Bash, claude, 그리고 우리 Python의 stdout/stderr 파이프)를 fd로 상속받았다. 부모들이 종료해도 손자가 잡고 있어서 파이프가 안 닫혔다. 그러니 우리 쪽 readline() / proc.wait()가 EOF를 못 봐서 영원히 기다림.

해결 두 단계:

(a) result 이벤트 받자마자 read 루프 탈출

elif etype == "result":
    logger.info("claude result sid=%s ...", ...)
    break   # 추가 이벤트 안 기다림

(b) finally에서 timeout/kill

finally:
    try:
        await asyncio.wait_for(stderr_task, timeout=2.0)
    except asyncio.TimeoutError:
        stderr_task.cancel()

    try:
        rc = await asyncio.wait_for(proc.wait(), timeout=5.0)
    except asyncio.TimeoutError:
        logger.warning("claude pid=%s 종료 안 됨 — kill", proc.pid)
        proc.kill()
        rc = await asyncio.wait_for(proc.wait(), timeout=3.0)

이제 lock은 최대 7초 안에 풀린다. 물론 가장 깔끔한 해결은 (a)에서 BOT_SYSTEM_PROMPT의 백그라운드 명령을 nohup ... </dev/null >/dev/null 2>&1 &로 fd 완전 분리해서 처음부터 손자가 부모 파이프를 안 잡게 하는 것. 하지만 Claude가 가끔 명령을 약간 변형해서 칠 수 있으니 (b)는 안전망으로 유지.

문제 4: 비개발자 사용자 대응

봇 첫 사용자가 비개발자였다. "간단하게 너가 개발을 얼마나 잘 하는지 확인해보고싶은데 뭘 요청하면 좋을까?"라는 질문에 봇이 *"코드 리팩토링, Git 워크플로우, MCP 서버 작성..."* 같은 답을 줬다.

BOT_SYSTEM_PROMPT에 사용자 프로필을 박아넣었다.

## 사용자 프로필
주식(특히 한국 주식)에 관심이 많은 개인 투자자입니다.
비개발자이며 코드를 직접 다루지 않습니다.

## 답변 스타일
- 개발자 용어를 전면에 내세우지 마세요. 파일 경로, 라이브러리,
  코드 스니펫, 터미널 명령 등은 필요한 경우에만 결과물 끝에 작게 덧붙이세요.
- 활용 예시·아이디어는 반드시 주식/투자 맥락으로 들어주세요.
  ("Python 코드 짜줘" 같은 예시 X)

추가로 모호한 요청 처리 규칙도 박아두었다.

요청이 명확하지 않으면 바로 시작하지 말고 한국어로 친절하게 되물어 주세요.

되묻는 형식:
1. 한 줄로 어디가 모호한지 짧게 짚기
2. 구체적 예시 2~3개를 주식/투자 맥락으로 제시
3. "다르게 말씀해주셔도 괜찮아요" 한 줄 덧붙이기

이거 들어가니까 봇이 갑자기 다른 사람이 됐다. 같은 모델, 같은 도구, 시스템 프롬프트 한 장 차이.

문제 5: 텔레그램 Markdown 렌더링 안 됨

Claude는 ## 헤딩, - 불릿, ```code``` 같은 Markdown으로 답하는데, 텔레그램에선 그게 그대로 글자로 나온다 (#, -, ```이 다 보임).

parse_mode="MarkdownV2"는 escape 규칙이 너무 빡세고 parse_mode="HTML"이 가장 안전. 위 Markdown→HTML 섹션 참고.

문제 6: 단톡방에서 매번 멘션해야 봇이 듣는 문제

화이트리스트의 두 사용자 + 봇이 들어있는 그룹챗을 만들었더니, 기본 설정으론 봇이 @abc_Bot some message 같이 멘션이 있어야만 메시지를 받는다 (Privacy Mode).

@BotFather에서 /setprivacy → Disable로 끄면 그룹의 모든 메시지를 받는다. 비개발자의 채팅대화를 내가 보고 피드백을 줄 수가 없어서 만든 단톡방이라서 비개발자가 자유롭게 클로드와 대화하고 나는 가끔 이야기하는 공간이다. 둘이서 대화할 일은 없기 때문에 Privacy 설정을 없앴다.

문제 7: editMessageText의 "not modified" 에러

스트리밍 도중 같은 텍스트로 edit 호출을 두 번 보내면 텔레그램이 HTTP 400을 던진다 (Bad Request: message is not modified). 무해하지만 로그가 시끄러워진다.

ok = result.get("ok", False)
if not ok and "not modified" in (result.get("description") or ""):
    ok = True

명시적으로 "이건 OK로 처리"라고 잡았다.


공식 대안 — Claude Code Channels

사실 Anthropic이 이미 공식으로 내놨다. 2026년 3월에 research preview로 풀린 Claude Code Channels. Telegram, Discord, iMessage 지원.

설정은 5분 컷이다.

/plugin install telegram@claude-plugins-official
/telegram:configure <BotFather 토큰>
exit
claude --channels plugin:telegram@claude-plugins-official
# 봇한테 메시지 보내면 페어링 코드 옴
/telegram:access pair <코드>
/telegram:access policy allowlist

구조도 다르다. 공식은 "이미 켜져있는 Claude Code 세션에 외부 이벤트를 push"하는 모델 (Bun 기반 MCP 서버가 메시지를 받아 세션에 던짐). 우리는 "메시지가 올 때마다 claude CLI를 새로 띄우고 --resume으로 이어가는" 모델.

비교:

  우리 봇 공식 Channels
설정 Python 코드 + launchd plist + 디버깅 /plugin install + 페어링
인증 OAuth (env에서 API 키 제거) claude.ai 로그인 필수
항상 켜둠 launchd로 24/7 자동 재시작 claude --channels 세션 살아있는 동안만
권한 처리 bypassPermissions (위험) permission relay — 외부에서 승인
첨부 마커([IMAGE: ...]) 정규식 파싱 공식 reply/react/edit_message MCP 도구
보안 직접 짠 PreToolUse hook 공식 페어링 + allowlist 정책
유지보수 본인 책임 Anthropic이 관리

그런데 왜 직접 만들었나

그냥 해보고싶었다. 할 수 있을 것 같아서,, 근데 만들고나서 비교해보니까 괜히 만들었다 싶기는 하네.
다만 알게 된 지금도 일부는 계속 직접 운영할 만한 이유는 있다.

 

1. launchd로 24/7 떠있는 게 필요한 케이스

공식 Channels는 claude --channels 세션이 살아있는 동안에만 메시지를 받는다. 즉 터미널을 항상 띄워둬야 하거나, tmux/screen에 박아둬야 한다. 노트북을 닫으면 끝. 백그라운드 데몬으로 돌리는 건 공식이 권장하는 패턴이 아니다.

내가 만든 봇은 launchd가 관리하니까 노트북을 켜둔 상태면 자동 시작되고, 죽으면 10초 안에 다시 뜬다. 모바일에서 메시지 보낼 때 "터미널 켜져있나?" 신경 안 써도 된다. 흥

 

2. 다중 사용자 메시지 격리가 필요한 경우

공식은 한 세션이 여러 사용자의 메시지를 받는 구조다. 개발자(나) + 비개발자(다른 사람)가 같이 쓰는 봇에선, "어떤 사용자가 보낸 요청이고, 응답을 누구에게 보낼지"를 코드 레벨에서 격리해야 사고가 안 난다. 한 번은 사용자 A가 요청했는데 응답이 사용자 B에게도 가는 사고가 있었고(위 문제 1), 이걸 PreToolUse hook + 마커 기반 첨부 라우팅 + 시스템 프롬프트 가드로 막아뒀다. 공식 채널의 페어링/allowlist만으론 그 정도 격리가 안 된다.

 

3. 깊은 커스터마이징

비개발자 사용자를 위한 답변 스타일 강제(사용자 프로필 — 비개발자, 주식 투자자, 모호한 요청 시 되묻는 형식 등) 같은 건 시스템 프롬프트 한 장으로 봇 성격을 완전히 바꿀 수 있어야 한다. 공식 채널도 이건 가능하긴 한데(CLAUDE.md에 박으면 됨), 봇별로 다른 인격을 운영하려면 결국 봇별로 다른 디렉토리에서 다른 claude --channels를 띄워야 한다. 그럴 거면 그냥 봇별 launchd 인스턴스가 더 직관적이다.

 

4. 디버깅 가시성

공식 채널은 MCP 서버 ↔ Claude 세션 사이에서 무슨 일이 일어나는지 사용자가 직접 보기 어렵다. 우리는 stream-json 이벤트를 한 줄씩 로그에 박았으니 "어떤 도구를 어떤 입력으로 호출했고 결과가 뭐였는지" 다 따라갈 수 있다. 봇이 갑자기 이상하게 동작할 때 이 로그가 진짜 큰 도움이 됐다 (위 문제 3 디버깅도 이 로그 없었으면 못 했다).

추천

새로 만든다면 공식 Channels로 시작하라. 5분 설정, 보안 모델 정상, 유지보수 비용 0. 위에서 내가 며칠 동안 디버깅한 문제들(fd leak, proc.wait hang, 재시작 패러독스 등)을 처음부터 안 만난다.

직접 만들 가치가 있는 경우는 (a) 노트북 닫혀도 봇이 떠있어야 하거나, (b) 비개발자 다중 사용자 격리 필요하거나, (c) 봇별로 완전히 다른 시스템 프롬프트/도구 정책이 필요할 때 정도. 그 외엔 공식이 다 낫다.


마무리

작업 흐름은 보통 이렇다.

  1. 노트북 앞에 앉을 시간이 없음
  2. 텔레그램으로 봇한테 "그 봇 코드에서 X 부분 좀 고쳐줘" 요청
  3. Claude가 코드 보고, 분석하고, 수정하고, 마지막에 봇 자기 자신 재시작
  4. 새 코드로 동작하는 걸 텔레그램에서 바로 확인

API 비용 0원, 노트북 켤 필요 없음, claude의 모든 도구(Read/Edit/Bash/Web/...)를 모바일에서 그대로 부림.

토이 프로젝트로 시작했는데 의외로 일상 도구가 될 것 같다. 화이트리스트와 bypassPermissions 조합이 위험하긴 한데, 그냥.. 내 장난감이니까!ㅎㅎ 외부 공개할 거면 더 많은 설정이 있어야겠지.

시스템 프롬프트로 모든 걸 강제하는 건 한계가 있어서, 실제로 위험한 건 PreToolUse hook으로 코드 레벨에서 차단해두는 게 안전하긴 할거다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/10   »
일 월 화 수 목 금 토
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함