
글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.
프로그램 입력을 고칠 때마다 병원 관리자와 Back-Office의 FormItem을 같이 찾아야 했어요. 추천 대상과 시술 장비, 주의사항은 하위 시술의 기본 템플릿 편집 화면에도 있었어요. 같은 입력을 여러 곳에서 따로 만들고 있었던 거예요.
입력은 겹쳐도 제출하는 일은 달랐어요. 병원 관리자는 프로그램을 등록하고 검수를 요청하고, Back-Office는 요청을 확인해 승인하거나 반려해요. 운영 메모와 노출 설정은 Back-Office에만, 요청자와 검수 요청 종류는 병원 관리자에만 필요했어요. 두 폼을 통째로 합치면 이 차이까지 공용 코드에 따라오게 됐어요.

같은 프로그램을 다뤄도 두 앱에 필요한 값은 조금씩 달라요. 그림의 입력값은 예시예요.
같이 고쳐야 할 입력과 값의 타입은 공유하되, 검수와 제출은 각 앱에 남겼어요. API 응답을 폼 값으로 바꾸는 일, 편집한 값을 요청으로 바꾸는 일, 카테고리를 골랐을 때 기본 템플릿을 가져오는 일도 입력 컴포넌트에서 나눠 냈어요.
아래 코드는 설명에 필요한 부분만 옮겼어요. 병원을 가리키는 내부 식별자는 partner로 바꿨어요.
이름을 모으는 것에서 값의 타입까지
처음 공용화할 때는 필드 이름을 enum으로 모았어요. 오타는 줄었지만 price에 문자열을 쓰는 건 막지 못했어요. getFieldValue가 돌려준 값을 다룰 때도 이름만 맞고 값은 잘못 이해할 수 있었어요.
지금 프로그램 공통 폼과 앱별 폼에서는 createTypedForm<T>()을 사용해요. 폼 값 타입을 한 번 전달하면 Item, List, useForm, useFormInstance, useWatch를 같은 타입 기준으로 쓸 수 있도록 만든 래퍼예요.
// 공유 패키지
export const ProgramForm = createTypedForm<IProgramFormCommon>();
// Back-Office 앱: 공통 값에 운영·검수 전용 값을 더해요.
export interface IProgramInspectionClient extends IProgramFormCommon {
memo?: string;
hidePrice?: boolean | null;
rejectReason?: string;
// 나머지 앱 전용 필드 생략
}
export const ProgramInspectionForm =
createTypedForm<IProgramInspectionClient>();공유 입력은 ProgramForm으로 공통 필드만 읽고, Back-Office의 전용 입력은 ProgramInspectionForm으로 추가 필드까지 읽어요. 두 API가 쓰는 건 같은 Ant Design Form 컨텍스트예요. 값이나 인스턴스를 복제하지 않고 접근할 수 있는 타입만 나눴어요.
const form = ProgramForm.useFormInstance();
const name = form.getFieldValue('programName'); // string
const price = form.getFieldValue('price'); // number
form.setFieldValue('price', 100000);
form.setFieldsValue({ programName: '프로그램명', price: 100000 });
// 아래 두 호출은 타입 검사에서 걸려요.
form.getFieldValue('programNmae');
form.setFieldValue('price', '100000');getFieldValue('price')는 number를 돌려주고 setFieldValue('price', ...)도 그 타입만 받게 하고 싶었어요. TFieldNamePath<T>가 이름과 중첩 경로를 제한하고, TPathValue<T, P>가 고른 경로에 해당하는 값 타입을 찾도록 만들었어요.
// TFieldNamePath<T>와 TDeepPartial<T>의 구현은 생략했어요.
type TPathValue<T, P> =
P extends keyof T
? T[P]
: P extends readonly [infer K, ...infer Rest]
? K extends keyof T
? Rest extends []
? T[K]
: TPathValue<T[K], Rest>
: never
: never;
// 인스턴스에서 제공하는 메서드의 타입
interface ITypedFieldAccess<T> {
getFieldValue: <P extends TFieldNamePath<T>>(
name: P,) => TPathValue<T, P>;
setFieldValue: <P extends TFieldNamePath<T>>(
name: P,
value: TPathValue<T, P> | undefined,) => void;
}infer로 경로의 첫 항목과 나머지를 나눠서, 경로를 따라 값의 타입을 찾는 방식이에요. setFieldsValue는 중첩 객체 일부만 바꿀 수 있도록 TDeepPartial<T>를 받고, useWatch에는 아직 값이 등록되지 않은 경우를 고려해 undefined도 포함했어요.
Ant Design과 연결하는 데 필요한 타입 단언은 래퍼 안에 두었어요. 호출부에서는 반환값을 다시 단언하지 않고, 필드 이름과 값 타입이 연결된 API를 사용해요.

