게시판에 날짜와 수정 기능을 붙여 완성하고, 렌더링과 무관하게 값을 들고 있는
useRef, 폼 하나로 추가·수정을 처리하는 학생 관리, 불필요한 리렌더링을 막는memo까지 정리한 날.
Day.4에서 배열 state의 추가·삭제·수정 패턴을 익혔다. 오늘은 그걸 게시판에 마저 적용하고, 새로운 도구 두 가지(useRef, memo)를 배웠다.
글을 쓴 시각을 같이 저장하고, 상세 화면에서 수정 모드로 바꿔 고칠 수 있게 했다.
const now = new Date()
const todayNow =
`${now.getFullYear()}-` +
`${(now.getMonth() + 1).toString().padStart(2, '0')}-` +
`${now.getDate().toString().padStart(2, '0')} ` +
`${now.getHours().toString().padStart(2, '0')}:` +
`${now.getMinutes().toString().padStart(2, '0')}`
// 예) 2026-09-30 17:32
new Date()로 현재 시각 객체를 만든다.getMonth()는 0부터 시작(1월이 0)해서 항상 +1을 해 준다.padStart(2, '0'): 문자열이 2자리가 되도록 앞에 0을 채운다. 9월이 09로 나온다.글 작성할 때와 수정할 때 같은 코드를 두 번 쓰게 되는데, 함수로 한 번만 만들어 두면 깔끔하다.
const getNow = () => {
const now = new Date()
const p = (n) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`
}
Day.4에서 한 "수정 중인가?"를 state로 기억하는 방식이다. 이번에는 글 하나를 상세로 보는 화면이라 modiMode라는 true/false로 처리한다.
const [modiMode, setModiMode] = useState(false)
const updatePost = () => {
// 저장 버튼: posts에서 수정한 글(id가 같은 것)만 새 제목/내용/시각으로 교체한다
const updated = posts.map((x) =>
x.id === selPost1.id
? { ...x, title: selPost1.title, content: selPost1.content, date: getNow() }
: x
)
setPosts(updated) // 바뀐 배열을 state에 저장하고
setModiMode(false) // 수정 모드를 끈다
}
<div className="post-detail">
{selPost1 && (
<div>
{modiMode ? (
<>
{/* value를 state와 연결 → 입력창의 내용을 React가 관리한다 */}
{/* 입력 → onChange가 새 객체로 selPost1을 바꿈 → 리렌더링 → value에 반영 */}
<input type="text" value={selPost1.title}
onChange={(e) => setSelPost1({ ...selPost1, title: e.target.value })} />
<textarea value={selPost1.content}
onChange={(e) => setSelPost1({ ...selPost1, content: e.target.value })} />
<button className="ModiBtn" onClick={updatePost}>저장</button>
</>
) : (
<>
<h2>{selPost1.title}</h2>
<h2>{selPost1.content}</h2>
<p>{selPost1.date}</p>
<button className="ModiBtn" onClick={() => setModiMode(true)}>수정</button>
</>
)}
</div>
)}
</div>
수정 중에는 selPost1(선택한 글의 복사본)만 바뀌고, 목록(posts)은 저장을 눌러야 바뀐다. 실행해서 확인해 보니 수정 중에는 목록의 제목이 그대로 T1, T2였고, 저장하자 T1-edit, T2로 바뀌었다. 저장 전에는 임시 초안을 고치는 셈이다.
(1) 저장 직후 상세 화면의 날짜가 옛날 값이다.
저장하면 posts에는 새 날짜가 들어가지만, 상세 화면이 보여 주는 selPost1에는 새 날짜가 반영되지 않는다. 목록의 날짜는 갱신됐는데(old1 → 현재 시각) 상세의 날짜는 old1 그대로였다. 다시 그 글을 클릭해야 맞춰진다. 저장할 때 같이 갱신해 주면 된다.
setSelPost1({ ...selPost1, date }) // updatePost 안에서 상세용 객체도 같이 갱신
Day.3, Day.4에서 정리한 것처럼 id만 저장하고 posts.find(...)로 꺼내 쓰는 방식이면 이 문제 자체가 생기지 않는다. 다만 이 방식은 수정 중인 초안을 별도 state로 따로 들고 있어야 한다.
(2) 수정 모드에서 다른 글을 클릭하면 수정 모드가 유지된다.
T1을 수정 중에 T2를 클릭하면, T2의 상세가 아니라 T2의 내용이 담긴 입력창이 뜬다(입력창 값이 T2였다). modiMode가 그대로 true이기 때문이다. 글을 선택하는 함수에서 수정 모드를 꺼 주면 된다.
const selPost = (post) => {
setSelPost1(post)
setModiMode(false)
}
참고로 필기 코드의 updatePost 함수 안에서 또 const updatePost = posts.map(...)로 같은 이름의 변수를 만들고 있었다. 문법상 오류는 아니지만 바깥 함수 이름을 안쪽이 가려서 읽기 어려워서, 위 코드에서는 updated로 바꿨다.
Day.4에서 짚은 내용은 이 코드에도 그대로 남아 있다. 삭제 버튼이 클릭 가능한 div 안쪽에 있어서 e.stopPropagation()이 필요하고, id: posts.length + 1은 삭제 후 id가 겹칠 수 있다.
스타일은 Board.css로 따로 빼서 import "./Board.css"로 불러온다. CSS는 직접 다 외워서 쓰기보다 AI를 적극 활용하라는 조언을 들었고, 이 파일도 그렇게 만들었다. 핵심은 이 정도다.
* { margin: 0; padding: 0; box-sizing: border-box; } /* 기본 여백 제거 */
.board-app { padding: 20px; max-width: 600px; margin: 0 auto; /* 가운데 정렬 */
border: 2px solid pink; border-radius: 10px; }
.board-item { border: 1px solid gray; margin: 10px 0; padding: 10px;
cursor: pointer; border-radius: 5px; transition: 0.2s; }
.board-item:hover { background-color: #ffe6f0; } /* 마우스를 올리면 색 변경 */
.post-detail textarea { height: 200px; }
box-sizing: border-box는 padding과 border를 width에 포함해서 계산하게 해서, 입력창을 width: 100%로 줘도 삐져나오지 않게 한다.
useRef(초기값)은 { current: 초기값 } 모양의 객체를 만들어 준다. 이 객체는 다시 렌더링되어도 같은 객체가 유지되고, current를 바꿔도 리렌더링이 일어나지 않는다. useState와 비교하면 이렇다.
useState | useRef | |
|---|---|---|
| 값을 바꾸면 | 리렌더링 (화면 갱신) | 리렌더링 없음 |
| 렌더링 사이에 값 유지 | O | O |
| 바꾸는 방법 | setXxx(새값) | ref.current = 새값 |
const inRef = useRef(1)
<button onClick={() => {
inRef.current++
console.log(inRef.current)
}}>버튼 1 증가</button>
버튼을 두 번 누르면 콘솔에 2, 3이 찍힌다. 값은 올라가고 있지만 컴포넌트 함수는 처음 한 번 말고는 다시 실행되지 않았다(렌더링 횟수 1). 화면에 {inRef.current}를 그려 놓았어도 다음에 렌더링될 때까지는 안 바뀐다.
const cntRef = useRef() // 초기값 없음 → undefined
const [init, setInit] = useState(0)
useEffect(() => {
cntRef.current = init
}, [init])
return (
<div>
<p>{init}</p>
<p>{cntRef.current}</p>
<button onClick={() => setInit(init + 1)}>state 1증가</button>
</div>
)
버튼을 누를 때마다 화면의 두 숫자가 이렇게 바뀐다.
(처음) 0, (비어 있음)
1회 클릭 1, 0
2회 클릭 2, 1
3회 클릭 3, 2
state가 바뀌어 컴포넌트가 다시 그려질 때 {cntRef.current}는 아직 이전 값을 들고 있다. useEffect는 렌더링이 끝난 뒤에 실행되기 때문이다. 순서는 이렇다.
init이 1로 바뀜cntRef.current는 아직 0이다.useEffect가 실행되어 cntRef.current를 1로 바꾼다. (리렌더링은 안 일어나서 화면에는 반영되지 않는다.)그래서 현재 값과 바로 직전 값을 나란히 보여 주는 패턴으로 쓸 수 있다. 필기에서는 초기값 없이 useRef()로 만들었는데, 이 값은 undefined다. 필기에는 "undefined는 (null, false, 0, "")와 같다"고 적었는데, 모두 falsy(조건문에서 거짓으로 취급)라는 점은 같지만 같은 값은 아니다. useRef()는 undefined, useRef(null)은 null이 들어간다.
참고로 React 공식 문서는 렌더링 중에 ref.current를 읽어서 화면에 그리는 것을 권하지 않는다. 위 코드는 "ref는 리렌더링을 일으키지 않는다"는 동작을 눈으로 보려는 실험용이다.
const ref = useRef()
const [num, setNum] = useState(0)
useEffect(() => { ref.current = num }, [num])
const increase = () => { ref.current++ } // ref만 올린다
버튼은 두 개다. 1증가는 state를, ref증가는 ref만 올린다. 실행해 보면 이렇다.
1증가 클릭 → 화면: 1, 0
ref증가 클릭 → 화면: 1, 0 (ref는 올랐지만 화면 그대로)
ref증가 클릭 → 화면: 1, 0
1증가 클릭 → 화면: 2, 3 ← 리렌더링되는 순간에야 ref에 쌓인 값이 보인다
ref는 바뀌어도 화면을 갱신하지 못하고, 어떤 이유로든 다른 곳에서 리렌더링이 일어나야 그제야 쌓인 값이 화면에 보인다.
useRef가 가장 많이 쓰이는 곳 중 하나가 타이머 id 보관이다.
const [num, setNum] = useState(0)
const ref = useRef(null)
useEffect(() => {
ref.current = setInterval(() => {
setNum((n) => n + 1) // 1초마다 숫자를 올린다 → 1초마다 리렌더링
}, 1000)
// cleanup 함수: useEffect 안에서 return하는 함수
return () => clearInterval(ref.current)
}, [])
return <div>{num}</div>
setInterval은 타이머의 id를 돌려준다. 나중에 clearInterval(id)로 멈추려면 그 id를 기억해야 하고, 값이 바뀌어도 화면은 상관없으니 ref에 저장한다.[]라서 처음 한 번만 타이머를 만든다.setNum((n) => n + 1)처럼 함수형 업데이트를 쓴 이유는 Day.2에서 한 것과 같다. 이 effect는 처음 한 번만 만들어져서 안쪽의 num이 처음 값(0)에 고정된다. setNum(num + 1)로 쓰면 계속 0 + 1만 설정하게 된다.cleanup이 정말 필요한지 확인해 봤다. 개발 모드의 StrictMode는 effect를 한 번 실행 → cleanup → 다시 실행해서 정리가 제대로 짜여 있는지 검사한다. 200ms 간격 타이머로 1.05초 뒤 숫자를 비교했다.
| 1.05초 뒤 숫자 | |
|---|---|
| cleanup 있음 | 5 (정상) |
| cleanup 없음 | 10 (타이머가 2개 돌아서 2배 빠름) |
cleanup을 빼면 첫 번째 타이머가 멈추지 않고 남아 있어서 숫자가 두 배로 올라갔다. 타이머를 만들었으면 정리하는 코드도 같이 쓴다.
Day.4에서는 입력창 state를 각각 만들었다. 이번에는 입력창이 여러 개일 때 state 하나(객체)로 묶어서 관리하고, 폼과 목록을 컴포넌트로 나눴다. 구조는 이렇다.
App (students, selected state + 추가/수정/삭제 함수)
├─ StudentForm (입력 폼, 입력값 state)
└─ StudentList (목록, 수정/삭제 버튼)
const App = () => {
const [students, setStudents] = useState([])
const [selected, setSelected] = useState(null) // 수정하려고 고른 학생
const onAddUpdate = (student) => {
if (student.id) {
// 수정: id가 같으면 폼에서 받은 새 객체로 교체, 아니면 기존 객체 유지
setStudents((stu) => stu.map((x) => (x.id === student.id ? student : x)))
} else {
// 추가: 이전 배열을 복사하고, 폼에서 받은 값(name, age, major)에 고유 id를 붙여 넣는다
setStudents((stu) => [...stu, { ...student, id: Date.now() }])
}
setSelected(null)
}
const onEdit = (stu) => { setSelected(stu) }
const onDelete = (id) => {
setStudents((stu) => stu.filter((x) => x.id !== id))
}
return (
<div>
<h2>학생 정보 관리</h2>
<StudentForm onSubmit={onAddUpdate} selected={selected} />
<StudentList students={students} onEdit={onEdit} onDelete={onDelete} />
</div>
)
}
student.id가 있는지로 구분한다. 새로 입력한 학생에는 id가 없고, 목록에서 골라 온 학생에는 id가 있다.setStudents((stu) => ...): set 함수에 함수를 넘기면 "현재 최신 state"를 인자로 받는다. Day.2의 함수형 업데이트와 같다.const StudentForm = ({ onSubmit, selected }) => {
const [student, setStudent] = useState({ name: '', age: '', major: '' })
const handleChange = (e) => {
const { name, value } = e.target
// e.target의 name 속성값과 value를 꺼내서 쓴다
setStudent((stu) => ({
...stu,
[name]: value, // 계산된 속성명: name 변수의 '값'이 키가 된다
}))
}
const handleSubmit = (e) => {
e.preventDefault() // form의 기본 동작(페이지 새로고침)을 막는다
if (!student.name) return // 이름이 비어 있으면 무시
onSubmit(student) // 부모가 내려준 함수에 입력한 객체를 넘긴다
setStudent({ name: '', age: '', major: '' }) // 입력 칸 초기화
}
// selected가 바뀔 때마다: 선택한 학생이 있으면 폼에 채우고, 없으면 빈 폼으로
useEffect(() => {
setStudent(selected || { name: '', age: '', major: '' })
}, [selected])
return (
<form onSubmit={handleSubmit}>
<input placeholder="이름" value={student.name} name="name" onChange={handleChange} />
<input placeholder="나이" value={student.age} name="age" onChange={handleChange} />
<input placeholder="전공" value={student.major} name="major" onChange={handleChange} />
<button type="submit">{student.id ? '수정' : '추가'}</button>
</form>
)
}
여기서 새로 배운 것들이다.
name 속성 + [name]: value: 필기에서 헷갈렸던 부분이다. const { name, value } = e.target의 name은 객체의 키 이름이 아니라 <input name="age">처럼 태그에 적어 둔 name 속성값이다. 입력창에 name="age"를 붙여 두면 handleChange 하나로 세 칸을 전부 처리한다. [name]: value는 대괄호 안의 변수 값을 키로 쓰는 문법(계산된 속성명)이라서, name이 'age'이면 { age: value }가 된다.<form onSubmit> + e.preventDefault(): 폼을 제출하면 브라우저가 기본적으로 페이지를 새로고침한다. 이걸 막아야 React 화면이 유지된다. 버튼에 type="submit"을 두면 Enter 키로도 제출된다.useEffect(..., [selected]): 부모에서 selected가 바뀌면 폼의 내용을 그 학생 값으로 맞춘다. 선택이 null이 되면 빈 폼으로 돌아간다.student.id가 있느냐(수정 모드인지)로 바뀐다.const StudentList = ({ students, onEdit, onDelete }) => (
<ul>
{students.map((stu) => (
<li key={stu.id}>
{stu.name}|{stu.age}|{stu.major}
<button onClick={() => onEdit(stu)}>수정</button>
<button onClick={() => onDelete(stu.id)}>삭제</button>
</li>
))}
</ul>
)
실행해 보니 흐름은 이랬다.
이름을 비운 채 제출 → 목록 0개 (무시됨)
kim / 20 입력 후 제출 → 목록: kim|20, 폼 비워짐
수정 클릭 → 폼에 kim, 20이 채워지고 버튼이 '수정'으로 바뀜
나이를 21로 바꿔 제출 → 목록: kim|21, 버튼이 '추가'로 돌아옴
kim이 채워진 '수정' 모드로 남는다. 이 상태로 제출하면 map에서 같은 id를 못 찾아서 아무 변화 없이 폼만 비워진다. 삭제할 때 선택된 학생이면 setSelected(null)로 풀어 주는 처리가 필요하다.StudentFrom(오타)이었는데 위 코드에서는 StudentForm으로 바로잡았다.부모가 리렌더링되면 자식 컴포넌트도 기본적으로 전부 다시 렌더링된다. props가 안 바뀌어도 마찬가지다. memo는 props가 바뀌지 않았으면 다시 그리지 않도록 해 주는 도구다.
실험용 구조다. 렌더링될 때마다 console.log로 알려 준다.
App (버튼으로 state 증가)
├─ Child1 = memo(...) ← memo로 감쌈
│ ├─ Child2 ← memo 없음
│ └─ Child3 ← memo 없음
└─ Child4 ← memo 없음
// App: 버튼을 누르면 num이 바뀌어 App이 리렌더링된다
<button onClick={increase}>1증가</button>
<p>{num}</p>
<Child1 />
<Child4 />
// Child1: memo로 감싼다
import React, { memo } from 'react'
const Child1 = memo((props) => {
console.log('child1 렌더링')
return (
<div>
<p>첫번째 자식</p>
<Child2 />
<Child3 />
</div>
)
})
export default Child1
버튼을 누를 때마다 렌더링 로그가 찍히는 컴포넌트를 확인해 봤다.
버튼 클릭 → child4 렌더링 ← Child4만 계속 다시 그려진다
Child4: memo가 없어서 App이 다시 그려질 때마다 같이 다시 그려진다.Child1: memo가 있고 props가 안 바뀌었으니 건너뛴다.Child2, Child3: memo가 없는데도 안 그려진다. Child1 자체가 다시 실행되지 않으니, Child1 안에서 만들어지는 Child2, Child3도 다시 만들어질 기회가 없는 것이다. 필기의 "Child2, 3은 Child1의 자식이라서 렌더링되지 않는다"는 설명이 맞다.그러면 memo만 감싸면 항상 건너뛸까? 두 가지를 더 실험했다.
(1) 함수를 props로 넘기는 경우
<Child1 onReset={() => {}} /> // 렌더링마다 새 함수가 만들어진다
버튼 클릭 → child1, child2, child3, child4 전부 렌더링
memo는 props를 얕게 비교한다. 함수는 컴포넌트가 실행될 때마다 새로 만들어지는 다른 객체라서, 내용이 같아도 "props가 바뀌었다"고 판단한다. 그러면 memo는 의미가 없어지고 Child1이 다시 그려지면서 Child2, Child3도 같이 다시 그려진다. 이럴 때는 useCallback으로 함수를 고정한다.
const onReset = useCallback(() => { /* ... */ }, [])
<Child1 onReset={onReset} />
버튼 클릭 → child4 렌더링 ← 다시 Child1이 건너뛰어진다
필기의 Child1은 const { onReset } = props로 onReset을 받고 리셋 버튼에 연결하고 있지만, 이번 예제에서는 부모가 onReset을 안 넘겨서 props가 빈 객체 {}였다. 그래서 memo가 잘 동작한 것이다.
(2) 과하게 쓰지 않기
memo는 비교하는 비용도 든다. 모든 컴포넌트에 감싸는 것이 아니라, 렌더링이 무겁고 부모가 자주 바뀌는 곳에 필요할 때 쓴다.
true/false state로 분기하고, 수정 중에는 복사본(selPost1)만 고치다가 저장할 때 목록을 갱신한다. 이때 상세용 객체의 날짜도 같이 갱신하고, 글을 선택할 때 수정 모드를 꺼 줘야 한다.getMonth() + 1과 padStart(2, '0')로 맞춘다.useRef는 값을 바꿔도 리렌더링이 안 되는 보관함이다. useEffect는 렌더링이 끝난 뒤 실행되므로 ref에 현재 값을 넣어 두면 화면에는 이전 값이 보인다.ref에 저장하고, cleanup 함수에서 clearInterval로 정리한다. cleanup이 없으면 StrictMode에서 타이머가 두 개 돌았다.name 속성 + [name]: value로 onChange 하나에 처리하고, 폼은 onSubmit + e.preventDefault()를 쓴다. 추가/수정 구분은 id가 있는지로 한다.memo는 props가 같으면 다시 그리지 않는다. 자식이 건너뛰어지면 그 안의 후손도 같이 건너뛰어진다. 함수 props는 매번 새로 만들어지므로 useCallback과 같이 쓴다.Tags: React useRef memo useCallback useEffect 폼처리 CRUD 프론트엔드 개발자 학습기록