검색 기능 재사용성 높이기까지 ♻️

♪ 모두 해체해버렸다 나는 써보지도 않았는데 ♪

vue리팩토링

여러 뷰에 흩어져 있던 환자 검색 기능을 컴포넌트로 추상화했다. 그런데 막상 묶고 나니 재사용이 더 어려워졌다. 왜 그랬고, 어떻게 다시 나눴는지 정리한다.

개요

환자 검색 기능을 작업하면서 여러 불편함(코드 스멜)을 느꼈다. 마침 코드 스멜 스터디를 진행하던 시기였고, 이 기능을 개선한 내용을 동료들과 발표로 공유했다. 개선 방향에 대한 반응이 긍정적이어서 실제 실무 리팩토링으로 이어졌다.

이 글은 두 번의 리팩토링 과정을 다룬다. 1차에서 검색 기능을 컴포넌트로 추상화했지만, 그 결과물이 오히려 "재사용하기 어려운" 상태였음을 발견하고, 2차에서 역할을 분리해 다시 설계했다. 핵심은 무엇을 묶을 것인가가 아니라 무엇을 묶지 말아야 하는가였다.


1차 리팩토링: 컴포넌트 추상화

어떤 코드 스멜이 있었나

검색 기능이 간단하지 않고 복잡하며 비대했다. 이벤트 핸들러와 비동기 통신 로직이 함께 얽혀 있었고, 검색이 필요한 뷰(당시 6개)마다 이 로직을 직접 만들어 사용하고 있었다.

그 결과 변경이 생길 때마다 비용이 컸다.

  • 새로운 검색 필드를 추가할 때: 일부 뷰에만 적용되거나, 검색 기능을 쓰는 모든 뷰에 일괄 적용이 필요했다.
  • 새로운 기능에 검색을 넣을 때: 의존하는 코드들을 일일이 찾아서 추가해야 했다.
  • 검색 기능을 수정할 때: API payload 변경, 최근 검색어 추가, UI 개선 등 모든 변경이 여러 뷰에 걸친 일괄 수정으로 번졌다.

실제로 겪은 문제는 다음과 같다.

  • 차트번호 검색을 추가할 때, 기존에 검색 기능을 제공하던 모든 뷰에 일괄 적용해야 했다.
  • 검색 기능을 포함한 새로운 뷰를 만들 때, 의존 코드들을 찾아 직접 붙여야 했다. 기능이 각 뷰의 도메인 로직에 강하게 의존하고 있었기 때문이다.

💡 환자 도메인은 여러 뷰에서 재사용될 수 있고, 명확히 분리될 수 있는 영역이다. 더군다나 진료 예약, CRM 등 여러 기능이 환자 검색 결과에 관심을 두고 있었다. "개선되면 좋겠다"는 동료들의 의견에 힘입어 실무 적용을 결정했다.

개선 방향: 컴포넌트로 추상화하기

흩어진 검색 로직을 PatientSearchInputGroup 컴포넌트로 묶었다. 이름·전화번호·차트번호 입력 필드와 검색 결과 드롭다운, 관련 핸들러를 하나의 컴포넌트 안에 캡슐화했다.

<!-- PatientSearchInputGroup.vue (핵심만 발췌) -->
<template>
  <div>
    <MDropDownHpInput
      ref="nameInput"
      v-model="name"
      :type="'text'"
      :options="patientListByKeyword"
      :validate-rule="nameValidationRule"
      isChartNumVisible
    />
    <MDropDownHpInput
      ref="hpNoInput"
      v-model="hpNo"
      :type="'tel'"
      :options="patientListByKeyword"
      :limit-cnt="13"
      :validate-rule="hpValidationRule"
      isChartNumVisible
    />
    <MDropDownHpInput
      ref="chartNoInput"
      v-model="chartNum"
      :type="'text'"
      :options="patientListByKeyword"
      :limit-cnt="8"
      :validate-rule="chartNumberValidationRule"
      isChartNumMode
      isChartNumVisible
    />
  </div>
</template>

<script setup lang="ts">
const props = withDefaults(
  defineProps<{ name: string; hpNo: string; chartNum: string }>(),
  { name: '', hpNo: '', chartNum: '' }
)

const emit = defineEmits<{
  'update:name': [value: string]
  'update:hpNo': [value: string]
  'update:chartNum': [value: string]
  select: [value: IHospitalPatientDetail]
}>()
</script>

