Skip to content
isdnetworks
Go back

필드 하나가 늘어서 멈췄다

외부 시스템에서 자료를 받아오는 연동이 갑자기 실패했는데 우리 쪽은 그 사이 아무것도 안 바꿨다.

응답을 찍어 예전 것과 대 봤다.

{ "code": "0000", "message": "OK", "traceId": "abc123" }

traceId 가 새로 생겨 있었고 우리 파서는 그것을 모른다.

Table of contents

Open Table of contents

모르는 필드에 예외를 던지고 있었다

파싱 코드가 이랬다.

foreach ($response as $key => $value) {
    if (!in_array($key, self::KNOWN_FIELDS)) {
        throw new Exception("Unknown field: $key");
    }
    ...
}

KNOWN_FIELDS 에 없는 키를 만나면 예외를 던지는데 엄격하게 검증하려는 의도는 이해된다.

오타나 형식 변경을 빨리 잡으려는 것인데 외부 시스템에 대해서는 이 엄격함이 위험하다.

그쪽은 알리지 않고 필드를 추가하는데 추가는 하위 호환이라 그쪽에서는 알릴 변경도 아니다.

우리 안에서 쓰는 JSON 이면 엄격한 검증이 맞다. 양쪽을 우리가 고치므로 안 맞으면 그 자리에서 잡는 것이 낫다.

밖에서 오는 JSON 은 사정이 다르다. 우리가 못 정하는 것에 우리 규칙을 강요하면 상대가 바뀔 때마다 깨진다.

원인 — 알림 없이 반복된 실패

더 나빴던 것이 따로 있는데 이 연동은 배치로 돌고 실패하면 재시도한다.

재시도도 같은 이유로 실패하고 응답이 바뀐 것이 원인이라 몇 번을 다시 해도 같다.

계속 실패하는데 알림이 없었고 로그에는 쌓이고 있었지만 아무도 안 봤다.

며칠 뒤 자료가 안 들어온다는 제보로 알았다. 실패한 것보다 실패가 며칠 조용했던 것이 더 큰 문제였다.

Log::info 에 남긴다는 것과 누가 본다는 것은 다른 이야기다. 쌓이기만 하는 로그는 사후에 읽을 근거는 되지만 알림은 아니다.

그 사이에 못 받은 자료는 그대로 구멍으로 남았다. 나중에 다시 받아 채우는 작업이 따로 필요했다.

조치 — 무시하되 기록하기

모르는 필드를 무시하게 바꿨다.

$known = array_intersect_key($response, array_flip(self::KNOWN_FIELDS));

array_intersect_key 로 필요한 것만 뽑고 나머지는 버리므로 새 필드가 생겨도 안 깨진다.

다만 그냥 버리면 상대가 무언가 바꿨다는 사실을 우리가 영영 모른다.

$unknown = array_diff_key($response, array_flip(self::KNOWN_FIELDS));
if ($unknown) {
    Log::info('unknown fields in response', ['fields' => array_keys($unknown)]);
}

array_diff_key 로 모르는 것만 골라 이름을 남기면 깨지지 않으면서 변화는 아는 상태가 된다.

나중에 그 필드가 필요해지면 이 로그가 힌트가 되고 같은 연동이 3회 연속 실패하면 알리게 걸었다.

한 번 실패는 흔하고 망이 잠깐 끊길 수도 있는데 연속 실패는 회복이 안 되는 상태라 성격이 다르다.

반대 방향도 확인했다

필드가 빠지는 경우도 봤다.

$name = $response['name'];    // 없으면?

없는 키에 접근하면 경고만 나고 null 이 되는데 예외가 아니라 조용한 null 이다.

null 이 그대로 저장되거나 다음 계산에서 0으로 취급되므로 필수 필드는 명시적으로 확인하게 했다.

foreach (self::REQUIRED_FIELDS as $f) {
    if (!isset($response[$f])) {
        throw new Exception("Missing required field: $f");
    }
}

모르는 필드는 관용하고 없는 필수 필드는 거부하는데 방향이 반대여도 목적은 조용한 실패를 막는 것이다.

KNOWN_FIELDSREQUIRED_FIELDS 를 한 규칙으로 정하려 하면 어느 쪽도 안 맞는다. 들어오는 것이 여분인지 모자람인지로 갈라야 했다.

여분은 우리가 안 쓰면 그만이고 모자람은 우리가 쓸 것이 없는 상태다. 그래서 앞은 넘기고 뒤는 멈춘다.

응답 구조가 두 가지였다

이 연동을 파다가 하나 더 발견했는데 성공과 실패의 응답 구조가 완전히 달랐다.

성공: { "code": "0000", "data": { ... } }
실패: { "errors": [ { "code": "1001", "message": "..." } ] }

실패 응답에는 code 가 최상위에 없는데 우리 코드는 $response['code'] 를 먼저 읽고 있었다.

그러면 실패했을 때 오류 메시지를 읽기도 전에 죽는다. 로그에는 Undefined index: code 만 남는다.

진짜 원인은 errors 안에 있는데 그것이 안 보이므로 로그가 원인을 가린 증상이 되는 것이다.

순서를 바꿔서 오류 구조를 먼저 확인하고 없으면 성공 구조로 가게 했다.

data 를 읽는 성공 경로를 먼저 쓰는 것이 자연스러워 보인다. 그런데 읽는 순서는 어느 쪽이 더 자주 오느냐가 아니라 어느 쪽이 안 읽히면 손해가 큰가로 정해야 했다.

data 를 못 읽으면 자료가 안 들어오고 errors 를 못 읽으면 왜 안 들어오는지를 모른다.

참고 자료 — 외부 연동 체크리스트

이 일들을 정리해 목록으로 만들었다.

모르는 필드를 무시하되 기록한다
필수 필드 없음은 명시적으로 거부한다
성공과 실패의 응답 구조가 다른지 확인한다 — 다르면 실패 쪽을 먼저 읽는다
연속 실패에 알림을 건다 — 한 번은 흔하고 연속은 다르다
재시도가 안전한지 본다 — 조회는 안전하고 생성·변경은 아니다
응답 전문을 로그에 남긴다 — 실패했을 때 무엇이 왔는지 알아야 한다

마지막이 특히 중요했는데 원래는 json_decode 결과만 로그에 남기고 있었다.

파싱이 실패하면 아무것도 안 남으므로 원본을 먼저 남기고 파싱하게 순서를 바꿨다.

이번 건도 원문이 남았으면 traceId 를 바로 봤을 것이다. 조사 시간의 대부분이 무엇이 왔는지 다시 받는 데 들었다.

정리


Share this post on:

Previous Post
조회 키가 어느 컬럼인지 정확히 알아야 했다
Next Post
도우미 함수인 줄 알았는데 외부를 부르고 있었다