React_Day.6

조현기·4일 전

React_Day.6

memo에 이어 useCallback, useMemo로 불필요한 렌더링과 재계산을 줄이는 법을 정리하고, 로그인·회원 수정이 되는 회원 관리 앱과 폼 연습 문제를 만든 날.

Day.5의 memo 실험에서 함수를 props로 넘기면 memo가 소용없어진다는 걸 확인했다. 오늘은 그 해결책인 useCallback부터 시작한다.


1. useCallback: 함수의 주소를 고정하기

컴포넌트는 렌더링될 때마다 함수 전체가 다시 실행되고, 그 안에 선언한 함수도 매번 새로 만들어진다. 내용이 같아도 새 함수는 이전과 다른 주소를 가진다. memo는 props를 비교할 때 이 주소를 보기 때문에, 함수 props는 매번 "바뀐 것"으로 취급된다.

useCallback은 함수를 기억해 두고 같은 주소를 계속 쓰게 해 준다.

const App = () => {
  const [num, setNum] = useState(0)
  const increase = () => setNum(num + 1)

  // 처음 한 번만 만들고 계속 재사용한다 → 주소가 유지된다
  const onClickReset = useCallback(() => {
    setNum(0)
  }, [])

  return (
    <div>
      <button onClick={increase}>1증가</button>
      {num}
      <Child1 onClickReset={onClickReset} />
      <Child4 />
    </div>
  )
}
const Child1 = memo((props) => {
  console.log('child1 렌더링')
  return (
    <div>
      <p>첫번째 자식</p>
      <button onClick={props.onClickReset}>리셋버튼</button>
      <Child2 />
      <Child3 />
    </div>
  )
})

(Child2, Child3, Child4는 Day.5와 같다.)

두 번째 인자인 의존성 배열은 useEffect와 같은 규칙이다.

  • []: 처음 한 번만 만들고 계속 재사용한다. 주소가 유지된다.
  • [num]: num이 바뀔 때마다 함수를 새로 만든다.

useCallback을 쓴 버전과 안 쓴 버전을 나란히 실행해서 1증가를 눌렀을 때 렌더링되는 컴포넌트를 비교했다.

1증가 클릭 후 렌더링된 것
useCallback 사용Child4만
일반 함수 그대로 전달Child1, Child2, Child4

일반 함수를 넘기면 App이 다시 그려질 때마다 onClickReset이 새로 만들어지고, Child1(memo)은 "props가 바뀌었다"고 판단해서 같이 다시 그려진다. 그러면 안쪽의 Child2, Child3도 따라 그려진다. useCallback으로 주소를 고정하면 Child1이 건너뛰어진다. 리셋 버튼은 두 경우 모두 숫자를 0으로 돌렸다.

이 구조가 동작하는 이유를 정리하면 이렇다.

  1. React.memo는 이전 props와 같으면 렌더링을 생략하고, 달라졌으면 렌더링한다.
  2. 함수는 매번 새로 만들어지므로 그냥 넘기면 항상 "다르다".
  3. useCallback으로 같은 주소를 유지하면 memo가 "같다"고 판단한다.

memo와 useCallback은 세트로 쓴다. 둘 중 하나만 써서는 효과가 없다. useCallback만 쓰면 받는 쪽이 어차피 매번 렌더링되고, memo만 쓰면 함수 props 때문에 건너뛰지 못한다.

한 가지 주의할 점이 있다. useCallback(..., [])로 만든 함수는 처음 만들어질 때의 값을 기억한 채로 계속 쓰인다. 위 코드의 setNum(0)은 setNum이 항상 같은 함수라서 문제없지만, 함수 안에서 num 같은 state를 읽는다면 의존성 배열에 [num]을 넣어야 최신 값을 쓴다. 아니면 다음에 나오는 것처럼 setNum((n) => n + 1) 같은 함수형 업데이트로 써서 state를 직접 읽지 않게 만든다.


2. 학생 관리 앱에 적용하기

Day.5의 학생 관리 앱에 memo와 useCallback을 적용했다.

