여러 뷰에 흩어져 있던 환자 검색 기능을 컴포넌트로 추상화했다. 그런데 막상 묶고 나니 재사용이 더 어려워졌다. 왜 그랬고, 어떻게 다시 나눴는지 정리한다.
개요
환자 검색 기능을 작업하면서 여러 불편함(코드 스멜)을 느꼈다. 마침 코드 스멜 스터디를 진행하던 시기였고, 이 기능을 개선한 내용을 동료들과 발표로 공유했다. 개선 방향에 대한 반응이 긍정적이어서 실제 실무 리팩토링으로 이어졌다.
이 글은 두 번의 리팩토링 과정을 다룬다. 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>
각 뷰가 검색 내부 동작을 몰라도 되도록 캡슐화한 점은 개선이었다. 하지만 발표와 리뷰 과정에서 이 추상화의 한계가 드러났다. 
2차 리팩토링: 역할 줄이기
1차 결과물의 코드 스멜
가장 핵심적인 피드백은 다음과 같았다.
재사용 의미는 있으나, 재사용 가능한 범위가 한정적이다.
문제를 정리하면 이렇다.
name,hpNo,chartNum세 필드를 한 덩어리로 필요로 하는 곳에서만 쓸 수 있었다. 일부 필드만 필요한 뷰는 이 컴포넌트를 그대로 쓸 수 없었다.- 다양한 경우를 옵션(prop)으로 받아 처리하려 하면, 옵션이 늘어날수록 기능 수정에 오히려 유연하지 못해졌다.
- 공통된 정책임이 분명한데도 뷰마다 관리 로직이 달라, 사용자에게 혼란을 줄 수 있었다.
- 필드 커스텀을 지원하지 못했다. 필드의 순서나
title표시 여부가 뷰마다 달랐다. - 드롭다운 옵션 UI가 공통 컴포넌트 안에서
booleanprop으로 분기되고 있어 커스텀이 어려웠다.
마지막 문제는 컴포넌트 내부에 그대로 드러나 있었다. 드롭다운 옵션의 표시 형태를 isChartNumberVisible이라는 prop으로 v-if 분기하고 있었다.
<!-- 개선 전: 옵션 UI가 컴포넌트 내부에 하드코딩됨 -->
<template v-if="isChartNumberVisible">
<!-- 이름 + 차트번호 + 전화번호 -->
</template>
<template v-else>
<!-- 이름 + 전화번호 -->
</template>
새로운 표시 형태가 필요할 때마다 v-else-if가 늘어나는 구조였다. 한 곳을 고치면 다른 뷰의 드롭다운에 영향을 주어 버그가 날 수 있었다.
결국 1차 결과물은 UI 구성과 이벤트(비즈니스) 처리라는 두 가지 역할을 한 컴포넌트에 함께 추상화하고 있었다. 충분히 정리되지 않은 채 묶어버린 성급한 추상화였다. 
개선 방향: 역할을 둘로 나누기
추상화의 단위를 "컴포넌트 하나"에서 "역할 단위"로 바꿨다. 재사용해야 하는 검색 동작과, 자유로워야 하는 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차에서 한 컴포넌트가 떠안고 있던 두 역할을 분리하자, 옵션이 늘어나는 대신 각 뷰가 필요한 만큼만 조합하게 됐다.
참고: 기존
isChartNumberVisibleprop은 slot 전환 이후 "추후 제거 예정"으로 남겨, 사용처를 점진적으로 옮길 수 있게 했다.
결과
- 드롭다운 옵션 UI를 뷰별로 독립 구성 (slot) —
boolean분기 누적 제거 - 검색 동작·정책을
usePatientSearch한곳으로 모아 응집도 향상 - 착수 시점 6개 뷰에 분산돼 있던 검색 기능을, 이후 사용처가 9개로 늘어날 때까지 공통 기능으로 빠르게 확장 적용

배운 점
이번 리팩토링에서 가장 크게 남은 건, 컴포넌트 추상화가 항상 정답은 아니라는 점이다. 무엇을 묶느냐보다 무엇을 묶지 말아야 하느냐를 먼저 따져야 했다. 재사용해야 하는 로직과 뷰마다 달라야 하는 UI를 한 단위로 묶으면, 추상화가 오히려 재사용을 가로막는다. 1차에서 그 둘을 한 컴포넌트에 욱여넣었기에, 2차에서 검색 동작은 composable로 모으고 옵션 UI는 slot으로 위임하고 나서야 비로소 재사용이 풀렸다.
그리고 이 한계를 혼자 발견한 게 아니라는 점도 중요했다. 비즈니스 임팩트가 있는 지점을 추려 동료들과 이야기를 나눴고, 1차 추상화의 한계 역시 리뷰 과정에서 드러났다. 동료들로부터 코드를 새로운 시각으로 바라보는 법을 배웠다. 결국 리팩토링은 한 번에 끝나는 작업이 아니라, 문제를 다시 보고 풀어가는 과정을 반복하며 달성된다는 것을 체감했다.