바닐라 부트캠프

[WIL] 바닐라코딩 부트캠프 8주차 후기 - redux, 전역으로 끌어올리기!

feel2 2025. 4. 6. 13:40
반응형

이번주는 전역상태로 상태를 관리하는 것을 목표로 과제를 진행하였다.

 

보통 상태를 지역변수로 관리하거나, 공통으로 쓰는 상태라면 부모로 상태를 끌어올려서 상태를 관리한다.

 

그런데 만약 상태를 부모 자식 관계가 아닌데도 다른 컴포넌트에서 사용을 해야한다면 어떻게 해야할까?

그럴때 바로 전역으로 상태를 끌어올려서 관리를 하면 된다!

 

그럼 한번 시작해보자!

 

이번주 과제


✅ 과제 주제

 

이번주 과제는 React Redux를 이용하여, 캘린더 사이트를 만들어 보는 걸 진행하였다.

캘린더를 실제로 그려보고, 이벤트를 등록하거나 수정하는 것까지를 목표로 과제를 진행하였다.

 

✅ 사전에 알아야 하는 개념

 

전역 상태 관리 도구인 Redux를 알아보기 전에 전역 상태가 왜 필요한지부터 알면 좋을 것 같다.

 

전역 상태?

여러 컴포넌트가 동일한 상태를 공유해야 할 때, 전역 상태 관리는 필수적이라고 한다.

왜냐하면 전역 상태 관리는 상태를 중앙에서 관리하여, 여러 컴포넌트 간의 상태 동기화를 쉽게 할 수 있기 때문이다. 이를 통해 애플리케이션의 복잡성을 줄이고, 유지 보수를 용이하게 할 수 있다.

 

그럼 글로벌 상태로 관리해야 하는 경우와 로컬 상태로 관리해야 하는 경우의 기준은 무엇일까?

 

상태를 전역으로 관리해야 할지, 로컬로 관리해야 할지 결정하는 기준은 상태의 사용 범위와 필요성에 따라 다르다.

 

전역 상태로 관리해야 하는 경우

 

1. 여러 컴포넌트에서 공유해야 하는 상태

  • ex) 사용자 인증 정보, 다크 모드 설정, 언어 설정, 테마, 장바구니 데이터

2. 페이지 전반에 걸쳐 유지해야 하는 상태

  • ex) 로그인 상태, 사용자 프로필 정보

3. 서버에서 가져온 데이터를 여러 곳에서 사용할 때

  • ex) API에서 가져온 데이터를 여러 컴포넌트에서 사용해야 하는 경우

4. 다른 컴포넌트에서도 상태를 변경해야 할 때

  • ex) 알림 시스템(모든 페이지에서 상태 변경이 반영되어야 함)

 

로컬 상태로 관리해야 하는 경우

 

1. 한 컴포넌트에서만 사용되는 상태

  • ex) 모달 열림/닫힘 상태, 폼 입력값, 특정 UI 요소의 상태

2. 자식 컴포넌트로만 전달되는 상태

  • ex) 부모에서 자식으로만 내려가는 단순한 상태라면 useState나 useReducer로 관리 가능

3. 한 번 사용되고 사라지는 데이터

  • ex) 검색 입력값, 특정 필터링 조건 등

 

지금까지 전역 상태 관리는 왜 필요한지를 알아보았다. 그럼 전역으로 상태를 관리해주는 Redux는 무엇일까?

 

Redux?

https://ko.redux.js.org/tutorials/essentials/part-1-overview-concepts/

 

Redux는 "actions"라는 이벤트를 사용하여 애플리케이션 상태를 관리하고 업데이트하기 위한 패턴 및 라이브러리다.

 

전체 애플리케이션에서 사용해야 하는 상태에 대한 중앙 집중식 저장소 역할을 하며, 예측 가능한 방식으로만 상태를 업데이트할 수 있도록 하는 규칙이 있다.

 

Redux의 데이터 흐름

 

