수정 API에서 비밀번호를 추방했더니, 부분 업데이트 사고 세 가지가 같이 드러났습니다
어느 날 아침, 프랜차이즈 ERP 의 배달 플랫폼 매출 수집이 한 매장에서 멈춰 있었습니다. 로그를 보니 그 매장의 플랫폼 계정이 로그인에 연속으로 실패했고, 실패가 이어지자 자동 수집이 스스로 꺼진 상태였습니다. 담당자에게 물으니 전날 그 계정의 비밀번호를 바꿨다고 했습니다. 새 비밀번호 자체는 맞는 값이었습니다.
원인은 비밀번호 끝에 붙은 공백 한 칸이었습니다. 담당자는 메신저로 받은 비밀번호를 복사해 계정 수정 폼의 비밀번호칸에 붙여넣었고, 복사할 때 딸려 온 후행 공백이 그대로 저장됐습니다. 플랫폼은 그 값을 틀린 비밀번호로 받았고, 우리 시스템은 실패가 이어지자 계정을 껐습니다. 공백 한 칸이 매출 수집 하루를 지운 셈입니다.
공백이 아니라 구조가 문제였습니다
처음엔 trim 한 줄로 끝낼 문제로 봤습니다. 붙여넣을 때 앞뒤 공백을 지우면 같은 사고는 재발하지 않습니다. 그런데 계정 수정 폼을 다시 보니 비밀번호칸이 이름·메모와 나란히 놓여 있었고, 저장 버튼 하나로 전부 같은 PUT 요청을 타고 있었습니다. 비밀번호는 비워두면 미변경으로 처리하는 관례였습니다.
이 관례가 사고 표면입니다. 빈 값의 뜻이 계약에 숨어 있어서, 호출하는 쪽이 빈 문자열을 보내는지 아예 안 보내는지에 따라 결과가 갈립니다. 담당자가 이름만 고치려다 비밀번호칸에 무언가 남겨두면 그것도 저장됩니다. 비밀번호가 다른 필드와 같은 경로를 타는 한, 이런 사고는 trim 을 넣어도 다른 모양으로 다시 옵니다.
필드가 아니라 이벤트로 떼어냈습니다
선택지는 둘이었습니다. 관성대로 칸을 두고 trim 만 붙이는 쪽과, 비밀번호 변경을 전용 이벤트로 떼어내는 쪽입니다.
수정 폼에 칸 유지 | 전용 이벤트로 분리 | |
|---|---|---|
변경 의도 | 빈 값 여부로 추정 | 버튼을 눌러야 시작 |
일반 수정 경로의 사고 가능성 | 그대로 남음 | 비밀번호를 아예 안 읽음 |
빈 값의 의미 | 계약에 숨어 있음 | 존재하지 않음 |
작업량 | trim 한 줄 | 엔드포인트 하나·팝업 하나 |
이미 사고가 한 번 났기 때문에 분리 쪽으로 갔습니다. 비밀번호는 이름이나 메모 같은 상태가 아니라, 바꾸는 행위 자체가 사건인 값입니다. 대형 서비스들이 비밀번호 변경을 늘 별도 화면에 두는 이유도 같습니다. 저쪽이 먼저 그렇게 하고 있었고, 우리도 같은 원리를 적용했습니다.
비밀번호는 필드가 아니라 이벤트입니다. 바꾸는 행위 자체가 사건입니다.

바뀐 구조는 이렇습니다.
비밀번호 변경 전용 PATCH 엔드포인트를 새로 뒀습니다. 일반 수정 PUT 은 요청에 비밀번호가 실려 와도 읽지 않습니다
신규 등록 POST 만 비밀번호를 인라인으로 받습니다. 계정을 만들 때는 비밀번호가 필수이고 변경 의도를 물을 필요가 없기 때문입니다
수정 모달의 비밀번호칸은 [비밀번호 변경] 버튼으로 바뀌었습니다. 누르면 중첩 팝업이 열리고, 두 번 입력과 보기 토글을 거쳐 그 자리에서 바로 저장됩니다
공백은 프론트에서 trim 하고 백엔드에서 한 번 더 strip 합니다. 한쪽만 하면 API 를 직접 부르는 다른 입구가 뚫립니다
자동 재개도 여기서 정리했습니다. 비밀번호가 실제로 바뀐 경우에만 꺼져 있던 수집을 다시 켭니다. 응답에 수집 활성 여부를 담아 내려보내고, 화면의 토글·토스트·배너는 그 값으로만 갱신합니다. 클라이언트가 결과를 추측하지 않습니다.

