본문으로 건너뛰기
2026. 6. 27·약 4분

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

값이 든 상자, 값이 빠져나가 점선만 남은 상자, 손대지 않아 옅게 남은 상자

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

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


이 글에서 다루는 내용​

부분 수정 요청에서 필드 생략과 null을 구분하는 방법을 살펴보고, JSON Merge Patch·JSON Patch·필드 마스크와 명시적인 수정 필드 목록을 비교합니다.





JSON에는 undefined가 없습니다​

JavaScript 객체에서는 필드 생략, undefined, null, 실제 값을 구분할 수 있습니다. undefined가 언제나 "아예 언급하지 않음"을 뜻하지는 않습니다. 필드를 명시적으로 undefined로 설정할 수도 있고, 그 의미는 애플리케이션이 정합니다.

JSON에는 undefined가 없습니다. JSON.stringify는 객체에서 값이 undefined인 필드를 생략하지만 null은 그대로 남깁니다.

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

서버가 받는 JSON에는 여전히 필드 생략·명시적인 null·실제 값이라는 세 상태가 있습니다. 생략은 유지, null은 비우기, 실제 값은 설정으로 계약을 정하면 세 의도를 표현할 수 있습니다. 문제는 서버가 이를 nullable 필드 하나로 역직렬화하면서 생략과 null을 같은 값으로 받아 버리는 경우입니다.

따라서 JSON의 표현력이 부족한지보다 서버가 필드의 존재 여부를 보존하는지를 먼저 확인해야 합니다. 이를 보존하기 어렵거나 null 자체를 값으로 저장해야 한다면 수정할 필드나 연산을 별도로 명시하는 방법을 택할 수 있습니다.




부분 수정 계약의 표준 방식​

표준 방식은 수정할 필드와 연산을 계약에 드러내는 방법에 따라 나뉩니다.

해법아이디어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는 모호함이 없는 대신 요청 본문이 연산 목록이 되어 사람이 읽고 쓰기에 부담스럽습니다. 필드 마스크는 "무엇을 바꾸는가"를 값과 분리해 명시함으로써 두 문제를 다 피하고, 대신 마스크 목록을 관리하는 비용이 듭니다.

세 방식 모두 수정 의도를 해석할 규칙을 계약으로 정합니다. Merge Patch는 필드의 존재 여부와 null에 의미를 부여하고, 나머지 두 방식은 연산이나 필드 목록을 별도로 보냅니다.




바꿀 필드를 명시하는 계약​

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

// 수정 요청: 바꿀 필드를 명시적으로 선언한다
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으로 두고 else 없는 when으로 모든 값을 처리하면 새 필드를 추가했을 때 누락된 분기를 컴파일러가 알려줍니다. 위의 if만으로는 이런 검사가 이루어지지 않습니다.

폼의 dirty fields로 PATCH 본문을 만드는 프론트엔드 코드도 같은 정보를 수집합니다. 클라이언트가 변경한 필드를 보내면 서버는 그 목록에 있는 값만 반영합니다.

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

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

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

다음 글에서는 이 필드들을 컬럼과 JSON 컬럼 중 어디에 저장할지 살펴봅니다.




FE ↔ BE 대응표​

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

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



References​

표준 문서

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


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