https://ko.redux.js.org/tutorials/essentials/part-1-overview-concepts/

 

 

  • 초기 설정 :
    • store는 root reducer 함수를 통해 생성한다.
    • 처음에 store는 root reducer를 한번 호출하고, 초기 state의 반환값을 저장한다.
    • UI가 처음 렌더링되면, UI 구성 요소는 Redux Store의 현재 상태에 액세스하고, 해당 데이터를 사용하여 렌더링 할 내용을 결정한다. 또한 store를 구독하여, state가 변경되었는지 알 수 있어 store를 업데이트 할 수 있다.
  • 업데이트 :
    • 사용자가 버튼을 클릭한다.
    • Redux store에 dispatch를 한다. dispatch({type: 'counter/increment'})
    • store는 reducer에 있는 함수를 실행하여, action과 함께 state의 이전 상태를 새로운 state로 반환한다.
    • store는 자신을 구독한 UI의 모든 부분에 자신이 업데이트 되었음을 알린다.
    • store로부터 데이터를 필요로 하는 각 UI 컴포넌트들은 데이터가의 일부가 변경되었는지 확인한다.
    • 데이터를 바라보는 각 컴포넌트들은 새로운 데이터로 리랜더링을 하므로, 화면에 표시된 내용을 업데이트 할 수 있다.

 

✅ 도전이 되었던 부분

 

이번 과제를 하면서 도전이 되었던 부분은 유효성 검증 부분이었다.

 

물론 유효성 검증 라이브러리를 쓰면 좀 더 쉽게 구현할 수 있지만, 라이브러리를 쓰지 않고 유효성 검증을 직접 구현해보고 싶었다.

유효성 검증이 필요한 부분은 이벤트 등록과 이벤트 수정 부분이었다.

 

이벤트 등록의 경우, 이벤트를 등록하기 위해 값을 입력할 때 적절한 유효성 검증을 해주어야 한다.

 

그래서 간단한 text의 경우에는 빈 입력에 대해서만 검증을 해주면 되기 때문에 input 태그에 내장되어 있는 유효성 검증을 이용하였다.

 

unction EventForm() {

...
    <div>
      <label className="block text-sm font-medium text-gray-700">✅ 이벤트 이름</label>
      <input 
        name='title' type="text" required placeholder="이벤트 이름을 입력해주세요."
        onInvalid={handleInvalid} onChange={handleChange}
        className="w-full px-4 py-2 border rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500"
      />
      {isError.title && <p className={styles.error}>이벤트 이름을 입력해주세요.</p>}
    </div>
...

}

 

모든 이벤트 폼의 항목이 이런식으로 간단하게 유효성 검증이 되면 좋겠지만,

이벤트 시작 시간이나 이벤트 종류 시간의 경우에는 커스텀 검증 로직이 필요해 보였다.

 

function EventForm() {

...
   <div>
      <label className={styles.label}>✅ 이벤트 시작 시간</label>
      <input 
        name='startedAt' type="text" id='startedAt' pattern="^([01]?[0-9]|2[0-3]):00$"
        required placeholder="12:00" value={content.startedAt} onInvalid={handleInvalid} onChange={handleChange}
        className={styles.item}
      />
    </div>
...

}

 

이렇게 하면 15:00 이런 형태가 아니거나 빈 값인 경우에는 아닌 경우 유효성 검증이 된다.

하지만 시간의 경우에는 생각해볼 수 있는 엣지케이스가 더 존재한다.

 

  • 시작시간이나 종료시간이 24:00 이상의 값이 입력되는 경우
    • 25:00, 26:00…
  • 이미 등록된 이벤트와 시간이 겹치는 경우
    • 미리 등록된 이벤트(등록시간: 13:00, 종료시간: 15:00)
    • 내가 등록하려는 이벤트(등록시간: 14:00, 종료시간: 17:00)
  • 종료시간이 시작 시간보다 앞선 경우
    • 내가 등록하려는 이벤트(등록시간: 15:00, 종료시간: 13:00)

 

이러한 경우에는 기본으로 제공되는 유효성 검증으로는 막을 수가 없다.

 

그래서 직접 제출 버튼을 눌렀을 경우 유효성 검증을 할 수 있도록 로직을 작성하였다.

 

