[개발 깨알 상식_Tips] / 중복 키가 조용히 덮어쓴 JSON 설정.md

중복 키가 조용히 덮어쓴 JSON 설정

조회

2026년 7월 13일 | 개발 깨알 상식_Tips


설정 파일에는 "timeout": 5가 분명히 있었는데, 프로그램이 읽은 값은 30초였다. 파일 끝쪽에 같은 키가 한 번 더 들어간 것이 원인이었다. JSON 문법은 정상이라 파서는 예외를 내지 않았고, Python의 json.loads는 뒤에 나온 값을 남겼다. 나는 처음에 환경변수나 기본값 병합 순서를 의심했지만, 실제 문제는 같은 객체 안의 중복 키가 파싱 단계에서 조용히 사라진 것이었다.

JSON 원문, 일반 파싱, 중복 키 검사 흐름

Figure 1. 일반 파싱은 마지막 값만 남기지만, 키-값 쌍 단계에서는 중복을 잡을 수 있다.

중복 키는 보기보다 위험하다. timeout처럼 뒤 값이 이기는 설정이면 실행이 느려지는 정도로 끝날 수 있지만, 권한 플래그나 배포 대상, 저장 경로가 겹치면 사람이 검토한 앞쪽 값과 실제 실행값이 달라진다. 로그에는 최종 딕셔너리만 남기 때문에 원문을 다시 열기 전까지는 왜 그 값이 선택됐는지도 보이지 않는다.

문법 오류가 아니라 상호운용성 오류

JSON 표준은 객체 안의 이름이 고유해야 한다고 권고한다. 다만 중복 이름을 만났을 때 반드시 실패하라고 정하지는 않는다. 그래서 구현마다 행동이 달라질 수 있다. 어떤 파서는 마지막 값을 남기고, 어떤 도구는 첫 값을 택하며, 어떤 검증기는 오류를 낸다. 같은 파일이 로컬 스크립트와 배포 도구에서 다르게 읽힐 여지가 생긴다.

{
  "service": "worker",
  "timeout": 5,
  "retry": 2,
  "timeout": 30
}

Python 표준 json 모듈은 기본 설정에서 반복된 이름을 받아들이고 마지막 이름-값 쌍만 사용한다. 따라서 json.loads가 성공했다는 사실은 JSON 텍스트를 읽었다는 뜻이지, 설정 이름이 한 번씩만 등장했다는 보증은 아니다. 이 둘을 같은 검증으로 취급하면 중복 키는 정상 입력처럼 다음 단계로 넘어간다.

딕셔너리가 된 뒤에는 증거가 없다

중복을 dict로 바꾼 뒤 검사하려고 하면 이미 늦다. 딕셔너리에는 timeout이 한 칸만 남아 있고, 그 값이 원문에서 한 번 나왔는지 두 번 나왔는지 알 수 없다. 최종 키 개수와 원문 멤버 수를 비교하는 방식도 문자열 안의 콜론, 중첩 객체, escape 때문에 금방 흔들린다.

Python에서는 object_pairs_hook을 쓰면 각 객체를 딕셔너리로 접기 전에 키-값 쌍 목록으로 받을 수 있다. 이 지점에서는 등장 순서와 반복 횟수가 남아 있다. 나는 외부 설정이나 배포 manifest를 받을 때 이 훅에서 중복을 발견하면 즉시 예외를 내도록 둔다.

import json

