fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2025년 4월 27일

조건부 subscription React 패턴

Subscription 헤드리스 컴포넌트의 장점

#React#GraphQL

TL;DR

하나의 이벤트, 서로 다른 도메인에서의 요구사항을 풀어나가기 위해 최선의 패턴을 고민해본 이야기 GraphQL Subscription 을 Headless Component 로 화면별 대응을 위임

개요

EMR에서 '환자'는 거의 모든 화면의 공통 기반 데이터다. 차트, 대기리스트, 예약 모달, 진료기록 — 어디서든 환자 정보를 기반으로 동작한다.

그런데 만약 환자가 삭제되면? 그 환자를 보고 있던 모든 화면이 동시에 영향을 받는다. 문제는, 화면마다 해야 할 행동이 전부 다르다는 것이었다.

이 글은 '환자 삭제'라는 하나의 이벤트에 대해, 개별적이고 서로 다른 요구사항을 Headless Component 구조로 풀어낸 경험을 정리한다.

환자 관련 Subscription이 없다

환자는 정말 많은 화면에서 기본이 된다.

대기리스트에서, 차트를 열때, 예약 현황을 볼때, 진료기록과 접수메모를 수정할때도 모두 환자 정보를 토대로 쌓인다.

중요한 데이터인 만큼 환자정보가 자주 삭제되진 않겠지만, 만약 삭제된 환자를 계속 보고 있는 탭에서는 모든 뮤테이션과 refetch 되는 쿼리가 에러가 난다.

서비스가 크지 않아서 이때까지 발견을 못한건지, 아니면 후순위였는지 모르겠지만 내가 보기엔 꽤나 치명적인 문제였다.

그래서 최대한 빠른 일정을 잡아서 개선해보자고 백엔드 개발을 요청드렸고, 스키마가 나왔을때 바로 FE 개발에 들어갔다.

환자 상태 변경의 액션은 크게 세가지다.

  1. 환자 정보 수정
  2. 임시환자(PrePatientModel) → 환자(PatientModel) 로 전환
  3. 환자 삭제

문제의 핵심은 '환자가 삭제되었다' 라는 하나의 이벤트를 받았을 때 화면마다 해야 하는 행동이 전부 다르다는 것이었다.

  • 차트를 보고 있다면 → 차트를 닫고, 대기리스트(혹은 초기 접수 페이지)로 이동시킨다.
  • 예약 모달을 열고 있다면 → 모달을 닫아야 한다.
  • 대기리스트에 있다면 → 목록에서 해당 환자가 사라져야 한다.
  • 진료기록/접수메모를 수정 중이라면 → 해당 데이터를 refetch하거나 페이지를 벗어나야 한다.

즉, 개별적이고 서로 다른 요구사항이 하나의 이벤트에 매달려 있었다. 이벤트를 수신하는 구조는 공유하되, 그에 대한 반응은 각 도메인에 위임해야 했다.

설계: 서로 다른 요구사항은 props 콜백에서

환자 관련 Subscription을 어디에 어떻게 걸 것인지가 핵심이었다.

만약 특정 컨테이너(예: ChartContainer)에

useSubscription을 직접 호출하면 어떻게 될까?

typescript
const ChartContainer = ({ patientId }) => {
  useSubscription(PATIENT_CHANGED, {
    variables: { patientId },
    onData: ({ data }) => {
      // 환자 삭제 시 처리 로직...
    },
  });
  return <ChartView />;
};

문제는

  1. 환자를 보고 있는 화면이 한 곳이 아니다. 차트, 대기리스트, 플래너 예약 모달, 사이드바... 전부 각각 구독 로직을 복붙해야 한다.
  2. 삭제 시 대응 방식이 화면마다 다르다. 차트에서는 차트를 닫아야 하고, 대기리스트에서는 목록에서 빼야 하고, 예약 모달에서는 모달을 닫아야 한다.
  3. 환자 ID가 없을 때도 무조건 구독이 살아있다. 빈 화면에서도 쓸데없이 WebSocket 연결을 유지하게 된다.

