연구 노트EthiLab

비밀번호가 만료됐더니 배치가 네 배 느려졌다

인증이 필요한 구간은 일부인데 전체가 느려졌다. 실패한 로그인을 아무도 기억해두지 않았기 때문이다.
📅 2026.08.18⏱ 읽기 약 8
EthiLab알고리즘 시그널 연구소실시간 스윙 트레이딩 시그널을 웹에서 확인하세요바로가기 →

30분짜리 배치가 두 시간을 썼다

매일 장 마감 뒤에 도는 데이터 수집 배치가 있다. 시장 전 종목의 시세를 받아 지표를 계산하고 신호를 뽑는 일을 한다. 평소 30분대에 끝나던 것이 어느 날 1시간 50분 02초를 썼다.

이상한 건 실패한 게 아니라는 점이었다. 배치는 완주했고 상태도 성공이었다. 그냥 느렸다.

단계별 소요를 뽑아보니 셋이 눈에 띄었다.

단계소요결과
시세 수집32분 05초정상
투자자 수급37분 14초성공 0건 / 1,060종목
종가 확인32분 43초정상

셋을 합치면 102분, 전체의 93%다. 그런데 여기서 앞뒤가 맞지 않았다. 인증을 실제로 요구하는 건 수급 수집 하나뿐이다. 나머지 둘은 로그인 없이도 공개 데이터를 받아오는 경로이고, 실제로 값도 정상적으로 들어왔다. 인증이 필요 없는 구간까지 왜 같이 느려지는가.

로그인을 3,180번 시도하고 있었다

로그를 뒤지니 로그인 시도 횟수가 나왔다. 시세 수집에서 1,051회, 수급에서 1,061회, 종가 확인에서 1,047회. 합쳐서 3,180회다.

종목 수가 1,060개니까 종목마다 한 번씩 로그인을 시도한 것이다. 배치 시작에 한 번 하면 될 일을.

사용하던 라이브러리의 소스를 열어보고서야 구조가 보였다.

def get_auth_session():
    global _auth_session
    if _auth_session is None:
        _auth_session = build_krx_session()   # 워밍업 2회 + 로그인 1회 = 왕복 3회
    return _auth_session

로그인에 성공하면 세션을 전역에 담아두고 재사용한다. 여기까지는 평범하다. 문제는 실패했을 때다. 실패하면 세션이 None으로 남고, None이면 위 조건이 다시 참이 되어 처음부터 다시 시도한다.

성공은 캐시되는데 실패는 캐시되지 않는다. 계정이 잠겨 로그인이 구조적으로 불가능한 상태에서는, 이 함수를 부를 때마다 무조건 왕복 3회가 나간다. 종목당 실제 왕복은 데이터 1회가 아니라 로그인 3회 + 데이터 1회 = 4회였다.

공개 데이터 경로가 함께 느려진 이유도 이걸로 풀렸다. 그쪽은 세션이 없으면 무인증 요청으로 자동 폴백하도록 되어 있어서 결과는 정상이었다. 다만 폴백하기 전에 매번 로그인을 세 번씩 두드리고 있었다. 값은 맞는데 시간만 버린 것이다.

단서는 엉뚱한 곳에 있었다

원인을 확정하기 전에, 다른 실험에서 나온 숫자 하나가 방향을 정해줬다. 시세 수집은 종목당 30일치를 받고, 종가 확인은 1일치만 받는다. 데이터 양이 서른 배 차이다.

종목당 소요를 재보니 1.82초와 1.85초였다. 사실상 같다.

이건 시간을 먹는 것이 데이터 양이 아니라 왕복 그 자체라는 뜻이다. 페이로드를 줄여봐야 소용이 없고, 줄여야 할 것은 호출 횟수다. 이 한 줄이 없었다면 "30일치를 줄여보자" 쪽으로 갔을 것이고, 그건 아무것도 바꾸지 못했을 것이다.

고친 것

네 가지를 함께 넣었다.

