React_Day.4

조현기·4일 전

React_Day.4

배열 state에 항목을 추가·삭제·수정하는 패턴을 게시판, 할 일 목록, 회원 폼으로 반복해서 익힌 날. 핵심은 state를 직접 건드리지 않고 새 배열을 만들어서 set에 넣는 것이다.

Day.3에서 게시판의 목록과 상세 보기까지 만들었다. 오늘은 거기에 추가, 삭제, 수정을 붙인다. 코드는 길어졌지만 쓰는 패턴은 세 가지로 정리된다.

하고 싶은 일쓰는 방법새 배열을 만드는 도구
추가setList([...list, 새항목])스프레드 ...
삭제setList(list.filter(x => x.id !== id))filter
수정setList(list.map(x => x.id === id ? {...x, 값} : x))map + 객체 스프레드

1. 배열에 추가할 때는 push가 아니라 set

배열 추가는 set변수명([...변수명, 새로넣을값]) 이렇게 한다.
JavaScript의 배열명.push(새값)은 배열의 주소가 그대로라서 다시 렌더링되지 않는다.
기존 값의 주소가 바뀌어야 React가 다시 렌더링하기 때문이다.

필기의 이 내용을 직접 실행해서 확인했다. 버튼을 누르면 push로 배열에 값을 넣고 setPosts(posts)를 호출하는 컴포넌트다.

const [posts, setPosts] = useState([1, 2])
const add = () => { posts.push(posts.length + 1); setPosts(posts) }
// 버튼 클릭 후 화면: 1,2   ← 배열 안에는 3이 들어갔지만 화면은 그대로

push는 같은 배열 객체의 내용만 바꾼다. React는 setPosts에 넘어온 값이 이전 state와 같은 객체(같은 주소)인지 먼저 비교하고, 같으면 "바뀐 게 없다"고 보고 렌더링을 건너뛴다. 그래서 화면이 안 바뀐다.

스프레드를 쓰면 내용은 복사하고 주소는 새로 만든다.

setPosts([...posts, newPost])
// 기존 posts를 펼쳐서 복사하고, newPost를 마지막에 붙여 '새 배열'을 만든다.
// 기존 게시글은 그대로 유지되고, 추가만 된다.

2. 게시판에 글 쓰기 추가

Day.3의 게시판에 제목/내용 입력창과 등록 버튼을 붙였다. 입력값은 Day.2에서 한 제어 컴포넌트(value + onChange)로 관리한다.

import React, { useState } from 'react'

const App = () => {
  const [posts, setPosts] = useState([
    { id: 1, title: '첫번째 제목', content: '첫번째 내용' },
    { id: 2, title: '두번째 제목', content: '두번째 내용' },
    { id: 3, title: '세번째 제목', content: '세번째 내용' },
  ])
  const [selPost1, setSelPost1] = useState(null)
  const selPost = (post) => { setSelPost1(post) }   // 클릭한 객체

  // 새 게시글 제목 / 내용을 관리하는 state
  const [newTitle, setNewTitle] = useState('')
  const [newContent, setNewContent] = useState('')

  const addPost = () => {
    if (newTitle.trim() && newContent.trim()) {   // 공백만 있는 입력은 걸러낸다
      const newPost = {
        id: posts.length + 1,
        title: newTitle,       // 입력한 제목이 newTitle에 저장되어 있다
        content: newContent,
      }
      // setPosts가 호출되면 React가 이전 state와 새 값을 비교한다.
      // 새로 만든 배열이라 주소가 다르므로 → 리렌더링 → 화면이 바뀐다.
      setPosts([...posts, newPost])

      // 등록하고 나면 입력창을 비운다
      setNewTitle('')
      setNewContent('')
    }
  }

  return (
    <div className="board-app">
      <h1>Board</h1>
      <div className="board-li">
        <input placeholder="제목 입력" value={newTitle}
               onChange={(e) => setNewTitle(e.target.value)} /><br />
        <textarea placeholder="내용 입력" value={newContent}
                  onChange={(e) => setNewContent(e.target.value)} />
        <button onClick={addPost}>게시글 등록</button>

        <h2>Board List</h2>
        {posts.map((x) => (
          <div key={x.id} className="board-item" onClick={() => selPost(x)}>
            <h2>{x.title}</h2>
          </div>
        ))}
      </div>
      <hr />
      {selPost1 && (
        <div>
          <h2>{selPost1.title}</h2>
          <h2>{selPost1.content}</h2>
        </div>
      )}
    </div>
  )
}