떼어내는 김에 드러난 사고 세 가지
비밀번호를 옮기려고 PUT 을 열어보니, 같은 자리에 부분 업데이트의 고전 사고가 셋 더 있었습니다. 셋 다 비밀번호와 무관해 보이지만 뿌리는 같습니다. 어떤 필드를 다루고 어떤 필드를 안 다루는지가 계약에 적혀 있지 않았습니다.
첫째, 안 보내는 필드가 NULL 로 덮이는 문제입니다. 계정에는 조치 메모 칸이 있는데, 메모를 안 보내는 호출부가 PUT 을 치면 기존 메모가 NULL 로 지워졌습니다. 옵셔널로 받되 없으면 유지하거나, 아예 안 읽거나 — 둘 중 하나가 계약에 적혀 있지 않으면 미전송 호출부가 데이터를 지웁니다. 이번엔 메모 입력을 등록 모달에만 두고 PUT 은 메모를 읽지 않게 했습니다.
둘째, 낙관적 UI 가 서버 정책을 우회하는 문제입니다. 수집 토글을 클라이언트가 먼저 켜고 요청을 보내는 구조였는데, 서버가 재개를 거부해도 화면에는 켜진 토글이 살아남았습니다. 정책이 걸린 상태는 응답이 준 사실로만 바꾸도록 고쳤습니다. 같은 이유로 수정 모달의 편집 상태도 목록 데이터에서 파생되게 바꿨습니다. 그 전에는 즉시 반영 뒤에도 '수집 중지' 배너가 옛 값으로 남아 있었습니다.
셋째, 필드를 없애는 배포가 구 클라이언트에게 조용한 실패가 되는 문제입니다. 배포 순서는 백엔드 먼저, 프론트 다음이었고 순서 자체는 맞았습니다. 진짜 위험은 브라우저에 캐시된 구 프론트 번들과 신 백엔드의 조합이었습니다. 옛 화면의 인라인 비밀번호칸에 입력하면 '수정했습니다' 가 뜨는데, 새 PUT 은 그 값을 읽지 않으니 비밀번호는 그대로입니다. 에러가 아니라 무시로 나타나서 더 위험합니다.
배포 뒤에는 강제 새로고침을 안내했습니다. 이건 임시방편입니다. 근본 해법은 구버전 번들을 감지해 리로드를 유도하는 배포 쪽 장치이고, 그건 다음 과제로 적어뒀습니다.

작업 마지막에 두 레포를 걸쳐 최종 리뷰를 돌렸고, 두 건이 걸렸습니다. 한 곳에서 trim 없이 전송하는 경로가 남아 있었고, 다른 한 곳에서 낙관적 토글이 서버가 거부한 재개를 화면에 살려두고 있었습니다. 둘 다 이 글에서 원칙으로 적은 것 그대로였습니다. 원칙을 세운 사람이 같은 자리에서 다시 틀린 셈입니다.
비밀번호 쪽 사각지대를 리뷰가 잡아준 것은 이번이 처음이 아닙니다. 혼자서는 안 보이는 자리가 늘 그쪽에 있습니다.
백엔드 테스트 14건을 추가했고 전체 스위트가 통과했습니다. 비밀번호가 걸릴 수 있는 경로는 등록 POST 와 전용 변경 PATCH 둘로 명시됐고, 수정 PUT 은 크리덴셜과 무관한 경로가 됐습니다.
어떤 필드를 이벤트로 떼어내나
모든 필드를 이렇게 쪼개면 API 가 파편화됩니다. 분리 명분은 사고가 났을 때 피해가 크고, 변경 의도가 명시적이어야 하는 값에 한정됩니다. 비밀번호·API 키·연동 토큰 같은 크리덴셜이 그 자리입니다. 빈 값이면 미변경이라는 관례도 소규모 내부 도구에선 충분할 때가 있습니다. 이 사례는 실제 사고가 먼저 났기 때문에 구조 전환이 정당화됐습니다.
자기 시스템을 점검한다면 다섯 가지입니다.
수정 API 가 비밀번호나 토큰을 다른 필드와 같은 요청으로 받고 있는가
부분 업데이트에서 안 보낸 필드가 유지되는지 지워지는지가 문서에 적혀 있는가
토글·상태 배너가 서버 응답으로 갱신되는가, 클릭 즉시 바뀌는가
API 에서 필드를 빼는 배포에 구 번들 대응(공지·강제 리로드·전이 기간 거부 응답)이 있는가
입력 위생이 프론트와 백엔드 양쪽에 있는가
상태는 함께 고쳐도 되지만 사건은 전용 입구와 전용 확인 절차를 가질 자격이 있습니다. 수정 폼에서 비밀번호칸을 뗀 것은 화면을 하나 더 만든 일이 아니라, 일반 수정 경로에서 크리덴셜 사고가 날 가능성 자체를 없앤 일입니다. 공백 한 칸이 자동 수집을 세우기 전에 그 칸을 옮겨두는 편이 비용이 덜 듭니다.