함수형 프로그래밍으로 검색 기능 만들기

김상민·2025년 8월 19일

사이드프로젝트

목록 보기
1/1
post-thumbnail

검색 기능을 구현하면서 점진적으로 함수형 프로그래밍 스타일로 개선해나간 과정을 기록해보려고 한다.
코드가 적힌 순서대로 실행되어 로직이 잘 읽히도록 노력했다.

최초 코드

처음엔 그냥 기본적인 자바스크립트 방식으로 검색을 구현했다. 띄어쓰기를 고려해서 replace로 공백을 제거하는 방식을 썼다.

// 함수형 프로그래밍을 위해 FxTS 라이브러리를 사용했다.
import { filter, pipe, toArray, isUndefined, unless } from '@fxts/core' 

...

  // 기존 카테고리 필터 로직에 검색 로직을 추가했다.
  const search = Route.useSearch()

  const { data: policies } = useSuspenseQuery({
    ...getPoliciesQueryOptions(),
    select: ({ data: policies }) => {
      pipe(
        policies,
        unless(
          () => isUndefined(search.category),
          filter((policy) => policy.category === search.category!)
        ),
        // 여기 부터 개선하게 될 코드
        unless(
          () => isUndefined(search.keyword),
          filter(
            (policy) =>
              policy.title
                .toLowerCase()
                .replace(/\s+/g, '')
                .includes(search.keyword!.toLowerCase().replace(/\s+/g, '')) ||
              policy.description
                .toLowerCase()
                .replace(/\s+/g, '')
                .includes(search.keyword!.toLowerCase().replace(/\s+/g, ''))
          )
        ),
        toArray
      )
    },
  })

동작은 하지만 코드가 잘 읽히지 않고 저 || 비교연산자를 기준으로 딱봐도 같은 코드가 반복되고 있다. 이 두가지 부분을 어떻게든 개선해 보고 싶었다.

Step 1: 공통 로직 분리

가장 먼저 텍스트 정규화 로직을 함수로 분리했다. 일단 중복을 제거해보려는 시도였다.

// 텍스트 정규화 함수 분리
const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, '')

filter((policy) => {
  const normalizedKeyword = normalizeText(search.keyword!)
  return (
    normalizeText(policy.title).includes(normalizedKeyword) ||
    normalizeText(policy.description).includes(normalizedKeyword)
  )
})

이제 normalizeText라는 함수명으로 의도가 명확해졌고, 키워드도 한 번만 정규화 하니까 조금 나아보였다.
그런데 return 이후의 조금 극단적이지만 코드를 순서 그대로 읽어보면 이렇게 된다.

정규화 하는 함수에 title을 넘기고 그 결과에 정규화된 키워드가 포함되거나 정규화 하는 함수에 description을 넘기고 그 결과에 정규화된 키워드가 포함되는 policy를 필터링한다.

Step 2: 체이닝 방식으로 복귀

Step 1에서의 함수 분리는 좋았지만, 읽기가 어려웠고 여전히 || 연산자를 기준으로 같은 코드가 반복된다. 차라리 체이닝 방식이 읽는 순서대로 실행되기 때문에 더 낫다고 생각했다.

filter(
  (policy) =>
    policy.title
      .toLowerCase()
      .replace(/\s+/g, '')
      .includes(search.keyword!.toLowerCase().replace(/\s+/g, '')) ||
    policy.description
      .toLowerCase()
      .replace(/\s+/g, '')
      .includes(search.keyword!.toLowerCase().replace(/\s+/g, ''))
)

Step 3: pipe로 공통로직 순서대로 실행하기

텍스트 정규화 로직을 함수화 한건 좋은 시도였고, 문제는 함수의 실행순서라고 생각했다.
그래서 반복되고 있던 search.keyword 부터 일단 pipe의 첫 번째 인자로 넣어보기로 했다.

filter((policy) =>
  pipe(
    search.keyword!,
    normalizeText,
    (normalizedKeyword) =>
      some((field) => normalizeText(field).includes(normalizedKeyword), [
        policy.title,
        policy.description,
      ])
  )
)

이제 키워드는 한 번만 정규화되고, 로직의 흐름도 더 명확해졌다. 키워드를 정규화한 다음, 그 결과를 가지고 필드들과 비교하는 구조가 되었다.

