Skip to content

[Refactor] Observer pattern을 통한 전역 모달 관리#155

Open
odukong wants to merge 5 commits intodevfrom
refactor/#153/modal-refactor
Open

[Refactor] Observer pattern을 통한 전역 모달 관리#155
odukong wants to merge 5 commits intodevfrom
refactor/#153/modal-refactor

Conversation

@odukong
Copy link
Collaborator

@odukong odukong commented Mar 10, 2026

✏️ Summary

📑 Tasks

⁉️기존 구조에서 observer 패턴을 도입한 이유

기존의 구조는 비슷하지만 각각 다른 UI의 모달 컴포넌트을 쉽게 구성할 수 있도록 컴파운트 패턴을 활용하여 UI의 유연성을 높였습니다. 또한 모달이 open, close 상태를 기준으로 열리고 닫힐 수 있도록 use-modal 훅을 분리하여 상태를 관리하도록 했습니다.

하지만 실제 모달 컴포넌트를 코드에 사용하는 측면에 있어 두 가지 문제점이 있다고 생각했습니다.

만약 어떤 컴포넌트(select-company.tsx)에서 사용하는 모달의 개수가 2가지 이상이 된다면, 각각의 모달의 상태를 위해
각각 use-modal훅을 호출하여 각자의 isOpen를 관리하도록 해야 했습니다. 한 가지 모달만 사용할 때도 무조건 use-modal훅을 호출해야 한다는 사실도 DX 관점에서 좋지 않다는 생각이 들기도 했습니다.

// (이전) 모달 상태 관리 
const { autoPlay, isOpen, handleModal } = useModal(3000); // 기업 선택 이후 3초 후 넘어가게 하는 모달
const alertModal = useModal(); // 경험 등록 여부 확인 모달

또한 JSX 코드에서 실제 컴포넌트 내용과는 약간 무관한, 동떨어져 있는 모달 코드를 JSX 코드에 함께 작성해야해 컴포넌트의 의미를 명확하게 하지 못하는 한계가 있다고 느껴졌습니다.

// 반환부에 실질적인 컴포넌트 코드과 모달 코드가 섞여 있어 가독성을 해쳐 의미를 파악하기 어려움
return (
  <div className={styles.layout}>
    <h1 className={styles.title}>어떤 기업을 분석할까요?</h1>
    <MatchingAutoComplete
      value={inputValue}
      onChange={setInputValue}
      results={searchResults}
      onDebounceChange={setSearchKeyword}
      selectedItem={selectedCompany}
      onSelect={setSelectedCompany}
      onSearch={handleModal}
      onSearch={handleSearch}
    />
    {/** 경험 등록 여부 확인 모달 */}
    <Modal isOpen={alertModal.isOpen} onClose={alertModal.closeModal}>
    </Modal>
    {/** 기업 선택 후, 대기 모달 */}
    <Modal autoPlay={autoPlay} isOpen={isOpen} onClose={handleModal}>
    </Modal>
  </div>
);

이러한 문제점을 해결하기 위해 observer pattern을 도입하기로 했습니다.

observer pattern을 도입한 이유는 다음과 같습니다.

  • 가장 큰 목적은 애플리케이션 전체에서 쓰이는 공통 UI인 모달의 렌더링 책임을 전역 ModalContainer 한 곳으로 위임하여, 개별 컴포넌트의 JSX 구조를 본연의 역할에 맞게 간결하게 유지하고 관심사를 분리하는 것입니다.
  • modalStore.open()과 같은 단순한 명령형 함수 호출만으로 쉽게 모달을 제어할 수 있어 개발 생산성을 높일 수 있습니다.
  • 특히, 전역 상태 라이브러리의 도입을 최소화하려는 팀의 지향점에 맞춰, Context API의 리렌더링 문제나 외부 라이브러리 의존 없이 순수 자바스크립트 클래스만으로 가볍게 전역 상태를 관리할 수 있다는 점을 중요하게 생각했습니다.

observer class 구현 (modal.store.ts)

modalList 배열을 통해 모달 상태를 관리하는 순수 JS 클래스를 선언하고, 싱글톤 인스턴스로 export 하였습니다.

