기술

'비웠다'와 '안 보냈다'가 서버에는 같은 빈 값이었습니다 — 부분 수정 API 가 개점일을 지운 경로

2026.09.268분 읽기

프랜차이즈 ERP 의 매장 계약 화면에는 최초 운영시작일, 그러니까 개점일이 들어 있습니다. 개점 카운트가 이 값 기준이라 이 날짜가 밀리면 점포개발 지표가 통째로 흔들립니다. 며칠 전 이 개점일 50건을 일괄 보정하는 작업이 있었습니다.

보정하다 눈에 걸린 것이 있었습니다. 50건 중 11건이 '개점일 == 계약시작일'이었습니다. 우연이라기엔 너무 정확한 일치였습니다. 원래 있던 값이 사라진 자리를 계약시작일이 메운 것으로 보였습니다. 일정하게 반복되는 어긋남은 오차가 아니라 신호입니다.

그때는 값만 되돌렸습니다. 원인은 몰랐고, 보정 기록 옆에 계약 규칙 문서 한 줄만 남겨뒀습니다. '경로를 못 막으면 다시 덮인다 — 이번 보정은 결과만 되돌린 것이다.' 이 글은 그 한 줄을 실제로 따라간 기록입니다.


조건부 전송과 무조건 덮어쓰기가 만나는 자리

계약 수정 경로를 추적했습니다. 서비스는 요청에 실린 최초 운영시작일을 그대로 계약 행에 넣고 있었습니다. 프론트는 값이 있을 때만 필드를 요청에 싣고 있었습니다. 둘을 나란히 놓으면 구조가 보입니다.

// 프론트 — 값이 있을 때만 싣는다
if (form.date) payload.date = form.date

// 서버 — 실린 값을 그대로 넣는다
contract.date = payload.date   // 안 실렸으면 빈 값

프론트 입장에서 조건부 전송은 자연스러운 방어입니다. 그런데 서버에는 두 가지 상황이 똑같은 모양으로 도착합니다. 담당자가 날짜를 지운 것과, 화면이 그 필드를 안 실은 것입니다. 서버는 둘 다 빈 값으로 받아 기존값 위에 덮어씁니다.

그래서 폼을 한 번 저장하는 것만으로 값이 사라집니다. 에러도 없고 로그도 없습니다. 11건의 정확한 일치는 그렇게 지워진 자리를 계약시작일이 메운 흔적으로 보였습니다.

담당자가 비운 값과 화면이 안 보낸 값이 서버에서 같은 빈 값으로 합쳐져 기존 개점일을 덮어쓰는 구조

두 가지가 더 있었습니다. 계약 등록 경로에는 재계약 가드가 있었는데, 수정 경로에만 없었습니다. 가드는 대개 처음 만들 때의 관점으로 쓰이고, 수정은 나중에 붙습니다. 그리고 계약은 2년 주기로 갱신됩니다. 갱신할 때마다 이 자리가 한 번씩 노출됩니다.

주기가 긴 것이 이 사건의 절반을 만들었습니다. 2년에 한 번 노출되는 버그는 재현이 안 됩니다. 재현이 안 되면 '그때 그건 입력 실수였겠지'로 닫힙니다.


세 갈래 중 가장 싼 것을 골랐습니다

안

내용

비고

A

프론트가 항상 필드를 보낸다 (빈 문자열 포함)

서버가 「빈 값으로 왔다 = 비웠다」로 읽을 수 있다. 폼 하나 고치면 끝이라 싸다

B

값을 지우는 전용 창구를 둔다

삭제가 명시적 행위가 된다. 옆 필드가 이미 이렇게 풀려 있다

C

서버가 「필드 없음」과 「빈 값」을 구분해 부분 수정한다

이번에 고른 방향. 싸지만 「지울 방법」이 없어진다

C 를 골랐습니다. 수정 경로를 '안 보내면 기존값 유지'로 바꿨습니다. 그 전환으로 깨진 테스트는 새 규칙에 맞게 갱신했습니다. 테스트의 원래 의도인 '계약시작일이 개점일을 덮으면 안 된다'는 새 규칙에서도 그대로 만족됩니다.

B 안은 이미 옆에 있었습니다. 같은 도메인의 매장 마스터 개점일은 전용 저장 창구로 풀려 있었습니다. 그 주석에는 이렇게 적혀 있었습니다. '전체 교체 방식에 얹으면 안 보낸 필드를 빈 값으로 지워 개점일이 조용히 날아간다.' 같은 함정을 옆 필드에서 이미 한 번 밟은 것입니다.


같은 모양의 버그 넷, 처방은 같지 않았습니다

폼의 날짜 네 칸이 전부 조건부 전송이었습니다. 계약 시작일, 최초 운영시작일, 교육일, 계약 만료일입니다. 넷을 한 번에 고치려다 멈췄습니다. 성격이 갈렸기 때문입니다.

필드

성격

「안 보내면 유지」로 충분한가

계약 시작일

갱신 여부를 판정하는 기준값

빈 값이 들어가면 갱신으로 오판될 수 있다. 단순 유지가 맞는지 따로 본다

최초 운영시작일

값이 지워지는 문제

예 (이번에 고침)

교육일

값이 지워지는 문제

예

