상황

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

외부 서비스에 의존하는 컴포넌트는 시작 실패가 전체 앱을 죽이지 않도록 격리하는 것이 첫 번째다. 그리고 격리한 다음에는 반드시 복구 경로를 만들어야 한다. “실패해도 괜찮다"와 “실패하면 영원히 꺼진다"는 다른 이야기다.