
글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.
예약 폼에서 가족을 추가한 뒤 날짜를 고르면 이전 가족 수를 기준으로 움직이는 버그가 있었어요. 날짜와 시간, 예약 대상을 고르는 화면인데 그 값을 바꾸는 곳은 흩어져 있었어요.
가족 목록은 React Query 캐시에 있고 예약 대상과 일시는 Zustand에도 저장했어요. 방금 고른 날짜는 컴포넌트의 로컬 상태에도 있었어요. 한쪽의 값이 바뀌면 다른 쪽에 옮겨 적어야 했고, 새 가족을 추가하는 일에도 캐시와 스토어를 함께 손대야 했어요.
서버에서 받은 가족 목록은 React Query에 남기고, 선택 여부와 희망 일시를 폼으로 옮겼어요. 제출할 수 있는 값인지 판단하는 코드는 Zod에 모으고요. 저장할 위치를 바꾼 것보다 같은 선택을 다시 맞춰 쓰지 않게 하는 것이 필요했어요.
값을 바꾼 뒤 유효성도 따로 바꾸고 있었어요
Zustand 스토어에는 작성 중인 값과 isValid가 함께 있었어요.
type BookingDraftStore = {
form: Partial<BookingFormValues>;
isValid: boolean;
updateParticipant: (
memberId: number,
values: Partial<ParticipantDraft>,) => void;
applyScheduleToAll: (date: string, time: string) => void;
};참여자나 일시를 바꾸는 액션은 끝에서 validateDraft를 다시 호출했어요.
updateParticipant: (memberId, values) =>
set((state) => {
// 값 수정
state.isValid = validateDraft(state.form);
});기존 액션은 이 호출을 하고 있었지만 새 액션에서 빠뜨리기 쉬운 구조였어요. 값은 바뀌었는데 버튼은 이전 isValid를 볼 수 있어요. 가족을 추가하거나 삭제할 때는 캐시의 사람 목록과 스토어의 배열까지 맞춰야 했어요.

리팩터링 전 예약 화면의 상태 배치예요. 선택 여부와 희망 일시가 React Query 캐시와 Zustand 스토어에 사람마다 한 벌씩 있었고, 희망 일시는 컴포넌트 로컬 상태에도 흩어져 있었어요.
가족을 추가하면 기존 ID를 백업하고 목록을 다시 받은 뒤, 선택과 일시를 복원하고 새 가족에게 날짜를 넣었어요. 그런데 스토어에는 그 가족을 체크한 뒤에야 추가했어요. 같은 사람의 일시라도 캐시 쪽과 스토어 쪽이 따로 움직일 수 있었던 거예요.
Zustand를 쓰는 것 자체가 버그의 원인은 아니었어요. 서버가 정하는 사람 목록에 작성 중인 선택과 일시를 섞어 두고, 그 값을 다시 스토어에 옮기면서 생긴 문제였어요.
사람 목록은 서버에서 받고 선택은 폼에서 해요
새 구조에서는 서버가 준 이름과 가족 목록을 React Query로 읽어요. 사용자가 고른 사람과 일시는 React Hook Form에 쓰고, Zod가 그 값으로 제출 가능 여부를 계산해요. 아직 확인하지 않은 시트의 선택과 날짜 선택기의 표시값만 로컬 상태에 남겼어요.