1. 로그인은 배치 시작에 한 번만 한다. 시작 시점에 한 번 시도해서 성공하면 그 세션을 라이브러리 전역에 등록해 재사용하고, 실패하면 환경 변수에서 자격 증명을 지운다. 라이브러리가 if login_id and login_pw: 로 진입을 막아두고 있어서, 값이 없으면 재시도 자체가 일어나지 않는다. 라이브러리 내부를 건드리지 않고 진입 조건만 닫는 방식이라 버전이 올라가도 깨지지 않는다.

2. 인증이 없으면 수급 단계를 통째로 건너뛴다. 못 받을 걸 알면서 1,060번 시도할 이유가 없다.

3. 연속 실패 차단기를 달았다. 인증이 정상이어도 외부 사정으로 계속 빈 응답이 올 수 있어, 연속 20건이 실패하면 남은 종목을 포기한다.

4. 중복 조회 하나를 없앴다. 종가 확인 단계가 받아오던 값은 앞 단계에서 이미 전 종목치를 확보해둔 것과 같은 값이었다. 반환값에 얹어 넘기는 것으로 끝났고, 외부 호출은 0회가 됐다.

차단기 초안이 틀렸다

3번은 처음에 이렇게 짰다.

if is_empty_response:
    consecutive_empty += 1
else:
    consecutive_empty = 0

빈 응답을 세고, 빈 응답이 아니면 리셋한다. 자연스러워 보인다.

리뷰에서 반례가 나왔다. 예외와 빈 응답이 번갈아 오면 예외 쪽이 else로 빠져 카운터를 0으로 되돌린다. 실패가 계속되는데 차단기는 영원히 발동하지 않는다. 실제로 그 패턴을 만들어 돌려보니 그대로 재현됐다.

리셋 조건을 "빈 응답이 아닐 때"가 아니라 "성공했을 때" 로 바꿔서 닫았다. 실패의 종류를 세는 게 아니라 성공을 세야 했던 것이다.

그리고 내가 방금 증명한 걸 어겼다

여기서 끝났으면 깔끔한 이야기인데, 뒷이야기가 있다.

수정을 마친 뒤 "시세 수집이 30일치를 받으니, 어제까지는 데이터베이스에 있는 걸 재사용하고 오늘 하루치만 새로 받으면 더 빨라지겠다"는 생각으로 캐시 경로를 만들었다. 설계도 받고 구현도 끝냈다.

그런데 그 경로도 종목당 호출을 한 번 한다. 하루치를 받든 30일치를 받든 왕복은 1회로 같다. 방금 1.82초와 1.85초로 "왕복이 시간을 지배한다"를 확정해놓고, 다음 단계에서 페이로드를 줄이는 최적화를 만든 것이다. 이득은 0이었다.

되돌렸다. 미측정 전제 위에 얹혀 있던 문제도 두 개 더 나왔지만, 롤백의 첫 사유는 그게 아니라 자기가 증명한 명제를 스스로 어긴 것이었다.

💡 성능 문제를 만나면 "무엇을 얼마나 받아오는가"부터 보게 된다. 그런데 네트워크 왕복이 지배적인 구간에서는 페이로드 크기가 거의 영향을 주지 않는다. 두 구간의 데이터 양이 서른 배 다른데 소요가 같다면, 그 순간부터 최적화의 축은 "양"이 아니라 "횟수"다. 이 판별은 프로파일러 없이도 가능하다. 성격이 다른 두 구간의 단위 소요를 나란히 재보는 것으로 충분하다.

남은 것

계정 자체는 오너가 비밀번호를 갱신해 풀었다. 그러니 이 수정들은 정상 상태에서는 아무 일도 하지 않는다.

그래도 되돌리지 않는다. 이번에 드러난 건 "계정이 잠겼다"가 아니라 실패가 반복될 때 시스템이 자기를 방어하지 못한다는 사실이었기 때문이다. 자격 증명은 언제든 다시 만료되고, 외부 서비스는 언제든 다시 막힌다. 그때 배치가 두 시간을 태우는지 12분에 포기하고 알려주는지가 이 수정의 값이다.