
Frontend Fundamentals 는 토스 프론트엔드 개발팀에서 제시하는 "FE에서 좋은 코드란 어떤 코드인가?"에 대한 지침서이다.
이제 막 부트캠프를 수료했고 4차례의 팀 프로젝트를 하면서, 다른 사람의 코드를 리뷰하는 작업이 꽤 많은 공수가 들어가는 작업이고, 하나의 기능을 구현할 때 너무 복잡한 로직이 있거나 결합도가 함수를 보수할 때 개발의 생산성이 얼마나 떨어지는지 경험할 수 있었다.
때문에 '프론트엔드에서 코드를 잘 쓴다는 건 어떻게 어떤 기준을 따라야 하는거지??'와 같은 고민을 자주 할 수밖에 없었다.
오늘은 그런 고민을 하고 있던 나에게 하나의 이정표가 되어 준 Frontend Fundamentals 아티클에 대해서 정리해보는 시간을 가질려고 한다.
토스에서는 좋은 코드의 기준을 얼마나 쉽게 수정이 가능한가 라고 제시하고 있다.
이유는 FE는 새로운 요구사항에 즉각적으로 반응이 되어야 하기 때문이라고 하는데, 그만큼 코드가 수시로 바뀌는 경우가 자주 발생하기 때문에 빠르게 변경 가능한 코드가 개발의 생산성을 결정하기 때문이라고 해석된다.
이처럼 변경하기 쉬운 코드를 작성하기 위해서는 지켜야 하는 규칙이 4가지가 있다고 한다. 해당 4가지 기준은 과연 무엇인지 하나씩 살펴 보자.
변경하기 쉬운 코드를 작성하기 위해 지켜야 하는 첫번째 규칙, 바로 가독성이다.
가독성이란 말 그대로 읽기 쉬운 정도이다. 간단하다. 쉽게 읽히는 코드가 쉽게 변경이 가능한 것이다. 토스는 쉽게 읽히는 코드를 짜기 위해서는 "사용자가 생각하는 맥락이 적어야 한다."는 메시지를 강조한다.
쉽게 생각해 우리가 누군가와 대화를 할 때 상대방이 A 이야기를 하다가 갑자기 B 이야기, 삼천포로 빠지면 "그래서 네가 말하고 싶은게 뭔데??ㅡㅡ" 하는 상황과 같은 맥락이다.
하나의 함수 또는 컴포넌트에서 많은 숫자의 분기가 들어가, 동시에 실행되지 않는 코드가 있으면 동작을 한눈에 파악하기 어렵다고 한다. 아래의 코드 예시를 보자.
function SubmitButton() {
const isViewer = useRole() === "viewer";
useEffect(() => {
if (isViewer) {
return;
}
showButtonAnimation();
}, [isViewer]);
return isViewer ? (
<TextButton disabled>Submit</TextButton>
) : (
<Button type="submit">Submit</Button>
);
}
개선된 코드는 다음과 같다.
function SubmitButton() {
const isViewer = useRole() === "viewer";
return isViewer ? <ViewerSubmitButton /> : <AdminSubmitButton />;
}
function ViewerSubmitButton() {
return <TextButton disabled>Submit</TextButton>;
}
function AdminSubmitButton() {
useEffect(() => {
showAnimation();
}, []);
return <Button type="submit">Submit</Button>;
}
기존에 코드는 분기 처리가 하나의 함수에 곳곳에 있다. 따라서 코드를 읽을 때 맥락이 이리 튀고 저리 튄다.
반면에 개선된 코드는 <ViewerSubmitButton />과 <AdminSubmitButton /> 에서 하나의 분기만 관리하기 때문에, 코드를 읽는 사람이 한 번에 고려해야 할 맥락이 적어진 상황이다.
마치 책을 읽는 것처럼 한 줄 한줄 읽으면서 내려갈 때 이해가 순탄하게 되는 코드가 가독성이 좋은 코드이다.
토스는 한 사람이 코드를 읽을 때 동시에 고려할 수 있는 맥락의 개수는 제한되어 있다고 이야기 한다.
코드를 읽는 사람이 해당 함수에서 이것만 읽어도 충분히 이해가 될 것 같다고 생각이 된다면 나머지는 불필요한 맥락이라고 판단하여 추상화 하는 것이다.
포스팅이 길어지는 것을 염려하여 따로 코드 예시는 들지 않겠다.
다음은 페이지 전체의 url 쿼리 파라미터를 한번에 관리하는 usePageState hook이다.
import moment, { Moment } from "moment";
import { useMemo } from "react";
import {
ArrayParam,
DateParam,
NumberParam,
useQueryParams
} from "use-query-params";
const defaultDateFrom = moment().subtract(3, "month");
const defaultDateTo = moment();
export function usePageState() {
const [query, setQuery] = useQueryParams({
cardId: NumberParam,
statementId: NumberParam,
dateFrom: DateParam,
dateTo: DateParam,
statusList: ArrayParam
});
return useMemo(
() => ({
values: {
cardId: query.cardId ?? undefined,
statementId: query.statementId ?? undefined,
dateFrom:
query.dateFrom == null ? defaultDateFrom : moment(query.dateFrom),
dateTo: query.dateTo == null ? defaultDateTo : moment(query.dateTo),
statusList: query.statusList as StatementStatusType[] | undefined
},
controls: {
setCardId: (cardId: number) => setQuery({ cardId }, "replaceIn"),
setStatementId: (statementId: number) =>
setQuery({ statementId }, "replaceIn"),
setDateFrom: (date?: Moment) =>
setQuery({ dateFrom: date?.toDate() }, "replaceIn"),
setDateTo: (date?: Moment) =>
setQuery({ dateTo: date?.toDate() }, "replaceIn"),
setStatusList: (statusList?: StatementStatusType[]) =>
setQuery({ statusList }, "replaceIn")
}
}),
[query, setQuery]
);
}
이 Hook이 가지고 있는 책임은 "페이지가 필요한 모든 쿼리 파라미터를 관리하는 것" 이다. 문제는 이 Hook은 담당할 책임이 무제한적으로 늘어날 가능성이 존재한다.
Hook이 담당하고 있는 영역이 점점 넓어지면, 구현이 길어지고, 어떤 역할을 하는 Hook인지 파악하기 힘들어진다.
이처럼 hook이나 함수로 로직의 일부분을 추상화 할 때에, 혹시나 내가 너무 많은 책임을 전가시키는 것은 아닌가? 수시로 점검할 필요가 있어 보인다.
변경하기 쉬운 코드를 작성하기 위해 지켜야 하는 두번째 규칙, 예측 가능성이다.
예측 가능성이란, 함께 협업하는 동료들이 함수나 컴포넌트의 동작을 얼마나 예측할 수 있는지를 말한다. 함수의 이름과 파라미터, 반환 값만 보고도 어떤 동작을 하는지 알 수 있어야 한다고 이야기 한다.
같은 이름을 가지는 함수나 변수는 동일한 동작을 해야 한다는 이야기이다. 미세한 동작의 차이가 코드의 예측 가능성을 낮추고, 코드를 읽는 사람에게 혼란을 줄 수 있다고 한다.
같은 역할을 수행하는 hook 이나 함수는, 서로 간의 반환 타입을 똑같이 가져가주는 것이 좋다는 이야기이다. 서로 다른 반환 타입을 가지면 코드의 일관성이 떨어져서, 같이 일하는 동료들이 코드를 읽는 데에 혼란을 줄 수 있다고 한다.
다음은 checkIsNameValid라는 유효성 검사 함수인데 모두 같은 객체 타입을 반환하고 있고 객체의 형식 또한 통일을 해주고 있는 모습이다.
/** 사용자 이름은 20자 미만이어야 해요. */
function checkIsNameValid(name: string) {
if (name.length === 0) {
return {
ok: false,
reason: "이름은 빈 값일 수 없어요."
};
}
if (name.length >= 20) {
return {
ok: false,
reason: '이름은 20자 이상 입력할 수 없어요.'
};
}
return { ok: true };
}
/** 사용자 나이는 18세 이상 99세 이하의 자연수여야 해요. */
function checkIsAgeValid(age: number) {
if (!Number.isInteger(age)) {
return {
ok: false,
reason: "나이는 정수여야 해요."
};
}
if (age < 18) {
return {
ok: false,
reason: "나이는 18세 이상이어야 해요."
};
}
if (age > 99) {
return {
ok: false,
reason: "나이는 99세 이하이어야 해요."
};
}
return { ok: true };
}
앞서 예측 가능성을 좋게 가져가기 위해서는 함수의 이름, 파라미터, 반환값만 보고도 내부 로직이 어떻게 돌아가고 있는지 예측 가능해야 한다고 했다.
근데 아래 예시를 보자. 전혀 예측할 수 없는 생뚱맞는 loggin 코드가 중간에 눈치없이 포함되어 있다.
fetchBalance 함수의 이름만 봐서는 안에서 로깅이 이루어지고 있는지 모르기 때문에 자칫 치명적인 버그를 초래할 수 있다
async function fetchBalance(): Promise<number> {
const balance = await http.get<number>("...");
logging.log("balance_fetched");
return balance;
}
변경하기 쉬운 코드를 작성하기 위해 지켜야 하는 세번째 규칙, 응집도이다.
응집도(Cohesion)란, 수정되어야 할 코드가 항상 같이 수정되는지를 의미한다. 응집도가 좋지 못하면 코드의 한 부분을 수정했을 때, 의도치 않게 다른 부분에서 오류가 발생할 수 있기 때문에 디버깅 하기도 쉽지 않을 것이다. 이처럼 함께 수정되어야 할 부분은 반드시 함께 수정되도록 구조적으로 뒷받침해주어야 한다.
프로젝트에서 코드를 작성하다 보면 Hook, 컴포넌트, 유틸리티 함수 등을 여러 파일로 나누어서 관리하게 되는데. 이런 파일들을 쉽게 만들고, 찾고, 삭제할 수 있도록 올바른 디렉토리 구조를 갖추는 또한 중요하다
이 때 , 다음과 같이 함께 수정되는 코드 파일을 하나의 디렉토리 아래에 둔다면, 코드 사이의 의존 관계를 파악하기 쉬워요.
└─ src
│ // 전체 프로젝트에서 사용되는 코드
├─ components
├─ containers
├─ hooks
├─ utils
├─ ...
│
└─ domains
│ // Domain1에서만 사용되는 코드
├─ Domain1
│ ├─ components
│ ├─ containers
│ ├─ hooks
│ ├─ utils
│ └─ ...
│
│ // Domain2에서만 사용되는 코드
└─ Domain2
├─ components
├─ containers
├─ hooks
├─ utils
└─ ...
변경하기 쉬운 코드를 작성하기 위해 지켜야 하는 마지막 규칙! 결합도이다.
결합도(Coupling)란, 코드를 수정했을 때의 영향범위를 말한다. 코드를 수정했을 때 영향범위가 너무 넓으면, 변경을 했을 때 일어나는 부수효과를 쉽게 예측하기가 어려워진다.
앞서, 맥락을 줄일 수 있는 전략에 대해서 이야기할 때 1.3번과 동일한 케이스이다. 함수 또는 hook을 추상화할 때 그 범주가 너무 넓거나 담당하는 책임이 많아질수록 하나의 함수를 건드렸을 때 리렌더링이 일어나는 컴포넌트들이 많아지는 등, 많은 부수효과가 생길 가능성이 있다.
이럴 떄에는 추상화 레벨을 하나씩 쪼개어 각자 하나의 역할만 할 수 있도록 함수의 역할을 분담해주어야 한다.
여러 컴포넌트에서 중복 코드가 생겼을 때 이를 하나의 컴포넌트 또는 함수로 공통화 하는 경우가 많다. 이는 가독성을 좋게 할 수는 있겠지만, 이 역시 하나로 공통화한 함수가 두개 이상의 함수에 영향을 줄 수 있다는 이야기로써, 결합도는 증가한다는 이야기이다.
이처럼 앞에서 제시한 규칙들이 서로 상충할 때가 있다. 반드시 모든 것들을 다 지킬 수가 없다는 이야기이다. 무엇을 더 우선순위로 잡고 코드를 작성할지는 프로젝트 규모, 논리의 복잡도에 따라서 적절하게 결정하면 될 문제인 것 같다.
Props Drilling은 부모 컴포넌트와 자식 컴포넌트 사이에 결합도가 생겼다는 것을 나타내는 명확한 표시라고 한다. 만약에 Drilling되는 프롭이 변경되면, 프롭을 참조하는 모든 컴포넌트가 변경되어야 하기 때문이다.
props drlling 현상은 조합 컴포넌트 (A->B->C 순으로 되는 컴포넌트를 A(+B)->C 순으로 통합시켜버리는 것)을 사용하거나 아니면 context api나 기타 전역 상태 관리 라이브러리를 사용하면 해결될 것으로 보인다.
오늘은 "좋은 코드는 과연 어떻게 작성하는가??"라는 호기심에서 출발해, 토스에서 제시한 Frontend Fundamentals 아티클에서 그 해답을 찾는 여정을 밟아보았다.
코딩에 정답은 없다는 말을 자주 듣는다. 그만큼 중요한 것은 코드를 작성할 때의 논리력인 것 같다. 논리를 설득력 있게 뒷받침 해주는 여러 기준이나 배경 지식이 있다면 오늘 그 중에 하나를 배운게 아닌가 싶다.