선언적인 코드 작성하기
선언적인 코드(Declarative Code)는 프론트엔드 개발을 하다 보면 자주 만나게 되는 개념입니다. 특히 React 생태계에서 웹 서비스를 개발하다 보면 선언적인 코드에 대해 고민하게 되는데요. 이번 아티클에서는 토스 프론트엔드 챕터에서 생각하는 선언적인 코드란 무엇인지, 그리고 실제로 어떻게 선언적인 코드를 작성하는지 공유해드리려고 합니다.
선언적인 코드
토스 프론트엔드 챕터에서는 선언적인 코드를 “추상화 레벨이 높아진 코드”로 생각하고 있습니다. 예를 들어서, 아래와 같이 주어진 배열의 합을 구하는 함수 sum
을 생각해봅시다.
sum([1, 2, 3]);
sum
함수는 아래와 같이 for
문으로 구현할 수 있습니다.
function sum(nums: number[]) {
let result = 0;
for (const num of nums) {
result += num;
}
return result;
}
여기에서 sum
함수는 초기값이 0이고, 배열이 가지고 있는 각각의 원소를 순회하면서 결과값에 더하는 작업을 추상화합니다. 덕분에 sum
을 다루는 사람은 복잡한 제어 흐름을 이해할 필요 없이, “배열의 합을 구한다” 라고 하는 동작에 집중하여 함수를 사용할 수 있습니다.
토스는 이렇게 동작에 집중하여 추상화된 sum
함수를 선언적인 코드로 생각하고 있습니다.
여기에서 한 걸음 더 나아가서 sum
함수 내부의 for ... of
문을 살펴봅시다.
for (const num of nums) {
/* 동작 ... */
}
이 제어 흐름도 선언적인 코드로 볼 수 있습니다. 배열이 가지고 있는 각각의 요소를 순회하는 동작을 추상화하고 있기 때문입니다.
실제로 ECMAScript 표준에 따라서 for ... of
가 추상화하는 로직을 그대로 드러내면 아래와 같이 나타낼 수 있습니다.
const iterator = nums[Symbol.iterator]();
let step;
while (!(step = iterator.next()).done) {
const num = step;
/* 동작 ... */
}
위와 같이, for ... of
문은 Iterator를 생성하고, Iterator가 끝날 때까지 다음 요소를 차례차례 가져오는 작업을 “각각의 요소를 순회하는 작업”으로 추상화합니다. 이런 관점에서 봤을 때, for ... of
문은 선언적인 코드입니다.
- 실제로는 생성된 Iterator를 삭제하는 동작도 존재하므로 보다 추상화되는 로직이 많습니다.
코드의 관점을 벗어나면 보다 재미있는 예시를 생각할 수 있습니다.
“왼쪽으로 10걸음 걸어라” 라고 하는 말을 생각합시다. 여기에서
- “왼쪽”은 “북쪽을 바라보았을 때 90도 돌아간 위치” 를 추상화한 것입니다.
- “90도”는 “한 번의 회전을 360등분한 각의 90배만큼 시초선에 대해 시계 반대 방향으로 돌아간 것” 을 추상화한 것입니다.
- “시계 방향” 의 정의는 “북반구에서 해시계의 바늘이 돌아가는 방향” 을 추상화한 것입니다.
그래서 “왼쪽으로 10걸음 걸어라”는 사실 “북쪽을 바라보았을 때 한 번의 회전을 360등분한 각의 90배만큼 북반구에서 해시계의 바늘이 돌아가는 방향으로 돌아서, 동물이 육상에서 다리를 이용해 움직이는 가장 빠른 방법보다 느린, 신체를 한 지점에서 다른 지점으로 옮겨가는 행위를 10번 반복해라” 라는 말을 추상화한, 선언적인 말로 볼 수 있을 것입니다.
좋은 선언적인 코드 작성하기
위에서 선언적인 코드를 추상화 레벨이 높아진 코드로 살펴보았습니다. 그런데 선언적인 코드는 항상 좋은 것일까요? 토스에서는 추상화가 항상 좋은 것은 아닌 것처럼 선언적인 코드도 잘 쓰는 것이 중요하다고 생각합니다.
토스에서는 좋은 코드를 판단하는 제1원칙을 “수정하기 쉬운 코드”라고 생각합니다. 비즈니스 요구사항은 항상 빠르게 변하기 때문에, 개발자가 기민하게 대응하는 것이 중요하기 때문입니다. 그러면 선언적인 코드가 언제 수정하기 쉽고, 언제 그렇지 않은지 살펴봅시다.
먼저 아래와 같은 회원가입 폼 컴포넌트를 살펴봅시다.
<SignUpForm
onSubmit={result => {
/* 회원가입 결과에 따라서 특정 동작 수행 ... */
}}
/>
위 컴포넌트는 회원가입 로직을 하나의 컴포넌트로 추상화했기 때문에 선언적인 컴포넌트로 볼 수 있습니다.
이 코드는 수정하기 쉬울까요?
먼저 회원가입 폼을 여러 곳에서 사용한다면 각각의 폼을 중복해서 개발할 필요 없이 한 번만 개발하면 되기 때문에 효율적일 것입니다. 또한 회원가입 폼에 변경이 생긴다고 하더라도, 한 곳에서만 바꾸면 다른 화면들에 모두 반영되기 때문에 빨리 수정할 수 있을 것입니다.
수정하기 어려운 지점은 없을까요?
화면마다 SignUpForm
이 조금씩 다르다면, 공통화된 것이 오히려 코드의 복잡함을 가져올 수도 있습니다. 예를 들어서, 어떤 페이지에서는 SNS 회원가입을 일반 이메일 회원가입보다 먼저 보여줘야 할 수 있습니다. 또, 다른 페이지에서는 텍스트나 스타일을 조금씩 다르게 보여줘야 할 수 있습니다.
아래와 같이 SignUpForm
에서 바뀔 수 있는 부분이 많다면, 내부 구현과 인터페이스도 복잡해지고, 쓰는 쪽에서도 불편할 것입니다.
<SignUpForm
signUpOrder={['sns', 'normal']
title="사이트에 어서 오세요"
subtitle="먼저 회원가입을 해주세요."
primaryButtonColor={colors.blue}
secondaryButtonColor={colors.grey}
/* 많은 Prop 들 ... */
onCancel={/* ... */}
onSubmit={result => {
/* 회원가입 결과에 따라서 특정 동작 수행 ... */
}}
이처럼 토스에서는 선언적인 코드가 항상 좋은 것이 아니라, 앞으로 제품이 어떻게 변화할지, 비즈니스 요구사항이 어떻게 되는지에 따라서 달라질 수 있다고 생각하고 있습니다. 앞으로 코드의 어떤 부분이 수정될지 예측하고, 이에 따라 적절한 선언 레벨을 따르는 코드를 작성할 필요가 있습니다.
토스의 선언적 라이브러리
그렇다면 다양한 상황에서 일반적으로 사용할 수 있는 좋은 선언적 코드는 없을까요? 이번 아티클은 토스 프론트엔드 챕터가 100개가 넘는 서비스들에서 자주 사용하고 있는 선언적 라이브러리에 대해서 소개하고 마무리하려고 합니다.
useOverlay
토스에서는 BottomSheet, Dialog, Toast와 같이 화면 위에 뜨는 오버레이를 띄워야 하는 상황이 많습니다. 토스는 이렇게 오버레이를 띄우는 동작을 추상화하여 useOverlay
라고 하는 Hook 을 사용합니다.
const overlay = useOverlay();
<button
onClick={() => {
overlay.open(({ isOpen, close }) => {
return (
<BottomSheet open={isOpen} onClose={close}>
나는 바텀시트야
</BottomSheet>
);
})
}}
>
바텀시트 열기
</button>
예를 들어서, 위 코드에서는 바텀시트 열기
버튼을 누르면 나는 바텀시트야
라고 하는 바텀시트를 띄웁니다.
useOverlay
가 없었더라면 아래와 같이 제어 흐름이 드러나는 코드를 작성했어야 할 것입니다.
const [isSheetOpen, setIsSheetOpen] = useState(false);
<button onClick={() => setIsSheetOpen(true)}>
바텀시트 열기
</button>
<BottomSheet open={isSheetOpen} onClose={() => setIsSheetOpen(false)}>
나는 바텀시트야
</BottomSheet>
useOverlay
에 대한 자세한 정보는 Slash libraries의 useOverlay Hook을 참고해주세요.
ImpressionArea
토스 앱에서는 어떤 영역이 보여졌는지/숨겨졌는지에 따라서 동작하는 로직이 많습니다. 예를 들어서, 사용자가 특정한 요소를 보면 폭죽을 터뜨리거나 토스트를 보여주는 식이죠. 토스에서는 ImpressionArea
라고 하는 컴포넌트로 이를 추상화하고 있습니다.
<ImpressionArea onImpressionStart={() => { /* 보여졌을 때 실행 */ }}>
<div>내가 보여졌으면 onImpressionStart가 실행돼</div>
</ImpressionArea>
ImpressionArea
가 없었더라면, 복잡한 IntersectionObserver
API 를 사용하거나, 복잡한 Scroll 이벤트 핸들러 로직을 사용해야 했을 것입니다.
ImpressionArea
에 대한 자세한 정보는 Slash libraries의 ImpressionArea 컴포넌트를 참고해주세요.
LoggingClick
토스에서는 데이터 주도 의사결정을 위해서 화면에 진입하는 사용자가 몇 명인지, 그 중 몇 명이 버튼을 누르는지를 기록하는 경우가 있습니다. 이런 누르는 동작에 대한 기록을 추상화하여 LoggingClick
컴포넌트를 사용하고 있습니다.
<LoggingClick params={{ price }}>
<button onClick={buy}>사기</button>
</LoggingClick>
예를 들어서, 위 코드에서 사용자가 버튼을 누르면 “사기” 버튼에 대한 동작이 분석 시스템에 기록됩니다.
LoggingClick
이 없었다면 아래와 같이 log
함수를 실행하는 것이 그대로 드러났어야 할 것입니다.
<button
onClick={() => {
log({ title: '사기', price });
buy();
}}
>
사기
</button>