export default App

실행해 보니 이렇게 동작했다.

  • 제목만 넣고 내용을 비운 채 등록 → 목록 3개 그대로 (trim() 검사에서 걸러진다)
  • 제목과 내용을 모두 넣고 등록 → 목록 끝에 새 글이 붙고, 입력창은 ""로 비워진다

trim()은 문자열 앞뒤 공백을 지운다. 스페이스만 누른 입력은 trim()하면 빈 문자열이 되어 falsy라서 if를 통과하지 못한다.


3. 삭제와 useEffect: 지운 글의 상세가 남는 문제

filter로 삭제 기능을 붙였다. 삭제할 id와 다른 글만 남기면 그게 곧 삭제다.

const deletePost = (id) => {
  // 클릭한 id와 일치하지 않는 게시글만 남긴다 → 일치하는 글은 사라진다
  setPosts(posts.filter((post1) => id !== post1.id))
}

그런데 문제가 생긴다. 글을 클릭해서 상세를 보고 있는 상태에서 그 글을 삭제하면, 목록에서는 사라졌는데 아래 상세 영역에는 계속 남아 있다. selPost1이 삭제된 객체를 그대로 들고 있기 때문이다. 이걸 막으려고 useEffect를 썼다.

useEffect(() => {
  // some: 조건을 만족하는 요소가 '하나라도' 있으면 true
  // 현재 목록 안에 선택한 글과 id가 같은 글이 없다면(= !some) → 선택을 null로 되돌린다
  if (selPost1 && !posts.some((post) => post.id === selPost1.id)) {
    setSelPost1(null)
  }
}, [posts, selPost1])

Day.3에서 배운 의존성 배열이 여기서 쓰인다. posts나 selPost1이 바뀔 때마다 "선택한 글이 아직 목록에 있나?"를 검사해서, 없으면 선택을 해제한다. 삭제 버튼은 제목 옆에 붙였다.

<h2>{x.title} <button onClick={() => { deletePost(x.id) }}>삭제</button></h2>

필기에는 key를 왜 쓰는지도 적어 두었다. React가 배열을 렌더링할 때 각 항목을 식별해서, 상태가 바뀔 때 어떤 항목이 바뀌고 추가되고 삭제됐는지 효율적으로 파악해 DOM 업데이트를 최적화하기 위해서다. 맞는 설명이다.

선택한 글을 삭제하면 상세가 사라지는 것도 직접 확인했다. 그런데 이 코드를 돌려 보면서 필기에 없던 문제가 두 가지 보였다.

(1) 다른 글을 삭제하면 열어 둔 상세가 닫힌다

삭제 버튼은 글 하나를 감싸는 <div onClick={() => selPost(x)}> 안쪽에 있다. 그래서 삭제 버튼을 눌러도 클릭 이벤트가 위로 전달(버블링)되어 바깥 div의 onClick까지 실행된다. 결과는 이렇다.

T1 글을 열어 둔 상태에서 T2의 삭제를 누르면 → T2가 삭제되고, 동시에 "T2를 선택"하는 onClick이 실행된다 → useEffect가 "선택한 글이 목록에 없네" 하고 선택을 해제한다 → 엉뚱하게 T1의 상세도 같이 사라진다.

실제로 T1을 열어 둔 채 T2를 삭제했더니 상세 영역이 사라졌다. 삭제 버튼에서 이벤트가 위로 올라가지 않게 막으면 된다.

<button onClick={(e) => { e.stopPropagation(); deletePost(x.id) }}>삭제</button>

(2) 새 글의 id가 겹칠 수 있다

id: posts.length + 1은 글이 삭제되지 않을 때만 안전하다. 1, 2, 3 중에서 1을 지우면 남은 id는 2, 3인데 길이는 2라서, 새 글의 id가 3이 되어 기존 글과 id가 겹친다. 실제로 삭제 뒤에 새 글을 등록해 보니 "같은 key가 있다"는 React 경고가 떴다. key가 겹치면 항목을 제대로 식별하지 못한다.

그래서 뒤에 나오는 할 일 목록에서는 Date.now()를 id로 썼다. 지금 시각의 밀리초 숫자라서 길이에 상관없이 겹칠 일이 거의 없다. (조금 더 단단하게 하려면 crypto.randomUUID()나 "다음에 쓸 id"를 별도 state로 두는 방법도 있다.)