const StudentApp = () => {
  const [students, setStudents] = useState([])
  const [selected, setSelected] = useState(null)

  // useCallback으로 주소를 고정해서, memo가 props가 안 바뀐 것으로 인식하게 한다
  const deleteStudent = useCallback((id) => {
    setStudents((stu) => stu.filter((s) => s.id !== id))
  }, [])

  const onAddUpdate = useCallback((student) => {
    if (student.id) {
      // 수정: id가 같으면 폼에서 받은 새 객체로 교체
      setStudents((stu) => stu.map((s) => (s.id === student.id ? student : s)))
    } else {
      // 추가: 기존 배열 복사 + 폼 값에 고유 id
      setStudents((stu) => [...stu, { ...student, id: Date.now() }])
    }
    setSelected(null)
  }, [])

  const handleEdit = useCallback((student) => {
    setSelected(student)
  }, [])

  return (
    <div>
      <h2>학생 정보 관리</h2>
      <StudentForm onSubmit={onAddUpdate} selected={selected} />
      <StudentList students={students} onEdit={handleEdit} onDelete={deleteStudent} />
    </div>
  )
}
export default React.memo(StudentForm)   // 자식의 불필요한 렌더링 방지
export default React.memo(StudentList)

여기서 눈여겨볼 점은 setStudents((stu) => ...)처럼 함수형 업데이트를 쓴 이유다. students를 함수 안에서 직접 읽지 않으니 의존성 배열을 []로 두어도 항상 최신 state 기준으로 동작한다. 만약 setStudents(students.filter(...))로 썼다면 students가 의존성이라서 목록이 바뀔 때마다 함수가 새로 만들어져 useCallback이 의미가 없어진다.

실제로 어떤 동작에서 어느 컴포넌트가 렌더링되는지 로그로 확인했다.

처음 화면                → form, list, form   (form은 effect에서 state를 한 번 더 설정해서 두 번)
이름 입력 중             → form               (폼 자신의 state라서 폼만. list는 안 그려진다)
추가 제출                → form, list         (students가 바뀌어서 list도)
수정 클릭                → form, form         (selected가 바뀌어서 form만. list는 그대로)
삭제                     → list               (form은 props가 그대로라서 건너뛴다)

memo와 useCallback 덕분에 글자를 입력할 때 목록이 같이 다시 그려지지 않고, 삭제할 때 폼이 다시 그려지지 않는다. 다만 학생이 몇 명 없는 화면에서는 눈에 보이는 차이가 없다. 목록이 수백 개가 되거나 항목 렌더링이 무거울 때 효과가 나는 도구라서, 지금은 "원리를 익히는 연습"에 가깝다.


3. useMemo: 계산 결과 기억하기

useCallback이 함수를 기억한다면, useMemo는 계산한 값을 기억한다.

const [num, setNum] = useState(0)
const [light, setLight] = useState('불')

// num*num 계산 결과를 기억해 두고, num이 바뀔 때만 다시 계산한다
const comp = useMemo(() => {
  console.log('복잡한 연산')
  return num * num
}, [num])
  • App이 렌더링될 때마다 useMemo가 num이 이전과 같은지 확인한다.
  • 같으면 기억해 둔 이전 결과를 반환한다.
  • 다르면 다시 계산한다.

on/off 버튼을 눌러도 num은 그대로라서 제곱 계산이 다시 일어나지 않는다. 직접 확인했다. 토글을 세 번 눌러도 계산 횟수는 0이었고, num증가(+100)를 눌렀을 때만 계산이 1번 일어나서 10000이 나왔다.

기억하는 것예
useCallback(fn, deps)함수자식에게 넘기는 이벤트 핸들러
useMemo(() => 값, deps)계산 결과 값무거운 계산, 필터링/정렬 결과

num * num은 아주 가벼운 계산이라서 이 예제에서는 쓸 이유가 없다. 계산이 정말 오래 걸릴 때(큰 배열을 걸러내고 정렬하는 것 등)에 쓰는 도구이고, 어디까지나 성능을 위한 최적화라서 먼저 코드를 단순하게 짜고 느릴 때 붙이는 것이 순서다.

필기 코드의 토글 버그

필기에 있던 on/off 버튼 코드는 이랬다.

const Toggle = () => {
  if (light == '불') { setLight('off') }
  else { setLight('on') }
}

