본 포스팅은 SOLID Principles in React를 보고 개인적으로 해석(발 번역)한 내용입니다.

좋은 코드는 일반적으로 3가지의 특징이 잘 결합된 코드를 말합니다.

  • Functional: The code comes together to create the desired functionality.
  • Maintainable: 대체 코드를 만들기, 새로운 feature를 추가하기, 다른 개발자가 코드의 동작을 알아보기가 쉽다.
  • Robust: 훌륭한 테스트 커버로 기능을 깨기 어려우며, 에러가 일어날 때 올바르게 처리한다.

위 특징들은 개발자라면 모두 알고 있는 사실입니다. 우리가 궁금한 내용은

"어떻게 우리가 저 3가지를 지키며 개발할 수 있을까?"

입니다.

이러한 질문은 소프트웨어 산업이 발전하며 과거의 실수로부터 대답을 얻어왔습니다. 소위 "rules of thumb"라는 규칙들이 위의 3가지를 만족하기 위해 생겨나게 되었죠. 이런 rules of thumb는 우리가 흔히 best practices, software development philosophies, design principles 라고 부릅니다.

하지만 이런 general한 규칙들은 개발자의 재량에 의존합니다. 특정 원칙을 너무 강하게 따르면 오히려 소기의 3가지 목적을 이루는데 역효과가 나기도 하죠. 이런 벨런스를 찾는 것은 프로그래밍 언어, 스타일, 개인적인 취향, 요구사항, 시간 등등의 요소에 달려있습니다.

이 포스팅은 SOLID principles에 대한 내용과 이를 어떻게 React를 사용한 프론트엔드 코드에 적용할 수 있을지에 관련된 내용입니다.

Software design principles exist to make our code more functional, more maintainable, and/or more robust. They are rules of thumb based on wisdom from the past, and will hold for almost any language/paradigm/project you work on. They won't give you any specific knowledge of programming, however they are probably the best "bang-for-your-buck" that you can find in order to develop your ability to write functional, clean, and robust software.

들어가기 전

Software design principles는 언어에 상관 없이 도움을 줍니다. 하지만 SOLID가 OOP에서 발전한 사실은 명백한 사실입니다. 자바스크립트는 prototype을 이용한 OOP를 지원하지만, 구분되는 다른 특징들도 존재합니다. 게다가 리액트는 OOP보다 함수형 프로그래밍에 더 그 근본을 두며 발전하고 있습니다. 특히 hooks나 클로져 등이 그렇죠. 따라서 모든 SOLID를 자바에 적용하듯 자바스크립트에 적용할 수도 없고 그래서도 안됩니다. 우리는 SOLID의 각 원칙이 어떤 의도를 가지고 있느냐에 집중해야 하며 이를 어떻게 프론트엔드에 의미있게 적용할 수 있을지 고민해야 합니다.

우리는 더 나은 React코드를 위해 SOLID 원칙을 사용할 수 있지만, 적용 가능한 구현을 위해 자유도를 반드시 고려해야 합니다.

What is SOLID?

SOLID는 아래의 5가지 디자인 원칙을 말합니다.

  • Single Responsibility Principle (SRP/단일 책임 원칙).
  • Open/Closed Principle (OCP/개방폐쇄의 원칙).
  • Liskov Substitution Principle (LSP/리스코브 치환 원칙).
  • Interface Segregation Principle (ISP/인터페이스 분리의 원칙).
  • Dependency Inversion Principle (DIP/의존성 역전의 원칙).

우리는 위 원칙들을 리액트에 적용하기 위한 특정 구현법으로 해석할 수 있습니다.

  • SRP: 모든 function/class/component는 한 가지 일만 해야 한다.
  • OCP: 기존 코드의 내용을 변경하지 않고, 특정 기능을 모듈에 추가할 수 있어야 한다.
  • LSP: 만약 B가 A를 상속한다면, A를 사용하는 모든 곳에서 B를 사용할 수 있어야 한다.
  • ISP: 컴포넌트가 신경쓰지 않는 props에 의존하지 않도록 한다.
  • DIP: 고레벨 코드는 디테일한 구현에 의존하면 안된다 - 항상 추상화를 사용해야 한다

위 내용은 우리가 모던 리액트 코드를 작성할 때 구현할 수 있는 내용들입니다. 각각의 디테일을 살펴보며 어떻게 이를 적용해 나가는지 살펴보겠습니다.

Single Responsibility Principle (SRP)

