증상
옵션(변형)을 확장해 전체 PUT을 보내니 400이 떨어졌다.
중복된 옵션값
값이라고 하니 속성값을 봤다. 허용목록, 표기, 중복 조합. 세 번 고쳤고 세 번 다 헛일이었다.
원인
프로토타입 하나를 deepcopy로 복제해 변형을 만들고 있었는데, 복제본이 원본의 식별자 필드까지 그대로 가져갔다. 결과적으로 모든 변형이 같은 ID를 달고 전송됐다. 플랫폼이 말한 "중복된 옵션값"은 속성값이 아니라 옵션 식별자를 가리킨 것이었다.
★★★ deepcopy는 "값"만 복제하지 않는다. 식별자도 값이다. 프로토타입 복제는 동일성을 생산하는 연산이라, 유일해야 할 필드가 조용히 따라온다.
★ 복제 직후 ID 계열 필드를 지우는 것을 규약으로 둬라. "나중에 각자 채운다"는 방식은 하나만 빠뜨려도 그 건이 원본과 같은 ID로 나간다. 지우면 빠뜨린 것이 즉시 드러나고, 덮어쓰면 빠뜨린 것이 정상처럼 보인다.
★★ 이건 식별자가 잘못 설계돼서 생긴 문제가 아니다. 필드는 정확하고 유일했다. 우리가 사본을 만들면서 유일성을 깼다. 식별자 유일성이 깨지는 경로는 보통 절단(길이 제한이 서로 다른 키를 같게 만듦)과 부모 공유인데, 복제가 세 번째다.
★★★ 메시지의 명사가 원인을 가리키지 않는다
플랫폼이 쓰는 단어와 당신이 아는 단어가 같은 뜻이라는 보장이 없다. "값"이라고 쓰여 있으면 값을 파게 되는데, 그 단어는 그쪽 스키마의 어휘다. 같은 컬렉션의 다른 항목에서도 같은 형태가 나왔다 — 누락 메시지가 요구한 것은 그 필드가 아니라 그 필드가 켜는 검증이었다.
★ 에러 문구는 위치 정보이지 원인 정보가 아니다. 그 근처를 보라는 뜻이지, 그것이 범인이라는 뜻이 아니다.
★★★ 로그를 앞 100자로 자르면 답이 화면에 안 나온다
메시지 전문에는 중복된 ID 목록이 그대로 들어 있었다. 응답 안에 답이 있었는데 로그 포맷이 [:110]에서 잘라내고 있었다.
★ 110자는 "에러 메시지 한 줄"을 담기에 충분해 보인다. 그래서 아무도 의심하지 않는다. 하지만 요약은 앞에 오고 근거는 뒤에 온다 — 플랫폼이 위반 항목 목록을 붙여 보내면, 그 목록이 정확히 잘리는 위치다.
★★ 이 컬렉션에는 이미 "오류 문구의 모든 절을 용의선상에 둔다"가 적혀 있다. 그걸 적어두고도 다시 밟았다 — 그때는 혹은 뒷절을 안 읽은 것이었고, 이번엔 애초에 전문이 남지 않았다.
★ 규칙을 적는 것과 로그 포맷을 고치는 것은 다른 작업이다. → 실패 응답은 자르지 않고 전문을 남겨라. 요약은 성공 응답에만 쓴다. 실패는 드물고, 드문 만큼 전문을 남길 여유가 있다.
★★★ 그 다음 겹 — 고치면 다음 에러가 나온다
ID를 비웠더니 이번엔 이렇게 나왔다.
판매중인 상품은 삭제할 수 없습니다
전체 PUT에서 보낸 목록이 곧 최종 상태다. 기존 항목을 빼고 새 목록을 보내는 것은 삭제 요청으로 읽힌다. 판매 중인 항목은 지울 수 없으므로 원본을 첫 항목으로 그대로 남기고 변형을 뒤에 추가하는 형태여야 했다.
| 겹 | 메시지 | 실제 원인 |
|---|---|---|
| 1 | 중복된 옵션값 | 복제가 식별자까지 가져감 |
| 2 | 판매중인 상품은 삭제할 수 없습니다 | 기존 항목은 유지해야 함 |
| 3 | 중복된 옵션값 (다시) | 두 출처의 옵션축이 같은 축이었다 |
★★★ 세 번째 겹 — 같은 메시지가 다른 원인으로 다시 나온다
기존 상품의 속성축이 색상인데 소스 쪽 변형축도 색상이었다. 변형을 추가해도 옵션값이 원본과 같아져 또 중복이 된다. 1번을 고친 코드는 여기에 아무 소용이 없다.
★★★ 메시지가 같다고 원인이 같은 것이 아니다. 한 번 본 에러 문구는 "그때 그거"로 읽히기 때문에, 재발이 아니라 신종일 가능성을 놓친다.
★★ 서로 다른 두 출처의 축을 합칠 때, 축 이름이 겹치면 곱집합이 아니라 충돌이 된다. 직교한다고 가정하고 합치는 코드는 같은 축일 때 조용히 중복을 만든다. 합치기 전에 축 이름의 교집합을 확인하라.
★★ 그리고 어느 시점에는 포기해야 한다 — skip:축충돌
3회 실패하면 사유를 적고 다음 건으로 간다.
★★★ 기준은 재시도 횟수가 아니라 "재시도가 새 정보를 만드는가"다. 접근 경로를 바꾸는 재시도는 매번 새 정보를 낳지만, 구조적으로 불가능한 건은 30번을 해도 같은 답이다. 축이 겹친다는 사실은 재시도로 바뀌지 않는다.
★ 한 건을 붙들면 배치가 멈춰 작업 전체를 포기하게 된다. 개별 건을 놓지 못하는 것이 오히려 완주를 막는다.
★ 사유를 적은 skip은 손실이 아니라 보류다. 그냥 건너뛰면 나중에 재처리 대상인지조차 알 수 없다. 스킵 사유는 실패 사유와 같은 자리에 같은 형식으로 남긴다.
★★ 첫 에러가 사라졌다고 고쳐진 게 아니다. 다음 겹이 나오는 것이 정상 경과다. 겹이 안 나오면 그때 재조회로 확인한다 — 이 문서의 첫 문장이 그것이다.
검증 방법
★ 한 번에 한 겹만, 가설 하나만 바꿔 재시도한다. 세 곳을 같이 고치면 어느 것이 겹을 벗겼는지 모른다. 그리고 전송 직전 페이로드에서 식별자 필드의 유일성을 어서션으로 건다 — len({x.id for x in items}) == len(items). 이 한 줄이면 세 번의 헛수고가 없었다.