Firestore에서 중첩 필드 하나를 수정할 때 다른 값이 사라지지 않게 하려면 어떻게 하나요?
Firestore의 중첩 값 하나만 바꾸려면 'profile.theme'처럼 필드 경로를 지정하세요. profile 객체를 통째로 넘기면 이름 등 다른 하위 값이 교체될 수 있습니다. 수정 뒤 문서를 다시 읽어 다른 필드가 남았는지 비교합니다.
적용 조건·범위: 실행 환경: Linux, Node.js 24.19.0, Firebase Web SDK 12.19.0, Firebase CLI 14.27.0, Firestore Emulator 1.19.8, OpenJDK 17.0.20입니다.
본문 근거: 공식 문서의 중첩 필드 수정 안내 · 공식 SDK의 입력 처리 코드 · FieldPath API
오류 증상과 재현 환경
프로필 설정 화면에서 테마만 변경했는데, 다시 조회한 문서에서 displayName과 locale이 사라졌다고 가정하겠습니다. 아래는 회사 장애 이력이 아니라, 이 글을 위해 로컬 Firestore 에뮬레이터에서 직접 실행한 독립 재현 사례입니다.
- 학습 목표: 객체 전체 교체와 중첩 필드 수정을 구분하고, 저장 완료 뒤 다시 읽은 값으로 수정 범위를 검증합니다.
- 선수 지식: JavaScript 객체, 모듈, async/await, Firestore 문서와 setDoc·updateDoc의 기본 개념입니다.
- 실행 환경: Linux, Node.js 24.19.0, Firebase Web SDK 12.19.0, Firebase CLI 14.27.0, Firestore Emulator 1.19.8, OpenJDK 17.0.20입니다.
- 확인일: 2026년 9월 23일. 실제 클라우드 계정·로그인·운영 데이터는 사용하지 않았습니다.
SDK는 웹 클라이언트용 모듈형 API를 사용하되, 재현 스크립트는 Node.js에서 실행합니다. React·Next.js 화면이나 TaskFlow 누적 체크포인트를 새로 만들지 않습니다. 기존 Firebase 과정의 CRUD 학습 뒤 참고할 수 있는 독립 보충 글입니다.
목차
최소 재현: 테마만 바꿨는데 이름이 사라집니다
초기 문서에는 이름·테마·언어가 같은 profile 객체에 있고, 바깥에 active가 있습니다. 목표는 theme만 light에서 dark로 바꾸는 것입니다.
{
profile: {
displayName: 'Mina',
theme: 'light',
locale: 'ko',
},
active: true,
}
수정 전 · verify.mjs의 쓰기 호출 부분
await updateDoc(ref, {
profile: { theme: 'dark' },
});
이 호출 뒤 에뮬레이터에 저장된 문서를 getDocFromServer(ref)로 다시 읽으면 다음 값이 나옵니다. 이름과 언어는 빈 문자열이 된 것이 아니라 필드 자체가 없어집니다. active는 바깥 필드라 그대로 유지됩니다.
{
profile: { theme: 'dark' },
active: true,
}
아래 전체 코드에서는 사례마다 초기 문서를 다시 저장합니다. 실패 사례 다음에 수정 코드만 실행하면 이미 사라진 이름과 언어는 돌아오지 않기 때문입니다.
원인: 수정 범위는 객체 깊이가 아니라 필드 경로입니다
updateDoc은 전달된 객체를 항상 재귀적으로 합쳐 주는 함수가 아닙니다. 입력 키가 profile이면 그 필드의 값 전체를 새 객체로 바꿉니다. 키가 profile.theme이면 profile 아래 theme만 수정 대상으로 삼습니다.
공식 SDK의 parseUpdateData도 입력 키를 필드 경로로 바꾸고, 그 경로를 수정 대상 목록에 넣습니다. 이번 문제는 저장 실패나 SDK 결함이 아니라, 호출자가 지정한 수정 범위와 의도의 불일치입니다. 공식 SDK의 입력 처리 코드에서 확인할 수 있습니다.