모든 function/class/component는 한 가지 일만 해야 한다.

리액트로 프론트엔드를 제작할 때 따라야 하는 가장 중요한 원칙입니다. SRP는 우리가 코드를 파편화 하고, 몇 천줄로 이루어진 하나의 파일을 50~100의 작은 파일들로 나눌 수 있도록 장려합니다. 이러한 특징은 특정 기능을 추출하여 파일을 분리된 함수로 나누어 모듈화를 돕습니다.

모듈화는 코드를 읽기 쉽고 유지보수하기 용이하게 만듭니다. 또한 코드를 이상치에 민감하지 않게 robust하게 만들어줍니다. 큰 파일을 작은 부분으로 나누어 테스트도 용이합니다. 요약하면, 좋은 테스트 코드를 짜기 어렵거나 너무 큰 파일들이 생성되는 상황이 반복된다면 코드를 더 나눠야 하는 신호일 수 있습니다.

큰 function/components는 여러가지 일을 하고 있다는 힌트입니다. 모듈화를 위해 작게 유지하도록 계속 노력하는 것이 좋습니다.

What Does "one thing" Mean?

우리는 계속 "한 가지"일을 해야 한다고 얘기하고 있습니다. 이 "한 가지"라는 의미가 무엇인지 조금 더 알아보겠습니다. Clean Code의 저자이자 SOLID 원칙의 선조인 Robert Martin은 이를 다음과 같이 정의합니다.

함수의 안에서 의미있게 다른 함수를 추출하지 못할 때 "한 가지"일을 한다. 함수 안의 코드에서 다른 함수를 추출해낼 수 있다면 이는 함수가 한 가지 이상의 일을 하고 있는 것이다. - Robert C Martin, Clean Code, Lesson 1

이러한 원칙을 어디까지 따를지는 개발자에게 달려있습니다. 하나의 원칙을 극한까지 따르는 것은 오히려 해로운 경우가 많죠. 하지만 이러한 원칙은 우리에게 출발점과 불확실한 상황에서 따를 수 있는 규칙을 제공합니다.

Using this in React

리액트의 입장에서 우리는 컴포넌트가 한 가지 책임을 갖도록 모듈화해야 합니다. 리액트에서의 한 가지가 "one pice of the UI"일까요? 또는 "one piece of logic associated with a piece of UI"일까요? 아래의 TodosPage 컴포넌트의 예시를 살펴보겠습니다. API를 통해 todos를 받아오고 첫 10가지를 분리하여 사용자에게 보여주는 기능입니다.

const TodosPage = () => {
    const [todos, setTodos] = useState([]);

    // 1. Fetching data from API.
    useEffect(() => {
        async function getTodos() {
            const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
            const firstTen = data.slice(0, 10);
            setTodos(firstTen);
        };
        getTodos();
    }, []);

    // 2. Converting todo array into list of React elements.
    const renderTodos = () => {
        return todos.map(todo => {
            return (
                <li>
                    {`ID: ${todo.id}, Title: ${todo.title}`}
                </li>
            )
        });
    };

    // 3. Structuring and displaying the todos.
    return (
        <div>
            <h1>My Todos:</h1>
            <ul>
                {renderTodos()}
            </ul>
        </div>
    )
};

위 코드가 하는 일을 생각해봅시다.

  1. 외부 API를 통해 todos를 받아온다.
  2. 받아온 todos를 보여주기 위한 리스트 형태로 변경한다.
  3. 유저에게 리스트를 보여준다.

우리의 TodosPage 컴포넌트는 todos가 어디서부터 왔는지, 어떤 포멧으로 보여줄지 등을 신경 쓸 필요가 없습니다. TodosPage는 오직 유저에게 todos를 보여주는 일만 신경쓰면 되는 것이죠. 아래와 같이 컴포넌트를 나눌 수 있습니다.

  • TodosPage - 유저에게 todos를 포함한 페이지를 보여준다.
  • TodosList - list의 실제 생성을 담당한다.
const TodosPage = () => {
  return (
    <div>
      <h1>My Todos</h1>
      <TodosList />
    </div>
  )
};

const TodosList = () => {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
      async function getTodos() {
          const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
          const firstTen = data.slice(0, 10);
          setTodos(firstTen);
      };
      getTodos();
  }, []);


  const renderTodos = () => {
      return todos.map(todo => {
          return (
              <li>
                  {`ID: ${todo.id}, Title: ${todo.title}`}
              </li>
          )
      });
  };

  return <ul>{renderTodos()}</ul>;
}

