본문으로 건너뛰기
SoulLog

React 학습 2주차 — State 관리하기

12분 읽기

1주차에 이어 React 공식 문서의 'State 관리하기' 챕터를 학습하며 정리한 내용입니다.


State를 사용해 Input 다루기

명령형 vs 선언형 UI 프로그래밍

React는 선언형 UI 프로그래밍을 권장합니다. jQuery를 사용하던 과거 방식이 명령형 UI 프로그래밍의 대표적인 예입니다.

명령형 방식 (jQuery) — 각 UI 요소에 직접 명령을 내립니다.

// 예약 패널을 열 때 각 요소에 하나하나 지시하는 방식
function openReservationPanel(classType, reservationDate = '', floorType = '', roomType = '') {
    let url = "/community/reservation/meeting_room/getMeetingRoomInfo";
    let addHtmlId = "#tab-panel-meeting";
    $("#reservation-branch-name").text($("#spaceName").text());
 
    if (classType == "reservation_meeting_panel") {
        resetInviteData()
        resetSeatPanel()
        handleTab($('.tab').eq(0));
        layerOpen('.btn-meeting-reservation', '#layer-reservation')
    } else if (classType == "reservation_seat_panel") {
        addHtmlId = "#tab-panel-seat";
        url = "/community/reservation/seat/getSeatList";
 
        resetInviteData()
        handleTab($('.tab').eq(1))
        layerOpen('.btn-seat-reservation', '#layer-reservation')
    } else if (classType == "reservation_invite_panel") {
        addHtmlId = "#tab-panel-invite";
        url = "/community/reservation/invite/getInviteInfo";
 
        resetInviteData()
        resetSeatPanel()
        handleTab($('.tab').eq(2));
    }
 
    $.ajax({
        type: "POST",
        url: url,
        data: { branch_id: branchId, reservation_date: reservationDate },
        success: function (res) {
            if (res.status == "success") {
                $(addHtmlId).html(res.data)
            }
        },
    });
}

명령형 UI 예시 — jQuery 기반 예약 패널

선언형 방식 (Vue.js) — 개별 UI에 명령하는 대신, 연관된 state 값만 변경합니다.

reservation(tab) {
    this.displayAsideLevel1 = true  // 첫 번째 패널 오픈 여부
    this.displayAsideLevel2 = true  // 두 번째 패널 오픈 여부
    this.panel = 'reservation'       // 패널 타입
    this.$nextTick(() => {
        this.$refs.reservation.activeTab = tab  // 열려야 하는 탭
    })
},

Vue도 이미 선언형 방식이었고, React도 동일한 철학을 따릅니다.

선언형 UI로 설계하는 5단계

React 공식 문서에서 선언적 방식으로 state를 설계할 때 아래 5단계를 제안합니다.

  1. 컴포넌트의 다양한 시각적 state 확인하기
  2. 무엇이 state 변화를 트리거하는지 알아내기
  3. 메모리의 state를 useState로 표현하기
  4. 불필요한 state 변수를 제거하기
  5. state 설정을 위해 이벤트 핸들러를 연결하기

여러 개의 Input 상태 관리

Input이 여러 개인 경우, 각각에 대해 하나하나 state를 선언하는 것은 비효율적입니다. 현재 권장되는 방식은 name을 key로 활용하는 방법입니다.


State 구조 선택하기

1. 관련 state는 단일 변수로 병합하기

항상 동시에 업데이트되는 state 변수가 2개 이상이라면 단일 state 변수로 병합하는 것을 고려합니다. 마우스 위치의 x, y 좌표가 대표적인 예입니다.

2. state의 모순 피하기

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);  // 전송 중
  const [isSent, setIsSent] = useState(false);         // 전송 완료
 
  async function handleSubmit(e) {
    e.preventDefault();
    setIsSending(true);
    await sendMessage(text);
    setIsSending(false);
    setIsSent(true);
  }
}

위 코드의 문제는 isSendingisSent가 논리적으로 동시에 true가 될 수 없음에도 불구하고, 두 setter를 따로 관리하다 보면 실수로 동시에 true가 될 수 있다는 점입니다. 이런 경우 status라는 단일 state로 통합하고 상수로 가독성을 높이는 방법을 권장합니다.

3. 불필요한 state 피하기

export default function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [fullName, setFullName] = useState('');  // ← 불필요
  // ...
}

fullName${firstName} ${lastName}으로 항상 동일하게 계산되므로, 별도 state로 관리할 필요 없이 렌더링 시점에 계산하면 됩니다. 기존 state로 도출할 수 있다면 별도 state를 만들지 않는 것이 좋습니다.

Vue에서는 data에 선언하고 한 번도 사용하지 않는 경우가 생기기도 했는데, React는 사용되지 않는 state에 대해 직접 피드백을 주기 때문에 자연스럽게 불필요한 state를 줄이게 됩니다.

4. state의 중복 피하기

