외부 시스템에서 자료를 받아오는 연동이 갑자기 실패했는데 우리 쪽은 그 사이 아무것도 안 바꿨다.
응답을 찍어 예전 것과 대 봤다.
{ "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_FIELDS 와 REQUIRED_FIELDS 를 한 규칙으로 정하려 하면 어느 쪽도 안 맞는다. 들어오는 것이 여분인지 모자람인지로 갈라야 했다.
여분은 우리가 안 쓰면 그만이고 모자람은 우리가 쓸 것이 없는 상태다. 그래서 앞은 넘기고 뒤는 멈춘다.
응답 구조가 두 가지였다
이 연동을 파다가 하나 더 발견했는데 성공과 실패의 응답 구조가 완전히 달랐다.
성공: { "code": "0000", "data": { ... } }
실패: { "errors": [ { "code": "1001", "message": "..." } ] }
실패 응답에는 code 가 최상위에 없는데 우리 코드는 $response['code'] 를 먼저 읽고 있었다.
그러면 실패했을 때 오류 메시지를 읽기도 전에 죽는다. 로그에는 Undefined index: code 만 남는다.
진짜 원인은 errors 안에 있는데 그것이 안 보이므로 로그가 원인을 가린 증상이 되는 것이다.
순서를 바꿔서 오류 구조를 먼저 확인하고 없으면 성공 구조로 가게 했다.
data 를 읽는 성공 경로를 먼저 쓰는 것이 자연스러워 보인다. 그런데 읽는 순서는 어느 쪽이 더 자주 오느냐가 아니라 어느 쪽이 안 읽히면 손해가 큰가로 정해야 했다.
data 를 못 읽으면 자료가 안 들어오고 errors 를 못 읽으면 왜 안 들어오는지를 모른다.
참고 자료 — 외부 연동 체크리스트
이 일들을 정리해 목록으로 만들었다.
모르는 필드를 무시하되 기록한다
필수 필드 없음은 명시적으로 거부한다
성공과 실패의 응답 구조가 다른지 확인한다 — 다르면 실패 쪽을 먼저 읽는다
연속 실패에 알림을 건다 — 한 번은 흔하고 연속은 다르다
재시도가 안전한지 본다 — 조회는 안전하고 생성·변경은 아니다
응답 전문을 로그에 남긴다 — 실패했을 때 무엇이 왔는지 알아야 한다
마지막이 특히 중요했는데 원래는 json_decode 결과만 로그에 남기고 있었다.
파싱이 실패하면 아무것도 안 남으므로 원본을 먼저 남기고 파싱하게 순서를 바꿨다.
이번 건도 원문이 남았으면 traceId 를 바로 봤을 것이다. 조사 시간의 대부분이 무엇이 왔는지 다시 받는 데 들었다.
정리
- 외부 응답은 예고 없이 필드가 늘고 상대는 그것을 호환 깨짐으로 안 본다
- 모르는 필드에 예외를 던지면 정상적인 변화에 깨진다
- 실패보다 실패가 며칠 조용했던 것이 더 큰 문제였다
- 모르는 것은 무시하되 이름을 기록해 변화를 안다
- 없는 필수 필드는 명시적으로 거부한다
- 조용한
null이 그대로 저장되거나 0으로 취급된다 - 성공과 실패의 응답 구조가 다를 수 있다
- 실패 쪽을 먼저 읽어야 진짜 오류가 보인다
- 연속 실패에 알림을 걸어 회복 안 되는 상태를 사람이 안다
- 응답 원본을 파싱 전에 남겨야 파싱이 죽어도 단서가 남는다