UI를 구별하게 되었습니다. 하지만 아직 TodoList 컴포는트는 많은 책임을 갖고 있습니다. 그러니 조금 더 분해해봅시다. 먼저 각각의 todo를 어떻게 렌더링 할지에 대한 내용을 추출하여 TodoItem으로 만들어 봅시다.

const TodosList = () => {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
      async function getTodos() {
          const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
          const firstTen = data.slice(0, 10);
          setTodos(firstTen);
      };
      getTodos();
  }, []);

  const renderTodos = () => {
      return todos.map(todo => {
          return <Todoitem id={todo.id} title={todo.title} />
      });
  };

  return <ul>{renderTodos()}</ul>;
}

const TodoItem = ({id, title}) => {
  return <li>{`ID: ${id}, Title: ${title}`}</li>
};

코드가 조금 더 나아진 느낌이 납니다. todo의 포맷에 대한 구체적인 구현은 다른 컴포넌트에게 넘겨 TodosList 컴포넌트가 더 적은 책임을 지게 되었습니다. UI는 어느정도 분리가 된 것 같습니다. 하지만 API를 호출하는 로직적인 부분이 조금 지저분해 보입니다. 만약 TodosList가 todos를 props로 받는다면 코드를 조금 더 간결하게 만들 수 있을 것 같습니다. 우리의 컴포넌트를 깔끔하게 하고 순수하게 UI에 관련된 책임만을 갖게 될 것입니다.

const TodosList = ({todos}) => {

  const renderTodos = () => {
      return todos.map(todo => {
          return <Todoitem id={todo.id} title={todo.title} />
      });
  };

  return <ul>{renderTodos()}</ul>;
}

적용을 위해 APIWrapper 컴포넌트를 만들어 TodosList를 감싸도록 하겠습니다. API를 통해 todos를 받아 TodosList에 전달하는 역할을 합니다.

const APIWrapper = ({children}) => {

  const [todos, setTodos] = useState([]);

  useEffect(() => {
      async function getTodos() {
          const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
          const firstTen = data.slice(0, 10);
          setTodos(firstTen);
      };
      getTodos();
  }, []);

  const todoListWithTodos = React.Children.map(
    children,
    (child) => {
      return React.cloneElement(child, { todos: todos })
    }
  )
  
  return (
    <div>
      {todos.length > 0 ? todoListWithTodos : null}
    </div>
  )
};

리액트가 익숙한 분들은 HOC가 떠오르셨을 것입니다. 이제 우리의 코드는 SRP에 더 알맞는 코드가 되었습니다.

// The main component in our web application which controls the TodosPage.
const TodosPage = () => {
  return (
    <div>
      <h1>My Todos</h1>
      <APIWrapper>
        <TodosList />
      <APIWrapper />
    </div>
  )
}
// The subcomponents we have created to modularise our program.
const APIWrapper = ({children}) => {

  const [todos, setTodos] = useState([]);

  useEffect(() => {
      async function getTodos() {
          const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
          const firstTen = data.slice(0, 10);
          setTodos(firstTen);
      };
      getTodos();
  }, []);

  const todoListWithTodos = React.Children.map(
    children,
    (child) => {
      return React.cloneElement(child, { todos: todos })
    }
  )
  
  return (
    <div>
      {todos.length > 0 ? todoListWithTodos : null}
    </div>
  )
}

const TodosList = ({todos}) => {

  const renderTodos = () => {
      return todos.map(todo => {
          return <Todoitem id={todo.id} title={todo.title} />
      });
  };

  return <ul>{renderTodos()}</ul>;
}

const TodoItem = ({id, title}) => {
  return <li>{`ID: ${id}, Title: ${title}`}</li>
}

각 컴포넌트들이 담당하는 역할을 확인해보겠습니다.

  • TodosPage - todos와 관련된 내용에 신경쓰지 않고 이를 포함한 페이지 표시에 집중합니다.
  • APIWrapper - todos의 포멧은 신경쓰지 않습니다. todos를 받아와 TodosList에 넘기는 작업에 집중합니다.
  • TodosList - todos가 어디서 왔는지 신경쓰지 않습니다. 리스트를 받아 표시하는 일에 집중합니다.
  • TodoItem - 얼마나 많은 todos가 있는지, 어디서 왔는지, 어떤 페이지에 표시될 것인지 신경쓰지 않습니다. id와 title을 받아 li 포멧으로 생성하는 일에 집중합니다.

