레거시 데이터의 탈바꿈
- 바닐라 JS 기반 설문 폼을 현대적 구조로 전환하는 에디터 개발-
이번 포스팅에서는 최근 프로젝트에서 진행했던 설문 서식 에디터 개발 경험과 그 핵심 아키텍처를 공유하고자 합니다.
1. 배경 및 문제 상황
기존 시스템에 존재하던 설문 폼은 바닐라 자바스크립트(Vanilla JS)를 사용하여 화면을 직접 HTML로 그리고, 각 객체에 부여된 고유한 숫자 ID를 기반으로 DOM을 찾아 코드를 주입하는 매우 전통적인 방식으로 구성되어 있었습니다. 이 레거시 구조는 단순히 화면에 설문을 렌더링하고 답변하는 데는 문제가 없었지만, 결정적인 치명타 포인트를 가지고 있었습니다. 바로 실제 질문과 답변의 계층 구조를 적재된 데이터를 통해 파악할 수 없고, 어렵게 수집한 데이터를 통계 및 시각화용으로 가공하기 매우 어렵다는 점이었습니다.
따라서 단순히 레거시를 React로 포팅하는 것을 넘어, 기존의 평면적이고 파편화된 데이터를 실제 질문-답변 구조(Label, Question, Option)에 맞춘 현대적인 구조로 변환하는 새로운 서식 에디터가 필요했습니다.
2. 에디터 컴포넌트 아키텍처와 철저한 관심사 분리
복잡한 데이터를 다루는 에디터인 만큼, 가장 중점을 둔 부분은 각 편집 스텝별로 비즈니스 로직과 화면 상태가 섞이지 않도록 컴포넌트를 철저히 분리하는 것이었습니다.
전체 편집 흐름과 탭 전환은 최상단 컨테이너인 TemplateEditorContainer가 제어하며, 그 아래에 각 단계별 래퍼(Wrapper) 컴포넌트들이 독립적으로 배치된 구조를 띠고 있습니다.
- STEP 01. FormMapping: 레거시 요소에 라벨, 질문, 답변이라는 속성을 지정하는 단계
- STEP 02. FormGrouping: 요소 간의 논리적인 계층과 그룹을 묶는 단계
- STEP 03. RuleMaking: 동적 이벤트와 계산 로직을 부여하는 단계
[Mapper와 Form의 분리] 각 단계의 래퍼 컴포넌트 내부로 들어가면, 화면은 다시 좌측의 편집 사이드바(Mapper) 와 우측의 서식 화면(Form) View 컴포넌트로 나뉩니다. Mapper는 STEP별로 완전히 고유하지만, Form은 단순 렌더링 컴포넌트로 클린하게 유지해 재사용성을 높였습니다.
- Mapper: 요소에 속성을 변경하거나 동작을 부여하는 조작 UI입니다.
- Form: 실제 설문지가 렌더링되며, 에디터 상에서 요소를 직접 클릭해 선택할 수 있는 메인 화면입니다.
[왜 이렇게 설계했을까요?] 에디터는 사용자의 클릭과 입력에 따라 수많은 상태(State) 변화가 일어납니다. 화면에 그려지는 요소의 개수도 많고, 각 요소가 가지는 고유한 스타일이나 상태값도 많습니다.
만약 단일 컴포넌트나 느슨한 구조에서 이를 모두 처리했다면, '요소 지정' 단계의 코드가 '규칙 만들기' 단계에 의도치 않은 사이드 이펙트를 발생시키거나, 상태 처리 속도가 아주 느려지고 화면이 버벅일 위험이 매우 컸을 것입니다.
각 편집 단계(STEP 01~03)마다 완전히 독립적인 래퍼 컴포넌트를 두고 그 안에서 Mapper와 Form을 조합하는 방식을 택함으로써, 특정 단계의 도메인 로직이 다른 단계로 침범하는 것을 원천 차단했습니다. 덕분에 코드의 복잡도를 낮추고 유지보수성과 렌더링 안정성을 크게 확보할 수 있었습니다.
또한, 데이터의 흐름은 요소 변경(React State) → 적용하기(React Hook Form으로 중간 상태 홀딩) → 저장하기(RHF 값을 서버 API 규격으로 변환하여 전송)의 단계로 최적화하고, 하위 컴포넌트들은 재사용성을 고려해 적절한 분리를 진행함과 함께, useCallback과 memo를 적절히 활용해 무거운 화면에 부하를 주는 불필요한 재렌더링을 방지했습니다.
3. 3단계 데이터 구조화 프로세스
과거의 DOM 의존형 데이터를 구조적인 통계 데이터로 바꾸는 과정은 세 가지 주요 단계(Step)로 나누어 진행됩니다.
STEP 01. 요소 지정하기 (Mapping) 가장 먼저, 중첩되지 않은 평면적인(Flat) 형태의 레거시 데이터를 라벨(Label), 질문(Question), 옵션(Option) 중 하나의 성격으로 매핑합니다. 질문은 사용자의 직접적인 답변을 요구하는 요소이며, 옵션은 그 답변의 실질적인 '값'을 지닙니다. 특히 응답 값을 받기 위해서는 해당 요소가 반드시 '옵션'으로 지정되도록 강제하여, 데이터 정합성을 확보했습니다.
STEP 02. 그룹 만들기 (Grouping) 성격이 지정된 요소들을 묶어 논리적인 계층을 만듭니다. 관계있는 질문들을 하나의 카드 뷰(섹션)로 묶어주는 '그룹 구성', 하나의 질문에 여러 응답을 묶어주고, 기존 순수 자바스크립트로 하나하나 제어되던 Radio의 동작을 RadioGroup으로 지정할 수 있게 해주는 '답변 그룹', 요소들을 시각적으로 정렬함과 동시에, 이렇게 배치된 요소들을 온도계, 슬라이더, 표 형식으로 지정할 수 있는 '한 줄 배치' 기능을 통해 데이터의 계층 구조와 UI의 완성도, 설문의 사용성 개선을 동시에 잡았습니다.
STEP 03. 규칙 만들기 (Rule Making) 정적인 설문에 동적인 동작(로직)을 부여합니다. 기준 요소의 변화에 따라 다른 대상을 조작하는 '이벤트'(예: 기타 항목 체크 시 입력창 활성화)와, 특정 응답들의 정적, 동적 수치값을 가져와 합산이나 점수를 매기는 '계산' 기능을 구현했습니다.
4. 핵심 트러블슈팅: 두 가지 ID 체계의 혼용
이 프로젝트에서 고민했던 부분 중 하나는 바로 식별자(ID) 체계였습니다. 레거시 시스템에서 넘어온 요소들은 기존의 고유 번호(예: formClauSn)를 갖고 있었습니다. 하지만 새로운 모던 아키텍처에서는 구조화된 L/Q/O 각각의 상태를 식별할 새로운 식별자(UUID)가 필요했습니다.
만약 사용자가 에디터에서 요소를 '질문'에서 '라벨'로 변경한다면 요소의 성격이 변했으므로 새로운 UUID가 발급됩니다. 이럴 경우, 기존에 해당 요소에 걸어두었던 '규칙(Rule)'들이 식별자를 잃고 붕괴되는 심각한 문제가 발생할 수 있었습니다. 이를 방지하기 위해 에디터에서는 두 가지 ID를 목적에 맞게 분리해 사용했습니다.
- templateItemId: 기존 레거시 시스템의 식별자를 기반으로 한 불변값입니다. 요소 지정(Mapping) 단계와 규칙 만들기(Rule) 단계에서는, 요소의 성격이 뒤바뀌더라도 기존 규칙이 유지되도록 이 불변값을 식별자로 고정했습니다.
- id (UUID): 계층 구조가 확립되는 그룹 만들기(Grouping) 단계부터는 자연스럽게 새로운 데이터 아키텍처에 맞는 UUID를 식별자로 사용했습니다.
5. 코드 레벨의 데이터 통신 구현
데이터 조작을 프론트엔드 환경에 맞게 원활히 처리하기 위해 TypeScript를 전면 도입하고 커스텀 훅을 기반으로 데이터 통신을 구축했습니다. 에디터 진입 시, 백엔드로부터 레거시 데이터를 조회하고 프론트엔드에서 다루기 편한 형태로 가공하는 함수 로직은 다음과 같습니다.
export const findQuestionnaireEditingViewByQuestionnaireTemplateId = async (payload: {
questionnaireTemplateId: string;
}): Promise<QuestionnaireTemplateEditingViewRdo> => {
const url = `${templateLoadResource}/find-questionnaire-template-editing-view-by-questionnaire-template-id/query`;
const response = await surveyAxios.post<QuestionnaireTemplateEditingViewRdo>(url, payload);
return transformTemplateTextAlign(response.data);
};
또한 규칙 편집 중 발생하는 CUD 작업 등은 React Query를 활용하여 선언적으로 서버 상태를 관리했습니다.
export const useRuleDeleteMutation = (questionnaireTemplateId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: RemoveItemRuleCommand) => removeItemRule(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ruleQueryKeys.list(questionnaireTemplateId),
});
},
});
};
마치며
과거 바닐라 자바스크립트의 DOM 조작에 갇혀있던 레거시 폼을 모던 프론트엔드 생태계로 이끌어내고, 통계적으로 가치 있는 '데이터'로 탈바꿈시킨 이번 프로젝트는 개발적으로 무척 흥미로운 도전이었습니다. 앞으로 유사한 마이그레이션이나 복잡한 에디터 뷰 설계를 진행하실 때, 이 경험이 유용한 참고 자료가 되기를 바랍니다!
Hazel