Step 4: 필드 배열을 기준으로 pipe 실행

그런데 some 함수를 보다보니 괜히 함수로 감싸야하고, 비교 대상 필드 배열도 두번째 인자로 위치시켜야했다. 다시 생각해보니 비교 대상 필드 배열을 pipe의 첫번째 인자로 하는게 자연스러울 것 같았다.

filter((policy) =>
  pipe(
    [policy.title, policy.description],
    map(normalizeText),
    some((field) => field.includes(normalizeText(search.keyword!)))
  )
)

이제 로직이 정말 읽는 순서대로 진행된다
1. 비교 대상 필드들을 배열로 만들어
2. 모든 배열 원소를 정규화하고
3. 정규화된 키워드가 포함된 필드가 있는지 확인한다

하지만 여전히 some안의 콜백 함수 로직이 눈에 거슬렸다. 간단하니까 읽어지는 것 같지만 함수가 중첩되어 순서에 맞지 않다.

Step 5: 한번 더 pipe 실행하기

이쯤 되니 함수 실행 순서가 반대일 때는 pipe를 사용하면 되는구나라는 생각이 저절로 들었다.
some 안의 콜백도 pipe를 이용해 순서대로 읽게 했다.

filter((policy) =>
  pipe(
    [policy.title, policy.description],
    map(normalizeText),
    some((field) => pipe(
      search.keyword!, 
      normalizeText, 
      (keyword) => field.includes(keyword)
    ))
  )
)

이제 모든 부분이 순서대로 실행되어 데이터가 어떻게 변화되는지 잘 읽을 수 있게 되었다. 키워드를 2번 정규화하게 되지만, 성능에 영향을 미치지는 않을거라 가독성을 높이는게 더 좋다고 생각했다.

최종 결과

const { data: policies } = useSuspenseQuery({
  ...getPoliciesQueryOptions(),
  select: ({ data: policies }) =>
    pipe(
      policies,
      unless(
        () => isUndefined(search.category),
        filter((policy) => policy.category === search.category!)
      ),
      unless(
        () => isUndefined(search.keyword),
        filter((policy) =>
          pipe(
            [policy.title, policy.description],
            map(normalizeText),
            some((field) => pipe(
              search.keyword!, 
              normalizeText, 
              (keyword) => field.includes(keyword)
            )
           )
          )
        )
      ),
      toArray
    ),
})

마지막으로..

 some((field) => pipe(
   search.keyword!, 
   normalizeText, 
   field.includes // 화살표 함수가 아닌 메서드를 pipe에 넘기면?
 )

마지막 화살표 함수를 쓴 부분을 꼭 저렇게 써야하나 라는 생각이 들어 메서드를 넘겨보았다.
하지만 동작을 하지 않았는데, 이유는 this 바인딩 때문이었다.
공부할 때 정말 많이 봤던 내용이라 알고 있다고 생각했는데 전혀 아니었다.
this 바인딩에 대해서는 다시 잘 정리해서 다음 글로 써야겠다.

느낀점

  • 함수형으로 로직을 작성하면 코드를 읽는 순서와 실행 순서를 일치시킬 수 있다.
  • 새로운 비교 대상 필드가 생겨도 배열에 넣으면 되니 확장성도 좋다.
  • 카테고리 필터링에 검색 필터링을 쉽게 추가한 것도 pipe의 힘이라고 생각한다.
  • 의도적으로라도 pipemap, filter, some 같은 함수들로 표현하다보니 다양한 로직들을 함수형으로 작성할 수 있겠다는 자신감이 생겼다.

오늘 작성한 코드들은 모두 FxTS라는 라이브러리를 사용했다. 오늘 내가 사용한 함수들 외에도 아주 많은 API를 제공하는데, 함수형 프로그래밍에 대한 두려움의 장벽을 많이 허물어 준 라이브러리다. 사용법이 정말 간단하면서 타입추론도 잘 되고, 일관성 있게 코드를 작성할 수 있게 도와준다.
유인동님의 도서 멀티패러다임 프로그래밍의 리뷰어로 참여하면서 함수형 프로그래밍과 FxTS에 대해 알게 되었는데 돌아보니 정말 감사한 경험이었다.

profile
성장하는 웹 프론트엔드 개발자 입니다.

0개의 댓글