처음과 비교하여 모듈화가 많이 진행되었습니다. 유지보수가 쉽고 컴포넌트가 한 가지 일만 관심을 갖습니다.

Overdoing

처음과 비교하여 오히려 더 복잡해지지 않았을까요? 첫 코드를 살펴보겠습니다.

const TodosPage = () => {
    const [todos, setTodos] = useState([]);

    useEffect(() => {
        async function getTodos() {
            const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
            const firstTen = data.slice(0, 10);
            setTodos(firstTen);
        };
        getTodos();
    }, []);

    const renderTodos = () => {
        return todos.map(todo => {
            return (
                <li>
                    {`ID: ${todo.id}, Title: ${todo.title}`}
                </li>
            )
        });
    };

    return (
        <div>
            <h1>My Todos:</h1>
            <ul>
                {renderTodos()}
            </ul>
        </div>
    )
};

누군가는 더 어렵다고 할 수 있습니다. 모든 코드를 뿌려놓아 오히려 무슨일이 일어나는지 파악하기 어려울 수 있습니다. 첫 코드를 보면 어떤 일이 일어나는지 모두 파악할 수 있습니다. TodosPage 컴포넌트가 어떻게 동작하는지 보기 위해선 우리는 4개의 모든 컴포넌트를 살펴봐야 합니다. 이렇게 첫 코드에 비해 오히려 코드에 대한 이해가 어려워진 경우 over-fragmentation으로 정의할 수 있습니다.

여기서 말하고자 하는 핵심은, 이런 원칙(SRP)이 우리의 소기의 3가지 목적 중 하나라도 달성하도록 도와주느냐는 것입니다.

  • 유즈케이스에 요구되는 기능을 제공한다.
  • 코드를 읽고 확장하기 쉽도록 하여 유지보수가 쉬워진다.
  • robustness를 높여 테스트를 쉽게하며 좋은 에러 핸들링을 더할 수 있다.

원칙만을 계속 따라가다 본래의 목적을 놓친다면 다시 돌아가야 할 수 있습니다.

The 'React-Way' of Fragmenting Component

위에서 본 패턴은 logic을 담당하는 container 컴포넌트와 UI를 담당하는 presentational 컴포넌트로 생각할 수 있습니다. 이런 패턴은 Dan Abramov가 코드를 깔끔히 정리하고 관심사를 분리하기 위해 승인한 개념입니다. 그러나 hooks의 등장 이후, 우리는 custom hooks 라는 새로운 분리 기법을 갖게 되었습니다. hooks를 깊게 살펴보지는 않겠지만, 이를 이용해 어떻게 페이지를 구현할 수 있는지 살펴보겠습니다.

const TodosPage = () => {
    const todos = useTodos();

    const renderTodos = () => {
        return todos.map(todo => {
            return <Todoitem id={todo.id} title={todo.title} />
        });
    };

    return (
        <div>
            <h1>My Todos:</h1>
            <ul>
                {renderTodos()}
            </ul>
        </div>
    )
};

const TodoItem = ({id, title}) => {
  return <li>{`ID: ${id}, Title: ${title}`}</li>
};

function useTodos(){
    const [todos, setTodos] = useState([]);

    useEffect(() => {
        async function getTodos() {
            const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos");
            setTodos(data);
        };
        getTodos();
    }, []);

    return todos;
};

API 로직을 useTodos 커스텀 훅으로 추출했습니다. 또한 각각의 todo 포멧팅을 TodoItem 컴포넌트로 분리했습니다. 위 코드는 SRP를 밸런스 있게 따라가면서도 TodosPage가 어떤 역할을 하는지 명확하게 전달할 수 있는 것 같습니다. 이런 함수/컴포넌트들은 상대적으로 쉽게 테스트 될 수 있습니다.

Use a combination of separate components and custom hooks to modularise larger components.

Open/Closed Principle (OCP)

많은 작은 컴포넌트로 큰 컴포넌트를 만든다.

OCP는 우리가 작성하는 코드의 확장성을 가져야 한다는 것을 설명합니다. 새로운 기능을 추가하는 경우 기존의 코드를 수정하지 않고 추가가 가능해야 합니다. 리액트의 언어로 얘기하자면 큰 컴포넌트를 만드는 경우 inheritance보다 composition을 사용해야 한다는 얘기입니다.