초기값이 '불'이라서 첫 클릭은 off가 되는데, 그다음부터는 light가 '불'이 아니니까 계속 else로 가서 on만 나온다. 세 번 눌렀을 때 화면 표시가 off → on → on이었다. 초기값이 'on'/'off' 중 하나였어야 한다. 아래처럼 쓰면 더 간단하다.

const [light, setLight] = useState('on')
const toggle = () => setLight((l) => (l === 'on' ? 'off' : 'on'))

함수 이름도 Toggle처럼 대문자로 시작하면 컴포넌트와 헷갈리므로 toggle로 쓴다.


4. 회원 관리 앱: 가입 / 로그인 / 내 정보 수정 / 회원 관리

지금까지 배운 것을 한곳에 모은 연습이다. 화면에는 이런 기능이 들어 있다.

  • 회원가입 폼: 아이디/이메일/비밀번호를 받아서 목록에 추가
  • 로그인 폼: 아이디와 비밀번호가 맞는 회원을 찾아 로그인 상태로 만든다
  • 로그인 중에는 회원가입 폼 대신 "로그인된 사용자"와 내 정보 수정 폼, 로그아웃 버튼을 보여 준다
  • 회원 목록: 클릭하면 상세 보기, 삭제 버튼, 상세에서 수정 모드

핵심 state를 정리하면 이렇다.

const [users, setUsers] = useState([
  { id: 1, name: 'user1', email: 'user1@example.com', password: 'pw1' },
  { id: 2, name: 'user2', email: 'user2@example.com', password: 'pw2' },
])
const [selUser1, setSelUser1] = useState(null)       // 목록에서 선택한 회원
const [modiMode, setModiMode] = useState(false)      // 선택한 회원의 수정 모드
const [form, setForm] = useState({ name: '', email: '', password: '' })       // 가입 폼
const [loginForm, setLoginForm] = useState({ name: '', password: '' })        // 로그인 폼
const [loggedInUser, setLoggedInUser] = useState(null)                        // 로그인한 회원
const [editUserForm, setEditUserForm] = useState({ name: '', email: '', password: '' }) // 내 정보 수정 폼

폼마다 { name, email, password } 객체 state를 따로 두고, 입력창은 onChange={(e) => setForm({ ...form, name: e.target.value })}처럼 객체 스프레드로 한 칸만 바꾼다. Day.5에서는 name 속성과 [name]: value로 하나의 핸들러로 묶었는데, 입력창이 여러 폼에 흩어진 경우에는 이렇게 칸마다 쓰는 방식도 많이 쓴다.

로그인과 내 정보 수정

const loginUser = () => {
  // find: 아이디와 비밀번호가 모두 같은 회원을 찾는다 (없으면 undefined)
  const user = users.find((u) => u.name === loginForm.name && u.password === loginForm.password)

  if (user) {
    setLoggedInUser(user)                       // 로그인 상태 저장
    setEditUserForm(user)                       // 수정 폼에 현재 정보를 채워 둔다
    setLoginForm({ name: '', password: '' })
  } else {
    alert('아이디 또는 비밀번호가 틀렸습니다.')
  }
}

const updatemyInfo = () => {
  const updateUsers = users.map((x) =>
    x.id === loggedInUser.id ? { ...x, ...editUserForm } : x   // 내 정보만 덮어쓴다
  )
  setUsers(updateUsers)
  setLoggedInUser({ ...loggedInUser, ...editUserForm })        // 로그인 정보도 같이 갱신
  setEditUserForm({ name: '', email: '', password: '' })
  alert('회원 정보 수정 완료')
}

로그인 여부로 화면을 바꾸는 부분은 Day.2의 삼항 연산자다.

{!loggedInUser ? (
  <div className="auth-section"> {/* 회원가입 폼 */} </div>
) : (
  <>
    <h2>로그인된 사용자 : {loggedInUser.name}</h2>
    <button onClick={logoutUser}>로그아웃</button>
    {/* 내 정보 수정 폼 */}
  </>
)}

이전 날 배운 것들도 그대로 들어 있다. 삭제 버튼에는 e.stopPropagation()을 써서 삭제가 목록 클릭(선택)으로 번지는 것을 막았다. Day.4에서 문제가 됐던 부분을 이번에는 처음부터 반영했다. 선택한 회원이 삭제됐을 때 상세를 닫는 useEffect도 Day.4와 같다.