더 단순한 방법

useEffect로 "지워졌으면 해제"를 하는 대신, Day.3에서 본 것처럼 선택한 글의 id만 저장하고 find로 찾으면 지워진 순간 find가 undefined를 돌려줘서 useEffect 없이도 상세가 사라진다. 오늘은 useEffect 쓰는 연습을 한 셈이고, 실무에서는 가능하면 계산할 수 있는 값은 useEffect 없이 렌더링 중에 계산하는 쪽이 더 단순하다.


4. 자식이 입력받고, 부모가 추가한다

입력창과 추가 버튼은 자식(Add)에, 목록 state는 부모(App)에 두었다. 자식은 입력값만 들고 있다가 버튼을 누르면 부모가 내려준 함수를 호출해서 값을 올려보낸다.

// App.jsx (부모)
import React, { useState } from 'react'
import Add from './components/Add'

const copy = [
  { id: 1, done: false, content: '내용1' },
  { id: 2, done: false, content: '내용2' },
  { id: 3, done: false, content: '내용3' },
]

const App = () => {
  const [todo, setTodo] = useState(copy)

  const onAdd = (content) => {
    const newTodo = { id: todo.length + 1, done: false, content }
    // 기존 배열의 복사본 + newTodo → 새 배열이 만들어진다
    setTodo([...todo, newTodo])
  }

  return (
    <div>
      <Add onAdd={onAdd} />
      {todo.map((i) => <div key={i.id}>{i.content}</div>)}
    </div>
  )
}

export default App
// components/Add.jsx (자식)
import React, { useState } from 'react'

const Add = ({ onAdd }) => {
  const [content, setContent] = useState('')

  const onChContent = (e) => {
    setContent(e.target.value)   // 입력한 값이 content에 저장된다
  }

  const onAdd2 = () => {
    // 추가 버튼을 클릭했을 때 부모의 onAdd를 호출하면서 값을 올려보낸다
    onAdd(content)
  }

  return (
    <div>
      <input value={content} onChange={onChContent} placeholder="할 일 입력" />
      <button onClick={onAdd2}>추가</button>
    </div>
  )
}

export default Add

Day.3에서는 값과 setter를 내려보내서 자식이 직접 바꿨다. 이번에는 "추가"라는 동작을 하는 함수 onAdd를 내려보내고, 자식은 그 함수에 데이터를 인자로 넘기는 방식이다. 데이터는 자식에서 부모로 올라가고, 부모가 자기 state를 바꾼다. 부모의 state를 부모만 바꾸게 하고 싶을 때 이쪽이 더 깔끔하다.

필기에는 Add를 두 가지로 적어 두었다. 하나는 <input value={content} ...>이고, 다른 하나는 value 없이 onChange만 있다. 둘을 실행해 비교해 보니 추가는 둘 다 잘 되지만, 추가한 뒤 입력창이 비워지지 않는다. 두 버전 모두 입력창에 "new"가 그대로 남아 있었다. onAdd(content) 다음에 setContent('')가 없기 때문이다. value가 없는 쪽은 아예 React가 값을 관리하지 않는 비제어 입력이라서, 비우려면 value={content}로 바꾸고 setContent('')까지 넣어야 한다.

const onAdd2 = () => {
  onAdd(content)
  setContent('')     // 추가한 뒤 입력창 비우기
}

5. 수정: "지금 무엇을 수정 중인가"를 state로 기억하기

수정 기능의 핵심은 어떤 항목을 수정 중인지를 state(modiId)로 기억하고, 그에 따라 화면을 바꾸는 것이다.

import React, { useState } from 'react'