공통 타입과 앱별 타입을 나누되, 읽고 쓰는 메서드도 그 타입을 따르도록 했어요.
입력은 공유하고 조립은 쓰는 쪽에 남겼어요
하위 시술 템플릿 편집 화면과 프로그램 폼에는 같은 입력이 있어요. 추천 대상, 시술 특장점, 시술 장비, 시술 과정, 주의사항이에요.
시술 특장점에서 차이가 드러났어요. 템플릿 편집에서는 아이콘과 문구를 등록하지만, 프로그램에서는 가져온 아이콘은 그대로 두고 문구만 고쳐요. 공유 컴포넌트에 이 경우를 위한 옵션을 더하는 대신, FormList와 그 안에 놓을 입력을 분리했어요.
// 하위 시술 템플릿 편집: 아이콘과 문구를 함께 입력해요.
<ProcedureHighlight.FormList>
{({ field, index }) => (
<>
<ProcedureHighlight.Image field={field} />
<ProcedureHighlight.Input field={field} index={index} />
</>
)}
</ProcedureHighlight.FormList>
// 프로그램 폼: 문구만 수정해요.
<ProcedureHighlight.FormList>
{({ field, index }) => (
<ProcedureHighlight.Input field={field} index={index} />
)}
</ProcedureHighlight.FormList>FormList가 토글과 목록을 맡고, 목록의 각 행에 어떤 입력을 넣을지는 호출하는 쪽이 정해요. 입력 모양이 같은 추천 대상과 시술 과정은 그대로 공유했어요. 시술 장비와 주의사항은 기본 조립을 제공하면서 필요한 조각도 열어 두었고요.
템플릿 입력 일부는 기존 Ant Design Form을 그대로 써요. 입력을 조합하는 compound 구조를 쓴다고 모두 typed API가 되는 것은 아니에요. 어떤 필드를 읽고 쓰는지는 typed API가 제한하고, 아이콘과 문구 중 무엇을 보여 줄지는 입력을 조합하는 쪽이 정해요.