실행해 보며 확인한 것

  • 틀린 비밀번호로 로그인 → alert 실행, 로그인 안 됨
  • 맞는 정보로 로그인 → "로그인된 사용자 : user1"이 뜨고 수정 폼에 user1이 채워짐
  • 이름을 user1x로 바꿔 저장 → 로그인 표시와 목록 모두 user1x로 바뀜

허술한 부분

  • 내 정보를 수정하고 나면 수정 폼이 빈칸이 된다. updatemyInfo 끝에서 setEditUserForm을 빈 값으로 초기화하기 때문에, 로그인을 유지한 채 폼이 비어 버린다(실행 후 수정 폼 값이 ""). 비우는 대신 방금 저장한 값으로 채워 두는 것이 자연스럽다.
  • 로그인한 회원을 목록에서 삭제해도 로그인 상태가 유지된다. 삭제 후에도 "로그인된 사용자 : user1x"가 그대로 떴다. loggedInUser가 users와 별개로 복사본을 들고 있어서 생기는 일이다. 로그인 상태를 객체 대신 id만 저장하고 users.find로 꺼내 쓰면 Day.3, Day.4에서 정리한 것과 같은 이유로 이런 불일치가 사라진다.
  • 회원 id가 users.length + 1이다. Day.4에서 정리한 대로, 삭제 후 가입하면 id가 겹칠 수 있다.
  • 시각은 useEffect(..., [])로 처음 한 번만 구해서 보여 준다. 시계처럼 흐르지 않고 화면을 연 시점에 멈춰 있다. 또 렌더링마다 const now = new Date()를 만드는데 쓰지 않는 값이다. updateUser 안에도 쓰지 않는 today1이 있어서 정리 대상이다.
  • 비밀번호를 평문으로 state에 들고 비교한다. 연습용이라 괜찮지만, 실제 서비스에서는 비밀번호를 브라우저에서 비교하거나 저장하지 않고 서버에서 해시로 검증한다. 샘플 데이터의 아이디/이메일/비밀번호는 연습용 가짜 값(example.com)으로 바꿨다.

CSS

스타일은 member.css로 분리했다. 가운데 정렬(display: flex, align-items: center), 연한 하늘색 배경, 입력창/버튼 공통 스타일을 줬다. 한 가지 짚을 점은 CSS에 .po-item 스타일이 있는데 JSX에서는 className="user-list"를 쓰고 있어서 .po-item 규칙은 어디에도 적용되지 않는다. 그리고 .user-list에 max-height: 30vh; overflow-y: auto가 걸려 있는데 이 클래스가 회원 한 명 한 명에 붙어 있어서 의도한 "목록 전체 스크롤"과 다르게 동작한다. 목록 전체를 감싸는 div에 주는 것이 맞다.


5. 입력 연습: 글자 수, 로딩 버튼, 숫자 → 배열

const [text, setText] = useState('')
const [btn, setBtn] = useState('꾸욱')
const [num, setNum] = useState('')
const [ary, setAry] = useState([])

useEffect(() => {
  const result_ary = []
  for (let i = 1; i <= num; i++) {
    result_ary.push(i)
  }
  setAry(result_ary)
}, [num])
<input value={text} placeholder="입력하세요" onChange={(e) => setText(e.target.value)} />
<p>글자수 : {text.length}</p>

<button onClick={() => {
  setBtn('로딩중')
  setTimeout(() => { setBtn('완료') }, 3000)    // 3초 뒤에 '완료'로 바꾼다
}}>{btn}</button>

<input value={num} placeholder="숫자 입력" onChange={(e) => setNum(Number(e.target.value))} />
<p>{ary.join(',')}</p>
  • 글자 수: text.length를 그대로 렌더링하면 입력할 때마다 자동으로 갱신된다. 별도 state가 필요 없다. 다만 length는 UTF-16 코드 단위로 세서 이모지 '👍'는 2로 나온다([...'👍'].length는 1).
  • 로딩 버튼: 클릭 즉시 로딩중으로 바꾸고, setTimeout으로 3초 뒤에 완료로 바꾼다. 비동기 처리를 흉내 내는 기본 패턴이다.
  • 숫자 → 배열: 5를 입력하면 1,2,3,4,5가 표시된다.