운 좋게도 이는 리액트 팀에서 추천하는 방법입니다. 공식 문서만 읽어봐도 이미 나와있는 내용이죠.

"At Facebook, we use React in thousands of components, and we haven't found any use cases where we would recommend creating component inheritance hierarchies. Props and composition give you all the flexibility you need to customize a component's look and behaviour in an explicit and safe way." - React Documentation, 2020 [8]

이 말은 큰 컴포넌트는 작은 컴포넌트로부터 만들어야 한다는 뜻입니다. 필요한 경우 props도 이용할 수 있습니다.

반면 아래와 같은 코드는 작성해서는 안됩니다.

// Bad!
class InputBox extends React.Component {
  constructor(props){
    super(props);
    this.state = {input: ""};
    this.handleChange = this.handleChange.bind(this);
  };

  handleChange(e){
    this.setState({})
  }
  render(){
    return (
      <div>
        <h1>Enter your name: </h1>
        <input value={this.state.input} onChange={this.handleChange} />
      </div>
    )
  }
}

class FancyInputBox extends InputBox {
  render(){
    return (
      <div>
        <h1 style={{color: "red"}}>Enter your name: </h1>
        <input value={this.state.input} onChange={this.handleChange} />
      </div>
    )
  }
}

위 코드는 흔히 말하는 inheritance를 사용했습니다. 이런 방식은 매우 잘못된 방식입니다. FancyInputBox는 InputBox에 강하게 결합되어 있습니다. InputBox에서 발생하는 모든 변화가 FancyInputBox에 그대로 전달되죠. 이는 확장성이 안좋다고 볼 수 있습니다.

"Tight-coupling" means that one component relies heavily on another, such that any change in one could break the other. This goes against the idea of modularity in code, and so we usually want to avoid this where possible.

A React Example

위 코드는 아래와 같이 더 나은 코드로 만들 수 있습니다.

// Better!!!
const InputBox = ({stylesForH1, h1Message}) => {

  const [input, setInput] = useState("");

  return (
    <>
      <h1 style={stylesForH1}>{h1Message}</h1>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
    </>
  )
};

const FancyInputBox = () => {
  return (
    <div>
      <InputBox stylesForH1={{color: "red"}} h1Message={"Enter your name: "} />
    </div>
  )
};

style, message를 props로 받는 InputBox 컴포넌트를 만들었습니다. 이후 FancyInputBox 컴포넌트를 만들어 InputBox에 원하는 props를 전달하는 방식으로 구현합니다.

이는 작은 컴포넌트로 부터 큰 컴포넌트를 만드는 하나의 예시입니다. 이는 InputBox를 수정, 확장 하는데 용이합니다.

Use composition to make large components, rather than extending from other components.

Liskov Substitution Principle(LSP)

하위 클래스를 대체할 수 있는 클래스 만들기

LSP는 하위 클래스는 상위 클래스를 대체할 수 있다는 내용입니다. 만약 B가 A를 상속했다면 우리가 A를 사용하는 모든 장소에서 A를 B로 대체할 수 있어야 한다는 내용입니다.

LSP는 리액트에 적용하지 않습니다. 최근 코드는 hooks를 기반으로 작성되며 class는 많이 사용되지 않고 있습니다.

Interface Segregation Principle (ISP)

컴포넌트에게 필요한 props만 전달한다

ISP는 사용하지 않는 인터페이스에 의존하면 안된다는 것을 의미합니다. javascript에는 interface가 없으므로 이는 크게 연관있는 얘기는 아닙니다. 하지만 이 아이디어 뒤에 있는 원칙을 해석해보면 우리가 리액트 코드에 적용할 수 있는 내용이 나옵니다. 컴포넌트에게 필요한 것들만 전달한다. 이는 어떤 high-level function도 구체적인 구현법은 신경쓰지 말아야 함을 의미합니다.

구체적인 구현 방법은 특정 과제 해결에 대한 특정한 방법을 말합니다. 예를들어 "getting todos from the API"는 우리가 해결해야 하는 과제이며 이를 해결하는 방법 중 "axios 라이브러리를 이용한다."는 구체적 구현 방법이죠.

Illustrating in React

리액트에선 props를 통해 필요한 내용을 전달합니다.

const DisplayUser = (props) => {
  return (
    <div>
      <h1>Hello, {props.user.name}! </h1>
    </div>
  )
};

