
Form과 Modal의 충돌을 막는 상태 관리
“수정을 버튼을 눌렀는데 상세정보 확인창이 같이 떠요.”
“신규 등록 창에 입력하던 데이터가, 다른 팝업이 떴다 닫히니 사라졌어요.”
이번에 나왔던 버그 리포트 입니다. 기능이 많은 페이지, 특히 데이터 테이블과 여러 종류의 Form, Modal이 얽힌 화면에서 이런 UI 충돌은 종종 일어나곤 했는데요.
해당 문제를 어떠한 방식으로 해결했고, 단순히 버그를 수정하는걸 넘어, 문제가 재발할 수 없게끔 만들었던 내용에 대해 공유해보려 합니다.
문제 상황

처음에 개발할 땐 단순하게 isEditFormOpen
, isDetailModalOpen
같은 boolean 상태 여러 개로 시작했어요. 하지만 기능이 추가될수록 상태 변수는 늘어나고, 상태를 제어하는 로직도 많아지며, Form과 Modal이 동시에 열리는 현상이 발생했어요.
// 선택된 ID - 모든 form, modal이 공유
let selectedId: string | null = null;
// 기능이 추가 될 수록 관리할 상태와 제어 함수 급증
let isEditFormOpen = false;
let isDeleteModalOpen = false;
let isDetailModalOpen = false;
const openEditForm = (id: string) => {
selectedId = id;
isEditFormOpen = true;
};
const openDeleteModal = (id: string) => {
selectedId = id;
isDeleteModalOpen = true;
};
const openDetailModal = (id: string) => {
selectedId = id;
isDetailModalOpen = true;
};
이 코드의 가장 큰 문제는 Form의 상태와 Modal의 상태를 각각 별도의 상태로 관리한다는 점이고, 상태들이 서로를 전혀 인지하지 못하는 병렬 구조여서 다음과 같은 사이드 이펙트가 발생했어요.
-
입력 흐름 꼬임: Form에 데이터를 입력하는 중 다른 Modal이 위에 뜨면서 포커스를 잃음.
-
UI 비일관성: 두 개 이상의 Overlay(Form, Modal)가 동시에 열리면서 사용자경험 저하.
-
유지보수 난이도 상승: 새로운 Form이나 Modal을 추가할 때, 기존의 것을 닫는 방어 코드를 모든 이벤트 핸들러에 추가해야 하고, 로직이 분산되고 복잡도 증가.
1차 리팩터링: 두 개의 중앙 상태 객체로 통합
목표는 “흩어진 상태들을 중앙에서 관리하자” 였어요.
N개의 boolean 대신, form
과 modal
이라는 단 두 개의 상태 객체를 도입했고, type
문자열을 사용해 어떤 종류의 Form이나 Modal이 열렸는지를 구분하는 방식이에요.
let form = $state({ type: '', licenseId: '' });
let modal = $state({ type: '', licenseId: '' });
그리고 이 상태를 제어하는 전용 함수들과 이벤트 핸들러를 만들었어요.
// 상태를 변경하는 로직을 함수로 분리
const openForm = (type: string, licenseId: string = '') => {
// ...
form.type = type;
form.licenseId = licenseId;
};
const openModal = (type: string, licenseId: string: '') => {
// ...
modal.type = type;
modal.licenseId = '';
};
const closeForm = () => (form.type = '');
const closeModal = () => (modal.type = '');
// 발생하는 모든 이벤트를 단일 핸들러로 통합
const handleAction = (action: string, row: any) => {
switch (action) {
case 'edit':
openForm('edit', row.id);
break;
case 'detail':
openModal('detail', row.id);
break;
// ... action 종류만큼 case도 계속 증가
default:
break;
}
}
이 정도만으로도 코드는 비교적 깔끔해졌고, 다음과 같은 이점이 있었어요.
-
상태 변수 감소: 수십 개가 될 뻔했던 상태 변수가 단 두 개로 줄어 관리 포인트가 명확해졌어요.
-
제어 로직 중앙화:
openForm
,closeModal
등 상태를 변경하는 로직이 전용 함수로 분리되어 재사용 성이 높아지고 코드를 파악하기 쉬워졌어요. -
이벤트 핸들러 통합: 발생하는 모든 이벤트를
handleAction
이라는 단일 함수가switch
문으로 처리하게 되면서, 이벤트 관리 로직이 한곳으로 모였어요.
그러나 아직 두 가지 문제가 해결되지 않았어요.
1. 충돌 가능성: 가장 중요한 문제에요. 여전히 form
과 modal
은 서로를 인식하지 못하는 병렬상태였죠. 이로 인해 API 응답 지연과 같은 비동기 상황에서 충돌이 발생했는데요.
사용자가 ‘상세보기 Modal’을 여는 버튼을 먼저 클릭한 상황에서,
Modal에 필요한 데이터를 서버에서 가져오는 데 1~2초가 걸리고, 그 사이에 사용자가 ‘신청 Form’을 열었는데 잠시 후, 먼저 요청했던 Modal의 데이터 로딩이 끝나자마자, 현재 열려있는 Form을 인지하지 못하고 그 위에 Modal을 그대로 띄워버렸어요.
2. 너무 많은 if
와 switch
: 렌더링 코드는 if/else if 체인이 되었고, handleAction 함수는 switch 문으로 가득 찼어요. 새로운 Form이나 Modal을 하나 추가하려면 case와 else if 블록을 모두 수정해야 했죠.
{#if form.type}
{#if form.type === 'create'}
<!-- ... -->
{:else if form.type === 'v1-create'}
<!-- ... -->
{:else if form.type === 'edit'}
<!-- ... 끝없이 이어지는 else if -->
{/if}
{/if}
이렇게 새로운 case를 계속 추가하는 구조는 OCP를 위반하고, 기존 핵심 로직을 반복적으로 수정하며, 잘못 변경 시 다른 기능이 깨질 가능성이 높아져 결국 수정하기 어려운 코드가 돼요.
💡 OCP(개방-폐쇄 원칙)
OCP는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다라는 SOLID 원칙 중 하나에요. 새로운 기능을 추가할 때는 기존에 안정적으로 동작하던 로직을 되도록 수정하지 않고, 확장 포인트에 새로운 코드를 덧붙이는 방식으로 설계하는 원칙이에요.
1차 리팩터링에서 상태 및 상태 제어 코드를 하나로 모으는 데는 성공했지만, form과 modal이라는 두개의 병렬 상태를 하나로 합쳐야만 충돌 또한 근본적으로 해결될 것 같다는 판단을 했고
길게 늘어진 if/else
와 switch
를 보며 “분기문들을 없애고 더 선언적으로 바꿀 순 없을까..” 라는 생각으로 다시 리팩터링을 진행했어요.
2차 리팩터링: 상태를 하나로 통합하는 “단일 유니온 상태”
목표는 “화면에는 어떠한 Form이나 Modal도 없거나, 아니면 하나만 있게 하자” 였고,
여러 개의 boolean 대신, 페이지의 현재 UI 상태를 표현하는 단 하나의 객체를 만들었죠. Form과 Modal은 모두 기본 콘텐츠 위에 겹쳐서 나타나는 Overlay 요소에요. 그래서 이들을 관리하는 단일 상태 객체의 이름을 overlay로 명명하고 관리했어요.
이때 TypeScript의 판별된 유니온(Discriminated Union) 타입을 사용하면 구조적인 안정성을 높일 수 있더라구요.
- 판별된 유니온(Discriminated Union): 여러 개의 타입을 하나로 묶을 때, kind와 같은 공통 속성(판별자)을 두고, 각 타입에서 이 속성 값을 서로 다른 리터럴 값으로 고정해두는 TypeScript 패턴이에요.
이 판별자 덕분에 TS는 특정 지점에서 어떤 타입을 다루고 있는지 정확히 추론할 수 있어 코드의 안정성이 높아져요.
// 1. 어떤 Form과 Modal이 있는지 타입을 정의
type FormType = 'create' | 'edit' | 'renewal';
type ModalType = 'delete' | 'detail' | 'confirm-issue';
// 2. 페이지의 UI 상태를 단 하나의 타입으로 정의
type OverlayState =
| { kind: 'none' } // 아무것도 없는 상태
| { kind: 'form'; type: FormType; licenseId?: string } // Form이 열린 상태
| { kind: 'modal'; type: ModalType; licenseId: string }; // Modal이 열린 상태
// 3. 이 타입으로 단 하나의 상태 변수만 선언
let overlay = $state<OverlayState>({ kind: 'none' });
kind
라는 속성이 현재 상태를 명확하게 구분하고, 이제 overlay.kind
가 form
이면서 동시에 modal
인 것은 타입상 불가능해요. 버그 발생을 코드 레벨에서 차단한 것이죠.
세 가지 핵심 개념
단일 상태 객체를 효과적으로 관리하기 위해 다음과 같이 구현했어요.
1. 단일 진입점으로 이벤트 통일
상태를 변경하는 로직이 여러 곳에 흩어져 있으면 관리가 어려워요. openForm()
, openModal()
처럼 여러 함수를 두는 대신, 모든 UI 열기 요청을 처리하는 하나의 함수 openOverlay
를 만들어요.
// 모든 열기 요청은 이 함수를 통함
const openOverlay = (spec: OpenOverlaySpec): void => {
// kind와 type에 따라 ID 필요 여부 등 유효성 검사...
// 상태 변경은 오직 이 곳에서만 발생
setOverlay({ kind: spec.kind, type: spec.type, ... });
};
이제 상태 변경의 흐름을 추적하기 위해 openOverlay
함수만 살펴보면 되므로 코드 예측 가능성이 향상돼요.
2. Dispatcher와 Action Map으로 분기문 제거
테이블의 각 행에는 ‘수정’, ‘삭제’, ‘상세보기’ 등 다양한 버튼이 있어요. 이 버튼 클릭 이벤트를 if/else
나 switch
로 처리하는 대신, Action Map으로 처리해요.
// 액션 이름과 실행할 함수를 매핑해놓은 객체
const actionMap: Record<Action, (row: RowLike) => void> = {
edit: (row) => openOverlay({ kind: 'form', type: 'edit', licenseId: row.id }),
detail: (row) =>
openOverlay({ kind: 'modal', type: 'detail', licenseId: row.id }),
// 새로운 액션이 필요하면 여기에 한 줄만 추가
};
// 모든 액션을 처리하는 단일 Dispatcher
const dispatch = (action: Action, row: RowLike): void => {
actionMap[action](row);
};
이렇게 actionMap
을 사용하면 새로운 액션을 추가할 때 dispatch 함수는 건드리지 않고, 매핑 객체에 한 줄만 추가해요.
즉, 기능 확장은 열어두고, 기존 안정 로직 변경은 최소화하는 OCP 원칙을 지키는 구조가 돼요.
3. 동적 컴포넌트 매핑으로 렌더링 단순화
조건문으로 어떻게 그릴지 하나하나 명령하는 대신, Map을 사용해 상태가 이러면, 이것을 그린다고 선언하는 방식으로 리팩터링 했어요.
const formComponenyMap = {
create: CreateForm,
edit: EditForm,
};
// 현재 overlay 상태가 바뀌면 알아서 렌더링 할 컴포넌트가 결정됨
const FormComponent = $derived(
overlay.kind === 'form' ? formComponenyMap[overlay.type] : null
);
이로 인해 HTML 템플릿은 이전보다 훨씬 간결해지고, 상태와의 관계를 명확하게 만들어 이해하기 쉽게 만들었어요.
{#if FormComp}
<!-- key를 통해 컴포넌트가 교체될 때 상태가 섞이지 않도록 보장 -->
{#key `${overlay.kind}:${overlay.type}:${overlay.licenseId}`}
<FormComp onclose={closeOverlay} {...props} />
{/key}
{/if}
여기서 {#key}
는 React의 key
와 비슷해 보이지만, 동작 의도와 방식이 달라요.
React에서 key
는 reconciliation(가상 DOM 비교) 과정에서 재사용 여부를 결정하는 힌트로, 값이 같으면 컴포넌트를 재사용하고 다르면 언마운트 후 새로 마운트해요. 반면 Svelte의 {#key}
는 제어문 자체로, 값이 변하면 가상 DOM 비교 없이 항상 해당 블록을 언마운트하고 새로 마운트하죠.
즉, React에서는 key 변경이 재마운트를 유발할 수 있는 조건인 반면, Svelte에서는 key 변경이 재마운트를 보장하는 규칙이에요.
이 코드에서는 overlay.kind
, overlay.type
, overlay.licenseId
를 조합한 고유 키를 부여함으로써,
다른 대상(예: type이나 licenseId가 바뀐 경우)으로 전환 시 기존 컴포넌트의 로컬 상태, 이벤트 리스너, 비동기 요청이 그대로 남아 섞이는 문제를 구조적으로 방지해요.
또한 언마운트 시 onDestroy
훅이 반드시 호출되어 네트워크 요청 취소, 타이머 해제 등 필요한 정리 작업이 안전하게 실행되고,
그 결과, Form이나 Modal을 전환할 때마다 항상 깨끗한 초기 상태로 시작할 수 있고, 상태 누수 없이 예측 가능한 동작을 구현할 수 있는 것이죠.
리팩터링 후 효과
- 상태 충돌 해결
- Form과 Modal이 동시에 열리거나 입력 흐름이 꼬이는 문제 해결
kind
필드가 동시에 하나만 활성화될 수 있도록 타입 레벨에서 강제되어, 실수 등으로 인한 중복 렌더링 차단
- 코드 가독성 및 유지보수성 향상
- 기존에는 Form/Modal 열기 로직이 여러 곳에 흩어져 있었지만, 이제 모든 open/close 이벤트를
openOverlay
,closeOverlay
단일 디스패처로 동합해, 모든 열기/닫기 흐름을 한 곳에서 제어 가능 - 동적 매핑과 조건부 렌더링으로 인해 UI 분기 코드 간결화
- 버그 재발 방지
- OverlayState에 판별된 유니온을 적용해, 새로운 상태나 타입을 추가할 때 엄격한 타입 체크 자동으로 동작
- 잘못된 분기/누락은 컴파일 단계에서 즉시 에러로 드러남
- 기능 추가 속도 개선
- 기존에는 Form/Modal 마다 상태, 이벤트, 렌더링 코드를 각각 작성해야 했지만, 이제는
actionMap
,componentMap
에 1줄 매핑이면 충분 - 결과적으로 상태 관리, 로직 작성 및 수정 시간 대폭 감소
마무리
이번 작업은 단순하게 버그 하나를 고치는게 아니라, 상태 관리 설계 자체를 바로잡은 리팩터링 이었어요. 그 결과 안정성은 물론 향후 확장 및 유지보수 비용까지 줄였구요.
프론트엔드에서 상태 관리는 데이터를 효율적으로 관리하고, 그 변화를 추적해 화면에 정확히 반영하는 것이에요.
한번 구조가 잘못 잡히면 기능이 늘어날수록 복잡해지고 버그가 자주 일어나는 만큼 프론트엔드 개발에서 중요도가 높은 작업에 속한다고 생각해요.
이번 개선을 통해 “예측 가능한 구조로 설계” 하는 것이 얼마나 중요한지 다시 배웠으며,
앞으로도 예측 가능한 코드와 안정적인 사용자 경험을 제공할 수 있게끔 꾸준히 고민하고 개선해나가려 합니다.