차이가 있는 입력만 조립을 다르게 하고, 나머지는 같은 구현을 사용해요.
공통 입력은 검수 요청이나 승인 API를 몰라요. onFinish와 요청 버튼은 앱이 맡아요. 병원 관리자가 요청을 보내는 일과 Back-Office가 승인하는 일을 같은 컴포넌트에 넣지 않았어요.
서버의 모양을 입력칸까지 가져오지는 않았어요
서버가 템플릿 항목을 종류별로 나누고 orderNo를 붙여 보내는 모양은 입력칸에 그대로 쓰기 불편했어요. 화면에서는 행을 추가하거나 지우고, 이미지는 업로드 컴포넌트의 파일 목록으로 편집해야 했어요.
그래서 응답을 폼 값으로 바꾸는 함수와, 폼 값을 요청 본문으로 바꾸는 함수를 따로 두었어요. 입력 컴포넌트는 편집에 필요한 모양만 다뤄요.
API 응답 → 응답 변환 함수 → 폼 값 → 요청 변환 함수 → API 요청
↑
FormItem은 이 모양만 다뤄요.이렇게 바꾸면 입력 컴포넌트마다 응답과 요청 형식을 해석하지 않아도 돼요. 편집 중인 값은 폼 모양으로 유지하고, 서버와 만나는 곳에서만 변환해요.
저장된 이미지와 방금 올린 이미지도 같은 입력에서 다뤄요. 저장된 에셋을 UploadFile로 바꿀 때 response에 원본을 넣어 두면, 업로드가 끝난 새 파일과 같은 자리에서 에셋을 읽을 수 있어요. 제출 코드에서 둘을 따로 분기할 필요가 없어져요.
지금 썸네일과 자체 제작 이미지의 폼 타입은 UploadFile<TCommonAsset>이에요. 업로드 응답에 제네릭을 지정해서 response.id를 읽는 부분까지 에셋 타입을 유지했어요.
type TProgramClientImage = UploadFile<TCommonAsset>;
// 저장된 이미지도 업로드 컴포넌트가 사용하는 형태로 바꿔요.
function convertCommonAssetToUploadFile<T extends TImageFileInfo>(
asset: T,
): UploadFile & { response: T } {
return {
uid: String(asset.id),
name: asset.originalFileName || asset.fileName,
status: 'done',
url: asset.src,
response: asset,
};
}요청을 만들 때도 폼 값을 그대로 펼치지 않아요. API에 필요한 필드를 골라 담고, 썸네일은 이미지 ID로, 날짜는 문자열로 바꿔요. 화면에서만 필요한 이전 가격이나 입력 모드는 요청에 섞이지 않아요. 나중에 더한 건강검진 구성도 같은 폼에서 편집하지만 별도 API가 저장하므로 프로그램 요청에서는 제외해요.
템플릿 목록의 변환에는 몇 가지 규칙이 있어요.
- 응답은 orderNo로 정렬하고, 폼에서는 배열 순서로 편집해요.
- 제출할 때 제목만 사용하는 섹션은 빈 줄을 거른 뒤 순번을 다시 매겨요. 이미지가 있는 섹션은 같은 필터를 적용하지 않아요.
- 시술 전후 사진은 배열 인덱스가 아니라 orderNo로 짝을 찾아 한 행으로 합쳐요. 요청할 때는 같은 순번의 두 배열로 나눠요.
- null, undefined와 빈 배열은 구분해요. 응답에 섹션이 없으면 토글을 끄고, 빈 배열이면 토글은 켠 채 초기 입력 행을 보여줘요.
- 임시저장과 최종 요청에서 빈 섹션을 보내는 방식은 달라요. 임시저장은 편집 중인 빈 배열을 보낼 수 있고, 검수, 등록 요청은 비어 있는 섹션을 제외해요.

공유할 입력의 모양과, 각 요청에서 보내야 하는 모양을 구분했어요.
처음 불러온 값과 카테고리를 바꾼 값을 구분했어요
편집 화면에서는 값을 언제 채우는지도 나눴어요. 처음 상세를 열 때는 저장된 프로그램을 불러오지만, 카테고리를 바꾸면 그 하위 시술의 기본 템플릿을 가져와야 해요. 이 둘을 구분하지 않으면 조회 응답이 사용자가 고친 값을 다시 덮을 수 있어요.
검수 상세 컨테이너에서는 필요한 조회가 끝나고 초기값을 만들 수 있을 때 폼을 그려요. 같은 드로어에서 다른 건으로 이동해 초기값이 바뀌면 resetFields()로 다시 채워요. 비동기 응답을 initialValues에 넘기는 것만으로 기존 인스턴스가 다시 초기화되지는 않기 때문이에요.
하위 시술 템플릿을 가져오는 동작은 SyncTreatmentSub가 맡아요. 폼에서 시술과 하위 시술 ID를 읽고, 둘 다 있을 때만 조회하도록 query options에 skipToken을 사용해요.
effect에서는 조회 응답뿐 아니라 필드가 touched 상태인지도 봐요. 시술 ID만 확인하면 같은 시술 안에서 하위 항목을 바꾼 경우가 빠져서, 하위 시술 ID의 touched 상태도 함께 확인해요.
const form = ProgramForm.useFormInstance();
const treatmentId =
ProgramForm.useWatch(['treatmentId'], form) ||
form.getFieldValue(['treatmentId']);
const treatmentSubId =
ProgramForm.useWatch(['treatmentSubId'], form) ||
form.getFieldValue(['treatmentSubId']);
const { data } = useQuery(
treatmentSubDetailQueryOptions({ treatmentId, treatmentSubId }),
);
const isTouchedTreatmentId = form.isFieldsTouched(['treatmentId']);
const isTouchedTreatmentSubId = form.isFieldsTouched(['treatmentSubId']);
useEffect(() => {
if (!data || !(isTouchedTreatmentId || isTouchedTreatmentSubId)) return;
// 실제 구현은 추천 대상·특장점·장비·과정·주의사항을 갱신해요.
const { recommendedTargets } = data.data;
form.setFieldValue(
'recommendedTarget',
isEmpty(recommendedTargets)
? INITIAL_RECOMMENDED_TARGET
: recommendedTargets?.map((title) => ({ title })),
);
}, [data, form, isTouchedTreatmentId, isTouchedTreatmentSubId]);기본 템플릿에서 가져오는 값은 다섯 섹션으로 한정했어요. 직접 입력하는 추가 혜택과 시술 전후 사진은 이 동기화에서 바꾸지 않아요. 조회 응답을 폼 값으로 바꾸는 일도 이 컴포넌트 안에서 처리해요. 입력 컴포넌트마다 조회와 값 갱신을 반복하지 않도록 한 거예요.

