키를 정렬했는데 왜 다른 키가 움직였죠? JSON 재배치 CLI의 안전한 --inplace

요구사항은 처음에 아주 짧았습니다.
JSON object의 key를 주어진 배열 순서대로 바꿔 주세요.
예를 들어 원본이 다음과 같다고 하겠습니다.
{
"a": 1,
"b": 2,
"c": 3,
"d": 4
}
요청 순서는 ['d', 'b']입니다.
결과를 어떻게 예상하시나요?
{
"d": 4,
"b": 2,
"a": 1,
"c": 3
}
저는 처음에 이런 “선택 key를 앞으로 모으는 정렬”을 떠올릴 수 있다고 봤습니다. 하지만 실제 요구는 달랐습니다.
배열에 없는 key는 원래 자리에 남아야 했습니다.
따라서 결과는 이것입니다.
{
"a": 1,
"d": 4,
"c": 3,
"b": 2
}
a와 c는 움직이지 않았습니다.
원래 b와 d가 차지하던 두 자리 안에서만 d, b 순서로 바뀌었습니다.
작은 차이처럼 보이지만 algorithm도, test도, CLI의 안전성도 여기서 갈립니다.
key 순서가 아니라 slot의 소유권을 정해야 합니다
“key를 정렬한다”는 말에는 여러 해석이 숨어 있습니다.
- 배열에 적힌 key를 object 앞쪽으로 모은다.
- 배열 순서를 우선하고 나머지는 뒤에 붙인다.
- 전체 key 집합을 comparator로 정렬한다.
- 선택된 key가 있던 자리만 다시 채운다.
이번 CLI가 구현한 것은 마지막입니다.
원본 key sequence를 다시 보면 다음과 같습니다.
index 0: a unselected
index 1: b selected
index 2: c unselected
index 3: d selected
선택된 slot은 index 1과 3입니다.
요청 순서에서 실제 원본에 존재하는 key는 d, b입니다.
그 둘을 선택 slot에 차례로 넣습니다.
index 0: a unchanged
index 1: d selected slot 1
index 2: c unchanged
index 3: b selected slot 2
이렇게 표현하면 요구사항이 훨씬 선명해집니다.
선택되지 않은 key는 value뿐 아니라 위치도 소유합니다.
구현은 한 번의 순회면 충분했습니다
핵심 함수는 선택된 key와 선택되지 않은 key를 별도로 정렬하지 않습니다.
def reorder_selected_slots(
source: dict[str, Any], key_order: list[str]
) -> dict[str, Any]:
selected = [key for key in key_order if key in source]
selected_set = set(selected)
selected_iter = iter(selected)
result: dict[str, Any] = {}
for key in source:
output_key = next(selected_iter) if key in selected_set else key
result[output_key] = source[output_key]
return result
동작은 세 단계입니다.
- 요청 순서에서 원본에 존재하는 key만
selected에 남깁니다. - 원본 object를 insertion order대로 한 번 순회합니다.
- 현재 slot이 selected였다면 iterator의 다음 key를 넣고, 아니면 원래 key를 그대로 넣습니다.
새 abstraction이나 일반화된 sorting framework는 필요하지 않았습니다. 이 문제는 comparator 문제가 아니라 slot 치환 문제였기 때문입니다.
200줄짜리 “JSON transformation engine”을 만들었다면 요구사항보다 engine 설명이 더 길어졌을 겁니다. 핵심 함수는 작은 편이 낫습니다. 복잡성은 뒤에 나올 destructive boundary에 써야 합니다.
누락 key와 중복 key도 규칙을 먼저 정했습니다
CLI의 두 번째 positional argument는 JSON string array입니다.
python3 scripts/reorder-json-keys.py obj.json '["d", "missing", "b", "d"]'
여기에는 두 edge case가 있습니다.
missing은 원본 object에 없습니다.d는 요청 배열에 두 번 들어 있습니다.
현재 규칙은 다음과 같습니다.
- 원본에 없는 key는 건너뜁니다.
- 중복 key는 첫 번째 등장만 사용합니다.
- 배열이 아니거나 문자열 아닌 원소가 있으면 error로 종료합니다.
중복 제거에는 dict.fromkeys()를 사용합니다.
def parse_key_order(raw: str) -> list[str]:
value = json.loads(raw)
if not isinstance(value, list) or not all(
isinstance(key, str) for key in value
):
raise ValueError("key order must be a JSON array of strings")
return list(dict.fromkeys(value))
이 규칙 덕분에 ['d', 'missing', 'b', 'd']는 실질적으로 ['d', 'b']처럼 작동합니다.
중요한 것은 조용히 처리한다는 사실보다 어떻게 처리할지 test로 고정했다는 것입니다. “중복은 마지막이 이긴다”도 가능한 계약입니다. 하지만 첫 번째가 이긴다고 정했다면 mutation 하나가 그 우선순위를 바꾸지 못하게 전용 regression test가 필요합니다.
기본 모드는 원본을 건드리지 않습니다
--inplace 없이 실행하면 원본의 sibling file을 만듭니다.
python3 scripts/reorder-json-keys.py obj.json '["d", "b"]'
입력이 obj.json이면 출력은 obj.sorted.json입니다.
rendering 규칙도 고정했습니다.
- UTF-8
- 2-space indentation
- Unicode를 ASCII escape로 강제하지 않음
NaN같은 비표준 숫자를 허용하지 않음- 마지막 newline 추가
def render_json(value: dict[str, Any]) -> str:
return json.dumps(
value,
ensure_ascii=False,
indent=2,
allow_nan=False,
) + "\n"
같은 입력과 key order는 같은 text output을 만듭니다. review할 때 의미 없는 formatting diff를 줄이려면 이 결정론이 필요합니다.
다만 기본 mode가 모든 write risk를 제거하는 것은 아닙니다.
같은 이름의 obj.sorted.json이 이미 있으면 현재 구현은 그 output path를 다시 씁니다.
원본 보존과 output 충돌 방지는 다른 계약입니다.
--inplace부터는 algorithm보다 transaction이 중요합니다
원본을 직접 바꾸는 option은 편리합니다.
python3 scripts/reorder-json-keys.py --inplace obj.json '["d", "b"]'
문제는 파일을 읽고 새 JSON을 계산하는 사이에 다른 process가 원본을 고칠 수 있다는 것입니다.
T0 CLI reads obj.json
T1 another process adds "new": true
T2 CLI writes the reordered T0 snapshot
T2가 그대로 실행되면 key 정렬과 관계없는 new가 사라집니다.
CLI는 자기 작업에는 성공했지만 다른 사람의 작업을 덮어썼습니다.
이 race를 막기 위해 --inplace는 처음 읽은 snapshot을 destructive boundary에서 다시 확인합니다.
stat 하나만 비교하지 않았습니다
현재 구현은 파일 identity를 다섯 값으로 기록합니다.
(
st_dev,
st_ino,
st_size,
st_mtime_ns,
st_ctime_ns,
)
그리고 content의 SHA-256도 비교합니다.
content_changed = (
hashlib.sha256(current_bytes).digest()
!= hashlib.sha256(source_bytes).digest()
)
identity_changed = current_identity != expected_identity
metadata만 보면 같은 크기로 빠르게 바뀐 content를 놓칠 수 있습니다. content hash만 보면 path가 다른 inode로 교체됐다가 같은 bytes를 가리키는 identity change를 놓칠 수 있습니다.
그래서 둘 중 하나라도 바뀌면 ConcurrentModificationError를 냅니다.
obj.json changed while being rewritten
여기서 검사 시점도 중요합니다. 처음 읽은 직후 한 번 확인하고 끝내지 않습니다.
- source를 읽기 전후에 identity가 같은지 확인합니다.
write_inplace()에 들어오자마자 snapshot을 다시 확인합니다.- temporary output을 쓴 뒤 다시 확인합니다.
- backup을 만든 뒤, 실제 replace 직전에 다시 확인합니다.
검사와 교체 사이의 window를 완전히 0으로 만드는 lock은 아닙니다. 하지만 stale snapshot을 읽어 놓고 한참 뒤 무조건 덮어쓰는 구현보다 destructive boundary를 훨씬 좁힙니다.
backup을 먼저 만들고 같은 directory에서 교체합니다
--inplace의 write 순서는 다음과 같습니다.
source snapshot revalidation
-> same-directory temporary file write + fsync
-> source revalidation
-> backup file write + fsync
-> directory fsync
-> source revalidation
-> os.replace(temporary, source)
-> directory fsync
temporary file은 source와 같은 directory에 만듭니다. backup 이름은 다음 형태입니다.
obj.json.backup.<random-suffix>
원본 mode도 temporary와 backup에 적용합니다.
그다음 os.replace()로 temporary path를 source path에 놓습니다.
실패했을 때 temporary file은 finally에서 지웁니다.
동시 변경이 감지되면 원본을 교체하지 않으며, stale snapshot 검사 test에서는 backup과 temporary file도 남지 않는지 확인합니다.
backup은 성공 뒤 자동 삭제하지 않습니다. 사용자가 복구 가능성을 얻는 대신 cleanup 책임도 얻습니다. 이 trade-off는 조용히 숨길 항목이 아니라 CLI 사용법에 함께 적어야 합니다.
실패-우선 검사가 요구사항의 차이를 읽는지 확인했습니다
현재 integration test는 네 가지를 검사합니다.
- 선택된 slot만 재배치되고 기본 mode에서 원본은 그대로인가?
- 중복 요청에서 첫 번째 key가 우선하는가?
--inplace가 backup을 남기고 원본을 교체하는가?- stale snapshot이면 바뀐 원본을 보존하고 artifact를 남기지 않는가?
이번 글을 확인하며 먼저 일부러 잘못된 기대를 넣었습니다.
actual = list(
reorder_selected_slots(
{"a": 1, "b": 2, "c": 3, "d": 4},
["d", "b"],
)
)
assert actual == ["d", "b", "a", "c"]
선택 key를 앞으로 모을 것이라는 sentinel은 예상대로 실패했습니다. 실제 결과는 다음과 같았습니다.
['a', 'd', 'c', 'b']
그다음 선택되지 않은 slot이 유지된다는 assertion은 통과했고, 현재 integration test 네 개도 통과했습니다.
이 fail-first 확인이 필요한 이유는 단순합니다. 검사가 계속 green이기만 하면 요구사항 차이를 실제로 읽는지 알 수 없습니다. 잘못된 “compact reorder” 기대가 red가 되어야 그다음 “slot-preserving reorder” green을 믿을 수 있습니다.
작은 CLI일수록 destructive option을 가볍게 보면 안 됩니다
이 도구의 핵심 재배치 함수는 짧습니다. 오히려 그래야 합니다.
까다로운 부분은 JSON key 순서를 바꾸는 algorithm이 아니었습니다.
- 선택하지 않은 key의 자리를 계약으로 정의하는 일
- 누락과 중복의 우선순위를 고정하는 일
- output text를 결정론적으로 만드는 일
- 원본을 바꾸기 직전에도 snapshot이 같은지 확인하는 일
- 실패했을 때 복구할 backup을 남기는 일
--inplace 한 줄을 추가하는 순간 CLI는 formatter가 아니라 작은 file transaction이 됩니다.
그래서 구현 순서도 달라져야 합니다.
먼저 “어떤 key가 어디에 남아야 하는가”를 test로 고정합니다. 그다음 기본 output을 만듭니다. 마지막으로 destructive mode를 별도 경계로 다루고, stale snapshot을 일부러 만들어 실패시키는 test를 둡니다.
파일을 예쁘게 정렬하는 도구가 다른 수정을 지워버리면 formatting은 성공해도 작업은 실패입니다.
작은 utility일수록 boring한 안전장치가 좋습니다.
same-directory temporary file, backup, repeated revalidation, os.replace(), 그리고 red를 실제로 본 regression test면 충분했습니다.
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.