function EventForm() {

    ...

const [isError, setIsError] = useState({
title: false, startedAt: false, endedAt: false,
invalidTime: false, isDuplicated: false
});



const handleInvalid = (e) => {
e.preventDefault();

    if (e.target.name === 'title') {
      setIsError({ ...isError, title: true });
    }
    if (e.target.name === 'startedAt') {
      setIsError({ ...isError, startedAt: true });
    }
    if (e.target.name === 'endedAt') {
      setIsError({ ...isError, endedAt: true });
    }
};

function handleSubmit(e) {
  e.preventDefault();

  if (Number(content.startedAt.split(':')[0]) >= Number(content.endedAt.split(':')[0])) {
    setIsError((prev) => ({ ...prev, invalidTime: true }));
    return;
  }

  const duplicatedEvent = events.filter((event) => {
    if (event.date !== content.date) return false;

    const [startTime, endTime] = [event.startedAt, event.endedAt].map((t) => Number(t.split(':')[0]));
    const [nowStartTime, nowEndTime] = [content.startedAt, content.endedAt].map((t) => Number(t.split(':')[0]));

    const isOverlapping =
      (nowStartTime >= startTime && nowStartTime < endTime) ||  // 기존 범위에 새 이벤트의 시작 시간이 포함됨
      (nowEndTime > startTime && nowEndTime <= endTime) ||      // 기존 범위에 새 이벤트의 종료 시간이 포함됨
      (nowStartTime <= startTime && nowEndTime >= endTime);     // 새 이벤트가 기존 이벤트를 완전히 감쌈

    if (isOverlapping) {
      setIsError((prev) => ({ ...prev, isDuplicated: true }));
      setDuplicatedTime({ startTime, endTime });
    }

    return isOverlapping;
  });

  if (duplicatedEvent.length) return;

  if (isError.title || isError.startedAt || isError.endedAt || isError.invalidTime
    || isError.isDuplicated) {
    return;
  }

  dispatch(addEvent(content));
  navigate(-1);
}


     ...

     return (
    <>
        ...
        <div>
      <label className={styles.label}>✅ 이벤트 시작 시간</label>
      <input 
        name='startedAt' type="text" id='startedAt' pattern="^([01]?[0-9]|2[0-3]):00$"
        required placeholder="12:00" value={content.startedAt} onInvalid={handleInvalid} onChange={handleChange}
        className={styles.item}
      />
      {isError.startedAt && <p className={styles.error}>시간은 반드시 정각(15:00, 17:00 등)으로 입력해주세요.</p>}
      {isError.overTime && <p className={styles.error}>24시 이후의 값은 올바르지 않은 시간 값입니다.</p>}
      {isError.isDuplicated && <p className={styles.error}>이미 등록된 이벤트와 시간이 겹칩니다. {duplicatedTime.startTime}시 ~ {duplicatedTime.endTime}시</p>}
    </div>

    <div>
      <label className={styles.label}>✅ 이벤트 종료 시간</label>
      <input 
        name='endedAt' type="text" id='endedAt' pattern="^([01]?[0-9]|2[0-3]):00$"
        required placeholder="13:00" value={content.endedAt} onInvalid={handleInvalid} onChange={handleChange}
        className={styles.item}
      />
      {isError.endedAt && <p className={styles.error}>시간은 반드시 정각(15:00, 17:00 등)으로 입력해주세요.</p>}
      {isError.invalidTime && <p className={styles.error}>시작 시간보다 이전이거나 같을 수 없습니다.</p>}
    </div>
  </div>
  ...
  </>)

}

 

이렇게 하니 기본 유효성 검증은 handleInvalid 여기서 핸들링하고, 커스텀한 유효성 검증은 handleSubmit 에서 검증을 하였다.

 

이렇게 하니, 기본 유효성 검증도 되면서 내가 생각한 엣지 케이스도 다룰 수 있게 되었다.

 

 

멘토링 시간


✅ 멘토와 나눈 대화

 

