front-end/react

[React] React에서 컴포넌트 간 상태 교환 문제 탐구 및 해결

hojung 2022. 11. 19.
728x90
반응형

1. 상태 관리의 어려움

리액트는 기본적으로 컴포넌트를 분리한다. 

컴포넌트를 분리하여 작성하면 그에 해당하는 로직을 쉽게 알아볼 수 있고 state변화에 따른 렌더링을 쉽게 다룰 수 있기 때문에 권장되는 방식이지만 여기에는 한 가지 문제가 생긴다. 바로 리액트에서는 state의 흐름이 단방향이라는 문제이다. 

 

만약 컴포넌트를 다음과 같은 방식으로 분리해두었다면 자식 컴포넌트에서 부모 컴포넌트로 state를 전달하는 것은 불가능하다. 

 

컴포넌트

만약 다음과 같은 페이지를 만들 때 컴포넌트를 어떻게 분리할 것인가에 대한 고민이 있었다. 나는 위에 검색 창에 해당하는 부분과 밑의 테이블을 분리했는데 여기에는 문제가 있었다. 

내가 구현한 컴포넌트는 다음과 같이 분리 되어 있었는데 userSearchForm의 검색 조건이 UserTable에 영향을 미쳐야 한다는 문제였다. 나는 데이터 페칭 라이브러리로 react-Query를 이용하고 있는데 UserTable 내부에서의 변경은 query 데이터를 변경하는 것에 문제가 없었지만 문제는 두 컴포넌트가 분리되어 있었고 

위의 다이어그램 처럼 userSearchForm에서 검색을 하면 UserTable에 결과가 반영되어야 하는 것이었다. 

검색의 과정은 단순히 검색 조건을 query문 안에 파라미터로써 보내면 그에 해당하는 데이터를 백엔드에서 like문으로 처리하여 보내주는 방식으로 구현하였다. 

 

다음은 ManageUserPage에서 데이터를 페칭해오는 useQuery문이다. 

const { data: userList, refetch } = useQuery(
		['getAllMembers', currentPage],
		() => getAllMembers(handleField(), searchQuery)(currentPage, 10),
		{},
	);

첫번째 인자로 field와 두번째 인자로 searchQuery를 받아 전달하고 이 useQuery문으로 받아온 데이터는 바로 userTable에 전달이 된다. 하지만 userSearchForm에서의 변경 사항을 userSearchForm의 부모 컴포넌트에게 전달하는 방법을 고민했는데 react에서는 간단하게 Dispatch함수에 해당하는 setter를 전달하는 것으로 간단하게 해결이 되기는 한다. 

 

2. 첫 번째 방법 setter 전달

