관심사 분리: UI와 로직을 나누는 방법

**관심사 분리(Separation of Concerns)**는 소프트웨어 설계의 오래된 원칙입니다. 서로 다른 역할을 하는 코드는 서로 다른 곳에 있어야 한다는 뜻입니다. React에서 이것은 주로 UI(렌더링)와 로직(데이터, 상태)을 분리하는 것을 의미합니다.

분리하지 않은 컴포넌트

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [searchQuery, setSearchQuery] = useState('');
  const [sortOrder, setSortOrder] = useState('asc');
 
  useEffect(() => {
    setLoading(true);
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
 
  const filteredUsers = useMemo(() => {
    return users
      .filter(u => u.name.includes(searchQuery))
      .sort((a, b) =>
        sortOrder === 'asc'
          ? a.name.localeCompare(b.name)
          : b.name.localeCompare(a.name)
      );
  }, [users, searchQuery, sortOrder]);
 
  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>오류: {error}</div>;
 
  return (
    <div>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="검색..."
      />
      <button onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
        정렬: {sortOrder}
      </button>
      <ul>
        {filteredUsers.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

하나의 컴포넌트에 API 호출, 필터링, 정렬, 렌더링이 모두 섞여 있습니다. 로직만 테스트하고 싶어도 컴포넌트 전체를 마운트해야 합니다.

관심사 분리: 로직을 훅으로 분리

로직을 커스텀 훅으로 꺼냅니다.

// useUserList.js - 로직만 담당
function useUserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [searchQuery, setSearchQuery] = useState('');
  const [sortOrder, setSortOrder] = useState('asc');
 
  useEffect(() => {
    setLoading(true);
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
 
  const filteredUsers = useMemo(() => {
    return users
      .filter(u => u.name.includes(searchQuery))
      .sort((a, b) =>
        sortOrder === 'asc'
          ? a.name.localeCompare(b.name)
          : b.name.localeCompare(a.name)
      );
  }, [users, searchQuery, sortOrder]);
 
  const toggleSort = () => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
 
  return { filteredUsers, loading, error, searchQuery, setSearchQuery, sortOrder, toggleSort };
}
// UserList.jsx - UI만 담당
function UserList() {
  const {
    filteredUsers,
    loading,
    error,
    searchQuery,
    setSearchQuery,
    sortOrder,
    toggleSort,
  } = useUserList();
 
  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>오류: {error}</div>;
 
  return (
    <div>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="검색..."
      />
      <button onClick={toggleSort}>정렬: {sortOrder}</button>
      <ul>
        {filteredUsers.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

컴포넌트는 이제 받아온 데이터를 어떻게 그릴지만 신경 씁니다.

더 나아가기: Presentational & Container 분리

로직을 담당하는 컴포넌트(Container)와 표현만 담당하는 컴포넌트(Presentational)를 분리하는 패턴입니다.

// Presentational: UI만, 상태 없음, 순수하게 props만 받음
function UserListView({ users, searchQuery, onSearchChange, sortOrder, onToggleSort }) {
  return (
    <div>
      <input value={searchQuery} onChange={onSearchChange} />
      <button onClick={onToggleSort}>정렬: {sortOrder}</button>
      <ul>
        {users.map(user => <li key={user.id}>{user.name}</li>)}
      </ul>
    </div>
  );
}
 
// Container: 로직만, JSX 최소화
function UserListContainer() {
  const { filteredUsers, loading, error, ...handlers } = useUserList();
 
  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>오류: {error}</div>;
 
  return <UserListView users={filteredUsers} {...handlers} />;
}

UserListView는 완전히 순수합니다. 어떤 데이터든 props로 받으면 그것을 그립니다. 스토리북(Storybook)에서 UI만 독립적으로 개발하거나, 테스트에서 다양한 데이터로 렌더링을 확인할 때 편리합니다.

어느 정도로 분리해야 할까?

모든 컴포넌트를 항상 분리할 필요는 없습니다.

분리가 도움이 되는 경우

  • 같은 로직을 여러 곳에서 쓸 때
  • 로직을 독립적으로 테스트하고 싶을 때
  • 컴포넌트가 너무 길어져서 읽기 힘들 때
  • UI를 스토리북 등에서 독립적으로 개발할 때

분리가 오히려 복잡성을 늘리는 경우

  • 한 곳에서만 쓰이는 단순한 컴포넌트
  • 로직이 UI와 강하게 결합되어 있어 분리의 이점이 없을 때

정리

관심사 분리의 핵심은 “이 코드가 무엇을 책임지는가”를 명확히 하는 것입니다.

역할담당
데이터 가져오기, 상태 관리커스텀 훅
비즈니스 로직, 계산커스텀 훅 또는 순수 함수
화면 그리기컴포넌트

컴포넌트가 커스텀 훅으로부터 데이터와 핸들러를 받아 JSX를 반환하는 구조가 되면, 각 파일의 역할이 분명해지고 코드를 이해하기 쉬워집니다.