Frontend Clean Code

오형근·2022년 8월 15일
0

Frontend

목록 보기
8/10
post-thumbnail

최근 토스 컨퍼런스 영상들 중에 프론트엔드 관련된 영상을 찾아보다가 좋은 내용을 담고 있는 영상이 있어 풀어보고자 한다.

평소 프론트엔드에서 사용할 코드 아키텍쳐를 어떻게 구성해야할지 고민이 많던 내게 아주 좋은 대답이 되는 영상이었다.

실무에서 바로 쓰는 Frontend Clean Code

실무에서 클린 코드의 의의는 유지보수 시간의 단축입니다. 동료 혹은 과거의 스스로 짠 코드를 빠르게 이해할 수 있다면 유지보수할 때 드는 개발 시간이 짧아집니다.

프론트엔드 코드 방법론은 내가 계속 속 시원하게 답을 구하지 못하던 부분이었기에, 앞으로의 프론트엔드 코드 설계에 있어 좋은 공부가 되었다고 생각한다.

이제부터 영상을 낱낱히 파헤쳐보자!


실무에서 클린 코드의 의의

아래 말은 개발자라면 한 번쯤 들어봤을 말이다.

???: 그 코드는 안 건드리시는 게 좋을 거에요. 일단 제가 만질게요^^;;

기업 혹은 팀의 규모와 레거시 코드 여부를 떠나 정확하게 설계된 코드 아키텍쳐가 있지 않다면 흔히 나올 수 있는 상황이다.
스파게티코드만큼 난해한 것은 아니지만, 코드를 짠 장본인 외에는 쉽게 이해하지 못하는(혹은 장본인 조차도 이해하기 어려운)코드는 개발에서 '지뢰'처럼 작용한다.

이러한 코드는 다음과 같은 특징을 갖는다.

  • 흐름 파악이 어렵고
  • 도메인 맥락 표현이 안 되어
  • 동료에게 물어봐야 알 수 있는 코드

이런 코드는 개발 과정에서 병목이 되어 유지보수의 시간을 연장시키는 원인이 되고 심할 경우 기능 추가가 불가한 상태가 되도록 만든다. 또한 성능이 좋지 않은 경우가 많아서 유저 입장에서 쾌적한 경험을 갖기 어렵다.

여기서 알 수 있는 점은 다음과 같다.

실무에서 클린 코드의 의의 = 유지보수 시간의 단축

클린 코드는 유지보수 시간 단축 뿐만 아니라 버그 발생 시의 디버깅 시간도 단축시켜준다.
그렇다면 왜 다들 이러한 클린 코드를 유지해나가기 어려워하는 것일까?

"처음"에는 클린했습니다.

개발자라면 처음 코드를 짤 때 유지보수를 고려하여 논리정연한 코드를 짜려고 할 것이고, 실제로 처음에는 그런 클린함이 유지되는 코드가 많을 것이다.

그러나 문제는 기존 코드를 고쳐 기능을 추가하는 상황에서 발생한다.
실제 현업의 대부분은 기능 추가를 위한 코드 수정인데, 이때 충분히 생각하고 경계하지 않으면 코드는 금방 클린함을 잃기 마련이다.

안일한 코드 추가의 함정

영상 속의 예시를 통해 어떤 함정에 빠질 수 있는지 살펴보자.

예시 속 userflow는 다음과 같다.

보험에 대한 질문 입력

if: 해당 유저를 담당하는 설계사가 존재
	담당 설계사 사진이 들어간 팝업을 먼저 띄운다.

이에 대한 기존 코드는 다음과 같다.

function QuestionPage() {
  async function handleQuestionSubmit() {
    const 약관동의 = await 약관동의_받아오기();
    if(!약관동의) {
      await 약관동의_팝업열기();
    }
    await 질문전송(questionValue);
    alert("질문이 등록되었어요.");
  }
  return (
  	<main>
    	<form>
          <textarea placeholder="어떤 내용이 궁금한가요?">
            <Button onClick={handleQuestionSubmit}>질문하기</Button>
          </textarea>
    	</form>
    </main>
  )
}

위 코드는 질문하기 버튼 클릭 > 약관동의 여부를 판단해 필요 시 팝업 띄우기 > 질문 전송 및 성공 alert 내보내기의 순서로 진행된다.

