개발 slecs

봇 여러 개가 같은 이유로 죽는 걸 보고 공통 헬퍼를 만든 아침

목차

managed MySQL 환경에서 커넥션이 조용히 끊기는 현상은 꽤 오래된 골칫거리였다. 특히 봇들은 대부분 스케줄러로 돌아가기 때문에 쿼리를 날리기 직전까지 커넥션 상태를 알 수 없다. 정상적으로 보이던 세션이 idle timeout이나 managed 환경 특유의 강제 drop으로 이미 끊겨 있고, 다음 쿼리에서 Lost connection to MySQL server 같은 에러가 터지면 그냥 태스크 전체가 날아간다. 여태까지는 각 봇에서 각자의 방식으로 try/except를 넣거나, 커넥션 체크 로직을 복붙해서 쓰고 있었다. 어떤 봇은 그것조차 없이 그냥 터지면 다음 스케줄 때 재시도를 기다리는 식이었다.

이번 아침 작업 블록에서 그 상황을 한번에 정리하기로 했다.

왜 이번에 건드렸나

직접적인 트리거는 여러 봇이 비슷한 타이밍에 비슷한 이유로 실패 로그를 남기고 있다는 걸 확인한 것이었다. 서로 다른 도메인의 봇들인데 에러 패턴이 동일하다. MySQL managed 서비스 특성상 일정 시간 이상 idle 상태였던 커넥션을 서버 측에서 끊어버리는데, 봇들은 그걸 모르고 기존 커넥션 객체를 그대로 들고 쿼리를 날린다. 결과는 항상 같다.

문제는 이걸 각 봇 파일 안에서 해결하면 결국 비슷한 코드가 6군데에 똑같이 들어가게 된다는 점이다. 이미 그 조짐이 보였다. 한 봇에서 ping() 호출로 커넥션 상태를 확인하는 로직을 넣었고, 다른 봇에는 그게 없었다. 구현 방식도 봇마다 미묘하게 달랐다. 이 상태로 두면 나중에 한 곳을 고쳐도 나머지는 그냥 버그가 남는다.

이런 종류의 패턴, 즉 "여러 모듈이 같은 인프라 문제를 각자의 방법으로 방어하고 있다"는 건 공통 레이어로 추출할 신호다. 총괄 입장에서 보면 코드 일관성 문제이기도 하고, 운영 신뢰도 문제이기도 하다.

db.py - 공통 헬퍼 설계

핵심 파일은 db.py 하나다. auto-reconnect 로직을 가진 MySQL 헬퍼를 공유 모듈로 만들었다.

설계 포인트를 정리하면 이렇다.

  • 커넥션 상태 확인: 쿼리 실행 전에 커넥션이 살아있는지 체크. ping(reconnect=True) 계열 접근을 쓰되, 그것조차 실패할 때를 대비한 fallback 재연결 경로 확보.
  • 재시도 래핑: 단순 커넥션 에러(OperationalError, InterfaceError)는 일회성 재시도로 커버. 쿼리 자체의 논리적 오류(ProgrammingError, IntegrityError)는 재시도 없이 바로 올려보냄.
  • 호출부 변화 최소화: 각 봇 파일에서 커넥션 얻는 방식만 헬퍼를 통하도록 바꾸면 되게끔. 쿼리 코드는 그대로 두는 게 목표였다.
# 에러 분류 예시 (실제 파일 기준 개념 표현)
RETRIABLE_ERRORS = (OperationalError, InterfaceError)
NON_RETRIABLE_ERRORS = (ProgrammingError, IntegrityError, DataError)

def execute_with_retry(conn, query, params=None, retries=1):
    for attempt in range(retries + 1):
        try:
            ensure_connection(conn)
            cursor = conn.cursor()
            cursor.execute(query, params)
            return cursor
        except RETRIABLE_ERRORS as e:
            if attempt < retries:
                reconnect(conn)
                continue
            raise
        except NON_RETRIABLE_ERRORS:
            raise

ensure_connection이 실질적인 핵심인데, ping 실패 시 커넥션 파라미터를 가지고 재연결을 시도한다. managed 환경에서 drop이 발생한 직후라도 재연결 자체는 보통 성공한다. 문제는 "내가 지금 끊겼다는 걸 모르는 상태"에서 쿼리를 날리는 것이지, 재연결 자체가 막혀 있는 건 아니니까.

6개 봇 파일 적용

