상황
Lynceus 백엔드가 서버에서 재시작됐다. 프로세스는 정상 기동되었고, API 엔드포인트도 응답했다. 그런데 그날 오전 내내 Telegram 알림이 한 건도 오지 않았다.
로그를 보니 이 한 줄이 있었다.
2026-07-07 08:01:03 ERROR telegram_bot_start_failed
ConnectionError: Connection to api.telegram.org timed out
그게 전부였다. 봇 시작 실패. 이후 어떤 알림도, 어떤 재시도도, 어떤 복구 시도도 없었다.
발견 과정
직접 발견이 아니었다. 오전 장 시작 후 매수 체결 알림이 없다는 것을 서버에 접속해서 확인하다가 알게 됐다. 정상이라면 9시 이후에 매매 관련 알림 서너 건이 와 있어야 했다.
로그를 보면 telegram_bot_start_failed가 기동 직후 찍혀 있었다. 그 아래로는 정상 매매 로그들 — order_placed, order_filled, position_updated — 이 계속 찍히고 있었다. 봇만 빠져있었다.
현상
telegram_bot.start()는 FastAPI lifespan의 startup 단계에서 호출된다. 봇 시작 실패가 백엔드 전체를 죽이면 안 되기 때문에 예외를 삼키도록 설계되어 있었다.
async def start(self) -> None:
"""FastAPI lifespan 시작 시 호출."""
if not settings.TELEGRAM_BOT_TOKEN:
logger.info("telegram_bot_disabled_no_token")
return
try:
self._app = Application.builder().token(settings.TELEGRAM_BOT_TOKEN).build()
self._app.add_handler(CommandHandler("status", self._handle_status))
# ... 핸들러 등록 ...
await self._app.initialize()
await self._app.start()
await self._app.updater.start_polling(
drop_pending_updates=True,
allowed_updates=["message"],
)
self._enabled = True
logger.info("telegram_bot_started", ...)
except Exception as exc: # noqa: BLE001
logger.exception("telegram_bot_start_failed", extra={"error": str(exc)})
self._enabled = False
# 여기서 끝. 재시도 없음.
설계 의도는 맞다. 봇이 죽어도 거래 엔진은 살아야 한다. 그러나 “실패해도 백엔드는 정상 동작"과 “실패 후 복구 없음"은 다른 이야기다.
_enabled = False가 되면 이후 모든 send_message() 호출은 내부에서 즉시 리턴된다.
async def send_message(self, text: str) -> None:
if not self._enabled:
return # 조용히 버림
# ...
알림이 사라진다. 에러도 없이.
원인
서버 재시작 직후 Telegram API(api.telegram.org)로의 첫 TLS 연결은 간헐적으로 실패한다. 콜드 스타트 상황에서 네트워크 스택 초기화, DNS 캐시 워밍, TLS 핸드셰이크가 겹치면 첫 연결 시도가 타임아웃 나는 경우가 있다.
30초 후에 같은 연결을 시도하면 성공한다. 문제는 start()가 실패 후 다시 시도할 방법이 없다는 것이었다.
더 정확히 말하면, 실패 자체보다 “실패를 사용자에게 알릴 수단이 봇뿐"이라는 것이 문제다. 봇이 죽으면 봇 장애를 알릴 방법도 없다.
해결
1. _start_once() 추출
시작 로직을 별도 메서드로 분리해 재시도에서 재사용할 수 있게 했다.
async def _start_once(self) -> None:
"""Telegram application을 1회 시작한다."""
self._app = Application.builder().token(settings.TELEGRAM_BOT_TOKEN).build()
self._app.add_handler(CommandHandler("status", self._handle_status))
# ... 핸들러 등록 ...
await self._app.initialize()
await self._app.start()
await self._app.updater.start_polling(
drop_pending_updates=True,
allowed_updates=["message"],
)
self._enabled = True
logger.info("telegram_bot_started", ...)
await self.send_message(f"[Lynce] 통합 봇 활성화")
2. 백오프 재시도 태스크
_START_RETRY_BACKOFF_SECONDS = [10, 30, 60, 120, 300]
async def _retry_start(self) -> None:
"""시작 실패 후 백오프 재시도로 Telegram 봇을 자가복구한다."""
for attempt, delay_seconds in enumerate(_START_RETRY_BACKOFF_SECONDS, start=1):
try:
await asyncio.sleep(delay_seconds)
await self._start_once()
logger.info(
"telegram_bot_start_recovered",
extra={"attempt": attempt, "delay_seconds": delay_seconds},
)
return
except asyncio.CancelledError:
logger.info("telegram_bot_start_retry_cancelled")
raise
except Exception as exc: # noqa: BLE001
logger.warning(
"telegram_bot_start_retry_failed",
extra={"attempt": attempt, "delay_seconds": delay_seconds, "error": str(exc)},
)
self._enabled = False
await self._cleanup_failed_start()
logger.critical(
"telegram_bot_start_retry_exhausted",
extra={"attempts": len(_START_RETRY_BACKOFF_SECONDS)},
)
재시도 간격은 [10, 30, 60, 120, 300]초로 설정했다. 첫 시도에서 일시적 TLS 실패라면 10초 안에 복구된다. 5번을 모두 소진하면(총 약 8분) critical 로그를 남겨 사람이 개입해야 한다는 신호를 준다.
asyncio.CancelledError는 재raise한다. 앱이 종료될 때 stop()이 태스크를 취소하므로, 이 예외를 삼키면 정상 종료가 안 된다.
3. 실패한 시작에서 자원 정리
initialize() → start() → start_polling() 중간에 실패하면 반쯤 초기화된 Application 객체가 남는다. 다음 재시도에서 새 Application을 만들기 전에 정리해야 한다.
async def _cleanup_failed_start(self) -> None:
"""실패한 시작 시도에서 남은 app 자원을 정리한다."""
app = self._app
if app is None:
return
try:
if app.updater and app.updater.running:
await app.updater.stop()
if getattr(app, "running", False):
await app.stop()
await app.shutdown()
except Exception:
logger.exception("telegram_bot_start_cleanup_failed")
finally:
self._app = None
4. 종료 시 재시도 태스크 취소
앱이 종료될 때 백그라운드에서 재시도 중이라면 취소한다.
async def stop(self) -> None:
"""FastAPI lifespan 종료 시 호출."""
if self._start_retry_task is not None and not self._start_retry_task.done():
self._start_retry_task.cancel()
try:
await self._start_retry_task
except asyncio.CancelledError:
pass
self._start_retry_task = None
if self._app is None:
return
# ... 정상 종료 로직 ...
수정 후 start()
async def start(self) -> None:
if not settings.TELEGRAM_BOT_TOKEN:
logger.info("telegram_bot_disabled_no_token")
return
if self._start_retry_task is not None and not self._start_retry_task.done():
logger.info("telegram_bot_start_retry_already_running")
return
try:
await self._start_once()
except Exception as exc: # noqa: BLE001
logger.exception("telegram_bot_start_failed", extra={"error": str(exc)})
self._enabled = False
await self._cleanup_failed_start()
self._start_retry_task = asyncio.create_task(self._retry_start())
실패하면 즉시 백그라운드 재시도 태스크를 생성한다. start()는 그대로 리턴되고 백엔드는 계속 기동된다.
회귀 가드 테스트
재시도 복구와 재시도 소진 두 경우를 각각 테스트한다.
@pytest.mark.asyncio
async def test_start_retry_recovers_after_initial_failure(monkeypatch, caplog):
"""start() 최초 실패 후 백그라운드 재시도 성공 시 봇을 다시 활성화한다."""
sleep_mock = AsyncMock()
monkeypatch.setattr("app.services.telegram_bot.asyncio.sleep", sleep_mock)
bot = LynceTelegramBot()
calls = 0
async def fake_start_once():
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("temporary telegram failure")
bot._enabled = True
monkeypatch.setattr(bot, "_start_once", fake_start_once)
await bot.start()
assert bot.is_enabled is False
await bot._start_retry_task
assert bot.is_enabled is True
assert calls == 2
sleep_mock.assert_awaited_once_with(10) # 첫 재시도 간격
assert "telegram_bot_start_recovered" in caplog.text
@pytest.mark.asyncio
async def test_start_retry_exhausted_after_five_failures(monkeypatch, caplog):
"""5회 재시도 소진 후 critical 로그가 정확히 1회만 남는다."""
sleep_mock = AsyncMock()
monkeypatch.setattr("app.services.telegram_bot.asyncio.sleep", sleep_mock)
bot = LynceTelegramBot()
monkeypatch.setattr(bot, "_start_once", AsyncMock(side_effect=RuntimeError("outage")))
await bot.start()
await bot._start_retry_task
assert bot.is_enabled is False
assert sleep_mock.await_count == 5
assert caplog.text.count("telegram_bot_start_retry_exhausted") == 1
asyncio.sleep을 목으로 교체해서 테스트 속도를 유지한다. 5번 재시도 소진 테스트에서 await_count == 5를 단언해 백오프 횟수가 의도대로 동작하는지 확인한다.
일반화: 외부 서비스 의존 컴포넌트의 시작 패턴
Telegram 봇은 외부 API에 의존하는 컴포넌트의 대표 사례다. 동일한 문제는 Redis, 메시지 큐 컨슈머, 외부 알림 서비스 등에서도 발생한다.
패턴을 정리하면:
1. 외부 서비스 시작 로직을 격리한다
# 한 번의 시작 시도를 독립 메서드로
async def _start_once(self) -> None:
...
# 메인 start()는 성공/실패만 제어하고 재시도를 위임
async def start(self) -> None:
try:
await self._start_once()
except Exception:
self._start_retry_task = asyncio.create_task(self._retry_start())
2. 반쯤 초기화된 자원을 정리한다
연결 과정에서 실패하면 초기화된 만큼만 정리해야 한다. initialize() 이후 실패 시 shutdown()을 호출하지 않으면 다음 시도에서 자원이 중복 초기화된다.
3. 재시도 소진 시 명시적 신호를 남긴다
logger.critical()은 모니터링 도구가 알람을 트리거하는 수준이다. “재시도를 다 쓸 때까지 실패했다"는 사람이 봐야 할 상황이다.
4. 앱 종료 시 백그라운드 태스크를 취소한다
백그라운드에서 무한히 재시도 중에 앱이 종료되면 태스크가 cancellation 없이 남는다. stop()에서 반드시 cancel() + await를 처리한다.
5. CancelledError를 삼키지 않는다
재시도 루프의 asyncio.sleep() 중에 취소되면 CancelledError가 발생한다. 이를 except Exception으로 잡으면 취소를 무시하고 계속 실행된다. 항상 except asyncio.CancelledError를 별도로 처리하고 raise한다.
정리
| 문제 | 내용 |
|---|---|
| 직접 원인 | lifespan 기동 시 첫 TLS 연결 지연으로 start() 예외 발생 |
| 구조적 원인 | 예외 catch 후 _enabled = False만 설정, 재시도 없음 |
| 영향 | 세션 내내 Telegram 알림 0건, 장애 인지 불가 |
| 해결 | 시작 실패 시 [10, 30, 60, 120, 300]초 백오프 백그라운드 재시도 |
| 소진 시 | logger.critical — 사람이 개입해야 함을 명시 |
| 종료 처리 | stop()에서 재시도 태스크 cancel() + await |
외부 서비스에 의존하는 컴포넌트는 시작 실패가 전체 앱을 죽이지 않도록 격리하는 것이 첫 번째다. 그리고 격리한 다음에는 반드시 복구 경로를 만들어야 한다. “실패해도 괜찮다"와 “실패하면 영원히 꺼진다"는 다른 이야기다.