const App = () => {
  const [todo, setTodo] = useState([
    { id: 1, msg: '공부' },
    { id: 2, msg: '운동' },
  ])

  const [modiId, setModiId] = useState(null)   // 수정 중인 항목의 id (없으면 null)
  const [modiMsg, setModiMsg] = useState('')   // 수정창에 입력 중인 값

  const onModify = (id, msg) => {
    setModiId(id)      // 수정 버튼을 누르면 그 항목의 id를 저장 (예: modiId = 1)
    setModiMsg(msg)    // 현재 내용을 수정창에 채워 넣는다
  }

  const saveClick = (id) => {
    // 데이터를 고쳐서 배열을 새로 만든다: 같은 id만 바꾸고 나머지는 그대로
    setTodo(todo.map((todo1) => todo1.id === id ? { ...todo1, msg: modiMsg } : todo1))
    setModiId(null)    // 저장하면 수정 모드를 끝낸다
    setModiMsg('')
  }

  return (
    <div>
      <ul>
        {todo.map((todo1) => (
          <li key={todo1.id}>
            {todo1.id === modiId ? (
              // 수정 중인 항목이면 입력창과 저장 버튼
              <>
                <input value={modiMsg} onChange={(e) => setModiMsg(e.target.value)} />
                <button onClick={() => saveClick(todo1.id)}>저장</button>
              </>
            ) : (
              // 아니면 내용과 수정 버튼
              <>
                {todo1.msg}
                <button onClick={() => onModify(todo1.id, todo1.msg)}>수정</button>
              </>
            )}
          </li>
        ))}
      </ul>
    </div>
  )
}

export default App

두 번째 항목의 수정을 눌러 보니 입력창에 기존 값 운동이 채워졌고, 값을 바꿔 저장하자 해당 항목만 바뀌었다.

여기서 쓴 도구를 정리하면 이렇다.

  • { ...todo1, msg: modiMsg }: 객체 스프레드. 기존 객체의 모든 속성을 복사하고 msg만 새 값으로 덮어쓴 새 객체를 만든다. 객체도 배열처럼 직접 고치지 않고 새로 만든다.
  • todo.map(...)으로 같은 id인 항목만 새 객체로 바꾸고 나머지는 그대로 돌려주면, 결과는 일부만 바뀐 새 배열이다.
  • <>...</>(Fragment)로 감싸서 불필요한 div 없이 여러 요소를 하나의 묶음으로 반환한다.
  • modiId가 null인지 아닌지가 화면 분기 기준이다. Day.2의 삼항 연산자 조건부 렌더링이다.

필기에는 같은 내용을 내가 쓴 버전과 강사님 버전으로 나란히 적어 두었는데, 구조는 같고 변수명(x/todo1, modMsg/modiMsg)만 달랐다. 위 코드는 변수 이름이 더 읽기 좋은 쪽으로 정리했다.


6. 추가 · 수정 · 삭제를 하나의 입력창으로

입력창 하나와 버튼 하나로 추가와 수정을 겸하는 버전도 만들었다. modiId가 null이면 추가 모드, 값이 있으면 수정 모드다.

import React, { useState } from 'react'