헬퍼가 완성되면 적용 자체는 단순하지만 파일이 여러 개라 하나씩 손봐야 했다.

파일 역할
formpack/generate.py 폼 데이터 기반 콘텐츠 생성
kpopdex/bot/discover_groups.py 그룹 정보 탐색/수집
money/generate.py 금융 관련 데이터 생성
vtuberprofile/bot/discover_talents.py 탤런트 정보 탐색
vtuberprofile/bot/fetch_schedule.py 일정 데이터 수집
vtuberprofile/bot/sync_talents.py 탤런트 데이터 동기화

각 파일에서 커넥션을 직접 생성하거나 모듈 레벨에서 들고 있던 패턴을 헬퍼를 거치는 방식으로 교체했다. 가장 신경 쓴 부분은 기존 로직의 흐름을 건드리지 않는 것이었다. 커넥션 객체를 넘기는 방식이 파일마다 조금씩 달랐기 때문에 기계적으로 치환할 수 없었고, 각 파일의 패턴을 파악해서 개별적으로 수정했다.

vtuberprofile 쪽이 파일이 세 개라 작업량이 제일 많았다. discover_talents.py, fetch_schedule.py, sync_talents.py 각각 커넥션을 쓰는 방식이 조금씩 달랐다. sync_talents.py는 커넥션을 함수 파라미터로 받는 구조여서 헬퍼 적용이 깔끔했는데, fetch_schedule.py는 모듈 상단에서 커넥션을 만들어두고 재사용하는 패턴이라 ensure_connection 호출 위치를 고민해야 했다.

삽질한 부분이 하나 있었다. kpopdex/bot/discover_groups.py에서 커넥션 에러를 이미 잡고 있는 try/except 블록이 있었는데, 그게 헬퍼의 재시도 로직과 겹쳐서 에러가 두 번 처리될 수 있는 구조가 됐다. 헬퍼에서 재시도 후 성공하면 문제없지만, 헬퍼가 최종적으로 에러를 올려보냈을 때 기존 except 블록이 그걸 삼켜버리면 재시도가 성공한 것처럼 보이는 상황이 생길 수 있었다. 기존 except 블록을 정리하면서 처리 흐름을 단일 경로로 만들었다.

문서화까지 한 이유

마지막 커밋이 CLAUDE.md 업데이트였다. 코드 변경을 다 끝내고 나서 문서를 썼는데, 이게 귀찮은 작업처럼 느껴질 수 있지만 봇 작업에서는 꽤 중요하다.

봇 코드는 나 혼자 보는 게 아니고, 나중에 새 봇을 추가하거나 기존 봇에 DB 로직을 붙일 때 "그냥 직접 커넥션 만들어서 쓰면 되겠지" 하고 같은 실수가 반복될 수 있다. CLAUDE.md에 공통 헬퍼 패턴을 명시해두면 Claude Code 컨텍스트에서도 참조가 되고, 사람이 봐도 "아 이게 표준이구나"가 바로 보인다.

문서에 넣은 내용은 단순하다. 어떤 모듈을 쓰는지, 왜 직접 커넥션을 만들면 안 되는지, 에러 분류 기준이 뭔지. 길게 쓰지 않았다. 패턴 문서는 짧고 명확한 게 낫다. 예시 하나, 금지 패턴 하나면 충분하다.

이 작업이 남긴 것

managed MySQL 환경에서 커넥션 drop은 피할 수 없다. 서비스 측에서 언제든 idle 커넥션을 끊을 수 있고, 유지보수 때도 잠깐 drop이 생긴다. 방어 코드를 안 짜는 게 이상한 거다.

오늘 작업 전까지는 그 방어 코드가 봇마다 제각각이었다. 헬퍼 하나 만들고 여섯 파일에 적용하는 데 아침 한 블록이 들었는데, 앞으로 봇이 늘어날수록 이 투자의 이자가 붙는다. 새 봇 만들 때 DB 연결 부분에서 고민할 필요가 없어졌고, 재연결 로직을 고칠 일이 생기면 db.py 한 파일만 손보면 된다.

공통 레이어를 뽑는 타이밍은 "두 번째 복붙이 보이는 순간"이 맞다. 세 번째부터는 이미 늦어서 정리하는 데 비용이 더 든다. 이번엔 여섯 군데였으니 조금 늦긴 했지만, 더 늦기 전에 한 게 그나마 다행이다.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.