수정 코드: profile.theme만 지정합니다
1. 기존 문서의 한 필드만 바꾸는 경우
verify.mjs의 updateDoc 호출에서 객체 키 profile을 'profile.theme'으로 바꾸고 값은 문자열 하나만 전달합니다. 아래는 수정 후 부분 코드입니다.
await updateDoc(ref, {
'profile.theme': 'dark',
});
점은 경로 구분자입니다. JavaScript 객체 키 전체를 따옴표로 감싸야 합니다. 수정 후 이름·언어·active가 보존되는지를 함께 검사해야 하며, theme만 바뀌었다는 확인으로 끝내지 않습니다.
2. 문서가 없으면 생성해도 되는 경우
await setDoc(
ref,
{ profile: { theme: 'dark' } },
{ merge: true },
);
위처럼 비어 있지 않은 중첩 객체를 merge하면 이 예제에서는 다른 하위 필드가 유지됩니다. 다만 setDoc(..., { merge: true })는 문서가 없으면 생성할 수 있습니다. 반드시 기존 문서만 수정해야 한다면 updateDoc의 실패 동작이 더 적합합니다. 공식 문서의 중첩 필드 수정 안내와 함께 구분해 보세요.
3. 필드명 자체에 점이 들어 있는 경우
await updateDoc(
dottedRef,
new FieldPath('preferences', 'ui.theme'),
'dark',
);
실제 키가 ui.theme라는 한 문자열이라면 문자열 경로로 이어 붙이지 않습니다. new FieldPath('preferences', 'ui.theme')은 preferences 아래의 문자 그대로인 ui.theme 키를 뜻합니다. FieldPath는 firebase/firestore에서 가져옵니다. 사용자 입력을 임의 경로로 그대로 받아 쓰는 대신 수정 가능한 필드를 제한하는 편이 안전합니다.
실습 프로젝트 전체 코드
다음 네 파일을 firestore-map-repro 폴더에 UTF-8로 저장합니다. 모두 전체 파일이며 생략된 선언은 없습니다. HTML·CSS가 없는 Node.js 검증 프로젝트라 별도 화면 파일은 필요하지 않습니다.
안전 주의: 아래 Rules는 로컬 재현 전용입니다. map_repro 컬렉션에 한해 인증 없이 접근하도록 허용하므로 운영 프로젝트에 배포하지 마세요. 실제 서비스의 보안 규칙을 이 파일로 바꾸면 안 됩니다. 프로젝트 ID는 실서비스와 분리된 demo-blogflow-map으로 고정했고, 스크립트는 에뮬레이터 환경변수가 맞지 않으면 즉시 중단합니다.
package.json · 전체 파일
{
"name": "blogflow-firestore-map-repro",
"private": true,
"type": "module",
"scripts": {
"verify": "firebase emulators:exec --project demo-blogflow-map --only firestore \"node verify.mjs\""
},
"dependencies": { "firebase": "12.19.0" },
"devDependencies": { "firebase-tools": "14.27.0" }
}
firebase.json · 전체 파일
{
"firestore": { "rules": "firestore.rules" },
"emulators": {
"firestore": { "host": "127.0.0.1", "port": 8080 },
"ui": { "enabled": false },
"singleProjectMode": true
}
}
firestore.rules · 전체 파일
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /map_repro/{id} {
allow read, write: if true;
}
}
}
verify.mjs · 전체 파일
import assert from 'node:assert/strict';
import { initializeApp, deleteApp } from 'firebase/app';
import {
getFirestore, connectFirestoreEmulator, doc,
setDoc, updateDoc, getDocFromServer, FieldPath, terminate,
} from 'firebase/firestore';
if (process.env.FIRESTORE_EMULATOR_HOST !== '127.0.0.1:8080') {
throw new Error('Run npm run verify with the local emulator.');
}
const app = initializeApp({ projectId: 'demo-blogflow-map' });
const db = getFirestore(app);
connectFirestoreEmulator(db, '127.0.0.1', 8080);
const ref = doc(db, 'map_repro', 'profile');
const initial = {
profile: { displayName: 'Mina', theme: 'light', locale: 'ko' },
active: true,
};
const expected = {
profile: { displayName: 'Mina', theme: 'dark', locale: 'ko' },
active: true,
};
async function check(label, write, want) {
await setDoc(ref, initial);
await write();
const actual = (await getDocFromServer(ref)).data();
assert.deepEqual(actual, want);
console.log(`${label}: ${JSON.stringify(actual)} | PASS`);
return actual;
}
try {
console.log(`Node ${process.version} | Firebase 12.19.0 | local emulator`);
console.log(`INPUT: ${JSON.stringify(initial)}`);
console.log(`GOAL: ${JSON.stringify(expected)}`);
const before = await check(
'BEFORE map replacement',
() => updateDoc(ref, { profile: { theme: 'dark' } }),
{ profile: { theme: 'dark' }, active: true },
);
assert.notDeepEqual(before, expected);
console.log('BEFORE goal: FAIL (displayName and locale missing)');
await check(
'AFTER dotted path',
() => updateDoc(ref, { 'profile.theme': 'dark' }),
expected,
);
await check(
'ALTERNATIVE merge',
() => setDoc(ref, { profile: { theme: 'dark' } }, { merge: true }),
expected,
);
await check(
'EDGE empty map',
() => setDoc(ref, { profile: {} }, { merge: true }),
{ profile: {}, active: true },
);
const dottedRef = doc(db, 'map_repro', 'literal-dot');
await setDoc(dottedRef, { preferences: { 'ui.theme': 'light', locale: 'ko' } });
await updateDoc(dottedRef, new FieldPath('preferences', 'ui.theme'), 'dark');
const dottedActual = (await getDocFromServer(dottedRef)).data();
assert.deepEqual(dottedActual, { preferences: { 'ui.theme': 'dark', locale: 'ko' } });
console.log(`EDGE FieldPath: ${JSON.stringify(dottedActual)} | PASS`);
console.log('RESULT: 5 scenarios passed; before-case data loss reproduced.');
} finally {
await terminate(db);
await deleteApp(app);
}
설치와 실행
이 독립 예제를 처음 실행할 때만 의존성을 설치합니다. Node.js와 Java가 준비되어 있어야 합니다. 아래 명령은 Windows PowerShell에서도 한 줄씩 실행할 수 있습니다.
cd firestore-map-repro
node --version
java -version
npm install
npm run verify
첫 실행은 에뮬레이터 파일을 내려받습니다. 8080 포트를 사용 중인 서비스가 있으면 먼저 다른 작업과 충돌하지 않는지 확인하세요. 에뮬레이터가 테스트를 실행한 후 종료되며, 마지막에 Script exited successfully (code 0)이 나와야 합니다.
CLI 14.27.0은 이번 Java 17 환경의 재현 버전을 고정한 것입니다. 최신 CLI 설치를 안내하는 예제가 아닙니다. 실행 중 Java 21 이상으로 전환하라는 경고를 확인했습니다. 향후 CLI를 올릴 때에는 현재 Emulator Suite 설치 요구사항을 다시 확인하세요. CLI는 테스트용 개발 의존성이며 운영 앱 의존성에 추가하지 않습니다.
실제 검증 결과
2026년 9월 23일, 위 코드로 실제 로컬 쓰기 요청을 완료한 뒤 getDocFromServer로 에뮬레이터 값을 다시 읽었습니다. 네 가지 일반 사례와 특수 필드명 한 사례를 비교했습니다. 단순 문법 검사나 모의 함수 반환값 비교가 아닙니다.
| 입력·호출 | 예상 결과 | 실제 결과 |
|---|---|---|
| updateDoc + profile 객체 | profile 전체 교체 | theme만 남음. displayName·locale 소실 재현 |
| updateDoc + profile.theme | theme만 변경 | Mina·ko·active 유지, theme=dark |
| setDoc + 중첩 객체 + merge | theme 변경, 다른 값 유지 | 점 경로 수정과 같은 저장값 |
| setDoc + profile: {} + merge | profile이 빈 객체로 교체 | profile={}, active=true |
FieldPath('preferences', 'ui.theme') |
점 포함 키만 변경 | ‘ui.theme’=dark, locale=ko 유지 |
BEFORE goal: FAIL (displayName and locale missing)
RESULT: 5 scenarios passed; before-case data loss reproduced.
Script exited successfully (code 0)
여기서 BEFORE goal: FAIL은 원래 코드가 “다른 필드를 보존한다”는 목표를 달성하지 못했다는 뜻입니다. 검증 스크립트는 그 잘못된 결과까지 예상값과 대조하므로 전체 종료 코드는 0입니다. 원래 코드 실행이 예외로 실패했다고 해석하면 안 됩니다. 예상과 다른 값이 나오면 assert가 예외를 발생시켜 검증을 실패시킵니다.
실습을 마쳤다면 직접 입력의 이름·언어를 다른 값으로 바꿔 같은 보존 결과가 나오는지 확인해 보세요. 이름이 없는 초기값을 넣어 놓고 “수정 후 이름 보존”을 기대하지 않도록 초기 상태도 함께 기록합니다.
적용 한계와 추가 점검
- 이미 사라진 값은 복구하지 않습니다. 점 경로 수정은 앞으로의 불필요한 교체를 막는 방법입니다. 삭제된 값 복원은 신뢰할 수 있는 백업이나 원본 데이터가 필요합니다.
- 빈 객체는 예외를 확인합니다. 이번 실행에서
{ profile: {} }를 merge해도 기존 profile 내부는 비워졌습니다. “merge이면 무조건 모든 기존 필드를 보존한다”는 규칙으로 일반화하면 안 됩니다. - 동시 수정은 별도 문제입니다. 서로 다른 필드 경로를 좁혀 쓰는 것은 전체 객체 교체 위험을 줄이지만, 같은 필드에 대한 경쟁이나 읽은 값에 의존하는 계산까지 해결하지 않습니다. 오래된 객체를 펼쳐 전체 profile을 다시 저장하는 방식도 이번 해결책으로 사용하지 않았습니다.
- 권한 검증은 미실시입니다. 로컬 전용 허용 Rules로 쓰기 범위만 검사했습니다. 운영 Auth·App Check·사용자별 Rules·실제 네트워크·과금·운영 프로젝트 저장은 검증하지 않았습니다.
- 화면 동작은 검증 범위 밖입니다. React·Next.js 폼, 모바일 앱, 오프라인 동기화, 다중 사용자 충돌을 재현한 결과가 아닙니다.
실무 적용 시에는 수정 전 문서 → 전송 payload의 최상위 키 → 저장 완료 → 다시 읽은 문서 순서로 비교하세요. 필드가 사라졌다면 먼저 전송한 키가 profile인지 profile.theme인지 확인하고, 그다음 다른 쓰기 요청이 같은 객체를 덮어쓰는지 조사합니다.
관련 학습
- Firestore CRUD 사용법 — 문서 경로와 기본 쓰기 API를 먼저 확인합니다.
- Firestore undefined 오류 해결: 선택 필드가 저장을 막을 때 — 저장값을 다시 읽기 전에 Unsupported field value: undefined로 쓰기가 거부된다면, 선택 필드를 생략할지 null로 저장할지 먼저 확인합니다.
- Firebase 보안 규칙 설계 — 데이터 수정 범위를 정한 뒤 실제 사용자 권한을 별도로 검증합니다.
공식 근거
공식 자료 확인일: 2026년 9월 23일. 동일 증상의 GitHub 이슈 검색에서는 직접 대응되는 이슈를 찾지 못했습니다. 관련 없는 이슈를 근거로 삼지 않고 공식 문서·SDK 구현·공식 테스트와 이번 실행 결과를 대조했습니다. SDK 저장소 테스트 자체를 전체 실행한 것은 아닙니다.
글 소개·학습 준비·전체 목차

Firestore에서 중첩 객체의 값 하나만 수정하려면 updateDoc(ref, { 'profile.theme': 'dark' })처럼 필드 경로를 지정합니다. { profile: { theme: 'dark' } }를 전달하면 profile 안의 기존 이름·언어 설정까지 교체될 수 있습니다. 요청은 성공해도 데이터는 의도와 달라지는 문제입니다.
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 20개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.