본문 바로가기

Dev.

[실전] 프론트 기존 컴포넌트를 개선한 경험 공유

출처 - pixel

 제가 '내부에 개발자가 필요한 이유, 리팩터링'(링크)에서 리팩터링을 틈틈이 하는 것이 좋다고 결론을 내렸었습니다. 최근에 회사에서 작업을 하면서 리팩터링도 함께 진행했었는데요. 그에 대한 경험을 간략히 정리해보고자 합니다.

목차

  • 왜 리팩터링을 결심했나요?
  • 테스트 코드 부터
  • 역할분리
  • 새롭게 만들 컴포넌트 설계
  • 테스트 코드 통과
  • 마무리

왜 리팩터링을 결심했나요?

기획의 요구사항만 만족하면 일은 끝났지만, 리팩터링을 해야겠다고 판단한 이유는 아래와 같습니다.

😫 기존 코드가 너무 복잡하다

 

  • forwardRef 컴포넌트를 불필요하게 사용 그로 인해 데이터 흐름이 역행하는 경우가 발생 ↩
  • global 상태를 사용하지 않아도 될 곳에서 global 상태를 사용 ❌
  • 모듈의 역할분리가 안됨 🗂
  • 컴포넌트간 강하게 연결되어 있음 🖇재활용이 힘든 hooks로 되어 있음 ♻️​

 

한마디로 아무리 기존 코드를 살펴봐도 이해하기가 힘들 정도로 코드 복잡도가 높았습니다.

 

테스트 코드 부터

프런트엔드 영역에서 테스트코드에 대해 긍정적인 입장을 취하시는 분은 생각보다 많이 없습니다. 우선순위를 뒤로 미루는 경우가 허다합니다. 그 이유가 있는데요. 아무래도 화면단을 맡고 있다 보니 화면의 쓰임새나 UX/UI 등등이 변경됨에 따라 프런트단 코드도 자주 변경이 됩니다.

그렇다 보니. 그 이유는 '리팩터링 하려고? 그래.. 테스트 코드는 있고?'(링크)에서 확인할 수 있습니다.

제가 맡은 프로젝트에도 애초에 테스트 코드는 작성되어 있지 않았습니다. 새롭게 구축해 나가야 하는 상황인데요. 우선 테스트 코드는 아래의 조건만 만족시키기로 했습니다.

  • 비즈니스 로직만 테스트 코드를 넣자
  • 새롭게 작성될 코드는 TDD 방법으로 개발하자

 

문제점 파악

기존의 코드는 부모 컴포넌트에 코드 뭉텅이로 모여 있었습니다. 컴포넌트 설계를 하여 최대한 역할 분리를 하고자 했습니다. 아래는 기존에 코드가 어떻게 구성되어 있었는지를 그려 봤습니다.

출처 - 자체제작

기존 코드는 크게 3가지 문제점이 있었습니다

역할 분리가 전혀 안되어 있다.

아마 초기에는 간단한 요구사항으로 시작했지만, 아마 요구사항이 계쏙 쌓여 갔을 겁니다. 그리고 시간이 촉박하다 보니 기존 구조에 코드를 쌓는 형식으로 개발을 이어 갔을 겁니다.

결국 하나의 컴포넌트에 다 때려 박는 구조로 되었고, 이는 코드 가독성을 떨어트리고 코드 간 의존성을 높였을 겁니다.

데이터 흐름이 역행한다

무슨 이유인지는 모르겠지만 애초에 컴포넌트를 forwardRef()로 감싸서 ref를 주입하는 형식으로 구현을 해놨었습니다. 심지어 컴포넌트의 내부 상태를 부모 컴포넌트에서 열어볼 수 있도록 인터페이스를 제공해 주는 형태로 되어 있었습니다.

컴포넌트가 공통적으로 사용되는 아이라, 여러 곳에서 사용하려고 미리 메모리에 올리려고 한 의도라면 이해가 됐지만 그렇지도 않았습니다.