사용하는 쪽은 v-model로 필드를 바인딩하고 @select로 선택 결과만 받으면 된다.

<template>
  <PatientSearchInputGroup
    v-model:name="name"
    v-model:hpNo="hpNo"
    v-model:chartNum="chartNum"
    @select="(patient) => { consult.patient = patient }"
  />
</template>

각 뷰가 검색 내부 동작을 몰라도 되도록 캡슐화한 점은 개선이었다. 하지만 발표와 리뷰 과정에서 이 추상화의 한계가 드러났다. is-it-going-well


2차 리팩토링: 역할 줄이기

1차 결과물의 코드 스멜

가장 핵심적인 피드백은 다음과 같았다.

재사용 의미는 있으나, 재사용 가능한 범위가 한정적이다.

문제를 정리하면 이렇다.

  • name, hpNo, chartNum 세 필드를 한 덩어리로 필요로 하는 곳에서만 쓸 수 있었다. 일부 필드만 필요한 뷰는 이 컴포넌트를 그대로 쓸 수 없었다.
  • 다양한 경우를 옵션(prop)으로 받아 처리하려 하면, 옵션이 늘어날수록 기능 수정에 오히려 유연하지 못해졌다.
  • 공통된 정책임이 분명한데도 뷰마다 관리 로직이 달라, 사용자에게 혼란을 줄 수 있었다.
  • 필드 커스텀을 지원하지 못했다. 필드의 순서나 title 표시 여부가 뷰마다 달랐다.
  • 드롭다운 옵션 UI가 공통 컴포넌트 안에서 boolean prop으로 분기되고 있어 커스텀이 어려웠다.

마지막 문제는 컴포넌트 내부에 그대로 드러나 있었다. 드롭다운 옵션의 표시 형태를 isChartNumberVisible이라는 prop으로 v-if 분기하고 있었다.

<!-- 개선 전: 옵션 UI가 컴포넌트 내부에 하드코딩됨 -->
<template v-if="isChartNumberVisible">
  <!-- 이름 + 차트번호 + 전화번호 -->
</template>
<template v-else>
  <!-- 이름 + 전화번호 -->
</template>

새로운 표시 형태가 필요할 때마다 v-else-if가 늘어나는 구조였다. 한 곳을 고치면 다른 뷰의 드롭다운에 영향을 주어 버그가 날 수 있었다.

결국 1차 결과물은 UI 구성과 이벤트(비즈니스) 처리라는 두 가지 역할을 한 컴포넌트에 함께 추상화하고 있었다. 충분히 정리되지 않은 채 묶어버린 성급한 추상화였다. stressedcat

개선 방향: 역할을 둘로 나누기

추상화의 단위를 "컴포넌트 하나"에서 "역할 단위"로 바꿨다. 재사용해야 하는 검색 동작과, 자유로워야 하는 UI 표현을 분리했다.

1. composable — 비즈니스 로직 분리

검색 키워드 처리, 비동기 통신, 결과·로딩 상태 관리를 usePatientSearch composable로 옮겼다. UI에서 독립되니 어떤 필드 구성이든, 어떤 뷰든 동일한 검색 동작을 재사용할 수 있게 됐다.

// usePatientSearch.ts — 노출 인터페이스(발췌)
export function usePatientSearch(patient?: IPatient) {
  const patientListByKeyword = ref<IHospitalPatientSearchResponse[]>([])
  const isLoading = ref({ name: false, hpNo: false, chartNum: false })

  // 검색 타입별 최소 글자 수
  const NAME_SEARCH_INDEX = 2
  const HP_SEARCH_INDEX = 8
  const CHART_NUM_SEARCH_INDEX = 1

  // 같은 키워드는 다시 호출하지 않도록 결과를 캐싱
  const keywordMap = ref(new Map())
  async function loadPatientList(type: SEARCH_TYPE, keyword: string) {
    if (!keywordMap.value.has(keyword)) {
      patientListByKeyword.value =
        await $api.hospitalPatientService.searchByTypeAndKeyword({ /* ... */ })
      keywordMap.value.set(keyword, patientListByKeyword.value)
    } else {
      patientListByKeyword.value = keywordMap.value.get(keyword)
    }
  }

  return {
    // 입력 핸들러: debounce + 최소 글자 수 가드를 입혀 노출
    nameInputKeyup: debouncedHelper(nameInputKeyup, 'name'),
    hpNoInputKeyup: debouncedHelper(hpNoInputKeyup, 'hpNo'),
    chartNumInputKeyup: debouncedHelper(chartNumInputKeyup, 'chartNum'),
    patientListByKeyword,
    isLoading,
    isPatient,
    isMember,
    updatePatientDetail,
    setPatientMetadata,
    reset,
  }
}

