어제 못 끝낸 회원·게시판 프로젝트를 마무리하고, 전역 상태 관리 라이브러리인 Redux(Redux Toolkit)를 처음 배운 날.
Day.8에서 뼈대를 잡던 프로젝트를 오늘 끝냈다. 그다음에는 Day.7의 Context를 이어받아, 앱이 커졌을 때 쓰는 Redux로 넘어갔다.
어제는 로그인, 회원가입, 메뉴까지 하고 게시판 관련 페이지가 빈 컴포넌트였다. 오늘 나머지 페이지를 채웠다.
| 파일 | 오늘 한 일 |
|---|---|
LoginPage | 로그인 성공 시 setCurrentUser + localStorage 저장 + /boardList로 이동, 실패하면 alert |
MemberListPage | 관리자(admin)로 로그인했을 때만 회원 아이디 목록을 보여 줌 |
CreatePostPage | 로그인한 사람만 글 작성, localStorage의 posts에 저장 |
EditPostPage | useParams로 글 id를 읽어 폼에 채우고 수정 내용을 저장 |
BoardListPage | 글 목록 출력, 작성자 본인에게만 수정/삭제 노출 |
Navibar | 로그인하면 아이디님과 로그아웃 버튼 노출 |
Day.8에서 짚은 로그인 문제(localStorage만 저장하고 Context state는 안 바꿈)가 이번에 setCurrentUser(loginUser)와 navigate('/boardList'), 실패 시 alert로 채워졌다.
글 작성: 로그인하지 않았으면 안내하고 로그인 페이지로 보낸다.
const { currentUser } = useAuth()
// navigate는 화면이 렌더링된 이후에 실행되어야 한다
// (이벤트 핸들러 안이나 useEffect 안에서 호출한다)
useEffect(() => {
if (!currentUser) {
alert('로그인이 필요합니다.')
navigate('/login')
}
}, [])
const onSubmit1 = (e) => {
e.preventDefault()
if (!title.trim() || !content.trim()) {
alert('제목과 내용을 입력해주세요')
return
}
let posts = JSON.parse(localStorage.getItem('posts')) || [] // 기존 글 목록 (없으면 [])
const newPost = {
id: Date.now(),
title,
content,
writeId: currentUser.userId, // 로그인한 사람의 아이디
}
posts.push(newPost)
localStorage.setItem('posts', JSON.stringify(posts))
navigate('/boardList') // 이벤트 핸들러 안이라서 바로 사용
}
필기에서 posts.push(newPost)가 괜찮은 이유를 직접 적어 두었다. posts는 React state가 아니라 localStorage에서 꺼낸 지역 변수라서 React가 감시하는 값이 아니고, 그래서 push로 바꿔도 된다. Day.8에서 정리한 내용 그대로다. (필기에 주석 처리된 setTitle('')는 이 페이지를 떠나면 컴포넌트가 사라지니 초기화할 필요가 없다는 의문이었는데, 맞는 판단이다.)
글 수정: 주소의 id로 글을 찾아 폼을 채운다.
const { id } = useParams() // 문자열로 반환
const [post, setPost] = useState({ title: '', content: '' })
useEffect(() => {
const posts = JSON.parse(localStorage.getItem('posts'))
const currentPost = posts.find((x) => x.id === parseInt(id))
if (currentPost) { setPost(currentPost) } // 찾으면 입력창에 기존 제목/내용이 채워진다
}, [id])
const onSubmit1 = (e) => {
e.preventDefault()
const posts = JSON.parse(localStorage.getItem('posts')) || []
// 같은 id의 글만 수정한 값(post)으로 교체. id와 writeId는 그대로 유지
const newposts = posts.map((x) =>
x.id === parseInt(id) ? { id: x.id, ...post, writeId: x.writeId } : x
)
localStorage.setItem('posts', JSON.stringify(newposts))
navigate('/boardList')
}
{ id: x.id, ...post, writeId: x.writeId }에서 스프레드 뒤에 writeId를 다시 쓴 이유는, post에 들어 있는 값보다 원래 글의 작성자를 우선하려는 것이다. 객체 스프레드는 뒤에 오는 값이 앞의 값을 덮어쓴다.
글 목록: 본인 글에만 수정/삭제를 보여 준다.
{currentUser && x.writeId === currentUser.userId && (
<div className="flex gap-1">
<Link to={`/posts/edit/${x.id}`}>수정</Link>
<button onClick={() => handleDelete(x.id)}>삭제</button>
</div>
)}
const handleDelete = (id) => {
const newPosts = posts.filter((x) => { return x.id !== id }) // 삭제할 글만 빼고 새 배열
setPosts(newPosts) // 화면 갱신
localStorage.setItem('posts', JSON.stringify(newPosts)) // 저장소 갱신
}
화면 state(setPosts)와 저장소(localStorage)를 둘 다 갱신하는 것도 Day.8에서 정리한 원칙이다.
회원 목록: 관리자만 보이게 했다.
{currentUser && currentUser.userId === 'admin' && currentUser.password === 'admin'
? (<ul>{users.length > 0
? users.map((x, index) => <li key={index}>{x.userId}</li>)
: <li>회원이 없습니다.</li>}</ul>)
: (<div>회원목록은 관리자만 볼 수 있습니다.</div>)}
코드를 그대로 가져다 전체 앱을 가입 → 로그인 → 글쓰기 → 수정 → 삭제 순서로 실행해 봤다. 핵심 기능은 모두 의도대로 동작했다.
/login으로 이동하고, 없는 계정으로 로그인하면 alert가 뜬다./boardList로 이동하고 메뉴에 user1님과 로그아웃이 보인다./posts/create에 들어가면 로그인이 필요합니다.가 뜨고 /login으로 간다.alert로 막힌다.user2)의 목록 화면에는 수정/삭제가 안 보이고, 작성자가 삭제하면 화면과 저장소에서 같이 사라진다.admin만 목록이 보이고 일반 회원과 비로그인은 안내 문구만 보인다.연습용 프로젝트라 기능은 충분하지만, "버튼을 숨기는 것"과 "막는 것"은 다르다는 점이 크게 보였다.
/posts/edit/글id를 직접 입력하면 다른 회원도, 로그아웃한 사람도 폼이 열리고 저장된다. 실제로 user2가 user1의 글 제목을 바꿨고(작성자는 그대로 user1), 비로그인으로도 열렸다. 수정 페이지에서도 currentUser와 writeId를 비교해서 다르면 돌려보내야 한다. 이런 검사는 서버가 있다면 서버에서도 해야 한다.JSON.parse(localStorage.getItem('posts'))가 null이라 posts.find에서 Cannot read properties of null (reading 'find')로 깨진다. 다른 곳처럼 || []를 붙이면 된다.user1을 두 번 가입하자 users에 2개가 들어갔다(admin 회원 목록에 user1이 두 번 나왔다). 가입할 때 users.some(...)으로 중복을 확인해야 한다.admin/admin 문자열 비교다. 아무나 admin/admin으로 가입하면 관리자 화면을 볼 수 있다. 실제로는 서버가 권한을 가진 계정을 따로 관리해야 한다. 비밀번호를 평문으로 저장하는 것은 Day.7부터 계속 연습용이라는 전제다.localStorage를 직접 읽어서 currentUser를 따로 state로 갖고 있다. 필기에도 "useAuth()를 사용해도 됨"이라고 적혀 있듯이, Context가 이미 있으니 useAuth()로 쓰면 코드가 줄어든다.상태 관리 라이브러리.
리액트는 컴포넌트 단위로 상태를 관리하는데, 컴포넌트가 많아질수록 상태 전달이 복잡해진다(props drilling).
Redux는 컴포넌트들이 이 상태를 쉽게 공유하도록 도와준다.
Day.7에서 Context로 풀었던 문제와 같은 문제를 푸는 도구다. 한 곳(Store)에 상태를 모아 두고, 필요한 컴포넌트가 직접 꺼내 쓰고, 필요한 컴포넌트만 다시 그린다.
필기에 풀어 쓴 설명을 정리하면 이렇다. 컴포넌트를 서로 연결해서 전달하는 대신, 한 공간에서 상태를 직접 관리할 수 있는 공간(Store)을 만든다. "상태를 바꾸겠다"는 요청의 종류를 정하고, 그 요청이 들어오면 상태를 어떻게 바꿀지 함수로 만들어 둔다.
| 구분 | Context API | Redux |
|---|---|---|
| 개념 | 리액트 내장 기능. 전역 상태를 제공/구독하는 간단한 방법 | 외부 라이브러리. 예측 가능한 상태 관리 도구 |
| 주요 목적 | 테마, 다국어, 로그인 정보 같은 단순 전역 상태 공유 | 복잡한 비즈니스 로직, 상태 흐름 추적, 대규모 앱의 상태 관리 |
| 데이터 흐름 | Provider → Consumer | Action → Reducer → Store → View (단방향) |
| 복잡성 | 비교적 단순 | 설정, 구조, 코드량이 많음 (하지만 툴링이 풍부) |
{ type: '...' }useSelector), 액션을 보낼(useDispatch) 때 쓰는 react-redux 훅.createSlice, configureStore를 쓴다.컴포넌트 → (dispatch) → 액션 → 리듀서(slice) → 새로운 상태 → 스토어 → (useSelector) → 컴포넌트 반영
식당에 비유하면 이렇다. Action은 주문서, Reducer는 주문서를 보고 요리(상태 변경)하는 사람, Store는 완성된 음식(상태)이 놓이는 곳이다. 사용자 클릭(UI)이 dispatch(action)으로 주문서를 넣으면 Reducer가 상태를 바꾸고, Store에 저장된 새 상태가 UI에 자동으로 반영된다.
npm install @reduxjs/toolkit react-redux
파일은 세 군데로 나뉜다. slice(상태와 변경 방법), store(slice를 모음), main.jsx(앱에 연결).
slice = 전역 상태의 한 조각(예:
counterSlice,todoSlice,userSlice).
createSlice는 액션과 리듀서를 한 번에 만드는 함수다.
import { createSlice } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
reset: (state) => { state.value = 0; },
},
});
export const { increment, decrement, reset } = counterSlice.actions;
// 자동으로 액션 생성자(action creator)를 만들어 준다.
export default counterSlice.reducer;
createSlice에 넘기는 옵션이다.
| 옵션 | 필수 | 설명 |
|---|---|---|
name | 필수 | 슬라이스 이름. 액션 타입의 앞부분이 된다 (counter/increment) |
initialState | 필수 | 초기 상태값 (객체, 배열, 숫자 모두 가능) |
reducers | 필수 | 상태 변경 로직. 이 함수들마다 액션과 리듀서가 자동 생성된다 |
extraReducers | 선택 | 비동기 액션이나 다른 slice의 액션을 처리 |
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
const store = configureStore({
reducer: {
counter: counterReducer, // 슬라이스 등록
},
});
export default store;
configureStore()에 등록하면 모든 컴포넌트가 가져다 쓸 수 있게 된다.
import { Provider } from 'react-redux'
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
)
Context의 Provider와 같은 역할이다. 이 안쪽의 모든 컴포넌트가 store에 접근한다.
import { useSelector, useDispatch } from "react-redux";
import { increment, decrement, reset } from "../redux/counterSlice";
const Counter = () => {
const count = useSelector((state) => state.counter.value); // store에서 값 꺼내오기
const dispatch = useDispatch(); // 액션 실행 준비
return (
<div>
<h2>카운터: {count}</h2>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(reset())}>Reset</button>
</div>
);
};
useSelector: store에서 값을 꺼낸다.useDispatch: 액션을 실행시킨다.직접 실행해 보니 처음 카운터: 0, +1을 두 번 누르고 -1을 한 번 누르면 1, Reset을 누르면 0이 되었다.
컴포넌트의 이벤트 → dispatch → store → slice → 다시 store → 컴포넌트가 useSelector로 값을 가져와 {count}를 출력한다. 액션을 보내면 store가 직접 처리하는 것이 아니라 createSlice가 만들어 준 reducer가 상태를 계산하고 store가 업데이트를 관리한다.
{
type: "counter/increment",
payload: 10 // 추가 데이터
}
액션 생성자를 직접 호출해서 확인했다.
increment() // { type: 'counter/increment' }
incrementByAmount(10) // { type: 'counter/incrementByAmount', payload: 10 }
increment()처럼 데이터가 필요 없으면 type만 있고, 데이터를 넘기면 payload에 들어간다. 리듀서에서는 (state, action)의 action.payload로 꺼낸다(state.value += action.payload). type은 슬라이스name/리듀서이름으로 자동 생성된다.
name이 state의 key가 되는 것이 아니다. 필기의 옵션 표에는 "name: 슬라이스 이름 (state 접근 시 key로 사용됨)"이라고 적혀 있다. 확인해 보니 state.counter.value에서 counter는 configureStore의 reducer: { counter: ... }에 적은 키였다. 키를 cnt로 바꿔서 등록하면 state.cnt.value로 접근해야 했고, 액션 타입은 여전히 counter/increment였다. name은 액션 타입의 앞부분을 만드는 데 쓰인다. 보통은 둘을 같은 이름으로 맞춰서 헷갈리지 않게 한다.dispatch 전후를 비교해 보니 store 객체는 그대로이고, store.getState()가 돌려주는 state 객체만 새로 만들어졌다. 이전 state는 { counter: { value: 0 } } 그대로 남았다. 정확히는 "Reducer가 새로운 state를 돌려주고, Store가 그걸 저장한다"이다.dispatch는 store의 기능이다. 필기에 "Action을 Store에 바로 전달하는 것이 아니라 Reducer에 전달해야 한다"고 적혀 있는데, 실제로는 dispatch(action)으로 store에 액션을 보내면 store가 내부에서 reducer를 호출한다. 개발자가 reducer를 직접 호출하지 않는다는 점이 핵심이다.state.value += 1이 "직접 바꾸는 것"처럼 보이는데 괜찮을까? 3원칙은 "상태는 직접 바꾸지 않는다"인데 slice 안에서는 state.value += 1로 쓴다. Redux Toolkit이 Immer라는 라이브러리를 내장해서, 이 코드를 받아 원본은 건드리지 않고 새 state를 만들어 준다. 직접 확인했다. reducer({value: 5}, increment())를 호출하면 결과는 {value: 6}인 새 객체이고 원래 {value: 5}는 그대로였다. 이 방식은 createSlice 안의 reducer에서만 통한다. 일반 함수나 React useState에서는 여전히 새 객체를 만들어야 한다.Counter는 state.counter.value를, 다른 컴포넌트(Label)는 state.label.text를 useSelector로 읽게 했다. label만 바꾸면 Label만 1번 렌더링되고 Counter는 0번이었고, 반대로 카운터를 올리면 Counter만 1번이었다. useSelector가 고른 값이 바뀔 때만 그 컴포넌트가 다시 그려진다. 이 때문에 Context보다 렌더링 범위를 줄이기 쉽다.Provider 없이 useSelector를 쓰면 could not find react-redux context value; please ensure the component is wrapped in a <Provider> 에러가 난다. Day.7 Context에서 본 것과 같은 실수다.옵션 표의 extraReducers는 서버 요청 같은 비동기 작업을 처리할 때 쓴다. createAsyncThunk로 비동기 액션을 만들고, 시작/성공/실패 때마다 state를 바꾼다. 실제로 돌려 봤다.
const fetchUser = createAsyncThunk('user/fetch', async (id) => ({ id, name: 'u' + id }))
const userSlice = createSlice({
name: 'user',
initialState: { status: 'idle', data: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => { state.status = 'loading' })
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'done'
state.data = action.payload
})
},
})
dispatch(fetchUser(7)) 직후 status는 loading, 끝난 뒤에는 done과 데이터가 들어 있었다. 서버에서 데이터를 받아 오는 화면의 "로딩중 → 완료" 흐름을 이 구조로 만든다.
필기의 옵션 표에서 name은 빼면 에러(name is a required option for createSlice)가 났지만, initialState나 reducers는 빼도 만들어지긴 한다. 다만 상태가 없거나 바꿀 방법이 없는 slice라서 쓸 수가 없으므로 사실상 필수라고 보면 된다.
| 장점 | 이유 |
|---|---|
| 예측 가능한 상태 관리 | 모든 상태 변경이 action → reducer 흐름으로 진행된다 |
| 상태 추적 가능 | Redux DevTools로 액션 히스토리와 상태 변화를 확인할 수 있다 |
| 여러 컴포넌트 공유 용이 | props drilling 없이 어디서든 store에 접근한다 |
Day.7의 Context와 비교해 정리했다. 로그인 사용자, 테마처럼 값이 자주 안 바뀌는 단순한 전역 값은 Context로 충분하다. 값이 자주 바뀌고, 여러 화면이 같이 쓰고, 상태 변화의 이력을 추적해야 하는 규모가 되면 Redux가 낫다. 이 프로젝트의 로그인 상태는 Context로 만든 것이 맞는 선택이고, Redux는 다음 단계의 도구로 익히는 중이다.
참고: Redux 공식 문서의 Store 페이지.
posts 처리가 남은 과제다.createSlice(상태 + 액션 + 리듀서), configureStore(slice를 모음), Provider(앱에 연결), useSelector/useDispatch(컴포넌트에서 읽고 보내기)를 쓴다.configureStore에 등록한 키이고, name은 액션 타입의 접두사다. Reducer가 만드는 것은 새 state이지 새 Store가 아니다.state.value += 1처럼 써도 Immer가 원본을 지키며 새 state를 만들어 준다.useSelector로 고른 값이 바뀔 때만 리렌더링되어서, 필요한 컴포넌트만 다시 그릴 수 있다.Tags: React Redux ReduxToolkit createSlice useSelector 상태관리 localStorage 프론트엔드 개발자 학습기록