useImperativeHandle(ref, () => ({
  validate: () => { ... },
  getState: () => ({
    name: name,
    phone: phone,
    danji: danji,
  }),
}), [ ])

 

😨 갓뎀!..

React는 단방향 데이터 흐름을 기본 콘셉트로 하고 있습니다. 하지만 이는 컴포넌트 관계의 깊이가 깊어졌을 때, props drill의 형태로 되기 때문에 불편한 점이 있습니다. 그래서 고려되는 것들이 global state를 관리해 주는 Store 라이브러리들이 있죠. recoil, redux, jotai 등등이 있습니다.

하지만, 이렇게 ref의 형태로 데이터를 상위 컴포넌트로 전달하는 건 React에서는 특별한 이유가 아닌 이상 일반적이지 않습니다.

hooks 이 재활용이 안된다

기존의 hooks 은 문제점이 많았습니다. 한 곳에만 사용할 수 있도록 설계되어 있었고 비즈니스 로직을 가지고 있기보다 state만 mount 시점에 set 시키고 뱉어내기만 하는 구조였습니다.

useConsultingDetail.js - 기존코드

const useConsultingDetail = () => {
  const setContent = useSetRecoilState(counselingContent)
  ...
  useEffect(() => {
     const setDatas = async () => {
        const { content } = await fetchConsulting(id) 
         ...
        setContent(content)
     }
     setDatas()
  }, [setContent])

  return {
   setContent,
  }
}

 

이 hooks의 사용도가 틀렸다라기보다그 용도가 너무 제한적입니다. 데이터 패칭을 하고 끝인 거죠. 그러면서 비즈니스 로직은 컴포넌트에 가득했습니다.

그리고 useEffect가 들어가 있기 때문에, hooks을 재사용하기가 껄끄럽습니다.

 

 


새롭게 만들 컴포넌트 설계

위와 같은 점들을 개선하고자 설계한 컴포넌트 관계는 아래와 같습니다.

출처 - 자체제작

그림은 복잡하지만 제가 한 일은 아래 4가지입니다.

  • 부모 컴포넌트를 분해한다. 🔪
  • 자식 컴포넌트들은 모두 props로 내려받는다. 단 2개 이상의 관계를 가질땐 Global state 를 사용한다. 🌧
  • 데이터를 가공하는 비즈니스 로직들은 관련 hooks 이나 libs, utils 에 분리시킨다. 🗂
  • hooks에 만큼은 test code를 붙인다. (TDD 로 개발한다) 👩‍❤️‍👨

대표적인 예시로 usePhoneNumber hooks 코드는 아래와 같이 작성했습니다.

usePhoneNumber.js - 개선한 코드

const usePhoneNumber = () => {
   const taost = useToast()
   const [phone, setPhone] = useState()
   const [isSetRequiredValues, setIsSetRequiredValues] = useRecoilState(atom)

   const validate = () => {
    ...
   }
   const fetchConsultingCount = () => {
    ...
   }
   const isShowErrMsg = (condition) => !isSetRequiredValues && condition

   return {
      phone,
      setPhone,
      validate,
      fetchConsultingCount,
      isShowErrMsg,
   }
}

위 코드는 3가지 장점이 있습니다.

재사용성이 높아졌습니다.

어떤 컴포넌트에서도 phoneNumber와 관련된 함수를 어디서든 마음껏 꺼내쓸 수 있습니다.

응집도가 높아졌습니다

동일한 관심사들이 위 hooks 에 모이게 됐습니다. 아마 앞으로도 이 hooks 에 phoneNumber와 관련된 로직들이 추가가 되겠죠.

결합도가 느슨해졌습니다.

이 hooks를 사용하기 위해 따로 해야 할 건 전혀 없습니다. 그냥 내가 원하는 내부 함수를 꺼내 쓰기만 하면 됩니다.

 

 


테스트 코드 통과