DisplayUser 컴포넌트는 user에 대해 관심을 갖지 않습니다. 그저 user의 name이 필요할 뿐이죠. 만약 user의 name프로퍼티와 관련된 수정이 일어난다면 이러한 의존성은 문제가 발생할 수 있습니다. 예를 들어, 원래 user는 아마 이런식으로 작성되어 있을겁니다.

const user = {
  name: "josh",
  age: 23,
  hairColor: "blonde",
  heightInCm: 175
};

하지만, 이런식으로 변경될 수 있죠.

const user = {
  personalInfo: {
    name: "josh",
    age: 23
  },
  physicalFeatures: {
    hairColor: "blone",
    heightInC,: 175
  }
};

이렇게 되면 props.user.name은 undefined가 되고 DisplayUser 컴포넌트는 깨져버립니다. 이를 고치기 위해 우리는 DisplayUser 컴포넌트에게 구체적인 구현법을 적용하는게 아닌, 필요한 내용만 전달할 수 있도록 해야 합니다.

const DisplayUser = ({name}) => {
  return (
    <div>
      <h1>Hello, {name}! </h1>
    </div>
  )
};

const App = () => {
  const user = {
    personalInfo: {
      name: "josh",
      age: 23
    },
    physicalFeatures: {
      hairColor: "blone",
      heightInC,: 175
    }
  }
  return (
    <div>
      <DisplayUser name={user.personalInfo.name} />
    </div>
  )
};

이런 식으로 우리가 구체적인 구현을 변경하더라도 신경쓸 부분은 DisplayUser 컴포넌트에게 올바른 props의 전달 뿐입니다. DisplayUser 코드는 수정될 이유가 없죠.

Destructure out the needed props for a component if possible. This way, the component does not rely on the details in its parent component.

Dependency Inversion Principle (DIP)

언제나 구체적인 구현이 아닌 추상화를 이용해 high-level 코드를 작성한다.

DIP는 "hide the wiring behind the wall"을 명시합니다. 이는 언제나 추상화를 이용해 low-level details와 상호작용 하도록 하여 구현하는 것이죠. SRP, ISP와 매우 강하게 연결된 개념입니다. 리액트 개발자에게 있어 이는 high-level 코드가 특정 과제를 어떻게 해결하는지 신경쓰면 안된다는 의미로 해석되죠. 예를 들어 어떤 API를 호출해 todos 리스트를 받는다고 가정해봅시다. SRP에서 작성한 코드를 살펴보겠습니다.

const TodosPage = () => {
    const todos = useTodos();

    const renderTodos = () => {
        return todos.map(todo => {
            return <Todoitem id={todo.id} title={todo.title} />
        });
    };

    return (
        <div>
            <h1>My Todos:</h1>
            <ul>
                {renderTodos()}
            </ul>
        </div>
    )
};

TodosPage가 todos가 어떻게 또는 어디서 왔는지 신경쓰나요? 그렇지 않습니다. 오로지 useTodos()를 이용해 받아오는데에 집중하죠. useTodos 함수 뒤에는 많은 연결이 존재합니다. 이런 구현법은 우리의 코드와 목적을 훨씬 읽기 쉽게 만들어줄 뿐 아니라 useTodos()에 대한 목적도 훨씬 간결하게 알 수 있습니다. 그리고 그게 어떻게 사용되는지도 파악하기 쉽죠. 이런 목표는 SRP와 매우 강하게 연관되어 있습니다. 왜냐하면 우리가 function/components에서 기능을 계속 추출하기 때문에 필연적으로 high-level 코드가 추상화를 이용해 상호작용 하도록 하기 때문입니다. 우리가 만약 todos를 어떻게 받는지에 관한 내용을 고치고 싶다면(axios 대신 fetch를 쓰고싶다 등), 우리는 TodosPage 컴포넌트를 수정하지 않고도 이를 수행할 수 있습니다.

function useTodos(){
    const [todos, setTodos] = useState([]);

    useEffect(() => {

      // Refactored to use fetch() instead of axios.get() to call an API
        async function getTodosWithFetch() {
            const response = await fetch("https://jsonplaceholder.typicode.com/todos");
            const data = await response.json();
            setTodos(data);
        };
        getTodosWithFetch();
    }, []);

    return todos;
};

useTodos의 구체적인 구현법을 수정했습니다. 하지만 TodosPage는 아무것도 변화하지 않죠. 이는 또 이런 방식으로도 구현해도 문제가 없습니다.