이 세 가지를 동시에 해결하기 위해 Headless Component 패턴으로 Subscription을 관리하기로 했다.

Headless Component: UI(JSX)는 렌더링하지 않고(return null), 로직만 수행하는 컴포넌트. React의 Mount/Unmount 생명주기를 활용하면서도, 시각적인 렌더링 비용은 없는 컴포넌트를 말한다.

계층 구조

3개의 레이어로 관심사를 분리했다.

typescript
[Layer 1] useSubscriptionDeletePatient.ts
   └─ GraphQL Subscription 정의 및 호출 (인프라)
[Layer 2] use-change-patient-on-subs.ts
   └─ "환자가 삭제되면 어떻게 대응할지" 비즈니스 로직
[Layer 3] PatientDeleteSubscription.tsx  ⭐ Headless Component
   └─ return null (UI 없음)
   └─ 부모가 조건부 렌더링으로 생명주기 제어

구현

Layer 1: Infra — Subscription Hook

가장 아래 계층. GraphQL Subscription을 Apollo Client의

useSubscription 으로 연결하는 역할만 수행한다.

typescript
// use-subscription-delete-patient.ts

import { gql, useSubscription } from '@apollo/client';
import { v4 as uuidv4 } from 'uuid';

export const clientId_patient_info = uuidv4();

export const useSubscriptionDeletePatient = (patientId: string) => {
  const accessToken = localStorage.getItem('ACCESS_TOKEN_KEY');
  const selectedHospital = sessionStorage.getItem('SELECTED_HOSPITAL_ID');

  const { data } = useSubscription(PatientChanged_Patient_InfoDocument, {
    variables: {
      hospitalId: selectedHospital,
      accessToken: accessToken,
      clientId: clientId_patient_info,
      patientId,
    },
    skip: !patientId,
  });

  return { data };
};

WebSocket 특성상, "내가 환자를 삭제한 행위" 자체도 Subscription으로 돌아온다. 그러면 삭제를 실행한 본인 화면에서도 "환자가 삭제되었어요" 토스트가 뜨는 이상한 UX가 된다. 이를 방지하기 위해 각 클라이언트 인스턴스에 고유 uuid를 할당하고, 서버에서 보낸

clientId 와 비교하여 자기 자신의 이벤트는 무시하도록 처리한다.

Layer 2: Business Logic — 이벤트 대응

Subscription으로 들어온 데이터를 받아서 **"환자가 삭제되었을 때 무엇을 할 것인가"**를 결정하는 계층이다.

typescript
// use-change-patient-on-subs.ts

interface UseChangePatientOnSubsProps {
  patientId: string;
  /** 각 도메인에서 삭제 시 실행할 커스텀 콜백 */
  onSubsCallback?: () => void;
}

export const useChangePatientOnSubs = ({
  patientId,
  onSubsCallback,
}: UseChangePatientOnSubsProps) => {
  const { data } = useSubscriptionDeletePatient();
  const { data: updated } = todolistChangeSubscription(patientId);
  const [searchParams, setSearchParams] = useSearchParams();

  useEffect(() => {
    if (!updated) return;

    // 콜백이 없으면 기본 토스트 표시
    if (!onSubsCallback) {
      toastbarService.errorMsg('환자정보가 삭제되었어요.');
    }

    // URL에서 환자 파라미터 정리
    const params = new URLSearchParams(searchParams);
    if (params.has('p')) {
      params.delete('p');
      setSearchParams(params, { replace: true });
      onSubsCallback?.();
      return;
    }

    onSubsCallback?.();
  }, [updated, onSubsCallback]);
	};

핵심은 ‘자유로운 핸들링’ 이다.

