[React #15] 관심사의 분리(Separation of Concerns, SoC)와 Custom Hook

Kayoung Kim·2022년 2월 16일
16

React

목록 보기
15/15
post-thumbnail

Separation of Concerns

  • 하나의 함수, 변수, 클래스, 컴포넌트에게 한번에 너무 많은 일(concerns)을 부여하게 되면 그 코드를 읽는 사람은 혼란스러울 수 있다.
  • 가장 간단한 해결책은 한번에 한 가지 걱정만 하도록 단위를 잘게 나누는 것이다. 즉, 코드는 단위 별로 하나의 관심사만 갖도록 하고 그 관심사에 대해서만 충실히 동작하도록 만들어야 한다.
  • 그렇게 되면 어떤 문제가 생겼을 때 전체 기능을 파악하기 위해 읽어야 하는 코드의 단위가 줄어들게 되고, 코드에 대한 파악이 빨라지므로 문제를 효과적으로 해결할 수 있다.Divide & Conquer (분할 - 정복)의 용이
  • 또한 하나의 수정사항으로 인해 전체가 수정되는 것이 아니라, 해당 사항이 있는 일부분만 변경시키기 때문에 변화에 대해서도 내구성을 갖추게 된다.
  • 컴퓨터 공학에서는 이렇게 '한번에 한 가지만 걱정해도 괜찮도록' 각각의 관심사에 따라 코드를 분리하는 기법을 관심사의 분리 라고 부른다.
  • 관심사의 분리가 적절히 구현된 코드에서는 Loose Coupling (낮은 결합도, 각각의 코드가 서로 얽혀있지 않고 독립적으로 잘 분리되어 있음)과 High Cohesive (높은 응집도, 유사한 내용끼리 비슷한 위치에 잘 모여 있음)와 같은 특성을 발견할 수 있다.

관심사의 분리 장점

  • 코드가 더욱 명료해짐 : 자신이 어떤 일을 하고, 어떤 목적을 가지고 설계된 코드인지 보다 잘 드러나게 됨.
  • 코드 재사용성이 올라감 : 여러 역할이 엉켜있는 코드보다, 역할 별로 잘 분리되어 있는 코드를 재사용하기가 쉬움.
  • 유지 보수가 용이함 : 변경 사항이 발생했을 때 해당 관심사에 연관된 코드만 수정하면 됨.
  • 테스트 코드를 작성하기 쉬워짐 : 얽혀있는 로직보다 분리되어 있는 로직에 대한 테스트가 보다 더 간단해짐

예시

const loginForm = document.getElementsByClassName('loginForm')[0];
const loginBtn = document.getElementById('loginBtn');

function handleInput() {
  const idValue = document.getElementById('id').value;
  const pwValue = document.getElementById('pw').value;

  const isIdValid = idValue.length > 1;
  const isPasswordValid = pwValue.length > 1;
  const isLoginInputValid = isIdValid && isPasswordValid;

  loginBtn.disabled = !isLoginInputValid
  loginBtn.style.opacity = isLoginInputValid ? 1 : 0.3;
  loginBtn.style.cursor = isLoginInputValid ? 'pointer' : 'default';
}

const init = () => {
  loginForm.addEventListener('input', handleInput);
};

init();
function $(selector) {
  return document.querySelector(selector)
}

const loginForm = $('.loginForm');
const loginBtn = $('#loginBtn');

const validator = {
  id: (id) => id.length > 1,
  password: (password) => password.length > 1,
}

function validateForm(form, validator) {  
  for (const key in form) {
    const value = form[key]
    const isValid = validator[key](value)
    
    if (!isValid) return false
  }
  
  return true
}

function handleButtonActive(button, isFormValid) {
  button.disabled = isFormValid ? false : true;
  button.style.opacity = isFormValid ? 1 : 0.3;
  button.style.cursor = isFormValid ? 'pointer' : 'default';  
}

function handleInput() {
  const isFormValid = validateForm({
    id: $('.id').value,
    password: $('.pw').value
  }, validator)
  
  handleButtonActive(loginBtn, isFormValid)
}

function init() {
  loginForm.addEventListener('input', handleInput);
};

init();

View와 Logic의 분리

  • '관심사의 분리'를 프론트엔드 개발에 적용할 수 있다.

  • Redux와 Create React App의 창시자로 React 생태계에서 아주 유명한 Dan Abramov는 2015년 본인의 블로그에 Presentational - Container 패턴으로 React 컴포넌트를 관리하는 방법을 제안했다. (본문 링크)

  • Presentational Component : UI Only 컴포넌트 (View)

    const UserList = ({ users }) => {
    	return (
    		<ul>
    			{users.map(user => {
    				return <li key={user.id}>{user.name}</li>
    			})}
    		</ul>
    	)
    }
    • state, effect 등 로직 없음 → 화면을 렌더링하는 데 필요한 코드만 존재 (갖더라도 UI에 대한 상태만)
    • 함수 컴포넌트로 구현
  • Container Component : Logic Only 컴포넌트 (Logic)

    class UserListContainer extends React.Component {
    	state = {
    		users: []
    	}
    
    	componentDidMount() {
    		fetchUsers("/users")
    			.then(res => res.json())
    			.then(res => this.setState({ users: res.users }))
    	}
    
    	render() {
    		return <UserList users={this.state.users}>
    	}
    }
    • 데이터 호출, state, 필터링 등 복잡한 로직을 처리하는 코드만 존재 (UI 관련 코드를 가져서는 안됨)
    • 클래스형 컴포넌트로 구현
  • 이 패턴이 제안될 당시에는 Hooks가 존재하지 않았다. 그래서 함수 컴포넌트는 State와 Effect를 처리할 수 없었고, UI를 그리는 데만 제한적으로 사용되었다. 반면 클래스형 컴포넌트는 State와 Life Cycle API(Effect에 대응됨)를 사용해 React의 모든 기능을 구현할 수 있었다.
    => 이렇게 View와 Logic을 분리함으로써 컴포넌트는 더 작은 영역에 대해 더 확실한 책임을 지는 여러 개의 컴포넌트로 분할된다.

  • View에 집중하는 Presentational Component는 단순히 주입 받은 정보를 렌더링할 뿐(ex. (state, props) ⇒ UI)이다. 주입 받은 정보만 올바르다면 컴포넌트는 항상 올바른 UI를 리턴하게 된다. 로직과 분리되었기 때문에 여러 곳에서 재활용하기도 쉽고, 여러 가지 Container Component와 조합하기도 쉽다.

  • 하지만 Hooks의 등장으로 더이상 이 패턴을 권장하지 않는다.

Custom Hook

  • Custom Hook은 이름이 use로 시작하는 자바스크립트 함수다.
  • Custom Hook을 사용하면 지금까지 컴포넌트 내부에 한 덩이로 결합하여 사용했던 State와 Effect를 다음과 같이 분리하여 사용할 수 있다. 마치 여러 벽돌을 끼워 맞춰 건물을 만들듯이 React 컴포넌트를 여러 Hook을 조합하는 방식으로 개발할 수 있게 된다.
  • 또한 로직을 독립적인 함수로 분리함으로서 하나의 로직을 여러 곳에서 반복적으로 재사용할 수 있다.
const UserList = ({ users }) => {
	const [users, setUsers] = useState([])

	useEffect(() => {
		fetchUsers("/users")
			.then(res => res.json())
			.then(res => setUsers(res.users))
	}, [])
	
	return (
		<ul>
			{users.map(user => {
				return <li key={user.id}>{user.name}</li>
			})}
		</ul>
	)
}
  • View와 Logic을 분리해보면 아래와 같다.
const UserList = ({ users }) => {
	// Logic
	const users = useGetUserList()
	
	// View
	return (
		<ul>
			{users.map(user => {
				return <li key={user.id}>{user.name}</li>
			})}
		</ul>
	)
}
const useGetUserList = () => {
	const [users, setUsers] = useState([])

	useEffect(() => {
		fetchUsers("/users")
			.then(res => res.json())
			.then(res => setUsers(res.users))
	}, [])

	return users
}
  • UserList 의 내용을 파악해야 하는 입장에서 적절하게 지어진 Custom Hook의 이름과, 그 Hook이 리턴하는 user 라는 이름의 변수를 통해 세부 구현사항은 모르겠지만, 어쨌든 저것이 유저들에 대한 정보라는 사실은 어렵지 않게 알 수 있게 된다.
  • 이제 유저의 정보를 가져오는 세부 구현 사항에 관해서는 UserList 라는 컴포넌트를 살펴보지 않고 useGetUserList hook만 집중하여 살펴보면 된다.

여러 기능이 포함된 컴포넌트의 분리

  • 사용자에게 input을 받고, 해당 내용을 서버로 전송하는 기능과 화면의 스크롤 높이, 위치 등을 받아오는 기능이 모두 들어있는 경우
const UserInput = () => {
	const [size, setSize] = useState({ width: 0, height: 0 })
	const [position, setPosition] = useState({ x: 0, y: 0 })
	const [userInfo, setUserInfo] = useState({
		username: "",
		id: "",
		password: "",
		email: ""
	})

	const handleUserInput = (e) => {
		const { name, value } = e.target
		
		setUserInfo(prev => ({ ...prev, [name]: value }))
	}

	useEffect(() => {
		const handleDocumentSize = () => { ... }

		document.addEventListener("resize", handleDocumentSize)
		return () => {
			document.removeEventListener("resize", handleDocumentSize)
		}
	}, [])

	useEffect(() => {
		const handleDocumentPosition = () => { ... }

		document.addEventListener("resize", handleDocumentPosition)
		return () => {
			document.removeEventListener("resize", handleDocumentPosition)
		}
	}, [])

	return (
		<div>
			<input name="username" onChange={handleUserInput} />
			<input name="id" onChange={handleUserInput} />
			<input name="password" onChange={handleUserInput} />
			<input name="email" onChange={handleUserInput} />
		<div />
	)
}
  • 두 가지 Custom Hook으로 나누어 분리해보면, 먼저 UserInput 에 Logic을 추출하여 View 만 남았고, 유저 정보와 관련된 useUserInput 과 화면 크기 조절시마다 문서의 크기와 위치를 리턴하는 useDocumentResize 두 가지 로직으로 분리되어 있음을 확인할 수 있다.
const UserInput = () => {
	const { userInfo, handleUserInfo } = useUserInput()
	const { size, position } = useDocumentResize()

	return (
		<div>
			<input name="username" onChange={handleUserInput} />
			<input name="username" onChange={handleUserInput} />
			<input name="username" onChange={handleUserInput} />
			<input name="username" onChange={handleUserInput} />
		<div/>
	)
}
  • 원래 UserInput 에 한덩어리로 있던 유저 정보 입력 관련한 로직이 useUserInfo 로 분리되었다.
const useUserInfo = () => {
	const [userInfo, setUserInfo] = useState({
		username: "",
		id: "",
		password: "",
		email: ""
	})

	const handleUserInput = (e) => {
		const { name, value } = e.target
		
		setUserInfo(prev => ({ ...prev, [name]: value }))
	}

	return { userInfo, handleUserInput }
}
  • 이전에 UserInput 에 한덩어리로 있던 화면 리사이즈 관련한 로직이 useDocumentResize 로 관심사에 따라 분리되었다.
const useDocumentResize = () => {
	const [size, setSize] = useState({ width: 0, height: 0 })
	const [position, setPosition] = useState({ width: 0, height: 0 })

	useEffect(() => {
		const handleDocumentSize = () => { ... }

		document.addEventListener("resize", handleDocumentSize)
		return () => {
			document.removeEventListener("resize", handleDocumentSize)
		}
	}, [])

	useEffect(() => {
		const handleDocumentPosition = () => { ... }

		document.addEventListener("resize", handleDocumentPosition)
		return () => {
			document.removeEventListener("resize", handleDocumentPosition)
		}
	}, [])

	return { size, position }
}
  • 모든 경우에 대해 무조건적으로 이런 식의 분리가 권장되는 것은 아니다. 섣부른 추상화는 오히려 코드를 여기저기로 분리시켜 변경을 힘들게 만든다. 추상화가 필요한 경우와 그렇지 않은 경우를 잘 판단하여 코드를 분리해야 한다.

참고

1) Custom Hook의 이름은 use로 시작되어야 한다.

  • React에서 Hook의 동작을 처리하는 내부적인 규칙과도 관련이 되어 있고, 공식적인 컨벤션이기 때문에 Custom Hook을 작성할 때는 꼭 use- 로 시작하는 이름을 지어야 한다.