조회 조건, 적용 조건, 갱신할 필드를 한곳에서 확인할 수 있도록 했어요.
변환 규칙은 테스트로 남겼어요
변환은 처음에 테스트 없이 만들었어요. 입력 타입을 맞춘 뒤에도 요청 내용이 틀릴 수 있어서, 두 앱의 응답 변환과 프로그램 요청, 검수 요청, 임시저장에 각각 테스트를 붙였어요.
단순히 변환한 값이 있는지만 보지는 않아요. 응답 순서가 섞여 있을 때도 시술 전후 이미지가 맞는 순번으로 짝지어지는지, 이미지 객체에 ID가 빠졌을 때 별도 ID를 사용하는지, 지운 썸네일이 null로 요청되는지 같은 조건을 확인해요.
아래는 실제 변환 테스트를 줄인 예시예요. createClientForm은 나머지 기본값을 채우는 테스트 헬퍼예요.
it('빈 제목을 거른 뒤 남은 순서대로 순번을 매긴다', () => {
const form = createClientForm({
isRecommended: SAFEDOC_PROVIDED_TYPE.SafedocProvided,
recommendedTarget: [
{ title: '' },
{ title: '30대' },
{ title: '40대' },
],
});
const result = getDetailImageInspection(form);
const templates = result[PROGRAM_DETAIL_TEMPLATE_TYPE.SafedocTemplate].templates;
expect(templates.RECOMMENDED_TARGET).toEqual([
{ type: 'RECOMMENDED_TARGET', orderNo: 0, title: '30대' },
{ type: 'RECOMMENDED_TARGET', orderNo: 1, title: '40대' },
]);
});반대로 이미지가 있는 시술 특장점은 제목이 비어도 같은 방식으로 지우면 안 돼요. 그 차이도 테스트에 남겼어요. 임시저장은 빈 제목을 거른 결과가 빈 배열이어도 그대로 보내는 조건을 따로 확인하고요.
폼에서 typed API로 접근하는 이름과 값은 타입 검사로 확인하고, API에 어떤 모양으로 보내는지는 변환 테스트로 확인해요. 타입이 맞는 것과 요청 내용이 맞는 것을 같은 검증으로 취급하지 않았어요.

입력은 같은 인스턴스를 사용해요. 필드 접근과 제출 데이터의 검증은 각각 다른 곳에서 맡아요.
프로그램 필드를 바꾸면 공유 타입과 FormItem을, API 형식이 바뀌면 변환 함수와 테스트를 찾아가요. 검수 요청과 승인 처리까지 입력 컴포넌트를 따라올 필요는 없어졌어요.
typed form이 잡는 것은 코드에서 넘기는 필드 경로와 값 타입이에요. 실제 서버 응답이나 비동기 조회 뒤 입력이 덮이는 문제는 그것만으로 잡히지 않아요. 변환 결과를 검사하는 테스트와 실제 조회, 편집, 제출 화면을 보는 일은 그래서 따로 남겼어요.