리팩터링 뒤의 상태 배치를 같은 틀로 다시 그린 표예요. 작성값은 폼 한 곳에만 쓰고, 서버 목록이 바뀌면 동기화 훅이 폼 배열을 그 목록에 맞춰요.
날짜 선택기가 보여주는 값과 제출할 값도 구분했어요. 시트 안에서 고르는 동안에는 임시 값으로 두다가 확인하면 폼에 넣어요. 시트를 여닫는 동작은 오버레이 라이브러리가 맡아요.
예약 작성값을 다른 화면이 공유할 필요는 없어서 페이지에 FormProvider를 뒀어요. 공용 훅 useSchemaForm이 zodResolver를 연결하고, ParticipantSelector 안의 동기화 훅이 서버 목록으로 빈 participants 배열을 채워요. 두 이름은 공개용 가명이에요.
function BookingPage() {
const methods = useSchemaForm({
schema: BookingFormSchema,
defaultValues: {
participants: [],
},
});
// handleScheduleSelect는 아래 '날짜 일괄 적용' 절에서 다룬다.
return (
<FormProvider {...methods}>
<ScheduleSection onSelect={handleScheduleSelect} />
<ParticipantSelector />
<RequestNoteField />
<TermsSection />
<SubmitButton />
</FormProvider>
);
}
리팩터링 뒤 예약 신청 화면과 그 화면이 쓰는 폼 값이에요. 시간까지 고르면 폼이 공통 일시를 모든 사람 칸에 옮겨 적고, 신청 버튼은 스키마 조건을 모두 채울 때 켜져요.
하나의 participants 배열에도 서버가 정한 이름과 ID, 사용자가 바꾸는 선택 여부와 희망 일시가 같이 들어가요. 요청에는 선택하지 않은 사람도 selected 값을 달고 가요. 서버 목록을 다시 받더라도 이미 작성한 선택과 일시까지 덮어쓰지는 않도록 해야 했어요.
폼을 페이지 안에서 만들면서, 화면마다 전역 Zustand 스토어를 비우던 정리 코드는 지웠어요.
isValid는 폼 값에서 읽되 계산 시점도 봐야 했어요
예약 폼의 조건은 다음처럼 옮겼어요.
const ParticipantSchema = z.object({
memberId: z.number(),
selected: z.boolean(),
name: z.string(),
preferredDate: z.string({ message: '희망 날짜를 입력해 주세요.' }),
preferredTime: z.string({ message: '희망 시간을 입력해 주세요.' }),
});
export const BookingFormSchema = z
.object({
requestNote: z.string().max(200, '최대 200자까지 입력할 수 있어요.').optional(),
participants: z
.array(ParticipantSchema)
.min(1, '가족 예약이 최소 한 명 이상 있어야 합니다.')
.default([]),
preferredDate: z.string(),
preferredTime: z.string(),
})
.superRefine((data, ctx) => {
if (data.participants.some((participant) => participant.selected)) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['participants'],
message: '최소 한 명은 예약 대상이어야 합니다.',
});
});최소 한 명을 골라야 하고, 공통 preferredDate와 preferredTime이 있어야 신청할 수 있어요. 사람별 날짜와 시간은 문자열 여부만 검사해요. 날짜 선택기가 시간까지 골라 넘기기 전에는 공통 값이 준비되지 않아 제출이 막혀요.
버튼은 별도 isValid 상태 대신 formState.isValid를 읽어요. 스키마에 오류 위치와 문구도 있지만, 이 화면은 아직 필드별 오류를 보여주지 않고 버튼을 끄는 방식만 써요.
다만 라이브러리로 옮겼다고 검증 시점까지 명확해진 것은 아니었어요. useSchemaForm과 예약 폼은 mode를 지정하지 않고, 날짜 핸들러도 setValue에 shouldValidate를 주지 않아요. 체크박스와 사람별 일시 변경은 useFieldArray의 update를 쓰고요.
React Hook Form 7.56 소스를 따라가 보니 버튼이 갱신되는 이유는 participants 배열에 있었어요. isValid를 구독하면 기본 제출 모드에서도 register나 Controller에 연결된 입력 변경으로 유효성이 다시 계산돼요. 하지만 shouldValidate 없는 setValue만으로는 그렇지 않아요. 이 화면의 setValue는 useFieldArray가 관리하는 배열 전체를 바꾸고, useFieldArray가 그 변경 뒤 유효성을 계산하고 있었어요.
배열을 건드리지 않고 날짜만 setValue로 바꾸면 이 동작에 기대지 못해요. 값만 최신이고 버튼은 그대로인 문제가 다시 생길 수 있어요. mode를 호출부에서 정하거나 해당 setValue에 shouldValidate를 넘기는 수정은 아직 남아 있어요.
가족이 늘거나 줄면 작성 중인 배열에 반영해요
가족 목록을 다시 받아도 이미 고른 날짜는 지켜야 해요. useBookingParticipants가 회원 정보와 가족 쿼리를 합쳐 본인을 앞에 둔 목록을 만들고, 동기화 훅이 그 목록에 맞춰 폼 배열을 바꿔요. 회원 정보를 기다리는 동안에는 null이라 건드리지 않아요.
// 새 칸을 만드는 함수: 서버 값에 폼의 공통 희망 일시를 붙인다.
function useParticipantInitializer(control: Control<BookingFormValues>) {
const preferredDate = useWatch({ control, name: 'preferredDate' }) ?? '';
const preferredTime = useWatch({ control, name: 'preferredTime' }) ?? '';
const initParticipant = useCallback(
(override: Partial<Participant> = {}) =>
<T extends { memberId: number; name: string }>(base: T) => ({
...base,
preferredDate,
preferredTime,
...override,
}),
[preferredDate, preferredTime],
);
return { initParticipant };
}
function useSyncParticipants() {
const { control, setValue } = useFormContext<BookingFormValues>();
const current = useWatch({ control, name: 'participants' });
const serverParticipants = useBookingParticipants();
const { initParticipant } = useParticipantInitializer(control);
useEffect(() => {
if (serverParticipants === null) return;
// 처음: 서버 목록 전체로 채운다.
if (current.length === 0) {
setValue('participants', serverParticipants.map(initParticipant()));
return;
}
// 서버 목록이 짧아졌다: 서버에 남은 사람만 남긴다.
if (current.length > serverParticipants.length) {
setValue(
'participants',
intersectionBy(current, serverParticipants, (p) => p.memberId),
);
return;
}
// 서버 목록이 길어졌다: 폼에 없는 사람을 선택한 채로 뒤에 붙인다.
if (current.length < serverParticipants.length) {
setValue('participants', [
...current,
...differenceBy(serverParticipants, current, (p) => p.memberId).map(
initParticipant({ selected: true }),
),
]);
}
}, [serverParticipants, current, initParticipant, setValue]);
}처음 채울 때는 서버 목록의 기본값을 써요. 본인은 선택된 상태, 기존 가족은 선택하지 않은 상태로 시작해요. 이후 추가된 가족은 selected: true로 붙여 바로 예약 대상이 돼요.
새 사람의 일시는 initParticipant가 폼의 공통 preferredDate와 preferredTime에서 가져와요. 날짜를 고른 뒤 가족을 추가해도 새 칸에 같은 일시가 들어가요. 이미 있던 사람은 현재 폼 값을 남겨 개별 선택과 일시를 지켜요.