// useTodos.js
import localTodos from "./todos.json";
function useTodos(){
    const data = localTodos.todos;
    return todos;
};
export default useTodos;

// TodosPage.js
import useTodos from "./useTodos.js";

const TodosPage = () => {
    const todos = useTodos();

    const renderTodos = () => {
        return todos.map(todo => {
            return <Todoitem id={todo.id} title={todo.title} />
        });
    };

    return (
        <div>
            <h1>My Todos:</h1>
            <ul>
                {renderTodos()}
            </ul>
        </div>
    )
};

위 코드는 todos를 얻는데 심지어 API를 호출하지도 않습니다. 대신 로컬 파일의 내용을 읽어 전달하죠. 구체적 구현법은 엄청나게 변경되었습니다. 하지만 우리가 useTodos 함수를 이용한 추상화에 의존하여 실제 todos를 전달받는 코드를 사용하기에 TodosPage 컴포넌트는 아무것도 수정할 필요가 없습니다.

Layers of abstraction

우리가 high-level 코드에서 얼마나 깊게 추상화를 진행해야 할까요? Robert Martin은 이에 관해 다음과 같이 말했습니다.

There is a fundamental rule for functions: every line of the function should be at the same level of abstraction, and that level should be one below the function name." - Robert C Martin, Clean Code, Lesson 1 [5]

이는 우리가 최종 low-level에 도달할 때 까지 한 단계씩 천천히 기능을 추출해야 한다는 것을 의미합니다. 좀 모호한 정의면서 프로그래머의 역량과 경험에 의존하죠. 잘못한 코드를 먼저 살펴보겠습니다.

const TodosList = () => {
  const [todos, setTodos] = useState([]);
  const [term, setTerm] = useState("");

  useEffect(() => {
      async function getTodos() {
          const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
          const filtered = data.filter(todo => todo.completed === false);
          const pattern = new RegExp(term, "g");
          const searched = filtered.filter(todo => pattern.test(todo.title));
          setTodos(searched);
      };
      getTodos();
  }, [term]);


  const renderTodos = () => {
      return todos.map(todo => {
          return (
              <li>
                  {`ID: ${todo.id}, Title: ${todo.title}`}
              </li>
          )
      });
  };

  return(
    <div>
    <input value={term} onChange={(e) => setTerm(e.target.value)} />
      <ul>
        {renderTodos()}
      </ul>
    </div>
  );
}

TodosPage에 많은 implementation details가 들어있습니다. 이는 대체 무슨일이 일어나는지 이해하기 어렵게 만들죠. 하지만 몇 가지 좋은 함수 명명과 추상화로 고칠 수 있습니다.

const TodosList = () => {
  const [term, setTerm] = useState("");
  const todos = useTodos(term);

  const renderTodos = () => {
      return todos.map(todo => {
          return (
              <li>
                  {`ID: ${todo.id}, Title: ${todo.title}`}
              </li>
          )
      });
  };

  return(
    <div>
    <input value={term} onChange={(e) => setTerm(e.target.value)} />
      <ul>
        {renderTodos()}
      </ul>
    </div>
  );
};

function useTodos(term){
    const [todos, setTodos] = useState([]);

    useEffect(() => {
      async function getTodos() {
          const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
          const filtered = data.filter(todo => todo.completed === false);
          const pattern = new RegExp(term, "g");
          const searched = filtered.filter(todo => pattern.test(todo.title));
          setTodos(searched);
      };
      getTodos();
    }, [term]);

    return todos;
};

이제 처음보다 좀 나아졌죠. TodosPage에서 구체적인 구현을 추출하여 우리가 해왔던 방식으로 해결했습니다. 하지만 useTodos함수에 과부하가 걸려있는 것 같습니다. 상대적으로 high-level로 추상적인 네트워크 요청과 같은 개념과 상대적으로 low-level인 패턴 매칭, array manipulation 등이 섞여있죠. 마치 Robert Martin이 말한 것 처럼 "This is rude! The programmer is taking you from the heights [high-level concepts] to the depths [low-level concepts] in one line." 인 것 같습니다. 따라서 우리는 좀 더 들어갈 수 있을겁니다.

function useTodos(term){
    const [todos, setTodos] = useState([]);

    useEffect(() => {
      async function getTodos() {
          const { data } = await axios.get("https://jsonplaceholder.typicode.com/todos/");
          const filteredAndMatchedTodos = filterAndMatchTodos(data, term);
          setTodos(filteredAndMatchedTodos);
      };
      getTodos();
    }, [term]);

    return todos;
};