멘토링 시간에는 내가 계획한 대로 스케줄을 잘 진행하고 있는지, 생활하는데는 문제가 없는지 얘기를 나누었다.

거의 이제 바코에 온지 2달이 다 된 것 같다…

 

처음에는 9-10이(무려 13시간!!)을 매일 할 수 있을까 생각을 했다. 처음에는(지금도 마찬가지지만..) 잠이 부족하여 중간에 피곤하여 집중이 안될때도 있었다.

 

그래도 지금은 요령이 생겨서 중간중간마다 스트레칭도 해주고, 커피도 마시거나 너무 졸리면 좀 일어나서 하다보면 또 괜찮아졌다.

 

앞으로 또 어떤 과제들이 쏟아질지 모르겠지만, 바코를 끝내고 난 뒤에 내 모습이 어떨지는 기대가 된다.

 

✅ 멘토링이 학습에 준 영향

 

이번 과제를 할 때는 너무 집중해서 한 나머지 멘토링 시간에 질문을 하지 못 했다.

다만 멘토님과의 과제 세션 시간에 얻는 인사이트는 많은 것 같다.

 

먼저 이런 경우가 있다.

 

export const calendarSlice = createSlice({
  name: "calendar",
  initialState,
  reducers: {

    backwordOneWeek: (state) => {
      const currentDate = new Date(state.currentDate);
      currentDate.setDate(currentDate.getDate() - 7);
      state.currentDate = currentDate.toISOString();
    },

    backwordOneDay: (state) => {
      const currentDate = new Date(state.currentDate);
      currentDate.setDate(currentDate.getDate() - 1);
      state.currentDate = currentDate.toISOString();
    },
    forwardOneWeek: (state) => {
      const currentDate = new Date(state.currentDate);
      currentDate.setDate(currentDate.getDate() + 7);
      state.currentDate = currentDate.toISOString();
    },

    forwardOneDay: (state) => {
      const currentDate = new Date(state.currentDate);
      currentDate.setDate(currentDate.getDate() + 1);
      state.currentDate = currentDate.toISOString();
    },

    ...
  }

 

스케줄을 등록할 수 있는 화면이 Daily, Weekly 이렇게 2가지 버전이 있다고 하자.

물론 이렇게 각 버튼마다 reducer 메서드를 두어서 작업을 할 수 있다.

 

그런데 만약 기획자가 월 단위로 이동할 수 있는 기능을 추가해 달라고 한다면?

이런 방식이면 메서드를 또 2개를 더 추가해야 한다.

 

그래서 이런식으로 수정한다면 메서드를 더 늘릴 필요 없이 의존성을 줄일 수 있다.

 

export const calendarSlice = createSlice({
  name: "calendar",
  initialState,
  reducers: {

    backword: (state) => {
      const currentDate = new Date(state.currentDate);
      if (state.viewMode === 'weekly') {
        currentDate.setDate(currentDate.getDate() - 7);
        state.currentDate = currentDate.toISOString();
      } else {
        currentDate.setDate(currentDate.getDate() - 1);
        state.currentDate = currentDate.toISOString();
      }

    },

    forward: (state) => {
      const currentDate = new Date(state.currentDate);
      if (state.viewMode === 'weekly') {
        currentDate.setDate(currentDate.getDate() + 7);
        state.currentDate = currentDate.toISOString();
      } else {
        currentDate.setDate(currentDate.getDate() + 1);
        state.currentDate = currentDate.toISOString();
      }
    },

    ...
}

 

이렇게 변경을 한다면 다른 요구사항이 추가되더라도 메서드를 추가할 필요없이 메서드 내부에 조건문만 수정하면 된다.

 

이런게 사소해 보일 수 있더라도, 좋은 개발자가 되기 위해서는 이런 사소한 고민을 자주하고, 고려할 줄 아는 개발자가 진짜 잘하는 개발자로 성장할 수 있는 개발자가 될 것이다.

 

멘토님께 질문을 드려 어떻게 해결하면 좋을지를 여쭤보았다.

 

한줄 후기


꼭 필요한 것들만 전역으로 상태 관리하자!

참조


반응형