파이썬 시간대(timezone) 처리 완전정리 — 거래소 API 시간 맞추기

🌐 English

자동매매 봇에서 가장 조용하게, 가장 오래 사람을 괴롭히는 버그는 시간입니다. 에러도 안 나고, 로그도 정상으로 보이는데, 캔들 하나가 밀려 있고 그래서 신호가 9시간 늦게 뜹니다. 이 글은 파이썬에서 시간대(timezone, 지역별 표준시 오프셋)를 다룰 때 반복적으로 밟게 되는 지뢰를 순서대로 정리한 노트입니다.

⚠️ 이 글은 교육·정보 제공 목적입니다. 투자 권유가 아니며, 투자 손실 책임은 본인에게 있습니다.

1. 모든 문제의 뿌리: naive datetime

파이썬 datetime은 두 종류입니다.

  • naive — 시간대 정보가 없는 datetime. "10시"라고만 적혀 있고 어디의 10시인지 모릅니다.
  • awaretzinfo가 붙어 있어 UTC 기준 몇 시인지 확정할 수 있는 datetime.

문제는 naive끼리는 아무 경고 없이 비교·뺄셈이 된다는 점입니다. UTC로 받은 거래소 시각과 KST로 만든 로컬 시각을 naive 상태로 빼면, 결과는 9시간 틀린 채 조용히 계산됩니다.

from datetime import datetime, timezone, timedelta

datetime.now()          # naive — 이 서버가 어느 TZ인지에 따라 값이 달라짐

datetime.utcnow()       # naive인데 값은 UTC — 최악의 조합, 쓰지 마세요

datetime.now(timezone.utc)              # aware UTC ✅

datetime.now(timezone(timedelta(hours=9)))   # aware KST ✅

규칙 하나만 지키면 됩니다: 코드 안에서 돌아다니는 datetime은 전부 aware. naive는 외부에서 들어온 문자열을 파싱한 직후 딱 한 지점에서만 존재하고, 즉시 aware로 승격시킵니다.

2. pytz? zoneinfo?

파이썬 3.9부터는 표준 라이브러리 zoneinfo가 있습니다. 외부 패키지 pytz는 이제 필요 없고, 오히려 localize()를 안 쓰면 LMT(지역 평균시)라는 이상한 오프셋이 붙는 함정이 있어 초보자에게 위험합니다.

from zoneinfo import ZoneInfo

KST = ZoneInfo("Asia/Seoul")     # 표준 라이브러리, 3.9+

now = datetime.now(KST)

다만 한국은 서머타임이 없어 오프셋이 항상 +09:00으로 고정입니다. 그래서 실무 봇 코드에서는 IANA 데이터베이스 의존성을 아예 없애려고 고정 오프셋을 쓰는 경우도 많습니다.

KST = timezone(timedelta(hours=9))   # tzdata 없는 컨테이너에서도 동작

def _now_kst() -> datetime:

    return datetime.now(KST)

둘 중 무엇이든 좋지만, 프로젝트 전체에서 한 곳에 정의하고 import해서 쓰세요. 모듈마다 KST = ...를 복붙해 두면 나중에 하나만 바뀌었을 때 추적이 지옥이 됩니다.

3. 가장 많이 틀리는 곳 — replace(tzinfo=) vs astimezone()

이름이 비슷해서 헷갈리지만, 하는 일이 정반대입니다.

  • dt.replace(tzinfo=KST)주장합니다. 숫자는 그대로 두고 "이건 KST였어"라고 라벨만 붙입니다.
  • dt.astimezone(KST)변환합니다. 실제 시각은 유지한 채 숫자를 KST로 바꿉니다.
# 거래소가 준 ISO 문자열 (UTC, 끝에 Z)

raw = "2026-08-06T01:00:00Z"

# ❌ 9시간 틀림 — 01:00을 KST 01:00이라고 우겨버림

wrong = datetime.fromisoformat(raw.replace("Z", "")).replace(tzinfo=KST)

# ✅ UTC로 정확히 파싱 후 KST로 변환 → 10:00 KST

right = datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(KST)

판단 기준은 간단합니다. 원본 문자열이 이미 어느 TZ인지 알고 있으면 astimezone, 원본이 TZ 없는 로컬 표기(예: 20260806T100000)라서 내가 알려줘야 하면 replace(tzinfo=)입니다.

4. 거래소 타임스탬프: 초냐 밀리초냐