function filterAndMatchTodos(todoList, searchTerm){
  const completedTodos = todoList.filter(todo => todo.completed === false);
  const pattern = new RegExp(searchTerm, "g");
  const matchingTodos = filtered.filter(todo => pattern.test(todo.title));
  return matchingTodos;
}

이제 좀 나아졌네요. useTodos 함수는 filtering/regex matching 같은 작업을 추출했습니다. 이제 useTodos 내의 모든 내용은 비슷한 정도의 추상화를 갖고 있습니다. 이정도만 해도 이미 충분히 좋은 코드입니다.

물론 더 추상화를 진행할 수 있습니다.

function filterAndMatchTodos(todoList, searchTerm){
  const completedTodos = filterTodoList(todoList);
  const matchingTodos = matchFilteredTodos(completedTodos, searchTerm); 
  return matchingTodos;
}

function filterTodoList(todoList){
  return todoList.filter(todo => todo.completed === false);
};

function matchFilteredTodos(compeltedTodos, searchTerm){
    const pattern = new RegExp(searchTerm, "g");
    const matchingTodos = compeltedTodos.filter(todo => pattern.test(todo.title));
    return matchingTodos;
};

...하지만 어떤 포인트에서 반드시 우리가 너무 멀리 온 것은 아닌지에 대한 판단이 필요합니다. 어떤 사람들은 위의 코드가 더 좋게 느껴질 것이고 누군가는 과하다고 느낄 수 있습니다. 예를 들면 filterTodoList는 그저 Array.filter 함수를 호출하는 것 뿐이고 따라서 이를 새로운 함수로 만드는건 과하다는 느낌이 들 수 있죠. 이 포스팅에서 여러번 말했던 것 처럼 이들은 가이드 라인에 불과합니다. 이런 가이드라인은 우리의 코드에 긍정적인 영향을 미치는 정도로 사용해야 합니다.

Final Thoughts

지금까지 SOLID의 의도를 해석하여 리액트 개발자로서 더 쉽고 좋은 어플리케이션을 개발하기 위한 가이드를 살펴봤습니다. 이런 생각은 모듈화를 통한 재사용성과 테스트 용이성에 중점이 잡혀있죠. 더 나아가 코드가 더 독립적일수록 버그에 따른 다른 영역에 대한 영향이 줄어들 것 입니다. 이는 우리가 원하는 "의존성 분리"이죠. 느슨한 결합도라고도 많이 부르는 것 같습니다. SRP를 적용하기 위해 custom hooks와 같은 최신 기술을 이용하여 기능을 추출하는 방법을 살펴봤습니다. 작은 컴포넌트들이 더 좋은 확장성을 갖기 위해 어떻게 open/closed principle을 적용할 수 있는지도 composition과 연관지어 살펴봤죠. ISP/DIP 에선 컴포넌트와 함수에 필요한 props만을 제공해야 하고, high-level에선 추상화를 이용해 상호작용 해야 한다는 것을 살펴봤습니다. 이러한 원칙들의 목표는 high-level 코드의 수정 없이 데이터베이스 작업과 같은 구체적인 구현법을 자유롭게 변경할 수 있게 하기 위함이었죠.

지금까지 원문에 대한 내용을 쭉 정리(발번역) 한 내용을 살펴봤습니다. 원문의 핵심은 다음과 같다고 생각합니다.

  • 소프트웨어 개발자는 좋은 코드를 작성해야 한다.
  • SOLID와 같은 원칙들은 좋은 코드를 작성하기 위해 선조들의 경험에서 탄생한 훌륭한 가이드이다.
  • 소기의 목적(좋은 코드)를 잃지 않는 선에서 좋은 가이드를 잘 적용해보자

위 내용을 리액트 개발자의 입장에서 어떻게 소화할 수 있는지에 관한 포스팅이라고 생각합니다.

개인적으로 정말 많이 도움 되었고 클린코드, 테스팅 과 같은 주제에 대해서도 많은 흥미가 생기는 글이었습니다.

읽어주신 분들께도 흥미로운 주제였길 바라며 어려운 주제인 만큼 오류나 틀린 내용이 존재한다면 피드백 해주시면 정말 감사하겠습니다.

<출처>

profile
웹 개발을 공부하고 있는 윤석주입니다.

0개의 댓글