세 번째 예제는 동작하지만 useEffect를 쓸 필요가 없다. num으로부터 계산할 수 있는 값(ary)을 별도 state로 두면, 입력할 때마다 렌더링이 두 번 일어난다(num 변경으로 한 번, effect 안의 setAry로 또 한 번). 실제로 5를 입력했을 때 렌더링 횟수가 2였다. 렌더링 중에 바로 계산하면 한 번이면 된다.

const [num, setNum] = useState(0)
const ary = Array.from({ length: num }, (_, i) => i + 1)   // 렌더링할 때 계산

Day.4에서 정리한 "계산할 수 있는 값은 useEffect/state 없이 렌더링 중에 계산한다"와 같은 이야기다. 또 하나, Number(e.target.value)는 abc를 넣으면 NaN이 되고, React가 value에 NaN이 들어왔다는 경고를 띄운다. 입력창은 비어 보이고 글자도 입력되지 않는다. -3을 넣으면 배열은 빈 값이다. 숫자만 받으려면 <input type="number">를 쓰는 것이 낫다.


6. 로그인 폼 연습

아이디/비밀번호 폼을 만들고, 로그인 버튼을 submit으로 설정해 새로고침을 막는다. 아이디와 비밀번호가 같으면 "로그인 성공", 다르면 "다시 확인 필요"를 alert로 띄운다.

const App = () => {
  const [form, setForm] = useState({ id: '', password: '' })

  const onsubmit = (e) => {
    e.preventDefault()                           // 제출 시 새로고침 방지
    form.id === form.password ? alert('로그인 성공') : alert('다시 확인 필요')
  }

  return (
    <form onSubmit={onsubmit}>
      <input type="text" value={form.id} placeholder="id입력"
             onChange={(e) => setForm({ ...form, id: e.target.value })} />
      <input type="password" value={form.password} placeholder="pw입력"
             onChange={(e) => setForm({ ...form, password: e.target.value })} />
      <button type="submit" onClick={onsubmit}>로그인</button>
    </form>
  )
}

필기 코드에는 <form onSubmit={onsubmit}>과 <button type="submit" onClick={onsubmit}>이 둘 다 있었다. 폼이 제출될 때 한 번, 버튼을 클릭할 때 또 한 번 실행될 것 같지만, 실제로는 버튼 클릭 1번에 핸들러가 한 번만 실행됐다. onClick으로 먼저 호출된 onsubmit 안의 e.preventDefault()가 클릭의 기본 동작(= 폼 제출)을 막아서 뒤의 onSubmit이 호출되지 않기 때문이다. 즉 동작은 맞지만 onClick은 불필요한 중복이고, 우연히 preventDefault 덕분에 문제가 없을 뿐이다. type="submit" 버튼과 onSubmit만으로 충분하고, 이렇게 하면 입력창에서 Enter 키로도 제출된다.


마무리

  • 함수 props는 렌더링마다 새로 만들어져서 memo를 무력화한다. useCallback으로 주소를 고정하고 memo와 세트로 쓴다.
  • useCallback 안에서 state를 직접 읽으면 오래된 값을 쓰게 되므로 함수형 업데이트(setX(prev => ...))를 쓰거나 의존성 배열에 넣는다.
  • useCallback은 함수, useMemo는 계산 결과 값을 기억한다. 둘 다 성능 최적화용이라 작은 앱에서는 필수가 아니다.
  • 로그인 상태, 선택한 항목처럼 다른 state와 연결된 값은 객체를 복사해 두기보다 id만 저장하고 꺼내 쓰면 삭제·수정과 어긋나지 않는다.
  • 다른 값에서 계산할 수 있는 값은 state와 useEffect 없이 렌더링 중에 계산한다.
  • 폼은 <form onSubmit> + type="submit" + e.preventDefault()로 처리한다. onClick을 중복으로 달지 않는다.

Tags: React useCallback useMemo memo 성능최적화 폼처리 로그인 프론트엔드 개발자 학습기록

profile
난 뭘까

0개의 댓글