"부분 갱신 경로"라고 이름 붙이고 PUT으로 구현했다 — Blogger 라벨이 사라진 이유
Blogger 글 발행과 수정을 자동화하는 시스템을 운영하다가, 이미 발행된 글의 라벨이 수정 이후 사라지는 문제를 만났습니다. 라벨을 지우는 코드는 어디에도 없었고, 라벨을 만들어 전달하는 단계까지는 기록상 전부 정상이었습니다.
원인은 라벨 쪽이 아니라 글을 수정하는 HTTP 메서드에 있었습니다. 더 정확히는, PUT을 부분 수정으로 착각한 채 함수 이름과 주석에 "패치"라고 적어둔 것이 원인이었습니다.
증상
게시글 목록을 훑다가 라벨이 하나도 없는 글 두 건을 발견했습니다.
| 글 ID | 발행 시점 라벨 | 발견 시점 라벨 |
|---|---|---|
db9bd577 | 8개 | 0개 |
186c41e4 | 5개 | 0개 |
같은 시기에 발행된 다른 글들은 멀쩡했습니다. 특정 글에서만 사라진 셈이라, 처음에는 라벨을 생성하는 LLM 응답이 비어 있었거나 전달 과정에서 누락된 것으로 의심했습니다.
그런데 내부 기록 테이블 label_validation_logs에는 두 글 모두 발행 시점에 8개, 5개를 Blogger로 전달한 기록이 남아 있었습니다. 만들지 않은 게 아니라, 만들어서 보냈는데 나중에 지워진 것이었습니다.
정상 구간부터 확정했다
라벨 생성 로직을 뜯어보기 전에, 어디까지가 정상인지를 먼저 잘랐습니다.
- 생성 단계 —
posts.seo_tags에 값이 저장돼 있음: 정상 - 전달 단계 —
label_validation_logs에 전달 기록 있음: 정상 - 최초 발행 —
create_post()는 POST로 새 글을 만들면서 라벨을 함께 실음: 정상
세 구간이 모두 정상이면, 라벨이 사라진 시점은 발행 이후에 그 글을 다시 건드린 요청 안에 있습니다. 발행된 글을 다시 건드리는 경로는 하나뿐이었습니다. 본문을 갱신하는 update_post_content()입니다.
커밋을 거슬러 올라가니 답이 나왔다
client.put이라는 문자열이 등장하고 사라진 커밋을 찾았습니다.
git log --oneline -S "client.put" -- backend/services/blogger_service.py
68d2b99 Blogger 라벨 소실 결함 수정(PUT→PATCH) + 화이트리스트 게이트 도입
bbe4596 feat(blogger): live 글 부분 갱신(Patch) 경로(P1 §4)
아래쪽 bbe4596이 이 함수를 처음 만든 커밋입니다. 커밋 제목이 "부분 갱신(Patch) 경로"입니다. 그런데 같은 커밋의 본문에는 이렇게 적혀 있습니다.
blogger_service.update_post_content()신설 — 기존create_post/publish_draft와 같은 패턴(PUT /posts/{id})
의도는 부분 갱신이었는데 구현은 PUT이었습니다. 그리고 그 착각이 함수 docstring에 그대로 박혀 있었습니다.
async def update_post_content(blogger_post_id: str, title: str, html: str,
transport=None) -> dict:
"""기존 Blogger 글(초안이든 live든)의 제목·본문을 갱신한다 (P1 §4 — Patch 경로).
`create_post`와 형제 함수다 — 새로 만들지 않고 이미 있는 글을 고친다. Blogger API v3는
`PUT /posts/{postId}`가 곧 갱신이다(신규 리소스를 만들지 않는다 — publish 여부는
그대로 유지된다). live 글에 써도 **공개 상태가 바뀌지 않는다** — 그게 이 함수가
"발행"이 아니라 "패치"인 이유다.
"""
async def _request():
...
body = {"title": title, "content": html}
async with httpx.AsyncClient(timeout=10.0, transport=transport) as client:
resp = await client.put(url, headers=headers, json=body)
PUT /posts/{postId}가 곧 갱신이다 — 이 문장 자체는 틀리지 않았습니다. 신규 리소스를 만들지 않는 것도 맞고, 공개 상태가 유지되는 것도 맞습니다. "갱신"과 "부분 갱신"을 같은 것으로 본 게 틀렸습니다.
실제로 관측된 동작
body에는 title과 content만 들어갑니다. labels는 아예 담기지 않습니다.
이 시스템에서 Blogger posts.update(PUT)에 title과 content만 보내자 기존 labels가 빈 배열로 사라졌습니다. 반면 posts.patch로 같은 필드만 보내자 기존 라벨이 그대로 유지됐습니다.
즉 코드는 라벨을 지운 적이 없지만, 라벨을 보내지 않은 것만으로 라벨이 지워졌습니다. "갱신"과 "부분 갱신"을 같은 것으로 본 자리에서 정확히 이 차이가 났습니다.
시간 순으로 보면 이렇습니다.
| 날짜 | 일 |
|---|---|
| 7/26 | update_post_content() 신설 — PUT으로 구현 |
| 7/29 | 라벨 있는 글에 본문만 갱신했더니 labels가 빈 배열로 사라지는 것을 실측 |
| 7/30 | PATCH로 수정 |
발행 후 본문을 다시 갱신한 글만 이 경로를 탔고, 확인된 사례에서 증상이 드러난 것도 이 기간이었습니다.
GET-then-merge 대신 PATCH를 택한 이유
고치는 방법은 두 가지였습니다.
| GET-then-merge | PATCH | |
|---|---|---|
| 방식 | 기존 글을 GET으로 읽어 보존할 필드를 합친 뒤 PUT | 바꿀 필드만 담아 PATCH |
| 요청 횟수 | 2회 | 1회 |
| 보존 필드 관리 | 코드에 나열해야 함 | 불필요 |
| 필드가 늘어날 때 | 나열 목록을 같이 고쳐야 함 | 영향 없음 |
| 동시 수정 | GET과 PUT 사이에 다른 수정이 끼면 덮어씀 | 해당 없음 |
GET-then-merge는 당장은 동작하지만 "보존해야 할 필드 목록"이라는 유지보수 대상을 새로 만듭니다. 나중에 Blogger가 필드를 추가하거나 우리가 location을 쓰기 시작하면 그 목록도 같이 고쳐야 하고, 고치는 걸 잊으면 똑같은 사고가 다시 납니다. 이번 사고 자체가 "필드를 빠뜨려서" 난 것이라, 같은 실수를 반복할 자리를 하나 더 만드는 셈입니다.
PATCH는 부분 갱신을 Google 쪽 시맨틱에 맡깁니다. 우리 코드는 바꿀 필드만 알면 되고, 나머지는 알 필요조차 없습니다.
수정
실제로 바뀐 코드는 두 줄입니다.
- resp = await client.put(url, headers=headers, json=body)
- _log_if_http_error(resp, "posts.update", correlation_id)
+ resp = await client.patch(url, headers=headers, json=body)
+ _log_if_http_error(resp, "posts.patch", correlation_id)
두 번째 줄도 같이 바꿨습니다. 오류 로그에 찍히는 엔드포인트 이름이 posts.update로 남아 있으면, 다음에 로그만 보고 원인을 찾는 사람이 또 헤맵니다.
그리고 docstring을 고쳤습니다. 이번 수정에서 코드보다 오래 남을 부분은 이쪽이라고 봤습니다.
"""...
`PATCH /posts/{postId}`를 쓴다(`PUT`이 아니다). Blogger API v3의 `posts.update`(PUT)는
리소스 전체 교체라 body에 없는 필드(labels·customMetaData·location)를 지운다 — 실측:
2026-07-29 이 함수가 PUT으로 라벨 있는 글에 본문만 갱신했더니 Blogger의 labels가
빈 배열로 사라졌다. `posts.patch`는 Google이 별도로 제공하는 부분 갱신 엔드포인트로,
body에 없는 필드는 건드리지 않는다 — title·content만 보내도 labels 등 기존 값이
그대로 남는다.
"""
호출하는 쪽의 시그니처는 그대로라 다른 파일은 손대지 않았습니다. 새 글을 만드는 create_post()는 POST라서 영향이 없습니다.
테스트를 두 개 붙였다
한 줄짜리 수정이라, 다음에 누가 되돌려도 알아채기 어렵습니다. 그래서 메서드 자체를 검사하는 테스트를 넣었습니다.
class TestUpdatePostContentUsesPatch(unittest.TestCase):
def test_uses_http_patch_not_put(self):
cap = _CapturedRequest()
with _env():
_run(B.update_post_content("EXISTING_ID", "제목", "<p>본문</p>",
transport=cap.transport()))
self.assertEqual(cap.method, "PATCH")
def test_body_omits_labels(self):
cap = _CapturedRequest()
with _env():
_run(B.update_post_content("EXISTING_ID", "제목", "<p>본문</p>",
transport=cap.transport()))
self.assertEqual(set(cap.body.keys()), {"title", "content"})
두 번째 테스트가 조금 반직관적입니다. body에 labels가 "없어야" 통과합니다. PATCH는 부분 갱신이므로 라벨을 안 담는 게 맞고, 오히려 여기서 라벨을 담기 시작하면 이 함수가 라벨을 관리하는 책임까지 떠안게 됩니다. 그건 다른 함수의 일입니다.
배포됐는지는 컨테이너 안에서 확인해야 한다
코드만 고치고 이미지를 다시 빌드하지 않으면 운영 컨테이너는 옛 코드로 계속 돕니다.
docker compose exec backend grep -rn "client.put" /app/services/; echo "exit=$?"
exit=1
grep이 아무것도 못 찾아서 종료 코드 1이 나온 것이고, 이게 원하는 결과입니다. 호스트에서 git diff로 확인하는 것과 컨테이너 안에서 확인하는 것은 다른 일입니다.
이미 사라진 라벨은 따로 복구했다
코드를 고쳐도 지워진 라벨이 돌아오지는 않습니다. posts.seo_tags에는 원래 값이 남아 있었으므로, 그걸 읽어 Blogger에 다시 써주는 일회성 스크립트를 돌렸습니다.
db9bd577 labels 0 → 8
186c41e4 labels 0 → 5
복구 후 로컬 인벤토리를 재동기화해서 DB 쪽 값과 Blogger 쪽 값이 일치하는 것까지 확인했습니다. 이 스크립트는 상시 실행 경로에 넣지 않았습니다. 원인을 고친 뒤에도 계속 도는 복구 스크립트는, 다음에 같은 문제가 생겼을 때 그걸 가려버립니다.
설명되지 않는 한 건이 남았다
두 건 중 db9bd577은 위 설명과 정확히 맞아떨어집니다.
그런데 186c41e4는 이 경로를 호출한 기록을 찾지 못했습니다. PUT을 탄 적이 없어 보이는데도 라벨이 사라졌습니다. PATCH 전환으로 설명되지 않는 두 번째 경로가 아직 있다는 뜻입니다.
현재 후보는 이 저장소 바깥의 시스템입니다. 우리 쪽 발행 API는 항상 초안(isDraft=true)으로만 글을 만들고, 초안을 실제 게시 상태로 바꾸는 일은 다른 시스템이 합니다. 그쪽이 게시 전환을 하면서 PUT을 쓰고 있다면 같은 결과가 나옵니다. 다만 그 코드를 볼 수 없어 확정하지 못했고, 지금은 미해결로 두고 있습니다.
두 건 중 한 건만 설명된 상태로 닫는 게 찜찜하긴 한데, 설명되는 쪽을 억지로 늘려서 나머지 한 건까지 덮는 것보다는 낫다고 봤습니다. 지금은 발행 후 라벨 개수를 주기적으로 대조하는 확인만 남겨뒀습니다.
같은 문제를 의심한다면
Blogger API로 발행된 글을 수정하는 코드가 있다면, 확인 자체는 간단합니다. 수정 요청을 보내기 전과 후에 같은 글을 조회해서 labels를 비교하면 됩니다. 조회는 이미 인증이 붙어 있는 코드 경로를 그대로 쓰면 되고, 별도 도구가 필요하지 않습니다.
주의할 점은 응답 코드로는 판단할 수 없다는 것입니다. 요청이 200을 돌려줬다는 것만으로는 아무것도 보장되지 않습니다. 필드가 지워진 응답도 200으로 돌아옵니다.
댓글
댓글 쓰기