모든 서비스에 약관 동의 페이지는 존재한다.
디자인 상관없이 약관 동의 페이지를 만들라고 하면,
AI도움 없이 과연 어느 정도까지 만들 수 있을까?
이 글을 본 주니어라면 한 번 직접 구현해보도록 하자.
빠르게 만들기보다는 자기가 구현할 수 있는 최대한 만큼 구현해보는 것이 목표이다.

Check List 상태 관리
상태란,
동의하기 체크 요소들을 한 배열에 담아서 관리하였다.
const [checkedList, setCheckedList] = useState(TERMS_LIST.map((term) => term.defaultChecked));
만약 하나씩 관리하게 된다면,
체크박스의 개수 만큼 상태를 직접 생성해주어야 할 것이다 → 유지 보수가 굉장히 힘들어진다.
그럼 이제 체크박스들을 클릭했을 때, 값을 바꿔주는 함수를 만들어보자.
const changeCheckedList = (index) => { checkedList[index] = !checkedList[index]; }
이 코드의 문제점은 무엇일까?
이 코드에서 checkedList의 실제 데이터 값은 바뀐다.
하지만 React가 이 데이터의 변화를 탐지 못한다.
그래서 바뀐 데이터를 실제로 화면에 다시 보여주기 위해서는 바뀐 상황을 React에 알려야하고
우리는 흔히 useState에서 set 함수로 “바꼈다!” 라고 알려주는 것이다.
const changeCheckedList = (index) => { checkedList[index] = !checkedList[index]; setCheckedList(checkedList); }
자 그럼, 이렇게 setCheckedList에 수정된 checkedList를 넣어주면 된다.
정말 그럴싸해보이지만, 아쉽게도 이 코드도 문제가 있다.
setCheckedList는 값이 바뀌었는지 shallow 비교(얕은 비교)를 한다.
즉 참조만 비교하기에 setCheckedList 함수는 checkedList의 주소를 비교하는 것이다.
당연히 checkedList의 주소가 바뀐 것이 아니기에 React는 바뀌지 않았다고 생각한다.
그럼 어떡할까?
const changeCheckedList = (index) => { const newCheckedList = [...checkedList]; newCheckedList[index] = !newCheckedList[index]; setCheckedList(newCheckedList); }
이렇게 새로운 배열을 선언해주면 된다.
파생값이란,
const isRequiredAllChecked = TERMS_LIST.every((term, index)=>{ return !term.required || checkedList[index]; })
얘는 왜 useState를 감지 하지 않고도 버튼의 disabled 여부는 잘 바뀔까?
이것은 결국 checkedList가 바뀔 때마다 다시 계산되는 파생값이다.
바뀌는 값이라고 무조건 useState를 사용해야하는 것이 아니라,
앞으로는 정말 탐지해야하는 상태를 잘 분석하고 어떤 것이 파생값인지 잘 구분해야할 것 같다.
조금 더 쉽게 생각해보면, 로직이 아니라 UI에서 유저가 수정할 수 있는 변수를 “상태”라고 생각하면 좋을 것 같다.
궁금증 - 최적화
위에 내가 setCheckedList를 새로 선언하면서 생각한 궁금증이 있다.
- 하나만 체크하고 싶은데.. 모든 데이터들이 새로운 배열에 쓰여지는 작업이 불필요하지 않나?
- 값의 변화는 없는데 새로운 배열이 선언되어서 리렌더링이 되는 경우가 있지 않을까?
이러한 해결방법이 결국 최적화가 되는 것이 아닐까?
전체 코드
먼저 React dev tools를 사용하여, 현재 불필요한 렌더링이 있는지 확인해보았다.

