React_Day.5

조현기·3일 전

React_Day.5

게시판에 날짜와 수정 기능을 붙여 완성하고, 렌더링과 무관하게 값을 들고 있는 useRef, 폼 하나로 추가·수정을 처리하는 학생 관리, 불필요한 리렌더링을 막는 memo까지 정리한 날.

Day.4에서 배열 state의 추가·삭제·수정 패턴을 익혔다. 오늘은 그걸 게시판에 마저 적용하고, 새로운 도구 두 가지(useRef, memo)를 배웠다.


1. 게시판 완성: 작성 시각과 글 수정

글을 쓴 시각을 같이 저장하고, 상세 화면에서 수정 모드로 바꿔 고칠 수 있게 했다.

날짜 문자열 만들기

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가 겹칠 수 있다.

CSS

스타일은 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%로 줘도 삐져나오지 않게 한다.


2. useRef: 렌더링과 상관없이 값을 들고 있기

useRef(초기값)은 { current: 초기값 } 모양의 객체를 만들어 준다. 이 객체는 다시 렌더링되어도 같은 객체가 유지되고, current를 바꿔도 리렌더링이 일어나지 않는다. useState와 비교하면 이렇다.

useStateuseRef
값을 바꾸면리렌더링 (화면 갱신)리렌더링 없음
렌더링 사이에 값 유지OO
바꾸는 방법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는 렌더링이 끝난 뒤에 실행되기 때문이다. 순서는 이렇다.

  1. 버튼 클릭 → init이 1로 바뀜
  2. 컴포넌트가 다시 실행되면서 화면을 그린다. 이때 cntRef.current는 아직 0이다.
  3. 화면이 그려진 뒤에 useEffect가 실행되어 cntRef.current를 1로 바꾼다. (리렌더링은 안 일어나서 화면에는 반영되지 않는다.)

그래서 현재 값과 바로 직전 값을 나란히 보여 주는 패턴으로 쓸 수 있다. 필기에서는 초기값 없이 useRef()로 만들었는데, 이 값은 undefined다. 필기에는 "undefined는 (null, false, 0, "")와 같다"고 적었는데, 모두 falsy(조건문에서 거짓으로 취급)라는 점은 같지만 같은 값은 아니다. useRef()는 undefined, useRef(null)은 null이 들어간다.

참고로 React 공식 문서는 렌더링 중에 ref.current를 읽어서 화면에 그리는 것을 권하지 않는다. 위 코드는 "ref는 리렌더링을 일으키지 않는다"는 동작을 눈으로 보려는 실험용이다.

state와 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는 바뀌어도 화면을 갱신하지 못하고, 어떤 이유로든 다른 곳에서 리렌더링이 일어나야 그제야 쌓인 값이 화면에 보인다.

타이머 id를 ref에 저장하고 cleanup 하기

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 함수는 컴포넌트가 화면에서 사라질 때(언마운트) 실행되어 타이머, 이벤트 리스너처럼 메모리를 차지하는 것들을 정리한다. 필기에는 "언마운트될 때 실행"이라고만 적었는데, 보충하면 의존성 배열의 값이 바뀌어 effect가 다시 실행되기 직전에도 이전 effect의 cleanup이 먼저 실행된다.

cleanup이 정말 필요한지 확인해 봤다. 개발 모드의 StrictMode는 effect를 한 번 실행 → cleanup → 다시 실행해서 정리가 제대로 짜여 있는지 검사한다. 200ms 간격 타이머로 1.05초 뒤 숫자를 비교했다.

1.05초 뒤 숫자
cleanup 있음5 (정상)
cleanup 없음10 (타이머가 2개 돌아서 2배 빠름)

cleanup을 빼면 첫 번째 타이머가 멈추지 않고 남아 있어서 숫자가 두 배로 올라갔다. 타이머를 만들었으면 정리하는 코드도 같이 쓴다.


3. 학생 정보 관리: 폼 하나로 추가와 수정

Day.4에서는 입력창 state를 각각 만들었다. 이번에는 입력창이 여러 개일 때 state 하나(객체)로 묶어서 관리하고, 폼과 목록을 컴포넌트로 나눴다. 구조는 이렇다.

App (students, selected state + 추가/수정/삭제 함수)
├─ StudentForm  (입력 폼, 입력값 state)
└─ StudentList  (목록, 수정/삭제 버튼)

App: 상태와 동작을 한곳에

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의 함수형 업데이트와 같다.

StudentForm: 입력 하나하나가 아니라 객체로

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가 있느냐(수정 모드인지)로 바뀐다.

StudentList

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으로 바로잡았다.

4. memo: 부모가 다시 그려져도 자식이 안 그려지게

부모가 리렌더링되면 자식 컴포넌트도 기본적으로 전부 다시 렌더링된다. 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에 현재 값을 넣어 두면 화면에는 이전 값이 보인다.
  • 타이머 id 같은 것은 ref에 저장하고, cleanup 함수에서 clearInterval로 정리한다. cleanup이 없으면 StrictMode에서 타이머가 두 개 돌았다.
  • 입력창이 여러 개면 name 속성 + [name]: value로 onChange 하나에 처리하고, 폼은 onSubmit + e.preventDefault()를 쓴다. 추가/수정 구분은 id가 있는지로 한다.
  • memo는 props가 같으면 다시 그리지 않는다. 자식이 건너뛰어지면 그 안의 후손도 같이 건너뛰어진다. 함수 props는 매번 새로 만들어지므로 useCallback과 같이 쓴다.

Tags: React useRef memo useCallback useEffect 폼처리 CRUD 프론트엔드 개발자 학습기록

profile
난 뭘까

0개의 댓글