import type { ReactNode } from "react";

class ModalStore {
  private _modalList: ModalItem[] = []; // 모달 리스트 관리
  private _listner: ((list: ModalItem[]) => void) | null = null;
  private _timers = new Map<string, NodeJS.Timeout>(); // 타이머 관리

  subscribe(callback: (list: ModalItem[]) => void) {
    this._listner = callback;    // 모달 리스트 상태 업데이트 함수 등록
  }

  unsubscribe() {
    this._listner = null;  
  }

  open(
    content: ReactNode,
    autoPlay?: number,
    onClose?: () => void,
    id: string = new Date().toString()
  ) {
      // 모달을 open하는 메서드
  }

  close(id: string) {
     // 특정 모달만 리셋하는 메서드
  }

  reset() {
      // 모든 모달을 리셋하는 메서드
  }
}

export const modalStore = new ModalStore();

모달을 보여줄 Provider 정의 (modal-provider.ts)

modalStore를 실질적으로 구독하여 전역 상태가 변경될 때마다 _modalList에 등록된 모달들을 렌더링하는 최상단 렌더링 컨테이너입니다. 특정 컴포넌트에서 modalStore.open() 명령을 보내면, 상태가 변경되었음으로 판단하고 해당 모달을 보여주기 위해 렌더링되는 것입니다.

export const ModalProvider = () => {
  const { pathname } = useLocation();
  const [modals, setModals] = useState<ModalItem[]>([]);

  useEffect(() => {
    modalStore.subscribe(setModals);            // modal-provider가 modal-store에 대한 상태를 구독합니다.
    return () => modalStore.unsubscribe();
  }, []);

  useEffect(() => {
    modalStore.reset();
  }, [pathname]);

  return (
     // 구독한 store의 상태가 변경되면 리렌더링을 진행
    <>
      {modals.map((modal) => {
        return (
          <Modal
            key={modal.id}
            isOpen={true}
            autoPlay={modal.autoPlay}
            onClose={() => modalStore.close(modal.id)}
          >
            {modal.content}
          </Modal>
        );
      })}
    </>
  );
};

컴포넌트는 오직 모달을 열어라라는 명령(modalStore.open)만 내리기 때문에, 실제 UI 렌더링 책임은 전역 컨테이너로 넘어갔으며, 컴포넌트 내 JSX는 자신의 컴포넌트 내용 자체에만 집중할 수 있습니다.

// 경험 등록 여부 확인 모달 (모달 관련 코드가 JSX코드 밖으로 이동)
useEffect(() => {
  if (data?.totalElements === 0) {
    modalStore.open(
      <>
        <Modal.Content>
          <Modal.Title>아직 등록된 경험이 없습니다</Modal.Title>
          <Modal.SubTitle>지금 바로 경험을 등록하러 가볼까요?</Modal.SubTitle>
        </Modal.Content>
        <Modal.Buttons>
          <Button variant="secondary" onClick={() => navigate(ROUTES.HOME)}>
            나가기
          </Button>
          <Button
            variant="primary"
            onClick={() => navigate(ROUTES.EXPERIENCE_CREATE)}
          >
            이동하기
          </Button>
        </Modal.Buttons>
      </>
    );
  }
}, [data, navigate]);

[추가적인 트러블 슈팅]
전역으로 모달을 분리하면서, 모달이 열린 상태에서 뒤로 가기를 누르거나 페이지를 이동할 경우 모달 자체의 상태가 변화한 것이 아니기 때문에 onClose 이벤트가 발생하지 않아 이동 페이지에서도 모달이 계속 떠 있는 문제가 있었습니다.