유닉스 타임스탬프(1970-01-01 UTC부터 흐른 시간)는 거래소마다 단위가 다릅니다. 바이낸스는 밀리초, 어떤 API는 초, 또 어떤 곳은 마이크로초를 줍니다. 1000배 차이라 잘못 해석하면 1970년이나 55000년으로 날아갑니다.

방어법은 자릿수로 자동 판별하는 헬퍼 하나입니다.

def ts_to_kst(ts) -> datetime:

    """초/밀리초/마이크로초 타임스탬프를 KST aware datetime으로."""

    v = float(ts)

    if v > 1e14:      # 마이크로초

        v /= 1_000_000

    elif v > 1e11:    # 밀리초

        v /= 1000

    return datetime.fromtimestamp(v, tz=timezone.utc).astimezone(KST)

fromtimestamptz=를 꼭 넘기세요. 빼먹으면 서버 로컬 TZ 기준 naive가 나오고, 개발 PC에서는 맞다가 UTC로 도는 서버에 올리는 순간 틀어집니다.

반대 방향, 즉 서명이나 nonce로 밀리초를 만들어 보낼 때는 이렇게 씁니다.

import time

ms = int(time.time() * 1000)   # 거래소 signed request의 timestamp 파라미터

5. 실전 사례 — 캔들이 한 칸 밀리는 이유

실제로 겪은 사례입니다. 분봉을 모아 15분봉으로 재집계하는 코드에서, 거래소가 준 시각은 UTC인데 리샘플 기준을 로컬 시간으로 잡았습니다. 결과적으로 09:00~09:15 구간에 08:00대 체결이 섞여 들어갔고, 지표는 정상 범위의 값을 뱉었습니다. 에러가 안 나니 몇 주를 몰랐습니다.

교훈은 두 가지였습니다.

  • 집계는 UTC로, 표시는 KST로. 내부 계산·저장·비교는 전부 UTC로 통일하고, 사람이 읽는 순간에만 KST로 변환합니다.
  • 로그는 두 벌 다 남긴다. UTC와 KST를 함께 찍으면 시간 관련 버그의 원인 파악 시간이 극적으로 줄어듭니다.
now_utc = datetime.now(timezone.utc)

now_kst = now_utc.astimezone(KST)

log = {

    "at_utc": now_utc.isoformat(),

    "at_kst": now_kst.strftime("%Y-%m-%d %H:%M:%S KST"),

}

추가로, 파싱 실패에 대한 정책을 미리 정해두세요. 시각 파싱이 터졌을 때 예외로 봇 전체를 죽일지, 보수적인 기본값으로 대체하고 경고만 남길지는 상황에 따라 다릅니다. 다만 말없이 datetime.now()로 대체하는 것만은 피하세요 — 가장 찾기 어려운 종류의 버그가 됩니다.

6. 장 시간 판정에서의 함정

"지금이 장중인가"를 판단하는 함수는 봇의 심장입니다. 그런데 이 함수가 naive datetime.now()를 쓰면, 서버 TZ가 UTC인 클라우드에 배포하는 순간 장중을 새벽으로 착각해 하루 종일 아무것도 안 합니다. 조용히 아무 일도 안 하기 때문에 알림도 안 옵니다.

def is_market_hours() -> bool:

    now = datetime.now(KST)                 # 반드시 aware

    if now.weekday() >= 5:                  # 토·일

        return False

    return (9, 0) <= (now.hour, now.minute) < (15, 30)

휴장일(공휴일) 처리는 별도 캘린더가 필요합니다. 여기서는 요일만 걸렀지만, 실운영이라면 휴장일 목록을 파일로 두고 주기적으로 갱신하는 편이 안전합니다.

체크리스트

  • datetime.utcnow()datetime.now()(인자 없이)를 코드에서 전부 제거
  • TZ 상수는 프로젝트에 한 번만 정의하고 import
  • 외부 문자열 → astimezone(변환) / TZ 없는 로컬 표기 → replace(tzinfo=)(주장)
  • 타임스탬프는 자릿수로 초·밀리초·마이크로초 자동 판별
  • fromtimestamp에는 항상 tz= 지정
  • 내부 저장·비교는 UTC, 출력만 KST. 로그는 두 벌 다
  • 장 시간 판정 함수는 반드시 aware datetime 사용

시간 버그는 터질 때 요란하지 않습니다. 그래서 더 비쌉니다. 처음 설계할 때 30분 들여 규칙을 정해두는 게, 나중에 며칠을 아껴줍니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기