def reject_duplicates(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError(f"duplicate JSON key: {key}")
        result[key] = value
    return result

config = json.loads(raw_text, object_pairs_hook=reject_duplicates)

이 검사는 최상위 객체에만 적용되는 것이 아니다. 디코더가 만나는 중첩 객체마다 훅이 호출되므로, 안쪽의 database.timeout 같은 반복도 잡을 수 있다. 에러에는 키 이름만 적지 말고 가능하면 파일명과 설정 섹션을 함께 남기는 편이 좋다. 비밀값 자체를 로그에 출력하지 않아도 수정 위치는 충분히 좁힐 수 있다.

반대로 문서 전체에서 같은 이름을 한 번만 허용하는 검사는 너무 강하다. primary.timeoutbackup.timeout은 서로 다른 객체의 정상 필드다. 금지해야 할 것은 같은 객체 범위 안에서의 반복이다. 그래서 텍스트 검색으로 timeout 출현 횟수만 세면 false positive가 많아지고, 결국 경고를 무시하게 된다. 파서가 넘겨주는 객체별 쌍 목록을 쓰는 이유가 여기에 있다.

병합 뒤 중복과 원문 중복을 나눠 보기

여기서 헷갈리기 쉬운 부분은 여러 설정을 의도적으로 병합하는 경우다. 기본 설정의 timeout=5를 운영 설정의 timeout=30으로 덮는 것은 정책일 수 있다. 반면 하나의 JSON 객체 안에 같은 이름이 두 번 들어간 것은 작성 오류에 가깝다. 두 상황을 모두 “마지막 값 우선”으로 묶으면 의도한 override와 오타를 구분할 수 없다.

나는 순서를 분리한다. 먼저 각 원본 파일을 중복 키 금지 모드로 독립 파싱하고, 그다음 명시적인 병합 함수에서 override를 허용한다. 병합 단계에서는 어떤 파일의 어떤 키가 이전 값을 바꿨는지 짧은 계획 로그를 남긴다. 그러면 원문 오류는 입구에서 멈추고, 의도한 재정의만 별도의 감사 흔적으로 남는다.

레거시 파일에 중복 키가 이미 많다면 곧바로 자동 정리부터 하지 않는다. 마지막 값을 유지할지, 앞쪽 값을 살릴지, 두 값을 배열로 바꿀지는 업무 의미에 따라 다르기 때문이다. 먼저 중복 키 목록과 원문 위치를 보고서로 뽑고, 소유자가 선택한 값을 명시적으로 남긴 뒤 파일을 다시 생성한다. 파서가 선택한 마지막 값을 정답으로 간주해 일괄 저장하면 잘못된 설정을 정상 형태로 굳힐 수 있다.

생성기와 편집기까지 회귀 범위에 넣기

손으로 쓴 JSON만 검사해도 절반은 놓친다. 템플릿 조각을 문자열로 이어 붙이거나, 서로 다른 플러그인이 같은 설정 블록을 추가하면 생성 결과에 중복 키가 생길 수 있다. 특히 단순 텍스트 치환은 기존 키의 존재를 모르기 때문에 같은 줄을 한 번 더 넣기 쉽다. 최종 산출물을 일반 파서로 한 번 읽고 끝내면 문제는 그대로 통과한다.

  • 원본 JSON 파일마다 중복 키 검사를 실행한다.
  • 템플릿 렌더링 뒤 최종 JSON에도 같은 검사를 반복한다.
  • 설정 병합은 파싱과 분리하고 override 출처를 기록한다.
  • 중복 샘플 하나를 CI의 실패 fixture로 고정한다.

formatter도 해결책으로 믿지 않는 편이 낫다. formatter가 이미 일반 파서로 읽은 뒤 다시 출력한다면, 보기 좋게 정렬된 파일에는 마지막 값만 남는다. 이 결과만 diff로 보면 중복이 정리된 것처럼 보이지만 실제로는 어느 값이 사라졌는지 검토 기회까지 없어진다. 포맷팅 전에 검증해야 하는 이유다.

최종값보다 원문 계약을 먼저 지키기

운영에서 필요한 것은 “현재 timeout이 30이다”라는 결과만이 아니다. 그 값이 한 번 선언됐는지, 명시적인 override로 바뀌었는지, 우연히 중복된 뒤 마지막 값이 살아남았는지가 함께 보여야 한다. JSON 중복 키는 이 출처 정보를 파싱 순간 지워 버린다.

비슷한 증상을 만나면 나는 최종 딕셔너리부터 비교하지 않는다. 원문에서 같은 이름이 반복됐는지 먼저 확인하고, 쌍 목록 단계에서 실패시키고, 의도한 병합은 별도 함수로 옮긴다. 이 순서를 지키면 조용히 달라진 설정값이 재현 가능한 입력 오류로 바뀌고, 다음 배포에서 같은 파일을 다시 통과시키지 않을 기준도 남는다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.