여기서 새 기능을 추가하려면 다음과 같은 두 코드를 추가하면 된다.

  • 연결 중인 전문가가 있으면 팝업 띄우는 로직
  • 팝업 컴포넌트

설계대로 개발을 하면 아래와 같은 코드가 나온다.

function QuestionPage() {
  const [popupOpened, setPopupOpened] = useState(false); // 팝업 상태
  
  async function handleQuestionSubmit() {
    cosnt 연결전문가 = await 연결전문가_받아오기(); // 연결 중인 전문가가 있으면 팝업 띄우기
    if (연결전문가 !== null) {
      setPopupOpened(true);
    } else {
    	const 약관동의 = await 약관동의_받아오기();
    	if(!약관동의) {
      	await 약관동의_팝업열기();
    	}
    	await 질문전송(questionValue);
    	alert("질문이 등록되었어요.");
  	}
  async function handleMyExpertQuestionSubmit() { // 팝업 버튼 클릭 핸들러
  	await 연결전문가_질문전송(questionValue, 연결전문가.id);
    alert(`${연결전문가.name}에게 질문이 등록되었어요.`);
  }
  return (
  	<main>
    	<form>
          <textarea placeholder="어떤 내용이 궁금한가요?" />
            <button onClick={handleQuestionSubmit}>질문하기</button>
    	</form>
      {popupOpened && ( // 팝업 컴포넌트
      	<연결전문가팝업 onSubmit={handleMyExpertQuestionSubmit} />
      )}
    </main>
  )
}
 	

위 코드는 기능적으로 문제 없이 잘 동작하겠지만, 이전의 클린함을 잃어버린 나쁜 코드가 되었다. 왜일까?

하나의 목적인 코드가 흩뿌려져 있다.

추가된 부분은 모두 연결전문가 관련 기능을 위해 추가한 내용인데, 같은 목적을 가진 코드가 세 부분으로 나뉘어 코드 여기저기로 흩뿌려져 있기 때문에 추후 작업을 진행할 때 스크롤을 위아래로 이동하게 만들어 가독성과 생산성을 떨어뜨린다.

하나의 함수가 여러가지 일을 하고 있다.

위 코드의 handleQuestionSubmit함수는 기능 추가 전까지는 하나의 일을 하고 있었지만, 기능을 추가하면서 3가지의 일을 하게 되었다. 이러한 경우 세부 구현을 모두 읽어야만 함수의 역할을 파악할 수 있고, 코드 추가 및 삭제에도 시간이 걸리게 된다는 단점이 있다.

함수의 세부 구현 단계가 제각각이다.

위 코드에서 함수 handleQuestionSubmithandleMyExpertQuestionSubmit은 비슷한 이름을 가지고 있다. 그러나handleMyExpertQuestionSubmit은 한 가지 작업만 수행하는 데에 비해handleQuestionSubmit은 3가지 일을 동시에 수행하고 있다.

이렇게 비슷한 이름을 가진 함수의 세부 구현이 다르면 하나의 함수로 다른 함수의 역할을 잘못 지레짐작해버리는 경우가 생긴다. 이러한 요소들이 모두 가독성을 떨어뜨리는 원인이 된다.

그 때는 맞고 지금은 틀리다.

간단한 기능만 하나 추가했을 뿐인데 클린 코드가 난잡한 코드가 되어버렸다. 그러나 이러한 경우 PR을 하더라도 기능적으로 오류가 없기 때문에 난잡한 코드임을 파악하기 어렵다. 이것이 정말 중요한 문제이다. 이러한 난잡한 코드는 당장에는 문제가 없을지 모르지만 서비스의 규모가 커지고 코드가 추가될수록 생산성을 기하급수적으로 떨어뜨릴 것이다.

큰 그림을 보며 리팩토링

이제 이를 방지하기 위해 코드를 고쳐보자.

함수 세부 구현 단계 통일

function QuestionPage() {
  const 연결전문가 = useFetch(연결전문가_받아오기);
  
  async function handleNewExpertQuestionSubmit() {
  	await 질문전송(questionValue);
   	alert("질문이 등록되었어요.");
  }
  async function handleMyExpertQuestionSubmit() { // 팝업 버튼 클릭 핸들러
  	await 연결전문가_질문전송(questionValue, 연결전문가.id);
    alert(`${연결전문가.name}에게 질문이 등록되었어요.`);
  }	

위 코드를 보면 기존 함수의 이름을 바꿔서 함수의 위계를 맞췄다. 그리고 전자는 새로운 전문가에게 연결하는 로직만을, 후자는 연결중인 전문가에게 연결하는 로직만을 담아 함수의 역할을 하나로 명확하게 만들었다.

하나의 목적인 코드는 뭉쳐 두기

return (
	<main>
    	<form>
		<textarea placeholder="어떤 내용이 궁금한가요?" />
        {연결전문가.conneted ? (
          	<PopupTriggerButton
              popup={(
              	<연결전문가팝업
                  onButtonSubmit={handleMyExpertQuestionSumbit} />
              )}
        >
              질문하기 </PopupTriggerButton>
        ) : (

기존에 분리되어 있던 팝업을 여는 버튼과 팝업 코드를 모아PopupTriggerButton이라는 컴포넌트를 만들었다. 그리고 띄워질 팝업을 props로 내려보내 같은 목적을 가진 코드를 뭉쳐두었다.

함수가 한 가지 일만 하도록 쪼개기

					<Button onClick={async () => {
  							await openPopupToNotAgreedUsers();
    						await handleMyExpertQuestionSubmit();
  						}}
                    >
  					질문하기
  					</Button>
				)}
                </form>
			</main>
		);
}
async function openPopupToNotAgreedUsers() {
	const 약관동의 = await 약관동의_받아오기();
  	if(!약관동의) {
    	await 약관동의_팝업열기();
    }
}

마지막으로 함수 하나에서 하나의 일만 하도록 코드를 분리했다.
약관동의 함수를 받아오는 로직과 팝업여는 로직으로 분리하여 필요한 기능만 필요한 시점에 불러올 수 있도록 한 것이다.

코드가 길어졌다???

상관없다. 클린코드 != 짧은코드 == **원하는 로직을 빠르게 찾을 수 있는 코드**라는 점을 꼭 기억하도록 하자.

원하는 로직을 빠르게 찾으려면?

위와 같이 코드를 리팩토링하여 원하는 로직을 빠르게 찾으려면 어떤 원칙을 가지고 코드를 설계해야 할까?

  1. 응집도를 높여 뭉쳐두기
  • 같은 목적을 가진 코드를 모아 정리해두자.
  1. 단일책임 원칙에 의거하여 쪼개기
  • 하나의 함수가 여러가지 일을 하지 않도록 단일책임 원칙을 생각하며 코드를 분리해주자.
  1. 함수의 추상화 단계 조정하기
  • 함수의 세부 구현 단계가 제각각인 경우, 추상화 단계를 조정해서 핵심 개념을 필요한 만큼만 노출하도록 하자.

위의 원칙을 적용한 코드를 실무 예시를 들어 살펴보자.

로직을 빠르게 찾을 수 있는 코드

응집도 | 같은 목적의 코드는 뭉쳐두자

기존 코드

function QuestionPage() {
	const [popupOpened, setPopupOpened] = useState(false); // 팝업을 조작하는 코드
  	async function handleClick() {
    	setPopupOpened(true);
    }
  	function handlePopupSubmit() { // 팝업을 조작하는 코드
    	await 질문전송(연결전문가.id);
      	alert("질문을 전송했습니다.");
    }
  	return ( // 팝업을 조작하는 코드
    	<>
        <button onClick={handleClick}>질문하기</button>
        <Popup title="보험 질문하기" open={popupOpened}> 
          <div>전문가가 설명드려요</div>
          <button onClick={handlePopupSubmit}>확인</button>
        </Popup>
        </>
    )
}

위의 코드는 기존의 코드이다. 이 원본 코드에서는 팝업을 조작하는 코드가 세 군데에 뚝뚝 떨어져 있다. 이렇게 되면 파악도 한 번에 안 될 뿐더러 버그 발생 위험이 높아진다.

리팩토링 v1

function QuestionPage() {
	const [openPopup] = useMyExpertPopup(연결전문가.id);
  
  	function handleClick() {
    	openPopup();
    }
  
  	return <button onclick={handleClick}>질문하기</button>;
}

위 코드처럼 커스텀 훅을 사용하여 한 군데로 뭉쳤다.
코드를 간결하게 하였지만, 오히려 코드 파악이 어려워졌다.
페이지의 포인트인 팝업이 열리는 로직과 어떤 팝업이 나오는지가 모두 커스텀 훅에 가려진 것이다.

위와 같은 코드는 커스텀 훅의 대표적인 안티패턴이다.
즉, 코드를 아무렇게나 뭉쳐서는 안된다는 것이다.

그럼 무엇을 뭉쳐야 할까?

디테일적인 부분을 숨겨둔다면 코드가 어떤 기능을 하는지 목적을 한눈에 파악하기 쉬워진다. 코드 파악에 필수적인 핵심 정보는 노출시키고 나머지 부분은 정리하여 모아두자. 다시 말하지만, 클린 코드 != 짧은 코드이다. 뭉쳐서 모아둔다고 능사가 아니다..!

코드 응집: 핵심 데이터와 세부 구현 나누기

위 팝업 코드에서 핵심 데이터가 무엇인지 먼저 파악해보면

  • 팝업 버튼 클릭 시 수행하는 액션
  • 팝업의 제목
  • 팝업의 내용

그리고 세부 구현은

  • 팝업을 열고 닫을 때 사용하는 상태
  • 컴포넌트의 세부 마크업
  • 팝업의 버튼 클릭 시 특정 함수를 호출하는 바인딩

이렇게 핵심 데이터가 무엇인지, 세부 구현이 무엇인지 파악하고 세부 구현만을 커스텀 훅에 정리하여 숨겨두면 세부 구현을 읽지 않고도 어떤 팝업인지 파악할 수 있게 된다.

이러한 개발 스타일을 선언적 프로그래밍이라고 한다.

선언적 프로그래밍

선언적 프로그래밍은 다음을 말한다

핵심 데이터만 전달받고 세부 구현을 뭉쳐 숨겨두는 개발 스타일

이러한 방식으로 개발을 하게 되면 '무엇'을 하는 함수인지 빠르게 이해할 수 있고, 세부 구현은 안쪽에 숨겨두어 신경 쓸 필요가 없다는 장점이 있다. 또한, '무엇'에 해당하는 부분만 바꿔 쉽게 재사용할 수 있다.

이와 반대되는 개념은 명령형 프로그래밍인데, 코드를 숨겨두지 않고 세부 구현을 하나하나 작성해두는 방식을 말한다. 명령형 프로그래밍은 세부 구현을 모두 꺼내두어서 코드를 커스텀하기 쉽지만, 읽는데 오래 걸리고 재사용하기 어렵다는 단점이 있다.

선언적 프로그래밍이 무조건 좋은 건가요?

당연히 아니다. 리액트 jsx 문법을 사용하면 HTML에도 선언적 프로그래밍을 할 수 있다는 장점이 있지만 props로 '어떻게 해야 하는지'를 세부적으로 넘겨야 하는 경우에는 명령형 설계도 필요하다. 상황에 맞게 코드를 짜는 유연함을 가질 수 있도록 노력하자.

단일책임 | 하나의 일을 하는 뚜렷한 함수를 만들자

객체지향 프로그래밍의 SOLID 원칙에도 포함되어 있는 단일책임 원칙이다. 그만큼 패러다임과 관계없이 중요한 원칙으로 작용한다는 것인데, 어떻게 이를 명확하게 지킬 수 있을까?

중요 포인트가 모두 담겨있는 함수명을 짓자

async function handle질문제출() {
	const 약관동의 = await 약관동의_받아오기(); // 약관 체크 후 팝업
  	if(!약관동의) {
    	await 약관동의_팝업열기();
    }
  	await 질문전송(questionValue); // 질문제출
  	alert("질문이 등록되었어요");
}

위의 코드를 보면 handle질문제출 함수가 하는 일은 약관 체크 후 팝업, 질문 제출의 두 가지이다. 그러나 함수의 이름을 보면 질문제출만을 담고 있어 함수의 역할을 모두 보여주지 못하고 있다.

이렇게 중요 포인트가 모두 담겨있지 않은 함수명은 읽는 이가 예상한대로 코드가 동작하지 않으며, 곧 코드에 대한 신뢰 하락으로 이어진다. 그 다음부터는 함수명을 믿지 못하여 결국에는 세부 구현을 모두 읽어보아야 하는 상황에 놓이게 된다.

이러한 상황에서 기능 추가가 들어간다면 함수는 더욱 난잡해진다. 이러한 상황이 반복된다면 의도치 않게 세상에서 제일가는 스파게티 코드를 작성하고 있는 자신을 발견할 수 있을 것이다.

리팩토링 Tip 1: 한 가지 일만 하는, 명확한 이름의 함수

다음과 같이 함수를 나누어보자.

async function handle연결전문가질문제출() {
	await 연결전문가_질문전송(questionValue);
  	alert(`${연결전문가.name}에게 질문이 등록되었어요.`);
}
async function handle새전문가질문제출() {
	await 질문전송(questionValue);
  	alert("질문이 등록되었어요.");
}
async function handle약관동의팝업() {
  	const 약관동의 = await 약관동의_받아오기();
  	if(!약관동의) {
    	await 약관동의_팝업열기();
    }
}

이처럼 함수를 기능별로 분리한 뒤, 역할에 맞는 이름을 지어주고 필요할 때 각각 호출하여 사용하면 된다.

리팩토링 Tip 2: 한 가지 일만 하는, 기능성 컴포넌트

함수를 분리하느 것 외에도 리액트 컴포넌트를 이용하여 로직을 분리해보자.

<button
	onClick={async () => {
  		log("제출 버튼 클릭")
    	await openConfirm();
  	}}  
/>

위의 코드를 보면 버튼 클릭 함수에 로그를 찍는 함수와 API콜이 섞여 들어있는 것을 확인할 수 있다.
이를 다음과 같이 바꾸자.

<LogClick message="제출 버튼 클릭">
	<button onClick={openConfirm} />
</LogClick>

이처럼 로그는 버튼을 감싼 컴포넌트에서 찍고, 버튼 클릭함수에서는 API 콜만 신경쓰도록 하여 하나의 기능만을 수행하는 컴포넌트를 만들어야 한다.

리팩토링 Tip 3: 조건이 많아지면 한글 이름도 유용해요!

영상을 보면서 재밌으면서도 좋은 팁이라고 생각한 팁이다.

const 패널티풀림 = reasons.indexOf("PENALTY") > -1;
const 평점4점이상 = review.rate >= 80;

if(패널티풀림) {
	return //...
}
if(평점4점이상) {
	return //...
}

영어로 이름 짓기가 복잡하다면 위의 코드처럼 한글 변수명을 사용하는 것도 유용하다. 한글 변수명 짓기는 도메인이 복잡해서 영어 이름 길게 짓는게 오히려 복잡도를 높일 때, 상수를 직관적으로 보고 싶을 때, 복잡한 조건문이 많아질 때 더욱 좋게 작용한다.

이렇게 되면 영어로 이름을 지을 때보다 코드의 역할 파악이 훨씬 빨라져서 나중에 복잡한 코드를 만들더라도 pseudo code를 읽는 것처럼 쉽게 코드를 파악할 수 있다.

추상화 | 핵심 개념을 뽑아내자

프런트엔드 코드의 추상화: 컴포넌트

다음 코드를 살펴보자.

<div style={팝업스타일}>
	<button onclick={async () => {
    	const res = await 회원가입();
      	if(res.success) {
        	프로필로이동();
        }
    }}>전송</button>
</div>

위의 코드는 팝업 컴포넌트를 제로부터 디테일하게 구현한 것이다.

아래의 코드는 위 팝업 코드를 제출액션과 성공액션이라는 중요 개념만 남기고 추상화한 것이다.

<Popup
  onSubmit={회원가입}
  onSuccess={프로필로 이동}
/>

이렇게 되면 컴포넌트의 역할을 정확하고 빠르게 파악할 수 있다.

프런트엔드 코드의 추상화: 함수

const planner = 
	await fetchPlanner(plannerId)
const label = planner.new ? "새로운 상담사" : "연결중인 상담사"

위 코드는 설계사 라벨을 얻는 코드를 세부 구현한 것이고, 아래의 코드는 이 세부 구현을 getPlannerLabel이라는 함수명 안에 모두 추상화한 코드이다.

const label = 
	await getPlanneerLabel(plannerId)

얼마나 추상화할 것인가?

추상화에도 당연히 정도가 있다. 조금만 추상화하여 노출되는 세부 구현 정도를 높일 수도 있고, 많이 추상화하여 대부분의 기능을 숨길 수 있다.

아래 코드를 차례대로 살펴보자.

Level.0

<Button onClick={showConfirm}>
  전송
</Button>
{isShowConfirm && (
	<Confirm onClick={() => {showMessage("성공")}} />
)}

위 코드는 버튼을 클릭하면 컨펌창을 띄우고, 컨펌 버튼을 클릭하면 특정 메세지를 띄우는 구체적으로 구현된 코드가 있다. 이를 단계별로 추상화해보면 다음과 같다.

Level.1

<ConfirmButton 
  onConfirm={() => {showMessage("성공")}}
>
  전송
</ConfirmButton>

위와 같이 추상화하면 onConfirm을 통해 원하는 액션마나 넘기면 된다.

Level.2

<ConfirmButton message="성공">
  전송
</ConfirmButton>

위 코드처럼 만들면 메세지 prop 만 넘겨서 컨펌창에 원하는 메세지를 보여주도록 더 간단히 추상화할 수 있다.

Level.3

<ConfirmButton />

더 나아가면 위와 같이 모든 기능을 함축한 컴포넌트를 만들 수도 있다.

여기서 알 수 있는 것은 추상화의 정도가 높아질수록 컴포넌트의 variation이 줄어든다는 것이다. 상황에 맞게 유저의 입력값을 받아야할 수도 있고, 다른 컴포넌트와 상호작용을 해야할 수도 있기 때문에 그때그때 맞게 추상화를 진행하도록 하자.

추상화 정도에 답은 없다. 상황에 따라 필요한 만큼 추상화하면 된다.

추상화할 때 주의점

추상화의 정도를 결정하는 것은 개발자의 자유이지만, 같은 위계 상의 컴포넌트나 함수의 추상화 수준이 섞여 있다면 문제가 발생할 수 있다. 추상화 수준이 섞여 있다면 코드 파악이 어려워지므로 같은 위계 상의 코드는 같은 추상화 정도를 가질 수 있도록 하자. 그렇지 않으면 글의 윗부분에서 언급했던 것처럼 코드의 기능을 지레짐작하는 계기가 될 수도 있다.

추상화 정도를 결정하는 것은 자유지만, 같은 위계 상의 코드는 같은 추상화 정도를 적용해야 한다.

액션 아이템

지금까지 다룬 내용을 총 정리하여 앞으로 코드를 짤 때 지켜야할 규칙을 세워보면 다음과 같다.

담대하게 기존 코드 수정하기

코드 구조 뜯기를 두려워하면 클린한 실무 코드를 유지할 수 없다. 잘못된 부분이 있다면 두려워말고 기존 코드를 뜯어고칠 수 있는 담대함을 기르자.

만약 PR의 file changes가 많은 것이 두렵다면 mother branch를 따 와서 리팩토링한 PR을 추가로 만드는 것도 좋다.

큰 그림 보는 연습하기

그때는 맞고 지금은 틀릴 수 있다는 것을 늘 생각하자. 기능이 추가될 때, 해당 코드 자체는 클린해도 전체적으로는 어지러워질 수 있다.

팀과 함께 공감대 형성하기

코드에는 정답이 존재하지 않는다. 팀원과의 충분한 커뮤니케이션을 통해 코드에 대해 명시적으로 이야기할 수 있어야한다. 각자 문제라고 생각하는 지점을 공유하고, 추후 해당 부분을 개선할 방법을 함께 고민해보자.

문서로 적어보기

클린 코드는 모호한 개념이다. 이 코드가 향후 어떤 점에서 위험할 수 있는지, 어떻게 개선할 수 있는지 글로써 정리해두어야 비로소 명확해진다. 나만의 코드 작성 원칙을 적어보자.


마무리

이 글을 작성하면서 원본 영상을 여러 번 다시 돌려보고 곱씹었다. 물론 지금 이 원칙들이 제대로 적용된다는 보장은 하지 못하지만, 앞으로의 경험에 조금씩 적용하다보면 나도 모르게 클린 코드를 작성하는 좋은 코드 리뷰어가 될 수 있을 것이라고 생각한다.

0개의 댓글