const ManageUserList = () => {
	const queryClient = useQueryClient();
	const [email, handleEmail, setEmail] = useInput('');
	const [searchQuery, handleSearchQuery, setSearchQuery] = useInput('');

첫 번째로는 setter를 전달하는 방법이 있다. 내가 정의한 useInput훅은 단순히 input의 값을 useState의 state로 정의하고 이벤트가 발생할 때마다 setValue함수를 실행해주는 handler함수와 setter를 반환해주는 훅이다. 구현 필요 조건으로 다음 두 가지가 있다. 

1.  userSearchForm의 change Event가 ManageUserList의 상태에 영향을 미쳐야 한다. 

2.  userSearchForm에서 검색 버튼을 누르면 ManageUserList에 있는 상태가 초기화 되어야한다. 

나는 두 가지 이슈를 다음과 같은 방법으로 해결해보았다. 

 

1.  userSearchForm의 change Event가 ManageUserList의 상태에 영향을 미쳐야 한다. 

기본적으로 리액트는 단방향 데이터 흐름구조를 따르기 때문에 자식 컴포넌트인 userSearchForm에서 부모 컴포넌트인 userManageList로 props로 상태를 전달하는 것은 불가능하다. 하지만 리액트에서는 이와 같은 경우 

const [value, setValue] = useState();

에서 setValue를 (setter)를 자식 컴포넌트에 props로 전달하는 방식으로 이 문제를 해결할 수 있는데 예시 코드는 다음과 같다. 

부모 컴포넌트인 manageUserList안에 정의된 state들
자식 컴포넌트에서 props로 value와 setter모두를 받는다.

이렇게 setter를 전달해주면 자식 컴포넌트에서 발생하는 이벤트로도 부모 컴포넌트의 state를 변경할 수 있다. 

자식 컴포넌트 내부에서 props로 setter를 받아 사용한다.

이렇게 사용하면 자식 컴포넌트의 변경 이벤트가 부모 컴포넌트의 state에 영향을 줄 수 있게 되는 것이다. 

그림으로 표현하면 다음과 같다. 

하지만 이렇게 되면 문제가 발생하는데 검색 조건이 하나 늘어날 때마다,

인풋 박스가 searchForm에 늘어날 때마다 자식 컴포넌트는 새로운 props로 3개씩 더 추가해주어야 한다는 것이다.

이 방법은 유지 보수 능력을 떨어뜨릴 수 있을 것이라고 생각했고 state의 변경이 자식 컴포넌트에서 부모 컴포넌트로 전달되기 때문에 검색어를 입력할 때마다 페이지에 존재하는 모든 컴포넌트의 리렌더링을 불러올 것이라고 생각했다. 

 

따라서 두 번째 방법을 사용해보기로 했다. 

 

3. Redux의 사용 

리액트에서 가장 애용되는 상태관리 라이브러리로 redux가 존재한다. 예전에는 redux-toolkit의 부재로 작성해야하는 코드의 양이 너무 많아 한 때 주춤했지만 toolkit을 잘 사용한다면 아직도 redux는 쉽게 이해할 수 있는 상태관리 라이브러리로 좋은 선택지이다. 

 

redux store는 하나로만 구성되어야하지만 최근에는 slice를 만들어 각 기능별 object마다 `createSlice`라는 api를 통해 원하는 상태 별로 코드를 작성할 수 있다. 내가 작성한 코드는 다음과 같다. 

 

/src/store/project/index.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

export type InitialState = {
	field: string;
	searchQuery: string;
	isDeclaration: boolean;
};

const initialState: InitialState = {
	field: '',
	searchQuery: '',
	isDeclaration: false,
};

export const projectSlice = createSlice({
	name: 'project',
	initialState,
	reducers: {
		setProjectField(state, action: PayloadAction<string>) {
			state.field = action.payload;
		},
		setProjectSearchQuery(state, action: PayloadAction<string>) {
			state.searchQuery = action.payload;
		},
		setProjectIsDeclaration(state, action: PayloadAction<boolean>) {
			state.isDeclaration = action.payload;
		},
		clearAllProjectSearchQuery(state) {
			state.field = '';
			state.isDeclaration = false;
			state.searchQuery = '';
		},
	},
});

export default projectSlice;

export const { setProjectField, setProjectSearchQuery, setProjectIsDeclaration, clearAllProjectSearchQuery } =
	projectSlice.actions;
export const selectProject = (state: any): InitialState => state.project;

다음과 같이 reducer 들을 정의해주었고 이 reducer들은 userSearchForm의 인풋들의 changeEvent마다 dispatch될 것이다. 

위와 같이 정의된 slice는 다시 main store에도 정의가 되어야한다. 

 

/src/store/slices/store.ts

import { configureStore } from '@reduxjs/toolkit';
import { bannerSlice } from '@/store/slices/banner';
import projectSlice from './slices/project';

export const store = configureStore({
	reducer: {
		banner: bannerSlice.reducer,
		project: projectSlice.reducer,
	},
});

export type RootState = ReturnType<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;

위와 같이 configureStore api안 reducer에 project의 reducer들도 정의가 되어야한다. 

 

하지만 위와 같이 redux를 이용하는 방법은 다음과 같은 문제가 있었다. 

 

1. input의 changeEvent마다 useDispatch()를 통해 redux의 상태를 변경하면 input의 값이 입력되고 커서가 떠나는 순간 dispatch 가 되어서 원하는 값이 잘 저장되지 않는다. (한 박자 느리다. )

 

2. 위의 페이지 같은 검색창은 changeEvent가 바로바로 적용되어야하는 페이지라 dispatch를 한 후 useSelector로 값을 받아오는 과정은 동기적 보장이 되지 않는다. 

 

따라서 위와 같이 redux를 사용하는 시도는 실패로 돌아갔다. 

 

4. 3번째 방법 컴포넌트 분리하지 않기

- 사실 제일 중요한 부분이다. 내가 위에서 두 가지의 삽질을 했던 이유는 맨 처음 가정이 잘못 되었기 때문이었다. 

위와 같은 검색 페이지는 input에 의해 변화되는 값에 의한 결과가 바로바로 페이지 하단 부에 반영이 되어야하는 페이지 였다. 따라서 위와 같은 페이지는 하나의 form과 같은 것이라고 볼 수 있었고 결과 값을 나타내는 컴포넌트인 table이 입력을 받아오는 부분인 userSearchForm의 부모 컴포넌트인 자체가 말이 되지 않았던 것이다. 

 

따라서 컴포넌트 설계가 처음서부터 잘못된 것을 깨닫고 다음과 같이 컴포넌트 구조를 변경하였다.

 

두 가지 가능성이 있을 수 있겠다. 

 

1. 아예 분리하지 않기 -> 하나의 form 으로 보고 하나의 함수형 컴포넌트 안에 인풋 박스들과 table을 모두 작성한다. 

2. 인풋 박스들을 부모 컴포넌트, table을 자식 컴포넌트로 하여 인풋에 의해 query된 data를 table에게 prop으로 전달한다. 

 

나는 위의 두 가지 방법을 모두 수행해보았고 결과는 성공이었다. 

위와 같은 삽질을 진행해보며 컴포넌트의 첫 설계가 얼마나 중요한 지 form에 대한 이해를 높일 수 있었다. 

 

728x90
반응형

댓글