더 많은 곳에서 선언형으로 작성하기

Park June Chul·2022년 2월 13일
1

잘 코딩하기

목록 보기
4/6
post-thumbnail

https://stackoverflow.com/a/33656983/16303534

StackOverflow의 위 답변은 선언형에 대한 좋은 예시인 것 같아서 가져왔습니다.

선언형은 실제로 수행해야 하는 일을 나열하기보다는, 어떤 일을 해야 하는지 만을 단순히 적고, 나머지 부분은 생략하는 개발 방법입니다.

React로 프로그램을 개발함에 있어서 UI 혹은 data fetching을 선언형으로 작성하고자 하는 시도, 혹은 예시는 이미 인터넷에 많이 있습니다. 하지만 선언형은 단순히 UI나 fetch를 해결하고자 나온 개념이 아닙니다.

여기서는 그 이외의 부분들을 선언형으로 가져가보는 예시를 설명합니다.

무한 loop를 지원하는 Carousel 컴포넌트를 만드는 상황을 가정해 보겠습니다.

img

한 화면에 동시에 3개의 이미지를 렌더해야 하기 때문에 아래와 같은 코드를 작성해볼 수 있습니다.

const Carousel = ({ data, index }) => {
   const prevIndex = index - 1;  // 왼쪽 (이전)
   const currentIndex = index;   // 가운데
   const nextIndex = index + 1;  // 오른쪽 (다음)
  
   return (
     <Container>
       <PrevSlide style={} src={data[prevIndex]} />
       <CurrentSlide style={} src={data[currentIndex]} />
       <NextSlide style={} src={data[nextIndex]} />
     </Container>
   );
};

이 작은 코드에서도 선언형으로 바꿔볼 수 있는 부분이 있습니다.

바로 인덱스를 관리하는 부분입니다.

이전, 현재, 다음 인덱스를 가져오는 코드를 아래와 같이 함수로 분리해보도록 하겠습니다.

// loop index - v1
const useLoopIndex = (index: number, max: number) => {
  return [index - 1, index, index + 1];
};

그럼 이제 Carousel 의 코드는 다음과 같이 변경될 수 있습니다.

const Carousel = ({ data, index }) => {
   const [prev, current, next] = useLoopIndex(index, data.length);
  
   /* ... */
};

하지만 위 코드들에는 몇 가지 문제점이 있습니다.

loop가능한 Carousel에서 이전, 다음 인덱스를 가져오는 작업은 실제로는 위 코드처럼 -1 +1 로 간단히 작성할 수 있는것이 아닙니다.

만약 현재 인덱스가 0 이라면 prevIndex가 -1을 가리키기 때문에 data[-1] 에 접근하면서 에러가 발생하겠지요,

이 버그를 수정하기 위해 useLoopIndex의 코드를 아래와 같이 고쳤습니다.

// loop index - v2
// edge에서의 `loop` 기능이 제대로 동작하게 합니다.
const useLoopIndex = (index: number, max: number) => {
  return [
    index - 1 >= 0 ? index - 1 : max - 1,
    index,
    index + 1 >= max ? 0 : index + 1,
  ];
};

이제 useLoopIndex 함수는 문제 없이 작동하는 것 처럼 보입니다.

올바른 input 이 들어올 때에 한해서는요.

useLoopIndex(10, 3) 같은 입력을 넣으면 [9, 10, 0] 같은 이상한 output이 나오게 됩니다.

이상한 값을 넣는 사람이 잘못이라고 생각하실 수 도 있지만, 다음과 같은 관점에서 보면 범위를 벗어난 값이 들어오는게 그렇게 이상한 일이 아닐 수도 있습니다.

  • Carousel처럼 애니메이션을 다루는 경우, 값이 범위를 벗어나는 일은 의외로 발생할 수 있습니다. Animation과 결합된 코드에서 clamping 처리는 중요합니다.
  • 만약 우리가 만드는 Carousel이 npm에 올라가 있는 별도의 모듈이고, 다른 사람들이 막 가져다 쓴다고 하면 이러한 우아한 처리를 해주는것이 더 좋을 수 있습니다.

그래서 결국은 잘못된 input이라도, 제대로 동작할 수 있도록 함수를 수정하기로 했습니다.

useLoopIndex를 아래와 같이 수정합니다.

// loop index - v3
// 범위를 벗어난 input에 대해서도 동작을 보장하도록 합니다.
const useLoopIndex = (index: number, max: number) => {
  index = Math.abs(index) % max;
  return [
    index - 1 >= 0 ? index - 1 : max - 1,
    index,
    index + 1 >= max ? 0 : index + 1,
  ];
};

이제 우리의 Carousel은 어떠한 숫자를 넣던지에 관계없이 loop기능이 동작하게 되었습니다!

하지만, 이 글의 주제는 Carousel 만들기가 아닙니다.
이제 다시 선언형 프로그래밍 으로 돌아가서 이러한 일련의 과정들이 왜 좋은지에 대해서 얘기해보도록 하겠습니다.

위 3번의 변경사항을 적용하는동안 Carousel 컴포넌트에 영향이 있었나요?
전혀 없었습니다.

Carousel 컴포넌트는 그저 useLoopIndex 가 올바르게 동작할것을 기대하고 그 값만 사용하지, 내부적으로 일어나는 행동에 대해서는 전혀 의존성을 가지지 않습니다.