데이터가 중복 저장되는 state 구조는 피해야 합니다. 동일한 데이터를 두 군데에서 관리하면 동기화 문제가 생깁니다.

5. props를 state에 그대로 넣지 말기

props를 state로 미러링하는 패턴은 특정 props의 업데이트를 의도적으로 무시하고 싶을 때만 사용합니다. 일반적인 경우에는 props를 직접 사용하면 됩니다.

6. 지나치게 중첩된 state 피하기

중첩이 깊어질수록 업데이트가 복잡해집니다. 가능하면 평탄한(flat) 구조로 state를 설계하는 것이 좋습니다.

메모리 누수 관련

Deep Dive 내용에서 메모리 사용량 개선을 위해서는 삭제된 항목을 테이블 객체에서 제거해야 한다는 언급이 있었습니다. 이 기회에 JavaScript 메모리 누수에 대해서도 간략히 정리했습니다.

메모리 누수란? 더 이상 사용하지 않는 메모리가 해제되지 않고 점유되는 현상을 말합니다. 이런 변수나 데이터를 가비지 변수/가비지 데이터라고 부릅니다.

주요 발생 원인은 다음과 같습니다.

  • 잘못된 클로저 사용
  • 의도치 않게 생성된 전역 변수
  • 분리된 DOM 노드 (노드 삭제 후 전역 변수에 참조가 남아있는 경우)
  • console.log 남용
  • 해제하지 않은 타이머

메모리 누수는 페이지 로딩 속도 저하로 이어지며, 리플로우(Reflow)와 리페인트(Repaint) 문제로 이어질 수 있습니다.

  • 리플로우: 화면 레이아웃이 변경될 때 렌더 트리 상 노드의 위치와 크기를 재계산하는 과정
  • 리페인트: 레이아웃에는 영향 없이 외관(색상 등)이 변경될 때 발생하는 과정

참고 자료:


컴포넌트 간 State 공유하기

두 컴포넌트가 동일한 state를 공유해야 할 때는 State 끌어올리기(Lifting State Up) 패턴을 사용합니다. Accordion이 대표적인 예입니다.

  • 여러 자식 컴포넌트를 함께 제어해야 한다면, state를 공통 부모 컴포넌트에 위치시킵니다.
  • 이벤트 핸들러를 자식에 전달하여 부모 쪽에서 state를 변경하도록 합니다.

이 부분에서 React와 Vue의 방식이 확연히 다릅니다.

  • Vue: 자식이 데이터를 직접 처리한 뒤 .sync modifier나 $emit으로 부모에 전달합니다. 자식이 주도하는 느낌입니다.
  • React: 부모가 이벤트 핸들러를 자식에게 전달하고, 자식은 그것을 호출합니다. 부모가 주도하는 느낌입니다.

State를 보존하고 초기화하기

state는 React의 컴포넌트 UI 트리 위치에 연결됩니다.

state와 UI 트리 위치의 연관 관계

같은 위치에 같은 컴포넌트가 렌더링되면 state가 보존되고, 컴포넌트가 제거되거나 위치가 바뀌면 state가 초기화됩니다. state를 강제로 초기화하고 싶다면 key prop을 활용하면 됩니다.


State 로직을 Reducer로 작성하기

한 컴포넌트에서 state 업데이트가 여러 이벤트 핸들러로 분산되면 관리가 어려워집니다. 이 경우 Reducer를 사용하면 state 로직을 한 곳에 모을 수 있습니다.

Reducer는 state를 직접 설정하는 대신, 이벤트 핸들러에서 actiondispatch합니다.

useState 방식

function handleAddTask(text) {
  setTasks([...tasks, { id: nextId++, text, done: false }]);
}
 
function handleChangeTask(task) {
  setTasks(tasks.map(t => t.id === task.id ? task : t));
}
 
function handleDeleteTask(taskId) {
  setTasks(tasks.filter(t => t.id !== taskId));
}

useReducer 방식dispatch로 action을 전달합니다.

function handleAddTask(text) {
  dispatch({ type: 'added', id: nextId++, text });
}
 
function handleChangeTask(task) {
  dispatch({ type: 'changed', task });
}
 
function handleDeleteTask(taskId) {
  dispatch({ type: 'deleted', id: taskId });
}

Reducer 함수 내부에서는 일반적으로 switch문을 사용합니다. Reducer는 반드시 순수 함수여야 하며, 각 action은 단일 사용자 상호작용을 설명해야 합니다.

Reducer를 반드시 써야 하는 것은 아닙니다. 로직이 복잡하거나 이벤트 핸들러가 많아질 때 도입을 고려하면 됩니다. 첨부파일 관련 처리처럼 추가/변경/삭제 action이 명확한 경우에 특히 잘 맞습니다.

Reducer 사용이 적합한 첨부파일 처리 케이스

팀 내 과거 프로젝트를 살펴봤는데 useReducer를 활용한 사례가 없었습니다. 앞으로 복잡한 state 로직이 생긴다면 적극 도입을 고려해볼 만한 패턴입니다.