단일 책임 원칙
프로그래밍에서 중요한 원칙 중 SOLID 원칙이 있다. 다섯 가지 원칙의 앞글자를 딴 것인데, 이 중에서 가장 첫 번째는 단일 책임 원칙(Single Responsibility Principle)이다. 즉, 하나의 모듈은 하나의 역할만 담당해야 하며 담당한 역할 외의 다른 이유로 수정되어서는 안 된다는 것이다. 가장 중요한 원칙으로 여겨지는 데는 이유가 있을 것이고 또 그만큼 납득되는 내용이기도 하다. 모듈이 많은 역할을 한 번에 담당하거나 유지 보수 과정에서 담당하는 역할이 늘어나게 된다면 코드가 복잡해지고 히스토리 관리도 어려우며 유지 보수 비용은 계속 증가할 테니 말이다.
그러나 이는 어디까지나 원칙일 뿐 정답을 제시하지 않는다. 실제 코드를 작성하고 관리하는 입장으로서 실무자가 모든 것을 선택해야 한다. 간단한 input과 output 인터페이스를 가진 유틸성 함수라면 고민거리가 없겠지만, 비즈니스 로직과 결합된다거나 복잡한 의존성 안에서 동작하는 모듈을 설계해야 하는 경우에는 어떨까? 모든 로직을 분리해야 한다는 관점으로 접근하면 문제가 해결될까? 하나의 역할이라는 것은 어떤 기준으로 정의할 수 있을까? 개인적으로 이에 대한 고민 없이 단일 책임 원칙을 적용했다가 오히려 코드가 복잡해지는 경험을 했기에, 이에 대해 정리해 보려고 한다.
세 개의 분리된 검색 조건
상품을 탐색하기 위한 가장 중요한 단계는 검색이다. 검색 조건을 여러 가지 입력해서 이에 해당하는 상품의 목록을 표시하는 것은 커머스 서비스의 일반적인 UX 플로우 중 하나이다. 검색 조건의 성격이 여러 가지인 점을 고려했을 때, 각각의 검색 조건의 카테고리를 분류해 볼 수 있다. 예를 들어 호텔 상품을 검색하는 페이지라고 가정했을 때 다음과 같은 세 가지 카테고리로 분류된다.
- 기본 검색 조건
- 위치
- 날짜
- 인원
- 검색 결과를 좁힐 수 있는 필터 조건
- 호텔의 성급
- 특정 가격 범위
- 제공되는 서비스
- 시설
- 검색 결과의 우선순위 조건
- 정렬 기준
- 페이지네이션
처음에는 각 카테고리에 해당되는 데이터의 성격도 다르고, 화면 상에서 UI나 기능적으로도 요구사항이 달랐기 때문에 위화감을 느끼지 않고 서로 다른 상태로 분리했다. 기본 검색 조건의 경우 필수적으로 선택되어야 할 값이기 때문에 화면 상단의 서치 바 형태로 강조된다. 필터나 우선순위 조건은 옵셔널한 값이기 때문에 사이드바 또는 목록의 추가 기능 형태로 제공된다. 필터 조건은 사용자가 여러 가지를 빠르게 중복 선택하는 경우를 고려해서 debounce 처리도 해주어야 했다. 결과적으로 세 개의 카테고리별로 검색 조건을 관리하는 커스텀 훅을 각각 하나씩 구현했다. 여기까지만 보면 단일 책임 원칙을 준수하여 로직 분리를 잘 한 것으로 여길 수 있을지도 모르겠다.
검색 조건은 한 가지 목적을 가진다
안타깝게도 이후 코드 관리 및 동작상으로 몇 가지 문제가 발생했다. 검색 조건 각각의 성격은 서로 다를지라도 목적은 상품 결과를 얻기 위한 API 요청 하나로 통일된다. 페이지 랜딩시에는 URL 쿼리로부터 검색 조건의 초기값을 받아오는데, 이를 코드상에서는 세 가지 상태로 나누어 관리하다가 API 요청시 다시 합치는 등 다소 불필요한 데이터 변환 과정이 포함됐다. 더군다나 세 개의 분리된 상태 관리 로직이 전부 동일한 패턴을 취하고 있었기에 오히려 DRY(Don't Repeat Yourself) 원칙에 어긋나는 결과로 이어졌다.
또한 각각의 검색 조건은 서로 복잡한 의존성으로 엮여 있다. 예를 들어 페이지 이외의 검색 조건이 변경되었을 때 무조건 페이지가 1로 리셋되어야 하는데, 이를 구현하려면 기본 검색 조건과 우선순위 조건이 서로 의존성을 가지게 된다. 각 조건을 담당하고 있는 리액트 컴포넌트 위계가 서로 다르다 보니 의존성이 있는 검색 조건을 prop 또는 global state 어느 형태로 전달받더라도 리렌더링 이슈에서 완전히 자유로울 수 없었다. 이 상태에서 로직을 세 개로 분리하니 오히려 상태 변화에 대한 관리 포인트가 늘어나서 코드 흐름이 복잡해지는 결과를 낳았다.
검색 조건이 서로 의존성을 가진다는 것은 이들이 상품 결과를 얻기 위한 API 요청이라는 한 가지 목적을 공유하기 때문이라는 점과도 이어진다. 돌고 돌아, 분리하고 났더니 오히려 함께 있는 것이 낫다는 결론을 얻었다.
합치거나, 관점을 바꾸거나
문제를 경험하고 나서 세 개의 분리된 커스텀 훅은 하나로 합쳐졌고, 어느 정도 코드는 개선되었다. 그러나 합치는 데도 장점만 있지는 않다는 점 또한 느꼈다. 검색 조건의 서로 다른 특성으로 인해 오히려 상태 내부에서 분기 처리가 늘어나는 것은 트레이드오프라 할 수 있다. 또한 위 사례에서부터 교훈을 얻어 하나의 API 엔드포인트에 대한 요청 파라미터는 하나의 로직에서 관리해야 한다는 결론을 내릴 수는 없을 것이다. 검색 조건 관리라는 특정 비즈니스 요구사항에 한해, 검색 조건을 분리하여 관리하는 것보다는 합치는 것이 더 나은 문제 해결 방법이었을 뿐이다.
그리고 더 나은 것에서 나아가 최선은 문제를 근본적으로 해결하는 방법일 것이다. 이런 이유로 관점을 달리 해 위 문제를 다시 한번 개선할 수 있다. 검색 조건을 상태로 관리해야 한다는 생각으로 인해 URL 쿼리를 리액트 또는 기타 상태 관리 라이브러리에서 제공하는 인터페이스에 맞추어 저장하도록 만든 점을 복기해 보자. 이같은 라이브러리들에서 제공하는 상태 관리 기능은 필요에 따라서는 유용하고 프론트엔드의 일반적인 개발 패턴이기도 하다. 다만 주의해야 할 점은 상태가 필요 이상으로 남용되지 않도록 하는 것이다. 상태 저장에는 메모리 비용이 따르고, 상태 업데이트에는 리렌더링 비용이 따르기 때문이다. 사소한 것처럼 보이더라도 상태 선언 이전에는 반드시 필요한 상태인지를 명확히 정의하는 과정이 있어야 자원을 낭비하지 않을 수 있다.
반드시 필요한 상태인지의 여부는 어떻게 알 수 있을까? 상태에서 다루고자 하는 데이터의 원천이 기존 코드베이스에 이미 존재하는지 확인해 보면 좋다. prop으로 받을 수 있거나, 글로벌 상태에 포함되어 있거나, 혹은 여러 사람이 협업하다 보니 이미 같은 데이터를 다루는 로직이 존재하고 있을 수도 있다. 또한 검색 조건 사례에서와 같이 URL 쿼리가 필요한 데이터를 이미 가지고 있다면 상태를 선언하지 않고도 그 역할을 대체할 수 있다. 어차피 검색 조건이 변경되었을 때 URL 쿼리 또한 갱신되어야 하기 때문에, URL 쿼리를 일종의 상태 스토어로 사용해도 무방하다는 관점이다. 아래 1번과 2번을 비교하면 차이가 쉽게 보일 것이다.
- URL 쿼리 파싱 > 상태 저장 > API 요청 > 사용자 이벤트 발생 > 상태 변경 > URL 쿼리 갱신 > API 요청
- URL 쿼리 파싱 > API 요청 > 사용자 이벤트 발생 > URL 쿼리 갱신 > API 요청
이처럼 내가 겪었던 문제 상황에서 단계별로 코드를 개선해나가는 과정은 전적으로 함께 협업했던 동료의 도움으로 이루어졌다. 덕분에 문제를 해결하는 최선의 방법을 어떻게 찾을 수 있는지 소중한 인사이트를 얻었다.