2) Hook 안에서 다른 Hook을 호출할 수 있다.

  • Hook은 State, Effect를 포함할 수 있는 자바스크립트 함수이고, 또 다른 Hook 역시 동일하게 State, Effect를 포함하고 있는 자바스크립트 함수이기 때문에 Hook과 Hook이 중첩 호출될 수 있다.
import { useParams } from "react-router-dom"

const useGetUserList = () => {
	const [users, setUsers] = useState([])
	**const { id } = useParams()**

	useEffect(() => {
		fetchUsers(`/users/${id}`)
			.then(res => res.json())
			.then(res => setUsers(res.users))
	}, [])

	return users
}

3) 같은 Hook을 사용하는 두 개의 컴포넌트는 state를 공유하지 않는다.

  • 두 Custom Hook은 서로 호출되는 위치와 타이밍이 다르며, 애초에 서로 다른 스코프(유효범위)를 생성하기 때문에 컴포넌트를 여러번 호출하는 것처럼 완전히 독립적으로 작동한다.

Use Cases of Cumstom Hook

1) useToggle

  • boolean 타입의 state를 toggle하는 기능 예시.
    export const useToggle = (initialValue = false) => {
      const [state, setState] = useState(initialValue);
    
    	const handleToggle = () => {
    		setState(prev => !prev)
    	}
    
      return [state, handleToggle]
    };