const App = () => {
  // 1. 입력창 state
  const [text, setText] = useState('')
  // 2. 목록 state
  const [list, setList] = useState([
    { id: 1, do: '운동' },
    { id: 2, do: '공부' },
    { id: 3, do: '독서' },
  ])
  // 3. 수정 상태 (null이면 추가 모드)
  const [modiId, setModiId] = useState(null)

  const onAddUpdate = () => {
    if (modiId !== null) {
      // 수정 모드: 같은 id만 새 내용으로 바꾼다
      setList(list.map((x) => (x.id === modiId ? { ...x, do: text } : x)))
      setModiId(null)
      setText('')
    } else {
      // 추가 모드: 새 항목을 끝에 붙인다
      setList([...list, { id: Date.now(), do: text }])
      setText('')
    }
  }

  const onModify = (x) => {      // 수정 버튼: 수정 모드로 전환하고 입력창에 기존 값을 채운다
    setModiId(x.id)
    setText(x.do)
  }

  const onDelete = (id) => {
    setList(list.filter((x) => x.id !== id))
  }

  return (
    <div>
      <h1>오늘 할 일</h1>
      <input value={text} placeholder="할 일 입력" onChange={(e) => setText(e.target.value)} />
      <button onClick={onAddUpdate}>{modiId !== null ? '수정완료' : '추가'}</button>
      <hr />
      <ul>
        {list.map((x) => (
          <li key={x.id}>
            {x.do}
            <button onClick={() => onModify(x)}>수정</button>
            <button onClick={() => onDelete(x.id)}>삭제</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default App
  • 수정 버튼을 누르기 전(modiId가 null) → 버튼 글자는 추가
  • 수정 버튼을 누르면(modiId에 id가 들어감) → 입력창에 기존 값이 채워지고 버튼 글자가 수정완료로 바뀐다

실행해서 수정 버튼을 누르면 입력창에 운동이 채워지고 버튼이 수정완료가 되었다. 값을 고쳐서 누르면 그 항목만 바뀌고 버튼은 추가로 돌아왔다. 삭제도 정상이었다.

이 코드에서 아쉬운 점은 빈 입력도 추가된다는 것이다. 입력창을 비운 채 추가를 누르면 빈 항목이 생겼다. 2번의 trim() 검사를 넣어서 if (!text.trim()) return을 맨 앞에 두면 막을 수 있다.


7. 회원 목록 폼: 입력 검증까지

이름과 이메일 두 칸을 받아서 목록으로 보여주고 수정·삭제도 되는 폼이다. 6번과 같은 구조에 입력 검증이 더해졌다.

import React, { useState } from 'react'

const App = () => {
  const [name, setName] = useState('')
  const [mail, setMail] = useState('')
  const [edit, setEdit] = useState(null)    // 수정 중인 id (null이면 추가 모드)
  const [list, setList] = useState([])

  const add = () => {
    if (edit !== null) {                      // 수정 모드 → 저장
      if (mail.includes('@')) {
        setList(list.map((i) => (edit === i.id ? { ...i, name, mail } : i)))
        setName(''); setMail(''); setEdit(null)
      } else {
        alert('이메일에 @ 문자가 들어가야 합니다.')
      }
    } else {                                  // 추가 모드
      if (mail.includes('@')) {
        setList([...list, { id: Date.now(), name, mail }])
        setName(''); setMail('')
      } else {
        alert('이메일에 @ 문자가 들어가야 합니다.')
      }
    }
  }

  const del_btn = (id) => setList(list.filter((i) => i.id !== id))

  const edit_btn = (x) => {
    setEdit(x.id); setName(x.name); setMail(x.mail)
  }

  return (
    <div>
      <input value={name} placeholder="이름" onChange={(e) => setName(e.target.value)} />
      <input value={mail} placeholder="이메일" onChange={(e) => setMail(e.target.value)} />
      <button onClick={add}>{edit !== null ? '저장' : '추가'}</button>
      <hr />
      <div>
        {list.map((x) => (
          <p key={x.id}>
            이름:{x.name}, 이메일:{x.mail}
            <button onClick={() => edit_btn(x)}>수정</button>
            <button onClick={() => del_btn(x.id)}>삭제</button>
          </p>
        ))}
      </div>
    </div>
  )
}

export default App
  • mail.includes('@'): 문자열에 @가 들어 있으면 true. 없으면 alert로 알리고 추가/저장을 하지 않는다.
  • 필드가 둘이라 객체 단축 표기({ id, name, mail }처럼 변수명과 키 이름이 같을 때 한 번만 쓰기)를 쓰면 코드가 짧아진다.

@ 없이 입력했을 때 경고가 한 번 뜨고 목록에는 추가되지 않았고, @가 있으면 추가, 수정해서 저장하면 반영되었다. 다만 includes('@')는 아주 느슨한 검사다. 이메일 칸에 @ 한 글자만 넣어도 통과했다. 이름 칸이 비어 있어도 통과한다. 연습용으로는 충분하지만, 실제 서비스라면 정규식(/^[^\s@]+@[^\s@]+\.[^\s@]+$/ 같은 형태)이나 <input type="email">의 기본 검증을 같이 쓴다.


마무리

  • state 배열/객체는 직접 고치지 않고 새로 만들어서 set에 넣는다. push는 같은 배열이라 화면이 안 바뀐다.
  • 추가는 스프레드, 삭제는 filter, 수정은 map + 객체 스프레드다.
  • 수정 기능은 "수정 중인 id"를 state로 기억(null이면 평소 상태)하고, 그 값으로 화면을 분기한다. 한 입력창으로 추가와 수정을 겸할 수도 있다.
  • 자식에서 부모로 데이터를 올릴 때는 부모가 내려준 함수를 호출하면서 값을 인자로 넘긴다.
  • 클릭 가능한 요소 안의 버튼은 버블링을 주의한다(e.stopPropagation()). id는 length + 1 대신 Date.now() 같은 겹치지 않는 값을 쓴다.
  • 지워진 항목의 상세가 남는 문제는 useEffect로도 풀 수 있지만, id만 저장하고 find로 찾으면 더 단순하게 해결된다.

Tags: React useState 배열state CRUD 불변성 useEffect props 프론트엔드 개발자 학습기록

profile
난 뭘까

0개의 댓글