JSON 파싱 에러 잡기 — API 응답이 예상과 다를 때

🌐 English

API 연동 초반에 가장 많이 마주치는 트레이스백은 KeyError, TypeError: 'NoneType' object is not subscriptable, json.decoder.JSONDecodeError 셋입니다. 셋 다 원인은 하나입니다 — 응답이 내가 문서에서 본 모양과 다르다. 이 글은 그 "다름"의 종류를 분류하고, 각각을 어디서 막을지 정리한 실전 노트입니다.

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

깨지는 방식은 6가지뿐

  1. JSON이 아님 — 게이트웨이 오류 HTML, 빈 문자열, 점검 안내 페이지
  2. 필드 누락 — 있을 줄 알았던 키가 없음
  3. 값이 null — 키는 있는데 null
  4. 타입이 다름 — 숫자인 줄 알았는데 문자열 "12.0"
  5. 구조가 다름 — 리스트인 줄 알았는데 단일 객체, 혹은 빈 배열
  6. HTTP 200인데 에러 — 본문 안에 rt_cd, code 같은 업무 에러 코드

1번만 JSONDecodeError로 잡히고, 2~6번은 예외조차 나지 않습니다. 그래서 더 위험합니다.

1단계 — 파싱 자체를 감싼다 (파이썬)

가장 흔한 실수는 r.json()을 맨몸으로 호출하는 것입니다. 거래소가 점검에 들어가면 HTML을 주고, 여기서 바로 터집니다.

import json, requests

def fetch_json(url, **kw):

    r = requests.get(url, timeout=(3, 10), **kw)

    r.raise_for_status()

    if not r.content:                      # 빈 응답 (204 등)

        return None

    try:

        return r.json()

    except json.JSONDecodeError:

        # 앞 200자만 남긴다 — 전체를 찍으면 로그가 HTML로 도배됨

        raise ValueError(f"JSON 아님: {r.text[:200]!r}")

timeout을 튜플로 주는 것도 같이 기억해두세요. (연결 타임아웃, 읽기 타임아웃)입니다. 타임아웃 없는 요청 하나가 봇 전체를 무한정 멈춰 세울 수 있습니다.

2단계 — 값 꺼내기를 함수로 (null·타입 방어)

dict.get(key, default)키가 없을 때만 기본값을 줍니다. 키가 있고 값이 null이면 그대로 None이 나옵니다. 그래서 중첩 접근에는 or {}를 덧붙이는 습관이 유용합니다.

# ❌ output이 null이면 AttributeError

qty = resp.get("output", {}).get("qty")

# ✅ null도 흡수

qty = ((resp or {}).get("output") or {}).get("qty")

타입 방어는 변환 헬퍼를 하나 만들어 두는 게 가장 깔끔합니다. 실제 증권사·거래소 API는 숫자를 문자열로 주고, 심지어 정수 필드를 "12.0"으로 주기도 합니다.

def as_float(v):

    if v is None or v == "":

        return None

    try:

        return float(v)

    except (TypeError, ValueError):

        return None

def as_int(v):

    if v is None or v == "":

        return None

    try:

        return int(float(v))    # "12.0" → 12 까지 흡수

    except (TypeError, ValueError):

        return None

핵심은 예외를 던지지 않고 None을 반환한다는 점입니다. 값이 없다는 사실을 호출부가 판단하게 넘기는 것이지, 봇을 죽이는 게 아닙니다. 대신 호출부에서 None 체크를 빼먹으면 안 되니, "값이 없으면 이번 사이클을 건너뛴다"는 규칙을 명시적으로 씁니다.

price = as_float(item.get("price"))

if price is None or price <= 0:

    log.warning("가격 없음/이상 — 스킵: %s", item.get("symbol"))

    continue

3단계 — HTTP 200 안의 에러를 분리한다

많은 국내 API가 실패해도 HTTP 200을 줍니다. 본문 안 코드 필드로 성공/실패를 구분해야 합니다. 그래서 파싱 함수는 전송 계층 에러와 업무 계층 에러를 둘 다 봐야 합니다.

class APIBusinessError(Exception):

    pass

def parse(r: requests.Response) -> dict:

    r.raise_for_status()                       # 전송 계층

    body = r.json()

    if body.get("rt_cd") not in (None, "0"):   # 업무 계층

        raise APIBusinessError(f"{body.get('msg_cd')}: {body.get('msg1')}")

    return body