계약 만료일

값이 지워지는 문제

예

셋은 값이 지워지는 문제입니다. 하나는 판정 기준값이라, 빈 값이 들어가면 값이 지워지는 게 아니라 판정이 뒤집힙니다. 모양이 같다고 처방이 같지 않습니다. 넷을 일괄로 고쳤으면 조용한 삭제 하나를 조용한 오판으로 바꿨을 겁니다.

멈춘 이유가 하나 더 있습니다. 나머지 세 필드는 몇 건이 지워졌는지 안 세어봤습니다. 개점일은 보정 기록이 남아 있어 11이라는 숫자가 나왔지만, 나머지 셋은 그런 기록이 없습니다.

숫자가 없으면 '고쳤으니 됐다'로 끝나고, 이미 지워진 값은 영영 안 돌아옵니다. 세는 비용은 대개 쿼리 한 줄입니다. 그래서 '고치기 전에 먼저 센다'로 정리했습니다.

같은 코드 패턴을 가진 날짜 필드 넷 중 계약 시작일만 판정 기준값이라 처방이 다른 비교


값은 살아 있는가 — 백업본 495행과 대조했습니다

경로를 막았으니 다음은 값입니다. 보정 직전 백업본 495행과 현재 값을 대조했습니다. 현재 값이 백업보다 얼마나 앞당겨졌는지를 폭으로 나눴습니다.

현재가 백업보다 앞당겨진 폭

건수

180일 이상

40

30~180일

6

7~30일

6

1~7일

24

변화 없음

413

보정은 살아 있었습니다. 역행 5건, 그러니까 현재가 백업보다 늦어진 건이 있어 따로 추적했습니다.

전부 올해 개점한 신규 매장이었고, 이틀 사이에 그 컬럼만 단독으로 바뀐 일괄 작업이었습니다. 한 시각에 32건이 같은 초에 바뀌었고 대장의 변경자가 동일했습니다. 관측된 변경은 전부 의도적 보정이었고 코드가 덮은 흔적은 없었습니다.

다만 이 결론은 관측된 범위까지입니다. 계약 마스터에는 누가 바꿨는지 기록하는 칸이 없고 수정 시각만 자동 갱신됩니다. 행위자 추적은 대장 쪽에서만 됩니다. 로그 없이 지나간 변경은 이 방법으로 못 잡습니다.


고쳤다로 끝나지 않는 것들

재계약 잠복 버그. 재계약은 기존 계약 행을 되살려야 하는데 코드는 항상 새 행을 만듭니다. 실제 재계약이 발생하면 매장·브랜드·유효여부 세 값을 묶은 고유 제약을 위반해 저장이 실패합니다. 아직 사례 0건이라 안 터졌을 뿐입니다.

마스터와 최초 사건행을 동시에 정정하는 보정 메서드가 있지만, 부르는 컨트롤러가 없습니다. 보정은 실제로 SQL 로 이뤄지고 있습니다.

나머지 세 필드는 피해 규모를 센 뒤에 고칩니다.

사례 0건인 잠복 버그를 '안 터진 버그'로 세지 않습니다. 입력이 아직 안 온 것이지 코드가 맞는 게 아닙니다. 첫 재계약이 곧 첫 장애가 됩니다.

값 보정, 기록 한 줄, 수정 경로 차단으로 이어지는 세 단계와 보정에서 멈췄을 때의 재발 경고


되돌리는 것과 막는 것은 다른 작업입니다

이번 일에서 실제로 작동한 것은 보정 기록 옆의 한 줄이었습니다. '값은 되돌렸고, 경로는 아직 열려 있다.' 보정만 하고 끝냈으면 다음 갱신 주기에 그대로 재발했을 겁니다. 그리고 2년 뒤에는 이번 보정을 기억하는 사람이 없었을 겁니다.

'안 보내면 유지'가 항상 정답도 아닙니다. 이 방향은 값을 지울 방법을 없앱니다. 실제로 비워야 하는 필드가 있으면 전용 창구나 명시적 삭제 신호가 같이 있어야 그 필드를 비울 수 있습니다. 조용한 삭제를 막으려다 조용한 삭제 불가를 만들 수 있습니다.

같은 자리를 점검할 때 보는 것을 정리하면 이렇습니다.

부분 수정 API 에서 '필드 없음'과 '빈 값'이 서버에서 구분되는가

등록 경로의 가드가 수정 경로에도 있는가 — 같은 필드를 건드리는 경로를 전부 세어봤는가

같은 모양의 버그를 일괄로 고치기 전에, 그중 판정 기준값이 섞여 있는가

고치기 전에 몇 건이 지워졌는지 셌는가

보정 기록에 '값을 되돌렸다'와 '경로를 막았다'가 따로 적혀 있는가

부재와 공백은 다른 값입니다. 그런데 대부분의 전송 방식이 서버 매핑 단계에서 이 둘을 같은 빈 값으로 뭉갭니다. 구분이 필요하면 전송 규약 자체에 그 구분을 넣는 쪽이 맞습니다. 이번 개점일은 그 구분이 없어서 사라졌고, 되돌린 뒤에도 경로는 열려 있었습니다.

#부분수정API#조건부전송#null#폼설계#데이터유실#잠복버그#API설계