useParams로 주소에서 값을 읽어 상세 페이지를 만들고, 커스텀 훅으로 반복되는 로직을 분리하고, Tailwind CSS를 처음 써 본 날. 그리고 회원가입·로그인·게시판이 있는 프로젝트의 뼈대를 잡기 시작했다.
Day.7에서 react-router-dom의 Route와 Link, 그리고 useParams를 훑어봤다. 오늘은 useParams를 실전 예제로 써 보고, 같은 코드가 반복되는 문제를 커스텀 훅으로 풀었다.
회원 목록(/users)에서 이름을 누르면 그 회원의 상세(/users/1)로 이동하는 예제다.
// App.jsx
<UsersProvider>
<BrowserRouter>
<Routes>
<Route path="/users" element={<Users />} />
{/* /users 로 들어가면 <Users />를 보여 준다 */}
<Route path="/users/:id" element={<UserInfo />} />
{/* :id 자리에 오는 값이 id라는 이름으로 저장된다 */}
</Routes>
</BrowserRouter>
</UsersProvider>
// Users.jsx: 목록. 이름마다 Link를 걸어 /users/id 로 이동시킨다
export const UsersContext = createContext()
export const UsersProvider = ({ children }) => {
const users = [
{ id: 1, name: 'Alice1', age: 35, email: 'alice1@example.com' },
{ id: 2, name: 'Alice2', age: 37, email: 'alice2@example.com' },
{ id: 3, name: 'Alice3', age: 40, email: 'alice3@example.com' },
]
return (
<UsersContext.Provider value={users}>
{children} {/* Users, UserInfo에서 value 값을 가져다 쓸 수 있다 */}
</UsersContext.Provider>
)
}
export const Users = () => {
const users = useContext(UsersContext)
return (
<div>
{users.map((x) => (
<li key={x.id}><Link to={`/users/${x.id}`}>{x.name}</Link></li>
))}
</div>
)
}
// UserInfo.jsx: 주소의 :id를 읽어서 그 회원만 보여 준다
const UserInfo = () => {
const users = useContext(UsersContext) // users 배열 가져옴
const userId = parseInt(useParams().id) // :id 읽어오기
const currUser = users.find((x) => x.id === userId)
return (
<div>
<li>id:{currUser.id}, name:{currUser.name} age:{currUser.age}, email:{currUser.email}</li>
</div>
)
}
흐름은 이렇다. Day.3(find), Day.7(Context, useParams)에서 배운 것들이 한꺼번에 쓰인다.
Users가 목록을 그리고, 각 이름에 <Link to="/users/1"> 같은 링크를 건다./users/1이 되고, :id 규칙에 맞는 UserInfo가 보인다.UserInfo가 useParams()로 :id 자리의 값을 읽는다.users에서 find로 찾아서 보여 준다.필기의 설명처럼 useParams()가 돌려주는 것은 기존 users 배열의 객체가 아니라 주소에서 새로 만든 객체({ id: '2' })다. 그래서 그 값으로 users에서 같은 id를 가진 회원을 따로 찾아야 한다. 실행해 보니 /users/2로 들어가면 id:2, name:Alice2가 나왔고, 목록의 링크는 /users/1, /users/2로 만들어졌다.
값이 문자열('2')로 오기 때문에 parseInt로 숫자로 바꿔서 비교해야 x.id === userId가 맞는다. 문자열 '2'와 숫자 2는 ===로 비교하면 다르다.
(1) 없는 번호로 들어가면 화면이 깨진다. /users/99로 들어가면 find가 undefined를 돌려주고 currUser.id를 읽다가 에러가 난다.
Cannot read properties of undefined (reading 'id')
Day.3에서 정리한 "find 결과는 undefined일 수 있다"는 점이 그대로 나타났다. 상세 페이지는 주소를 사용자가 직접 바꿔 칠 수 있어서 더 중요하다.
if (!currUser) return <p>존재하지 않는 회원입니다.</p>
(2) parseInt는 너무 너그럽다. parseInt('2abc')는 2가 되지만 Number('2abc')는 NaN이다. /users/2abc처럼 이상한 주소도 2번 회원으로 보여 줄 수 있다. 엄격하게 하려면 Number(...)를 쓴다.
(3) users 배열이 Provider 안에서 렌더링마다 새로 만들어진다. 값이 바뀌지 않는데도 매번 새 배열이라 Day.7에서 본 것처럼 Context를 쓰는 컴포넌트가 불필요하게 다시 그려질 수 있다. 데이터가 고정이면 컴포넌트 밖으로 빼거나 useState/useMemo로 둔다.
React Hook을 사용해서 만든 재사용 가능한 함수.
Hook 로직을 함수로 분리해서 여러 컴포넌트에서 재사용하는 것.
React 코드를 쓰다 보면 이런 로직이 여러 컴포넌트에서 반복된다.
반복되는 Hook 로직을 하나의 함수로 빼 두면 필요한 곳에서 불러다 쓸 수 있다.
use로 시작한다. (useSomething) React와 린트 도구가 이 이름을 보고 "훅"으로 취급해서 규칙을 검사한다.if나 반복문 안에서 호출하지 않는다.function useSomething() {
const [state, setState] = useState()
return state
}
Day.7에서 Context 값을 쓸 때마다 useContext(AdminContext)를 쓰고 Context 객체를 import하는 게 반복됐다. 이걸 useAuth라는 훅으로 감쌌다.
// hooks/useAuth.jsx
const AuthContext = createContext()
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState({ name: '사용자' })
const login = (userInfo) => setUser(userInfo)
const logout = () => setUser(null)
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
)
}
// 커스텀 훅: useContext를 한 겹 감싼 것
export const useAuth = () => {
return useContext(AuthContext)
}
// Profile.jsx
const Profile = () => {
const { user, login, logout } = useAuth()
return (
<div>
{user ? <p>{user.name}님 환영</p> : <p>로그인하세요</p>}
<button onClick={logout}>로그아웃</button>
</div>
)
}
Profile은 AuthContext가 있다는 것도 몰라도 되고 useAuth() 한 줄만 부르면 된다. 앱 전체를 AuthProvider로 감싸는 것은 Day.7과 같다.
확인한 동작은 이렇다.
사용자님 환영, 로그아웃을 누르면 로그인하세요로 바뀐다.user가 있는지 없는지로 문구만 나눴기 때문이다. 이후 프로젝트에서는 이 부분을 로그인 상태에 따라 나눈다(5번).AuthProvider로 감싸지 않고 useAuth()를 쓰면 useContext가 undefined를 돌려줘서 Cannot destructure property 'user' of 'useAuth(...)' as it is undefined 에러가 난다.입력창이 여러 개면 매번 useState와 onChange를 쓰게 된다. 이것도 훅으로 묶었다.
// hooks/useInput.jsx
const useInput = (initvalue) => {
const [value, setValue] = useState(initvalue)
const onChange = (e) => { setValue(e.target.value) }
return { value, onChange } // 객체로 돌려준다
}
// Login.jsx
const username = useInput('') // { value: '', onChange: 함수 }
const password = useInput('')
<input placeholder="아이디" {...username} />
<input placeholder="비번" {...password} />
<p>아이디 : {username.value}</p>
<p>비밀번호 : {password.value}</p>
{...username}은 객체 스프레드를 JSX 속성 자리에서 쓴 것이다. username이 { value, onChange }라서 <input value={username.value} onChange={username.onChange} />와 똑같이 펼쳐진다. 아이디에 abc, 비밀번호에 123을 입력하니 아래 두 줄에 그대로 표시되었다.
필기에는 return {value,onChange}에 "html 코드가 아니기 때문에 중괄호로 바꿔야 함"이라는 메모가 있었다. 정확히 하면 JSX가 아니라 자바스크립트 값을 돌려주는 함수라서 값 두 개를 객체({ value, onChange }, 변수명과 키가 같을 때 줄여 쓰는 표기)로 묶어서 돌려주는 것이다. 배열로 돌려줄 수도 있다(useState가 그렇다).
한 가지 중요한 점은 useInput('')을 두 번 불러도 state는 각각 따로라는 것이다. 아이디와 비밀번호가 서로 섞이지 않았다. 커스텀 훅이 공유하는 것은 로직(코드)이지 state(값)가 아니다. 값을 같이 쓰고 싶으면 Context를 쓴다. useAuth가 Context를 감싼 이유다.
CSS 파일을 따로 쓰지 않고 className에 미리 만들어진 이름(유틸리티 클래스)을 붙여서 스타일을 주는 CSS 프레임워크다.
<div className="min-h-screen bg-gray-100 p-8">
<h1 className="text-3xl font-bold text-blue-200 underline">Tailwind CSS</h1>
</div>
| 클래스 | 의미 |
|---|---|
min-h-screen | 최소 높이를 화면 높이만큼 |
bg-gray-100 | 배경색을 연한 회색으로 |
p-8 | 안쪽 여백(padding) |
text-3xl | 글자 크기 |
font-bold | 굵게 |
text-blue-200 | 글자색을 연한 파랑으로 |
underline | 밑줄 |
설치는 Tailwind 공식 문서의 "Installing with Vite" 안내를 따랐다. Tailwind 4 기준으로 Vite 프로젝트에 이렇게 설정한다.
npm install tailwindcss @tailwindcss/vite
// vite.config.js: 플러그인을 추가한다
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ plugins: [react(), tailwindcss()] })
/* index.css */
@import "tailwindcss";
그리고 main.jsx에서 import './index.css'를 해 준다.
별도 프로젝트에서 Tailwind 4.3으로 빌드해서, 이 노트에 쓴 클래스들이 실제로 CSS로 만들어지는지 확인했다. 대부분 정상이었고 두 가지가 눈에 띄었다.
shodow-md(nav에 쓴 클래스)는 오타(shadow-md)라서 CSS가 만들어지지 않았다. Tailwind는 모르는 클래스 이름이어도 에러를 내지 않고 그냥 무시해서 오타를 알아채기 어렵다.shadow-olive-500은 그림자 색깔만 정하는 클래스다. 그림자 크기를 정하는 shadow-md 같은 클래스가 같이 있어야 그림자가 그려지는데, 폼에는 색 클래스만 있어서 화면에 그림자가 보이지 않는다.text-blue-200은 아주 연한 파랑이라 bg-gray-100 배경 위에서 글자가 잘 안 보인다. 처음 써 본 예제라 큰 문제는 아니지만 글자색은 보통 -600 이상의 짙은 숫자를 쓴다.회원·게시판 프로젝트를 만들기 전에 파일별 역할 정리본을 받았다. 핵심만 표로 옮겼다.
| 파일 | 역할 | 주요 기능 |
|---|---|---|
main.jsx | 앱의 시작 파일 | root에 앱을 렌더링, Router/Provider 적용 |
App.jsx | 전체 페이지 라우팅 관리 | URL에 따라 보여 줄 컴포넌트 결정, 공통 UI(네비게이션바) 포함 |
AuthContextPro.jsx | 로그인 사용자 상태를 전역으로 관리 | 로그인 사용자 저장, 로그아웃, 모든 컴포넌트에서 로그인 상태 사용 |
Navibar.jsx | 상단 메뉴 | 페이지 이동 메뉴, 로그인 상태에 따라 메뉴 변경, 로그아웃 |
HomePage.jsx | 메인 페이지 | 첫 화면 |
LoginPage.jsx | 로그인 처리 | 아이디/비밀번호 입력, localStorage에서 사용자 조회, 성공 시 사용자 정보 저장, 게시판으로 이동 |
SignUpPage.jsx | 회원가입 | 새 사용자 생성, localStorage에 저장 |
MemberListPage.jsx | 회원 목록 관리 | 목록 조회, 정보 수정, 삭제 |
BoardListPage.jsx | 게시글 목록 | map()으로 반복 출력, 작성자만 수정/삭제 |
CreatePostPage.jsx | 게시글 작성 | 로그인한 사람만 작성, localStorage 저장 |
EditPostPage.jsx | 게시글 수정 | 내용 수정, 수정된 데이터 저장 |
정리본에 "React는 기본적으로 부모 → 자식으로만 데이터를 전달할 수 있어서, 로그인 상태를 모든 페이지에서 쓰려면 Context가 필요하다"는 설명이 있다. Day.7에서 배운 props drilling 문제와 같은 이야기다. 로그인 상태는 메뉴, 로그인 페이지, 게시판 등 거의 모든 페이지가 필요로 해서 Context에 두는 것이다.
정리본의 구조대로 파일을 만들고 있다. 게시판 관련 세 페이지(BoardListPage, CreatePostPage, EditPostPage)와 MemberListPage는 아직 빈 컴포넌트이고, 오늘은 라우팅, 로그인 상태 관리, 메뉴, 회원가입, 로그인 일부까지 만들었다.
<AuthContextPro>
<BrowserRouter>
<Navibar /> {/* Routes 밖에 두면 모든 페이지에서 보인다 */}
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/join" element={<SignUpPage />} />
<Route path="/memberList" element={<MemberListPage />} />
<Route path="/boardList" element={<BoardListPage />} />
<Route path="/posts/create" element={<CreatePostPage />} />
<Route path="/posts/edit/:id" element={<EditPostPage />} /> {/* :id → useParams */}
</Routes>
</BrowserRouter>
</AuthContextPro>
/posts/edit/:id는 위 1번에서 배운 동적 경로다. 나중에 게시글 수정 페이지가 useParams로 어느 글인지 알아낸다.
const AuthContext = createContext()
export const AuthContextPro = ({ children }) => {
// 초기값을 localStorage에서 읽는다 (새로고침해도 로그인 여부를 알아야 하므로)
// 로그인되어 있으면 사용자 객체, 아니면 null
const [currentUser, setCurrnetUser] = useState(
JSON.parse(localStorage.getItem('currentUser')) || null,
)
// 로그아웃: 상태를 null로, localStorage의 로그인 정보도 삭제
const logout = () => {
setCurrnetUser(null)
localStorage.removeItem('currentUser')
}
return (
<AuthContext.Provider value={{ currentUser, setCurrnetUser, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext) // 커스텀 훅
Day.7에서 정리한 "localStorage는 useState의 초기값으로 읽는다"를 그대로 적용했다. 로그인 정보는 새로고침해도 유지되고, useAuth()는 2번에서 만든 커스텀 훅 패턴이다. 다만 useState(JSON.parse(...))는 값을 직접 넣어서 렌더링마다 JSON.parse를 실행한다. Day.7에서처럼 useState(() => JSON.parse(...) || null)로 함수를 넘기면 처음 한 번만 읽는다. 그리고 setCurrnetUser는 철자 오타(Current)인데 선언과 사용이 일관돼서 동작은 하지만, 나중에 헷갈리기 쉬워 setCurrentUser로 맞추는 게 좋다.
const { currentUser, logout } = useAuth()
const navigate = useNavigate() // 페이지를 이동시키는 훅
const logout1 = () => {
logout()
navigate('/') // 로그아웃하고 홈('/')으로 이동
}
<Link to="/">홈</Link>
<Link to="/memberList">회원목록</Link>
<Link to="/boardList">게시글목록</Link>
{!currentUser && (<> <Link to="/login">로그인</Link> <Link to="/join">회원가입</Link> </>)}
{currentUser && <button onClick={logout1}>로그아웃</button>}
<Link to>: 사용자가 클릭해서 이동한다.useNavigate: 코드에서 직접, 강제로 이동시킨다(redirect). 버튼 이벤트나 로그인 성공 직후처럼 "어떤 일이 끝나면 이동"할 때 쓴다.&& 조건부 렌더링이다. 로그인 안 했으면 로그인/회원가입, 했으면 로그아웃 버튼만 보인다.로그아웃을 확인해 보니 currentUser가 localStorage에서 지워지고, 주소가 /로 바뀌고, 메뉴에 로그인 링크가 다시 나타났다.
필기 코드에는 <Link to='boardList'>처럼 앞에 /가 없는 링크가 하나 있었다. Navibar가 Routes 밖에 있어서 /memberList 페이지에서도 /boardList로 연결되기는 했다. 그런데 /가 없는 상대 경로는 링크를 놓는 위치에 따라 계산이 달라지므로 to="/boardList"로 쓰는 것이 안전하다.
const onSubmit1 = (e) => {
e.preventDefault()
const user = { userId, password }
// const user = { userId: userId, password: password } 와 같다
// 변수 이름과 속성 이름이 같을 때 줄여 쓸 수 있다
// 가입한 유저 목록을 localStorage에서 가져온다. 없으면 빈 배열
let users = JSON.parse(localStorage.getItem('users')) || []
users.push(user) // 배열에 계정 정보를 추가
localStorage.setItem('users', JSON.stringify(users))
setUserId('')
setPassword('')
navigate('/login') // 로그인 페이지로 이동
}
가입을 실행해 보면 localStorage의 users에 [{"userId":"u1","password":"p1"}]이 저장되고 주소가 /login으로 바뀐다.
여기서 users.push(user)가 쓰였는데, Day.4에서 "state 배열에 push하면 안 된다"고 했는데 왜 괜찮을까? users는 React state가 아니라 localStorage에서 막 꺼내 파싱한 지역 변수이기 때문이다. 화면을 다시 그릴 필요 없이 값을 저장소에 쓰기만 하므로 직접 바꿔도 문제가 없다. "state는 직접 바꾸지 않는다"는 규칙은 React state에만 해당한다.
빠져 있는 것도 있다. 같은 아이디로 여러 번 가입할 수 있고, 빈 아이디/비밀번호도 가입된다. 비밀번호가 평문으로 저장되는 것은 Day.7에서 언급한 대로 연습용이다.
const onSubmit2 = (e) => {
e.preventDefault()
const users = JSON.parse(localStorage.getItem('users')) || []
// 가입할 때 저장한 정보와 입력한 아이디/비밀번호가 같은 사용자를 찾는다
const loginUser = users.find((x) => x.userId === userId && x.password === password)
if (loginUser) {
localStorage.setItem('currentUser', JSON.stringify(loginUser))
}
// 아이디/비번이 틀리면 alert를 띄울 예정 (아직 안 씀)
}
필기에도 "오늘 안에 마무리하지 못했다"고 적혀 있듯이 아직 덜 끝난 부분이다. 실행해서 흐름을 확인해 보니 이런 상태였다.
currentUser가 localStorage에 저장된다./login에 머물고, 메뉴에는 여전히 로그인/회원가입 링크가 보이고 로그아웃 버튼은 없다.원인은 AuthContextPro가 localStorage를 처음 한 번만 읽기 때문이다. localStorage에 저장만 하고 Context의 state(currentUser)는 건드리지 않았으니, React는 로그인했다는 사실을 모른다. 이어서 만들 때 필요한 것은 세 가지다.
const { setCurrnetUser } = useAuth()
const navigate = useNavigate()
if (loginUser) {
localStorage.setItem('currentUser', JSON.stringify(loginUser))
setCurrnetUser(loginUser) // Context 상태도 갱신 → 메뉴가 바로 바뀐다
navigate('/memberList') // 로그인 후 이동 (정리본에는 게시판으로 이동)
} else {
alert('아이디 또는 비밀번호가 틀렸습니다.')
}
이 수정판으로 다시 돌려 봤다. 틀린 비밀번호는 실패 처리가 되고 화면이 그대로였으며, 맞는 정보로 로그인하면 주소가 이동하고 메뉴에 로그아웃 버튼이 바로 나타났다. 저장소(localStorage)와 화면 상태(state)를 둘 다 갱신해야 한다는 점이 이번 구조의 핵심이다. localStorage는 새로고침해도 남는 보관함이고, state는 화면을 그리는 값이라서 역할이 다르다.
flex justify-center items-center h-screen으로 글자를 화면 가운데에 놓았다. 위 3번에서 쓴 클래스들이다.Navibar의 <nav className="bg-orange-300 shodow-md">는 3번에서 짚은 오타다.currentUser.name을 보여 주는 줄을 주석 처리해 둔 것은, 가입 때 저장하는 객체가 { userId, password }라서 name 속성이 없기 때문이다. 필요하면 userId를 보여 주면 된다./주소/:id 같은 동적 경로는 useParams()로 읽는다. 값은 문자열이고, 없는 id가 들어올 수 있으니 find 결과를 확인한다.use로 시작하는 함수로, 로직만 공유하고 state는 호출마다 따로다. useContext를 감싼 useAuth, 입력창을 묶은 useInput을 만들었다.className에 유틸리티 클래스를 붙인다. 모르는 클래스 이름은 에러 없이 무시돼서 오타를 알아채기 어렵다.<Link>는 클릭 이동, useNavigate는 코드로 이동이다.main → App(라우팅) → AuthContext(전역 상태) → 페이지들의 구조로 만들고 있으며, 로그인 마무리와 회원/게시판 페이지는 다음에 이어서 한다.Tags: React useParams 커스텀훅 Context react-router TailwindCSS localStorage 프론트엔드 개발자 학습기록