체크박스 하나만 바꿔도 모든 요소들이 다 리렌더링 되고 있다 😅
텍스트나 전체 동의하기 버튼은 리렌더링이 될 필요가 없다!
체크박스 하나 또는 그 파생값인 ‘다음으로’ 버튼만 리렌더링 되도록 만들고 싶다.
내 코드를 직접 보면서 어떻게 개선하면 좋을지 알아보자.
import "./App.css"; import { useState } from "react"; function App() { const [checkedList, setCheckedList] = useState(TERMS_LIST.map((term) => term.defaultChecked)); const changeCheckedList = (index) => { const newCheckedList = [...checkedList]; newCheckedList[index] = !newCheckedList[index]; setCheckedList(newCheckedList); } const isRequiredAllChecked = TERMS_LIST.every((term, index)=>{ return !term.required || checkedList[index]; }) const CheckBox = ({title, required, checked, onChange}) => { return ( <div> <input type="checkbox" checked={checked} onChange={onChange}/> <label>{required ? "[필수]" : "[선택]"} {title}</label> </div> ) } const makeAllChecked = () => { const newCheckedList = checkedList.map(() => true); setCheckedList(newCheckedList); } return ( <> <h1>이용 약관 동의</h1> <p>아래 이용 약관 동의를 클릭해주세요.</p> <button onClick={makeAllChecked}>전체 동의하기</button> {TERMS_LIST.map((term, index) => ( <CheckBox title={term.title} required = {term.required} checked={checkedList[index]} onChange={() => changeCheckedList(index)} /> ))} <button disabled={!isRequiredAllChecked}>다음으로</button> </> ); } const TERMS_LIST = [ { id: 0, title: '이용 약관 1', required: true, defaultChecked: true, }, { id: 1, title: '이용 약관 2', required: true, defaultChecked: true, }, { id: 2, title: '이용 약관 3', required: true, defaultChecked: true, }, { id: 3, title: '이용 약관 4', required: false, defaultChecked: false, }, { id: 4, title: '이용 약관 5', required: false, defaultChecked: false, }, ]; export default App;
헤더 고정하기
위에서 언급했지만,
<h1>이용 약관 동의</h1> <p>아래 이용 약관 동의를 클릭해주세요.</p>
이 부분은 전혀 리렌더링이 될 필요가 없는 코드이다.
물론 이 코드에서는 리렌더링이 되어도 성능에 영향을 주지는 않는다.
그래도 불필요한 리렌더링은 어떻게 방지할 수 있을지 알아보자.
memo 를 사용하면 된다. memo 는 컴포넌트를 자체를 저장한다고 생각하면 된다.따라서 부모 컴포넌트가 바뀌어도 memo를 사용하면 자식 컴포넌트를 props가 바뀌지 않는 이상 변하지 않는다.
이와 유사하게
useMemo 와 useCallback 함수가 있다.이 함수들의 차이는 나중에 React 훅 리뷰할겸 새로운 글에 작성하도록 하겠다.
아무튼 다시
memo 로 헤더를 만들어보면 다음과 같다.const Header = memo(() => { return ( <> <h1>이용 약관 동의</h1> <p>아래 이용 약관 동의를 클릭해주세요.</p> </> ) });
그리고 가장 중요한 것은 부모 컴포넌트인
App 컴포넌트에서 분리해야 한다.왜냐하면
App 안에 있다면, 리렌더링 되는 순간 다시 Header를 정의하게 된다.그럼
memo를 사용한 의미가 없다.이와 유사하게 부모 컴포넌트에서 정의 된 함수를 props로 받을 경우,
그 함수에
useCallback으로 감싸주어야 한다.CheckBox id 추가
브라우저 콘솔로 확인해보니 key값이 없어서 경고가 뜨고 있었다.
{TERMS_LIST.map((term, index) => ( <CheckBox key={term.id} title={term.title} required = {term.required} checked={checkedList[index]} onChange={() => changeCheckedList(index)} /> ))}
이렇게 컴포넌트 안에 key 값을 추가만 해주면 해결된다.
그러나 key를 추가하지 않아도 잘 렌더링 되는데, 왜 꼭 추가해야할까?
→ React가 리스트 아이템들을 효율적으로 추적하기 위해서이다.
예를 들어, 아래와 같은 상황이라고 해보자.
// key 없는 상태에서 맨 앞에 새 아이템 추가 [ <div>약관 1</div>, // 기존 <div>약관 2</div>, // 기존 <div>약관 3</div> // 기존 ] // 새 아이템 추가 후 [ <div>새 약관</div>, // 새로 추가 <div>약관 1</div>, <div>약관 2</div>, <div>약관 3</div> ]
우리는 그냥 앞에 새로 추가된 것 같지만, 실제로 React는 어떻게 이해하고 있을까?
React가 key 없이 비교하면,
- 첫 번째: "약관 1" → "새 약관" (내용 변경으로 인식)
- 두 번째: "약관 2" → "약관 1" (내용 변경으로 인식)
- 세 번째: "약관 3" → "약관 2" (내용 변경으로 인식)
- 네 번째: 없음 → "약관 3" (새로 생성)
결국 모든 item이 리렌더링 되는 것이다.
그러나 key가 있으면?
[ <div key="new">새 약관</div>, // 새로 생성 <div key="term1">약관 1</div>, // 위치만 이동 <div key="term2">약관 2</div>, // 위치만 이동 <div key="term3">약관 3</div> // 위치만 이동 ]
React는 이렇게 이해를 하고 새 아이템만 생성하고 나머지는 재사용하는 것이다!
그런데 막상 해보니 key값을 추가해줘도 모두 리렌더링 되고 있었다!?
좀 더 알아보니,
컴포넌트는 리렌더링되지만 (Virtual DOM은 생성) 기존 DOM 노드를 재사용하고 위치만 이동시키는 것이었다!
정리하며,
오늘 정말 간단한게 페이지를 구성해보았다.
지금 이 프로그래밍은 AI의 도움을 받지 않고 만든 코드였다.
내가 그동안 얼마나 AI에 의존했는지 깨닫게 되는 시간이었던 것 같다.
그동안 원하는 기능이 있으면 AI에게 만들어달라고 부탁했다.
어차피 나도 시간을 투자하면 그 기능을 어떻게든 만들 수 있다고 생각했고, 시간을 단축하기 위해 AI에게 맡겼다.
즉, 바이브 코딩을 해왔던 것이다. 하지만 이것이 정말 개발자의 역할이라고 할 수 있을까?
내가 이번 코드를 만들면서 든 생각은,
정말 코드 한줄 한줄마다 생각을 많이 하게 되었고 스스로 질문을 많이 하게 되었다.
단순히 AI가 짜준 코드를 보며 리뷰하는 과정보다 얻을 수 있는 질문과 깨달음이 훨씬 많은 것 같다.