export const ModalProvider = () => {
  const { pathname } = useLocation();
  const [modals, setModals] = useState<ModalItem[]>([]);

  useEffect(() => {
    modalStore.subscribe(setModals);
    return () => modalStore.unsubscribe();
  }, []);

  // pathname이 변경됨을 감지해 모달이 초기화되도록 함.
  useEffect(() => {
    modalStore.reset();
  }, [pathname]);

이를 위해 useLocation을 통해 pathname의 변경을 감지하고, 라우트가 바뀔 때 modalStore.reset()을 호출하여 모달이 닫힐 수있도록 ModalProvider에 로직을 추가했습니다.

👀 To Reviewer

  • 전역적으로 모달상태를 관리함에 따라 모달의 상태를 관리했던 use-modal은 이제 사용하지 않아 머지하기 전에 삭제할 예정입니다.
  • 또한 use-modal이 사용하지 않게 됨에 따라 경험 등록/보기페이지에서 사용되었던 훅을 제거하고 modalStore의 메서드(open, reset)를 호출하여 사하도록 수정하였습니다.
  • 이제 실제 태그 렌더링은 최상단 modal-provider.tsx에서 전담합니다. 따라서 주로 사용되던 모달 템플릿인 modal-basic 컴포넌트 내부에 선언되어 있던 래퍼 태그는 제거하였습니다.

📸 Screenshot

🔔 ETC

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능
    • 모달 관리 시스템이 개선되어 더 안정적인 사용자 경험을 제공합니다.
    • 작업 설명 필드의 최대 입력 가능 길이가 300자에서 500자로 확대되었습니다.

odukong added 5 commits March 7, 2026 21:43
- 기존 useModal 훅 기반의 선언적 구조에서 modalStore 기반의 명령형 구조로 전환
- observer Pattern을 활용하여 컴포넌트 내부 JSX와 모달 렌더링 로직의 결합도를 분리
- 수동 닫기(close) 및 페이지 이동(reset) 시 예약된 setTimeout을 명시적으로 클린업하여 사이드 이펙트를 방지합니다.
- 전역 ModalProvider 도입에 따라 ModalBasic 내부의 <Modal> 컨테이너 태그 제거
- Modal, ModalBasic을 사용하는 관련 컴포넌트 일괄 수정
@odukong odukong linked an issue Mar 10, 2026 that may be closed by this pull request
3 tasks
@github-actions github-actions bot added 🔗API api 연동 🛠️REFACTOR 코드 리팩토링 수빈🍋 labels Mar 10, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2026

📝 Walkthrough

개요

애플리케이션 전반에서 모달 상태 관리를 로컬 상태에서 중앙 집중식 modalStore로 마이그레이션하는 아키텍처 리팩토링입니다. 새로운 ModalProvider와 ModalStore 인프라를 도입하고, 기존 모달 사용 지점들을 통합된 store 기반 패턴으로 전환합니다.

변경 사항

코호트 / 파일(s) 요약
모달 스토어 인프라
src/shared/model/store/modal.store.ts, src/shared/model/store/index.ts
모달 구독/관리 기능을 제공하는 새로운 ModalStore 클래스 도입. subscribe/unsubscribe, open/close/reset 메서드와 자동 닫기(autoPlay) 기능 구현.
ModalProvider 및 루트 레이아웃
src/app/providers/modal-provider.tsx, src/app/routes/root-layout.tsx
경로 변경 감지 시 모달 목록을 렌더링하고 리셋하는 ModalProvider 컴포넌트 추가. RootLayout에 provider 통합.
모달 저장소 기반 리팩토링
src/features/experience-detail/model/use-leave-confirm.tsx, src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx, src/features/experience-matching/ui/select-company/select-company.tsx
로컬 useModal에서 modalStore.open/close로 전환. 삭제 확인, 이탈 확인, 회사 검색 모달 등 기존 모달 인스턴스를 store 기반 방식으로 리팩토링.
페이지 및 UI 컴포넌트 업데이트
src/pages/experience-detail/experience-detail-page.tsx, src/shared/ui/modal/modal-basic.tsx
ExperienceDetailPage에서 useLeaveConfirm 반환값 사용 제거. ModalBasic에서 isOpen prop 제거 및 프래그먼트 래퍼로 변경.
경미한 업데이트
src/shared/ui/textfield/textfield.tsx
jobDescription의 최대 길이를 300에서 500으로 증가.

시퀀스 다이어그램

sequenceDiagram
    participant Component as 컴포넌트
    participant ModalStore as modalStore
    participant ModalProvider as ModalProvider
    participant UI as 렌더링

    Component->>ModalStore: open(content, autoPlay?, onClose?, id?)
    ModalStore->>ModalStore: 모달 목록에 추가<br/>타이머 시작 (autoPlay)
    ModalStore->>ModalProvider: 구독자 알림
    ModalProvider->>ModalProvider: 모달 상태 업데이트
    ModalProvider->>UI: 각 모달 렌더링
    
    Note over Component,UI: 사용자 상호작용
    
    Component->>ModalStore: close(id)
    ModalStore->>ModalStore: 타이머 취소<br/>모달 제거
    ModalStore->>ModalStore: onClose 콜백 실행
    ModalStore->>ModalProvider: 구독자 알림
    ModalProvider->>UI: 모달 제거
Loading

예상 코드 리뷰 난이도

🎯 3 (중간) | ⏱️ ~25분

제안 라벨

수빈🍋

제안 리뷰어

  • u-zzn
  • qowjdals23
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 Observer pattern을 통한 전역 모달 관리라는 이번 PR의 핵심 변경사항을 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed PR이 해결하는 #153의 모든 체크리스트 항목(전역관리 구현, 모달 코드 리팩토링, JD 글자수 500자 변경)이 완료되었으며, 변경사항들이 요구사항을 충족합니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 Observer pattern 도입을 통한 전역 모달 관리 리팩토링과 관련된 범위 내 변경이며, 불필요한 범위 초과 변경은 없습니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed PR 설명이 제안된 템플릿 구조를 충실히 따르고 있으며, Summary, Tasks, To Reviewer 섹션이 모두 작성되어 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

🚀 빌드 결과

린트 검사 완료
빌드 성공

로그 확인하기
Actions 탭에서 자세히 보기

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/shared/ui/modal/modal-basic.tsx (1)

27-41: ⚠️ Potential issue | 🟠 Major

X 버튼이 onClose 액션을 타지 않습니다.

지금 구조에서는 Modal.XButtonModalProvideronClose만 호출하고, ModalBasicProps.onClose는 취소 버튼에서만 실행됩니다. 그래서 취소 버튼과 X 버튼의 정리 로직이 달라질 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/modal/modal-basic.tsx` around lines 27 - 41, The X button
currently invokes the provider's close handler instead of the modal instance's
onClose prop, causing inconsistent cleanup; update ModalBasic (the JSX block
using Modal.XButton, Modal.Buttons, and the props from ModalBasicProps.onClose)
so that Modal.XButton receives and calls the same onClose handler passed into
the component (e.g., forward the onClose prop down to Modal.XButton or attach a
click handler that calls ModalBasicProps.onClose) ensuring both the X button and
the cancel Button call the identical onClose logic.
src/features/experience-matching/ui/select-company/select-company.tsx (1)

19-19: 🛠️ Refactor suggestion | 🟠 Major

Props 타입을 별도로 정의하는 것을 권장해요.

코딩 가이드라인에 따르면 Props 타입명은 '컴포넌트명Props' 형식을 사용해야 해요. 인라인 타입 대신 SelectCompanyProps 인터페이스로 분리하면 가독성과 재사용성이 향상돼요.

♻️ 수정 제안
+interface SelectCompanyProps {
+  onClick: () => void;
+}
+
-export const SelectCompany = ({ onClick }: { onClick: () => void }) => {
+export const SelectCompany = ({ onClick }: SelectCompanyProps) => {

As per coding guidelines: "Props 타입명이 '컴포넌트명Props' 형식인지 확인"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-matching/ui/select-company/select-company.tsx` at
line 19, The SelectCompany component currently uses an inline prop type; extract
that into a named interface SelectCompanyProps and update the component
signature to use it (e.g., function SelectCompany(props: SelectCompanyProps) or
const SelectCompany = ({ onClick }: SelectCompanyProps) => ...), defining
SelectCompanyProps with onClick: () => void to follow the "ComponentNameProps"
naming guideline and improve readability/reuse (refer to SelectCompany and
onClick in the diff).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/features/experience-detail/model/use-leave-confirm.tsx`:
- Around line 49-53: The confirmLeave callback currently only calls
blocker.proceed(); update it to also explicitly close the leave modal by calling
modalStore.close(LEAVE_MODAL_ID) (same as cancelLeave) so the modal is dismissed
immediately and the behavior is consistent; locate the confirmLeave function in
use-leave-confirm.tsx and add modalStore.close(LEAVE_MODAL_ID) before or after
blocker.proceed() to ensure the modal is closed on confirmation.
- Line 28: Move the LEAVE_MODAL_ID constant out of the hook to module scope:
declare const LEAVE_MODAL_ID = "leave-confirm-modal" above the useLeaveConfirm
hook definition in use-leave-confirm.tsx (and export it if other modules need
it), then remove the in-hook declaration so all references inside the hook
(e.g., any usages within useLeaveConfirm) point to the module-level constant to
improve readability and reusability.

In `@src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx`:
- Around line 42-55: The delete modal currently calls modalStore.reset() in
handleOpenDeleteModal which closes all global modals; change it to open the
ModalBasic with a fixed unique id (e.g. const id = 'experience-delete') passed
to modalStore.open and then replace modalStore.reset() in both onClose and
onConfirm with modalStore.close(id) so only this modal is closed (keep onConfirm
calling onClickDelete() before modalStore.close(id)). Ensure you pass the id
when opening and use the same id in onClose/onConfirm.

In `@src/features/experience-matching/ui/select-company/select-company.tsx`:
- Line 66: The modal title hardcodes the Korean object particle ("을/를") and
should adapt to whether selectedCompany.name ends with a batchim; update the
text in Modal.Title where selectedCompany.name is used to either (a) call a
small utility (e.g., a josa helper like getJosa(name, '을', '를') or
hasBatchim-based function) and render "{selectedCompany.name}{josa} 선택하셨습니다", or
(b) switch to a neutral phrasing such as "선택한 기업: {selectedCompany.name}" to
avoid particle logic; modify the component to import/implement that josa helper
and use it around selectedCompany.name in the Modal.Title or replace the string
with the neutral form.
- Around line 60-77: The function handleSearch is misnamed because it doesn't
perform a search but confirms the selected company and shows a loading modal;
rename the function (e.g., to handleSelectConfirm or handleCompanySelect) and
update all references/usages to that new name (such as any onClick or prop
handlers that currently call handleSearch) so callers invoke the new identifier;
modify the function declaration for handleSearch to the chosen name and adjust
any exports/props passed into child components to match the new name, preserving
the existing logic that checks selectedCompany, opens modalStore, calls
setCompany(selectedCompany), and invokes onClick().

In `@src/shared/model/store/modal.store.ts`:
- Around line 57-59: The reset() method currently just clears _modalList and
calls _listner, which skips per-modal cleanup (timers and onClose); change
reset() to iterate over the existing _modalList (use a shallow copy) and call
the existing close(...) code-path for each modal (e.g., invoking the store's
close(id) or the modal's onClose/clearTimeout logic) so all timers and onClose
handlers run, then set _modalList = [] and invoke _listner once; ensure you
reference the existing close(...) function and _modalList/_listner symbols so
you reuse the proper cleanup logic rather than simply emptying the array.
- Around line 23-30: The modal ID generation in open() using new
Date().toString() can collide when open() is called rapidly; update open() to
generate collision-resistant IDs (e.g., use crypto.randomUUID() or a UUID v4
library, or a monotonic counter) instead of new Date().toString(); ensure the
generated id is assigned to new_modal.id and that existing close()/filter()
logic that relies on id (and this._modalList) continues to work with the new ID
format (update imports if you choose a UUID library).
- Around line 3-8: Change the ModalItem object shape from a type alias to an
interface: replace the `type ModalItem = { ... }` declaration with `interface
ModalItem { id: string; content: ReactNode; onClose?: () => void; autoPlay?:
number; }` so it follows the project's guideline of using interface for object
shapes; update any imports/exports or references to ModalItem in modal.store.ts
or other files if necessary to ensure the symbol name remains the same and
compiles.

---

Outside diff comments:
In `@src/features/experience-matching/ui/select-company/select-company.tsx`:
- Line 19: The SelectCompany component currently uses an inline prop type;
extract that into a named interface SelectCompanyProps and update the component
signature to use it (e.g., function SelectCompany(props: SelectCompanyProps) or
const SelectCompany = ({ onClick }: SelectCompanyProps) => ...), defining
SelectCompanyProps with onClick: () => void to follow the "ComponentNameProps"
naming guideline and improve readability/reuse (refer to SelectCompany and
onClick in the diff).

In `@src/shared/ui/modal/modal-basic.tsx`:
- Around line 27-41: The X button currently invokes the provider's close handler
instead of the modal instance's onClose prop, causing inconsistent cleanup;
update ModalBasic (the JSX block using Modal.XButton, Modal.Buttons, and the
props from ModalBasicProps.onClose) so that Modal.XButton receives and calls the
same onClose handler passed into the component (e.g., forward the onClose prop
down to Modal.XButton or attach a click handler that calls
ModalBasicProps.onClose) ensuring both the X button and the cancel Button call
the identical onClose logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 071436a1-c2fd-469b-a589-ac7dab18f1f0

📥 Commits

Reviewing files that changed from the base of the PR and between cf791e2 and 5988b12.

📒 Files selected for processing (10)
  • src/app/providers/modal-provider.tsx
  • src/app/routes/root-layout.tsx
  • src/features/experience-detail/model/use-leave-confirm.tsx
  • src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx
  • src/features/experience-matching/ui/select-company/select-company.tsx
  • src/pages/experience-detail/experience-detail-page.tsx
  • src/shared/model/store/index.ts
  • src/shared/model/store/modal.store.ts
  • src/shared/ui/modal/modal-basic.tsx
  • src/shared/ui/textfield/textfield.tsx

};

export const useLeaveConfirm = () => {
const LEAVE_MODAL_ID = "leave-confirm-modal";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

상수 LEAVE_MODAL_ID를 훅 외부로 이동하는 것을 권장해요.

현재 LEAVE_MODAL_ID가 훅 내부에 정의되어 있어요. 상수이므로 매 렌더링마다 재생성되는 것은 성능에 큰 영향이 없지만, 훅 외부로 이동하면 가독성과 재사용성이 향상돼요.

♻️ 개선 제안
+const LEAVE_MODAL_ID = "leave-confirm-modal";
+
 export const useLeaveConfirm = () => {
-  const LEAVE_MODAL_ID = "leave-confirm-modal";
   const mode = useExperienceDetailStore((s) => s.mode);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const LEAVE_MODAL_ID = "leave-confirm-modal";
const LEAVE_MODAL_ID = "leave-confirm-modal";
export const useLeaveConfirm = () => {
const mode = useExperienceDetailStore((s) => s.mode);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-detail/model/use-leave-confirm.tsx` at line 28, Move
the LEAVE_MODAL_ID constant out of the hook to module scope: declare const
LEAVE_MODAL_ID = "leave-confirm-modal" above the useLeaveConfirm hook definition
in use-leave-confirm.tsx (and export it if other modules need it), then remove
the in-hook declaration so all references inside the hook (e.g., any usages
within useLeaveConfirm) point to the module-level constant to improve
readability and reusability.

Comment on lines +49 to +53
const confirmLeave = useCallback(() => {
if (blocker.state === "blocked") {
blocker.proceed();
}
}, [blocker]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

confirmLeave에서 모달을 명시적으로 닫아주세요.

cancelLeave에서는 modalStore.close(LEAVE_MODAL_ID)를 호출하지만, confirmLeave에서는 blocker.proceed()만 호출하고 모달을 닫지 않아요. 라우트 변경 시 modalStore.reset()이 호출되어 정리될 수 있지만, 명시적으로 닫아주는 것이 더 안전하고 일관성 있는 패턴이에요.

🛠️ 수정 제안
 const confirmLeave = useCallback(() => {
   if (blocker.state === "blocked") {
+    modalStore.close(LEAVE_MODAL_ID);
     blocker.proceed();
   }
 }, [blocker]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const confirmLeave = useCallback(() => {
if (blocker.state === "blocked") {
blocker.proceed();
}
}, [blocker]);
const confirmLeave = useCallback(() => {
if (blocker.state === "blocked") {
modalStore.close(LEAVE_MODAL_ID);
blocker.proceed();
}
}, [blocker]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-detail/model/use-leave-confirm.tsx` around lines 49 -
53, The confirmLeave callback currently only calls blocker.proceed(); update it
to also explicitly close the leave modal by calling
modalStore.close(LEAVE_MODAL_ID) (same as cancelLeave) so the modal is dismissed
immediately and the behavior is consistent; locate the confirmLeave function in
use-leave-confirm.tsx and add modalStore.close(LEAVE_MODAL_ID) before or after
blocker.proceed() to ensure the modal is closed on confirmation.

Comment on lines +42 to +55
const handleOpenDeleteModal = () => {
modalStore.open(
<ModalBasic
title="이 경험을 삭제하시겠습니까?"
subTitle="작성한 내용은 즉시 제거되며, 복구할 수 없습니다."
closeText="취소"
confirmText="삭제"
onClose={() => modalStore.reset()} // 취소 시 닫기
onConfirm={() => {
onClickDelete(); // 실제 삭제 동작
modalStore.reset(); // 모달 닫기
}}
/>
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

이 확인창에서 reset()을 쓰면 다른 전역 모달까지 같이 닫힙니다.

onCloseonConfirm이 모두 modalStore.reset()을 호출해서, 이 삭제 모달과 무관한 다른 모달도 함께 사라집니다. 여기서는 고정 ID를 넘기고 modalStore.close(id)로 자기 자신만 닫도록 분리하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx`
around lines 42 - 55, The delete modal currently calls modalStore.reset() in
handleOpenDeleteModal which closes all global modals; change it to open the
ModalBasic with a fixed unique id (e.g. const id = 'experience-delete') passed
to modalStore.open and then replace modalStore.reset() in both onClose and
onConfirm with modalStore.close(id) so only this modal is closed (keep onConfirm
calling onClickDelete() before modalStore.close(id)). Ensure you pass the id
when opening and use the same id in onClose/onConfirm.

Comment on lines +60 to +77
const handleSearch = () => {
if (!selectedCompany) return;
// 기업 선택 후, 대기하는 모달
modalStore.open(
<>
<Modal.Content type="auto">
<Modal.Title>{selectedCompany.name}을 선택하셨습니다</Modal.Title>
<Modal.SubTitle>기업분석 내용을 불러오는 중입니다.</Modal.SubTitle>
</Modal.Content>
<Modal.Image />
</>,
3000,
() => {
setCompany(selectedCompany);
onClick();
}
}
prevIsOpen.current = isOpen;
}, [isOpen, selectedCompany, onClick, setCompany]);
);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

함수명 handleSearch가 실제 동작과 맞지 않아요.

이 함수는 기업을 "검색"하는 것이 아니라, 선택된 기업을 확정하고 로딩 모달을 표시한 후 다음 단계로 진행하는 역할을 해요. handleSelectConfirm 또는 handleCompanySelect와 같이 의도를 명확히 표현하는 이름으로 변경하는 것을 권장해요.

♻️ 이름 변경 제안
-  const handleSearch = () => {
+  const handleSelectConfirm = () => {
     if (!selectedCompany) return;
     // 기업 선택 후, 대기하는 모달
     modalStore.open(

그리고 사용처도 함께 변경해요:

-        onSearch={handleSearch}
+        onSearch={handleSelectConfirm}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-matching/ui/select-company/select-company.tsx` around
lines 60 - 77, The function handleSearch is misnamed because it doesn't perform
a search but confirms the selected company and shows a loading modal; rename the
function (e.g., to handleSelectConfirm or handleCompanySelect) and update all
references/usages to that new name (such as any onClick or prop handlers that
currently call handleSearch) so callers invoke the new identifier; modify the
function declaration for handleSearch to the chosen name and adjust any
exports/props passed into child components to match the new name, preserving the
existing logic that checks selectedCompany, opens modalStore, calls
setCompany(selectedCompany), and invokes onClick().

modalStore.open(
<>
<Modal.Content type="auto">
<Modal.Title>{selectedCompany.name}을 선택하셨습니다</Modal.Title>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

문법 개선: 조사 처리를 고려해 주세요.

{selectedCompany.name}을 선택하셨습니다에서 기업명이 받침으로 끝나는지에 따라 "을/를" 조사가 달라져야 해요. 예를 들어 "삼성을", "네이버를"처럼요. 한국어 자연스러움을 위해 조사 처리 유틸리티를 사용하거나, "선택한 기업: {name}" 형태로 변경하는 것을 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-matching/ui/select-company/select-company.tsx` at
line 66, The modal title hardcodes the Korean object particle ("을/를") and should
adapt to whether selectedCompany.name ends with a batchim; update the text in
Modal.Title where selectedCompany.name is used to either (a) call a small
utility (e.g., a josa helper like getJosa(name, '을', '를') or hasBatchim-based
function) and render "{selectedCompany.name}{josa} 선택하셨습니다", or (b) switch to a
neutral phrasing such as "선택한 기업: {selectedCompany.name}" to avoid particle
logic; modify the component to import/implement that josa helper and use it
around selectedCompany.name in the Modal.Title or replace the string with the
neutral form.

Comment on lines +3 to +8
type ModalItem = {
id: string;
content: ReactNode;
onClose?: () => void;
autoPlay?: number;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

객체 shape는 interface로 맞춰주세요.

여기는 유니언/리터럴 타입이 아니라 데이터 shape 정의라서 interface가 팀 규칙과 더 잘 맞습니다.

As per coding guidelines, "Prefer interface for defining object shapes; use type only for union types and literal types".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/model/store/modal.store.ts` around lines 3 - 8, Change the
ModalItem object shape from a type alias to an interface: replace the `type
ModalItem = { ... }` declaration with `interface ModalItem { id: string;
content: ReactNode; onClose?: () => void; autoPlay?: number; }` so it follows
the project's guideline of using interface for object shapes; update any
imports/exports or references to ModalItem in modal.store.ts or other files if
necessary to ensure the symbol name remains the same and compiles.

Comment on lines +23 to +30
open(
content: ReactNode,
autoPlay?: number,
onClose?: () => void,
id: string = new Date().toString()
) {
const new_modal = { id: id, content: content, autoPlay, onClose };
this._modalList = [...this._modalList, new_modal];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

기본 모달 ID가 충돌할 수 있습니다.

new Date().toString()는 충돌 없는 식별자가 아니어서 짧은 시간 안에 여러 open()이 호출되면 같은 ID가 나올 수 있습니다. 그러면 close()/타이머 정리가 엉키고, filter()가 여러 모달을 한 번에 지울 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/model/store/modal.store.ts` around lines 23 - 30, The modal ID
generation in open() using new Date().toString() can collide when open() is
called rapidly; update open() to generate collision-resistant IDs (e.g., use
crypto.randomUUID() or a UUID v4 library, or a monotonic counter) instead of new
Date().toString(); ensure the generated id is assigned to new_modal.id and that
existing close()/filter() logic that relies on id (and this._modalList)
continues to work with the new ID format (update imports if you choose a UUID
library).

Comment on lines +57 to +59
reset() {
this._modalList = [];
this._listner?.(this._modalList);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

reset()이 실제 close 정리를 우회합니다.

여기서는 리스트만 비우기 때문에 예약된 타이머와 모달별 onClose가 정리되지 않습니다. 경로 이동 후에도 오래된 timeout이 다시 실행되거나, 호출자가 기대한 cleanup이 빠질 수 있어서 close() 경로를 재사용하는 쪽이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/model/store/modal.store.ts` around lines 57 - 59, The reset()
method currently just clears _modalList and calls _listner, which skips
per-modal cleanup (timers and onClose); change reset() to iterate over the
existing _modalList (use a shallow copy) and call the existing close(...)
code-path for each modal (e.g., invoking the store's close(id) or the modal's
onClose/clearTimeout logic) so all timers and onClose handlers run, then set
_modalList = [] and invoke _listner once; ensure you reference the existing
close(...) function and _modalList/_listner symbols so you reuse the proper
cleanup logic rather than simply emptying the array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

수빈🍋 🔗API api 연동 🛠️REFACTOR 코드 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Refactor] 모달 상태관리 리팩토링

1 participant