검색 정책(최소 글자 수, debounce 시간, 동일 키워드 캐싱)이 한곳에 모이니, 뷰마다 달랐던 동작이 자연스럽게 통일됐다. "공통 정책인데 뷰마다 관리 로직이 달랐다"는 문제가 여기서 해소됐다.

2. slot — UI 커스텀 위임

드롭다운 옵션의 표시 형태를 boolean 분기 대신 slot으로 열었다. 기본 표시는 컴포넌트가 제공하되, 다르게 보여주고 싶은 뷰는 slot으로 덮어쓴다.

<!-- 개선 후: MDropDownHpInput 내부 — slot + 기본값 -->
<div class="info-wrap">
  <slot name="item" :item="item">
    <!-- slot을 안 넘기면 이 기본 UI 사용 -->
    <p class="send-name" v-html="item.nameHtml" />
    <p class="number" v-html="item.hpNoHtml" />
  </slot>
</div>

이제 뷰는 자신에게 맞는 드롭다운 항목을 직접 구성한다. 아래는 이름·생년월일·차트번호·전화번호를 함께 보여주는 뷰의 예시다.

<!-- 소비하는 뷰 — composable과 slot을 함께 사용 -->
<template>
  <MDropDownHpInput
    v-model="inputParams.name"
    :options="patientListByKeyword"
    :search-index="NAME_SEARCH_INDEX"
    :loading="isLoading['name']"
    @input="nameInputKeyup(inputParams.name)"
    @selectEnd="clickPatient"
  >
    <template #item="{ item }">
      <div class="left">
        <p class="name" v-html="item.nameHtml" />
        <p class="birth">{{ item.birth }}</p>
        <p class="chart-number" v-html="item.chartNumHtml" />
      </div>
      <p class="number" v-html="item.hpNoHtml" />
    </template>
  </MDropDownHpInput>
</template>

<script setup lang="ts">
const {
  nameInputKeyup,
  patientListByKeyword,
  isLoading,
  NAME_SEARCH_INDEX,
  // ...
} = usePatientSearch()
</script>

핵심은 재사용해야 하는 것(검색 동작)은 composable로 모으고, 자유로워야 하는 것(옵션 UI)은 slot으로 위임한 것이다. 1차에서 한 컴포넌트가 떠안고 있던 두 역할을 분리하자, 옵션이 늘어나는 대신 각 뷰가 필요한 만큼만 조합하게 됐다.

참고: 기존 isChartNumberVisible prop은 slot 전환 이후 "추후 제거 예정"으로 남겨, 사용처를 점진적으로 옮길 수 있게 했다.


결과

  • 드롭다운 옵션 UI를 뷰별로 독립 구성 (slot) — boolean 분기 누적 제거
  • 검색 동작·정책을 usePatientSearch 한곳으로 모아 응집도 향상
  • 착수 시점 6개 뷰에 분산돼 있던 검색 기능을, 이후 사용처가 9개로 늘어날 때까지 공통 기능으로 빠르게 확장 적용 muyaho

배운 점

이번 리팩토링에서 가장 크게 남은 건, 컴포넌트 추상화가 항상 정답은 아니라는 점이다. 무엇을 묶느냐보다 무엇을 묶지 말아야 하느냐를 먼저 따져야 했다. 재사용해야 하는 로직과 뷰마다 달라야 하는 UI를 한 단위로 묶으면, 추상화가 오히려 재사용을 가로막는다. 1차에서 그 둘을 한 컴포넌트에 욱여넣었기에, 2차에서 검색 동작은 composable로 모으고 옵션 UI는 slot으로 위임하고 나서야 비로소 재사용이 풀렸다.

그리고 이 한계를 혼자 발견한 게 아니라는 점도 중요했다. 비즈니스 임팩트가 있는 지점을 추려 동료들과 이야기를 나눴고, 1차 추상화의 한계 역시 리뷰 과정에서 드러났다. 동료들로부터 코드를 새로운 시각으로 바라보는 법을 배웠다. 결국 리팩토링은 한 번에 끝나는 작업이 아니라, 문제를 다시 보고 풀어가는 과정을 반복하며 달성된다는 것을 체감했다.