이것은 구체적인 할일이 아니라 동작만을 명시하는 선언형 프로그래밍의 강점입니다.

선언형 프로그래밍의 장점을 조금 더 나열해보자면 아래와 같습니다.

  • 재사용성이 늘어납니다.
    • useLoopIndex는 input과 ouput이 너무 단순 명확하기 때문에 Carousel이 아니라 다른 loop가 필요한 곳에서도 사용할 수 있습니다.
  • 테스트의 범위가 분리됩니다.
    • 이제 우리는 Carousel의 loop 기능을 useLoopIndex 함수를 테스트함으로써 정상적으로 동작할것임을 보장할 수 있습니다.
    • 그 전에는 어떻게 해야 했을까요? Carousel 에게 마우스 이벤트를 보내고, 렌더링 결과물을 체크함으로써 loop 기능이 잘 동작하는지를 테스트해야 했을 수도 있습니다.
  • Carousel 컴포넌트의 소스 코드 라인 수가 줄어듭니다.
    • 이것은 단순한 농담이 아니라 중요한 부분이라고 생각합니다.
  • 빠른 개발(일단 개발)이 가능하도록 합니다.
    • 우리는 useLoopIndex의 중간 단계에서도 얼마든지 개발을 시작할 수 있게 됩니다. v1, v2, v3 버전 모두 개발자가 input값을 통제한다면 Carousel 을 제작하기에는 충분합니다.
    • 덜 중요한 부분을 분리시키고, 나중에 완성시키는 개발 방법을 도입해보기 적당합니다.

어떻게 보면 TDD가 지향하는 철학과 비슷할지도 모르겠습니다.

검색 기록 컴포넌트 만들기

이번에는 네이버 검색 기록같은, 자신이 이전에 무엇을 검색했는지 보여주는 UI를 작업한다고 가정해 보겠습니다.

이번 예시에서는 위에서 언급한 빠른 개발(일단 개발) 이 선언형 프로그래밍을 통해서 어떻게 이루어지는지를 보여줍니다.

검색 기록 저장은 단순한 문제처럼 보이지만, 여러가지 정해야 할 문제들 로 인해 바로 시작할수는 없을수도 있습니다.

  • 어디에 저장해야 할까요?
    • 서버? 클라이언트?
    • 클라이언트에 저장한다면 localStorage, SQLite 어디에 저장할건가요?
  • 저장하는 KEY는 어떻게 되어야 할까요?
    • 팀/회사의 컨벤션이 있나요
    • 아니면 컨벤션을 만드는 회의부터 해야 할 수도 있습니다.
  • 몇 개를 저장해야 할까요?
    • 10개? 혹은 20개?

단순한 기능이지만 정해야 할 것들이 많습니다.

때때로 이러한 정할 거리들은 단순히 개발 영역을 벗어난 고민일수도 있고, 무엇보다 전체적인 개발 프로세스를 늦춥니다.

하지만 모든것을 명확히 한 다음 개발을 시작하기에는 너무 늦습니다.
여기서는 이러한 고민들을 뒤로 한 채, 아래와 같은 아주 간단한 useSearchHistory 함수를 만들어보도록 하겠습니다.

const useSearchHistory = () => {
  return ['테스트1', '테스트2', '테스트3'];
};

아주 간단합니다.
목업 데이터를 반환하는 useSearchHistory 함수를 만들었습니다.

이제부터는 검색 기록 저장 스펙이 어떤 방식으로 정해지던간에 SearchHistory 개발을 시작할 수 있습니다.

const SearchHistory = () => {
  // '어떻게 가져올지'는 상관 없이
  // '무엇이 필요한지' 만 정의합니다.
  const history = useSearchHistory();
  
  return (
    <div>
      {history.map(x => (
        <div>{x}</div>
      ))}
    </div>
  );
};

useSearchHistory는 나중에 시간이 날 때, 혹은 어디에 저장해야하는지가 명확해질 때 아래와 같이 다시 작성해볼 수 있습니다.

// AsyncStorage에 저장하고, KEY는 searchHistory
// 를 사용하는것으로 이야기가 마무리 되었습니다.
const useSearchHistory = () => {
  // 대충 수도 코드 입니다.
  return await AsyncStorage.getItem('searchHistory');
};

프로그램의 스펙은 언제든지 변할 수 있습니다.

나중에 기획 회의에서 사용자 경험 일관성을 위한 디바이스간 검색 기록 동기화 라는 스펙이 추가된 상황을 가정해 보겠습니다.

이제 검색 히스토리는 로컬 스토리지가 아니라 서버 API를 통해 가져와야 합니다.

const useSearchHistory = () => {
  // 대충 수도 코드 입니다.
  return await fetch('/history');
};

이게 끝입니다!

SearchHistory 컴포넌트는 검색 기록을 네트워크에서 가져오던, 로컬 스토리지에서 가져오던, 심지어 그냥 테스트 데이터를 넣던지 어느 단계에서든 작업이 가능하고, 각 단계에서 코드가 변경될 필요가 전혀 없습니다.

이러한 일련의 과정들은 구현선언의 뒤로 숨겼기 때문에 가능합니다.

profile
다른 곳에서 볼 수 없는 이상한 주제를 다룹니다. https://pjc0247.github.io/new-home

0개의 댓글