상품 몇 건의 이미지를 PUT /v1.2/products/{id} 로 고치는 일이었고 세 필드만 보내는 요청을 이미 신버전으로 짜 둔 상태였다. 그런데 대상 계정이 신버전을 못 쓴다는 것을 알게 됐고 openapi.json 을 보니 경로만 /v1/ 로 다르길래 구버전으로 같은 요청을 보내면 되겠다고 생각했다.
Table of contents
Open Table of contents
원인 — v1.2 가 막힌 이유
403 이 나와서 처음에는 인증 문제로 봤는데 접근 제어 코드를 보니 판정 기준이 상품이 아니라 입점사였다.
if (!$manager->is_v2_by_role && !in_array($provider->id, self::WHITELIST)) {
abort(403);
}
이 계정은 is_v2_by_role 이 거짓이고 WHITELIST 에도 없으므로 상품이 무엇이든 신버전은 403 이다. 판정 단위가 계정이면 대상을 바꿔도 결과가 안 바뀐다. 구버전이 유일한 경로였다.
두 매핑 함수의 차이
구버전으로 그냥 보내기 전에 요청 매핑 함수를 열어 봤다. 이름이 비슷한 두 함수가 있었고 신버전 쪽에만 이런 블록이 있었다.
// 미전송 필드는 기존 DB 값 사용
$defaults = [
'name' => $product->name,
'price' => $product->price,
'discount_price' => $product->discount_price,
// ...
];
$data = array_merge($defaults, $request->only(array_keys($defaults)));
구버전에는 이 array_merge() 블록이 없어서 모든 필드를 요청에서 무조건 채운다. 그러면 세 필드만 보냈을 때 name 이 비고 price 가 0이 되고 배송정책이 풀린다. 판매 중인 상품의 이름과 가격이 사라지는 것이다.
검증이 안 막는 이유
필수 필드가 비면 검증에서 걸리지 않는지 한 번 더 확인했다.
'name' => 'sometimes|required|string',
'price' => 'sometimes|required|numeric',
sometimes 가 붙어 있어서 키가 없으면 검증 자체를 건너뛴다. 부분 업데이트를 지원하려고 완화한 규칙인데 부분 업데이트가 구현되지 않은 쪽에서는 경고를 없애는 역할만 한다. 요청은 200 으로 성공하고 데이터는 지워진다.
스펙과 실제의 거리
나중에 두 버전의 openapi.json 을 diff 로 통째로 비교했다. 신버전은 구버전을 복제한 뒤 경로와 제목과 설명만 바꾼 파일이었고 실제 동작 차이는 하나도 반영돼 있지 않았다.
코드에서 역추적하니 다섯 가지가 달랐다. 구 입점사가 호출하면 403이라는 것과 응답에 필드 두 개가 추가된다는 것과 마이그레이션된 상품의 번호가 원래 번호로 내려온다는 것과 배송정책 조회가 채널 정책을 변환해 응답한다는 것과 계약이 끝난 정산 API가 신버전에 없다는 것이다.
게다가 두 스펙 모두 응답 스키마가 정의돼 있지 않고 상태 코드만 있었으며, 스펙에서 주석 처리된 엔드포인트 두 개가 실제 라우트에는 등록돼 호출되고 있었다. 스펙이 다른 버전의 복제본이면 그 스펙은 근거가 못 된다.
조치 — 실제 조치
두 가지 방법이 있었는데 구버전 매핑에도 병합 블록을 넣어 부분 갱신을 지원하게 하는 것과 클라이언트 쪽에서 DB 의 현재 값을 읽어 전체 요청을 재구성하는 것이다. 뒤쪽을 골랐다. 이유는 구버전 매핑을 바꾸면 그 경로를 쓰는 다른 입점사 요청의 의미가 함께 바뀌어서다. 대신 요청마다 조회가 한 번 더 붙는 것을 감수했다. 세 필드만 바꾸고 나머지는 읽은 값을 그대로 실어 보낸다.
번거롭지만 이것이 맞다. 구버전은 전체 교체 시맨틱이고 전체 교체 API 에는 전체를 보내야 한다. RFC 9110 도 PUT 을 요청에 실린 표현으로 대상 자원의 상태를 만들거나 교체하는 것으로 정의하고 부분 갱신은 PATCH 같은 별도 메서드의 몫으로 둔다.
전제의 이동
이번 건의 위험은 코드가 아니라 전제에 있었다. 신버전에서 세 필드만 보내도 됐으니 구버전도 되겠다는 생각이다. 버전 번호가 비슷하고 스펙상 경로만 다르면 자연스럽게 그렇게 넘어간다.
부분 업데이트 지원은 프로토콜이 아니라 구현이 정한다. 같은 PUT 이라도 서버가 병합하느냐 교체하느냐는 코드에 달려 있고, 신버전은 표준과 달리 병합하고 있었다. 그래서 엔드포인트를 갈아탈 때 요청 매핑 코드에 기존 값 병합 블록이 있는지와 검증 규칙이 필수를 조건부로 완화하고 있는지와 접근 제어의 판정 단위가 무엇인지와 스펙이 다른 버전의 복제본인지를 본다. 첫 항목이 이번 건의 전부였고 병합 블록 하나가 두 버전의 시맨틱을 가르고 있었다.
정리
- 부분 업데이트 지원은 HTTP 메서드가 아니라 구현이 정한다.
PUT이 병합하는 서버도 있다 - 요청 매핑 코드에 기존 값 병합 블록이 있는지로 판별한다
- 조건부 검증은 부분 업데이트가 없는 쪽에서 경고만 없앤다
- 접근 제어의 판정 단위가 계정이면 대상을 바꿔도
403이다 - 스펙이 다른 버전의 복제본이면 스펙은 근거가 아니다
- 코드나 실제 호출로 확인한다
- 엔드포인트를 갈아탈 때 전제를 그대로 옮기지 않는다