데이터 가공, 유효성 체크, 관련 API 페쳐, 이벤트 전송 등등의 비즈니스 로직들은 hooks에 응집시켜 놨습니다. 앞서 말씀드렸듯이 비즈니스 로직에 대해서만 테스트 코드를 달고자 했는데요. 이 hooks 들만 테스트 코드를 구축하고 통과시켰습니다.

테스트 결과

그래서 버그는 비즈니스 로직에서는 나지 않았고, 주로 뷰단이나 기존 코드와 접목되는 부분에서 났었습니다.

테스트 코드를 작성하는 데는 시간이 많이 소요되지 않았던 이유는 TDD 방법론을 적용했기 때문입니다. 테스트 코드를 먼저 작성하고 실제 코드를 작성한 거죠.

 useDealForm.test.js

describe("useDealForm", () => {
	context("initialTypes", () => {
		it("매매가, 월세가, 전세가 등등의 값이 있을때", async () => {
			const { result, waitForNextUpdate } = renderHook(() => useDealForm(), { wrapper: RecoilRoot })
			await act(async () => {
				... 값 셋팅 ...
				await waitForNextUpdate()
			})

			expect(result.current.initialTypes).toHaveLength(3)
		})
		it("매매가, 월세가, 전세가 등등의 값이 없을때", async () => {
			const { result } = renderHook(() => useDealForm(), { wrapper: RecoilRoot })
			expect(result.current.initialTypes).toHaveLength(0)
		})
	})
});

테스트 코드를 작성하면서, 이미 이 함수에 대한 경우의 수를 미리 예측하고 테스트코드를 작성합니다.

실제 코드를 작성하기 전에 이미 이 함수가 내 머릿속에 그려져 있죠.

 

bash

yarn dev --watch-all

 

을 실행시켜 놓고저는 지금부터 좋은 코드를 작성하는데 집중합니다. 오류는 테스트 코드가 알려줄 테니깐요.

하지만 앞으로 TDD를 적극활용하기 위해서는 mocking, config 코드와 예제들이 프로젝트 내에 더 많아져야 합니다.

 


마무리

프런트에서 가장 많이 사용되는 라이브러리가 바로 React입니다. 그래서 프런트단의 코드 설계를 논할 때 React를 빼고 이야기할 수 없을 것입니다.

저는 React의 가장 큰 장점은 가상돔(Virtural dom)이라는 주장은 사실 크게 와닿지 않습니다. 실제로 속도에 있어서 다른 라이브러리들이 더 우위를 점한다는 자료는 조금만 찾아봐도 나옵니다. Svelte 만 해도 React 보다 훨씬 빠르다는 건 누구도 부인할 수 없을 것입니다.

다만, React는 적정 수준의 속도를 보장해 주면서도, 코드 작성의 가이드라인을 제공해 준다는 것이 큰 장점입니다. 좋은 코드 작성을 위한 컴포넌트 설계가 가능한 거죠. 하지만 React의 이런 가이드라인을 무시하고 코드를 작성했을 때, 가독성을 떨어트리고 생산성을 저해시키죠.

일단 1️⃣ 최대한 잘게 쪼개는 것이 우선입니다. 그래야 읽기 편하고요. 그래야 재사용이 쉬워지고, 테스트하기 쉬워집니다. 그 속에서 융통성 있는 판단은 필요하겠죠.

또한 2️⃣ 응집도와 결합도를 항상 염두해야 합니다. 같은 관심사는 묶어줘야 하고요. 모듈은 각각 독립적이어야 합니다.

저는 이 두 가지를 염두에 두고, 앞으로도 리팩터링을 이어갈 생각입니다. 다음에는 리팩터링 책에서 나오는 리팩터링 기법들을 실제로 적용해 본 사례들을 글로 작성해 보겠습니다.

 

 

 
1. (행동·행위 등을) do, have; (연극·운동경기 등을) play
2. (만들다, 장만하다)
3. (동작·표정 등을)
 
 

 

 


아래는 개발 커뮤니티 링크입니다. 들어오셔서 기술 정보, 트렌드 정보, 유료스터디, 무료스터디, 모각코 등등을 이용해 보세요.