- Published on
라벨을 기다리다 값이 사라진다 — 비동기 관계 피커의 prefill 소실 수리
- Authors

- Name
- Hyo814
라벨을 기다리다 값이 사라진다 — 비동기 관계 피커의 prefill 소실 수리
용어 편집 화면에는 다른 레코드를 골라 연결하는 입력이 여러 개 있습니다. 값 영역(range), 상위 속성(parent), 상위 클래스(subclass_of), 클래스의 구성 속성 — 전부 asyncRelationPicker.js라는 공용 피커를 씁니다. 후보가 수백~수천 건이라 전부 내려받지 않고 서버에서 검색해 가져오는 방식이에요.
여기서 이런 제보가 들어왔습니다.
편집 들어가서 아무것도 안 바꾸고 저장만 눌렀는데, 값 영역이 비어버렸어요.
에러도 안 뜨고, 저장은 정상적으로 성공합니다. 값만 없어져 있어요. 가장 고약한 종류의 버그입니다.
1. 증상 — 재현이 되다 말다 한다
- 편집 진입 → 곧바로 저장: 값이 사라짐
- 편집 진입 → 잠깐 기다렸다 저장: 정상
- 네트워크가 빠른 환경에서는 거의 재현 안 됨
"기다렸다 저장하면 된다"는 조건에서 이미 답이 반쯤 나왔습니다. 화면이 그려진 시점과 값이 준비된 시점이 다르다는 뜻이니까요.
2. 재현 조건부터 고정하기
"되다 말다" 하는 버그는 재현 조건을 고정하지 않으면 고쳤는지도 알 수 없습니다. 그래서 원인을 찾기 전에 항상 재현되는 상태부터 만들었어요. DevTools의 Network 스로틀링만으로는 부족해서, 옵션 조회 엔드포인트에만 지연을 걸었습니다.
// 콘솔에서 임시로: 옵션 조회만 3초 지연
const _fetch = window.fetch;
window.fetch = (url, opt) =>
String(url).includes("/options/")
? new Promise((r) => setTimeout(() => r(_fetch(url, opt)), 3000))
: _fetch(url, opt);
이 상태로 편집 진입 직후 저장하면 100% 재현됩니다. 그리고 저장 직전에 hidden input을 찍어보니 값이 비어 있었어요.
document.querySelector("[name=range]").value; // "" ← 원래 "42"가 있어야 함
여기서 서버가 아니라 프런트 상태 문제라는 게 확정됐습니다. 서버 입장에선 "사용자가 값을 지우고 저장했다"는 완벽히 정상적인 요청을 받은 거라, 에러가 날 이유가 없었던 거예요.
3. 원인 — 라벨 fetch가 성공해야 값이 채워졌다
피커에 미리 선택된 값을 넣어주는 함수가 setValues(ids)입니다. 문제의 코드는 이랬어요.
// 변경 전
function setValues(ids) {
selected.clear(); // ① 일단 비운다
renderSelected();
syncHidden(); // ② 비운 상태로 hidden input 동기화
const normalized = normalizeIds(ids);
if (!normalized.length) return Promise.resolve();
return fetchOptions({ ids: normalized }, { silent: true }).then(function (json) {
if (!json) return; // ③ 조회 실패면 비운 채로 끝
(json.results || []).forEach(function (item) {
if (single && selected.size) return;
selected.set(String(item.id), item); // ④ 조회 성공해야 비로소 채움
});
renderSelected();
syncHidden();
});
}
이 함수는 성격이 다른 두 가지를 한 덩어리로 처리하고 있습니다.
- 값 — 어떤 id가 선택돼 있는가. 이미 알고 있음. 동기적으로 확정 가능.
- 라벨 — 그 id를 화면에 뭐라고 표시할 것인가. 서버에 물어봐야 함. 비동기.
그런데 코드는 라벨 조회가 성공해야 값까지 같이 채웁니다. 그래서 fetch가 끝나기 전에 저장을 누르면 selected는 비어 있고, syncHidden()이 이미 빈 값을 써둔 상태고, getValues()는 []를 돌려줍니다. 조회가 아예 실패한 경우(③)도 결과는 같아요.
비동기 작업의 지연과 실패가 값의 소실로 번역되고 있었던 것입니다.
4. 세 가지 해결책과 고른 이유
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
| ① 조회가 끝날 때까지 저장 버튼 비활성화 | 구현 간단, 소실 확실히 차단 | 조회가 실패하면 영영 저장 불가. 라벨 하나 못 가져왔다고 편집 전체가 잠김 | ✗ |
| ② 서버가 초기 렌더에 라벨을 함께 내려주기 | 비동기 자체가 사라짐 | 피커 4곳 + 서버 응답 스키마를 다 손봐야 함. 검색 경로는 어차피 비동기라 절반만 해결 | ✗ (범위 과대) |
| ③ 값은 동기로 먼저, 라벨만 비동기로 (씨앗 패턴) | 피커 파일 하나만 수정. 실패해도 값 보존 | 라벨이 잠깐 "불러오는 중…"으로 보임 | ✓ 채택 |
①은 안전해 보였지만 실패 케이스가 더 나빴습니다. 옵션 API가 잠깐 죽으면 사용자는 아무것도 저장할 수 없게 됩니다. 값을 지키려다 편집 자체를 막는 셈이에요.
②는 사실 가장 깔끔한 구조입니다. 다만 이 피커는 검색 결과도 서버에서 가져오기 때문에 초기 prefill만 embed해도 비동기 경로가 그대로 남습니다. 원인을 절반만 없애면서 손대는 파일은 제일 많은 선택이라 접었어요.
③을 고른 기준은 "라벨을 못 가져온 게 값을 버릴 이유가 되는가"였습니다. 안 됩니다. 라벨은 표시의 문제고 값은 데이터의 문제니까요. 이 둘을 분리하면 실패가 표시에만 머무릅니다.
5. 수정 — 선택 id를 먼저 심어두는 "씨앗"
선택 id를 임시 항목(_pending)으로 먼저 심어두고, 라벨은 나중에 채웁니다.
// 변경 후
function setValues(ids) {
selected.clear();
const normalized = normalizeIds(ids);
// 선택 id를 라벨 조회 전에 '동기적으로 먼저' 반영한다. 라벨 fetch는 비동기라,
// 조회가 끝나기 전(느린 응답)이나 실패해도 getValues()/hidden이 올바른 id를
// 돌려줘야 저장 시 값이 조용히 소실되지 않는다.
const seedIds = single ? normalized.slice(0, 1) : normalized;
seedIds.forEach(function (id) {
selected.set(String(id), { id: id, label: "불러오는 중…", _pending: true });
});
renderSelected();
syncHidden(); // ← 이 시점에 이미 올바른 id가 들어간다
if (!normalized.length) return Promise.resolve();
return fetchOptions({ ids: normalized }, { silent: true }).then(function (json) {
// 조회 실패(json 없음): 씨앗 id를 그대로 유지 — 라벨만 미해결이고 값은 보존한다.
if (!json) return;
// 조회 성공: 결과로 확인된 씨앗만 실제 라벨로 교체하고, 결과에 없는 씨앗
// (무효·제외·삭제된 id)은 제거해 예전 동작대로 유효한 값만 남긴다.
const confirmed = new Set();
(json.results || []).forEach(function (item) {
const id = String(item.id);
if (single && confirmed.size) return;
if (selected.has(id)) {
selected.set(id, item);
confirmed.add(id);
}
});
Array.from(selected.keys()).forEach(function (id) {
const cur = selected.get(id);
if (cur && cur._pending && !confirmed.has(id)) selected.delete(id);
});
renderSelected();
syncHidden();
});
}
네 갈래를 명시적으로 나눈 게 핵심입니다.
| 상황 | 씨앗 처리 | 결과 |
|---|---|---|
| 조회 전 / 조회 중 | 유지 | 값 보존, 라벨은 "불러오는 중…" |
| 조회 성공 + 결과에 있음 | 실제 항목으로 교체 | 정상 |
| 조회 성공 + 결과에 없음 | 삭제 | 무효·삭제된 id 정리 (기존 동작 유지) |
| 조회 실패 | 유지 | 값 보존 (라벨만 미해결) |
마지막 줄이 이 수정의 전부예요. 예전에는 "조회 실패 = 값 없음"이었지만, 이제는 "조회 실패 = 라벨을 모를 뿐 값은 그대로"입니다.
세 번째 줄(결과에 없는 씨앗 삭제)은 일부러 남겨둔 기존 동작입니다. 삭제된 레코드의 id가 prefill로 들어온 경우까지 보존하면 저장 시 서버에서 터지니까요. "조회에 성공했는데 결과에 없다"와 "조회 자체가 실패했다"는 다른 사건이고, 이 구분이 씨앗 패턴의 실제 내용입니다.
selected.has(id) 체크도 가드예요. 서버 응답에 씨앗과 무관한 항목이 섞여 와도 선택 목록을 오염시키지 않게 했습니다.
6. 검증
이 피커는 공용이라 소비처를 전부 확인해야 했습니다.
termTree.js— 값 영역(range), 상위 속성(parent), 상위 클래스(subclass_of)metaClassTree.js— 클래스 구성 속성(다중 선택)
앞서 만든 3초 지연 패치를 켠 상태로 돌린 항목들입니다.
| 시나리오 | 기대 | 결과 |
|---|---|---|
| 편집 진입 직후 저장 | 기존 값 유지 | ✓ (수정 전엔 소실) |
| 옵션 엔드포인트 강제 실패 후 저장 | 값 유지, 라벨만 미해결 | ✓ |
| 존재하지 않는 id로 prefill | 조회 후 제거 | ✓ |
| 단일 선택 모드에 id 2개 전달 | 첫 항목만 반영 | ✓ (기존 동작 유지) |
| 다중 선택 구성 속성 20건 prefill | 전량 유지 | ✓ |
7. 회고
- 비동기 실패가 "빈 값"으로 번역되면, 그건 UI 버그가 아니라 데이터 소실입니다. 로딩 실패는 보통 화면에 티가 나는데, 저장 경로에 섞이면 아무 흔적 없이 데이터를 지웁니다. 이번 건도 서버 로그에는 흔적이 하나도 없었어요.
- 하나의 함수가 값과 표시를 같이 책임지면 이런 결합이 생깁니다. 값은 동기적으로 확정할 수 있는데 표시 때문에 같이 기다리게 되는 구조였습니다.
- 폼 초기화 순서는 이렇게 지키는 게 안전합니다. ① 값 확정 → ② hidden 동기화 → ③ 라벨 채우기. ①과 ②는 절대 네트워크에 의존하지 않게.
- "느릴 때만 재현되는 버그"는 재현 조건을 코드로 고정하고 시작하면 훨씬 빨리 끝납니다. fetch를 감싸는 몇 줄이 이번 작업에서 제일 효율이 좋았어요.
- 해결책을 고를 땐 성공 경로보다 실패했을 때 뭐가 남는지로 비교하는 게 좋았습니다. ①과 ③은 정상 상황에선 똑같이 동작하고, 옵션 API가 죽었을 때만 갈립니다.
이런 종류는 QA에서도 잘 안 걸립니다. 테스트하는 사람은 화면이 다 그려진 뒤에 클릭하니까요. 결국 사용자는 항상 개발자보다 성급하게 클릭한다는 걸 전제로 초기화 순서를 짜는 수밖에 없습니다.