‘환자가 삭제됐다’는 이벤트는 하나지만, 그때 해야 할 일은 화면마다 다르다. 차트에서는 차트를 닫아야 하고, 플래너에서는 예약 모달을 닫아야 한다. 이 각 도메인마다 다른 대응을 콜백으로 위임함으로써 이 훅 자체는 재사용 가능한 상태를 유지할 수 있었다.

Layer 3: Headless Component — 조건부 렌더링의 핵심

이 컴포넌트가 이번 설계의 핵심이다. 코드는 고작 6줄이지만 역할은 명확하다.

typescript
// patient-delete-subscription.tsx

import { useChangePatientOnSubs } from './use-change-patient-on-subs';

interface PatientDeleteSubscriptionProps {
  patientId: string;
  onSubsCallback?: () => void;
}

export function PatientDeleteSubscription({
  patientId,
  onSubsCallback,
}: PatientDeleteSubscriptionProps) {
  useChangePatientOnSubs({ patientId, onSubsCallback });

  return null; // ✨ UI 없음. 로직만 수행.
}

이 컴포넌트의 존재 자체가 구독 중 이라는 뜻이고, 사라지면 구독 해제 라는 뜻이다. React의 Mount/Unmount 생명주기를 그대로 빌려 쓰는 아주 직관적인 패턴이다.

사용: 각 도메인에서의 조건부 렌더링

이제 각 화면에서는 "환자 ID가 존재할 때만" 이 컴포넌트를 마운트하면 된다.

typescript
// 차트 블록
const ChartBlockContent = ({ patientId }) => {
  return (
    <>
      <ChartView patientId={patientId} />
      
      {/* 환자를 보고 있을 때만 구독 시작 */}
      {patientId && (
        <PatientDeleteSubscription
          patientId={patientId}
          onSubsCallback={() => closeChart()} // 차트 닫기
        />
      )}
    </>
  );
};
typescript
// 플래너 예약 모달
const FormPatientInfo = ({ patientId }) => {
  return (
    <>
      <PatientInfoForm patientId={patientId} />

      {patientId && (
        <PatientDeleteSubscription
          patientId={patientId}
          onSubsCallback={() => closeModal()} // 모달 닫기
        />
      )}
    </>
  );
};

끝맺음

돌이켜보면, 이번 작업의 본질은 "Subscription을 걸자"가 아니었다.

환자 삭제라는 동일한 이벤트에 대해 차트는 닫기, 모달은 닫기, 대기리스트는 목록 갱신… 개별적이고 서로 다른 요구사항을 하나의 구조로 풀어내는 것이 진짜 과제였다.

Headless Component의 onSubsCallback 하나로 도메인별 반응을 위임함으로써, 구독 로직은 재사용하고 대응은 분리하는 구조를 만들 수 있었다.

사실 이상적인 구조는 앱 최상위에 단일 이벤트 스트림(EventBus)을 두고 각 컴포넌트가 필요한 이벤트만 골라서 refetch하는 방식이라고 한다.

다만 기존 코드베이스와의 정합성, 프로젝트 컨벤션, 당장의 일정을 고려했을 때 구조를 뜯어고칠 순 없었고, Headless Component 패턴이 가장 현실적인 선택이었다.

중요한 건 'WebSocket 연결도 비용이다'라는 인식이다.

무조건 연결하고 보는 게 아니라 누가, 언제, 얼마나 구독할 것인가를 설계하는 것.

그리고 '하나의 이벤트에 대해 서로 다른 화면이 각자 다르게 반응해야 할 때, 로직의 공유와 분리를 어디서 끊을 것인가'를 고민하는 것.

이것이 실시간 기능을 다루는 프론트엔드 개발자의 핵심 역량이 아닐까.

  • TL;DR
  • 개요
  • 환자 관련 Subscription이 없다
  • 설계: 서로 다른 요구사항은 props 콜백에서
  • 계층 구조
  • 구현
  • 사용: 각 도메인에서의 조건부 렌더링
  • 끝맺음