본문으로 건너뛰기
2026. 6. 27
© WONKOOK LEE

null은 비우기일까요, 건드리지 않기일까요?

체크된 필드만 저장소로 옮겨지는 그림

이 글은 직무 통합 과정에서 백엔드 실무를 맡게 된 프론트엔드 개발자가 실제 작업을 부딪히며 배운 것들을 주제 단위로 정리하는 시리즈입니다. 낯선 개념을 이미 아는 개념에 대응시키는 방식으로 씁니다.

  1. 도메인 모델은 왜 DB를 몰라야 할까요?
  2. DB 조회는 왜 아웃바운드일까요?
  3. 그 로직은 어디에 살아야 할까요?
  4. 이 API는 누가 부르나요?
  5. Response는 모델을 닮아야 할까요?
  6. null은 비우기일까요, 건드리지 않기일까요? — 이 글
  7. 컬럼일까요, JSON 컬럼일까요?
  8. DB 스키마에도 git이 필요할까요?
  9. 백엔드 도구들, FE로 치면 무엇일까요?

수정 API의 요청 본문을 설계하다가 오래 잊고 있던 질문과 다시 만났습니다. 프론트엔드에서 PATCH 요청을 보낼 때마다 어렴풋이 넘겼던 그 질문 — { "nickname": null }을 받으면 서버는 닉네임을 지워야 할까요, 그대로 둬야 할까요? 클라이언트를 만들 때는 서버가 알아서 해주겠거니 했는데, 이제 그 "알아서"를 제가 설계해야 하는 자리에 서 있었습니다.

부분 수정 요청에서 null 하나로는 왜 부족하고, 업계는 이 모호함을 어떻게 풀어왔을까요?


이 글에서 다루는 내용

이 글은 부분 수정(PATCH) API의 가장 미묘한 구석 — null의 이중 의미 — 을 다룹니다. 왜 이 모호함이 생기는지(JSON에는 undefined가 없습니다), 업계의 표준 해법들(JSON Merge Patch, JSON Patch, 필드 마스크)이 각각 무엇을 포기하고 무엇을 얻는지, 그리고 바꿀 필드를 명시하는 계약과 필드 단위 검증이 실제 코드에서 어떤 모양이 되는지를 봅니다. 폼의 dirty fields로 PATCH 본문을 만들던 프론트엔드의 경험이 그대로 이어집니다.





1. JSON에는 undefined가 없습니다

문제의 뿌리부터 보겠습니다. JavaScript 안에서는 "값이 없다"가 두 가지로 구분됩니다 — undefined(아예 언급 안 함)와 null(비어 있다고 명시함). 그래서 객체 안에서라면 "건드리지 않기"와 "비우기"를 구분할 수 있습니다.

그런데 이 구분은 전송 계층에서 사라집니다. JSON에는 undefined가 없습니다. JSON.stringify는 값이 undefined인 필드를 통째로 지워버립니다.

JSON.stringify({ nickname: undefined }); // '{}' — 필드 자체가 사라진다
JSON.stringify({ nickname: null }); // '{"nickname":null}'

그 결과 서버가 받는 세계에는 두 가지 상태만 남습니다 — "필드가 있다"와 "필드가 없다". 그리고 { "nickname": null }이 도착했을 때 이것이 비우라는 뜻인지 실수로 null이 들어간 것인지, 필드가 없을 때 이것이 건드리지 말라는 뜻인지 직렬화가 지운 것인지는 계약으로 정하지 않으면 아무도 모릅니다. 역직렬화 프레임워크가 "없는 필드"와 "null인 필드"를 똑같이 null로 만들어버리면 그나마 남아 있던 구분마저 사라집니다.

요컨대 이것은 버그가 아니라 표현력의 문제입니다. 세 가지 의도(유지·비우기·설정)를 두 가지 상태(있다/없다)에 싣고 있으니, 어딘가에 정보를 하나 더 실어야 합니다.


Recap

JavaScript의 undefined/null 구분은 JSON 직렬화에서 사라지고, 서버에는 "필드가 있다/없다"만 남습니다. 유지·비우기·설정이라는 세 가지 의도를 두 상태로는 다 표현할 수 없으므로, 부분 수정 계약에는 추가 정보가 필요합니다.




2. 업계의 세 가지 해법

이 문제는 오래된 만큼 표준화된 해법들이 있습니다.

해법아이디어null의 의미한계
JSON Merge Patch (RFC 7396)보낸 필드만 반영null = 삭제로 고정"null 값을 설정"을 표현할 수 없다
JSON Patch (RFC 6902)연산의 목록을 보냄 (op: replace/remove/...)연산이 명시하므로 모호함 없음본문이 장황하고 낯설다
필드 마스크 (Google AIP-134)값과 별도로 바꿀 필드 목록을 보냄목록에 있으면 값 그대로 반영 (null 포함)필드 목록 관리 비용