이걸 나눠두면 재시도 정책도 나눌 수 있습니다. 전송 에러(타임아웃, 502)는 재시도할 가치가 있지만, 업무 에러(잔고 부족, 잘못된 종목코드)는 백번 재시도해도 똑같이 실패합니다.

Go에서의 대응 — 타입이 있어서 오히려 조용하다

Go의 json.Unmarshal은 모르는 필드를 무시하고, 없는 필드는 제로값으로 둡니다. 즉 필드가 통째로 사라져도 에러가 안 납니다. 가격이 0.0으로 들어오고 봇은 그걸로 계산합니다.

type Ticker struct {

    Symbol string  `json:"symbol"`

    Price  string  `json:"price"`   // 문자열로 오는 거래소가 많음

}

var t Ticker

if err := json.Unmarshal(body, &t); err != nil {

    return fmt.Errorf("ticker 파싱 실패: %w", err)

}

price, err := strconv.ParseFloat(t.Price, 64)

if err != nil || price <= 0 {

    return fmt.Errorf("가격 이상: %q", t.Price)

}

"필드가 실제로 왔는가"와 "0이 왔는가"를 구분해야 한다면 포인터 필드를 씁니다. 오지 않으면 nil, 0이 오면 *v == 0으로 구별됩니다.

type Fill struct {

    Qty *float64 `json:"qty"`   // nil = 미수신, 0 = 실제 0

}

if f.Qty == nil {

    return errors.New("qty 필드 누락")

}

에러 응답을 파싱할 때 특히 조심할 게 있습니다. json.Unmarshal아무 유효한 JSON에나 성공하므로, 언마샬 성공만으로 "에러 응답이 맞다"고 판단하면 안 됩니다. 값 검사를 같이 해야 합니다.

if resp.StatusCode != http.StatusOK {

    var apiErr struct {

        Code int    `json:"code"`

        Msg  string `json:"msg"`

    }

    // 언마샬 성공 + Code가 실제로 채워짐 → 진짜 에러 봉투

    if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Code != 0 {

        return fmt.Errorf("API 오류 %d: %s", apiErr.Code, apiErr.Msg)

    }

    return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))

}

애초에 JSON이 아닌 경우 — 인덱스 방어

실시간 시세는 JSON이 아니라 구분자로 이어붙인 문자열인 경우도 흔합니다(예: ^ 구분). 이때는 인덱싱 전에 개수와 모양을 먼저 검사합니다. 이 두 줄이 없으면 필드 하나가 빠진 순간 인덱스 범위 초과로 프로세스가 죽습니다.

fields := strings.Split(payload, "^")

if len(fields) < 44 {        // 개수 가드 — 인덱싱 전에

    return

}

symbol := strings.TrimSpace(fields[0])

if len(symbol) != 6 {        // 모양 가드

    return

}

스키마 검증을 얹을까?

pydantic 같은 라이브러리로 응답 모델을 선언하면 위의 방어가 상당 부분 자동화됩니다. 다만 판단 기준을 분명히 하는 게 좋습니다.

  • 엔드포인트가 5개 미만이고 필드도 몇 개뿐 → 위의 헬퍼 함수로 충분. 의존성 늘릴 이유 없음
  • 응답 구조가 복잡·중첩이거나 여러 거래소를 하나의 내부 모델로 통합 → 스키마 검증이 확실히 이득

어느 쪽이든 원칙은 같습니다. 외부 데이터는 경계에서 한 번만 검증하고, 그 안쪽으로는 검증된 타입만 흘려보낸다. 비즈니스 로직 한복판에서 if data.get("x") is None이 튀어나오기 시작하면 경계 설계가 잘못된 신호입니다.

정리

  • r.json()은 항상 try로 감싸고, 실패 시 본문 앞부분만 로그에
  • .get(k, {})는 null을 못 막는다 → (x or {}) 병행
  • 숫자 변환은 예외 던지지 말고 None 반환 헬퍼로
  • 전송 계층 에러와 업무 계층 에러를 분리 → 재시도 정책도 분리
  • Go는 필드가 없어도 조용하다. 값 검사(0 이하, 빈 문자열)를 반드시 추가
  • "오지 않음"과 "0"의 구분이 필요하면 포인터 필드
  • 인덱싱 전에는 길이·모양 가드 먼저

API는 언젠가 바뀝니다. 예고 없이 필드가 하나 사라지는 날, 봇이 죽느냐 경고만 남기고 넘어가느냐는 이 경계 코드에서 갈립니다.

댓글

이 블로그의 인기 게시물

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

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

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