서버 목록이 바뀔 때 동기화 훅이 폼 배열을 맞추는 네 갈래예요. 길이로 무엇을 할지 고르고, 누구를 남기고 붙일지는 memberId로 정해요. 새 칸에는 폼의 공통 희망 일시를 붙여요.
이 구현은 배열 길이가 달라졌을 때만 사람을 맞춰요. 인원수가 같은 채 memberId가 바뀌거나 이름만 바뀌면 그대로 둬요. 추가와 삭제에는 대응하지만 같은 길이의 교체나 서버 필드 갱신까지 동기화하는 코드는 아니에요.
공통 일시와 개인별 변경도 폼에 써요
날짜 선택기는 시간까지 고른 뒤 날짜와 시간을 함께 넘겨요. 페이지 핸들러는 참여자들의 일시와 공통 일시를 폼에 적어요.
const handleScheduleSelect = useCallback(
({ date, time }: { date: string; time: string }) => {
const participants = methods.getValues('participants');
// 날짜·시간 형식 변환은 생략
methods.setValue(
'participants',
participants.map(initParticipant({ preferredDate: date, preferredTime: time })),
);
methods.setValue('preferredDate', date);
methods.setValue('preferredTime', time);
},
[methods, initParticipant],
);initParticipant에 새 일시를 덮어쓸 값으로 넘기니 선택 여부는 유지돼요. 공통 시간을 고르면 사람 카드에 '변경'이 나타나고, 개인 시트에서 확인하면 useFieldArray의 update가 그 사람만 바꿔요. 공통 일시를 다시 고르면 개인별로 바꿨던 일시도 덮어써요.
useFieldArray는 진료 대상 목록 컴포넌트에만 두었어요. 페이지 핸들러와 동기화 훅은 setValue로 배열 전체를 바꾸고, shouldDirty나 shouldValidate를 지정하지 않아요. 앞서 설명한 버튼 갱신 경로가 여기에 걸려 있어요.
폼으로 옮겨도 서버 목록과의 연결은 남아요
ParticipantSelector는 선택 UI를 조합하고, 가족 선택은 하위 컴포넌트가 맡아요. 서버 목록을 폼 배열에 반영하는 일은 동기화 훅으로 떼었어요. 날짜를 맞추려고 캐시와 스토어, 로컬 상태를 함께 바꾸던 코드를 이렇게 나누고 Zustand 스토어를 지웠어요.
React Hook Form에서도 커스텀 입력은 Controller로 연결해야 하고, 서버 목록과 작성 중인 값을 맞추는 코드는 필요해요. 특히 이 폼은 setValue의 검증과 dirty 갱신을 명시하지 않아 필드 배열의 동작에 기대고 있어요. 동기화 로직의 자동화 테스트도 아직 없어요.
가족을 추가할 때 공통 일시를 어디서 가져오는지는 이제 폼 안에서 찾을 수 있어요. 새 사람에게 값을 넣기 위해 캐시의 선택을 백업하고 스토어에 복원하는 과정을 거치지 않아요.