JSON Merge Patch는 가장 직관적이지만 "null이면 삭제"라는 고정 규칙 때문에 null이라는 값을 설정하고 싶은 경우를 포기합니다. JSON Patch는 모호함이 없는 대신 요청 본문이 연산 목록이 되어 사람이 읽고 쓰기에 부담스럽습니다. 필드 마스크는 "무엇을 바꾸는가"를 값과 분리해 명시함으로써 두 문제를 다 피하고, 대신 마스크 목록을 관리하는 비용을 냅니다.

셋의 공통점이 보입니다 — 어떤 방식이든 "바꾸겠다는 의도"를 값 바깥의 어딘가에 명시적으로 싣습니다. 값만 보고 의도를 추측하는 설계는 셋 중 어디에도 없습니다.


Recap

JSON Merge Patch는 null을 삭제로 고정하고, JSON Patch는 연산을 명시하며, 필드 마스크는 바꿀 필드 목록을 값과 분리해서 보냅니다. 표현은 달라도 공통점은 하나입니다 — 수정 의도를 값에서 추측하지 않고 계약에 명시합니다.




3. 바꿀 필드를 명시하는 계약

제가 실무에서 만난 것은 필드 마스크 계열의 패턴이었습니다. 수정 요청이 값과 함께 이번에 반영할 필드 목록을 들고 옵니다.

// 수정 요청 — 바꿀 필드를 명시적으로 선언한다
data class UpdateProfileRequest(
val permitted: Set<ProfileColumn>, // 이번에 반영할 필드 목록
val nickname: String?,
val militaryStatus: MilitaryStatus?,
/* ... */
)
// 서비스 — permitted에 있는 필드만 반영한다
if (ProfileColumn.NICKNAME in request.permitted) {
profile.nickname = request.nickname // 목록에 있으니 null도 "비우기"로 안심하고 해석한다
}
// 목록에 없는 필드는 값이 와 있어도 건드리지 않는다

null의 모호함이 사라진 것에 주목해 주십시오. 목록에 있는 필드의 null은 비우기고, 목록에 없는 필드는 값이 무엇이든 무시됩니다. 의도가 값이 아니라 목록에 실려 있기 때문입니다. 지난 글의 exhaustive when이 여기서 다시 등장합니다 — 필드 목록이 enum이라서, 새 필드를 추가하면 이 분기 코드가 컴파일 에러로 "나도 고쳐야 한다"고 알려줍니다.

프론트엔드 쪽 반쪽도 익숙한 모양입니다. 폼 라이브러리의 dirty fields — 사용자가 실제로 만진 필드 목록 — 로 PATCH 본문을 구성하던 그 코드가 사실 permitted 목록을 만드는 일이었습니다. 클라이언트는 의도를 수집하고 서버는 의도를 계약으로 받는, 같은 문제의 양쪽입니다.

검증도 이 계약 위에 얹힙니다. 검증 대상값은 "이번에 수정하는 값"이거나, 수정 목록에 없다면 "기존 값"입니다 — 그렇게 조합된 최종 상태를 놓고 필드별로 에러를 누적합니다.

// 필드별 에러를 모은다 — 폼 유효성 검사기와 같은 모양이다
val result = ValidationResult()
val military = request.militaryOrElse(existing) // 수정값이 없으면 기존값으로 검증한다
if (military.status.isServed && military.type == MilitaryType.UNKNOWN) {
result.put("militaryType", ErrorCode.MILITARY_TYPE_REQUIRED)
}

필드별 에러 객체를 돌려주는 모양까지 react-hook-formerrors 객체와 닮아 있습니다. 다른 점은 위치뿐입니다 — 클라이언트 검증은 UX를 위한 것이고, 최종 판정은 언제나 서버의 몫입니다.

계약의 미묘함은 여기까지입니다. 다음 글부터는 무대가 데이터베이스로 내려갑니다 — 이 필드들을 실제로 어떤 모양으로 저장할 것인가, 컬럼이냐 JSON 컬럼이냐부터 시작하겠습니다.


Recap

필드 마스크 계열의 permit 계약은 바꿀 필드 목록을 값과 분리해 명시함으로써 null의 모호함을 없앱니다. 클라이언트의 dirty fields가 그 목록의 출처가 되고, 검증은 수정값과 기존값을 조합한 최종 상태에 대해 필드별로 에러를 누적합니다 — 폼 검증기와 같은 모양이되 최종 판정은 서버에 있습니다.




FE ↔ BE 대응표

이 글에서 다룬 대응입니다.

BE 개념FE에서 가장 가까운 것대응의 핵심
부분 수정의 null 모호함JSON.stringify가 지우는 undefined전송 계층에서 의도 정보가 사라진다
필드 마스크 / permit 목록폼의 dirty fields의도를 값이 아니라 목록에 싣는다
필드 단위 검증 결과react-hook-formerrors 객체필드별 에러 누적, 최종 판정은 서버



References

표준 문서

  1. RFC 7396 — JSON Merge Patch
  2. RFC 6902 — JavaScript Object Notation (JSON) Patch
  3. Google AIP-134 — Standard methods: Update (field masks)


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자