2) useLockBodyScroll

  • Modal이 띄워져 배경 스크롤을 막아둬야 할 때 사용합니다.
    const useLockBodyScroll = () => {
      useLayoutEffect(() => {
        document.body.style.overflow = 'hidden';
        return () => {
          document.body.style.overflow = '';
        };
      }, []);
    };

3) useOutsideClick

  • 특정 DOM의 바깥 지점이 클릭 되었는지를 파악하기 위해 해당 DOM의 ref를 인자로 넘기고, 클릭이 일어났을 때의 동작을 두 번째 인자로 정의해둡니다.
    import { useState, useEffect, useRef } from "react";
    
    function App() {
      const ref = useRef();
      const [isModalOpen, setModalOpen] = useState(false);
    	
    	// 활용 예시
      **useOnClickOutside(ref, () => setModalOpen(false));**
    
      return (
        <div>
          {isModalOpen ? (
            **<div ref={ref}>**
              👋 Hey, I'm a modal. Click anywhere outside of me to close.
            </div>
          ) : (
            <button onClick={() => setModalOpen(true)}>Open Modal</button>
          )}
        </div>
      );
    }
    // Custom Hook이 Side Effect만 일으키고 return 값이 없으면
    // 굳이 리턴 값을 정의해주지 않아도 됩니다!
    function useOnClickOutside(ref, handler) {
      useEffect(
        () => {
          const listener = (event) => {
            if (!ref.current || ref.current.contains(event.target)) {
              return;
            }
            handler(event);
          };
    
          document.addEventListener("mousedown", listener);
          document.addEventListener("touchstart", listener);
          return () => {
            document.removeEventListener("mousedown", listener);
            document.removeEventListener("touchstart", listener);
          };
        },
    
        [ref, handler]
      );
    }

4) useFetch

  • 특정 url에 대한 GET 요청에 대해 loading, data, error 세 가지 값을 리턴하도록 하는 Custom Hook
  • loading: 데이터가 요청 중일 때는 true, 그 이외의 경우에는 false를 리턴
  • data: 서버에서 가져온 값. 초기값은 undefined, 완료된 후에는 해당 값을 리턴
  • error: 요청 도중 error가 일어났을 때 에러 객체를 리턴하고, 그 이외의 경우에는 false를 리턴
//usefetch.js
import { useState, useEffect } from "react";

//url을 인자로 받아온다.
const useFetch = url => {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setTimeout(() => {
      fetch(url)	
        .then(res => {
          if (!res.ok) {
            throw Error("could not fetch the data for that resource");
          }
          return res.json();
        })
        .then(_data => {
          setData(_data);
          setIsPending(false);
          setError(null);
        })
        .catch(err => {
          setIsPending(false);
          setError(err.message);
        });
    }, 1000);
  }, [url]);

  return { data, isLoading, error };
};

export default useFetch;
    export default function Component() {
      const url = "http://jsonplaceholder.typicode.com/posts"
      const { loading, data, error } = useFetch(url)
    
      return <div>{status}</div>
    }

0개의 댓글