React Cheat Sheet (기본)

1. 리액트 생태계 (React Ecosystem)

  • 리액트 생태계: 리액트를 중심으로 형성된 다양한 라이브러리와 도구들의 집합으로, 효율적인 애플리케이션 개발을 지원합니다.

2. 컴포넌트 (Components)

  • 컴포넌트: UI의 재사용 가능한 독립적인 구성 요소로, HTML, CSS, 자바스크립트 로직을 포함합니다.
  • 재사용 가능: 동일한 컴포넌트를 여러 곳에서 반복 사용 가능하여 개발 효율성을 높입니다.
  • UI 구축: 여러 컴포넌트를 결합하여 전체 사용자 인터페이스(UI)를 구성합니다.
  • 범위 설정: 컴포넌트의 기능과 책임을 명확히 정의하여 코드의 가독성과 유지보수성을 향상시킵니다.
  • 마크업, 스타일, 자바스크립트 로직: 각 컴포넌트는 HTML 마크업, CSS 스타일, 자바스크립트 로직을 포함하여 UI의 한 부분을 구성하고 제어합니다.

3. 코드 관리 (Code Management)

  • 코드 재활용: 컴포넌트를 통해 동일한 코드를 여러 곳에서 재사용함으로써 중복을 줄입니다.
  • 오류 가능성 감소: 컴포넌트 단위로 코드를 수정할 수 있어 오류 발생 가능성을 줄입니다.
  • 관심사 분리: 각 컴포넌트가 특정 기능을 담당하여 코드의 모듈화와 관리가 용이해집니다.

4. 프론트엔드 프레임워크 (Frontend Frameworks)

  • 리액트 외 프레임워크:
    • Angular
    • Vue
    • Svelte
    • Flutter (모바일 개발 프레임워크)
  • 컴포넌트 기반 아키텍처: 이러한 프레임워크들도 컴포넌트 기반 아키텍처를 채택하여 유사한 개발 방식을 제공합니다.

5. 프로젝트 관리 (Project Management)

  • 복잡도 관리: 컴포넌트화로 복잡한 UI를 작게 분리하여 관리하기 쉽게 만듭니다.
  • 개발자 협업: 여러 개발자가 각기 다른 컴포넌트를 담당하여 동시에 작업할 수 있어 협업이 용이합니다.
  • 개발 원칙: 컴포넌트 방식은 관심사의 분리와 코드의 모듈화를 통해 유지보수성과 확장성을 높입니다.

6. UI 관련 주요 요소 (UI Components)

  • HTML: 구조를 정의하는 마크업 언어.
  • CSS: 스타일링을 담당하는 스타일 시트 언어.
  • 유저 인터페이스 (UI): 사용자와 상호작용하는 화면 요소들.
  • 빌딩 블록: UI를 구성하는 기본 단위들 (예: 헤더, 푸터, 버튼 등).
  • 헤더: 웹사이트나 애플리케이션의 상단 부분을 구성하는 컴포넌트.
  • 상호작용 탭: 사용자 입력과 상호작용을 처리하는 컴포넌트.

7. JSX (JavaScript Syntax Extension)

  • 확장자: .jsx
  • 기능:
    • 자바스크립트 파일 내에 HTML 마크업 작성 가능
    • UI의 전체와 부분을 설명 및 생성
  • 주의사항:
    • 브라우저에서 직접 사용 불가
    • 개발 서버에서 변환 필요

8. React 컴포넌트

  • 정의:

    • 자바스크립트 함수와 유사
    • UI의 재사용 가능한 부분
  • 규칙:

    1. 함수 이름은 대문자로 시작
    2. 렌더링 가능한 값 반환 (주로 JSX 코드)
  • 생성 방법:

    • 평범한 자바스크립트 함수 작성
    • 대문자로 시작하는 이름 부여
    • JSX 코드 반환

9. 컴포넌트 생성

  • 새 컴포넌트 파일 추가:

    • 기존 App.jsx 파일 외에 새로운 파일에 컴포넌트 작성 가능
  • 자바스크립트 함수로 생성:

    • 리액트 컴포넌트는 단순한 자바스크립트 함수
function Header() {
  return (
    <header>
      <h1>제목</h1>
      <img src="logo.png" alt="로고" />
    </header>
  );
}

10. 컴포넌트 이름 규칙

  • 대문자로 시작:
    • 컴포넌트 이름은 반드시 대문자로 시작해야 함
    • 예: Header, Footer, Sidebar

11. 오류 방지 팁

  • 컴포넌트 이름 확인:

    • 대문자로 시작하지 않으면 컴포넌트로 인식되지 않음
  • JSX 문법 준수:

    • 모든 태그는 닫혀야 함
    • 중첩된 태그의 올바른 구조 유지

12. 이미지 불러오기 및 사용

// Header.jsx
import React from 'react';
import reactImg from './assets/react-core-concepts.png';

function Header() {
  return (
    <header>
      <h1>React 동적 이미지 예제</h1>
      <img src={reactImg} alt="React Core Concepts" />
    </header>
  );
}

export default Header;

13. 컴포넌트 재사용의 중요성

  • 재사용 가능성:
    • 동일한 컴포넌트를 여러 번 사용할 수 있어 코드 중복 감소
    • 예: 여러 헤더나 카드 컴포넌트
  • 유연성:
    • 각 사용 시 다른 데이터를 전달하여 다양한 결과 생성

14. Props 개념 이해

  • Props 정의:
    • 부모 컴포넌트가 자식 컴포넌트에 데이터를 전달하는 방법
  • Props의 역할:
    • 컴포넌트의 동적 데이터 설정
    • 컴포넌트의 재사용성을 높임

15. 컴포넌트에 Props 전달하기

  • 커스텀 속성 추가:

    • 컴포넌트를 사용할 때 원하는 속성을 추가

      <CoreConcept
        title="Components"
        description="핵심 UI 빌딩블록"
        image={componentsImg}
      />
  • 다양한 데이터 전달:

    • 문자열, 숫자, 객체, 배열 등 모든 종류의 데이터 전달 가능

16. Props를 받는 컴포넌트 작성

  • 컴포넌트 함수 매개변수로 props 사용:

    • 일반적으로 props라는 이름 사용

      function CoreConcept(props) {
        return (
          <li>
            <img src={props.image} alt={props.title} />
            <h3>{props.title}</h3>
            <p>{props.description}</p>
          </li>
        );
      }
  • 객체로 전달된 Props 사용:

    • props는 키-값 쌍을 가진 객체
    • 각 속성은 props.속성명으로 접근

17. Props를 통한 데이터 관리

  • 동적 데이터 사용:
    • 각 컴포넌트 사용 시 다른 데이터를 전달하여 다양한 내용 표시
  • 데이터 유형:
    • 문자열, 숫자, 객체, 배열 등 다양한 데이터 유형 전달 가능
  • 이미지 전달:
    • import를 사용하여 이미지 파일을 불러오고 Props로 전달

18. Props 활용 시 주의사항

  • 고유 키 지정 (리스트 사용 시):
    • 여러 컴포넌트를 리스트로 렌더링할 때 고유 key 속성 필수

      <CoreConcept
        key={uniqueId}
        title="Components"
        description="핵심 UI 빌딩블록"
        image={componentsImg}
      />

19. Props 명명 규칙:

  • 일관된 명명 규칙 사용 (예: camelCase)

20. Props 불변성 유지:

  • 자식 컴포넌트에서 Props 수정 금지

21. 컴포넌트 재사용 및 Props 전달

  • 컴포넌트 재사용
    • 동일한 컴포넌트를 여러 번 사용하여 코드 중복 최소화
    • 각 사용 시 다른 데이터를 전달
  • Props 전달 방법:
    • 커스텀 속성 추가
<CoreConcept
  title={CORE_CONCEPTS[0].title}
  description={CORE_CONCEPTS[0].description}
  image={CORE_CONCEPTS[0].image}
/>

22. 스프레드 연산자를 사용한 Props 전달

{CORE_CONCEPTS.map((concept, index) => (
  <CoreConcept key={index} {...concept} />
))}

23. Props를 받는 컴포넌트 작성 / 구조 분해 할당 (Destructuring) 사용:

// CoreConcept.jsx
function CoreConcept({ title, description, image }) {
  return (
    <li>
      <img src={image} alt={title} />
      <h3>{title}</h3>
      <p>{description}</p>
    </li>
  );
}

export default CoreConcept;

24. 고유 키 (key) 속성 사용

  • 리스트 렌더링 시 key 속성 필수:
    • 각 컴포넌트에 고유한 key 제공
{CORE_CONCEPTS.map((concept, index) => (
  <CoreConcept key={index} {...concept} />
))}

25. 코드 최적화 및 간결화

  • 스프레드 연산자 사용:
    • Props 전달을 간소화하여 코드 줄이기
  • 구조 분해 할당 활용:
    • Props를 개별 변수로 분리하여 가독성 향상

26. 개별 Props 전달

<CoreConcept
  title={CORE_CONCEPTS[0].title}
  description={CORE_CONCEPTS[0].description}
  image={CORE_CONCEPTS[0].image}
/>

27. Spread Operator (...)를 사용한 Props 전달

<CoreConcept {...CORE_CONCEPTS[0]} />

28. 단일 Prop 객체 전달

<CoreConcept concept={CORE_CONCEPTS[0]} />

29. Rest Property를 사용한 Props 그룹화

export default function CoreConcept({ ...concept }) {
  const { title, description, image } = concept;
  return (
    <li>
      <img src={image} alt={title} />
      <h3>{title}</h3>
      <p>{description}</p>
    </li>
  );
}

30. 선택적 Props와 기본값

export default function Button({ caption, type = "submit" }) {
  return <button type={type}>{caption}</button>;
}

31. 구조 분해 할당 (Destructuring) / Props를 개별 변수로 분리

export default function CoreConcept({ title, description, image }) {
  return (
    <li>
      <img src={image} alt={title} />
      <h3>{title}</h3>
      <p>{description}</p>
    </li>
  );
}

32. 단일 객체로 Props 그룹화

export default function CoreConcept({ concept }) {
  const { title, description, image } = concept;
  return (
    <li>
      <img src={image} alt={title} />
      <h3>{title}</h3>
      <p>{description}</p>
    </li>
  );
}

33. children Prop

  • 설명: 컴포넌트 태그 사이에 있는 내용을 전달
// TabButton.jsx
export default function TabButton({ children }) {
  return (
    <li>
      <button>{children}</button>
    </li>
  );
}
  • children Prop 전달 방법
// App.jsx
<TabButton>Components</TabButton>
<TabButton>Props</TabButton>
<TabButton>State</TabButton>
<TabButton>Lifecycle</TabButton>

34. 컴포넌트 합성 (Composition)

  • 설명: 컴포넌트를 다른 컴포넌트나 내용으로 감싸서 사용
// App.jsx
<ul>
  <TabButton>Components</TabButton>
  <TabButton>Props</TabButton>
  <TabButton>State</TabButton>
  <TabButton>Lifecycle</TabButton>
</ul>

35. children Prop과 구조 분해

// TabButton.jsx
export default function TabButton({ children }) {
  return (
    <li>
      <button>{children}</button>
    </li>
  );
}

36. 컴포넌트 상호작용 추가

  • 이벤트 핸들러 함수 정의
// TabButton.jsx
import React from 'react';

export default function TabButton({ children, onClick }) {
  return (
    <li>
      <button onClick={onClick}>{children}</button>
    </li>
  );
}
// App.jsx
import React from 'react';
import { CORE_CONCEPTS } from './data';
import CoreConcept from './CoreConcept';
import TabButton from './TabButton';

function App() {
  const handleClick = (buttonName) => {
    console.log(`Hello World! Button ${buttonName} clicked.`);
  };

  return (
    <div>
      <Header />
      <section id="core-concepts">
        <h2>Core Concepts</h2>
        <ul>
          {CORE_CONCEPTS.map((concept, index) => (
            <CoreConcept key={index} {...concept} />
          ))}
        </ul>
      </section>
      <section id="examples">
        <h2>Examples</h2>
        <menu>
          <TabButton onClick={() => handleClick('Components')}>Components</TabButton>
          <TabButton onClick={() => handleClick('Props')}>Props</TabButton>
          <TabButton onClick={() => handleClick('State')}>State</TabButton>
          <TabButton onClick={() => handleClick('Lifecycle')}>Lifecycle</TabButton>
        </menu>
      </section>
    </div>
  );
}

export default App;

37. 선언적 이벤트 처리 방식

  • 명령형 vs 선언형 코드

    • 명령형 코드: 전통적인 자바스크립트 방식으로 DOM 요소에 직접 이벤트 리스너 추가
    • 선언형 코드: React에서 onClick과 같은 Prop을 사용하여 이벤트 처리
  • React의 선언적 이벤트 처리

    • 장점: 코드의 가독성 및 유지보수성 향상, React의 상태 관리와 자연스럽게 통합
<button onClick={handleClick}>Click Me</button>

38. 함수 이름만 전달하기

  • 설명: 이벤트 핸들러는 함수 이름만 전달하고 호출하지 않음
    • 잘못된 예시:
<button onClick={handleClick()}>Click Me</button> // 즉시 실행됨
  • 올바른 예시:
<button onClick={handleClick}>Click Me</button> // 클릭 시 실행

39. 인수를 전달할 경우 화살표 함수 사용

  • 설명: 인수를 전달할 때는 화살표 함수를 사용하여 클릭 시 실행되도록 함
  • 예시:
<TabButton onClick={() => handleClick('Components')}>Components</TabButton>

40. Dynamic Content 설정

// App.jsx
<section id="dynamic-content">
  <h2>Dynamic Content</h2>
  <p>{dynamicContent}</p>
</section>

41. 상태 관리 (useState)

  • 설명: 버튼 클릭에 따라 내용을 동적으로 업데이트하기 위해 상태(State)를 사용
// App.jsx
import React, { useState } from 'react';
import TabButton from './TabButton';

function App() {
  const [dynamicContent, setDynamicContent] = useState('Select a button');

  const handleSelect = (content) => {
    setDynamicContent(content);
  };

  return (
    <div>
      <section id="examples">
        <menu>
          <TabButton onSelect={() => handleSelect('Components Content')}>Components</TabButton>
          <TabButton onSelect={() => handleSelect('Props Content')}>Props</TabButton>
          <TabButton onSelect={() => handleSelect('State Content')}>State</TabButton>
          <TabButton onSelect={() => handleSelect('Lifecycle Content')}>Lifecycle</TabButton>
        </menu>
      </section>
      <section id="dynamic-content">
        <h2>Dynamic Content</h2>
        <p>{dynamicContent}</p>
      </section>
    </div>
  );
}

export default App;

42. 탭메뉴 어플리케이션

  • 데이터 흐름 도식화
[컴포넌트 트리]
App
├── TabButton (x4)
│     └── onClick 이벤트 발생
│         ↓
│     handleSelect 함수 호출
│     └── 화살표 함수로 클릭된 버튼 식별자 전달
│
└── Dynamic Content
      └── 버튼에 따라 동적으로 업데이트
  • 이벤트 흐름 도식화
1. TabButton 컴포넌트 클릭
   (사용자가 버튼을 클릭)
   └── 이벤트 발생: onClick
        ↓
2. 화살표 함수 실행
   └── onSelect={() => handleSelect('버튼명')}
        ↓
3. App 컴포넌트의 handleSelect 함수 실행
   └── handleSelect('버튼명')
        ↓
4. 버튼명(selectedButton)에 따라 if 조건문 처리
   └── if (selectedButton === 'Components') { ... }
        ↓
5. setDynamicContent 함수 호출
   └── setDynamicContent('선택된 콘텐츠')
        ↓
6. App 컴포넌트의 Dynamic Content 영역 업데이트
   └── <p>{dynamicContent}</p>
  • 컴포넌트 트리 도식화
App 컴포넌트
  ├── <menu> 요소
  │     ├── TabButton 컴포넌트 ('Components')
  │     ├── TabButton 컴포넌트 ('JSX')
  │     ├── TabButton 컴포넌트 ('Props')
  │     └── TabButton 컴포넌트 ('State')
  │
  └── Dynamic Content 영역
        └── 클릭된 버튼에 따라 업데이트되는 <p> 태그

43. 변수로 UI 업데이트 실패 원인

  • 변수 사용으로 UI 업데이트 불가
    • 설명: 일반적인 변수를 사용해 UI를 업데이트하면 리액트가 컴포넌트를 다시 렌더링하지 않기 때문에 UI가 업데이트되지 않음
let tabContent = 'Please click a button';
  • 리액트의 JSX 코드 재평가
  • 설명: 리액트는 초기 렌더링 시 컴포넌트 함수를 한 번만 실행하고 그 후에는 상태가 변하지 않으면 다시 실행하지 않음
  • 예시: handleSelect 함수에서 변수를 변경해도 UI가 재평가되지 않음

44. 리액트 상태(state) 사용

  • 상태(state)를 이용한 UI 업데이트
    • 설명: 리액트의 상태 관리(useState)를 사용해야만 UI가 동적으로 업데이트됨
const [tabContent, setTabContent] = useState('Please click a button');
  • 상태 업데이트로 컴포넌트 재렌더링
    • 설명: 상태가 변경되면 리액트는 해당 컴포넌트를 다시 실행하여 UI를 업데이트
const handleSelect = (selectedButton) => {
  setTabContent(selectedButton);
};

45. 리액트 렌더링 동작 이해

  • 초기 렌더링
    • 설명: 리액트는 컴포넌트 함수를 처음 실행할 때 JSX 코드를 렌더링하고 이후 상태 변화가 없으면 다시 실행하지 않음
  • 이벤트 발생 후 상태 변경
    • 설명: 버튼 클릭과 같은 이벤트로 상태가 변경되면, 리액트는 해당 상태가 포함된 컴포넌트를 재평가하고 UI를 업데이트함

46. useState Hook 사용법

  • useState로 상태 초기화
    • 설명: useState는 상태를 관리하는 리액트 Hook으로, 컴포넌트의 상태를 초기화할 수 있음
const [selectedTopic, setSelectedTopic] = useState('Please click a button');
  • 상태 변경 함수 사용
    • 설명: useState에서 반환되는 두 번째 요소는 상태를 업데이트하는 함수로, 이를 호출하면 리액트가 컴포넌트를 재실행하여 UI를 업데이트함
const handleSelect = (selectedButton) => {
  setSelectedTopic(selectedButton);
};

47. 리액트 Hook 규칙

  • 컴포넌트 최상위에서 호출
    • 설명: 모든 리액트 Hook은 컴포넌트 함수의 최상위에서 호출해야 하며, 내부 함수나 조건문, 반복문 내에서 호출하면 안 됨
// 올바른 사용
const [state, setState] = useState('초기값');

// 잘못된 사용
if (true) {
  const [state, setState] = useState('초기값'); // 오류 발생
}
  • 리액트 Hook 함수는 리액트 컴포넌트 또는 커스텀 Hook 안에서만 호출 가능
    • 설명: useState 같은 리액트 Hook은 리액트 컴포넌트나 커스텀 Hook에서만 호출해야 함

48. 상태 스냅샷 및 컴포넌트 재실행

  • 상태 스냅샷
    • 설명: useState의 첫 번째 요소는 컴포넌트 실행 시의 상태 스냅샷을 나타내며, 상태가 업데이트되면 컴포넌트가 다시 실행됨
  • 컴포넌트 재실행 및 리렌더링
    • 설명: 상태가 변경되면 리액트는 해당 컴포넌트를 다시 실행하여 UI를 리렌더링

49. 상태 업데이트 스케줄

  • 상태 업데이트와 콘솔 로그 차이
    • 설명: 상태 업데이트는 비동기적으로 실행되며, 상태가 변경된 후 컴포넌트가 재실행될 때 새 상태 값을 사용할 수 있음
const handleSelect = (selectedButton) => {
  setSelectedTopic(selectedButton);
  console.log(selectedTopic); // 이전 상태 출력
};
  • 업데이트된 상태 값은 컴포넌트가 다시 실행된 후에만 반영됨
    • 설명: setSelectedTopic으로 상태를 업데이트하면 즉시 값이 반영되는 것이 아니라, 다음 컴포넌트 재실행 시 새로운 상태 값을 사용할 수 있음

50. 상태 초기화 및 오류 해결

  • 초기 상태 값 설정
    • 설명: 초기 상태값으로 components와 같은 유효한 식별자를 설정해 오류 방지.
const [selectedTopic, setSelectedTopic] = useState('components'); // 유효한 초기 값 설정
  • 조건부 렌더링으로 오류 방지
    • 설명: 초기 상태가 유효하지 않으면 렌더링하지 않도록 조건부 렌더링 추가.
{EXAMPLES[selectedTopic] && (
  <div id="tab-content">
    <h3>{EXAMPLES[selectedTopic].title}</h3>
    <p>{EXAMPLES[selectedTopic].description}</p>
    <pre>{EXAMPLES[selectedTopic].code}</pre>
  </div>
)}

51. 조건부 렌더링 방법

  • 삼항 연산자를 사용한 조건부 렌더링
    • 설명: 삼항 연산자를 사용하여 주제가 선택되지 않았을 때 대체 텍스트를, 선택되었을 때 콘텐츠를 렌더링.
{selectedTopic ? (
  <div id="tab-content">
    <h3>{EXAMPLES[selectedTopic].title}</h3>
    <p>{EXAMPLES[selectedTopic].description}</p>
    <pre>{EXAMPLES[selectedTopic].code}</pre>
  </div>
) : (
  <p>Please select a topic</p>
)}
  • 논리적 AND 연산자(&&)를 사용한 조건부 렌더링
    • 설명: 논리적 AND 연산자를 사용하여 조건이 참일 때만 렌더링.
{selectedTopic && (
  <div id="tab-content">
    <h3>{EXAMPLES[selectedTopic].title}</h3>
    <p>{EXAMPLES[selectedTopic].description}</p>
    <pre>{EXAMPLES[selectedTopic].code}</pre>
  </div>
)}

52. 변수 사용한 조건부 렌더링

  • 변수를 사용해 JSX 코드 저장
  • 설명: tabContent 변수에 조건부로 JSX 코드를 저장하고, 렌더링 시 해당 변수를 출력.
let tabContent = <p>Please select a topic</p>;

if (selectedTopic) {
  tabContent = (
    <div id="tab-content">
      <h3>{EXAMPLES[selectedTopic].title}</h3>
      <p>{EXAMPLES[selectedTopic].description}</p>
      <pre>{EXAMPLES[selectedTopic].code}</pre>
    </div>
  );
}

53. className을 통한 동적 스타일링

  • className으로 스타일 적용
    • 설명: 리액트에서 className prop을 사용해 HTML 요소에 CSS 클래스를 설정.
<button className="active">Tab</button>
  • JSX에서 className을 사용
    • 설명: JSX에서는 HTML의 class 대신 className을 사용해야 함.
<button className={isSelected ? 'active' : ''}>Tab</button>

54. 동적 클래스 설정 (조건부 스타일링)

  • 삼항 연산자를 통한 동적 className 설정
    • 설명: 삼항 연산자를 사용하여 선택 여부에 따라 CSS 클래스를 조건부로 설정.
<button className={isSelected ? 'active' : ''}>Tab</button>

55. JSX 배열 렌더링

  • JSX는 배열 형태의 요소를 렌더링할 수 있으며, 배열의 항목을 화면에 동적으로 출력할 수 있음.
const coreConcepts = ['Components', 'JSX', 'Props'];
return (
  <ul>
    {coreConcepts.map(concept => <CoreConcept key={concept.id} title={concept} />)}
  </ul>
);

56. 데이터 기반으로 컴포넌트 생성

  • 설명: 배열 데이터를 기반으로 컴포넌트를 자동으로 생성하여 중복된 코드 작성을 방지.
const coreConcepts = [{title: 'Components', description: '...'}, {title: 'JSX', description: '...'}];
return (
  <div>
    {coreConcepts.map(concept => (
      <CoreConcept key={concept.title} title={concept.title} description={concept.description} />
    ))}
  </div>
);

57. key prop 설정

  • 고유 key prop 설정
  • 설명: React는 각 리스트 아이템에 고유한 key prop이 필요하며, 이를 통해 효율적으로 렌더링과 업데이트를 관리.
{coreConcepts.map(concept => (
  <CoreConcept key={concept.title} title={concept.title} />
))}

58. key prop으로 경고 제거

  • 설명: key prop은 각 리스트 항목을 고유하게 식별하기 위해 사용되며, 이를 추가하지 않으면 React가 경고를 출력.
// 고유한 값인 title을 key로 설정
<CoreConcept key={concept.title} title={concept.title} />

59. JSX 코드와 createElement

  • JSX 코드
    • 설명: 리액트에서 JSX는 자바스크립트 코드 안에서 HTML을 작성할 수 있도록 하는 문법 확장.
const element = <h1>Hello, World!</h1>;
  • React.createElement
    • 설명: JSX 없이 리액트 요소를 생성할 때 사용하는 메소드. JSX 코드의 대체 방식.
const element = React.createElement('h1', null, 'Hello, World!');

60. JSX를 대체하는 createElement

  • JSX와 createElement 비교
  • JSX 예시:
const app = <App />;
  • createElement 예시:
const app = React.createElement(App, null);
  • createElement 함수 구조
    • 설명: createElement는 3가지 인수를 받음:
      1. 컴포넌트 타입: 생성할 컴포넌트나 태그 (예: 'div', App)
      2. props 객체: 컴포넌트에 전달할 props (없다면 null)
      3. child 요소: 컴포넌트 안에 포함될 자식 요소들
const element = React.createElement('div', { className: 'container' }, 'Hello, World!');

61. 빌드 과정과 JSX

  • JSX 코드의 빌드 과정

    • 설명: JSX는 브라우저에서 바로 실행되지 않기 때문에, 빌드 과정에서 createElement로 변환되어 실행됨.

    • 빌드 전 예시 (JSX):

      const element = <h1>Hello, World!</h1>;
    • 빌드 후 예시 (createElement로 변환):

      const element = React.createElement('h1', null, 'Hello, World!');
  • 빌드 과정 없이 리액트 사용

    • 설명: 빌드 없이 JSX를 사용하지 않고 리액트를 사용하려면 createElement 메소드를 직접 사용하여 컴포넌트를 생성.

62. React 프로젝트에서 JSX와 createElement 선택

  • JSX의 편리함
    • 설명: 대부분의 리액트 프로젝트는 JSX를 사용하여 더 직관적이고 간결하게 작성.
    • 예시 (JSX가 더 간결한 이유):
const element = <h1 className="header">Hello, World!</h1>;
  • createElement를 사용할 수 있는 경우
    • 설명: 빌드 과정이 없거나 JSX를 사용할 수 없는 환경에서 createElement 메소드를 대체 사용.
    • 예시 (빌드 과정 없이):
const element = React.createElement('h1', { className: 'header' }, 'Hello, World!');

63. JSX와 createElement 선택

  • 설명: 빌드 과정이 필요한 환경에서는 JSX를 사용하는 것이 더 편리하고 가독성이 좋음. 빌드 과정이 없는 환경에서는 createElement 메소드를 사용하여 컴포넌트를 생성.

64. JSX 코드의 변환 이해

  • 설명: JSX 코드는 빌드 도구(Babel 등)를 통해 React.createElement로 변환됨. 이를 통해 JSX 없이도 동일한 결과를 얻을 수 있음.

65. JSX 표현식의 규칙

  • JSX는 하나의 부모 요소가 필요
    • 설명: JSX에서 여러 형제 요소를 반환하려면 하나의 부모 요소로 감싸야 함.

    • 예시 (잘못된 코드):

      return (
        <h1>Header</h1>
        <p>Main Content</p>
      );
    • 예시 (수정된 코드):

      return (
        <div>
          <h1>Header</h1>
          <p>Main Content</p>
        </div>
      );

66. 형제 요소를 반환하려면 Fragment 사용

  • 설명: 불필요한 div 태그 없이 형제 요소를 반환하고 싶을 때, React.Fragment 또는 빈 태그(<> </>)를 사용.
return (
  <React.Fragment>
    <h1>Header</h1>
    <p>Main Content</p>
  </React.Fragment>
);
  • 빈 태그(<> </>)로 Fragment 대체
    • 설명: 최신 리액트에서는 React.Fragment 대신 빈 태그(<> </>)를 사용 가능.
return (
  <>
    <h1>Header</h1>
    <p>Main Content</p>
  </>
);

67. 컴포넌트 분할의 필요성

  • root 컴포넌트가 너무 많은 역할을 하는 경우

    • 설명: 하나의 컴포넌트가 너무 많은 책임을 맡고 있다면, 코드를 관리하기 어려워짐.
    • 신호: 컴포넌트가 다양한 섹션(예: 헤더, 탭 버튼, 상호작용 섹션)과 상태를 관리하는 경우.
    • 해결책: 컴포넌트를 더 작은 하위 컴포넌트로 나누어 책임을 분리함.
  • 컴포넌트를 쪼개는 이유

    • 설명: 컴포넌트를 잘게 나누면, 상태 관리와 재실행이 더 명확해지고, 특정 역할에 맞는 책임을 부여할 수 있음.
    • 예시:
      • Header 컴포넌트: 상단 텍스트 관리
      • TabButton 컴포넌트: 탭 버튼 관리
      • InteractiveSection 컴포넌트: 상호작용 섹션 관리

68. 상태 관리와 컴포넌트 재실행

  • 상태 관리(state management)

    • 설명: 컴포넌트의 상태가 변경되면 리액트는 해당 컴포넌트와 그 하위 컴포넌트를 재실행(re-render)함.
    • 예시: selectedTopic 상태가 변경되면, 전체 App 컴포넌트가 재실행되고, 상태를 사용하는 모든 하위 컴포넌트도 재실행됨.
  • 컴포넌트 재실행의 영향

    • 설명: 컴포넌트가 재실행되면, 모든 JSX 코드가 다시 평가되고 화면이 업데이트됨. 이때, 불필요한 재실행을 방지하기 위해 상태를 적절히 분할하는 것이 중요.
    • 예시:
      • App 컴포넌트가 재실행될 때 Header 컴포넌트도 불필요하게 재실행되므로, 상태를 분리하여 최적화 가능.

69. 컴포넌트 분리 전략

  • 상호작용이 있는 부분을 별도의 컴포넌트로 분리

    • 설명: App 컴포넌트에서 상호작용 섹션과 같은 역할이 명확한 부분을 별도의 컴포넌트로 분리하여 상태 관리와 재실행을 최소화할 수 있음.
    • 예시:
      • Header 컴포넌트를 분리하여 selectedTopic 상태에만 의존하게 하고, 상태 변경 시에만 재실행되도록 최적화.
  • 상태를 사용하지 않는 부분도 컴포넌트로 분리

    • 설명: 상태와 무관한 부분도 분리하여, 상태가 변경될 때 해당 부분이 불필요하게 재실행되지 않도록 함.
    • 예시: 정적 컨텐츠를 렌더링하는 컴포넌트는 상태 변경 시 재실행되지 않음.
// 데이터 흐름 도식화

[컴포넌트 트리]
App
├── Header
│     └── selectedTopic 상태에 따라 텍스트 업데이트
│
├── TabButtons
│     └── 탭 버튼 클릭 시 selectedTopic 상태 변경
│
└── InteractiveSection
      └── 상호작용 요소 관리
// 상태 관리 및 컴포넌트 재실행 흐름

1. 탭 버튼 클릭
   └── selectedTopic 상태 업데이트
        ↓
2. App 컴포넌트 재실행
   └── selectedTopic 상태가 변경되면 전체 App 컴포넌트가 재실행됨
        ↓
3. Header 컴포넌트도 재실행
   └── Header 컴포넌트는 selectedTopic 상태를 사용하므로 재실행됨
        ↓
4. InteractiveSection은 상태 변경에 영향을 받지 않으면 재실행되지 않음

70. 상태 관리와 컴포넌트 재실행 (State Management & Re-rendering)

  • 상태 관리 분리

    • 설명: selectedTopic 상태를 App 컴포넌트에서 Examples 컴포넌트로 이동하여, App 컴포넌트가 재실행되지 않게 최적화.
    • 예시:
      • Examples 컴포넌트 내에서 상태 관리.
      • App 컴포넌트는 상태와 무관한 부분만 렌더링.
  • 상태 변경과 재실행 방지

    • 설명: 상태가 변경될 때 관련된 컴포넌트만 재실행되도록 상태를 관리하여 불필요한 재실행 방지.
// 파일 구조 예시

/components
  ├── CoreConcepts.jsx
  ├── Examples.jsx
  ├── TabButton.jsx
  └── CoreConcept.jsx
/data.js
App.jsx
// 데이터 흐름 도식화

[컴포넌트 트리]
App
├── CoreConcepts
│     └── CORE_CONCEPTS 데이터를 렌더링
│
└── Examples
      ├── TabButton
      │     └── 탭 버튼 클릭 시 handleSelect 함수로 selectedTopic 상태 업데이트
      └── selectedTopic 상태에 따라 EXAMPLES 데이터 출력
// 상태 관리 및 컴포넌트 재실행 흐름

1. 탭 버튼 클릭
   └── selectedTopic 상태 업데이트 (Examples 컴포넌트 내부)
        ↓
2. Examples 컴포넌트 재실행
   └── selectedTopic 상태가 변경되어 해당 부분만 재실행됨
        ↓
3. App 컴포넌트는 재실행되지 않음
   └── App 컴포넌트는 상태 관리에서 제외되었기 때문

71. Forwarded Props (전달 속성)

  • 설명: props가 커스텀 컴포넌트에서 자동으로 전달되지 않기 때문에, 수동으로 구조 분해를 통해 특정 속성을 전달해야 함.
  • 해결 방법
    • 설명: 구조 분해 또는 forwarded props를 사용하여, 해당 속성을 자동으로 HTML 요소에 전달되도록 함.
// Section.jsx
export default function Section({ title, children, ...props }) {
  return (
    <section {...props}>
      <h2>{title}</h2>
      {children}
    </section>
  );
}

72. JSX 코드에서 다중 슬롯 설정 (Slot System)

  • 기본 Slot 개념
    • 설명: 여러 요소를 동적으로 배치할 수 있는 슬롯을 설정하는 방법. 특정 위치에 JSX 요소를 전달하는 방식으로 컴포넌트를 유연하게 사용할 수 있음.
// Tabs.jsx
function Tabs({ buttons, children }) {
  return (
    <>
      <menu>
        {buttons}
      </menu>
      <div>
        {children}
      </div>
    </>
  );
}
// Examples.jsx
<Tabs buttons={
  <>
    <TabButton onClick={() => handleSelect('Components')}>Components</TabButton>
    <TabButton onClick={() => handleSelect('JSX')}>JSX</TabButton>
  </>
}>
  {tabContent}
</Tabs>

73. Static Markup

  • Static Markup (정적인 마크업): 변하지 않는 요소(예: 헤더)들은 React 컴포넌트 외부에, 예를 들어 index.html에 작성할 수 있음. 이런 정적인 요소는 React의 상태나 속성에 영향을 받지 않음.

74. Props and State

  • Props (속성): 부모 컴포넌트가 자식 컴포넌트로 전달하는 데이터. 상태 변화가 필요 없는 정적 요소는 props로 전달할 필요 없음.
  • State (상태): React에서 동적으로 변하는 데이터는 컴포넌트의 state로 관리.

75. Public Folder and Image Rendering

  • Public Folder (공유 폴더): public 폴더에 저장된 파일들은 웹사이트 방문자도 접근 가능. 이미지와 같은 파일을 public 폴더에 넣으면 경로 없이 바로 참조 가능.
<img src="/game-logo.png" alt="Hand-drawn Tic-Tac-Toe" />

76. 폴더 구조

  • public 폴더: 이 폴더에 저장된 파일은 개발 서버와 빌드 프로세스를 통해 직접 브라우저에서 접근 가능.
    • 예: localhost:5173/some-image.jpg
    • 용도: 파비콘, index.html에서 사용하는 이미지 등 빌드 과정이 필요 없는 정적인 리소스.
  • src/assets 폴더: 프로젝트 내부의 코드에서 import되어 사용되는 이미지. 직접 참조 불가, 코드에서 import된 후 빌드 시 최적화되어 사용.
    • 용도: 컴포넌트에서 사용하는 이미지 또는 동적으로 처리되는 리소스.

77. 이미지 참조

  • public 폴더 이미지 참조
    • 파일을 직접적으로 index.html이나 index.css에서 참조 가능.
    • URL을 통해 직접 접근 가능 (localhost:5173/이미지_이름.jpg).
  • src 폴더 이미지 참조
    • React 코드에서 import하여 참조.
    • 빌드 시 자동 최적화 및 경로 처리.
import someImage from './assets/some-image.jpg';
<img src={someImage} alt="Example" />;

78. 빌드 프로세스와 최적화

  • public 폴더: 빌드 과정에서 변경되지 않음. 정적 파일로 그대로 제공.
  • src 폴더: 빌드 과정에서 최적화되고, 참조한 이미지 파일은 필요한 위치로 자동 이동.

79. 웹사이트 방문자 접근

  • public 폴더: 웹사이트 방문자가 직접 접근 가능.
  • src 폴더: 웹사이트 방문자는 직접 접근 불가. 코드에서 참조된 경우에만 제공.

80. 어떤 폴더를 사용해야 할까?

  • public 폴더: 빌드 프로세스에서 처리되지 않으며, 공개적으로 접근 가능한 이미지나 정적 리소스.
  • src 폴더: 컴포넌트 내에서 사용되는 동적인 리소스, 최적화가 필요한 이미지.

81. 상태 변경 함수 (State Update Function)

  • 목적: 리액트에서 상태를 변경할 때 사용하는 함수 (setState 계열).
  • 함수 전달 방식: 상태를 변경할 때, 이전 상태에 기반해야 할 경우 함수 형태를 전달.
setState((prevState) => !prevState);

82. 리액트의 상태 변경 스케줄

  • 목적: 리액트는 상태 변경을 즉각 실행하지 않고, 상태 변경 스케줄을 관리하여 비동기적으로 처리.
  • 중요성: 상태 변경은 컴포넌트가 렌더링되는 동안 즉각 실행되지 않으므로, 최신 상태값을 사용하려면 함수 형태의 상태 변경을 사용해야 함.

83. 상태 변경의 비동기적 특성

  • 목적: 리액트는 상태 변경을 즉시 반영하지 않으며, 스케줄링을 통해 차후에 반영함.
  • 방법: 상태 변경 함수 호출 시, 리액트는 최신 상태값을 비동기적으로 가져옴.

84. 컴포넌트 렌더링 사이클

  • 목적: 상태 변경 후 컴포넌트는 다시 렌더링되며, 해당 렌더링 사이클 내에서 상태가 업데이트됨.
  • 방법: 컴포넌트의 렌더링 사이클을 고려하여 상태 변경 로직을 설계.

85. useState 훅

  • 설명: 리액트에서 상태를 선언하고 관리할 수 있는 훅. 상태와 상태를 변경하는 함수를 반환.
const [state, setState] = useState(initialState);
  • 상태 업데이트 (State Update)
    • 설명: 상태를 변경하고 리액트에서 UI가 다시 렌더링되도록 하는 과정.
    • 방법: 상태 변경 함수(setState)를 호출하여 새로운 값을 설정.
setPlayerName(newName);

86. 이벤트 핸들러 (Event Handler)

  • 설명: 특정 이벤트가 발생했을 때 실행되는 함수.
  • 예시: 사용자가 input 필드에 값을 입력하면 onChange 이벤트가 실행됨.
const handleChange = (event) => {
  setPlayerName(event.target.value);
};

87. onChange 이벤트

  • 설명: 입력 필드에서 발생하는 이벤트로, 사용자가 입력할 때마다 호출됨.
  • 사용: onChange 속성에 함수 연결.
<input value={playerName} onChange={handleChange} />

88. 입력 필드 제어 (Controlled Input)

  • 설명: 리액트에서 입력 필드의 값을 상태로 관리하여, UI와 데이터를 일관성 있게 유지.
<input value={editablePlayerName} onChange={handleChange} />

89. 양방향 바인딩 (Two-Way Binding)

  • 설명: 사용자의 입력에 따라 상태를 업데이트하고, 그 상태를 다시 입력 필드에 반영하는 방식.

90. 다차원 리스트 렌더링

const rows = [
  [null, null, null],
  [null, null, null],
  [null, null, null]
];

rows.map((row, rowIndex) => (
    <li key={rowIndex}>
        {row.map((col, colIndex) => (
            <li key={colIndex}>
              <button>{playerSymbol}</button>
            </li>
        ))}
    </li>
));

91. key 속성

  • 설명: React에서 배열을 렌더링할 때 각 요소를 고유하게 식별하기 위해 사용하는 속성. 중첩 배열에서 rowIndex와 colIndex로 고유한 키를 부여.

92. 틱택토 게임 / Lifting State Up (상태 끌어올리기)

  • 설명: 여러 컴포넌트가 동일한 상태를 필요로 할 때, 그 상태를 상위 컴포넌트로 끌어올려 하위 컴포넌트에 상태를 공유하는 방식.
const [activePlayer, setActivePlayer] = useState('X'); // App 컴포넌트에서 상태 관리
1. App 컴포넌트: activePlayer 상태 설정 및 관리
   ↓
2. GameBoard 컴포넌트와 Player 컴포넌트에 activePlayer 상태 전달
   ↓
3. Player 컴포넌트: activePlayer가 'X' 또는 'O'인지에 따라 CSS 클래스 추가 (highlight-player)
   ↓
4. GameBoard 컴포넌트: activePlayer 상태를 받아 버튼 클릭 시 해당 기호(X 또는 O) 표시
   ↓
5. handleSelectSquare 함수 실행: 버튼 클릭 시 게임판 상태와 activePlayer 상태 업데이트
   ↓
6. setActivePlayer 함수: 현재 차례의 플레이어를 번갈아가며 설정
   ↓
7. UI 업데이트: 현재 차례의 플레이어에 강조 표시 및 게임판에 X 또는 O 기호 표시

93. 틱택토 게임 / 계산된 값 사용

  1. 상태 끌어올리기: GameBoard는 더 이상 상태를 직접 관리하지 않고, 부모(App) 컴포넌트에서 제어하는 방식으로 변경. 이를 통해 gameTurns와 activePlayer와 같은 상태를 한 곳에서 관리.
  2. 게임 진행 순서 관리: 각 턴에 대한 정보를 gameTurns 배열에 객체로 저장하여, 어떤 플레이어가 어디에 수를 두었는지 기록.
  3. 불변성 유지: 기존 배열을 변경하지 않고 복사한 후, 새로운 정보를 추가하는 방식으로 상태를 불변하게 관리함.
  4. 현재 플레이어 계산: 가장 최신의 차례 정보를 기반으로 다음 플레이어를 결정함.
  5. 게임의 로직 통합: gameTurns 상태를 로그와 게임판 모두에서 공유할 수 있도록 상태를 관리하는 구조로 변경.
// 데이터 흐름 도식화:

1. App 컴포넌트에서 gameTurns와 activePlayer 상태 관리
   ↓
2. GameBoard 컴포넌트에서 onSelectSquare 호출
   ↓
3. handleSelectSquare 함수 실행
   ↓
4. 클릭된 버튼의 정보 (rowIndex, colIndex)와 currentPlayer 계산
   ↓
5. gameTurns 배열에 새로운 턴 정보 추가
   ↓
6. 상태 업데이트 후 로그와 게임판에 반영

94. 틱택토 게임 / Props에서 State 파생하기

  1. 상태 끌어올리기: App 컴포넌트에서 gameTurns 상태를 관리하고, GameBoard와 Log 컴포넌트에 상태를 전달함.
  2. 파생 상태: gameBoard는 gameTurns 상태를 기반으로 계산된 상태이며, 이를 통해 각 플레이어가 선택한 위치에 기호를 표시함.
  3. 이벤트 처리: 각 버튼 클릭 시 행(row)과 열(col)에 대한 정보를 추출하여 handleSelectSquare로 전달하고, gameTurns 상태를 업데이트함.
  4. 데이터 불변성: 상태 업데이트는 불변성을 유지하며, 기존 gameTurns 배열에 새로운 턴 정보를 추가하는 방식으로 관리.
// 데이터 흐름 도식화:

1. App 컴포넌트에서 gameTurns 상태 관리
   ↓
2. GameBoard 컴포넌트에서 전달받은 gameTurns Prop으로 gameBoard 파생 후 게임보드 렌더
   ↓
3. GameBoard 컴포넌트에 turns 배열과 onSelectSquare 전달
   ↓
4. GameBoard 컴포넌트에서 각 버튼 클릭 시 onSelectSquare 호출
   ↓
5. handleSelectSquare 함수에서 클릭된 버튼의 row, col 정보와 player 정보를 gameTurns에 업데이트
   ↓
6. gameTurns 배열을 기반으로 gameBoard 파생 및 업데이트
   ↓
7. 업데이트된 gameBoard는 화면에 다시 렌더링

95. 틱택토 게임 / 컴포넌트 간의 State(상태) 공유

  1. 상태 끌어올리기 (State Lifting)

    • App 컴포넌트:
      • gameTurns라는 상태를 App 컴포넌트에서 관리.
      • 이 상태를 GameBoard와 Log 컴포넌트로 props로 전달하여 각 컴포넌트가 동일한 상태를 기반으로 렌더링되도록 함.
  2. 파생 상태 (Derived State)

    • GameBoard 컴포넌트:
      • gameTurns 상태를 기반으로 gameBoard라는 파생 상태를 계산.
      • gameBoard는 각 턴에서 플레이어가 선택한 위치에 기호를 표시하여 보드에 반영.
      • gameBoard는 App 컴포넌트가 관리하는 상태(gameTurns)에 따라 동적으로 업데이트되어 최신 게임 보드를 표시함.
  3. 이벤트 처리 (Event Handling)

    • 버튼 클릭 이벤트:
      • GameBoard의 각 버튼에 클릭 이벤트를 설정.
      • 버튼이 클릭되면 행(row)과 열(col)의 인덱스를 추출하여 handleSelectSquare 함수로 전달.
    • handleSelectSquare:
      • 클릭된 버튼의 row, col 정보와 현재 플레이어의 기호를 사용해 새로운 턴 정보를 생성.
      • 생성된 턴 정보를 gameTurns 상태에 추가하여 게임 상태를 업데이트함.
  4. 데이터 불변성 (Data Immutability)

    • gameTurns 상태 관리:
      • 상태 업데이트 시 불변성을 유지하기 위해 기존 gameTurns 배열을 직접 수정하지 않고, 새로운 턴 정보를 배열에 추가하여 상태를 업데이트함.
      • 불변성을 유지하면 React가 상태 변경을 감지하기 쉬워져 효율적인 업데이트가 가능.
// 데이터 흐름 요약
데이터 흐름 순서 (텍스트로 요약)

1. App 컴포넌트에서 gameTurns 상태를 관리
    - 상태는 각 컴포넌트 간에 공유될 수 있도록 끌어올려 관리함.

2. GameBoard 컴포넌트는 gameTurns 속성(prop)을 받아, 이를 기반으로 gameBoard 파생 상태를 계산
    - gameBoard에는 각 플레이어의 기호가 나타나며, 이를 통해 게임 보드를 렌더링함.

3. App 컴포넌트는 GameBoard 컴포넌트에 turns 배열과 onSelectSquare 함수를 전달
    - onSelectSquare는 각 버튼 클릭 시 GameBoard에서 호출될 콜백 함수임.

4. GameBoard 컴포넌트에서 각 버튼 클릭 시 onSelectSquare 호출
    - 각 버튼은 클릭 시 row와 col 정보를 handleSelectSquare로 전달함.

5. handleSelectSquare 함수는 클릭된 버튼의 row, col 정보와 플레이어 기호를 gameTurns에 업데이트
    - 업데이트된 gameTurns는 새로운 턴을 반영하여 최신 상태로 유지됨.

6. gameTurns 배열을 기반으로 gameBoard를 업데이트
    - gameBoard는 최신 턴 정보를 반영해 변경됨.

7. 업데이트된 gameBoard는 화면에 렌더링
    - 상태 변화에 따라 보드가 다시 그려지며 UI에 즉시 반영됨.

96. 틱택토 게임 / 버튼 중복 클릭 방지 및 비활성화

  • GameBoard 컴포넌트:

    • 버튼: 각 버튼에 disabled 속성을 추가하여 중복 클릭을 방지.
  • 비활성화 조건:

    • playerSymbol이 X 또는 O일 경우: 해당 버튼은 이미 선택된 상태이며, disabled를 true로 설정해 비활성화.
    • playerSymbol이 null인 경우: 버튼이 아직 선택되지 않았으므로 disabled를 false로 설정해 활성화.
  • 상태에 따른 조건부 렌더링:

    • playerSymbol 값에 따라 버튼의 disabled 속성이 동적으로 설정됨.
    • 조건: playerSymbol이 X나 O가 아니라 null이면 아직 선택되지 않은 상태이므로 버튼이 활성화됨.
  • 기능 확인:

    • 설정 후 버튼이 한 번만 클릭되고, 한 번 클릭된 버튼은 비활성화되므로 다시 클릭할 수 없음.
    • 각 버튼이 한 번씩만 선택되도록 제한하여 게임의 규칙성을 유지하고, 로그의 불필요한 증가를 방지함.
// 데이터 흐름 요약 (텍스트로 설명)

1. App 컴포넌트에서 gameTurns와 GameBoard 상태 관리.
   - gameTurns 상태는 각 플레이어의 선택을 기록하며, 버튼 상태를 결정하는 기준이 됨.

2. GameBoard 컴포넌트는 gameTurns와 onSelectSquare 콜백을 props로 받음.
   - gameTurns 속성으로 각 버튼의 playerSymbol 상태를 파생하여 버튼 상태를 결정함.

3. 각 버튼에 조건부 disabled 설정:
   - GameBoard의 각 버튼은 playerSymbol 상태에 따라 disabled 속성을 가짐
   - playerSymbol이 X나 O면 버튼을 비활성화하고, null이면 활성화 상태로 설정됨.

4. 버튼 클릭 시 이벤트 처리:  
   - 클릭된 버튼의 row와 col 정보가 handleSelectSquare 함수로 전달됨.
   - 선택된 정보는 gameTurns 상태에 추가되어 업데이트됨.

5. 업데이트된 상태로 UI 재렌더링:
   - 버튼이 클릭됨에 따라 gameTurns 상태가 변경되고, 이에 따라 버튼 비활성화 상태가 유지됨.
   - 이후 동일 버튼을 클릭해도 게임 진행에 영향을 미치지 않음.

97. 틱택토 게임 / 분리된 파일로 데이터 아웃소싱

틱택토 게임 승리 조건을 확인하는 로직을 구현하는 방법. 가능한 모든 우승 조합을 배열로 정의하고, 매 차례마다 현재 게임 상태와 비교하여 승리 여부를 판단한다. 우승 조건은 행과 열의 인덱스로 표현되며, 별도의 헬퍼 파일(winning-combinations.js)에 저장된다. 이 파일을 메인 앱 컴포넌트(App.jsx)에서 가져와서 사용하며, 승리 시 게임판 위에 승리 화면을 표시한다.

[1] 게임 시작
     |
     v
[2] 플레이어의 턴
     |
     v
[플레이어가 버튼 클릭]
     |
     v
[3] 게임 상태 업데이트]
     |
     v
[4] 우승 조건 확인]
     |
     v
+-----------------------+
| 우승 조건 일치?       |
+-----------------------+
     |             |
    예          아니오
     |             |
     v             v
[5] 게임 종료 처리   +----------------------+
     |             | 무승부인가?            |
     v             +----------------------+
[승리 화면 표시]            |          |
     |                  예          아니오
     v                  |           |
[게임 종료]               v           v
                   [무승부 화면 표시]  [다음 플레이어로 전환]
                        |             |
                        v             v
                   [게임 종료]      [2] 플레이어의 턴으로 이동

98. 틱택토 게임 / 계산된 값 끌어올리기

이번 설명에서는 게임(예: 틱택토)의 우승 조건을 매 차례마다 동적으로 확인하는 로직을 최적화하는 방법을 다룹니다. 기존에는 handleSelectSquare 함수 내에서 hasWinner 상태를 별도로 관리했으나, 이는 중복된 로직으로 인해 불필요하다고 판단됩니다. 대신, gameTurns 상태에서 우승 여부를 파생시켜 관리하는 것이 효율적입니다.

이를 위해 gameBoard 상태를 App 컴포넌트로 끌어올려 상태 관리와 우승 조건 확인 로직을 중앙화합니다. GameBoard 컴포넌트는 이제 board 속성을 받아 게임판을 렌더링하며, 게임판의 기호는 App 컴포넌트에서 관리됩니다. 이러한 구조 변경을 통해 코드의 중복을 제거하고, 상태 관리의 일관성을 유지할 수 있습니다.

또한, WINNING_COMBINATIONS를 매 차례마다 재실행하여 모든 우승 조합을 검토함으로써 우승 여부를 신속하게 판단할 수 있습니다. 게임판의 각 버튼 기호를 gameBoard 상태에서 추출하여 우승 조건을 충족하는지 확인합니다. 이러한 최적화는 코드의 효율성과 가독성을 높이며, 유지보수를 용이하게 합니다.

[게임 시작]
     |
     v
[App 컴포넌트 초기화]
     |
     v
[gameBoard 상태 생성 및 초기화]
     |
     v
[GameBoard 컴포넌트 렌더링]
     |
     v
[플레이어가 버튼 클릭]
     |
     v
[handleSelectSquare 함수 실행]
     |
     v
[gameBoard 업데이트]
     |
     v
[우승 조건 검토]
     |
     v
+-------------------------+
| 우승 조건 충족 여부 확인 |
+-------------------------+
         |           |
        예           아니오
         |              |
         v              v
[게임 종료 처리]     [gameBoard 상태 업데이트]
     |                      |
     v                      v
[승리 화면 표시]          [다음 플레이어 턴]
     |                      |
     v                      v
[게임 종료]               [플레이어의 턴으로 돌아감]

99. 틱택토 게임 / 계산된 값에서 새로운 계산된 값 파생하기

이 설명은 틱택토 게임에서 우승자를 결정하는 로직을 구현하는 과정에 대한 것입니다.

  • 우승 조건 검사:

    • 우승 조건 배열(WINNING_COMBINATIONS)을 순회하면서 각 우승 조합을 확인합니다.
    • 각 우승 조합의 버튼 위치를 사용하여 gameBoard에서 해당 기호를 가져옵니다.
    • 첫 번째 버튼의 기호가 null이 아니고, 세 버튼의 기호가 모두 동일하면 우승자가 결정됩니다.
  • 우승자 처리 및 메시지 표시:

    • 우승자(winner) 변수를 통해 우승자를 저장하고, 화면에 메시지를 표시합니다.
    • AND 연산자(&&)를 사용하여 우승자가 있을 때만 메시지를 표시합니다.
  • 게임 진행 제어 필요성:

    • 우승자가 결정되어도 게임이 멈추지 않는 문제가 있습니다.
    • 게임 종료 시 더 이상 입력을 받지 않도록 로직을 추가해야 합니다.
    • 게임 오버 화면을 개선하여 사용자 경험을 높입니다.
  • 다음 단계:

    • 게임 종료 후 입력 차단 기능 구현.
    • 게임 오버 화면 디자인 개선.
    • 게임 재시작 기능 추가.
[게임 시작]
     ↓
[우승 조합 반복문 시작]
     ↓
[첫 번째 버튼 기호 가져오기]
     ↓
[firstSymbol이 null인가?]
     ↙           ↘
   예             아니오
   ↓                ↓
[다음 우승 조합으로]   [두 번째, 세 번째 버튼 기호 가져오기]
                        ↓
          [기호들이 모두 동일한가?]
                        ↙           ↘
                      예             아니오
                      ↓                ↓
          [우승자 결정 및 메시지 표시]   [다음 우승 조합으로]
                      ↓
             [게임 종료 처리 필요]
                      ↓
          [게임 오버 화면 개선 작업]

100. 틱택토 게임 / 틱택토 게임: "게임 오버" 화면 & 무승부 여부 확인

// 게임 진행 및 종료 조건 확인:

[게임 시작]
     ↓
[플레이어의 턴 진행]
     ↓
[gameTurns 업데이트]
     ↓
[우승자 확인 로직]
     ↓
┌───────────────────────┐
│ 우승자가 있는가?      │
└─────────┬─────────────┘
          │
        예 ▼           아니오
    [winner 변수 설정]      │
          │                ↓
          │        [gameTurns.length === 9 인가?]
          │                │
          ▼               예▼           아니오
[GameOver 컴포넌트 렌더링]   [hasDraw = true]   [게임 진행]
          │                │
          ▼                ▼
     [승리자 메시지]     [GameOver 컴포넌트 렌더링]
          │                │
          ▼                ▼
    [Rematch 버튼]       [무승부 메시지]
          │                │
          ▼                ▼
    [게임 재시작 기능]     [Rematch 버튼]
// 컴포넌트 간 데이터 흐름:

[App 컴포넌트]
     │
     │-- (우승자 또는 무승부 확인)
     │
     │
     │
     ▼
[GameOver 컴포넌트]
     │
     │-- (winner 속성 전달)
     │
     ▼
[조건부 렌더링]
     │
┌───────────────┐
│ winner가 있는가? │
└──────┬──────────┘
       │
     예▼           아니오▼
[승리자 메시지]     [무승부 메시지]
       │              │
       ▼              ▼
  [Rematch 버튼]   [Rematch 버튼]
// 사용자 상호작용 및 게임 재시작 (추후 구현):

[사용자가 Rematch 버튼 클릭]
              │
              ▼
      [게임 상태 초기화]
              │
              ▼
         [게임 재시작]

101. 틱택토 게임 / 불변성이 어떤 경우에서든 중요한 이유

이 설명은 React로 만든 게임(틱택토 등)에서 재대결(Rematch) 버튼을 통해 게임을 재시작하는 기능을 구현하는 과정과, 그 과정에서 발생한 버그의 원인과 해결 방법에 대한 것입니다.

  • 게임 재시작 기능 구현:

    • App 컴포넌트에서 handleRestart 함수를 생성하여 gameTurns 상태를 빈 배열로 재설정합니다.
    • GameOver 컴포넌트에 onRestart 속성을 전달하고, 내부의 Rematch 버튼에 onClick 핸들러로 연결합니다.
  • 버그 발생 및 원인 분석:

    • 게임 재시작 후에도 게임판이 초기화되지 않고, 게임 오버 화면이 사라지지 않는 문제가 발생했습니다.
    • 이는 gameBoard를 생성하는 과정에서 원본 배열(initialGameBoard)이 직접 수정되었기 때문입니다.
    • 자바스크립트에서 배열과 객체는 참조 타입이므로, 복사 없이 직접 수정하면 원본 데이터가 변경됩니다.
  • 버그 해결:

    • initialGameBoard의 깊은 복사(deep copy) 를 생성하여 gameBoard를 만들어야 합니다.
    • map 함수와 스프레드 연산자(...)를 사용하여 내부 배열까지 복사합니다.
    • 이를 통해 게임판이 올바르게 초기화되고, 게임이 정상적으로 재시작됩니다.
// 게임 재시작 기능 흐름:

[Rematch 버튼 클릭]
         │
         ▼
[handleRestart 함수 호출]
         │
         ▼
[gameTurns 상태를 빈 배열로 재설정]
         │
         ▼
[App 컴포넌트 재렌더링]
         │
         ▼
[파생 상태들 업데이트]
   ├─ gameBoard 재생성
   ├─ activePlayer 재설정
   └─ winner 상태 초기화
         │
         ▼
[게임판 초기화 및 게임 재시작]
// 버그 발생 및 해결 흐름:

[초기 버그 상황]
         │
[gameBoard 생성 시 initialGameBoard를 직접 수정]
         │
         ▼
[게임 재시작 후에도 initialGameBoard가 수정된 상태]
         │
         ▼
[게임판이 초기화되지 않고 이전 상태 유지]
         │
         ▼
[버그 원인 파악]
   └─ 배열이 참조 타입이므로 원본이 변경됨
         │
         ▼
[해결책 적용]
   └─ initialGameBoard의 깊은 복사본으로 gameBoard 생성
         │
         ▼
[gameBoard가 새로운 배열로 생성됨]
         │
         ▼
[게임 재시작 시 게임판이 올바르게 초기화]
// 컴포넌트 간 데이터 흐름:

[App 컴포넌트]
   │
   ├─ handleRestart 함수 정의
   │
   ├─ GameOver 컴포넌트에 onRestart 속성 전달
   │        │
[GameOver 컴포넌트]
   │
   ├─ Rematch 버튼에 onClick 핸들러로 onRestart 설정
   │        │
[사용자 인터랙션]
   │
   └─ Rematch 버튼 클릭 시 onRestart 호출
             │
             ▼
[App 컴포넌트의 handleRestart 실행]
// 게임 재시작 기능 흐름도

[사용자]
  │
  ├─── 클릭 ───▶ [Rematch 버튼]
                       │
                       ▼
              [onRestart 호출]
                       │
                       ▼
             [handleRestart 실행]
                       │
                       ▼
          [gameTurns 상태 초기화]
                       │
                       ▼
             [게임 상태 재설정]
                       │
                       ▼
              [게임판 재렌더링]
// 버그 발생 및 해결 흐름도

[게임판 생성 시]
    │
    ├─ (잘못된 방법) initialGameBoard 직접 수정
    │                    │
    │                    ▼
    │          [원본 배열이 수정됨]
    │                    │
    │                    ▼
    │          [게임 재시작 시 문제 발생]
    │
    └─ (올바른 방법) initialGameBoard 깊은 복사
                         │
                         ▼
               [새로운 gameBoard 생성]
                         │
                         ▼
               [게임 재시작 시 정상 동작]

102. 틱택토 게임 / State(상태)를 끌어올리면 안 되는 경우

게임에서 승자가 나왔을 때 기호('X', 'O') 대신 플레이어의 이름을 표시하기 위해, 플레이어 이름을 App 컴포넌트에서 접근할 수 있도록 해야 합니다. 하지만 Player 컴포넌트의 상태를 끌어올리면 매 타이핑마다 App 컴포넌트 전체가 재렌더링되어 비효율적입니다. 또한 Player 컴포넌트는 두 번 사용되므로 상태 관리가 복잡해집니다.

이를 해결하기 위해 App 컴포넌트에 새로운 상태 players를 추가하고, 플레이어 이름을 저장합니다. Player 컴포넌트에서 저장 버튼을 클릭할 때만 handlePlayerNameChange 함수를 호출하여 players 상태를 업데이트합니다. 이때 이전 상태를 보존하면서 변경된 플레이어의 이름만 업데이트합니다.

동적 속성 설정을 사용하여 객체의 키를 기호(symbol)로 지정하고, 해당 플레이어의 이름을 업데이트합니다. 게임 종료 시 승자의 기호를 사용하여 players 상태에서 승자 이름을 가져와 화면에 표시합니다.

[Player 컴포넌트 1]       [Player 컴포넌트 2]
      │                          │
(로컬 상태로 이름 관리)     (로컬 상태로 이름 관리)
      │                          │
      ├─── 저장 버튼 클릭 ───▶   │
      │     onSave('X', newName)  │
      │                          ├─── 저장 버튼 클릭 ───▶
      │                          │     onSave('O', newName)
      │                          │
───────────────────────────────────────────────
                      │
          [App 컴포넌트: handlePlayerNameChange 호출]
                      │
          setPlayers(prevPlayers => ({
              ...prevPlayers,
              [symbol]: newName,
          }))
                      │
          [players 상태 업데이트: { X: 이름1, O: 이름2 }]
                      │
          게임 진행...
                      │
          게임 종료 시 승자 결정(winnerSymbol)
                      │
          const winnerName = players[winnerSymbol]
                      │
          <GameOver winner={winnerName} />
                      │
          [GameOver 컴포넌트: 승자 이름 표시]
  • 상태 업데이트 시 이전 상태 보존:

    • 상태 업데이트 함수에서 이전 상태(prevPlayers)를 복사하여 다른 플레이어의 이름이 유지되도록 합니다.
    • React에서 상태를 업데이트할 때 불변성을 유지하는 것이 중요합니다.
  • 컴포넌트 간 데이터 전달:

    • App 컴포넌트에서 Player 컴포넌트로 onSave 함수를 속성으로 전달합니다.
    • Player 컴포넌트에서 이름이 변경되면 onSave를 호출하여 App 컴포넌트에 알립니다.
  • 렌더링 최적화:

    • 플레이어 이름 입력 시 App 컴포넌트가 재렌더링되지 않으므로 성능 향상에 도움이 됩니다.
    • 상태 끌어올리기 대신 필요한 데이터만 상위 컴포넌트에서 관리합니다.

103. 틱택토 게임 / State(상태) 끌어올리기 대안

  • 목표: 게임에서 승자가 나왔을 때 플레이어의 기호('X', 'O') 대신 해당 플레이어의 이름을 화면에 표시하고, 이름 변경 시 승자 이름도 업데이트되도록 구현.

  • 플레이어 이름 업데이트:

    • Player 컴포넌트에서 이름 변경 후 저장 버튼을 클릭하면 onChangeName 함수를 호출하여 App 컴포넌트의 players 상태를 업데이트.
    • onChangeName는 symbol과 playerName을 인자로 받아, 해당 플레이어의 이름을 업데이트.
  • App 컴포넌트에서 승자 이름 표시:

    • 우승자 결정 로직에서 승자의 기호 대신 players 상태에서 해당 기호의 이름을 가져와 winner 변수에 저장.
    • GameOver 컴포넌트에 winner 속성으로 전달하여 승자 이름을 표시.
  • 동적 속성 접근 활용:

    • 자바스크립트의 computed property names를 사용하여 객체의 속성을 동적으로 접근 및 업데이트.
    • 이를 통해 코드를 더욱 유연하고 재사용 가능하게 유지.
  • 결과:

    • 게임에서 플레이어의 이름을 변경하고 승리하면, 변경된 이름으로 승자 메시지가 표시됨.
    • 게임의 모든 필수 기능이 완성되어 플레이 가능한 상태가 됨.
[Player 컴포넌트]
   │
   ├─── (사용자 입력: 플레이어 이름 변경)
   │         │
   │   [playerName 상태 업데이트]
   │
(저장 버튼 클릭)
   │
   └───▶ [handleEdit 함수 호출]
             │
             ├── isEditing이 true인가?
             │         │
             │        예▼
             │
             ├── [onChangeName(symbol, playerName) 호출]
             │
             └── isEditing 상태 토글 (편집 모드 종료)
             
────────────────────────────────────────

[App 컴포넌트]
   │
(온초점) ────────▶ [handlePlayerNameChange 함수 호출]
                     │
                     ├── `players` 상태 업데이트
                     │      (이전 상태 보존, 변경된 이름만 업데이트)
                     │
                     └── [players 상태 변경]
                     
────────────────────────────────────────

(게임 진행 중)
   │
[우승자 결정 로직]
   │
   ├── 우승자의 기호: firstSquareSymbol ('X' 또는 'O')
   │
   └───▶ [winner = players[firstSquareSymbol]]
              │
              └── 승자 이름을 players 상태에서 가져옴
              
────────────────────────────────────────

[GameOver 컴포넌트]
   │
(승자 이름 표시)
   │
   └── `<p>{winner} won!</p>` 렌더링

104. 틱택토 게임 / 마무리 다듬기 및 컴포넌트 개선

이 설명은 React로 개발한 게임(틱택토 등)의 App 컴포넌트를 리팩토링하여 코드의 가독성과 유지보수성을 향상시키는 과정에 대한 것입니다.

  • App 컴포넌트의 복잡한 로직을 함수로 분리:

    • 우승자를 결정하는 로직을 deriveWinner 함수로 분리하여 App 컴포넌트를 간결하게 만듭니다.
    • 게임판을 생성하는 로직을 deriveGameBoard 함수로 분리합니다.
  • 상수의 관리 및 명명 규칙 일관성 유지:

    • PLAYERS와 INITIAL_GAME_BOARD와 같은 상수를 컴포넌트 외부로 이동하고, 대문자와 언더스코어를 사용하는 UPPER_SNAKE_CASE 명명법을 적용합니다.
  • 코드의 가독성과 유지보수성 향상:

    • 주요 로직이 함수로 분리되어 App 컴포넌트가 더 읽기 쉽고 이해하기 쉬워집니다.
    • 주석과 불필요한 코드를 제거하여 코드가 더 깔끔해집니다.
  • 결과 확인:

    • 리팩토링 후에도 애플리케이션이 정상적으로 작동함을 확인합니다.
    • 리팩토링 과정을 통해 React 컴포넌트와 상태 관리에 대한 추가적인 경험과 학습을 쌓았습니다.
// App 컴포넌트 구조 개선 흐름:

[App 컴포넌트]
      │
      ├─ 상태 및 변수 선언
      │    ├─ gameTurns
      │    ├─ players
      │    └─ 기타 상태들
      │
      ├─ 파생 상태 계산
      │    ├─ gameBoard = deriveGameBoard(gameTurns)
      │    └─ winner = deriveWinner(gameBoard, players)
      │
      ├─ UI 렌더링
      │    ├─ GameBoard 컴포넌트
      │    ├─ Player 컴포넌트
      │    └─ GameOver 컴포넌트 (필요 시)
      │
      └─ 이벤트 핸들러
           ├─ handleSelectSquare
           ├─ handleRestart
           └─ handlePlayerNameChange
// 함수 분리 및 호출 흐름:

[App 컴포넌트]                         [외부 함수]
      │                                     │
      ├─ gameBoard = deriveGameBoard(gameTurns) ───▶ [deriveGameBoard 함수]
      │                                     │
      └─ winner = deriveWinner(gameBoard, players) ─▶ [deriveWinner 함수]
// 상수 관리 흐름:

[상수 선언]
  ├─ INITIAL_GAME_BOARD
  └─ PLAYERS

[App 컴포넌트]
  ├─ 초기 상태로 상수 사용
  │    ├─ gameTurns 초기화
  │    └─ players 초기화
  │
  └─ 파생 상태 계산 및 로직에서 상수 사용
// 전체 데이터 흐름 요약:

[사용자 인터랙션]
      │
      ├─ 게임 시작
      │
      ├─ 플레이어가 버튼 클릭
      │    │
      │    └─ handleSelectSquare 호출
      │          │
      │          └─ gameTurns 업데이트
      │
      ├─ App 컴포넌트 재렌더링
      │    │
      │    ├─ gameBoard 재계산 (deriveGameBoard)
      │    │
      │    └─ winner 재계산 (deriveWinner)
      │
      ├─ 우승자 결정 시
      │    │
      │    └─ GameOver 컴포넌트 표시
      │
      └─ Rematch 버튼 클릭 시
           │
           └─ handleRestart 호출
                 │
                 └─ gameTurns 초기화

105. 투자 계산기 앱 / Header(헤더) 컴포넌트 추가하기

// 프로젝트 구조 설정

src 폴더
  │
  ├── components 폴더
  │     │
  │     └── Header.jsx
  │
  └── App.jsx
// Header 컴포넌트 생성 및 내보내기

[Header.jsx]
  │
  ├── Header 컴포넌트 함수 정의
  │     │
  │     └── JSX 반환
  │            ├── <header>
  │            │     ├── <img>
  │            │     └── <h1>
  │            └── </header>
  │
  └── export default Header
// App 컴포넌트에서 Header 컴포넌트 사용

[App.jsx]
  │
  ├── Header 컴포넌트 임포트
  │     │
  │     └── import Header from './components/Header';
  │
  └── App 컴포넌트 함수 정의
        │
        └── JSX 반환
              │
              ├── <div>
              │     ├── <Header />
              │     └── {/* 기타 컴포넌트 */}
              └── </div>

106. 투자 계산기 앱 / User Input(사용자 입력) 컴포넌트로 시작하기

  • 목적: 투자 계산기 앱에서 사용자 입력을 처리하기 위한 UserInput 컴포넌트를 추가하여, 초기 투자 금액, 연간 투자액, 예상 수익률, 투자 기간과 같은 필요한 입력 값을 수집하고, 이를 통해 투자 결과 데이터를 계산하고자 합니다.
  • 구현 내용:
    • 새로운 UserInput 컴포넌트를 생성하고, section 요소를 반환하며 id="user-input"을 부여하여 CSS 스타일링과 연동합니다.
    • 입력 필드들은 div와 className="input-group"을 사용하여 구성하고, 각 입력 필드는 p 태그 안에 label과 input 요소로 구성됩니다.
    • 모든 input 요소는 type="number"로 설정하고, required 속성을 추가하여 필수 입력 값으로 지정합니다.
  • 추가 작업:
    • 사용자 입력 값을 상태로 관리하기 위한 로직을 추가하여, 입력 값 변경 시 상태를 업데이트하고 입력 필드에 값을 반영하는 양방향 바인딩을 구현해야 합니다.
    • 이를 통해 입력된 값을 기반으로 투자 결과를 계산하고, 결과를 화면에 표시할 수 있습니다.
[UserInput 컴포넌트 생성]
        │
        ▼
[<section id="user-input"> 반환]
        │
        ▼
[<div className="input-group"> 생성]
        │
        ▼
┌──────────────────────────────────────────┐
│                                          │
▼                                          ▼
[p]                                        [p]
│                                          │
<label htmlFor="initial-investment">       <label htmlFor="annual-investment">
  Initial Investment ($)                     Annual Investment ($)
</label>                                   </label>
<input                                      <input
  type="number"                               type="number"
  id="initial-investment"                     id="annual-investment"
  required                                    required
  value={initialInvestment}                   value={annualInvestment}
  onChange={...}                              onChange={...}
/>                                         />
│                                          │
└──────────────────────────────────────────┘
        │
        ▼
(동일한 방식으로 Expected Return과 Duration 입력 필드 추가)
        │
        ▼
[각 입력 값에 대한 상태 관리]
        │
        ▼
[양방향 바인딩 구현]
        │
        ▼
[입력 값 변경 시 상태 업데이트]
        │
        ▼
[상태 변경 시 입력 필드에 값 반영]
        │
        ▼
[수집된 입력 값을 사용하여 투자 결과 계산]

107. 투자 계산기 앱 / 이벤트 핸들링 & 양방향 바인딩 활용

  • 상태 관리 및 양방향 바인딩 구현:

    • UserInput 컴포넌트에서 useState 훅을 사용하여 사용자 입력 값을 관리합니다.
    • 네 가지 입력 필드를 하나의 상태 객체 (userInput)로 관리하며, 초기 값을 설정합니다.
    • handleChange 함수를 정의하여 입력 값 변경 시 상태를 업데이트합니다.
    • 상태 업데이트 시 이전 상태를 복사하고, 변경된 속성만 덮어씁니다.
    • input 요소의 value 속성을 상태 값으로 설정하고, onChange 이벤트 핸들러에서 handleChange 함수를 호출하여 양방향 바인딩을 구현합니다.
  • 이벤트 핸들러에 익명 함수 사용:

    • onChange 이벤트 핸들러에 익명 함수를 사용하여 handleChange에 필요한 인자를 전달합니다.
    • 이벤트 객체에서 event.target.value를 가져와 newValue로 사용합니다.
  • App 컴포넌트에서 UserInput 컴포넌트 사용:

    • UserInput 컴포넌트를 import하고, Header 아래에 렌더링합니다.
    • JSX에서 여러 형제 요소를 반환할 때 하나의 루트 요소로 감싸기 위해 React Fragment를 사용합니다.
  • 결과 데이터 계산 및 출력 준비:

    • 사용자 입력 값을 관리할 수 있게 되었으므로, 이제 입력된 값을 기반으로 결과 데이터를 계산하고, 이를 화면에 출력할 수 있습니다.
// UserInput 컴포넌트 내부 흐름

[UserInput 컴포넌트]
       │
       ├─ 상태 선언 및 초기화
       │    └─ useState로 userInput 상태 생성
       │         {
       │           initialInvestment: 10000,
       │           annualInvestment: 1200,
       │           expectedReturn: 6,
       │           duration: 10,
       │         }
       │
       ├─ handleChange 함수 정의
       │    └─ (inputIdentifier, newValue) 받아 상태 업데이트
       │         ├─ 이전 상태 복사 (...prevInput)
       │         └─ 변경된 속성만 덮어쓰기 ([inputIdentifier]: newValue)
       │
       ├─ 각 input 필드에 양방향 바인딩 구현
       │    ├─ value 속성에 userInput의 해당 속성 설정
       │    ├─ onChange 이벤트 핸들러에 익명 함수 사용
       │         └─ handleChange 호출
       │              ├─ 첫 번째 인자: 해당 속성의 이름 (문자열)
       │              └─ 두 번째 인자: event.target.value
       │
       └─ 사용자 입력 값 변경 시
            └─ handleChange 함수 호출로 상태 업데이트
// App 컴포넌트에서 UserInput 컴포넌트 사용

[App 컴포넌트]
       │
       ├─ `UserInput` 컴포넌트 import
       │
       ├─ JSX에서 Header와 UserInput을 렌더링
       │    └─ 하나의 루트 요소로 감싸기 (React Fragment)
       │
       └─ App 컴포넌트 렌더링 시
            └─ Header와 UserInput이 화면에 표시됨
// 전체 애플리케이션 흐름

[애플리케이션 시작]
       │
       ├─ App 컴포넌트 렌더링
       │    ├─ Header 컴포넌트 렌더링
       │    └─ UserInput 컴포넌트 렌더링
       │
       ├─ UserInput 컴포넌트에서 초기 상태 설정
       │
       ├─ 사용자 입력 값 변경
       │    └─ handleChange 함수 호출로 상태 업데이트
       │         └─ userInput 상태 변경
       │
       ├─ 상태 변경 시 입력 필드에 값 반영 (양방향 바인딩)
       │
       └─ 결과 데이터 계산 및 출력 예정
            ├─ 수집된 입력 값을 기반으로 계산
            └─ 계산 결과를 테이블 등에 출력
// 시각적 흐름도:

[사용자]
   │
   └─ 입력 필드 값 변경 (e.g., initialInvestment)
        │
        ▼
[onChange 이벤트 발생]
        │
        ▼
[익명 함수 호출]
        │
        ├─ inputIdentifier: 'initialInvestment'
        └─ newValue: event.target.value
        │
        ▼
[handleChange 함수 호출]
        │
        └─ setUserInput 상태 업데이트
              ├─ 이전 상태 복사 (...prevInput)
              └─ 변경된 속성만 업데이트 ([inputIdentifier]: newValue)
        │
        ▼
[userInput 상태 변경]
        │
        ▼
[컴포넌트 재렌더링]
        │
        └─ 입력 필드의 value 속성에 새로운 상태 값 반영

108. 투자 계산기 앱 / State(상태) 끌어올리기

  • 상태 끌어올리기 (Lifting State Up): UserInput 컴포넌트에서 관리하던 userInput 상태와 handleChange 함수를 App 컴포넌트로 이동하여 상태를 상위 컴포넌트에서 관리합니다. 이를 통해 여러 하위 컴포넌트에서 동일한 상태를 공유하고 사용할 수 있습니다.
  • Props로 상태와 함수 전달: App 컴포넌트에서 UserInput 컴포넌트에 userInput과 handleChange 함수를 props로 전달하여, UserInput 컴포넌트는 입력 필드의 값과 이벤트 처리를 상위 컴포넌트의 상태와 연동합니다.
  • Results 컴포넌트 생성 및 데이터 전달: 새로운 Results 컴포넌트를 생성하고, userInput 상태를 props로 전달합니다. Results 컴포넌트는 전달받은 입력 값을 사용하여 결과를 계산하고 화면에 출력합니다.
  • 데이터 흐름 통합 및 공유: 상태와 데이터를 상위 컴포넌트에서 관리하고 하위 컴포넌트로 전달함으로써, 애플리케이션의 데이터 흐름을 통합하고 컴포넌트 간에 데이터를 공유할 수 있습니다.
  • 함수형 프로그래밍 및 불변성 유지: 상태 업데이트 시 이전 상태를 복사하고 변경된 부분만 업데이트하여 불변성을 유지합니다. 이는 React에서 상태 관리를 할 때 중요한 패턴입니다.
// 상태 끌어올리기 및 상태 관리 흐름

[App 컴포넌트]
    │
    ├─ userInput 상태 정의
    │
    ├─ handleChange 함수 정의
    │
    ├─ UserInput 컴포넌트에 props로 전달
    │    ├─ userInput
    │    └─ onChange (handleChange 함수)
    │
    ├─ Results 컴포넌트에 props로 전달
    │    └─ input (userInput 상태)
    │
    └─ App 컴포넌트가 상태의 중앙 관리자가 됨
// UserInput 컴포넌트의 데이터 흐름

[UserInput 컴포넌트]
    │
    ├─ props 수신
    │    ├─ userInput
    │    └─ onChange
    │
    ├─ 입력 필드에 value와 onChange 설정
    │    ├─ value={userInput.initialInvestment}
    │    └─ onChange 이벤트 발생 시
    │         └─ onChange('initialInvestment', event.target.value) 호출
    │
    └─ 입력 값 변경 시 상위 컴포넌트의 상태 업데이트
// Results 컴포넌트의 데이터 흐름

[Results 컴포넌트]
    │
    ├─ props 수신
    │    └─ input (userInput 상태)
    │
    ├─ calculateInvestmentResults 함수 호출
    │    └─ input 값을 사용하여 결과 계산
    │
    ├─ 계산된 결과를 테이블로 출력
    │
    └─ 결과가 변경될 때마다 업데이트된 결과 표시
// 전체 애플리케이션 데이터 흐름

[사용자 입력]
    │
    └─ UserInput 컴포넌트에서 입력 값 변경
          │
          └─ onChange 호출로 App 컴포넌트의 userInput 상태 업데이트
                │
                └─ 상태 변경으로 App, UserInput, Results 컴포넌트 재렌더링
                      │
                      ├─ UserInput: 최신 userInput 값으로 입력 필드 업데이트
                      │
                      └─ Results: 새로운 input 값으로 결과 재계산 및 표시
// 시각적 흐름도

[사용자]
   │
   └─ 입력 필드 값 변경
        │
        ▼
[UserInput 컴포넌트]
   │
   └─ onChange 이벤트 발생
        │
        ▼
   props.onChange('initialInvestment', newValue)
        │
        ▼
[App 컴포넌트]
   │
   └─ handleChange 함수 실행
        │
        ▼
   userInput 상태 업데이트
        │
        ├─ 새로운 userInput 상태
        ▼
[컴포넌트 재렌더링]
   │
   ├─ UserInput 컴포넌트
   │    └─ props.userInput의 최신 값으로 입력 필드 업데이트
   │
   └─ Results 컴포넌트
        └─ props.input의 최신 값으로 결과 재계산 및 표시

109. 투자 계산기 앱 / 값 계산하기 및 숫자 값 올바르게 다루는 방법

  • 투자 결과 계산:

    • Results 컴포넌트에서 calculateInvestmentResults 함수를 사용하여 투자 결과를 계산합니다.
    • input 속성으로 전달된 userInput 상태를 함수에 전달합니다.
  • 결과 데이터 확인:

    • 계산된 결과는 배열 형태로, 각 요소는 연도별 투자 정보를 담고 있습니다.
    • 개발자 도구에서 console.log를 통해 결과를 확인할 수 있습니다.
  • 에러 발생 및 원인 분석:

    • 결과 값에 NaN 또는 이상한 값이 나타나는 문제가 발생합니다.
    • 원인은 input 필드의 값이 문자열로 저장되어 숫자 연산 시 문제가 발생하기 때문입니다.
    • 자바스크립트에서는 input 요소의 값이 항상 문자열입니다.
  • 버그 수정:

    • input 값들을 숫자로 변환하여 문제를 해결합니다.
    • handleChange 함수에서 newValue 앞에 +를 추가하여 문자열을 숫자로 변환합니다.
  • 수정 후 결과 확인:

    • 버그가 사라지고 올바른 결과를 얻을 수 있습니다.
  • 다음 단계:

    • 계산된 결과를 테이블 형태로 화면에 출력합니다.
// Results 컴포넌트에서 결과 계산 및 출력 준비:

[Results 컴포넌트]
     │
     ├─ input 속성 수신 (userInput 상태)
     │
     ├─ calculateInvestmentResults 함수 import
     │
     ├─ 결과 계산:
     │     resultsData = calculateInvestmentResults(input)
     │
     ├─ 결과 확인:
     │     console.log(resultsData)
     │
     ├─ 개발자 도구에서 resultsData 확인
     │
     └─ 다음 단계로 결과를 테이블에 출력
// 버그 발생 및 수정 과정:

[문제 발생]
     │
     ├─ 결과 값에 이상한 값, NaN 발생
     │
     ├─ 원인 분석:
     │     - input 필드의 값이 문자열
     │     - 문자열과 숫자의 연산으로 문제 발생
     │
     └─ 버그 수정:
           - 입력 값을 숫자로 변환
           - handleChange 함수에서 +newValue 사용
// 버그 수정 후 흐름:

[버그 수정 후]
     │
     ├─ App 컴포넌트의 handleChange 함수 수정
     │
     ├─ 입력 값이 숫자로 저장됨
     │
     ├─ calculateInvestmentResults 함수에서 올바른 계산 수행
     │
     └─ 결과 값이 정상적으로 표시됨
// 전체 데이터 흐름:
[사용자 입력]
     │
     └─ UserInput 컴포넌트에서 입력 값 변경
           │
           └─ onChange 이벤트 발생
                 │
                 └─ App 컴포넌트의 handleChange 함수 호출
                       │
                       └─ userInput 상태 업데이트 (+newValue로 숫자 변환)
                             │
                             └─ Results 컴포넌트에 새로운 input 속성 전달
                                   │
                                   └─ calculateInvestmentResults 함수로 결과 계산
                                         │
                                         └─ 결과 값 업데이트 및 화면에 표시

110. 투자 계산기 앱 / 리스트에 결과 출력하기 및 더 많은 값 파생하기

// Results 컴포넌트 데이터 흐름:

[Results 컴포넌트]
      │
      ├─ props로 input 수신 (userInput 상태)
      │
      ├─ calculateInvestmentResults 함수 호출하여 resultsData 계산
      │
      ├─ initialInvestment 계산
      │
      ├─ resultsData.map()을 사용하여 각 연도별 데이터 처리
      │    ├─ totalInterest 계산
      │    ├─ investedCapital 계산
      │    ├─ 각 값을 formatter.format()으로 포맷팅
      │
      └─ 테이블 생성 및 데이터 출력
<!-- 테이블 구조: -->
<table id="result">
  <thead>
  <tr>
    <th>Year</th>
    <th>Total Savings</th>
    <th>Interest (Year)</th>
    <th>Total Interest</th>
    <th>Invested Capital</th>
  </tr>
  </thead>
  <tbody>
  <!-- resultsData.map()을 통해 동적으로 생성된 행들 -->
  <tr key={yearData.year}>
    <td>{yearData.year}</td>
    <td>{formatter.format(yearData.valueEndOfYear)}</td>
    <td>{formatter.format(yearData.yearlyInterest)}</td>
    <td>{formatter.format(totalInterest)}</td>
    <td>{formatter.format(investedCapital)}</td>
  </tr>
  <!-- ... 반복 ... -->
  </tbody>
</table>
// 계산 로직 흐름:

[initialInvestment 계산]
  initialInvestment =
    resultsData[0].valueEndOfYear -
    resultsData[0].yearlyInterest -
    resultsData[0].yearlyContribution;

[각 연도별 totalInterest 계산]
  totalInterest =
    yearData.valueEndOfYear -
    initialInvestment -
    yearData.yearlyContribution * yearData.year;

[각 연도별 investedCapital 계산]
  investedCapital =
    initialInvestment + yearData.yearlyContribution * yearData.year;
// 전체 앱 데이터 흐름:

[사용자 입력] → [App 컴포넌트] → [Results 컴포넌트]
      │                 │                │
      └─ userInput 변경 ─┘                │
                                       │
                             [calculateInvestmentResults 함수 호출]
                                       │
                                [resultsData 생성]
                                       │
                             [Results 컴포넌트에서 테이블 출력]

111. 투자 계산기 앱 / 조건적 콘텐츠 출력

  • 문제점: 투자 계산기 앱에서 duration 입력 값이 0이거나 음수일 경우 앱이 충돌하는 문제가 발생합니다.
  • 해결 방법: App 컴포넌트에서 입력 값의 유효성을 검사하여, duration이 1 이상인 경우에만 결과를 렌더링하도록 합니다.
  • 구현 내용:
    • inputIsValid 상수를 추가하여 userInput.duration >= 1 조건을 확인합니다.
    • inputIsValid를 사용하여 결과 컴포넌트를 조건부로 렌더링하고, 유효하지 않은 경우 에러 메시지를 표시합니다.
    • 에러 메시지에 className="center"를 적용하여 스타일을 개선합니다.
  • 결과: 유효하지 않은 입력이 있을 경우 에러 메시지가 표시되고, 사용자가 입력을 수정하면 결과 테이블이 동적으로 업데이트됩니다.
  • 완성: 이러한 변경으로 앱이 완성되었으며, 주요 문제점을 해결하였습니다.
// 입력 값 변경 시 데이터 흐름

[사용자 입력 변경]
       │
       └─ UserInput 컴포넌트에서 입력 값 변경
             │
             └─ App 컴포넌트의 userInput 상태 업데이트
                   │
                   ├─ inputIsValid 재계산
                   │     └─ inputIsValid = userInput.duration >= 1
                   │
                   └─ 컴포넌트 재렌더링
                         │
                         ├─ inputIsValid가 true인 경우:
                         │     └─ Results 컴포넌트 렌더링
                         │
                         └─ inputIsValid가 false인 경우:
                               └─ 에러 메시지 표시
// 전체 앱의 데이터 흐름

[App 컴포넌트]
       │
       ├─ userInput 상태 관리
       ├─ inputIsValid 계산
       │
       ├─ UserInput 컴포넌트에 props로 userInput 전달
       ├─ Results 컴포넌트에 조건부로 input 전달
       │
       └─ 렌더링 내용 결정:
             ├─ inputIsValid가 true인 경우:
             │     └─ <Results input={userInput} />
             └─ inputIsValid가 false인 경우:
                   └─ <p className="center">Please enter a duration greater than 0.</p>

112. 리액트 앱 디버깅하기 / 리액트의 Strict Mode(엄격모드) 이해하기

Strict Mode를 활성화하려면 index.js 또는 index.jsx 파일에서 React.StrictMode로 최상위 컴포넌트를 감싸면 됩니다.

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

Strict Mode는 개발 단계에서 여러 가지 검사를 수행하여 잠재적인 문제를 찾아냅니다. 그중 하나는 컴포넌트 함수를 두 번씩 실행하는 것입니다. 이렇게 하면 부작용이 있는 코드나 잘못된 상태 관리로 인해 발생하는 문제를 쉽게 감지할 수 있습니다.

  • 문제의 원인

    • Results 컴포넌트에서 results 배열을 컴포넌트 함수 밖에 선언하면, 이 배열은 애플리케이션이 실행되는 동안 한 번만 생성됩니다. 따라서 컴포넌트가 다시 렌더링될 때마다 results 배열은 초기화되지 않고 이전 데이터에 새로운 데이터가 추가됩니다. 그 결과:
    • 테이블이 입력 값을 변경할 때마다 계속 길어집니다.
    • 동일한 키를 가진 요소들이 생겨 "키 중복" 오류 메시지가 발생합니다.
  • 문제 해결 방법

    • 이 문제를 해결하려면 results 배열을 컴포넌트 함수 내부로 이동하여, 컴포넌트가 렌더링될 때마다 배열이 새로 생성되도록 해야 합니다.
// Results.jsx
import { calculateInvestmentResults } from '../util/investment';

function Results({ input }) {
  // results 배열을 함수 내부에서 선언
  const results = calculateInvestmentResults(input);

  return (
    <table id="result">
      {/* 테이블 헤더 및 바디 */}
    </table>
  );
}

export default Results;

이렇게 하면 입력 값을 변경할 때마다 results 배열이 초기화되고, 새로운 데이터로 테이블이 갱신됩니다. Strict Mode를 활성화한 상태에서 앱을 새로 고침하면 오류가 사라지고, 콘솔에도 에러 메시지가 나타나지 않습니다.

113. 리액트 앱 디버깅하기 / 리액트 DevTools 사용하기

  • React Developer Tools 설치
  • Components 탭 활용
    1. 컴포넌트 트리 확인:
      • Components 탭에서는 애플리케이션의 컴포넌트 구조를 트리 형태로 시각화하여 보여줍니다.
      • 각 컴포넌트의 계층 관계를 한눈에 파악할 수 있습니다.
    2. 컴포넌트 선택 및 UI 매핑:
      • 컴포넌트를 클릭하거나 마우스를 올리면, 브라우저 화면에서 해당 컴포넌트가 강조 표시됩니다.
      • 이를 통해 UI의 특정 부분이 어떤 컴포넌트에 의해 렌더링되고 있는지 쉽게 확인할 수 있습니다.
    3. Props와 State 확인:
      • 우측 패널에서 선택한 컴포넌트의 Props(속성)와 State(상태)를 확인할 수 있습니다.
      • Props와 State의 현재 값과 타입을 볼 수 있으며, 이는 디버깅에 매우 유용합니다.
    4. Props와 State 수정:
      • Props는 읽기 전용이지만, State의 경우 직접 값을 수정하여 변경 사항이 UI에 어떻게 반영되는지 실시간으로 확인할 수 있습니다.
      • 예를 들어, duration 값을 10에서 12로 변경하면 UI도 즉시 업데이트됩니다.
    5. Hooks 확인:
      • 컴포넌트가 Hooks를 사용하고 있다면, 해당 Hooks의 상태와 값들을 확인할 수 있습니다.
      • 이를 통해 함수형 컴포넌트의 내부 상태를 더욱 쉽게 파악할 수 있습니다.
  • Profiler 탭 활용
    • Profiler 탭은 애플리케이션의 성능을 분석하는 데 사용됩니다.
    • 컴포넌트의 렌더링 시간, 업데이트 빈도 등을 확인하여 성능 최적화에 도움을 줍니다.
    • 이 기능은 성능 문제를 찾고 개선할 때 매우 유용하며, 추후에 자세히 살펴볼 수 있습니다.

114. refs, portals / 복습: State(상태)를 사용한 사용자 입력 관리 (양방향 바인딩)

  • 목표: Player 컴포넌트에서 사용자가 입력한 이름을 설정하여 'unknown entity'를 대체하고, 'Welcome [이름]' 형식으로 출력하고자 합니다.
  • 구현 방법:
    • useState 훅을 사용하여 입력된 이름(enteredPlayerName)과 제출 여부(submitted)를 상태로 관리합니다.
    • handleChange 함수를 통해 입력 필드의 값을 상태에 저장하고, 입력 시 submitted 상태를 false로 재설정합니다.
    • handleClick 함수를 통해 버튼 클릭 시 submitted 상태를 true로 설정합니다.
    • submitted 상태에 따라 조건부 렌더링을 통해 입력된 이름 또는 'unknown entity'를 출력합니다.
  • 문제점:
    • 입력할 때마다 'unknown entity'로 바로 변경되는 것은 사용자 경험 측면에서 바람직하지 않습니다.
    • 상태 관리와 조건부 렌더링으로 인해 코드가 복잡해집니다.
    • 버튼 클릭 시 입력된 값을 읽어들이는 간단한 작업에 비해 코드의 양이 많습니다.
  • 개선점:
    • ref를 사용하여 입력 필드의 값을 직접 참조함으로써 코드를 간소화할 수 있습니다.
    • 불필요한 상태 관리와 조건부 렌더링을 줄일 수 있습니다.
// 데이터 흐름 도식화:

[사용자 입력]
    │
    ▼
[입력 필드 onChange 이벤트 발생]
    │
    └─ handleChange 함수 호출
         ├─ setEnteredPlayerName(event.target.value)
         └─ setSubmitted(false)
    │
    ▼
[enteredPlayerName 상태 업데이트]
    │
    ▼
[input 요소의 value 속성에 enteredPlayerName 반영]
    │
    ▼
(사용자가 입력한 내용이 입력 필드에 표시됨)

────────────────────────────────────

[사용자가 버튼 클릭]
    │
    ▼
[버튼 onClick 이벤트 발생]
    │
    └─ handleClick 함수 호출
         └─ setSubmitted(true)
    │
    ▼
[submitted 상태가 true로 업데이트]

────────────────────────────────────

[화면 렌더링]
    │
    ▼
[조건부 렌더링]
    │
    ├─ submitted가 true인 경우:
    │     └─ 'Welcome [enteredPlayerName]' 출력
    │
    └─ submitted가 false인 경우:
          └─ 'Welcome unknown entity' 출력

115. refs, portals / 복습: Fragments (프래그먼트)

  • JSX의 중요한 규칙: JSX 표현식은 하나의 루트 요소만 가질 수 있습니다. 그렇지 않으면 오류가 발생합니다.
  • 문제점: 여러 요소를 반환하려고 할 때 추가적인 <div>로 감싸는 것은 DOM에 불필요한 요소를 추가하게 됩니다.
  • 리액트 프래그먼트의 활용: <Fragment> ... </Fragment> 또는 단축 문법인 <> ... </>를 사용하여 DOM에 영향을 주지 않으면서 여러 요소를 그룹화할 수 있습니다.
// 표준 문법
import { Fragment } from 'react';

return (
  <Fragment>
    <h2>Welcome!</h2>
    <p>React is awesome!</p>
  </Fragment>
);
// 단축문법
return (
  <>
    <h2>Welcome!</h2>
    <p>React is awesome!</p>
  </>
);
  • 장점:
    • 불필요한 DOM 요소를 추가하지 않습니다.
    • 코드의 가독성을 높이고 유지보수를 용이하게 합니다.
    • JSX의 규칙을 준수하면서도 깨끗한 렌더링 결과를 얻을 수 있습니다.
[JSX에서 여러 요소를 반환하려 함]
           │
           ▼
[문제 발생]
- 오류 메시지: "Adjacent JSX elements must be wrapped in an enclosing tag"
- 이유: 하나의 루트 요소만 허용됨
           │
           ▼
[해결책 1: <div>로 감싸기]
           │
           ▼
[단점]
- 불필요한 <div>가 DOM에 추가됨
- DOM 구조가 복잡해짐
           │
           ▼
[해결책 2: 리액트 프래그먼트 사용]
           │
           ├─ 방법 1: <Fragment> ... </Fragment>
           │         - 'react'로부터 Fragment를 import
           │
           ├─ 방법 2: <> ... </>
           │         - 단축 문법, import 불필요
           │
           ▼
[결과]
- 여러 요소를 그룹화하여 하나의 루트 요소로 만듦
- DOM에 불필요한 요소를 추가하지 않음
- 깨끗한 렌더링 결과 획득
JSX에서 여러 형제 요소 반환 ──▶ 오류 발생
                                 │
                                 ▼
                        "하나의 루트 요소만 허용됩니다"
                                 │
                                 ▼
                 ┌───────────────┴───────────────┐
                 │                               │
                 ▼                               ▼
        <div>로 감싸기                      리액트 프래그먼트 사용
           │                                       │
           ▼                                       ▼
  불필요한 <div> 추가                         DOM에 영향 없음
           │                                       │
           ▼                                       ▼
    DOM 구조 복잡화                            깨끗한 DOM 구조

116. refs, portals / Refs(참조) 소개: Refs(참조)로 HTML 요소 연결 및 접근

  • Refs란 무엇인가?

    • React의 refs는 DOM 요소에 직접 접근할 수 있는 방법을 제공합니다.
    • useRef 훅을 사용하여 참조를 생성하고, JSX 요소의 ref 속성을 통해 연결합니다.
    • 참조된 요소는 current 속성을 통해 접근할 수 있으며, 해당 요소의 모든 속성과 메서드에 접근 가능합니다.
  • 컴포넌트 간소화 방법

    • 입력 필드의 값을 매번 상태로 관리하고 onChange 이벤트를 처리하는 대신, 필요한 시점에만 참조를 통해 값을 가져올 수 있습니다.
    • 이로써 불필요한 상태 변수(submitted 등)와 함수(handleChange)를 제거하여 컴포넌트를 간결하게 만들 수 있습니다.
    • 버튼 클릭 시에만 상태를 업데이트하고, 그 외에는 입력 필드의 값 변화에 컴포넌트가 반응하지 않습니다.
  • 코드 간소화의 이점

    • 불필요한 리렌더링을 방지하여 성능을 향상시킵니다.
    • 코드의 양을 줄이고 가독성을 높입니다.
    • 상태 관리의 복잡성을 줄여 유지보수를 용이하게 합니다.
// 데이터 흐름 도식화

[사용자 입력]
      │
      ▼
┌──────────────────────┐
│ <input type="text"   │
│       ref={playerNameRef} /> │
└──────────────────────┘
      │
[사용자가 입력 필드에 텍스트 입력]
      │
      ▼
(입력 필드에만 값이 표시됨, 컴포넌트 상태 변화 없음)

────────────────────────────────────

[사용자가 'Set Name' 버튼 클릭]
      │
      ▼
┌───────────────────────────┐
│    handleClick 함수 호출   │
└───────────────────────────┘
      │
      ▼
[참조를 통해 입력 값 가져오기]
      │
      ▼
┌───────────────────────────┐
│ playerNameRef.current.value │
└───────────────────────────┘
      │
      ▼
[enteredPlayerName 상태 업데이트]
      │
      ▼
[컴포넌트 리렌더링]

────────────────────────────────────

[조건부 렌더링]
      │
      ▼
┌───────────────────────────┐
│ <h2>Welcome               │
│   {enteredPlayerName || 'unknown entity'} │
│ </h2>                      │
└───────────────────────────┘
      │
      ▼
[화면에 결과 출력]
// 시각적 흐름도:

1. 입력 단계
[사용자]
   │
   ▼
입력 필드에 텍스트 입력
   │
   ▼
<input type="text" ref={playerNameRef} />

2. 버튼 클릭 및 처리
[사용자]
   │
   ▼
'Set Name' 버튼 클릭
   │
   ▼
handleClick 함수 실행
   │
   ▼
playerNameRef.current.value 로 입력 값 획득
   │
   ▼
setEnteredPlayerName 로 상태 업데이트

3. 렌더링 및 출력
[React]
   │
   ▼
상태 변경 감지, 컴포넌트 리렌더링
   │
   ▼
조건부 렌더링으로 출력 결정
   │
   ├─ enteredPlayerName 이 빈 문자열이 아닌 경우:
   │       'Welcome [이름]' 출력
   │
   └─ enteredPlayerName 이 빈 문자열인 경우:
           'Welcome unknown entity' 출력

4. 최종 출력
[화면]
   │
   ▼
사용자에게 결과 표시

이와 같이 useRef를 활용하여 컴포넌트를 간소화하고, 필요한 시점에만 입력 값을 참조하여 상태를 업데이트함으로써 더욱 효율적인 React 컴포넌트를 만들 수 있습니다. 불필요한 상태 관리와 이벤트 핸들러를 제거하여 코드가 간결해지고, 유지보수가 쉬워집니다.

117. refs, portals / Refs(참조)로 DOM 제어

리액트에서 refs(참조)를 사용하여 입력 필드를 초기화하려는 아이디어는 이해할 수 있습니다. handleClick 함수 내에서 playerName.current.value = ''로 입력 값을 지우면 원하는 동작을 얻을 수 있습니다. 그러나 이 접근 방식은 리액트의 선언적 프로그래밍 철학과는 맞지 않습니다.

  • 선언적 프로그래밍 vs. 명령적 프로그래밍
    • 선언적 프로그래밍: 리액트는 UI의 상태를 선언적으로 기술합니다. 즉, 상태(state)가 변경되면 리액트가 자동으로 UI를 업데이트합니다.
    • 명령적 프로그래밍: 개발자가 직접 DOM 요소를 조작하여 UI를 변경합니다.

playerName.current.value = ''와 같이 직접 DOM 요소의 값을 변경하는 것은 명령적 프로그래밍에 해당하며, 리액트의 선언적 접근 방식에서 벗어납니다. 이는 다음과 같은 문제를 일으킬 수 있습니다:

  • UI와 상태의 불일치: 리액트의 상태 관리와 별개로 DOM을 직접 변경하면, 상태와 UI 간의 일관성이 깨질 수 있습니다.

  • 예상치 못한 동작: 리액트의 라이프사이클과 최적화 메커니즘을 우회하게 되어 버그가 발생할 가능성이 높아집니다.

  • 올바른 접근 방식: 상태를 활용한 입력 값 관리

    • 입력 필드의 값을 지우기 위해서는 상태를 활용하는 것이 리액트의 권장 방식입니다. 다음과 같이 useState를 사용하여 입력 값을 관리할 수 있습니다.
// 1. 상태 변수 추가
const [enteredPlayerName, setEnteredPlayerName] = useState('');

// 2. 입력 필드에 상태 연결
<input
  type="text"
  value={enteredPlayerName}
  onChange={(event) => setEnteredPlayerName(event.target.value)}
/>

// 3. 버튼 클릭 시 상태 초기화
function handleClick() {
  // 필요한 로직 수행
  setEnteredPlayerName(''); // 상태를 빈 문자열로 설정하여 입력 필드 초기화
}

// 4. 이름 출력 시 상태 사용
<h2>Welcome {enteredPlayerName || 'unknown entity'}</h2>
  • 이렇게 변경하면 다음과 같은 장점이 있습니다:

    • 일관성 유지: 상태 변화에 따라 UI가 업데이트되므로 상태와 UI 간의 일관성이 보장됩니다.
    • 리액트 철학 준수: 선언적 프로그래밍 방식에 맞게 컴포넌트를 구성하여 유지보수성과 확장성이 향상됩니다.
    • 예상 가능한 동작: 리액트의 라이프사이클과 최적화 메커니즘을 활용하므로 예기치 않은 버그를 방지할 수 있습니다.
  • 요약

    • Refs 사용 시 주의점: Refs는 DOM 요소에 직접 접근해야 할 때 유용하지만, 가능한 한 사용을 최소화하는 것이 좋습니다.
    • 상태 관리 활용: 입력 필드의 값과 같은 UI 요소는 상태를 통해 관리하여 리액트의 선언적 프로그래밍 방식을 따르는 것이 바람직합니다.
    • 최신 리액트 패턴 적용: 상태와 이벤트 핸들러를 활용하여 컴포넌트를 구성하면 코드의 가독성과 유지보수성이 향상됩니다.

118. refs, portals / Refs(참조) VS State(상태) 값

  1. 상태(state)란 무엇인가?

    • 정의: 상태는 컴포넌트의 데이터 변경을 추적하고, 변경 시 컴포넌트를 다시 렌더링하여 UI를 업데이트하는 값입니다.
    • 특징:
      • 상태가 변경되면 컴포넌트가 재실행(재렌더링)됩니다.
      • UI에 직접적으로 반영되어야 하는 값들을 관리합니다.
  2. 참조(refs)란 무엇인가?

    • 정의: 참조는 React에서 DOM 요소나 컴포넌트의 특정 인스턴스에 직접 접근할 수 있도록 하는 객체입니다.
    • 특징:
      • 참조가 변경되어도 컴포넌트는 재렌더링되지 않습니다.
      • DOM 요소에 직접 접근하거나, 컴포넌트 간의 데이터 흐름과 관계없는 값을 저장할 때 사용합니다.
  3. 상태를 참조로 대체할 수 없는 이유

    • 초기 렌더링 시 참조의 값은 undefined:
      • 컴포넌트가 처음 렌더링될 때 ref.current는 아직 연결되지 않은 상태입니다.
      • 따라서 ref.current.value에 접근하면 undefined 에러가 발생합니다.
    • 참조 변경 시 컴포넌트가 재렌더링되지 않음:
      • 참조의 값이 바뀌어도 컴포넌트는 재실행되지 않기 때문에 UI가 업데이트되지 않습니다.
    • 상태는 UI 업데이트를 위해 필요:
      • 상태를 통해 값이 변경될 때마다 컴포넌트를 재실행하여 최신 UI를 반영합니다.
  4. 상태와 참조의 목적과 사용 시기

    • 상태(state):
      • 목적: UI에 직접적으로 영향을 미치는 값들을 관리하고, 변경 시 UI를 업데이트합니다.
      • 사용 시기: 사용자 입력, 네트워크 응답 등으로 인해 변경되는 값들이 있을 때 사용합니다.
    • 참조(refs):
      • 목적: 컴포넌트의 렌더링 사이클과 관계없이 값을 저장하거나, DOM 요소에 직접 접근해야 할 때 사용합니다.
      • 사용 시기: 포커스 제어, 텍스트 선택, 미디어 재생 제어 등 DOM 조작이 필요할 때 사용합니다.
  5. 참조와 상태를 함께 사용하는 예시

    • 상황: 입력 필드의 값을 읽어와서 상태로 관리하여 UI에 반영해야 할 때.

    • 구현 방법:

      const [enteredPlayerName, setEnteredPlayerName] = useState('');
      const playerNameRef = useRef();
      
      function handleClick() {
        const enteredName = playerNameRef.current.value;
        setEnteredPlayerName(enteredName);
      }
    • 설명:

      • 참조를 사용하여 입력 필드의 값을 가져옵니다.
      • 상태를 업데이트하여 컴포넌트가 재실행되고, UI에 변경 사항이 반영됩니다.
  • 핵심 내용 정리
    • 참조(refs)와 상태(state)의 차이점 이해하기:
      • 상태(state):
        • 컴포넌트의 데이터 변경을 추적하며, 변경 시 컴포넌트를 재렌더링하여 UI를 업데이트합니다.
        • 상태 업데이트 함수(setState)를 호출하면 컴포넌트 함수가 다시 실행됩니다.
        • UI에 직접적인 영향을 미치는 값들을 관리합니다.
      • 참조(refs):
        • 컴포넌트의 렌더링 사이클과 관계없이 값을 저장하거나 DOM 요소에 직접 접근할 수 있습니다.
        • 참조의 값이 변경되어도 컴포넌트는 재렌더링되지 않습니다.
        • 내부에서 보이지 않는 값들이나 UI에 직접적인 영향을 주지 않는 값들을 관리합니다.
      • 상태를 참조로 대체할 수 없는 이유:
        • 참조는 컴포넌트의 재렌더링을 트리거하지 않으므로, UI 업데이트가 필요할 때는 상태를 사용해야 합니다.
        • 초기 렌더링 시 참조의 current 속성은 정의되지 않을 수 있으며, 이로 인해 에러가 발생할 수 있습니다.
      • 올바른 사용 방법:
        • UI 업데이트가 필요한 값: 상태(state)를 사용하여 관리합니다.
        • DOM 요소에 직접 접근이 필요한 경우: 참조(refs)를 사용합니다.
        • 둘을 함께 사용하여 장점 극대화: 참조를 통해 값을 가져오고, 상태를 통해 UI를 업데이트합니다.
      • 예시 설명:
        • 입력 필드에서 참조를 사용하여 값을 가져오지만, 가져온 값을 상태에 저장하여 컴포넌트를 재실행하고 UI에 반영합니다.
        • 이렇게 하면 참조와 상태의 장점을 모두 활용할 수 있습니다.
// 데이터 흐름 도식화

[컴포넌트 초기 렌더링]
       │
       ├─ 상태 초기화:
       │     enteredPlayerName = ''
       ├─ 참조 생성:
       │     playerNameRef = { current: undefined }
       │
       └─ JSX 렌더링:
             <input type="text" ref={playerNameRef} />
             <h2>Welcome {enteredPlayerName || 'unknown entity'}</h2>

────────────────────────────────────────

[사용자 입력]
       │
       └─ 입력 필드에 텍스트 입력 (예: "Alice")
             (참조된 input 요소에 값이 입력됨)

────────────────────────────────────────

[사용자가 '이름 설정하기' 버튼 클릭]
       │
       └─ handleClick 함수 호출
             │
             ├─ playerNameRef.current.value로 입력 값 가져오기
             │     enteredName = "Alice"
             │
             ├─ setEnteredPlayerName(enteredName) 호출
             │     상태 업데이트, 컴포넌트 재렌더링 트리거
             │
             └─ playerNameRef.current.value = '' 로 입력 필드 초기화

────────────────────────────────────────

[컴포넌트 재렌더링]
       │
       ├─ 상태 값:
       │     enteredPlayerName = "Alice"
       ├─ 참조 값:
       │     playerNameRef.current = [input 요소]
       │
       └─ JSX 렌더링:
             <input type="text" ref={playerNameRef} value="" />
             <h2>Welcome Alice</h2>

────────────────────────────────────────

[화면 출력]
       │
       ├─ 입력 필드는 비어 있음
       └─ 화면에 "Welcome Alice" 출력
// 1. 초기 렌더링 단계
[초기 상태]
│
├─ enteredPlayerName = ''
├─ playerNameRef = { current: undefined }
│
└─ <input ref={playerNameRef} />

// 2. 사용자 입력 단계
[사용자 입력]
│
└─ 입력 필드에 "Alice" 입력
    (playerNameRef.current.value = "Alice")

// 3. 버튼 클릭 및 처리 단계
[버튼 클릭]
│
└─ handleClick 함수 실행
    │
    ├─ enteredName = playerNameRef.current.value
    │     (enteredName = "Alice")
    ├─ setEnteredPlayerName(enteredName)
    │     (상태 업데이트, 컴포넌트 재렌더링)
    └─ playerNameRef.current.value = ''
        (입력 필드 초기화)

// 4. 컴포넌트 재렌더링 단계
[재렌더링]
│
├─ enteredPlayerName = "Alice"
└─ 화면 업데이트
    │
    ├─ 입력 필드는 빈 문자열
    └─ <h2>Welcome Alice</h2> 출력

// 5. 최종 결과
[화면 표시]
│
├─ 입력 필드: 빈 상태
└─ 화면에 "Welcome Alice" 출력
  • 설명:
    • 초기 렌더링 시에는 참조의 current 속성이 아직 설정되지 않았기 때문에 playerNameRef.current는 undefined입니다.
    • 사용자가 입력 필드에 텍스트를 입력하면, 참조된 input 요소의 value 속성에 값이 저장됩니다.
    • '이름 설정하기' 버튼을 클릭하면:
      • handleClick 함수가 호출되어 참조를 통해 입력 값을 가져옵니다.
      • 상태 업데이트 함수 setEnteredPlayerName을 호출하여 상태를 변경합니다.
      • 상태가 변경되었으므로 컴포넌트가 재실행되고 UI가 업데이트됩니다.
      • 입력 필드를 초기화하기 위해 playerNameRef.current.value = ''를 사용하지만, 이는 명령적 프로그래밍 방식이며, 가능한 한 상태 관리를 통해 처리하는 것이 좋습니다.
    • 컴포넌트가 재렌더링되면:
      • 업데이트된 상태 값에 따라 UI가 반영됩니다.
      • 입력 필드는 빈 문자열로 표시되고, 화면에는 새로운 이름이 출력됩니다.

119. refs, portals / 데모 프로젝트에 도전 과제 추가하기

  • 목표: React 앱을 발전시켜 타이머 도전 기능을 추가하고, 이를 위해 TimerChallenge 컴포넌트를 생성함
  • TimerChallenge 컴포넌트:
    • 재사용 가능한 컴포넌트로, 다양한 설정으로 사용 가능
    • title과 targetTime이라는 props를 받아 도전 제목과 목표 시간을 표시
    • 조건부 렌더링을 통해 복수형 처리 및 상태에 따른 UI 업데이트
    • 버튼을 통해 타이머 시작 및 멈춤 기능 제공 예정
  • App 컴포넌트에서의 활용:
    • 여러 개의 TimerChallenge 인스턴스를 생성하여 다양한 도전 제공
    • 도전 난이도에 따라 targetTime을 다르게 설정하여 사용자에게 다양한 난이도의 타이머 도전 제공
  • 향후 계획:
    • 상태(state)와 참조(ref)를 사용하여 타이머의 실제 동작 구현
    • 버튼 클릭 시 타이머가 시작되고, 목표 시간에 따라 동작하도록 기능 추가
    • UI가 타이머의 상태 변화에 따라 동적으로 업데이트되도록 구현
// 시각적 데이터 흐름

App 컴포넌트
│
└── <div id="challenges">
     │
     ├── <TimerChallenge title="Easy" targetTime={1} />
     │
     ├── <TimerChallenge title="Not Easy" targetTime={5} />
     │
     ├── <TimerChallenge title="Getting tough" targetTime={10} />
     │
     └── <TimerChallenge title="Pros only" targetTime={15} />

각 TimerChallenge 컴포넌트
│
├── Props:
│     - title
│     - targetTime
│
├── State:
│     - isActive
│
├── 렌더링되는 UI:
│     - <h2>{title}</h2>
│     - <p className="challenge-time">{targetTime} second(s)</p>
│     - <p>
│          <button onClick={...}>
│              {isActive ? "도전 끝내기" : "도전 시작"}
│          </button>
│       </p>
│     - <p className={isActive ? "active" : ""}>
│          {isActive ? "타이머가 실행 중입니다..." : "타이머가 비활성화됐습니다"}
│       </p>
│
└── 이벤트 흐름:
      - 버튼 클릭 시:
          - isActive 상태 토글
          - UI 업데이트
          - 타이머 로직 실행 (추후 구현)

120. refs, portals / 타이머 설정 & State(상태) 관리

  • 문제점: handleStop 함수에서 handleStart 함수 내에서 설정한 타이머에 접근해야 합니다.
  • 해결 방법: useRef 훅을 사용하여 타이머 ID를 저장하고, 이를 통해 여러 함수에서 타이머에 접근합니다.
  • 구현 내용:
    • timerRef 참조를 생성하여 타이머 ID를 저장합니다.
    • handleStart 함수에서 타이머를 설정하고, 타이머 ID를 timerRef.current에 저장합니다.
    • handleStop 함수에서 clearTimeout(timerRef.current)를 호출하여 타이머를 취소합니다.
    • 타이머의 시작 및 종료에 따라 timerStarted와 timerExpired 상태를 업데이트하여 UI를 동적으로 변경합니다.
  • 중요 포인트:
    • useRef는 컴포넌트가 재렌더링되어도 값이 유지되며, 참조 값이 변경되어도 재렌더링을 트리거하지 않습니다.
    • 상태와 참조를 적절히 활용하여 타이머 기능을 구현하고, 컴포넌트의 복잡성을 관리합니다.
// 데이터 흐름 도식화

[사용자가 '도전 시작' 버튼 클릭]
        │
        ▼
[handleStart 함수 호출]
        │
        ├─ setTimerStarted(true)
        ├─ setTimerExpired(false)
        ├─ timerRef.current = setTimeout(...)
        │     └─ 타이머 설정 (targetTime * 1000 밀리초 후 실행)
        │
        ▼
[타이머 실행 중]
        │
        ├─ UI 업데이트
        │     - 버튼 텍스트: '중지'
        │     - 메시지: '타이머가 실행 중입니다...'
        │     - 클래스 이름: 'active'
        │
        │
[사용자가 '중지' 버튼 클릭]
        │
        ▼
[handleStop 함수 호출]
        │
        ├─ clearTimeout(timerRef.current)
        └─ setTimerStarted(false)
            └─ UI 업데이트
                - 버튼 텍스트: '도전 시작'
                - 메시지: '타이머가 비활성화됐습니다'

[타이머가 만료된 경우]
        │
        ▼
[타이머 콜백 함수 실행]
        │
        ├─ setTimerExpired(true)
        └─ setTimerStarted(false)
            └─ UI 업데이트
                - '졌습니다' 메시지 표시
                - 버튼 텍스트: '도전 시작'
                - 메시지: '타이머가 비활성화됐습니다'

121. refs, portals / "DOM 요소 연결" 외 Refs(참조) 활용법

  1. 문제 상황 이해하기

    • 타이머 관리 필요성: 컴포넌트 내에서 setTimeout을 사용하여 타이머를 설정하고, 이를 중지하거나 취소해야 하는 상황이 있습니다.
    • 변수의 한계:
      • 컴포넌트 함수 내부에 변수를 선언하면 컴포넌트가 재렌더링될 때마다 변수가 재생성됩니다.
      • 컴포넌트 함수 외부에 변수를 선언하면 모든 컴포넌트 인스턴스 간에 변수가 공유되어 예상치 못한 동작이 발생합니다.
  2. 참조(useRef)의 사용 이유

    • 값 유지: 참조는 컴포넌트 재렌더링 간에도 값을 유지합니다.
    • 인스턴스별 독립성: 각 컴포넌트 인스턴스마다 고유한 참조 객체가 생성되므로 인스턴스 간 값이 공유되지 않습니다.
    • 재렌더링 트리거하지 않음: 참조의 current 값을 변경해도 컴포넌트가 재렌더링되지 않습니다.
  3. 참조 생성 및 사용 방법

    • 참조 생성:

      import { useRef } from 'react';
      
      function TimerChallenge() {
        const timerRef = useRef();
        // ...
      }
    • 타이머 설정 시 참조에 저장:

      function handleStart() {
        timerRef.current = setTimeout(() => {
          // 타이머 만료 시 실행할 코드
        }, targetTime * 1000);
      }
    • 타이머 취소 시 참조 사용:

      function handleStop() {
        clearTimeout(timerRef.current);
      }
  4. 상태와 참조의 차이점

    • 상태(State):
      • UI에 직접적인 영향을 주는 값 관리.
      • 상태 값 변경 시 컴포넌트가 재렌더링됨.
    • 참조(Refs):
      • UI에 직접적으로 영향을 주지 않는 값 관리.
      • 참조 값 변경 시 컴포넌트가 재렌더링되지 않음.
      • 재렌더링 간에도 값이 유지됨.
  5. 타이머 시작 및 중지 로직 구현

    • 상태 변수 정의:

      const [timerStarted, setTimerStarted] = useState(false);
      const [timerExpired, setTimerExpired] = useState(false);
    • 타이머 시작 함수:

      function handleStart() {
        setTimerStarted(true);
        setTimerExpired(false);
        timerRef.current = setTimeout(() => {
          setTimerExpired(true);
          setTimerStarted(false);
        }, targetTime * 1000);
      }
    • 타이머 중지 함수:

      function handleStop() {
        clearTimeout(timerRef.current);
        setTimerStarted(false);
      }
  6. 버튼 클릭 시 동작 제어

    • 조건부로 함수 연결:

      <button onClick={timerStarted ? handleStop : handleStart}>
        {timerStarted ? '중지' : '도전 시작'}
      </button>
  7. 여러 컴포넌트 인스턴스에서의 동작

    • 각 컴포넌트 인스턴스는 고유한 timerRef를 가지므로 타이머 간의 간섭이 발생하지 않습니다.
    • 컴포넌트 함수 내부에서 참조를 생성하므로 인스턴스별로 독립적인 타이머 관리가 가능합니다.
  8. 참조 사용 시 주의사항

    • 참조는 재렌더링 시 초기화되지 않음: 컴포넌트 함수가 재실행되어도 참조의 값은 유지됩니다.
    • 참조 변경은 재렌더링을 트리거하지 않음: 참조의 값을 변경해도 컴포넌트는 재렌더링되지 않으므로, UI 업데이트가 필요할 경우 상태를 사용해야 합니다.
  • 핵심 내용 정리
    • 문제점: 변수로 타이머 ID를 관리하면 컴포넌트 재렌더링 시 변수 값이 초기화되거나, 컴포넌트 인스턴스 간에 변수가 공유되어 예상치 못한 동작이 발생합니다.
    • 해결책: useRef 훅을 사용하여 타이머 ID를 참조에 저장합니다.
      • 참조는 컴포넌트 재렌더링 간에도 값이 유지되고, 인스턴스별로 독립적인 값을 가집니다.
    • 참조의 이점:
      • 컴포넌트 인스턴스 간 독립성: 각 컴포넌트 인스턴스는 고유한 참조를 가지므로, 다른 인스턴스와 간섭 없이 값을 관리할 수 있습니다.
      • 재렌더링 시 값 유지: 컴포넌트가 재실행되어도 참조의 값은 유지되므로 타이머 ID 등을 안전하게 보관할 수 있습니다.
    • 상태 vs 참조:
      • **상태(State)**는 값 변경 시 컴포넌트를 재렌더링하며, UI에 직접적인 영향을 주는 값을 관리합니다.
      • **참조(Refs)**는 값 변경 시 컴포넌트를 재렌더링하지 않으며, UI에 직접 영향을 주지 않는 값을 관리합니다.
    • 적용 사례:
      • 타이머 ID 관리: 타이머 ID는 UI에 직접적인 영향을 주지 않으면서 재렌더링 간에도 유지되어야 하므로 참조로 관리합니다.
      • DOM 요소 접근 외에도 참조 활용: 참조는 DOM 요소뿐만 아니라, 컴포넌트 내부에서 유지되어야 하는 값들을 관리하는 데에도 유용합니다.
// 데이터 흐름 도식화

[TimerChallenge 컴포넌트 인스턴스 생성]
        │
        ├─ 상태 변수 초기화:
        │     - timerStarted = false
        │     - timerExpired = false
        │
        └─ 참조 생성:
              - timerRef = useRef()

────────────────────────────────────

[사용자가 '도전 시작' 버튼 클릭]
        │
        ▼
[handleStart 함수 실행]
        │
        ├─ setTimerStarted(true)
        ├─ setTimerExpired(false)
        ├─ timerRef.current = setTimeout(...)
        │     └─ 타이머 설정 (targetTime * 1000 밀리초 후 실행)
        │
        └─ 컴포넌트 재렌더링 (timerStarted 상태 변경으로 인해)

────────────────────────────────────

[타이머 실행 중]
        │
        ├─ 사용자가 '중지' 버튼 클릭 가능
        │
        └─ 타이머 만료 시까지 대기

────────────────────────────────────

[사용자가 '중지' 버튼 클릭]
        │
        ▼
[handleStop 함수 실행]
        │
        ├─ clearTimeout(timerRef.current)
        ├─ setTimerStarted(false)
        │
        └─ 컴포넌트 재렌더링 (timerStarted 상태 변경으로 인해)

[타이머 만료 전에 중지됨]
        │
        └─ '졌습니다' 메시지 표시되지 않음

────────────────────────────────────

[타이머가 만료된 경우]
        │
        ▼
[setTimeout 콜백 함수 실행]
        │
        ├─ setTimerExpired(true)
        ├─ setTimerStarted(false)
        │
        └─ 컴포넌트 재렌더링 (상태 변경으로 인해)
            │
            └─ '졌습니다' 메시지 표시

────────────────────────────────────

[여러 컴포넌트 인스턴스 동작]
        │
        ├─ 각 인스턴스는 고유한 timerRef를 가짐
        │
        ├─ 인스턴스 A에서 타이머 시작
        │
        ├─ 인스턴스 B에서 타이머 시작
        │
        ├─ 인스턴스 A에서 타이머 중지
        │
        └─ 각 인스턴스의 타이머는 독립적으로 관리됨

122. refs, portals / 모달 컴포넌트 추가하기

  • ResultModal 컴포넌트를 생성하여 게임 결과를 표시하는 모달 다이얼로그를 구현했습니다.
    • <dialog> HTML 요소를 사용하여 내장 스타일과 기능(백드롭 등)을 활용했습니다.
    • 문제점: <dialog> 요소에 open 속성을 직접 추가하면 백드롭이 적용되지 않습니다.
    • 해결 방법:
      • 참조(ref)를 사용하여 다이얼로그를 제어합니다.
      • ResultModal에서 forwardRef를 사용하여 부모 컴포넌트(TimerChallenge)로부터 전달된 참조를 <dialog> 요소에 연결합니다.
      • TimerChallenge에서 useRef로 참조를 생성하고, useEffect를 통해 상태 변경 시 showModal() 메서드를 호출하여 다이얼로그를 엽니다.
    • 이점:
      • showModal() 메서드를 사용하여 다이얼로그를 열면 백드롭이 적용되어 더 나은 사용자 경험을 제공합니다.
      • 상태와 참조를 적절히 활용하여 컴포넌트 간에 DOM 요소를 제어할 수 있습니다.
    • 향후 개선 사항:
      • 게임 결과에 따라 점수를 계산하고 표시할 수 있습니다.
      • 승리와 패배에 따른 다양한 메시지를 표시할 수 있습니다.
// ResultModal.jsx
import React, { forwardRef } from 'react';

const ResultModal = forwardRef(({ result, targetTime }, ref) => (
  <dialog className="ResultModal" ref={ref}>
    <h2>{result === 'lost' ? '졌습니다' : '이겼습니다'}</h2>
    <p>
      목표 시간은 <strong>{targetTime}</strong>초였습니다.
    </p>
    <form method="dialog">
      <button>닫기</button>
    </form>
  </dialog>
));

export default ResultModal;
[TimerChallenge 컴포넌트]
      │
      ├─ 상태 관리:
      │     - timerExpired (타이머 만료 여부)
      │     - 기타 게임 상태
      │
      ├─ 참조 생성:
      │     - dialogRef = useRef()
      │
      ├─ useEffect 훅 설정:
      │     - timerExpired가 true로 변경될 때 실행
      │     - dialogRef.current.showModal() 호출
      │
      ├─ JSX 렌더링:
      │     - <section className="challenge">...</section>
      │     - {timerExpired && <ResultModal ref={dialogRef} ... />}
      │
      └─ 사용자 입력 처리:
            - 타이머 시작/중지 버튼 클릭 등

[상태 변경: timerExpired === true]
      │
      └─ useEffect 실행
            │
            └─ dialogRef.current.showModal() 호출
                  │
                  └─ [ResultModal의 <dialog> 요소 열림]
                        - 백드롭 적용
                        - 다이얼로그 표시

[사용자]
      │
      └─ 다이얼로그에서 '닫기' 버튼 클릭
            │
            └─ <form method="dialog">를 통해 다이얼로그 닫힘

[다이얼로그 닫힘 후]
      │
      └─ 게임 재시작 또는 다른 동작 가능

123. refs, portals / 커스텀 컴포넌트로 Refs(참조) 전달

  1. 부모 컴포넌트에서 ref 생성

    // TimerChallenge.jsx
    import React, { useRef } from 'react';
    
    function TimerChallenge(props) {
      const dialogRef = useRef();
    
      // ...
    }
  2. ref를 자식 컴포넌트에 전달

    // TimerChallenge.jsx
    return (
      <>
        {/* 기타 컴포넌트 */}
        <ResultModal ref={dialogRef} result="lost" targetTime={targetTime} />
      </>
    );
  3. 자식 컴포넌트에서 forwardRef 사용하여 ref 받기

    // ResultModal.jsx
    import React, { forwardRef } from 'react';
    
    const ResultModal = forwardRef((props, ref) => {
      const { result, targetTime } = props;
    
      return (
        <dialog className="ResultModal" ref={ref}>
          {/* 모달 내용 */}
        </dialog>
      );
    });
    
    export default ResultModal;
  4. forwardRef로 컴포넌트 감싸기 및 ref 매개변수 받기

    • 컴포넌트 함수를 forwardRef로 감싸고, 두 번째 매개변수로 ref를 받습니다.
  5. ref를 자식 컴포넌트의 DOM 요소에 연결

    • 전달받은 ref를 <dialog> 요소의 ref 속성에 연결합니다.
  6. 부모 컴포넌트에서 ref를 통해 자식의 DOM 요소 제어

    // TimerChallenge.jsx
    import React, { useEffect } from 'react';
    
    useEffect(() => {
      if (timerExpired) {
        dialogRef.current.showModal();
      }
    }, [timerExpired]);
    • dialogRef.current를 사용하여 <dialog> 요소에 접근하고 showModal() 메서드를 호출합니다.
  7. ResultModal 컴포넌트를 항상 렌더링

    • 조건부 렌더링 대신 항상 컴포넌트를 렌더링하여 ref 연결이 유지되도록 합니다.
  • 핵심 내용 정리
    • 문제점: 부모 컴포넌트에서 자식 컴포넌트의 특정 DOM 요소(dialog)에 접근하여 제어하려고 할 때, 일반적인 방법으로는 ref를 전달할 수 없습니다.
    • 해결책: React의 forwardRef 함수를 사용하여 부모 컴포넌트에서 생성한 ref를 자식 컴포넌트로 전달하고, 자식 컴포넌트에서 해당 ref를 DOM 요소에 연결합니다.
    • 구현 방법:
      1. 부모 컴포넌트에서 useRef로 ref 생성.
      2. 자식 컴포넌트를 forwardRef로 감싸고, ref를 두 번째 매개변수로 받음.
      3. 자식 컴포넌트의 DOM 요소에 ref 연결.
      4. 부모 컴포넌트에서 ref.current를 통해 자식의 DOM 요소 제어.
    • 결과: 부모 컴포넌트에서 자식 컴포넌트의 DOM 요소에 접근하여 메서드를 호출할 수 있으며, 이를 통해 모달 창을 표시하거나 숨길 수 있습니다.
[TimerChallenge 컴포넌트]
      │
      ├─ 참조 생성:
      │   └─ const dialogRef = useRef();
      │
      ├─ 자식 컴포넌트에 ref 전달:
      │   └─ <ResultModal ref={dialogRef} ... />
      │
      ├─ 상태 변화 감지 (timerExpired):
      │   └─ useEffect(() => {
      │         if (timerExpired) {
      │           dialogRef.current.showModal();
      │         }
      │       }, [timerExpired]);
      │
      └─ dialogRef를 통해 자식의 <dialog> 요소 제어

──────────────────────────────────────────

[ResultModal 컴포넌트]
      │
      ├─ forwardRef로 컴포넌트 정의:
      │   └─ const ResultModal = forwardRef((props, ref) => { ... });
      │
      ├─ 전달받은 ref를 <dialog>에 연결:
      │   └─ <dialog ref={ref}> ... </dialog>
      │
      └─ 부모 컴포넌트에서 전달된 ref를 통해 제어 가능

──────────────────────────────────────────

[실행 흐름]
      │
      ├─ TimerChallenge에서 타이머 시작
      │
      ├─ 타이머 만료 시 timerExpired 상태 변경
      │
      ├─ useEffect 훅에서 dialogRef.current.showModal() 호출
      │
      ├─ ResultModal의 <dialog> 요소 표시
      │
      └─ 사용자에게 모달 창이 나타남
// 1. 참조 생성 및 전달

TimerChallenge
┌────────────────────────────────────────────┐
│ const dialogRef = useRef();                │
│                                            │
│ return (                                   │
│   <>                                       │
│     {/* 기타 컴포넌트 */}                   │
│     <ResultModal ref={dialogRef} ... />    │
│   </>                                      │
│ );                                         │
└────────────────────────────────────────────┘
                     │
                     ▼
ResultModal
┌────────────────────────────────────────────┐
│ const ResultModal = forwardRef((props, ref) => { │
│   return (                                 │
│     <dialog ref={ref}>                     │
│       {/* 모달 내용 */}                     │
│     </dialog>                              │
│   );                                       │
│ });                                        │
└────────────────────────────────────────────┘
// 2. 상태 변화에 따른 모달 제어

TimerChallenge
┌────────────────────────────────────────────┐
│ useEffect(() => {                          │
│   if (timerExpired) {                      │
│     dialogRef.current.showModal();         │
│   }                                        │
│ }, [timerExpired]);                        │
└────────────────────────────────────────────┘
                     │
                     ▼
ResultModal의 <dialog> 요소가 표시됨

124. refs, portals / userImperativeHandle 훅으로 컴포넌트 API 노출시

  • 문제 인식: 부모 컴포넌트가 자식 컴포넌트의 내부 DOM 요소에 직접 접근하여 메소드를 호출하면, 컴포넌트의 내부 구현에 의존하게 되어 유지보수가 어렵습니다.
  • 해결 방법: useImperativeHandle 훅과 forwardRef 함수를 사용하여 자식 컴포넌트가 특정 메소드(open 등)를 노출하고, 부모 컴포넌트는 이 메소드를 통해 기능을 사용할 수 있도록 합니다.
  • 이점:
    • 캡슐화: 자식 컴포넌트의 내부 구현을 숨기고 필요한 인터페이스만 제공
    • 유지보수성: 내부 구조 변경 시에도 외부 영향 최소화
    • 협업 효율성: 여러 개발자들이 함께 작업할 때, 컴포넌트의 사용법을 쉽게 이해하고 적용할 수 있음
  • 적용 방법:
    1. 자식 컴포넌트를 forwardRef로 감싼다.
    2. useImperativeHandle 훅을 사용하여 외부에 노출할 메소드나 속성을 정의한다.
    3. 부모 컴포넌트에서 ref를 생성하고 자식 컴포넌트에 전달한다.
    4. 부모 컴포넌트는 자식 컴포넌트의 노출된 메소드를 호출한다.
// 1. forwardRef로 컴포넌트 감싸기
// ResultModal.jsx
import React, { forwardRef } from 'react';

const ResultModal = forwardRef((props, ref) => {
  // 컴포넌트 내용
});

export default ResultModal;
// 2. useImperativeHandle 훅 사용하기
// ResultModal.jsx
import React, { forwardRef, useImperativeHandle, useRef } from 'react';

const ResultModal = forwardRef((props, ref) => {
  const { result, targetTime } = props;
  const dialogRef = useRef();

  useImperativeHandle(ref, () => ({
    open: () => {
      dialogRef.current.showModal();
    },
  }));

  return (
          <dialog ref={dialogRef}>
            {/* 모달 내용 */}
          </dialog>
  );
});

export default ResultModal;
// 3. 부모 컴포넌트에서 메소드 호출하기
// TimerChallenge.jsx
import React, { useRef, useEffect } from 'react';
import ResultModal from './ResultModal';

function TimerChallenge(props) {
  const resultModalRef = useRef();

  useEffect(() => {
    if (timerExpired) {
      resultModalRef.current.open();
    }
  }, [timerExpired]);

  return (
          <>
            {/* 기타 컴포넌트 */}
            <ResultModal ref={resultModalRef} result="lost" targetTime={targetTime} />
          </>
  );
}

export default TimerChallenge;

125. refs, portals / 추가 예시: Refs(참조)와 State(상태)를 사용해야 하는 경우

  • 목표: TimerChallenge 컴포넌트를 개선하여 타이머를 성공적으로 멈췄을 때 모달(ResultModal)을 띄우고, 남은 시간을 표시하는 기능을 추가하고자 합니다.
  • setTimeout에서 setInterval로 변경: 기존의 setTimeout으로는 남은 시간을 지속적으로 추적할 수 없으므로, setInterval을 사용하여 10밀리초마다 남은 시간을 업데이트합니다.
  • 상태 관리:
    • 새로운 상태 변수 timeRemaining을 생성하여 남은 시간을 밀리초 단위로 저장합니다.
    • setTimeRemaining 함수를 사용하여 10밀리초마다 남은 시간을 감소시킵니다.
  • 타이머의 활성화 조건:
    • timeRemaining이 0보다 크고 초기 값보다 작을 때 타이머가 활성화되었다고 판단합니다.
  • 타이머 종료 처리:
    • 남은 시간이 0 이하가 되면 clearInterval을 통해 타이머를 중지하고, ResultModal을 띄웁니다.
    • 사용자가 수동으로 타이머를 멈출 때도 동일하게 모달을 띄웁니다.
  • 주의사항:
    • 컴포넌트 함수 내에서 상태를 직접 업데이트할 때는 조건문을 사용하여 무한 루프를 방지해야 합니다.
    • clearInterval을 사용할 때는 useRef를 통해 참조를 저장하고 관리합니다.
  • UI 업데이트:
    • 상태 변수와 조건문을 통해 버튼의 동작과 표시를 제어합니다.
    • 타이머의 상태에 따라 버튼의 이벤트 핸들러와 표시가 변경됩니다.
[타이머 시작 버튼 클릭]
          |
          v
[setInterval 설정]
          |
          v
[10ms마다 실행되는 함수]
          |
          v
[timeRemaining 상태 업데이트]
          |
          +------------------------------+
          |                              |
          v                              v
[timeRemaining > 0]                [timeRemaining <= 0]
          |                              |
          v                              v
[타이머 진행 중]                  [clearInterval 호출]
          |                              |
          v                              v
[UI 업데이트]                      [모달(ResultModal) 표시]
          |                              |
          v                              v
[타이머 중지 버튼 클릭 시]
          |
          v
[handleStop 함수 실행]
          |
          v
[clearInterval 호출]
          |
          v
[모달(ResultModal) 표시]

126. refs, portals / 컴포넌트 간의 State(상태) 공유

  • 목표: 타이머 챌린지 앱에서 남은 시간을 정확히 모달(ResultModal)에 전달하여, 사용자가 승리했는지 패배했는지 판단하고, 그에 따라 적절한 메시지와 점수를 표시하려고 합니다.
  • 문제점 식별 및 해결:
    • 문제점: 타이머가 만료되면 timeRemaining이 초기화되어 패배 조건이 제대로 적용되지 않는 문제가 발생했습니다.
    • 해결책:
      • 타이머 만료 시 timeRemaining을 초기화하지 않고, 새로운 handleReset 함수를 만들어 필요할 때만 초기화합니다.
      • handleReset 함수를 onReset 속성을 통해 모달에 전달하고, 모달에서 폼의 onSubmit 이벤트를 사용하여 호출합니다.
  • 구현 내용:
    • 남은 시간 전달: remainingTime 속성을 통해 모달에 남은 시간을 전달합니다.
    • 게임 결과 판단:
      • userLost 변수를 생성하여 remainingTime <= 0일 때 패배로 판단합니다.
      • userLost 값에 따라 다른 메시지('당신이 졌습니다' 또는 점수)를 표시합니다.
    • 시간 포맷팅:
      • remainingTime을 초 단위로 변환하고 toFixed(2)를 사용하여 소수점 두 자리까지 표시합니다.
    • 이벤트 처리 및 상태 관리:
      • 모달에서 폼의 onSubmit 이벤트를 통해 handleReset 함수를 호출하여 타이머를 재설정합니다.
      • 사용자가 모달을 닫을 때 타이머와 상태가 초기화되어 새로운 게임을 시작할 수 있습니다.
[TimerChallenge 컴포넌트]
       |
       v
[타이머 시작 및 timeRemaining 상태 업데이트]
       |
       v
[타이머 종료 조건 검사]
       |
       +------------------------------+
       |                              |
       v                              v
[timeRemaining > 0]             [timeRemaining <= 0]
       |                              |
       v                              v
[사용자 중지 버튼 클릭]          [타이머 만료]
       |                              |
       v                              v
[ResultModal 컴포넌트 호출]      [ResultModal 컴포넌트 호출]
       |                              |
       v                              v
[remainingTime 속성 전달]        [remainingTime 속성 전달]
       |                              |
       v                              v
[모달에서 userLost 계산]          [모달에서 userLost 계산]
       |                              |
       v                              v
[성공 메시지 및 점수 표시]        [패배 메시지 표시]
       |                              |
       v                              v
[모달 내 폼 제출(onSubmit)]
       |
       v
[onReset 호출(handleReset)]
       |
       v
[timeRemaining 상태 초기화]
       |
       v
[새로운 게임 시작]

127. refs, portals / 데모 앱의 "결과 모달창" 개선

이번 시간에는 타이머 챌린지 앱의 ResultModal 컴포넌트를 개선하여 사용자의 점수와 성공 메시지를 표시했습니다. 점수는 남은 시간과 목표 시간을 기반으로 계산되며, 계산 공식은 다음과 같습니다:

Score = (1 - (remainingTime / (targetTime * 1000))) * 100

여기서 remainingTime은 밀리초 단위이고, targetTime은 초 단위이므로 targetTime에 1000을 곱해 단위를 맞췄습니다. 계산 순서를 보장하기 위해 괄호를 사용했고, 점수를 깔끔하게 표시하기 위해 반올림을 적용했습니다.

사용자가 패배하지 않은 경우(!userLost), 모달에 점수와 성공 메시지를 표시합니다. 남은 시간이 0에 가까울수록 더 높은 점수를 받게 되며, 점수는 0부터 100 사이의 값입니다. 이 방식은 5초 챌린지 등 다른 모든 타이머 챌린지에도 동일하게 적용됩니다.

변경 사항을 저장하고 테스트하여 점수 계산과 표시가 올바르게 동작하는지 확인했습니다. 이를 통해 사용자에게 더 나은 피드백과 게임 경험을 제공할 수 있게 되었습니다.

[사용자: 타이머 시작]
          |
          v
[타이머 작동 - 남은 시간 감소]
          |
          v
[사용자: 타이머 멈춤]
          |
          v
[남은 시간(remainingTime) 체크]
          |
          v
[패배 여부 확인: userLost = remainingTime <= 0]
          |
          +-----------------------------+
          |                             |
          v                             v
[사용자 승리 (!userLost)]         [사용자 패배 (userLost)]
          |                             |
          v                             v
[점수 계산]                       [패배 메시지 표시]
Score = (1 - (remainingTime / (targetTime * 1000))) * 100
          |                             |
          v                             v
[점수 반올림 및 표시]              [모달에 패배 메시지 표시]
          |                             |
          v                             v
[모달에 성공 메시지와 점수 표시]     |
          |                             |
          v                             v
[사용자: 모달 확인 및 닫기]
          |
          v
[타이머 및 상태 초기화]
          |
          v
[새로운 게임 시작 가능]

128. refs, portals / 모달을 ESC(Escape) 키로 닫기

<dialog>요소를 사용하면 웹사이트 방문자가 키보드의 ESC(Escape) 키를 눌러 열린 대화창을 닫을 수 있습니다.

현재, 이것은 버튼 클릭으로 대화상자를 닫는 것과 달리, onReset함수를 트리거하지 않습니다.

ESC 키로 대화창을 닫을 때 onReset이 트리거되도록 하려면 <dialog> 요소에 내장된 onClose 속성을 추가하고 그 값을 onReset속성에 바인딩해야 합니다.

다음과 같습니다:

<dialog ref={dialog} className="result-modal" onClose={onReset}
   ...
</dialog>

129. refs, portals / Portals(포탈) 소개 및 이해하기

React Portal의 특징과 사용 방법에 대해 알아보겠습니다. 기존에 ResultModal 컴포넌트는 TimerChallenge 컴포넌트의 JSX 코드 내에 렌더링되어, DOM 구조상 깊게 중첩되어 있었습니다. 이는 시각적으로는 문제가 없어 보이지만, 기술적으로는 접근성이나 스타일링에서 문제가 발생할 수 있습니다.

이를 해결하기 위해 React Portal을 사용하여 ResultModal 컴포넌트를 DOM 내의 다른 위치, 즉 루트에 가까운 div 요소(id="modal")에 렌더링하도록 변경했습니다. 이를 통해 모달과 같은 오버레이 요소를 DOM 트리의 최상위 수준에 배치하여 구조를 명확히 하고, 잠재적인 스타일 및 접근성 문제를 방지할 수 있습니다.

포털을 사용하기 위해 react-dom 라이브러리에서 createPortal 함수를 임포트하고, 렌더링할 JSX 코드를 createPortal로 감싸고 두 번째 인수로 대상 DOM 노드를 전달합니다. 대상 DOM 노드는 document.getElementById('modal')를 사용하여 선택합니다.

[React 컴포넌트 계층 구조]
      |
      v
[TimerChallenge 컴포넌트]
      |
      v
[ResultModal 컴포넌트 렌더링]
      |
      v
[createPortal로 ResultModal JSX 감싸기]
      |
      v
[ReactDOM.createPortal(    <ResultModal />,    document.getElementById('modal'))]
      |
      v
[DOM의 다른 위치에 ResultModal 렌더링]
(예: <div id="modal"></div>)
      |
      v
[사용자에게 모달 표시]
// ResultModal.tsx
import React from 'react';

interface ResultModalProps {
  // 필요한 props를 정의합니다.
  onClose: () => void;
}

const ResultModal: React.FC<ResultModalProps> = ({ onClose }) => {
  return (
    <div className="modal-overlay">
      <div className="modal-content">
        <h2>모달 제목</h2>
        <p>모달 내용</p>
        <button onClick={onClose}>닫기</button>
      </div>
    </div>
  );
};

export default ResultModal;
// TimerChallenge.tsx
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import ResultModal from './ResultModal';

const TimerChallenge: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  const handleModalClose = () => {
    setIsModalOpen(false);
  };

  return (
    <div>
      <h1>타이머 챌린지</h1>
      {/* 타이머 관련 코드 */}
      <button onClick={() => setIsModalOpen(true)}>모달 열기</button>

      {isModalOpen &&
        ReactDOM.createPortal(
          <ResultModal onClose={handleModalClose} />,
          document.getElementById('modal') as HTMLElement
        )}
    </div>
  );
};

export default TimerChallenge;
<!-- index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>React App</title>
</head>
<body>
  <div id="root"></div>
  <div id="modal"></div> <!-- 모달을 렌더링할 대상 DOM 노드 -->
</body>
</html>

130. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 모듈 소개 & 초기 프로젝트

리액트 프로젝트 관리 앱의 개발을 통해 지금까지 배운 React의 기본기와 고급 개념을 종합적으로 연습해 본다. 이 앱은 새 프로젝트를 생성하고, 프로젝트별로 할 일을 추가하며, 프로젝트와 할 일을 삭제할 수 있는 기능을 제공한다. 컴포넌트와 상태 관리를 활용하고, 참조(ref)와 포털(Portal) 등의 개념을 적용하여 앱을 구현한다. 스타일링을 위해 Tailwind CSS를 사용한다.

[사용자]
    |
    v
[새 프로젝트 생성] ---> [프로젝트 목록에 추가]
    |
    v
[프로젝트 선택]
    |
    v
[할 일 추가] ---> [프로젝트의 할 일 목록에 추가]
    |
    v
[할 일 삭제] <--- [프로젝트의 할 일 목록]
    |
    v
[프로젝트 삭제] ---> [프로젝트 목록에서 제거]
// App.tsx
import React from 'react';
import ProjectManager from './components/ProjectManager';

const App: React.FC = () => {
  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">프로젝트 관리 앱</h1>
      <ProjectManager />
    </div>
  );
};

export default App;
// components/ProjectManager.tsx
import React, { useState } from 'react';
import ProjectForm from './ProjectForm';
import ProjectList from './ProjectList';

interface Project {
  id: number;
  name: string;
}

const ProjectManager: React.FC = () => {
  const [projects, setProjects] = useState<Project[]>([]);

  const addProject = (name: string) => {
    const newProject: Project = {
      id: Date.now(),
      name,
    };
    setProjects([...projects, newProject]);
  };

  const deleteProject = (id: number) => {
    setProjects(projects.filter((project) => project.id !== id));
  };

  return (
    <div>
      <ProjectForm addProject={addProject} />
      <ProjectList projects={projects} deleteProject={deleteProject} />
    </div>
  );
};

export default ProjectManager;
// components/ProjectForm.tsx
import React, { useState } from 'react';

interface ProjectFormProps {
  addProject: (name: string) => void;
}

const ProjectForm: React.FC<ProjectFormProps> = ({ addProject }) => {
  const [projectName, setProjectName] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (projectName.trim()) {
      addProject(projectName.trim());
      setProjectName('');
    }
  };

  return (
    <form onSubmit={handleSubmit} className="mb-4">
      <input
        type="text"
        value={projectName}
        onChange={(e) => setProjectName(e.target.value)}
        placeholder="프로젝트 이름"
        className="border p-2 mr-2"
      />
      <button type="submit" className="bg-blue-500 text-white p-2">
        프로젝트 추가
      </button>
    </form>
  );
};

export default ProjectForm;
// components/ProjectList.tsx
import React from 'react';
import TaskManager from './TaskManager';

interface Project {
  id: number;
  name: string;
}

interface ProjectListProps {
  projects: Project[];
  deleteProject: (id: number) => void;
}

const ProjectList: React.FC<ProjectListProps> = ({ projects, deleteProject }) => {
  return (
    <div>
      {projects.map((project) => (
        <div key={project.id} className="border p-2 mb-2">
          <h2 className="text-xl font-semibold">
            {project.name}
            <button
              onClick={() => deleteProject(project.id)}
              className="text-red-500 ml-4"
            >
              삭제
            </button>
          </h2>
          <TaskManager projectId={project.id} />
        </div>
      ))}
    </div>
  );
};

export default ProjectList;
// components/TaskManager.tsx
import React, { useState } from 'react';

interface Task {
  id: number;
  text: string;
}

interface TaskManagerProps {
  projectId: number;
}

const TaskManager: React.FC<TaskManagerProps> = () => {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [taskText, setTaskText] = useState('');

  const addTask = () => {
    if (taskText.trim()) {
      const newTask: Task = {
        id: Date.now(),
        text: taskText.trim(),
      };
      setTasks([...tasks, newTask]);
      setTaskText('');
    }
  };

  const deleteTask = (id: number) => {
    setTasks(tasks.filter((task) => task.id !== id));
  };

  return (
    <div className="ml-4">
      <input
        type="text"
        value={taskText}
        onChange={(e) => setTaskText(e.target.value)}
        placeholder="할 일 내용"
        className="border p-1 mr-2"
      />
      <button onClick={addTask} className="bg-green-500 text-white p-1">
        할 일 추가
      </button>
      <ul className="mt-2">
        {tasks.map((task) => (
          <li key={task.id} className="flex justify-between">
            {task.text}
            <button
              onClick={() => deleteTask(task.id)}
              className="text-red-500"
            >
              삭제
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TaskManager;

131. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / "프로젝트 사이드바" 컴포넌트 추가하기

React 프로젝트 관리 앱을 개발하면서 ProjectsSidebar 컴포넌트를 생성한다. 이 컴포넌트는 사용자에게 프로젝트 목록을 보여주고, 새로운 프로젝트를 추가할 수 있는 버튼을 제공한다. 컴포넌트는 다음과 같이 구성된다:

  • ProjectsSidebar.jsx 파일을 components 폴더 내에 생성한다.
  • ProjectsSidebar 함수형 컴포넌트를 정의하고, JSX 코드를 반환한다.
  • <aside> 요소를 사용하여 사이드바 내용을 감싼다.
  • <h2> 요소에 "당신의 프로젝트"라는 제목을 넣는다.
  • 새로운 프로젝트를 추가하기 위한 <button> 요소를 생성하고, "+프로젝트 추가"라는 텍스트를 포함한다.
  • App 컴포넌트에서 기존의 <h1> 요소를 제거하고, 대신 ProjectsSidebar 컴포넌트를 임포트하여 렌더링한다.
  • 루트 요소를 <Fragment>에서 <main> 요소로 변경하여, 사이드바와 프로젝트 상세 내용을 감싼다.
  • 변경 사항을 저장하고 브라우저에서 사이드바가 올바르게 표시되는지 확인한다.

이렇게 해서 기본적인 사이드바 구조를 완성하였고, 다음 단계에서는 스타일링을 추가하고 기능을 확장할 계획이다.

[App 컴포넌트]
      |
      v
[ProjectsSidebar 컴포넌트 렌더링]
      |
      v
<main>
  |
  +--<ProjectsSidebar />
      |
      v
    <aside>
      |
      +--<h2>당신의 프로젝트</h2>
      |
      +--<div>
            |
            +--<button>+프로젝트 추가</button>
// src/App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';

const App: React.FC = () => {
  return (
    <main className="flex">
      <ProjectsSidebar />
      {/* 추가할 프로젝트의 상세 내용이 여기 들어갈 것입니다 */}
    </main>
  );
};

export default App;
// src/components/ProjectsSidebar.tsx
import React from 'react';

const ProjectsSidebar: React.FC = () => {
  return (
    <aside className="w-64 bg-gray-100 p-4">
      <h2 className="text-xl font-bold mb-4">당신의 프로젝트</h2>
      {/* 프로젝트 목록이 여기 들어갈 것입니다 */}
      <div>
        <button className="w-full bg-blue-500 text-white py-2 px-4 rounded">
          +프로젝트 추가
        </button>
      </div>
    </aside>
  );
};

export default ProjectsSidebar;

132. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 테일윈드 CSS로 사이드바 & 버튼 스타일링하기

이번 시간에는 앱의 스타일링을 개선해 보겠습니다. App 컴포넌트에서 Tailwind CSS 클래스를 추가하여 화면 전체 높이를 차지하도록 설정하고, 상하 여백을 추가했습니다. ProjectsSidebar 컴포넌트의 aside 요소에 다양한 클래스를 적용하여 사이드바의 레이아웃과 디자인을 완성했습니다.

구체적으로는:

  • 너비 설정: w-1/3 클래스로 사이드바의 너비를 부모 요소의 1/3로 설정하고, 큰 화면에서는 md:w-72로 고정된 너비를 적용했습니다.
  • 패딩 추가: px-8과 py-16 클래스로 패딩을 추가하여 콘텐츠와 테두리 사이의 여백을 확보했습니다.
  • 배경 및 텍스트 색상: bg-stone-900과 text-stone-50 클래스로 배경색과 텍스트 색상을 설정했습니다.
  • 모서리 둥글게: rounded-r-xl 클래스로 사이드바의 오른쪽 모서리를 둥글게 만들었습니다.

H2 태그와 버튼에도 스타일을 적용했습니다:

  • H2 태그: font-bold, uppercase 등을 사용하여 텍스트를 굵게 하고 대문자로 변환했습니다.
  • 버튼: 패딩, 글자 크기, 색상, 호버 효과 등을 Tailwind CSS 클래스로 설정하여 시각적으로 매력적인 버튼을 만들었습니다.

이러한 스타일링을 통해 기본적인 사이드바와 버튼을 완성했습니다. 다음 단계로는 이 버튼을 클릭하면 새로운 창이 열려서 새로운 프로젝트의 상세 정보를 추가할 수 있는 컴포넌트를 구현해 보겠습니다.

// App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';

const App: React.FC = () => {
  return (
          <div className="h-screen my-8">
            <main className="flex">
              <ProjectsSidebar />
              {/* 앞으로 추가할 프로젝트 상세 내용 */}
            </main>
          </div>
  );
};

export default App;
// components/ProjectsSidebar.tsx
import React from 'react';

const ProjectsSidebar: React.FC = () => {
  return (
          <aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
            <h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
              당신의 프로젝트
            </h2>
            {/* 프로젝트 목록이 여기에 들어갈 예정 */}
            <button className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100">
              +프로젝트 추가
            </button>
          </aside>
  );
};

export default ProjectsSidebar;

133. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / "새 프로젝트" 컴포넌트와 재사용 가능한 "입력" 컴포넌트 추가하기

새로운 프로젝트를 추가할 수 있는 NewProject 컴포넌트를 생성하였다. 이 컴포넌트에서는 프로젝트의 제목, 상세설명, 마감일을 입력할 수 있는 필드를 제공한다. 코드의 재사용성과 중복 제거를 위해 Input 컴포넌트를 별도로 생성하여 라벨과 입력 필드를 구성하였다.

Input 컴포넌트는 isTextarea 속성을 통해 <input> 또는 <textarea>를 조건부로 렌더링한다. 또한, 나머지 속성은 spread 연산자 ...를 사용하여 해당 요소에 전달된다.

App 컴포넌트에서는 NewProject 컴포넌트를 ProjectsSidebar 컴포넌트 옆에 배치하기 위해 flex 클래스를 사용하고, 요소 간 간격을 주기 위해 gap-8 클래스를 적용하였다.

현재는 스타일링이 적용되지 않았으며, 이후에 추가할 예정이다. 이렇게 함으로써 사이드바와 프로젝트 입력 폼이 화면에 나란히 표시되며, 사용자로부터 프로젝트 정보를 입력받을 수 있는 준비를 마쳤다.

[App 컴포넌트]
      |
      v
<main class="flex gap-8">
      |
      +-- [ProjectsSidebar 컴포넌트]
      |
      +-- [NewProject 컴포넌트]
            |
            v
      <div>
        |
        +-- [메뉴 요소]
        |     |
        |     +-- [취소 버튼]
        |     +-- [저장 버튼]
        |
        +-- [프로젝트 입력 디브]
              |
              +-- [Input 컴포넌트 (Title)]
              |     |
              |     +-- <label>Title</label>
              |     +-- <input ... />
              |
              +-- [Input 컴포넌트 (Description)]
              |     |
              |     +-- <label>Description</label>
              |     +-- <textarea ... />
              |
              +-- [Input 컴포넌트 (Due Date)]
                    |
                    +-- <label>Due Date</label>
                    +-- <input ... />
// components/Input.tsx
import React from 'react';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  isTextarea?: boolean;
}

const Input: React.FC<InputProps> = ({ label, isTextarea, ...rest }) => {
  return (
          <p>
            <label>{label}</label>
            {isTextarea ? (
                    <textarea {...rest}></textarea>
            ) : (
                    <input {...rest} />
            )}
          </p>
  );
};

export default Input;
// components/NewProject.tsx
import React from 'react';
import Input from './Input';

const NewProject: React.FC = () => {
  return (
          <div>
            <menu>
              <button>취소</button>
              <button>저장</button>
            </menu>
            <div>
              <Input label="Title" />
              <Input label="Description" isTextarea />
              <Input label="Due Date" />
            </div>
          </div>
  );
};

export default NewProject;
// App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';

const App: React.FC = () => {
  return (
          <div className="h-screen my-8">
            <main className="flex gap-8">
              <ProjectsSidebar />
              <NewProject />
            </main>
          </div>
  );
};

export default App;

134. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 테일윈드 CSS로 버튼과 입력 항목 스타일링

이번에는 Tailwind CSS를 활용하여 앱의 스타일링을 개선했다. 맞춤 너비를 설정하고, 메뉴와 사이드바의 높이를 맞추기 위해 여백을 추가했다. 메뉴 섹션에서는 Flexbox를 활성화하고, 요소들을 정렬 및 배치했다.

버튼들에 스타일을 적용하여 취소 버튼과 저장 버튼의 시각적 차이를 두었다. 특히 저장 버튼은 더욱 눈에 띄게 디자인했다.

Input.jsx 파일에서는 입력 필드들의 스타일을 개선했다. 문단과 라벨, 입력창에 Tailwind CSS 클래스를 추가하여 레이아웃과 디자인을 조정했다. 입력창과 문자 영역의 스타일링을 위해 클래스 문자열을 상수로 저장하여 코드의 중복을 줄였다.

다음 단계로는 프로젝트 추가 버튼을 클릭했을 때 새로운 창을 열고, 사용자로부터 입력값을 받아 실제로 새로운 프로젝트를 생성하는 기능을 구현할 예정이다.

[NewProject 컴포넌트]
        |
        v
<div class="w-[35rem] mt-16">
        |
        +-- [메뉴 섹션]
        |       |
        |       +-- <menu class="flex items-center justify-end gap-4 my-4">
        |               |
        |               +-- [취소 버튼]
        |               |       |
        |               |       +-- 클래스: text-stone-800 hover:text-stone-900
        |               |
        |               +-- [저장 버튼]
        |                       |
        |                       +-- 클래스: bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2
        |
        +-- [입력 필드 섹션]
                |
                +-- [Input 컴포넌트들]
                        |
                        +-- <p class="flex flex-col gap-1 my-4">
                                |
                                +-- <label class="text-sm font-bold uppercase text-stone-600">라벨</label>
                                |
                                +-- <input 또는 textarea class="{classes}">
                                        |
                                        +-- classes = "w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600"
// components/Input.tsx
import React from 'react';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
  label: string;
  isTextarea?: boolean;
}

const Input: React.FC<InputProps> = ({ label, isTextarea, ...rest }) => {
  const classes =
          'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';

  return (
          <p className="flex flex-col gap-1 my-4">
            <label className="text-sm font-bold uppercase text-stone-600">{label}</label>
            {isTextarea ? (
                    <textarea className={classes} {...rest}></textarea>
            ) : (
                    <input className={classes} {...rest} />
            )}
          </p>
  );
};

export default Input;
// components/NewProject.tsx
import React from 'react';
import Input from './Input';

const NewProject: React.FC = () => {
  return (
          <div className="w-[35rem] mt-16">
            <menu className="flex items-center justify-end gap-4 my-4">
              <button className="text-stone-800 hover:text-stone-900">취소</button>
              <button className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2">
                저장
              </button>
            </menu>
            <div>
              <Input label="Title" />
              <Input label="Description" isTextarea />
              <Input label="Due Date" type="date" />
            </div>
          </div>
  );
};

export default NewProject;

135. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / JSX와 테일윈드 스타일을 위한 컴포넌트 분리 (재사용 가능성 향상)

프로젝트 관리 앱에서 NoProjectSelected라는 새로운 컴포넌트를 생성하여, 프로젝트가 선택되지 않았거나 추가되지 않았을 때 표시될 대체 콘텐츠를 구성했습니다. 이 컴포넌트는 이미지와 텍스트, 그리고 "새 프로젝트 추가" 버튼을 포함합니다.

스타일링을 위해 Tailwind CSS 클래스를 활용하여 레이아웃과 디자인을 조정했습니다. 이미지는 assets 폴더에서 가져와서 <img> 태그로 표시하고, 텍스트 요소들에도 적절한 스타일을 적용했습니다.

버튼의 중복 코드를 제거하고 재사용성을 높이기 위해 Button 컴포넌트를 별도로 생성했습니다. 이 컴포넌트는 프로젝트 사이드바와 NoProjectSelected 컴포넌트에서 모두 사용되며, 자식 요소를 통해 버튼의 내용을 유연하게 지정할 수 있습니다.

마지막으로, App 컴포넌트에서 NewProject 대신 NoProjectSelected 컴포넌트를 렌더링하도록 변경하여, 현재는 항상 대체 콘텐츠가 표시되도록 설정했습니다. 이후에는 버튼 클릭 여부에 따라 조건부로 컴포넌트를 렌더링하여 기능을 확장할 예정입니다.

[App 컴포넌트]
      |
      v
[main 요소]
      |
      +-- [ProjectsSidebar 컴포넌트]
      |
      +-- [NoProjectSelected 컴포넌트]
            |
            v
    <div class="mt-16 text-center w-2/3">
          |
          +-- <img src={noProjectImage} alt="빈 할 일 목록" class="w-16 h-16 object-contain mx-auto" />
          |
          +-- <h2 class="text-xl font-bold text-stone-500 my-4">선택된 프로젝트 없음</h2>
          |
          +-- <p class="text-stone-400 mb-4">프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오</p>
          |
          +-- <p class="mt-8">
                  |
                  +-- [Button 컴포넌트]
                        |
                        +-- "새 프로젝트 추가"
           

[Button 컴포넌트]
      |
      v
<button class="..."> {children} </button>
      ^
      |
나머지 props와 children을 받아서 버튼에 적용
// src/components/NoProjectSelected.tsx
import React from 'react';
import Button from './Button';
import noProjectImage from '../assets/no-projects.png';

const NoProjectSelected: React.FC = () => {
  return (
          <div className="mt-16 text-center w-2/3">
            <img
                    src={noProjectImage}
                    alt="빈 할 일 목록"
                    className="w-16 h-16 object-contain mx-auto"
            />
            <h2 className="text-xl font-bold text-stone-500 my-4">
              선택된 프로젝트 없음
            </h2>
            <p className="text-stone-400 mb-4">
              프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오
            </p>
            <p className="mt-8">
              <Button>새 프로젝트 추가</Button>
            </p>
          </div>
  );
};

export default NoProjectSelected;
// src/components/Button.tsx
import React from 'react';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

const Button: React.FC<ButtonProps> = ({ children, ...rest }) => {
  return (
          <button
                  className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100"
                  {...rest}
          >
            {children}
          </button>
  );
};

export default Button;
// src/components/ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';

const ProjectsSidebar: React.FC = () => {
  return (
          <aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
            <h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
              당신의 프로젝트
            </h2>
            {/* 프로젝트 목록이 여기에 들어갈 예정 */}
            <Button>+프로젝트 추가</Button>
          </aside>
  );
};

export default ProjectsSidebar;
// src/App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NoProjectSelected from './components/NoProjectSelected';

const App: React.FC = () => {
  return (
          <div className="h-screen my-8">
            <main className="flex gap-8">
              <ProjectsSidebar />
              <NoProjectSelected />
            </main>
          </div>
  );
};

export default App;

136. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 컴포넌트 간 교환을 위한 State(상태) 관리법

  • 상태 관리 및 조건부 렌더링을 통해 버튼 클릭에 따라 다른 컴포넌트를 표시하도록 구현한다.
  • selectedProjectId를 사용하여 현재 선택된 프로젝트를 추적하고, 그 값에 따라 NewProject 또는 NoProjectSelected 컴포넌트를 렌더링한다.
  • handleStartAddProject 함수를 만들어 버튼 클릭 시 selectedProjectId를 null로 설정하여 새로운 프로젝트 추가 상태임을 나타낸다.
  • 상태는 앱 컴포넌트에서 관리하며, 하위 컴포넌트로 이벤트 핸들러와 상태를 전달한다.
  • 상태 업데이트 시 이전 상태를 보존하기 위해 상태 객체를 펼쳐서 새로운 상태를 생성한다.
  • 이벤트 핸들러를 props로 전달하고, 버튼의 onClick 속성에 연결하여 사용자 인터랙션에 따라 상태가 변경되도록 한다.
[App 컴포넌트]
      |
      v
[useState로 projectsState 관리]
      |
      v
[handleStartAddProject 함수 정의]
      |
      v
[onStartAddProject props로 전달]
      |
      v
+-------------------------+
|                         |
v                         v
[ProjectsSidebar 컴포넌트]  [NoProjectSelected 컴포넌트]
      |                             |
      v                             v
[Button 클릭 시]              [Button 클릭 시]
      |                             |
      v                             v
[onClick={onStartAddProject}]  [onClick={onStartAddProject}]
      |                             |
      +-------------+---------------+
                    |
                    v
[handleStartAddProject 호출]
                    |
                    v
[projectsState 업데이트: selectedProjectId = null]
                    |
                    v
[App 컴포넌트 렌더링]
      |
      v
[projectsState.selectedProjectId === null ?]
      |
      +------------ Yes ------------+
      |                             |
      v                             v
[content = <NewProject />]   [content = <NoProjectSelected />]
      |
      v
[UI 업데이트: NewProject 컴포넌트 표시]
// App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NoProjectSelected from './components/NoProjectSelected';
import NewProject from './components/NewProject';

interface Project {
  id: number;
  title: string;
  description: string;
  dueDate: string;
}

interface ProjectsState {
  projects: Project[];
  selectedProjectId: number | null | undefined;
}

const App: React.FC = () => {
  const [projectsState, setProjectsState] = useState<ProjectsState>({
    projects: [],
    selectedProjectId: undefined,
  });

  const handleStartAddProject = () => {
    setProjectsState((prevState) => ({
      ...prevState,
      selectedProjectId: null,
    }));
  };

  let content;
  if (projectsState.selectedProjectId === null) {
    content = <NewProject />;
  } else if (projectsState.selectedProjectId !== undefined) {
    // 선택된 프로젝트가 있을 때의 컴포넌트 (추후 구현)
    content = <div>선택된 프로젝트 내용</div>;
  } else {
    content = (
            <NoProjectSelected onStartAddProject={handleStartAddProject} />
    );
  }

  return (
          <div className="h-screen my-8">
            <main className="flex gap-8">
              <ProjectsSidebar onStartAddProject={handleStartAddProject} />
              {content}
            </main>
          </div>
  );
};

export default App;
// ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';

interface ProjectsSidebarProps {
  onStartAddProject: () => void;
}

const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
                                                           onStartAddProject,
                                                         }) => {
  return (
          <aside className="...">
            <h2 className="...">당신의 프로젝트</h2>
            {/* 프로젝트 목록 표시 예정 */}
            <Button onClick={onStartAddProject}>+프로젝트 추가</Button>
          </aside>
  );
};

export default ProjectsSidebar;
// NoProjectSelected.tsx
import React from 'react';
import Button from './Button';
import noProjectImage from '../assets/no-projects.png';

interface NoProjectSelectedProps {
  onStartAddProject: () => void;
}

const NoProjectSelected: React.FC<NoProjectSelectedProps> = ({
                                                               onStartAddProject,
                                                             }) => {
  return (
          <div className="...">
            {/* 이미지 및 텍스트 */}
            <Button onClick={onStartAddProject}>새 프로젝트 추가</Button>
          </div>
  );
};

export default NoProjectSelected;

137. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / Refs(참조)와 전달된 Refs(참조)로 사용자 입력 받아오기

  • 사용자 입력을 받아 새로운 프로젝트를 추가하는 기능을 구현한다.
    • useRef와 forwardRef를 사용하여 입력 필드의 값을 가져온다.
    • 저장 버튼 클릭 시 handleSave 함수에서 입력 값을 수집한다.
  • 수집된 데이터를 App 컴포넌트로 전달하여 상태를 업데이트한다.
    • handleAddProject 함수를 App 컴포넌트에 정의하고, setProjectsState를 사용하여 프로젝트 목록에 새로운 프로젝트를 추가한다.
    • 프로젝트에는 제목, 상세 설명, 마감일, 고유 ID가 포함된다.
  • 상태를 끌어올려(App 컴포넌트로) 전체적인 상태 관리를 한다.
    • NewProject 컴포넌트에서 onAdd prop을 통해 handleAddProject 함수를 호출한다.
  • 입력 필드의 유효성을 검사하고, 필요한 경우 에러 메시지를 표시할 예정이다.
    • 현재는 유효성 검사를 생략하고, 다음 단계에서 추가할 계획이다.
  • 입력 필드의 유형을 적절히 설정하여 사용자 경험을 향상시킨다.
    • 날짜 입력을 위해 <input type="date">를 사용하여 날짜 선택기를 제공한다.
  • 상태 업데이트를 확인하기 위해 console.log를 사용한다.
[사용자] 
   |
   v
[NewProject 컴포넌트에서 입력]
   |
   v
[handleSave 함수 호출 (저장 버튼 클릭)]
   |
   v
[입력 값 수집 (titleRef.current.value 등)]
   |
   v
[onAdd(props.onAdd) 함수 호출]
   |
   v
[App 컴포넌트의 handleAddProject 함수 실행]
   |
   v
[projectsState 상태 업데이트 (새 프로젝트 추가)]
   |
   v
[App 컴포넌트 렌더링]
   |
   v
[ProjectsSidebar 컴포넌트에 새로운 프로젝트 전달 (다음 단계에서 구현)]
// Input.tsx
import React, { forwardRef } from 'react';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
  label: string;
  isTextarea?: boolean;
}

const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
        ({ label, isTextarea, ...rest }, ref) => {
          const classes =
                  'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';

          return (
                  <p className="flex flex-col gap-1 my-4">
                    <label className="text-sm font-bold uppercase text-stone-600">{label}</label>
                    {isTextarea ? (
                            <textarea className={classes} ref={ref as React.Ref<HTMLTextAreaElement>} {...rest}></textarea>
                    ) : (
                            <input className={classes} ref={ref as React.Ref<HTMLInputElement>} {...rest} />
                    )}
                  </p>
          );
        }
);

export default Input;
// NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';

interface NewProjectProps {
  onAdd: (projectData: { title: string; description: string; dueDate: string }) => void;
}

const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
  const titleRef = useRef<HTMLInputElement>(null);
  const descriptionRef = useRef<HTMLTextAreaElement>(null);
  const dueDateRef = useRef<HTMLInputElement>(null);

  const handleSave = () => {
    const enteredTitle = titleRef.current?.value || '';
    const enteredDescription = descriptionRef.current?.value || '';
    const enteredDueDate = dueDateRef.current?.value || '';

    // 유효성 검사 (간단한 예시)
    if (!enteredTitle.trim() || !enteredDescription.trim() || !enteredDueDate.trim()) {
      alert('모든 필드를 입력해주세요.');
      return;
    }

    onAdd({
      title: enteredTitle,
      description: enteredDescription,
      dueDate: enteredDueDate,
    });
  };

  return (
          <div className="w-[35rem] mt-16">
            <menu className="flex items-center justify-end gap-4 my-4">
              <button className="text-stone-800 hover:text-stone-900">취소</button>
              <button onClick={handleSave} className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2">
                저장
              </button>
            </menu>
            <div>
              <Input label="Title" ref={titleRef} type="text" />
              <Input label="Description" ref={descriptionRef} isTextarea />
              <Input label="Due Date" ref={dueDateRef} type="date" />
            </div>
          </div>
  );
};

export default NewProject;
// App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './ProjectsSidebar';
import NewProject from './NewProject';
import NoProjectSelected from './NoProjectSelected';

interface Project {
  id: number;
  title: string;
  description: string;
  dueDate: string;
}

interface ProjectsState {
  projects: Project[];
  selectedProjectId: number | null | undefined;
}

const App: React.FC = () => {
  const [projectsState, setProjectsState] = useState<ProjectsState>({
    projects: [],
    selectedProjectId: undefined,
  });

  const handleAddProject = (projectData: { title: string; description: string; dueDate: string }) => {
    setProjectsState((prevState) => ({
      ...prevState,
      projects: [
        ...prevState.projects,
        {
          id: Math.random(),
          ...projectData,
        },
      ],
    }));
  };

  const handleStartAddProject = () => {
    setProjectsState((prevState) => ({
      ...prevState,
      selectedProjectId: null,
    }));
  };

  console.log(projectsState);

  let content;
  if (projectsState.selectedProjectId === null) {
    content = <NewProject onAdd={handleAddProject} />;
  } else if (projectsState.selectedProjectId !== undefined) {
    // 선택된 프로젝트가 있을 때의 컴포넌트 (추후 구현)
    content = <div>선택된 프로젝트 내용</div>;
  } else {
    content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
  }

  return (
          <div className="h-screen my-8">
            <main className="flex gap-8">
              <ProjectsSidebar onStartAddProject={handleStartAddProject} />
              {content}
            </main>
          </div>
  );
};

export default App;

138. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 프로젝트 생성 핸들링 & UI 업데이트

저장 버튼을 클릭하면 selectedProjectId를 undefined로 설정하여 NewProject 컴포넌트를 닫고 대체 화면으로 돌아가도록 수정하겠습니다. 또한, 사이드바에 프로젝트 목록을 표시하기 위해 projectsState.projects를 ProjectsSidebar 컴포넌트에 projects 속성으로 전달하겠습니다.

ProjectsSidebar에서는 전달받은 projects를 이용하여 프로젝트 목록을 렌더링하고, 각 프로젝트마다 선택 버튼을 추가하겠습니다. 버튼에는 프로젝트 제목을 표시하고, Tailwind CSS 클래스를 활용하여 스타일링하겠습니다.

프로젝트 목록의 상단에 여백을 추가하여 디자인을 개선하고, NewProject 컴포넌트에서 취소 버튼이 동작하도록 구현하겠습니다. 또한, 모든 입력 필드가 채워지지 않았을 때 에러 메시지를 표시하여 사용자 경험을 향상시키겠습니다.

// 데이터 흐름 도식화
[사용자: NewProject에서 '저장' 클릭]
        |
        v
[App 컴포넌트의 handleAddProject 호출]
        |
        v
[projectsState.projects에 새 프로젝트 추가]
        |
        v
[selectedProjectId를 undefined로 설정]
        |
        v
[App 컴포넌트 재렌더링]
        |
        v
[ProjectsSidebar에 업데이트된 projects 전달]
        |
        v
[ProjectsSidebar에서 프로젝트 목록 렌더링]
        |
        v
[사용자: 사이드바에서 프로젝트 목록 확인]
// App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';

interface Project {
  id: number;
  title: string;
  description: string;
  dueDate: string;
}

interface ProjectsState {
  projects: Project[];
  selectedProjectId: number | null | undefined;
}

const App: React.FC = () => {
  const [projectsState, setProjectsState] = useState<ProjectsState>({
    projects: [],
    selectedProjectId: undefined,
  });

  const handleAddProject = (projectData: {
    title: string;
    description: string;
    dueDate: string;
  }) => {
    const newProject: Project = {
      id: Math.random(),
      ...projectData,
    };
    setProjectsState((prevState) => ({
      projects: [...prevState.projects, newProject],
      selectedProjectId: undefined, // 컴포넌트 닫기
    }));
  };

  const handleStartAddProject = () => {
    setProjectsState((prevState) => ({
      ...prevState,
      selectedProjectId: null,
    }));
  };

  let content;
  if (projectsState.selectedProjectId === null) {
    content = <NewProject onAdd={handleAddProject} />;
  } else {
    content = (
            <NoProjectSelected onStartAddProject={handleStartAddProject} />
    );
  }

  return (
          <div className="h-screen my-8">
            <main className="flex gap-8">
              <ProjectsSidebar
                      onStartAddProject={handleStartAddProject}
                      projects={projectsState.projects}
              />
              {content}
            </main>
          </div>
  );
};

export default App;
// ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';

interface Project {
  id: number;
  title: string;
}

interface ProjectsSidebarProps {
  onStartAddProject: () => void;
  projects: Project[];
}

const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
                                                           onStartAddProject,
                                                           projects,
                                                         }) => {
  return (
          <aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
            <h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
              당신의 프로젝트
            </h2>
            <Button onClick={onStartAddProject}>+프로젝트 추가</Button>
            <ul className="mt-8">
              {projects.map((project) => (
                      <li key={project.id}>
                        <button
                                className="w-full text-left px-2 py-1 rounded-md text-stone-400 hover:text-stone-100 hover:bg-stone-800"
                        >
                          {project.title}
                        </button>
                      </li>
              ))}
            </ul>
          </aside>
  );
};

export default ProjectsSidebar;
// NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';

interface NewProjectProps {
  onAdd: (projectData: {
    title: string;
    description: string;
    dueDate: string;
  }) => void;
}

const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
  const titleRef = useRef<HTMLInputElement>(null);
  const descriptionRef = useRef<HTMLTextAreaElement>(null);
  const dueDateRef = useRef<HTMLInputElement>(null);

  const handleSave = () => {
    const enteredTitle = titleRef.current?.value || '';
    const enteredDescription = descriptionRef.current?.value || '';
    const enteredDueDate = dueDateRef.current?.value || '';

    if (!enteredTitle.trim() || !enteredDescription.trim() || !enteredDueDate.trim()) {
      alert('모든 필드를 입력해주세요.');
      return;
    }

    onAdd({
      title: enteredTitle,
      description: enteredDescription,
      dueDate: enteredDueDate,
    });
  };

  const handleCancel = () => {
    // 취소 버튼 클릭 시 동작 (예: 부모 컴포넌트에 알리기)
  };

  return (
          <div className="w-[35rem] mt-16">
            <menu className="flex items-center justify-end gap-4 my-4">
              <button
                      onClick={handleCancel}
                      className="text-stone-800 hover:text-stone-900"
              >
                취소
              </button>
              <button
                      onClick={handleSave}
                      className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2"
              >
                저장
              </button>
            </menu>
            <div>
              <Input label="Title" ref={titleRef} type="text" />
              <Input label="Description" ref={descriptionRef} isTextarea />
              <Input label="Due Date" ref={dueDateRef} type="date" />
            </div>
          </div>
  );
};

export default NewProject;

139. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 사용자 입력 유효성 검사 & useImperativeHandle로 에러 모달 띄우기

유효성 검사를 통해 사용자가 입력한 값이 유효한지 확인하고, 유효하지 않은 경우 에러 메시지를 모달로 표시하는 기능을 구현했습니다. 이를 위해 useRef를 사용하여 입력 필드에 접근하고, forwardRef와 useImperativeHandle을 활용하여 모달 컴포넌트를 제어할 수 있도록 했습니다. React Portal을 사용하여 모달을 DOM의 특정 위치에 렌더링함으로써 재사용성과 유연성을 높였습니다.

App 컴포넌트에서는 프로젝트 상태를 관리하며, ProjectsSidebar와 NoProjectSelected, NewProject 컴포넌트를 조건부로 렌더링합니다. 사용자가 저장 버튼을 클릭했을 때 입력 값이 유효한지 확인하고, 유효하지 않으면 모달을 통해 에러 메시지를 표시하며, 유효하면 새로운 프로젝트를 추가하고 모달을 닫습니다.

[App 컴포넌트]
      |
      v
[useState로 projectsState 관리]
      |
      v
[handleAddProject 함수 정의]
      |
      v
[handleStartAddProject 함수 정의]
      |
      v
[ProjectsSidebar 컴포넌트에 props 전달]
      |
      +---------------------------+
      |                           |
      v                           v
[ProjectsSidebar 컴포넌트]    [NoProjectSelected 컴포넌트]
      |                           |
      +-- [프로젝트 추가 버튼]     +-- [모달 참조 연결]
      |                           |
      v                           v
[버튼 클릭 시 onStartAddProject 호출]
      |
      v
[App 컴포넌트의 handleStartAddProject 실행]
      |
      v
[selectedProjectId = null]
      |
      v
[App 컴포넌트 렌더링 업데이트]
      |
      v
[NewProject 컴포넌트 렌더링]
      |
      v
[NewProject 컴포넌트 내 handleSave 실행]
      |
      v
[입력 값 유효성 검사]
      |
      +-- 유효하지 않으면 --> [Modal 컴포넌트 열기]
      |
      +-- 유효하면 --> [handleAddProject 호출]
                                   |
                                   v
                       [projectsState.projects에 새 프로젝트 추가]
                                   |
                                   v
                       [selectedProjectId = undefined]
                                   |
                                   v
                       [App 컴포넌트 렌더링 업데이트]
                                   |
                                   v
                       [NoProjectSelected 컴포넌트 렌더링]
// src/components/Modal.tsx
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import ReactDOM from 'react-dom';

interface ModalProps {
  children: React.ReactNode;
}

export interface ModalHandle {
  open: () => void;
  close: () => void;
}

const Modal = forwardRef<ModalHandle, ModalProps>(({ children }, ref) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useImperativeHandle(ref, () => ({
    open: () => {
      dialogRef.current?.showModal();
    },
    close: () => {
      dialogRef.current?.close();
    },
  }));

  return ReactDOM.createPortal(
          <dialog ref={dialogRef} className="modal">
            {children}
            <form method="dialog">
              <button className="mt-4 px-4 py-2 bg-stone-700 text-stone-50 rounded-md hover:bg-stone-600">
                닫기
              </button>
            </form>
          </dialog>,
          document.getElementById('modal-root') as HTMLElement
  );
});

export default Modal;
// src/components/Button.tsx
import React from 'react';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

const Button: React.FC<ButtonProps> = ({ children, ...rest }) => {
  return (
          <button
                  className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100"
                  {...rest}
          >
            {children}
          </button>
  );
};

export default Button;
// src/components/Input.tsx
import React, { forwardRef } from 'react';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
  label: string;
  isTextarea?: boolean;
}

const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
        ({ label, isTextarea, ...rest }, ref) => {
          const classes =
                  'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';

          return (
                  <p className="flex flex-col gap-1 my-4">
                    <label className="text-sm font-bold uppercase text-stone-600">{label}</label>
                    {isTextarea ? (
                            <textarea className={classes} ref={ref as React.Ref<HTMLTextAreaElement>} {...rest}></textarea>
                    ) : (
                            <input className={classes} ref={ref as React.Ref<HTMLInputElement>} {...rest} />
                    )}
                  </p>
          );
        }
);

export default Input;
// src/components/NoProjectSelected.tsx
import React, { useRef } from 'react';
import Button from './Button';
import Modal, { ModalHandle } from './Modal';
import noProjectImage from '../assets/no-projects.png';

interface NoProjectSelectedProps {
  onStartAddProject: () => void;
}

const NoProjectSelected: React.FC<NoProjectSelectedProps> = ({ onStartAddProject }) => {
  const modalRef = useRef<ModalHandle>(null);

  const handleButtonClick = () => {
    onStartAddProject();
  };

  return (
    <>
      <div className="mt-16 text-center w-2/3 mx-auto">
        <img
          src={noProjectImage}
          alt="빈 할 일 목록"
          className="w-16 h-16 object-contain mx-auto"
        />
        <h2 className="text-xl font-bold text-stone-500 my-4">선택된 프로젝트 없음</h2>
        <p className="text-stone-400 mb-4">
          프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오
        </p>
        <p className="mt-8">
          <Button onClick={handleButtonClick}>새 프로젝트 추가</Button>
        </p>
      </div>
      <Modal ref={modalRef}>
        <h2 className="text-lg font-bold mb-2">입력 오류</h2>
        <p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
      </Modal>
    </>
  );
};

export default NoProjectSelected;
// src/components/NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';
import Modal, { ModalHandle } from './Modal';
import Button from './Button';

interface NewProjectProps {
  onAdd: (projectData: {
    title: string;
    description: string;
    dueDate: string;
  }) => void;
}

const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
  const titleRef = useRef<HTMLInputElement>(null);
  const descriptionRef = useRef<HTMLTextAreaElement>(null);
  const dueDateRef = useRef<HTMLInputElement>(null);
  const modalRef = useRef<ModalHandle>(null);

  const handleSave = () => {
    const enteredTitle = titleRef.current?.value.trim() || '';
    const enteredDescription = descriptionRef.current?.value.trim() || '';
    const enteredDueDate = dueDateRef.current?.value.trim() || '';

    if (!enteredTitle || !enteredDescription || !enteredDueDate) {
      modalRef.current?.open();
      return;
    }

    onAdd({
      title: enteredTitle,
      description: enteredDescription,
      dueDate: enteredDueDate,
    });
  };

  const handleCancel = () => {
    // 선택된 프로젝트를 초기화하여 모달을 닫음
    // 추가 로직이 필요할 경우 여기에 작성
  };

  return (
    <>
      <div className="w-[35rem] mt-16">
        <menu className="flex items-center justify-end gap-4 my-4">
          <button
            onClick={handleCancel}
            className="text-stone-800 hover:text-stone-900"
          >
            취소
          </button>
          <button
            onClick={handleSave}
            className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2"
          >
            저장
          </button>
        </menu>
        <div>
          <Input label="Title" ref={titleRef} type="text" />
          <Input label="Description" ref={descriptionRef} isTextarea />
          <Input label="Due Date" ref={dueDateRef} type="date" />
        </div>
      </div>
      <Modal ref={modalRef}>
        <h2 className="text-lg font-bold mb-2">입력 오류</h2>
        <p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
      </Modal>
    </>
  );
};

export default NewProject;
// src/App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';
import Modal, { ModalHandle } from './components/Modal';

interface Project {
  id: number;
  title: string;
  description: string;
  dueDate: string;
}

interface ProjectsState {
  projects: Project[];
  selectedProjectId: number | null | undefined;
}

const App: React.FC = () => {
  const [projectsState, setProjectsState] = useState<ProjectsState>({
    projects: [],
    selectedProjectId: undefined,
  });

  const handleAddProject = (projectData: {
    title: string;
    description: string;
    dueDate: string;
  }) => {
    const newProject: Project = {
      id: Math.random(),
      ...projectData,
    };
    setProjectsState((prevState) => ({
      projects: [...prevState.projects, newProject],
      selectedProjectId: undefined, // 모달 닫기
    }));
  };

  const handleStartAddProject = () => {
    setProjectsState((prevState) => ({
      ...prevState,
      selectedProjectId: null,
    }));
  };

  let content;
  if (projectsState.selectedProjectId === null) {
    content = <NewProject onAdd={handleAddProject} />;
  } else {
    content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
  }

  return (
    <div className="h-screen my-8">
      <main className="flex gap-8">
        <ProjectsSidebar
          onStartAddProject={handleStartAddProject}
          projects={projectsState.projects}
        />
        {content}
      </main>
    </div>
  );
};

export default App;
// src/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>React App</title>
</head>
<body>
  <div id="root"></div>
  <div id="modal-root"></div> <!-- 모달을 렌더링할 대상 DOM 노드 -->
</body>
</html>

140. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 테일윈드 CSS로 모달 스타일링

  • 모달 컴포넌트 생성

    • 에러 메시지를 표시하기 위한 재사용 가능한 모달 컴포넌트 생성.
    • React.forwardRef와 useImperativeHandle을 사용하여 부모 컴포넌트에서 모달을 제어할 수 있도록 함.
    • React.createPortal을 사용하여 모달을 별도의 DOM 노드(modal-root)에 렌더링.
  • 유효성 검사 구현

    • NewProject 컴포넌트에서 저장 버튼 클릭 시 입력값의 유효성을 검사.
    • 입력값이 유효하지 않으면 모달을 열어 에러 메시지 표시.
    • 모든 입력값이 유효하면 프로젝트를 추가하고 NewProject 컴포넌트를 닫음.
  • 취소 버튼 기능 추가

    • 취소 버튼 클릭 시 handleCancelAddProject 함수를 호출하여 selectedProjectId를 undefined로 설정.
    • 이를 통해 NewProject 컴포넌트를 닫고 NoProjectSelected 컴포넌트를 표시.
  • 스타일링 개선

    • 모달 및 버튼에 Tailwind CSS 클래스를 추가하여 디자인 개선.
    • 모달 배경, 패딩, 둥근 모서리, 그림자 등을 적용.
    • 버튼의 색상 및 호버 효과 조정.
  • 프로젝트 목록 사이드바 업데이트

    • projectsState.projects를 ProjectsSidebar 컴포넌트에 전달.
    • 사이드바에서 프로젝트 목록을 동적으로 렌더링.
    • 각 프로젝트마다 선택 버튼 추가.
[사용자]
    |
    v
[ProjectsSidebar 컴포넌트] --클릭--> [App 컴포넌트의 handleStartAddProject]
    |
    v
[App 컴포넌트]
    | (selectedProjectId = null)
    v
[NewProject 컴포넌트]
    |
    |-- 입력값 (title, description, dueDate)
    |
    v
[저장 버튼 클릭]
    |
    v
[NewProject 컴포넌트의 handleSave]
    |
    |-- 유효성 검사
    |    |
    |    |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
    |    |
    |    |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
    |
    v
[App 컴포넌트의 handleAddProject]
    |
    |-- 프로젝트 추가
    |-- selectedProjectId = undefined
    |
    v
[App 컴포넌트]
    |
    v
[NoProjectSelected 컴포넌트]
    |
    |-- 취소 버튼 클릭 --> [App 컴포넌트의 handleCancelAddProject]
    |
    v
[App 컴포넌트]
    | (selectedProjectId = undefined)
    v
[NoProjectSelected 컴포넌트 표시]
// src/components/Modal.tsx
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import ReactDOM from 'react-dom';

interface ModalProps {
  children: React.ReactNode;
}

export interface ModalHandle {
  open: () => void;
  close: () => void;
}

const Modal = forwardRef<ModalHandle, ModalProps>(({ children }, ref) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useImperativeHandle(ref, () => ({
    open: () => {
      dialogRef.current?.showModal();
    },
    close: () => {
      dialogRef.current?.close();
    },
  }));

  return ReactDOM.createPortal(
          <dialog
                  ref={dialogRef}
                  className="backdrop:bg-stone-900/90 p-6 rounded-md shadow-lg"
          >
            {children}
            <form method="dialog">
              <button className="mt-4 px-4 py-2 bg-stone-700 text-stone-50 rounded-md hover:bg-stone-600">
                닫기
              </button>
            </form>
          </dialog>,
          document.getElementById('modal-root') as HTMLElement
  );
});

export default Modal;
// src/components/Button.tsx
import React from 'react';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

const Button: React.FC<ButtonProps> = ({ children, ...rest }) => {
  return (
          <button
                  className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100"
                  {...rest}
          >
            {children}
          </button>
  );
};

export default Button;
// src/components/Input.tsx
import React, { forwardRef } from 'react';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
  label: string;
  isTextarea?: boolean;
}

const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
        ({ label, isTextarea, ...rest }, ref) => {
          const classes =
                  'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';

          return (
                  <p className="flex flex-col gap-1 my-4">
                    <label className="text-sm font-bold uppercase text-stone-600">{label}</label>
                    {isTextarea ? (
                            <textarea className={classes} ref={ref as React.Ref<HTMLTextAreaElement>} {...rest}></textarea>
                    ) : (
                            <input className={classes} ref={ref as React.Ref<HTMLInputElement>} {...rest} />
                    )}
                  </p>
          );
        }
);

export default Input;
// src/components/NoProjectSelected.tsx
import React, { useRef } from 'react';
import Button from './Button';
import Modal, { ModalHandle } from './Modal';
import noProjectImage from '../assets/no-projects.png';

interface NoProjectSelectedProps {
  onStartAddProject: () => void;
}

const NoProjectSelected: React.FC<NoProjectSelectedProps> = ({ onStartAddProject }) => {
  const modalRef = useRef<ModalHandle>(null);

  const handleButtonClick = () => {
    onStartAddProject();
  };

  return (
          <>
            <div className="mt-16 text-center w-2/3 mx-auto">
              <img
                      src={noProjectImage}
                      alt="빈 할 일 목록"
                      className="w-16 h-16 object-contain mx-auto"
              />
              <h2 className="text-xl font-bold text-stone-500 my-4">선택된 프로젝트 없음</h2>
              <p className="text-stone-400 mb-4">
                프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오
              </p>
              <p className="mt-8">
                <Button onClick={handleButtonClick}>새 프로젝트 추가</Button>
              </p>
            </div>
            <Modal ref={modalRef}>
              <h2 className="text-lg font-bold mb-2">입력 오류</h2>
              <p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
            </Modal>
          </>
  );
};

export default NoProjectSelected;
// src/components/NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';
import Modal, { ModalHandle } from './Modal';
import Button from './Button';

interface NewProjectProps {
  onAdd: (projectData: {
    title: string;
    description: string;
    dueDate: string;
  }) => void;
}

const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
  const titleRef = useRef<HTMLInputElement>(null);
  const descriptionRef = useRef<HTMLTextAreaElement>(null);
  const dueDateRef = useRef<HTMLInputElement>(null);
  const modalRef = useRef<ModalHandle>(null);

  const handleSave = () => {
    const enteredTitle = titleRef.current?.value.trim() || '';
    const enteredDescription = descriptionRef.current?.value.trim() || '';
    const enteredDueDate = dueDateRef.current?.value.trim() || '';

    if (!enteredTitle || !enteredDescription || !enteredDueDate) {
      modalRef.current?.open();
      return;
    }

    onAdd({
      title: enteredTitle,
      description: enteredDescription,
      dueDate: enteredDueDate,
    });
  };

  const handleCancel = () => {
    // 부모 컴포넌트에 취소 함수 호출 (추가 구현 필요 시)
  };

  return (
          <>
            <div className="w-[35rem] mt-16">
              <menu className="flex items-center justify-end gap-4 my-4">
                <button
                        onClick={handleCancel}
                        className="text-stone-800 hover:text-stone-900"
                >
                  취소
                </button>
                <button
                        onClick={handleSave}
                        className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2"
                >
                  저장
                </button>
              </menu>
              <div>
                <Input label="Title" ref={titleRef} type="text" />
                <Input label="Description" ref={descriptionRef} isTextarea />
                <Input label="Due Date" ref={dueDateRef} type="date" />
              </div>
            </div>
            <Modal ref={modalRef}>
              <h2 className="text-lg font-bold mb-2">입력 오류</h2>
              <p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
            </Modal>
          </>
  );
};

export default NewProject;
// src/components/ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';

interface Project {
  id: number;
  title: string;
}

interface ProjectsSidebarProps {
  onStartAddProject: () => void;
  projects: Project[];
}

const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
                                                           onStartAddProject,
                                                           projects,
                                                         }) => {
  return (
          <aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
            <h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
              당신의 프로젝트
            </h2>
            <Button onClick={onStartAddProject}>+프로젝트 추가</Button>
            <ul className="mt-8">
              {projects.map((project) => (
                      <li key={project.id} className="mt-4">
                        <button
                                className="w-full text-left px-2 py-1 rounded-md text-stone-400 hover:text-stone-100 hover:bg-stone-800"
                                // 프로젝트 선택 기능 추가 예정
                        >
                          {project.title}
                        </button>
                      </li>
              ))}
            </ul>
          </aside>
  );
};

export default ProjectsSidebar;
// src/App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';

interface Project {
  id: number;
  title: string;
  description: string;
  dueDate: string;
}

interface ProjectsState {
  projects: Project[];
  selectedProjectId: number | null | undefined;
}

const App: React.FC = () => {
  const [projectsState, setProjectsState] = useState<ProjectsState>({
    projects: [],
    selectedProjectId: undefined,
  });

  const handleAddProject = (projectData: {
    title: string;
    description: string;
    dueDate: string;
  }) => {
    const newProject: Project = {
      id: Math.random(),
      ...projectData,
    };
    setProjectsState((prevState) => ({
      projects: [...prevState.projects, newProject],
      selectedProjectId: undefined, // NewProject 컴포넌트 닫기
    }));
  };

  const handleStartAddProject = () => {
    setProjectsState((prevState) => ({
      ...prevState,
      selectedProjectId: null,
    }));
  };

  const handleCancelAddProject = () => {
    setProjectsState((prevState) => ({
      ...prevState,
      selectedProjectId: undefined,
    }));
  };

  let content;
  if (projectsState.selectedProjectId === null) {
    content = <NewProject onAdd={handleAddProject} />;
  } else {
    content = (
      <NoProjectSelected onStartAddProject={handleStartAddProject} />
    );
  }

  return (
    <div className="h-screen my-8">
      <main className="flex gap-8">
        <ProjectsSidebar
          onStartAddProject={handleStartAddProject}
          projects={projectsState.projects}
        />
        {content}
      </main>
    </div>
  );
};

export default App;
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>React App</title>
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div> <!-- 모달을 렌더링할 대상 DOM 노드 -->
</body>
</html>

141. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 선택 가능한 프로젝트 구현 및 프로젝트 정보 보기

[사용자]
    |
    v
[ProjectsSidebar 컴포넌트]
    | (프로젝트 추가 버튼 클릭)
    v
[App 컴포넌트의 handleStartAddProject 호출]
    |
    v
[selectedProjectId = null]
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NewProject 컴포넌트]
    |
    |-- 입력값 (title, description, dueDate)
    |
    v
[저장 버튼 클릭]
    |
    v
[NewProject 컴포넌트의 handleSave 실행]
    |
    |-- 유효성 검사
    |    |
    |    |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
    |    |
    |    |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
    |
    v
[App 컴포넌트의 handleAddProject 실행]
    |
    |-- 프로젝트 추가
    |-- selectedProjectId = undefined
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NoProjectSelected 컴포넌트]
    |
    |-- 취소 버튼 클릭 --> [App 컴포넌트의 handleCancelAddProject 호출]
    |
    v
[App 컴포넌트]
    | (selectedProjectId = undefined)
    v
[NoProjectSelected 컴포넌트 표시]
    |
    |-- 프로젝트 목록 렌더링
    |-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
    |
    v
[App 컴포넌트의 handleSelectProject 실행]
    |
    |-- selectedProjectId = 선택된 프로젝트의 ID
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[SelectedProject 컴포넌트 표시]
    |
    |-- 프로젝트 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출 예정]

142. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 프로젝트 삭제 핸들링

[사용자]
    |
    v
[ProjectsSidebar 컴포넌트]
    | (프로젝트 추가 버튼 클릭)
    v
[App 컴포넌트의 handleStartAddProject 호출]
    |
    v
[selectedProjectId = null]
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NewProject 컴포넌트]
    |
    |-- 입력값 (title, description, dueDate)
    |
    v
[저장 버튼 클릭]
    |
    v
[NewProject 컴포넌트의 handleSave 실행]
    |
    |-- 유효성 검사
    |    |
    |    |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
    |    |
    |    |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
    |
    v
[App 컴포넌트의 handleAddProject 실행]
    |
    |-- 프로젝트 추가
    |-- selectedProjectId = undefined
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NoProjectSelected 컴포넌트]
    |
    |-- 프로젝트 목록 렌더링
    |-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
    |
    v
[App 컴포넌트의 handleSelectProject 실행]
    |
    |-- selectedProjectId = 선택된 프로젝트의 ID
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[SelectedProject 컴포넌트 표시]
    |
    |-- 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출]
    |
    v
[App 컴포넌트의 handleDeleteProject 실행]
    |
    |-- 프로젝트 삭제
    |-- selectedProjectId = undefined
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NoProjectSelected 컴포넌트 표시]

143. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / "프로젝트 태스크" 및 태스크 컴포넌트 추가

[사용자]
    |
    v
[ProjectsSidebar 컴포넌트]
    | (프로젝트 추가 버튼 클릭)
    v
[App 컴포넌트의 handleStartAddProject 호출]
    |
    v
[selectedProjectId = null]
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NewProject 컴포넌트]
    |
    |-- 입력값 (title, description, dueDate)
    |
    v
[저장 버튼 클릭]
    |
    v
[NewProject 컴포넌트의 handleSave 실행]
    |
    |-- 유효성 검사
    |    |
    |    |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
    |    |
    |    |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
    |
    v
[App 컴포넌트의 handleAddProject 실행]
    |
    |-- 프로젝트 추가
    |-- selectedProjectId = undefined
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NoProjectSelected 컴포넌트]
    |
    |-- 프로젝트 목록 렌더링
    |-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
    |
    v
[App 컴포넌트의 handleSelectProject 실행]
    |
    |-- selectedProjectId = 선택된 프로젝트의 ID
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[SelectedProject 컴포넌트 표시]
    |
    |-- 할 일 추가 --> [SelectedProject 컴포넌트의 handleAddTask 호출]
    |-- 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출]
    |
    v
[App 컴포넌트의 handleAddTask 실행]
    |
    |-- 선택된 프로젝트의 할 일 목록에 새 할 일 추가
    |
    v
[SelectedProject 컴포넌트 렌더링 업데이트]
    |
    v
[Tasks 컴포넌트 및 할 일 목록 업데이트]
    |
    |-- 할 일 추가 버튼 클릭 --> [NewTask 컴포넌트의 handleAddTask 호출]
    |
    v
[NewTask 컴포넌트의 handleAddTask 실행]
    |
    |-- App 컴포넌트의 handleAddTask 호출
    |
    v
[App 컴포넌트의 handleAddTask 실행]
    |
    |-- 선택된 프로젝트의 할 일 목록에 새 할 일 추가
    |
    v
[SelectedProject 컴포넌트 렌더링 업데이트]
    |
    v
[Tasks 컴포넌트 및 할 일 목록 업데이트]

144. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 태스크 관리 & Prop Drilling 이해하기

  • App 컴포넌트
    • 전체 프로젝트 상태(projectsState) 관리.
    • 프로젝트 추가, 선택, 취소, 삭제, 할 일 추가, 할 일 삭제를 처리하는 함수(handleAddProject, handleStartAddProject, handleCancelAddProject, handleSelectProject, handleDeleteProject, handleAddTask, handleDeleteTask) 정의.
    • 조건부 렌더링을 통해 NewProject, NoProjectSelected, SelectedProject 컴포넌트를 표시.
[사용자]
    |
    v
[ProjectsSidebar 컴포넌트]
    | (프로젝트 추가 버튼 클릭)
    v
[App 컴포넌트의 handleStartAddProject 호출]
    |
    v
[selectedProjectId = null]
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NewProject 컴포넌트]
    |
    |-- 입력값 (title, description, dueDate)
    |
    v
[저장 버튼 클릭]
    |
    v
[NewProject 컴포넌트의 handleSave 실행]
    |
    |-- 유효성 검사
    |    |
    |    |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
    |    |
    |    |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
    |
    v
[App 컴포넌트의 handleAddProject 실행]
    |
    |-- 프로젝트 추가
    |-- selectedProjectId = undefined
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[NoProjectSelected 컴포넌트]
    |
    |-- 프로젝트 목록 렌더링
    |-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
    |
    v
[App 컴포넌트의 handleSelectProject 실행]
    |
    |-- selectedProjectId = 선택된 프로젝트의 ID
    |
    v
[App 컴포넌트 렌더링 업데이트]
    |
    v
[SelectedProject 컴포넌트 표시]
    |
    |-- 할 일 추가 --> [SelectedProject 컴포넌트의 handleAddTask 호출]
    |-- 할 일 삭제 --> [SelectedProject 컴포넌트의 handleDeleteTask 호출]
    |-- 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출]
    |
    v
[App 컴포넌트의 handleAddTask 실행]
    |
    |-- 선택된 프로젝트의 할 일 목록에 새 할 일 추가
    |
    v
[SelectedProject 컴포넌트 렌더링 업데이트]
    |
    v
[Tasks 컴포넌트 및 할 일 목록 업데이트]
    |
    |-- 할 일 삭제 버튼 클릭 --> [Tasks 컴포넌트의 handleDeleteTask 호출]
    |
    v
[App 컴포넌트의 handleDeleteTask 실행]
    |
    |-- 선택된 프로젝트의 할 일 목록에서 특정 할 일 삭제
    |
    v
[SelectedProject 컴포넌트 렌더링 업데이트]
    |
    v
[Tasks 컴포넌트 및 할 일 목록 업데이트]

145. 프로젝트 관리 앱(컴포넌트, 상태, 참조 등 적용) / 태스크 지우기 & 사소한 버그 고치기

  • 문제 해결 및 검증

    1. 할 일 삭제 기능 확인

      • 애플리케이션을 실행한 후, 새로운 프로젝트를 생성하고 선택합니다.
      • 할 일 섹션에서 새로운 할 일을 추가하고, 추가된 할 일이 목록에 정상적으로 표시되는지 확인합니다.
      • 각 할 일 항목 옆의 삭제 버튼을 클릭하여 해당 할 일이 목록에서 제거되는지 확인합니다.
      • 모든 할 일을 삭제한 후, 할 일 목록이 없습니다.라는 메시지가 표시되는지 확인합니다.
    2. 빈 할 일 추가 방지 확인

      • 할 일 입력란에 아무 것도 입력하지 않고 할 일 추가 버튼을 클릭합니다.
      • 빈 할 일이 추가되지 않고, 에러 메시지가 표시되는지 확인합니다.
      • 공백만 입력하고 추가하려고 할 때도 같은 방식으로 처리되는지 확인합니다.
    3. 선택된 프로젝트 강조 표시 확인

      • 여러 개의 프로젝트를 생성한 후, 사이드바에서 프로젝트를 선택할 때 선택된 프로젝트가 강조 표시되는지 확인합니다.
      • 선택되지 않은 프로젝트는 기본 스타일이 적용되고, 선택된 프로젝트는 강조된 스타일이 적용되는지 확인합니다.
  • 개선 사항 및 다음 단계

    1. 상태 관리 최적화

      • 현재 App 컴포넌트는 다양한 상태와 함수를 관리하고 있으며, 이를 여러 컴포넌트로 전달하기 위해 프로퍼티 내리꽂기(prop drilling)를 사용하고 있습니다. 이로 인해 App 컴포넌트가 복잡해지고 유지보수가 어려워질 수 있습니다.
      • 개선 방안:
        • Context API 도입: React의 Context API를 사용하여 상태와 함수를 전역적으로 관리하고, 필요한 컴포넌트에서 직접 접근할 수 있도록 합니다.
        • 상태 관리 라이브러리 사용: Redux, Zustand, Recoil 등의 라이브러리를 도입하여 상태 관리를 더욱 효율적으로 수행할 수 있습니다.
    2. 할 일 수정 및 완료 기능 추가

      • 할 일 수정 기능: 사용자가 기존 할 일의 내용을 수정할 수 있는 기능을 추가합니다. 예를 들어, 할 일 텍스트를 클릭하면 수정 가능한 입력 필드로 전환되고, 변경된 내용을 저장할 수 있도록 합니다.
      • 할 일 완료 기능: 사용자가 할 일을 완료로 표시할 수 있는 기능을 추가합니다. 완료된 할 일은 시각적으로 구분되도록 스타일링합니다.
      • 할 일 필터링 기능: 완료된 할 일과 미완료된 할 일을 필터링하여 표시할 수 있는 기능을 추가합니다.
    3. 에러 모달 개선

      • 현재 NewProject 컴포넌트에서 에러 모달을 단순히 alert로 처리하고 있습니다. 이를 더욱 발전시켜 사용자에게 친절한 에러 메시지를 표시할 수 있도록 Modal 컴포넌트를 활용합니다.
    4. UI/UX 개선

      • 반응형 디자인: 다양한 디바이스에서 최적의 사용자 경험을 제공할 수 있도록 반응형 디자인을 적용합니다.
      • 접근성 향상: 키보드 네비게이션, ARIA 속성 등을 추가하여 접근성을 향상시킵니다.
      • 디자인 일관성 유지: 전체 애플리케이션의 디자인 일관성을 유지하여 사용자에게 일관된 경험을 제공합니다.

146. context API & uesReducer / 모듈 소개

  • 주요 학습 목표

    1. 프로퍼티 내리꽂기(Prop Drilling) 이해 및 문제점 파악
    2. 리액트의 컨텍스트(Context) API 소개 및 활용
    3. 리듀서(Reducer) 및 useReducer 훅을 사용한 복잡한 상태 관리
    4. 컨텍스트와 리듀서를 결합하여 효율적인 상태 관리 구현
  • 프로퍼티 내리꽂기(Prop Drilling)란 무엇인가?

    • 프로퍼티 내리꽂기(prop drilling)는 부모 컴포넌트에서 깊숙이 위치한 자식 컴포넌트로 데이터를 전달하기 위해, 중간에 있는 여러 컴포넌트를 통해 props를 계속해서 전달해야 하는 상황을 말합니다. 이는 다음과 같은 문제를 야기할 수 있습니다:
      • 유지보수의 어려움: 컴포넌트 트리가 깊어질수록 중간 컴포넌트들이 불필요하게 많은 props를 전달해야 합니다.
      • 재사용성 감소: 중간 컴포넌트들이 특정 props에 의존하게 되어, 해당 컴포넌트를 다른 곳에서 재사용하기 어려워집니다.
      • 코드 복잡성 증가: props 전달이 복잡해져 코드의 가독성과 관리가 어려워집니다.
  • 컨텍스트(Context) API를 사용한 상태 관리

    • 컨텍스트(Context) API는 리액트에서 전역적으로 데이터를 관리하고, 컴포넌트 트리의 어느 곳에서든 데이터에 접근할 수 있도록 도와줍니다. 이를 통해 프로퍼티 내리꽂기를 피할 수 있습니다.
    • 컨텍스트의 기본 개념
      • Context 생성: 데이터를 제공하는 곳에서 컨텍스트를 생성합니다.
      • Provider 사용: 생성한 컨텍스트의 Provider를 사용하여 데이터를 하위 컴포넌트에 제공합니다.
      • Consumer 사용: 하위 컴포넌트에서 useContext 훅을 사용하여 데이터를 소비합니다.
  • 리듀서(Reducer) 및 useReducer 훅을 사용한 상태 관리

    • useState 훅은 간단한 상태 관리에 적합하지만, 상태가 복잡해지면 여러 useState를 사용하거나 복잡한 상태 업데이트 로직을 작성해야 합니다. 이럴 때 useReducer 훅을 사용하여 더 체계적으로 상태를 관리할 수 있습니다.
    • 리듀서의 개념
      • 리듀서(Reducer)는 현재 상태와 액션을 받아 새로운 상태를 반환하는 함수입니다. 이는 상태 업데이트 로직을 분리하여 코드의 가독성과 유지보수성을 높여줍니다.
    • useReducer 훅 사용 예시
      • 리듀서를 사용하여 프로젝트와 할 일의 상태를 관리하도록 컨텍스트를 수정해보겠습니다.
// 1. 리듀서 함수 정의
// src/context/ProjectReducer.ts
import { Project, Task } from '../types';

export type Action =
  | { type: 'ADD_PROJECT'; payload: { title: string; description: string; dueDate: string } }
  | { type: 'DELETE_PROJECT'; payload: { id: number } }
  | { type: 'SELECT_PROJECT'; payload: { id: number | null } }
  | { type: 'ADD_TASK'; payload: { projectId: number; taskContent: string } }
  | { type: 'DELETE_TASK'; payload: { projectId: number; taskId: number } };

export interface ProjectState {
  projects: Project[];
  selectedProjectId: number | null;
}

export const projectReducer = (state: ProjectState, action: Action): ProjectState => {
  switch (action.type) {
    case 'ADD_PROJECT':
      const newProject: Project = {
        id: Math.random(),
        title: action.payload.title,
        description: action.payload.description,
        dueDate: action.payload.dueDate,
        tasks: [],
      };
      return {
        ...state,
        projects: [...state.projects, newProject],
        selectedProjectId: null,
      };
    case 'DELETE_PROJECT':
      return {
        ...state,
        projects: state.projects.filter((project) => project.id !== action.payload.id),
        selectedProjectId:
          state.selectedProjectId === action.payload.id ? null : state.selectedProjectId,
      };
    case 'SELECT_PROJECT':
      return {
        ...state,
        selectedProjectId: action.payload.id,
      };
    case 'ADD_TASK':
      return {
        ...state,
        projects: state.projects.map((project) =>
          project.id === action.payload.projectId
            ? {
                ...project,
                tasks: [
                  ...project.tasks,
                  { id: Math.random(), content: action.payload.taskContent },
                ],
              }
            : project
        ),
      };
    case 'DELETE_TASK':
      return {
        ...state,
        projects: state.projects.map((project) =>
          project.id === action.payload.projectId
            ? {
                ...project,
                tasks: project.tasks.filter((task) => task.id !== action.payload.taskId),
              }
            : project
        ),
      };
    default:
      return state;
  }
};
// 2. useReducer를 사용하여 상태 관리 로직을 리듀서로 이전
// src/context/ProjectContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { Project, Task } from '../types';
import { projectReducer, ProjectState, Action } from './ProjectReducer';

interface ProjectContextType extends ProjectState {
  dispatch: React.Dispatch<Action>;
}

const ProjectContext = createContext<ProjectContextType | undefined>(undefined);

export const useProjectContext = (): ProjectContextType => {
  const context = useContext(ProjectContext);
  if (!context) {
    throw new Error('useProjectContext must be used within a ProjectProvider');
  }
  return context;
};

interface ProjectProviderProps {
  children: ReactNode;
}

export const ProjectProvider: React.FC<ProjectProviderProps> = ({ children }) => {
  const initialState: ProjectState = {
    projects: [],
    selectedProjectId: null,
  };

  const [state, dispatch] = useReducer(projectReducer, initialState);

  return (
    <ProjectContext.Provider value={{ ...state, dispatch }}>
      {children}
    </ProjectContext.Provider>
  );
};
// 3. 컨텍스트의 디스패치 함수를 사용하여 상태를 업데이트
// src/App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';
import SelectedProject from './components/SelectedProject';
import { ProjectProvider, useProjectContext } from './context/ProjectContext';

const AppContent: React.FC = () => {
  const {
    projects,
    selectedProjectId,
    dispatch,
  } = useProjectContext();

  const selectedProject = projects.find((project) => project.id === selectedProjectId);

  const handleAddProject = (projectData: { title: string; description: string; dueDate: string }) => {
    dispatch({ type: 'ADD_PROJECT', payload: projectData });
  };

  const handleCancelAddProject = () => {
    dispatch({ type: 'SELECT_PROJECT', payload: { id: null } });
  };

  const handleStartAddProject = () => {
    dispatch({ type: 'SELECT_PROJECT', payload: { id: null } });
  };

  const handleDeleteProject = (id: number) => {
    dispatch({ type: 'DELETE_PROJECT', payload: { id } });
  };

  const handleAddTask = (projectId: number, taskContent: string) => {
    dispatch({ type: 'ADD_TASK', payload: { projectId, taskContent } });
  };

  const handleDeleteTask = (projectId: number, taskId: number) => {
    dispatch({ type: 'DELETE_TASK', payload: { projectId, taskId } });
  };

  let content;
  if (selectedProjectId === null) {
    content = <NewProject onAdd={handleAddProject} onCancel={handleCancelAddProject} />;
  } else if (selectedProjectId !== undefined) {
    if (selectedProject) {
      content = (
        <SelectedProject
          project={selectedProject}
          onDelete={handleDeleteProject}
          onAddTask={handleAddTask}
          onDeleteTask={handleDeleteTask}
        />
      );
    } else {
      content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
    }
  } else {
    content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
  }

  return (
    <div className="h-screen my-8">
      <main className="flex gap-8">
        <ProjectsSidebar
          projects={projects}
          selectedProjectId={selectedProjectId}
          onSelectProject={(id) => dispatch({ type: 'SELECT_PROJECT', payload: { id } })}
        />
        {content}
      </main>
    </div>
  );
};

const App: React.FC = () => {
  return (
    <ProjectProvider>
      <AppContent />
    </ProjectProvider>
  );
};

export default App;
// 4. 컨텍스트를 통해 전달받은 projects, selectedProjectId, onSelectProject를 사용
// src/components/ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';
import { Project } from '../types';

interface ProjectsSidebarProps {
  projects: Project[];
  selectedProjectId: number | null;
  onSelectProject: (id: number) => void;
}

const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
  projects,
  selectedProjectId,
  onSelectProject,
}) => {
  return (
    <aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
      <h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
        당신의 프로젝트
      </h2>
      <Button onClick={() => onSelectProject(-1)}>+프로젝트 추가</Button>
      <ul className="mt-8">
        {projects.map((project) => {
          const isSelected = project.id === selectedProjectId;
          const buttonClasses = isSelected
            ? 'w-full text-left px-2 py-1 rounded-md bg-stone-800 text-stone-200 hover:bg-stone-900 hover:text-stone-50'
            : 'w-full text-left px-2 py-1 rounded-md text-stone-400 hover:text-stone-100 hover:bg-stone-800';

          return (
            <li key={project.id} className="mt-4">
              <button
                className={buttonClasses}
                onClick={() => onSelectProject(project.id)}
              >
                {project.title}
              </button>
            </li>
          );
        })}
      </ul>
    </aside>
  );
};

export default ProjectsSidebar;
// 5. Tasks 컴포넌트는 할 일 추가와 삭제를 위한 함수를 받음
// src/components/Tasks.tsx
import React from 'react';
import NewTask from './NewTask';
import Button from './Button';
import { Task } from '../types';

interface TasksProps {
  tasks: Task[];
  onAddTask: (taskContent: string) => void;
  onDeleteTask: (taskId: number) => void;
}

const Tasks: React.FC<TasksProps> = ({ tasks, onAddTask, onDeleteTask }) => {
  return (
    <section className="mt-8">
      <h2 className="text-2xl font-bold text-stone-700 mb-4">할 일</h2>
      <NewTask onAddTask={onAddTask} />
      {tasks.length === 0 ? (
        <p className="text-stone-800">이 프로젝트에는 아직 할 일 목록이 없습니다.</p>
      ) : (
        <ul className="list-disc list-inside bg-stone-100 p-4 rounded-md">
          {tasks.map((task) => (
            <li key={task.id} className="flex items-center justify-between text-stone-600 my-2">
              <span>{task.content}</span>
              <Button
                onClick={() => onDeleteTask(task.id)}
                className="bg-red-500 text-white hover:bg-red-600 px-2 py-1 rounded-md text-sm"
              >
                삭제
              </Button>
            </li>
          ))}
        </ul>
      )}
    </section>
  );
};

export default Tasks;
// 6. SelectedProject는 프로젝트의 할 일 관리와 삭제를 담당
// src/components/SelectedProject.tsx
import React from 'react';
import Button from './Button';
import Tasks from './Tasks';
import { Project } from '../types';

interface SelectedProjectProps {
  project: Project;
  onDelete: (id: number) => void;
  onAddTask: (projectId: number, taskContent: string) => void;
  onDeleteTask: (projectId: number, taskId: number) => void;
}

const SelectedProject: React.FC<SelectedProjectProps> = ({
  project,
  onDelete,
  onAddTask,
  onDeleteTask,
}) => {
  const formattedDate = new Date(project.dueDate).toLocaleDateString('ko-KR', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
  });

  const handleDelete = () => {
    onDelete(project.id);
  };

  const handleAddTask = (taskContent: string) => {
    onAddTask(project.id, taskContent);
  };

  const handleDeleteTask = (taskId: number) => {
    onDeleteTask(project.id, taskId);
  };

  return (
    <div className="w-[35rem] mt-16 p-4 mb-4 border border-stone-300 rounded-md">
      <div className="flex items-center justify-between mb-4">
        <h1 className="text-3xl font-bold text-stone-600">{project.title}</h1>
        <button
          onClick={handleDelete}
          className="text-stone-600 hover:text-stone-950"
        >
          삭제
        </button>
      </div>
      <p className="mb-4 text-stone-400">마감일: {formattedDate}</p>
      <p className="text-stone-600 whitespace-pre-wrap mb-6">{project.description}</p>
      <Tasks tasks={project.tasks} onAddTask={handleAddTask} onDeleteTask={handleDeleteTask} />
    </div>
  );
};

export default SelectedProject;
  • 리듀서와 useReducer 훅을 사용하면 다음과 같은 장점이 있습니다:
    • 상태 업데이트 로직의 중앙집중화: 모든 상태 업데이트 로직을 리듀서 함수 내에 모아둠으로써 코드의 일관성과 가독성을 높일 수 있습니다.
    • 복잡한 상태 관리에 적합: 여러 상태 값이 상호 연관되어 있거나 복잡한 상태 변환이 필요한 경우, useReducer가 더 적합합니다.
    • 예측 가능한 상태 변화: 액션과 리듀서를 통해 상태가 변하기 때문에, 상태 변화가 예측 가능하고 디버깅이 용이합니다.

146. context API & uesReducer / Prop Drilling 이해

// 프로젝트 구조 예시
App
├── Header
│   └── CartModal
├── Shop
│   └── ProductList
│       └── Product
  • App: 전체 애플리케이션의 상태를 관리하는 최상위 컴포넌트.

  • Header: 사이트의 헤더로, 장바구니 아이콘을 클릭하면 CartModal이 열립니다.

  • Shop: 상품 목록을 보여주는 컴포넌트.

  • ProductList: 여러 개의 Product 컴포넌트를 포함하는 리스트.

  • Product: 개별 상품을 표시하며, 장바구니에 추가할 수 있는 버튼을 가집니다.

  • 문제 상황 시뮬레이션

    1. 장바구니 상태 관리: App 컴포넌트에서 장바구니의 상태(상품 목록)를 관리합니다.
    2. 상태 전달:
      • App에서 Header와 Shop 컴포넌트로 장바구니 상태와 관련 함수를 props로 전달합니다.
      • Header는 CartModal로 장바구니 상태를 전달하여 표시합니다.
      • Shop은 ProductList로, 그리고 다시 Product 컴포넌트로 장바구니 추가 함수를 전달합니다.
    • 위 예시에서 볼 수 있듯이, App 컴포넌트에서 addToCart 함수가 Shop → ProductList → Product로 전달됩니다. 이처럼 여러 단계의 컴포넌트를 거쳐 props를 전달해야 하는 상황이 발생하며, 이는 프로퍼티 내리꽂기의 대표적인 예시입니다.
  • 프로퍼티 내리꽂기의 문제점

    • 프로퍼티 내리꽂기는 작은 규모의 애플리케이션에서는 크게 문제가 되지 않을 수 있지만, 애플리케이션이 커지고 컴포넌트 트리가 깊어질수록 다음과 같은 문제점이 발생합니다:
      1. 유지보수의 어려움: 상태를 전달하는 컴포넌트가 많아질수록 props 전달을 관리하기 어려워집니다.
      2. 컴포넌트의 재사용성 저하: 중간 컴포넌트들이 불필요하게 props를 전달하게 되어, 해당 컴포넌트들을 다른 곳에서 재사용하기 어렵습니다.
      3. 코드 복잡성 증가: props를 계속해서 전달해야 하기 때문에 코드가 지저분해지고 가독성이 떨어집니다.
      4. 성능 문제: 불필요하게 많은 컴포넌트들이 props 변경을 감지하고 리렌더링될 수 있습니다.
  • 프로퍼티 내리꽂기 해결 방안

    • 프로퍼티 내리꽂기를 해결하기 위한 대표적인 방법은 리액트의 컨텍스트(Context) API를 활용하는 것입니다. 이를 통해 전역적으로 상태를 관리하고, 필요한 컴포넌트에서 직접 상태에 접근할 수 있게 됩니다. 또한, 리듀서(Reducer)와 useReducer 훅을 결합하면 더욱 복잡한 상태 관리도 효율적으로 처리할 수 있습니다.
    • 컨텍스트(Context) API란?
      • 컨텍스트(Context) API는 리액트에서 전역적으로 데이터를 관리하고, 컴포넌트 트리의 어느 곳에서든 데이터에 접근할 수 있도록 도와주는 기능입니다. 이를 통해 프로퍼티 내리꽂기를 피할 수 있으며, 특정 데이터를 여러 컴포넌트에서 공유할 때 유용하게 사용할 수 있습니다.
  • 컨텍스트의 주요 구성 요소

    • Context 생성: React.createContext를 사용하여 새로운 컨텍스트를 생성합니다.
    • Provider 컴포넌트: 컨텍스트의 Provider를 사용하여 데이터를 하위 컴포넌트에 제공합니다.
    • Consumer 컴포넌트 또는 useContext 훅: 하위 컴포넌트에서 컨텍스트의 데이터를 소비합니다.
// 1. CartContext를 생성하여 장바구니 상태와 관련 함수를 관리
// src/context/CartContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface Product {
  id: number;
  name: string;
  price: number;
}

interface CartContextType {
  cart: Product[];
  addToCart: (product: Product) => void;
  removeFromCart: (productId: number) => void;
}

const CartContext = createContext<CartContextType | undefined>(undefined);

export const useCart = (): CartContextType => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
};

interface CartProviderProps {
  children: ReactNode;
}

export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
  const [cart, setCart] = useState<Product[]>([]);

  const addToCart = (product: Product) => {
    setCart([...cart, product]);
  };

  const removeFromCart = (productId: number) => {
    setCart(cart.filter(product => product.id !== productId));
  };

  return (
    <CartContext.Provider value={{ cart, addToCart, removeFromCart }}>
      {children}
    </CartContext.Provider>
  );
};
// 2. App 컴포넌트를 CartProvider로 감싸서 모든 하위 컴포넌트에서 장바구니 상태에 접근할 수 있도록 함
// App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './context/CartContext';

const App: React.FC = () => {
  return (
          <CartProvider>
            <Header />
            <Shop />
            <CartModal />
          </CartProvider>
  );
};

export default App;
// 3. Header 컴포넌트에서 장바구니 상태를 소비하여 장바구니 아이콘에 상품 수를 표시
// Header.tsx
import React from 'react';
import { useCart } from '../context/CartContext';

const Header: React.FC = () => {
  const { cart } = useCart();

  return (
          <header>
            <h1>온라인 쇼핑몰</h1>
            <div>
              <span>장바구니: {cart.length}</span>
              {/* 장바구니 아이콘 클릭 시 CartModal 열기 */}
            </div>
          </header>
  );
};

export default Header;
// 4. Shop 컴포넌트에서 addToCart 함수를 사용하여 상품을 장바구니에 추가
// Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { useCart } from '../context/CartContext';

interface Product {
  id: number;
  name: string;
  price: number;
}

const Shop: React.FC = () => {
  const { addToCart } = useCart();

  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    // 추가 상품...
  ];

  return (
          <div>
            <h2>상품 목록</h2>
            <ProductList products={products} addToCart={addToCart} />
          </div>
  );
};

export default Shop;
// 5. Product 컴포넌트에서 직접적으로 컨텍스트를 사용할 수도 있지만, 이번 예시에서는 Shop에서 전달받은 addToCart 함수를 사용
// Product.tsx
import React from 'react';
import { Product } from '../types';

interface ProductProps {
  product: Product;
  addToCart: (product: Product) => void;
}

const Product: React.FC<ProductProps> = ({ product, addToCart }) => {
  return (
    <li>
      <span>{product.name} - {product.price}</span>
      <button onClick={() => addToCart(product)}>장바구니에 추가</button>
    </li>
  );
};

export default Product;
// 6. CartModal 컴포넌트에서 장바구니의 내용을 표시하고, 삭제 기능을 구현
// CartModal.tsx
import React from 'react';
import { useCart } from '../context/CartContext';

const CartModal: React.FC = () => {
  const { cart, removeFromCart } = useCart();

  return (
          <div className="modal">
            <h2>장바구니</h2>
            {cart.length === 0 ? (
                    <p>장바구니가 비었습니다.</p>
            ) : (
                    <ul>
                      {cart.map(product => (
                              <li key={product.id}>
                                <span>{product.name} - {product.price}</span>
                                <button onClick={() => removeFromCart(product.id)}>삭제</button>
                              </li>
                      ))}
                    </ul>
            )}
            {/* 결제 버튼 등 추가 기능 */}
          </div>
  );
};

export default CartModal;
  • 컨텍스트(Context) API의 장점

    • 프로퍼티 내리꽂기 방지: 중간 컴포넌트를 거치지 않고 필요한 컴포넌트에서 직접적으로 상태에 접근할 수 있습니다.
    • 코드 간결화: props 전달을 줄여 코드가 더 간결해지고, 컴포넌트의 재사용성이 높아집니다.
    • 전역 상태 관리: 애플리케이션 전반에 걸쳐 공유되는 상태를 효율적으로 관리할 수 있습니다.
  • 리듀서(Reducer)와 useReducer 훅 소개

    • 리듀서(Reducer)는 상태 업데이트 로직을 중앙집중화하여, 복잡한 상태 변화를 효율적으로 관리할 수 있게 도와줍니다. useReducer 훅은 useState보다 더 복잡한 상태 로직을 처리할 때 유용하게 사용할 수 있습니다.
    • 리듀서의 개념
      • 리듀서는 현재 상태와 액션을 받아 새로운 상태를 반환하는 순수 함수입니다. 이를 통해 상태 변화를 예측 가능하게 만들고, 코드의 유지보수성을 높일 수 있습니다.
src
├── components
│   ├── Header.tsx
│   ├── Shop.tsx
│   ├── ProductList.tsx
│   ├── Product.tsx
│   └── CartModal.tsx
├── context
│   ├── CartContext.tsx
│   └── CartReducer.ts
├── types.ts
├── App.tsx
└── index.tsx
// src/types.ts
export interface Product {
  id: number;
  name: string;
  price: number;
}
// src/context/CartReducer.ts
import { Product } from '../types';

export type CartAction =
  | { type: 'ADD_TO_CART'; payload: Product }
  | { type: 'REMOVE_FROM_CART'; payload: { id: number } };

export const cartReducer = (state: Product[], action: CartAction): Product[] => {
  switch (action.type) {
    case 'ADD_TO_CART':
      return [...state, action.payload];
    case 'REMOVE_FROM_CART':
      return state.filter(product => product.id !== action.payload.id);
    default:
      return state;
  }
};
// src/context/CartContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { Product } from '../types';
import { cartReducer, CartAction } from './CartReducer';

interface CartContextType {
  cart: Product[];
  addToCart: (product: Product) => void;
  removeFromCart: (productId: number) => void;
}

const CartContext = createContext<CartContextType | undefined>(undefined);

export const useCart = (): CartContextType => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
};

interface CartProviderProps {
  children: ReactNode;
}

export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
  const [cart, dispatch] = useReducer(cartReducer, []);

  const addToCart = (product: Product) => {
    dispatch({ type: 'ADD_TO_CART', payload: product });
  };

  const removeFromCart = (productId: number) => {
    dispatch({ type: 'REMOVE_FROM_CART', payload: { id: productId } });
  };

  return (
          <CartContext.Provider value={{ cart, addToCart, removeFromCart }}>
            {children}
          </CartContext.Provider>
  );
};
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './context/CartContext';

const App: React.FC = () => {
  return (
    <CartProvider>
      <Header />
      <Shop />
      <CartModal />
    </CartProvider>
  );
};

export default App;
// src/components/Header.tsx
import React from 'react';
import { useCart } from '../context/CartContext';

const Header: React.FC = () => {
  const { cart } = useCart();

  return (
    <header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
      <h1>온라인 쇼핑몰</h1>
      <div>
        <span>장바구니: {cart.length}</span>
        {/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
      </div>
    </header>
  );
};

export default Header;
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { useCart } from '../context/CartContext';
import { Product } from '../types';

const Shop: React.FC = () => {
  const { addToCart } = useCart();

  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    { id: 3, name: '상품 C', price: 30000 },
    // 추가 상품...
  ];

  return (
    <div style={{ padding: '20px' }}>
      <h2>상품 목록</h2>
      <ProductList products={products} addToCart={addToCart} />
    </div>
  );
};

export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';

interface ProductListProps {
  products: ProductType[];
  addToCart: (product: ProductType) => void;
}

const ProductList: React.FC<ProductListProps> = ({ products, addToCart }) => {
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {products.map(product => (
        <Product key={product.id} product={product} addToCart={addToCart} />
      ))}
    </ul>
  );
};

export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product } from '../types';

interface ProductProps {
  product: Product;
  addToCart: (product: Product) => void;
}

const Product: React.FC<ProductProps> = ({ product, addToCart }) => {
  return (
    <li style={{ marginBottom: '10px' }}>
      <span>{product.name} - {product.price}</span>
      <button style={{ marginLeft: '10px' }} onClick={() => addToCart(product)}>장바구니에 추가</button>
    </li>
  );
};

export default Product;
// src/components/CartModal.tsx
import React from 'react';
import { useCart } from '../context/CartContext';
import { Product } from '../types';

const CartModal: React.FC = () => {
  const { cart, removeFromCart } = useCart();

  // 모달 표시 로직은 추후 추가 가능
  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {cart.length === 0 ? (
        <p>장바구니가 비었습니다.</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {cart.map(product => (
            <li key={product.id} style={{ marginBottom: '10px' }}>
              <span>{product.name} - {product.price}</span>
              <button style={{ marginLeft: '10px' }} onClick={() => removeFromCart(product.id)}>삭제</button>
            </li>
          ))}
        </ul>
      )}
      {/* 결제 버튼 등 추가 기능 가능 */}
    </div>
  );
};

export default CartModal;

147. context API & uesReducer / Prop Drilling: 컴포넌트 구성으로 해결하기

  • 프로퍼티 내리꽂기(prop drilling) 문제의 첫 번째 해결책 컴포넌트 합성(Component Composition)
  1. 컴포넌트 합성(Component Composition)이란?
    • 컴포넌트 합성(Component Composition)은 여러 컴포넌트를 조합하여 더 복잡한 UI를 구성하는 리액트의 핵심 개념 중 하나입니다. 이를 통해 특정 데이터를 여러 컴포넌트에 전달할 때, 불필요한 중간 컴포넌트를 거치지 않고 효율적으로 데이터를 전달할 수 있습니다.
    • 장점:
      • 코드 간결화: 필요한 데이터만 직접 전달할 수 있어 코드가 깔끔해집니다.
      • 재사용성 향상: 중간 컴포넌트들이 불필요하게 props를 전달하지 않게 되어, 해당 컴포넌트들을 다른 곳에서 재사용하기 용이해집니다.
      • 가독성 향상: 데이터 흐름이 명확해져 코드의 가독성이 높아집니다.
    • 단점:
      • 일부 해결: 컴포넌트 트리가 깊어질 경우, 여전히 프로퍼티 내리꽂기가 발생할 수 있습니다.
      • 컴포넌트 구조 변경 필요: 기존 컴포넌트 구조를 변경해야 할 수 있습니다.
      • 상위 컴포넌트의 책임 증가: 상위 컴포넌트가 더 많은 책임을 지게 되어, 관리가 어려워질 수 있습니다.
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';

interface ShopProps {
  addToCart: (product: Product) => void;
}

const Shop: React.FC<ShopProps> = ({ addToCart }) => {
  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    // 추가 상품...
  ];

  return (
    <div>
      <h2>상품 목록</h2>
      <ProductList products={products} addToCart={addToCart} />
    </div>
  );
};

export default Shop;
// 컴포넌트 합성을 통해 Shop을 감싸는 역할로 사용하고, App 컴포넌트에서 직접 Product를 렌더링합니다.
// src/components/Shop.tsx
import React from 'react';

interface ShopProps {
  children: React.ReactNode;
}

const Shop: React.FC<ShopProps> = ({ children }) => {
  return (
    <div>
      <h2>상품 목록</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {children}
      </ul>
    </div>
  );
};

export default Shop;
  • App 컴포넌트 수정
    • App 컴포넌트에서 Shop을 사용하면서, Product 컴포넌트를 직접 렌더링하도록 변경합니다. 이를 통해 addToCart 함수를 중간 컴포넌트에 전달할 필요가 없습니다.
// src/App.tsx
import React, { useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import ProductList from './components/ProductList';
import Product from './components/Product';
import CartModal from './components/CartModal';
import { Product as ProductType } from './types';

const App: React.FC = () => {
  const [cart, setCart] = useState<ProductType[]>([]);

  const addToCart = (product: ProductType) => {
    setCart([...cart, product]);
  };

  const removeFromCart = (productId: number) => {
    setCart(cart.filter(product => product.id !== productId));
  };

  return (
    <div>
      <Header cart={cart} removeFromCart={removeFromCart} />
      <Shop addToCart={addToCart}>
        <ProductList products={products} addToCart={addToCart} />
      </Shop>
      <CartModal cart={cart} removeFromCart={removeFromCart} />
    </div>
  );
};

export default App;
// 컴포넌트 합성을 통해 Shop을 감싸고, Product 컴포넌트를 직접 렌더링합니다.
// src/App.tsx
import React, { useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import Product from './components/Product';
import CartModal from './components/CartModal';
import { Product as ProductType } from './types';

const App: React.FC = () => {
  const [cart, setCart] = useState<ProductType[]>([]);

  const addToCart = (product: ProductType) => {
    setCart([...cart, product]);
  };

  const removeFromCart = (productId: number) => {
    setCart(cart.filter(product => product.id !== productId));
  };

  const products: ProductType[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    { id: 3, name: '상품 C', price: 30000 },
    // 추가 상품...
  ];

  return (
    <div>
      <Header cart={cart} removeFromCart={removeFromCart} />
      <Shop>
        {products.map(product => (
          <Product key={product.id} product={product} addToCart={addToCart} />
        ))}
      </Shop>
      <CartModal cart={cart} removeFromCart={removeFromCart} />
    </div>
  );
};

export default App;
  • 불필요한 Shop 컴포넌트 수정
    • 이제 Shop 컴포넌트는 단순히 children을 감싸는 역할을 하므로, 더 이상 addToCart를 props로 받을 필요가 없습니다. 따라서 Shop 컴포넌트의 props를 수정합니다.
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';

interface ShopProps {
  addToCart: (product: Product) => void;
}

const Shop: React.FC<ShopProps> = ({ addToCart }) => {
  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    // 추가 상품...
  ];

  return (
    <div>
      <h2>상품 목록</h2>
      <ProductList products={products} addToCart={addToCart} />
    </div>
  );
};

export default Shop;
// src/components/Shop.tsx
import React from 'react';

interface ShopProps {
  children: React.ReactNode;
}

const Shop: React.FC<ShopProps> = ({ children }) => {
  return (
    <div>
      <h2>상품 목록</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {children}
      </ul>
    </div>
  );
};

export default Shop;
  • Product 컴포넌트 확인

    • Product 컴포넌트는 이제 addToCart 함수를 직접 받아서 사용합니다. 따라서 Shop 컴포넌트에서 addToCart를 전달할 필요가 없어졌습니다.
  • 결과 확인

    • 이제 App 컴포넌트에서 Product를 직접 렌더링함으로써, addToCart 함수를 중간 Shop 컴포넌트에 전달할 필요가 없어졌습니다. 이를 통해 프로퍼티 내리꽂기의 일부 문제를 해결할 수 있었습니다.
  • 장점:

    • 중간 컴포넌트의 props 전달이 줄어듦: Shop 컴포넌트는 단순히 children을 감싸는 역할만 하므로, 더 이상 addToCart 함수를 전달할 필요가 없습니다.
    • 코드 간결화: App 컴포넌트에서 직접적으로 Product를 렌더링함으로써, 코드가 더 간결해졌습니다.
    • 컴포넌트의 재사용성 향상: Shop 컴포넌트는 더 이상 특정 props에 의존하지 않으므로, 다른 곳에서도 쉽게 재사용할 수 있습니다.
  • 단점:

    • 상위 컴포넌트의 책임 증가: App 컴포넌트가 더 많은 책임을 지게 되어, 코드가 비대해질 수 있습니다.
    • 다른 컴포넌트와의 상태 공유 어려움: 상태를 공유해야 하는 컴포넌트가 여러 곳에 흩어져 있을 경우, 여전히 props 전달이 필요할 수 있습니다.
  • 컴포넌트 합성의 한계와 다음 해결책

    • 컴포넌트 합성을 통해 프로퍼티 내리꽂기를 일부 해결할 수 있었지만, 애플리케이션이 더욱 복잡해질수록 이 방법만으로는 한계가 있습니다. 예를 들어, 상태를 공유해야 하는 컴포넌트가 여러 곳에 흩어져 있을 경우, 여전히 props 전달이 필요할 수 있습니다.
    • 따라서, 다음 해결책인 컨텍스트(Context) API를 도입하여 전역 상태 관리를 구현함으로써, 프로퍼티 내리꽂기의 문제를 보다 효과적으로 해결할 수 있습니다. 또한, 리듀서(Reducer)와 함께 사용하여 복잡한 상태 로직도 체계적으로 관리할 수 있습니다.

148. context API & uesReducer / 컨텍스트 API 소개

  • 프로퍼티 내리꽂기(prop drilling) 문제 해결책 / 컨텍스트 API
    • 이전에 컴포넌트 합성을 통해 일부 문제를 해결했지만, 여전히 복잡한 애플리케이션에서는 더 효율적인 상태 관리 방법이 필요합니다. 바로 컨텍스트(Context) API가 이러한 문제를 해결해 줄 수 있습니다.
  1. 컨텍스트(Context) API란?

    • 컨텍스트(Context) API는 리액트에서 전역적으로 데이터를 공유하고 관리할 수 있는 기능입니다. 이를 통해 컴포넌트 트리의 깊은 곳에 있는 컴포넌트들 간에 데이터를 손쉽게 전달할 수 있으며, 불필요한 프로퍼티 내리꽂기를 방지할 수 있습니다. 컨텍스트는 다음과 같은 세 가지 주요 요소로 구성됩니다:
      1. Context 생성: React.createContext를 사용하여 새로운 컨텍스트를 생성합니다.
      2. Provider 컴포넌트: 생성한 컨텍스트의 Provider를 사용하여 데이터를 하위 컴포넌트에 제공합니다.
      3. Consumer 컴포넌트 또는 useContext 훅: 하위 컴포넌트에서 useContext 훅을 사용하여 데이터를 소비합니다.
  2. 컨텍스트(Context) API의 장점

    • 프로퍼티 내리꽂기(prop drilling)를 해결하는 데 있어 컨텍스트는 매우 효과적인 도구입니다. 주요 장점은 다음과 같습니다:
      • 간결한 코드: 중간 컴포넌트에 불필요한 props를 전달할 필요가 없어져 코드가 더 깔끔해집니다.
      • 컴포넌트 재사용성 향상: 중간 컴포넌트들이 특정 props에 의존하지 않게 되어, 해당 컴포넌트들을 다른 곳에서도 쉽게 재사용할 수 있습니다.
      • 유지보수 용이성: 상태 관리 로직이 중앙집중화되어, 변경 시 영향을 받는 부분을 쉽게 파악하고 수정할 수 있습니다.
      • 전역 상태 관리: 애플리케이션 전반에 걸쳐 공유되는 상태를 효율적으로 관리할 수 있습니다.

149. context API & uesReducer / 컨텍스트 소개 및 부여

// src 폴더 내에 store라는 이름의 새 폴더를 만듭니다. 
// 이 폴더는 애플리케이션의 전역 상태와 관련된 모든 컨텍스트 파일을 저장하는 역할을 합니다.

src
├── components
│   ├── Header.tsx
│   ├── Shop.tsx
│   ├── ProductList.tsx
│   ├── Product.tsx
│   └── CartModal.tsx
├── store
│   └── shopping-cart-context.tsx
├── types.ts
├── App.tsx
└── index.tsx
  • 컨텍스트(Context) 생성
    • 컨텍스트를 생성하고 이를 통해 애플리케이션의 전역 상태를 관리할 수 있습니다. 이번 예제에서는 장바구니의 상태를 관리하기 위한 CartContext를 생성하겠습니다.
// store 폴더 내에 shopping-cart-context.tsx 파일을 생성합니다. 이 파일에서 컨텍스트를 정의하고, 제공할 값을 설정합니다.
// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';

// 컨텍스트에서 제공할 데이터의 타입 정의
// 컨텍스트가 제공할 데이터의 타입을 정의합니다. 
// 여기서는 items 배열과 두 개의 함수 addItemToCart와 removeItemFromCart를 포함합니다.
interface CartContextType {
  items: Product[];
  addItemToCart: (product: Product) => void;
  removeItemFromCart: (productId: number) => void;
}

// 초기값 설정 (빈 컨텍스트 객체)
// CartContext: createContext를 사용하여 컨텍스트를 생성합니다. 
// 초기값으로 undefined를 설정하여, 컨텍스트를 사용하는 컴포넌트가 Provider 안에 있어야 함을 명시합니다.
const CartContext = createContext<CartContextType | undefined>(undefined);

// Provider 컴포넌트의 props 타입 정의
interface CartProviderProps {
  children: ReactNode;
}

// Provider 컴포넌트 정의
// CartProvider: 컨텍스트의 Provider 역할을 하는 컴포넌트입니다. 
// useState를 사용하여 items 상태를 관리하고, addItemToCart와 removeItemFromCart 함수를 정의하여 상태를 업데이트합니다. 
// 이 함수들은 하위 컴포넌트에서 사용할 수 있도록 컨텍스트 값을 통해 제공합니다.
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
  const [items, setItems] = useState<Product[]>([]);

  // 장바구니에 상품 추가하는 함수
  const addItemToCart = (product: Product) => {
    setItems((prevItems) => [...prevItems, product]);
  };

  // 장바구니에서 상품 제거하는 함수
  const removeItemFromCart = (productId: number) => {
    setItems((prevItems) => prevItems.filter(item => item.id !== productId));
  };

  return (
    <CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
      {children}
    </CartContext.Provider>
  );
};

export default CartContext;
// App 컴포넌트에 CartProvider 적용
// 이제 App 컴포넌트를 수정하여 CartProvider로 전체 애플리케이션을 감싸줍니다. 이를 통해 하위 컴포넌트들이 컨텍스트 값을 사용할 수 있게 됩니다.
// src/App.tsx
// CartProvider로 Header, Shop, CartModal 컴포넌트를 감싸줌으로써, 이들 컴포넌트와 그 자식 컴포넌트들이 CartContext의 값에 접근할 수 있게 됩니다.
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './store/shopping-cart-context';

const App: React.FC = () => {
  return (
          <CartProvider>
            <Header />
            <Shop />
            <CartModal />
          </CartProvider>
  );
};

export default App;
// 하위 컴포넌트에서 컨텍스트 사용하기
// 이제 Header, Shop, Product, CartModal 컴포넌트에서 컨텍스트를 사용하여 장바구니 상태를 읽고 업데이트할 수 있도록 수정합니다.
// src/components/Header.tsx
// Header 컴포넌트는 장바구니의 아이템 수를 표시합니다.
import React from 'react';
import CartContext from '../store/shopping-cart-context';
import { useContext } from 'react';

const Header: React.FC = () => {
  // useContext: useContext 훅을 사용하여 CartContext의 값을 가져옵니다.
  const cartCtx = useContext(CartContext);

  // 에러 핸들링: CartContext가 undefined인 경우 에러를 던져, 컴포넌트가 CartProvider 안에 사용되고 있음을 보장합니다.
  if (!cartCtx) {
    throw new Error('Header must be used within a CartProvider');
  }

  // totalItems: cartCtx.items.length를 통해 장바구니에 담긴 상품의 수를 계산하여 표시합니다.
  const totalItems = cartCtx.items.length;

  return (
    <header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
      <h1>온라인 쇼핑몰</h1>
      <div>
        <span>장바구니: {totalItems}</span>
        {/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
      </div>
    </header>
  );
};

export default Header;
// Shop 컴포넌트는 상품 목록을 표시하고, 각 상품을 장바구니에 추가할 수 있는 기능을 제공합니다.
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import CartContext from '../store/shopping-cart-context';
import { useContext } from 'react';
import { Product } from '../types';

const Shop: React.FC = () => {
  // useContext: CartContext의 값을 가져와서 addToCartHandler 함수를 정의합니다.
  const cartCtx = useContext(CartContext);

  if (!cartCtx) {
    throw new Error('Shop must be used within a CartProvider');
  }

  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    { id: 3, name: '상품 C', price: 30000 },
    // 추가 상품...
  ];

  // addToCartHandler: 전달받은 product를 cartCtx.addItemToCart 함수를 통해 장바구니에 추가합니다.
  const addToCartHandler = (product: Product) => {
    cartCtx.addItemToCart(product);
  };

  // ProductList: products 배열과 onAddToCart 함수를 ProductList 컴포넌트에 전달합니다.
  return (
    <div style={{ padding: '20px' }}>
      <h2>상품 목록</h2>
      <ProductList products={products} onAddToCart={addToCartHandler} />
    </div>
  );
};

export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';

interface ProductListProps {
  products: ProductType[];
  onAddToCart: (product: ProductType) => void;
}

const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {products.map(product => (
        <Product key={product.id} product={product} onAddToCart={onAddToCart} />
      ))}
    </ul>
  );
};

export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';

interface ProductProps {
  product: ProductType;
  onAddToCart: (product: ProductType) => void;
}

const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
  return (
    <li style={{ marginBottom: '10px' }}>
      <span>{product.name} - {product.price}</span>
      <button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
    </li>
  );
};

export default Product;
// src/components/CartModal.tsx
import React from 'react';
import CartContext from '../store/shopping-cart-context';
import { useContext } from 'react';
import { Product } from '../types';

const CartModal: React.FC = () => {
  // useContext: CartContext의 값을 가져와서 장바구니의 아이템들을 표시합니다.
  const cartCtx = useContext(CartContext);

  if (!cartCtx) {
    throw new Error('CartModal must be used within a CartProvider');
  }

  // removeFromCartHandler: 상품 삭제 버튼 클릭 시 해당 상품을 장바구니에서 제거하는 함수를 호출합니다.
  const removeFromCartHandler = (productId: number) => {
    cartCtx.removeItemFromCart(productId);
  };

  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {cartCtx.items.length === 0 ? (
        <p>장바구니가 비었습니다.</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {cartCtx.items.map(product => (
            <li key={product.id} style={{ marginBottom: '10px' }}>
              <span>{product.name} - {product.price}</span>
              <button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
            </li>
          ))}
        </ul>
      )}
      {/* 결제 버튼 등 추가 기능 가능 */}
    </div>
  );
};

export default CartModal;

150. context API & uesReducer / 컨텍스트 소비하기

  • 리액트의 컨텍스트(Context) API와 상태(state)를 연결하여 프로퍼티 내리꽂기(prop drilling) 문제를 완전히 해결하는 방법
  1. 컨텍스트(Context)와 상태(State) 연결하기
    • 컨텍스트(Context) API를 통해 전역 상태를 관리할 때, 상태(state)와 상태 업데이트 함수를 컨텍스트 값으로 제공해야 합니다. 이를 통해 애플리케이션의 모든 컴포넌트가 해당 상태를 읽고 업데이트할 수 있게 됩니다.
// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';

// 컨텍스트에서 제공할 데이터의 타입 정의
// CartContextType: 컨텍스트가 제공할 데이터의 타입을 정의합니다. 
// 여기서는 items 배열과 두 개의 함수 addItemToCart와 removeItemFromCart를 포함합니다.
interface CartContextType {
  items: Product[];
  addItemToCart: (product: Product) => void;
  removeItemFromCart: (productId: number) => void;
}

// 초기값 설정 (빈 컨텍스트 객체)
// CartContext: createContext를 사용하여 컨텍스트를 생성합니다. 
// 초기값으로 undefined를 설정하여, 컨텍스트를 사용하는 컴포넌트가 Provider 안에 있어야 함을 명시합니다.
const CartContext = createContext<CartContextType | undefined>(undefined);

// Provider 컴포넌트의 props 타입 정의
interface CartProviderProps {
  children: ReactNode;
}

// Provider 컴포넌트 정의
// CartProvider: 컨텍스트의 Provider 역할을 하는 컴포넌트입니다. 
// useState를 사용하여 items 상태를 관리하고, addItemToCart와 removeItemFromCart 함수를 정의하여 상태를 업데이트합니다. 
// 이 함수들은 하위 컴포넌트에서 사용할 수 있도록 컨텍스트 값을 통해 제공합니다.
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
  const [items, setItems] = useState<Product[]>([]);

  // 장바구니에 상품 추가하는 함수
  const addItemToCart = (product: Product) => {
    setItems((prevItems) => [...prevItems, product]);
  };

  // 장바구니에서 상품 제거하는 함수
  const removeItemFromCart = (productId: number) => {
    setItems((prevItems) => prevItems.filter(item => item.id !== productId));
  };

  return (
          <CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
            {children}
          </CartContext.Provider>
  );
};

export default CartContext;
// App 컴포넌트를 수정하여 CartProvider로 전체 애플리케이션을 감싸줍니다. 
// 이를 통해 하위 컴포넌트들이 컨텍스트 값을 사용할 수 있게 됩니다.
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './store/shopping-cart-context';

// CartProvider로 감싸기: CartProvider로 Header, Shop, CartModal 컴포넌트를 감싸서 이들 컴포넌트와 그 자식 컴포넌트들이 CartContext의 값에 접근할 수 있게 합니다.
const App: React.FC = () => {
  return (
    <CartProvider>
      <Header />
      <Shop />
      <CartModal />
    </CartProvider>
  );
};

export default App;
// 하위 컴포넌트에서 컨텍스트 사용하기
// 이제 각 컴포넌트에서 컨텍스트 값을 읽고 업데이트할 수 있도록 수정하겠습니다.
// src/components/Header.tsx
import React, { useContext } from 'react';
import CartContext from '../store/shopping-cart-context';

const Header: React.FC = () => {
  // useContext 사용: useContext 훅을 사용하여 CartContext의 값을 가져옵니다.
  const cartCtx = useContext(CartContext);

  if (!cartCtx) {
    // 에러 핸들링: CartContext가 undefined인 경우 에러를 던져, 컴포넌트가 CartProvider 안에 사용되고 있음을 보장합니다.
    throw new Error('Header must be used within a CartProvider');
  }

  // 아이템 수 계산: cartCtx.items.length를 통해 장바구니에 담긴 상품의 수를 계산하여 표시합니다.
  const totalItems = cartCtx.items.length;

  return (
          <header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
            <h1>온라인 쇼핑몰</h1>
            <div>
              <span>장바구니: {totalItems}</span>
              {/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
            </div>
          </header>
  );
};

export default Header;
// src/components/Shop.tsx
import React, { useContext } from 'react';
import ProductList from './ProductList';
import CartContext from '../store/shopping-cart-context';
import { Product } from '../types';

const Shop: React.FC = () => {
  // useContext 사용: CartContext의 값을 가져옵니다.
  const cartCtx = useContext(CartContext);

  if (!cartCtx) {
    throw new Error('Shop must be used within a CartProvider');
  }

  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    { id: 3, name: '상품 C', price: 30000 },
    // 추가 상품...
  ];

  // addToCartHandler 함수: addItemToCart 함수를 사용하여 상품을 장바구니에 추가합니다.
  const addToCartHandler = (product: Product) => {
    cartCtx.addItemToCart(product);
  };

  // ProductList에 onAddToCart 전달: ProductList 컴포넌트에 onAddToCart 함수를 전달하여, 각 상품에서 장바구니에 추가할 수 있도록 합니다.
  return (
    <div style={{ padding: '20px' }}>
      <h2>상품 목록</h2>
      <ProductList products={products} onAddToCart={addToCartHandler} />
    </div>
  );
};

export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';

// ProductListProps 인터페이스 정의: products 배열과 onAddToCart 함수를 명시적으로 타입 지정합니다.
interface ProductListProps {
  products: ProductType[];
  onAddToCart: (product: ProductType) => void;
}

const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {products.map(product => (
        <Product key={product.id} product={product} onAddToCart={onAddToCart} />
      ))}
    </ul>
  );
};

export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';

interface ProductProps {
  product: ProductType;
  onAddToCart: (product: ProductType) => void;
}

const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
  return (
    <li style={{ marginBottom: '10px' }}>
      <span>{product.name} - {product.price}</span>
      <button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
    </li>
  );
};

export default Product;
// src/components/CartModal.tsx
import React, { useContext } from 'react';
import CartContext from '../store/shopping-cart-context';
import { Product } from '../types';

const CartModal: React.FC = () => {
  // useContext 사용: CartContext의 값을 가져와서 장바구니 항목을 표시하고 삭제할 수 있도록 합니다.
  const cartCtx = useContext(CartContext);

  if (!cartCtx) {
    throw new Error('CartModal must be used within a CartProvider');
  }

  const { items, removeItemFromCart } = cartCtx;

  // 총 금액 계산: reduce 함수를 사용하여 장바구니에 담긴 상품의 총 금액을 계산하여 표시합니다.
  const totalPrice = items.reduce((total, item) => total + item.price, 0);

  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {items.length === 0 ? (
        <p>장바구니에 항목 없음!</p>
      ) : (
        <>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {items.map(product => (
              <li key={product.id} style={{ marginBottom: '10px' }}>
                <span>{product.name} - {product.price}</span>
                <button style={{ marginLeft: '10px' }} onClick={() => removeItemFromCart(product.id)}>삭제</button>
              </li>
            ))}
          </ul>
          <p>총 금액: {totalPrice}</p>
          {/* 결제 버튼 등 추가 기능 가능 */}
        </>
      )}
    </div>
  );
};

export default CartModal;
  • 컨텍스트(Context) API의 장점과 한계

    1. 프로퍼티 내리꽂기 방지: 중간 컴포넌트를 거치지 않고 필요한 컴포넌트에서 직접적으로 상태에 접근할 수 있습니다.
    2. 코드 간결화: props 전달을 줄여 코드가 더 간결하고 깔끔해집니다.
    3. 컴포넌트 재사용성 향상: 중간 컴포넌트들이 특정 props에 의존하지 않게 되어, 해당 컴포넌트들을 다른 곳에서도 쉽게 재사용할 수 있습니다.
    4. 전역 상태 관리: 애플리케이션 전반에 걸쳐 공유되는 상태를 효율적으로 관리할 수 있습니다.
  • 한계

    1. 과도한 사용 시 성능 저하: 너무 많은 컴포넌트가 컨텍스트에 의존하게 되면, 컨텍스트 값이 변경될 때마다 모든 소비자 컴포넌트가 리렌더링되어 성능 저하가 발생할 수 있습니다.
    2. 복잡한 상태 관리: 단순한 전역 상태 관리에는 유용하지만, 상태가 복잡해질 경우 리듀서와 함께 사용해야 하는 등 추가적인 관리가 필요합니다.
    3. 디버깅 어려움L 상태가 어디서 변경되었는지 추적하기 어려울 수 있습니다.

151. context API & uesReducer / 컨텍스트와 State(상태) 연결하기

  • 컨텍스트(Context)와 상태(State) 연결하기
    • 컨텍스트(Context) API를 통해 전역 상태를 관리할 때, 상태(state)와 상태 업데이트 함수를 컨텍스트 값으로 제공해야 합니다. 이를 통해 애플리케이션의 모든 컴포넌트가 해당 상태를 읽고 업데이트할 수 있게 됩니다.
// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';

// 컨텍스트에서 제공할 데이터의 타입 정의
interface CartContextType {
  items: Product[];
  addItemToCart: (product: Product) => void;
  removeItemFromCart: (productId: number) => void;
}

// 컨텍스트 생성 (초기값은 undefined로 설정)
const CartContext = createContext<CartContextType | undefined>(undefined);

// Provider 컴포넌트의 props 타입 정의
interface CartProviderProps {
  children: ReactNode;
}

// Provider 컴포넌트 정의
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
  const [items, setItems] = useState<Product[]>([]);

  // 장바구니에 상품 추가하는 함수
  const addItemToCart = (product: Product) => {
    setItems((prevItems) => [...prevItems, product]);
  };

  // 장바구니에서 상품 제거하는 함수
  const removeItemFromCart = (productId: number) => {
    setItems((prevItems) => prevItems.filter(item => item.id !== productId));
  };

  return (
    <CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
      {children}
    </CartContext.Provider>
  );
};

export default CartContext;
// 컨텍스트를 보다 쉽게 사용할 수 있도록 useCart라는 커스텀 훅을 생성하겠습니다.
// useCart 훅: useContext를 래핑하여, 컨텍스트 값을 쉽게 사용할 수 있도록 도와줍니다. 또한, 타입 안전성을 보장하며, CartProvider 내부에서만 사용되도록 에러를 던집니다.
// src/hooks/useCart.ts
import { useContext } from 'react';
import CartContext, { CartContextType } from '../store/shopping-cart-context';

const useCart = (): CartContextType => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
};

export default useCart;
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './store/shopping-cart-context';

const App: React.FC = () => {
  return (
    <CartProvider>
      <Header />
      <Shop />
      <CartModal />
    </CartProvider>
  );
};

export default App;
// src/components/Header.tsx
import React from 'react';
import useCart from '../hooks/useCart';

const Header: React.FC = () => {
  const { items } = useCart();

  const totalItems = items.length;

  return (
          <header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
            <h1>온라인 쇼핑몰</h1>
            <div>
              <span>장바구니: {totalItems}</span>
              {/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
            </div>
          </header>
  );
};

export default Header;
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';

const Shop: React.FC = () => {
  const { addItemToCart } = useCart();

  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    { id: 3, name: '상품 C', price: 30000 },
    // 추가 상품...
  ];

  const addToCartHandler = (product: Product) => {
    addItemToCart(product);
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>상품 목록</h2>
      <ProductList products={products} onAddToCart={addToCartHandler} />
    </div>
  );
};

export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';

interface ProductListProps {
  products: ProductType[];
  onAddToCart: (product: ProductType) => void;
}

const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {products.map(product => (
        <Product key={product.id} product={product} onAddToCart={onAddToCart} />
      ))}
    </ul>
  );
};

export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';

interface ProductProps {
  product: ProductType;
  onAddToCart: (product: ProductType) => void;
}

const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
  return (
    <li style={{ marginBottom: '10px' }}>
      <span>{product.name} - {product.price}</span>
      <button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
    </li>
  );
};

export default Product;
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';

const CartModal: React.FC = () => {
  const { items, removeItemFromCart } = useCart();

  const totalPrice = items.reduce((total, item) => total + item.price, 0);

  const removeFromCartHandler = (productId: number) => {
    removeItemFromCart(productId);
  };

  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {items.length === 0 ? (
        <p>장바구니에 항목 없음!</p>
      ) : (
        <>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {items.map(product => (
              <li key={product.id} style={{ marginBottom: '10px' }}>
                <span>{product.name} - {product.price}</span>
                <button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
              </li>
            ))}
          </ul>
          <p>총 금액: {totalPrice}</p>
          {/* 결제 버튼 등 추가 기능 가능 */}
        </>
      )}
    </div>
  );
};

export default CartModal;
  • 컨텍스트(Context) API의 장점과 한계
    • 장점
      1. 프로퍼티 내리꽂기 방지: 중간 컴포넌트를 거치지 않고 필요한 컴포넌트에서 직접적으로 상태에 접근할 수 있습니다.
      2. 코드 간결화: props 전달을 줄여 코드가 더 간결하고 깔끔해집니다.
      3. 컴포넌트 재사용성 향상: 중간 컴포넌트들이 특정 props에 의존하지 않게 되어, 해당 컴포넌트들을 다른 곳에서도 쉽게 재사용할 수 있습니다.
      4. 전역 상태 관리: 애플리케이션 전반에 걸쳐 공유되는 상태를 효율적으로 관리할 수 있습니다.
    • 한계
      1. 과도한 사용 시 성능 저하: 너무 많은 컴포넌트가 컨텍스트에 의존하게 되면, 컨텍스트 값이 변경될 때마다 모든 소비자 컴포넌트가 리렌더링되어 성능 저하가 발생할 수 있습니다.
      2. 복잡한 상태 관리: 단순한 전역 상태 관리에는 유용하지만, 상태가 복잡해질 경우 리듀서와 함께 사용해야 하는 등 추가적인 관리가 필요합니다.
      3. 디버깅 어려움: 상태가 어디서 변경되었는지 추적하기 어려울 수 있습니다

152. context API & uesReducer / 컨텍스트를 소비하는 여러가지 방법

  • 리액트의 컨텍스트(Context) API를 사용하는 두 가지 방법

    1. useContext 훅 사용
    2. Consumer 컴포넌트 사용
  • 컨텍스트(Context) 사용 방법

    • 리액트에서 컨텍스트를 사용하는 주요 방법은 두 가지가 있습니다:
      1. useContext 훅을 사용하는 방법 (현대적인 방식)
      2. Consumer 컴포넌트를 사용하는 방법 (구식 방식)
  • useContext 훅 사용하기

    • 이미 이전에 useContext 훅을 사용하여 컨텍스트 값을 소비하는 방법을 배웠습니다. 이 방식은 함수형 컴포넌트에서 간편하게 컨텍스트 값을 가져올 수 있어 널리 사용됩니다.
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';

const CartModal: React.FC = () => {
  const { items, removeItemFromCart } = useCart();

  const totalPrice = items.reduce((total, item) => total + item.price, 0);

  const removeFromCartHandler = (productId: number) => {
    removeItemFromCart(productId);
  };

  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {items.length === 0 ? (
        <p>장바구니에 항목 없음!</p>
      ) : (
        <>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {items.map(product => (
              <li key={product.id} style={{ marginBottom: '10px' }}>
                <span>{product.name} - {product.price}</span>
                <button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
              </li>
            ))}
          </ul>
          <p>총 금액: {totalPrice}</p>
          {/* 결제 버튼 등 추가 기능 가능 */}
        </>
      )}
    </div>
  );
};

export default CartModal;
  • Consumer 컴포넌트 사용하기
    • 구식 방식인 Consumer 컴포넌트는 주로 클래스형 컴포넌트나 오래된 코드베이스에서 사용됩니다. Consumer 컴포넌트는 render props 패턴을 사용하여 컨텍스트 값을 소비합니다.
// src/components/CartModalWithConsumer.tsx
import React from 'react';
import CartContext from '../store/shopping-cart-context';
import { Product } from '../types';

const CartModalWithConsumer: React.FC = () => {
  return (
    <CartContext.Consumer>
      {({ items, removeItemFromCart }) => {
        const totalPrice = items.reduce((total, item) => total + item.price, 0);

        const removeFromCartHandler = (productId: number) => {
          removeItemFromCart(productId);
        };

        return (
          <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
            <h2>장바구니</h2>
            {items.length === 0 ? (
              <p>장바구니에 항목 없음!</p>
            ) : (
              <>
                <ul style={{ listStyle: 'none', padding: 0 }}>
                  {items.map(product => (
                    <li key={product.id} style={{ marginBottom: '10px' }}>
                      <span>{product.name} - {product.price}</span>
                      <button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
                    </li>
                  ))}
                </ul>
                <p>총 금액: {totalPrice}</p>
                {/* 결제 버튼 등 추가 기능 가능 */}
              </>
            )}
          </div>
        );
      }}
    </CartContext.Consumer>
  );
};

export default CartModalWithConsumer;
  • 설명:

    • Consumer 컴포넌트는 자식으로 함수를 받습니다. 이 함수는 컨텍스트의 현재 값을 매개변수로 받아 JSX를 반환합니다.
    • 클래스형 컴포넌트에서도 useContext 훅을 사용할 수 없기 때문에, Consumer를 통해 컨텍스트 값을 소비합니다.
    • 함수형 컴포넌트에서도 Consumer를 사용할 수 있지만, 코드가 더 복잡하고 가독성이 떨어집니다.
  • useContext vs Consumer 비교

특성 useContext 훅 Consumer 컴포넌트
사용 용도 함수형 컴포넌트에서 간편하게 컨텍스트 소비 주로 클래스형 컴포넌트나 구식 코드베이스
코드 간결성 매우 간결하고 직관적 다소 복잡하고 길어질 수 있음
가독성 높음 낮음
TypeScript 지원 훅을 통해 타입 안전하게 사용 가능 render props 패턴으로 타입 지정 어려움
  • 결론:

    • 함수형 컴포넌트를 사용하는 경우, useContext 훅을 사용하는 것이 더 간결하고 가독성이 높아 추천됩니다.
    • 클래스형 컴포넌트나 구식 코드베이스에서는 Consumer 컴포넌트를 사용해야 할 수도 있습니다.
    • 현재 프로젝트가 주로 함수형 컴포넌트를 사용하고 있다면, useContext를 지속적으로 사용하는 것이 좋습니다.
  • 기존 코드에서 Consumer 사용법 제거하기

    • 현재 프로젝트에서 useContext 훅을 사용하고 있으므로, Consumer 컴포넌트를 사용한 코드를 제거하고 useContext를 사용하는 방식으로 일관성을 유지하는 것이 좋습니다. 이는 코드의 간결성과 유지보수성을 높이는 데 도움이 됩니다.

153. context API & uesReducer / 컨텍스트 값이 바뀌면 생기는 일

  • 리액트의 컨텍스트(Context) API를 사용하는 과정에서 컴포넌트의 컨텍스트 값 처리와 사용에 있어 반드시 알아두어야 할 중요한 개념

    • 컴포넌트가 컨텍스트 값을 사용할 때 발생하는 리렌더링(Re-rendering)
  • 컨텍스트(Context) 값과 컴포넌트의 리렌더링

    • 리액트에서 컴포넌트가 컨텍스트 값을 사용할 때, 해당 값은 여러 가지 상황에서 컴포넌트 함수의 재실행을 유발하게 됩니다. 이러한 재실행은 UI 업데이트를 위해 필수적이며, 다음과 같은 경우에 발생합니다:
      1. 상태(state)의 변경: 컴포넌트 내부 상태가 변경될 때.
      2. 부모 컴포넌트의 리렌더링: 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 리렌더링됩니다.
      3. 컨텍스트 값의 변경: 컨텍스트에 연결된 값이 변경될 때.
  • useContext 훅과 리렌더링

    • useContext 훅을 사용하는 컴포넌트는 해당 컨텍스트의 값이 변경될 때마다 자동으로 리렌더링됩니다. 이는 컨텍스트 값을 통해 전달된 데이터가 최신 상태로 유지되도록 보장하기 위한 리액트의 기본 동작입니다.
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';

const CartModal: React.FC = () => {
  const { items, removeItemFromCart } = useCart();

  const totalPrice = items.reduce((total, item) => total + item.price, 0);

  const removeFromCartHandler = (productId: number) => {
    removeItemFromCart(productId);
  };

  console.log('CartModal 리렌더링됨'); // 리렌더링 확인용 로그

  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {items.length === 0 ? (
        <p>장바구니에 항목 없음!</p>
      ) : (
        <>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {items.map(product => (
              <li key={product.id} style={{ marginBottom: '10px' }}>
                <span>{product.name} - {product.price}</span>
                <button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
              </li>
            ))}
          </ul>
          <p>총 금액: {totalPrice}</p>
          {/* 결제 버튼 등 추가 기능 가능 */}
        </>
      )}
    </div>
  );
};

export default CartModal;
  • 설명:

    • 리렌더링 확인: console.log를 통해 CartModal 컴포넌트가 언제 리렌더링되는지 확인할 수 있습니다.
    • useCart 훅 사용: useCart 훅을 통해 컨텍스트 값을 가져오며, items와 removeItemFromCart를 사용합니다.
    • 리렌더링 트리거: items 배열이 변경될 때마다 CartModal이 리렌더링됩니다.
  • 컴포넌트 재실행과 상태 업데이트

    • 리액트는 컴포넌트의 상태나 컨텍스트 값이 변경될 때마다 해당 컴포넌트 함수를 재실행하여 새로운 UI를 생성합니다. 이는 리액트의 선언적(declarative) 렌더링 방식의 핵심입니다.
// src/components/Header.tsx
import React from 'react';
import useCart from '../hooks/useCart';

const Header: React.FC = () => {
  const { items } = useCart();

  const totalItems = items.length;

  console.log('Header 리렌더링됨'); // 리렌더링 확인용 로그

  return (
    <header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
      <h1>온라인 쇼핑몰</h1>
      <div>
        <span>장바구니: {totalItems}</span>
        {/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
      </div>
    </header>
  );
};

export default Header;
  • 설명:

    • 리렌더링 확인: console.log를 통해 Header 컴포넌트가 언제 리렌더링되는지 확인할 수 있습니다.
    • useCart 훅 사용: useCart 훅을 통해 items 배열을 가져와 장바구니 항목 수를 표시합니다.
    • 리렌더링 트리거: items 배열이 변경될 때마다 Header 컴포넌트도 리렌더링됩니다.
  • 리렌더링의 원인과 최적화 방안

    • 컨텍스트 값을 사용하는 컴포넌트가 자주 리렌더링되는 것을 방지하고, 성능 최적화를 위해 다음과 같은 방법들을 고려할 수 있습니다.
      • 리렌더링의 원인과 최적화 방안 첫번째, 컨텍스트 값의 분리
        • 컨텍스트에서 제공하는 값이 너무 많거나 자주 변경되는 경우, 필요한 부분만 분리하여 별도의 컨텍스트로 관리하는 것이 좋습니다. 이를 통해 불필요한 리렌더링을 줄일 수 있습니다.
// src/store/cart-total-context.tsx
import React, { createContext, useContext } from 'react';
import useCart from '../hooks/useCart';

interface CartTotalContextType {
  totalPrice: number;
}

const CartTotalContext = createContext<CartTotalContextType | undefined>(undefined);

interface CartTotalProviderProps {
  children: React.ReactNode;
}

export const CartTotalProvider: React.FC<CartTotalProviderProps> = ({ children }) => {
  const { items } = useCart();

  const totalPrice = items.reduce((total, item) => total + item.price, 0);

  return (
    <CartTotalContext.Provider value={{ totalPrice }}>
      {children}
    </CartTotalContext.Provider>
  );
};

export const useCartTotal = (): CartTotalContextType => {
  const context = useContext(CartTotalContext);
  if (!context) {
    throw new Error('useCartTotal must be used within a CartTotalProvider');
  }
  return context;
};

export default CartTotalContext;
  • 설명:

    • CartTotalContext 생성: 총 금액만을 관리하는 별도의 컨텍스트를 생성합니다.
    • CartTotalProvider 컴포넌트: useCart 훅을 사용하여 items를 가져오고, totalPrice를 계산하여 제공하는 프로바이더입니다.
    • useCartTotal 훅: CartTotalContext의 값을 쉽게 사용할 수 있도록 하는 커스텀 훅입니다.
  • 리렌더링의 원인과 최적화 방안 두번째, React.memo를 사용한 컴포넌트 메모이제이션

    • 컴포넌트가 동일한 props와 context 값을 받을 때, 불필요한 리렌더링을 방지하기 위해 React.memo를 사용할 수 있습니다.
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';

interface ProductProps {
  product: ProductType;
  onAddToCart: (product: ProductType) => void;
}

const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
  console.log(`Product ${product.id} 리렌더링됨`); // 리렌더링 확인용 로그

  return (
    <li style={{ marginBottom: '10px' }}>
      <span>{product.name} - {product.price}</span>
      <button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
    </li>
  );
};

export default React.memo(Product);
  • 설명:

    • React.memo 적용: Product 컴포넌트를 React.memo로 감싸서, props가 변경되지 않는 한 리렌더링을 방지합니다.
    • 리렌더링 확인: console.log를 통해 Product 컴포넌트가 언제 리렌더링되는지 확인할 수 있습니다.
  • 리렌더링의 원인과 최적화 방안 세번째, 컨텍스트 값의 불변성 유지

    • 컨텍스트 값을 업데이트할 때, 불변성을 유지하는 것이 중요합니다. 이는 리액트가 값의 변경을 정확히 감지하고 필요한 컴포넌트만 리렌더링하도록 도와줍니다.
// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';

interface CartContextType {
  items: Product[];
  addItemToCart: (product: Product) => void;
  removeItemFromCart: (productId: number) => void;
}

const CartContext = createContext<CartContextType | undefined>(undefined);

interface CartProviderProps {
  children: ReactNode;
}

export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
  const [items, setItems] = useState<Product[]>([]);

  const addItemToCart = (product: Product) => {
    setItems((prevItems) => [...prevItems, product]); // 불변성 유지
  };

  const removeItemFromCart = (productId: number) => {
    setItems((prevItems) => prevItems.filter(item => item.id !== productId)); // 불변성 유지
  };

  return (
    <CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
      {children}
    </CartContext.Provider>
  );
};

export default CartContext;
  • 설명:
    • 불변성 유지: addItemToCart와 removeItemFromCart 함수에서 새로운 배열을 생성하여 상태를 업데이트함으로써 불변성을 유지합니다.
    • 리렌더링 최적화: 불변성을 유지함으로써 리액트가 정확히 값의 변경을 감지하고 필요한 컴포넌트만 리렌더링하도록 합니다.

154. context API & uesReducer / 리렌더링 최적화 및 추가 고려사항

  • 컨텍스트를 사용할 때 주의해야 할 점과 최적화 방법에 대해 간략히 살펴보겠습니다.

  • 불필요한 리렌더링 방지

    • 컨텍스트 값이 변경될 때마다 해당 컨텍스트를 사용하는 모든 컴포넌트가 리렌더링됩니다. 이를 방지하기 위해 다음과 같은 방법들을 고려할 수 있습니다:
      • 컨텍스트 값 분리: 자주 변경되는 값과 그렇지 않은 값을 별도의 컨텍스트로 분리하여 필요한 컴포넌트만 리렌더링되도록 합니다.
      • React.memo 사용: 컴포넌트를 React.memo로 감싸서, props나 context 값이 변경되지 않을 때 리렌더링을 방지합니다.
      • useCallback, useMemo 사용: 함수를 useCallback으로 감싸거나, 계산된 값을 useMemo로 감싸서 불필요한 함수 재생성을 방지합니다.
  • 컨텍스트의 과도한 사용 자제

    • 컨텍스트는 전역 상태 관리에 유용하지만, 너무 많은 컨텍스트를 생성하거나 과도하게 사용하는 것은 오히려 복잡성을 증가시키고 성능에 영향을 줄 수 있습니다. 필요한 경우에만 컨텍스트를 사용하고, 컴포넌트 간의 상태 공유가 꼭 필요하지 않은 경우에는 로컬 상태를 사용하는 것이 좋습니다.

155. context API & uesReducer / useReducer 훅 소개

  • 리액트의 컨텍스트(Context) API와 useReducer 훅을 결합하여 보다 복잡한 상태 관리를 효율적으로 구현하는 방법

  • useReducer를 도입하여 상태 관리의 복잡성을 줄이고, 보다 체계적으로 상태를 업데이트하는 방법을 살펴보겠습니다.

  • useReducer 훅 도입

    • useReducer는 복잡한 상태 로직을 관리하는 데 유용한 리액트 훅입니다. 특히 상태가 여러 부분으로 나뉘어 있거나, 상태 업데이트 로직이 복잡할 때 효과적입니다. useReducer는 상태 업데이트 로직을 리듀서 함수로 분리하여 관리하며, 이는 상태 변경을 더 예측 가능하고 유지보수하기 쉽게 만들어줍니다.
  • 리듀서 함수 정의

    • 리듀서 함수는 현재 상태와 액션을 받아서 새로운 상태를 반환하는 순수 함수입니다. 다음은 장바구니 상태를 관리하기 위한 리듀서 함수의 예제입니다.
// src/store/shoppingCartReducer.ts
import { Product } from '../types';

// CartAction 타입: 가능한 액션의 타입을 정의합니다. ADD_ITEM, REMOVE_ITEM, UPDATE_QUANTITY 세 가지 액션을 다룹니다.
export type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: { id: number } }
  | { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } };

// shoppingCartReducer 함수: 현재 상태(state)와 액션(action)을 받아서 새로운 상태를 반환합니다.
//  ADD_ITEM: 새로운 상품을 장바구니에 추가합니다.
//  REMOVE_ITEM: 지정된 ID의 상품을 장바구니에서 제거합니다.
//  UPDATE_QUANTITY: 지정된 ID의 상품 수량을 업데이트합니다.
export const shoppingCartReducer = (state: Product[], action: CartAction): Product[] => {
  switch (action.type) {
    case 'ADD_ITEM':
      return [...state, action.payload];
    case 'REMOVE_ITEM':
      return state.filter(item => item.id !== action.payload.id);
    case 'UPDATE_QUANTITY':
      return state.map(item =>
        item.id === action.payload.id ? { ...item, quantity: action.payload.quantity } : item
      );
    default:
      return state;
  }
};
  • CartContextProvider 컴포넌트 수정
    • 이제 CartContextProvider 컴포넌트에 useReducer를 도입하여 상태 관리를 리듀서 함수로 이전하겠습니다.
// src/store/CartContextProvider.tsx
import React, { useReducer, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';

interface CartContextProviderProps {
  children: ReactNode;
}

export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
  // useReducer 도입: useReducer 훅을 사용하여 상태(items)와 디스패치 함수(dispatch)를 관리합니다.
  const [items, dispatch] = useReducer(shoppingCartReducer, []);

  // 액션 디스패치 함수
  // addItemToCart: ADD_ITEM 액션을 디스패치합니다.
  const addItemToCart = (product: Product) => {
    dispatch({ type: 'ADD_ITEM', payload: product });
  };

  // removeItemFromCart: REMOVE_ITEM 액션을 디스패치합니다.
  const removeItemFromCart = (productId: number) => {
    dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
  };

  // updateItemQuantity: UPDATE_QUANTITY 액션을 디스패치합니다.
  const updateItemQuantity = (productId: number, quantity: number) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
  };

  // 컨텍스트 값 업데이트: ctxValue에 updateItemQuantity 함수를 추가하여 하위 컴포넌트에서 수량 업데이트 기능을 사용할 수 있게 합니다.
  const ctxValue: CartContextType = {
    items,
    addItemToCart,
    removeItemFromCart,
    updateItemQuantity, // 새로운 액션 함수 추가
  };

  return (
    <CartContext.Provider value={ctxValue}>
      {children}
    </CartContext.Provider>
  );
};

export default CartContextProvider;
  • 컨텍스트 타입 업데이트
    • 리듀서를 도입하면서 새로운 액션과 함수가 추가되었으므로, 컨텍스트 타입도 업데이트해야 합니다.
// src/store/shopping-cart-context.tsx
import { Product } from '../types';

export interface CartContextType {
  items: Product[];
  addItemToCart: (product: Product) => void;
  removeItemFromCart: (productId: number) => void;
  // updateItemQuantity 함수 추가: 컨텍스트 타입에 새로운 함수 updateItemQuantity를 추가하여, 하위 컴포넌트에서 상품 수량을 업데이트할 수 있도록 합니다.
  updateItemQuantity: (productId: number, quantity: number) => void; // 새로운 함수 추가
}

const CartContext = createContext<CartContextType | undefined>(undefined);

export default CartContext;
  • 컴포넌트에서 리듀서 기반 컨텍스트 사용하기
    • 이제 useReducer와 컨텍스트를 도입했으므로, 각 컴포넌트에서 이를 활용하여 상태를 업데이트하고 사용할 수 있습니다.
// src/components/Cart.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';

const Cart: React.FC = () => {
  // useCart 훅 사용: 컨텍스트에서 items, removeItemFromCart, updateItemQuantity 함수를 가져옵니다.
  const { items, removeItemFromCart, updateItemQuantity } = useCart();

  const totalPrice = items.reduce((total, item) => total + item.price * (item.quantity || 1), 0);

  // 핸들러 함수 정의:
  //    handleRemove: 상품 삭제 버튼 클릭 시 호출됩니다.
  //    handleUpdateQuantity: 수량 변경 시 호출됩니다.
  const handleRemove = (productId: number) => {
    removeItemFromCart(productId);
  };

  const handleUpdateQuantity = (productId: number, quantity: number) => {
    updateItemQuantity(productId, quantity);
  };

  console.log('Cart 리렌더링됨'); // 리렌더링 확인용 로그

  return (
    <div>
      <h2>장바구니</h2>
      {items.length === 0 ? (
        <p>장바구니에 항목 없음!</p>
      ) : (
        <>
          <ul>
            {items.map(item => (
              <li key={item.id}>
                <span>{item.name} - {item.price}</span>
                <button onClick={() => handleRemove(item.id)}>삭제</button>
                {/*상품 수량 입력 필드 추가: 각 상품 옆에 수량을 변경할 수 있는 <input> 필드를 추가하였습니다.*/}
                <input
                  type="number"
                  min="1"
                  value={item.quantity || 1}
                  onChange={(e) => handleUpdateQuantity(item.id, Number(e.target.value))}
                />
              </li>
            ))}
          </ul>
          <p>총 금액: {totalPrice}</p>
          {/* 결제 버튼 등 추가 기능 가능 */}
        </>
      )}
    </div>
  );
};

export default Cart;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
import useCart from '../hooks/useCart';

interface ProductProps {
  product: ProductType;
}

const Product: React.FC<ProductProps> = ({ product }) => {
  const { addItemToCart } = useCart();

  console.log(`Product ${product.id} 리렌더링됨`); // 리렌더링 확인용 로그

  const handleAddToCart = () => {
    addItemToCart({ ...product, quantity: 1 }); // 기본 수량 1로 추가
  };

  return (
    <li style={{ marginBottom: '10px' }}>
      <span>{product.name} - {product.price}</span>
      <button style={{ marginLeft: '10px' }} onClick={handleAddToCart}>장바구니에 추가</button>
    </li>
  );
};

export default React.memo(Product);
// src/hooks/useCart.ts
import { useContext } from 'react';
import CartContext, { CartContextType } from '../store/shopping-cart-context';

const useCart = (): CartContextType => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartContextProvider');
  }
  return context;
};

export default useCart;
// src/store/CartContextProvider.tsx
import React, { useReducer, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';

interface CartContextProviderProps {
  children: ReactNode;
}

export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
  const [items, dispatch] = useReducer(shoppingCartReducer, []);

  // 액션 디스패치 함수
  const addItemToCart = (product: Product) => {
    dispatch({ type: 'ADD_ITEM', payload: product });
  };

  const removeItemFromCart = (productId: number) => {
    dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
  };

  const updateItemQuantity = (productId: number, quantity: number) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
  };

  const ctxValue: CartContextType = {
    items,
    addItemToCart,
    removeItemFromCart,
    updateItemQuantity,
  };

  return (
    <CartContext.Provider value={ctxValue}>
      {children}
    </CartContext.Provider>
  );
};

export default CartContextProvider;
  • 리듀서를 활용한 상태 관리의 장점
    • useReducer를 활용하여 상태 관리 로직을 리듀서 함수로 분리함으로써 얻은 주요 장점은 다음과 같습니다:
      1. 상태 업데이트 로직의 중앙 집중화
        • 모든 상태 변경 로직이 하나의 리듀서 함수에 모여 있어, 상태 변경을 추적하고 관리하기 용이합니다.
      2. 예측 가능한 상태 변화
        • 리듀서 함수는 순수 함수이므로, 같은 입력에 대해 항상 같은 출력을 보장하여 상태 변화가 예측 가능합니다.
      3. 복잡한 상태 로직 관리
        • 상태가 복잡하거나 다양한 방식으로 변경될 때, useReducer는 상태 업데이트를 체계적으로 관리할 수 있게 도와줍니다.
      4. 코드의 가독성 및 유지보수성 향상
        • 상태 업데이트 로직이 컴포넌트에서 분리되어 있어, 컴포넌트 코드는 더욱 간결해지고, 상태 로직은 리듀서에서 독립적으로 관리됩니다.

156. context API & uesReducer / Action 보내기 & useReducer로 State(상태) 수정하기

  • useReducer 훅 도입 및 리듀서 함수 정의
    • useReducer는 복잡한 상태 로직을 관리할 때 유용한 리액트 훅입니다. 특히 상태가 여러 부분으로 나뉘어 있거나, 상태 업데이트 로직이 복잡할 때 효과적입니다. useReducer는 상태 업데이트 로직을 리듀서 함수로 분리하여 관리하며, 이는 상태 변경을 더 예측 가능하고 유지보수하기 쉽게 만들어줍니다.
// src/types.ts
export interface Product {
  id: number;
  name: string;
  price: number;
  quantity?: number; // 수량 추가
}
// src/store/shoppingCartReducer.ts
import { Product } from '../types';

export type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: { id: number } }
  | { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } };

export const shoppingCartReducer = (state: Product[], action: CartAction): Product[] => {
  switch (action.type) {
    case 'ADD_ITEM':
      // 이미 장바구니에 있는 상품이라면 수량을 증가시킴
      const existingItem = state.find(item => item.id === action.payload.id);
      if (existingItem) {
        return state.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: (item.quantity || 1) + (action.payload.quantity || 1) }
            : item
        );
      }
      return [...state, action.payload];
      
    case 'REMOVE_ITEM':
      return state.filter(item => item.id !== action.payload.id);
      
    case 'UPDATE_QUANTITY':
      return state.map(item =>
        item.id === action.payload.id
          ? { ...item, quantity: action.payload.quantity }
          : item
      );
      
    default:
      return state;
  }
};
// src/store/shopping-cart-context.tsx
import React, { createContext } from 'react';
import { Product } from '../types';

export interface CartContextType {
  items: Product[];
  addItemToCart: (product: Product) => void;
  removeItemFromCart: (productId: number) => void;
  updateItemQuantity: (productId: number, quantity: number) => void;
}

const CartContext = createContext<CartContextType | undefined>(undefined);

export default CartContext;
// src/hooks/useCart.ts
import { useContext } from 'react';
import CartContext, { CartContextType } from '../store/shopping-cart-context';

const useCart = (): CartContextType => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartContextProvider');
  }
  return context;
};

export default useCart;
// src/store/CartContextProvider.tsx
import React, { useReducer, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';

interface CartContextProviderProps {
  children: ReactNode;
}

export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
  const [items, dispatch] = useReducer(shoppingCartReducer, []);

  // 액션 디스패치 함수
  const addItemToCart = (product: Product) => {
    dispatch({ type: 'ADD_ITEM', payload: product });
  };

  const removeItemFromCart = (productId: number) => {
    dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
  };

  const updateItemQuantity = (productId: number, quantity: number) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
  };

  const ctxValue: CartContextType = {
    items,
    addItemToCart,
    removeItemFromCart,
    updateItemQuantity,
  };

  return (
    <CartContext.Provider value={ctxValue}>
      {children}
    </CartContext.Provider>
  );
};

export default CartContextProvider;
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';

const App: React.FC = () => {
  return (
    <CartContextProvider>
      <Header />
      <Shop />
      <CartModal />
    </CartContextProvider>
  );
};

export default App;
// src/components/Header.tsx
import React from 'react';
import useCart from '../hooks/useCart';

const Header: React.FC = () => {
  const { items } = useCart();

  const totalItems = items.reduce((total, item) => total + (item.quantity || 1), 0);

  console.log('Header 리렌더링됨'); // 리렌더링 확인용 로그

  return (
    <header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
      <h1>온라인 쇼핑몰</h1>
      <div>
        <span>장바구니: {totalItems}</span>
        {/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
      </div>
    </header>
  );
};

export default Header;
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';

const Shop: React.FC = () => {
  const { addItemToCart } = useCart();

  const products: Product[] = [
    { id: 1, name: '상품 A', price: 10000 },
    { id: 2, name: '상품 B', price: 20000 },
    { id: 3, name: '상품 C', price: 30000 },
    // 추가 상품...
  ];

  const addToCartHandler = (product: Product) => {
    addItemToCart(product);
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>상품 목록</h2>
      <ProductList products={products} />
    </div>
  );
};

export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';

interface ProductListProps {
  products: ProductType[];
}

const ProductList: React.FC<ProductListProps> = ({ products }) => {
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {products.map(product => (
        <Product key={product.id} product={product} />
      ))}
    </ul>
  );
};

export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
import useCart from '../hooks/useCart';

interface ProductProps {
  product: ProductType;
}

const Product: React.FC<ProductProps> = ({ product }) => {
  const { addItemToCart } = useCart();

  console.log(`Product ${product.id} 리렌더링됨`); // 리렌더링 확인용 로그

  const handleAddToCart = () => {
    addItemToCart({ ...product, quantity: 1 }); // 기본 수량 1로 추가
  };

  return (
    <li style={{ marginBottom: '10px' }}>
      <span>{product.name} - {product.price}</span>
      <button style={{ marginLeft: '10px' }} onClick={handleAddToCart}>장바구니에 추가</button>
    </li>
  );
};

export default React.memo(Product);
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';

const CartModal: React.FC = () => {
  const { items, removeItemFromCart, updateItemQuantity } = useCart();

  const totalPrice = items.reduce((total, item) => total + item.price * (item.quantity || 1), 0);

  const handleRemove = (productId: number) => {
    removeItemFromCart(productId);
  };

  const handleUpdateQuantity = (productId: number, quantity: number) => {
    if (quantity < 1) return; // 최소 수량 1로 제한
    updateItemQuantity(productId, quantity);
  };

  console.log('CartModal 리렌더링됨'); // 리렌더링 확인용 로그

  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {items.length === 0 ? (
        <p>장바구니에 항목 없음!</p>
      ) : (
        <>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {items.map(product => (
              <li key={product.id} style={{ marginBottom: '10px' }}>
                <span>{product.name} - {product.price}</span>
                <button style={{ marginLeft: '10px' }} onClick={() => handleRemove(product.id)}>삭제</button>
                <input
                  type="number"
                  min="1"
                  value={product.quantity || 1}
                  onChange={(e) => handleUpdateQuantity(product.id, Number(e.target.value))}
                  style={{ marginLeft: '10px', width: '60px' }}
                />
              </li>
            ))}
          </ul>
          <p>총 금액: {totalPrice}</p>
          {/* 결제 버튼 등 추가 기능 가능 */}
        </>
      )}
    </div>
  );
};

export default CartModal;
  • 리듀서를 활용한 상태 관리의 장점
    • useReducer를 활용하여 상태 관리 로직을 리듀서 함수로 분리함으로써 얻은 주요 장점은 다음과 같습니다:
      1. 상태 업데이트 로직의 중앙 집중화
        • 모든 상태 변경 로직이 하나의 리듀서 함수에 모여 있어, 상태 변경을 추적하고 관리하기 용이합니다.
      2. 예측 가능한 상태 변화
        • 리듀서 함수는 순수 함수이므로, 같은 입력에 대해 항상 같은 출력을 보장하여 상태 변화가 예측 가능합니다.
      3. 복잡한 상태 로직 관리
        • 상태가 복잡하거나 다양한 방식으로 변경될 때, useReducer는 상태 업데이트를 체계적으로 관리할 수 있게 도와줍니다.
      4. 코드의 가독성 및 유지보수성 향상
        • 상태 업데이트 로직이 컴포넌트에서 분리되어 있어, 컴포넌트 코드는 더욱 간결해지고, 상태 로직은 리듀서에서 독립적으로 관리됩니다.

157. Side Effect 다루기 & useEffect() 훅 활용 / 모듈 소개 & 초기 프로젝트

  • 리액트의 부수 효과(side effect)

    • 이를 효과적으로 관리하기 위한 useEffect 훅의 사용법을 배워보겠습니다.
  • 부수 효과(Side Effect)란 무엇인가?

    • 부수 효과의 정의
      • 부수 효과는 컴포넌트의 렌더링과 직접적으로 관련되지 않지만, 컴포넌트가 동작하는 과정에서 발생하는 모든 작업을 의미합니다. 리액트 컴포넌트 내에서 부수 효과를 일으킬 수 있는 작업에는 다음과 같은 것들이 있습니다:
        • 데이터 페칭(Data Fetching): 서버로부터 데이터를 가져오는 작업.
        • 구독(Subscriptions): 웹소켓이나 이벤트 리스너 등을 통해 외부 데이터 소스에 구독하는 작업.
        • 타이머 설정(Timers): setTimeout이나 setInterval을 사용하여 타이머를 설정하는 작업.
        • DOM 조작(Manual DOM Manipulation): 리액트가 관리하지 않는 외부 라이브러리와의 통합 등을 위해 DOM을 직접 조작하는 작업.
  • 왜 부수 효과를 관리해야 할까?

    • 부수 효과는 컴포넌트의 생명주기와 밀접하게 연결되어 있습니다. 적절하게 관리하지 않으면 다음과 같은 문제가 발생할 수 있습니다:
      • 메모리 누수(Memory Leaks): 컴포넌트가 언마운트된 후에도 타이머나 구독이 계속 실행되어 메모리 누수가 발생할 수 있습니다.
      • 불필요한 렌더링(Unnecessary Re-renders): 부수 효과가 자주 발생하면 컴포넌트가 불필요하게 자주 리렌더링될 수 있습니다.
      • 예측 불가능한 동작(Unpredictable Behavior): 상태 업데이트 로직이 복잡해지면서 예측하기 어려운 동작이 발생할 수 있습니다.

따라서, 부수 효과를 올바르게 관리하는 것은 리액트 애플리케이션의 성능과 안정성을 유지하는 데 매우 중요합니다.

  • useEffect 훅 소개

    • 리액트는 useEffect 훅을 제공하여 함수형 컴포넌트 내에서 부수 효과를 관리할 수 있게 합니다. useEffect를 사용하면 컴포넌트가 렌더링된 후 특정 작업을 수행하거나, 컴포넌트가 언마운트될 때 정리 작업을 수행할 수 있습니다.
  • useEffect의 기본 사용법

import React, { useEffect } from 'react';

const ExampleComponent: React.FC = () => {
  useEffect(() => {
    // 이곳에 부수 효과 코드를 작성합니다.
    console.log('컴포넌트가 마운트되었습니다.');

    // 정리 함수(return)를 반환할 수 있습니다.
    return () => {
      console.log('컴포넌트가 언마운트되었습니다.');
    };
  }, []); // 의존성 배열

  return <div>예제 컴포넌트</div>;
};

export default ExampleComponent;
  • 설명:

    • 첫 번째 인수: 부수 효과를 수행하는 함수입니다. 이 함수는 컴포넌트가 렌더링된 후에 실행됩니다.
    • 두 번째 인수: 의존성 배열입니다. 이 배열에 지정된 값이 변경될 때마다 부수 효과 함수가 실행됩니다. 빈 배열([])을 전달하면 컴포넌트가 처음 마운트될 때만 실행됩니다.
  • 의존성 배열(Dependency Array)

    • useEffect의 두 번째 인수인 의존성 배열은 부수 효과가 언제 실행될지를 제어합니다.
      • 빈 배열([]): 컴포넌트가 처음 마운트될 때만 실행됩니다.
      • 특정 값들: 배열에 지정된 값이 변경될 때마다 실행됩니다.
      • 배열을 생략: 컴포넌트가 리렌더링될 때마다 매번 실행됩니다. 이는 성능에 부정적인 영향을 줄 수 있으므로 주의해야 합니다.
  • 예시: PlacePicker 앱에서의 부수 효과 적용

    • 이제 PlacePicker 앱을 통해 useEffect를 어떻게 활용할 수 있는지 살펴보겠습니다. 이 앱은 사용자가 원하는 장소를 선택하고, 원하지 않는 장소를 삭제할 수 있는 기능을 제공합니다. 이번 강의에서는 장소 목록을 로컬 스토리지에 저장하고, 앱이 로드될 때 이를 불러오는 기능을 추가해보겠습니다. 이 과정에서 부수 효과를 관리하기 위해 useEffect를 사용할 것입니다.
  • 장소 목록을 로컬 스토리지에 저장하기

    • 사용자가 선택한 장소를 브라우저의 로컬 스토리지에 저장하여, 페이지를 새로고침해도 데이터가 유지되도록 합니다. 이를 위해 useEffect를 활용하여 장소 목록이 변경될 때마다 로컬 스토리지에 저장하고, 컴포넌트가 마운트될 때 로컬 스토리지에서 데이터를 불러옵니다.
  • 로컬 스토리지에서 데이터 불러오기

    • CartContextProvider.tsx에서 컴포넌트가 마운트될 때 로컬 스토리지에서 데이터를 불러와 상태를 초기화합니다.
// src/store/CartContextProvider.tsx
import React, { useReducer, useEffect, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';

interface CartContextProviderProps {
  children: ReactNode;
}

export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
  const [items, dispatch] = useReducer(shoppingCartReducer, []);

  // 로컬 스토리지에서 초기 데이터 불러오기
  // useEffect: 컴포넌트가 마운트될 때([] 의존성 배열) 로컬 스토리지에서 pickedPlaces 키로 저장된 데이터를 불러옵니다.
  // 불러온 데이터가 있으면 INITIALIZE_CART 액션을 디스패치하여 상태를 초기화합니다.
  useEffect(() => {
    const storedItems = localStorage.getItem('pickedPlaces');
    if (storedItems) {
      // 초기화 액션 추가: INITIALIZE_CART 액션을 리듀서에 추가하여, 로컬 스토리지에서 불러온 데이터를 상태로 설정합니다.
      dispatch({ type: 'INITIALIZE_CART', payload: JSON.parse(storedItems) });
    }
  }, []);

  // 장소 목록이 변경될 때마다 로컬 스토리지에 저장하기
  // items 상태가 변경될 때마다 로컬 스토리지에 현재 items 상태를 저장합니다.
  useEffect(() => {
    localStorage.setItem('pickedPlaces', JSON.stringify(items));
  }, [items]);

  // 액션 디스패치 함수
  const addItemToCart = (product: Product) => {
    dispatch({ type: 'ADD_ITEM', payload: product });
  };

  const removeItemFromCart = (productId: number) => {
    dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
  };

  const updateItemQuantity = (productId: number, quantity: number) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
  };

  const ctxValue: CartContextType = {
    items,
    addItemToCart,
    removeItemFromCart,
    updateItemQuantity,
  };

  return (
    <CartContext.Provider value={ctxValue}>
      {children}
    </CartContext.Provider>
  );
};

export default CartContextProvider;
  • 리듀서 함수 업데이트
    • shoppingCartReducer.ts에 INITIALIZE_CART 액션을 처리하도록 리듀서를 업데이트합니다.
// src/store/shoppingCartReducer.ts
import { Product } from '../types';

export type CartAction =
  | { type: 'INITIALIZE_CART'; payload: Product[] }
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: { id: number } }
  | { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } };

export const shoppingCartReducer = (state: Product[], action: CartAction): Product[] => {
  switch (action.type) {
    // INITIALIZE_CART 액션 처리: 로컬 스토리지에서 불러온 payload를 그대로 상태로 반환하여 초기화합니다. 
    case 'INITIALIZE_CART':
      return action.payload;
      
    case 'ADD_ITEM':
      const existingItem = state.find(item => item.id === action.payload.id);
      if (existingItem) {
        return state.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: (item.quantity || 1) + (action.payload.quantity || 1) }
            : item
        );
      }
      return [...state, action.payload];
      
    case 'REMOVE_ITEM':
      return state.filter(item => item.id !== action.payload.id);
      
    case 'UPDATE_QUANTITY':
      return state.map(item =>
        item.id === action.payload.id
          ? { ...item, quantity: action.payload.quantity }
          : item
      );
      
    default:
      return state;
  }
};
  • 컴포넌트에서 부수 효과 사용하기

    • 이제 장소 목록을 로컬 스토리지에 저장하고 불러오는 로직을 구현했으므로, 다른 부수 효과의 예제로 데이터를 비동기적으로 가져오는 기능을 추가해보겠습니다. 예를 들어, 장소 데이터를 외부 API에서 가져오는 기능을 추가할 수 있습니다.
  • 외부 API에서 데이터 페칭

    • Shop 컴포넌트에서 장소 데이터를 외부 API로부터 비동기적으로 가져오도록 수정합니다. 이를 통해 부수 효과의 대표적인 예제를 구현해보겠습니다.
// src/components/Shop.tsx
import React, { useEffect, useState } from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';

const Shop: React.FC = () => {
  const { addItemToCart } = useCart();
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  // 외부 API에서 장소 데이터 가져오기
  useEffect(() => {
    const fetchProducts = async () => {
      try {
        const response = await fetch('https://api.example.com/places'); // 실제 API 엔드포인트로 변경
        if (!response.ok) {
          throw new Error('데이터를 불러오는 데 실패했습니다.');
        }
        const data: Product[] = await response.json();
        setProducts(data);
      } catch (err: any) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchProducts();
  }, []); // 빈 배열을 전달하여 컴포넌트 마운트 시 한 번만 실행

  const addToCartHandler = (product: Product) => {
    addItemToCart(product);
  };

  if (loading) {
    return <div>로딩 중...</div>;
  }

  if (error) {
    return <div>에러: {error}</div>;
  }

  return (
    <div style={{ padding: '20px' }}>
      <h2>상품 목록</h2>
      <ProductList products={products} />
    </div>
  );
};

export default Shop;
  • useEffect 훅의 의존성 배열 관리

    • useEffect 훅을 사용할 때 가장 중요한 부분 중 하나는 의존성 배열을 올바르게 관리하는 것입니다. 잘못된 의존성 배열은 예기치 않은 버그를 초래할 수 있습니다.
  • 의존성 배열의 역할

    • 의존성 배열은 useEffect가 언제 실행되어야 하는지를 리액트에게 알려줍니다. 배열 내에 지정된 값이 변경될 때마다 useEffect의 첫 번째 인수로 전달된 함수가 실행됩니다.
  • 올바른 의존성 배열 설정하기

    • 빈 배열([]): 컴포넌트가 마운트될 때 한 번만 실행됩니다.
    • 특정 변수들: 배열에 포함된 변수 중 하나라도 변경되면 useEffect가 실행됩니다.
    • 배열을 생략: 컴포넌트가 리렌더링될 때마다 useEffect가 실행됩니다. 이는 대부분의 경우 원하지 않는 동작입니다.
  • ESLint의 react-hooks/exhaustive-deps 규칙

    • 리액트는 의존성 배열을 올바르게 설정하도록 돕기 위해 ESLint 규칙인 react-hooks/exhaustive-deps를 제공합니다. 이 규칙을 통해 useEffect의 의존성 배열이 정확하게 설정되었는지 확인할 수 있습니다. 따라서, 이 규칙을 활성화하고 코드를 작성할 때 이를 준수하는 것이 좋습니다.
  • useEffect 사용 시 주의사항

    • 무한 루프 방지
      • useEffect 내부에서 상태를 업데이트하면, 의존성 배열에 해당 상태가 포함될 경우 무한 루프가 발생할 수 있습니다. 이를 방지하기 위해 다음을 유의해야 합니다:
        • 상태 업데이트 함수는 의존성 배열에 포함시키지 않습니다.
        • 필요한 경우, 상태 업데이트 함수를 useCallback으로 메모이제이션하여 의존성 배열을 최적화합니다.
  • 클린업(Cleanup) 함수 사용

    • 부수 효과가 타이머, 구독, 이벤트 리스너 등을 포함할 때는 클린업 함수를 사용하여 컴포넌트가 언마운트될 때 이를 정리해야 합니다.
useEffect(() => {
  const timer = setTimeout(() => {
    console.log('타이머 완료');
  }, 1000);

  // 클린업 함수
  return () => {
    clearTimeout(timer);
    console.log('타이머 클리어');
  };
}, []);
  • 설명:

    • useEffect의 반환 값으로 클린업 함수를 제공하여, 컴포넌트가 언마운트되거나 의존성 배열의 값이 변경될 때 클린업 작업을 수행합니다.
  • useEffect 훅을 사용하면 안 되는 상황

    • 렌더링에 직접적인 영향을 주는 작업
      • 상태 업데이트를 렌더링 과정에서 직접 수행하는 것: 이는 무한 루프를 유발할 수 있습니다.
// 잘못된 예제
useEffect(() => {
  setState(newState); // 의존성 배열에 state가 포함되어 있을 경우 무한 루프 발생
}, [state]);
  • 렌더링에 영향을 주지 않는 작업
    • 단순한 변수 선언 또는 연산: 이러한 작업은 useEffect를 사용하지 않고 컴포넌트 함수 내에서 직접 수행할 수 있습니다.
// 올바른 예제
const total = items.reduce((sum, item) => sum + item.price, 0);

158. Side Effect 다루기 & useEffect() 훅 활용 / Side Effect(부수 효과)가 무엇인지 예시를 통한 소개

  • 예시: 사용자 위치 파악 및 장소 정렬 기능 추가

    • 사용자의 현재 위치를 파악하고, 이를 기반으로 장소 목록을 거리순으로 정렬하는 기능을 추가해보겠습니다. 이 과정에서 부수 효과를 관리하기 위해 useEffect를 사용할 것입니다.
  • App.tsx 수정

    • 먼저, App.tsx에서 사용자의 현재 위치를 파악하고 장소 목록을 정렬하는 로직을 추가합니다.
// src/App.tsx
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';
import { Product } from './types';
import { sortPlacesByDistance } from './loc';
import data from './data'; // 가짜 장소 데이터

const App: React.FC = () => {
  const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);

  useEffect(() => {
    // 사용자의 위치를 얻기 위한 콜백 함수
    const handlePosition = (position: GeolocationPosition) => {
      const { latitude, longitude } = position.coords;
      const sorted = sortPlacesByDistance(data, latitude, longitude);
      setSortedPlaces(sorted);
    };

    // 사용자의 위치를 요청
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
        console.error('위치 정보를 얻는 데 실패했습니다:', error);
      });
    } else {
      console.error('Geolocation을 지원하지 않는 브라우저입니다.');
    }
  }, []); // 빈 배열을 전달하여 컴포넌트 마운트 시 한 번만 실행

  return (
    <CartContextProvider>
      <Header />
      <Shop sortedPlaces={sortedPlaces} />
      <CartModal />
    </CartContextProvider>
  );
};

export default App;
  • 상태 추가:

    • sortedPlaces: 사용자의 현재 위치를 기준으로 정렬된 장소 목록을 저장합니다.
  • useEffect 사용:

    • 컴포넌트가 마운트될 때([] 의존성 배열) navigator.geolocation.getCurrentPosition을 호출하여 사용자의 현재 위치를 요청합니다.
    • 위치 정보가 성공적으로 얻어지면 handlePosition 콜백 함수가 호출되어 sortPlacesByDistance 함수를 사용해 장소 목록을 거리순으로 정렬하고, sortedPlaces 상태를 업데이트합니다.
    • 위치 정보 요청이 실패할 경우 에러를 콘솔에 출력합니다.
  • 컴포넌트 전달:

    • 정렬된 장소 목록인 sortedPlaces를 Shop 컴포넌트에 props로 전달합니다.
  • Shop.tsx 수정

    • Shop.tsx에서 정렬된 장소 목록을 받아 표시하도록 수정합니다.
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';

interface ShopProps {
  sortedPlaces: Product[];
}

const Shop: React.FC<ShopProps> = ({ sortedPlaces }) => {
  const { addItemToCart } = useCart();

  const addToCartHandler = (product: Product) => {
    addItemToCart(product);
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>장소 목록</h2>
      <ProductList products={sortedPlaces} />
    </div>
  );
};

export default Shop;
  • 설명:
    • sortedPlaces Props: App.tsx에서 전달된 정렬된 장소 목록을 props로 받아 ProductList 컴포넌트에 전달합니다.

159. Side Effect 다루기 & useEffect() 훅 활용 / Side Effect(부수 효과)의 잠재적 문제: 무한 루프

  • 부수 효과로 인한 무한 루프 문제

    • 컴포넌트 함수 내에서 부수 효과를 처리할 때, 특히 상태 업데이트를 포함하는 경우 무한 루프가 발생할 수 있습니다. 이는 상태 업데이트가 컴포넌트의 재렌더링을 유발하고, 재렌더링 과정에서 다시 상태 업데이트가 일어나 반복적으로 렌더링이 발생하는 상황을 말합니다.
  • 무한 루프가 발생하는 예시

    • 아래는 무한 루프를 유발하는 잘못된 예시입니다.
// 잘못된 예시: 무한 루프 발생
import React, { useState } from 'react';
import { sortPlacesByDistance } from './loc';
import data from './data';

const App: React.FC = () => {
  const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);

  // 컴포넌트 함수 내에서 직접 부수 효과 처리
  navigator.geolocation.getCurrentPosition((position) => {
    const sorted = sortPlacesByDistance(data, position.coords.latitude, position.coords.longitude);
    setSortedPlaces(sorted); // 상태 업데이트
  });

  return (
    <div>
      <Places places={sortedPlaces} />
    </div>
  );
};

export default App;
  • 문제점:

    • 상태 업데이트: setSortedPlaces를 호출하면 컴포넌트가 재렌더링됩니다.
    • 재렌더링 시 부수 효과 재실행: 컴포넌트가 재렌더링될 때마다 navigator.geolocation.getCurrentPosition이 다시 호출됩니다.
    • 무한 반복: 상태 업데이트와 재렌더링이 반복되면서 무한 루프가 발생합니다.
  • useEffect 훅을 사용한 부수 효과 관리

    • 이러한 문제를 해결하기 위해 useEffect 훅을 사용하여 부수 효과를 적절하게 관리할 수 있습니다. useEffect를 사용하면 컴포넌트의 생명주기와 연동하여 부수 효과를 실행하거나 정리할 수 있습니다.
import React, { useEffect, useState } from 'react';
import { sortPlacesByDistance } from './loc';
import data from './data';

const App: React.FC = () => {
  const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);

  useEffect(() => {
    // 부수 효과: 사용자 위치 파악 및 장소 정렬
    const handlePosition = (position: GeolocationPosition) => {
      const { latitude, longitude } = position.coords;
      const sorted = sortPlacesByDistance(data, latitude, longitude);
      setSortedPlaces(sorted);
    };

    // 사용자 위치 요청
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
        console.error('위치 정보를 얻는 데 실패했습니다:', error);
      });
    } else {
      console.error('Geolocation을 지원하지 않는 브라우저입니다.');
    }
  }, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행

  return (
    <div>
      <Places places={sortedPlaces} />
    </div>
  );
};

export default App;
  • 설명:

    • useEffect 훅 사용: 부수 효과를 useEffect 내부에 정의하여 컴포넌트의 생명주기와 연동시킵니다.
    • 의존성 배열([]): 빈 배열을 전달하면, useEffect는 컴포넌트가 처음 마운트될 때만 실행됩니다. 이를 통해 무한 루프를 방지할 수 있습니다.
    • 부수 효과 로직:
      • 사용자 위치 요청: navigator.geolocation.getCurrentPosition을 호출하여 사용자 위치를 요청합니다.
      • 위치 정보 처리: 위치 정보가 성공적으로 얻어지면, sortPlacesByDistance 함수를 사용하여 장소를 거리순으로 정렬하고, setSortedPlaces를 호출하여 상태를 업데이트합니다.
  • 무한 루프를 방지하는 방법

    • 위의 예시에서 useEffect의 의존성 배열을 빈 배열로 설정함으로써, 부수 효과가 컴포넌트가 처음 마운트될 때만 실행되도록 했습니다. 이렇게 하면 상태 업데이트가 발생해도 useEffect가 다시 실행되지 않아 무한 루프를 방지할 수 있습니다.
  • useEffect 사용 시 주의사항

    • 무한 루프 방지
      • useEffect 내부에서 상태를 업데이트할 때, 의존성 배열에 해당 상태를 포함시키지 않도록 주의해야 합니다. 그렇지 않으면 상태 업데이트가 재렌더링을 유발하고, 다시 useEffect가 실행되는 무한 루프가 발생할 수 있습니다.
  • 잘못된 예시: 의존성 배열에 상태 포함

useEffect(() => {
  setState(newState); // 상태 업데이트
}, [state]); // state가 변경될 때마다 실행 → 무한 루프
  • 올바른 예시: 의존성 배열에 상태 미포함
useEffect(() => {
  setState(newState); // 상태 업데이트
}, []); // 빈 배열: 한 번만 실행
  • 클린업 함수의 중요성
    • 타이머, 구독, 이벤트 리스너 등과 같은 부수 효과는 컴포넌트가 언마운트될 때 반드시 정리해야 합니다. 그렇지 않으면 메모리 누수나 예기치 않은 동작이 발생할 수 있습니다.
useEffect(() => {
  const handleResize = () => {
    console.log('창 크기 변경됨');
  };

  window.addEventListener('resize', handleResize);

  // 클린업 함수: 이벤트 리스너 제거
  return () => {
    window.removeEventListener('resize', handleResize);
    console.log('이벤트 리스너가 제거되었습니다.');
  };
}, []); // 빈 배열: 컴포넌트 마운트 시 한 번만 실행

160. Side Effect 다루기 & useEffect() 훅 활용 / 모든 Side Effect(부수 효과)가 useEffect를 사용하지 않는 이유

  • 모든 부수 효과가 useEffect의 사용을 필요로 하지 않으며, 상황에 따라 적절히 선택하여 사용하는 것이 중요함
  • 모든 부수 효과가 useEffect를 필요로 하지 않는 이유
    • 부수 효과는 컴포넌트의 렌더링과 직접적으로 관련되지 않지만, 컴포넌트가 동작하는 동안 실행되어야 하는 작업을 의미합니다. 그러나 이러한 부수 효과가 모두 useEffect를 통해 처리되어야 하는 것은 아닙니다. useEffect는 주로 컴포넌트의 생명주기와 연동된 부수 효과를 관리하는 데 사용되며, 다음과 같은 경우에 주로 필요합니다:
      • 컴포넌트가 마운트될 때 실행되어야 하는 작업: 예를 들어, 데이터 페칭, 구독 설정 등.
      • 특정 상태나 props가 변경될 때 실행되어야 하는 작업.
      • 컴포넌트가 언마운트될 때 정리(cleanup) 작업이 필요한 경우: 예를 들어, 타이머 제거, 이벤트 리스너 해제 등.

반면, 사용자 상호작용에 의해 직접 트리거되는 부수 효과는 useEffect를 사용하지 않고도 처리할 수 있습니다. 예를 들어, 버튼 클릭 시 로컬 스토리지에 데이터를 저장하는 작업은 이벤트 핸들러 내에서 직접 처리할 수 있습니다.

  • useEffect를 사용하지 않아도 되는 부수 효과의 예시
    • 이벤트 핸들러 내에서의 부수 효과
      • 사용자가 버튼을 클릭할 때마다 로컬 스토리지에 데이터를 저장하는 경우, 이는 특정 이벤트에 의해 트리거되므로 useEffect를 사용할 필요가 없습니다.
// src/components/PlaceItem.tsx
import React from 'react';
import { Product } from '../types';

interface PlaceItemProps {
  place: Product;
  onSelect: (place: Product) => void;
}

const PlaceItem: React.FC<PlaceItemProps> = ({ place, onSelect }) => {
  const handleSelect = () => {
    onSelect(place);
    // 부수 효과: 로컬 스토리지에 선택된 장소 ID 저장
    const storedIds = localStorage.getItem('selectedPlaces');
    const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
    
    if (!parsedIds.includes(place.id)) {
      const updatedIds = [...parsedIds, place.id];
      localStorage.setItem('selectedPlaces', JSON.stringify(updatedIds));
    }
  };

  return (
    <li>
      <span>{place.name} - {place.price}</span>
      <button onClick={handleSelect}>선택</button>
    </li>
  );
};

export default React.memo(PlaceItem);
  • 설명:

    • 이벤트 핸들러 내 부수 효과: handleSelect 함수는 사용자가 장소를 선택할 때 호출됩니다. 이 함수 내에서 로컬 스토리지에 데이터를 저장하는 부수 효과를 처리하고 있습니다.
    • useEffect 불필요: 이 작업은 특정 이벤트(버튼 클릭)에 의해 트리거되므로, useEffect를 사용하지 않아도 됩니다.
  • 컴포넌트 내부 상태 업데이트와 부수 효과

    • 컴포넌트 내부에서 상태를 업데이트하는 작업은 useEffect 없이도 처리할 수 있습니다. 예를 들어, 입력 필드의 값을 관리하는 것은 부수 효과가 아니므로 useEffect가 필요 없습니다.
// src/components/SearchBar.tsx
import React, { useState } from 'react';

interface SearchBarProps {
  onSearch: (query: string) => void;
}

const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
  const [query, setQuery] = useState<string>('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSearch(query);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={query} onChange={handleChange} placeholder="장소 검색" />
      <button type="submit">검색</button>
    </form>
  );
};

export default SearchBar;
  • 설명:

    • 상태 관리: query 상태는 입력 필드의 값을 관리합니다.
    • 부수 효과 없음: 단순한 상태 업데이트이므로 useEffect가 필요하지 않습니다.
  • useEffect와 부수 효과의 적절한 사용 사례

    • 데이터 페칭: 서버로부터 데이터를 가져오는 작업.

      useEffect(() => {
        const fetchData = async () => {
          const response = await fetch('https://api.example.com/data');
          const result = await response.json();
          setData(result);
        };
      
        fetchData();
      }, []);
    • 구독 설정: 웹소켓이나 이벤트 리스너를 통한 구독.

      useEffect(() => {
        const handleMessage = (message: MessageEvent) => {
          setMessages(prev => [...prev, message.data]);
        };
      
        websocket.addEventListener('message', handleMessage);
      
        return () => {
          websocket.removeEventListener('message', handleMessage);
        };
      }, []);
    • 타이머 설정: setInterval이나 setTimeout을 사용하여 타이머를 설정.

      useEffect(() => {
        const timer = setInterval(() => {
          setCount(prev => prev + 1);
        }, 1000);
      
        return () => clearInterval(timer);
      }, []);
  • useEffect가 불필요한 부수 효과

    • 이벤트 핸들러 내의 부수 효과: 사용자 클릭 시 로컬 스토리지에 데이터 저장.

161. Side Effect 다루기 & useEffect() 훅 활용 / useEffect가 필요 없는 경우: 다른 예시

  • 부수 효과와 useEffect의 사용 필요성 재확인

    • 부수 효과는 컴포넌트의 렌더링 과정과 직접적으로 관련되지 않지만, 컴포넌트가 동작하는 동안 실행되어야 하는 작업을 의미합니다. 예를 들어, 데이터 페칭, 구독 설정, 타이머 설정 등이 이에 해당합니다.
  • 중요한 점:

    • 모든 부수 효과가 useEffect를 필요로 하지 않습니다.
    • useEffect의 과도한 사용이나 불필요한 곳에서의 사용은 오히려 코드의 복잡성을 증가시키고 성능에 부정적인 영향을 줄 수 있습니다.
  • 로컬 스토리지(localStorage)와 부수 효과

    • 이번 예제에서는 사용자가 선택한 장소를 로컬 스토리지에 저장하고, 앱이 다시 로드될 때 이를 불러오는 기능을 구현해보겠습니다. 이 과정에서 부수 효과를 어떻게 관리할지 살펴보겠습니다.
  • 장소 추가 시 로컬 스토리지에 저장

    • 사용자가 장소를 선택할 때마다 해당 장소의 ID를 로컬 스토리지에 저장하는 기능을 구현합니다. 이는 사용자 상호작용에 의해 트리거되는 부수 효과로, useEffect를 사용할 필요가 없습니다.
// src/components/PlaceItem.tsx
import React from 'react';
import { Product } from '../types';

interface PlaceItemProps {
  place: Product;
  onSelect: (place: Product) => void;
}

const PlaceItem: React.FC<PlaceItemProps> = ({ place, onSelect }) => {
  const handleSelect = () => {
    onSelect(place);
    // 부수 효과: 로컬 스토리지에 선택된 장소 ID 저장
    const storedIds = localStorage.getItem('selectedPlaces');
    const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];

    if (!parsedIds.includes(place.id)) {
      const updatedIds = [...parsedIds, place.id];
      localStorage.setItem('selectedPlaces', JSON.stringify(updatedIds));
    }
  };

  return (
    <li>
      <span>{place.name} - {place.price}</span>
      <button onClick={handleSelect}>선택</button>
    </li>
  );
};

export default React.memo(PlaceItem);
  • 설명:

    • 사용자 상호작용 기반: handleSelect 함수는 사용자의 클릭 이벤트에 의해 호출됩니다.
    • 부수 효과 처리: 로컬 스토리지에 데이터를 저장하는 작업을 직접 이벤트 핸들러 내에서 처리합니다.
    • useEffect 불필요: 이 작업은 특정 이벤트(버튼 클릭)에 의해 트리거되므로, useEffect를 사용할 필요가 없습니다.
  • 장소 삭제 시 로컬 스토리지에서 제거

    • 사용자가 장소를 삭제할 때, 로컬 스토리지에서 해당 장소의 ID를 제거하는 기능을 구현합니다. 역시 이는 사용자 상호작용에 의해 트리거되는 부수 효과로, useEffect를 사용할 필요가 없습니다.
// src/components/CartModal.tsx
import React from 'react';
import { Product } from '../types';
import useCart from '../hooks/useCart';

const CartModal: React.FC = () => {
  const { items, removeItemFromCart, updateItemQuantity } = useCart();

  const handleRemove = (productId: number) => {
    removeItemFromCart(productId);
    // 부수 효과: 로컬 스토리지에서 선택된 장소 ID 제거
    const storedIds = localStorage.getItem('selectedPlaces');
    const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];

    const updatedIds = parsedIds.filter(id => id !== productId);
    localStorage.setItem('selectedPlaces', JSON.stringify(updatedIds));
  };

  const handleUpdateQuantity = (productId: number, quantity: number) => {
    if (quantity < 1) return; // 최소 수량 1로 제한
    updateItemQuantity(productId, quantity);
  };

  return (
    <div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
      <h2>장바구니</h2>
      {items.length === 0 ? (
        <p>장바구니에 항목 없음!</p>
      ) : (
        <>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {items.map(product => (
              <li key={product.id} style={{ marginBottom: '10px' }}>
                <span>{product.name} - {product.price}</span>
                <button style={{ marginLeft: '10px' }} onClick={() => handleRemove(product.id)}>삭제</button>
                <input
                  type="number"
                  min="1"
                  value={product.quantity || 1}
                  onChange={(e) => handleUpdateQuantity(product.id, Number(e.target.value))}
                  style={{ marginLeft: '10px', width: '60px' }}
                />
              </li>
            ))}
          </ul>
          <p>총 금액: {items.reduce((total, item) => total + item.price * (item.quantity || 1), 0)}</p>
        </>
      )}
    </div>
  );
};

export default CartModal;
  • 설명:

    • 사용자 상호작용 기반: handleRemove 함수는 사용자가 장소를 삭제할 때 호출됩니다.
    • 부수 효과 처리: 로컬 스토리지에서 해당 장소의 ID를 제거하는 작업을 직접 이벤트 핸들러 내에서 처리합니다.
    • useEffect 불필요: 이 작업은 특정 이벤트(버튼 클릭)에 의해 트리거되므로, useEffect를 사용할 필요가 없습니다.
  • 앱 시작 시 로컬 스토리지에서 데이터 불러오기

    • 앱이 시작될 때, 로컬 스토리지에 저장된 장소 ID들을 불러와 상태를 초기화하는 기능을 구현합니다. 이 경우에는 컴포넌트가 처음 마운트될 때 실행되는 부수 효과이므로, useEffect를 사용하는 것이 적절합니다.
// src/App.tsx
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';
import { Product } from './types';
import { sortPlacesByDistance } from './loc';
import data from './data'; // 가짜 장소 데이터

const App: React.FC = () => {
  const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);

  // 부수 효과: 사용자 위치 파악 및 장소 정렬
  useEffect(() => {
    const handlePosition = (position: GeolocationPosition) => {
      const { latitude, longitude } = position.coords;
      const sorted = sortPlacesByDistance(data, latitude, longitude);
      setSortedPlaces(sorted);
    };

    // 사용자 위치 요청
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
        console.error('위치 정보를 얻는 데 실패했습니다:', error);
      });
    } else {
      console.error('Geolocation을 지원하지 않는 브라우저입니다.');
    }
  }, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행

  // 부수 효과: 로컬 스토리지에서 선택된 장소 ID 불러오기 및 상태 초기화
  useEffect(() => {
    const storedIds = localStorage.getItem('selectedPlaces');
    const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
    const storedPlaces: Product[] = parsedIds.map(id => data.find(place => place.id === id)).filter(Boolean) as Product[];
    setSortedPlaces(storedPlaces);
  }, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행

  return (
    <CartContextProvider>
      <Header />
      <Shop sortedPlaces={sortedPlaces} fallbackText="장소를 거리순으로 정렬합니다" />
      <CartModal />
    </CartContextProvider>
  );
};

export default App;
  • 설명:

    • useEffect 사용: 컴포넌트가 처음 마운트될 때만 실행되는 두 개의 useEffect를 사용합니다.
      1. 사용자 위치 파악 및 장소 정렬: navigator.geolocation.getCurrentPosition을 호출하여 사용자 위치를 파악하고, 이를 기반으로 장소 목록을 거리순으로 정렬하여 sortedPlaces 상태를 업데이트합니다.
      2. 로컬 스토리지에서 선택된 장소 불러오기: 로컬 스토리지에서 저장된 장소 ID들을 불러와 data에서 해당 ID에 해당하는 장소 객체들을 찾아 sortedPlaces 상태를 초기화합니다.
    • 무한 루프 방지: 두 useEffect 모두 빈 의존성 배열([])을 전달하여, 컴포넌트가 처음 마운트될 때만 실행되도록 설정했습니다. 따라서, 상태 업데이트가 발생해도 useEffect가 다시 실행되지 않아 무한 루프를 방지할 수 있습니다.
  • 코드 최적화 및 클린업

    • 위의 예제에서는 useEffect를 적절히 사용하여 부수 효과를 관리하였지만, 로컬 스토리지 관련 작업은 useEffect를 사용하지 않고도 처리할 수 있는 부분이 있습니다. 특히, 초기 상태 설정을 컴포넌트 외부에서 수행하거나, 초기 상태를 설정할 때 함수를 사용하는 방법으로 최적화할 수 있습니다.
  • 초기 상태 설정 시 로컬 스토리지에서 불러오기

    • useState의 초기값을 함수로 전달하여, 컴포넌트가 처음 렌더링될 때 로컬 스토리지에서 데이터를 불러오도록 설정할 수 있습니다. 이를 통해 별도의 useEffect를 사용하지 않고도 초기 상태를 설정할 수 있습니다.
// src/App.tsx
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';
import { Product } from './types';
import { sortPlacesByDistance } from './loc';
import data from './data'; // 가짜 장소 데이터

const App: React.FC = () => {
  // 초기 상태 설정: 로컬 스토리지에서 불러오기
  const [sortedPlaces, setSortedPlaces] = useState<Product[]>(() => {
    const storedIds = localStorage.getItem('selectedPlaces');
    const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
    return parsedIds.map(id => data.find(place => place.id === id)).filter(Boolean) as Product[];
  });

  useEffect(() => {
    // 부수 효과: 사용자 위치 파악 및 장소 정렬
    const handlePosition = (position: GeolocationPosition) => {
      const { latitude, longitude } = position.coords;
      const sorted = sortPlacesByDistance(data, latitude, longitude);
      setSortedPlaces(sorted);
    };

    // 사용자 위치 요청
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
        console.error('위치 정보를 얻는 데 실패했습니다:', error);
      });
    } else {
      console.error('Geolocation을 지원하지 않는 브라우저입니다.');
    }
  }, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행

  return (
    <CartContextProvider>
      <Header />
      <Shop sortedPlaces={sortedPlaces} fallbackText="장소를 거리순으로 정렬합니다" />
      <CartModal />
    </CartContextProvider>
  );
};

export default App;
  • 설명:

    • useState 초기값 함수 사용: useState의 초기값을 함수로 전달하여, 컴포넌트가 처음 렌더링될 때 로컬 스토리지에서 데이터를 불러옵니다. 이렇게 하면 별도의 useEffect 없이도 초기 상태를 설정할 수 있습니다.
    • 무한 루프 방지: 초기 상태 설정 시 useEffect를 사용하지 않아도 되므로, useEffect와 관련된 무한 루프 문제를 방지할 수 있습니다.
  • useEffect가 불필요한 부수 효과의 예시 및 이유

    • 사용자 상호작용에 의한 부수 효과
      • 위에서 살펴본 로컬 스토리지에 데이터 저장 및 삭제는 사용자 상호작용(버튼 클릭)에 의해 트리거됩니다. 이러한 부수 효과는 이벤트 핸들러 내에서 직접 처리할 수 있으며, useEffect를 사용할 필요가 없습니다.
    • 이유:
      • 트리거 시점 명확: 부수 효과가 특정 이벤트에 의해 명확하게 트리거되므로, useEffect 없이도 효과적으로 관리할 수 있습니다.
      • 무한 루프 위험 없음: 이벤트 핸들러 내에서 상태를 업데이트하더라도, useEffect와 달리 컴포넌트가 리렌더링될 때 자동으로 다시 실행되지 않으므로 무한 루프의 위험이 없습니다.
  • useEffect의 적절한 사용과 비사용 상황 이해

    • useEffect를 사용해야 하는 상황
      • 컴포넌트 마운트 시 데이터 페칭: API로부터 데이터를 불러올 때.
      • 구독 설정 및 정리: 웹소켓이나 이벤트 리스너를 설정할 때.
      • 타이머 설정 및 정리: setInterval이나 setTimeout을 사용할 때.
      • 비동기 작업: 비동기적으로 실행되어야 하는 작업을 수행할 때.
    • useEffect를 사용하지 않아도 되는 상황
      • 사용자 상호작용에 의한 부수 효과: 버튼 클릭 등 특정 이벤트에 의해 트리거되는 작업.
      • 동기적인 작업: 로컬 스토리지와 같이 즉시 완료되는 작업.
      • 컴포넌트 렌더링과 직접적으로 연관된 작업: 상태 업데이트 등.
      • 이유:
        • 명확한 트리거 시점: 특정 이벤트에 의해 명확하게 트리거되므로, useEffect를 사용하지 않아도 됩니다.
        • 무한 루프 위험 없음: useEffect와 달리, 이벤트 핸들러 내에서 상태를 업데이트하더라도 자동으로 다시 실행되지 않으므로 무한 루프의 위험이 없습니다.

162. Side Effect 다루기 & useEffect() 훅 활용 / useEffect를 활용하는 다른 적용 사례

기존에는 Modal 컴포넌트를 열고 닫기 위해 부모 컴포넌트에서 ref를 사용하여 open과 close 메서드를 호출했습니다. 그러나 이 접근 방식 대신 Modal 컴포넌트에 open이라는 props를 추가하고, 부모 컴포넌트에서 상태(state)를 관리하여 모달의 열림과 닫힘을 제어하도록 변경했습니다.

이렇게 변경함으로써 useEffect 훅을 사용하여 open props의 변경에 따라 모달의 표시 여부를 제어해야 하는 상황이 생겼습니다. 특히 HTML의 <dialog> 요소는 showModal() 메서드를 호출해야 백드롭이 활성화된 모달이 나타나는데, 단순히 open 속성을 설정하는 것으로는 백드롭이 나타나지 않습니다. 따라서 useEffect를 사용하여 open props가 변경될 때마다 showModal()이나 close()를 호출하도록 구현했습니다.

[사용자 이벤트]
    │
    └─▶ setModalIsOpen(true) 또는 setModalIsOpen(false)
            │
            └─▶ modalIsOpen 상태 변경
                    │
                    └─▶ Modal 컴포넌트의 open props 변경
                            │
                            └─▶ useEffect 훅 감지
                                    │
                                    ├─ open === true ─▶ dialog.showModal()
                                    │
                                    └─ open === false ─▶ dialog.close()
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {children}
    </dialog>
  );
};

export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);

  const openModal = () => setModalIsOpen(true);
  const closeModal = () => setModalIsOpen(false);

  return (
    <div>
      <button onClick={openModal}>모달 열기</button>
      <Modal open={modalIsOpen}>
        <p>모달 내용</p>
        <button onClick={closeModal}>모달 닫기</button>
      </Modal>
    </div>
  );
};

export default App;

163. Side Effect 다루기 & useEffect() 훅 활용 / 브라우저 API를 싱크를 위한 useEffect 활용

  • 문제점 발견: Modal 컴포넌트에서 open prop 값에 따라 dialog.showModal() 또는 dialog.close()를 컴포넌트 함수 내부에서 직접 호출하려 했지만, 에러가 발생했습니다.
  • 에러 원인: 컴포넌트 함수가 처음 실행될 때 dialogRef가 아직 설정되지 않아 undefined 상태이며, 이로 인해 close 메서드를 호출할 수 없습니다.
  • 해결책: useEffect 훅을 사용하여 컴포넌트가 렌더링된 후에 dialog.showModal() 및 dialog.close() 메서드를 호출하도록 변경했습니다. 이렇게 하면 ref가 설정된 후에 메서드를 안전하게 호출할 수 있습니다.
  • 부수 효과의 필요성: showModal()과 close() 메서드는 DOM에 직접적인 영향을 주는 부수 효과이므로, 컴포넌트 함수 내부가 아닌 useEffect 내부에서 처리하는 것이 적절합니다.
  • 의존성 관리의 중요성: useEffect를 사용할 때는 의존성 배열에 필요한 모든 의존성을 정확히 지정해야 합니다. 그렇지 않으면 React는 의존성 경고를 표시합니다.
[App 컴포넌트]
    │
    ├─ 사용자 이벤트 발생 (버튼 클릭 등)
    │
    └─▶ setModalIsOpen(true/false) 호출
            │
            └─▶ modalIsOpen 상태 변경
                    │
                    └─▶ Modal 컴포넌트의 open prop 변경
                            │
                            └─▶ useEffect 훅 실행
                                    │
                                    ├─ ref가 설정되었는지 확인
                                    │
                                    ├─ open === true ─▶ dialog.showModal()
                                    │
                                    └─ open === false ─▶ dialog.close()
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {children}
    </dialog>
  );
};

export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);

  const openModal = () => setModalIsOpen(true);
  const closeModal = () => setModalIsOpen(false);

  return (
    <div>
      <button onClick={openModal}>모달 열기</button>
      <Modal open={modalIsOpen}>
        <p>모달 내용입니다.</p>
        <button onClick={closeModal}>모달 닫기</button>
      </Modal>
    </div>
  );
};

export default App;
  • 설명

    • Modal 컴포넌트에서의 useEffect 사용:
      • useEffect 훅을 사용하여 open prop의 변화에 따라 dialog.showModal() 또는 dialog.close()를 호출합니다.
      • dialogRef.current가 null인지 확인하여 참조가 유효한 경우에만 메서드를 호출합니다.
      • 의존성 배열에 [open]을 포함하여 open 값이 변경될 때마다 효과가 실행되도록 합니다.
    • 에러 방지:
      • 컴포넌트 함수 실행 시점에는 dialogRef.current가 아직 설정되지 않았기 때문에, 직접 메서드를 호출하면 에러가 발생합니다.
      • useEffect는 컴포넌트가 렌더링된 후에 실행되므로, 이 시점에서는 dialogRef.current가 유효합니다.
    • 부수 효과 관리:
      • DOM에 직접 영향을 미치는 작업은 부수 효과로 간주하여 useEffect 안에서 처리합니다.
    • App 컴포넌트에서의 상태 관리:
      • modalIsOpen 상태를 사용하여 모달의 열림과 닫힘을 제어합니다.
      • openModal과 closeModal 함수를 통해 상태를 변경합니다.
  • 주의사항

    • 의존성 배열 관리:
      • dialogRef는 useRef를 통해 생성되며, 변경되지 않는 값을 반환하므로 의존성 배열에 포함하지 않아도 됩니다.
      • 그러나 open은 상태나 props로 전달되는 값이므로, 의존성 배열에 포함해야 합니다.
    • dialog 요소의 특성:
      • <dialog> 요소의 open 속성은 읽기 전용이 아니므로, 직접 설정할 수 있습니다.
      • 그러나 showModal() 메서드를 사용해야 백드롭이 활성화된 모달로 표시됩니다.

164. Side Effect 다루기 & useEffect() 훅 활용 / Effect Dependencis(의존성) 이해하기

  • 문제 발생: Modal 컴포넌트에서 open 속성의 값에 따라 dialog.showModal() 또는 dialog.close()를 컴포넌트 함수 내부에서 직접 호출하려 했지만, 에러가 발생했습니다. 이는 컴포넌트 함수가 처음 실행될 때 dialogRef가 아직 설정되지 않았기 때문입니다.
  • 에러 원인: 컴포넌트 함수는 JSX가 렌더링되기 전에 실행되므로, dialog 요소와 ref 간의 연결이 아직 이루어지지 않았습니다. 따라서 dialogRef.current는 undefined이며, 이에 대해 메서드를 호출하면 에러가 발생합니다.
  • 해결 방법: useEffect 훅을 사용하여 컴포넌트가 렌더링된 후에 dialog.showModal() 또는 dialog.close()를 호출하도록 변경했습니다. 이렇게 하면 ref가 설정된 후에 안전하게 메서드를 호출할 수 있습니다.
  • 부수 효과 처리: dialog.showModal()과 dialog.close()는 UI에 직접적인 영향을 미치는 부수 효과이므로, 컴포넌트 함수 내부가 아닌 useEffect 내부에서 처리하는 것이 적절합니다.
  • 의존성 관리의 중요성: useEffect 훅의 의존성 배열에 open 속성을 포함해야 합니다. 그렇지 않으면 open 값이 변경되더라도 useEffect가 재실행되지 않아 모달이 열리거나 닫히지 않습니다.
  • 결과: useEffect에 open을 의존성으로 추가함으로써, open 값이 변경될 때마다 useEffect가 실행되어 모달이 정상적으로 열리고 닫힙니다. 이를 통해 코드가 이전보다 간결해졌습니다.
[사용자 이벤트]
    │
    └─▶ setModalIsOpen(true) 또는 setModalIsOpen(false)
          │
          └─▶ modalIsOpen 상태 변경
                │
                └─▶ Modal 컴포넌트의 open 속성 변경
                      │
                      └─▶ useEffect 훅 실행 (의존성: [open])
                            │
                            ├─ open === true ─▶ dialog.showModal()
                            │
                            └─ open === false ─▶ dialog.close()
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {children}
    </dialog>
  );
};

export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);

  const openModal = () => setModalIsOpen(true);
  const closeModal = () => setModalIsOpen(false);

  return (
    <div>
      <button onClick={openModal}>모달 열기</button>
      <Modal open={modalIsOpen}>
        <p>모달 내용입니다.</p>
        <button onClick={closeModal}>모달 닫기</button>
      </Modal>
    </div>
  );
};

export default App;

165. Side Effect 다루기 & useEffect() 훅 활용 / dialog 요소와 키보드 ESC 키

<dialog> 요소는 키보드의 ESC 키를 눌러 닫을 수 있습니다. 이 경우, 대화창은 사라지지만 ‘open’ Prop (즉, modalIsOpen 상태)에 전달된 상태가 false로 설정되지 않습니다.

따라서 모달을 다시 열 수 없습니다 (modalIsOpen 이 여전히 true이므로 UI는 상태와 동기화되어 있지 않습니다).

이 문제를 해결하기 위해, <dialog>에 내장된 onClose Prop을 추가하여 모달이 닫히는 것을 청취하도록 해야 합니다. 그런 다음 이 이벤트는 모달 컴포넌트의 커스텀 onClose Prop을 허용함으로써 App 컴포넌트로 "전달"됩니다.

Modal 컴포넌트는 다음과 같이 보여야 합니다:App컴포넌트에서는 이제 handleStopRemovePlace 함수를 <Modal> 컴포넌트의 onClose Prop에 값으로 지정할 수 있습니다

import { useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
 
function Modal({ open, children, onClose }) {
  const dialog = useRef();
 
  useEffect(() => {
    if (open) {
      dialog.current.showModal();
    } else {
      dialog.current.close();
    }
  }, [open]);
 
  return createPortal(
    <dialog className="modal" ref={dialog} onClose={onClose}>
      {children}
    </dialog>,
    document.getElementById('modal')
  );
}
 
export default Modal;

App컴포넌트에서는 이제 handleStopRemovePlace함수를 <Modal>컴포넌트의 onCloseProp에 값으로 지정할 수 있습니다:

<Modal open={modalIsOpen} onClose={handleStopRemovePlace}>
  <DeleteConfirmation
    onCancel={handleStopRemovePlace}
    onConfirm={handleRemovePlace}
  />
</Modal>

166. Side Effect 다루기 & useEffect() 훅 활용 / useEffect의 도움으로 고칠 수 있는 다른 문제들

  • 기능 추가: DeleteConfirmation 컴포넌트에 3초 후 자동으로 확인 동작이 실행되어 모달이 닫히고 항목이 삭제되는 기능을 추가하고자 합니다. 이를 위해 브라우저의 setTimeout 함수를 사용하여 타이머를 설정합니다.
  • 문제점 1: DeleteConfirmation 컴포넌트가 항상 렌더링되기 때문에, 모달이 열려 있지 않아도 타이머가 설정되어 3초 후에 onConfirm이 실행됩니다.
  • 해결책 1: DeleteConfirmation 컴포넌트를 modalIsOpen 상태에 따라 조건부로 렌더링하여, 모달이 열려 있을 때만 타이머가 설정되도록 합니다.
  • 문제점 2: 사용자가 3초 이내에 취소를 클릭하면, 타이머가 여전히 동작하여 3초 후에 onConfirm이 실행됩니다. 이는 타이머를 취소하지 않았기 때문입니다.
  • 해결책 2: useEffect 훅을 사용하여 타이머를 설정하고, 컴포넌트가 언마운트될 때 clearTimeout을 호출하여 타이머를 취소합니다. 이를 통해 모달이 닫히거나 취소되었을 때 타이머가 적절히 정리됩니다.
[사용자 이벤트: 삭제 버튼 클릭]
        │
        └─▶ setModalIsOpen(true)
              │
              └─▶ modalIsOpen 상태 변경
                    │
                    └─▶ Modal 컴포넌트의 open prop 변경
                          │
                          └─▶ Modal 컴포넌트에서 open이 true인 경우에만 children 렌더링
                                │
                                └─▶ DeleteConfirmation 컴포넌트 마운트
                                      │
                                      └─▶ useEffect 훅 실행하여 타이머 설정
                                            │
                                            ├─ 3초 후 onConfirm 실행
                                            │
                                            └─ 모달 닫힘 또는 취소 시
                                                  │
                                                  └─▶ 컴포넌트 언마운트
                                                        │
                                                        └─▶ useEffect의 클린업 함수로 타이머 취소
import React, { useEffect } from 'react';

interface DeleteConfirmationProps {
  onConfirm: () => void;
  onCancel: () => void;
}

const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
  useEffect(() => {
    console.log('타이머 설정됨');
    const timerId = setTimeout(() => {
      console.log('타이머 만료: onConfirm 실행');
      onConfirm();
    }, 3000);

    return () => {
      console.log('타이머 취소됨');
      clearTimeout(timerId);
    };
  }, [onConfirm]);

  return (
    <div>
      <p>정말로 삭제하시겠습니까?</p>
      <button onClick={onCancel}>취소</button>
      <button onClick={onConfirm}>확인</button>
    </div>
  );
};

export default DeleteConfirmation;
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {open ? children : null}
    </dialog>
  );
};

export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);
  const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const openModal = (item: string) => {
    setSelectedItem(item);
    setModalIsOpen(true);
  };

  const closeModal = () => {
    setModalIsOpen(false);
    setSelectedItem(null);
  };

  const handleConfirm = () => {
    if (selectedItem) {
      setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
    }
    closeModal();
  };

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item}>
            {item} <button onClick={() => openModal(item)}>삭제</button>
          </li>
        ))}
      </ul>
      <Modal open={modalIsOpen}>
        {modalIsOpen && (
          <DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
        )}
      </Modal>
    </div>
  );
};

export default App;

useEffect 훅을 사용하여 타이머를 설정하고 클린업 함수를 통해 타이머를 적절히 정리함으로써, 모달이 닫혔을 때도 타이머가 계속 실행되는 문제를 해결했습니다. 또한, DeleteConfirmation 컴포넌트를 조건부로 렌더링하여 모달이 열릴 때만 타이머가 설정되도록 했습니다. 이를 통해 사용자 경험을 향상시키고 예상치 못한 동작을 방지할 수 있습니다.

167. Side Effect 다루기 & useEffect() 훅 활용 / useEffect의 Cleanup 함수 소개

  • 타이머 관리 문제: 컴포넌트가 언마운트될 때도 타이머가 계속 실행되어 부작용이 발생하는 문제가 있었습니다.
  • useEffect 활용: 이 문제를 해결하기 위해 useEffect 훅을 사용하여 타이머를 설정하고, 컴포넌트가 언마운트될 때 타이머를 정리하는 클린업 함수를 정의했습니다.
  • 클린업 함수: useEffect 내부에서 함수를 반환하여 클린업 함수를 정의할 수 있으며, 이는 이펙트가 재실행되기 전이나 컴포넌트가 언마운트되기 전에 호출됩니다.
  • 의존성 배열: useEffect의 의존성 배열에 필요한 의존성을 추가해야 합니다. 그렇지 않으면 이펙트가 예상대로 동작하지 않거나 경고가 발생할 수 있습니다.
  • 클린업 함수의 실행 시점: 클린업 함수는 최초 실행 시에는 호출되지 않고, 이펙트가 다시 실행되기 전이나 컴포넌트가 언마운트될 때 호출됩니다.
  • 버그 해결: 이러한 방법을 통해 컴포넌트가 언마운트될 때 타이머를 정리하여 부작용을 방지하고 버그를 해결할 수 있습니다.
[DeleteConfirmation 컴포넌트 마운트]
        │
        └─▶ useEffect 실행하여 타이머 설정
              │
              └─▶ 3초 후 onConfirm 실행 예정
                    │
                    ├─ 사용자가 취소 버튼 클릭
                    │     │
                    │     └─▶ 컴포넌트 언마운트
                    │           │
                    │           └─▶ useEffect의 클린업 함수 실행
                    │                 │
                    │                 └─▶ clearTimeout으로 타이머 취소
                    │
                    └─ 3초 경과 시
                          │
                          └─▶ onConfirm 실행
import React, { useEffect } from 'react';

interface DeleteConfirmationProps {
  onConfirm: () => void;
  onCancel: () => void;
}

const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
  useEffect(() => {
    console.log('타이머 설정됨');
    const timer = setTimeout(() => {
      console.log('onConfirm 실행');
      onConfirm();
    }, 3000);

    return () => {
      console.log('타이머 정리됨');
      clearTimeout(timer);
    };
  }, [onConfirm]);

  return (
    <div>
      <p>정말로 삭제하시겠습니까?</p>
      <button onClick={onCancel}>취소</button>
      <button onClick={onConfirm}>확인</button>
    </div>
  );
};

export default DeleteConfirmation;
import React, { useState } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);
  const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const openModal = (item: string) => {
    setSelectedItem(item);
    setModalIsOpen(true);
  };

  const closeModal = () => {
    setModalIsOpen(false);
    setSelectedItem(null);
  };

  const handleConfirm = () => {
    if (selectedItem) {
      setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
      console.log(`${selectedItem} 삭제됨`);
    }
    closeModal();
  };

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item}>
            {item} <button onClick={() => openModal(item)}>삭제</button>
          </li>
        ))}
      </ul>
      <Modal open={modalIsOpen}>
        {modalIsOpen && (
          <DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
        )}
      </Modal>
    </div>
  );
};

export default App;
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {open ? children : null}
    </dialog>
  );
};

export default Modal;

168. Side Effect 다루기 & useEffect() 훅 활용 / 객체의 문제점 & 함수 의존성

  • 문제점 설명:

    • useEffect 훅의 의존성 배열에 함수(onConfirm)를 추가할 때, 무한 루프가 발생할 위험이 있습니다.
    • 이는 함수가 렌더링마다 새로 생성되기 때문입니다. 자바스크립트에서 함수는 객체이며, 객체는 항상 새로운 인스턴스로 간주됩니다.
    • 따라서 useEffect는 의존성이 변경되었다고 판단하여 계속해서 재실행되고, 이로 인해 상태 업데이트 및 재렌더링이 반복되어 무한 루프가 발생할 수 있습니다.
  • 함수 비교의 문제점:

    • 두 함수가 동일한 코드를 가지고 있어도, 자바스크립트에서는 서로 다른 객체로 취급됩니다.
    • 이로 인해 useEffect의 의존성 배열에 함수를 포함하면, 의존성이 변경되지 않았다고 생각하더라도 실제로는 변경된 것으로 간주됩니다.
  • 무한 루프의 위험:

    • useEffect 내부에서 상태를 업데이트하면 컴포넌트가 재렌더링되고, 이때 함수가 다시 생성되어 의존성이 변경됩니다.
    • 이 과정이 반복되면 무한 루프에 빠질 수 있습니다.
  • 잠재적 해결책:

    • 무한 루프를 방지하기 위해 함수가 재생성되지 않도록 해야 합니다.
    • 이를 위해 useCallback 훅을 사용하여 함수를 메모이제이션할 수 있습니다.
    • 또는 의존성 배열에서 함수를 제거하거나, 필요한 경우 함수의 의존성을 관리해야 합니다.
[App 컴포넌트]
    │
    ├─ handleRemovePlace 함수 생성 (렌더링마다 새로 생성)
    │
    └─▶ DeleteConfirmation 컴포넌트에 onConfirm으로 전달
          │
          └─▶ useEffect 의존성 배열에 onConfirm 포함
                │
                ├─ onConfirm 변경으로 useEffect 재실행
                │
                ├─ useEffect 내부에서 상태 업데이트
                │
                └─▶ 컴포넌트 재렌더링
                      │
                      └─▶ handleRemovePlace 함수 재생성
                            │
                            └─ (반복) 무한 루프 발생
import React, { useState } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);
  const [items, setItems] = useState(['Item 1', 'Item 2']);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const handleRemovePlace = () => {
    if (selectedItem) {
      setItems(items.filter((item) => item !== selectedItem));
      setSelectedItem(null);
    }
    setModalIsOpen(false);
  };

  const openModal = (item: string) => {
    setSelectedItem(item);
    setModalIsOpen(true);
  };

  return (
    <div>
      {items.map((item) => (
        <div key={item}>
          {item}
          <button onClick={() => openModal(item)}>삭제</button>
        </div>
      ))}
      <Modal open={modalIsOpen}>
        <DeleteConfirmation onConfirm={handleRemovePlace} onCancel={() => setModalIsOpen(false)} />
      </Modal>
    </div>
  );
};

export default App;
import React, { useEffect } from 'react';

interface DeleteConfirmationProps {
  onConfirm: () => void;
  onCancel: () => void;
}

const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
  useEffect(() => {
    const timer = setTimeout(() => {
      onConfirm();
    }, 3000);

    return () => {
      clearTimeout(timer);
    };
  }, [onConfirm]);

  return (
    <div>
      <p>삭제하시겠습니까?</p>
      <button onClick={onCancel}>취소</button>
      <button onClick={onConfirm}>확인</button>
    </div>
  );
};

export default DeleteConfirmation;
  • 문제 설명

    • useEffect의 의존성 배열에 onConfirm 함수를 포함했습니다.
    • App 컴포넌트가 재렌더링될 때마다 handleRemovePlace 함수가 새로 생성됩니다.
    • 함수는 객체이므로, 매 렌더링마다 새로운 참조를 가지게 됩니다.
    • 따라서 onConfirm이 변경되었다고 판단되어 useEffect가 계속 재실행됩니다.
    • useEffect 내부에서 onConfirm을 호출하거나 상태를 업데이트하면 재렌더링이 발생하고, 이 과정이 반복되어 무한 루프에 빠집니다.
  • 해결 방법

    • useCallback 훅 사용
      • handleRemovePlace 함수를 useCallback 훅으로 감싸서, 의존성이 변경되지 않는 한 동일한 함수 인스턴스를 유지하도록 합니다.
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);
  const [items, setItems] = useState(['Item 1', 'Item 2']);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const handleRemovePlace = useCallback(() => {
    if (selectedItem) {
      setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
      setSelectedItem(null);
    }
    setModalIsOpen(false);
  }, [selectedItem]);

  const openModal = (item: string) => {
    setSelectedItem(item);
    setModalIsOpen(true);
  };

  return (
    <div>
      {items.map((item) => (
        <div key={item}>
          {item}
          <button onClick={() => openModal(item)}>삭제</button>
        </div>
      ))}
      <Modal open={modalIsOpen}>
        <DeleteConfirmation onConfirm={handleRemovePlace} onCancel={() => setModalIsOpen(false)} />
      </Modal>
    </div>
  );
};

export default App;
useEffect(() => {
  const timer = setTimeout(() => {
    onConfirm();
  }, 3000);

  return () => {
    clearTimeout(timer);
  };
// 의존성 배열에서 onConfirm 제거
}, []);

169. Side Effect 다루기 & useEffect() 훅 활용 / useCallback 훅

  • 문제점: useEffect의 의존성 배열에 함수를 포함하면 함수가 렌더링될 때마다 새로 생성되기 때문에 무한 루프가 발생할 수 있습니다.
  • 해결책: React의 useCallback 훅을 사용하여 함수가 재생성되지 않도록 메모이제이션하면 무한 루프를 방지할 수 있습니다.
  • useCallback 사용 방법:
    • 첫 번째 인자로 콜백 함수를 전달합니다.
    • 두 번째 인자로 의존성 배열을 전달합니다.
  • 의존성 배열 관리:
    • 함수 내부에서 사용하는 props나 state가 있다면 의존성 배열에 포함해야 합니다.
    • 함수 내부에서 특별히 사용하는 props나 state가 없다면 빈 배열 []을 전달할 수 있습니다.
  • 이점: useCallback을 사용하면 함수가 재생성되지 않으므로 useEffect의 의존성으로 함수를 안전하게 추가할 수 있고, 무한 루프를 방지할 수 있습니다.
[App 컴포넌트]
    │
    ├─ useCallback으로 handleRemovePlace 함수 생성
    │   │
    │   └─ 의존성 배열 관리 (필요한 경우 의존성 추가)
    │
    └─▶ handleRemovePlace 함수를 DeleteConfirmation 컴포넌트에 전달
          │
          └─▶ DeleteConfirmation 컴포넌트의 useEffect에서 onConfirm을 의존성으로 포함
                │
                ├─ 함수가 재생성되지 않으므로 useEffect 재실행 없음
                │
                └─ 무한 루프 방지 및 정상 동작
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);
  const [items, setItems] = useState(['장소 1', '장소 2', '장소 3']);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const handleRemovePlace = useCallback(() => {
    if (selectedItem) {
      setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
      setSelectedItem(null);
    }
    setModalIsOpen(false);
  }, [selectedItem]);

  const openModal = (item: string) => {
    setSelectedItem(item);
    setModalIsOpen(true);
  };

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item}>
            {item} <button onClick={() => openModal(item)}>삭제</button>
          </li>
        ))}
      </ul>
      <Modal open={modalIsOpen}>
        {modalIsOpen && (
          <DeleteConfirmation onConfirm={handleRemovePlace} onCancel={() => setModalIsOpen(false)} />
        )}
      </Modal>
    </div>
  );
};

export default App;
import React, { useEffect } from 'react';

interface DeleteConfirmationProps {
  onConfirm: () => void;
  onCancel: () => void;
}

const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
  useEffect(() => {
    console.log('타이머 설정됨');
    const timer = setTimeout(() => {
      console.log('onConfirm 실행');
      onConfirm();
    }, 3000);

    return () => {
      console.log('타이머 정리됨');
      clearTimeout(timer);
    };
  }, [onConfirm]);

  return (
    <div>
      <p>정말로 삭제하시겠습니까?</p>
      <button onClick={onCancel}>취소</button>
      <button onClick={onConfirm}>확인</button>
    </div>
  );
};

export default DeleteConfirmation;
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {open ? children : null}
    </dialog>
  );
};

export default Modal;

170. Side Effect 다루기 & useEffect() 훅 활용 / useEffect의 Cleanup 함수: 다른 예시

  • 추가 기능: 모달에 진행 표시줄(progress bar)을 추가하여 자동 삭제까지 남은 시간을 사용자에게 보여줍니다.
  • 상태 관리: remainingTime이라는 새로운 상태를 추가하여 남은 시간을 밀리초 단위로 관리합니다.
  • 상수 정의: TIMER라는 상수를 정의하여 총 타이머 시간을 설정하고, 여러 곳에서 일관되게 사용합니다.
  • 상태 업데이트: setInterval 함수를 사용하여 일정 간격(예: 10ms)마다 remainingTime을 감소시킵니다.
  • useEffect 활용:
    • setInterval과 setTimeout을 useEffect 내부에서 설정하여 무한 루프를 방지하고, 컴포넌트가 마운트된 후에 실행되도록 합니다.
    • 의존성 배열을 관리하여 불필요한 재실행을 방지합니다.
  • 클린업 함수:
    • useEffect의 클린업 함수를 사용하여 컴포넌트가 언마운트되거나 이펙트가 재실행되기 전에 clearInterval과 clearTimeout을 호출하여 타이머를 정리합니다.
  • 문제 해결:
    • 진행 표시줄이 예상대로 동작하고, 컴포넌트가 언마운트된 후에도 백그라운드에서 타이머가 계속 실행되는 것을 방지합니다.
    • 무한 루프와 메모리 누수 등의 문제를 해결합니다.
[DeleteConfirmation 컴포넌트 마운트]
        │
        └─▶ useEffect 실행
              │
              ├─ setTimeout 설정 (TIMER ms 후 onConfirm 호출)
              │
              ├─ setInterval 설정 (10ms마다 remainingTime 감소)
              │     │
              │     └─▶ setRemainingTime(prevTime => prevTime - 10)
              │           │
              │           └─▶ remainingTime 상태 업데이트로 리렌더링
              │                 │
              │                 └─▶ progress 요소의 value 업데이트
              │
              └─ 클린업 함수 정의
                    │
                    └─ 컴포넌트 언마운트 또는 재실행 시
                          │
                          └─▶ clearTimeout 및 clearInterval로 타이머 정리
import React, { useEffect, useState } from 'react';

interface DeleteConfirmationProps {
  onConfirm: () => void;
  onCancel: () => void;
}

const TIMER = 3000; // 총 타이머 시간 (밀리초)

const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
  const [remainingTime, setRemainingTime] = useState(TIMER);

  useEffect(() => {
    console.log('타이머 및 인터벌 설정됨');

    // 지정된 시간 후에 onConfirm 호출
    const timer = setTimeout(() => {
      console.log('onConfirm 실행');
      onConfirm();
    }, TIMER);

    // 10ms마다 remainingTime 감소
    const interval = setInterval(() => {
      setRemainingTime((prevTime) => {
        if (prevTime <= 0) {
          clearInterval(interval);
          return 0;
        }
        return prevTime - 10;
      });
    }, 10);

    // 클린업 함수로 타이머 및 인터벌 정리
    return () => {
      console.log('타이머 및 인터벌 정리됨');
      clearTimeout(timer);
      clearInterval(interval);
    };
  }, [onConfirm]);

  return (
    <div>
      <p>정말로 삭제하시겠습니까?</p>
      <progress value={remainingTime} max={TIMER} />
      <button onClick={onCancel}>취소</button>
      <button onClick={onConfirm}>확인</button>
    </div>
  );
};

export default DeleteConfirmation;
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);
  const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const openModal = (item: string) => {
    setSelectedItem(item);
    setModalIsOpen(true);
  };

  const closeModal = () => {
    setModalIsOpen(false);
    setSelectedItem(null);
  };

  const handleConfirm = useCallback(() => {
    if (selectedItem) {
      setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
      console.log(`${selectedItem}이(가) 삭제되었습니다.`);
      setSelectedItem(null);
    }
    setModalIsOpen(false);
  }, [selectedItem]);

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item}>
            {item} <button onClick={() => openModal(item)}>삭제</button>
          </li>
        ))}
      </ul>
      <Modal open={modalIsOpen}>
        {modalIsOpen && (
          <DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
        )}
      </Modal>
    </div>
  );
};

export default App;
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {open ? children : null}
    </dialog>
  );
};

export default Modal;
  • 결론:
    • 진행 표시줄 추가를 통한 사용자 경험 향상:
      • 모달에 진행 표시줄을 추가하여 남은 시간을 시각적으로 표시함으로써 사용자에게 타이머의 존재를 알립니다.
    • useEffect와 클린업 함수의 활용:
      • useEffect 훅과 클린업 함수를 적절히 사용하여 타이머와 인터벌을 관리하고, 컴포넌트의 생명주기에 맞게 정리합니다.
    • 무한 루프 방지 및 성능 최적화:
      • useCallback 훅을 사용하여 함수의 재생성을 방지하고, useEffect의 의존성 배열을 올바르게 관리하여 무한 루프를 방지합니다.
    • 상태 관리 및 리렌더링:
      • remainingTime 상태를 관리하고, 상태 변경에 따라 컴포넌트가 리렌더링되어 진행 표시줄이 업데이트되도록 합니다.

171. Side Effect 다루기 & useEffect() 훅 활용 / State(상태) 업데이트 최적화

  • 성능 문제 인식:

    • DeleteConfirmation 컴포넌트에서 setInterval을 사용하여 상태를 10밀리초마다 업데이트하고 있어, 컴포넌트가 10밀리초마다 리렌더링됩니다.
    • 이는 React가 onConfirm 값을 비교하고 전체 JSX를 재평가하게 하며, 불필요한 성능 부담을 줍니다.
  • 최적화 방안: 진행 표시줄과 관련된 상태 로직과 useEffect 훅을 별도의 ProgressBar 컴포넌트로 분리합니다. 이를 통해 해당 부분만 리렌더링되도록 하여 불필요한 계산을 줄입니다.

  • 구현 단계:

    1. 새로운 컴포넌트 ProgressBar를 생성합니다.
    2. 기존의 상태(remainingTime)와 useEffect 훅을 ProgressBar로 이동합니다.
    3. DeleteConfirmation 컴포넌트에서 ProgressBar를 임포트하고 사용합니다.
    4. TIMER 값을 ProgressBar에 props로 전달하거나 상수로 정의합니다.
    5. 불필요해진 useState와 useEffect 임포트를 DeleteConfirmation에서 제거합니다.
  • 결과: 이제 ProgressBar 컴포넌트만 10밀리초마다 리렌더링되고, DeleteConfirmation 컴포넌트는 불필요한 리렌더링을 하지 않아 성능이 향상됩니다. 앱은 의도한 대로 동작합니다.

[DeleteConfirmation 컴포넌트]
      │
      ├─ ProgressBar 컴포넌트 렌더링
      │     │
      │     ├─ remainingTime 상태 관리
      │     │
      │     ├─ useEffect로 setInterval 설정
      │     │     │
      │     │     ├─ 10ms마다 remainingTime 감소
      │     │     ├─ remainingTime 변경 시 ProgressBar 리렌더링
      │     │     └─ 컴포넌트 언마운트 시 clearInterval로 인터벌 정리
      │     │
      │     └─ progress 요소에 remainingTime 및 TIMER 전달
      │
      ├─ onConfirm 및 onCancel 핸들러 유지
      │
      └─ ProgressBar 외의 다른 부분은 리렌더링되지 않음
import React, { useEffect, useState } from 'react';

interface ProgressBarProps {
  timer: number;
}

const ProgressBar: React.FC<ProgressBarProps> = ({ timer }) => {
  const [remainingTime, setRemainingTime] = useState(timer);

  useEffect(() => {
    const interval = setInterval(() => {
      setRemainingTime((prevTime) => {
        if (prevTime <= 0) {
          clearInterval(interval);
          return 0;
        }
        return prevTime - 10;
      });
    }, 10);

    return () => {
      clearInterval(interval);
    };
  }, [timer]);

  return (
    <progress value={remainingTime} max={timer} />
  );
};

export default ProgressBar;
import React from 'react';
import ProgressBar from './ProgressBar';

interface DeleteConfirmationProps {
  onConfirm: () => void;
  onCancel: () => void;
}

const TIMER = 3000; // 타이머 시간 (밀리초)

const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
  return (
    <div>
      <p>정말로 삭제하시겠습니까?</p>
      <ProgressBar timer={TIMER} />
      <button onClick={onCancel}>취소</button>
      <button onClick={onConfirm}>확인</button>
    </div>
  );
};

export default DeleteConfirmation;
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';

const App: React.FC = () => {
  const [modalIsOpen, setModalIsOpen] = useState(false);
  const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const openModal = (item: string) => {
    setSelectedItem(item);
    setModalIsOpen(true);
  };

  const closeModal = () => {
    setModalIsOpen(false);
    setSelectedItem(null);
  };

  const handleConfirm = useCallback(() => {
    if (selectedItem) {
      setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
      console.log(`${selectedItem}이(가) 삭제되었습니다.`);
      setSelectedItem(null);
    }
    setModalIsOpen(false);
  }, [selectedItem]);

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item}>
            {item} <button onClick={() => openModal(item)}>삭제</button>
          </li>
        ))}
      </ul>
      <Modal open={modalIsOpen}>
        {modalIsOpen && (
          <DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
        )}
      </Modal>
    </div>
  );
};

export default App;
import React, { useEffect, useRef } from 'react';

interface ModalProps {
  open: boolean;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ open, children }) => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) {
        dialog.showModal();
      }
    } else {
      if (dialog.open) {
        dialog.close();
      }
    }
  }, [open]);

  return (
    <dialog ref={dialogRef}>
      {open ? children : null}
    </dialog>
  );
};

export default Modal;

172. 퀴즈 앱 만들기 / 첫 컴포넌트 및 State(상태) 몇 가지

  • 프로젝트 시작 및 컴포넌트 추가
    • 퀴즈 프로젝트 빌드 시작: 함께 퀴즈 애플리케이션을 만들어보겠습니다.
    • 컴포넌트 폴더 생성: src 폴더 내에 components 폴더를 생성합니다.
    • Header.jsx 파일 추가:
      • 페이지 상단에 표시될 헤더를 위한 컴포넌트를 생성합니다.
      • 이 컴포넌트는 헤더 영역을 담당하며, 로고와 타이틀을 표시합니다.
    • Quiz.jsx 파일 추가:
      • 퀴즈 웹 애플리케이션의 핵심 기능을 담당하는 컴포넌트를 생성합니다.
      • 퀴즈 진행과 관련된 다른 컴포넌트를 렌더링하고 제어합니다.
[프로젝트 시작]
      ↓
[src/components 폴더 생성]
      ↓
[Header.jsx 생성]
      ↓
[Header 컴포넌트 구현]
      ├─ header 요소 반환
      │    ├─ img 요소: 로고 이미지 표시
      │    │    ├─ logoImg를 import하여 src에 설정
      │    │    └─ alt 속성에 대체 텍스트 추가
      │    └─ h1 요소: "ReactQuiz" 제목 표시
      ↓
[App.jsx에서 Header 컴포넌트 사용]
      ├─ import Header from './components/Header';
      └─ App 컴포넌트에서 <Header /> 렌더링
      ↓
[페이지 상단에 헤더 표시됨]
      ↓
[Quiz.jsx 생성]
      ↓
[Quiz 컴포넌트 구현]
      ├─ "현재 활성화된 질문" 반환 (초기 상태)
      └─ 상태 관리 시작
          ├─ useState 훅으로 상태 선언
          │    ├─ activeQuestionIndex: 현재 질문 인덱스
          │    └─ userAnswers: 사용자의 답변 배열
          └─ 향후 실제 질문과 답변 로직 구현 예정
import React from 'react';
import logoImg from '../assets/quiz-logo.png';

const Header: React.FC = () => {
  return (
    <header>
      <img src={logoImg} alt="퀴즈 로고" />
      <h1>ReactQuiz</h1>
    </header>
  );
};

export default Header;
import React, { useState } from 'react';

interface Question {
  questionText: string;
  options: string[];
  correctAnswerIndex: number;
}

const questions: Question[] = [
  {
    questionText: 'React는 무엇을 위한 라이브러리인가요?',
    options: ['데이터베이스', '사용자 인터페이스', '백엔드', '테스트'],
    correctAnswerIndex: 1,
  },
  // 추가적인 질문들을 여기에 추가
];

const Quiz: React.FC = () => {
  const [activeQuestionIndex, setActiveQuestionIndex] = useState<number>(0);
  const [userAnswers, setUserAnswers] = useState<number[]>([]);

  const currentQuestion = questions[activeQuestionIndex];

  const handleAnswer = (answerIndex: number) => {
    // 사용자의 답변 저장
    setUserAnswers([...userAnswers, answerIndex]);

    // 다음 질문으로 이동
    if (activeQuestionIndex < questions.length - 1) {
      setActiveQuestionIndex(activeQuestionIndex + 1);
    } else {
      // 퀴즈 완료 처리
      console.log('퀴즈 완료');
    }
  };

  return (
    <div>
      <h2>질문 {activeQuestionIndex + 1}</h2>
      <p>{currentQuestion.questionText}</p>
      <ul>
        {currentQuestion.options.map((option, index) => (
          <li key={index}>
            <button onClick={() => handleAnswer(index)}>{option}</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Quiz;
import React from 'react';
import Header from './components/Header';
import Quiz from './components/Quiz';

const App: React.FC = () => {
  return (
    <div>
      <Header />
      <Quiz />
    </div>
  );
};

export default App;

173. 퀴즈 앱 만들기 / 값 파생시키기, 질문 출력 및 답변 등록

  • 퀴즈 로직을 개선하고, 실제 질문 데이터를 추가합니다.
  • 상태 관리를 최적화하여 불필요한 상태를 제거하고, 파생 가능한 값은 계산하여 사용합니다.
  • 사용자에게 질문을 표시하고, 답변을 선택할 수 있도록 기능을 구현합니다.

  • 상태 최적화: 불필요한 상태(activeQuestionIndex)를 제거하고, userAnswers.length로부터 현재 질문의 인덱스를 파생합니다.
  • 질문 및 답변 표시: questions 데이터를 기반으로 현재 질문과 답변 옵션을 렌더링합니다.
  • 사용자 상호 작용 처리: 사용자가 답변을 선택하면 userAnswers 상태를 업데이트합니다.
  • 컴포넌트 구조 개선: App.jsx에서 Header와 Quiz 컴포넌트를 적절히 구조화하여 렌더링합니다.
  • 추가 작업 예정: 답변 순서 섞기, 퀴즈 종료 시 처리 등의 로직은 이후에 구현할 예정입니다.
[questions.js 파일 추가]
          ↓
[Quiz 컴포넌트에서 questions 데이터 import]
          ↓
[Quiz 컴포넌트 상태 관리]
  └─ userAnswers 상태 (사용자가 선택한 답변 배열)
          ↓
[activeQuestionIndex 계산]
  └─ activeQuestionIndex = userAnswers.length
          ↓
[현재 질문 및 답변 옵션 표시]
  ├─ questions[activeQuestionIndex].text를 통해 질문 표시
  └─ questions[activeQuestionIndex].options를 map하여 답변 옵션 표시
          ↓
[사용자가 답변 선택]
          ↓
[handleSelectAnswer 함수 호출]
          ↓
[userAnswers 상태 업데이트]
  └─ 이전 상태(prevUserAnswers)에 선택된 답변 추가
          ↓
[activeQuestionIndex 증가 (userAnswers.length 증가)]
          ↓
[다음 질문 표시]
          ↓
[반복]
  └─ 질문이 남아있는 동안 계속 진행
          ↓
[질문이 모두 끝나면]
  └─ 에러 발생 (추후 처리 예정)
// questions.ts
interface Question {
  id: number;
  text: string;
  options: string[];
}

const questions: Question[] = [
  {
    id: 1,
    text: 'React는 무엇을 위한 라이브러리인가요?',
    options: ['데이터베이스', '사용자 인터페이스', '백엔드', '테스트'],
  },
  {
    id: 2,
    text: 'TypeScript는 무엇의 슈퍼셋인가요?',
    options: ['JavaScript', 'Python', 'C++', 'Java'],
  },
  // 추가 질문들...
];

export default questions;
// Quiz.tsx
import React, { useState } from 'react';
import questions from '../questions';

const Quiz: React.FC = () => {
  const [userAnswers, setUserAnswers] = useState<string[]>([]);

  const activeQuestionIndex = userAnswers.length;
  const currentQuestion = questions[activeQuestionIndex];

  // 답변 옵션을 무작위로 섞는 함수
  const shuffleOptions = (options: string[]): string[] => {
    return options.sort(() => Math.random() - 0.5);
  };

  const [shuffledOptions, setShuffledOptions] = useState<string[]>([]);

  React.useEffect(() => {
    if (currentQuestion) {
      setShuffledOptions(shuffleOptions([...currentQuestion.options]));
    }
  }, [currentQuestion]);

  const handleSelectAnswer = (selectedAnswer: string) => {
    setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
  };

  if (!currentQuestion) {
    // 모든 질문이 끝났을 때 처리
    return <div>퀴즈가 완료되었습니다!</div>;
  }

  return (
    <div id="quiz">
      <div id="question">
        <h2>질문 {activeQuestionIndex + 1}</h2>
        <p>{currentQuestion.text}</p>
      </div>
      <ul id="answers">
        {shuffledOptions.map((option) => (
          <li key={option} className="answer">
            <button onClick={() => handleSelectAnswer(option)}>
              {option}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Quiz;
// App.tsx
import React from 'react';
import Header from './components/Header';
import Quiz from './components/Quiz';

const App: React.FC = () => {
  return (
    <>
      <Header />
      <main>
        <Quiz />
      </main>
    </>
  );
};

export default App;
  • 상태 관리 개선:

    • 답변을 선택할 때마다 옵션을 무작위로 섞어주어 첫 번째 답변이 항상 정답이 아니도록 하였습니다.
    • 이 외에도 사용자의 점수를 계산하거나 결과를 표시하는 등의 추가 기능을 구현할 수 있습니다.
  • 결론

    • 타입스크립트를 사용하여 타입 안전성을 높이고, 예기치 않은 오류를 줄일 수 있습니다.
    • 상태 관리를 최적화하고, React의 권장 방식에 따라 파생 가능한 값은 계산하여 사용합니다.
    • 컴포넌트 구조를 명확히 하고, 유지 보수성과 확장성을 높였습니다.

174. 퀴즈 앱 만들기 / 답변 셔플 & 퀴즈 로직 추가

  • 답변 순서 무작위 섞기:

    • 현재 답변들은 항상 같은 순서로 표시되며, 특히 첫 번째 답변이 항상 정답입니다.
    • 사용자가 답변의 위치에 의존하지 않고 선택하도록 답변의 순서를 무작위로 섞어야 합니다.
    • 이를 위해 답변 배열을 복사하여 새로운 배열을 만들고, sort() 메서드를 사용하여 무작위로 섞었습니다.
  • 퀴즈 완료 시 에러 방지 및 요약 화면 표시:

    • 기존에는 모든 질문에 답변을 하면 앱이 에러와 함께 충돌했습니다. 이는 activeQuestionIndex가 QUESTIONS 배열의 길이를 넘어섰기 때문입니다.
    • 이를 방지하기 위해 퀴즈가 완료되었는지 확인하는 quizIsComplete라는 계산된 값을 추가했습니다.
    • quizIsComplete가 true일 때는 퀴즈 요소 대신 요약 화면을 표시하도록 조건문을 사용했습니다.
  • 에러 발생 원인과 해결 방법

    • 에러 원인: 퀴즈가 완료되었을 때에도 activeQuestionIndex를 사용하여 답변을 섞으려 했기 때문에, 존재하지 않는 인덱스에 접근하여 에러가 발생했습니다.
    • 해결 방법: quizIsComplete 조건문을 먼저 실행하여, 퀴즈가 완료된 경우에는 더 이상 답변을 섞으려 하지 않도록 로직을 수정했습니다. 이렇게 하면 activeQuestionIndex가 배열의 범위를 벗어나지 않게 되어 에러를 방지할 수 있습니다.
  • 결과 확인

    • 수정된 로직으로 앱을 실행하면, 답변의 순서가 무작위로 섞여 표시됩니다.
    • 모든 질문에 답변하면 에러 없이 '퀴즈 종료' 화면이 나타납니다.
[Quiz 컴포넌트 시작]
      ↓
[현재 질문의 답변 배열 복사]
      ↓
[답변 배열 무작위로 섞기]
 (shuffledAnswers.sort(() => Math.random() - 0.5))
      ↓
[사용자에게 섞인 답변 표시]
      ↓
[사용자가 답변 선택]
      ↓
[userAnswers 상태 업데이트]
      ↓
[activeQuestionIndex 증가]
 (userAnswers.length로 계산)
      ↓
[퀴즈 완료 여부 확인]
 (quizIsComplete = activeQuestionIndex >= QUESTIONS.length)
      ↓
┌──────────────────────────────┐
│         퀴즈 완료 여부          │
└──────────────────────────────┘
          ↓                             ↓
[퀴즈 미완료 상태]              [퀴즈 완료 상태]
    │                                    │
    │                                    │
    ↓                                    ↓
[다음 질문 표시]                   [요약 화면 표시]
    │                                    │
    └────────────────────────────────────┘
import React, { useState, useEffect } from 'react';
import questions from '../questions';
import quizCompleteImage from '../assets/quiz-complete.png';

const Quiz: React.FC = () => {
  const [userAnswers, setUserAnswers] = useState<string[]>([]);
  const activeQuestionIndex = userAnswers.length;
  const quizIsComplete = activeQuestionIndex >= questions.length;

  // 현재 질문 가져오기
  const currentQuestion = questions[activeQuestionIndex];

  // 답변 순서 섞기
  const [shuffledAnswers, setShuffledAnswers] = useState<string[]>([]);

  useEffect(() => {
    if (!quizIsComplete && currentQuestion) {
      // 답변 배열 복사 및 섞기
      const shuffled = [...currentQuestion.answers];
      shuffled.sort(() => Math.random() - 0.5);
      setShuffledAnswers(shuffled);
    }
  }, [currentQuestion, quizIsComplete]);

  const handleSelectAnswer = (selectedAnswer: string) => {
    setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
  };

  if (quizIsComplete) {
    return (
      <div id="summary">
        <h2>퀴즈 종료</h2>
        <img src={quizCompleteImage} alt="트로피 아이콘" />
      </div>
    );
  }

  return (
    <div id="quiz">
      <div id="question">
        <h2>{currentQuestion.text}</h2>
      </div>
      <ul id="answers">
        {shuffledAnswers.map((answer) => (
          <li key={answer} className="answer">
            <button onClick={() => handleSelectAnswer(answer)}>
              {answer}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Quiz;
  • 상태 관리:

    • userAnswers: 사용자가 선택한 답변들을 저장하는 배열입니다.
    • activeQuestionIndex: 현재 질문의 인덱스로, userAnswers.length를 사용하여 계산합니다.
    • quizIsComplete: 퀴즈가 완료되었는지 여부를 나타내는 불리언 값입니다.
  • 답변 섞기 로직:

    • useEffect 훅을 사용하여 현재 질문이 변경될 때마다 실행됩니다.
    • currentQuestion.answers를 복사한 후 sort() 메서드를 사용하여 답변의 순서를 무작위로 섞습니다.
    • quizIsComplete가 false일 때만 실행하여, 퀴즈 완료 후에 에러가 발생하지 않도록 합니다.
  • 퀴즈 완료 시 화면 처리:

    • quizIsComplete가 true일 경우, 요약 화면을 반환합니다.
    • 요약 화면에는 '퀴즈 종료'라는 제목과 함께 quizCompleteImage를 표시합니다.
  • 에러 방지:

    • quizIsComplete를 먼저 체크하여, 퀴즈가 완료된 후에는 currentQuestion이나 shuffledAnswers에 접근하지 않도록 합니다.

175. 퀴즈 앱 만들기 / 질문 타이머 추가

  • 각 질문에 시간 제한 추가:

    • 각 질문마다 제한 시간을 설정하여 사용자가 제한된 시간 내에 답변하도록 유도합니다.
    • 예를 들어, 각 질문에 15초의 시간을 부여합니다.
  • 진행 표시줄(progress bar) 추가:

    • 시간이 흐름에 따라 줄어드는 진행 표시줄을 추가하여 남은 시간을 시각적으로 보여줍니다.
  • 시간 만료 시 자동으로 다음 질문으로 이동:

    • 사용자가 제한 시간 내에 답변하지 못하면, 답변을 등록하지 않고 자동으로 다음 질문으로 넘어갑니다.
  • 구현 전략:

    • 타이머 컴포넌트 분리:
      • Quiz 컴포넌트가 이미 복잡하기 때문에 타이머 기능을 별도의 컴포넌트로 분리합니다.
      • 새로운 컴포넌트인 QuestionTimer를 생성하여 타이머 관련 로직과 UI를 관리합니다.
  • 문제 상황:

    • 타이머가 만료되어도 즉시 다음 질문으로 넘어가지 않음.
    • 타이머가 재설정되지 않거나 화면에 표시되지 않는 경우 발생.
    • 타이머가 만료된 후에도 일정 시간 후에 작동하거나, 화면에 보이지 않는 등 예상치 못한 동작을 보임.
  • 원인 분석:

    • useEffect 훅에서 타이머와 인터벌의 설정 및 정리가 제대로 이루어지지 않아 불필요한 타이머나 인터벌이 누적될 수 있음.
    • 컴포넌트가 리렌더링될 때마다 새로운 타이머와 인터벌이 생성되어 의도하지 않은 동작을 유발.
  • 해결 방안:

    • useEffect 훅을 올바르게 사용하여 타이머와 인터벌이 한 번만 설정되고, 의존성 변경 시 또는 컴포넌트 언마운트 시 정리되도록 수정합니다.
    • Quiz 컴포넌트에서 QuestionTimer를 렌더링할 때 key 속성에 activeQuestionIndex를 전달하여 질문이 변경될 때마다 QuestionTimer 컴포넌트가 재생성되도록 합니다.
[Quiz 컴포넌트]
    ↓
[QuestionTimer 컴포넌트 생성 및 렌더링]
    ├─ props:
    │    ├─ timeout (예: 10000ms)
    │    └─ onTimeout (handleSelectAnswer(null))
    ↓
[QuestionTimer 컴포넌트에서 타이머 시작]
    ├─ setInterval → 일정 간격으로 remainingTime 감소 및 상태 업데이트
    └─ remainingTime이 0이 되면 onTimeout 호출
    ↓
[remainingTime 상태 업데이트]
    ↓
[진행 표시줄(progress bar) 업데이트]
    ↓
[remainingTime이 0이 되면]
    ├─ setInterval 정지
    └─ onTimeout 호출 → Quiz 컴포넌트의 handleSelectAnswer(null) 실행
    ↓
[Quiz 컴포넌트에서 다음 질문으로 이동]
    ↓
[QuestionTimer 컴포넌트 재렌더링 및 타이머 재설정]
    └─ key={activeQuestionIndex}로 인해 새로운 타이머 설정
import React, { useState, useEffect } from 'react';

interface QuestionTimerProps {
  timeout: number; // 밀리초 단위
  onTimeout: () => void;
}

const QuestionTimer: React.FC<QuestionTimerProps> = ({ timeout, onTimeout }) => {
  const [remainingTime, setRemainingTime] = useState<number>(timeout);

  useEffect(() => {
    // remainingTime 초기화
    setRemainingTime(timeout);

    let isCancelled = false;

    const interval = setInterval(() => {
      setRemainingTime((prevRemainingTime) => {
        const newRemainingTime = prevRemainingTime - 100;
        if (newRemainingTime <= 0) {
          clearInterval(interval);
          if (!isCancelled) {
            onTimeout();
          }
          return 0;
        }
        return newRemainingTime;
      });
    }, 100);

    // 정리 함수
    return () => {
      isCancelled = true;
      clearInterval(interval);
    };
  }, [timeout, onTimeout]);

  return (
    <progress
      id="question-timer"
      max={timeout}
      value={remainingTime}
    ></progress>
  );
};

export default QuestionTimer;
import React, { useState } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';

const Quiz: React.FC = () => {
  const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
  const activeQuestionIndex = userAnswers.length;
  const quizIsComplete = activeQuestionIndex >= questions.length;

  const currentQuestion = questions[activeQuestionIndex];

  const handleSelectAnswer = (selectedAnswer: string | null) => {
    setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
  };

  if (quizIsComplete) {
    return <div>퀴즈가 완료되었습니다!</div>;
  }

  return (
    <div id="quiz">
      <QuestionTimer
        key={activeQuestionIndex}
        timeout={10000} // 10초
        onTimeout={() => handleSelectAnswer(null)}
      />
      <div id="question">
        <h2>{currentQuestion.text}</h2>
      </div>
      <ul id="answers">
        {currentQuestion.options.map((option) => (
          <li key={option}>
            <button onClick={() => handleSelectAnswer(option)}>{option}</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Quiz;
  • 타이머 재설정 문제 해결:

    • QuestionTimer 컴포넌트에 key 속성을 설정하여 activeQuestionIndex가 변경될 때마다 컴포넌트가 재생성되도록 합니다.
    • 이를 통해 새로운 질문으로 이동할 때 타이머와 진행 표시줄이 올바르게 재설정됩니다.
  • 사용자 답변 처리:

    • handleSelectAnswer 함수는 string 또는 null 타입의 selectedAnswer를 받아들입니다.
    • 사용자가 답변을 선택하지 않고 시간 초과된 경우 null이 저장됩니다.

176. 퀴즈 앱 만들기 / Effect 의존성 & useCallback 활용법

  • 이전에 퀴즈 애플리케이션에 타이머 기능을 추가하였으나, 타이머가 만료된 후 다음 질문으로 바로 넘어가지 않는 문제가 발생하였습니다.

  • 또한, 타이머가 재설정되지 않거나, 예상치 못한 동작을 보이는 현상이 있었습니다.

  • 문제 진단:

    • 콘솔 로그 추가를 통한 디버깅:
      • QuestionTimer.jsx 파일의 useEffect 내부에 console.log를 추가하여 타이머와 인터벌이 어떻게 설정되고 있는지 확인하였습니다.
      • 콘솔에서 SETTING TIMEOUT과 SETTING INTERVAL 로그가 두 번 나타나는 것을 확인하였습니다.
        • 이는 React의 Strict Mode에서 컴포넌트와 훅이 두 번씩 호출되기 때문입니다.
        • StrictMode는 개발 중 잠재적인 문제를 발견하기 위해 컴포넌트를 두 번 렌더링합니다.
  • Effect 함수의 재실행 원인 분석:

    • QuestionTimer 컴포넌트는 퀴즈가 렌더링될 때 한 번만 렌더링되며, 그 후에는 재생성되지 않습니다.
    • 하지만 useEffect가 예상치 않게 계속 재실행되는 것을 발견하였습니다.
    • 의존성 배열에 포함된 값들 중 변경되는 값이 있는지 확인하였습니다.
      • timeout: 항상 같은 값(예: 10000밀리초)이므로 문제가 아님.
      • onTimeout 함수: 렌더링될 때마다 새로운 함수로 생성되어 참조가 변경됩니다.
  • 함수의 참조 동일성 문제:

    • JavaScript에서 함수는 객체이며, 참조형 데이터 타입입니다.
    • React 컴포넌트 내부에서 함수를 선언하면 렌더링 시마다 새로운 함수 객체가 생성됩니다.
    • 따라서 onTimeout 함수는 매 렌더링 시마다 새로운 참조를 가지게 되어, useEffect의 의존성 배열에 포함된 onTimeout이 변경되면서 useEffect가 재실행됩니다.
  • 해결 방법:

    • useCallback 훅을 사용하여 함수 메모이제이션:
      • 함수의 참조 동일성을 유지하기 위해 useCallback 훅을 사용합니다.
      • Quiz 컴포넌트에서 handleSkipAnswer 함수를 생성하고, useCallback으로 감싸서 의존성이 변경되지 않는 한 동일한 함수 참조를 유지합니다.
  • 결과 확인:

    • 수정 후, useEffect가 처음 마운트될 때만 실행되고, 이후에는 의도치 않은 재실행이 발생하지 않음을 확인하였습니다.
    • 그러나 타이머가 만료된 후에도 다음 질문으로 바로 넘어가지 않는 문제가 남아 있습니다.
    • 이는 타이머가 만료되었을 때 onTimeout이 즉시 실행되지 않거나, 다른 로직상의 문제가 있을 수 있습니다.
  • 요약:

    • 문제 원인: onTimeout 함수가 렌더링 시마다 새로운 참조를 가지게 되어, useEffect의 의존성 배열에 포함된 onTimeout이 변경되면서 useEffect가 재실행됨.
    • 해결 방법: useCallback 훅을 사용하여 함수의 참조 동일성을 유지하고, useEffect의 불필요한 재실행을 방지함.
[Quiz 컴포넌트 렌더링]
      ↓
[handleSelectAnswer 함수 생성]
(매 렌더링 시 새로운 함수 생성)
      ↓
[handleSkipAnswer 함수 생성]
(매 렌더링 시 새로운 함수 생성)
      ↓
[onTimeout으로 handleSkipAnswer 전달]
      ↓
[QuestionTimer 컴포넌트의 useEffect 의존성 배열에 포함된 onTimeout이 변경됨]
      ↓
[QuestionTimer의 useEffect 재실행]
      ↓
[타이머와 인터벌이 예상치 않게 재설정됨]

--------------------------------------------

(해결책 적용 후)

[Quiz 컴포넌트 렌더링]
      ↓
[useCallback으로 handleSelectAnswer 메모이제이션]
      ↓
[useCallback으로 handleSkipAnswer 메모이제이션]
      ↓
[onTimeout으로 handleSkipAnswer 전달]
      ↓
[QuestionTimer 컴포넌트의 useEffect 의존성 배열의 onTimeout이 변경되지 않음]
      ↓
[QuestionTimer의 useEffect 불필요한 재실행 방지]
      ↓
[타이머와 인터벌이 올바르게 동작]
import React, { useState, useCallback } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';

const Quiz: React.FC = () => {
  const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
  const activeQuestionIndex = userAnswers.length;
  const quizIsComplete = activeQuestionIndex >= questions.length;

  const handleSelectAnswer = useCallback((selectedAnswer: string | null) => {
    setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
  }, []);

  const handleSkipAnswer = useCallback(() => {
    handleSelectAnswer(null);
  }, [handleSelectAnswer]);

  if (quizIsComplete) {
    return <div>퀴즈가 완료되었습니다!</div>;
  }

  const currentQuestion = questions[activeQuestionIndex];

  return (
    <div id="quiz">
      <QuestionTimer
        key={activeQuestionIndex}
        timeout={10000}
        onTimeout={handleSkipAnswer}
      />
      <div id="question">
        <h2>{currentQuestion.text}</h2>
      </div>
      <ul id="answers">
        {currentQuestion.options.map((option) => (
          <li key={option}>
            <button onClick={() => handleSelectAnswer(option)}>{option}</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Quiz;
import React, { useState, useEffect } from 'react';

interface QuestionTimerProps {
  timeout: number;
  onTimeout: () => void;
}

const QuestionTimer: React.FC<QuestionTimerProps> = ({ timeout, onTimeout }) => {
  const [remainingTime, setRemainingTime] = useState<number>(timeout);

  useEffect(() => {
    console.log('SETTING TIMEOUT');
    const timer = setTimeout(() => {
      onTimeout();
    }, timeout);

    return () => {
      clearTimeout(timer);
    };
  }, [timeout, onTimeout]);

  useEffect(() => {
    console.log('SETTING INTERVAL');
    setRemainingTime(timeout);

    const interval = setInterval(() => {
      setRemainingTime((prevRemainingTime) => {
        const newTime = prevRemainingTime - 100;
        if (newTime <= 0) {
          clearInterval(interval);
          return 0;
        }
        return newTime;
      });
    }, 100);

    return () => {
      clearInterval(interval);
    };
  }, [timeout]);

  return (
    <progress id="question-timer" max={timeout} value={remainingTime}></progress>
  );
};

export default QuestionTimer;
  • 결론:
    • useCallback 훅을 사용하여 함수의 참조 동일성을 유지함으로써 useEffect의 불필요한 재실행을 방지하였습니다.
    • 이를 통해 타이머와 인터벌이 올바르게 설정되고, 예상치 않은 동작을 수정할 수 있었습니다.
    • 그러나 여전히 남아있는 문제들을 해결하기 위해서는 추가적인 수정이 필요합니다.

176. 퀴즈 앱 만들기 / Effect Cleanup 함수 활용 & 컴포넌트 초기화 Key(키) 사용법

  • 퀴즈 애플리케이션에 타이머 기능을 추가하여 각 질문마다 제한 시간을 설정했습니다.

  • 그러나 진행 표시줄(progress bar)이 예상보다 빠르게 줄어들고, 다음 질문으로 넘어갈 때 타이머와 진행 표시줄이 초기화되지 않는 문제가 발생했습니다.

  • 문제 분석 및 해결 과정:

    1. 진행 표시줄이 예상보다 빠르게 줄어드는 문제:

      • 현상: 10초로 설정된 타이머가 실제로는 약 5초 만에 진행 표시줄이 모두 줄어듭니다.
      • 원인: setInterval이 두 번 실행되어 상태 업데이트가 두 배로 빠르게 일어납니다.
      • 이유: React의 Strict Mode(엄격 모드)가 활성화되어 컴포넌트 함수와 훅이 두 번씩 호출되기 때문입니다.
        • StrictMode는 개발 중 잠재적인 문제를 발견하기 위해 컴포넌트를 두 번 렌더링합니다.
      • 해결 방법:
        • useEffect 훅에 클린업 함수(cleanup function)를 추가하여 이전 인터벌을 정리합니다.

        • 인터벌을 설정하는 useEffect 훅에서 반환값으로 클린업 함수를 제공합니다:

          useEffect(() => {
            const interval = setInterval(...);
            return () => {
              clearInterval(interval);
            };
          }, [timeout]);
    2. 타이머와 진행 표시줄이 다음 질문으로 넘어갈 때 초기화되지 않는 문제:

      • 현상: 다음 질문으로 넘어가도 진행 표시줄이 초기화되지 않고, 타이머도 재설정되지 않습니다.
      • 원인: React는 컴포넌트의 상태를 유지하며, QuestionTimer 컴포넌트가 재생성되지 않기 때문입니다.
        • Quiz 컴포넌트에서 QuestionTimer를 렌더링할 때, activeQuestionIndex가 변경되어도 QuestionTimer 컴포넌트는 동일한 상태로 유지됩니다.
      • 해결 방법:
        • QuestionTimer 컴포넌트에 key 속성을 추가하여 activeQuestionIndex가 변경될 때마다 컴포넌트를 재생성하도록 합니다:

          <QuestionTimer
            key={activeQuestionIndex}
            timeout={10000}
            onTimeout={handleSkipAnswer}
          />
        • React는 key 속성의 변화를 감지하여 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트합니다.

        • 이를 통해 새로운 질문으로 넘어갈 때마다 타이머와 진행 표시줄이 초기화됩니다.

    3. 클린업 함수의 중요성 및 Strict Mode에서의 동작 이해:

      • Strict Mode의 목적:
        • 개발 중에 발생할 수 있는 잠재적인 버그를 식별하고 조기에 해결할 수 있도록 돕습니다.
        • 컴포넌트와 훅을 두 번씩 실행하여 순수하지 않은 함수나 부작용을 유발하는 코드를 감지합니다.
      • 클린업 함수 추가로 문제 해결:
        • useEffect 훅에서 클린업 함수를 제공하지 않으면, 인터벌이나 타이머가 중복으로 실행되어 예기치 않은 동작을 유발합니다.
        • 클린업 함수를 통해 이전 효과를 정리하여 메모리 누수와 버그를 방지합니다.
  • 최종 결과:

    • 진행 표시줄이 예상한 대로 타이머에 맞춰 줄어듭니다.
    • 타이머가 만료되면 다음 질문으로 넘어가고, 진행 표시줄과 타이머가 초기화됩니다.
    • 모든 질문에 대해 이 과정이 반복되며, 퀴즈가 정상적으로 동작합니다.
  • React에서 key 속성의 활용:

    • key 속성은 목록 렌더링 시에만 사용하는 것이 아니라, 컴포넌트를 강제로 재생성하고자 할 때 유용하게 사용할 수 있습니다.
    • key 값이 변경되면 React는 해당 컴포넌트를 언마운트하고 새로운 컴포넌트를 마운트합니다.
    • 이를 통해 상태 초기화 및 컴포넌트 재생성이 필요할 때 효과적으로 활용할 수 있습니다.
[Quiz 컴포넌트]
    ↓
[QuestionTimer 컴포넌트 렌더링]
    ├─ props:
    │    ├─ key = activeQuestionIndex
    │    ├─ timeout
    │    └─ onTimeout
    ↓
[QuestionTimer 컴포넌트 마운트]
    ↓
[useEffect 훅 실행]
    ├─ setInterval 설정 (남은 시간 감소)
    ├─ setTimeout 설정 (onTimeout 호출 예정)
    └─ 클린업 함수 등록 (clearInterval, clearTimeout)
    ↓
[남은 시간 업데이트 및 진행 표시줄 업데이트]
    ↓
[남은 시간이 0이 되면]
    ├─ clearInterval 호출
    ├─ onTimeout 호출 → Quiz 컴포넌트의 handleSkipAnswer 실행
    ↓
[Quiz 컴포넌트에서 userAnswers 상태 업데이트]
    ↓
[activeQuestionIndex 증가]
    ↓
[Quiz 컴포넌트 재렌더링]
    ↓
[QuestionTimer 컴포넌트 재생성 (key 값 변경)]
    ├─ 이전 컴포넌트 언마운트 (클린업 함수 실행)
    └─ 새로운 컴포넌트 마운트 (타이머 및 인터벌 재설정)
    ↓
[새로운 질문에 대해 타이머 및 진행 표시줄 초기화]
    ↓
[위 과정 반복]
import React, { useState, useEffect } from 'react';

interface QuestionTimerProps {
  timeout: number; // 밀리초 단위
  onTimeout: () => void;
}

const QuestionTimer: React.FC<QuestionTimerProps> = ({ timeout, onTimeout }) => {
  const [remainingTime, setRemainingTime] = useState<number>(timeout);

  useEffect(() => {
    // 남은 시간 초기화
    setRemainingTime(timeout);

    // 타이머 설정
    const timer = setTimeout(() => {
      onTimeout();
    }, timeout);

    // 인터벌 설정 (진행 표시줄 업데이트)
    const interval = setInterval(() => {
      setRemainingTime((prevRemainingTime) => {
        const newTime = prevRemainingTime - 100;
        if (newTime <= 0) {
          clearInterval(interval);
          return 0;
        }
        return newTime;
      });
    }, 100);

    // 클린업 함수: 타이머와 인터벌 정리
    return () => {
      clearTimeout(timer);
      clearInterval(interval);
    };
  }, [timeout, onTimeout]);

  return (
    <progress id="question-timer" max={timeout} value={remainingTime}></progress>
  );
};

export default QuestionTimer;
import React, { useState, useCallback } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';

const Quiz: React.FC = () => {
  const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
  const activeQuestionIndex = userAnswers.length;
  const quizIsComplete = activeQuestionIndex >= questions.length;

  const handleSelectAnswer = useCallback((selectedAnswer: string | null) => {
    setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
  }, []);

  const handleSkipAnswer = useCallback(() => {
    handleSelectAnswer(null);
  }, [handleSelectAnswer]);

  if (quizIsComplete) {
    return <div>퀴즈가 완료되었습니다!</div>;
  }

  const currentQuestion = questions[activeQuestionIndex];

  return (
    <div id="quiz">
      <QuestionTimer
        key={activeQuestionIndex} // key 속성 추가
        timeout={10000} // 10초 제한 시간
        onTimeout={handleSkipAnswer}
      />
      <div id="question">
        <h2>{currentQuestion.text}</h2>
      </div>
      <ul id="answers">
        {currentQuestion.options.map((option) => (
          <li key={option}>
            <button onClick={() => handleSelectAnswer(option)}>{option}</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Quiz;
  • 결론:

    • 클린업 함수의 중요성:
      • useEffect 훅에서 클린업 함수를 사용하여 인터벌과 타이머를 정리함으로써 메모리 누수와 버그를 방지할 수 있습니다.
    • key 속성을 통한 컴포넌트 재생성:
      • key 속성을 활용하여 컴포넌트를 강제로 재생성함으로써 상태 초기화와 같은 필요한 동작을 구현할 수 있습니다.
    • 함수의 참조 동일성 유지:
      • useCallback 훅을 사용하여 함수의 참조가 변경되지 않도록 함으로써 useEffect의 의존성 배열에서 불필요한 재실행을 방지할 수 있습니다.
  • React Strict Mode:

    • 개발 중에 잠재적인 버그를 발견하고 예방하기 위한 도구로, 컴포넌트와 훅을 두 번씩 실행합니다.
    • 이에 따라 발생하는 예상치 못한 동작은 클린업 함수를 통해 해결할 수 있습니다.
  • useEffect 훅의 클린업 함수:

    • 반환값으로 클린업 함수를 제공하여 컴포넌트 언마운트 또는 의존성 변경 시 실행됩니다.
    • 인터벌, 타이머, 이벤트 리스너 등의 정리에 필수적입니다.
  • key 속성의 활용:

    • 목록 렌더링뿐만 아니라, 컴포넌트를 강제로 재생성해야 할 때 유용하게 사용할 수 있습니다.

177. 퀴즈 앱 만들기 / 선택된 답변 강조 & 추가 State(상태) 관리

  • 사용자에게 선택한 답변을 강조 표시하고, 정답 여부를 보여주기:

    • 사용자가 답변을 선택하면 해당 답변을 강조 표시합니다.
    • 1초 후에 선택한 답변이 맞는지 틀린지 표시합니다.
      • 정답이면 초록색(correct 클래스)
      • 오답이면 빨간색(wrong 클래스)
    • 약 2초 후에 다음 질문으로 넘어갑니다.
  • 구현 전략:

    • 추가 상태 관리
      • answerState라는 새로운 상태를 추가하여 현재 답변의 상태를 관리합니다.
        • 초기값은 빈 문자열 ''입니다.
        • 값은 'answered', 'correct', 'wrong' 중 하나가 됩니다.
      • handleSelectAnswer 함수 수정:
        • 사용자가 답변을 선택하면 answerState를 'answered'로 설정합니다.
        • 1초 후에 선택한 답변이 정답인지 확인하고, answerState를 'correct' 또는 'wrong'으로 업데이트합니다.
      • 컴포넌트 렌더링 로직 수정:
        • answerState와 사용자의 선택에 따라 버튼의 CSS 클래스를 동적으로 변경합니다.
          • 선택된 답변을 강조 표시하고, 정답 여부에 따라 색상을 변경합니다.
      • 질문 진행 로직 조정:
        • 답변이 완료되면 다음 질문으로 넘어가기 전에 잠시 대기합니다.
        • answerState를 통해 현재 질문을 유지하거나 다음 질문으로 넘어갈지 결정합니다.
      • 문제 발생:
        • 구현 후 테스트해보니, 답변을 선택할 때 강조 표시가 깜빡거리거나 예상치 못한 동작을 보입니다.
  • 문제 원인 분석:

    • activeQuestionIndex와 userAnswers의 관리 방식 문제:
      • activeQuestionIndex를 userAnswers.length와 answerState에 따라 동적으로 변경하고 있습니다.
        • answerState가 빈 문자열이면 userAnswers.length를 사용하고, 그렇지 않으면 userAnswers.length - 1을 사용합니다.
      • 이러한 방식은 상태 업데이트와 컴포넌트 렌더링 사이에 혼란을 초래하여, 컴포넌트가 현재 질문과 이전 질문 사이에서 빠르게 전환되는 문제가 발생합니다.
    • 결과적으로, 버튼의 강조 표시와 CSS 클래스 적용이 불안정해져 깜빡거리는 현상이 나타납니다.
[사용자가 답변 선택]
        ↓
[handleSelectAnswer 함수 호출]
        ├─ answerState를 'answered'로 설정
        ├─ setTimeout으로 1초 후에 정답 확인
        ↓
[1초 후]
        ├─ 선택한 답변이 정답인지 확인
        ├─ answerState를 'correct' 또는 'wrong'으로 업데이트
        ↓
[컴포넌트 렌더링]
        ├─ answerState와 userAnswers를 기반으로 버튼 CSS 클래스 적용
        │    ├─ 선택된 답변 강조 (selected)
        │    ├─ 정답이면 'correct' 클래스 적용
        │    └─ 오답이면 'wrong' 클래스 적용
        ↓
[2초 후]
        ├─ answerState를 ''로 초기화
        ├─ 다음 질문으로 이동 (userAnswers 배열 업데이트)
        ↓
[문제 발생]
        ├─ activeQuestionIndex가 answerState에 따라 동적으로 변경되어
        └─ 컴포넌트가 현재 질문과 이전 질문 사이에서 빠르게 전환됨
  • 문제 해결 및 코드 수정:
    • activeQuestionIndex 관리 방식 수정:
      • activeQuestionIndex를 항상 userAnswers.length로 유지합니다.
      • 현재 질문을 유지하기 위해 다른 상태 변수를 사용합니다.
    • isAnswerRevealed 상태 추가:
      • 현재 답변의 정답 여부를 사용자에게 보여줄지 여부를 나타내는 새로운 상태를 추가합니다.
      • 답변을 선택하면 isAnswerRevealed를 true로 설정하고, 2초 후에 다음 질문으로 넘어갑니다.
    • selectedAnswer 상태 추가:
      • 사용자가 선택한 답변을 저장하는 상태를 추가합니다.
      • 이를 통해 선택된 답변을 추적하고 CSS 클래스를 적용합니다.
// Quiz.tsx
import React, { useState, useCallback, useEffect } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';

const Quiz: React.FC = () => {
  const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
  const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
  const [isAnswerRevealed, setIsAnswerRevealed] = useState<boolean>(false);

  const activeQuestionIndex = userAnswers.length;
  const quizIsComplete = activeQuestionIndex >= questions.length;

  const currentQuestion = questions[activeQuestionIndex];

  const handleSelectAnswer = useCallback((answer: string | null) => {
    if (isAnswerRevealed) return; // 이미 답변을 선택한 경우 무시

    setSelectedAnswer(answer);
    setIsAnswerRevealed(true);

    // 1초 후에 다음 작업 수행
    setTimeout(() => {
      // 정답을 userAnswers에 저장
      setUserAnswers((prevUserAnswers) => [...prevUserAnswers, answer]);

      // 1초 후에 다음 질문으로 이동
      setTimeout(() => {
        setSelectedAnswer(null);
        setIsAnswerRevealed(false);
      }, 1000);
    }, 1000);
  }, [isAnswerRevealed]);

  const handleSkipAnswer = useCallback(() => {
    handleSelectAnswer(null);
  }, [handleSelectAnswer]);

  useEffect(() => {
    // 타이머가 만료되어도 답변을 선택하지 않은 경우 처리
    if (isAnswerRevealed && selectedAnswer === null) {
      setUserAnswers((prevUserAnswers) => [...prevUserAnswers, null]);
      setTimeout(() => {
        setIsAnswerRevealed(false);
      }, 1000);
    }
  }, [isAnswerRevealed, selectedAnswer]);

  if (quizIsComplete) {
    return <div>퀴즈가 완료되었습니다!</div>;
  }

  const correctAnswer = currentQuestion.answers[0];

  return (
    <div id="quiz">
      <QuestionTimer
        key={activeQuestionIndex}
        timeout={10000}
        onTimeout={handleSkipAnswer}
      />
      <div id="question">
        <h2>{currentQuestion.text}</h2>
      </div>
      <ul id="answers">
        {currentQuestion.answers.map((answer) => {
          let className = '';
          if (isAnswerRevealed) {
            if (answer === selectedAnswer) {
              className = answer === correctAnswer ? 'correct' : 'wrong';
            } else if (answer === correctAnswer) {
              className = 'correct';
            }
          }

          return (
            <li key={answer}>
              <button
                className={className}
                onClick={() => handleSelectAnswer(answer)}
                disabled={isAnswerRevealed} // 답변 선택 후 버튼 비활성화
              >
                {answer}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default Quiz;
  • 문제 원인: activeQuestionIndex와 userAnswers.length를 answerState에 따라 동적으로 변경하면서 컴포넌트의 상태 관리가 복잡해지고, 예상치 못한 렌더링 문제가 발생했습니다.

  • 해결 방법: 새로운 상태 변수인 selectedAnswer와 isAnswerRevealed를 도입하여 상태 관리를 단순화하고, 컴포넌트의 렌더링 로직을 안정화했습니다.

  • 이점:

    • 상태 관리가 명확해지고, 컴포넌트의 재렌더링 문제가 해결되었습니다.
    • 사용자 경험이 향상되어 답변 선택 후 정답 여부를 명확하게 보여줄 수 있습니다.
  • 추가 학습 포인트:

    • 상태 관리는 최대한 단순하게: 불필요하게 상태 변수를 조작하거나 의존성을 복잡하게 만들면 예기치 않은 문제가 발생할 수 있습니다.
    • 컴포넌트 렌더링 흐름 이해: 상태 변화에 따른 컴포넌트의 렌더링 흐름을 명확히 이해하고, 상태 업데이트가 어떤 영향을 미치는지 고려해야 합니다.
    • React에서의 시간 기반 동작 관리: setTimeout 등을 사용할 때는 클린업과 상태 관리를 신중히 해야 합니다.

178. 퀴즈 앱 만들기 / 컴포넌트 분리를 통해 문제 해결하기

  • 문제 상황:

    • 답변이 예상치 못하게 다시 섞이는 문제 발생
      • 사용자가 답변을 선택할 때마다 답변의 순서가 바뀌는 현상이 발생했습니다.
      • 이는 Quiz 컴포넌트에서 상태(answerState)가 변경될 때마다 컴포넌트가 재렌더링되고, 그로 인해 답변이 다시 섞이기 때문입니다.
    • 한 번만 답변을 섞도록 수정하려고 시도
      • useRef 훅을 사용하여 답변을 한 번만 섞고, 이후에는 동일한 순서를 유지하도록 수정했습니다.
      • 하지만 이로 인해 새로운 질문으로 넘어갈 때 답변이 다시 섞이지 않고, 이전 질문의 답변이 그대로 표시되는 문제가 발생했습니다.
  • 문제 원인 분석:

    • useRef를 사용한 접근의 한계
      • useRef는 컴포넌트 인스턴스 간에 상태를 공유하지 않습니다.
      • 따라서 Quiz 컴포넌트가 재렌더링되어도 useRef로 저장한 shuffledAnswers는 업데이트되지 않습니다.
      • 결과적으로 새로운 질문으로 넘어갈 때도 이전에 섞은 답변 순서가 그대로 유지됩니다.
  • 해결 방법

    1. Answers 컴포넌트 생성 및 로직 분리

      • Answers 컴포넌트를 생성하여 답변 목록을 렌더링하고, 답변을 섞는 로직을 이 컴포넌트로 이동시켰습니다.
      • 이렇게 함으로써 각 질문마다 Answers 컴포넌트가 별도로 관리되며, 질문이 바뀔 때마다 새로운 인스턴스가 생성됩니다.
    2. key 속성을 활용한 컴포넌트 재생성

      • Answers 컴포넌트에 key 속성으로 activeQuestionIndex를 설정하여, 질문이 변경될 때마다 컴포넌트가 재생성되도록 했습니다.
      • React는 key 값이 변경되면 해당 컴포넌트를 언마운트하고 새로운 컴포넌트를 마운트합니다.
    3. Question 컴포넌트 생성으로 구조 개선

      • Question 컴포넌트를 추가로 생성하여, 질문 텍스트와 타이머, 답변 등을 하나의 컴포넌트로 묶었습니다.
      • 이를 통해 Quiz 컴포넌트의 복잡도를 낮추고, key 충돌 문제를 해결했습니다.
      • Question 컴포넌트에 key 속성을 설정하여, 질문이 변경될 때마다 전체 질문 컴포넌트가 재생성되도록 했습니다.
[Quiz 컴포넌트]
    ├─ 상태:
    │    ├─ userAnswers
    │    ├─ answerState
    │    └─ selectedAnswer
    ├─ 함수:
    │    ├─ handleSelectAnswer
    │    └─ handleSkipAnswer
    └─ 렌더링:
         └─ [Question 컴포넌트 key={activeQuestionIndex}]
              ├─ props:
              │    ├─ questionText
              │    ├─ answers
              │    ├─ onSelectAnswer
              │    ├─ onSkipAnswer
              │    ├─ selectedAnswer
              │    └─ answerState
              └─ 렌더링:
                   ├─ [QuestionTimer 컴포넌트]
                   ├─ 질문 텍스트 표시
                   └─ [Answers 컴포넌트 key={activeQuestionIndex}]
                        ├─ props:
                        │    ├─ answers
                        │    ├─ onSelect
                        │    ├─ selectedAnswer
                        │    └─ answerState
                        └─ 로직:
                             ├─ 답변을 한 번만 섞음 (useRef 사용)
                             ├─ 답변 목록 렌더링
                             └─ 선택된 답변에 따라 CSS 클래스 적용
  • 결론

    • 문제 해결: 컴포넌트를 분리하고 key 속성을 적절히 활용함으로써 상태 관리와 컴포넌트 재생성을 효과적으로 제어할 수 있었습니다.
    • 컴포넌트 분리의 이점:
      • 각 컴포넌트가 자신의 역할과 상태를 관리하므로 코드의 복잡성이 줄어듭니다.
      • 재사용성과 유지 보수성이 향상됩니다.
    • React의 key 속성 활용:
      • key 속성을 통해 React가 컴포넌트를 식별하고, 필요한 경우 재생성하도록 제어할 수 있습니다.
      • 이는 특히 상태 초기화나 컴포넌트별로 독립적인 로직을 관리할 때 유용합니다.
  • 추가 학습 포인트:

    • useRef 훅의 활용:
      • 컴포넌트가 재렌더링되어도 값이 변경되지 않는 변수를 저장할 때 사용합니다.
      • 그러나 컴포넌트가 재생성되면 useRef로 저장한 값도 초기화됩니다.
    • 상태 관리의 중요성:
      • 상태 관리를 명확하게 하고, 불필요한 상태 변경을 피해야 합니다.
      • 상태 변경이 컴포넌트의 재렌더링에 어떤 영향을 미치는지 이해해야 합니다.
    • 컴포넌트 설계 원칙:
      • 한 가지 역할을 수행하는 작은 컴포넌트로 분리하는 것이 좋습니다.
      • 이는 코드의 가독성과 유지 보수성을 높여줍니다.

179. 퀴즈 앱 만들기 / key (재렌더링 vs. 재생성)

React에서 컴포넌트의 key 값을 변경하면, React는 해당 컴포넌트를 재렌더링하는 것이 아니라, 완전히 새로운 컴포넌트로 인식하여 기존 컴포넌트를 언마운트(unmount)하고 새로운 컴포넌트를 마운트(mount) 합니다. 이로 인해 컴포넌트는 재생성되며, 이 과정에서 컴포넌트의 모든 상태와 useRef로 저장된 값들이 초기화됩니다.

  • 따라서:

    • 재렌더링(Re-rendering): 컴포넌트의 상태나 props가 변경되어 컴포넌트 함수가 다시 실행되는 것입니다. 이 경우, 컴포넌트의 상태와 useRef로 저장된 값은 유지됩니다.
    • 재생성(Re-creation): 컴포넌트가 언마운트되고 새로운 인스턴스로 다시 마운트되는 것입니다. 이 경우, 컴포넌트의 상태와 useRef로 저장된 값은 초기화됩니다.
  • 예를 들어:

    • 재렌더링 시나리오:
      • 상태 업데이트 또는 부모 컴포넌트의 재렌더링으로 인해 컴포넌트 함수가 다시 실행됩니다.
      • 이때 useRef로 저장된 값은 그대로 유지됩니다.
      • 컴포넌트의 내부 상태나 화면에 표시되는 내용이 업데이트될 수 있지만, 컴포넌트의 식별자는 변경되지 않습니다.
    • 재생성 시나리오:
      • 컴포넌트의 key 값이 변경되면, React는 이전 컴포넌트를 언마운트하고 새로운 컴포넌트를 마운트합니다.
      • 이 과정에서 컴포넌트의 상태와 useRef로 저장된 값은 초기화됩니다.
      • 컴포넌트는 새로운 인스턴스로 취급되며, 초기 상태에서 다시 시작합니다.

결론적으로, 컴포넌트의 key 값을 변경하면 컴포넌트는 재렌더링되는 것이 아니라 완전히 재생성되며, 이로 인해 useRef로 정의한 값도 초기화되어 유지되지 않게 됩니다.

  • 추가 설명

    • useRef의 동작 원리:
      • useRef는 컴포넌트 인스턴스 내에서 유지되는 변수 저장소로, 재렌더링 간에 값이 유지됩니다.
      • 그러나 컴포넌트가 언마운트되면 해당 인스턴스와 연결된 모든 값이 사라집니다.
      • 따라서 컴포넌트가 재생성되면 새로운 useRef 객체가 생성되고, 초기값으로 설정됩니다.
    • key 속성의 역할:
      • React는 key 속성을 사용하여 요소나 컴포넌트를 식별합니다.
      • 동일한 key 값을 가진 컴포넌트는 동일한 것으로 간주되어 상태와 useRef 값이 유지됩니다.
      • key 값이 변경되면 React는 해당 컴포넌트를 새로운 것으로 인식하여 이전 컴포넌트를 언마운트하고 새로운 컴포넌트를 마운트합니다.
  • 주의점

    • key 속성 남용 주의: 불필요하게 key 값을 자주 변경하여 컴포넌트를 재생성하는 것은 성능에 부정적인 영향을 미칠 수 있습니다. 필요한 경우에만 사용하시길 권장합니다.

180. 퀴즈 앱 만들기 / 컴포넌트에 필요한 로직 이동하기 (State 하위 이동)

  • 상황 요약:

    • 기존 기능:
      • 퀴즈 애플리케이션에서 각 질문에 대해 타이머와 무작위로 섞인 답변을 제공.
      • 사용자가 답변을 선택하면 답변이 강조 표시되고, 정답 여부에 따라 색상이 변경되며, 일정 시간 후 다음 질문으로 자동으로 넘어감.
    • 발생한 문제점:
      • 답변 섞기 문제: Quiz 컴포넌트의 상태(answerState)가 변경될 때마다 컴포넌트가 재렌더링되며, 이로 인해 답변이 다시 섞이는 현상이 발생.
      • 컴포넌트 키 충돌: Quiz 컴포넌트 내에서 Question과 Answers 컴포넌트에 동일한 key 값을 부여하여 React의 key 충돌 경고가 발생.
      • 상태 관리 복잡성: Quiz 컴포넌트가 지나치게 많은 상태(answerState, selectedAnswer 등)를 관리하게 되어 코드가 복잡해짐.
  • 문제 해결 요약:

    • 컴포넌트 분리: Quiz, Question, Answers, QuestionTimer 컴포넌트로 로직을 분리하여 각 컴포넌트가 자신의 역할에 집중하도록 함.
    • 키 관리: 각 컴포넌트에 고유한 key 값을 부여하여 컴포넌트가 재생성되도록 함으로써 답변 섞기 로직이 올바르게 작동하도록 함.
    • 상태 관리 최적화: Quiz 컴포넌트에서 answerState를 제거하고, Question 컴포넌트 내에서 개별적으로 상태를 관리함으로써 코드의 복잡성을 줄임.
    • 타이머 및 인터벌 관리: QuestionTimer 컴포넌트 내에서 타이머와 인터벌을 관리하고, useEffect 클린업 함수를 통해 중복 실행을 방지함.
    • 사용자 피드백 강화: 사용자가 답변을 선택하면 시각적으로 피드백을 제공하고, 자동으로 다음 질문으로 넘어가는 기능을 구현하여 사용자 경험을 향상시킴.

181. 퀴즈 앱 만들기 / 선택된 답변 기반 타이머 설정

[Quiz 컴포넌트]
    ├─ 상태:
    │    ├─ userAnswers: (string | null)[]
    │    └─ activeQuestionIndex: number (userAnswers.length)
    ├─ 함수:
    │    ├─ handleSelectAnswer (useCallback)
    │    └─ handleSkipAnswer (useCallback)
    └─ 렌더링:
         └─ [Question 컴포넌트 key={activeQuestionIndex}]
              ├─ props:
              │    ├─ questionText: string
              │    ├─ answers: string[]
              │    ├─ onSelectAnswer: (answer: string | null) => void
              │    └─ onSkipAnswer: () => void
              └─ 렌더링:
                   ├─ [QuestionTimer 컴포넌트 key={timer}]
                   ├─ 질문 텍스트 표시
                   └─ [Answers 컴포넌트 key={activeQuestionIndex}]
                        ├─ props:
                        │    ├─ answers: string[]
                        │    ├─ onSelect: (answer: string) => void
                        │    ├─ selectedAnswer: string | null
                        │    └─ answerState: 'correct' | 'wrong' | ''
                        └─ 로직:
                             ├─ 답변을 한 번만 섞음 (useRef 사용)
                             ├─ 답변 목록 렌더링
                             └─ 선택된 답변에 따라 CSS 클래스 적용

[사용자 상호작용]
    ├─ 사용자가 답변 선택
    │    ↓
    ├─ [Question 컴포넌트] handleSelectAnswer 호출
    │    ├─ selectedAnswer 상태 업데이트
    │    ├─ 1초 후 isCorrect 상태 업데이트
    │    └─ 2초 후 Quiz 컴포넌트로 onSelectAnswer 호출
    │
    ├─ 타이머 만료 시
    │    ↓
    ├─ [QuestionTimer 컴포넌트] onTimeout 호출
    │    ↓
    └─ [Question 컴포넌트] handleSkipAnswer 호출
         └─ Quiz 컴포넌트로 onSelectAnswer(null) 호출

[Quiz 컴포넌트]
    ↓
    └─ userAnswers 업데이트
         ↓
    └─ activeQuestionIndex 증가
         ↓
    └─ [Question 컴포넌트] 재생성 (key 변경)
         ├─ [QuestionTimer 컴포넌트] 재생성
         └─ [Answers 컴포넌트] 재생성
  • 현재 상태:

    • 타이머가 중복 실행되지 않도록 key 속성을 통해 컴포넌트를 재생성.
    • 사용자가 답변을 선택하거나 타이머가 만료되면 적절한 피드백을 제공하고, 다음 질문으로 넘어감.
    • 정답 여부에 따른 시각적 피드백을 제공.
  • 남은 문제점:

    • 타이머가 답변 선택 후 빠르게 만료됨:
      • 답변 선택 시 QuestionTimer의 타이머가 빠르게 만료되어 정답 여부를 확인하기 전에 다음 질문으로 넘어가는 문제.
    • 동시에 다수의 타이머 실행:
      • 여전히 일부 상황에서 다중 타이머가 실행되고 있어, 진행 표시줄이 올바르게 업데이트되지 않는 문제가 발생.

182. 퀴즈 앱 만들기 / 퀴즈 결과 출력하기

[Quiz 컴포넌트]
    ├─ 상태:
    │    ├─ userAnswers: (string | null)[]
    │    └─ activeQuestionIndex: number (userAnswers.length)
    ├─ 함수:
    │    ├─ handleSelectAnswer (useCallback)
    │    └─ handleSkipAnswer (useCallback)
    └─ 렌더링:
         └─ [Question 컴포넌트 key={activeQuestionIndex}]
              ├─ props:
              │    ├─ questionText: string
              │    ├─ answers: string[]
              │    ├─ onSelectAnswer: (answer: string | null) => void
              │    └─ onSkipAnswer: () => void
              └─ 렌더링:
                   ├─ [QuestionTimer 컴포넌트 key={timer}]
                   ├─ 질문 텍스트 표시
                   └─ [Answers 컴포넌트 key={activeQuestionIndex}]
                        ├─ props:
                        │    ├─ answers: string[]
                        │    ├─ onSelect: (answer: string) => void
                        │    ├─ selectedAnswer: string | null
                        │    └─ answerState: 'correct' | 'wrong' | ''
                        └─ 로직:
                             ├─ 답변을 한 번만 섞음 (useRef 사용)
                             ├─ 답변 목록 렌더링
                             └─ 선택된 답변에 따라 CSS 클래스 적용

[사용자 상호작용]
    ├─ 사용자가 답변 선택
    │    ↓
    ├─ [Question 컴포넌트] handleSelectAnswer 호출
    │    ├─ selectedAnswer 상태 업데이트
    │    ├─ 1초 후 isCorrect 상태 업데이트
    │    └─ 2초 후 Quiz 컴포넌트로 onSelectAnswer 호출
    │
    ├─ 타이머 만료 시
    │    ↓
    ├─ [QuestionTimer 컴포넌트] onTimeout 호출
    │    ↓
    └─ [Question 컴포넌트] handleSkipAnswer 호출
         └─ Quiz 컴포넌트로 onSelectAnswer(null) 호출

[Quiz 컴포넌트]
    ↓
    └─ userAnswers 업데이트
         ↓
    └─ activeQuestionIndex 증가
         ↓
    └─ [Question 컴포넌트] 재생성 (key 변경)
         ├─ [QuestionTimer 컴포넌트] 재생성
         └─ [Answers 컴포넌트] 재생성

[퀴즈 완료]
    ↓
┌─────────────────┐
│ [Summary 컴포넌트] │
└─────────────────┘
         ├─ props:
         │    └─ userAnswers: (string | null)[]
         └─ 렌더링:
              ├─ 정답/오답/건너뛴 질문 수 표시
              └─ 각 질문에 대한 상세 정보 표시

183. 리액트와 최적화 테크닉 살펴보기 / 리액트의 컴포넌트 트리 생성, 리액트가 시스템 뒷편에서 동작하는 방식

  • 간단한 카운터 프로젝트를 통한 React 내부 동작 이해

    • 이번 섹션에서는 간단한 카운터 프로젝트를 통해 React가 DOM을 어떻게 업데이트하고, 사용자에게 보이는 화면을 어떻게 관리하는지, 그리고 컴포넌트 함수들이 어떻게 실행되는지를 심층적으로 다룹니다. 이를 통해 React 개발자로서 꼭 알아야 할 내부 메커니즘을 이해하고, 더 나은 코드와 애플리케이션을 작성할 수 있게 됩니다.
  • 프로젝트 개요:

    • 목표: React의 내부 동작 방식을 이해하기 위한 간단한 카운터 애플리케이션 제작.
    • 구성 요소:
      • App 컴포넌트: 메인 컴포넌트로, 모든 하위 컴포넌트를 포함.
      • Header 컴포넌트: 애플리케이션의 헤더를 렌더링.
      • Counter 컴포넌트: 실제 카운터 기능을 담당.
      • IconButton 컴포넌트: 카운터를 증가/감소시키는 버튼.
      • CounterOutput 컴포넌트: 현재 카운터 값을 표시.
  • 주요 학습 포인트:

    1. React의 컴포넌트 실행 과정:
      • React는 컴포넌트를 렌더링할 때, 먼저 컴포넌트 함수들을 실행합니다.
      • 컴포넌트 함수는 JSX 코드를 반환하며, 이는 JavaScript 코드로 변환되어 실제 DOM 요소로 렌더링됩니다.
    2. 컴포넌트 트리 구조:
      • App 컴포넌트가 최상위에 위치하며, Header와 Counter 컴포넌트를 자식으로 가집니다.
      • Counter 컴포넌트는 IconButton과 CounterOutput 컴포넌트를 포함하여 더 깊은 트리 구조를 형성합니다.
    3. 상태 관리 및 리렌더링:
      • Counter 컴포넌트는 내부 상태(count)를 관리하며, 상태가 변경될 때마다 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
      • 리액트는 효율적인 업데이트를 위해 가상 DOM을 사용하여 실제 DOM과의 차이를 계산하고, 필요한 부분만 업데이트합니다.
    4. 컴포넌트 간 데이터 흐름:
      • 상위 컴포넌트(App)에서 하위 컴포넌트(Header, Counter)로 데이터를 전달하는 방식(props)을 통해 컴포넌트 간의 데이터 흐름을 관리합니다.
      • 이벤트 핸들러를 통해 하위 컴포넌트에서 상위 컴포넌트로 데이터를 전달할 수 있습니다.
    5. 개발자 도구를 통한 컴포넌트 실행 확인:
      • 브라우저의 개발자 도구를 활용하여 각 컴포넌트가 어떻게 실행되고, 렌더링되는지 로그를 통해 확인할 수 있습니다.
      • 이를 통해 컴포넌트 트리의 구조와 상태 변화를 시각적으로 이해할 수 있습니다.
  • 결론:

    • 이번 데모 프로젝트를 통해 React의 기본적인 동작 원리와 컴포넌트 간의 상호작용 방식을 이해할 수 있었습니다.
    • 이러한 이해는 복잡한 애플리케이션을 개발할 때, 효율적이고 유지보수하기 쉬운 코드를 작성하는 데 큰 도움이 됩니다.
[App 컴포넌트]
    ├─ 렌더링:
    │    ├─ [Header 컴포넌트]
    │    └─ [Counter 컴포넌트]
    │         ├─ 상태:
    │         │    └─ count: number
    │         ├─ 렌더링:
    │         │    ├─ [IconButton 컴포넌트] (증가 버튼)
    │         │    ├─ [IconButton 컴포넌트] (감소 버튼)
    │         │    └─ [CounterOutput 컴포넌트]
    │         └─ 함수:
    │              ├─ handleIncrement
    │              └─ handleDecrement
    └─ 상태 변경 시:
         └─ setCount 호출
              └─ [Counter 컴포넌트] 리렌더링
                   └─ [CounterOutput 컴포넌트] 업데이트

[사용자 상호작용]
    ├─ 사용자가 증가 버튼 클릭
    │    ↓
    ├─ [IconButton 컴포넌트] onClick 호출
    │    ↓
    ├─ [Counter 컴포넌트] handleIncrement 실행
    │    ↓
    ├─ 상태(count) 증가
    │    ↓
    └─ [CounterOutput 컴포넌트] 업데이트하여 새로운 count 값 표시
// App.tsx
import React from 'react';
import Header from './Header';
import Counter from './Counter';

const App: React.FC = () => {
  return (
    <div id="app">
      <Header title="간단한 카운터 앱" />
      <Counter initialCount={0} />
    </div>
  );
};

export default App;
// Header.tsx
import React from 'react';

interface HeaderProps {
  title: string;
}

const Header: React.FC<HeaderProps> = ({ title }) => {
  return (
    <header>
      <h1>{title}</h1>
    </header>
  );
};

export default Header;
// Counter.tsx
import React, { useState } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

const Counter: React.FC<CounterProps> = ({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);

  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  const handleDecrement = () => {
    setCount((prevCount) => prevCount - 1);
  };

  return (
    <section>
      <CounterOutput count={count} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
    </section>
  );
};

export default Counter;
// IconButton.tsx
import React from 'react';

interface IconButtonProps {
  onClick: () => void;
  icon: React.ReactElement;
}

const IconButton: React.FC<IconButtonProps> = ({ onClick, icon }) => {
  return (
    <button onClick={onClick}>
      {icon}
    </button>
  );
};

export default IconButton;
// CounterOutput.tsx
import React from 'react';

interface CounterOutputProps {
  count: number;
}

const CounterOutput: React.FC<CounterOutputProps> = ({ count }) => {
  return (
    <div>
      <h2>카운트: {count}</h2>
    </div>
  );
};

export default CounterOutput;
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

184. 리액트와 최적화 테크닉 살펴보기 / 리액트 DevTools Profiler로 컴포넌트 함수 실행 분석하기

  • 주요 내용:

    1. React의 컴포넌트 트리 생성:
      • React는 최상위 컴포넌트(App 컴포넌트)부터 시작하여 하위 컴포넌트(Header, Counter 등)를 순차적으로 실행하고 렌더링합니다.
      • 각 컴포넌트는 JSX 코드를 반환하며, 이는 React에 의해 가상 DOM으로 변환됩니다.
      • 가상 DOM은 실제 DOM과의 차이를 계산하여 효율적으로 업데이트됩니다.
    2. 컴포넌트 함수의 실행 과정:
      • React는 컴포넌트를 렌더링할 때 해당 컴포넌트의 함수를 호출하여 JSX를 반환합니다.
      • 컴포넌트가 렌더링될 때마다 함수가 실행되지만, 상태(state)나 속성(props)의 변경에 따라 리렌더링 여부가 결정됩니다.
      • 상태가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
    3. React Profiler의 활용:
      • React DevTools의 Profiler 탭을 통해 어떤 컴포넌트가 언제, 왜 렌더링되었는지 시각적으로 확인할 수 있습니다.
      • Profiler는 컴포넌트 트리의 렌더링 과정을 그래픽 데이터로 제공하여 성능 최적화에 도움을 줍니다.
      • Flame Graph와 Ranked Chart 모드를 통해 컴포넌트의 렌더링 순서와 시간을 분석할 수 있습니다.
    4. 컴포넌트 재렌더링의 원인:
      • 상태 변화: 컴포넌트의 상태가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
      • 속성 변화: 부모 컴포넌트에서 전달받는 속성이 변경되면 자식 컴포넌트가 리렌더링됩니다.
      • 컨텍스트 변화: React Context API를 사용할 때, 컨텍스트의 값이 변경되면 이를 구독하는 모든 컴포넌트가 리렌더링됩니다.
  • 결론:

    • React의 컴포넌트 트리와 함수 실행 과정을 이해하는 것은 성능 최적화와 더불어 유지보수성이 높은 코드를 작성하는 데 필수적입니다.
    • React Profiler와 같은 도구를 활용하여 렌더링 과정을 시각적으로 분석함으로써, 불필요한 리렌더링을 줄이고 효율적인 애플리케이션을 개발할 수 있습니다.
[App 컴포넌트]
    ├─ 렌더링:
    │    ├─ [Header 컴포넌트]
    │    └─ [Counter 컴포넌트]
    │         ├─ 상태:
    │         │    └─ count: number
    │         ├─ 렌더링:
    │         │    ├─ [IconButton 컴포넌트] (증가 버튼)
    │         │    ├─ [IconButton 컴포넌트] (감소 버튼)
    │         │    └─ [CounterOutput 컴포넌트]
    │         └─ 함수:
    │              ├─ handleIncrement
    │              └─ handleDecrement
    └─ 상태 변경 시:
         └─ setCount 호출
              └─ [Counter 컴포넌트] 리렌더링
                   └─ [CounterOutput 컴포넌트] 업데이트

[사용자 상호작용]
    ├─ 사용자가 증가 버튼 클릭
    │    ↓
    ├─ [IconButton 컴포넌트] onClick 호출
    │    ↓
    ├─ [Counter 컴포넌트] handleIncrement 실행
    │    ↓
    ├─ 상태(count) 증가
    │    ↓
    └─ [CounterOutput 컴포넌트] 업데이트하여 새로운 count 값 표시
// App.tsx
import React from 'react';
import Header from './Header';
import Counter from './Counter';

const App: React.FC = () => {
  return (
    <div id="app">
      <Header title="간단한 카운터 앱" />
      <Counter initialCount={0} />
    </div>
  );
};

export default App;
// Header.tsx
import React from 'react';

interface HeaderProps {
  title: string;
}

const Header: React.FC<HeaderProps> = ({ title }) => {
  return (
    <header>
      <h1>{title}</h1>
    </header>
  );
};

export default Header;
// Counter.tsx
import React, { useState } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

const Counter: React.FC<CounterProps> = ({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);

  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  const handleDecrement = () => {
    setCount((prevCount) => prevCount - 1);
  };

  return (
    <section>
      <CounterOutput count={count} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
    </section>
  );
};

export default Counter;
// IconButton.tsx
import React from 'react';

interface IconButtonProps {
  onClick: () => void;
  icon: React.ReactElement;
}

const IconButton: React.FC<IconButtonProps> = ({ onClick, icon }) => {
  return (
    <button onClick={onClick}>
      {icon}
    </button>
  );
};

export default IconButton;
// CounterOutput.tsx
import React from 'react';

interface CounterOutputProps {
  count: number;
}

const CounterOutput: React.FC<CounterOutputProps> = ({ count }) => {
  return (
    <div>
      <h2>카운트: {count}</h2>
    </div>
  );
};

export default CounterOutput;
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
  • React Profiler를 통한 컴포넌트 실행 분석

    • React Profiler의 활용:
      • React DevTools의 Profiler 탭을 사용하면 애플리케이션의 렌더링 성능을 분석하고, 어떤 컴포넌트가 언제, 왜 렌더링되었는지를 시각적으로 확인할 수 있습니다.
      • 이를 통해 불필요한 리렌더링을 식별하고, 성능을 최적화할 수 있습니다.
  • rofiler 사용 방법:

    1. Profiler 설치 및 활성화:
      • Chrome: React Developer Tools 확장 프로그램을 설치합니다.
      • 설치 후, 브라우저의 개발자 도구를 열고 Profiler 탭으로 이동합니다.
    2. 프로파일링 시작:
      • Profiler 탭에서 Start profiling 버튼을 클릭하여 프로파일링을 시작합니다.
      • 애플리케이션과 상호작용합니다 (예: 카운터 값 증가/감소).
    3. 프로파일링 중지 및 결과 확인:
      • Stop profiling 버튼을 클릭하여 프로파일링을 종료합니다.
      • Flame Graph 또는 Ranked Chart 모드로 결과를 시각화하여 확인할 수 있습니다.
    4. Flame Graph 모드:
      • 컴포넌트 함수들이 실행된 순서와 렌더링 시간을 시각적으로 보여줍니다.
      • 상위 컴포넌트가 하위 컴포넌트를 렌더링하는 방식을 한눈에 파악할 수 있습니다.
    5. Ranked Chart 모드:
      • 렌더링된 컴포넌트들을 렌더링 시간 기준으로 순위화하여 보여줍니다.
      • 어떤 컴포넌트가 가장 많은 시간을 소요했는지 쉽게 확인할 수 있습니다.
  • 실제 예시:

    1. 앱 실행 및 프로파일링:
      • 개발 서버를 실행하고 애플리케이션을 로드합니다.
      • Profiler 탭에서 Start profiling을 클릭합니다.
      • 카운터의 증가 버튼을 여러 번 클릭하여 상태를 변경합니다.
      • Stop profiling을 클릭하여 결과를 확인합니다.
    2. Flame Graph 분석:
      • App 컴포넌트가 최상위에 위치하며, Header와 Counter 컴포넌트를 자식으로 포함합니다.
      • Counter 컴포넌트가 상태 변경(count)에 따라 리렌더링되며, IconButton과 CounterOutput 컴포넌트도 함께 리렌더링됩니다.
      • Flame Graph를 통해 각 컴포넌트의 렌더링 시간을 시각적으로 확인할 수 있습니다.
    3. Ranked Chart 분석:
      • Counter 컴포넌트가 가장 많은 렌더링 시간을 소요함을 확인할 수 있습니다.
      • IconButton과 CounterOutput 컴포넌트가 상대적으로 적은 시간을 소요함을 볼 수 있습니다.
  • 문제점 발견 및 최적화:

    • 불필요한 리렌더링: 만약 특정 컴포넌트가 불필요하게 자주 리렌더링된다면, 이를 최적화할 필요가 있습니다.
      • React.memo: 컴포넌트를 메모이제이션하여, props가 변경되지 않는 한 리렌더링을 방지할 수 있습니다.
      • useCallback: 함수의 참조를 유지하여, 불필요한 자식 컴포넌트의 리렌더링을 방지합니다.
      • useMemo: 계산 비용이 높은 값을 메모이제이션하여 성능을 최적화할 수 있습니다.
  • 예시 최적화: React.memo 사용

// IconButton.tsx
import React from 'react';

interface IconButtonProps {
  onClick: () => void;
  icon: React.ReactElement;
}

const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
  console.log('IconButton 렌더링');
  return (
    <button onClick={onClick}>
      {icon}
    </button>
  );
});

export default IconButton;
  • 설명:

    • React.memo를 사용하여 IconButton 컴포넌트를 메모이제이션합니다.
    • 부모 컴포넌트가 리렌더링되더라도, IconButton의 props가 변경되지 않으면 재렌더링되지 않습니다.
    • 콘솔 로그를 통해 실제 렌더링 여부를 확인할 수 있습니다.
  • 결과:

    • 카운터의 상태가 변경되어도 IconButton 컴포넌트는 불필요하게 리렌더링되지 않음을 확인할 수 있습니다.
    • 이를 통해 애플리케이션의 성능이 향상되고, 리소스 사용이 최적화됩니다.

185. 리액트와 최적화 테크닉 살펴보기 / memo()로 컴포넌트 함수 실행 방지

  • React 애플리케이션 최적화를 위한 컴포넌트 재렌더링 제어

    • 이번 섹션에서는 React 애플리케이션의 성능 최적화를 위해 컴포넌트 재렌더링을 제어하는 방법을 다룹니다.
    • 특히, React.memo를 사용하여 불필요한 컴포넌트 재렌더링을 방지하는 방법과 그에 따른 최적화 전략을 소개합니다.
    • 또한, React Profiler를 활용하여 컴포넌트 렌더링 과정을 분석하고 최적화 포인트를 식별하는 방법을 배웁니다.
  • 프로젝트 상황:

    • 현재 문제점:
      • App 컴포넌트에서 상태(enteredNumber)가 업데이트될 때마다 모든 자식 컴포넌트(Header, Counter 등)가 불필요하게 재렌더링됨.
      • 이는 성능 저하를 유발할 수 있으며, 특히 대규모 애플리케이션에서는 더욱 문제가 될 수 있음.
    • 해결 방안:
      • React.memo를 사용하여 특정 컴포넌트가 전달받는 props가 변경되지 않는 한 재렌더링되지 않도록 설정.
      • React.memo의 올바른 사용법과 주의사항을 이해하고, 필요한 컴포넌트에만 적용.
  • 목표:

    1. React.memo를 활용하여 Counter 컴포넌트의 불필요한 재렌더링을 방지.
    2. React Profiler를 사용하여 렌더링 성능을 분석하고 최적화 효과를 확인.
    3. 컴포넌트 최적화의 모범 사례를 이해하고 적용.
  • React.memo를 사용한 컴포넌트 최적화

    • React.memo는 고차 컴포넌트(Higher-Order Component)로, 주어진 컴포넌트를 메모이제이션하여 동일한 props가 전달될 경우 재렌더링을 방지합니다.
    • 이를 통해 성능을 최적화할 수 있습니다.
  • 주의사항:

    • React.memo는 shallow comparison(얕은 비교)을 수행하므로, props로 전달되는 객체나 배열이 변경되지 않는 한 재렌더링을 방지합니다.
    • 모든 컴포넌트에 무분별하게 적용하면 오히려 성능에 부정적인 영향을 줄 수 있습니다. 비교 자체에 비용이 들기 때문입니다.
    • 주로 순수 컴포넌트(props가 동일하면 항상 동일한 결과를 반환하는 컴포넌트)에 적용하는 것이 좋습니다.
  • 적용 대상:

    • 자주 재렌더링되지만, 실제로는 props가 자주 변경되지 않는 컴포넌트.
    • 렌더링 비용이 높은 컴포넌트.
  • Counter 컴포넌트 최적화

    • Counter 컴포넌트는 initialCount와 내부 상태 count를 관리합니다.
    • App 컴포넌트의 enteredNumber 상태 변경 시, Counter 컴포넌트가 불필요하게 재렌더링됩니다.
    • 이를 React.memo로 최적화합니다.
// Counter.tsx
import React, { useState } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);

  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  const handleDecrement = () => {
    setCount((prevCount) => prevCount - 1);
  };

  console.log('Counter 렌더링');

  return (
    <section>
      <CounterOutput count={count} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
    </section>
  );
});

export default Counter;
  • 설명:

    • React.memo를 사용하여 Counter 컴포넌트를 감쌉니다.
    • 이제 App 컴포넌트의 enteredNumber 상태 변경 시 Counter 컴포넌트는 props인 initialCount가 변경되지 않는 한 재렌더링되지 않습니다.
    • console.log를 추가하여 렌더링 여부를 확인할 수 있습니다.
  • IconButton 컴포넌트 최적화

    • IconButton 컴포넌트도 React.memo로 감싸서 불필요한 재렌더링을 방지할 수 있습니다.
// IconButton.tsx
import React from 'react';

interface IconButtonProps {
  onClick: () => void;
  icon: React.ReactElement;
}

const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
  console.log('IconButton 렌더링');
  return (
    <button onClick={onClick}>
      {icon}
    </button>
  );
});

export default IconButton;
  • 설명:

    • React.memo를 사용하여 IconButton 컴포넌트를 메모이제이션합니다.
    • 부모 컴포넌트의 상태 변화가 IconButton의 props에 영향을 주지 않는 한 재렌더링되지 않습니다.
    • console.log를 통해 렌더링 여부를 확인할 수 있습니다.
  • React Profiler를 활용한 성능 분석

    • React Profiler는 애플리케이션의 렌더링 성능을 분석하고, 어떤 컴포넌트가 얼마나 자주, 얼마나 오래 렌더링되는지를 시각적으로 확인할 수 있는 도구입니다.
  • Profiler 사용 방법:

    1. React Developer Tools 설치:
      • Chrome 또는 Firefox의 확장 프로그램 스토어에서 React Developer Tools를 설치합니다.
    2. Profiler 탭 열기:
      • 개발자 도구를 열고 Profiler 탭으로 이동합니다.
    3. 프로파일링 시작:
      • Start profiling 버튼을 클릭하여 프로파일링을 시작합니다.
      • 애플리케이션과 상호작용합니다 (예: 카운터 증가/감소, 입력값 변경 등).
    4. 프로파일링 중지 및 분석:
      • Stop profiling 버튼을 클릭하여 프로파일링을 종료합니다.
      • Flame Graph와 Ranked Chart 모드를 통해 렌더링 과정을 시각적으로 분석합니다.
  • Flame Graph vs Ranked Chart:

    • Flame Graph:
      • 컴포넌트가 렌더링된 순서와 렌더링 시간을 시각적으로 표시합니다.
      • 렌더링 계층 구조를 한눈에 파악할 수 있습니다.
    • Ranked Chart:
      • 렌더링 시간이 긴 컴포넌트부터 짧은 순서대로 나열합니다.
      • 성능 최적화가 필요한 컴포넌트를 쉽게 식별할 수 있습니다.
  • 예시 분석

    1. 프로파일링 실행:
      • 프로파일링을 시작하고, 카운터를 여러 번 증가/감소시킵니다.
      • 입력값을 변경하여 App 컴포넌트의 상태를 업데이트합니다.
    2. 결과 확인:
      • Flame Graph에서 Counter 컴포넌트와 IconButton 컴포넌트가 어떻게 렌더링되는지 확인합니다.
      • React.memo를 적용한 컴포넌트는 props가 변경되지 않으면 재렌더링되지 않음을 확인할 수 있습니다.
      • Ranked Chart에서 가장 많은 렌더링 시간을 소요하는 컴포넌트를 식별합니다.
    3. 최적화 효과 확인:
      • React.memo를 적용한 후, 불필요한 컴포넌트의 렌더링이 감소했는지 확인합니다.
      • 렌더링 시간이 줄어들어 성능이 향상되었는지 확인합니다.
  • 컴포넌트 최적화의 모범 사례

    1. 필요한 컴포넌트에만 React.memo 적용:
      • 모든 컴포넌트를 메모이제이션하는 것은 비효율적입니다.
      • 주로 순수 컴포넌트(props가 변경되지 않으면 렌더링 결과가 동일한 컴포넌트)에 적용합니다.
      • 렌더링 비용이 높은 컴포넌트나, 자주 재렌더링되지 않아도 되는 컴포넌트에 사용합니다.
    2. useCallback과 useMemo 사용:
      • useCallback: 함수 참조를 메모이제이션하여 불필요한 함수 재생성을 방지합니다. 이를 통해 하위 컴포넌트의 재렌더링을 줄일 수 있습니다.
      • useMemo: 계산 비용이 높은 값을 메모이제이션하여 성능을 최적화합니다.
  • 예시: useCallback 사용

// Counter.tsx
import React, { useState, useCallback } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  const handleDecrement = useCallback(() => {
    setCount((prevCount) => prevCount - 1);
  }, []);

  console.log('Counter 렌더링');

  return (
    <section>
      <CounterOutput count={count} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
    </section>
  );
});

export default Counter;
  • 설명:

    • handleIncrement과 handleDecrement 함수를 useCallback으로 감싸서 함수 참조를 메모이제이션합니다.
    • 부모 컴포넌트의 재렌더링 시, 하위 컴포넌트에 전달되는 함수의 참조가 동일하게 유지되어 불필요한 재렌더링을 방지할 수 있습니다.
  • Counter.tsx 최적화

    • React.memo와 useCallback을 활용하여 Counter 컴포넌트를 최적화합니다.
// Counter.tsx
import React, { useState, useCallback } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);

  // useCallback을 사용하여 함수 참조를 메모이제이션
  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  const handleDecrement = useCallback(() => {
    setCount((prevCount) => prevCount - 1);
  }, []);

  console.log('Counter 렌더링');

  return (
    <section>
      <CounterOutput count={count} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
    </section>
  );
});

export default Counter;
  • IconButton.tsx 최적화
    • 이미 React.memo를 적용했으므로 추가 최적화는 필요하지 않습니다. 하지만, 만약 더 복잡한 로직이 있다면 추가 최적화를 고려할 수 있습니다.
// IconButton.tsx
import React from 'react';

interface IconButtonProps {
  onClick: () => void;
  icon: React.ReactElement;
}

const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
  console.log('IconButton 렌더링');
  return (
    <button onClick={onClick}>
      {icon}
    </button>
  );
});

export default IconButton;
  • App.tsx 최적화
    • App 컴포넌트는 enteredNumber 상태를 관리하고 있습니다.
    • 이 상태가 변경될 때마다 App 컴포넌트가 리렌더링되지만, React.memo를 사용하여 자식 컴포넌트가 불필요하게 리렌더링되지 않도록 합니다.
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import Counter from './Counter';

const App: React.FC = () => {
  const [enteredNumber, setEnteredNumber] = useState<string>('');

  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setEnteredNumber(e.target.value);
  }, []);

  console.log('App 렌더링');

  return (
    <div id="app">
      <Header title="간단한 카운터 앱" />
      <input type="text" value={enteredNumber} onChange={handleInputChange} placeholder="숫자를 입력하세요" />
      <button>설정</button>
      <Counter initialCount={0} />
    </div>
  );
};

export default App;

186. 리액트와 최적화 테크닉 살펴보기 / React.memo() 체크해봐야될 점

React.memo()를 사용하면 함수 조차도 재실행되지 않는 것인지.. 확인 필요

187. 리액트와 최적화 테크닉 살펴보기 / 컴포넌트 함수 실행을 방지를 위한 구조

  • React 애플리케이션의 컴포넌트 재렌더링 최적화: React.memo와 컴포넌트 구조 개선

    • 이번 섹션에서는 React 애플리케이션의 성능 최적화를 위해 React.memo를 신중하게 사용하는 방법과, 컴포넌트 구조를 현명하게 구성하여 불필요한 재렌더링을 방지하는 방법을 다룹니다.
    • 특히, 컴포넌트 분할과 상태 관리의 중요성을 강조하며, 실제 예제를 통해 최적화 과정을 시연합니다.
  • 주요 내용:

    1. React.memo의 활용과 한계:
      • React.memo는 컴포넌트를 메모이제이션하여, 동일한 props가 전달될 경우 재렌더링을 방지합니다.
      • 그러나 모든 상황에서 React.memo가 최적의 해결책은 아닙니다. 특히, 상위 컴포넌트의 상태 변화가 많은 경우, memo의 비교 과정 자체가 성능에 부담을 줄 수 있습니다.
    2. 현명한 컴포넌트 구성의 중요성:
      • 컴포넌트를 적절히 분리하여, 상태 변경이 필요한 부분만 재렌더링되도록 구조를 설계합니다.
      • 예를 들어, 입력 필드와 카운터 기능을 별도의 컴포넌트로 분리하면, 입력 상태 변경 시 전체 컴포넌트 트리가 아닌 해당 컴포넌트만 재렌더링됩니다.
    3. 상태 끌어올리기(Lifting State Up):
      • 상태를 상위 컴포넌트로 끌어올려, 필요한 하위 컴포넌트에만 전달합니다.
      • 이를 통해 불필요한 상태 관리와 재렌더링을 줄일 수 있습니다.
    4. 컴포넌트 분할과 React.memo의 조화:
      • 컴포넌트를 분할하여, React.memo를 효과적으로 적용할 수 있는 구조를 만듭니다.
      • 불필요한 재렌더링을 방지하면서도, 필요한 경우에는 상태 변경에 따라 적절히 렌더링되도록 합니다.
  • 결론:

    • React.memo는 성능 최적화에 유용한 도구이지만, 컴포넌트 구조를 현명하게 설계하는 것이 더 중요합니다.
    • 적절한 컴포넌트 분할과 상태 관리를 통해 불필요한 재렌더링을 최소화하고, 애플리케이션의 성능을 향상시킬 수 있습니다.
[App 컴포넌트]
    ├─ 상태:
    │    ├─ enteredNumber: string
    │    └─ chosenCount: number
    ├─ 함수:
    │    ├─ handleInputChange (useCallback)
    │    └─ handleSetCount (useCallback)
    ├─ 렌더링:
    │    ├─ [Header 컴포넌트] (React.memo 적용)
    │    ├─ [ConfigureCounter 컴포넌트]
    │    │    ├─ 상태:
    │    │    │    └─ enteredNumber: string
    │    │    ├─ 함수:
    │    │    │    └─ handleSetClick (useCallback)
    │    │    └─ 렌더링:
    │    │         ├─ input
    │    │         └─ [Button 컴포넌트]
    │    └─ [Counter 컴포넌트] (React.memo 적용)
    │         ├─ 상태:
    │         │    └─ count: number
    │         ├─ 함수:
    │         │    ├─ handleIncrement (useCallback)
    │         │    └─ handleDecrement (useCallback)
    │         └─ 렌더링:
    │              ├─ [IconButton 컴포넌트] (React.memo 적용)
    │              ├─ [IconButton 컴포넌트] (React.memo 적용)
    │              └─ [CounterOutput 컴포넌트] (React.memo 적용)
    └─ 렌더링 최적화:
         ├─ React.memo를 통해 Header와 Counter 컴포넌트의 불필요한 재렌더링 방지
         └─ 컴포넌트 분할로 ConfigureCounter에서 상태 변경 시 다른 컴포넌트 재렌더링 방지

[사용자 상호작용]
    ├─ 사용자가 ConfigureCounter의 input에 숫자 입력
    │    ↓
    ├─ [ConfigureCounter 컴포넌트] handleSetClick 호출
    │    ├─ handleSetCount 호출 (App 컴포넌트의 함수)
    │    └─ chosenCount 상태 업데이트
    │         ↓
    └─ [Counter 컴포넌트] count 업데이트 및 재렌더링
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';

const App: React.FC = () => {
  const [chosenCount, setChosenCount] = useState<number>(0);

  // useCallback을 사용하여 함수 참조를 메모이제이션
  const handleSetCount = useCallback((newCount: number) => {
    setChosenCount(newCount);
  }, []);

  console.log('App 렌더링');

  return (
    <div id="app">
      <Header title="최적화된 카운터 앱" />
      <ConfigureCounter onSet={handleSetCount} />
      <Counter initialCount={chosenCount} />
    </div>
  );
};

export default App;
// Header.tsx
import React from 'react';

interface HeaderProps {
  title: string;
}

const Header: React.FC<HeaderProps> = React.memo(({ title }) => {
  console.log('Header 렌더링');
  return (
    <header>
      <h1>{title}</h1>
    </header>
  );
});

export default Header;
// ConfigureCounter.tsx
import React, { useState, useCallback } from 'react';
import log from './log'; // log.js 파일에서 log 함수 임포트

interface ConfigureCounterProps {
  onSet: (newCount: number) => void;
}

const ConfigureCounter: React.FC<ConfigureCounterProps> = () => {
  const [enteredNumber, setEnteredNumber] = useState<string>('');

  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setEnteredNumber(e.target.value);
  }, []);

  const handleSetClick = useCallback(() => {
    const newCount = parseInt(enteredNumber, 10) || 0;
    log('ConfigureCounter');
    onSet(newCount);
  }, [enteredNumber, onSet]);

  console.log('ConfigureCounter 렌더링');

  return (
    <section>
      <input
        type="number"
        value={enteredNumber}
        onChange={handleInputChange}
        placeholder="숫자를 입력하세요"
      />
      <button onClick={handleSetClick}>설정</button>
    </section>
  );
};

export default ConfigureCounter;
// Counter.tsx
import React, { useState, useCallback } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);

  // useCallback을 사용하여 함수 참조를 메모이제이션
  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  const handleDecrement = useCallback(() => {
    setCount((prevCount) => prevCount - 1);
  }, []);

  console.log('Counter 렌더링');

  return (
    <section>
      <CounterOutput count={count} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
    </section>
  );
});

export default Counter;
// IconButton.tsx
import React from 'react';

interface IconButtonProps {
  onClick: () => void;
  icon: React.ReactElement;
}

const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
  console.log('IconButton 렌더링');
  return (
    <button onClick={onClick}>
      {icon}
    </button>
  );
});

export default IconButton;
// CounterOutput.tsx
import React from 'react';

interface CounterOutputProps {
  count: number;
}

const CounterOutput: React.FC<CounterOutputProps> = React.memo(({ count }) => {
  console.log('CounterOutput 렌더링');
  return (
    <div>
      <h2>카운트: {count}</h2>
    </div>
  );
});

export default CounterOutput;
// log.js
const log = (componentName) => {
  console.log(`${componentName} 실행`);
};

export default log;
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
  • 문제 상황:

    • App 컴포넌트의 enteredNumber 상태가 변경될 때마다 모든 자식 컴포넌트(Header, Counter 등)가 불필요하게 재렌더링됨.
    • 이는 성능 저하를 유발할 수 있으며, 특히 대규모 애플리케이션에서는 더욱 문제가 될 수 있음.
  • 해결 방안:

    1. 컴포넌트 분할:
      • 입력 관련 기능을 ConfigureCounter 컴포넌트로 분리하여, 입력 상태 변경 시 해당 컴포넌트만 재렌더링되도록 함.
    2. React.memo의 활용:
      • Header, Counter, IconButton, CounterOutput 컴포넌트에 React.memo를 적용하여, props가 변경되지 않는 한 재렌더링을 방지.
    3. 함수 메모이제이션:
      • useCallback을 사용하여 함수 참조를 메모이제이션함으로써, 하위 컴포넌트에 전달되는 함수의 참조가 동일하게 유지되도록 함.

188. 리액트와 최적화 테크닉 살펴보기 / useCallback() 훅 이해하기

  • React 애플리케이션의 컴포넌트 재렌더링 최적화: React.memo와 useCallback의 활용

    • 이번엔 React 애플리케이션에서 React.memo와 useCallback을 사용하여 불필요한 컴포넌트 재렌더링을 방지하는 방법을 심층적으로 다룹니다.
    • 특히, 컴포넌트 간의 데이터 흐름과 함수 참조의 안정성을 유지함으로써 성능 최적화를 달성하는 전략을 설명합니다.
  • 문제 상황:

    • Counter 컴포넌트 내부에서 IconButton과 같은 하위 컴포넌트에 함수를 props로 전달할 때, 부모 컴포넌트의 상태 변화로 인해 함수 참조가 매번 새롭게 생성됩니다.
    • 이로 인해 React.memo를 사용하더라도 하위 컴포넌트가 불필요하게 재렌더링됩니다.
  • 해결 방안:

    • useCallback의 활용:
      • useCallback 훅을 사용하여 함수 참조를 메모이제이션함으로써, 동일한 의존성 배열을 가질 때 함수 참조가 변경되지 않도록 합니다.
    • React.memo의 적절한 사용:
      • React.memo를 사용하여 컴포넌트를 메모이제이션하고, props가 변경되지 않는 한 재렌더링을 방지합니다.
  • 최적화 전략의 적용:

    • Counter 컴포넌트에서 handleIncrement와 handleDecrement 함수를 useCallback으로 감싸서 함수 참조를 안정화합니다.
    • 이를 통해 IconButton 컴포넌트에 전달되는 onClick 핸들러의 참조가 변경되지 않아, React.memo가 제대로 작동하게 됩니다.
  • 결과 검증:

    • 콘솔 로그와 React Profiler를 통해 최적화의 효과를 확인합니다.
    • 불필요한 컴포넌트 재렌더링이 방지되었는지 확인합니다.
  • 결론:

    • React.memo와 useCallback을 적절히 활용함으로써, React 애플리케이션의 성능을 효과적으로 최적화할 수 있습니다.
    • 특히, 함수 참조의 안정성을 유지하는 것은 불필요한 재렌더링을 방지하는 데 핵심적인 역할을 합니다.

189. 리액트와 최적화 테크닉 살펴보기 / useMemo() 훅 이해하기

  • React 애플리케이션의 컴포넌트 재렌더링 최적화: React.memo와 useMemo의 활용

    • 이번엔 React 애플리케이션에서 React.memo와 useMemo를 사용하여 불필요한 컴포넌트 재렌더링과 복잡한 계산을 최적화하는 방법을 심층적으로 다룹니다.
    • 특히, 컴포넌트 구조 개선과 useMemo를 통해 성능을 향상시키는 전략을 설명합니다.
  • React.memo의 한계와 useMemo의 필요성:

    • React.memo는 컴포넌트를 메모이제이션하여 props가 변경되지 않으면 재렌더링을 방지하지만, 재렌더링될 경우엔 컴포넌트 내부의 함수나 계산된 값은 여전히 재실행됩니다.
    • 복잡한 계산이나 불필요한 함수 실행을 방지하기 위해 useMemo 훅이 필요합니다.
  • 현명한 컴포넌트 구성의 중요성:

    • 컴포넌트를 적절히 분리하여 상태 변경이 필요한 부분만 재렌더링되도록 구조를 설계합니다.
    • 상태를 상위 컴포넌트로 끌어올려 필요한 하위 컴포넌트에만 전달함으로써 불필요한 재렌더링을 줄입니다.
  • useMemo의 활용:

    • useMemo 훅을 사용하여 복잡한 계산의 결과를 메모이제이션함으로써, 의존성 배열에 지정된 값이 변경되지 않는 한 계산을 재실행하지 않습니다.
    • 이를 통해 성능을 최적화하고, 불필요한 계산을 방지할 수 있습니다.
  • 최적화 전략의 적용과 주의사항:

    • useMemo는 복잡한 계산이 필요한 경우에만 사용해야 하며, 지나치게 남용하면 오히려 성능에 악영향을 줄 수 있습니다.
    • 의존성 배열을 정확하게 설정하여 필요한 경우에만 계산이 재실행되도록 해야 합니다.
  • 결론:

    • React.memo와 useMemo를 적절히 활용함으로써, React 애플리케이션의 성능을 효과적으로 최적화할 수 있습니다.
    • 특히, useMemo를 통해 복잡한 계산을 메모이제이션함으로써 불필요한 함수 실행을 방지하고, 애플리케이션의 응답성을 향상시킬 수 있습니다.

190. 리액트와 최적화 테크닉 살펴보기 / 리액트의 가상 DOM 사용 - 직접 살펴보기

  • React의 렌더링 과정과 가상 DOM(Virtual DOM)의 역할

    • 이번엔 React 애플리케이션에서 컴포넌트가 실행되고, JSX 코드가 실제 화면에 어떻게 렌더링되는지를 심층적으로 이해합니다.
    • 특히, React가 사용하는 가상 DOM의 개념과 그 최적화 메커니즘을 중점적으로 다룹니다.
  • 컴포넌트 함수의 실행과 JSX 반환:

    • React 컴포넌트는 함수로 정의되며, 이 함수가 실행될 때 JSX 코드를 반환합니다.
    • 반환된 JSX는 컴포넌트 트리를 형성하며, 이는 결국 네이티브 HTML 요소로 변환됩니다.
  • 가상 DOM(Virtual DOM)의 개념:

    • React는 실제 DOM 대신 메모리 상에 존재하는 가상 DOM을 사용하여 렌더링 효율성을 극대화합니다.
    • 가상 DOM은 실제 DOM의 경량화된 사본으로, 빠른 계산과 비교를 가능하게 합니다.
  • 렌더링 최적화 메커니즘:

    • 컴포넌트 렌더링: 컴포넌트 함수가 실행되면, 새로운 가상 DOM 트리가 생성됩니다.
    • 가상 DOM 비교(Diffing): React는 새로운 가상 DOM과 이전 가상 DOM을 비교하여 변경된 부분만을 식별합니다.
    • 실제 DOM 업데이트: 변경된 부분만 실제 DOM에 적용하여 불필요한 재렌더링을 방지합니다.
  • 함수 재실행과 최적화:

    • 컴포넌트 상태나 props가 변경될 때마다 컴포넌트 함수가 재실행됩니다.
    • React.memo, useCallback, useMemo 등의 훅을 활용하여 함수 참조를 안정화하고, 불필요한 재실행을 방지할 수 있습니다.
  • 결론:

    • React의 가상 DOM과 렌더링 최적화 메커니즘을 이해함으로써, 애플리케이션의 성능을 효과적으로 향상시킬 수 있습니다.
    • 가상 DOM을 통한 효율적인 업데이트와 메모이제이션 기법의 적절한 활용은 React 애플리케이션의 응답성을 높이는 핵심 요소입니다.

191. 리액트와 최적화 테크닉 살펴보기 / State(상태)를 관리할 때 Key(키)의 역할

  • React 컴포넌트 상태 관리와 고유 키의 중요성

    • 이번엔 React 컴포넌트의 상태(state)가 각 컴포넌트 인스턴스에 독립적으로 관리된다는 개념과, 동적인 목록을 렌더링할 때 고유한 키(key)를 사용하는 것의 중요성에 대해 다룹니다.
    • 특히, 동일한 컴포넌트 타입을 여러 인스턴스로 생성할 때 상태가 공유되지 않도록 보장하는 방법과 키의 올바른 사용법을 중점적으로 설명합니다.
  • 컴포넌트 상태의 독립성:

    • 각 컴포넌트 인스턴스는 자체적인 상태를 가지며, 동일한 컴포넌트 타입의 여러 인스턴스 간에 상태가 공유되지 않습니다.
    • 이는 컴포넌트의 재사용성을 높이고, 각 인스턴스가 독립적으로 동작할 수 있도록 합니다.
  • 동적인 목록 렌더링과 키의 역할:

    • 동적으로 생성되는 목록에서 각 항목을 고유하게 식별하기 위해 key 속성을 사용합니다.
    • key는 React가 각 컴포넌트 인스턴스를 정확히 식별하고, 효율적으로 업데이트할 수 있도록 도와줍니다.
  • 인덱스를 키로 사용하는 문제점:

    • 리스트 항목의 인덱스를 키로 사용하면, 항목의 순서가 변경되거나 새로운 항목이 추가될 때 상태가 예상치 못하게 변경될 수 있습니다.
    • 이는 특히 항목의 순서가 빈번하게 변경되거나 동적으로 추가/삭제되는 경우에 문제가 됩니다.
  • 고유한 ID를 키로 사용하는 해결 방안:

    • 각 리스트 항목에 고유한 ID를 부여하고, 이를 키로 사용하여 React가 각 항목을 올바르게 추적할 수 있도록 합니다.
    • 이를 통해 상태가 올바른 컴포넌트 인스턴스에 유지되며, 예기치 않은 상태 변경을 방지할 수 있습니다.
  • 결론:

    • React에서 컴포넌트의 상태는 각 인스턴스에 독립적으로 관리되며, 동적인 목록을 렌더링할 때는 고유한 키를 사용하여 컴포넌트 인스턴스를 정확히 식별하는 것이 매우 중요합니다.
    • 이를 통해 컴포넌트의 재사용성과 상태 관리의 효율성을 높일 수 있습니다.

192. 리액트와 최적화 테크닉 살펴보기 / Key(키)가 중요한 이유 부가 설명

  • React에서 키(Key)의 중요성과 추가 이점

    • 이번엔 React에서 리스트를 렌더링할 때 사용하는 key 속성의 중요성과, 단순히 상태(state) 관리의 문제를 방지하는 것을 넘어서는 추가적인 이점에 대해 다룹니다.
    • 특히, 인덱스를 키로 사용하는 것의 문제점과 고유한 키를 사용했을 때의 장점을 상세히 설명합니다.
  • 키(Key)의 역할:

    • React는 리스트 항목을 렌더링할 때 각 항목을 고유하게 식별하기 위해 key 속성을 사용합니다.
    • 키는 React의 재조정(Reconciliation) 과정에서 중요한 역할을 하며, 효율적인 업데이트를 가능하게 합니다.
  • 인덱스를 키로 사용하는 문제점:

    • 리스트의 인덱스를 키로 사용하면, 항목의 순서가 변경되거나 새로운 항목이 추가될 때 React가 각 항목을 정확히 식별하지 못해 불필요한 재렌더링이 발생할 수 있습니다.
    • 이로 인해 UI가 깜빡이거나 상태가 잘못 매핑되는 문제가 발생할 수 있습니다.
  • 고유한 키를 사용하는 이점:

    • 고유한 키를 사용하면 React가 각 항목을 정확히 추적할 수 있어, 변경된 부분만 효율적으로 업데이트할 수 있습니다.
    • 이는 성능 최적화뿐만 아니라 사용자 경험을 향상시키는 데에도 기여합니다.
  • 키 사용의 추가 이점:

    • 성능 최적화: 불필요한 DOM 업데이트를 최소화하여 렌더링 성능을 향상시킵니다.
    • 상태 유지: 각 컴포넌트 인스턴스의 상태가 올바르게 유지되도록 보장합니다.
    • 애니메이션 및 트랜지션: 키를 사용하여 항목의 삽입, 삭제, 이동 시 애니메이션을 자연스럽게 적용할 수 있습니다.
  • 결론:

    • 리스트 렌더링 시 키를 적절히 사용하는 것은 React 애플리케이션의 성능과 안정성을 높이는 데 필수적입니다.
    • 인덱스 대신 고유한 키를 사용함으로써, 상태 관리의 문제를 방지하고 효율적인 렌더링을 구현할 수 있습니다.

193. 리액트와 최적화 테크닉 살펴보기 / Key(키)를 사용한 컴포넌트 초기화

  • React에서 키(Key)의 활용: 목록 외의 컴포넌트 상태 재설정

    • 이번 섹션에서는 React에서 key 속성이 목록 렌더링 외에도 컴포넌트의 상태(state)를 효율적으로 관리하고 재설정하는 데 어떻게 활용될 수 있는지에 대해 다룹니다.
    • 특히, useEffect를 사용하여 상태를 재설정하는 대신 key를 활용하는 패턴의 장점과 구현 방법을 설명합니다.
  • 키(Key)의 일반적인 역할:

    • React에서 key는 주로 목록의 각 항목을 고유하게 식별하기 위해 사용됩니다.
    • 이는 React의 재조정(Reconciliation) 과정에서 중요한 역할을 하며, 효율적인 업데이트를 가능하게 합니다.
  • 키의 추가 활용:

    • key는 목록 외의 컴포넌트에도 적용할 수 있으며, 이를 통해 특정 조건에서 컴포넌트를 재설정(재생성)할 수 있습니다.
    • 이 패턴은 컴포넌트의 key가 변경될 때 React가 해당 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트하도록 유도합니다.
  • useEffect를 사용한 상태 재설정의 문제점:

    • useEffect를 사용하여 특정 prop이 변경될 때 상태를 재설정하는 방식은 추가적인 렌더링과 함수 실행을 초래할 수 있습니다.
    • 이는 불필요한 컴포넌트 함수 실행과 성능 저하를 유발할 수 있습니다.
  • 키를 활용한 상태 재설정의 장점:

    • key를 변경함으로써 React는 기존 컴포넌트를 제거하고 새로운 컴포넌트를 생성하므로, 상태가 초기화됩니다.
    • 이 접근 방식은 useEffect를 사용하는 것보다 더 효율적이며, 컴포넌트 함수 실행을 최소화합니다.
  • 결론:

    • React에서 key 속성은 목록 렌더링 외에도 컴포넌트의 상태를 효율적으로 관리하고 재설정하는 데 유용하게 활용될 수 있습니다.
    • 특히, useEffect를 사용한 상태 재설정 대신 key를 활용하는 패턴은 성능 최적화와 코드의 간결성을 동시에 달성할 수 있는 효과적인 방법입니다.
[사용자 상호작용]
    ├─ 사용자가 입력값을 변경하고 '설정' 버튼을 클릭
    │    ↓
    ├─ [App 컴포넌트]의 chosenCount 상태 업데이트
    │    └─ chosenCount를 Counter 컴포넌트에 initialCount 및 key로 전달
    │
    ├─ [Counter 컴포넌트]가 키 변경 감지
    │    └─ React가 기존 Counter 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트
    │         └─ Counter의 상태(state)가 초기화됨
    │
    └─ [Counter 컴포넌트]의 새로운 상태로 렌더링
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';

const App: React.FC = () => {
  const [chosenCount, setChosenCount] = useState<number>(0);

  const handleSetCount = useCallback((newCount: number) => {
    setChosenCount(newCount);
  }, []);

  console.log('App 렌더링');

  return (
    <div id="app">
      <Header title="카운터 앱" />
      <ConfigureCounter onSet={handleSetCount} />
      <Counter initialCount={chosenCount} />
    </div>
  );
};

export default App;
// Counter.tsx
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import CounterHistory from './CounterHistory';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

interface CounterChange {
  change: string;
}

const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);
  const [history, setHistory] = useState<CounterChange[]>([]);

  useEffect(() => {
    setCount(initialCount);
    setHistory([]);
  }, [initialCount]);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
    setHistory((prevHistory) => [...prevHistory, { change: '증가하기' }]);
  }, []);

  const handleDecrement = useCallback(() => {
    setCount((prevCount) => prevCount - 1);
    setHistory((prevHistory) => [...prevHistory, { change: '감소하기' }]);
  }, []);

  const isPrime = useMemo(() => {
    console.log('isPrime 계산 실행');
    const checkPrime = (num: number): boolean => {
      if (num <= 1) return false;
      if (num === 2) return true;
      for (let i = 2; i <= Math.sqrt(num); i++) {
        if (num % i === 0) return false;
      }
      return true;
    };
    return checkPrime(count);
  }, [count]);

  console.log('Counter 렌더링');

  return (
    <section>
      <CounterOutput count={count} isPrime={isPrime} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
      <CounterHistory history={history} />
    </section>
  );
});

export default Counter;
  • 설명:

    • Counter 컴포넌트는 initialCount prop이 변경될 때마다 useEffect를 통해 count와 history를 초기화합니다.
    • 이로 인해 Counter 컴포넌트가 다시 렌더링되며, 두 번의 상태 업데이트가 발생할 수 있습니다.
  • 문제점:

    • useEffect로 상태를 재설정하면, 컴포넌트가 두 번 렌더링될 수 있습니다.
    • 이는 불필요한 성능 비용을 초래할 수 있습니다.
  • 개선된 방법: 키(Key)를 활용한 상태 재설정

    • 해결 방안:
      • Counter 컴포넌트에 key 속성을 추가하고, chosenCount를 키로 사용하여 컴포넌트를 재생성함으로써 상태를 초기화합니다.
      • 이를 통해 useEffect를 사용하지 않고도 상태를 재설정할 수 있으며, 컴포넌트가 한 번만 렌더링됩니다.
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';

const App: React.FC = () => {
  const [chosenCount, setChosenCount] = useState<number>(0);

  const handleSetCount = useCallback((newCount: number) => {
    setChosenCount(newCount);
  }, []);

  console.log('App 렌더링');

  return (
    <div id="app">
      <Header title="카운터 앱" />
      <ConfigureCounter onSet={handleSetCount} />
      {/* Counter 컴포넌트에 key를 추가하여 chosenCount가 변경될 때마다 새로운 인스턴스를 생성 */}
      <Counter key={chosenCount} initialCount={chosenCount} />
    </div>
  );
};

export default App;
// Counter.tsx
import React, { useState, useCallback, useMemo } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import CounterHistory from './CounterHistory';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';

interface CounterProps {
  initialCount: number;
}

interface CounterChange {
  change: string;
}

const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);
  const [history, setHistory] = useState<CounterChange[]>([]);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
    setHistory((prevHistory) => [...prevHistory, { change: '증가하기' }]);
  }, []);

  const handleDecrement = useCallback(() => {
    setCount((prevCount) => prevCount - 1);
    setHistory((prevHistory) => [...prevHistory, { change: '감소하기' }]);
  }, []);

  const isPrime = useMemo(() => {
    console.log('isPrime 계산 실행');
    const checkPrime = (num: number): boolean => {
      if (num <= 1) return false;
      if (num === 2) return true;
      for (let i = 2; i <= Math.sqrt(num); i++) {
        if (num % i === 0) return false;
      }
      return true;
    };
    return checkPrime(count);
  }, [count]);

  console.log('Counter 렌더링');

  return (
    <section>
      <CounterOutput count={count} isPrime={isPrime} />
      <div>
        <IconButton onClick={handleIncrement} icon={<PlusIcon />} />
        <IconButton onClick={handleDecrement} icon={<MinusIcon />} />
      </div>
      <CounterHistory history={history} />
    </section>
  );
});

export default Counter;
  • 설명:

    • App 컴포넌트에서 Counter 컴포넌트에 key={chosenCount}를 추가했습니다.
    • chosenCount가 변경될 때마다 Counter 컴포넌트의 key가 변경되어 React는 기존 컴포넌트를 언마운트하고 새로운 컴포넌트를 마운트합니다.
    • 이로 인해 Counter 컴포넌트의 상태(count, history)가 초기화됩니다.
    • useEffect를 사용하지 않아도 상태를 재설정할 수 있으며, 컴포넌트가 한 번만 렌더링됩니다.
  • 장점:

    • 성능 최적화: 불필요한 상태 업데이트와 추가 렌더링을 방지합니다.
    • 코드 간결성: useEffect를 사용하지 않아 코드가 간결해집니다.
    • 상태 초기화의 명확성: key의 변경을 통해 컴포넌트의 상태가 초기화됨을 명확히 할 수 있습니다.

194. 리액트와 최적화 테크닉 살펴보기 / State(상태) 스케줄링 & 배칭

  • React의 상태(State) 업데이트 스케줄링과 배칭(Batching)에 대한 이해

    • React에서 상태(state) 관리는 애플리케이션의 동적인 부분을 효과적으로 처리하는 핵심 요소입니다.
    • 이번엔 상태 업데이트의 실행 일정이 React에서 어떻게 조정되고 실행되는지, 그리고 이를 효율적으로 관리하기 위한 최선의 방법에 대해 자세히 살펴보겠습니다.
  • 상태 업데이트의 비동기성

    • 상태 업데이트는 즉각적으로 반영되지 않습니다.
      • React에서 setState나 useState의 상태 업데이트 함수(예: setChosenCount)를 호출하면, 해당 상태는 즉각적으로 변경되지 않습니다.
      • 대신, React는 상태 업데이트를 스케줄링하여 최적화된 시점에 일괄적으로 처리합니다.
      • 이로 인해 상태 업데이트 직후에 console.log를 사용하면 이전 상태 값을 확인할 수 있습니다.
const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(count + 1);
  console.log(count); // 아직 0이 출력됩니다.
};

위 예시에서 버튼을 클릭하면 count는 1로 업데이트되지만, console.log는 여전히 이전 값인 0을 출력합니다. 이는 상태 업데이트가 비동기적으로 처리되기 때문입니다.

  • 함수형 업데이트(Function Form) 사용의 중요성
    • 이전 상태에 의존하는 업데이트는 함수형 업데이트를 사용해야 합니다.
      • 상태 업데이트가 비동기적이기 때문에, 여러 상태 업데이트가 동시에 발생할 경우 예상치 못한 결과가 발생할 수 있습니다.
      • 이를 방지하기 위해 상태 업데이트 함수에 함수를 전달하여 이전 상태 값을 안전하게 참조할 수 있습니다.
const handleIncrement = () => {
  setCount(count + 1);
  setCount(count + 1);
};

위와 같이 동일한 상태 업데이트 함수를 연속으로 호출하면, 의도한 대로 두 번 증가하지 않고 한 번만 증가할 수 있습니다.

const handleIncrement = () => {
  setCount(prevCount => prevCount + 1);
  setCount(prevCount => prevCount + 1);
};

이 경우, prevCount는 각각의 업데이트 시점에서 최신 상태 값을 참조하므로, 두 번 클릭 시 count는 정확히 2씩 증가합니다.

  • useEffect를 사용한 상태 초기화의 문제점
    • useEffect를 사용하여 상태를 초기화하면 불필요한 렌더링이 발생할 수 있습니다.
    • 상태 초기화를 위해 useEffect를 사용하는 방법은 다음과 같습니다:
useEffect(() => {
  setCount(initialCount);
  setHistory([]);
}, [initialCount]);

하지만 이 접근 방식은 컴포넌트가 두 번 렌더링되게 만들 수 있으며, 이는 성능 저하와 코드 복잡성을 초래할 수 있습니다.

  • key 속성을 활용한 상태 재설정
    • 컴포넌트에 key를 부여하여 상태를 효율적으로 초기화할 수 있습니다.
      • key 속성을 변경하면 React는 해당 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트하게 됩니다. 이를 통해 상태를 초기화할 수 있으며, useEffect를 사용하지 않아도 됩니다.
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';

const App: React.FC = () => {
  const [chosenCount, setChosenCount] = useState<number>(0);

  const handleSetCount = useCallback((newCount: number) => {
    setChosenCount(newCount);
  }, []);

  console.log('App 렌더링');

  return (
    <div id="app">
      <Header title="카운터 앱" />
      <ConfigureCounter onSet={handleSetCount} />
      {/* key 속성에 chosenCount를 추가하여 변경 시 컴포넌트 재생성 */}
      <Counter key={chosenCount} initialCount={chosenCount} />
    </div>
  );
};

export default App;

위 예시에서 Counter 컴포넌트에 key={chosenCount}를 추가함으로써, chosenCount가 변경될 때마다 React는 기존 Counter 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트합니다. 이로 인해 Counter의 상태가 초기화됩니다.

  • React의 배칭(Batching) 개념
    • React는 여러 상태 업데이트를 하나로 묶어 처리합니다.
      • 동일한 이벤트 핸들러 내에서 여러 상태 업데이트가 발생하면, React는 이를 하나의 배치로 처리하여 컴포넌트를 한 번만 렌더링합니다.
      • 이는 성능 최적화에 크게 기여합니다.
const handleMultipleUpdates = () => {
  setCount(prev => prev + 1);
  setAnotherState(prev => prev + 1);
  // React는 이 두 상태 업데이트를 하나의 배치로 처리합니다.
};

이처럼 React는 여러 상태 업데이트를 효율적으로 처리하여 불필요한 렌더링을 방지합니다.

  • 요약 및 최적화 팁
    • 상태 업데이트는 비동기적: 상태 업데이트 직후의 상태 값을 신뢰하지 말고, 필요한 경우 useEffect나 함수형 업데이트를 사용하세요.
    • 함수형 업데이트 사용: 이전 상태에 의존하는 경우, 상태 업데이트 함수에 함수를 전달하여 최신 상태 값을 안전하게 참조하세요.
    • key 속성 활용: 컴포넌트의 상태를 초기화하거나 재설정해야 할 때는 key 속성을 변경하여 컴포넌트를 재생성하세요.
    • 배칭 이해 및 활용: React의 배칭 개념을 이해하고, 여러 상태 업데이트를 효율적으로 관리하세요.
    • 불필요한 useEffect 사용 피하기: 상태 초기화나 업데이트에 useEffect를 남용하지 말고, 가능한 경우 key 속성이나 함수형 업데이트를 활용하세요.

195. 리액트와 최적화 테크닉 살펴보기 / MillionJS로 리액트 최적화하기

  • React 성능 최적화를 위한 Million.js 패키지 소개

    • React 애플리케이션의 성능을 최적화하는 방법은 다양합니다.
    • 이전 섹션에서는 React.memo, useCallback, useMemo 등을 활용하여 성능을 향상시키는 방법에 대해 배웠습니다.
    • 이제 추가적으로 알아볼 성능 최적화 도구는 Million.js 패키지입니다.
    • 이 패키지는 React의 가상 DOM(Virtual DOM) 매커니즘을 대체하여 더 빠르고 효율적인 렌더링을 가능하게 합니다.
  • Million.js란?

    • Million.js는 React 애플리케이션의 성능을 최적화하기 위해 개발된 경량 패키지입니다.
    • 공식 홈페이지에서 자세한 정보를 확인할 수 있으며, 주요 특징은 다음과 같습니다:
      • 가상 DOM 대체: React의 기존 가상 DOM을 대체하여 더욱 효율적인 렌더링을 제공합니다.
      • 고성능 렌더링: 복잡한 UI에서도 빠른 렌더링 속도를 보장합니다.
      • 무료 사용: 현재 공식에서는 무료로 제공되고 있습니다.
      • 간편한 통합: 프로젝트에 쉽게 통합할 수 있는 설정 옵션을 제공합니다.
  • Million.js 설치 및 설정

    • Million.js를 React 프로젝트에 통합하여 성능을 최적화하는 과정은 간단합니다. 아래 단계별로 따라해 보세요.
      1. 개발 서버 종료 및 패키지 설치

        • 먼저, 현재 실행 중인 개발 서버를 종료합니다.

        • 그런 다음, 프로젝트 디렉토리에서 million 패키지를 설치합니다.

          npm install million
      2. Vite 설정 파일 수정

        • 이번 예제에서는 Vite 기반의 프로젝트를 사용하고 있습니다.

        • Vite 설정 파일(vite.config.js 또는 vite.config.ts)을 열어 Million.js를 활성화하도록 수정합니다.

          // vite.config.js
          import { defineConfig } from 'vite';
          import react from '@vitejs/plugin-react';
          import { millionVite } from 'million/compiler';
          
          // Million.js를 통합하기 위한 플러그인 추가
          export default defineConfig({
            plugins: [
              react(),
              millionVite()
            ],
          });
        • 설명:

          • millionVite 플러그인을 추가하여 Million.js가 Vite 빌드 프로세스에 통합됩니다.
          • 공식 문서에서는 다양한 빌드 도구에 대한 설정 방법을 제공하므로, 다른 도구를 사용하는 경우 해당 문서를 참고하세요.
      3. 특정 컴포넌트 제외 설정

        • 일부 컴포넌트는 Million.js의 최적화에서 제외해야 할 수 있습니다.

        • 예를 들어, 특정 아이콘 컴포넌트에서 문제가 발생할 경우, 해당 컴포넌트를 제외할 수 있습니다.

          // IconComponent.tsx
          // million-ignore
          import React from 'react';
          
          const IconComponent: React.FC = () => {
            return <svg>/* SVG 내용 */</svg>;
          };
          
          export default IconComponent;
        • 설명:

          • // million-ignore 주석을 컴포넌트 함수 앞에 추가하여 Million.js의 최적화 대상에서 제외시킵니다.
          • 이는 공식 문서에서 권장하는 방식으로, 문제가 발생하는 컴포넌트를 안전하게 제외할 수 있습니다.
      4. 개발 서버 재시작 및 확인

        • 모든 설정을 마친 후, 개발 서버를 다시 시작합니다.

          npm run dev
        • 개발 서버가 정상적으로 실행되고, 애플리케이션이 오류 없이 작동하는지 확인합니다. 성능 향상을 확인하기 위해 복잡한 UI를 가진 애플리케이션에서 Million.js를 적용해보는 것이 좋습니다.

  • Million.js의 성능 향상 효과

    • Million.js는 React의 가상 DOM을 대체하여 다음과 같은 성능 향상 효과를 제공합니다:
      • 빠른 렌더링: 더욱 효율적인 DOM 업데이트로 복잡한 애플리케이션에서도 빠른 렌더링 속도를 유지합니다.
      • 최적화된 상태 관리: 상태 업데이트 시 불필요한 렌더링을 최소화하여 성능을 최적화합니다.
      • 경량화: 추가적인 패키지 무게가 가볍기 때문에 번들 사이즈를 크게 증가시키지 않습니다.
  • 주의 사항:

    • 간단하고 빠른 앱에서는 성능 차이를 직접 느끼기 어려울 수 있습니다. 그러나 데이터가 많고 복잡한 UI를 가진 대규모 애플리케이션에서는 성능 향상을 확실히 경험할 수 있습니다.
    • 공식 문서를 참고하여 프로젝트에 맞는 최적화 옵션을 설정하는 것이 중요합니다.
  • Million.js의 작동 방식

    • Million.js는 React의 가상 DOM 매커니즘을 대체하여 더 효율적인 렌더링을 제공합니다.

196. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / 소개

  • React 클래스 기반 컴포넌트 이해하기

    • React는 초기 버전부터 함수형 컴포넌트와 클래스 기반 컴포넌트를 모두 지원해 왔습니다.
    • 최근의 트렌드는 주로 함수형 컴포넌트와 Hooks를 사용하는 방향으로 전환되고 있지만, 클래스 기반 컴포넌트는 여전히 중요한 역할을 하고 있습니다.
    • 이번엔 클래스 기반 컴포넌트가 무엇인지, 왜 존재하는지, 어떻게 정의하고 사용하는지, 그리고 오류 경계(Error Boundaries)에 어떻게 활용되는지에 대해 자세히 알아보겠습니다.
  • 클래스 기반 컴포넌트란?

    • 클래스 기반 컴포넌트는 ES6 클래스 문법을 사용하여 정의되는 React 컴포넌트입니다.
    • 함수형 컴포넌트와 달리, 클래스 컴포넌트는 React.Component를 상속받아야 하며, render 메서드를 반드시 구현해야 합니다.
  • 함수형 컴포넌트 vs 클래스 기반 컴포넌트:

    • 함수형 컴포넌트:

      const MyComponent = () => {
        return <div>Hello, World!</div>;
      };
    • 클래스 기반 컴포넌트:

      import React from 'react';
      
      class MyComponent extends React.Component {
        render() {
          return <div>Hello, World!</div>;
        }
      }
      
      export default MyComponent;
  • 클래스 기반 컴포넌트의 특징

    1. 상태 관리(State Management):
      • 클래스 컴포넌트는 this.state를 사용하여 내부 상태를 관리합니다.
      • 상태를 변경할 때는 this.setState 메서드를 사용합니다.
    2. 라이프사이클 메서드(Lifecycle Methods):
      • 클래스 컴포넌트는 컴포넌트의 생명주기 동안 특정 시점에 호출되는 메서드를 제공합니다.
      • 예를 들어, componentDidMount, componentDidUpdate, componentWillUnmount 등이 있습니다.
    3. this 바인딩:
      • 클래스 컴포넌트에서는 메서드 내에서 this를 적절히 바인딩해야 합니다.
      • 이를 위해 생성자에서 바인딩하거나, 클래스 필드 문법을 사용할 수 있습니다.
  • 클래스 기반 컴포넌트 정의하기

import React from 'react';

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    // 초기 상태 설정
    this.state = {
      count: 0,
    };
    // 메서드 바인딩 (필요한 경우)
    this.handleIncrementBinded = this.handleIncrement.bind(this);
  }

  // 이벤트 핸들러 메서드
  handleIncrement() {
    this.setState((prevState) => ({
      count: prevState.count + 1,
    }));
  }

  // 필수 render 메서드
  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.handleIncrementBinded}>Increment</button>
      </div>
    );
  }
}

export default MyComponent;
  • 설명:

    • 생성자(Constructor): props를 받아서 부모로부터 전달된 속성을 초기화하고, 초기 상태를 설정합니다.
    • 상태(State): this.state를 통해 컴포넌트의 내부 상태를 관리합니다.
    • 이벤트 핸들러: 메서드 내에서 this를 올바르게 참조하기 위해 바인딩이 필요합니다.
    • render 메서드: JSX를 반환하여 UI를 렌더링합니다.
  • 클래스 기반 컴포넌트의 라이프사이클 메서드

    • 클래스 컴포넌트는 컴포넌트의 생명주기 동안 특정 시점에 호출되는 여러 라이프사이클 메서드를 제공합니다.
    • 주요 메서드는 다음과 같습니다:
      1. 마운팅(Mounting) 단계:
        • constructor(props): 컴포넌트가 생성될 때 호출됩니다. 상태 초기화 및 메서드 바인딩을 수행합니다.
        • static getDerivedStateFromProps(props, state): props가 변경될 때 상태를 업데이트할 필요가 있을 때 사용됩니다.
        • render(): UI를 렌더링합니다.
        • componentDidMount(): 컴포넌트가 DOM에 마운트된 후 호출됩니다. 네트워크 요청, 구독 설정 등에 사용됩니다.
      2. 업데이트(Updating) 단계:
        • static getDerivedStateFromProps(props, state): 업데이트 시 상태를 동기화할 필요가 있을 때 사용됩니다.
        • shouldComponentUpdate(nextProps, nextState): 컴포넌트가 업데이트되어야 하는지 여부를 결정합니다.
        • render(): UI를 다시 렌더링합니다.
        • getSnapshotBeforeUpdate(prevProps, prevState): 업데이트 전에 DOM의 상태를 캡처할 때 사용됩니다.
        • componentDidUpdate(prevProps, prevState, snapshot): 업데이트 후 호출됩니다. 이전 상태와 비교하여 추가 작업을 수행할 수 있습니다.
      3. 언마운팅(Unmounting) 단계:
        • componentWillUnmount(): 컴포넌트가 DOM에서 제거되기 전에 호출됩니다. 타이머 제거, 구독 해제 등에 사용됩니다.
import React from 'react';

class LifecycleDemo extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    console.log('Constructor');
  }

  static getDerivedStateFromProps(props, state) {
    console.log('getDerivedStateFromProps');
    return null;
  }

  componentDidMount() {
    console.log('componentDidMount');
    // 예: 데이터 페칭
  }

  shouldComponentUpdate(nextProps, nextState) {
    console.log('shouldComponentUpdate');
    return true; // 업데이트 허용
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    console.log('getSnapshotBeforeUpdate');
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log('componentDidUpdate');
  }

  componentWillUnmount() {
    console.log('componentWillUnmount');
  }

  handleIncrement = () => {
    this.setState((prevState) => ({
      count: prevState.count + 1,
    }));
  };

  render() {
    console.log('Render');
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.handleIncrement}>Increment</button>
      </div>
    );
  }
}

export default LifecycleDemo;
  • 콘솔 로그 순서:

    1. Constructor
    2. getDerivedStateFromProps
    3. Render
    4. componentDidMount
    5. (버튼 클릭 시)
      • shouldComponentUpdate
      • getDerivedStateFromProps
      • Render
      • getSnapshotBeforeUpdate
      • componentDidUpdate
  • 오류 경계(Error Boundaries)

    • 오류 경계는 React 컴포넌트 트리에서 발생하는 JavaScript 오류를 포착하고, UI를 깨지지 않게 보호하는 역할을 합니다.
    • 오류 경계는 클래스 기반 컴포넌트에서만 구현할 수 있습니다. 함수형 컴포넌트에서는 현재 공식적으로 지원되지 않습니다.
  • 오류 경계의 주요 메서드:

    • static getDerivedStateFromError(error): 오류가 발생했을 때 상태를 업데이트하여 대체 UI를 렌더링할 수 있게 합니다.
    • componentDidCatch(error, info): 오류를 로깅하거나 사이드 이펙트를 처리할 수 있습니다.
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // 오류 리포팅 서비스에 오류를 기록할 수 있습니다.
    console.error('ErrorBoundary caught an error', error, info);
  }

  render() {
    if (this.state.hasError) {
      // 폴백 UI를 커스터마이즈할 수 있습니다.
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

export default ErrorBoundary;
import React from 'react';
import ErrorBoundary from './ErrorBoundary';
import BuggyComponent from './BuggyComponent';

const App = () => (
  <div>
    <ErrorBoundary>
      <BuggyComponent />
    </ErrorBoundary>
  </div>
);

export default App;
  • 설명:

    • BuggyComponent에서 오류가 발생하면, ErrorBoundary가 이를 포착하고 폴백 UI를 렌더링합니다.
    • ErrorBoundary는 자식 컴포넌트 트리에서 발생한 오류를 포착하므로, 애플리케이션 전체가 깨지지 않고 안정성을 유지할 수 있습니다.
  • 왜 클래스 기반 컴포넌트를 여전히 배워야 하는가?

    1. 레거시 코드와의 호환성:
      • 기존 프로젝트나 서드 파티 라이브러리에서 클래스 기반 컴포넌트를 많이 사용합니다.
      • 유지보수나 확장을 위해 클래스 컴포넌트에 대한 이해가 필요합니다.
    2. 오류 경계 구현:
      • 현재까지 오류 경계는 클래스 컴포넌트에서만 구현할 수 있습니다.
      • 향후 함수형 컴포넌트에서도 오류 경계가 지원될 수 있지만, 현재는 클래스 기반 컴포넌트가 필요합니다.
    3. 깊이 있는 이해:
      • 클래스 기반 컴포넌트를 이해함으로써 React의 내부 동작 원리를 더 깊이 있게 파악할 수 있습니다.
  • 하지만, 왜 함수형 컴포넌트가 선호되는가?

    1. 간결함과 가독성:
      • 함수형 컴포넌트는 더 짧고 간결한 코드로 작성할 수 있습니다.
      • Hooks를 사용하여 상태와 사이드 이펙트를 관리함으로써 코드가 명확해집니다.
    2. Hooks의 강력한 기능:
      • useState, useEffect, useReducer 등 Hooks는 클래스 컴포넌트의 기능을 대체하고 더 유연한 상태 관리를 가능하게 합니다.
      • 커스텀 Hooks를 통해 로직을 재사용할 수 있습니다.
    3. 향후 React 개발 방향:
      • React 팀은 함수형 컴포넌트와 Hooks를 중심으로 개발을 진행하고 있습니다.
      • 새로운 기능과 최적화가 주로 함수형 컴포넌트에 적용됩니다.

197. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / 무엇을 & 왜

  • React 클래스 기반 컴포넌트 심화 이해

    • 이전에 함수형 컴포넌트와 클래스 기반 컴포넌트의 기본적인 차이점에 대해 살펴보았습니다.
    • 이제 더욱 심도 있게 클래스 기반 컴포넌트가 무엇인지, 왜 존재하는지, 그리고 어떻게 사용하는지에 대해 알아보겠습니다.
    • 또한, 클래스 기반 컴포넌트에서 상태(state)를 관리하고, 사이드 이펙트를 다루는 방법에 대해서도 살펴보겠습니다.
  • 클래스 기반 컴포넌트의 정의와 구조

    • 클래스 기반 컴포넌트는 ES6 클래스 문법을 사용하여 정의되는 React 컴포넌트입니다.
    • 함수형 컴포넌트와는 달리, 클래스 컴포넌트는 React.Component를 상속받아야 하며, 반드시 render 메서드를 구현해야 합니다.
import React from 'react';

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    // 초기 상태 설정
    this.state = {
      count: 0,
    };
    // 메서드 바인딩 (필요한 경우)
    this.handleIncrementBinded = this.handleIncrement.bind(this);
  }

  // 이벤트 핸들러 메서드
  handleIncrement() {
    this.setState((prevState) => ({
      count: prevState.count + 1,
    }));
  }

  // 필수 render 메서드
  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.handleIncrementBinded}>Increment</button>
      </div>
    );
  }
}

export default MyComponent;
  • 설명:

    • 생성자(Constructor): 컴포넌트가 생성될 때 호출되며, props를 전달받아 super(props)를 호출해야 합니다. 여기서 초기 상태를 설정하고, 필요시 메서드를 바인딩합니다.
    • 상태(State): this.state를 통해 컴포넌트의 내부 상태를 관리합니다.
    • 이벤트 핸들러: 메서드 내에서 this를 올바르게 참조하기 위해 바인딩이 필요합니다.
    • render 메서드: JSX를 반환하여 UI를 렌더링합니다.
  • 상태(State) 관리와 업데이트

    • 클래스 기반 컴포넌트에서 상태 관리는 this.state와 this.setState를 통해 이루어집니다.
    • 상태는 컴포넌트의 동적인 데이터를 저장하며, 상태가 변경될 때마다 render 메서드가 다시 호출되어 UI가 업데이트됩니다.
import React from 'react';

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: props.initialCount || 0,
    };
    this.handleIncrement = this.handleIncrement.bind(this);
    this.handleDecrement = this.handleDecrement.bind(this);
  }

  handleIncrement() {
    this.setState((prevState) => ({
      count: prevState.count + 1,
    }));
  }

  handleDecrement() {
    this.setState((prevState) => ({
      count: prevState.count - 1,
    }));
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.handleIncrement}>+</button>
        <button onClick={this.handleDecrement}>-</button>
      </div>
    );
  }
}

export default Counter;
  • 설명:

    • 초기 상태 설정: constructor 내에서 this.state를 초기화합니다. props.initialCount가 제공되지 않는 경우 기본값으로 0을 사용합니다.
    • 상태 업데이트: this.setState 메서드를 사용하여 상태를 업데이트합니다. 이전 상태(prevState)를 기반으로 새로운 상태를 계산합니다.
    • 렌더링: this.state.count 값을 사용하여 현재 카운트를 화면에 표시합니다.
  • 라이프사이클 메서드(Lifecycle Methods)

    • 클래스 기반 컴포넌트는 컴포넌트의 생명주기 동안 특정 시점에 호출되는 여러 라이프사이클 메서드를 제공합니다.
    • 이는 컴포넌트가 마운트(생성)되고 업데이트되며 언마운트(제거)될 때 특정 작업을 수행할 수 있게 해줍니다.
  • 주요 라이프사이클 메서드:

    1. 마운팅(Mounting) 단계:
      • constructor(props): 컴포넌트가 생성될 때 호출됩니다. 초기 상태 설정 및 메서드 바인딩을 수행합니다.
      • static getDerivedStateFromProps(props, state): props가 변경될 때 상태를 업데이트할 필요가 있을 때 사용됩니다.
      • render(): UI를 렌더링합니다.
      • componentDidMount(): 컴포넌트가 DOM에 마운트된 후 호출됩니다. 네트워크 요청, 구독 설정 등에 사용됩니다.
    2. 업데이트(Updating) 단계:
      • static getDerivedStateFromProps(props, state): 업데이트 시 상태를 동기화할 필요가 있을 때 사용됩니다.
      • shouldComponentUpdate(nextProps, nextState): 컴포넌트가 업데이트되어야 하는지 여부를 결정합니다.
      • render(): UI를 다시 렌더링합니다.
      • getSnapshotBeforeUpdate(prevProps, prevState): 업데이트 전에 DOM의 상태를 캡처할 때 사용됩니다.
      • componentDidUpdate(prevProps, prevState, snapshot): 업데이트 후 호출됩니다. 이전 상태와 비교하여 추가 작업을 수행할 수 있습니다.
    3. 언마운팅(Unmounting) 단계:
      • componentWillUnmount(): 컴포넌트가 DOM에서 제거되기 전에 호출됩니다. 타이머 제거, 구독 해제 등에 사용됩니다.
import React from 'react';

class LifecycleDemo extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    console.log('Constructor');
  }

  static getDerivedStateFromProps(props, state) {
    console.log('getDerivedStateFromProps');
    return null;
  }

  componentDidMount() {
    console.log('componentDidMount');
    // 예: 데이터 페칭
  }

  shouldComponentUpdate(nextProps, nextState) {
    console.log('shouldComponentUpdate');
    return true; // 업데이트 허용
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    console.log('getSnapshotBeforeUpdate');
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log('componentDidUpdate');
  }

  componentWillUnmount() {
    console.log('componentWillUnmount');
  }

  handleIncrement = () => {
    this.setState((prevState) => ({
      count: prevState.count + 1,
    }));
  };

  render() {
    console.log('Render');
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.handleIncrement}>Increment</button>
      </div>
    );
  }
}

export default LifecycleDemo;
  • 콘솔 로그 순서:

    1. Constructor
    2. getDerivedStateFromProps
    3. Render
    4. componentDidMount
    5. (버튼 클릭 시)
      • shouldComponentUpdate
      • getDerivedStateFromProps
      • Render
      • getSnapshotBeforeUpdate
      • componentDidUpdate
  • 오류 경계(Error Boundaries)

    • 오류 경계는 React 컴포넌트 트리에서 발생하는 JavaScript 오류를 포착하고, UI를 깨지지 않게 보호하는 역할을 합니다.
    • 오류 경계는 클래스 기반 컴포넌트에서만 구현할 수 있습니다.
    • 함수형 컴포넌트에서는 현재 공식적으로 지원되지 않습니다.
  • 오류 경계의 주요 메서드:

    • static getDerivedStateFromError(error): 오류가 발생했을 때 상태를 업데이트하여 대체 UI를 렌더링할 수 있게 합니다.
    • componentDidCatch(error, info): 오류를 로깅하거나 사이드 이펙트를 처리할 수 있습니다.
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // 오류 리포팅 서비스에 오류를 기록할 수 있습니다.
    console.error('ErrorBoundary caught an error', error, info);
  }

  render() {
    if (this.state.hasError) {
      // 폴백 UI를 커스터마이즈할 수 있습니다.
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

export default ErrorBoundary;
import React from 'react';
import ErrorBoundary from './ErrorBoundary';
import BuggyComponent from './BuggyComponent';

const App = () => (
  <div>
    <ErrorBoundary>
      <BuggyComponent />
    </ErrorBoundary>
  </div>
);

export default App;
  • 설명:

    • BuggyComponent에서 오류가 발생하면, ErrorBoundary가 이를 포착하고 폴백 UI를 렌더링합니다.
    • ErrorBoundary는 자식 컴포넌트 트리에서 발생한 오류를 포착하므로, 애플리케이션 전체가 깨지지 않고 안정성을 유지할 수 있습니다.
  • 함수형 컴포넌트의 선호 이유

    • 현재 대부분의 React 프로젝트에서는 함수형 컴포넌트와 Hooks를 사용하는 것이 표준입니다.
    • 클래스 기반 컴포넌트는 선택 사항이며, 새로운 프로젝트에서는 거의 사용되지 않습니다.
    • 그 이유는 다음과 같습니다:
      1. 간결함과 가독성:
        • 함수형 컴포넌트는 더 짧고 간결한 코드로 작성할 수 있습니다.
        • Hooks를 사용하여 상태와 사이드 이펙트를 관리함으로써 코드가 명확해집니다.
      2. Hooks의 강력한 기능:
        • useState, useEffect, useReducer 등 Hooks는 클래스 컴포넌트의 기능을 대체하고 더 유연한 상태 관리를 가능하게 합니다.
        • 커스텀 Hooks를 통해 로직을 재사용할 수 있습니다.
      3. 향후 React 개발 방향:
        • React 팀은 함수형 컴포넌트와 Hooks를 중심으로 개발을 진행하고 있습니다.
        • 새로운 기능과 최적화가 주로 함수형 컴포넌트에 적용됩니다.
      4. 테스트 용이성:
        • 함수형 컴포넌트는 사이드 이펙트가 적고, 순수 함수로 작성될 수 있어 테스트가 용이합니다.
import React, { useState } from 'react';

const Counter = ({ initialCount }) => {
  const [count, setCount] = useState(initialCount || 0);

  const handleIncrement = () => {
    setCount(prevCount => prevCount + 1);
  };

  const handleDecrement = () => {
    setCount(prevCount => prevCount - 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>+</button>
      <button onClick={handleDecrement}>-</button>
    </div>
  );
};

export default Counter;

198. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / 퍼스트 클래스 기반 컴포넌트 추가하기

  • 함수형
// User.js (함수형 컴포넌트)
import React from 'react';

const User = (props) => {
  return (
    <li>
      {props.name}
    </li>
  );
};

export default User;
  • 클래스형
// User.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';

class User extends Component {
  render() {
    return (
      <li>
        {this.props.name}
      </li>
    );
  }
}

export default User;
  • 불필요한 생성자 제거

    • 초기 프로젝트의 User 컴포넌트에는 상태 초기화가 필요 없으므로, 생성자 메서드를 삭제할 수 있습니다.
  • 변경 전:

class User extends Component {
  constructor(props) {
    super(props);
    // 초기화 작업이 필요 없는 경우
  }

  render() {
    return (
      <li>
        {this.props.name}
      </li>
    );
  }
}
  • 변경 후:
class User extends Component {
  render() {
    return (
      <li>
        {this.props.name}
      </li>
    );
  }
}
  • render 메서드 구현

    • 클래스 기반 컴포넌트는 render 메서드 내에서 JSX를 반환해야 합니다.
    • 이는 함수형 컴포넌트에서 return 문을 사용하는 것과 동일한 역할을 합니다.
  • this 키워드 사용

    • 클래스 컴포넌트에서는 props와 state에 접근할 때 this 키워드를 사용해야 합니다.
    • 따라서 this.props.name으로 변경합니다.
class User extends Component {
  render() {
    return (
      <li>
        {this.props.name}
      </li>
    );
  }
}

199. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / State 및 이벤트 작업하기

  • React 클래스 기반 컴포넌트 변환 실습 계속

    • 이제 함수형 컴포넌트를 클래스 기반 컴포넌트로 변환하는 과정을 좀 더 구체적으로 살펴보겠습니다.
    • 이전 단계에서 기본적인 변환 방법을 이해했으니, 이번에는 상태(state) 관리와 메서드 바인딩(binding) 등 클래스 컴포넌트의 핵심 요소들을 구현해보겠습니다.
  • 프로젝트 구조 확인

    • 먼저, 초기 프로젝트 구조를 확인하고 필요한 파일들을 준비합니다. 주요 파일은 다음과 같습니다:
      • App.js: 루트 컴포넌트
      • Users.js: 사용자 목록을 렌더링하는 컴포넌트 (함수형)
      • User.js: 개별 사용자를 렌더링하는 컴포넌트 (클래스 기반으로 변환할 예정)
  • User 컴포넌트 클래스 기반으로 변환하기

    • 현재 User 컴포넌트는 함수형 컴포넌트로 정의되어 있습니다.
    • 이를 클래스 기반 컴포넌트로 변환하는 과정을 단계별로 진행해보겠습니다.
  • 기존 함수형 User 컴포넌트:

// User.js (함수형 컴포넌트)
import React from 'react';

const User = (props) => {
  return (
    <li>
      {props.name}
    </li>
  );
};

export default User;
  • 변환된 클래스 기반 User 컴포넌트:
// User.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';

class User extends Component {
  render() {
    return (
      <li>
        {this.props.name}
      </li>
    );
  }
}

export default User;
  • 변경 사항 설명:

    1. 클래스 선언:
      • class User extends Component로 컴포넌트 클래스를 정의합니다.
      • Component는 React에서 제공하는 기본 클래스입니다.
    2. render 메서드:
      • 클래스 컴포넌트는 반드시 render 메서드를 구현해야 합니다.
      • render 메서드 내에서 JSX를 반환하여 UI를 렌더링합니다.
    3. props 접근:
      • 함수형 컴포넌트에서는 매개변수로 받은 props를 직접 사용했지만, 클래스 컴포넌트에서는 this.props를 통해 접근합니다.
  • Users 컴포넌트 업데이트

    • Users 컴포넌트는 현재 함수형 컴포넌트로 작성되어 있으며, User 컴포넌트를 렌더링하고 있습니다.
    • 클래스 기반 User 컴포넌트를 정상적으로 렌더링하기 위해 Users 컴포넌트에는 특별한 변경이 필요하지 않습니다.
    • 그러나 전체적인 구조를 이해하기 위해 Users 컴포넌트를 다시 확인해보겠습니다.
// Users.js (함수형 컴포넌트)
import React, { useState } from 'react';
import User from './User';

const Users = () => {
  const [showUsers, setShowUsers] = useState(true);

  const toggleUsersHandler = () => {
    setShowUsers((prevShowUsers) => !prevShowUsers);
  };

  return (
    <div>
      <button onClick={toggleUsersHandler}>Toggle Users</button>
      {showUsers && (
        <ul>
          <User name="John" />
          <User name="Jane" />
          <User name="Doe" />
        </ul>
      )}
    </div>
  );
};

export default Users;
  • 설명:

    • showUsers 상태를 통해 사용자 목록의 표시 여부를 관리합니다.
    • toggleUsersHandler 함수는 버튼 클릭 시 showUsers 상태를 토글합니다.
    • User 컴포넌트를 여러 번 렌더링하여 사용자 목록을 표시합니다.
  • 클래스 컴포넌트에서 상태(state) 관리하기

    • 클래스 기반 컴포넌트에서 상태를 관리하는 방법은 함수형 컴포넌트와 다릅니다.
    • 클래스 컴포넌트에서는 this.state와 this.setState를 사용하여 상태를 관리합니다.
    • 다음은 Users 컴포넌트를 클래스 기반으로 변환하고 상태를 관리하는 예시입니다.
  • 함수형 Users 컴포넌트 변환 전:

// Users.js (함수형 컴포넌트)
import React, { useState } from 'react';
import User from './User';

const Users = () => {
  const [showUsers, setShowUsers] = useState(true);

  const toggleUsersHandler = () => {
    setShowUsers((prevShowUsers) => !prevShowUsers);
  };

  return (
    <div>
      <button onClick={toggleUsersHandler}>Toggle Users</button>
      {showUsers && (
        <ul>
          <User name="John" />
          <User name="Jane" />
          <User name="Doe" />
        </ul>
      )}
    </div>
  );
};

export default Users;
  • 변환된 클래스 기반 Users 컴포넌트:
// Users.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
import User from './User';

class Users extends Component {
  constructor(props) {
    super(props);
    // 초기 상태 설정
    this.state = {
      showUsers: true,
    };
    // 메서드 바인딩
    this.toggleUsersHandler = this.toggleUsersHandler.bind(this);
  }

  toggleUsersHandler() {
    this.setState((prevState) => ({
      showUsers: !prevState.showUsers,
    }));
  }

  render() {
    return (
      <div>
        <button onClick={this.toggleUsersHandler}>Toggle Users</button>
        {this.state.showUsers && (
          <ul>
            <User name="John" />
            <User name="Jane" />
            <User name="Doe" />
          </ul>
        )}
      </div>
    );
  }
}

export default Users;
  • 변경 사항 설명:

    1. 클래스 선언 및 상속:
      • class Users extends Component로 컴포넌트 클래스를 정의합니다.
    2. 생성자(Constructor):
      • constructor(props)를 통해 초기 상태를 설정합니다.
      • this.state에 초기 상태 객체를 할당합니다.
      • toggleUsersHandler 메서드를 bind(this)를 사용하여 바인딩합니다. 이는 메서드 내에서 this가 클래스 인스턴스를 가리키도록 보장합니다.
    3. 상태 업데이트:
      • toggleUsersHandler 메서드 내에서 this.setState를 사용하여 상태를 업데이트합니다.
      • this.setState는 기존 상태를 병합(Merge)하며, 부분적인 상태 업데이트가 가능합니다.
    4. 렌더링:
      • this.state.showUsers를 통해 상태 값을 참조하고, 사용자 목록의 표시 여부를 결정합니다.
      • User 컴포넌트를 클래스 기반으로 변환했으므로, 동일한 방식으로 렌더링됩니다
  • this 바인딩 문제 해결

    • 클래스 기반 컴포넌트에서 메서드를 정의할 때 this 바인딩은 중요한 이슈입니다.
    • 메서드 내에서 this가 클래스 인스턴스를 올바르게 참조하지 않으면, 상태나 props에 접근할 때 오류가 발생할 수 있습니다.
    • 이를 해결하기 위한 두 가지 주요 방법을 소개합니다.
      1. 생성자에서 메서드 바인딩:

        • 가장 전통적인 방법으로, 생성자 내에서 메서드를 바인딩합니다.

          constructor(props) {
            super(props);
            this.state = { showUsers: true };
            this.toggleUsersHandler = this.toggleUsersHandler.bind(this);
          }
        • 장점:

          • 명확하고 일관된 방법입니다.
          • 모든 메서드에 대해 바인딩을 명시적으로 처리할 수 있습니다.
        • 단점:

          • 생성자가 복잡해질 수 있습니다.
          • 바인딩을 잊을 경우 오류가 발생할 수 있습니다.
      2. 클래스 필드 문법(Class Fields Syntax) 사용:

        • ES6 클래스 필드 문법을 사용하면, 바인딩을 보다 간결하게 처리할 수 있습니다.

          class Users extends Component {
            state = {
              showUsers: true,
            };
          
            toggleUsersHandler = () => {
              this.setState((prevState) => ({
                showUsers: !prevState.showUsers,
              }));
            };
          
            render() {
              // 렌더링 로직
            }
          }
        • 장점:

          • 생성자에서 바인딩을 할 필요가 없습니다.
          • 코드가 더 간결하고 읽기 쉽습니다.
        • 단점:

          • 일부 구형 브라우저에서는 지원되지 않을 수 있습니다.
          • 설정에 따라 Babel 등의 트랜스파일러가 필요할 수 있습니다.
        • 주의 사항:

          • 클래스 필드 문법을 사용하려면 프로젝트 설정에서 해당 문법을 지원하도록 해야 합니다.
          • Create React App(CRA)와 같은 최신 빌드 도구는 기본적으로 지원합니다.
  • 전체 클래스 기반 Users 컴포넌트 예시

    • 위에서 설명한 내용을 종합하여, 클래스 필드 문법을 사용한 Users 컴포넌트의 전체 코드를 작성해보겠습니다.
// Users.js (클래스 기반 컴포넌트 - 클래스 필드 문법 사용)
import React, { Component } from 'react';
import User from './User';

class Users extends Component {
  state = {
    showUsers: true,
  };

  toggleUsersHandler = () => {
    this.setState((prevState) => ({
      showUsers: !prevState.showUsers,
    }));
  };

  render() {
    return (
      <div>
        <button onClick={this.toggleUsersHandler}>Toggle Users</button>
        {this.state.showUsers && (
          <ul>
            <User name="John" />
            <User name="Jane" />
            <User name="Doe" />
          </ul>
        )}
      </div>
    );
  }
}

export default Users;
  • 변경 사항 설명:

    1. 클래스 필드 문법 도입:
      • state를 클래스 필드로 정의하여 초기 상태를 설정합니다.
      • toggleUsersHandler 메서드를 화살표 함수로 정의하여 자동으로 this 바인딩을 처리합니다.
    2. 렌더링 로직:
      • 이전과 동일하게 this.state.showUsers를 통해 상태를 참조하고, 사용자 목록의 표시 여부를 결정합니다.
      • User 컴포넌트를 렌더링합니다.
  • 장점:

    • 코드가 더 간결하고 명확해집니다.
    • this 바인딩을 신경 쓸 필요가 없어집니다.
    • 클래스 필드 문법을 통해 상태와 메서드를 쉽게 관리할 수 있습니다.

200. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / 컴포넌트 수명 주기(클래스 컴포넌트에만 해당!) 부작용 처리하기!!

  • React 클래스 기반 컴포넌트에서 부작용(Effects) 관리하기

    • 이제까지 함수형 컴포넌트에서 useState와 useEffect를 사용하여 상태를 관리하고 부작용을 처리하는 방법을 배웠습니다.
    • 하지만 클래스 기반 컴포넌트에서는 이러한 작업을 어떻게 수행할까요?
    • 이번엔 UserFinder라는 함수형 컴포넌트를 클래스 기반 컴포넌트로 변환하면서 부작용을 처리하는 방법을 배워보겠습니다.
    • 이를 통해 클래스 기반 컴포넌트에서 상태 관리와 부작용 처리의 차이점을 명확히 이해할 수 있을 것입니다.
  • 기존 UserFinder 함수형 컴포넌트 확인

// UserFinder.js (함수형 컴포넌트)
import React, { useState, useEffect } from 'react';
import Users from './Users';

const DUMMY_USERS = [
  { id: 'u1', name: 'John Doe' },
  { id: 'u2', name: 'Jane Doe' },
  { id: 'u3', name: 'Max Mustermann' },
  { id: 'u4', name: 'Mary Poppins' },
];

const UserFinder = () => {
  const [enteredFilter, setEnteredFilter] = useState('');
  const [filteredUsers, setFilteredUsers] = useState(DUMMY_USERS);

  useEffect(() => {
    const timer = setTimeout(() => {
      if (enteredFilter === '') {
        setFilteredUsers(DUMMY_USERS);
        return;
      }
      setFilteredUsers(
        DUMMY_USERS.filter((user) =>
          user.name.toLowerCase().includes(enteredFilter.toLowerCase())
        )
      );
    }, 500);

    return () => {
      clearTimeout(timer);
    };
  }, [enteredFilter]);

  const filterChangeHandler = (event) => {
    setEnteredFilter(event.target.value);
  };

  return (
    <div>
      <input type="text" onChange={filterChangeHandler} />
      <Users users={filteredUsers} />
    </div>
  );
};

export default UserFinder;
  • 설명:

    • 상태 관리:
      • enteredFilter: 입력된 필터 문자열을 관리합니다.
      • filteredUsers: 필터링된 사용자 목록을 관리합니다.
    • 부작용 관리 (useEffect):
      • enteredFilter가 변경될 때마다 필터링 로직을 실행합니다.
      • 500ms의 딜레이를 두어 사용자의 입력이 완료된 후에 필터링을 수행합니다.
      • 클린업 함수(return () => { clearTimeout(timer); })를 통해 타이머를 정리합니다.
  • UserFinder 컴포넌트 클래스 기반으로 변환하기

    • 함수형 컴포넌트를 클래스 기반 컴포넌트로 변환하면서 상태 관리와 부작용 처리를 어떻게 하는지 알아보겠습니다.
  • 변환 단계 요약:

    1. 클래스 선언 및 상속:
      • class UserFinder extends Component
    2. 생성자(Constructor):
      • 초기 상태 설정 (this.state)
      • 메서드 바인딩 (this.filterChangeHandler = this.filterChangeHandler.bind(this))
    3. 상태 관리:
      • this.state를 통해 상태 객체 관리
    4. 부작용 관리:
      • componentDidMount와 componentDidUpdate를 사용하여 useEffect와 동일한 역할 수행
      • componentWillUnmount를 사용하여 클린업 작업 수행
    5. 이벤트 핸들러:
      • 클래스 메서드로 정의하고 this 바인딩
  • 변환된 UserFinder 클래스 컴포넌트:

// UserFinder.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
import Users from './Users';

const DUMMY_USERS = [
  { id: 'u1', name: 'John Doe' },
  { id: 'u2', name: 'Jane Doe' },
  { id: 'u3', name: 'Max Mustermann' },
  { id: 'u4', name: 'Mary Poppins' },
];

class UserFinder extends Component {
  constructor(props) {
    super(props);
    this.state = {
      enteredFilter: '',
      filteredUsers: DUMMY_USERS,
    };
    this.filterChangeHandler = this.filterChangeHandler.bind(this);
  }

  componentDidMount() {
    console.log('UserFinder mounted');
    // 초기 마운트 시 필요한 작업을 여기에 추가할 수 있습니다.
  }

  componentDidUpdate(prevProps, prevState) {
    console.log('UserFinder updated');
    if (prevState.enteredFilter !== this.state.enteredFilter) {
      // Debounce 로직 구현
      clearTimeout(this.timer);
      this.timer = setTimeout(() => {
        if (this.state.enteredFilter === '') {
          this.setState({ filteredUsers: DUMMY_USERS });
          return;
        }
        this.setState({
          filteredUsers: DUMMY_USERS.filter((user) =>
            user.name.toLowerCase().includes(this.state.enteredFilter.toLowerCase())
          ),
        });
      }, 500);
    }
  }

  componentWillUnmount() {
    console.log('UserFinder will unmount');
    clearTimeout(this.timer);
  }

  filterChangeHandler(event) {
    this.setState({ enteredFilter: event.target.value });
  }

  render() {
    return (
      <div>
        <input type="text" onChange={this.filterChangeHandler} />
        <Users users={this.state.filteredUsers} />
      </div>
    );
  }
}

export default UserFinder;
  • 변경 사항 설명:

    1. 클래스 선언 및 상속:
      • class UserFinder extends Component로 클래스 기반 컴포넌트를 정의합니다.
    2. 생성자(Constructor):
      • constructor(props)를 통해 초기 상태를 설정합니다.
      • this.state에 enteredFilter와 filteredUsers를 정의합니다.
      • this.filterChangeHandler 메서드를 bind(this)를 사용하여 바인딩합니다.
    3. 상태 관리:
      • 함수형 컴포넌트에서는 useState 훅을 사용했지만, 클래스 컴포넌트에서는 this.state와 this.setState를 사용합니다.
    4. 부작용 관리:
      • useEffect는 함수형 컴포넌트에서 부작용을 처리하는 데 사용되지만, 클래스 컴포넌트에서는 componentDidMount, componentDidUpdate, componentWillUnmount를 사용하여 동일한 작업을 수행합니다.
      • componentDidMount:
        • 컴포넌트가 마운트된 후 호출됩니다.
        • 초기 마운트 시 필요한 작업을 수행할 수 있습니다.
      • componentDidUpdate:
        • 컴포넌트가 업데이트된 후 호출됩니다.
        • 이전 상태(prevState)와 현재 상태를 비교하여 필요한 작업을 수행합니다.
        • 여기서는 enteredFilter가 변경되었을 때 필터링 로직을 실행합니다.
      • componentWillUnmount:
        • 컴포넌트가 언마운트되기 직전에 호출됩니다.
        • 타이머 정리와 같은 클린업 작업을 수행합니다.
    5. 이벤트 핸들러:
      • 클래스 컴포넌트에서는 메서드를 정의할 때 this 바인딩을 명시적으로 해야 합니다.
      • 생성자에서 this.filterChangeHandler = this.filterChangeHandler.bind(this)를 통해 this를 바인딩합니다.
    6. Debounce 로직 구현:
      • componentDidUpdate에서 enteredFilter가 변경될 때마다 타이머를 설정하여 500ms 후에 필터링을 수행합니다.
      • 이전 타이머를 정리하여 불필요한 필터링을 방지합니다.
      • 이는 함수형 컴포넌트의 useEffect와 동일한 역할을 합니다.
  • 주요 차이점 및 주의사항

    1. useEffect vs 라이프사이클 메서드:
      • useEffect (함수형 컴포넌트):
        • 특정 상태나 props가 변경될 때마다 실행됩니다.
        • 의존성 배열을 통해 언제 실행될지 제어할 수 있습니다.
        • 클린업 함수를 반환하여 이전 효과를 정리할 수 있습니다.
      • 라이프사이클 메서드 (클래스 기반 컴포넌트):
        • componentDidMount: 컴포넌트가 마운트된 후 한 번 실행됩니다.
        • componentDidUpdate: 컴포넌트가 업데이트될 때마다 실행됩니다.
        • componentWillUnmount: 컴포넌트가 언마운트되기 직전에 실행됩니다.
        • 타이머 정리와 같은 클린업 작업을 componentWillUnmount에서 수행합니다.
    2. 상태 관리:
      • 함수형 컴포넌트:
        • useState 훅을 사용하여 여러 개의 상태를 독립적으로 관리할 수 있습니다.
        • 상태 업데이트 시 기존 상태를 병합하지 않고, 새로운 상태로 대체됩니다.
      • 클래스 기반 컴포넌트:
        • this.state를 통해 상태 객체를 관리합니다.
        • this.setState를 사용하여 상태를 업데이트할 때, 리액트가 자동으로 기존 상태와 병합합니다.
        • 따라서, setState를 호출할 때마다 전체 상태 객체을 다시 정의할 필요가 없습니다.
    3. this 바인딩:
      • 함수형 컴포넌트:
        • this 키워드를 사용하지 않기 때문에, 바인딩 문제가 발생하지 않습니다.
      • 클래스 기반 컴포넌트:
        • 메서드 내에서 this를 사용할 때, 올바르게 바인딩되지 않으면 오류가 발생할 수 있습니다.
        • 생성자에서 bind(this)를 사용하거나, 클래스 필드 문법을 활용하여 자동으로 this를 바인딩할 수 있습니다.
  • 전체 클래스 기반 UserFinder 컴포넌트 코드

// UserFinder.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
import Users from './Users';

const DUMMY_USERS = [
  { id: 'u1', name: 'John Doe' },
  { id: 'u2', name: 'Jane Doe' },
  { id: 'u3', name: 'Max Mustermann' },
  { id: 'u4', name: 'Mary Poppins' },
];

class UserFinder extends Component {
  constructor(props) {
    super(props);
    this.state = {
      enteredFilter: '',
      filteredUsers: DUMMY_USERS,
    };
    this.filterChangeHandler = this.filterChangeHandler.bind(this);
  }

  componentDidMount() {
    console.log('UserFinder mounted');
    // 초기 마운트 시 필요한 작업을 여기에 추가할 수 있습니다.
  }

  componentDidUpdate(prevProps, prevState) {
    console.log('UserFinder updated');
    if (prevState.enteredFilter !== this.state.enteredFilter) {
      // Debounce 로직 구현
      clearTimeout(this.timer);
      this.timer = setTimeout(() => {
        if (this.state.enteredFilter === '') {
          this.setState({ filteredUsers: DUMMY_USERS });
          return;
        }
        this.setState({
          filteredUsers: DUMMY_USERS.filter((user) =>
            user.name.toLowerCase().includes(this.state.enteredFilter.toLowerCase())
          ),
        });
      }, 500);
    }
  }

  componentWillUnmount() {
    console.log('UserFinder will unmount');
    clearTimeout(this.timer);
  }

  filterChangeHandler(event) {
    this.setState({ enteredFilter: event.target.value });
  }

  render() {
    return (
      <div>
        <input type="text" onChange={this.filterChangeHandler} />
        <Users users={this.state.filteredUsers} />
      </div>
    );
  }
}

export default UserFinder;
  • 설명:

    1. 생성자(Constructor):
      • this.state를 초기화하여 enteredFilter와 filteredUsers를 설정합니다.
      • filterChangeHandler 메서드를 bind(this)를 사용하여 바인딩합니다.
    2. componentDidMount:
      • 컴포넌트가 마운트된 후 한 번 실행됩니다.
      • 초기 데이터 페칭이나 구독 설정과 같은 작업을 수행할 수 있습니다.
    3. componentDidUpdate:
      • 컴포넌트가 업데이트될 때마다 호출됩니다.
      • 이전 상태(prevState)와 현재 상태를 비교하여 필요한 작업을 수행합니다.
      • 여기서는 enteredFilter가 변경되었을 때만 필터링 로직을 실행합니다.
      • 타이머를 설정하여 500ms 후에 필터링을 수행하고, 이전 타이머를 정리합니다.
    4. componentWillUnmount:
      • 컴포넌트가 언마운트되기 직전에 호출됩니다.
      • 타이머를 정리하여 메모리 누수를 방지합니다.
    5. filterChangeHandler:
      • 입력 필드의 변경 이벤트를 처리하여 enteredFilter 상태를 업데이트합니다.
    6. render:
      • 현재 상태에 따라 입력 필드와 사용자 목록을 렌더링합니다.
  • 클래스 기반 컴포넌트의 부작용 관리

    • 클래스 기반 컴포넌트에서는 함수형 컴포넌트의 useEffect 훅과 동일한 역할을 수행하는 라이프사이클 메서드가 존재합니다.
    • 이를 통해 컴포넌트의 생명 주기 동안 부작용을 관리할 수 있습니다.

201. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / 클래스 컴포넌트 및 컨텍스트

  • React Context와 클래스 컴포넌트 사용법
    1. Context 정의하기
      • createContext 함수를 호출하여 Context를 생성합니다.
      • 초기 값을 설정할 수 있으며, 필요에 따라 Provider를 통해 값을 제공할 수 있습니다.
      • 생성된 Context는 정적 값일 수도 있고, 상태(state) 갱신에 따라 동적으로 변경될 수도 있습니다.
    2. Provider 설정
      • Context의 Provider 컴포넌트를 사용하여 하위 컴포넌트에 데이터를 전달합니다.
      • provider는 Context 객체의 일부로, value prop을 통해 데이터를 전달합니다.
      • Provider 내부의 값은 상태 관리 로직에 따라 변경될 수 있습니다.
    3. 클래스 컴포넌트에서 Context 사용하기
      • Context.Consumer 사용하기
        • Context.Consumer 컴포넌트를 이용하여 데이터를 접근합니다.
        • 함수형 컴포넌트와 클래스 컴포넌트 모두에서 사용 가능합니다.
        • render 메소드 내에서 Consumer를 사용하여 Context 데이터를 렌더링합니다.
      • static contextType 사용하기
        • 클래스 컴포넌트에서는 static contextType 프로퍼티를 설정하여 Context에 접근할 수 있습니다.
        • contextType을 설정하면 this.context를 통해 Context 데이터를 사용할 수 있습니다.
        • 단, 클래스 컴포넌트는 한 번에 하나의 Context만 연결할 수 있는 제약이 있습니다.
    4. 제약사항
      • 클래스 컴포넌트는 Hook을 사용할 수 없기 때문에, useContext를 통한 Context 접근이 불가능합니다.
      • 여러 개의 Context를 사용해야 할 경우, Context.Consumer를 중첩하거나 다른 방법(예: 컴포넌트 래핑)을 사용해야 합니다.
    5. 예제 파일 구조
      • App 컴포넌트: Context의 Provider를 설정하여 애플리케이션 전체에 데이터를 제공합니다.
      • users-context.js: 사용자 데이터를 관리하는 Context를 정의합니다.
      • UserFinder 컴포넌트: 클래스 컴포넌트에서 Context 데이터를 소비합니다.
App 컴포넌트
    ├── UsersContext.Provider (value: users)
    │       ├── UserFinder 클래스 컴포넌트
    │       │       └── this.context.users
    │       └── 다른 하위 컴포넌트들
    └── 다른 컴포넌트들
  • App 컴포넌트는 UsersContext.Provider를 통해 users 데이터를 전달합니다.
  • UserFinder 클래스 컴포넌트는 this.context.users를 통해 Context 데이터를 소비합니다.
  • 다른 하위 컴포넌트들도 필요에 따라 UsersContext를 통해 데이터를 접근할 수 있습니다.
// store/users-context.tsx
import React from 'react';

// 사용자 데이터 타입 정의
interface User {
  id: string;
  name: string;
}

// Context의 데이터 타입 정의
interface UsersContextType {
  users: User[];
  addUser: (user: User) => void;
}

// 초기 값 설정
const DUMMY_USERS: User[] = [
  { id: 'u1', name: 'John Doe' },
  { id: 'u2', name: 'Jane Smith' },
];

const UsersContext = React.createContext<UsersContextType>({
  users: DUMMY_USERS,
  addUser: () => {},
});

export default UsersContext;
// App.tsx
import React, { Component } from 'react';
import UsersContext from './store/users-context';
import UserFinder from './components/UserFinder';

interface AppState {
  users: { id: string; name: string }[];
}

class App extends Component<{}, AppState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      users: [
        { id: 'u1', name: 'John Doe' },
        { id: 'u2', name: 'Jane Smith' },
      ],
    };
  }

  addUserHandler = (user: { id: string; name: string }) => {
    this.setState((prevState) => ({
      users: prevState.users.concat(user),
    }));
  };

  render() {
    return (
      <UsersContext.Provider
        value={{
          users: this.state.users,
          addUser: this.addUserHandler,
        }}
      >
        <UserFinder />
        {/* 다른 컴포넌트들 */}
      </UsersContext.Provider>
    );
  }
}

export default App;
// components/UserFinder.tsx
import React, { Component } from 'react';
import UsersContext from '../store/users-context';

interface UserFinderState {
  searchTerm: string;
}

class UserFinder extends Component<{}, UserFinderState> {
  static contextType = UsersContext;
  context!: React.ContextType<typeof UsersContext>;

  constructor(props: {}) {
    super(props);
    this.state = {
      searchTerm: '',
    };
  }

  searchChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
    this.setState({ searchTerm: event.target.value });
  };

  render() {
    const { users } = this.context;
    const filteredUsers = users.filter((user) =>
      user.name.includes(this.state.searchTerm)
    );

    return (
      <div>
        <input type="text" onChange={this.searchChangeHandler} />
        <ul>
          {filteredUsers.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      </div>
    );
  }
}

export default UserFinder;
// Optional: Context의 타입 정의를 별도로 관리
// store/users-context.d.ts
import React from 'react';

export interface User {
  id: string;
  name: string;
}

export interface UsersContextType {
  users: User[];
  addUser: (user: User) => void;
}

declare const UsersContext: React.Context<UsersContextType>;

export default UsersContext;
  • 설명
    1. Context 정의 (users-context.tsx)
      • User와 UsersContextType 인터페이스를 정의하여 Context의 구조를 명확히 합니다.
      • UsersContext를 createContext로 생성하고 초기값을 설정합니다.
    2. Provider 설정 (App.tsx)
      • App 클래스 컴포넌트에서 UsersContext.Provider를 사용하여 users 데이터와 addUser 함수를 하위 컴포넌트에 전달합니다.
      • 상태 관리 로직을 통해 users 데이터를 업데이트할 수 있습니다.
    3. Consumer 사용 (UserFinder.tsx)
      • UserFinder 클래스 컴포넌트에서 static contextType = UsersContext;를 설정하여 Context에 접근합니다.
      • this.context.users를 통해 Context 데이터를 사용하며, searchTerm 상태를 기반으로 필터링된 사용자 목록을 렌더링합니다.
    4. 타입스크립트의 장점
      • 인터페이스를 사용하여 Context의 데이터 구조를 명확히 정의함으로써 타입 안정성을 확보할 수 있습니다.
      • 클래스 컴포넌트에서도 타입스크립트를 통해 Context의 타입을 쉽게 활용할 수 있습니다.

이와 같이 React의 Context API를 클래스 컴포넌트와 함께 사용할 수 있으며, 타입스크립트를 통해 더욱 견고하고 명확한 코드를 작성할 수 있습니다. 다만, 클래스 컴포넌트에서는 Hook을 사용할 수 없기 때문에 여러 Context를 동시에 사용할 때 제약이 있을 수 있다는 점을 유의해야 합니다.

  • 위 예시 코드를 Context.Consumer 사용하여 작성해보기
App 컴포넌트
    ├── UsersContext.Provider (value: users)
    │       ├── UserFinder 클래스 컴포넌트
    │       │       └── UsersContext.Consumer
    │       │               └── this.props.context.users
    │       └── 다른 하위 컴포넌트들
    └── 다른 컴포넌트들
// store/users-context.tsx
import React from 'react';

// 사용자 데이터 타입 정의
export interface User {
  id: string;
  name: string;
}

// Context의 데이터 타입 정의
export interface UsersContextType {
  users: User[];
  addUser: (user: User) => void;
}

// 초기 값 설정
const DUMMY_USERS: User[] = [
  { id: 'u1', name: 'John Doe' },
  { id: 'u2', name: 'Jane Smith' },
];

const UsersContext = React.createContext<UsersContextType>({
  users: DUMMY_USERS,
  addUser: () => {},
});

export default UsersContext;
// App.tsx
import React, { Component } from 'react';
import UsersContext, { User } from './store/users-context';
import UserFinder from './components/UserFinder';

interface AppState {
  users: User[];
}

class App extends Component<{}, AppState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      users: [
        { id: 'u1', name: 'John Doe' },
        { id: 'u2', name: 'Jane Smith' },
      ],
    };
  }

  addUserHandler = (user: User) => {
    this.setState((prevState) => ({
      users: prevState.users.concat(user),
    }));
  };

  render() {
    return (
      <UsersContext.Provider
        value={{
          users: this.state.users,
          addUser: this.addUserHandler,
        }}
      >
        <UserFinder />
        {/* 다른 컴포넌트들 */}
      </UsersContext.Provider>
    );
  }
}

export default App;
// components/UserFinder.tsx
import React, { Component } from 'react';
import UsersContext, { User } from '../store/users-context';

interface UserFinderState {
  searchTerm: string;
}

class UserFinder extends Component<{}, UserFinderState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      searchTerm: '',
    };
  }

  searchChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
    this.setState({ searchTerm: event.target.value });
  };

  render() {
    return (
      <UsersContext.Consumer>
        {(context) => {
          const filteredUsers = context.users.filter((user: User) =>
            user.name.toLowerCase().includes(this.state.searchTerm.toLowerCase())
          );

          return (
            <div>
              <input type="text" onChange={this.searchChangeHandler} />
              <ul>
                {filteredUsers.map((user: User) => (
                  <li key={user.id}>{user.name}</li>
                ))}
              </ul>
            </div>
          );
        }}
      </UsersContext.Consumer>
    );
  }
}

export default UserFinder;
// store/users-context.d.ts
// Optional: Context의 타입 정의를 별도로 관리
import React from 'react';

export interface User {
  id: string;
  name: string;
}

export interface UsersContextType {
  users: User[];
  addUser: (user: User) => void;
}

declare const UsersContext: React.Context<UsersContextType>;

export default UsersContext;
  • 추가적인 고려사항
    • 중첩된 Consumer: 여러 개의 Context를 사용해야 하는 경우, Context.Consumer를 중첩하여 사용할 수 있습니다.
    • 그러나 이는 코드의 가독성을 떨어뜨릴 수 있으므로 주의가 필요합니다.
<FirstContext.Consumer>
  {(firstContext) => (
    <SecondContext.Consumer>
      {(secondContext) => (
        // 컴포넌트 로직
      )}
    </SecondContext.Consumer>
  )}
</FirstContext.Consumer>
  • 컴포넌트 래핑: 여러 Context를 사용하는 경우, 별도의 래핑 컴포넌트를 만들어 관리할 수 있습니다. 이는 코드의 중첩을 줄이고, 가독성을 높이는 데 도움이 됩니다.
  • 함수형 컴포넌트로의 전환: 가능하다면, Hook을 활용할 수 있는 함수형 컴포넌트로 전환하는 것도 고려해볼 만합니다. 함수형 컴포넌트는 useContext 훅을 사용하여 더 간결하게 Context를 사용할 수 있습니다.

202. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / 클래스 컴포넌트 대 함수형 컴포넌트: 요약

  • 클래스 컴포넌트 vs 함수형 컴포넌트
    • 클래스 컴포넌트의 특징
      • 존재 이유: 과거 React에서는 상태(state)와 생명주기 메소드를 사용하기 위해 클래스 컴포넌트를 사용했습니다.
      • 구조:
        • constructor를 사용하여 초기 상태를 설정
        • render() 메소드를 통해 JSX를 반환
        • 생명주기 메소드를 통해 컴포넌트의 라이프사이클 관리
      • 컨텍스트 사용: Context.Consumer 또는 static contextType을 통해 Context API에 접근
      • 사용 사례:
        • 특정 상황에서 반드시 클래스 컴포넌트를 사용해야 하는 경우 (예: 오류 경계(Error Boundaries))
        • 기존 코드베이스나 팀에서 클래스 컴포넌트를 주로 사용하는 경우
    • 함수형 컴포넌트의 특징
      • 현대 React의 주류: React의 최신 버전에서는 함수형 컴포넌트가 주로 사용됩니다.
      • 장점:
        • 간결함: 더 적은 코드로 동일한 기능을 구현 가능
        • Hook 사용 가능: useState, useEffect 등 다양한 Hook을 통해 상태 관리 및 부작용 처리
        • 가독성: 코드가 더 직관적이고 이해하기 쉬움
        • 성능 최적화: React의 최적화 기능과 잘 호환
      • 컨텍스트 사용: useContext 훅을 통해 간편하게 Context API에 접근
      • 사용 사례:
        • 새로운 프로젝트 또는 기능 구현 시 주로 사용
        • 상태 관리 및 부작용 처리가 필요한 경우

203. 컴포넌트를 구축하는 다른 방법: 클래스 컴포넌트 / 오류 경계 소개

  • 오류 경계(Error Boundary)란?

    • 정의: 오류 경계는 React 애플리케이션에서 자식 컴포넌트 트리 내에서 발생한 JavaScript 오류를 포착하고, 이를 우아하게 처리하여 애플리케이션 전체가 중단되지 않도록 하는 컴포넌트입니다.
    • 용도:
      • 예기치 않은 오류로 인해 애플리케이션이 완전히 중단되는 것을 방지.
      • 사용자에게 친화적인 오류 메시지를 제공.
      • 오류를 로깅하여 문제를 분석하고 해결할 수 있도록 도움.
  • 오류 경계의 동작 방식

    1. 오류 발생: 자식 컴포넌트에서 예기치 않은 오류가 발생.
    2. 오류 포착: 오류 경계 컴포넌트의 componentDidCatch 생명주기 메소드가 호출되어 오류를 포착.
    3. 상태 업데이트: 오류가 발생했음을 나타내는 상태(hasError)를 업데이트.
    4. 대체 UI 렌더링: 오류 발생 시, 지정된 대체 UI를 렌더링하여 사용자에게 오류 메시지를 표시.
  • 오류 경계의 구현 조건

    • 클래스 컴포넌트 필요: 현재까지 오류 경계는 클래스 컴포넌트로만 구현 가능. 함수형 컴포넌트에서는 Hook을 이용한 오류 경계 구현이 지원되지 않음.
    • 특정 생명주기 메소드 사용: componentDidCatch와 getDerivedStateFromError 메소드를 통해 오류를 포착하고 상태를 업데이트.
  • 오류 경계의 제한 사항

    • 이벤트 핸들러에서 발생한 오류는 포착하지 못함: 이벤트 핸들러 내에서 발생한 오류는 자동으로 오류 경계에 의해 포착되지 않음.
    • 비동기 코드에서 발생한 오류는 포착하지 못함: setTimeout이나 Promise 내에서 발생한 오류는 포착되지 않음.
    • 오류 경계 자체에서 발생한 오류는 포착하지 못함: 오류 경계 컴포넌트 자체에서 발생한 오류는 또 다른 오류 경계에 의해 포착됨.
  • 오류 경계의 사용 사례

    • 애플리케이션의 특정 부분 보호: 전체 애플리케이션 대신 특정 컴포넌트 트리를 보호하여 오류 발생 시 해당 부분만 대체 UI로 교체.
    • 로그 수집 및 분석: 포착된 오류를 외부 서비스로 전송하여 로그를 수집하고 분석.
    • 사용자 경험 향상: 예기치 않은 오류 발생 시 사용자에게 친화적인 메시지를 제공하여 애플리케이션 사용의 연속성 유지.
App 컴포넌트
    ├── ErrorBoundary
    │       ├── UserFinder 컴포넌트
    │       │       └── Users 컴포넌트 (오류 발생 가능)
    │       └── 다른 하위 컴포넌트들
    └── 다른 컴포넌트들
// components/ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react';

interface ErrorBoundaryProps {
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(_: Error): ErrorBoundaryState {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
    console.error('Uncaught error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;
// 사용자 목록을 렌더링하며, 특정 조건에서 의도적으로 오류를 발생시키는 컴포넌트입니다.
// components/Users.tsx
import React, { Component } from 'react';

interface UsersProps {
  users: { id: string; name: string }[];
}

class Users extends Component<UsersProps> {
  componentDidUpdate(prevProps: UsersProps) {
    if (this.props.users.length === 0) {
      throw new Error('No users available!');
    }
  }

  render() {
    return (
      <ul>
        {this.props.users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  }
}

export default Users;
// 사용자를 검색하고, 검색 결과에 따라 Users 컴포넌트를 렌더링하는 컴포넌트입니다.
// components/UserFinder.tsx
import React, { Component } from 'react';
import Users from './Users';

interface UserFinderState {
  searchTerm: string;
  users: { id: string; name: string }[];
}

class UserFinder extends Component<{}, UserFinderState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      searchTerm: '',
      users: [
        { id: 'u1', name: 'John Doe' },
        { id: 'u2', name: 'Jane Smith' },
      ],
    };
  }

  searchChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
    this.setState({ searchTerm: event.target.value });
  };

  render() {
    const filteredUsers = this.state.users.filter(user =>
      user.name.toLowerCase().includes(this.state.searchTerm.toLowerCase())
    );

    return (
      <div>
        <input type="text" onChange={this.searchChangeHandler} />
        <Users users={filteredUsers} />
      </div>
    );
  }
}

export default UserFinder;
// 애플리케이션의 최상위 컴포넌트로, ErrorBoundary를 사용하여 UserFinder 컴포넌트를 감쌉니다.
// App.tsx
import React, { Component } from 'react';
import UserFinder from './components/UserFinder';
import ErrorBoundary from './components/ErrorBoundary';

interface AppState {
  // 필요 시 상태 정의
}

class App extends Component<{}, AppState> {
  render() {
    return (
      <ErrorBoundary>
        <UserFinder />
        {/* 다른 컴포넌트들 */}
      </ErrorBoundary>
    );
  }
}

export default App;
  • ErrorBoundary 컴포넌트

    • 클래스 컴포넌트로 구현: 오류 경계는 클래스 컴포넌트로만 구현 가능합니다.
    • 생명주기 메소드:
      • static getDerivedStateFromError: 오류가 발생했을 때 상태를 업데이트하여 대체 UI를 렌더링할 수 있도록 함.
      • componentDidCatch: 오류 정보를 로깅하거나 외부 서비스로 전송할 수 있음.
    • 상태 관리: hasError 상태를 통해 오류 발생 여부를 관리.
    • 렌더링 로직: 오류 발생 시 대체 UI를, 그렇지 않으면 자식 컴포넌트를 렌더링.
  • Users 컴포넌트

    • 오류 발생 조건: users 배열의 길이가 0일 경우 의도적으로 오류를 발생시킴.
    • 오류 발생 방법: throw new Error('No users available!')를 통해 JavaScript 오류를 발생시킴.
  • UserFinder 컴포넌트

    • 상태 관리: searchTerm과 users 상태를 관리.
    • 검색 기능: 입력된 검색어에 따라 users 배열을 필터링하여 Users 컴포넌트에 전달.
    • 오류 발생 시나리오: 검색 결과가 없을 경우 Users 컴포넌트에서 오류가 발생하고, 이를 ErrorBoundary가 포착하여 대체 UI를 렌더링.
  • App 컴포넌트

    • 오류 경계 적용: ErrorBoundary를 사용하여 UserFinder 컴포넌트를 감쌈으로써, UserFinder 및 그 하위 컴포넌트에서 발생하는 오류를 포착하고 처리.
    • 다른 컴포넌트 보호: 필요에 따라 다른 컴포넌트들도 ErrorBoundary로 감싸 보호할 수 있음.
  • 타입스크립트의 장점

    • 타입 안정성: 인터페이스를 사용하여 컴포넌트의 props와 state의 구조를 명확히 정의함으로써 타입 오류를 사전에 방지.
    • 개발자 경험 향상: 타입 정보를 기반으로 한 자동 완성과 코드 내비게이션 기능을 통해 개발 생산성 향상.
    • 가독성 및 유지보수성: 명확한 타입 정의를 통해 코드의 가독성과 유지보수성을 높임.
  • 추가적인 고려사항

    • 오류 경계의 적용 범위

      • 전체 애플리케이션 보호: 최상위 컴포넌트를 ErrorBoundary로 감싸면 전체 애플리케이션에서 발생하는 오류를 포착할 수 있음.
      • 부분적인 보호: 특정 컴포넌트 트리만을 보호하여, 해당 부분에서 발생하는 오류만 대체 UI로 처리하고 나머지 애플리케이션은 정상 동작 유지.
    • 오류 경계의 재사용성

      • 재사용 가능한 오류 경계: 다양한 컴포넌트를 감쌀 수 있도록 오류 경계 컴포넌트를 재사용 가능하게 설계.
      • 커스터마이징: 각 오류 경계마다 다른 대체 UI를 제공하거나, 오류 로깅 로직을 다르게 설정할 수 있음.
    • 오류 로깅 및 분석

      • 외부 서비스 연동: 오류 정보를 외부 로깅 서비스(예: Sentry, LogRocket)와 연동하여 실시간으로 오류를 모니터링하고 분석.
      • 개발 및 배포 환경 구분: 개발 환경에서는 자세한 오류 메시지를 표시하고, 배포 환경에서는 사용자에게 친화적인 오류 메시지를 표시하도록 설정.
    • 오류 경계와 함수형 컴포넌트

      • 향후 지원 가능성: 현재는 클래스 컴포넌트로만 오류 경계를 구현할 수 있지만, 향후 React에서 함수형 컴포넌트를 이용한 오류 경계 지원이 추가될 수 있음.
      • 대안 방안: 오류 경계가 필요한 부분은 클래스 컴포넌트를 사용하고, 나머지 부분은 함수형 컴포넌트를 사용하는 혼합 접근법 고려.

이와 같이 React의 오류 경계(Error Boundary)를 활용하면, 애플리케이션 내에서 발생할 수 있는 예기치 않은 오류를 효과적으로 관리하고 사용자 경험을 향상시킬 수 있습니다. 클래스 컴포넌트로 구현되어야 한다는 제약이 있지만, 올바르게 활용하면 애플리케이션의 안정성과 신뢰성을 크게 높일 수 있습니다.

204. HTTP 요청 보내기 / 소개

  • React 애플리케이션에서 서버 및 데이터베이스 연동의 필요성

    1. 로컬 데이터의 한계
      • 데모 프로젝트: 초기에는 프로젝트에 필요한 데이터가 React 프로젝트의 일부로 관리되었습니다. 예를 들어, 장소 선택 기능에서 사용하는 가짜 데이터나 이미지가 로컬에 저장되어 있었습니다.
      • 제한점:
        • 데이터의 공유 불가능: 로컬 데이터는 개별 사용자의 브라우저에서만 접근 가능하며, 서로 다른 사용자 간의 데이터 공유가 불가능합니다.
        • 데이터 수정의 어려움: 중앙 서버가 없으면 데이터를 실시간으로 수정하거나 업데이트하는 것이 불가능합니다.
    2. 중앙 서버 및 데이터베이스의 필요성
      • 중앙 서버 역할:
        • 데이터의 중앙 관리: 모든 사용자들이 동일한 중앙 서버와 데이터베이스를 통해 데이터를 공유하고 접근할 수 있습니다.
        • 실시간 데이터 업데이트: 데이터가 변경될 때마다 중앙 서버를 통해 모든 사용자에게 실시간으로 반영할 수 있습니다.
      • 실제 애플리케이션 예시:
        • 페이스북, 아마존과 같은 대규모 애플리케이션은 중앙 서버와 데이터베이스를 통해 전 세계 사용자들이 데이터를 공유하고 상호작용할 수 있습니다.
    3. React 애플리케이션과 백엔드 서버의 연동
      • 데이터 가져오기 (Fetching Data):
        • fetch API: React 애플리케이션 내에서 백엔드 서버로부터 데이터를 가져오는 데 사용됩니다.
        • 예시 시나리오: 사용자가 장소를 검색하면, React 앱이 백엔드 서버에 HTTP 요청을 보내고, 서버는 해당 장소 데이터를 반환합니다.
      • 데이터 보내기 (Sending Data):
        • POST 요청: 사용자가 새로운 데이터를 생성하거나 기존 데이터를 수정할 때 서버로 데이터를 전송합니다.
        • 예시 시나리오: 사용자가 새로운 장소를 추가하면, React 앱이 해당 데이터를 백엔드 서버로 전송하여 데이터베이스에 저장합니다.
    4. React 애플리케이션에서 중앙 서버와의 통신 구현
      • 데이터베이스와의 상호작용: 중앙 서버는 데이터베이스와 통신하여 데이터를 저장, 수정, 삭제, 조회하는 역할을 합니다.
      • API 엔드포인트 설정: 백엔드 서버는 RESTful API 엔드포인트를 통해 React 애플리케이션과 통신합니다.
      • 보안 및 인증: 데이터 전송 시 보안 및 인증 절차를 통해 데이터의 무결성과 안전성을 보장합니다.
  • 요약

    • 로컬 데이터 관리는 소규모 데모 프로젝트에 적합하지만, 실제 애플리케이션에서는 중앙 서버와 데이터베이스를 통한 데이터 관리가 필수적입니다.
    • React 앱은 fetch API를 사용하여 백엔드 서버와 통신하며, 데이터를 가져오고 전송하여 사용자 간의 데이터 공유와 실시간 업데이트를 가능하게 합니다.
    • 중앙 서버는 데이터베이스와 연동되어 데이터의 중앙 관리와 안전한 접근을 보장합니다.

205. HTTP 요청 보내기 / 데이터베이스 연결/해지하는 방법

  • React 애플리케이션과 백엔드 서버 및 데이터베이스 연동의 필요성

    1. 로컬 데이터의 한계
      • 데모 프로젝트: 초기 프로젝트에서는 필요한 데이터(예: 장소 정보, 이미지 등)를 React 프로젝트 내에서 관리했습니다.
      • 제한점:
        • 데이터 공유 불가능: 로컬 데이터는 각 사용자 브라우저에서만 접근 가능하여, 사용자 간 데이터 공유가 불가능합니다.
        • 데이터 수정의 어려움: 중앙 서버가 없으면 데이터를 실시간으로 수정하거나 업데이트하는 것이 불가능합니다.
    2. 중앙 서버 및 데이터베이스의 필요성
      • 중앙 서버 역할:
        • 데이터의 중앙 관리: 모든 사용자가 동일한 중앙 서버와 데이터베이스를 통해 데이터를 공유하고 접근할 수 있습니다.
        • 실시간 데이터 업데이트: 데이터가 변경될 때마다 중앙 서버를 통해 모든 사용자에게 실시간으로 반영됩니다.
      • 실제 애플리케이션 예시:
        • 페이스북, 아마존과 같은 대규모 애플리케이션은 중앙 서버와 데이터베이스를 통해 전 세계 사용자들이 데이터를 공유하고 상호작용할 수 있습니다.
    3. React 애플리케이션과 백엔드 서버의 연동
      • 데이터 가져오기 (Fetching Data):
        • fetch API: React 애플리케이션 내에서 백엔드 서버로부터 데이터를 가져오는 데 사용됩니다.
        • 예시 시나리오: 사용자가 장소를 검색하면, React 앱이 백엔드 서버에 HTTP 요청을 보내고, 서버는 해당 장소 데이터를 반환합니다.
      • 데이터 보내기 (Sending Data):
        • POST 요청: 사용자가 새로운 데이터를 생성하거나 기존 데이터를 수정할 때 서버로 데이터를 전송합니다.
        • 예시 시나리오: 사용자가 새로운 장소를 추가하면, React 앱이 해당 데이터를 백엔드 서버로 전송하여 데이터베이스에 저장합니다.
    4. 보안 및 접근 제어
      • 프론트엔드와 백엔드 분리: React 앱(프론트엔드)과 백엔드 서버를 분리하여 운영합니다.
      • 인증 정보 보호: 백엔드 서버는 데이터베이스 인증 정보를 숨기고, 프론트엔드에서는 백엔드 API 엔드포인트를 통해 데이터에 접근합니다.
      • HTTP 요청 제어: 백엔드 서버는 허용된 요청만을 처리하여 데이터의 무단 접근과 조작을 방지합니다.
    5. 백엔드 서버와 데이터베이스 연동
      • 백엔드 서버 역할: 데이터베이스와 소통하여 데이터를 저장, 수정, 삭제, 조회하는 역할을 합니다.
      • API 엔드포인트 설정: 백엔드 서버는 RESTful API 엔드포인트를 통해 React 애플리케이션과 통신합니다.
      • 보안 및 인증: 데이터 전송 시 보안 및 인증 절차를 통해 데이터의 무결성과 안전성을 보장합니다.
  • 요약

    • 로컬 데이터 관리는 소규모 데모 프로젝트에 적합하지만, 실제 애플리케이션에서는 중앙 서버과 데이터베이스를 통한 데이터 관리가 필수적입니다.
    • React 앱은 fetch API를 사용하여 백엔드 서버와 통신하며, 데이터를 가져오고 전송하여 사용자 간의 데이터 공유와 실시간 업데이트를 가능하게 합니다.
    • 백엔드 서버는 데이터베이스와 연동되어 데이터의 중앙 관리와 안전한 접근을 보장합니다.

206. HTTP 요청 보내기 / 초기 프로젝트 & 임시 백엔드 API

  • 프로젝트 개요

    • 앱 기능:
      • 장소 선택(Placepicker) 웹사이트로, 현재는 동작하지 않음.
      • 목표: 장소를 선택하거나 선택 해제하는 기능 추가.
      • 데이터는 백엔드에서 fetch를 통해 가져올 예정.
      • 장소 선택/해제 변경 사항은 백엔드에 저장.
  • 백엔드 개요

    • 백엔드 기술:
      • Node.js로 작성.
      • 간단한 임시 백엔드가 제공됨.
    • 백엔드 기능:
      • REST API 설정:
        • GET /places: 사용자가 선택 가능한 장소 목록을 가져옴.
        • GET /user-places: 사용자가 저장한 장소를 가져옴.
        • POST /user-places: 사용자가 장소를 저장하거나 변경함.
    • 역할:
      • 백엔드는 특정 요청만 허용하도록 설계.
      • 데이터를 중앙 서버에 저장해 사용자 간 데이터 공유 가능.
  • 학습 및 주의점

    • 기본 개념 복습:
      • GET 요청과 URL의 역할 이해 필요.
      • 첨부 파일에 제공된 자료 활용 가능.
    • 섹션 목표:
      • React 앱에서 임시 백엔드와 연결.
      • 백엔드 로직에 집중하기보다는 프론트엔드와의 연동에 초점.
import React, { useState, useEffect } from 'react';

interface Place {
  id: string;
  name: string;
}

const PlacePicker: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]);
  const [selectedPlaces, setSelectedPlaces] = useState<string[]>([]);

  // Fetch available places
  useEffect(() => {
    fetch('http://localhost:5000/places')
      .then((response) => response.json())
      .then((data) => setPlaces(data))
      .catch((error) => console.error('Error fetching places:', error));
  }, []);

  // Toggle place selection
  const togglePlaceSelection = (placeId: string) => {
    setSelectedPlaces((prev) =>
      prev.includes(placeId) ? prev.filter((id) => id !== placeId) : [...prev, placeId]
    );
  };

  // Save user selections
  const saveSelection = async () => {
    try {
      const response = await fetch('http://localhost:5000/user-places', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ places: selectedPlaces }),
      });
      if (!response.ok) {
        throw new Error('Failed to save places');
      }
      console.log('Selection saved successfully');
    } catch (error) {
      console.error('Error saving selection:', error);
    }
  };

  return (
    <div>
      <h1>Place Picker</h1>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            <label>
              <input
                type="checkbox"
                checked={selectedPlaces.includes(place.id)}
                onChange={() => togglePlaceSelection(place.id)}
              />
              {place.name}
            </label>
          </li>
        ))}
      </ul>
      <button onClick={saveSelection}>Save Selection</button>
    </div>
  );
};

export default PlacePicker;
import express from 'express';
import bodyParser from 'body-parser';

const app = express();
const PORT = 5000;

// Middleware
app.use(bodyParser.json());

// Mock data
let places = [
  { id: '1', name: 'Place A' },
  { id: '2', name: 'Place B' },
  { id: '3', name: 'Place C' },
];
let userPlaces: string[] = [];

// Routes
app.get('/places', (req, res) => {
  res.json(places);
});

app.get('/user-places', (req, res) => {
  res.json(userPlaces);
});

app.post('/user-places', (req, res) => {
  const { places: selectedPlaces } = req.body;
  if (!Array.isArray(selectedPlaces)) {
    return res.status(400).json({ error: 'Invalid data format' });
  }
  userPlaces = selectedPlaces;
  res.status(200).json({ message: 'User places updated' });
});

// Start server
app.listen(PORT, () => {
  console.log(`Backend running on http://localhost:${PORT}`);
});

207. HTTP 요청 보내기 / 앱의 데이터 Fetching을 위한 준비

  • 주의사항

    • 두 개의 프로세스 필요:
      • React 개발 서버: npm run dev로 실행.
      • 백엔드 서버: 백엔드 폴더로 이동 후 node app.js로 실행.
      • Node.js 설치가 필요하며, 이는 모든 운영체제(Mac, Windows, Linux)에서 지원됨.
  • 데이터 처리 방식

    1. 데이터 저장소:
      • 장소 데이터는 리액트 프로젝트에 포함되지 않음.
      • 데이터와 이미지는 백엔드에 저장되며, API를 통해 접근 가능.
    2. HTTP 요청:
      • 백엔드 API를 호출해 장소 목록 데이터를 가져옴.
      • GET /places: 장소 데이터를 fetch.
      • Fetch 특징:
        • 데이터 요청에는 시간이 걸리며, 이는 UI가 즉시 데이터를 기다릴 수 없음을 의미.
        • 컴포넌트는 초기 상태로 빈 배열을 렌더링해야 하며, 데이터가 도착하면 상태를 업데이트하여 UI를 다시 렌더링.
    3. 로컬 저장소와의 차이:
      • localStorage는 데이터 접근이 즉각적임.
      • 백엔드는 HTTP 요청과 네트워크 통신을 통해 데이터를 제공하므로 시간이 걸림.
import React, { useState, useEffect } from 'react';

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string;
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 초기 상태는 빈 배열

  // 백엔드에서 장소 데이터 가져오기
  useEffect(() => {
    const fetchPlaces = async () => {
      try {
        const response = await fetch('http://localhost:5000/places');
        if (!response.ok) {
          throw new Error('Failed to fetch places');
        }
        const data: Place[] = await response.json();
        setPlaces(data); // 데이터 상태 업데이트
      } catch (error) {
        console.error('Error fetching places:', error);
      }
    };

    fetchPlaces();
  }, []); // 의존성 배열이 비어 있으므로 컴포넌트가 처음 렌더링될 때만 실행

  if (places.length === 0) {
    return <p>Loading places...</p>;
  }

  return (
    <div>
      <h2>Available Places</h2>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            <img src={place.image} alt={place.name} width="100" />
            <p>{place.name}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default AvailablePlaces;
import express from 'express';

const app = express();
const PORT = 5000;

// 장소 데이터 (임시)
const places = [
  { id: '1', name: 'Central Park', image: 'https://via.placeholder.com/100' },
  { id: '2', name: 'Eiffel Tower', image: 'https://via.placeholder.com/100' },
  { id: '3', name: 'Great Wall of China', image: 'https://via.placeholder.com/100' },
];

// CORS 설정 (프론트엔드와 통신 허용)
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  next();
});

// GET /places 엔드포인트
app.get('/places', (req, res) => {
  res.json(places);
});

// 서버 실행
app.listen(PORT, () => {
  console.log(`Backend running on http://localhost:${PORT}`);
});
  • 설명

    1. React 코드:
      • useState로 초기 상태를 빈 배열로 설정.
      • useEffect로 컴포넌트가 마운트될 때 백엔드에서 데이터 fetch.
      • fetchPlaces 함수로 비동기 HTTP 요청 처리 및 상태 업데이트.
    2. Node.js 코드:
      • Express를 사용해 백엔드 API 구축.
      • 간단한 장소 데이터를 반환하는 GET /places 엔드포인트 제공.
      • CORS 설정으로 프론트엔드와 백엔드 간 통신 허용.
  • 프로세스 실행:

    • React 개발 서버: npm run dev.
    • 백엔드 서버: node app.js.
    • 두 프로세스가 동시에 실행되어야 데이터 통신 가능.

208. HTTP 요청 보내기 / HTTP 요청을 보내지 않는 방법 (잘못된 이유 부가 설명)

  1. Fetch 함수와 HTTP 요청

    • Fetch 함수란:
      • 브라우저가 제공하는 HTTP 요청 함수.
      • 데이터를 가져올 때 뿐만 아니라 데이터를 보낼 때도 사용 가능.
      • URL만 제공하면 기본적으로 GET 요청을 실행.
    • 사용법:
      • fetch('http://localhost:3000/places')로 백엔드 서버의 /places 엔드포인트를 호출.
      • fetch는 Promise를 반환:
        • 데이터를 가져오는 작업이 비동기로 실행됨.
        • 응답이 도착하면 then()으로 처리.
      • 응답 데이터를 JSON 형식으로 변환하려면 response.json()을 호출.
        • 이 역시 Promise를 반환하므로, 또 다른 then()으로 처리 가능.
  2. React 컴포넌트 내에서의 Fetch 문제

    • 무한 루프의 원인:
      • fetch를 컴포넌트 함수에서 직접 호출하면, 상태 업데이트 후 컴포넌트가 다시 렌더링되며 fetch가 계속 실행됨.
      • 상태 업데이트 → 렌더링 → fetch 호출 → 상태 업데이트... 무한 반복 발생.
    • 해결책:
      • React의 useEffect를 사용해 컴포넌트가 처음 렌더링될 때만 fetch 호출.
      • useEffect의 의존성 배열을 빈 배열([])로 설정하여 한 번만 실행되도록 제어.
  3. JSON 데이터와 처리

    • JSON이란:
      • 텍스트 기반의 데이터 교환 형식.
      • 자바스크립트의 객체와 배열과 유사하지만, 모든 키는 따옴표로 감싸야 함.
      • 백엔드와 데이터를 주고받는 데 표준적으로 사용됨.
    • 처리 방식:
      • response.json()으로 JSON 데이터를 자바스크립트 객체로 변환.
import React, { useState, useEffect } from 'react';

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string;
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
  const [isLoading, setIsLoading] = useState<boolean>(true); // 로딩 상태 관리
  const [error, setError] = useState<string | null>(null); // 에러 메시지 관리

  // 백엔드에서 데이터 fetch
  useEffect(() => {
    const fetchPlaces = async () => {
      try {
        const response = await fetch('http://localhost:3000/places');
        if (!response.ok) {
          throw new Error('Failed to fetch places');
        }
        const data: Place[] = await response.json();
        setPlaces(data); // 상태 업데이트
      } catch (err: any) {
        setError(err.message || 'Something went wrong');
      } finally {
        setIsLoading(false); // 로딩 상태 해제
      }
    };

    fetchPlaces();
  }, []); // 의존성 배열이 비어 있으므로 컴포넌트가 처음 렌더링될 때만 실행

  if (isLoading) {
    return <p>Loading...</p>;
  }

  if (error) {
    return <p>Error: {error}</p>;
  }

  return (
    <div>
      <h2>Available Places</h2>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            <img src={place.image} alt={place.name} width="100" />
            <p>{place.name}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default AvailablePlaces;
  • useEffect의 역할:

    • 데이터를 가져오는 부수 효과(side effect)를 처리.
    • 의존성 배열이 비어 있으면 컴포넌트가 처음 마운트될 때 한 번 실행됨.
  • 비동기 데이터 처리:

    • await와 async를 사용해 fetch 호출과 응답 대기.
    • 오류 처리를 위해 try-catch 블록 사용.
  • 상태 관리:

    • isLoading: 데이터를 가져오는 동안 로딩 상태 표시.
    • error: 데이터 요청 중 발생한 에러 메시지 표시.

209. HTTP 요청 보내기 / useEffect로 HTTP 요청 (GET 요청) 전송하기

  • useEffect를 사용해 무한 루프 문제를 해결하고 이미지 출력 개선
  • 문제 해결 요약
    1. 무한 루프 문제:
      • useEffect를 사용해 fetch 호출을 컴포넌트가 처음 렌더링될 때 한 번만 실행되도록 수정.
      • 의존성 배열을 빈 배열([])로 설정하여 추가 렌더링이 발생하지 않도록 제어.
    2. 이미지 경로 문제:
      • 백엔드 서버에 저장된 이미지 파일에 직접 접근할 수 없으므로, 백엔드 프로젝트에서 이미지 파일 요청을 허용하는 설정 필요.
      • 프론트엔드에서 백엔드 URL과 이미지 파일명을 조합하여 이미지에 접근.
import React, { useState, useEffect } from 'react';

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string; // 이미지 파일 이름
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
  const [isLoading, setIsLoading] = useState<boolean>(true); // 로딩 상태 관리
  const [error, setError] = useState<string | null>(null); // 에러 메시지 관리

  // 데이터 fetch
  useEffect(() => {
    const fetchPlaces = async () => {
      try {
        const response = await fetch('http://localhost:3000/places');
        if (!response.ok) {
          throw new Error('Failed to fetch places');
        }
        const data: Place[] = await response.json();
        setPlaces(data); // 상태 업데이트
      } catch (err: any) {
        setError(err.message || 'Something went wrong');
      } finally {
        setIsLoading(false); // 로딩 상태 해제
      }
    };

    fetchPlaces();
  }, []); // 빈 배열로 설정해 무한 루프 방지

  // 로딩 상태
  if (isLoading) {
    return <p>Loading...</p>;
  }

  // 에러 처리
  if (error) {
    return <p>Error: {error}</p>;
  }

  return (
    <div>
      <h2>Available Places</h2>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            {/* 이미지 경로를 백엔드 URL과 결합 */}
            <img
              src={`http://localhost:3000/images/${place.image}`}
              alt={place.name}
              width="100"
            />
            <p>{place.name}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default AvailablePlaces;
import express from 'express';
import path from 'path';

const app = express();
const PORT = 3000;

// 장소 데이터 (임시)
const places = [
  { id: '1', name: 'Central Park', image: 'central-park.jpg' },
  { id: '2', name: 'Eiffel Tower', image: 'eiffel-tower.jpg' },
  { id: '3', name: 'Great Wall of China', image: 'great-wall.jpg' },
];

// 정적 파일 서빙 설정
app.use('/images', express.static(path.join(__dirname, 'images')));

// API 엔드포인트
app.get('/places', (req, res) => {
  res.json(places);
});

// 서버 실행
app.listen(PORT, () => {
  console.log(`Backend running on http://localhost:${PORT}`);
});
  • 이미지 파일 요청 처리

    • 백엔드에서 정적 파일 서빙을 설정하여 images 폴더에 저장된 파일을 클라이언트가 요청 가능하게 설정.
  • 이미지 폴더 구조

    • 백엔드 프로젝트 디렉토리:
backend/
├── app.js
├── images/
│   ├── central-park.jpg
│   ├── eiffel-tower.jpg
│   ├── great-wall.jpg
  • 결과
    • React 컴포넌트가 백엔드에서 장소 데이터를 가져오고 상태를 업데이트.
    • 백엔드의 /images 경로를 통해 이미지 파일 요청을 허용.
    • 프론트엔드에서 이미지 경로를 템플릿 리터럴로 생성하여 UI에 표시.
    • 데이터와 이미지가 모두 정상적으로 렌더링됨.

210. HTTP 요청 보내기 / async / await 사용하기

  • Fetch 코드 개선: async/await 사용
  • 개선 내용
    • 기존 then() 체인 방식에서 async/await 구문으로 변경:
    • 장점:
      • 코드 가독성이 좋아짐.
      • 비동기 로직이 더 직관적으로 표현됨.
    • useEffect 내부에서 직접 async 함수를 사용할 수 없으므로, useEffect 내부에 새로 정의한 함수에서 비동기 로직 실행.
import React, { useState, useEffect } from 'react';

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string;
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
  const [isLoading, setIsLoading] = useState<boolean>(true); // 로딩 상태 관리
  const [error, setError] = useState<string | null>(null); // 에러 메시지 관리

  // 데이터 fetch
  useEffect(() => {
    const fetchPlaces = async () => {
      try {
        const response = await fetch('http://localhost:3000/places');
        if (!response.ok) {
          throw new Error('Failed to fetch places');
        }
        const data: Place[] = await response.json();
        setPlaces(data); // 상태 업데이트
      } catch (err: any) {
        setError(err.message || 'Something went wrong');
      } finally {
        setIsLoading(false); // 로딩 상태 해제
      }
    };

    // 정의한 비동기 함수 호출
    fetchPlaces();
  }, []); // 빈 배열로 설정해 무한 루프 방지

  // 로딩 상태
  if (isLoading) {
    return <p>Loading...</p>;
  }

  // 에러 처리
  if (error) {
    return <p>Error: {error}</p>;
  }

  return (
    <div>
      <h2>Available Places</h2>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            {/* 이미지 경로를 백엔드 URL과 결합 */}
            <img
              src={`http://localhost:3000/images/${place.image}`}
              alt={place.name}
              width="100"
            />
            <p>{place.name}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default AvailablePlaces;
  • 변경 사항 요약

    1. 비동기 함수 정의:
      • useEffect 내부에서 fetchPlaces라는 비동기 함수를 새로 정의.
      • async/await 키워드를 사용하여 fetch 요청 및 응답 데이터 처리.
    2. 비동기 함수 호출:
      • 정의한 fetchPlaces 함수를 useEffect 내부에서 즉시 호출.
    3. finally 블록 추가:
      • 데이터를 가져오든, 에러가 발생하든 로딩 상태를 항상 해제(setIsLoading(false))하도록 보장.
  • 이 방식의 장점

    • async/await 사용으로 비동기 코드를 더 직관적이고 가독성 높게 작성.
    • try-catch 블록을 통해 오류를 명확히 처리.
    • useEffect의 패턴을 유지하면서도 React의 규칙을 위반하지 않음.

211. HTTP 요청 보내기 / 로딩 State(상태) 다루기

  • 데이터 로딩 상태와 대체 텍스트를 활용한 사용자 경험 개선
    • 목표
      • 데이터를 fetch하는 동안 로딩 상태를 나타내는 메시지(대체 텍스트) 제공.
      • 데이터가 도착하지 않거나 없는 경우에도 적절한 대체 메시지 제공.
import React, { useState, useEffect } from 'react';

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string;
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
  const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태 관리
  const [error, setError] = useState<string | null>(null); // 에러 메시지 관리

  // 데이터 fetch
  useEffect(() => {
    const fetchPlaces = async () => {
      setIsFetching(true); // 로딩 상태 시작
      try {
        const response = await fetch('http://localhost:3000/places');
        if (!response.ok) {
          throw new Error('Failed to fetch places');
        }
        const data: Place[] = await response.json();
        setPlaces(data); // 상태 업데이트
      } catch (err: any) {
        setError(err.message || 'Something went wrong');
      } finally {
        setIsFetching(false); // 로딩 상태 종료
      }
    };

    fetchPlaces();
  }, []); // 빈 배열로 설정해 무한 루프 방지

  return (
    <div>
      <h2>Available Places</h2>
      {/* 로딩 상태 표시 */}
      {isFetching && <p className="fallback-text">데이터를 불러오는 중입니다...</p>}

      {/* 에러 처리 */}
      {error && !isFetching && <p className="fallback-text">Error: {error}</p>}

      {/* 데이터 없는 경우 대체 메시지 */}
      {!isFetching && places.length === 0 && !error && (
        <p className="fallback-text">표시할 장소가 없습니다.</p>
      )}

      {/* 장소 목록 출력 */}
      {places.length > 0 && (
        <ul>
          {places.map((place) => (
            <li key={place.id}>
              <img
                src={`http://localhost:3000/images/${place.image}`}
                alt={place.name}
                width="100"
              />
              <p>{place.name}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default AvailablePlaces;
  • 주요 개선 사항

    1. isFetching 상태 추가:
      • 데이터를 가져오는 동안 로딩 상태를 true로 설정.
      • 데이터 fetch 완료 후 false로 변경.
    2. 대체 텍스트 관리:
      • 로딩 중: isFetching이 true일 때 "데이터를 불러오는 중입니다..." 출력.
      • 에러 발생: error 상태를 확인하여 에러 메시지 출력.
      • 데이터 없음: isFetching이 false이고 places 배열이 비어 있을 때 "표시할 장소가 없습니다." 출력.
    3. 조건부 렌더링:
      • 각각의 상태(isFetching, error, places)에 따라 적절한 메시지 또는 데이터를 렌더링.
  • 데이터 흐름 요약

    1. 컴포넌트 렌더링:
      • useEffect가 실행되고 fetchPlaces 비동기 함수 호출.
      • isFetching 상태가 true로 변경되며 로딩 메시지 표시.
    2. 데이터 fetch:
      • 백엔드에서 장소 데이터를 가져옴.
      • 성공 시 places 상태 업데이트.
      • 실패 시 error 상태 업데이트.
      • fetch 완료 후 isFetching 상태가 false로 변경.
    3. UI 렌더링:
      • 로딩 상태: "데이터를 불러오는 중입니다..." 표시.
      • 에러 발생: 에러 메시지 표시.
      • 데이터 없음: "표시할 장소가 없습니다." 표시.
      • 데이터 존재: 장소 목록 렌더링.
  • 네트워크 스로틀링 테스트

    • Chrome 개발자 도구 > 네트워크 > 속도 > Slow 3G 선택.
    • 새로고침하여 로딩 상태 메시지와 데이터를 가져오는 동작 확인.
  • 결과

    • 로딩 중 메시지: 데이터를 가져오는 동안 "데이터를 불러오는 중입니다..." 표시.
    • 에러 처리: 데이터 fetch 실패 시 적절한 에러 메시지 출력.
    • 데이터 없음 처리: 장소 데이터가 없을 경우 "표시할 장소가 없습니다." 표시.
    • 데이터 출력: 장소 데이터를 성공적으로 가져오면 목록과 이미지를 렌더링.

212. HTTP 요청 보내기 / HTTP 에러 다루기

  • 목표
    1. HTTP 요청 실패 처리:
      • 네트워크 오류나 잘못된 엔드포인트로 인해 발생할 수 있는 오류를 처리.
      • 실패 원인에 따라 적절한 에러 메시지 출력.
    2. 세 가지 상태 관리:
      • 데이터 상태: 가져온 데이터를 저장.
      • 로딩 상태: 데이터를 가져오는 중임을 나타냄.
      • 에러 상태: 요청 실패 시 오류 메시지 표시.
    3. UI에서 에러 메시지 출력:
      • 사용자 경험 개선을 위해 에러 발생 시 사용자에게 명확히 알림.
import React, { useState, useEffect } from 'react';
import Error from './components/Error'; // Error 컴포넌트 추가

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string;
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 데이터 상태
  const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태
  const [error, setError] = useState<string | null>(null); // 에러 상태

  // 데이터 fetch
  useEffect(() => {
    const fetchPlaces = async () => {
      setIsFetching(true); // 로딩 시작
      setError(null); // 에러 초기화
      try {
        const response = await fetch('http://localhost:3000/placessss'); // 유효하지 않은 엔드포인트로 테스트
        if (!response.ok) {
          throw new Error('장소를 불러오는 데 실패했습니다. 다시 시도해주세요.');
        }
        const data: Place[] = await response.json();
        setPlaces(data); // 데이터 상태 업데이트
      } catch (err: any) {
        setError(err.message || '장소를 불러오는 중 오류가 발생했습니다.');
      } finally {
        setIsFetching(false); // 로딩 종료
      }
    };

    fetchPlaces();
  }, []);

  // 에러 상태 처리
  if (error) {
    return <Error title="에러가 발생했습니다!" message={error} />;
  }

  // 로딩 상태 처리
  if (isFetching) {
    return <p className="fallback-text">데이터를 불러오는 중입니다...</p>;
  }

  // 데이터 없음 처리
  if (!isFetching && places.length === 0) {
    return <p className="fallback-text">표시할 장소가 없습니다.</p>;
  }

  // 데이터 출력
  return (
    <div>
      <h2>Available Places</h2>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            <img
              src={`http://localhost:3000/images/${place.image}`}
              alt={place.name}
              width="100"
            />
            <p>{place.name}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default AvailablePlaces;
import React from 'react';

interface ErrorProps {
  title: string;
  message: string;
}

const Error: React.FC<ErrorProps> = ({ title, message }) => {
  return (
    <div className="error-container">
      <h2>{title}</h2>
      <p>{message}</p>
    </div>
  );
};

export default Error;

213. HTTP 요청 보내기 / Fetch된 데이터 변환

  • 사용자 위치 기반 장소 분류
    • 목표
      1. 사용자의 현재 위치 가져오기:
        • 브라우저의 navigator.geolocation.getCurrentPosition 메소드를 사용해 위치 정보 가져오기.
        • 위도와 경도를 사용하여 장소 데이터를 분류.
      2. 장소 분류:
        • 제공된 loc.js 파일의 sortPlacesByDistance 함수로 장소 데이터를 사용자 위치 기준으로 정렬.
      3. 로딩 상태 업데이트:
        • 위치 및 장소 데이터를 모두 가져온 후 isFetching을 false로 설정.
import React, { useState, useEffect } from 'react';
import Error from './components/Error'; // 에러 컴포넌트
import { sortPlacesByDistance } from './loc'; // 장소 분류 함수

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string;
  latitude: number; // 장소 위도
  longitude: number; // 장소 경도
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 데이터 상태
  const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태
  const [error, setError] = useState<string | null>(null); // 에러 상태

  useEffect(() => {
    const fetchPlaces = async () => {
      setIsFetching(true); // 로딩 시작
      setError(null); // 에러 초기화

      try {
        const response = await fetch('http://localhost:3000/places');
        if (!response.ok) {
          throw new Error('장소를 불러오는 데 실패했습니다. 다시 시도해주세요.');
        }
        const resData: { places: Place[] } = await response.json();

        // 사용자 위치 가져오기
        navigator.geolocation.getCurrentPosition(
          (position) => {
            const { latitude, longitude } = position.coords;

            // 장소 분류
            const sortedPlaces = sortPlacesByDistance(resData.places, latitude, longitude);
            setPlaces(sortedPlaces); // 데이터 상태 업데이트
            setIsFetching(false); // 로딩 종료
          },
          (geoError) => {
            setError('사용자 위치를 가져오는 데 실패했습니다. 위치 접근 권한을 확인해주세요.');
            setIsFetching(false); // 로딩 종료
          }
        );
      } catch (err: any) {
        setError(err.message || '데이터를 가져오는 중 오류가 발생했습니다.');
        setIsFetching(false); // 로딩 종료
      }
    };

    fetchPlaces();
  }, []);

  if (error) {
    return <Error title="에러가 발생했습니다!" message={error} />;
  }

  if (isFetching) {
    return <p className="fallback-text">데이터를 불러오는 중입니다...</p>;
  }

  if (places.length === 0) {
    return <p className="fallback-text">표시할 장소가 없습니다.</p>;
  }

  return (
    <div>
      <h2>Available Places</h2>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            <img
              src={`http://localhost:3000/images/${place.image}`}
              alt={place.name}
              width="100"
            />
            <p>{place.name}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default AvailablePlaces;
  • 주요 변경 사항

    1. 사용자 위치 가져오기:
      • navigator.geolocation.getCurrentPosition 메소드 사용:
      • 성공 시: 위도와 경도를 받아 sortPlacesByDistance 함수 호출.
      • 실패 시: 에러 메시지 설정(setError).
    2. 장소 분류:
      • sortPlacesByDistance 함수 사용:
      • 사용자 위치와 장소 데이터를 기반으로 장소를 정렬.
      • 정렬된 데이터를 places 상태로 업데이트.
    3. 로딩 상태 관리:
      • 장소 데이터를 fetch하거나 위치 정보를 가져오는 동안 isFetching을 true로 설정.
      • 모든 작업이 완료된 후 isFetching을 false로 설정.
  • 작동 흐름 요약

    1. HTTP 요청:
      • 장소 데이터를 백엔드에서 fetch.
    2. 위치 가져오기:
      • navigator.geolocation.getCurrentPosition 메소드로 사용자 위치 정보(위도, 경도) 가져오기.
    3. 장소 정렬:
      • 사용자 위치를 기준으로 장소 데이터를 sortPlacesByDistance 함수로 정렬.
    4. 상태 업데이트:
      • 정렬된 데이터를 places 상태에 저장.
      • 작업 완료 후 isFetching을 false로 설정.
    5. UI 렌더링:
      • 로딩 중: "데이터를 불러오는 중입니다..." 메시지 표시.
      • 에러 발생: 에러 메시지 출력.
      • 데이터 없음: "표시할 장소가 없습니다." 메시지 출력.
      • 데이터 있음: 정렬된 장소 목록 출력.

214. HTTP 요청 보내기 / 코드 추출 및 코드 구조 개선

  • 코드 정리 및 헬퍼 파일 추가
    • 목표
      1. HTTP 요청 코드 분리:
        • fetchAvailablePlaces라는 헬퍼 함수를 http.js에 작성해 HTTP 요청 코드를 분리.
        • 재사용 가능하고 가독성이 좋은 구조로 변경.
      2. AvailablePlaces 파일 정리:
        • HTTP 요청과 관련된 코드를 제거하고 헬퍼 함수 호출로 대체.
        • 핵심 로직에만 집중할 수 있도록 개선.
export async function fetchAvailablePlaces() {
  const response = await fetch('http://localhost:3000/places');

  if (!response.ok) {
    throw new Error('장소를 불러오는 데 실패했습니다. 다시 시도해주세요.');
  }

  const resData = await response.json();
  return resData.places; // 장소 데이터 반환
}
import React, { useState, useEffect } from 'react';
import Error from './components/Error'; // 에러 컴포넌트
import { sortPlacesByDistance } from './loc'; // 장소 분류 함수
import { fetchAvailablePlaces } from './http'; // 헬퍼 함수 import

// 장소 데이터 타입 정의
interface Place {
  id: string;
  name: string;
  image: string;
  latitude: number;
  longitude: number;
}

const AvailablePlaces: React.FC = () => {
  const [places, setPlaces] = useState<Place[]>([]); // 데이터 상태
  const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태
  const [error, setError] = useState<string | null>(null); // 에러 상태

  useEffect(() => {
    const fetchData = async () => {
      setIsFetching(true); // 로딩 시작
      setError(null); // 에러 초기화

      try {
        // 헬퍼 함수 호출
        const fetchedPlaces = await fetchAvailablePlaces();

        // 사용자 위치 가져오기
        navigator.geolocation.getCurrentPosition(
          (position) => {
            const { latitude, longitude } = position.coords;

            // 장소 분류
            const sortedPlaces = sortPlacesByDistance(fetchedPlaces, latitude, longitude);
            setPlaces(sortedPlaces); // 데이터 상태 업데이트
            setIsFetching(false); // 로딩 종료
          },
          (geoError) => {
            setError('사용자 위치를 가져오는 데 실패했습니다. 위치 접근 권한을 확인해주세요.');
            setIsFetching(false); // 로딩 종료
          }
        );
      } catch (err: any) {
        setError(err.message || '데이터를 가져오는 중 오류가 발생했습니다.');
        setIsFetching(false); // 로딩 종료
      }
    };

    fetchData();
  }, []);

  if (error) {
    return <Error title="에러가 발생했습니다!" message={error} />;
  }

  if (isFetching) {
    return <p className="fallback-text">데이터를 불러오는 중입니다...</p>;
  }

  if (places.length === 0) {
    return <p className="fallback-text">표시할 장소가 없습니다.</p>;
  }

  return (
    <div>
      <h2>Available Places</h2>
      <ul>
        {places.map((place) => (
          <li key={place.id}>
            <img
              src={`http://localhost:3000/images/${place.image}`}
              alt={place.name}
              width="100"
            />
            <p>{place.name}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default AvailablePlaces;
  • 변경 사항 요약

    1. http.js에서 fetchAvailablePlaces 함수 작성:
      • HTTP 요청 코드와 응답 검증 코드를 헬퍼 함수로 분리.
      • 응답 데이터(resData.places)만 반환하도록 설계.
    2. AvailablePlaces에서 HTTP 요청 코드 제거:
      • fetchAvailablePlaces 호출로 대체.
      • 필요한 데이터만 받아 사용하도록 단순화.
    3. 기능 유지:
      • 장소 데이터를 가져오고 사용자 위치를 기준으로 분류하는 기존 로직 유지.
  • 결과

    1. 코드 구조 개선:
      • HTTP 요청 코드가 http.js 파일로 분리되어 재사용 가능.
      • AvailablePlaces 파일은 로직에 집중할 수 있도록 정리.
    2. 작동 확인:
      • 장소 데이터가 올바르게 fetch되고 사용자 위치를 기준으로 정렬.
      • UI에 정상적으로 출력.

215. HTTP 요청 보내기 / POST 요청으로 데이터 전송

  • 사용자 장소 선택 기능 추가 및 백엔드 동기화
    • 목표
      1. 사용자 선택 장소 저장:
        • 사용자가 장소를 선택하면 해당 장소를 userPlaces 상태에 추가.
        • 선택한 장소를 백엔드에 PUT 요청으로 저장.
      2. 선택한 장소 가져오기:
        • 앱이 로드되면 사용자가 선택한 장소를 백엔드에서 fetch하여 userPlaces 상태를 초기화.
      3. HTTP 요청 관리 개선:
        • http.js에 updateUserPlaces 함수 추가하여 PUT 요청 구현.
        • 백엔드의 데이터 구조에 맞게 요청 데이터 형식을 조정.
// 사용자 선택 장소 업데이트
export async function updateUserPlaces(places) {
  const response = await fetch('http://localhost:3000/user-places', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ places }), // places 키로 객체를 감싸서 전달
  });

  if (!response.ok) {
    throw new Error('사용자 데이터 업데이트에 실패했습니다.');
  }

  const resData = await response.json();
  return resData; // { message: "Success" } 형태의 응답 반환
}
import React, { useState, useEffect } from 'react';
import { updateUserPlaces, fetchAvailablePlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces';

const App = () => {
  const [userPlaces, setUserPlaces] = useState([]); // 사용자 선택 장소
  const [error, setError] = useState(null);

  // 사용자 장소 업데이트
  const handleSelectPlace = async (selectedPlace) => {
    try {
      // 상태 업데이트
      setUserPlaces((prevUserPlaces) => {
        if (prevUserPlaces.some((place) => place.id === selectedPlace.id)) {
          return prevUserPlaces; // 이미 선택된 장소는 무시
        }
        return [...prevUserPlaces, selectedPlace];
      });

      // 백엔드로 업데이트 요청
      await updateUserPlaces([...userPlaces, selectedPlace]);
    } catch (err) {
      setError('장소를 업데이트하는 중 문제가 발생했습니다.');
      console.error(err);
    }
  };

  useEffect(() => {
    const fetchUserPlaces = async () => {
      try {
        const response = await fetch('http://localhost:3000/user-places');
        if (!response.ok) {
          throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
        }
        const resData = await response.json();
        setUserPlaces(resData.places || []);
      } catch (err) {
        setError('사용자 데이터를 불러오는 중 문제가 발생했습니다.');
      }
    };

    fetchUserPlaces();
  }, []);

  return (
    <div>
      <h1>사용자 선택 장소</h1>
      {error && <p>{error}</p>}
      <AvailablePlaces onSelectPlace={handleSelectPlace} />
      <h2>선택한 장소</h2>
      <ul>
        {userPlaces.map((place) => (
          <li key={place.id}>{place.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default App;
const AvailablePlaces = ({ onSelectPlace }) => {
  const [places, setPlaces] = useState([]);
  const [isFetching, setIsFetching] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setIsFetching(true);
      setError(null);

      try {
        const fetchedPlaces = await fetchAvailablePlaces();
        setPlaces(fetchedPlaces);
      } catch (err) {
        setError('장소 데이터를 불러오는 중 문제가 발생했습니다.');
      } finally {
        setIsFetching(false);
      }
    };

    fetchData();
  }, []);

  if (isFetching) return <p>데이터를 불러오는 중입니다...</p>;
  if (error) return <p>{error}</p>;

  return (
    <ul>
      {places.map((place) => (
        <li key={place.id} onClick={() => onSelectPlace(place)}>
          {place.name}
        </li>
      ))}
    </ul>
  );
};

export default AvailablePlaces;
  • 작동 흐름

    1. 앱 초기화:
      • 백엔드에서 GET /user-places 요청으로 사용자 선택 장소를 가져와 userPlaces 상태에 저장.
    2. 장소 선택:
      • 사용자가 장소를 클릭하면 handleSelectPlace 호출:
      • 선택된 장소를 userPlaces 상태에 추가.
      • updateUserPlaces를 호출해 백엔드에 PUT 요청으로 업데이트.
    3. 데이터 구조 관리:
      • places 배열을 백엔드 API에 JSON 형식으로 전달:
      • { "places": [ ... ] } 구조로 요청.
    4. UI 업데이트:
      • 선택된 장소는 화면에 즉시 추가되며, 새로고침 후에도 유지.
  • 장점

    • 코드 재사용:
      • http.js 파일의 헬퍼 함수로 HTTP 요청 로직 분리.
      • 동일한 요청 로직을 다른 컴포넌트에서도 활용 가능.
    • 데이터 동기화:
      • 사용자 선택 상태가 백엔드와 동기화되어 새로고침 후에도 유지.
    • 유지보수 용이성:
      • 각 기능이 모듈화되어 필요한 부분만 수정 가능.

216. HTTP 요청 보내기 / 최적의 업데이트 방법

  • 낙관적 업데이트와 에러 처리 구현
    • 목표
      1. 낙관적 업데이트:
        • HTTP 요청이 완료되기 전에 로컬 상태를 업데이트하여 UI를 즉시 반영.
        • 요청 실패 시 상태를 복구하여 이전 상태로 되돌림.
      2. 에러 처리:
        • 요청 실패 시 사용자에게 에러 메시지를 표시.
        • 모달을 사용하여 에러 메시지를 띄우고 닫을 수 있도록 구현.
import React, { useState, useEffect } from 'react';
import { updateUserPlaces, fetchAvailablePlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces';
import Modal from './components/Modal'; // 모달 컴포넌트
import Error from './components/Error'; // 에러 컴포넌트

const App = () => {
  const [userPlaces, setUserPlaces] = useState([]); // 사용자 선택 장소
  const [errorUpdatingPlaces, setErrorUpdatingPlaces] = useState(null); // 업데이트 에러 상태
  const [error, setError] = useState(null);

  // 사용자 장소 업데이트
  const handleSelectPlace = async (selectedPlace) => {
    const previousPlaces = [...userPlaces]; // 이전 상태 저장
    const updatedPlaces = [...userPlaces, selectedPlace];

    setUserPlaces(updatedPlaces); // 낙관적 업데이트

    try {
      // 백엔드로 업데이트 요청
      await updateUserPlaces(updatedPlaces);
    } catch (err) {
      setUserPlaces(previousPlaces); // 이전 상태로 복구
      setErrorUpdatingPlaces({
        message: err.message || '장소 업데이트에 실패했습니다.',
      });
    }
  };

  // 에러 닫기 핸들러
  const handleError = () => {
    setErrorUpdatingPlaces(null); // 에러 상태 초기화
  };

  useEffect(() => {
    const fetchUserPlaces = async () => {
      try {
        const response = await fetch('http://localhost:3000/user-places');
        if (!response.ok) {
          throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
        }
        const resData = await response.json();
        setUserPlaces(resData.places || []);
      } catch (err) {
        setError('사용자 데이터를 불러오는 중 문제가 발생했습니다.');
      }
    };

    fetchUserPlaces();
  }, []);

  return (
    <div>
      <h1>사용자 선택 장소</h1>
      {error && <p>{error}</p>}
      <AvailablePlaces onSelectPlace={handleSelectPlace} />
      <h2>선택한 장소</h2>
      <ul>
        {userPlaces.map((place) => (
          <li key={place.id}>{place.name}</li>
        ))}
      </ul>

      {/* 에러 모달 */}
      {errorUpdatingPlaces && (
        <Modal onClose={handleError}>
          <Error
            title="에러가 발생했습니다!"
            message={errorUpdatingPlaces.message}
            onConfirm={handleError}
          />
        </Modal>
      )}
    </div>
  );
};

export default App;
import React from 'react';

const Modal = ({ children, onClose }) => {
  return (
    <div className="modal">
      <div className="modal-content">
        {children}
        <button onClick={onClose}>닫기</button>
      </div>
    </div>
  );
};

export default Modal;
import React from 'react';

const Error = ({ title, message, onConfirm }) => {
  return (
    <div className="error-container">
      <h2>{title}</h2>
      <p>{message}</p>
      <button onClick={onConfirm}>확인</button>
    </div>
  );
};

export default Error;

217. HTTP 요청 보내기 / 데이터 삭제 (DELETE HTTP 요청)

  • 사용자 장소 제거 기능 추가 및 초기 로드 시 데이터 불러오기
    • 목표
      1. 장소 제거 기능:
        • 사용자가 선택한 장소 목록에서 장소를 클릭하면 제거.
        • 장소 제거 후 백엔드와 동기화하여 user-places.json에서 데이터 삭제.
      2. 초기 로드 시 사용자 장소 불러오기:
        • 앱이 로드될 때 백엔드에서 userPlaces 데이터를 fetch하여 상태를 초기화.
import React, { useState, useEffect, useCallback } from 'react';
import { updateUserPlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces';
import Modal from './components/Modal'; // 모달 컴포넌트
import Error from './components/Error'; // 에러 컴포넌트

const App = () => {
  const [userPlaces, setUserPlaces] = useState([]); // 사용자 선택 장소
  const [errorUpdatingPlaces, setErrorUpdatingPlaces] = useState(null); // 업데이트 에러 상태
  const [error, setError] = useState(null); // 데이터 fetch 에러 상태

  // 장소 제거 핸들러
  const handleRemovePlace = useCallback(
    async (placeId) => {
      const previousPlaces = [...userPlaces]; // 이전 상태 저장
      const updatedPlaces = userPlaces.filter((place) => place.id !== placeId); // 제거 후 상태

      setUserPlaces(updatedPlaces); // 낙관적 업데이트

      try {
        await updateUserPlaces(updatedPlaces); // 백엔드 동기화
      } catch (err) {
        setUserPlaces(previousPlaces); // 이전 상태로 복구
        setErrorUpdatingPlaces({
          message: err.message || '장소를 삭제하는 데 실패했습니다.',
        });
      }
    },
    [userPlaces] // userPlaces 상태가 변경될 때 함수 재생성
  );

  // 에러 닫기 핸들러
  const handleError = () => {
    setErrorUpdatingPlaces(null); // 에러 상태 초기화
  };

  useEffect(() => {
    // 초기 로드 시 사용자 장소 불러오기
    const fetchUserPlaces = async () => {
      try {
        const response = await fetch('http://localhost:3000/user-places');
        if (!response.ok) {
          throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
        }
        const resData = await response.json();
        setUserPlaces(resData.places || []);
      } catch (err) {
        setError('사용자 데이터를 불러오는 중 문제가 발생했습니다.');
      }
    };

    fetchUserPlaces();
  }, []);

  return (
    <div>
      <h1>사용자 선택 장소</h1>
      {error && <p>{error}</p>}
      <AvailablePlaces onSelectPlace={(place) => handleSelectPlace(place)} />
      <h2>선택한 장소</h2>
      <ul>
        {userPlaces.map((place) => (
          <li key={place.id} onClick={() => handleRemovePlace(place.id)}>
            {place.name}
          </li>
        ))}
      </ul>

      {/* 에러 모달 */}
      {errorUpdatingPlaces && (
        <Modal onClose={handleError}>
          <Error
            title="에러가 발생했습니다!"
            message={errorUpdatingPlaces.message}
            onConfirm={handleError}
          />
        </Modal>
      )}
    </div>
  );
};

export default App;
// 사용자 선택 장소 업데이트
export async function updateUserPlaces(places) {
  const response = await fetch('http://localhost:3000/user-places', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ places }), // places 키로 객체를 감싸서 전달
  });

  if (!response.ok) {
    throw new Error('사용자 데이터 업데이트에 실패했습니다.');
  }

  return await response.json(); // { message: "Success" } 형태의 응답 반환
}
  • 작동 흐름 요약

    1. 초기 로드:
      • useEffect에서 GET /user-places 요청으로 사용자 장소를 fetch.
      • userPlaces 상태 초기화.
    2. 장소 제거:
      • 사용자가 장소를 클릭하면 handleRemovePlace 실행.
      • 로컬 상태에서 장소 제거(낙관적 업데이트).
      • updateUserPlaces로 백엔드에 변경사항 동기화.
    3. 요청 실패:
      • catch 블록에서 이전 상태로 복구.
      • 에러 모달로 사용자에게 실패 알림.
    4. 에러 모달:
      • 모달에서 "닫기" 버튼 클릭 시 에러 상태 초기화.
  • 장점

    • 낙관적 업데이트:
      • UI가 즉각적으로 반응하여 사용자 경험 향상.
    • 에러 처리:
      • 요청 실패 시 상태 복구 및 사용자 알림으로 신뢰도 유지.
    • 유지보수 용이성:
      • HTTP 요청, 에러 처리, 상태 관리를 분리하여 코드 가독성 및 확장성 개선.

218. HTTP 요청 보내기 / 실습: 데이터 가져오기

  • 최종 구현: 장소 추가, 삭제, 저장, 그리고 초기 데이터 로딩
    • 최종 기능
      1. 사용자 장소 로드:
        • 앱 로드 시 사용자가 저장한 장소를 fetch하여 상태를 초기화.
        • 로딩 상태 및 에러 상태 관리로 사용자 경험 개선.
      2. 장소 추가 및 삭제:
        • 장소를 선택하여 추가하거나 선택된 장소를 클릭하여 삭제.
        • 변경사항을 백엔드와 동기화.
      3. 에러 처리:
        • 데이터 로드 또는 업데이트 실패 시 사용자에게 에러 메시지 표시.
        • 로딩 중 로딩 메시지를 표시.
import React, { useState, useEffect } from 'react';
import { fetchUserPlaces, updateUserPlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces'; // 장소 선택 컴포넌트
import Modal from './components/Modal'; // 모달 컴포넌트
import Error from './components/Error'; // 에러 컴포넌트

const App = () => {
  const [userPlaces, setUserPlaces] = useState([]); // 사용자 장소
  const [isFetching, setIsFetching] = useState(false); // 로딩 상태
  const [error, setError] = useState(null); // 에러 상태
  const [errorUpdatingPlaces, setErrorUpdatingPlaces] = useState(null); // 업데이트 에러 상태

  // 사용자 장소 로드
  useEffect(() => {
    const fetchPlaces = async () => {
      setIsFetching(true);
      setError(null);

      try {
        const places = await fetchUserPlaces(); // 헬퍼 함수로 장소 가져오기
        setUserPlaces(places);
      } catch (err) {
        setError({
          message: err.message || '사용자 장소를 불러오는 데 실패했습니다.',
        });
      } finally {
        setIsFetching(false);
      }
    };

    fetchPlaces();
  }, []);

  // 장소 추가
  const handleSelectPlace = async (selectedPlace) => {
    const previousPlaces = [...userPlaces];
    const updatedPlaces = [...userPlaces, selectedPlace];

    setUserPlaces(updatedPlaces); // 낙관적 업데이트

    try {
      await updateUserPlaces(updatedPlaces); // 백엔드 업데이트
    } catch (err) {
      setUserPlaces(previousPlaces); // 상태 복구
      setErrorUpdatingPlaces({
        message: err.message || '장소를 추가하는 데 실패했습니다.',
      });
    }
  };

  // 장소 제거
  const handleRemovePlace = async (placeId) => {
    const previousPlaces = [...userPlaces];
    const updatedPlaces = userPlaces.filter((place) => place.id !== placeId);

    setUserPlaces(updatedPlaces); // 낙관적 업데이트

    try {
      await updateUserPlaces(updatedPlaces); // 백엔드 업데이트
    } catch (err) {
      setUserPlaces(previousPlaces); // 상태 복구
      setErrorUpdatingPlaces({
        message: err.message || '장소를 삭제하는 데 실패했습니다.',
      });
    }
  };

  // 에러 닫기
  const handleError = () => {
    setErrorUpdatingPlaces(null);
  };

  return (
    <div>
      <h1>사용자 선택 장소</h1>

      {/* 에러 메시지 */}
      {error && (
        <Error title="에러가 발생했습니다!" message={error.message} onConfirm={() => setError(null)} />
      )}

      {/* AvailablePlaces 컴포넌트 */}
      <AvailablePlaces onSelectPlace={handleSelectPlace} />

      {/* 선택된 장소 */}
      <h2>선택한 장소</h2>
      {isFetching ? (
        <p>데이터를 불러오는 중입니다...</p>
      ) : (
        <ul>
          {userPlaces.map((place) => (
            <li key={place.id} onClick={() => handleRemovePlace(place.id)}>
              {place.name}
            </li>
          ))}
        </ul>
      )}

      {/* 업데이트 에러 모달 */}
      {errorUpdatingPlaces && (
        <Modal onClose={handleError}>
          <Error
            title="에러가 발생했습니다!"
            message={errorUpdatingPlaces.message}
            onConfirm={handleError}
          />
        </Modal>
      )}
    </div>
  );
};

export default App;
// 사용자 선택 장소 가져오기
export async function fetchUserPlaces() {
  const response = await fetch('http://localhost:3000/user-places');
  if (!response.ok) {
    throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
  }
  const resData = await response.json();
  return resData.places || [];
}

// 사용자 선택 장소 업데이트
export async function updateUserPlaces(places) {
  const response = await fetch('http://localhost:3000/user-places', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ places }), // places를 키로 감싸서 전달
  });
  if (!response.ok) {
    throw new Error('사용자 데이터를 업데이트하는 데 실패했습니다.');
  }
  return await response.json();
}
  • 작동 흐름 요약
    1. 앱 초기화:
      • useEffect에서 fetchUserPlaces를 호출해 사용자 장소를 가져옴.
      • 로딩 상태(isFetching)와 에러 상태(error)로 사용자 경험 관리.
    2. 장소 추가:
      • handleSelectPlace 호출:
      • UI 즉시 업데이트(낙관적 업데이트).
      • 백엔드 업데이트 요청.
      • 요청 실패 시 이전 상태 복구 및 에러 메시지 표시.
    3. 장소 제거:
      • handleRemovePlace 호출:
      • UI 즉시 업데이트(낙관적 업데이트).
      • 백엔드 업데이트 요청.
      • 요청 실패 시 이전 상태 복구 및 에러 메시지 표시.
    4. 에러 처리:
      • 에러 메시지를 모달로 표시.
      • "닫기" 버튼 클릭 시 에러 상태 초기화.

219. 커스텀 리액트 Hook 빌드 / 초기 프로젝트

  • 핵심 내용
    1. 리액트 훅 복습
      • useState와 useEffect 같은 기본 훅에 대해 복습합니다.
      • 리액트 훅의 규칙을 되새기며 사용자 정의 커스텀 훅을 만드는 법을 학습합니다.
    2. 커스텀 훅 소개
      • 커스텀 훅이 필요한 이유를 배우고, 그 개념을 이해합니다.
      • 커스텀 훅을 만들어 사용하면서 코드 재사용성과 가독성을 높이는 방법을 학습합니다.
    3. 데이터 Fetch 섹션 복습
      • 이 섹션은 이전에 완료한 데이터 Fetch 프로젝트를 기반으로 진행됩니다.
      • 가짜 백엔드와 리액트 코드로 구성되어 있으며, 백엔드에 HTTP 요청을 보내 데이터를 가져옵니다.
      • 따라서 데이터 Fetch 섹션을 미리 완료하거나, 데이터 Fetch 과정에 대한 기본 이해가 필요합니다.

220. 커스텀 리액트 Hook 빌드 / "Hooks(훅)의 규칙" 복습 & 훅을 사용하는 이유

  • 리액트 훅의 두 가지 중요한 규칙

    1. 첫 번째 규칙:
      • 훅은 리액트 컴포넌트 함수 또는 다른 커스텀 훅 안에서만 사용할 수 있습니다.
      • 일반 함수나 컴포넌트 외부에서는 훅을 사용할 수 없습니다.
    2. 두 번째 규칙:
      • 훅은 조건문, 반복문, 중첩 함수 안에 위치할 수 없습니다.
      • 항상 동일한 순서로 호출되어야 하므로 이러한 제약이 필요합니다.
  • 커스텀 훅의 필요성 및 역할

    1. 왜 커스텀 훅이 필요한가?
      • 컴포넌트 간에 중복되는 로직(예: HTTP 요청, 상태 관리 등)을 재사용할 수 있습니다.
      • jsx를 반환하지 않는 로직(예: useEffect 호출)을 분리하여 재사용성을 높입니다.
    2. 일반 함수와의 차이점:
      • 단순히 로직을 일반 함수로 분리하면 훅(useState, useEffect 등)을 사용할 수 없습니다.
      • 훅은 컴포넌트 함수 내부 또는 다른 커스텀 훅에서만 사용할 수 있기 때문입니다.
      • 커스텀 훅은 이러한 제한을 극복하고, 재사용 가능한 상태 관리와 사이드 이펙트를 처리할 수 있습니다.
    3. 커스텀 훅의 작동 방식:
      • 커스텀 훅은 use로 시작하는 이름을 사용해 컴포넌트에서 호출할 수 있습니다.
      • 내부에서 다른 훅(useState, useEffect 등)을 사용하여 데이터를 처리하고, 컴포넌트에서 필요로 하는 데이터를 반환합니다.
      • 이를 통해 컴포넌트 내 로직을 분리하고 재사용성을 확보합니다.
  • 커스텀 훅을 활용한 예시

    • 예를 들어, 두 컴포넌트 App.jsx와 AvailablePlaces.jsx에서 HTTP 요청을 보내고, loading 및 error 상태를 관리하는 로직이 유사합니다.
    • 이 로직을 커스텀 훅으로 분리하면, 해당 훅을 호출해 같은 기능을 수행할 수 있습니다.
    • 이렇게 하면 코드 중복이 줄어들고, 유지보수성이 높아집니다.
[HTTP 요청 처리 로직 (useEffect)]
    ⬇
[두 컴포넌트에서 유사한 로직 발견]
    ⬇
[로직을 일반 함수로 분리 -> 문제 발생 (훅 사용 불가)]
    ⬇
[커스텀 훅으로 로직 재사용]
    ⬇
[컴포넌트 내부에서 커스텀 훅 호출 -> 데이터 및 상태 관리]

리액트 훅에는 두 가지 중요한 규칙이 있습니다.
첫 번째, 훅은 컴포넌트 함수 또는 다른 커스텀 훅 내부에서만 사용할 수 있습니다.
두 번째, 훅은 조건문, 반복문, 중첩 함수 내부에 위치하면 안 됩니다.

이 규칙을 바탕으로, 커스텀 훅은 컴포넌트 간 중복되는 로직을 재사용하는 데 필요한 도구입니다.
useEffect와 같은 훅 호출 로직을 분리하여 컴포넌트의 로직을 단순화하고, 유지보수성을 높일 수 있습니다.

일반 함수로 로직을 분리하면 훅의 제약 조건을 위반하여 올바르게 작동하지 않을 수 있습니다.
하지만 커스텀 훅은 이러한 제약을 준수하면서도, 다양한 컴포넌트에서 로직을 공유할 수 있도록 합니다.
결과적으로 컴포넌트 내부에서 상태 관리 및 데이터 처리를 효율적으로 수행할 수 있게 됩니다.

221. 커스텀 리액트 Hook 빌드 / 커스텀 Hooks(훅) 생성하기

  • 커스텀 훅 생성 및 활용 개념
    1. 폴더 및 파일 구조
      • src/hooks 폴더를 생성하여 훅 관련 파일을 관리합니다. (선택 사항)
      • 훅 파일 이름은 자유롭게 지정할 수 있지만, use로 시작하는 이름을 권장합니다.
        • 예: useFetch.js
    2. 훅의 이름 규칙
      • 함수 이름이 use로 시작해야 리액트에서 훅으로 인식됩니다.
      • 리액트는 use로 시작하는 함수에 훅 관련 규칙을 적용합니다.
        • 예: 잘못된 위치에서 호출 시 에러로 이를 감지.
      • 이름 규칙이 없으면 잘못된 사용으로 인해 앱이 먹통이 되거나 비정상 동작할 수 있습니다.
    3. 커스텀 훅의 역할
      • 특정 로직(예: useEffect로 HTTP 요청 처리, 상태 관리 등)을 재사용 가능한 형태로 분리합니다.
      • 이를 통해 컴포넌트를 간결하게 유지하고, 여러 컴포넌트에서 동일한 로직을 재사용할 수 있습니다.
    4. 커스텀 훅의 장점
      • 컴포넌트 분리: 컴포넌트에서 로직을 분리하여 더 읽기 쉽고 유지보수하기 쉽도록 만듭니다.
      • 재사용성: 여러 컴포넌트에서 동일한 훅을 호출하여 동일한 작업을 수행할 수 있습니다.
      • 내부 로직 관리: 내부적으로 상태를 관리하고 필요한 데이터를 반환할 수 있습니다.
    5. 커스텀 훅의 동작 방식
      • 기존 컴포넌트에서 사용하던 훅(useState, useEffect 등)을 커스텀 훅 안으로 옮깁니다.
      • 필요 시 데이터를 반환하여 컴포넌트에서 활용할 수 있게 합니다.
      • 결과: 컴포넌트가 더 간결해지고, 동일한 훅을 여러 컴포넌트에서 재사용할 수 있습니다.
[컴포넌트(App.jsx, AvailablePlaces.jsx 등)]
    ⬇
[중복된 로직 발견 (HTTP 요청, 상태 관리)]
    ⬇
[커스텀 훅 생성 (useFetch)]
    ⬇
[중복된 로직을 커스텀 훅으로 이동]
    ⬇
[컴포넌트에서 커스텀 훅 호출 -> 상태 및 데이터 관리]
    ⬇
[컴포넌트 간결화 및 로직 재사용성 향상]
import { useState, useEffect } from "react";

type FetchState<T> = {
  data: T | null;
  isLoading: boolean;
  error: string | null;
};

export const useFetch = <T>(url: string) => {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    isLoading: true,
    error: null,
  });

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error("데이터를 가져오는데 실패했습니다.");
        }
        const data = (await response.json()) as T;
        setState({ data, isLoading: false, error: null });
      } catch (error) {
        setState({ data: null, isLoading: false, error: (error as Error).message });
      }
    };

    fetchData();
  }, [url]);

  return state; // { data, isLoading, error }
};
import React from "react";
import { useFetch } from "./hooks/useFetch";

type User = {
  id: number;
  name: string;
  email: string;
};

const App: React.FC = () => {
  const { data, isLoading, error } = useFetch<User[]>("https://jsonplaceholder.typicode.com/users");

  if (isLoading) return <p>로딩 중...</p>;
  if (error) return <p>오류: {error}</p>;

  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name} - {user.email}</li>
      ))}
    </ul>
  );
};

export default App;
  • 설명
    1. useFetch 훅은 URL을 받아 데이터를 가져오고, 상태(isLoading, error, data)를 관리합니다.
    2. App 컴포넌트는 useFetch를 호출하여 데이터를 가져오고, 로딩/에러 상태에 따라 UI를 렌더링합니다.
    3. 여러 컴포넌트에서 동일한 useFetch를 호출하여 간편하게 데이터를 가져올 수 있습니다.

222. 커스텀 리액트 Hook 빌드 / 커스텀 Hooks(훅): State(상태) 관리 & 상태 값 반환

  • 커스텀 훅을 활용한 useFetch 구현 과정

    1. 상태 값 관리
      • useFetch 훅은 컴포넌트에서 필요로 하는 모든 상태 값(isFetching, data, error)을 관리합니다.
      • 상태를 초기화하고, HTTP 요청의 진행 상태와 결과를 관리합니다.
    2. 매개변수 활용
      • 매개변수로 fetchFn을 받아 다양한 HTTP 요청 로직에 재사용할 수 있습니다.
      • initialValue 매개변수를 추가해 초기 상태값을 지정할 수 있도록 유연성을 제공합니다.
    3. 상태 값 반환
      • 커스텀 훅에서 그룹화된 상태 값(isFetching, data, error)을 반환하여 컴포넌트에서 쉽게 사용할 수 있습니다.
      • 반환 시 객체 형태로 상태 값을 제공하여 구조 분해 할당을 통한 간결한 사용이 가능합니다.
    4. 재사용성과 유연성
      • useFetch 훅은 다양한 컴포넌트에서 호출할 수 있으며, 매개변수를 통해 특정 요청 또는 상태 관리를 유연하게 설정할 수 있습니다.
      • 초기값, 에러 메시지, fetch 함수 등을 매개변수로 받아 상황에 맞게 사용할 수 있습니다.
    5. 의존성 관리
      • fetchFn 같은 외부 의존성을 useEffect의 의존성 배열에 추가하여 최신 데이터를 항상 가져오도록 보장합니다.
  • 커스텀 훅 사용의 이점

    • 컴포넌트 내부 로직을 간결화하고 재사용 가능한 형태로 분리합니다.
    • 컴포넌트에 속한 상태값을 효율적으로 관리하여 리액트의 상태 관리 규칙을 준수합니다.
    • 동일한 상태 관리 로직을 여러 컴포넌트에서 공유할 수 있습니다.
[HTTP 요청과 상태 관리 필요]
    ⬇
[커스텀 훅 생성: useFetch]
    ⬇
[fetchFn 매개변수로 HTTP 요청 로직 정의]
    ⬇
[useState로 상태값 관리: isFetching, data, error]
    ⬇
[초기값 설정 (initialValue)]
    ⬇
[fetchFn 실행 -> 상태값 업데이트]
    ⬇
[그룹화된 상태값 객체로 반환]
    ⬇
[컴포넌트에서 useFetch 호출 및 사용]
import { useState, useEffect } from "react";

type FetchState<T> = {
  data: T | null;
  isFetching: boolean;
  error: string | null;
};

export const useFetch = <T>(
  fetchFn: () => Promise<T>,
  initialValue: T | null = null
) => {
  const [state, setState] = useState<FetchState<T>>({
    data: initialValue,
    isFetching: false,
    error: null,
  });

  useEffect(() => {
    const fetchData = async () => {
      setState((prev) => ({ ...prev, isFetching: true }));
      try {
        const data = await fetchFn();
        setState({ data, isFetching: false, error: null });
      } catch (error) {
        setState({ data: null, isFetching: false, error: (error as Error).message });
      }
    };

    fetchData();
  }, [fetchFn]);

  return state; // { data, isFetching, error }
};
import React from "react";
import { useFetch } from "./hooks/useFetch";

type Place = {
  id: number;
  name: string;
};

const App: React.FC = () => {
  const fetchPlaces = async (): Promise<Place[]> => {
    const response = await fetch("https://example.com/api/places");
    if (!response.ok) throw new Error("데이터를 가져오는데 실패했습니다.");
    return response.json();
  };

  const { data, isFetching, error } = useFetch<Place[]>(fetchPlaces, []);

  if (isFetching) return <p>로딩 중...</p>;
  if (error) return <p>오류: {error}</p>;

  return (
    <ul>
      {data?.map((place) => (
        <li key={place.id}>{place.name}</li>
      ))}
    </ul>
  );
};

export default App;
  • 설명
    1. useFetch
      • HTTP 요청 함수(fetchFn)와 초기값(initialValue)을 매개변수로 받아 상태값을 관리하고 반환합니다.
    2. 컴포넌트 사용
      • useFetch에서 반환된 상태값(data, isFetching, error)을 컴포넌트에서 활용하여 UI를 렌더링합니다.
    3. 재사용성
      • 다양한 HTTP 요청 함수를 fetchFn으로 전달하여 여러 컴포넌트에서 동일한 상태 관리 로직을 재사용할 수 있습니다.

223. 커스텀 리액트 Hook 빌드 / 커스텀 Hooks(훅)에서 중첩 함수 노출시키기

  • 상태 업데이트 문제 및 해결 방안

    1. 문제 정의
      • useFetch 훅에서 상태값(userPlaces)을 관리하고 있으므로, 컴포넌트에서 직접 상태를 업데이트할 수 없음.
      • 기존 handleSelectPlace, handleRemovePlace 같은 상태 업데이트 함수가 의도대로 작동하지 않음.
    2. 해결 방안
      • 상태 업데이트 함수 노출:
        • useFetch 훅에서 setFetchedData 상태 업데이트 함수를 반환하도록 변경.
        • 이를 통해 컴포넌트가 상태를 업데이트할 수 있게 함.
      • 사용 방법:
        • 컴포넌트에서 useFetch로부터 반환된 setFetchedData를 추출하고, 필요 시 별칭(setUserPlaces)을 지정하여 사용.
    3. 독립적인 상태 관리
      • 각 컴포넌트에서 호출된 useFetch는 독립적인 상태 스냅샷을 관리.
      • 한 컴포넌트의 상태값 변경은 다른 컴포넌트의 상태에 영향을 미치지 않음.
      • 이는 useState가 각 컴포넌트에서 독립적으로 작동하는 방식과 동일.
    4. 의존성 경고 해결
      • setUserPlaces를 의존성 배열에 추가하여 경고 제거.
      • 리액트는 상태 업데이트 함수가 불변임을 보장하므로 실제 동작에는 영향을 미치지 않음.
  • 커스텀 훅 사용의 결과

    • 상태 업데이트 가능:
      • handleSelectPlace, handleRemovePlace 같은 함수가 다시 작동 가능.
    • 컴포넌트 간 독립성 유지:
      • 각 컴포넌트는 독립된 상태 스냅샷을 유지.
    • 경고 제거:
      • 의존성 배열에 상태 업데이트 함수를 추가하여 완성도를 높임.
    • 코드 단순화:
      • useEffect 등 기존의 불필요한 코드를 제거하고 간결화.
[useFetch 훅 호출]
    ⬇
[isFetching, data, error, setFetchedData 반환]
    ⬇
[컴포넌트에서 setFetchedData 추출 및 별칭 지정]
    ⬇
[handleSelectPlace, handleRemovePlace 함수에서 setFetchedData 호출]
    ⬇
[상태 업데이트 및 UI 렌더링 반영]
    ⬇
[독립적 상태 스냅샷으로 다른 컴포넌트에 영향 없음]
import { useState, useEffect } from "react";

type FetchState<T> = {
  data: T | null;
  isFetching: boolean;
  error: string | null;
};

export const useFetch = <T>(
  fetchFn: () => Promise<T>,
  initialValue: T | null = null
) => {
  const [state, setState] = useState<FetchState<T>>({
    data: initialValue,
    isFetching: false,
    error: null,
  });

  useEffect(() => {
    const fetchData = async () => {
      setState((prev) => ({ ...prev, isFetching: true }));
      try {
        const data = await fetchFn();
        setState({ data, isFetching: false, error: null });
      } catch (error) {
        setState({ data: null, isFetching: false, error: (error as Error).message });
      }
    };

    fetchData();
  }, [fetchFn]);

  // 상태와 상태 업데이트 함수를 반환
  return {
    ...state,
    setFetchedData: (data: T) => setState((prev) => ({ ...prev, data })),
  };
};
import React, { useCallback } from "react";
import { useFetch } from "./hooks/useFetch";

type Place = {
  id: number;
  name: string;
};

const App: React.FC = () => {
  const fetchPlaces = async (): Promise<Place[]> => {
    const response = await fetch("https://example.com/api/places");
    if (!response.ok) throw new Error("데이터를 가져오는데 실패했습니다.");
    return response.json();
  };

  const { data, isFetching, error, setFetchedData } = useFetch<Place[]>(fetchPlaces, []);

  // 장소 선택 함수
  const handleSelectPlace = useCallback(
    (place: Place) => {
      if (data) {
        setFetchedData([...data, place]);
      }
    },
    [data, setFetchedData]
  );

  // 장소 삭제 함수
  const handleRemovePlace = useCallback(
    (id: number) => {
      if (data) {
        setFetchedData(data.filter((place) => place.id !== id));
      }
    },
    [data, setFetchedData]
  );

  if (isFetching) return <p>로딩 중...</p>;
  if (error) return <p>오류: {error}</p>;

  return (
    <div>
      <ul>
        {data?.map((place) => (
          <li key={place.id}>
            {place.name}
            <button onClick={() => handleRemovePlace(place.id)}>삭제</button>
          </li>
        ))}
      </ul>
      <button onClick={() => handleSelectPlace({ id: Date.now(), name: "새 장소" })}>
        장소 추가
      </button>
    </div>
  );
};

export default App;
  • 설명
    1. useFetch
      • 상태값(data, isFetching, error)과 상태 업데이트 함수(setFetchedData)를 반환.
    2. handleSelectPlace, handleRemovePlace
      • setFetchedData를 사용해 상태를 업데이트.
    3. 상태 독립성
      • 다른 컴포넌트에서 동일한 useFetch를 호출해도 상태값은 독립적으로 관리됨.
    4. 의존성 배열
      • setFetchedData를 useCallback 의존성 배열에 추가하여 경고를 제거.

224. 커스텀 리액트 Hook 빌드 / 다중 컴포넌트에서 커스텀 Hook(훅) 사용하기

해당 내용은 커스텀 훅(Custom Hook)을 활용해 컴포넌트 로직을 더욱 효율적으로 관리하는 방법을 설명하고 있다. 기존에는 각 컴포넌트마다 useEffectuseState를 이용해 데이터 로딩, 에러 상태 관리, 데이터 저장 등을 처리했다. 하지만 이는 중복된 로직이 발생할 수 있으며, 재사용성이 떨어질 수 있다.

  • 커스텀 훅을 통한 개선점:

    • 중복 제거 및 재사용성 극대화:
      • 여러 컴포넌트가 공통으로 필요한 로직(데이터 로딩, 에러 처리 등)을 별도의 커스텀 훅(useFetch)에 담아서 재사용할 수 있다.
      • 이렇게 하면 각 컴포넌트는 훅을 호출하여 필요한 값을 쉽게 받아와 사용할 수 있고, 로직을 반복 작성하지 않아도 된다.
    • 독립적인 상태 관리:
      • 한 컴포넌트가 커스텀 훅을 사용한다고 해서 다른 컴포넌트의 상태에 영향을 주지 않는다.
      • 예를 들어, App 컴포넌트와 AvailablePlaces 컴포넌트 모두 동일한 useFetch 훅을 사용하더라도, 각자의 훅 인스턴스는 독립적으로 상태를 유지하므로 한쪽의 상태 업데이트가 다른 쪽에 간섭하지 않는다.
  • 구체적인 예:

    • 원래 AvailablePlaces 컴포넌트는 useEffect와 useState를 통해 직접 데이터 로딩과 상태 관리를 담당했다.
    • 이제 useFetch라는 커스텀 훅을 이용해 fetchAvailablePlaces 함수와 초기값(빈 배열)을 입력하면, 로딩 상태(ifFetching), 오류 상태(error), 로딩된 데이터(fetchedData), 그리고 필요하다면 setFetchedData(이후 setAvailablePlaces로 명명) 등을 리턴받는다.
    • 기존에 AvailablePlaces에서 사용했던 상태 변수(availablePlaces)와 이를 변경하던 로직은 모두 useFetch를 통해 간결해진다. useEffect를 직접 작성하지 않아도 되고, fetchedData를 availablePlaces로 이름만 바꾸어 사용하면 된다.
    • 다른 컴포넌트에서 마찬가지로 useFetch를 재사용할 때, 서로 상태가 독립적이므로 충돌하지 않는다.

정리하자면, 커스텀 훅을 이용하면 로직 중복을 제거하고, 유지보수를 쉽게 하며, 컴포넌트를 더 효율적으로 구성할 수 있다.

[AvailablePlaces 컴포넌트] -- useFetch 훅 호출 --> [useFetch 훅 내부]
      (fetchAvailablePlaces 함수, 초기값)       (데이터 로딩, 에러 처리, 상태 관리)
                    |
                    v
            ifFetching, error, fetchedData, setFetchedData
                    |
                    v
       [AvailablePlaces 컴포넌트에서 state 대체]
  1. AvailablePlaces 컴포넌트는 useFetch 훅을 호출하며 fetchAvailablePlaces 함수와 빈 배열을 초기값으로 전달한다.
  2. useFetch는 내부에서 fetchAvailablePlaces를 이용해 데이터를 불러오고, 로딩 상태, 에러 상태, 로딩된 데이터를 관리한다.
  3. useFetch 훅은 관리된 상태들을 객체 형태로 반환한다.
  4. AvailablePlaces 컴포넌트는 반환된 데이터를 받아 기존 useState나 useEffect를 대체한다.
import { useState, useEffect } from 'react';

interface UseFetchReturnType<T> {
  ifFetching: boolean;
  error: Error | null;
  fetchedData: T;
  setFetchedData: React.Dispatch<React.SetStateAction<T>>;
}

function useFetch<T>(fetchFunction: () => Promise<T>, initialValue: T): UseFetchReturnType<T> {
  const [ifFetching, setIfFetching] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [fetchedData, setFetchedData] = useState<T>(initialValue);

  useEffect(() => {
    let isMounted = true;
    setIfFetching(true);

    fetchFunction()
      .then((data) => {
        if (isMounted) {
          setFetchedData(data);
          setIfFetching(false);
        }
      })
      .catch((err) => {
        if (isMounted) {
          setError(err);
          setIfFetching(false);
        }
      });

    return () => {
      isMounted = false;
    };
  }, [fetchFunction]);

  return { ifFetching, error, fetchedData, setFetchedData };
}

export default useFetch;
// 실제 데이터 패칭 로직은 상황에 맞게 구현
export async function fetchAvailablePlaces(): Promise<string[]> {
  const response = await fetch('/api/places');
  if (!response.ok) {
    throw new Error('Failed to fetch places');
  }
  const data: string[] = await response.json();
  return data;
}
import React from 'react';
import useFetch from './hooks/useFetch';
import { fetchAvailablePlaces } from './fetchAvailablePlaces';

export default function AvailablePlaces() {
  // useFetch 훅을 통해 로딩 상태, 오류 상태, 데이터 상태를 가져온다.
  const { ifFetching, error, fetchedData, setFetchedData } = useFetch<string[]>(fetchAvailablePlaces, []);

  // fetchedData를 availablePlaces로 이름 변경
  const availablePlaces = fetchedData;
  const setAvailablePlaces = setFetchedData;

  // 기존에 있던 useEffect 로직 중 거리 계산 관련 부분이 있다면, 추후 해당 로직에 맞게 재활용할 수 있음.
  // 예: const distanceCalculations = ... (일단 주석으로 남김)
  // useEffect(...) 로직은 이제 필요 없으므로 제거 가능

  if (ifFetching) return <div>로딩 중...</div>;
  if (error) return <div>오류 발생: {error.message}</div>;

  return (
    <div>
      <h1>사용 가능한 장소 목록</h1>
      <ul>
        {availablePlaces.map((place, index) => (
          <li key={index}>{place}</li>
        ))}
      </ul>
      {/* 이후 distance 계산 로직이나 관련 요소를 추가할 수 있음 */}
    </div>
  );
}
  • 위 예시는 간단한 형태지만, 여기서 보여주는 것은 다음과 같다:
    • useFetch 훅을 통해 데이터 로딩 로직을 중앙 집중화한다.
    • AvailablePlaces 컴포넌트는 이 훅으로부터 얻은 fetchedData를 availablePlaces라는 이름으로 사용하며, 로딩 상태나 오류 상태도 훅에서 제공받는다.
    • 결과적으로 컴포넌트 자체의 로직이 간결해지고, 다른 컴포넌트에서도 이 훅을 재사용할 수 있으며, 각 컴포넌트는 서로 독립적으로 상태를 관리할 수 있다.

225. 커스텀 리액트 Hook 빌드 / 유동성 있는 커스텀 Hooks(훅) 생성하기

이전에는 단순히 서버나 API로부터 장소 데이터를 가져오는(fetch) 함수(fetchAvailablePlaces)를 커스텀 훅(useFetch)에 전달하여 데이터를 관리하고 있었다. 이제는 여기에 정렬 기능을 추가해보는 과정을 다룬다. 핵심 아이디어는 다음과 같다.

  1. 정렬 기능 추가를 위한 함수 분리:

    • fetchSortedPlaces라는 별도의 함수를 만들어서 장소 데이터를 가져온 뒤, 사용자의 위치를 기반으로 해당 장소들을 거리 순으로 정렬한다.
    • 이 함수는 비동기(async) 함수이며 Promise를 반환한다.
  2. Promise 활용:

    • 사용자 위치를 가져오는 브라우저 API(예: navigator.geolocation.getCurrentPosition)는 콜백 패턴을 사용한다. 이를 Promise로 감싸면(new Promise((resolve, reject) => {...})), async/await 패턴이나 useFetch 훅에서 쉽게 사용할 수 있다.
    • 이처럼 기존의 콜백 기반 코드를 Promise 기반 비동기로 전환하는 것은 자바스크립트에서 흔히 쓰이는 패턴이다.
  3. 정렬 로직의 흐름:

    • fetchSortedPlaces 함수:
      1. 내부에서 fetchAvailablePlaces 함수를 호출해 장소 데이터를 가져온다.
      2. 브라우저의 위치 정보를 Promise를 통해 받아온다.
      3. 위치 정보를 바탕으로 가져온 장소들을 거리 순으로 정렬한다.
      4. 정렬된 장소 목록을 resolve(sortedPlaces)를 통해 Promise로 반환한다.
    • 이렇게 하면 useFetch 훅은 fetchSortedPlaces 함수를 전달받아 Promise를 반환하는 fetch 함수를 호출하게 되고, 결과적으로 거리 순으로 정렬된 장소 데이터를 받게 된다.
  4. 커스텀 훅 활용 장점 재확인:

    • useFetch 훅은 fetch 함수를 변경하지 않고도 동일한 로직(ifFetching, error, fetchedData)을 유지할 수 있다.
    • 이제 fetchSortedPlaces 함수는 장소를 가져오고 정렬하는 모든 로직을 책임진다.
    • AvailablePlaces 컴포넌트는 단순히 useFetch(fetchSortedPlaces, [])를 호출하기만 하면 정렬된 장소 데이터를 손쉽게 얻을 수 있다.
    • 상태 관리나 useEffect를 직접 구현할 필요 없어 컴포넌트가 더욱 깔끔해진다.
[AvailablePlaces 컴포넌트] 
           |
           V  useFetch 호출 (fetch 함수로 fetchSortedPlaces 전달)
[useFetch 훅] --- fetchSortedPlaces 호출 ---> [fetchSortedPlaces 함수]
                                                |
                                                V
                                   fetchAvailablePlaces + 사용자 위치 취득 + 정렬
                                                |
                                                V
                                          resolve(sortedPlaces)
                                                |
                                                V
[useFetch 훅: fetchedData에 sortedPlaces 저장]
           |
           V
[AvailablePlaces 컴포넌트: 정렬된 장소 데이터 사용]
// fetchAvailablePlaces.ts
export async function fetchAvailablePlaces(): Promise<{name: string; lat: number; lng: number;}[]> {
  const response = await fetch('/api/places');
  if (!response.ok) {
    throw new Error('Failed to fetch places');
  }
  const data: {name: string; lat: number; lng: number;}[] = await response.json();
  return data;
}
// fetchSortedPlaces.ts
import { fetchAvailablePlaces } from './fetchAvailablePlaces';

interface Place {
  name: string;
  lat: number;
  lng: number;
}

// 사용자 위치 정보를 Promise로 감싸는 함수(예: geolocation API)
function getUserPosition(): Promise<GeolocationPosition> {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject);
  });
}

export async function fetchSortedPlaces(): Promise<Place[]> {
  // 장소 데이터 가져오기
  const places = await fetchAvailablePlaces();

  // 사용자 위치 정보 가져오기(Promise로 감싸기)
  const position = await getUserPosition();
  const userLat = position.coords.latitude;
  const userLng = position.coords.longitude;

  // 단순 거리 계산(예: 피타고라스 공식)
  const sortedPlaces = places.sort((a, b) => {
    const distA = Math.sqrt((a.lat - userLat)**2 + (a.lng - userLng)**2);
    const distB = Math.sqrt((b.lat - userLat)**2 + (b.lng - userLng)**2);
    return distA - distB;
  });

  // 정렬된 장소 반환
  return sortedPlaces;
}
import { useState, useEffect } from 'react';

interface UseFetchReturnType<T> {
  ifFetching: boolean;
  error: Error | null;
  fetchedData: T;
}

function useFetch<T>(fetchFunction: () => Promise<T>, initialValue: T): UseFetchReturnType<T> {
  const [ifFetching, setIfFetching] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [fetchedData, setFetchedData] = useState<T>(initialValue);

  useEffect(() => {
    let isMounted = true;
    setIfFetching(true);

    fetchFunction()
      .then((data) => {
        if (isMounted) {
          setFetchedData(data);
          setIfFetching(false);
        }
      })
      .catch((err) => {
        if (isMounted) {
          setError(err);
          setIfFetching(false);
        }
      });

    return () => {
      isMounted = false;
    };
  }, [fetchFunction]);

  return { ifFetching, error, fetchedData };
}

export default useFetch;
// AvailablePlaces.tsx
import React from 'react';
import useFetch from './useFetch';
import { fetchSortedPlaces } from './fetchSortedPlaces';

export default function AvailablePlaces() {
  // fetchSortedPlaces를 이용하여 정렬된 장소 데이터 가져오기
  const { ifFetching, error, fetchedData: availablePlaces } = useFetch(fetchSortedPlaces, []);

  if (ifFetching) return <div>로딩 중...</div>;
  if (error) return <div>오류 발생: {error.message}</div>;

  return (
    <div>
      <h1>거리 기준 정렬된 장소 목록</h1>
      <ul>
        {availablePlaces.map((place, index) => (
          <li key={index}>{place.name}</li>
        ))}
      </ul>
    </div>
  );
}
  • fetchSortedPlaces 함수는 Promise를 반환하며, 내부에서 fetchAvailablePlaces로 장소를 가져오고, 사용자의 위치를 Promise로 받아와 거리 순으로 정렬한 뒤 결과를 resolve한다.
  • useFetch 훅은 이전과 동일한 인터페이스를 유지하면서도 정렬된 데이터까지 손쉽게 관리한다.
  • AvailablePlaces 컴포넌트는 로직이 단순화되고, 훅을 통해 거리 정렬된 장소 목록을 바로 얻어 화면에 렌더링한다.

이로써 커스텀 훅을 활용하는 장점(로직 재사용, 코드 단순화, 유지보수성 향상)이 더욱 명확히 드러난다.

226. 양식 및 사용자 입력 작업 / 소개

어떤 웹 애플리케이션을 만들든, 결국 사용자 입력을 처리하는 시점은 오게 된다. 양식(Form)과 입력값 검증(Validation)은 웹 개발에서 필수적인 주제이며, 이는 React를 사용하든, 다른 라이브러리나 프레임워크를 사용하든, 혹은 순수 HTML/CSS/JS만 사용하든 동일하게 중요한 부분이다.

  1. 양식 제출 관리와 사용자 입력 검증 이해하기:

    • 양식 제출 이벤트를 다루는 방법과 입력된 데이터의 적합성을 판단하는 로직을 구현하는 방법을 배운다.
    • 이는 단순히 값을 읽는 것이 아니라, 사용자가 원하는 형식의 데이터를 제대로 입력했는지 확인하는 과정을 포함한다.
  2. 브라우저 기본 검증 기능 활용:

    • 현대 브라우저들은 기본적으로 일부 검증 기능을 제공한다. 예를 들어, required 속성이나 type="email" 등을 통해 간단한 유효성 검사를 브라우저에서 알아서 처리한다.
    • 이를 적절히 활용하면 개발자가 직접 모든 검증 로직을 구현하는 부담을 덜 수 있다.
  3. React에서 가능한 커스텀 솔루션 제안:

    • 단순한 HTML 양식 검증을 넘어, React의 상태관리와 라이프사이클을 활용하면 더욱 세밀한 사용자 입력 처리와 커스텀 검증 로직을 적용할 수 있다.
    • 예를 들어, 사용자가 입력할 때마다 실시간으로 유효성 검증을 하고, UI에 피드백을 제공하는 등의 고급 기능을 손쉽게 구현할 수 있다.
사용자 입력 ──> HTML 양식(form)
      │                │
      │                └─> 브라우저 기본 검증 (required, type 등)
      │
      │ (제출 이벤트 발생)
      v
   React 컴포넌트에서 상태 관리
      │
      │─ 검증 로직(커스텀) 적용
      v
양식 제출 처리 로직(서버 전송/로컬 상태 업데이트 등)
  • 사용자가 양식에 값을 입력한다.
  • 브라우저가 기본 검증을 수행한다(예: 비어있는 값에 대해 경고 표시).
  • 제출 이벤트가 발생하면 React가 해당 값을 받아 커스텀 검증 로직을 실행하고, 이를 토대로 상태를 업데이트하거나 서버에 요청을 전송한다.
import React, { useState, FormEvent } from 'react';

interface FormData {
  email: string;
  password: string;
}

function isEmailValid(email: string): boolean {
  // 간단한 email 검증 예시 (실제 프로덕션에서는 더 정교한 검증 사용)
  return email.includes('@');
}

export default function SimpleForm() {
  const [formData, setFormData] = useState<FormData>({ email: '', password: '' });
  const [error, setError] = useState<string | null>(null);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    setError(null);
  };

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();

    // 브라우저 기본 검증 외에 커스텀 검증 로직 추가
    if (!isEmailValid(formData.email)) {
      setError('이메일 형식이 올바르지 않습니다.');
      return;
    }
    if (formData.password.length < 6) {
      setError('비밀번호는 6자 이상이어야 합니다.');
      return;
    }

    // 모든 검증 통과 시 서버 전송 또는 상태 업데이트 가능
    // 여기서는 단순히 console에 출력
    console.log('Form submitted:', formData);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label>
          이메일:
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            required // 브라우저 기본 검증 활용
          />
        </label>
      </div>
      <div>
        <label>
          비밀번호:
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            required // 브라우저 기본 검증 활용
          />
        </label>
      </div>

      {error && <p style={{color: 'red'}}>{error}</p>}

      <button type="submit">제출</button>
    </form>
  );
}
  • required 속성 등을 통해 브라우저 기본 검증을 활용할 수 있다.
  • isEmailValid 함수를 통한 간단한 커스텀 검증 로직을 추가했다.
  • 폼 제출 시(handleSubmit) 커스텀 검증을 수행한 뒤 문제가 없다면 콘솔에 결과를 출력한다. 실제로는 서버에 전송하거나 로컬/전역 상태를 업데이트하는 등의 작업을 수행할 수 있다.

이를 통해 양식 처리 과정, 브라우저 기본 검증 기능, 커스텀 검증 로직 적용 방법을 한 번에 살펴볼 수 있다.

227. 양식 및 사용자 입력 작업 / Forms 소개 및 까다로운 이유

양식(form)이 어려운 이유는 단순히 입력 필드의 집합이라는 정의에서 끝나지 않는다. 기본적으로 양식은 사용자의 입력을 받고, 이를 처리하며, 최종적으로 서버로 전송하거나 내부 로직으로 전달하는 중요한 요소이다. 여기에 "검증"이라는 과정이 더해지면 복잡성이 상승한다.

  • 양식의 기본 개념:

    • 양식은 입력 필드의 집합으로, 일반적으로 label과 함께 사용된다.
    • 이를 통해 사용자는 이메일, 비밀번호, 주소 등 다양한 형태의 데이터를 입력할 수 있다.
  • 양식의 두 가지 주요 목적:

    1. 제출 관리 및 데이터 추출:

      • 입력한 데이터를 상태(state)를 통해 관리하는 방법(양방향 바인딩)
      • ref를 이용해 DOM 요소로부터 값을 추출하는 방법
      • 브라우저가 제공하는 FormData 객체를 사용해 간편하게 필드 데이터를 얻는 방법 이 부분은 상대적으로 직관적이고 쉽다. 다양한 방법이 있지만 개념 자체는 단순하다.
    2. 데이터 검증(Validation):

      • 검증은 가장 까다로운 부분이다. 사용자가 부정확한 데이터(잘못된 이메일 형식, 너무 짧은 비밀번호 등)를 입력했을 때 적절한 시점에 오류 메시지를 노출하는 것은 UI/UX 상의 중요한 도전 과제이다.
        • 입력할 때마다 실시간 검증을 하면 오류가 너무 일찍 나타날 수 있다(아직 완성되지 않은 데이터인데도 사용자에게 경고).
        • 입력이 끝났을 때 검증하면 오류가 너무 오래 유지될 수 있다.
        • 제출할 때 한 번에 검증하면 오류가 너무 늦게 나타날 수 있다. 따라서 언제 검증을 수행할지(실시간, 포커스 아웃 시점, 제출 시점) 균형을 맞추는 것이 어렵다. 이번에 각 접근법을 비교하고, 상황에 맞는 최적의 절충안을 찾는 방법을 살펴보자.

요약하자면, 양식에서 데이터 추출과 제출 관리는 쉽지만, 검증 시점과 방법을 올바르게 결정하는 것이 양식 구현을 어렵게 만드는 핵심 요인이다.

사용자 입력 ──> 양식 필드(입력 요소) ──> 데이터 추출 (state, ref, FormData)
            │
            └─> 검증 로직 (입력 시점/포커스 아웃 시점/제출 시점?)
                 │
                 │─> 오류 메시지 조건부 표시
                 v
          최종 제출 (서버 전송 또는 내부 로직 처리)
  • 사용자는 양식 필드에 값을 입력한다.
  • 데이터 추출 단계에서는 값이 쉽게 가져올 수 있다. (쉬운 부분)
  • 검증 시점과 방식에 따라 오류 메시지를 언제, 어떻게 노출할지 결정해야 한다. (어려운 부분)
  • 최종적으로 검증을 통과한 데이터만 서버나 내부 로직에 전달된다.
import React, { useState, useRef } from 'react';

interface FormData {
  email: string;
  password: string;
}

function isEmailValid(email: string): boolean {
  return email.includes('@');
}

export default function SignupForm() {
  const [formData, setFormData] = useState<FormData>({ email: '', password: '' });
  const [error, setError] = useState<string | null>(null);

  const emailRef = useRef<HTMLInputElement>(null);
  const passwordRef = useRef<HTMLInputElement>(null);

  const handleBlur = (field: 'email' | 'password') => {
    const value = field === 'email' ? formData.email : formData.password;
    
    // 포커스를 벗어났을 때 검증
    if (field === 'email' && !isEmailValid(value)) {
      setError('이메일 형식이 올바르지 않습니다.');
    } else if (field === 'password' && value.length < 6) {
      setError('비밀번호는 6자 이상이어야 합니다.');
    } else {
      setError(null);
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    // 데이터 입력 중에는 실시간 검증X, 포커스 벗어날 때 검증
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // 제출 시점에 최종 검증
    if (!isEmailValid(formData.email)) {
      setError('이메일 형식이 올바르지 않습니다.');
      return;
    }
    if (formData.password.length < 6) {
      setError('비밀번호는 6자 이상이어야 합니다.');
      return;
    }
    // 검증 통과 시 서버 전송 로직 등 수행
    console.log('Form submitted:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>이메일:</label>
        <input
          type="email"
          name="email"
          ref={emailRef}
          value={formData.email}
          onChange={handleChange}
          onBlur={() => handleBlur('email')}
          required
        />
      </div>
      <div>
        <label>비밀번호:</label>
        <input
          type="password"
          name="password"
          ref={passwordRef}
          value={formData.password}
          onChange={handleChange}
          onBlur={() => handleBlur('password')}
          required
        />
      </div>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <button type="submit">가입하기</button>
    </form>
  );
}
  • 이 예시에서는 포커스 아웃(blur) 시점에 검증을 수행한다.
  • 이메일과 비밀번호 모두 입력 후 필드 밖을 클릭(포커스 아웃)하면 검증이 실행되며, 문제 있으면 오류 메시지가 표시된다.
  • 제출 시점에도 추가 검증을 통해 빈틈없는 데이터 검증을 한다.

이런 방식으로 검증 시점을 다양하게 조절하고, 사용자 경험을 개선하며, 최종적으로는 사용하는 앱과 사용자 요구사항에 맞는 최적의 검증 전략을 선택할 수 있다.

228. 양식 및 사용자 입력 작업 / Form 제출 다루기

HTML 양식(Form)에서 기본적으로 submit 버튼을 누르면 브라우저는 해당 양식의 데이터를 서버로 전송하고, 페이지를 새로고침한다. 이는 전통적인 웹 애플리케이션 패턴에서는 유용하지만, React 기반의 싱글 페이지 애플리케이션(SPA)에서는 불필요한 페이지 새로고침과 서버 요청을 야기하여 문제를 일으킨다.

  • 양식 제출을 관리하는 방법:
    1. 기본 제출 행동 방지:
      • 버튼의 type="button"으로 설정하기:
        • 이 경우 버튼은 더 이상 submit 동작을 하지 않고, 단순히 클릭 이벤트만 발생시킨다.
      • form 요소의 onSubmit 이벤트 처리하기:
        • form 요소에 onSubmit 핸들러를 지정하면, 내부 버튼(기본값 type="submit") 클릭 시 onSubmit 이벤트가 발생한다.
        • 여기서 event.preventDefault()를 호출해 브라우저의 기본 제출 동작을 막을 수 있다.
        • 이 방식은 양식 데이터 추출 및 검증 로직을 구현할 때 편리하다. 왜냐하면 onSubmit 이벤트 핸들러에서 바로 event 객체에 접근할 수 있고, preventDefault()로 기본 동작을 막은 뒤 React 로직(상태 업데이트, 데이터 검증, 서버 요청 등)을 자유롭게 수행할 수 있기 때문이다. 정리하자면, React 애플리케이션에서는 기본 HTML 양식 제출 방식(페이지 새로고침, 서버 전송)을 방지하고, preventDefault()를 통해 자체적인 데이터 처리 로직을 구현하는 것이 일반적이다. 이렇게 하면 페이지 리로딩 없이도 사용자 입력을 처리하고, 검증하고, 필요하다면 추후에 원하는 시점에 HTTP 요청을 보낼 수 있다.
(사용자) ──양식 제출 버튼 클릭──> [form onSubmit 이벤트] 
                             │  │
                             │  ├─ event.preventDefault() 호출로 기본 제출 방지
                             │
                             v
                     (React 로직 처리)
                     │    ┌──────────────────┐
                     │    │    상태 관리,    │
                     │    │   사용자 입력 검증 │
                     │    │  백엔드로 데이터 전송 │
                     │    └──────────────────┘
                     │
                     v
                   결과 반영 (UI 업데이트, 서버 응답 반영 등)

이 흐름에서 핵심은 preventDefault()로 기본 브라우저 동작을 막고, React가 주도적으로 모든 로직을 처리한다는 점이다.

import React, { FormEvent } from 'react';

export default function Login() {
  const handleSubmit = (event: FormEvent) => {
    event.preventDefault(); // 기본 제출(페이지 새로고침, 서버전송) 동작 방지
    console.log('Submitted!');
    // 여기서 상태 관리, 검증, 서버 요청 등의 로직을 추가할 수 있다.
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">사용자 이름:</label>
        <input type="text" id="username" name="username" required />
      </div>
      <div>
        <label htmlFor="password">비밀번호:</label>
        <input type="password" id="password" name="password" required />
      </div>
      <button type="submit">로그인</button>
      <button type="reset">리셋</button>
    </form>
  );
}
  • <form> 태그에 onSubmit={handleSubmit}를 지정하고, handleSubmit 함수 내부에서 event.preventDefault() 호출.
  • 페이지 리로딩 없이 "Submitted!" 메시지가 콘솔에 나타난다.
  • 차후에 이 위치에서 사용자가 입력한 값(username, password)을 추출하고, 검증 로직을 추가한 뒤, 비동기 요청(HTTP 요청) 등을 통해 백엔드 서버와 통신할 수 있다.

이렇게 하면 리액트 애플리케이션에서 양식을 다루는 기초 패턴을 확립하고, 이후 검증 로직이나 데이터 추출 로직을 점진적으로 적용할 수 있다.

229. 양식 및 사용자 입력 작업 / State(상태) & 일반 Handler로 사용자 입력 수집 및 관리

양식(Form) 데이터를 관리하는 과정에서 각 입력 필드마다 상태값을 개별적으로 관리할 수도 있고, 하나의 객체 상태에 여러 필드 값을 함께 담아 관리할 수도 있다.

  • 다양한 상태 관리 방법:

    1. 개별 상태 관리:
      • 각 입력 필드(예: email, password)에 대해 useState를 따로 사용하는 방법이다.
        • 장점: 직관적이고 단순하다.
        • 단점: 필드가 많아질수록 상태와 이벤트 핸들러가 많아져 관리가 복잡해진다.
    2. 하나의 객체 상태 관리:
      • 하나의 상태 객체(enteredValues)를 생성하고, 여기에 여러 입력 필드(예: email, password)를 속성으로 담는다.
        • 장점: 상태 업데이트 함수(setEnteredValues) 하나로 모든 입력 필드를 처리할 수 있다.
        • 단점: 초기 이해가 필요하지만, 많은 필드를 가진 대형 양식에서 코드량이 줄어들고 구조가 명확해진다.
  • 핵심 아이디어:

    • 하나의 핸들러 함수(handleInputChange)로 모든 입력 필드의 변화를 처리할 수 있다.
    • 이 함수는 이벤트 객체와 함께 어떤 필드를 업데이트할지에 대한 식별자(예: 'email', 'password')를 인자로 받아, 상태 객체를 업데이트한다.
    • 스프레드 연산자(...)를 사용하여 기존 상태를 복사한 뒤, 특정 필드만 업데이트하는 형태를 취한다.
  • 결과:

    • 여러 개의 관리 함수 대신, 하나의 일반화된 함수로 양식 내의 모든 입력 필드 값을 업데이트하고, handleSubmit 함수에서 객체 상태(enteredValues)를 한 번에 활용할 수 있다.
사용자 입력 (키보드 입력)
       └───> onChange 이벤트
                 └──> handleInputChange(필드 식별자, 이벤트)
                        └──> setEnteredValues((prevValues) => ({
                               ...prevValues,
                               [필드 식별자]: event.target.value
                             }))
                              │
                              v
                         enteredValues 상태 업데이트
                              │
                              v
                         handleSubmit 호출 시
                         console.log(enteredValues)
import React, { useState, FormEvent, ChangeEvent } from 'react';

interface EnteredValues {
  email: string;
  password: string;
}

export default function LoginForm() {
  const [enteredValues, setEnteredValues] = useState<EnteredValues>({
    email: '',
    password: '',
  });

  const handleInputChange = (field: keyof EnteredValues, event: ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value;
    setEnteredValues((prevValues) => ({
      ...prevValues,
      [field]: value,
    }));
  };

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault();
    console.log('Submitted Values:', enteredValues);
    // 여기서 enteredValues를 사용해 검증, 서버 전송 등 원하는 로직을 실행할 수 있다.
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          value={enteredValues.email}
          onChange={(e) => handleInputChange('email', e)}
          required
        />
      </div>
      <div>
        <label htmlFor="password">비밀번호</label>
        <input
          id="password"
          type="password"
          value={enteredValues.password}
          onChange={(e) => handleInputChange('password', e)}
          required
        />
      </div>
      <button type="submit">로그인</button>
    </form>
  );
}
  • enteredValues 상태에 모든 필드 값 저장
  • handleInputChange 하나로 모든 입력 필드 상태 업데이트
  • handleSubmit에서 enteredValues를 한 번에 접근 가능

이를 통해 복잡한 양식도 효율적으로 관리할 수 있게 되며, 추가 필드가 늘어나도 코드를 크게 복잡하게 만들지 않고 확장할 수 있다.

230. 양식 및 사용자 입력 작업 / Refs(참조)로 사용자 입력 수집

사용자 입력을 추적하는 또 다른 방법은 참조(Ref)를 활용하는 것이다. 이 방법은 useState를 통해 상태를 관리하는 대신, DOM 요소에 직접 접근하여 값을 얻어오는 방식이다.

  • Ref를 사용하는 방법:

    1. Ref 객체 생성:
      • useRef 훅을 사용하여 emailRef, passwordRef와 같은 참조 변수를 만든다.
    2. 입력 요소에 ref 속성 연결:
      • <input ref={emailRef} ... />처럼 입력 필드에 ref를 연결하면, 해당 Ref 객체는 해당 DOM 요소를 가리키게 된다.
    3. 값 추출:
      • handleSubmit 등의 이벤트 핸들러에서 emailRef.current.value를 호출하면, 현재 입력창에 입력된 값에 직접 접근할 수 있다.
      • 이는 브라우저 DOM API를 활용하는 방식으로, 별도의 상태나 onChange 핸들러 없이도 값 추출이 가능하다.
  • 장점:

    • 상태나 onChange 핸들러 없이도 쉽게 값 접근 가능
    • 코드량이 상대적으로 적을 수 있음
  • 단점:

    • 상태 기반 관리에 비해 입력값을 "깨끗하게" 재설정하거나 제어하는 데 불편
    • 복잡한 양식에서 많은 Ref를 관리하는 것은 번거로울 수 있음
    • DOM 요소에 직접 접근하기 때문에 React 철학(상태를 통한 데이터 흐름 관리)과 다소 어긋날 수 있음

정리하자면, Ref를 통한 입력값 추출 방법은 간단하지만, 대규모 양식이나 복잡한 상황에서는 오히려 관리가 어려울 수 있으며, React 철학과는 약간 거리가 있다.

입력 필드 (DOM 요소) <-- ref 속성 연결 --> Ref 객체 (emailRef, passwordRef)
                                     │
                                     v
                             handleSubmit 함수
                                     │
                                     v
                              emailRef.current.value
                              passwordRef.current.value

이 흐름을 통해서 입력값을 상태로 관리하지 않고도, 제출 시점에 DOM 요소로부터 직접 값을 가져올 수 있다.

import React, { useRef, FormEvent } from 'react';

export default function RefLogin() {
  const emailRef = useRef<HTMLInputElement>(null);
  const passwordRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault();

    const enteredEmail = emailRef.current?.value;
    const enteredPassword = passwordRef.current?.value;

    console.log('Entered Email:', enteredEmail);
    console.log('Entered Password:', enteredPassword);

    // 필요하다면 여기서 서버로 데이터 전송, 검증 로직 추가 등 수행
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" type="email" ref={emailRef} />
      </div>
      <div>
        <label htmlFor="password">비밀번호</label>
        <input id="password" type="password" ref={passwordRef} />
      </div>
      <button type="submit">로그인</button>
    </form>
  );
}
  • useRef로 DOM 요소에 대한 참조를 만든다.
  • onChange나 value 속성이 필요 없다.
  • 제출 시 ref.current.value를 통해 직접 DOM에서 값 추출 가능.

231. 양식 및 사용자 입력 작업 / FormData & 네이티브 브라우저 API로 값 채취

  • 복잡한 폼 처리의 배경

    • 단순한 로그인 폼(이메일, 비밀번호)과 달리, 회원가입(Signup) 폼은 더 많은 입력 필드(이름, 성, 이메일, 비밀번호, 체크박스 그룹, 셀렉트 박스 등)를 포함하는 경우가 많다.
    • 이러한 복잡한 폼에서 사용자가 입력한 값을 효율적으로 추출하고 관리하는 것은 중요하다.
  • 이벤트 핸들러와 FormData 활용

    • 폼 제출 이벤트를 처리하기 위해 handleSubmit 함수를 사용한다.
    • onSubmit 속성에 handleSubmit을 연결하고, 이벤트 객체(event)를 통해 폼 제출 시 발생하는 동작을 제어할 수 있다.
    • event.preventDefault()를 통해 폼이 전통적인 방식(페이지 리로드, HTTP 요청 전송)으로 제출되는 것을 막고, 자바스크립트에서 직접 처리할 수 있다.
  • FormData 객체란?

    • FormData는 브라우저에 내장된 생성자 함수로, 폼 요소에 입력된 값을 손쉽게 추출할 수 있는 객체를 생성한다.
    • 사용 방법:
      • const formData = new FormData(event.target);
      • event.target는 폼을 가리키며, 해당 폼 내부의 모든 입력값을 FormData 객체가 참조하게 된다.
  • 이름(name) 속성의 중요성

    • FormData는 폼 내부의 name 속성을 기준으로 값을 관리한다.
    • 모든 입력 요소(input, select, textarea 등)는 name 속성을 가져야 하며, 이를 통해 FormData는 해당 값에 접근할 수 있다.
  • FormData를 통한 값 추출

    1. 단일 값 추출 (formData.get('name'))
      • 예: const email = formData.get('email');
      • 특정 name 속성을 가진 입력 필드의 값을 얻는다.
    2. 여러 값 추출 (formData.getAll('name'))
      • 체크박스나 다중 선택이 가능한 셀렉트 박스 등, 같은 name을 공유하는 여러 값들을 배열로 얻는다.
      • 예: const selectedChannels = formData.getAll('acquisition');
    3. 입력값 전체 객체 변환 (Object.fromEntries(formData.entries()))
      • formData.entries()를 통해 [key, value] 형태의 쌍을 얻을 수 있고, 이를 Object.fromEntries()에 전달하여 모든 입력값을 객체 형태로 변환할 수 있다.
      • 예: const data = Object.fromEntries(formData.entries()); 다만, getAll로 추출해야 하는 복수 값(체크박스 그룹)인 경우 Object.fromEntries()만으로는 처리하기 어렵다. 이럴 때는 getAll()을 사용하여 별도로 추출한 후 data 객체에 병합할 수 있다.
  • 체크박스 그룹의 처리

    • 여러 개의 체크박스가 같은 name을 가질 경우, getAll('name')을 사용하면 선택된 모든 체크박스 값들의 배열을 얻는다.
    • 이 배열을 기존에 추출한 data 객체에 새로운 속성으로 추가해 최종적인 데이터 구조를 완성할 수 있다.
  • 정리

    • FormData를 사용하면 개별 stateref 없이도 복잡한 폼의 모든 입력값을 빠르게 얻을 수 있다.
    • get(), getAll(), 그리고 Object.fromEntries()를 적절히 활용하면 폼 데이터를 구조화된 객체 형태로 손쉽게 사용할 수 있다.
    • 다음 단계에서는 이렇게 얻은 폼 데이터를 검증하는 로직을 추가하는 방법을 배울 수 있다.
사용자 입력값 입력 ──> 폼(form)
                             │ (onSubmit 이벤트)
                             ▼
                       handleSubmit(event)
                             │
                             │ event.preventDefault()
                             ▼
                       new FormData(event.target) 생성
                             │
                             ▼
                        FormData 객체
               ┌────────────────────────┐
               │ get('name')            │
               │ getAll('name')         │
               │ entries()              │
               └────────────────────────┘
                             │
                             ▼
                   Object.fromEntries()
                             │
                             ▼
                    최종 데이터 객체(data)
                             │
                             └─> console.log(data) or 서버 전송, 상태 업데이트 등
import React, { FormEvent } from 'react';

interface SignupData {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  // 체크박스 그룹의 경우 문자열 배열
  acquisition?: string[];
}

const Signup: React.FC = () => {
  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);

    // 객체 변환 (체크박스 같은 복수값 제외)
    const data = Object.fromEntries(formData.entries()) as Omit<SignupData, 'acquisition'>;

    // 체크박스 그룹 처리
    const acquisitionChannels = formData.getAll('acquisition') as string[];

    // 최종 데이터 객체 생성
    const signupData: SignupData = {
      ...data,
      acquisition: acquisitionChannels
    };

    console.log(signupData);
    // TODO: 서버로 전송하거나 상태 관리 등에 활용
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>
          이름(First Name):
          <input type="text" name="firstName" required />
        </label>
      </div>
      <div>
        <label>
          성(Last Name):
          <input type="text" name="lastName" required />
        </label>
      </div>
      <div>
        <label>
          이메일(Email):
          <input type="email" name="email" required />
        </label>
      </div>
      <div>
        <label>
          비밀번호(Password):
          <input type="password" name="password" required />
        </label>
      </div>
      <div>
        <p>저희를 어떻게 찾으셨나요? (체크박스)</p>
        <label>
          <input type="checkbox" name="acquisition" value="SNS" />
          SNS
        </label>
        <label>
          <input type="checkbox" name="acquisition" value="SearchEngine" />
          검색엔진
        </label>
        <label>
          <input type="checkbox" name="acquisition" value="Friend" />
          지인추천
        </label>
      </div>
      <button type="submit">회원가입</button>
    </form>
  );
};

export default Signup;
  • Object.fromEntries(formData.entries())를 통해 대부분의 단일값 필드(firstName, lastName, email, password)를 객체로 변환한다.
  • 체크박스와 같이 여러 값을 가지는 필드는 formData.getAll() 메소드를 통해 배열 형태로 추출한 후, 최종 데이터 객체에 병합한다.
  • 이렇게 얻은 signupData 객체를 통해 추후 검증, 서버 전송, 또는 UI 상태 업데이트를 수행할 수 있다.

정리하자면, FormData를 사용하면 복잡한 폼에서 모든 입력값을 쉽게 추출하고 구조화할 수 있으며, 이를 통해 개발 효율을 높이고 유지보수를 쉽게 할 수 있다.

232. 양식 및 사용자 입력 작업 / Form 초기화하기

  • 폼 리셋에 대한 필요성

    • 사용자 입력값을 다루는 과정에서 특정 이벤트 후(예: 제출 후) 폼을 초기 상태로 되돌리고 싶을 때가 있다.
    • 폼을 초기 상태로 되돌리는 행위를 리셋(reset)이라 하며, 이는 사용자가 입력한 내용을 모두 초기값(대개 빈 문자열)으로 되돌리는 것을 의미한다.
  • 폼 리셋 방법

    1. HTML 속성 이용 (reset 타입 버튼)

      • <button type="reset"> 버튼을 사용하면 폼 내의 모든 입력창 값이 기본값(일반적으로 빈 값)으로 초기화된다.
      <form>
        <input type="text" name="email" />
        <button type="reset">리셋</button>
      </form>
      • 이 방식은 순수 HTML 기능을 활용하는 것으로, 추가적인 자바스크립트 코드 없이 폼 초기화를 처리할 수 있다.
    2. 상태(State) 관리 통한 리셋

      • 리액트에서 상태로 입력값을 관리한다면, 상태를 초기값으로 재설정(setState)하는 것만으로도 연결된 입력창들이 초기값으로 돌아간다.

      • 예를 들어, email과 password를 상태로 관리한다면

        setEnteredValues({ email: '', password: '' });
      • 이렇게 하면 상태를 사용해 제어되는 입력값들은 상태 변경에 따라 자동으로 빈 값으로 업데이트된다.

    3. 참조(Ref)를 통한 직접 DOM 값 초기화

      • ref를 사용해 특정 DOM 요소에 직접 접근한다면, ref.current.value = '' 형태로 직접 값을 초기화할 수도 있다.
      • 하지만 이는 리액트 철학(가상 DOM을 통한 상태 관리)과 어긋나므로 가능한 피하는 것이 좋다.
      • DOM 조작은 리액트가 제어하도록 두는 편이 바람직하며, 필요하다면 다른 방법(상태 관리나 폼 메소드)을 사용하는 것을 권장한다.
    4. Form 요소의 reset() 메소드 호출

      • 제출 이벤트 핸들러에서 event.target은 폼 요소를 가리킨다.
      • event.target.reset()을 호출하면 해당 폼이 초기 상태로 리셋된다.
      • 이 방법은 reset 타입의 버튼을 이용하는 것과 유사하며, 코드에서 명시적으로 폼을 리셋하고 싶을 때 사용한다.
      • 상태를 하나하나 빈 값으로 되돌리거나 참조를 이용해 DOM을 직접 조작하는 것보다 간편하고 깔끔하다.
  • 총정리

    • 폼 리셋을 위해서는 type="reset" 버튼 사용, 상태 초기화, event.target.reset() 호출 등의 방법을 사용할 수 있다.
    • 가능한 리액트의 상태 관리나 HTML 본연의 기능(reset 버튼, form.reset())을 활용하는 것이 권장된다.
    • 이런 리셋 방법을 활용하면 폼 제출 후 재입력을 위한 초기화, 잘못된 입력 시 재입력 등 다양한 상황에서 편리하다.
사용자 입력값 입력 ──> 폼(form)
                │
                │ (리셋 이벤트 혹은 특정 시점)
                ▼
        리셋 방법 선택:
        ┌─────────────────────────────────┐
        │1. <button type="reset"> 클릭    │
        │2. 상태 관리: setValues(...) 호출 │
        │3. DOM 접근(ref): ref.value = '' │
        │4. form.reset() 메소드 호출      │
        └─────────────────────────────────┘

                ▼
              폼 초기화
                │
                └─> 모든 입력값이 빈 상태 또는 초기값으로 돌아감

아래 예시는 React + TypeScript를 가정한 코드이다. 폼 제출 후 event.target.reset()을 통해 폼을 초기화하는 예시를 들어보았다. 또한 버튼을 통한 reset 방법과 상태를 통한 초기화 예시도 간략히 포함하였다.

import React, { useState, FormEvent } from 'react';

interface FormValues {
  name: string;
  email: string;
  password: string;
}

const Signup: React.FC = () => {
  const [values, setValues] = useState<FormValues>({
    name: '',
    email: '',
    password: ''
  });

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // 여기서 values를 서버로 전송하거나, 검증 로직 수행 가능

    // 방법1: form.reset() 이용
    (event.target as HTMLFormElement).reset();
    // 이 방법으로 form 요소 내 모든 입력값이 초기화됨

    // 방법2: 상태 초기화
    // setValues({ name: '', email: '', password: '' });
    // 상태를 통한 제어 컴포넌트라면 이 코드로도 모든 입력값 초기화 가능

    // 방법3: reset 타입 버튼 (JSX 내에 단순히 <button type="reset">을 사용)
    // 별도의 코드 없이도 폼 초기화 가능
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;
    setValues(prev => ({ ...prev, [name]: value }));
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>
          이름:
          <input type="text" name="name" value={values.name} onChange={handleChange} />
        </label>
      </div>
      <div>
        <label>
          이메일:
          <input type="email" name="email" value={values.email} onChange={handleChange} />
        </label>
      </div>
      <div>
        <label>
          비밀번호:
          <input type="password" name="password" value={values.password} onChange={handleChange} />
        </label>
      </div>
      <button type="submit">회원가입</button>
      <button type="reset">리셋</button>
    </form>
  );
};

export default Signup;
  • type="reset" 버튼 클릭 시 HTML의 내장 기능을 통해 폼이 초기화된다.
  • handleSubmit 내에서 (event.target as HTMLFormElement).reset()을 호출하면 JS로도 폼 전체를 쉽게 초기화할 수 있다.
  • 상태를 통한 제어형 컴포넌트라면 setValues를 초기 상태로 재설정함으로써 입력값을 비울 수도 있다.

요약하자면, 폼 리셋을 위해 HTML 속성, 리액트 상태, form.reset() 메소드 등 다양한 전략을 사용할 수 있으며, 상황에 따라 가장 직관적이고 유지보수하기 쉬운 방법을 선택하면 된다. 다음 단계인 폼 유효성 검증을 배우기 전, 폼 리셋 방법을 이해해두면 더 효율적인 폼 처리 로직을 구현할 수 있다.

233. 양식 및 사용자 입력 작업 / State(상태)로 매 키보드 입력마다 유효성 검사하기

아래 정리는, 입력 검증(유효성 검사) 전략, 특히 실시간 검증(매 키 입력 시 검증)과 그 한계점에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표로 데이터 흐름을 도식화하고, 타입스크립트 기반의 React 예시 코드를 첨부하였습니다.

  • 입력 검증(유효성 검사)이 필요한 이유

    • 사용자가 입력한 값이 우리가 기대하는 형식이나 규칙에 맞는지 확인하는 과정이 필요하다.
    • 예를 들어, 이메일 주소 입력창에 실제 이메일 형태(‘@’ 포함)가 아닌 값을 입력하면 에러 메시지를 표시해줘야 한다.
  • 실시간(매 키 입력) 검증

    1. 상태 기반 접근 필수성
    • FormData나 refs를 사용한 폼 데이터 추출은 주로 폼 제출 후에 데이터를 얻는다.
    • 실시간 검증은 사용자가 한 글자 입력할 때마다 값이 어떻게 변하는지 알아야 하므로, 입력값이 변경될 때마다 상태를 업데이트하고 이를 기반으로 즉각 검증하는 상태 기반 접근이 유용하다.
    1. 상태 변화 감지
    • 이메일 입력값이 바뀔 때마다 컴포넌트가 재렌더링되고, 이때 이메일이 유효한지(@ 문자 포함 여부 등) 검사한다.
    • 검사 결과를 통해 emailIsInvalid라는 불리언 값을 계산하고, 이를 UI에 반영해 사용자에게 실시간으로 피드백(오류 메시지) 제공.
  • 문제점 및 개선 여지

    • 초기 단계부터 바로 오류 메시지 표시: 사용자가 아직 아무런 입력을 제대로 하지 않았는데 오류 메시지가 등장하면 사용자 경험이 나빠질 수 있다.
    • 값을 모두 지웠을 때 오류 표시 안 되는 문제: 한번 유효한 값을 입력한 후 이를 모두 지우면 빈 값 상태에선 오류를 표시할지 말지 결정하기 어렵다.
    • 사용자 경험(UX) 측면의 고민 필요: 사용자에게 유효한 값 입력의 기회를 주고, 필요할 때만 에러를 표시하는 등, 적절한 검증 타이밍이 필요하다.

앞으로 배울 포커스 해제 시점에서의 검증(onBlur 이벤트 활용) 등을 통해 보다 자연스러운 사용자 경험을 제공할 수 있다.

사용자 입력 (키 입력 발생)
       │
       ▼
   상태 업데이트 (enteredValues.email 변경)
       │
       ▼ 재랜더링
컴포넌트 함수 재실행 ──> 유효성 검사(@ 문자 존재 여부 등)
       │                              
       ├─ 유효하면: emailIsInvalid = false
       │
       └─ 유효하지 않으면: emailIsInvalid = true
                              │
                              ▼
                         UI 업데이트
                    ┌───────────────────┐
                    │ 에러 메시지 표시  │
                    │ (CSS 클래스 적용) │
                    └───────────────────┘
  • 아래 코드는 React + TypeScript 환경에서의 간단한 예시이다.
    • 사용자의 이메일 입력 변화에 따라 실시간으로 유효성 검증을 수행.
    • @ 문자가 없으면 에러 메시지를 표시.
    • 향후 onBlur 등의 이벤트로 개선 가능.
import React, { useState, ChangeEvent } from 'react';

const StateLogin: React.FC = () => {
  const [enteredEmail, setEnteredEmail] = useState<string>('');

  // 이메일 유효성 검사 로직:
  // 1. 입력한 값에 '@'가 없으면 유효하지 않은 이메일로 판단.
  // 2. 다만, 입력값이 완전히 비어있을 때는 아직 사용자가 입력 중일 수 있으니 에러를 표시하지 않을 수도 있다.
  const emailIsInvalid = enteredEmail.trim() !== '' && !enteredEmail.includes('@');

  const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => {
    setEnteredEmail(event.target.value);
  };

  return (
    <form>
      <div className={`control ${emailIsInvalid ? 'control-error' : ''}`}>
        <label htmlFor="email">이메일</label>
        <input 
          type="email" 
          id="email" 
          value={enteredEmail} 
          onChange={handleEmailChange} 
        />
        {emailIsInvalid && (
          <p className="error-text">유효한 이메일 주소를 입력해 주십시오.</p>
        )}
      </div>
      <button type="submit">로그인</button>
    </form>
  );
};

export default StateLogin;
  • enteredEmail 상태를 통해 입력값을 실시간으로 추적.

  • emailIsInvalid 변수를 통해 입력값에 대한 유효성 판단:

    • 비어 있지 않고(enteredEmail.trim() !== '')
    • @ 문자가 없으면(!enteredEmail.includes('@')), 이메일이 유효하지 않다고 판단.
  • emailIsInvalidtrue일 때만 .control-error CSS 클래스 적용 및 에러 메시지 표시.

  • 이 접근 방식은 즉각적인 피드백 제공이 가능하지만, 사용자 경험 최적화를 위해 입력 완료 또는 포커스 해제 시점에서 검증하는 등의 추가 전략이 필요.

  • 요약:

    • 실시간 검증은 상태 관리 접근법을 통해 각 키 입력 시 입력값을 평가하고 즉각적으로 에러 메시지를 표시하는 방법이다.
    • 하지만 초기부터 에러를 표시하거나, 빈 값 상태에서 오류 처리 등 다양한 UX 문제가 발생할 수 있다.
    • 향후 포커스 해제나 제출 시점의 검증, 조건부 처리 로직 등을 통해 이 경험을 개선할 수 있다.

234. 양식 및 사용자 입력 작업 / Blur 상태시 입력 유효성 검사

아래 정리는, 포커스 해제(blur) 이벤트를 활용한 유효성 검증 전략 및 이를 개선하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 이용한 데이터 흐름 도식과 타입스크립트 기반의 React 예시 코드를 첨부하였습니다.

  • 포커스 해제(blur) 이벤트를 통한 검증

    1. blur 이벤트 활용
    • 사용자가 특정 입력창의 포커스를 잃는 순간(입력창 바깥을 클릭하거나 Tab키로 다른 곳으로 이동하는 경우)을 감지하여 유효성 검증을 수행할 수 있다.
    • onBlur 이벤트 핸들러를 설정하면 포커스 해제 시점을 정확히 파악할 수 있다.
    1. 입력 상호작용 상태 추적 (didEdit/didBlur)
    • 단순히 입력값뿐 아니라, 사용자가 해당 입력 필드에 실제로 상호작용을 했는지(포커스를 가졌었는지)를 상태로 관리한다.
    • 예를 들어 didEdit.email과 같은 상태를 두어, 사용자가 해당 입력창을 "건드린" 후 포커스를 잃었을 때만 오류 메시지를 표시하도록 할 수 있다.
    • 이를 통해 초기에는 오류 메시지를 표시하지 않다가, 사용자가 해당 입력 필드를 한 번이라도 포커스했다가 벗어난 뒤에만 유효성 검사를 진행하도록 제어할 수 있다.
  • UX 개선을 위한 추가 로직

    • 단순히 포커스 해제 시점에서만 검증을 하면, 사용자가 다시 수정하려고 키를 입력하는 순간에도 이전 오류 메시지가 계속 표시될 수 있다.
    • 이를 개선하기 위해:
      • 사용자가 다시 타이핑을 시작하면(didEdit를 false로 재설정) 오류 메시지를 숨긴다.
      • 즉, 사용자가 다시 값을 수정하는 순간 "새로운 기회"를 주어 오류 메시지가 사라지고, 이후 포커스 해제 시점에 다시 검증하여 오류 메시지를 보여준다.
    • 이 방식은 포커스 해제 검증, 실시간 검증, 그리고 상태 리셋을 적절히 조합하여 사용자 경험을 개선한다.
  • 정리

    • 포커스 해제 검증: 처음부터 오류 메시지를 보여주지 않고, 사용자가 실제로 해당 입력창과 상호작용한 후 포커스를 잃을 때만 오류를 표시한다.
    • 재타이핑 시 상태 리셋: 사용자가 다시 입력을 시작하면 오류 메시지를 숨기고, 다시 포커스를 잃을 때 유효성 검증을 재실행.
    • 이러한 전략을 통해 불필요한 오류 메시지 노출을 줄이고, 사용자에게 더 자연스러운 입력 경험을 제공할 수 있다.
사용자 입력 필드에 포커스 획득
       │
       ├─ (처음에는 오류 메시지 없음)
       │
       ▼ 사용자가 입력값 변경 (키 입력)
 입력값 상태 업데이트 ──> didEdit 상태 업데이트(사용자가 타이핑 시작 시 false로 리셋)
       │
       └─ 유효성 검증은 아직 비활성(입력 중이므로 오류 표시 안 함)

사용자가 포커스를 잃음(onBlur 이벤트 발생)
       │
       ▼
   didEdit 상태 = true (사용자가 이 필드를 건드림)
       │
       ├─ 입력값이 유효하지 않다면?
       │    └─ 오류 메시지 표시(emailIsInvalid = true)
       │
       └─ 입력값이 유효하다면?
            └─ 오류 메시지 표시 안 함(emailIsInvalid = false)

사용자가 다시 입력(키 입력) 시작
       │
       ▼
  didEdit 상태 리셋(= false) → 오류 메시지 숨김
       │
       └─ (유효한 값 입력할 때까지 오류 메시지 미표시)

다시 포커스 해제 시점에 유효성 검증 반복
       │
       └─ 조건에 따라 오류 메시지 표시 여부 업데이트
  • 아래 코드는 React + TypeScript 환경에서의 간단한 예시이다.
    • onBlur 이벤트로 포커스 해제 시 검증 진행
    • didEdit 상태를 통해 사용자가 필드를 건드렸는지 추적
    • 재타이핑 시 didEdit를 false로 리셋하여 오류 메시지를 숨기는 로직 추가
import React, { useState, ChangeEvent, FocusEvent } from 'react';

type InputKeys = 'email';

interface ValuesState {
  email: string;
}

interface DidEditState {
  email: boolean;
}

const BlurValidationForm: React.FC = () => {
  const [values, setValues] = useState<ValuesState>({ email: '' });
  const [didEdit, setDidEdit] = useState<DidEditState>({ email: false });

  // 이메일 유효성 검사 로직:
  // @문자가 없고, 사용자가 이 필드를 건드려(didEdit.email = true) 포커스를 잃은 상황에서만 오류 표시
  const emailIsInvalid = didEdit.email && !values.email.includes('@');

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
    // 사용자가 키 입력을 시작하면 didEdit를 false로 리셋해서, 오류 메시지 숨기기
    setDidEdit(prev => ({ ...prev, [name]: false }));
  };

  const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
    const { name } = e.target as HTMLInputElement;
    // 포커스 해제 시 didEdit를 true로 설정해, 이 필드를 건드렸음을 표시
    setDidEdit(prev => ({ ...prev, [name]: true }));
  };

  return (
    <form>
      <div className={`control ${emailIsInvalid ? 'control-error' : ''}`}>
        <label htmlFor="email">이메일</label>
        <input
          type="email"
          id="email"
          name="email"
          value={values.email}
          onChange={handleInputChange}
          onBlur={handleInputBlur}
        />
        {emailIsInvalid && (
          <p className="error-text">유효한 이메일 주소를 입력해주세요.</p>
        )}
      </div>
      <button type="submit">제출</button>
    </form>
  );
};

export default BlurValidationForm;
  • onBlur 이벤트 핸들러(handleInputBlur)를 통해 사용자가 필드에서 포커스를 벗어날 때 didEdit를 true로 변경한다.

  • emailIsInvalid는 didEdit.email이 true이며 @ 문자가 없을 때만 true가 된다.

  • onChange 이벤트에서 키 입력 시 didEdit.email을 false로 리셋하여 이전 오류 메시지를 숨기고, 새로운 시작 기회를 부여한다.

  • 이로써 사용자 경험을 개선:

    • 처음에는 오류 메시지 숨김
    • 필드 포커스 해제 시 검증 후 오류 메시지 표시
    • 다시 입력 시작 시 오류 메시지 숨김
    • 포커스 해제 시 다시 검증
  • 요약:

    • 포커스 해제 이벤트(onBlur)를 활용해 사용자에게 "입력을 마무리하고 다른 곳으로 이동했을 때" 검증하는 기회를 줄 수 있다.
    • 이때 didEdit 상태를 통해 사용자가 필드를 실제로 건드린 순간을 추적하여, 처음에는 오류를 숨기고, 포커스 해제 시점에서만 검증을 진행하고 오류를 표시한다.
    • 또한, 사용자가 다시 입력을 시작하면 오류 메시지를 숨겨 재입력 기회를 제공할 수 있다. 이러한 전략적 결합을 통해 더 직관적이고 사용자 친화적인 입력 검증 경험을 제공할 수 있다.

235. 양식 및 사용자 입력 작업 / Form 제출시 입력 유효성 검사

아래 정리는, 폼 유효성 검증을 양식 제출 시점에 수행하는 전략과 이로 인해 얻을 수 있는 단순성, 그리고 기존의 키 입력/포커스 기반 검증과 제출 기반 검증을 병행하는 필요성에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.

  • 제출 시점 유효성 검증이란?

    • 개념: 사용자가 입력을 완료한 뒤 "제출(submit)" 버튼을 눌렀을 때에만 유효성 검사를 실행하는 방법이다.
    • 장점:
      • 매 키 입력마다 상태를 업데이트하고 실시간으로 검증하는 복잡한 로직 불필요
      • 참조(ref)를 통해 입력값을 관리하는 경우, 별도로 상태 없이도 제출 시점에만 값을 얻어와 검증 가능
    • 한계:
      • 키 입력 즉시 피드백이나 포커스 해제 시 피드백보다 느리게 오류를 알릴 수 있음
      • 사용자가 입력 오류를 제출 직전까지 알지 못하므로 UX 측면에서 즉각성 부족
  • 구현 방식

    1. 참조(ref) 사용 시:
    • FormData나 상태 업데이트 없이, 제출 시 ref.current.value를 통해 입력값에 접근 가능
    • handleSubmit 함수 내에서 email.includes('@') 등의 조건으로 이메일 유효성 확인
    • 유효하지 않으면 상태(emailIsInvalid)를 true로 설정하여 오류 메시지 표시
    • 유효한 데이터가 제출되면 emailIsInvalid를 false로 재설정하여 오류 메시지 제거
    1. 다른 검증 방식과의 결합:
    • 이미 키 입력이나 포커스 해제 기반 검증을 구현했다 하더라도, 제출 시점 검증을 추가하는 것이 바람직할 수 있다.
    • 이유: 사용자가 검증 로직을 우회하고 직접 "제출"을 눌렀을 때 잘못된 데이터가 서버로 전송되는 것을 방지하기 위함
    • 즉, 최종 안전망 역할
  • 정리

    • 제출 시점 검증은 간단하고 코드량이 적은 방식이지만, 즉각적인 피드백 제공은 어렵다.
    • 키 입력, 포커스 해제 검증과 제출 검증을 적절히 혼합하면 보다 나은 사용자 경험을 제공하고, 잘못된 데이터가 넘어가는 것을 방지할 수 있다.
    • 어떤 전략을 선택할지는 애플리케이션의 요구사항과 원하는 사용자 경험에 따라 달라진다.
사용자 입력 ──> (참조 ref를 통한 값 보관) ──> 제출 버튼 클릭(onSubmit)
                                              │
                                              ▼
                                      handleSubmit 함수 실행
                                              │
                                  입력값(ref.value) 가져오기
                                              │
                                유효성 검사(이메일 '@' 포함 여부)
                                              │
                         ┌─────────────────────┴────────────────────┐
                         │                                          │
                  유효하지 않음                                    유효함
                         │                                          │
       setEmailIsInvalid(true)                                 setEmailIsInvalid(false)
         오류 메시지 표시                                      오류 메시지 미표시 (또는 제거)
                         │                                          │
                         └──────────────────────────────────────────┘

아래 코드는 React + TypeScript 환경에서 참조(ref)를 이용하고, 제출 시점에 유효성 검사를 수행하는 간단한 예시이다.

import React, { useRef, useState, FormEvent } from 'react';

const RefBasedLogin: React.FC = () => {
  const emailInputRef = useRef<HTMLInputElement>(null);
  const [emailIsInvalid, setEmailIsInvalid] = useState(false);

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault();

    const enteredEmail = emailInputRef.current?.value || '';
    // 이메일 유효성 검사: '@' 포함 여부 확인
    const emailIsValid = enteredEmail.includes('@');

    if (!emailIsValid) {
      // 유효하지 않은 경우 오류 상태 설정
      setEmailIsInvalid(true);
      // 유효하지 않은 상태에서 여기서 return 하여 이후 로직 중단
      return;
    }

    // 유효한 경우 오류 상태 초기화
    setEmailIsInvalid(false);

    // 여기서 서버 전송 로직(HTTP 요청) 또는 다음 단계 진행
    console.log('유효한 이메일 주소가 제출되었습니다:', enteredEmail);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div className={`control ${emailIsInvalid ? 'control-error' : ''}`}>
        <label htmlFor="email">이메일</label>
        <input type="email" id="email" ref={emailInputRef} />
        {emailIsInvalid && (
          <p className="error-text">유효한 이메일 주소를 입력해 주십시오.</p>
        )}
      </div>
      <button type="submit">로그인</button>
    </form>
  );
};

export default RefBasedLogin;
  • emailInputRef를 통해 이메일 필드 값을 제출 시점에 가져옴.

  • handleSubmit 내에서 email.includes('@') 조건으로 유효성 검사.

  • 유효하지 않다면 emailIsInvalid를 true로 두어 오류 메시지 표시.

  • 유효하다면 emailIsInvalid를 false로 두고 폼 제출 로직(예: 서버 전송) 진행.

  • 키 입력이나 포커스 해제 시점에서 검증하지 않고, 제출 시점에만 검증을 수행.

  • 요약:

    • 참조(ref) 기반 접근은 제출 시점 유효성 검증을 단순화한다.
    • 이 방식은 사용자가 최종적으로 제출을 시도하는 순간에만 유효성 검사를 수행하므로 구현이 쉽고 코드량이 적다.
    • 다만 즉각적인 피드백은 제공하기 어렵고, 필요하다면 다른 검증 방식(키 입력, 포커스 해제)과 병행하여 사용할 수 있다.
    • 이러한 전략들의 조합을 통해 앱의 요구사항과 사용자 경험에 맞는 최적의 검증 패턴을 형성할 수 있다.

236. 양식 및 사용자 입력 작업 / 내장된 검증 Props(속성)으로 입력 유효성 검사

아래 정리는, 브라우저가 제공하는 내장(form) 유효성 검사 속성(HTML5 빌트인 검증 기능)을 활용하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.

  • 브라우저 내장(form) 유효성 검사 기능 활용

    1. 기능 개요
    • 리액트나 자바스크립트 코드 없이도, HTML5에서 제공하는 required, type="email", minLength, pattern 등의 속성을 사용하면 브라우저가 자동으로 유효성 검사를 수행한다.
    • 사용자가 폼을 제출(submit)하려 할 때 브라우저가 이 속성들을 해석하고, 만약 조건이 충족되지 않는다면 기본적인 오류 메시지를 표시한다.
    1. 주요 속성 예시
    • required: 해당 입력필드가 비어있으면 제출 불가
    • type="email": 이메일 형식이 아닌 입력값에 대해 오류 메시지 표시(@ 포함 필요 등)
    • minLength (또는 maxLength): 입력값의 최소/최대 길이 제한
    • pattern: 정규식을 통해 특정 형식의 값만 허용
    1. 장점
    • 추가적인 자바스크립트/리액트 코드 없이 폼 검증 가능
    • 브라우저가 기본 제공하는 오류 메시지를 자동으로 표시
    • 최소한의 노력으로 간단한 유효성 검증 구현 가능
    1. 단점 및 고려 사항
    • 사용자 경험(UX)을 세밀히 제어하기 어렵다.
    • 기본 오류 메시지가 브라우저마다 다르며, 커스터마이징하려면 추가 코드 필요
    • 복잡한 검증 로직이 필요한 경우 한계가 있음
  • 정리

    • 브라우저 내장 유효성 검증 속성을 사용하면 간단한 검증을 손쉽게 구현할 수 있다.
    • 모든 입력필드에 required나 type, minLength, pattern 등을 적절히 지정하기만 하면 별도 코드 없이 오류 메시지 표시 및 검증 로직이 동작한다.
    • 복잡한 상황에서는 다른 검증 방법과 조합하거나 사용자 정의 검증 로직을 추가적으로 고려할 수 있다.
사용자 입력 ──> 폼 요소(입력 필드)
       │         │
       │         ├── required, type="email", minLength 등 속성 지정
       │         │
       │         ▼
       │    사용자 폼 제출 시도
       │
       ▼
브라우저 기본 검증 실행
       │
       ├─ 유효성 조건 만족 O → 폼 제출 진행
       │
       └─ 유효성 조건 불만족 X → 브라우저 오류 메시지 표시
                    (사용자에게 유효한 값 요구)

아래 예시는 React + TypeScript 환경에서 브라우저의 기본 유효성 검증을 활용하는 폼 예제이다. 상태 관리나 추가 검증 로직 없이, HTML 속성만으로 기본적인 검증을 수행한다.

import React from 'react';

const Signup: React.FC = () => {
  return (
    <form>
      <div className="control">
        <label htmlFor="firstName">이름(First Name)</label>
        <input 
          type="text" 
          id="firstName" 
          name="firstName" 
          required 
        />
      </div>

      <div className="control">
        <label htmlFor="email">이메일(Email)</label>
        <input 
          type="email" 
          id="email" 
          name="email" 
          required 
        />
      </div>

      <div className="control">
        <label htmlFor="password">비밀번호(Password)</label>
        <input 
          type="password" 
          id="password" 
          name="password" 
          required 
          minLength={6}
        />
      </div>

      <div className="control">
        <label htmlFor="acquisition">어떻게 알게 되었나요?(Select)</label>
        <select 
          id="acquisition" 
          name="acquisition" 
          required
        >
          <option value="">선택해 주세요</option>
          <option value="search">검색 엔진</option>
          <option value="sns">SNS</option>
          <option value="friend">지인 추천</option>
        </select>
      </div>

      <div className="control">
        <label>
          <input 
            type="checkbox" 
            name="terms" 
            required 
          />
          이용약관에 동의합니다.
        </label>
      </div>

      <button type="submit">회원가입</button>
    </form>
  );
};

export default Signup;
  • required: 해당 필드를 비우면 브라우저가 제출을 막고 기본 오류 메시지 표시.

  • type="email": 이메일 형식이 아니면 브라우저가 자동으로 오류 메시지 표시.

  • minLength={6}: 비밀번호 최소 길이를 6글자로 지정, 미달 시 오류 메시지.

  • select 및 checkbox에도 required 지정 가능.

  • 요약:

    • 브라우저 내장 유효성 검증 속성을 활용하면, 추가 코드 없이 HTML 속성만으로 기본적인 폼 검증을 구현할 수 있다.
    • 제출 시 브라우저가 자동으로 검증을 수행하고 오류 메시지를 표시하기 때문에 간단하고 편리하지만, 커스터마이징에는 한계가 있을 수 있다.
    • 상황에 따라 이 방식을 적절히 활용하거나 다른 검증 방법과 조합할 수 있다.

236. 양식 및 사용자 입력 작업 / 커스텀과 내장 검증 로직 혼합

아래 정리는 HTML5 빌트인 유효성 검사 기능과 커스텀 자바스크립트(리액트) 로직을 결합하여 유효성 검증을 강화하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 React 예시 코드를 첨부하였습니다.

  • 빌트인 유효성 검증과 커스텀 로직의 결합

    1. 브라우저 빌트인 검증 속성 활용
    • required, type="email", minLength 등 브라우저 내장 속성을 통해 기본적인 유효성 검사를 수행할 수 있다.
    • 제출 시 브라우저가 자동으로 검사하고 오류 메시지 표시.
    1. 추가 커스텀 로직 필요성
    • 빌트인 속성만으로는 해결하기 어려운 특정 검증 상황이 존재한다. 예: 비밀번호와 비밀번호 확인 필드 간의 값 일치 여부.
    • 이 경우, 직접 자바스크립트(또는 리액트의 상태 관리)를 통해 추가적인 검증을 수행할 수 있다.
    1. 구현 예: 비밀번호 일치 검사
    • 비밀번호 필드(password)와 비밀번호 확인 필드(confirm-password) 두 값이 일치하는지 확인하는 로직을 handleSubmit 내에서 추가.
    • 만약 일치하지 않으면 상태(passwordsAreNotEqual)를 true로 설정하고, 오류 메시지를 표시하며 제출을 중단(return)한다.
    1. 혼합 접근의 장점
    • 빌트인 검증으로 기본적인 형식 에러를 간단히 잡아냄.
    • 커스텀 로직으로 고유한 검증 요구사항(비밀번호 일치 등)을 충족.
    • 사용자 경험(UX)을 향상시키고 원하는 형태의 검증 흐름을 구현 가능.
  • 정리

    • 빌트인 유효성 검증과 커스텀 검증 로직을 조합하면, 최소한의 코드로도 강력하고 유연한 폼 검증을 구현할 수 있다.
    • 빌트인 검증으로 기본 형태를 잡고, 필요한 경우 추가적인 상태와 로직을 더해 맞춤형 검증을 수행하면 된다.
사용자 양식 입력 
       │
       └─> 브라우저 빌트인 검증 (required, type="email", minLength 등)
                │
                ├─ 기본 조건 충족 못할 시 브라우저 오류 메시지 표시
                │
                ▼ 기본 조건 충족 (빌트인 검증 통과)
양식 제출 버튼 클릭(onSubmit)
       │
       ▼
  handleSubmit 함수 실행
       │
       ├─ 커스텀 검증 로직 (예: password와 confirm-password 일치 여부 확인)
       │     ├─ 불일치 시: setPasswordsAreNotEqual(true), 제출 중단(return)
       │     └─ 일치 시: passwordsAreNotEqual(false)
       │
       └─ 모든 검증 통과 시: 데이터 처리(로그, 서버 전송 등)

아래 코드는 React + TypeScript 환경에서 빌트인 검증과 커스텀 로직을 조합한 회원가입 폼 예시이다.

import React, { useState, FormEvent } from 'react';

interface SignupData {
  firstName: string;
  email: string;
  password: string;
  'confirm-password': string;
  terms: string;
}

const EnhancedSignup: React.FC = () => {
  const [passwordsAreNotEqual, setPasswordsAreNotEqual] = useState(false);

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    const form = event.currentTarget;
    // 브라우저 빌트인 검증: form.checkValidity()를 통해 확인 가능
    // 이 예제에서는 브라우저가 submit 시 자동으로 오류 표시

    const formData = new FormData(form);
    const data: SignupData = {
      firstName: formData.get('firstName') as string,
      email: formData.get('email') as string,
      password: formData.get('password') as string,
      'confirm-password': formData.get('confirm-password') as string,
      terms: formData.get('terms') as string,
    };

    // 커스텀 검증: 비밀번호와 비밀번호 확인 일치 여부 검사
    if (data.password !== data['confirm-password']) {
      setPasswordsAreNotEqual(true);
      return; // 제출 중단
    }

    // 비밀번호 일치 시 오류 상태 초기화
    setPasswordsAreNotEqual(false);

    // 여기서 서버 전송 또는 다른 로직 수행
    console.log('유효한 데이터 제출:', data);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div className="control">
        <label htmlFor="firstName">이름(First Name)</label>
        <input 
          type="text" 
          id="firstName" 
          name="firstName" 
          required 
        />
      </div>

      <div className="control">
        <label htmlFor="email">이메일(Email)</label>
        <input 
          type="email" 
          id="email" 
          name="email" 
          required 
        />
      </div>

      <div className="control">
        <label htmlFor="password">비밀번호(Password)</label>
        <input 
          type="password" 
          id="password" 
          name="password" 
          required 
          minLength={6}
        />
      </div>

      <div className="control">
        <label htmlFor="confirm-password">비밀번호 확인(Confirm Password)</label>
        <input 
          type="password" 
          id="confirm-password" 
          name="confirm-password" 
          required 
          minLength={6}
        />
        {passwordsAreNotEqual && (
          <div className="control-error">
            비밀번호가 일치하지 않습니다.
          </div>
        )}
      </div>

      <div className="control">
        <label>
          <input 
            type="checkbox" 
            name="terms" 
            required 
          />
          이용약관에 동의합니다.
        </label>
      </div>

      <button type="submit">회원가입</button>
    </form>
  );
};

export default EnhancedSignup;
  • 브라우저 빌트인 검증으로 기본 형태(required, type="email", minLength 등)를 처리.

  • handleSubmit 내에서 추가 커스텀 검증(비밀번호 일치)을 수행.

  • 불일치 시 setPasswordsAreNotEqual(true)로 상태 업데이트 → 오류 메시지 표시 및 제출 중단.

  • 모든 조건 만족 시 서버 전송 혹은 다음 단계 진행 가능.

  • 요약:

    • 빌트인 검증 속성과 커스텀 검증 로직을 결합하면 기본적인 형식 검사는 브라우저가 알아서 처리하고, 특수한 검증 조건(비밀번호 일치 등)은 개발자가 추가 로직으로 구현할 수 있다.
    • 이로써 코드 양을 줄이면서도 유연하고 강력한 검증 로직을 갖춘 폼을 만들 수 있다.

237. 양식 및 사용자 입력 작업 / 재사용 가능한 입력 컴포넌트 구축 및 활용

아래 정리는 복잡한 입력 관리 로직과 유효성 검증 코드 중복을 줄이기 위해 재사용 가능한 커스텀 Input 컴포넌트를 도입하는 방법을 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.

  • 문제 상황

    • 기존 StateLogin.jsx 컴포넌트에서는 이메일, 비밀번호 등 여러 입력 필드에 대해 상태, 포커스 해제, 키 입력 검증 로직이 중복되어 있었다.
    • 입력 필드마다 비슷한 JSX 코드와 유효성 검증 로직이 반복되며, 코드가 장황해지고 유지보수가 어려워진다.
  • 해결 방법: 재사용 가능한 커스텀 Input 컴포넌트

    1. 커스텀 컴포넌트 생성
    • Input.jsx 파일에서 Input 컴포넌트를 만든다.
    • 이 컴포넌트는 라벨(label), id, name, type, value, onChange, onBlur, error 등 다양한 속성을 받아, 내부에서 공통된 JSX 구조를 렌더링한다.
    • 오류 상태(error)가 true일 경우, 특정 오류 메시지를 표시하도록 한다.
    1. StateLogin.jsx에서의 적용
    • 기존의 이메일, 비밀번호 등 입력 필드에 대한 JSX 코드를 모두 Input 컴포넌트를 통해 대체한다.
    • Input 컴포넌트에 필요한 props(label, type, name, id, value, onBlur, onChange, error) 등을 전달하면, 컴포넌트 내부에서 적절히 렌더링한다.
    • 이를 통해 동일한 구조의 입력 필드를 손쉽게 재사용할 수 있으며, 필요 시 오류 메시지만 교체하거나 다른 속성만 변경하는 등 유지보수가 용이해진다.
    1. 유효성 검증 로직 단순화
    • 유효성 검증(이메일 형식 체크, 비밀번호 길이 체크 등)은 StateLogin.jsx에서 상태와 로직으로 관리하되, 오류 여부를 Input 컴포넌트에 error props로 전달하기만 하면 된다.
    • 각 필드마다 별도의 JSX 반복 없이, Input 컴포넌트를 공통 기반으로 활용하며 로직 분리와 코드 간결성을 확보한다.
  • 정리

    • 커스텀 Input 컴포넌트 도입으로 중복 코드를 제거하고, 유지보수성과 가독성을 높일 수 있다.
    • 유효성 검증, 이벤트 핸들러, 값 전달 등의 로직은 상위 컴포넌트(StateLogin.jsx)에서 관리하면서, 하위 컴포넌트(Input.jsx)는 UI 렌더링에만 집중한다.
    • 이렇게 하면 다양한 폼 필드에 쉽게 적용하고 확장할 수 있는 패턴을 마련할 수 있다.
사용자 키 입력, 포커스 변화
       │
       ▼
   StateLogin.jsx
  (상태 관리, 유효성 검증)
       │
       │ props 전달: label, type, value, error 등
       ▼
   Input.jsx (커스텀 컴포넌트)
   ┌─────────────────────────────────────┐
   │ label, input, 에러 메시지 공통 구조   │
   │ props를 통해 전달받은 값에 따라 렌더링 │
   └─────────────────────────────────────┘
       │
       └─ 렌더링 결과: 재사용 가능한 입력 UI

아래 코드는 React + TypeScript 환경에서 Input 커스텀 컴포넌트와 이를 사용하는 StateLogin 컴포넌트를 구현한 간단한 예시이다.

// Input.tsx
import React, { ChangeEvent, FocusEvent } from 'react';

interface InputProps {
  label: string;
  id: string;
  error?: string; // 오류 메시지 (없으면 오류 없음)
  // 나머지 속성들: type, name, value, onChange, onBlur 등
  [key: string]: any;
}

const Input: React.FC<InputProps> = ({ label, id, error, ...rest }) => {
  return (
    <div className={`control ${error ? 'control-error' : ''}`}>
      <label htmlFor={id}>{label}</label>
      <input id={id} {...rest} />
      {error && <p className="error-text">{error}</p>}
    </div>
  );
};

export default Input;
// StateLogin.tsx
import React, { useState, ChangeEvent, FocusEvent } from 'react';
import Input from './Input';

const StateLogin: React.FC = () => {
  const [enteredValues, setEnteredValues] = useState({ email: '', password: '' });
  const [didEdit, setDidEdit] = useState({ email: false, password: false });

  // 유효성 검증 로직
  const emailIsInvalid = didEdit.email && !enteredValues.email.includes('@');
  const passwordIsInvalid = didEdit.password && enteredValues.password.trim().length < 6;

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setEnteredValues(prev => ({ ...prev, [name]: value }));
    // 키 입력 시 didEdit를 false로 reset하여 오류 메시지 숨기기
    setDidEdit(prev => ({ ...prev, [name]: false }));
  };

  const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
    const { name } = e.target;
    // 포커스 해제 시 didEdit를 true로 설정
    setDidEdit(prev => ({ ...prev, [name]: true }));
  };

  // 오류 메시지
  const emailErrorMsg = emailIsInvalid ? '유효한 이메일 주소를 입력해주십시오.' : '';
  const passwordErrorMsg = passwordIsInvalid ? '유효한 비밀번호를 입력하십시오.' : '';

  return (
    <form>
      <div className="control-row">
        <Input
          label="이메일"
          id="email"
          name="email"
          type="email"
          value={enteredValues.email}
          onChange={handleInputChange}
          onBlur={handleInputBlur}
          error={emailErrorMsg}
        />
        <Input
          label="비밀번호"
          id="password"
          name="password"
          type="password"
          value={enteredValues.password}
          onChange={handleInputChange}
          onBlur={handleInputBlur}
          error={passwordErrorMsg}
        />
      </div>
      <button type="submit">로그인</button>
    </form>
  );
};

export default StateLogin;
  • Input 컴포넌트는 공통적인 UI 구조(레이블, 인풋, 오류 메시지)를 정의.

  • StateLogin 컴포넌트에서는 상태와 유효성 검증 로직을 유지하면서, 각 필드를 Input 컴포넌트를 사용해 간결하게 렌더링.

  • 에러 메시지나 이벤트 핸들러를 props로 전달할 수 있으므로, 같은 구조를 가진 다른 입력 필드에도 쉽게 적용 가능.

  • 요약:

    • 커스텀 Input 컴포넌트를 활용하면 중복되는 코드 구조를 제거하고, 유지보수성과 확장성을 개선할 수 있다.
    • 상태 관리와 유효성 검증은 상위 컴포넌트에서 이루어지고, 하위 Input 컴포넌트는 UI 요소 렌더링에만 집중함으로써 코드가 더 명확하고 재사용성 높은 패턴을 형성할 수 있다.

238. 양식 및 사용자 입력 작업 / 유효성 검사(검증) 로직 아웃소싱

아래 정리는 유효성 검증 로직을 재사용 가능한 형태로 분리하고, 이를 여러 컴포넌트에서 활용하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.

  • 문제 상황

    • 기존 컴포넌트(StateLogin.jsx) 내에서 이메일, 비밀번호 등의 유효성 검증 로직이 하드코딩되어 있었다.
    • 이 검증 로직은 다른 컴포넌트(Signup.jsx 등)에서도 반복적으로 필요할 수 있고, 추후 수정 시 여러 곳에서 중복 수정이 필요하게 되어 유지보수성이 저하된다.
  • 해결 방법: 유틸리티 함수로 유효성 검증 로직 분리

    1. 유틸리티 함수 정의
    • util/validation.js 파일에 isEmail, isNotEmpty, hasMinLength 등과 같이 공통적으로 사용할 수 있는 검증 함수를 정의한다.
    • 예:
      • isEmail(value: string): boolean
      • isNotEmpty(value: string): boolean
      • hasMinLength(value: string, length: number): boolean
    1. 컴포넌트에서 유틸리티 함수 활용
    • StateLogin.jsx 등 필요하는 곳에서 import { isEmail, hasMinLength } from '../util/validation';와 같이 로딩.
    • 유효성 검사 시 직접 로직을 작성하지 않고, 해당 함수들에 값을 전달해 결과를 얻는다.
    • 예: emailIsInvalid = didEdit.email && !isEmail(enteredValues.email)
    • 예: passwordIsInvalid = didEdit.password && !hasMinLength(enteredValues.password, 6)
    1. 장점
    • 검증 로직이 한 곳에 집중 관리되어, 다른 컴포넌트에서도 쉽게 재사용 가능.
    • 추후 검증 로직 수정 시, 유틸리티 함수만 수정하면 전체 앱에 반영.
    • 코드 반복 감소 및 유지보수성 향상.
  • 정리

    • 유효성 검증 로직을 유틸 폴더의 독립된 함수로 분리함으로써 코드 중복을 제거하고 재사용성을 극대화할 수 있다.
    • 이 방식은 커스텀 컴포넌트 도입과 마찬가지로 관리하기 쉬운 코드를 작성하는 방법 중 하나이다.
    • 향후 더욱 복잡한 검증 요구사항에도 손쉽게 대응 가능하다.
사용자 입력 ──> StateLogin.jsx
       │            │
       │            ├─ didEdit 상태 검사
       │            ├─ enteredValues 확인
       │            ▼
       │         유틸 검증 함수 호출
       │         (isEmail, isNotEmpty, hasMinLength 등)
       │
       ├─ 검증 결과 반환(Boolean)
       │
       └─ 결과에 따라 emailIsInvalid, passwordIsInvalid 등 상태 설정
         │
         └─ 상태 기반으로 UI 업데이트(오류 메시지 표시/숨김)
export function isEmail(value: string): boolean {
  return value.includes('@');
}

export function isNotEmpty(value: string): boolean {
  return value.trim() !== '';
}

export function hasMinLength(value: string, length: number): boolean {
  return value.trim().length >= length;
}
import React, { useState, ChangeEvent, FocusEvent } from 'react';
import Input from './Input';
import { isEmail, hasMinLength } from '../util/validation';

const StateLogin: React.FC = () => {
  const [enteredValues, setEnteredValues] = useState({ email: '', password: '' });
  const [didEdit, setDidEdit] = useState({ email: false, password: false });

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setEnteredValues(prev => ({ ...prev, [name]: value }));
    // 키 입력 시 didEdit를 false로 reset
    setDidEdit(prev => ({ ...prev, [name]: false }));
  };

  const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
    const { name } = e.target;
    setDidEdit(prev => ({ ...prev, [name]: true }));
  };

  // 유효성 검증: 유틸 함수 활용
  const emailIsInvalid = didEdit.email && !isEmail(enteredValues.email);
  const passwordIsInvalid = didEdit.password && !hasMinLength(enteredValues.password, 6);

  const emailErrorMsg = emailIsInvalid ? '유효한 이메일 주소를 입력해주십시오.' : '';
  const passwordErrorMsg = passwordIsInvalid ? '유효한 비밀번호를 입력하십시오.' : '';

  return (
    <form>
      <div className="control-row">
        <Input
          label="이메일"
          id="email"
          name="email"
          type="email"
          value={enteredValues.email}
          onChange={handleInputChange}
          onBlur={handleInputBlur}
          error={emailErrorMsg}
        />
        <Input
          label="비밀번호"
          id="password"
          name="password"
          type="password"
          value={enteredValues.password}
          onChange={handleInputChange}
          onBlur={handleInputBlur}
          error={passwordErrorMsg}
        />
      </div>
      <button type="submit">로그인</button>
    </form>
  );
};

export default StateLogin;
  • validation.ts 내 함수들을 통해 유효성 검사 로직을 캡슐화하여 재사용.

  • StateLogin 컴포넌트는 검증 함수 호출만으로 유효성 판단 가능.

  • 검증 로직 변경 시 validation.ts만 수정하면 전체 앱에 반영.

  • 요약:

    • 유효성 검증 로직을 유틸 함수로 분리하면, 다른 컴포넌트에서도 검증 로직을 쉽게 재사용할 수 있고, 유지보수 시에도 한 곳만 수정하면 되므로 코드 품질과 개발 효율성을 높일 수 있다.
    • 이렇게 컴포넌트화, 유틸리티화된 로직을 적절히 결합함으로써 더 깔끔하고 재사용 가능한 코드를 작성할 수 있다.

239. 양식 및 사용자 입력 작업 / 커스텀 useInput Hook(훅) 생성

아래 정리는 커스텀 훅(useInput)을 활용해 입력값 및 유효성 검증 로직을 재사용 가능한 형태로 캡슐화하고, 컴포넌트 코드를 대폭 간결화하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.

  • 문제 상황

    • 기존 StateLogin.jsx에서는 각 입력 필드(이메일, 비밀번호)마다 상태 관리, 이벤트 핸들러, 유효성 검증 로직을 중복 작성하고 있었다.
    • 상태(enteredValues, didEdit) 및 이벤트 핸들러(handleInputChange, handleInputBlur)를 모든 입력 필드마다 반복 구현하면서 코드가 복잡해지고 확장성이 떨어졌다.
  • 해결 방법: 커스텀 훅(useInput) 도입

    1. 커스텀 훅의 개념
    • 커스텀 훅은 리액트 훅(useState, useEffect 등)을 기반으로 만든 재사용 가능한 함수이다.
    • 상태 관리 로직과 유효성 검증 로직을 훅으로 추출하면, 다양한 컴포넌트나 여러 개의 입력 필드에서 손쉽게 동일한 로직을 공유할 수 있다.
    1. useInput 훅 구현 전략
    • useInput 훅은 단일 입력 필드에 대한 상태(enteredValue), 수정 여부(didEdit), 유효성 검증 함수(validationFn), 그리고 이벤트 핸들러(handleInputChange, handleInputBlur)를 관리한다.
    • validationFn을 훅에 인자로 전달하면, 훅 내부에서 enteredValue를 이용해 유효성 검사 결과(valueIsValid)를 도출하고, 이를 기반으로 hasError 상태를 제공한다.
    • 컴포넌트는 단순히 useInput 훅을 호출해 value, hasError, handleInputChange, handleInputBlur 등을 받아 UI에 반영하고, hasError를 바탕으로 오류 메시지를 표시하거나 제출 로직을 제어할 수 있다.
    1. 장점
    • 코드 중복 감소: 각 입력 필드마다 동일한 로직을 반복 작성할 필요가 없다.
    • 유지보수성 향상: 유효성 검증 변경 시 훅 내부 함수만 수정하면 모든 입력 필드에 반영된다.
    • 유연성 증가: 다양한 유효성 검증 함수를 validationFn으로 전달함으로써 재사용성과 확장성을 확보한다.
  • 정리

    • 커스텀 훅을 사용하면 입력 필드 상태 관리 및 유효성 검증 로직을 한 곳에 모아 재사용할 수 있다.
    • 이로써 컴포넌트 코드는 최소한의 useInput 훅 호출과 props 전달만으로 깔끔하게 유지되며, 다양한 입력 필드를 쉽게 관리할 수 있다.
사용자 키 입력, 포커스 변화
       │
       ▼
   useInput(기본값, 검증함수) 훅
       │
       ├─ useState로 enteredValue, didEdit 관리
       ├─ handleInputChange, handleInputBlur 정의
       ├─ validationFn(enteredValue) 호출로 valueIsValid 판별
       └─ hasError = didEdit && !valueIsValid 계산

       ▼
  호출한 컴포넌트(StateLogin 등)
       │
       ├─ const { value, hasError, handleInputChange, handleInputBlur } = useInput(...)
       ├─ UI에 value 반영, hasError 시 오류 메시지 표시
       └─ 제출 시 hasError 검사 후 로직 분기
import { useState } from 'react';

type ValidationFn = (value: string) => boolean;

interface UseInputReturn {
  value: string;
  hasError: boolean;
  handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  handleInputBlur: (e: React.FocusEvent<HTMLInputElement>) => void;
}

export function useInput(defaultValue: string, validationFn: ValidationFn): UseInputReturn {
  const [enteredValue, setEnteredValue] = useState(defaultValue);
  const [didEdit, setDidEdit] = useState(false);

  const valueIsValid = validationFn(enteredValue);
  const hasError = didEdit && !valueIsValid;

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEnteredValue(e.target.value);
    setDidEdit(false);
  };

  const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    setDidEdit(true);
  };

  return {
    value: enteredValue,
    hasError,
    handleInputChange,
    handleInputBlur
  };
}
import React, { FormEvent } from 'react';
import Input from './Input';
import { useInput } from '../hooks/useInput';
import { isEmail, hasMinLength } from '../util/validation';

const StateLogin: React.FC = () => {
  const {
    value: emailValue,
    hasError: emailHasError,
    handleInputChange: handleEmailChange,
    handleInputBlur: handleEmailBlur
  } = useInput('', (val) => isEmail(val));

  const {
    value: passwordValue,
    hasError: passwordHasError,
    handleInputChange: handlePasswordChange,
    handleInputBlur: handlePasswordBlur
  } = useInput('', (val) => hasMinLength(val, 6));

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    // 유효성 검사
    if (emailHasError || passwordHasError) {
      return; // 오류가 있으면 제출 중단
    }

    // 유효하면 후속 작업 (예: 서버 전송)
    console.log('이메일:', emailValue, '비밀번호:', passwordValue);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div className="control-row">
        <Input
          label="이메일"
          id="email"
          name="email"
          type="email"
          value={emailValue}
          onChange={handleEmailChange}
          onBlur={handleEmailBlur}
          error={emailHasError ? '유효한 이메일 주소를 입력해주세요.' : ''}
        />
        <Input
          label="비밀번호"
          id="password"
          name="password"
          type="password"
          value={passwordValue}
          onChange={handlePasswordChange}
          onBlur={handlePasswordBlur}
          error={passwordHasError ? '유효한 비밀번호를 입력하십시오.(6글자 이상)' : ''}
        />
      </div>
      <button type="submit">로그인</button>
    </form>
  );
};

export default StateLogin;
  • useInput 훅은 단일 입력 필드 상태 관리 및 유효성 검증을 전담한다.

  • 각 입력 필드는 useInput 훅을 별도로 호출해 자신만의 상태와 검증 로직을 갖는다.

  • StateLogin에서는 더 이상 상태나 이벤트 핸들러를 직접 관리할 필요 없이, useInput에서 반환한 값과 함수만으로 UI와 검증 로직을 연결할 수 있다.

  • 요약:

    • 커스텀 훅을 통해 입력 필드에 대한 상태 관리와 유효성 검증 로직을 재사용 가능하고 깔끔하게 캡슐화할 수 있다.
    • 이 접근법은 코드 중복을 제거하고 유지보수성을 높이며, 다른 폼 필드 추가나 변경에도 쉽게 대응할 수 있는 유연한 구조를 제공한다.

240. 양식 및 사용자 입력 작업 / 서드 파티 Form 라이브러리 사용하기

아래 정리는 폼 처리 및 유효성 검증을 위한 다양한 접근 방식과, 이를 더욱 편리하게 해줄 수 있는 서드파티 라이브러리의 존재를 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.

  • 기본적인 폼 처리:

    • HTML form 요소와 다양한 입력 타입을 활용하고, onSubmit, onChange, onBlur 같은 이벤트를 통해 입력값을 제어하는 방법.
    • 상태(state)와 참조(ref), FormData, 그리고 브라우저 내장 유효성 검증 기능 등을 사용해 사용자 입력값을 얻고 검증할 수 있다는 것을 배웠다.
  • 유효성 검증(Validation) 전략:

    • 키 입력 즉시 검증, 포커스 해제 시점 검증, 폼 제출 시점 검증 등의 다양한 방식.
    • 브라우저 내장 속성(required, type="email", minLength 등) 사용, 커스텀 로직 추가, 커스텀 훅(useInput)과 유틸 함수로 검증 로직 재사용성 확보.
  • 코드 재사용성 향상:

    • 커스텀 입력 컴포넌트(Input 컴포넌트)로 UI 로직 재사용.
    • 검증 로직을 유틸 함수나 커스텀 훅으로 분리하여 유지보수성과 확장성을 높임.
  • 서드파티 라이브러리 소개

    • React Hook Form, Formik 등 검증된 서드파티 라이브러리 존재.
    • 이들 라이브러리는 상태 관리, 검증 로직 적용, 에러 메시지 표시 등을 더욱 간단히 처리할 수 있도록 돕는다.
    • 라이브러리를 활용하면 개발 속도 및 생산성이 향상될 수 있지만, 기본 원리를 이해하고 있어야 라이브러리를 효과적으로 활용 가능.
  • 정리

    • 이번 섹션을 통해 자바스크립트/리액트 코드를 직접 작성하는 방법을 배우는 것은 매우 중요하다.
    • 하지만 실무에서 서드파티 라이브러리를 활용하는 것은 개발 속도와 유지보수를 용이하게 하는 좋은 선택이 될 수 있다.
    • 기본 원리를 잘 이해한 상태에서 라이브러리를 탐색하고 활용해보는 경험이 개발자로서의 역량을 넓히는 데 도움이 된다.
사용자 입력 ──> 리액트 상태 관리 or FormData or refs 
       │
       ├─ 브라우저 내장 검증, 커스텀 검증 로직, 커스텀 훅(useInput)
       │       │
       │       └─ hasError, valueIsValid 등 Boolean 결과 반환
       │
       └─ UI 업데이트: 오류 메시지 표시, 제출 가능 여부 제어
       
추후 확장:
서드파티 라이브러리 (React Hook Form, Formik 등)
       │
       ├─ 폼 상태 관리 및 검증 로직 자동화
       ├─ 커스텀 훅/컴포넌트 제공
       └─ 오류 메시지 및 유효성 검사 결과를 더 쉽게 관리
import React from 'react';
import { useForm } from 'react-hook-form';

interface FormData {
  email: string;
  password: string;
}

const HookFormExample: React.FC = () => {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<FormData>();

  const onSubmit = (data: FormData) => {
    console.log('양식 데이터:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div className="control">
        <label htmlFor="email">이메일:</label>
        <input
          id="email"
          type="email"
          {...register('email', { required: '이메일은 필수입니다.', pattern: { value: /\S+@\S+\.\S+/, message: '올바른 이메일 주소를 입력하세요.' } })}
        />
        {errors.email && <p className="error-text">{errors.email.message}</p>}
      </div>

      <div className="control">
        <label htmlFor="password">비밀번호:</label>
        <input
          id="password"
          type="password"
          {...register('password', { required: '비밀번호는 필수입니다.', minLength: { value: 6, message: '비밀번호는 최소 6자 이상이어야 합니다.' } })}
        />
        {errors.password && <p className="error-text">{errors.password.message}</p>}
      </div>

      <button type="submit">로그인</button>
    </form>
  );
};

export default HookFormExample;
  • useForm 훅을 사용해 폼 상태 및 유효성 검증을 자동화.

  • register 함수를 통해 각 입력 필드를 폼 상태에 연결하고, 검증 규칙(required, pattern, minLength)을 손쉽게 지정.

  • 에러 메시지를 errors 객체에서 바로 가져와 표시할 수 있어 별도의 상태 관리나 훅 구현 필요 없음.

  • 기존 섹션에서 배운 모든 개념(검증, 상태 관리, 유효성 표시)이 라이브러리를 통해 한층 간단해진다.

  • 요약:

    • 이 섹션을 통해 기본적인 폼 처리 개념을 숙지한 후, 서드파티 라이브러리 사용 가능성을 알게 되었다.
    • 기본 원리에 대한 이해는 필수이며, 이를 바탕으로 React Hook Form, Formik 등과 같은 라이브러리를 활용하면 폼 개발이 더욱 편리해진다.

241. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 소개

  • 프로젝트 앱

    • 음식 탐색: 사용자는 다양한 종류의 음식을 볼 수 있습니다. 이때, 음식 목록은 백엔드에서 받아오는 데이터일 수 있습니다.
    • 장바구니 담기: 사용자는 원하는 음식을 선택하여 장바구니에 추가할 수 있습니다. 음식의 수량 조정, 추가/삭제 등의 기능이 포함됩니다.
    • 주문 검토: 사용자가 장바구니에 담은 내용을 확인한 뒤, 주문 양식을 통해 자신의 정보(주소, 연락처 등)를 입력하고 주문 내용을 최종 확인하게 됩니다.
    • 주문 제출: 최종적으로 주문을 "임시 백엔드"로 제출하게 되며, 이 과정에서 HTTP 요청/응답 처리를 다루게 됩니다.
  • 이번 프로젝트를 통해 우리는 다음과 같은 점들을 학습 및 실습하게 됩니다.

    • 리액트 컴포넌트 빌드 및 구성: 작은 단위 컴포넌트에서 시작해, 점진적으로 전체 앱을 구성하는 과정을 밟습니다.
    • 상태(State) 관리: 각 컴포넌트 내에서 다루는 상태를 적절히 관리하고, 필요에 따라 상위나 컨텍스트를 통해 상태를 공유하는 방법을 다룹니다.
    • 컨텍스트(Context) 활용: 전역적으로 필요한 상태(예: 현재 장바구니 상태, 사용자 인증 정보 등)를 컨텍스트를 통해 관리하여, 여러 컴포넌트가 손쉽게 접근할 수 있도록 합니다.
    • HTTP 요청 및 응답 처리: 백엔드에서 데이터를 가져오거나(메뉴 아이템 로딩), 주문을 제출하는 등의 API 통신 과정에서, 비동기 처리와 후속 부작용(side effects)을 어떻게 다루는지 실습합니다.
    • 지금까지 학습한 모든 기능의 종합적 활용: 이전 강의들에서 배운 모든 기술을 종합해 실제 제품 수준의 프론트엔드 로직으로 발전시키는 경험을 쌓습니다.
사용자 상호작용(버튼 클릭, 아이템 선택)
     │
     ↓
[프론트엔드 UI 컴포넌트] → (상태 변경 요청) → [상태 관리 (State/Context)]
     │                                   │
     │                                   ↓
     │                               [HTTP 요청 발생] → [백엔드 서버]
     │                                                   │
     │                                                   ↓
     │                                               [응답 (데이터)]
     │                                   ↑
     └───────────────────────────────────┘
                   │
                   ↓
            UI 업데이트 (데이터 반영)
  • 사용자가 UI를 통해 어떤 액션을 취하면(UI 컴포넌트)

  • 해당 변화가 상태 관리 로직(Context, State)에 반영

  • 필요 시 백엔드로 데이터를 요청하거나 주문을 제출하는 HTTP 요청 발생

  • 서버로부터 응답을 받으면 상태를 업데이트하고 UI를 재렌더링

  • 최종적으로 사용자에게 새로운 상태가 반영된 인터페이스 제공

  • git commit 1. 프로젝트 초기화

  • git commit 2. 푸드 컴포넌트 생성

242. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 앱 구상 및 첫 컷포넌트 추가

이번 단계에서는 프로젝트 초기 구성을 토대로 음식 주문 앱의 "헤더(Header)" 컴포넌트를 먼저 만들어보는 과정을 다룹니다. 이러한 작업 순서를 선택한 이유는 다음과 같습니다.

  • 간단한 것부터 시작하기: 복잡한 로직 없이 단순히 UI 요소(헤더) 하나를 먼저 구현하면서 프로젝트의 뼈대를 잡아나갑니다. 이를 통해 빠른 성취감을 얻고, 이후 확장 기능(음식 목록, 장바구니, 결제 등)에 집중하기 전에 기초 환경을 안정화할 수 있습니다.

  • 프로젝트 구조 확립: src/components 폴더를 생성하고, 그 안에 Header.jsx (실제 작업 시 Header.tsx) 파일을 만들어 컴포넌트를 정의합니다. 이렇게 컴포넌트를 분리 관리하면서 코드의 가독성과 유지보수성을 높이는 것이 리액트 프로젝트에서 일반적인 패턴입니다.

  • 헤더 컴포넌트 내용:

    • 메인 헤더 요소(header)를 반환하는 컴포넌트 함수를 작성합니다.
    • header 태그 안에 div를 두고 그 안에 로고 이미지를 <img> 태그로 추가합니다.
    • h1 요소를 사용해 음식점 이름(예: "ReactFood")을 제목으로 표시합니다.
    • 하단에 버튼을 하나 만들고, 이를 통해 앞으로 구현할 장바구니 모달 창을 열 수 있도록 할 예정입니다. 현재는 하드코딩된 0이라는 숫자를 버튼 옆에 표시합니다.
  • 스타일 적용:

    • index.css 파일에 이미 정의된 #main-header 및 #title 등의 스타일 규칙을 활용합니다.
    • header 요소에 id="main-header" 속성을 부여하여 스타일을 적용하고, 내부의 div나 h1에도 적절한 ID를 할당해 스타일 규칙을 활용합니다.
    • img 태그를 통해 assets 폴더의 logo.png 이미지를 불러오고 alt 텍스트를 제공하여 접근성을 고려합니다.
  • 헤더 컴포넌트 적용:

    • 루트 컴포넌트(App)에서 기존에 작성된 임의의 h1 요소를 제거하고, 대신 새로 만든 Header 컴포넌트를 import 및 렌더링합니다.
    • 이렇게 하면 프로젝트를 실행했을 때 헤더가 화면에 표시되고, 이후 다른 요소(음식 목록, 장바구니, 결제창 등)를 점진적으로 추가할 수 있습니다.
[Header 컴포넌트 렌더링 요청]
     │
     ↓
<Header /> 컴포넌트
     │   ┌───────────────────┐
     │   │  정적 데이터 (로고,  |
     │   │  제목 등) 렌더링     |
     │   └───────────────────┘
     ↓
   화면 UI 갱신 (헤더 표시)

여기서 아직 HTTP 요청이나 상태 변화가 없는 단순한 컴포넌트이므로, 데이터 흐름은 매우 단순합니다. 추후 장바구니 아이템 수 표시나, 백엔드 데이터와 연동될 때 흐름이 복잡해질 것입니다.

243. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / Meals(음식) 데이터 Fetching하기 (GET HTTP 요청)

이번 단계에서는 음식 메뉴 데이터를 백엔드(임시 서버)로부터 가져와 화면에 표시하는 과정을 다룹니다. 이전 단계에서 간단한 헤더 컴포넌트를 만들었다면, 이제 본격적으로 비동기 데이터 로딩과 상태 관리를 다루게 됩니다.

핵심 흐름은 다음과 같습니다.

  • Meals 컴포넌트 생성:

    • src/components/Meals.jsx 파일을 생성하여 Meals라는 컴포넌트를 정의합니다. 이 컴포넌트는 음식 목록을 표시하는 <ul> 요소를 반환하게 됩니다.
    • <ul> 요소에는 id="meals"라는 속성을 부여하며, index.css에 정의된 스타일 규칙을 활용할 수 있습니다.
  • 백엔드 데이터 가져오기:

    • 백엔드의 /meals 엔드포인트에서 음식 목록을 GET 요청으로 받아옵니다. 이를 위해 fetch 함수를 사용합니다.
    • 응답(response)이 성공적이면(response.ok가 true) response.json()을 통해 JSON 데이터를 자바스크립트 객체로 변환합니다. 이 데이터에는 음식의 id, name, price, description 등의 정보가 포함되어 있습니다.
  • 상태 관리(useState):

    • 응답을 통해 받은 음식 목록은 컴포넌트의 상태로 관리합니다.
    • useState 훅을 사용하여 loadedMeals라는 상태 변수에 음식 목록을 저장합니다. 초기에는 빈 배열로 시작하고, 데이터 로딩이 완료되면 setLoadedMeals를 호출하여 상태를 업데이트합니다.
    • 상태가 업데이트되면 리액트는 자동으로 컴포넌트를 재렌더링하여 UI를 업데이트합니다.
  • useEffect 사용:

    • 단순히 컴포넌트 함수 안에서 fetch 요청을 호출하면 무한 루프를 초래할 수 있습니다. 왜냐하면 상태 업데이트 → 컴포넌트 재렌더링 → 다시 fetch 호출 → 상태 업데이트… 이런 순환이 발생할 수 있기 때문입니다.
    • 이를 방지하기 위해 useEffect 훅을 사용합니다. useEffect는 컴포넌트가 렌더링된 뒤에 특정 부수효과(HTTP 요청)를 실행할 수 있도록 도와주며, 의존성 배열을 통해 재실행 시점을 제어할 수 있습니다.
    • 여기서는 useEffect 안에 fetchMeals라는 비동기 함수를 정의하고 호출함으로써 컴포넌트 최초 렌더링 후 한 번만 데이터를 가져오도록 합니다.
  • UI 렌더링:

    • 데이터가 로딩되고 나면 loadedMeals.map()을 통해 각 음식 항목을 <li>로 렌더링합니다. 현재는 음식의 이름만 표시하지만, 추후 필요에 따라 가격, 설명, 이미지 등을 추가할 수 있습니다.

이 과정을 통해 컴포넌트는 비동기 데이터 로딩부터 상태 업데이트, UI 렌더링까지의 전 과정을 경험하게 됩니다.

   [Meals 컴포넌트 렌더링]
          │
          ↓
   useEffect 실행 → fetchMeals 함수 호출
          │
          ↓
     fetch('http://localhost:3000/meals') 
          │
          ↓  (HTTP GET 요청)
     [백엔드 서버]
          │
          ↓ (JSON 데이터 응답)
   응답 → response.json() 
          │
          ↓
   데이터(loadedMeals)에 setLoadedMeals를 통해 상태 업데이트
          │
          ↓
   컴포넌트 재렌더링 → loadedMeals.map()으로 UI 요소(li) 생성
          │
          ↓
        화면에 음식 목록 표시

244. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / "MealItem" 컴포넌트 추가하기

이번 단계에서는 이전에 불러온 음식 데이터(각 메뉴 아이템)를 화면에 더욱 풍부하게 표시하기 위해 새로운 컴포넌트인 MealItem을 추가합니다. 이 과정을 통해 UI 마크업 구조 분리, CSS 클래스 적용, 전달받은 데이터 활용 등을 학습하게 됩니다.

주요 흐름은 다음과 같습니다.

  • MealItem 컴포넌트 추가:

    • src/components/MealItem.jsx 파일을 생성하고, MealItem 함수를 내보냅니다. 이 컴포넌트는 각 음식 메뉴 아이템에 해당하는 마크업을 반환하며, 다음과 같은 내용을 포함합니다.
      • article 요소를 사용하여 한 음식 아이템을 묶습니다.
      • 클래스(meal-item)를 적용하여 CSS 스타일을 쉽게 관리합니다.
      • 음식 이미지를 표시하기 위해 <img> 태그를 사용하고, alt 속성에 음식 이름을 전달합니다.
      • 음식 이름, 가격, 상세설명(description)을 각각 적절한 태그(h3, p)로 감싸 표시합니다.
      • 하단에 장바구니에 담기와 같은 액션 버튼을 배치합니다(지금은 기능 미구현 상태).
  • Meals 컴포넌트에서 MealItem 사용:

    • 기존에 Meals.jsx에서는 <li> 태그로 직접 음식 이름만 표시하던 방식을 개선합니다.
      • loadedMeals.map()을 통해 각 음식 메뉴를 순회할 때, 이제 <MealItem meal={meal} key={meal.id} /> 형태로 반환합니다.
      • 이렇게 하면 MealItem 컴포넌트에 해당 음식 데이터(예: meal.image, meal.name, meal.price, meal.description)를 속성(props)으로 전달할 수 있습니다.
  • 이미지 경로 수정:

    • 백엔드에서 제공하는 meal.image는 상대 경로로만 주어지므로, 실제 이미지가 표시되려면 http://localhost:3000/와 같은 베이스 URL을 앞에 붙여주어야 합니다.
    • 이를 위해 자바스크립트 템플릿 리터럴(`)을 사용해 src 속성을 동적으로 완성하여 이미지가 정상적으로 표시되도록 합니다.
  • 추후 계획:

    • 음식 아이템을 보기 좋게 표시한 뒤, 다음으로는 버튼 스타일링 및 가격 표시 형식을 개선할 예정이며, 그 후 장바구니 기능을 구현하는 단계로 나아갈 것입니다.

정리하자면, 이번 단계에서는 개별 음식 메뉴를 위한 MealItem 컴포넌트를 도입하여 UI를 구조화하고, 백엔드로부터 가져온 이미지와 상세정보를 실제 화면에 반영하는 방법을 익혔습니다.

       [Meals 컴포넌트]
            │
       loadedMeals(state)
            │
        ┌───┴─────────────────┐
        │                      │ (.map)
        ↓                      ↓
   MealItem(meal 데이터) → UI 렌더링
        │
        ↓
 <article> 내부의 img, h3, p 등
        │
        ↓
     화면에 음식 상세 정보 표시
  • Meals 컴포넌트에서 불러온 데이터(loadedMeals)를 map()으로 순회하며 MealItem 컴포넌트에 하나씩 전달합니다.
  • MealItem은 전달받은 meal 정보를 활용하여 이미지, 이름, 가격, 상세설명 등을 표시합니다.
  1. 1. Meals.tsx, MealItme.tsx

245. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 숫자를 통화 형식으로 변환 및 측정

이번 단계에서는 음식 메뉴 가격을 국제화(Internationalization, i18n) 기능을 사용해 통일된 화폐 형식으로 표시하는 방법을 다룹니다. 이를 통해 가격을 일정한 형식으로 나타내어 사용자 경험을 개선할 수 있습니다.

  • 별도 유틸리티 폴더(util) 및 파일 생성:

    • 프로젝트의 구조적 확장성과 재사용성을 위해 src/util 폴더를 만들고, 그 안에 formatting.js(또는 formatting.ts) 파일을 생성합니다. 이 파일 안에서 화폐 형식 변환기를 정의합니다.
  • Intl.NumberFormat 활용:

    • JavaScript 내장 객체인 Intl.NumberFormat을 사용하여 화폐 형식을 정의합니다. 예를 들어, en-US 로케일과 USD(미국 달러) 단위를 기반으로 숫자를 통일된 달러 표기로 표시할 수 있습니다.
const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
});
  • MealItem 컴포넌트에서 적용:
    • MealItem.jsx(또는 MealItem.tsx)에서 이 currencyFormatter를 import한 뒤, 음식 가격(meal.price) 표시 시 currencyFormatter.format(meal.price)를 사용합니다. 이를 통해 UI에 표시되는 가격이 $10.00과 같은 일정한 형식으로 나타나게 됩니다.

이러한 작업을 통해 가격이 일관되게 표현되며, 이후 다른 컴포넌트나 기능에서도 동일한 포맷터를 재사용할 수 있습니다.

[MealItem 컴포넌트]
      │
      │ (meal.price 사용)
      ↓
[util/formatting.js] 내 currencyFormatter
      │
      │ currencyFormatter.format(meal.price)
      ↓
   포맷된 화폐 문자열 반환
      │
      ↓
[MealItem 컴포넌트 UI] → 화면에 통일된 화폐 형식 가격 표시

246. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 설정이 자유롭고 유동성 있는 커스텀 버튼 컴포넌트 생성하기

  • UI 폴더 생성 및 버튼 컴포넌트 작성:

    • src/components/UI 폴더를 만들고, Button.jsx 파일을 생성합니다.
    • Button 컴포넌트는 빌트인 <button> 요소를 감싸며, 다양한 속성과 스타일을 유연하게 적용할 수 있는 재사용 가능한 컴포넌트가 됩니다.
  • 자식 콘텐츠(Children) 활용:

    • 커스텀 버튼 컴포넌트 내에서 {props.children}를 사용하여 <Button>버튼 텍스트</Button>처럼 자식 요소로 전달되는 텍스트나 JSX를 버튼 내부에 표시합니다.
  • 조건부 스타일링(textOnly 속성):

    • textOnly라는 boolean 속성을 받아, 만약 textOnly가 true라면 CSS 클래스를 text-button으로, 아니면 기본 button 클래스를 적용합니다. 이를 통해 같은 컴포넌트로 다양한 형태의 버튼을 표현할 수 있습니다.
  • 클래스 이름 병합하기:

    • 외부에서 전달된 className 속성을 컴포넌트가 내부적으로 사용하는 기본 클래스(button 또는 text-button)에 합쳐서 최종 클래스 이름을 구성합니다.
  • 나머지 속성 전개(Resting Props):

    • ...props 문법을 사용하여 onClick, type 등의 다양한 속성을 <button> 요소에 그대로 전달합니다. 이를 통해 커스텀 버튼이 실제 <button>과 유사한 사용 경험을 제공할 수 있습니다.
  • 적용 예시:

    • 헤더에서 사용한 장바구니 버튼을 커스텀 버튼으로 변경하고, textOnly 속성을 추가해 텍스트 링크 형태의 버튼으로 변경합니다.
    • MealItem 컴포넌트에서 장바구니에 담기 버튼 또한 커스텀 버튼으로 교체하여 UI 일관성을 높입니다.
[Button 컴포넌트]
    │
    │ props (textOnly, className, onClick, etc.)
    ↓
 조건에 따른 CSS 클래스 결정
    │
    ↓
<button ...props> {props.children} </button>
    │
    ↓
 화면에 일관된 스타일 및 기능의 버튼 표시
    │
    ↓
[MealItem 컴포넌트 / Header 컴포넌트 등]
    │
    └─ Button 컴포넌트를 사용하여 다양한 위치에서 일관된 버튼 UI 제공

247. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / Cart 컨텍스트와 Reducer로 시작하기

이번 단계에서는 장바구니 데이터 관리를 위해 컨텍스트(Context)와 리듀서(useReducer)를 활용하는 방식을 다룹니다. 장바구니 데이터는 앱 곳곳에서 필요하므로, 단일 컴포넌트(App)에서 상태를 관리하는 대신 리액트의 Context를 사용하여 전역 상태로 관리하고, 이를 필요한 컴포넌트가 쉽게 접근할 수 있도록 합니다.

  • 주요 개념 정리:
    • 컨텍스트 생성 (CartContext):
      • createContext를 사용해 장바구니 데이터 전송용 컨텍스트를 생성합니다.
      • 이 컨텍스트에는 장바구니 항목 배열, 항목 추가 함수(addItem), 항목 제거 함수(removeItem) 등의 정보 구조를 정의합니다.
    • Provider 컴포넌트 (CartContextProvider):
      • 컨텍스트를 실제로 제공하기 위해 Provider 컴포넌트를 만듭니다. Provider는 장바구니와 관련된 상태를 내부에서 관리하고, 다른 컴포넌트들이 이 상태에 쉽게 접근하도록 해줍니다.
      • 여기서 상태 관리를 위해 useReducer 훅을 사용합니다.
        • useReducer는 복잡한 상태 업데이트 로직을 간결히 관리할 수 있습니다.
        • cartReducer 함수는 액션 타입(예: ADD_ITEM, REMOVE_ITEM)에 따라 상태를 업데이트합니다.
    • ADD_ITEM 로직:
      1. 새로 추가되는 항목이 이미 장바구니에 있는지 확인(findIndex).
      2. 이미 존재한다면 해당 항목의 수량(quantity)을 증가시킵니다.
      3. 존재하지 않는다면 새로운 항목을 장바구니 배열에 추가하되, 불변성을 지켜 기존 배열을 복사한 뒤 새 항목을 추가합니다.
      4. 상태를 반환하여 UI를 재렌더링합니다.
    • 상태 불변성 유지:
      • 기존 상태를 직접 수정하지 않고, 복사본을 만들어 수정한 뒤 반환합니다. 이를 통해 예측 가능한 상태 관리와 디버깅이 쉬워집니다.
[MealsItem 컴포넌트] -- "장바구니에 담기" 버튼 클릭 --> [CartContextProvider] -- useReducer --> [cartReducer]
       │                                                    │
       │ addItem() 호출                                      │
       │----------------------------------------------------> 액션 { type: 'ADD_ITEM', item: {...} }
                                                            │
                                                            ↓
                                                       새 상태 반환 (장바구니 업데이트)
                                                            │
                                                            ↓
                                                   Context 업데이트 → UI 재렌더링
  • 사용자가 "장바구니에 담기" 버튼을 누르면, 해당 액션이 CartContextProvider의 addItem 함수를 통해 cartReducer에 전달됩니다.
  • cartReducer는 현재 상태와 액션을 바탕으로 장바구니 항목 배열을 업데이트하고 새로운 상태를 반환합니다.
  • 업데이트된 상태는 Context를 통해 앱 전체로 공유되며, 관련 컴포넌트(UI)가 자동으로 재렌더링되어 변경 사항을 반영합니다.
// src/store/CartContext.tsx
import React, {useReducer, createContext, ReactNode} from 'react';

interface CartItem {
    id: string;
    name: string;
    price: number;
    quantity: number;
}

interface CartState {
    items: CartItem[];
}

type CartAction =
    | { type: 'ADD_ITEM'; item: CartItem }
    | { type: 'REMOVE_ITEM'; id: string };

interface CartContextProps {
    items: CartItem[];
    addItem: (item: CartItem) => void;
    removeItem: (id: string) => void;
}

const CartContext = createContext<CartContextProps>({
    items: [],
    addItem: () => {
    },
    removeItem: () => {
    },
});

const cartReducer = (state: CartState, action: CartAction): CartState => {
    if (action.type === 'ADD_ITEM') {
        const existingCartItemIndex = state.items.findIndex(
            (item) => item.id === action.item.id
        );

        let updatedItems: CartItem[];

        if (existingCartItemIndex >= 0) {
            // 항목이 이미 존재하는 경우 수량 증가
            const existingItem = state.items[existingCartItemIndex];
            const updatedItem = {...existingItem, quantity: existingItem.quantity + action.item.quantity};
            updatedItems = [...state.items];
            updatedItems[existingCartItemIndex] = updatedItem;
        } else {
            // 항목이 없는 경우 새로 추가 (quantity 초기값 1 또는 action.item.quantity 사용)
            updatedItems = state.items.concat(action.item);
        }

        return {items: updatedItems};
    }

    if (action.type === 'REMOVE_ITEM') {
        const updatedItems = state.items.filter(item => item.id !== action.id);
        return {items: updatedItems};
    }

    return state;
};

const CartContextProvider: React.FC<{ children: ReactNode }> = ({children}) => {
    const [cartState, dispatch] = useReducer(cartReducer, {items: []});

    const addItemHandler = (item: CartItem) => {
        dispatch({type: 'ADD_ITEM', item});
    };

    const removeItemHandler = (id: string) => {
        dispatch({type: 'REMOVE_ITEM', id});
    };

    const contextValue: CartContextProps = {
        items: cartState.items,
        addItem: addItemHandler,
        removeItem: removeItemHandler,
    };

    return (
        <CartContext.Provider value={contextValue}>
            {children}
        </CartContext.Provider>
    );
};

export {CartContext, CartContextProvider};
  • CartContext를 생성하고, 장바구니 상태와 업데이트 함수를 제공합니다.

  • CartContextProvider는 useReducer를 활용해 장바구니 상태를 관리하고, 액션 타입(ADD_ITEM, REMOVE_ITEM)에 따라 상태를 업데이트합니다.

  • MealsItem 혹은 다른 컴포넌트에서 CartContext를 구독(useContext)하여 addItem, removeItem 함수를 사용하면 장바구니 상태를 손쉽게 제어할 수 있습니다.

  • git commit 1.

248. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / Cart 컨텍스트와 Reducer 끝내기 및 활용

이번 단계에서는 장바구니의 항목을 제거하는 로직을 구현하고, 이 컨텍스트를 이용해 앱 곳곳에서 장바구니 데이터를 활용하는 방법을 다룹니다. 이전 단계에서 구현한 항목 추가 로직(ADD_ITEM)에 이어, 항목 제거 로직(REMOVE_ITEM)을 구현하여 장바구니 항목 수량 조정 기능을 완성하게 됩니다.

  • REMOVE_ITEM 로직 추가:

    • cartReducer에 REMOVE_ITEM 액션을 처리하는 로직을 추가합니다. 프로세스는 다음과 같습니다.
      1. 제거할 항목의 ID를 통해 현재 장바구니 상태(state.items)에서 해당 항목을 찾습니다.
      2. 해당 항목의 수량(quantity)이 1보다 큰 경우, 수량을 1 감소시킵니다.
      3. 수량이 1이라면, 해당 항목을 장바구니 배열에서 완전히 제거(splice)합니다.
    • 이 때 상태는 불변성을 지키며 업데이트합니다. 즉, 기존 배열을 직접 수정하지 않고 새로운 배열을 생성하거나 복사한 뒤 변경을 가한 뒤 반환합니다.
  • CartContextProvider 개선:

    • CartContextProvider 내에서 useReducer를 사용해 장바구니 상태와 업데이트 로직을 관리합니다.
      • ADD_ITEM 액션: 항목 추가 혹은 기존 항목 수량 증가
      • REMOVE_ITEM 액션: 항목 수량 감소 혹은 항목 완전 제거
    • 이 Provider는 value로 현재 장바구니 항목 배열과 addItem, removeItem 함수를 노출합니다. 이후 앱 컴포넌트에서 이 Provider로 전체 앱을 감싸면, 어떤 컴포넌트에서도 useContext를 통해 장바구니 데이터와 조작 함수를 손쉽게 사용 가능합니다.
  • MealItem 및 Header 컴포넌트 연동:

    • MealItem 컴포넌트에서 "장바구니에 담기" 버튼을 클릭할 때 addItem을 호출하여 장바구니 상태를 업데이트합니다.
    • Header 컴포넌트에서는 장바구니의 총 아이템 수(모든 항목 수량 합)를 표시합니다. useContext로 장바구니 상태를 받아, reduce 메서드로 모든 항목의 quantity를 합산하여 동적으로 표시할 수 있습니다.

이 과정을 통해 복잡한 상태 관리 로직을 컨텍스트와 리듀서로 분리하여 코드의 유지보수성과 확장성을 높이며, 다양한 컴포넌트에서 쉽게 장바구니 상태를 참조하고 조작할 수 있게 합니다.

       [MealItem 컴포넌트]  -- "장바구니에 담기" 클릭 -->  addItem() 호출
                           |                                ↓
                           |                         [CartContextProvider]
                           |                                ↓
                           |                         dispatch({type:'ADD_ITEM', item})
                           |                                ↓
                           |                          [cartReducer] ADD_ITEM 로직
                           |                                ↓
                          (장바구니 상태 업데이트)
                           |
       [Header 컴포넌트] -- useContext로 장바구니 items 접근
                           |---- reduce() 통해 items의 총 quantity 계산 → 화면 반영
                           
       [장바구니 항목 제거 로직] -- REMOVE_ITEM dispatch 호출 시
                           |--> [cartReducer] REMOVE_ITEM 로직
                           |--> 항목 수량 감소 또는 항목 완전 삭제
                           |--> 상태 변경 → UI 재렌더링

249. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 새 컨텍스트로 모달에서 Cart 열기

이번 단계에서는 장바구니 모달 창을 구현하고, 이를 통해 장바구니 데이터를 사용자에게 보여주는 과정을 다룹니다. 또한 사용자의 진행 상태(장바구니 보기/결제창 이동)를 관리하기 위해 별도의 컨텍스트(UserProgressContext)를 추가하여 모달 열림/닫힘 및 단계 전환을 제어합니다.

  1. Cart 컴포넌트 추가:
  • Cart.jsx 파일에서 Cart 함수를 내보내어 구현합니다.
  • 장바구니 데이터를 모달 창 형태로 표시합니다. 모달 안에 장바구니 항목 목록, 총 금액, 그리고 '닫기', '결제하기' 버튼을 배치합니다.
  1. 컨텍스트를 통한 장바구니 데이터 접근:
  • CartContext를 사용해 현재 장바구니 항목 목록(cartCtx.items)에 접근하고, 이를 .map()으로 렌더링하여 화면에 표시합니다.
  • 총 금액은 모든 항목의 (가격 × 수량) 합을 구한 뒤 통화 포맷터(currencyFormatter)로 포맷팅하여 표시합니다.
  1. UserProgressContext를 통한 모달 열림/닫힘 상태 관리:
  • UserProgressContext를 생성하고, showCart, hideCart, showCheckout, hideCheckout 함수를 정의합니다.
  • userProgress 상태를 통해 현재 단계(빈 문자열=모달 닫힘, 'cart'=장바구니 열림, 'checkout'=결제창 열림)를 관리합니다.
  • Header 컴포넌트의 장바구니 버튼을 클릭하면 showCart를 호출하여 모달을 엽니다.
  • Cart 컴포넌트 내에서 '닫기' 버튼을 클릭하면 hideCart를 호출해 모달을 닫습니다.
  1. 모달 컴포넌트와의 연동:
  • 모달 자체는 open 속성을 통해 열리고 닫히며, userProgress가 'cart'인지 여부에 따라 open 속성값을 결정합니다.
  • 모달을 닫을 때 cleanup 함수를 사용하거나, ref를 적절히 관리하여 기대한 대로 모달이 닫히도록 처리합니다.

이로써 사용자 인터페이스 흐름에서 장바구니 내용을 모달로 띄울 수 있고, 필요에 따라 모달을 닫거나 결제 프로세스로 진행할 수 있습니다.

[Header 컴포넌트]
     │ (장바구니 버튼 클릭)
     ↓ showCart() 호출
[UserProgressContext] -- progress = 'cart'
     │
     ↓
[Cart 컴포넌트]
     │
     ├─ useContext(CartContext) → cartCtx.items
     │              (장바구니 아이템 렌더링)
     │
     └─ useContext(UserProgressContext) → userProgress
           │
           └─ progress === 'cart' → 모달 open = true
                 │
                 └─ '닫기' 버튼 클릭 → hideCart() 호출
                     → progress = '' (빈 문자열)
                     → 모달 open = false

250. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / Cart Items 작업

이번 단계에서는 장바구니 항목을 별도 컴포넌트(CartItem)로 분리하고, 항목에 대한 수량 증가/감소 로직을 추가하는 과정을 다룹니다. 이를 통해 코드 구조를 개선하고 재사용성을 높이며, 장바구니 UI를 좀 더 직관적으로 만들 수 있습니다.

  • CartItem 컴포넌트 분리:

    • Cart 컴포넌트 내부에서 바로 JSX를 작성하는 대신, 각 장바구니 항목을 별도의 CartItem 컴포넌트로 분리합니다.
      • CartItem은 li 요소를 반환하며, 해당 항목의 이름, 수량, 가격을 표시합니다.
      • 수량 증가/감소를 위한 버튼 두 개를 렌더링하고, 이를 통해 항목의 수량을 제어할 수 있습니다.
  • 데이터 및 함수 전달 방식:

    • CartItem 컴포넌트는 부모(Cart)로부터 name, quantity, price, onIncrease, onDecrease 등 필요한 값을 속성(props)으로 전달받습니다. 이를 통해 CartItem 내부에서는 단순히 UI 렌더링과 이벤트 처리 호출만 담당합니다.
    • 이렇게 하면 CartItem은 컨텍스트 접근 없이도 작동하며, Cart 컴포넌트는 CartContext를 사용해 addItem, removeItem 함수를 호출하는 중간자를 담당합니다.
  • 기능적 변화:

    • 이제 장바구니 화면에서 각 항목의 + 버튼을 누르면 수량이 증가하고, - 버튼을 누르면 수량이 감소하며, 수량이 1보다 작아지면 해당 항목이 장바구니에서 사라집니다.
    • 이로써 장바구니 UI는 보다 직관적이고 인터랙티브해졌습니다.
[Cart 컴포넌트]
       │  items.map(...)  
       │
       ↓ (각 item 전달)
   <CartItem 
     name={item.name} 
     quantity={item.quantity} 
     price={item.price}
     onIncrease={() => cartCtx.addItem({ ...item, quantity: 1 })}
     onDecrease={() => cartCtx.removeItem(item.id)}
   />

[CartItem 컴포넌트]
    │
    │ onIncrease 버튼 클릭 → onIncrease 함수 호출 → cartCtx.addItem(...)
    │ onDecrease 버튼 클릭 → onDecrease 함수 호출 → cartCtx.removeItem(...)
    ↓
장바구니 상태 변화(useReducer로 관리)
    │
    └─ 변경된 상태 UI 재렌더링

이 흐름을 통해 CartItem에서 발생한 클릭 이벤트가 Cart 컴포넌트 → CartContext → useReducer를 거쳐 상태 업데이트로 이어지고, 그 결과 UI에 즉각 반영됩니다.

  • git commit 1
    • CartItem 컴포넌트는 단순히 UI와 이벤트 처리 함수(onIncrease, onDecrease)를 받습니다. 데이터나 로직 처리는 모두 상위(Cart 컴포넌트)에서 컨텍스트를 통해 처리합니다.
    • Cart 컴포넌트는 장바구니 상태를 가져와 각 CartItem을 렌더링하고, 수량 변경 이벤트 시 CartContext를 통해 상태를 업데이트합니다.
    • 장바구니 아이템이 없을 경우 결제 버튼을 숨김으로써 직관적인 사용자 경험을 제공합니다.

251. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 커스텀 입력 컴포넌트 추가 & 모달 보임 여부 관리

이번 단계에서는 결제창(Checkout) 기능을 추가하고, 모달 열림/닫힘 제어 로직을 더욱 정교하게 다룹니다. 구체적으로 다음과 같은 변화가 이루어집니다.

  • '결제창으로 이동' 버튼 조건부 표시:

    • 장바구니에 항목이 있을 때만 '결제창으로 이동' 버튼을 표시합니다. 항목이 없는 경우에는 해당 버튼을 렌더링하지 않아, 불필요한 UI 노출을 피합니다
  • Checkout 컴포넌트 추가:

    • 새로운 결제창 컴포넌트를 추가하고, 모달 형식으로 띄웁니다. 이 컴포넌트는 사용자의 정보(이름, 이메일, 주소, 우편번호, 도시 등)를 입력받는 양식을 표시하며, 총 결제 금액을 확인할 수 있습니다.
    • 이 양식에서는 필수 입력값을 위해 브라우저의 기본 유효성 검사를 활용하고, 재사용 가능한 Input 컴포넌트를 도입하여 여러 종류의 입력 필드를 손쉽게 구현합니다.
  • UserProgressContext를 통한 상태 제어:

    • Cart 모달에서 '결제창으로 이동' 버튼을 누르면 showCheckout() 함수를 호출하여 progress 상태를 'checkout'으로 변경합니다.
    • 이로써 Cart 모달은 자동으로 닫히고, Checkout 모달이 표시됩니다.
    • 닫기 버튼이나 ESC 키로 모달을 닫을 때에도 UserProgressContext 상태를 적절히 업데이트하여, 상태와 실제 모달 표시 상태를 항상 일치시킵니다.
  • ESC 키로 모달 닫기 처리 개선:

    • 기존에는 ESC 키로 모달을 닫을 경우 앱 내부 상태(UserProgress)가 변경되지 않아 모달을 다시 열 수 없는 문제가 있었습니다.
    • 이를 onClose 이벤트 핸들러를 활용해 UserProgress 상태를 동기화하는 방식으로 해결했습니다.
    • 또한 장바구니에서 체크아웃으로 넘어갈 때 발생하던 버그도 조건부 로직을 통해 해결했습니다.

이로써 사용자는 장바구니 → 결제창으로 자연스럽게 이동할 수 있으며, 모달을 어떤 방식으로 닫더라도 앱 상태가 일관성 있게 유지됩니다.

[Header] -- (장바구니 버튼 클릭) --> progress = 'cart' → Cart 모달 표시
[Cart 컴포넌트]
    │ (장바구니 항목 추가/삭제) 
    │
    │ (결제창으로 이동 버튼 클릭) 
    ↓
progress = 'checkout' → Cart 모달 닫힘, Checkout 모달 표시
[Checkout 컴포넌트]
    │
    │ (ESC 키로 모달 닫기 or 닫기 버튼 클릭)
    ↓
progress = '' (빈 문자열) → Checkout 모달 닫힘
  • 장바구니 > 결제창 이동 시 progress 상태를 변경하여 모달 전환을 제어합니다.

  • ESC나 닫기 버튼으로 모달 닫을 때도 progress 상태를 동기화해 언제든지 다시 모달을 열 수 있습니다.

  • git commit 1

    • 장바구니에 항목이 있을 때만 결제창으로 이동하는 버튼을 노출합니다.
    • 버튼 클릭 시 UserProgressContext를 통해 progress = 'checkout'으로 변경, 결제 모달 표시.
    • ESC나 닫기 버튼으로 모달을 닫아도 UserProgress 상태가 동기화되어, 다음에도 모달을 정상적으로 열 수 있습니다.
    • 입력 양식은 재사용 가능한 Input 컴포넌트를 사용해 깔끔하고 일관성 있는 UI를 제공합니다.

이로써 결제창 전환 기능이 완성되었으며, 앱 상태와 UI가 항상 일치하도록 관리할 수 있습니다.

252. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / Form 제출과 유효성 검사 관리

이번 단계에서는 결제(Checkout) 양식을 실제로 제출(submit)하는 과정을 다룹니다. 목표는 다음과 같습니다.

  1. 양식 제출 기본 동작 차단:
  • 결제 양식에 onSubmit 핸들러를 설정하고, 이벤트 객체의 preventDefault()를 호출하여 브라우저의 기본 폼 제출 동작(페이지 새로고침 및 서버 전송)을 막습니다.
  • 이로써 양식 제출 시 발생하는 기본 동작을 제어하고, 원하는 로직(예: 비동기 요청)을 실행할 수 있게 됩니다.
  1. 브라우저 기본 유효성 검사 활용:
  • 각 입력 필드에 required 속성을 적용하면, 브라우저가 자동으로 입력값이 비어있는 경우 경고 메시지를 표시하고 폼 제출을 막아줍니다. 이로써 간단한 필수 입력 검증을 쉽게 구현할 수 있습니다.
  • 고급 유효성 검증 또는 사용자 정의 검증 로직이 필요한 경우에는 추가 상태 관리나 커스텀 로직을 결합할 수 있지만, 여기서는 기본 기능만 활용합니다.
  1. 입력값 추출:
  • 양식 제출 시 이벤트 객체(event)에서 event.target으로 양식 요소에 접근하고, 이 요소를 FormData 생성자에 전달합니다.

  • FormData 객체를 통해 모든 입력 필드에 할당된 name 속성을 기준으로 사용자가 입력한 값을 간단히 얻을 수 있습니다.

    const formData = new FormData(event.target);
    const data = Object.fromEntries(formData.entries());
  • 이렇게 하면 data가 { fullName: '입력값', email: '입력값', ... } 형태의 객체가 되어, 각 입력 필드에 대한 값을 속성으로 쉽게 접근할 수 있습니다.

  1. 백엔드 전송 준비:
  • 추출한 양식 데이터는 장바구니 데이터와 함께 백엔드로 전송해야 합니다. 이 작업은 다음 단계에서 다루며, 현재 단계에서는 양식 제출 시 입력값을 안전하고 유연하게 추출하는 방법을 익혔습니다.

정리하자면, 이번 단계에서 폼 제출 로직을 제어하고, 브라우저 기본 유효성 검증 및 FormData를 활용하여 입력값을 손쉽게 추출하는 방법을 구현했습니다. 다음 시간에는 이 데이터를 백엔드 서버로 실제 전송하여 주문을 완료하는 과정을 다룰 예정입니다.

   [Checkout 컴포넌트]
       │ (onSubmit)
       │
       ↓ preventDefault() → 기본 제출 동작 방지
       ↓ FormData(event.target)
       ↓ Object.fromEntries(formData.entries())
       │
       │→ data 객체 획득 { fullName: "...", email: "...", ... }
       │
       │ (다음 단계에서) 백엔드로 주문 데이터 전송
  • 사용자가 양식을 제출하면 폼 제출 이벤트 발생

  • preventDefault()를 통해 기본 동작 차단

  • FormData와 Object.fromEntries를 사용해 입력값을 객체 형태로 추출

  • 추출한 데이터는 향후 백엔드로 전송하는 데 사용

  • git commit 1

    • onSubmit 핸들러(handleSubmit)를 통해 폼 제출 제어
    • preventDefault()로 기본 동작 방지
    • FormData와 Object.fromEntries를 활용하여 사용자가 입력한 값을 객체 형태로 추출
    • 추출된 데이터와 장바구니 데이터는 콘솔에 로그로 확인하며, 다음 단계에서 실제 백엔드 요청에 활용할 예정

253. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / Order 데이터로 POST 요청 전송

이번 단계에서는 결제 양식 데이터를 장바구니 데이터와 함께 백엔드로 전송하여 주문을 완료하는 과정을 다룹니다.

  • POST 요청 전송:

    • 이전에는 GET 요청을 사용해 음식 데이터를 가져왔으나, 이번에는 주문 데이터를 백엔드에 POST 요청으로 전송합니다.
    • 이를 위해 handleSubmit 함수 안에서 fetch를 호출하되, 다음과 같이 옵션을 설정합니다.
      • method: 'POST' : POST 요청을 명시
      • headers: {'Content-Type': 'application/json'} : 전송하는 데이터가 JSON 형식임을 지정
      • body: JSON.stringify({...}) : 주문 정보와 고객 정보(양식으로부터 추출한 데이터)를 JSON 형태로 변환
  • 데이터 구조 일치화:

    • 백엔드에서는 name 필드를 사용하므로, 프론트엔드 결제 양식 필드도 full-name 대신 name으로 맞추어 백엔드와 호환되게 합니다.
    • 이를 통해 주문 데이터가 백엔드에서 기대하는 형태와 일치하게 됩니다.
  • 양식 데이터 + 장바구니 데이터 결합:

    • 양식 제출 시 FormData를 이용해 입력값을 추출합니다.

      const formData = new FormData(event.target);
      const data = Object.fromEntries(formData.entries());
    • data에는 사용자가 입력한 고객 정보가 담기며, 여기에 cartCtx.items(장바구니 항목)를 함께 객체로 묶어 백엔드에 전송합니다.

  • 백엔드 응답 확인:

    • 요청을 전송한 뒤, 브라우저 네트워크 탭에서 요청과 응답을 확인할 수 있습니다.
    • 응답이 성공적으로 도착하면 백엔드의 orders.json 파일에 주문 기록이 생성됩니다.
    • 이로써 주문 데이터가 제대로 백엔드에 반영되었음을 알 수 있습니다.
  • 후속 개선점:

    • 현재는 성공/실패 여부나 로딩 상태를 사용자에게 표시하지 않습니다. 이후 단계에서는 에러 처리나 로딩 스피너 등을 도입해 사용자 경험을 개선할 예정입니다.
[Checkout 컴포넌트] (사용자 양식 작성 → 결제 버튼 클릭)
       │
       │ onSubmit 이벤트 발생
       ↓
 preventDefault() 실행
       │
       ↓
 FormData를 통해 사용자 입력값 추출
       │
       ↓ (cartCtx.items와 결합)
       { 
         customer: { name: "사용자입력", email: "...", ... }, 
         items: [...장바구니 항목들...] 
       }
       │
       ↓ fetch('/orders', { method: 'POST', ... })
       │
       │    백엔드로 주문 데이터 전송
       │
       ↓ 백엔드 응답 성공 (201 CREATED 등)
  orders.json 파일에 주문 기록 저장
  • git commit 1
    • 양식 제출 시 기본 동작 차단 후, 사용자 입력 데이터와 장바구니 데이터를 JSON 형태로 백엔드에 POST 요청
    • 응답 확인을 통해 백엔드에서 주문이 정상적으로 처리되었는지 검증
    • 추후 개선을 통해 에러 핸들링, 로딩 상태 표시 등 사용자 경험 향상을 진행할 예정

254. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 커스텀 HTTP Hook(훅) 추가 & 일반적인 에러 방지

이번 단계에서는 HTTP 요청을 처리하는 공통 로직을 커스텀 훅(useHttp)으로 추출하여, 중복 코드를 줄이고 다양한 컴포넌트(예: 결제 컴포넌트, 음식 목록 컴포넌트)에서 재사용 가능하게 합니다. 이를 통해 코드 유지보수성이 향상되고, 로딩 상태나 에러 상태를 간단히 공유할 수 있습니다.

  • useHttp 커스텀 훅 도입:

    • HTTP 요청 로직(데이터 패칭, 에러 처리, 로딩 상태 관리)을 별도의 훅으로 추출합니다.
    • 이 훅은 sendRequest 함수를 제공하며, 컴포넌트는 이 함수를 호출해 원하는 시점에 비동기 요청을 트리거할 수 있습니다.
  • 상태 관리 (로딩, 에러, 데이터):

    • useState로 로딩 상태(isLoading), 에러 상태(error), 그리고 응답 데이터(data)를 관리합니다.
    • 요청 전 isLoading을 true로 설정해 로딩 UI를 표시하고, 요청 완료 후 isLoading을 false로 되돌려 정상 응답 시 데이터 업데이트, 오류 시 error 업데이트를 통해 에러 메시지를 표시할 수 있습니다.
  • 비동기 로직 처리 (async/await + fetch):

    • fetch를 사용해 비동기 요청을 보내며, 응답 상태에 따라 에러를 던지거나 정상 데이터를 반환합니다.
    • 응답이 실패(에러 상태 코드)할 경우 백엔드가 보내는 에러 메시지를 파싱해 사용자에게 좀 더 정확한 에러 정보를 제공할 수 있습니다.
  • useCallback과 의존성 관리:

    • sendRequest 함수를 useCallback으로 감싸, 의존성 변화로 인한 무한 루프를 방지합니다.
    • 또한 요청 구성 객체(requestConfig)를 컴포넌트 함수 밖에서 정의해 매 렌더링마다 새로운 객체를 생성하지 않도록 합니다.
  • 결과:

    • 여러 컴포넌트에서 HTTP 요청 로직을 공유 가능
    • 로딩 중 상태, 에러 상태, 성공 시 데이터 등 다양한 상황을 UI에 쉽게 반영 가능
    • 무한 루프, 프로미스 오작동 등의 문제를 해결하며 안정적인 요청 처리 로직 구축
[컴포넌트] -- useHttp 훅 사용 --> { sendRequest, data, isLoading, error } 상태 얻음
      │
      │ (필요할 때) sendRequest 호출 (예: useEffect 또는 이벤트 핸들러)
      ↓
   HTTP 요청 전송(fetch)
      │
      ↓ 응답 대기
      │
      ├─ 성공 응답 → data 업데이트, isLoading = false
      │
      ├─ 실패 응답 → error 상태 업데이트, isLoading = false
      │
      └─ isLoading, error, data 상태를 통해 UI 변경
  • git commit 1
    • useHttp 훅을 통해 GET/POST 요청 로직을 Meals와 Checkout에서 재사용하고, 로딩/에러 상태를 간단히 UI에 반영할 수 있게 됩니다.
    • 무한 루프 문제는 useCallback과 의존성 관리, 객체 재생성을 피하는 전략으로 해결했습니다.
    • 이로써 HTTP 요청 관리 로직을 명확하게 분리하고, 필요 시 다양한 컴포넌트에서 쉽게 활용할 수 있습니다.

255. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / HTTP 로딩과 에러 State(상태) 다루기

이번 단계에서는 사용자 경험(UX) 개선을 목표로, HTTP 요청 상태에 따른 다양한 UI 표현을 도입합니다. 예를 들어:

  • 로딩 상태 표시: 데이터를 가져오는 중일 때 '메뉴 가져오는 중...'과 같은 문구를 화면 중앙에 표시합니다. 이를 통해 사용자가 현재 진행 상황을 명확히 알 수 있습니다.
  • 에러 상태 표시: 요청 실패 시 단순한 텍스트 대신 에러 컴포넌트를 통해 좀 더 분명하고 시각적으로 구분되는 오류 메시지를 제공합니다. 이 컴포넌트는 제목(예: '메뉴를 가져오는데 실패함')과 실제 에러 메시지를 표시함으로써, 사용자가 문제 상황을 쉽게 이해할 수 있습니다.

이러한 개선 작업을 통해, 사용자에게 단순히 빈 화면을 보여주는 대신 현재 상황을 명확히 전달하고, 문제가 발생했을 때도 의미 있는 피드백을 제공하게 됩니다.

향후 결제(Checkout) 컴포넌트에서도 같은 커스텀 훅을 사용하여, 요청 상태(로딩, 에러, 성공)에 따른 UI를 유사한 방식으로 개선할 계획입니다.

[Meals 컴포넌트]
     │
     ├─ useHttp 훅으로 데이터 요청
     │    │
     │    ├─ isLoading: true일 때 → "메뉴 가져오는 중..." 출력 (center 정렬)
     │    ├─ error 발생 시 → Error 컴포넌트로 에러 메세지 출력
     │    └─ 성공 시 → 메뉴 리스트 정상 표시
     ↓
  사용자의 화면에 로딩/에러/성공 상태에 따른 적절한 UI 반영
  • git commit 1
    • 로딩 상태: .center 클래스를 통해 로딩 중 표시 텍스트를 중앙 정렬하고, 사용자에게 명확한 피드백을 제공.
    • 에러 상태: Error 컴포넌트를 활용해 에러 발생 시 좀 더 시각적이고 이해하기 쉬운 피드백을 제공.
    • 정상 상태: 정상 데이터 로딩 시 메뉴 리스트를 보여줌.

256. 연습 프로젝트: 음식 주문 앱에 http 및 양식 추가 / 마무리 단계

이번 단계에서는 결제(Checkout) 컴포넌트에서 useHttp 커스텀 훅을 활용하여 주문 요청 전송 과정을 개선하고, 다양한 상태(로딩, 에러, 성공)별로 사용자 경험을 최적화하는 방법을 다룹니다.

  • 주요 개선점:
    • POST 요청을 수동으로 트리거:
      • 이전에는 컴포넌트 로딩 시점에 GET 요청을 자동으로 보냈다면, 결제 컴포넌트에서는 양식(폼) 제출 시점에만 주문 요청(POST)을 보내야 합니다.
      • 이를 위해 useHttp 훅에서 제공하는 sendRequest 함수를 양식 제출 핸들러(handleSubmit)에서 호출, 그 시점에만 POST 요청을 발생시킵니다.
    • 데이터 바디(body) 동적 설정:
      • useHttp 훅에서 요청 구성을 위한 객체를 미리 전달하되, 실제 양식 데이터(고객 정보, 장바구니 아이템)를 sendRequest 함수 호출 시점에 바디로 추가할 수 있습니다.
      • 이렇게 하면 POST 요청을 유연하게 처리할 수 있고, 양식 입력값을 제출 시점에만 활용할 수 있습니다.
    • 여러 상태(state) 관리 및 UI 개선:
      • 로딩 중(isLoading = true)일 때: '주문 데이터 보내는 중...'과 같은 메시지 출력
      • 에러 발생 시(error 존재): 에러 컴포넌트를 통해 사용자에게 명확한 오류 메시지 제공
      • 성공적으로 주문 완료 시(data 수신 후 에러 없음): 성공 모달을 띄워서 '주문 완료' 메시지 표시하고, 모달 닫기 시 장바구니를 비우고(clearCart) useHttp 훅의 데이터를 초기화(clearData)하여 재주문 시 상태를 재설정
    • 상태 재설정(clearData):
      • 한 번 성공한 주문 이후 새 주문 시에도 이전 주문 성공 상태가 남아 있지 않도록, useHttp에 clearData 함수를 추가하여 완료 후 상태를 초기값으로 되돌립니다.

이러한 개선을 통해, 사용자에게 직관적이고 명확한 피드백을 제공할 수 있으며, 주문 프로세스를 매끄럽게 진행할 수 있습니다.

[Checkout 컴포넌트]
     │
     │ useHttp 훅 사용 (POST 요청용 구성 전달)
     │
     │ (양식 제출 시) handleSubmit 호출
     ↓
 sendRequest(config + formData) 실행
     │
     ├─ 로딩 중: "주문 데이터 보내는 중..." 표시
     │
     ├─ 에러 발생: 에러 컴포넌트를 통해 오류 메시지 표시
     │
     └─ 성공: 성공 모달 표시 → 확인(Okay) 버튼 클릭 시
              ├─ 장바구니 비우기 (clearCart)
              └─ useHttp 데이터 초기화 (clearData)
  • git commit 1
    • error: Failed to execute 'fetch' on 'Window': Request with GET/HEAD method cannot have body.
    • 이 에러 메시지는 GET 또는 HEAD 요청에 body를 설정할 수 없다는 의미입니다. 현재 useHttp 훅 구현에서 requestConfig에 method: 'GET'를 사용했으나, body를 함께 보내고 있는 상황일 가능성이 큽니다.
    • 즉, useHttp 훅 내부에서 finalConfig.body = bodyData ? JSON.stringify(bodyData) : requestConfig.body ? JSON.stringify(requestConfig.body) : null와 같이 설정하고 있을 때, GET 요청에도 body가 들어가는 경우가 발생할 수 있습니다. GET 요청에는 절대로 body를 포함할 수 없으므로, 이 조건을 수정하거나 GET 요청일 경우 body를 아예 설정하지 않도록 해야 합니다.

257. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리액트 앱의 State에 대한 또 다른 관점

  • 핵심 내용:

    • 리덕스(Redux)는 앱 전반에 걸쳐 공유되는 상태(App-wide state)를 효율적으로 관리하기 위한 상태 관리 라이브러리입니다.
    • 기존의 리액트 상태 관리(useState, useReducer)나 컨텍스트(Context)만으로도 로컬 상태나 일부 컴포넌트 간 공유 상태를 관리할 수 있지만, 상태가 많아지고 구조가 복잡해지면 prop 드릴링(prop drilling) 문제와 복잡성이 증가합니다.
    • 리덕스는 이러한 크로스 컴포넌트(Cross-Component) 또는 앱 전반에 걸친(App-wide) 상태 관리 문제를 체계적이고 예측 가능하게 해결합니다.
    • 리덕스를 사용하면 특정 상태를 중앙 스토어(Store)에 모으고, 액션(Action)과 리듀서(Reducer) 개념을 통해 상태 업데이트 로직을 명확하게 관리할 수 있습니다. 이로써 코드 구조가 개선되고, 큰 규모의 애플리케이션에서도 상태를 체계적으로 유지할 수 있습니다.
  • 정리하자면:

    • 로컬 상태(Local State): 단일 컴포넌트 내에서만 사용되는 상태 → useState로 충분
    • 크로스 컴포넌트 상태(Cross-Component State): 여러 컴포넌트에 걸쳐 참조되는 상태 → 컨텍스트(Context)나 prop 드릴링, 혹은 리덕스
    • 앱 와이드 상태(App-wide State): 애플리케이션 전반에 영향을 미치는 전역 상태(예: 인증 정보) → 컨텍스트나 리덕스 활용
    • 리덕스는 컨텍스트보다 더 체계화되고, 예측 가능한 전역 상태 관리 패턴을 제공함
(로컬 상태) useState/useReducer
       │
       ▼
  단일 컴포넌트 영향

(크로스 컴포넌트/앱 와이드 상태)
       │
       ▼
  리액트 컨텍스트 (간단한 전역 상태)
       │
       ▼
   prop 드릴링 감소
       │
       └─ 여전히 복잡해질 수 있음

(더 큰 규모, 복잡한 상태)
       │
       ▼
        리덕스(Redux)
         ├─ 중앙 스토어(Store)에 상태 집중화
         ├─ 액션(Action)과 리듀서(Reducer)로 명확한 업데이트 로직
         └─ 규모가 커져도 예측 가능하고 유지 관리 용이
  • git commit 1
  • 위 코드와 구조 예시는 리덕스를 활용하는 기본적인 패턴을 보여줍니다.
    • configureStore로 스토어를 설정하고, slice(조각)별 리듀서를 조합합니다.
    • Provider로 앱 전체를 감싸서 전역 상태를 어디서든 접근 가능하게 합니다.
    • useSelector와 useDispatch 훅을 통해 컴포넌트에서 상태를 읽고 업데이트할 수 있습니다.

258. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리덕스 대 리액트 컨텍스트

리덕스를 사용하는 주요 이유 중 하나는 리액트 컨텍스트(React Context)의 한계를 보완하기 위함입니다. 컨텍스트만으로 앱 와이드 상태를 관리할 수 있지만, 이는 규모가 커질수록 다음과 같은 잠재적 문제가 있습니다.

  • 설정 및 관리 복잡성 증가:

    • 대형 애플리케이션에서는 관리해야 할 상태가 많아지며, 이를 모두 컨텍스트로 처리하면
      • 너무 많은 ContextProvider가 중첩되어 복잡한 JSX 구조가 형성되거나
      • 하나의 거대한 ContextProvider가 모든 상태를 관리하게 되어 유지보수가 어려워질 수 있습니다.
  • 성능 이슈:

    • 공식 리액트 팀원들의 언급에 따르면, 컨텍스트는 테마 변경이나 인증 상태처럼 변경 빈도가 낮은(App-wide지만 자주 바뀌지 않는) 상태 관리에는 적합하지만,
    • 변경 빈도가 높은(High-frequency updates) 상태를 컨텍스트로 관리하면 성능 저하가 발생할 수 있습니다.

결국, 규모가 크고 상태 변경이 빈번한 애플리케이션에서는 컨텍스트만으로는 한계가 있으며, 이때 리덕스(Redux)가 더 예측 가능하고 체계적인 상태 관리 패턴을 제공하여 상황을 개선할 수 있습니다.

       (앱 와이드 상태 관리 필요)
               │
        리액트 컨텍스트만 사용
               │
               ├─ 대형 앱: 다수의 ContextProvider 중첩 
               │          또는 하나의 거대 Provider로 복잡성 ↑
               │
               ├─ 변경 빈도 높은 상태 관리 시 성능 문제
               │
               ↓
           한계 및 비효율 ↑
               │
       ────────────────────────
               │
               ▼
            리덕스 도입
               │
        중앙 스토어 & 정형화된 상태 업데이트 패턴
               │
           복잡성↓, 예측 가능성↑, 성능 개선
  • git commit 1
    • 위 코드 예시에서:
      • 테마 관리(빈도 낮은 변경)는 컨텍스트 사용
      • 인증 상태(로그인/로그아웃) 등 빈번하게 바뀔 수 있는 상태는 리덕스로 관리
      • 이렇게 하면 복잡한 전역 상태를 더 체계적으로 관리하고, 컨텍스트의 성능 및 복잡성 문제를 완화할 수 있습니다.

259. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리덕스 작동 방식

  • 리덕스(Redux)의 작동 방식은 다음과 같습니다.
    • 중앙 저장소(Store):
      • 애플리케이션 전체의 상태를 하나의 중앙 저장소에 보관합니다. 이 저장소에는 인증 상태, 테마, 사용자 입력 등 모든 앱 와이드 상태가 모여있습니다.
    • 구독(Subscription):
      • 컴포넌트는 이 중앙 저장소를 구독합니다.
      • 구독한 컴포넌트는 저장소 상태가 변경될 때마다 통지를 받고, 변경된 상태에 따라 UI를 업데이트합니다.
    • 액션(Action):
      • 컴포넌트에서 상태 변경을 직접 수행하지 않고, 상태를 변경하고 싶을 때 “액션” 객체를 발송(dispatch)합니다.
      • 액션은 단순한 자바스크립트 객체로, 어떤 종류의 상태 변경을 원하는지 설명하는 “type” 필드와 필요하다면 추가 데이터(payload)를 포함합니다.
    • 리듀서(Reducer):
      • 액션을 받은 리덕스 스토어는 해당 액션을 리듀서 함수에 전달합니다.
      • 리듀서 함수는 현재 상태와 액션을 바탕으로 새로운 상태를 계산하고 반환합니다.
      • 리듀서는 순수 함수로, 이전 상태를 바탕으로 새로운 상태를 반환함으로써 상태 변경을 예측 가능하고 일관성 있게 관리합니다.

종합 흐름: 컴포넌트 → 액션 발송(dispatch) → 리듀서가 새로운 상태 계산 → 스토어 업데이트 → 구독 컴포넌트에게 알림 → 컴포넌트 UI 업데이트

[컴포넌트] ─ (dispatch) → [액션] → [스토어(리듀서)]
   │                                 │
   │ (구독)                          │ (새 상태 반환)
   ↓                                 ↓
[상태 변경 통지 수신] ←───────────────[새로운 상태 저장]
   │
   ↓ (상태 업데이트 반영)
[컴포넌트 UI 재렌더링]
  • git commit 1
    • 컴포넌트는 액션을 디스패치(dispatch)합니다.
    • 스토어는 해당 액션을 리듀서(여기서는 slice를 통한 간단한 리듀서)로 전달해 새로운 상태를 계산합니다.
    • 상태가 변경되면 스토어를 구독하는 컴포넌트가 변경 사항을 받고 UI를 업데이트합니다.

260. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 반드시 읽어야 할 내용: 리덕스 createStore()는 사용(불)가능합니다.

  • 반드시 읽어야 할 내용: 리덕스 createStore()는 사용(불)가능합니다.

    • 다음엔 리덕스와 그 사용법에 대해 알아봅니다. 이 강의들 중에, createStore()라는 함수를 사용하여 리덕스 저장소라는 걸 생성할 것입니다.
    • 코드에 이 함수를 사용하면, IDE에서 또는 앱을 실행할 때 사용 중단 경고가 나올 수도 있습니다.
  • 그 경고는 무시해야 합니다!

    • createStore()는 여전히 문제없이 사용할 수 있습니다.
    • 리액트 리덕스 팀은 리덕스 툴킷이라는 추가 패키지와 리덕스 스토어를 생성하는 다른 방법을 사용할 것을 권장합니다. 이 패키지는 뒷부분에서 조금 더 다룰 예정입니다. 하지만 먼저 createStore()를 살펴보면서, 리덕스의 작동 방식과 기능을 공부하게 될 것입니다. 이는 (언급했듯이 나중에 다룰 예정인) 리덕스 툴킷을 사용하든 사용하지 않든 필요한 중요한 지식입니다!

261. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 핵심 리덕스 개념 탐색하기

아래 정리는 리덕스(Redux)의 기초를 실제 코드 실행 과정과 함께 이해할 수 있도록 구성한 것입니다. 또한, 데이터 흐름도를 화살표로 표현하고, 타입스크립트 기반의 폴더 구조 및 예시 코드를 전체적으로 제안합니다.

  1. 초기 환경 설정
  • 빈 폴더를 하나 준비한 뒤, 해당 폴더에서 npm init -y 명령을 통해 기본 package.json 파일을 생성한다.
  • npm install redux 명령을 통해 리덕스 패키지를 설치한다.
    • 설치 후 node_modules 폴더가 생성되며, 여기 Redux 관련 파일들이 들어있다. 이렇게 하면 리덕스를 사용할 준비가 끝난다. 아직 React 앱이나 다른 UI 요소는 필요 없다. 단순히 Node.js 환경에서 Redux 동작을 실습할 수 있다.
  1. Redux 스토어(store) 생성
  • 스토어는 애플리케이션의 상태(state)를 담는 객체이며, createStore() 함수를 통해 생성할 수 있다.
  • 스토어를 생성할 때 반드시 리듀서(reducer) 함수를 인자로 전달해야 한다.
    • 스토어: 상태를 관리하고, 구독(subscribe)과 액션 디스패치(dispatch)를 제공한다.
    • 리듀서: 액션(action)을 기반으로 새로운 상태를 반환하는 순수 함수이다.
  1. 리듀서(reducer) 함수 구성
  • 리듀서는 (현재 상태, 액션) => 새로운 상태 형태의 순수 함수이다.
  • 초기 상태가 없을 때를 대비하여 리듀서 함수 파라미터에 초기 상태(default state)를 설정해야 한다.
  • 예: 카운터를 관리하는 counterReducer를 만들 수 있다. 초기 상태를 { counter: 0 }로 두고, 액션에 따라 counter 값을 변경한다.
  • 리듀서는 상태를 직접 변경하지 않고, 기존 상태를 참조한 뒤 새로운 상태 객체를 반환한다.
  1. 스토어 구독(subscribe)
  • store.subscribe(구독함수)를 사용하면 상태가 변경될 때마다 특정 함수를 실행할 수 있다.
  • 구독 함수 내부에서는 store.getState()를 호출해 최신 상태를 가져올 수 있으며, 이를 콘솔에 출력하거나 UI 갱신에 활용할 수 있다.
  1. 액션(action) 디스패치
  • store.dispatch({ type: 'increment' })와 같은 형태로 액션을 발송하면(dispatch) 리듀서가 호출되어 새로운 상태가 계산된다.
  • 액션은 최소한 type 필드를 가져야 하며, 문자열을 통해 어떤 동작을 하는지 식별한다.
  • 최초 스토어 생성 시에도 리덕스는 내부적으로 초기화를 위한 액션을 한 번 발송하므로 리듀서는 최소 한 번 실행된다.
  • 우리가 커스텀 액션을 디스패치하면 상태가 갱신되고, 구독 함수가 실행되어 상태 변화를 확인할 수 있다.
  1. 정리
  • npm init & redux 설치 -> reducer 정의 -> store 생성 -> subscribe로 상태 변화 감지 -> dispatch로 액션 발송
  • 초기 상태를 정하고, 액션에 따라 상태를 변화시킨다.
  • 현재까지는 단순히 하나의 액션(type: 'increment')으로 상태를 변경하는 예제를 통해 Redux의 기본 개념을 살펴봤다.
   ┌────────────────┐      ┌────────────────┐
   │    Action      │      │    Reducer     │
   │ (ex:'increment')│      │ (counterReducer)│
   └───────┬────────┘      └───────┬────────┘
           │                         │
           │ dispatch(action)        │ returns new state
           ▼                         │
      ┌───────────┐  getState()  ┌──▼───────────┐
      │   Store    │─────────────>│  Subscriber  │
      │ (createStore)              └─────────────┘
      └─────┬─────┘
            │
            │ subscribe(subscriberFn)
            │
            ▼
        console.log(state) or UI update

데이터 흐름: 액션 디스패치 → 리듀서 호출 → 새로운 상태 계산 → 스토어에 상태 저장 → 구독 함수 호출 → 상태 확인 및 출력

262. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 더 많은 리덕스 기본 사항

아래 정리는 리덕스(Redux)에서 액션 타입별로 상태 변경 로직을 처리하는 과정을 이해하기 쉽게 설명한 뒤, 데이터 흐름도를 제시하고, 타입스크립트 기반의 예시 코드를 제안한다.

  1. 액션 타입과 리듀서(Reducer)
  • 리덕스에서 상태 변경은 액션(Action)에 의해 트리거된다. 액션은 { type: string, payload?: any } 형식의 객체이며, 최소한 type 필드를 가져야 한다.
  • counterReducer 예시에서는 두 가지 액션 타입을 가정할 수 있다:
    • 'increment' : 카운터 값을 1 증가시키는 액션
    • 'decrement' : 카운터 값을 1 감소시키는 액션
  1. 리듀서 내부 로직 변경하기
  • 리듀서 함수는 (state, action) => newState 형태의 순수 함수이다.
  • 액션 타입에 따라 조건문(if 또는 switch)을 사용해 상태 변경 로직을 다르게 처리할 수 있다.
  • 예를 들어, 초기 상태를 { counter: 0 }라고 했을 때,
    • action.type === 'increment'일 경우: { counter: state.counter + 1 } 반환
    • action.type === 'decrement'일 경우: { counter: state.counter - 1 } 반환
    • 그 외의 액션(예: 초기화 액션)은 변경 없이 현재 상태를 그대로 반환
  1. 동작 과정
  • 스토어(store)를 생성할 때 createStore(counterReducer)로 스토어를 만든다.
  • 구독(subscribe) 함수를 등록하면, 상태가 변경될 때마다 해당 함수가 호출되어 새로운 상태를 확인할 수 있다.
  • store.dispatch({ type: 'increment' })를 호출하면 리듀서가 실행되어 counter가 1 증가하고, 구독 함수가 트리거되어 카운터 값을 콘솔에 출력한다.
  • 이어서 store.dispatch({ type: 'decrement' })를 호출하면 카운터가 1 감소하여 0이 되고, 다시 구독 함수가 실행되어 변경된 값을 출력한다.
  1. 결과
  • 초기화 시점에 리덕스는 내부적으로 초기 액션을 디스패치하나 여기서는 counter를 변경하지 않는다.
  • increment 액션 후에는 counter 값이 증가한다.
  • decrement 액션 후에는 counter 값이 감소한다.
  • 이를 통해 액션 타입별로 다양한 상태 변화를 구현할 수 있으며, 리액트 없이도 리덕스 로직을 실행하는 데 문제가 없다.
  • 다음 단계에서는 실제 리액트 앱 안에서 리덕스를 사용하는 법을 배울 수 있다.
   ┌──────────────────┐      ┌───────────────────┐
   │   Action          │      │     Reducer       │
   │ {type:'increment'}│      │ (counterReducer)  │
   │ {type:'decrement'}│      └───────┬──────────┘
   └───────┬─────────┘              │
           │   dispatch(action)      │
           ▼                         │ returns new state based on action
      ┌───────────┐    getState() ┌─▼────────────┐
      │   Store    │ ─────────────>│ Subscriber   │
      │ (createStore)                └────────────┘
      └─────┬─────┘
            │
       subscribe(subscriberFn)
            │
            ▼
       console.log(state) or UI update
  • dispatch()를 통해 액션을 스토어에 전달하면, 리듀서에서 액션 타입 검사 후 새로운 상태를 반환한다.
  • 스토어는 새 상태를 저장하고 구독 함수가 호출되어 상태 변화를 반영한다.

263. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리액트용 리덕스 스토어 만들기

아래 정리는 리덕스를 리액트 앱에 통합하는 과정에 대한 설명을 이해하기 쉽게 정리한 것이다. 또한, 데이터 흐름도를 제시하고, 타입스크립트 기반의 예시 코드까지 제안한다.

  1. 리덕스 스토어 준비하기
  • 리액트 앱에 리덕스를 사용하려면 먼저 리덕스 스토어를 준비해야 한다.
  • 일반적으로 리액트 앱의 src/ 폴더 안에 store/ 폴더를 만들어 리덕스 관련 파일을 정리한다.
  • store/index.js 또는 store/index.ts 파일을 만들어, 여기서 createStore()를 호출해 스토어를 생성한다.
  1. 카운터 리듀서(counterReducer) 구현
  • 카운터 상태를 관리하기 위한 리듀서를 만든다.
  • 리듀서는 (state, action) => newState 형태의 순수 함수이며, 상태를 변경할 때는 기존 상태를 기반으로 새로운 객체를 반환한다.
  • 초기 상태를 { counter: 0 }로 둔 뒤, action.type에 따라 다음과 같이 처리한다:
    • 'increment': counter 값을 1 증가
    • 'decrement': counter 값을 1 감소
    • 그 외 액션: 변경 없이 현재 상태 반환
  1. 스토어 생성
  • createStore(counterReducer)를 호출하여 스토어를 만든다.
  • 이렇게 생성된 스토어를 export하여 앱 전반에서 사용할 수 있게 한다.
  • 이전 강의(또는 연습)에서는 스토어를 직접 구독(subscribe)하고 dispatch를 통해 상태 변화를 테스트했다.
  • 하지만 이제는 스토어와 리액트 앱을 연결할 것이므로, 스토어 파일에서 바로 subscribe나 dispatch를 하지 않는다.
  1. 리액트 앱과 스토어 연결(제공, Provide)
  • 리액트 앱에서 리덕스 스토어를 사용하기 위해서는 스토어를 리액트 컴포넌트 트리에 주입해줄 필요가 있다.

  • 이를 위해 react-redux 라이브러리가 제공하는 <Provider> 컴포넌트를 사용한다.

  • <Provider store={store}>로 래핑하면, 트리 하위 모든 컴포넌트가 리덕스 스토어에 접근할 수 있게 된다.

  • “제공(Provide)”한다는 것은 리액트 컴포넌트 계층 전반에 걸쳐 스토어를 참조할 수 있는 환경을 만드는 것을 의미한다.

  • 요약

    • store/ 폴더 내에서 리듀서와 스토어를 정의하고 export한다.
    • 리액트 엔트리 포인트(예: index.tsx)에서 <Provider>로 앱을 감싸 스토어를 제공한다.
    • 이후 앱 내 컴포넌트는 useSelector, useDispatch를 통해 상태 조회와 액션 디스패치가 가능해진다.
┌──────────────────┐    ┌───────────────────┐
│   React App       │    │   Redux Store     │
│(Components)       │    │ (createStore)     │
└─────┬────────────┘    └───────┬──────────┘
      │                Provide   │
      │ <Provider store={store}> │
      │                          │
      │ useDispatch(action)      │
      │------------------------->│
      │                          │
      │ useSelector(state)       │
      │<-------------------------│
      │                          │
┌─────▼────────────┐    ┌───────────────────┐
│    Reducer        │    │   state changes   │
│(counterReducer)   │    │ store updates state│
└───────────────────┘    └───────────────────┘
  • 리액트 컴포넌트들이 <Provider>를 통해 스토어에 접근 가능해진다.
  • 컴포넌트는 useDispatch()로 액션을 스토어에 전달한다.
  • 스토어는 리듀서 호출 후 상태를 업데이트한다.
  • 컴포넌트는 useSelector()를 통해 스토어 상태를 읽는다.

예시 코드는 Redux 툴킷(Redux Toolkit) 사용 패턴을 참조하며, 가장 많이 사용되고 검증된 형태를 제시합니다. (물론 전통적인 createStore 사용 방식도 유사하지만, 최근에는 Redux Toolkit을 사용하는 것이 일반적이며, 타입스크립트와의 궁합도 좋습니다.)

// store/counterSlice.ts
import {createSlice, PayloadAction} from '@reduxjs/toolkit';

interface CounterState {
    value: number;
}

const initialState: CounterState = {
    value: 0,
};

const counterSlice = createSlice({
    name: 'counter',
    initialState,
    reducers: {
        increment(state) {
            state.value += 1;
        },
        decrement(state) {
            state.value -= 1;
        },
        incrementByAmount(state, action: PayloadAction<number>) {
            state.value += action.payload;
        },
    },
});

export const {increment, decrement, incrementByAmount} = counterSlice.actions;
export default counterSlice.reducer;
  • createSlice를 사용하면 액션 생성 함수와 리듀서가 한 파일에 모여 관리가 쉽습니다.
  • increment, decrement 액션을 지원하며, 상태는 value라는 숫자 카운터를 관리합니다.
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

// 스토어 상태 타입과 디스패치 타입을 추론 기반으로 제공
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
  • configureStore를 사용해 store를 생성합니다.
  • 추후 React 컴포넌트에서 useSelector, useDispatch 사용할 때 타입을 안전하게 활용할 수 있도록 RootState, AppDispatch를 내보냅니다.
// main.tsx
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {Provider} from "react-redux";
import {store} from "./store";

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <Provider store={store}>
      <App/>
    </Provider>
  </StrictMode>,
)
  • <Provider store={store}>로 감싸면 App 컴포넌트 이하에서 스토어를 사용할 수 있습니다.
// App.tsx (예시 컴포넌트)
import {useSelector, useDispatch} from 'react-redux';
import {RootState, AppDispatch} from "./store";
import {increment, decrement, incrementByAmount} from './store/counterSlice.ts';

function App() {
  const count = useSelector((state: RootState) => state.counter.value);
  const dispatch = useDispatch<AppDispatch>();

  return (
    <div>
      <h1>카운터: {count}</h1>
      <button onClick={() => dispatch(decrement())}>감소</button>
      <button onClick={() => dispatch(increment())}>증가</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>5만큼 증가</button>
    </div>
  );
}

export default App;
  • useSelector로 상태를 읽고(state.counter.value),

  • useDispatch로 액션을 발생시킵니다(dispatch(increment()) 등).

  • 정리

    1. 스토어 생성: createStore 또는 configureStore로 애플리케이션 전역 상태를 담을 스토어를 만듭니다.
    2. 리듀서 정의: 상태 변경 로직을 담은 리듀서 함수를 만들거나, Redux Toolkit의 createSlice를 사용해 리듀서와 액션을 함께 정의합니다.
    3. Provider로 주입: main.tsx 또는 index.tsx에서 <Provider store={store}>로 감싸 하위 컴포넌트에서 스토어에 접근하게 합니다.
    4. 컴포넌트에서 접근: useSelector로 상태를 읽고, useDispatch로 액션을 발행해 상태를 변경합니다.
  • git commit 1

264. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 스토어 제공하기

아래 정리는 위 내용(리덕스 스토어를 리액트 앱에 제공(Provider)하는 과정)에 대한 자세한 설명과 데이터 흐름도, 그리고 타입스크립트 기반의 예시 코드를 포함합니다. Redux Toolkit 및 react-redux 기반의 일반적인 패턴을 예로 들어 설명합니다.

  • 전반적인 개념

    • Provider 컴포넌트: react-redux 라이브러리가 제공하는 컴포넌트로, 리액트 컴포넌트 트리에 리덕스 스토어를 주입하는 역할을 합니다.
    • 최상단 수준에서의 주입: 앱의 시작점(일반적으로 index.tsx 또는 main.tsx)에서 <Provider>로 전체 앱을 감싸면, 그 하위 모든 컴포넌트가 리덕스 스토어에 접근할 수 있습니다.
    • 부분적인 주입 가능: <Provider>를 특정 하위 트리에서만 사용할 수도 있지만, 그러면 그 하위 컴포넌트만 스토어에 접근 가능합니다. 일반적으로 앱 전체를 스토어와 연계하고자 할 때는 최상단에서 <Provider>로 감싸는 것이 권장됩니다.
  • 과정 정리

    1. store/index.ts (또는 원하는 위치)에서 리덕스 스토어를 생성합니다.
    2. index.tsx (리액트 앱의 엔트리 포인트)에서 react-redux로부터 Provider를 임포트하고, 생성한 스토어를 임포트합니다.
    3. <Provider store={store}><App /> 또는 루트 컴포넌트를 감쌉니다.
    4. 이제 <App /> 이하의 모든 컴포넌트는 useSelector와 useDispatch 훅을 통해 스토어 액세스 및 액션 디스패치가 가능해집니다.

이는 마치 리액트의 Context API에서 <Context.Provider>를 사용했던 것과 비슷한 개념입니다. 단, 여기서는 Redux 스토어를 제공하므로 Provider는 Redux 스토어를 리액트 컴포넌트 트리에 전달하는 역할을 합니다.

  • 기대 효과
    • 스토어를 전체 컴포넌트 트리에 주입함으로써 어느 컴포넌트든 쉽게 상태를 읽을 수 있고(useSelector), 상태 변경을 위한 액션 디스패치(useDispatch)가 가능합니다.
    • 아직 이 시점에서는 스토어를 "제공"만 했을 뿐이며, 실제로 상태를 읽거나 변경하지 않았습니다. 추후 컴포넌트들에서 액션을 디스패치하거나 상태를 구독하는 단계가 필요합니다.
 store (index.ts) ----> index.tsx(main.tsx)에서 Provider로 감쌈 ----> App 및 모든 하위 컴포넌트
     └─(생성된 Redux Store)           └─<Provider store={store}>      
                                               │
                                               ▼
                                     모든 하위 컴포넌트에서 useSelector, useDispatch 가능
  • 설명:
    • store/index.ts에서 스토어를 생성합니다.
    • index.tsx에서 Provider로 <App />을 감쌉니다.
    • 그 결과 App 및 하위 컴포넌트들에서 스토어에 직접 접근(상태 조회, 액션 디스패치)이 가능합니다.

265. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리액트 컴포넌트에서 리덕스 데이터 사용하기

아래는 위 내용(리덕스 스토어에서 상태를 읽어오는 방법, 특히 useSelector 훅을 사용하는 방법)에 대한 자세한 정리입니다. 또한 데이터 흐름을 도식화하고, 타입스크립트 기반 예시 코드를 포함합니다. 예시는 Redux Toolkit과 react-redux를 활용하는 가장 널리 사용되고 검증된 패턴을 기반으로 작성하였습니다.

  • 전반적인 개념

    • useSelector 훅: react-redux가 제공하는 함수형 컴포넌트 전용 훅으로, 리덕스 스토어에 저장된 상태를 쉽게 조회할 수 있게 해줍니다.
    • 상태 선택(Selector) 함수: useSelector에 전달하는 함수는 현재 리덕스 스토어의 상태 state를 매개변수로 받아, 컴포넌트에서 필요로 하는 부분만 리턴합니다. 이를 통해 방대한 전역 상태 중 필요한 일부분만 선택할 수 있습니다.
    • 자동 구독 관리: useSelector를 사용하면 해당 컴포넌트는 리덕스 스토어에 자동으로 구독(subscribe)되며, 상태가 변경될 때마다 자동으로 컴포넌트가 재렌더링되고 최신 상태를 반영합니다. 컴포넌트가 언마운트되면 구독이 자동으로 해지되므로 따로 관리할 필요가 없습니다.
  • 흐름 예시

    1. 컴포넌트가 렌더링될 때 useSelector 호출.
    2. useSelector에 전달한 선택자 함수(selector function)가 리덕스 스토어 상태를 인자로 받음.
    3. 선택자 함수는 필요한 상태 조각을 리턴.
    4. useSelector는 해당 값을 컴포넌트로 반환.
    5. 스토어 상태가 변경되면 useSelector로 상태를 읽고 있는 컴포넌트들은 자동으로 업데이트됨.
  • 기대 효과

    • 컴포넌트는 오직 필요한 상태만 가져와서 UI를 업데이트하므로, 불필요한 재렌더링을 피할 수 있습니다.
    • 코드가 직관적이며, connect 함수에 비해 간결하고 가독성이 좋습니다.
    • 함수형 컴포넌트에서 훅 기반 접근을 자연스럽게 지원합니다.
        ┌────────────────┐
        │  Redux Store    │
        └───────┬────────┘
                │
       (useSelector로 상태 선택)
                │
                ▼
     ┌────────────────────┐
     │ React Component     │
     │ (Counter Component) │
     └───────┬────────────┘
             │ (상태 변경 시 자동 업데이트)
             ▼
        UI 업데이트
  • 설명:
    • Redux Store에 관리되는 전역 상태가 있습니다.
    • 컴포넌트 내부에서 useSelector를 호출하면, 스토어 상태를 받아 필요한 부분을 선택하고 반환합니다.
    • 컴포넌트는 이 값을 사용해 UI를 렌더링합니다.
    • 이후 스토어 상태가 바뀌면, useSelector가 자동으로 최신 값으로 컴포넌트를 재렌더링하여 UI를 최신 상태로 유지합니다.
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

// RootState와 AppDispatch 타입을 추론 및 내보내기
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = { value: 0 };

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// main.tsx
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {Provider} from "react-redux";
import {store} from "./store";

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <Provider store={store}>
      <App/>
    </Provider>
  </StrictMode>,
)
// components/Counter.tsx
import {useSelector} from 'react-redux';
import {RootState} from "../store";

function Counter() {
    // useSelector 훅을 사용하여 Redux 상태에서 카운터 값을 추출
    const counter = useSelector((state: RootState) => state.counter.value);

    return (
        <div>
            <h2>현재 카운터 값: {counter}</h2>
            {/* 추후 increment, decrement 버튼을 추가할 수 있음 */}
        </div>
    );
}

export default Counter;
  • useSelector를 통해 state.counter.value를 받아와 컴포넌트에 표시합니다.

  • 상태가 변경되면 자동으로 최신 값으로 재렌더링됩니다.

  • git commit 1

266. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 내부 컴포넌트에서 Action을 Dispatch하기

아래 정리는 위 내용(리덕스 상태를 변경하기 위해 액션을 디스패치하는 과정)에 대한 자세한 개념 설명과 데이터 흐름도, 그리고 타입스크립트 기반의 예시 코드를 포함합니다. 여기서는 useDispatch 훅을 사용해 액션을 스토어에 전달하고, 리듀서가 이에 따라 상태를 변경하는 전형적인 패턴을 다룹니다.

  • 전반적인 개념

    • useDispatch 훅: react-redux에서 제공하는 훅으로, 리덕스 스토어에 액션을 보낼 수 있는 dispatch 함수를 반환합니다.
    • 액션(action): { type: string, payload?: any } 형태의 객체로, 상태 변경을 유발하는 이벤트를 표현합니다.
    • 리듀서(reducer): 액션에 따라 상태를 변경하는 순수 함수. dispatch(action)이 호출되면 해당 액션 타입에 맞는 리듀서 로직이 실행되어 새로운 상태를 반환합니다.
  • 주요 흐름

    1. 컴포넌트에서 useDispatch() 훅을 사용해 dispatch 함수를 얻습니다.
    2. dispatch 함수에 { type: 'INCREMENT' } 또는 { type: 'DECREMENT' }와 같은 액션을 전달합니다.
    3. 리덕스 스토어는 해당 액션 타입에 맞는 리듀서 로직을 실행하여 상태를 업데이트합니다.
    4. 상태가 바뀌면 useSelector를 통해 상태를 읽는 컴포넌트가 자동으로 업데이트됩니다.
  • 기대 효과

    • 컴포넌트 내에서 클릭 이벤트나 사용자 상호작용에 따라 쉽게 상태를 변경할 수 있습니다.
    • 전역 상태 변경 로직을 명확하게 관리할 수 있어, 코드 가독성과 유지보수성이 향상됩니다.
   (버튼 클릭)
       │
       ▼
   dispatch(action) --→ Redux Store --→ Reducer(상태 변경) --→ 새로운 상태
       │
       ▼
   컴포넌트 재렌더링 (useSelector로 최신 상태 반영)
  • 설명:
    • 사용자가 "Increment" 버튼 클릭 → dispatch({ type: 'INCREMENT' }) 실행
    • Store는 Reducer를 통해 상태를 업데이트
    • 업데이트된 상태는 useSelector로 상태를 사용하는 컴포넌트에 반영 → UI 업데이트
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = { value: 0 };

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    }
  }
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>(); 
  const counter = useSelector((state: RootState) => state.counter.value);

  const incrementHandler = () => {
    dispatch(increment()); // { type: 'counter/increment' } 액션 디스패치
  };

  const decrementHandler = () => {
    dispatch(decrement()); // { type: 'counter/decrement' } 액션 디스패치
  };

  return (
    <div style={{ textAlign: 'center' }}>
      <h2>현재 카운터 값: {counter}</h2>
      <div>
        <button onClick={incrementHandler}>Increment</button>
        <button onClick={decrementHandler}>Decrement</button>
      </div>
    </div>
  );
}

export default Counter;
  • useDispatch를 사용하여 dispatch 함수 획득.

  • 버튼 클릭 시 dispatch(increment()) 또는 dispatch(decrement())로 액션 디스패치.

  • 상태 변경 후 useSelector로 최신 상태 반영.

  • 정리

    1. useDispatch를 통한 액션 디스패치: 컴포넌트에서 useDispatch를 사용하면 리덕스 스토어로 액션을 보낼 수 있습니다.
    2. 리듀서에 따른 상태 변경: 디스패치된 액션 타입에 따라 리듀서가 실행되고 상태가 업데이트됩니다.
    3. 자동 UI 업데이트: 상태 변경 시 useSelector를 통해 해당 상태를 읽는 컴포넌트들이 자동으로 최신 상태를 반영합니다.
    4. 단순하고 명확한 로직: 액션 디스패치는 직관적이며, 리덕스 도입으로 전역 상태 관리가 체계화됩니다.

이로써 버튼 클릭(사용자 상호작용)을 통해 리덕스 상태를 변경하는 전 과정(상태 읽기, 액션 디스패치, 자동 UI 업데이트)을 이해할 수 있게 되었습니다.

267. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 클래스 기반 컴포넌트가 있는 리덕스

아래는 클래스 기반 컴포넌트를 리덕스와 연동하는 방법을 정리한 것입니다. 함수형 컴포넌트에서는 useSelector, useDispatch를 사용하지만, 클래스 기반 컴포넌트에서는 connect 함수를 활용해 리덕스 상태와 액션을 프랍(props)으로 매핑할 수 있습니다. 이 과정을 통해 클래스 컴포넌트도 전역 상태를 간단히 관리하고 업데이트할 수 있게 됩니다.

  • 전반적인 개념

    • 클래스 기반 컴포넌트와 리덕스:
      • 함수형 컴포넌트에서는 useSelector, useDispatch 훅을 사용하지만, 클래스 컴포넌트에는 훅을 사용할 수 없습니다. 이때 react-redux가 제공하는 connect 함수를 통해 리덕스 스토어를 클래스 컴포넌트와 연동할 수 있습니다.
    • connect 함수:
      • connect(mapStateToProps, mapDispatchToProps) 형태로 사용하며, 두 개의 함수를 인자로 받습니다.
      • mapStateToProps는 리덕스 스토어의 상태를 받아 컴포넌트의 props로 매핑합니다.
      • mapDispatchToProps는 dispatch 함수를 받아 액션 디스패치를 props의 함수로 매핑합니다.
      • 이 과정을 통해 클래스 컴포넌트 내에서 this.props를 통해 상태나 디스패치 함수를 접근할 수 있습니다.
  • 동작 방식

    1. connect 호출 시 mapStateToProps와 mapDispatchToProps 함수를 등록합니다.
    2. connect는 고차 컴포넌트(HOC)를 반환하며, 이 HOC를 클래스 컴포넌트에 적용합니다.
    3. 그 결과, 해당 클래스 컴포넌트의 props에는 리덕스 스토어 상태와 액션 디스패치 함수가 주입됩니다.
    4. 컴포넌트는 this.props를 통해 상태를 조회하고, 액션을 디스패치해 상태를 변경할 수 있습니다.
  • 기대 효과

    • 함수형 컴포넌트와 동일하게 전역 상태 관리 가능
    • 기존 클래스 기반 프로젝트에서 훅 전환 없이 리덕스 상태 관리 사용 가능
    • 전환 비용 없이 레거시 코드 유지 및 개선 용이
    Redux Store ----> connect(mapStateToProps, mapDispatchToProps)
           │                          │
           │                          └──> Props (상태 및 디스패치 함수) ---> Class Component
           │                                        (this.props로 접근)
           │
    Action Dispatch <---------- mapDispatchToProps <---------- Class Component (this.props로 액션 호출)
  • 설명:
    • connect 함수를 통해 스토어에서 상태를 가져와 props로 제공(mapStateToProps)
    • 액션 디스패치 함수 또한 props로 제공(mapDispatchToProps)
    • 클래스 컴포넌트는 this.props를 통해 상태 조회, 액션 디스패치 가능
    • 액션 디스패치 시 스토어 상태가 업데이트되고, 컴포넌트는 변경된 상태를 다시 props로 수신
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = { value: 0 };

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    }
  }
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement } from '../store/counterSlice';

// 클래스 컴포넌트용 prop 타입
interface CounterProps {
  counter: number;
  increment: () => void;
  decrement: () => void;
}

class CounterClass extends Component<CounterProps> {
  incrementHandler = () => {
    this.props.increment(); // props로 주입된 increment 함수 호출
  };

  decrementHandler = () => {
    this.props.decrement(); // props로 주입된 decrement 함수 호출
  };

  render() {
    return (
      <div style={{ textAlign: 'center' }}>
        <h2>현재 카운터 값: {this.props.counter}</h2>
        <div>
          <button onClick={this.incrementHandler}>Increment</button>
          <button onClick={this.decrementHandler}>Decrement</button>
        </div>
      </div>
    );
  }
}

// Redux 상태를 props로 매핑
const mapStateToProps = (state: RootState) => {
  return {
    counter: state.counter.value,
  };
};

// 액션 디스패치를 props로 매핑
const mapDispatchToProps = (dispatch: AppDispatch) => {
  return {
    increment: () => dispatch(increment()),
    decrement: () => dispatch(decrement()),
  };
};

// connect를 이용해 CounterClass 컴포넌트를 리덕스 스토어에 연결
export default connect(mapStateToProps, mapDispatchToProps)(CounterClass);
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import CounterClass from './components/CounterClass';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <Provider store={store}>
      <CounterClass />
    </Provider>
  </React.StrictMode>
);
  • mapStateToProps는 스토어 상태 state.counter.value를 counter prop으로 매핑.

  • mapDispatchToProps는 dispatch(increment()) 등을 수행하는 함수를 increment, decrement prop으로 매핑.

  • connect로 감싼 CounterClass 컴포넌트는 props를 통해 상태와 디스패치 함수에 접근 가능.

  • 정리

    1. 클래스 컴포넌트에서 리덕스 사용: 함수형 컴포넌트의 훅 대신 connect 함수를 사용.
    2. mapStateToProps, mapDispatchToProps: 이 두 함수를 통해 스토어 상태와 디스패치 함수를 컴포넌트의 props로 주입.
    3. HOC(고차 컴포넌트) 패턴: connect는 컴포넌트를 래핑하여 리덕스 스토어와 연결하는 HOC 패턴을 사용.
    4. 레거시 코드 및 기존 프로젝트 지원: 기존 클래스 기반 코드에서도 리덕스 전역 상태 관리를 쉽게 통합 가능.

이로써 클래스 기반 컴포넌트에서 connect를 사용해 리덕스 상태 및 액션에 접근하는 방식을 이해할 수 있습니다. 이는 함수형 컴포넌트의 훅 패턴과 유사한 목적을 달성하나, 구형 문법 지원 또는 레거시 코드 유지에 유용한 방법입니다.

268. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 작업에 페이로드 연결하기

아래는 리덕스(Redux)에서 액션에 추가 데이터(payload)를 담아 상태 변경을 유연하게 하는 방법에 대한 설명입니다. 기존의 단순한 type만 포함한 액션에서 한 걸음 더 나아가, 액션 객체에 원하는 데이터를 함께 담아 리듀서에서 이를 활용하는 패턴을 정리하였습니다.

  • 전반적인 개념

    • 액션(action) 객체: { type: string; payload?: any } 형태로, 상태 변경을 일으키기 위한 정보를 담는 객체입니다. 여기서 payload는 액션에 부가적인 데이터를 전달하는 용도로 활용할 수 있습니다.
    • payload의 필요성: 이전 예제에서는 단순히 카운터를 1씩 증가/감소하는 액션만 보냈습니다. 그러나 실전에서는 사용자의 입력값, 서버 응답 데이터, 특정 수치 등을 기반으로 상태를 변경해야 합니다. 이럴 때 payload를 통해 원하는 값을 액션과 함께 전달할 수 있습니다.
    • 유연한 상태 변경: 리듀서(reducer)는 액션에 담긴 payload 값을 참조하여 상태를 변경합니다. 이렇게 하면 리듀서 로직을 하드코딩된 값에 의존하지 않고, 다양한 상황에 대응할 수 있게 됩니다.
  • 과정 정리

    1. 액션 발생 시 dispatch({ type: 'increase', amount: 5 })처럼 추가 데이터를 전달할 수 있습니다. 이때 amount는 임의의 이름으로 원하는 속성을 담을 수 있습니다.
    2. 리듀서는 해당 액션의 action.amount(또는 action.payload) 값을 참조하여 상태를 변경합니다.
    3. 이렇게 하면 5, 10, 사용자 입력 값 등 자유롭게 변경할 수 있는 증가량을 상태 변경 로직에 반영할 수 있습니다.
  • 기대 효과

    • 상태 변경 로직이 더 유연해지고, 재사용 가능해집니다.
    • 사용자의 입력, 외부 데이터 등 다양한 소스를 바탕으로 전역 상태를 쉽게 업데이트할 수 있습니다.
    • 코드 유지보수성과 확장성이 높아집니다.
   (사용자 동작: "10만큼 증가" 버튼 클릭)
          │
          ▼
     dispatch({ type: 'increase', amount: 10 })
          │
          ▼
       Redux Store 
          │
          ▼
       Reducer 함수 ----- action.amount(10) -----> 새로운 상태 (counter += 10)
          │
          ▼
     컴포넌트 자동 업데이트 (useSelector 통해 새로운 상태 반영)
  • 설명:
    • 사용자가 버튼 클릭 → dispatch로 액션 발생
    • 액션 객체에는 type뿐만 아니라 amount: 10 같은 추가 데이터 포함
    • 리듀서가 action.amount를 참조해 상태 변경
    • 변경된 상태는 useSelector로 컴포넌트에 반영, UI 자동 업데이트
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = { value: 0 };

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      // 액션에서 payload로 받은 값을 상태 변경에 활용
      state.value += action.payload;
    }
  }
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>();
  const counter = useSelector((state: RootState) => state.counter.value);

  const increaseHandler = () => {
    // 10만큼 증가하는 액션 디스패치
    // 액션 객체에 payload로 숫자(10)를 전달
    dispatch(incrementByAmount(10));
  };

  return (
    <div style={{ textAlign: 'center' }}>
      <h2>현재 카운터 값: {counter}</h2>
      <div>
        <button onClick={() => dispatch(decrement())}>-1 감소</button>
        <button onClick={() => dispatch(increment())}>+1 증가</button>
        <button onClick={increaseHandler}>+10 증가</button>
      </div>
    </div>
  );
}

export default Counter;
  • incrementByAmount 리듀서에서 action.payload를 사용해 상태를 변경합니다.
  • PayloadAction<number>로 타입을 지정해 payload가 number임을 명확히 했습니다.

  • increaseHandler에서 incrementByAmount(10) 액션을 디스패치합니다.

  • 리듀서(incrementByAmount)는 이 payload 값을 사용해 상태를 10만큼 증가시킵니다.

  • 버튼 클릭 시 카운터 값이 10씩 증가하는 것을 확인할 수 있습니다.

  • 정리

    1. 액션에 payload 추가: 액션 객체에 type 이외의 값(예: amount, payload)을 포함시켜 상태 변경 로직을 유연하게 합니다.
    2. 리듀서에서 payload 활용: 리듀서 함수는 action.payload를 참조하여 동적으로 상태를 변경할 수 있습니다.
    3. 입력값 처리 및 확장성 확보: 하드코딩된 값을 피하고, 사용자 입력값이나 외부에서 주어진 데이터를 활용할 수 있어, 현실적인 시나리오에 쉽게 적용할 수 있습니다.

이로써 단순한 액션에서 한 걸음 더 나아가, 액션 객체를 활용해 다양한 상황에 대처하는 확장성 있는 상태 관리가 가능함을 확인할 수 있습니다.

269. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 여러 State 속성 작업하기

아래는 전역 상태에 단일 숫자 카운터 값뿐 아니라, "카운터를 보여줄지 여부"를 제어하는 불리언 상태(showCounter)도 리덕스 스토어에서 관리하는 과정을 정리한 것입니다. 또한 이를 토글하는 액션을 추가하는 방법과 전체 흐름을 도식화하였으며, 타입스크립트 기반의 예시 코드를 제시합니다.

  • 전반적인 개념

    • 전역 상태 관리 확대: 단순히 counter라는 숫자 하나만 관리하는 대신, showCounter라는 불리언 상태를 추가하여 카운터의 표시 여부를 전역 상태로 관리할 수 있습니다.
    • Reducer에서 상태 확장: Reducer 초기 상태에 showCounter: true와 같이 불리언 필드를 추가하고, 새로운 액션 타입(toggle)을 처리하는 조건문(또는 switch문)을 구현합니다.
    • toggle 액션 디스패치: 버튼 클릭 시 dispatch({ type: 'toggle' })를 호출하여 showCounter 값을 반전시킵니다.
    • useSelector로 상태 읽기: 컴포넌트에서 useSelector를 통해 showCounter 값을 읽어와, 해당 값이 true일 때만 카운터 UI를 렌더링하게 합니다.
  • 상태 관리의 유연성

    • 이 예제에서는 전역 상태로 UI 표시 여부를 관리했지만, 일반적으로 표시/숨김 같은 UI 상태는 로컬 상태(useState)로 관리하는 것이 적절합니다.
    • 여기서는 학습 목적으로 전역 상태 관리를 확장해보는 것이므로, 실제 프로젝트 상황에 따라 적절한 상태 관리 범위를 결정해야 합니다.
토글 버튼 클릭
       │
       ▼
   dispatch({ type: 'toggle' }) 
       │
       ▼
    Redux Store 
       │
       ▼
   Reducer 함수에서 
   showCounter = !showCounter
       │
       ▼
  showCounter 값 변경
       │
       ▼
useSelector로 showCounter 읽는 컴포넌트 자동 업데이트
       │
       ▼
 카운터 표시/숨김 상태 변경 (UI 렌더링 반영)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialState: CounterState = {
  value: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

export const { increment, decrement, incrementByAmount, toggle } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount, toggle } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>();
  const counter = useSelector((state: RootState) => state.counter.value);
  const show = useSelector((state: RootState) => state.counter.showCounter);

  const incrementHandler = () => {
    dispatch(increment());
  };

  const decrementHandler = () => {
    dispatch(decrement());
  };

  const increaseByTenHandler = () => {
    dispatch(incrementByAmount(10));
  };

  const toggleCounterHandler = () => {
    dispatch(toggle());
  };

  return (
    <div style={{ textAlign: 'center' }}>
      <h2>리덕스 카운터</h2>
      {show && (
        <div>
          <h3>현재 카운터 값: {counter}</h3>
          <button onClick={decrementHandler}>-1 감소</button>
          <button onClick={incrementHandler}>+1 증가</button>
          <button onClick={increaseByTenHandler}>+10 증가</button>
        </div>
      )}
      <button onClick={toggleCounterHandler}>토글 카운터 표시</button>
    </div>
  );
}

export default Counter;
  • showCounter 필드를 추가해 카운터 표시 여부를 관리하는 상태를 확장했습니다.
  • toggle 리듀서를 통해 showCounter 값을 반전시킵니다.

  • showCounter 상태를 읽어 카운터를 조건부로 렌더링합니다.

  • toggleCounterHandler에서 dispatch(toggle())를 호출해 showCounter 값을 반전시킵니다.

  • 이로써 토글 버튼 클릭 시 카운터가 사라졌다가 다시 나타나는 동작을 확인할 수 있습니다.

  • 정리

    • 전역 상태 확장: 단일 숫자 카운터 이외에도 불리언 필드(showCounter)를 전역 상태로 관리하여 UI 표시 여부를 제어할 수 있습니다.
    • 토글 액션 추가: toggle 액션을 통해 상태를 간단히 반전시키는 로직을 리듀서에 추가했습니다.
    • useSelector와 조건부 렌더링: 컴포넌트에서 useSelector로 showCounter 값을 읽고 조건부 렌더링을 적용해 UI를 동적으로 제어합니다.
    • 실제 상황에 맞는 상태 관리 선택: 학습 목적으로 전역 상태 관리에 포함했지만, 실제로는 UI 표시 여부는 로컬 상태(useState)로 관리하는 것이 더 적절한 경우가 많습니다. 상황에 따라 전역/로컬 상태 관리 방식을 신중히 선택해야 합니다.
  • git commit 1

270. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리덕스 State를 올바르게 사용하는 방법

아래 정리는 Redux 리듀서에서 상태를 업데이트할 때 반드시 지켜야 할 "상태 불변성(immutability)" 원칙에 대한 내용입니다. 기존 상태를 직접 변경하는 대신 항상 새로운 상태 객체를 반환해야 하는 이유와 그 중요성에 대해 자세히 설명합니다. 또한 데이터 흐름을 도식화하고, 타입스크립트 기반의 예시 코드를 제시합니다.

  • 전반적인 개념

    • 불변성(immutability):
      • Redux 상태는 변경 불가능한(immutable) 것으로 간주해야 합니다.
      • 즉, 리듀서에서 기존 상태 객체를 직접 수정하지 않고, 항상 새로운 상태 객체를 반환해야 합니다.
    • 새로운 상태 스냅숏 반환:
      • 리듀서는 액션에 따라 상태를 변경할 때마다 완전히 새로운 상태 스냅숏을 반환합니다.
      • 기존 상태를 덮어쓰는 것이 아니라, 새로운 객체를 반환함으로써 리액트 컴포넌트와 Redux DevTools 등이 상태 변화를 추적할 수 있게 됩니다.
    • 왜 불변성이 중요한가?
      • 예측 가능성: 상태 변경이 명확히 기록되어 디버깅이 용이해집니다.
      • 성능 최적화: 리액트는 참조 변화를 감지하여 효율적으로 렌더링을 최적화합니다. 불변성을 지키면 참조 비교를 통한 상태 변경 감지가 용이해집니다.
      • 유지보수성: 규모가 커질수록 예측 불가능한 상태 변경은 버그를 야기합니다. 불변성을 지키는 습관은 안정적인 코드 관리에 도움이 됩니다.
  • 잘못된 예시

    • 기존 상태 객체 state를 직접 수정한 뒤 그대로 반환하는 경우.

      state.counter++;
      return state; // 기존 state를 직접 변경하는 잘못된 예
    • 이렇게 하면 참조가 동일하므로 상태 변경 추적이 어렵고, 의도치 않은 부작용 발생 가능성이 높습니다.

  • 올바른 패턴

    • 기존 상태를 복사한 새 객체를 만든 뒤, 필요한 속성만 변경하여 반환합니다.

      return {
        ...state,
        counter: state.counter + 1,
      };
    • 객체나 배열이 중첩된 경우에도 마찬가지로 내부까지 새로운 객체/배열로 복사하여 변경해야 합니다.

액션 발생 (dispatch action)
      │
      ▼
    Reducer 함수
      │
      ▼ (절대 기존 state 수정 금지)
 새 상태 객체 반환 (기존 state + 변경 사항을 반영한 신규 객체)
      │
      ▼
  Redux Store 상태 업데이트
      │
      ▼
React 컴포넌트 재렌더 (상태 변경 반영)
  • 리듀서: 기존 state를 인자로 받지만, 이를 직접 변경하지 않고 새로운 객체를 반환
  • Store: 새로운 상태 객체로 업데이트
  • UI: 변경된 상태를 반영하여 자동 렌더링
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialState: CounterState = {
  value: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      // Redux Toolkit의 createSlice는 immer 라이브러리를 사용하므로
      // 여기서 state.value++ 처럼 작성해도 내부적으로 불변성을 유지합니다.
      // 그러나 전통적인 Redux 패턴에서는 새로운 객체를 반환해야 합니다.
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
    // 전통적인 Redux 패턴 예시 (불변성 유지):
    // 아래와 같이 불변성을 스스로 유지하는 코드라면,
    // 새로운 객체를 직접 반환하는 방식으로 작성해야 합니다.
    // increment(state) {
    //   return {
    //     ...state,
    //     value: state.value + 1,
    //   };
    // },
  },
});

export const { increment, decrement, incrementByAmount, toggle } = counterSlice.actions;
export default counterSlice.reducer;
  • createSlice를 쓰면 immer를 통해 불변성이 자동 유지되지만, 전통적인 Redux 패턴에서는 ...state 스프레드 연산자를 사용해 새로운 객체를 반환해야 합니다.

  • 예를 들어 불변성을 직접 관리하려면:

    increment(state) {
      // 전통적 방식: 새로운 객체 반환
      return {
        ...state,
        value: state.value + 1,
      };
    },
    • 이처럼 항상 새로운 객체를 반환하는 패턴을 따라야 합니다.
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount, toggle } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>();
  const { value, showCounter } = useSelector((state: RootState) => state.counter);

  const incrementHandler = () => dispatch(increment());
  const decrementHandler = () => dispatch(decrement());
  const increaseByTenHandler = () => dispatch(incrementByAmount(10));
  const toggleHandler = () => dispatch(toggle());

  return (
          <div style={{ textAlign: 'center' }}>
            <h2>리덕스 카운터</h2>
            {showCounter && <div>현재 값: {value}</div>}
            <button onClick={decrementHandler}>-1 감소</button>
            <button onClick={incrementHandler}>+1 증가</button>
            <button onClick={increaseByTenHandler}>+10 증가</button>
            <button onClick={toggleHandler}>토글</button>
          </div>
  );
}

export default Counter;
  • 정리
    1. 불변성 유지: 리듀서에서 기존 상태 객체를 직접 수정하지 않고, 항상 새로운 객체를 반환해야 합니다.
    2. 참조 타입 주의: 객체와 배열은 참조 타입이므로, 기존 상태를 실수로 변경하지 않도록 주의해야 합니다.
    3. 예측 가능성과 유지보수성 확보: 불변성을 지키면 상태 변경 흐름이 명확해져 디버깅과 유지보수가 쉬워집니다.
    4. Redux Toolkit 사용 권장: Redux Toolkit은 immer를 통해 불변성을 자동으로 지원하여 실수 가능성을 줄여줍니다.

이로써 Redux 상태 업데이트 시 불변성을 유지하는 이유와 방법을 숙지할 수 있습니다. 불변성은 작은 예제에서도 지켜야 할 중요한 원칙이며, 규모가 큰 애플리케이션에서는 더욱 중요한 가이드라인입니다.

271. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리덕스 도전과제 및 리덕스 툴킷 소개

아래 정리는 기존에 설명한 Redux 사용 시 발생할 수 있는 문제점과 이를 더 쉽고 효율적으로 해결할 수 있는 Redux Toolkit에 대한 소개를 다룹니다. 또한 데이터 흐름 도식화와 타입스크립트 기반의 예시 코드도 포함합니다.

  • 발생 가능한 문제점

    1. 액션 식별자(type) 관리의 어려움:
    • 오타 발생 시 제대로 동작하지 않음
    • 규모가 커질수록 식별자 충돌 가능성 증가
    • 해결책: 상수(const)를 사용해 액션 타입을 중앙에서 관리하거나 다른 구조를 도입
    1. 상태 관리 복잡성 증가:
    • 애플리케이션 규모가 커지면 상태도 복잡해짐
    • 중첩된 객체, 배열 등을 매번 불변성을 유지하며 복사하기 번거로움
    • 리듀서 파일이 지나치게 커져 유지보수 어려움
    • 해결책: 리듀서를 여러 개로 분리하고 combineReducers 사용, 또는 관리 용이한 패턴 적용
    1. 불변성(immutability) 유지의 어려움:
    • 중첩된 상태 구조를 변경하지 않고 새로운 객체를 생성하는 과정이 복잡
    • 실수로 기존 상태를 mutate(변경)할 위험 존재
    • 해결책: 불변성 유지를 도와주는 라이브러리(immer) 또는 Redux Toolkit 활용
  • Redux Toolkit의 등장

    • Redux Toolkit은 Redux를 더 쉽게 설정하고 사용하게 해주는 공식 라이브러리
    • 기본 Redux에 비해 설정 과정 단축, 불변성 자동 유지(immer 통합), 액션 및 리듀서 관리 용이성 제공
    • 필수는 아니지만 사용 시 생산성과 유지보수성이 크게 향상
(전통적인 Redux 환경)
  상태 증가 -----→ Reducer 복잡도 증가 -----→ 관리 어려움 증가
         │                  │                       │
         ▼                  ▼                       ▼
     액션 식별자       중첩된 상태 복사       불변성 유지 실수
         │                  │                       │
         ▼                  ▼                       ▼
   상수화/정리         Reducer 분리           immer 사용
         │                  │                       │
         ▼                  ▼                       ▼
                 Redux Toolkit 도입
  • 전통적 Redux 사용 시 애플리케이션 규모 증가 → 여러 문제 발생
  • 액션 식별자 상수화, 리듀서 분리, 불변성 보조 툴 사용 등 해결책 존재
  • 궁극적으로 Redux Toolkit 사용 시 대부분의 문제점 대폭 완화
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = { value: 0 };

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      // immer에 의해 state를 직접 변경하는 것처럼 써도 불변성 자동 유지
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
  • 별도의 액션 타입 상수 필요 없음 ('counter/increment' 자동 생성)
  • 불변성 유지 걱정 줄어듦 (immer가 처리)
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer, // 분리된 리듀서들 추가 가능
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>();
  const counter = useSelector((state: RootState) => state.counter.value);

  return (
    <div style={{ textAlign: 'center' }}>
      <h2>리덕스 카운터</h2>
      <div>현재 값: {counter}</div>
      <button onClick={() => dispatch(decrement())}>-1 감소</button>
      <button onClick={() => dispatch(increment())}>+1 증가</button>
      <button onClick={() => dispatch(incrementByAmount(10))}>+10 증가</button>
    </div>
  );
}

export default Counter;
  • 정리
    1. 액션 타입 관리 문제: 상수화나 Redux Toolkit 사용으로 해결 가능
    2. 상태 복잡성 증가: 리듀서 분할, 불변성 라이브러리(immer) 또는 Redux Toolkit 도입으로 간소화
    3. 불변성 유지 어려움: immer를 내장한 Redux Toolkit 사용 시 자동 해결
    4. Redux Toolkit 활용: 설정 간소화, 유지보수성 증가, 실수 방지

앞으로 더 복잡한 프로젝트에서 Redux Toolkit을 활용하면, 전통적인 Redux 설정이나 상태 관리의 복잡성을 크게 줄이고 개발 효율과 안정성을 높일 수 있습니다.

272. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / State 슬라이스 추가하기

아래는 Redux Toolkit의 createSlice를 사용해 리덕스를 더욱 간결하고 유지보수하기 쉽게 만드는 방법을 정리한 내용입니다. 기존 Redux 방식 대비 장점과 특징을 알아보고, 데이터 흐름을 도식화한 뒤, 타입스크립트 기반 예시 코드를 제시합니다.

  • Redux Toolkit과 createSlice

    • createSlice: 액션 타입, 액션 생성자, 리듀서를 한 번에 정의할 수 있는 기능을 제공하는 함수.
    • 별도의 액션 타입 상수나 액션 생성자 함수를 작성할 필요 없이, 하나의 slice 안에 리듀서 로직을 선언하는 순간 자동으로 해당 액션 타입과 액션 생성 함수가 만들어짐.
    • 내부적으로 Immer 라이브러리를 사용해 상태의 불변성을 자동으로 유지해주므로, 리듀서 내부에서 state.counter++처럼 직접 상태를 변경하는 코드를 작성하는 것처럼 보여도, 실제로는 불변성이 유지된 새로운 상태가 반환됨.
  • 특징 및 장점

    1. 액션 타입, 액션 생성 함수, 리듀서 통합 관리: 각각 따로 만들 필요 없이 한 곳에서 관리 가능.
    2. 불변성 신경 X: Immer 덕분에 기존 상태를 직접 변경하듯 작성해도 불변성을 깨지 않음.
    3. 코드량 감소 및 가독성 증가: 기존 Redux 대비 보일러플레이트 코드가 크게 줄어듦.
    4. 확장성: 상태가 복잡해지고 slice가 늘어나도 유지보수가 쉬움. 각 slice 파일에 해당 기능 관련 상태와 로직을 집중시킬 수 있음.
  • 액션(payload) 처리

    • createSlice로 만든 리듀서 함수에서는 state와 action을 매개변수로 받을 수 있음.
    • 필요한 경우 action.payload를 사용해 상태 변경 시 추가 데이터를 참조할 수 있음.
        createSlice() 호출
               │
               ▼
       slice(리듀서, 액션 자동 생성)
               │
      ┌────────┴────────┐
      │                 │
  상태 변경 로직    자동 생성된 액션
   (immer로 불변성)   (dispatch로 호출)
      │                 │
      ▼                 ▼
    새로운 상태       dispatch(action)
      │                 │
      └──────▶ Redux Store 상태 업데이트 ───▶ React UI 반영
  • createSlice를 통해 한 번에 리듀서 로직+액션 생성
  • dispatch(action) 시 slice 리듀서가 불변성을 자동 유지하며 상태 업데이트
  • 변경된 상태는 UI에 자동 반영
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialState: CounterState = {
  value: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: 'counter',           // slice 이름 (액션 타입 prefix로 사용됨)
  initialState: initialState,
  reducers: {
    increment(state) {
      // immer가 자동으로 불변성 관리 → state.value++ 사용 가능
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      // action.payload를 사용해 동적 상태 변경 가능
      state.value += action.payload;
    },
    toggleCounter(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

export const { increment, decrement, incrementByAmount, toggle } = counterSlice.actions;
export default counterSlice.reducer;

// increment, decrement, incrementByAmount, toggleCounter 리듀서 함수 정의
// 각 함수는 Redux Toolkit에 의해 자동으로 액션 생성 함수(increment(), decrement(), incrementByAmount(), toggle())를 만듦.
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Redux Toolkit의 configureStore 사용으로 스토어 설정 간소화.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount, toggle } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>();
  const { value, showCounter } = useSelector((state: RootState) => state.counter);

  return (
    <div style={{ textAlign: 'center' }}>
      <h2>리덕스 카운터 (Redux Toolkit)</h2>
      {showCounter && <div>카운트: {value}</div>}
      <button onClick={() => dispatch(decrement())}>-1 감소</button>
      <button onClick={() => dispatch(increment())}>+1 증가</button>
      <button onClick={() => dispatch(incrementByAmount(10))}>+10 증가</button>
      <button onClick={() => dispatch(toggle())}>카운터 토글</button>
    </div>
  );
}

export default Counter;
// 액션 생성 함수를 바로 dispatch할 수 있으며, 별도의 액션 타입 문자열 관리 불필요
  • 정리
    1. createSlice로 액션, 리듀서, 상태 한 번에 관리
    2. 불변성 자동 유지 (immer 내장): 상태 변경 로직 단순화
    3. 보일러플레이트 감소: 코드량 및 복잡성 현저히 줄어듦
    4. 쉽고 직관적인 API: 규모가 커져도 관리 용이, 확장성 좋음

Redux Toolkit을 사용하면 Redux 설정과 유지 관리가 훨씬 편리해집니다. 이는 규모가 커지는 프로젝트에서도 효율적이고 안정적으로 상태 관리를 수행할 수 있게 해주는 강력한 도구입니다.

273. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리덕스 툴킷 State 연결하기

아래는 Redux Toolkit의 configureStore를 사용하여 store를 설정하고, createSlice에서 정의한 slice의 reducer를 통합하는 방법에 대한 정리입니다. 이로써 기존 createStore와 달리, 여러 slice를 손쉽게 병합할 수 있고, 액션 생성 함수를 직관적으로 사용할 수 있게 됩니다.

  • Redux Toolkit의 configureStore

    • configureStore: Redux store를 생성하는 함수로, 여러 slice로 나뉜 리듀서들을 쉽고 효율적으로 하나로 합쳐주는 기능을 제공합니다.
    • 기존 createStore 대신 configureStore를 사용하면 다음과 같은 장점이 있습니다:
      • 여러 개의 slice reducer를 손쉽게 병합: slice마다 정의한 reducer를 객체 형태로 전달해주면 configureStore가 내부적으로 combineReducers를 호출하여 하나의 최종 리듀서를 만듭니다.
      • 추가적인 미들웨어, DevTools 지원: 별다른 설정 없이도 Redux DevTools, 기본 미들웨어 지원.
  • 예시 흐름

    1. 하나의 slice를 만들 때는 createSlice로 reducer와 actions를 함께 정의합니다.
    2. configureStore로 store를 생성할 때, reducer 필드에 slice의 reducer를 전달합니다. 이때 slice가 하나면 바로 reducer: counterSlice.reducer처럼 설정하면 됩니다.
    3. 만약 여러 slice가 있다면 다음과 같이 reducer: { counter: counterSlice.reducer, auth: authSlice.reducer } 형태로 전달할 수 있습니다.
    4. configureStore는 이 reducer 맵을 내부적으로 하나의 큰 리듀서로 병합합니다.
    5. action을 디스패치할 때는 slice에서 자동으로 생성되는 액션 생성 함수를 사용합니다. 이러한 액션 함수는 slice 객체의 actions 프로퍼티에서 가져올 수 있습니다.
    • 예: dispatch(counterActions.increment())처럼 액션을 디스패치하면 됩니다.
    • 각 액션에는 식별자 문자열을 직접 다룰 필요 없이, createSlice가 자동으로 액션 타입을 관리합니다.
createSlice() ----------------> slice.reducer, slice.actions
       │
       ▼
configureStore({ reducer: slice.reducer }) ----> Redux Store 생성
       │
       ▼
dispatch(slice.actions.increment()) -----------> slice.reducer 처리
       │
       ▼
  Redux Store 상태 업데이트 & UI 반영
  • 설명:
    • createSlice로 slice 정의
    • configureStore에 slice의 reducer 전달
    • dispatch(slice.actions.actionName()) 형태로 액션 디스패치
    • Redux Toolkit 내부적으로 액션 타입 관리 및 불변성 처리 진행
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialState: CounterState = {
  value: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value++; // immer로 불변성 자동 유지
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    toggleCounter(state) {
      state.showCounter = !state.showCounter;
    },
  }
});

export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
  • counterSlice.actions를 통해 increment, decrement, incrementByAmount, toggleCounter 액션 생성 함수를 사용할 수 있습니다.
  • counterSlice.reducer는 이 slice에 해당하는 리듀서 함수를 의미합니다.
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice'; // 단일 slice reducer

export const store = configureStore({
  reducer: counterReducer,
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
  • 단일 slice만 있을 경우 reducer: counterReducer로 바로 전달.
  • 다중 slice가 있다면 reducer: { counter: counterReducer, other: otherReducer } 형태로 전달 가능.
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { counterActions } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>();
  const { value, showCounter } = useSelector((state: RootState) => state);

  return (
    <div style={{ textAlign: 'center' }}>
      <h2>리덕스 카운터</h2>
      {showCounter && <div>값: {value}</div>}
      <button onClick={() => dispatch(counterActions.decrement())}>-1 감소</button>
      <button onClick={() => dispatch(counterActions.increment())}>+1 증가</button>
      <button onClick={() => dispatch(counterActions.incrementByAmount(10))}>+10 증가</button>
      <button onClick={() => dispatch(counterActions.toggleCounter())}>카운터 토글</button>
    </div>
  );
}

export default Counter;
  • counterActions.increment() 형태로 액션을 디스패치.

  • 액션 타입 문자열이나 별도 액션 생성 함수를 직접 정의할 필요 없음.

  • 정리

    1. configureStore를 통한 store 생성: configureStore는 createStore를 대체하며, 복잡한 리듀서 병합 과정을 쉽게 처리합니다.
    2. 단일 또는 다중 slice 관리: slice가 하나면 reducer: slice.reducer를, 여러 개면 { key: slice.reducer } 객체 형태로 전달해 손쉽게 병합 가능합니다.
    3. 액션 디스패치 간소화: createSlice에서 자동으로 생성된 slice.actions를 사용하여 액션을 디스패치하면, 액션 타입 관리 부담이 사라집니다.

이로써 Redux Toolkit을 통해 더욱 편리하고 체계적으로 Redux 상태 관리를 진행할 수 있습니다.

274. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 리덕스 툴킷으로 모든 것을 마이그레이션

아래는 Redux Toolkit의 createSlice를 통해 액션과 리듀서를 간결하고 명확하게 관리하는 방법과 원리에 대한 정리입니다. 또한 데이터 흐름 도식화, 타입스크립트 기반 예시 코드를 포함하였습니다.

  • 액션 생성의 자동화

    • 기존 Redux: 액션 타입 상수 정의, 액션 생성 함수 작성, 리듀서에서 if문 또는 switch문으로 액션 타입 처리 등의 반복적이고 번거로운 과정이 필요했습니다.
    • Redux Toolkit의 createSlice:
      1. createSlice 내 reducers 객체에 메서드 형태로 상태 변경 로직(리듀서) 작성
      2. 각 메서드 이름을 통해 자동으로 액션 타입과 액션 생성 함수가 만들어짐
      3. slice.actions 객체를 통해 액션 생성 함수에 바로 접근 가능
    • 액션 타입 문자열 관리나 오타 걱정 없이, slice.actions.someAction() 형태로 액션을 쉽게 디스패치할 수 있습니다.
  • payload 처리

    • 액션에 추가 데이터를 전달하고 싶다면 actions.someAction(payloadData) 형태로 호출하면 됩니다.
    • Redux Toolkit은 이 값을 action.payload 필드로 리듀서 함수에 전달합니다.
    • 별도로 action.amount와 같이 필드명을 정의할 필요 없이, 항상 action.payload를 통해 추가 데이터를 접근할 수 있습니다.
  • 정리

    • 장점:
      • 액션 타입 관리 불필요
      • 액션 생성 함수 자동화
      • payload 처리 간단화
      • 유지보수성과 가독성 향상
    • 결과적으로, 개발자가 해야 할 일이 줄고, 실수할 여지가 적어집니다.
 createSlice() 정의
       │
       ▼
  slice.actions ----> 액션 생성 함수 자동 생성
       │
       ▼ (dispatch)
   dispatch(slice.actions.incrementByAmount(10))
       │
       ▼
    Redux Store
       │
       ▼
   slice.reducer에서 action.payload 사용
   (이 경우 payload = 10)
       │
       ▼
   상태 업데이트 & UI 반영
  • 설명:
    • createSlice로 리듀서를 정의하면 자동으로 actions가 생성됨
    • 컴포넌트에서 dispatch(slice.actions.someAction(payload)) 형태로 액션 디스패치
    • 리듀서 내부에서 action.payload를 통해 전달된 데이터 사용
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialState: CounterState = {
  value: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      // 전달된 payload(숫자)를 action.payload로 접근 가능
      state.value += action.payload;
    },
    toggleCounter(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
  • counterActions.increment() 호출 시 { type: 'counter/increment' } 액션 객체 자동 생성
  • counterActions.incrementByAmount(10) 호출 시 { type: 'counter/incrementByAmount', payload: 10 } 액션 객체 자동 생성
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: counterReducer,
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { counterActions } from '../store/counterSlice';

function Counter() {
  const dispatch = useDispatch<AppDispatch>();
  const { value, showCounter } = useSelector((state: RootState) => state);

  const incrementHandler = () => {
    dispatch(counterActions.increment());
  };

  const decrementHandler = () => {
    dispatch(counterActions.decrement());
  };

  const increaseByTenHandler = () => {
    dispatch(counterActions.incrementByAmount(10));
  };

  const toggleHandler = () => {
    dispatch(counterActions.toggleCounter());
  };

  return (
    <div style={{ textAlign: 'center' }}>
      <h2>리덕스 카운터 (Redux Toolkit)</h2>
      {showCounter && <div>값: {value}</div>}
      <button onClick={decrementHandler}>-1 감소</button>
      <button onClick={incrementHandler}>+1 증가</button>
      <button onClick={increaseByTenHandler}>+10 증가</button>
      <button onClick={toggleHandler}>토글</button>
    </div>
  );
}

export default Counter;
  • counterActions.increment()로 간단히 액션 디스패치 가능

  • incrementByAmount(10) 호출 시 자동으로 { payload: 10 } 액션 객체 생성 및 전달

  • 정리

    1. 액션 타입 및 액션 생성 자동화: createSlice가 액션 타입, 액션 생성 함수를 자동으로 생성하므로, 반복적 작업 감소
    2. payload 접근 통일: 액션에 전달한 추가 데이터는 항상 action.payload로 접근하므로 일관성과 예측 가능성 높아짐
    3. 코드 간결 및 유지보수성 향상: 분명하고 직관적인 코드 패턴으로 실수와 혼란 감소

이로써 Redux Toolkit을 통해 액션과 리듀서 로직을 훨씬 더 단순하고 안정적으로 관리할 수 있게 됩니다.

275. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 다중 슬라이스 작업하기

아래는 Redux Toolkit을 이용해 여러 개의 slice(슬라이스)를 생성하고, 이를 하나의 스토어에서 관리하는 방법에 대한 정리입니다. 또한 auth(인증) 상태를 관리하는 새로운 slice를 만들고, 여러 slice를 configureStore에 결합하는 과정을 다룹니다. 마지막으로, slice를 여러 개 사용할 때 useSelector로 상태에 접근하는 방법도 정리합니다.

  • 상태의 분리와 Slice

    • Slice란?:
      • 전역 상태를 주제별로 나눈 "조각"입니다.
      • 예를 들어, 카운터 관련 상태는 counterSlice, 인증 관련 상태는 authSlice로 분리할 수 있습니다.
    • 논리적 분리:
      • 한 slice는 하나의 개념(예: 카운터)과 관련된 상태와 로직(리듀서, 액션)만 관리하고, 다른 slice는 또 다른 개념(예: 인증)에 집중합니다.
      • 이를 통해 코드 구조가 명확해지고 유지보수가 용이해집니다.
  • 여러 Slice 통합

    • 하나의 Redux 스토어는 하나의 루트 리듀서만 가질 수 있지만, configureStore를 사용하면 여러 slice 리듀서를 하나의 객체로 전달할 수 있습니다.

      const store = configureStore({
        reducer: {
          counter: counterSlice.reducer,
          auth: authSlice.reducer,
        },
      });
    • 이렇게 하면 counter와 auth라는 두 개의 slice 상태를 전역적으로 관리할 수 있습니다.

  • 상태 접근과 액션 디스패치

    • 여러 slice가 존재하면, useSelector를 통해 state를 읽을 때 state.sliceName을 통해 해당 slice 상태로 접근해야 합니다.
      • 예: state.counter.value 혹은 state.auth.isAuthenticated
    • 액션 생성 함수는 각 slice마다 slice.actions를 통해 export한 뒤, 컴포넌트에서 dispatch(sliceActions.someAction()) 형태로 사용합니다.
  • 예시: 인증 상태 추가

    • authSlice를 만들어 isAuthenticated 상태를 관리하고, login, logout 액션을 제공할 수 있습니다.
    • login 액션 호출 시 isAuthenticated = true, logout 액션 호출 시 isAuthenticated = false로 상태를 갱신합니다.
    • 여러 컴포넌트(Header, Auth, UserProfile 등)에서 이 인증 상태를 참조하거나 변경할 수 있습니다.
counterSlice.reducer ---> configureStore({ reducer: { counter: counterSlice.reducer, auth: authSlice.reducer } }) ---> Redux Store
authSlice.reducer    ---^

dispatch(authActions.login()) ---> authSlice.reducer --> state.auth.isAuthenticated = true
dispatch(authActions.logout()) ---> authSlice.reducer --> state.auth.isAuthenticated = false

useSelector((state) => state.counter.value)  // 카운터 상태 접근
useSelector((state) => state.auth.isAuthenticated)  // 인증 상태 접근
  • 설명:
    • configureStore에 여러 slice 리듀서를 객체 형태로 전달 → 내부적으로 combineReducers 실행
    • dispatch 시 해당 액션에 대응하는 slice 리듀서가 호출되어 상태 변경
    • useSelector 사용 시 state.sliceName을 통해 원하는 slice 상태를 참조
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialCounterState: CounterState = {
  value: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: 'counter',
  initialState: initialCounterState,
  reducers: {
    increment(state) {
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    toggleCounter(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
import { createSlice } from '@reduxjs/toolkit';

interface AuthState {
  isAuthenticated: boolean;
}

const initialAuthState: AuthState = {
  isAuthenticated: false,
};

const authSlice = createSlice({
  name: 'auth',
  initialState: initialAuthState,
  reducers: {
    login(state) {
      state.isAuthenticated = true;
    },
    logout(state) {
      state.isAuthenticated = false;
    }
  },
});

export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    auth: authReducer, 
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// counter와 auth라는 두 개의 slice reducer를 하나의 store로 통합
// state.counter로 카운터 관련 상태 접근, state.auth로 인증 관련 상태 접근
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { authActions } from '../store/authSlice';

function Header() {
  const dispatch = useDispatch<AppDispatch>();
  const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);

  const logoutHandler = () => {
    dispatch(authActions.logout());
  };

  return (
    <header>
      <h1>My App</h1>
      <nav>
        <ul>
          {isAuth && <li><a href="/">My Page</a></li>}
          {isAuth && <li><button onClick={logoutHandler}>Logout</button></li>}
          {!isAuth && <li>로그인 필요</li>}
        </ul>
      </nav>
    </header>
  );
}

export default Header;
// 인증 상태(isAuthenticated)를 읽어 조건부 렌더링
// 로그아웃 액션을 디스패치하여 인증 상태 변경
  • 정리
    • Slice 분리: 주제별로 상태를 나눈 slice를 여러 개 만들 수 있습니다.
    • configureStore로 합치기: 여러 slice.reducer를 객체 형태로 configureStore에 전달하면 자동으로 하나의 루트 리듀서로 합쳐집니다.
    • 상태 접근 시 slice 이름 사용: useSelector로 상태 접근 시 state.sliceName을 통해 해당 slice 상태에 접근합니다.
    • 액션 관리 용이: 각 slice에서 정의한 액션을 별도로 export하고, 컴포넌트에서 필요할 때 dispatch(actions.someAction()) 형태로 사용합니다.

이로써 Redux Toolkit을 활용한 다중 slice 관리와 전역 상태에 쉽게 접근하는 방법을 이해할 수 있으며, 애플리케이션 규모가 커질 때도 상태 관리를 체계적으로 유지할 수 있습니다.

276. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 새 슬라이스에서 읽기 및 Dispatch하기

아래는 Redux Toolkit을 사용해 여러 slice의 상태를 관리하고, 리액트 컴포넌트와 연동하여 로그인/로그아웃 기능을 구현하는 과정을 정리한 것입니다. 또한 데이터 흐름을 도식화하고 타입스크립트 기반 예시 코드를 제시합니다.

  • 전반적인 개념

    • 여러 slice의 통합: 하나의 Redux store 안에 여러 slice를 두어 서로 다른 종류의 상태(예: 카운터, 인증)를 독립적으로 관리할 수 있습니다.
    • UI 동기화: slice 상태를 useSelector로 읽고, 액션(예: login, logout)을 useDispatch로 디스패치하여 상태 변화를 쉽게 반영할 수 있습니다.
    • 상태에 따른 조건부 렌더링: 인증 상태(isAuthenticated)를 기준으로 Auth 폼 혹은 UserProfile, Navigation 등을 조건부로 렌더링할 수 있습니다.
  • 구체적인 흐름

    1. authSlice를 통해 isAuthenticated 상태와 login, logout 액션을 정의합니다.
    2. configureStore로 counterSlice와 authSlice를 묶어 하나의 스토어를 만듭니다.
    3. 컴포넌트(App, Header, Auth, UserProfile)에서 useSelector로 전역 상태를 읽고, useDispatch로 액션을 디스패치합니다.
    4. App 컴포넌트에서 state.auth.isAuthenticated를 참조해 Auth 컴포넌트나 UserProfile을 조건부로 렌더링합니다.
    5. Auth 컴포넌트에서 로그인 폼 제출 시 authActions.login() 액션을 디스패치하여 인증 상태를 갱신합니다.
    6. Header 컴포넌트에서는 authActions.logout() 액션을 디스패치해 로그아웃 기능을 구현합니다.

이로써 하나의 스토어에서 여러 slice 상태를 관리하고, 전역 상태 변화를 쉽고 직관적으로 UI에 반영할 수 있습니다.

사용자 동작: 
로그인 버튼 클릭
       │
       ▼
 dispatch(authActions.login()) 
       │
       ▼
    authSlice.reducer에서
    isAuthenticated = true로 변경
       │
       ▼
 Redux Store 업데이트
       │
       ▼
 useSelector(state => state.auth.isAuthenticated)
       │
       ▼
   App / Header 컴포넌트가 상태 변화 감지
   조건부 렌더링 업데이트 (Auth → UserProfile, Navigation 표시)
  • 로그아웃도 동일한 흐름으로 authActions.logout() 액션 호출 시 isAuthenticated = false로 변하고 UI가 변경됩니다.
import { createSlice } from '@reduxjs/toolkit';

interface AuthState {
  isAuthenticated: boolean;
}

const initialAuthState: AuthState = {
  isAuthenticated: false,
};

const authSlice = createSlice({
  name: 'auth',
  initialState: initialAuthState,
  reducers: {
    login(state) {
      state.isAuthenticated = true;
    },
    logout(state) {
      state.isAuthenticated = false;
    },
  },
});

export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer, 
    auth: authReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { authActions } from '../store/authSlice';

function Auth() {
  const dispatch = useDispatch<AppDispatch>();

  const loginHandler = (event: React.FormEvent) => {
    event.preventDefault();
    dispatch(authActions.login());
  };

  return (
    <form onSubmit={loginHandler}>
      <h2>로그인</h2>
      <button type="submit">로그인하기</button>
    </form>
  );
}

export default Auth;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { authActions } from '../store/authSlice';

function Header() {
  const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);
  const dispatch = useDispatch<AppDispatch>();

  const logoutHandler = () => {
    dispatch(authActions.logout());
  };

  return (
    <header>
      <h1>My App</h1>
      <nav>
        <ul>
          {isAuth && <li><a href="/">내 페이지</a></li>}
          {isAuth && <li><button onClick={logoutHandler}>로그아웃</button></li>}
          {!isAuth && <li>로그인 필요</li>}
        </ul>
      </nav>
    </header>
  );
}

export default Header;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import Header from './Header';
import Auth from './Auth';
import UserProfile from './UserProfile';

function App() {
  const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);

  return (
    <>
      <Header />
      {!isAuth && <Auth />}
      {isAuth && <UserProfile />}
    </>
  );
}

export default App;
  • 정리
    1. 여러 slice 상태 통합: 하나의 store로 multiple slice를 관리하고, 전역 상태를 구조적으로 관리할 수 있습니다.
    2. 조건부 렌더링: 인증 상태에 따라 UI를 동적으로 변경하는 로직을 구현할 수 있습니다.
    3. 액션 디스패치와 상태 읽기 간소화: useDispatch와 useSelector를 통한 간단하고 직관적인 상태 관리.
    4. 실용성 향상: 실제 앱에서 다양한 slice를 관리할 때도 손쉽게 확장 가능하며, 유지보수성이 우수합니다.

이로써 Redux Toolkit을 이용해 다중 slice 상태를 전역적으로 관리하고, UI에 반영하는 전체 흐름을 이해하고 구현할 수 있습니다.

277. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 코드 분할하기

아래는 Redux Toolkit을 사용한 상태 관리 구조를 더 잘 정리하고, 유지보수하기 쉬운 형태로 리팩토링하는 방법에 대한 정리입니다. 각각의 slice(슬라이스)를 별도 파일로 분리하고, index.ts(또는 index.js) 파일에서 모두 결합하는 패턴을 설명합니다. 또한 데이터 흐름을 도식화하고, 타입스크립트 기반의 예시 코드를 제공합니다.

  • 슬라이스 파일 분리의 필요성

    • 애플리케이션 규모가 커질수록 단일 파일에 모든 slice, reducer, 액션을 모아두면 가독성과 유지보수가 어려워집니다.
    • Redux Toolkit을 사용하면 slice를 각자 자신의 파일에 분리한 뒤, index.ts에서 이를 결합하여 스토어를 구성할 수 있습니다.
    • 상태 타입별로 파일을 분리하면, 해당 slice와 관련된 상태, 리듀서 로직, 액션을 한 곳에서 관리할 수 있어 명확하고 직관적입니다.
  • 리팩토링 절차

    1. 개별 slice 파일 생성: 예를 들어 counterSlice.ts 파일에는 카운터 관련 상태, 리듀서, 액션을 정의하고 export 합니다. authSlice.ts 파일에는 인증 관련 로직을 정의합니다.

    2. index.ts에서 통합: configureStore로 store를 생성할 때, 여러 slice에서 export한 reducer를 모두 reducer 프로퍼티의 객체로 전달합니다.

      reducer: {
        counter: counterReducer,
        auth: authReducer,
      }
    3. 액션/리듀서 export 관리: 각 slice 파일에서 .actions를 export하고, .reducer를 export해서 index.ts에서 스토어 설정 시 활용합니다. 컴포넌트에서는 필요한 slice 액션을 해당 slice 파일에서 import하여 사용합니다.

  • 장점

    • 코드 구조가 명확해져서 특정 기능(카운터, 인증 등) 관련 코드를 한 파일에 모을 수 있습니다.
    • 유지보수성이 향상되고, 새로운 기능 추가나 기존 코드 수정이 용이해집니다.
    • 협업 시 다른 개발자들이 특정 기능의 로직을 빠르게 파악하고 수정하기 쉽습니다.
counterSlice.ts ----------------> export counterActions, counterReducer
authSlice.ts -------------------> export authActions, authReducer

index.ts (store 설정)
   ┌─────────────────────────────────┐
   │ import counterReducer           │
   │ import authReducer              │
   └─────────────────────────────────┘
                  │
                  ▼
     configureStore({
       reducer: {
         counter: counterReducer,
         auth: authReducer
       }
     })
                  │
                  ▼
              Redux Store

컴포넌트 ---------> slice별 actions import ---> dispatch(actions.someAction())
                               │
                               ▼
                         slice.reducer
                               │
                               ▼
                            상태 변경
  • 설명:
    • 각 slice 파일에서 reducer와 actions를 export
    • index.ts에서 이를 모아 store 생성
    • 컴포넌트에서 slice별 actions를 필요한 만큼 import 및 dispatch
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialCounterState: CounterState = { value: 0, showCounter: true };

const counterSlice = createSlice({
  name: 'counter',
  initialState: initialCounterState,
  reducers: {
    increment(state) {
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    toggleCounter(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
import { createSlice } from '@reduxjs/toolkit';

interface AuthState {
  isAuthenticated: boolean;
}

const initialAuthState: AuthState = {
  isAuthenticated: false,
};

const authSlice = createSlice({
  name: 'auth',
  initialState: initialAuthState,
  reducers: {
    login(state) {
      state.isAuthenticated = true;
    },
    logout(state) {
      state.isAuthenticated = false;
    },
  },
});

export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    auth: authReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { authActions } from '../store/authSlice';

function Header() {
  const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);
  const dispatch = useDispatch<AppDispatch>();

  const logoutHandler = () => {
    dispatch(authActions.logout());
  };

  return (
    <header>
      <h1>My App</h1>
      <nav>
        <ul>
          {isAuth && <li><button onClick={logoutHandler}>로그아웃</button></li>}
          {!isAuth && <li>로그인 필요</li>}
        </ul>
      </nav>
    </header>
  );
}

export default Header;
  • 정리
    1. 슬라이스 별 파일 분리: 기능별로 slice 파일을 분리하면 대규모 애플리케이션도 쉽게 관리할 수 있습니다.
    2. index.ts에서 store 통합: 각 slice.reducer를 가져와 configureStore에 전달, 하나의 store로 통합.
    3. 컴포넌트에서의 사용 편의: 필요한 slice의 actions, state를 해당 slice 파일에서 바로 가져와 사용 가능.
    4. 유지보수성 향상: 논리적으로 관련된 코드가 한 파일에 모여 있어, 코드 구조가 깔끔하고 변경 용이.

이로써 Redux Toolkit을 활용한 구조적이고 확장성 있는 상태 관리 패턴을 구현할 수 있으며, 대규모 앱에서도 유지보수성과 가독성을 크게 개선할 수 있습니다.

278. 리덕스에 뛰어들기 (컨텍스트 API의 대안) / 요약

아래는 리덕스(Redux)와 리덕스 툴킷(Redux Toolkit)을 활용한 상태 관리 개념에 대한 최종 정리입니다. 전반적인 개념, 데이터 흐름, 그리고 타입스크립트 기반의 실용 예시 코드를 담았습니다. 이 정리를 통해 리덕스의 핵심 아이디어를 다시 확인하고, 실제 프로젝트에 어떻게 적용할지 명확히 알 수 있습니다.

  • 핵심 개념

    • Redux의 기본 원리:
      • 단 하나의 전역 상태 저장소(Store)를 가집니다.
      • 상태 변경은 오직 액션(Action) 디스패치를 통해서만 가능합니다.
      • 액션은 리듀서(Reducer)에 의해 처리되며, 리듀서는 이전 상태와 액션을 입력으로 받아 새로운 상태를 반환합니다.
      • 상태는 불변성(immutability)을 유지해야 합니다.
    • Redux Toolkit:
      • Redux를 더 쉽고 단순하게 설정하고 관리할 수 있도록 해주는 공식 도구입니다.
      • createSlice, configureStore 등의 기능을 통해 보일러플레이트 코드를 줄이고, 불변성 관리(immer 내장)와 액션 타입 관리 등을 자동화합니다.
  • 주요 포인트

    1. useSelector와 useDispatch를 통해 React 컴포넌트 내에서 상태 조회 및 액션 디스패치가 가능합니다.
    2. 액션에 payload를 추가함으로써 사용자 입력값이나 동적 데이터를 리듀서로 전달할 수 있습니다.
    3. 클래스형 컴포넌트 연결: React-Redux의 connect 함수로 클래스형 컴포넌트도 쉽게 상태와 액션에 연결할 수 있습니다.
    4. React Context vs Redux:
    • Context는 전역 상태 공유에 용이하지만, 성능 이슈나 복잡성 관리에 한계가 있을 수 있습니다.
    • Redux는 강력하고 예측 가능한 상태 관리를 제공하지만 서드 파티 라이브러리를 추가하는 부담이 있습니다.
    • 상황에 따라 Context 또는 Redux를 선택할 수 있습니다.
  • 결론

    • Redux와 Redux Toolkit을 활용하면 예측 가능하고 일관된 패턴으로 전역 상태를 관리할 수 있습니다.
    • Redux Toolkit을 사용하면 보일러플레이트 코드 감소, 간편한 액션/리듀서 관리, 불변성 자동 처리 등의 이점을 누릴 수 있습니다.
    • 프로젝트 규모, 성능 요구사항, 팀 선호도 등에 따라 Context와 Redux 중 적절한 도구를 선택할 수 있습니다.
사용자 상호작용(버튼 클릭 등)
       │
       ▼ dispatch(action)
   Redux Store
       │ 
       ▼ Reducer(이전 상태, 액션 → 새로운 상태)
       │
       ▼ 새로운 상태 Store에 저장
       │
       ▼ useSelector로 상태 읽기 → React UI 업데이트
  • 설명:
    • 사용자의 동작으로 액션 발생
    • 액션은 Store에 전달(dispatch)
    • Reducer가 액션을 처리하여 새로운 상태를 반환
    • Store에 저장된 변경된 상태를 useSelector로 컴포넌트에서 조회
    • 상태 변화가 UI에 즉각 반영
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  showCounter: boolean;
}

const initialCounterState: CounterState = { value: 0, showCounter: true };

const counterSlice = createSlice({
  name: 'counter',
  initialState: initialCounterState,
  reducers: {
    increment(state) {
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    toggleCounter(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
import { createSlice } from '@reduxjs/toolkit';

interface AuthState {
  isAuthenticated: boolean;
}

const initialAuthState: AuthState = { isAuthenticated: false };

const authSlice = createSlice({
  name: 'auth',
  initialState: initialAuthState,
  reducers: {
    login(state) {
      state.isAuthenticated = true;
    },
    logout(state) {
      state.isAuthenticated = false;
    },
  },
});

export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    auth: authReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import Header from './Header';
import Auth from './Auth';
import UserProfile from './UserProfile';
import Counter from './Counter';

function App() {
  const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);

  return (
    <React.Fragment>
      <Header />
      {!isAuth && <Auth />}
      {isAuth && <UserProfile />}
      <Counter />
    </React.Fragment>
  );
}

export default App;
// useSelector로 전역 상태(auth.isAuthenticated)를 읽어 인증 상태에 따라 Auth 또는 UserProfile을 조건부 렌더링
// counterActions, authActions를 컴포넌트에서 import하여 useDispatch로 액션 디스패치 가능
  • 정리
    1. Redux & Redux Toolkit 기본 이해: 상태 관리 핵심 개념과 Redux 흐름 숙지
    2. useSelector & useDispatch 활용: React 컴포넌트에서 상태 조회와 액션 디스패치 간소화
    3. Redux Toolkit 장점: 코드량 감소, 불변성 자동 관리, 직관적인 액션/리듀서 정의
    4. Context vs Redux 선택: 프로젝트 요구사항에 따라 적절한 도구를 선택
    5. 클래스형 컴포넌트 지원: connect 함수로 클래스형 컴포넌트도 Redux와 연계 가능 (현대 개발에서는 주로 훅 기반 함수형 컴포넌트 사용)

이로써 Redux의 주요 아이디어와 Redux Toolkit 활용 방법을 확실히 이해했으며, 실제 프로젝트에 적용할 기초를 다졌습니다.

279. 고급 리덕스 / 리덕스 및 부작용(및 비동기 코드)

아래는 Redux에서 비동기 코드(예: HTTP 요청)나 부수 효과(side effects)를 처리하는 방법에 대한 개념 정리입니다. Redux의 핵심 규칙인 "리듀서는 순수 함수여야 한다"는 점을 상기하면서, 비동기 로직이나 부수 효과를 처리할 수 있는 위치와 패턴을 살펴봅니다.

  • 리듀서의 특성

    • 순수함수: 리듀서(Reducer)는 이전 상태와 액션을 입력받아 새로운 상태를 반환하는 순수 함수여야 합니다.
    • 부수 효과 없음: 리듀서 내부에서는 네트워크 요청, 타이머 설정 등과 같은 부수 효과(side effects)를 실행하면 안 됩니다.
    • 비동기 작업 금지: 리듀서는 동기적으로만 동작해야 하며, 비동기 코드(HTTP 요청 등)를 포함해서는 안 됩니다.
  • 부수 효과와 비동기 로직 처리 위치

    • 리덕스 흐름에서 비동기 작업(예: API 호출)이나 부수 효과를 처리해야 한다면 다음 두 가지 전략 중 하나를 사용할 수 있습니다.
      1. 컴포넌트에서 부수 효과 처리 (useEffect 등 활용):
      • React 컴포넌트 단에서 useEffect를 통해 비동기 요청을 수행한 뒤, 요청 완료 시점에 dispatch로 액션을 발생시켜 상태를 갱신할 수 있습니다.
      • 이 경우 Redux는 비동기 로직을 전혀 알지 못하고, 단지 액션 결과만 받아 처리합니다.
      1. 커스텀 액션 크리에이터 (Thunk 등) 사용:
      • Redux Thunk나 Redux Toolkit의 createAsyncThunk 등을 사용하여 액션 생성 함수 내부에서 비동기 로직을 처리할 수 있습니다.
      • 이를 통해 리듀서는 순수성을 유지하면서도, 액션 생성 과정에서 비동기 요청을 처리하고, 그 결과로 액션을 디스패치하는 방식으로 상태를 갱신할 수 있습니다.
  • 정리

    • 리듀서는 동기적이고 부수 효과 없는 순수 함수여야 합니다.
    • 비동기나 부수 효과가 필요한 경우, 컴포넌트 외부(혹은 액션 크리에이터)에서 처리하고 그 결과를 리듀서로 전달해야 합니다.
    • Redux Thunk나 Redux Toolkit의 기능을 이용하면 비동기 로직을 깔끔하고 유지보수하기 쉽게 통합할 수 있습니다.
(컴포넌트) useEffect / 커스텀 액션 크리에이터
       │
       ▼
   비동기 요청(API 호출)
       │      성공/실패 응답
       ▼
 dispatch(action with payload)
       │
       ▼
   Redux Store
       │
       ▼
  Reducer (순수함수, 동기적)
   상태 업데이트
       │
       ▼
React 컴포넌트 re-render
  • 설명:
    • 비동기 요청은 리듀서 밖에서 처리
    • 요청 완료 후 dispatch로 액션을 보냄
    • 리듀서는 액션에 따라 순수하게 상태 업데이트
    • UI는 변경된 상태를 반영
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from './index';

interface User {
  id: string;
  name: string;
}

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
}

const initialState: UserState = {
  user: null,
  loading: false,
  error: null,
};

// 비동기 액션 생성 함수 (Thunk)
export const fetchUser = createAsyncThunk('user/fetchUser', async (userId: string) => {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error('Failed to fetch user');
  }
  const data: User = await response.json();
  return data;
});

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    clearError(state) {
      state.error = null;
    },
  },
  extraReducers: builder => {
    builder
      .addCase(fetchUser.pending, state => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action: PayloadAction<User>) => {
        state.loading = false;
        state.user = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || 'Something went wrong';
      });
  },
});

export const userActions = userSlice.actions;
export const selectUser = (state: RootState) => state.user;

export default userSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { fetchUser, userActions } from '../store/userSlice';

function UserProfile() {
  const dispatch = useDispatch<AppDispatch>();
  const { user, loading, error } = useSelector((state: RootState) => state.user);

  useEffect(() => {
    dispatch(fetchUser('1234'));
  }, [dispatch]);

  if (loading) return <p>로딩중...</p>;
  if (error) return <p>에러 발생: {error}</p>;
  if (!user) return <p>사용자 정보 없음</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      {/* 유저 정보 표시 */}
    </div>
  );
}

export default UserProfile;
// UserProfile 컴포넌트는 useEffect 내에서 fetchUser 비동기 액션을 디스패치
// 비동기 요청이 완료되면 extraReducers를 통해 리듀서가 상태를 업데이트
// 리듀서 자체는 여전히 순수하고 동기적이므로, 비동기 처리는 fetchUser 액션 크리에이터 내에서 발생
  • 정리
    • 리덕스 리듀서는 순수함수이며 부수 효과가 없어야 한다는 핵심 개념을 숙지
    • 부수 효과나 비동기 코드는 리듀서 밖에서 처리: 컴포넌트 또는 thunk 액션 크리에이터 사용
    • Redux Toolkit의 createAsyncThunk 등 비동기 편의 기능 활용
    • 이 접근 방식을 통해 리덕스 상태 관리와 비동기 로직을 깔끔하게 분리하고 유지보수할 수 있음

280. 고급 리덕스 / 복습/연습: 1/2부

아래는 리덕스를 사용해 장바구니 기능을 구현하는 과정에 대한 정리입니다. 장바구니 토글 상태 관리와 실제 장바구니 품목 관리 로직을 Redux Toolkit으로 구현하는 핵심 아이디어를 담고 있습니다. 또한 데이터 흐름을 도식화하고 타입스크립트 기반 예시 코드를 포함하였습니다.

  • 구현 목표

    • 장바구니 토글: "장바구니 보기/숨기기" 상태를 토글하는 UI 전용 slice를 만듭니다. (예: uiSlice)
    • 장바구니 품목 관리: 장바구니 항목을 추가/삭제/수정하는 로직을 담은 cart slice를 만듭니다. (예: cartSlice)
  • Redux를 통한 상태 관리 구조

    • UI 상태(slice): 장바구니가 현재 표시 중인지(cartIsVisible)를 저장하는 uiSlice. 토글 액션(toggleCart)을 통해 상태를 변경합니다.
    • 카트 상태(slice): 장바구니 항목 목록, 총합계 등 데이터를 담는 cartSlice. addItemToCart, removeItemFromCart 등 액션을 통해 항목을 추가/삭제하고, 수량 및 가격을 업데이트합니다.
  • 구현 흐름

    1. Redux Toolkit의 createSlice를 사용해 uiSlice와 cartSlice를 각각 정의합니다.
    2. uiSlice에는 cartIsVisible 초기 상태와 toggleCart 리듀서 함수를 둡니다.
    3. cartSlice에는 초기 상태로 항목 배열(items)과 총 개수(totalQuantity) 등의 필드를 정의하고, addItemToCart 같은 리듀서 함수로 아이템 추가 로직을 구현합니다.
    4. 아이템 추가 시, 이미 존재하는 아이템인지 확인 후 수량 및 총 가격 업데이트 또는 새 아이템 추가
    5. configureStore로 두 slice의 reducer를 결합하고, React 앱에 <Provider>로 store를 주입합니다.
    6. 컴포넌트에서 useDispatch, useSelector 훅을 사용하여 액션 디스패치 및 상태 조회를 수행합니다.
사용자 클릭(장바구니 버튼) 
       │
       ▼ dispatch(uiActions.toggleCart())
       │
       ▼
     uiSlice.reducer - cartIsVisible 상태 토글
       │
       ▼
    React 컴포넌트 re-render 
       │
       ▼ 상태 반영 (장바구니 UI 표시/숨기기)

사용자 클릭("장바구니에 추가" 버튼)
       │
       ▼ dispatch(cartActions.addItemToCart({id, title, price}))
       │
       ▼
   cartSlice.reducer - items 배열 및 totalQuantity 갱신
       │
       ▼
    React 컴포넌트 re-render 
       │
       ▼ 상태 반영 (장바구니 내 항목 수, 가격 등 업데이트)
import { createSlice } from '@reduxjs/toolkit';

interface UIState {
  cartIsVisible: boolean;
}

const initialUIState: UIState = { cartIsVisible: false };

const uiSlice = createSlice({
  name: 'ui',
  initialState: initialUIState,
  reducers: {
    toggleCart(state) {
      state.cartIsVisible = !state.cartIsVisible;
    },
  },
});

export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  title: string;
  price: number;
  quantity: number;
  totalPrice: number;
}

interface CartState {
  items: CartItem[];
  totalQuantity: number;
}

const initialCartState: CartState = {
  items: [],
  totalQuantity: 0,
};

interface AddItemPayload {
  id: string;
  title: string;
  price: number;
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: initialCartState,
  reducers: {
    addItemToCart(state, action: PayloadAction<AddItemPayload>) {
      const newItem = action.payload;
      const existingItem = state.items.find(item => item.id === newItem.id);
      state.totalQuantity++;
      if (!existingItem) {
        // 새 아이템 추가
        state.items.push({
          id: newItem.id,
          title: newItem.title,
          price: newItem.price,
          quantity: 1,
          totalPrice: newItem.price,
        });
      } else {
        // 기존 아이템 수량 증가 및 총 가격 업데이트
        existingItem.quantity++;
        existingItem.totalPrice += newItem.price;
      }
    },
    removeItemFromCart(state, action: PayloadAction<string>) {
      const id = action.payload;
      const existingItem = state.items.find(item => item.id === id);
      if (!existingItem) return;
      state.totalQuantity--;
      if (existingItem.quantity === 1) {
        // 수량 1 → 삭제
        state.items = state.items.filter(item => item.id !== id);
      } else {
        // 수량 감소 및 총 가격 감소
        existingItem.quantity--;
        existingItem.totalPrice -= existingItem.price;
      }
    },
  },
});

export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './uiSlice';
import cartReducer from './cartSlice';

export const store = configureStore({
  reducer: {
    ui: uiReducer,
    cart: cartReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from './store';
import Cart from './components/Cart';
import Layout from './components/Layout';

function App() {
  const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);

  return (
    <Layout>
      {showCart && <Cart />}
      {/* 제품 목록 및 기타 UI */}
    </Layout>
  );
}

export default App;
import React from 'react';
import { useDispatch } from 'react-redux';
import { uiActions } from '../store/uiSlice';

const CartButton: React.FC = () => {
  const dispatch = useDispatch();

  const toggleCartHandler = () => {
    dispatch(uiActions.toggleCart());
  };

  return <button onClick={toggleCartHandler}>내 장바구니</button>;
};

export default CartButton;
import React from 'react';
import CartButton from './CartButton';
import { useSelector } from 'react-redux';
import { RootState } from '../store';

const Layout: React.FC = (props) => {
  // totalQuantity를 가져와서 상단 레이아웃에 표시할 수 있습니다.
  const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);

  return (
    <div>
      <header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
        <h1>리덕스 장바구니 예제</h1>
        <div style={{ display: 'flex', alignItems: 'center' }}>
          <CartButton />
          <span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
        </div>
      </header>
      <main style={{ padding: '1rem' }}>{props.children}</main>
    </div>
  );
};

export default Layout;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

const Cart: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();
  const items = useSelector((state: RootState) => state.cart.items);

  const addItemHandler = (id: string, title: string, price: number) => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  const removeItemHandler = (id: string) => {
    dispatch(cartActions.removeItemFromCart(id));
  };

  if (items.length === 0) {
    return <p>장바구니가 비어있습니다!</p>;
  }

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
      <h2>내 장바구니</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {items.map(item => (
          <li key={item.id} style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
            <div><strong>{item.title}</strong></div>
            <div>가격: {item.price}</div>
            <div>수량: {item.quantity}</div>
            <div>총 가격: {item.totalPrice}</div>
            <div style={{ marginTop: '0.5rem' }}>
              <button onClick={() => removeItemHandler(item.id)}>-</button>
              <button onClick={() => addItemHandler(item.id, item.title, item.price)}>+</button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Cart;
  • 정리
    1. 상태 슬라이스 분리: UI 전용 slice와 Cart 전용 slice를 구분해 구조적이고 관리하기 쉽게 만듭니다.
    2. Redux Toolkit 활용: createSlice, configureStore 등을 사용해 보일러플레이트 코드를 최소화하고 불변성 관리를 자동화합니다.
    3. useSelector, useDispatch로 연동: 컴포넌트에서 Redux 상태를 쉽게 읽고, 액션을 디스패치해 상태를 변경할 수 있습니다.
    4. 유연한 로직 확장: 아이템 추가, 제거, 수량 업데이트 등 다양한 상태 변화를 slice 리듀서에서 관리하며, 향후 부수 효과나 비동기 로직도 손쉽게 확장 가능합니다.

이로써 장바구니 기능을 비롯한 복잡한 전역 상태 관리도 Redux Toolkit을 통해 직관적이고 효율적으로 구현할 수 있습니다.

281. 고급 리덕스 / 복습/연습: 2/2부

아래는 리덕스를 사용해 장바구니 항목을 추가/삭제/수정하는 과정을 상세히 설명한 내용을 정리한 것입니다. 전반적인 로직 정리, 데이터 흐름도식, 그리고 타입스크립트 기반 예시 코드를 통해 이해를 돕습니다.

  • 주요 기능

    • 장바구니에 항목 추가:
      • 이미 장바구니에 있는 항목인지 확인
      • 존재하지 않으면 새 항목을 추가(수량 1로 시작)
      • 존재한다면 해당 항목의 수량 증가 및 총 가격 업데이트
      • 장바구니의 총 수량(totalQuantity)도 증가
    • 장바구니에서 항목 제거:
      • 해당 ID를 가진 항목을 찾음
      • 항목 수량이 1이라면 배열에서 제거(필터링)
      • 항목 수량이 1보다 크다면 수량 감소 및 총 가격 재계산
      • 장바구니의 총 수량(totalQuantity)도 감소
  • cartSlice 상태 예시:

    {
      items: [
        {
          id: string;
          title: string;
          price: number;
          quantity: number;
          totalPrice: number;
        },
        ...
      ];
      totalQuantity: number;
    }
  • UI 연계

    • 제품 목록 컴포넌트(ProductList 등)에서 "장바구니에 추가" 버튼 클릭 시 cartActions.addItemToCart 디스패치
    • 장바구니 컴포넌트(Cart)에서 "-" 버튼을 누르면 cartActions.removeItemFromCart 디스패치
    • CartItem 컴포넌트에서도 "+" 버튼을 통해 addItemToCart 디스패치 가능
    • useSelector로 cart.totalQuantity, cart.items 상태를 읽어 UI에 반영
  • 정리

    • Redux Toolkit을 사용하면 불변성 관리와 액션/리듀서 작성이 단순화되어 복잡한 로직을 쉽게 처리할 수 있습니다.
    • 단일 항목 처리(add/remove) 로직을 명확히 정의하고, 컴포넌트에서 액션을 디스패치함으로써 상태와 UI를 안정적으로 동기화할 수 있습니다.
사용자 동작 (장바구니에 추가 버튼 클릭)
       │
       ▼ dispatch(cartActions.addItemToCart({id, title, price}))
       │
       ▼
   cartSlice.reducer
   - 항목 존재 여부 확인
   - 새로운 항목 추가 또는 기존 항목 수량·가격 업데이트
   - totalQuantity 증가
       │
       ▼
Redux Store 상태 업데이트
       │
       ▼
useSelector로 컴포넌트에서 상태 읽기
       │
       ▼
UI 업데이트 (항목 수, 총 수량 반영)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  title: string;
  price: number;
  quantity: number;
  totalPrice: number;
}

interface CartState {
  items: CartItem[];
  totalQuantity: number;
}

const initialCartState: CartState = {
  items: [],
  totalQuantity: 0,
};

interface AddItemPayload {
  id: string;
  title: string;
  price: number;
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: initialCartState,
  reducers: {
    addItemToCart(state, action: PayloadAction<AddItemPayload>) {
      const newItem = action.payload;
      const existingItem = state.items.find(item => item.id === newItem.id);
      state.totalQuantity++;
      if (!existingItem) {
        state.items.push({
          id: newItem.id,
          title: newItem.title,
          price: newItem.price,
          quantity: 1,
          totalPrice: newItem.price,
        });
      } else {
        existingItem.quantity++;
        existingItem.totalPrice += newItem.price;
      }
    },
    removeItemFromCart(state, action: PayloadAction<string>) {
      const id = action.payload;
      const existingItem = state.items.find(item => item.id === id);
      if (!existingItem) return;
      state.totalQuantity--;
      if (existingItem.quantity === 1) {
        state.items = state.items.filter(item => item.id !== id);
      } else {
        existingItem.quantity--;
        existingItem.totalPrice -= existingItem.price; // 총 가격 감소 처리
      }
    },
  },
});

export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

interface CartItemProps {
  item: {
    id: string;
    title: string;
    quantity: number;
    price: number;
    totalPrice: number;
  };
}

const CartItem: React.FC<CartItemProps> = (props) => {
  const { id, title, quantity, price, totalPrice } = props.item;
  const dispatch = useDispatch<AppDispatch>();

  const removeItemHandler = () => {
    dispatch(cartActions.removeItemFromCart(id));
  };

  const addItemHandler = () => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  return (
    <li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
      <div><strong>{title}</strong></div>
      <div>가격: {price}</div>
      <div>수량: {quantity}</div>
      <div>총 가격: {totalPrice}</div>
      <div style={{ marginTop: '0.5rem' }}>
        <button onClick={removeItemHandler}>-</button>
        <button onClick={addItemHandler}>+</button>
      </div>
    </li>
  );
};

export default CartItem;
  • 설명:

    • CartItem 컴포넌트는 removeItemHandler 및 addItemHandler를 통해 removeItemFromCart와 addItemToCart 액션을 디스패치합니다.
    • 이를 통해 장바구니 항목의 수량과 가격이 업데이트되며, Redux 상태 변화에 따라 UI가 자동으로 재렌더링됩니다.
  • 요약

    • 리덕스 Slice를 통해 장바구니 상태(항목, 수량, 총 가격)를 체계적으로 관리
    • addItemToCart, removeItemFromCart 리듀서 로직으로 아이템 추가/삭제 로직 구현
    • 컴포넌트에서 useDispatch, useSelector로 액션 디스패치 및 상태 조회
    • Redux Toolkit을 활용하면 불변성 관리, 액션 타입 관리가 단순화되어 로직 구현이 직관적이고 유지보수하기 쉬워집니다.

282. 고급 리덕스 / 리덕스 및 비동기 코드

아래는 리덕스를 비동기 코드(HTTP 요청)와 결합하는 방법을 다룬 내용에 대한 정리입니다. 기본적인 원칙(리듀서에서 부수 효과 금지)과 이를 해결하는 두 가지 접근법(컴포넌트 내부에서 처리 또는 액션 생성 함수를 통한 처리)을 명확히 설명합니다. 또한 데이터 흐름 도식과 타입스크립트 기반 예시 코드를 제공합니다.

  1. 개념 정리
  • 문제 상황

    • 장바구니 상태를 프론트엔드에서만 관리하면, 새로고침 시 데이터가 사라집니다.
    • 이를 해결하기 위해 장바구니 변경 시 백엔드(파이어베이스 등)에 HTTP 요청을 보내 상태를 저장합니다.
    • 새로고침 시 백엔드에서 데이터를 가져와서 초기 상태로 반영할 수 있어야 합니다.
  • Redux 원칙

    • 리듀서(Reducer)는 순수 함수여야 함: 부수 효과(side effect), 비동기 코드, HTTP 요청 등을 리듀서 안에서 실행할 수 없음.
    • 즉, 상태 변경 로직은 순수하게, 동기적으로 동작해야 하며, 외부 세계와의 상호작용(HTTP 요청)은 리듀서 밖에서 처리해야 합니다.
  • 두 가지 접근법

    1. 컴포넌트 내부에서 비동기 코드 실행:
    • React 컴포넌트(useEffect 등)에서 HTTP 요청을 보내고, 완료 시점에 dispatch로 액션을 발생시켜 상태를 업데이트합니다.
    • 이 경우 Redux는 비동기 로직을 직접 다루지 않고, 단지 결과 액션만 처리합니다.
    1. 액션 생성 함수(Action Creator)에서 처리 (Thunk 등 사용):
    • 커스텀 액션 생성 함수를 만들어 비동기 코드를 그 안에서 실행하고, 비동기 처리 완료 후 결과 액션을 디스패치합니다.
    • Redux Thunk나 Redux Toolkit의 createAsyncThunk 등을 활용할 수 있습니다.
    • 이렇게 하면 리덕스 흐름에 자연스럽게 비동기 로직을 통합할 수 있습니다.
  • 결론

    • 리듀서 내부에는 절대 부수 효과나 비동기 코드가 들어가면 안 됩니다.
    • 비동기 로직은 컴포넌트나 액션 생성 함수에서 처리해야 합니다.
    • 두 접근법 중 하나를 선택하거나 상황에 따라 혼합해 사용할 수 있습니다.
사용자 상호작용(장바구니 업데이트)
        │
        ▼
   (선택지 1) 컴포넌트에서 HTTP 요청
   (선택지 2) 액션 생성 함수(Thunk 등)에서 HTTP 요청
        │
        ▼ 요청 완료
   dispatch(결과 액션)
        │
        ▼
    Redux Reducer (순수 함수)
   상태 업데이트 (비동기 로직 없음)
        │
        ▼
   React 컴포넌트 re-render
import { AppDispatch } from './index';
import { cartActions } from './cart-slice';

// 액션 생성 함수 예시(Thunk 사용)
export const sendCartData = (cart: { items: any[]; totalQuantity: number }) => {
  return async (dispatch: AppDispatch) => {
    // 비동기 HTTP 요청
    const sendRequest = async () => {
      const response = await fetch('https://your-backend.firebaseio.com/cart.json', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' }, // 중요!! 어떤 데이터 타입으로 보내는지 명시 필요!!
        body: JSON.stringify(cart),
      });

      if (!response.ok) {
        throw new Error('장바구니 저장 실패!');
      }
    };

    try {
      await sendRequest();
      dispatch(cartActions.showNotification({ status: 'success', title: 'Success!', message: 'Cart data sent!' }));
    } catch (error) {
      dispatch(cartActions.showNotification({ status: 'error', title: 'Error!', message: 'Sending cart data failed!' }));
    }
  };
};
// sendCartData 액션 생성 함수에서 비동기 HTTP 요청을 실행한 뒤, 성공 또는 실패 시 적절한 액션을 디스패치합니다.
// 리듀서는 여전히 순수하고 동기적이며, 비동기 로직은 액션 생성 함수 내에서 처리합니다.
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cart-slice';
// ... 다른 리듀서들

export const store = configureStore({
  reducer: {
    cart: cartReducer,
    // ... 다른 slice들
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { sendCartData } from '../store/cart-actions';
import { RootState } from '../store';

const Cart: React.FC = () => {
  const dispatch = useDispatch();
  const cart = useSelector((state: RootState) => state.cart);

  useEffect(() => {
    dispatch(sendCartData(cart));
  }, [cart, dispatch]);

  // cart 상태를 렌더링
  return <div>장바구니 렌더링...</div>;
};

export default Cart;
  • 설명:

    • Cart 컴포넌트는 useEffect를 통해 cart 상태 변화 시 sendCartData를 호출하여 백엔드에 비동기 요청을 보냅니다.
    • 비동기 코드는 리듀서가 아닌 액션 생성 함수에서 처리하므로, 리덕스 규칙을 지키면서 HTTP 요청을 통합할 수 있습니다.
  • 요약

    • 리덕스 리듀서는 순수성을 유지해야 하므로 비동기 HTTP 요청은 리듀서에서 할 수 없습니다.
    • 비동기 로직을 컴포넌트 내부나 액션 생성 함수(Thunk)로 추상화할 수 있습니다.
    • Redux Toolkit과 Thunk를 사용하면 깔끔하고 예측 가능한 비동기 상태 관리를 구현할 수 있습니다.

283. 고급 리덕스 / 프론트엔드 코드와 백엔드 코드

이번 예시는 다음과 같은 핵심 포인트를 다룹니다.

  • 리듀서 함수 내부에서 부수 효과(비동기 HTTP 요청) 코드를 절대 실행하지 않습니다.
  • 비동기 로직(HTTP 요청)은 액션 생성 함수(Thunk)나 컴포넌트 내부(useEffect) 등에서 처리합니다.

아래 예시는 사람들이 널리 사용하고 검증된 Redux Toolkit 패턴을 따르며, 타입스크립트를 사용합니다. 또한 전체 폴더 구조와 모든 파일의 코드 예시를 제공합니다.

아래 예시는 Firebase 대신 일반 Node.js Express 서버를 사용한 전체 예시 코드입니다. 백엔드(Node.js + Express)와 프론트엔드(React + Redux Toolkit + TypeScript) 모두를 제시합니다. 이 예시에서는 다음과 같은 특징을 갖습니다.

  • 백엔드: Node.js + Express로 구성. 장바구니 데이터를 메모리에 저장하는 간단한 예시. 실제 프로젝트에서는 DB 연동 가능.
  • 프론트엔드: Redux Toolkit과 Thunk를 사용해 장바구니 상태 관리 및 백엔드와의 비동기 HTTP 요청 처리.
  • 리듀서에서는 비동기 요청이나 부수 효과 없음, 비동기 로직은 Thunk 액션 또는 컴포넌트에서 처리.

아래 코드는 검증된 패턴(Thunk, Redux Toolkit, React-Redux 사용)과 타입스크립트를 사용합니다. 코드 생략 없이, 전체 폴더 구조 및 파일 예시를 모두 제공합니다.

my-app/
├─ server/
│  ├─ server.ts         // Node.js + Express 서버 코드
│  ├─ cartData.json     // (옵션) 서버 시작 시 빈 파일 또는 초기 데이터
│  └─ package.json      // 서버용 의존성 (express)
├─ src/
│  ├─ store/
│  │  ├─ cart-slice.ts
│  │  ├─ ui-slice.ts
│  │  ├─ cart-actions.ts
│  │  └─ index.ts
│  ├─ components/
│  │  ├─ Layout.tsx
│  │  ├─ Cart.tsx
│  │  ├─ CartItem.tsx
│  │  ├─ CartButton.tsx
│  │  ├─ Products.tsx
│  │  ├─ ProductItem.tsx
│  │  └─ Notification.tsx
│  ├─ App.tsx
│  ├─ index.tsx
│  └─ react-app-env.d.ts
├─ package.json
└─ tsconfig.json
  • 전제 조건:

    • 프론트엔드: React, Redux Toolkit, react-redux, TypeScript 설치 완료
    • 백엔드: Node.js, Express 설치 (npm install express @types/express)
  • 실행 방법:

    • 백엔드: cd server && npm install && npx ts-node server.ts (또는 tsconfig 설정 후 npm run dev 등)
    • 프론트엔드: npm start (CRA 기준)
    • 백엔드 서버는 http://localhost:3001에서 동작, 프론트엔드는 http://localhost:3000에서 동작 (CORS 문제를 피하려면 proxy 설정 또는 서버 포트 조정 필요)
// server/server.ts (타입스크립트 기반 Node.js 서버)
import express, { Request, Response } from 'express';
import cors from 'cors';

interface CartItem {
  id: string;
  title: string;
  price: number;
  quantity: number;
  totalPrice: number;
}

interface CartData {
  items: CartItem[];
  totalQuantity: number;
}

// 메모리에 장바구니 데이터 저장 (실제 프로덕션에선 DB 연동 권장)
let cartData: CartData = {
  items: [],
  totalQuantity: 0
};

const app = express();
app.use(cors());
app.use(express.json());

// GET /cart: 장바구니 데이터 가져오기
app.get('/cart', (req: Request, res: Response) => {
  res.json(cartData);
});

// PUT /cart: 장바구니 데이터 저장/갱신
app.put('/cart', (req: Request, res: Response) => {
  const newCartData = req.body as CartData;
  // 백엔드는 로직 없이 그대로 저장
  cartData = {
    items: newCartData.items || [],
    totalQuantity: newCartData.totalQuantity || 0
  };
  res.json({ message: '장바구니 데이터 업데이트 완료' });
});

app.listen(3001, () => {
  console.log('백엔드 서버가 http://localhost:3001 에서 동작 중입니다.');
});
  • 설명:
    • /cart 엔드포인트로 GET, PUT 요청 처리.
    • PUT으로 받은 데이터를 그대로 cartData에 저장. 별도의 추가 로직이나 변환 없음.
    • 실제 서비스에서는 DB나 파일에 저장하거나 추가 로직을 구현할 수 있음.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  title: string;
  price: number;
  quantity: number;
  totalPrice: number;
}

interface CartState {
  items: CartItem[];
  totalQuantity: number;
  changed: boolean;
}

const initialCartState: CartState = {
  items: [],
  totalQuantity: 0,
  changed: false,
};

interface AddItemPayload {
  id: string;
  title: string;
  price: number;
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: initialCartState,
  reducers: {
    replaceCart(state, action: PayloadAction<CartState>) {
      state.totalQuantity = action.payload.totalQuantity;
      state.items = action.payload.items;
      state.changed = false;
    },
    addItemToCart(state, action: PayloadAction<AddItemPayload>) {
      const newItem = action.payload;
      const existingItem = state.items.find(item => item.id === newItem.id);
      state.totalQuantity++;
      state.changed = true;
      if (!existingItem) {
        state.items.push({
          id: newItem.id,
          title: newItem.title,
          price: newItem.price,
          quantity: 1,
          totalPrice: newItem.price,
        });
      } else {
        existingItem.quantity++;
        existingItem.totalPrice += newItem.price;
      }
    },
    removeItemFromCart(state, action: PayloadAction<string>) {
      const id = action.payload;
      const existingItem = state.items.find(item => item.id === id);
      if (!existingItem) return;
      state.totalQuantity--;
      state.changed = true;
      if (existingItem.quantity === 1) {
        state.items = state.items.filter(item => item.id !== id);
      } else {
        existingItem.quantity--;
        existingItem.totalPrice -= existingItem.price;
      }
    },
  },
});

export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface Notification {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

interface UIState {
  cartIsVisible: boolean;
  notification: Notification | null;
}

const initialUIState: UIState = {
  cartIsVisible: false,
  notification: null,
};

interface ShowNotificationPayload {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

const uiSlice = createSlice({
  name: 'ui',
  initialState: initialUIState,
  reducers: {
    toggleCart(state) {
      state.cartIsVisible = !state.cartIsVisible;
    },
    showNotification(state, action: PayloadAction<ShowNotificationPayload>) {
      state.notification = {
        status: action.payload.status,
        title: action.payload.title,
        message: action.payload.message,
      };
    },
  },
});

export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { AppDispatch, RootState } from './index';
import { uiActions } from './ui-slice';
import { cartActions } from './cart-slice';

export const fetchCartData = () => {
  return async (dispatch: AppDispatch) => {
    dispatch(uiActions.showNotification({
      status: 'pending',
      title: '불러오는 중...',
      message: '장바구니 데이터를 가져오는 중...'
    }));

    const fetchData = async () => {
      const response = await fetch('http://localhost:3001/cart');
      if (!response.ok) {
        throw new Error('데이터 불러오기 실패!');
      }
      const data = await response.json();
      return data;
    };

    try {
      const cartData = await fetchData();
      dispatch(cartActions.replaceCart({
        items: cartData.items || [],
        totalQuantity: cartData.totalQuantity || 0,
        changed: false,
      }));
      dispatch(uiActions.showNotification({
        status: 'success',
        title: '성공!',
        message: '장바구니 데이터 불러오기 성공!'
      }));
    } catch (error) {
      dispatch(uiActions.showNotification({
        status: 'error',
        title: '오류!',
        message: '장바구니 데이터를 가져오지 못했습니다.'
      }));
    }
  };
};

export const sendCartData = (cart: RootState['cart']) => {
  return async (dispatch: AppDispatch) => {
    dispatch(uiActions.showNotification({
      status: 'pending',
      title: '보내는 중...',
      message: '장바구니 데이터를 서버에 보내는 중...'
    }));

    const sendRequest = async () => {
      const response = await fetch('http://localhost:3001/cart', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          items: cart.items,
          totalQuantity: cart.totalQuantity
        }),
      });
      if (!response.ok) {
        throw new Error('장바구니 전송 실패!');
      }
    };

    try {
      await sendRequest();
      dispatch(uiActions.showNotification({
        status: 'success',
        title: '성공!',
        message: '장바구니 데이터 전송 성공!'
      }));
    } catch (error) {
      dispatch(uiActions.showNotification({
        status: 'error',
        title: '오류!',
        message: '장바구니 데이터 전송에 실패했습니다!'
      }));
    }
  };
};
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './ui-slice';
import cartReducer from './cart-slice';

export const store = configureStore({
  reducer: {
    ui: uiReducer,
    cart: cartReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState, AppDispatch } from './store';
import { fetchCartData, sendCartData } from './store/cart-actions';
import Layout from './components/Layout';
import Cart from './components/Cart';
import Products from './components/Products';
import Notification from './components/Notification';

let isInitial = true;

const App: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();
  const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);
  const cart = useSelector((state: RootState) => state.cart);
  const notification = useSelector((state: RootState) => state.ui.notification);

  useEffect(() => {
    dispatch(fetchCartData());
  }, [dispatch]);

  useEffect(() => {
    if (isInitial) {
      isInitial = false;
      return;
    }

    if (cart.changed) {
      dispatch(sendCartData(cart));
    }
  }, [cart, dispatch]);

  return (
    <>
      {notification && <Notification status={notification.status} title={notification.title} message={notification.message}/>}
      <Layout>
        {showCart && <Cart />}
        <Products />
      </Layout>
    </>
  );
}

export default App;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartButton from './CartButton';

interface LayoutProps {
  children?: React.ReactNode;
}

const Layout: React.FC<LayoutProps> = (props) => {
  const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);

  return (
    <div>
      <header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
        <h1>리덕스 장바구니 예제</h1>
        <div style={{ display: 'flex', alignItems: 'center' }}>
          <CartButton />
          <span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
        </div>
      </header>
      <main style={{ padding: '1rem' }}>{props.children}</main>
    </div>
  )
}

export default Layout;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartItem from './CartItem';

const Cart: React.FC = () => {
  const items = useSelector((state: RootState) => state.cart.items);

  if (items.length === 0) {
    return <p>장바구니가 비어있습니다!</p>;
  }

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
      <h2>내 장바구니</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {items.map(item => (
          <CartItem 
            key={item.id}
            item={{
              id: item.id,
              title: item.title,
              quantity: item.quantity,
              price: item.price,
              totalPrice: item.totalPrice
            }}
          />
        ))}
      </ul>
    </div>
  );
};

export default Cart;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

interface CartItemProps {
  item: {
    id: string;
    title: string;
    quantity: number;
    price: number;
    totalPrice: number;
  };
}

const CartItem: React.FC<CartItemProps> = (props) => {
  const { id, title, quantity, price, totalPrice } = props.item;
  const dispatch = useDispatch<AppDispatch>();

  const removeItemHandler = () => {
    dispatch(cartActions.removeItemFromCart(id));
  };

  const addItemHandler = () => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  return (
    <li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
      <div><strong>{title}</strong></div>
      <div>단가: {price}</div>
      <div>수량: {quantity}</div>
      <div>총 가격: {totalPrice}</div>
      <div style={{ marginTop: '0.5rem' }}>
        <button onClick={removeItemHandler}>-</button>
        <button onClick={addItemHandler}>+</button>
      </div>
    </li>
  );
};

export default CartItem;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { uiActions } from '../store/ui-slice';

const CartButton: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();

  const toggleCartHandler = () => {
    dispatch(uiActions.toggleCart());
  };

  return <button onClick={toggleCartHandler}>내 장바구니</button>;
};

export default CartButton;
import React from 'react';
import ProductItem from './ProductItem';

const DUMMY_PRODUCTS = [
  { id: 'p1', price: 6, title: 'My First Book', description: 'The first book I ever wrote.' },
  { id: 'p2', price: 5, title: 'My Second Book', description: 'The second book I ever wrote.' },
];

const Products: React.FC = () => {
  return (
    <section>
      <h2>구매할 상품 선택</h2>
      <ul>
        {DUMMY_PRODUCTS.map(product => (
          <ProductItem 
            key={product.id} 
            id={product.id} 
            title={product.title} 
            price={product.price} 
            description={product.description}
          />
        ))}
      </ul>
    </section>
  );
};

export default Products;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

interface ProductItemProps {
  id: string;
  title: string;
  price: number;
  description: string;
}

const ProductItem: React.FC<ProductItemProps> = (props) => {
  const { id, title, price, description } = props;
  const dispatch = useDispatch<AppDispatch>();

  const addToCartHandler = () => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  return (
    <li style={{ marginBottom: '1rem', border: '1px solid #ddd', padding: '1rem' }}>
      <h3>{title}</h3>
      <p>{description}</p>
      <div>가격: {price}</div>
      <button onClick={addToCartHandler}>장바구니에 추가</button>
    </li>
  );
};

export default ProductItem;
import React from 'react';

interface NotificationProps {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

const Notification: React.FC<NotificationProps> = (props) => {
  let backgroundColor = '#ccc';

  if (props.status === 'pending') {
    backgroundColor = 'blue';
  }
  if (props.status === 'success') {
    backgroundColor = 'green';
  }
  if (props.status === 'error') {
    backgroundColor = 'red';
  }

  return (
    <section style={{ backgroundColor, padding: '1rem', position: 'fixed', top: '1rem', right: '1rem', color: '#fff' }}>
      <h2>{props.title}</h2>
      <p>{props.message}</p>
    </section>
  );
};

export default Notification;
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
  • 정리:
    • 백엔드는 Node.js + Express를 사용, /cart 엔드포인트에서 GET/PUT 지원. 데이터는 메모리에 저장.
    • 프론트엔드는 Redux Toolkit + Thunk 액션으로 비동기 요청 처리.
    • 리듀서 내부는 순수 상태 업데이트 로직만 담당.
    • 비동기 HTTP 요청은 cart-actions.ts에서 Thunk 액션으로 처리하고, 컴포넌트(App.tsx useEffect)에서 상태 변화 시점에 액션 디스패치.
    • 이로써 Node.js 백엔드와 연동하는 완전한 예시 코드가 완료되었습니다.

284. 고급 리덕스 / 우리의 논리를 어디에 둘 것인가

아래는 비동기 코드(HTTP 요청)와 부수 효과 처리 문제를 해결하는 더 나은 패턴을 정리한 예시입니다. 이전에 설명한 단점(컴포넌트 내부에서 리덕스 상태를 임의로 변환하고, 비동기 요청 준비를 하는 것)이 아닌, 액션 생성 함수(Thunk)를 통해 모든 비동기 및 데이터 변환 로직을 처리하는 방식을 보여줍니다. 이를 통해 리듀서는 순수 로직만 유지하고, 컴포넌트는 단순히 액션 디스패치만 담당하며, 비동기 로직과 데이터 변환은 액션 생성 함수에서 처리됩니다.

  • 핵심 아이디어:
    • 리듀서: 순수 상태 업데이트 로직만 담당 (부수효과 X, 비동기 X)
    • 액션 생성 함수(Thunk): 비동기 로직, 데이터 변환, HTTP 요청 모두 여기서 처리
    • 컴포넌트: 단순히 액션 디스패치 및 상태 표시 (비동기/데이터 변환 로직 없음)

이렇게 하면 컴포넌트와 리듀서 모두 깔끔하고 유지보수하기 쉽습니다.

  • 전제 조건:
    • 백엔드: Node.js + Express (단순히 /cart로 GET/PUT 요청에 대해 메모리 상의 cartData로 응답)
    • 프론트엔드: Redux Toolkit, React-Redux, TypeScript 사용
    • 비동기 로직 및 데이터 변환 로직은 Thunk 액션에서 수행
사용자 상호작용 (제품 "장바구니에 추가" 버튼 클릭)
       │
       ▼ dispatch(addToCartThunk(제품정보))
       │
       ▼ Thunk 액션 함수
          - 현재 cart 상태 조회(useSelector 필요없음, getState 사용)
          - 기존 cart 상태 + 새로운 제품정보로 데이터 변환 (불변성 유지)
          - 변환된 cart 상태 백엔드에 PUT 요청
          - 요청 성공 후 cartActions.replaceCart(...)로 리듀서 상태 업데이트
       │
       ▼
    Redux Reducer(순수 로직)
   상태 업데이트 후 UI 재렌더
       │
       ▼
React 컴포넌트는 useSelector로 상태 조회, 변경사항 반영

이 흐름에서 컴포넌트 내부에서 상태 변환이나 비동기 로직을 하지 않고, Thunk 액션 함수에서 모든 것을 처리합니다. 리듀서는 여전히 순수 상태 변경만 수행합니다.

----- [예시 코드] 고급 리덕스 / 우리의 논리를 어디에 둘 것인가 -----

  • 설명: store/cartSlice.ts
    • 이 예제에서는 addItemToCart, removeItemFromCart 같은 로직을 직접 리듀서에 넣지 않고 thunk에서 변환 후 replaceCart로 대체하는 패턴을 사용할 수 있습니다.
    • 하지만 필요하다면 addItemToCart, removeItemFromCart 유지 후, thunk 내에서 이 액션들 디스패치 가능.
    • 이전 예제와 달리 addItemToCart, removeItemFromCart를 직접 리듀서에 두지 않고, 모든 변환 로직을 Thunk로 옮기는 패턴을 취할 수도 있습니다.
    • 만약 이전처럼 addItemToCart, removeItemFromCart를 유지하고 싶다면 그대로 두고, Thunk 내에서 getState로 현재 상태 가져온 뒤 addItemToCart 디스패치 후 백엔드 호출 로직 추가도 가능.

여기서는 "모든 변환을 Thunk에서 처리"하는 극단적 패턴을 시연하기 위해 addItemToCart 등을 리듀서에서 제거했습니다.

  • 설명: store/cartActions.ts

    • addItemToCartThunk: 이전엔 리듀서나 컴포넌트에서 했던 변환 로직을 Thunk에서 처리.
    • getState()로 현재 cart 상태 가져와 새로운 cart 상태로 변환 후 replaceCart 액션 디스패치.
    • 데이터 변환, 비동기 요청(백엔드 PUT) 모두 Thunk에서 처리 가능. 컴포넌트는 단순히 addItemToCartThunk를 디스패치하기만 하면 됨.
  • 정리:

    • 이 예시에서는 비동기 로직 및 데이터 변환을 Thunk 액션(예: addItemToCartThunk)에서 처리하여 리듀서와 컴포넌트를 깔끔하게 유지합니다.
    • 리듀서는 순수 상태 변경만 담당하며, 컴포넌트는 단순히 액션을 디스패치할 뿐입니다.
    • 액션 크리에이터(Thunk)에서 HTTP 요청, 데이터 변환, 상태 업데이트(리듀서 액션 디스패치) 모두 수행 가능.
    • 이렇게 하면 컴포넌트 로직과 리듀서 로직을 최소화하고, 비동기 및 부수 효과 로직을 명확히 액션 생성 함수에 격리할 수 있습니다.

이 패턴을 사용하면 처음 제기된 문제(컴포넌트 내부에서 비동기 로직과 상태 변환 로직을 혼용하는 불편함)를 해결하고, 코드 유지보수성과 확장성을 높일 수 있습니다.

285. 고급 리덕스 / 리덕스와 함께 useEffect 사용하기

아래는 리듀서 내에 모든 장바구니 상태 변환 로직을 유지하면서, 비동기 HTTP 요청(백엔드 동기화)을 컴포넌트 내부(App.tsx)의 useEffect를 통해 처리하는 예시 코드입니다. 이 방식은 다음과 같은 특징을 가집니다.

  • 리듀서(Reducer): 상태 변환 로직(장바구니 항목 추가/삭제)을 모두 유지. 즉, 리듀서는 순수 로직만 담당하고 부수 효과나 비동기 코드를 포함하지 않음.
  • 컴포넌트 내부 부수 효과 처리: App.tsx에서 useEffect를 사용해 장바구니 상태 변화 시 HTTP 요청(Firebase로 PUT 요청)을 보내 백엔드에 상태를 동기화.
  • Thunk 액션 미사용: 비동기 로직을 Thunk 액션이 아닌 컴포넌트에서 직접 처리하므로 별도 cart-actions.ts 파일 필요 없음.
  • 유리한 점: 리듀서 로직은 깔끔하게 유지, 부수 효과(비동기 로직)는 컴포넌트로 격리.

아래 예시는 Firebase를 백엔드로 사용합니다. Node.js 백엔드를 사용하려면 URL을 Node.js 서버로 변경하면 됩니다. 여기서는 PUT 요청으로 Firebase에 데이터를 저장하며, Firebase 특성상 .json 엔드포인트를 통해 데이터베이스에 바로 쓰기 가능하다고 가정합니다.

  • 전제 조건:
    • React, Redux Toolkit, react-redux, TypeScript 셋업 완료
    • Firebase Realtime Database URL: https://your-firebase-project.firebaseio.com/cart.json
    • Redux 리듀서에서 상태 변환 로직 유지(addItemToCart, removeItemFromCart 등)
    • App 컴포넌트에서 useEffect로 장바구니 상태 변화 감지 후 PUT 요청
사용자 "장바구니에 추가" 버튼 클릭
       │
       ▼ dispatch(cartActions.addItemToCart(...))
       │
       ▼
    Redux Reducer 
     (상태 변환 로직)
       │
       ▼
   cart 상태 변경
       │
       ▼
 useSelector로 App 컴포넌트에서 cart 상태 감지
       │
       ▼ (useEffect 동작)
 HTTP PUT 요청으로 Firebase에 cart 데이터 동기화

리듀서는 여전히 순수한 상태 업데이트만 담당하며, 부수 효과와 비동기 로직은 컴포넌트(App.tsx)에서 처리합니다.

----- [예시 코드] 고급 리덕스 / 리덕스와 함께 useEffect 사용하기 -----

  • 설명: store/cartSlice.ts

    • addItemToCart, removeItemFromCart 등 모든 로직 리듀서 안에 있음.
    • changed 필드로 cart 데이터 변경 감지.
  • 설명: App.tsx

    • App.tsx에서 cart 상태 변화를 감지(useSelector)하고, useEffect로 백엔드(Firebase)에 PUT 요청.
    • 리듀서에서 변환 로직 유지(addItemToCart, removeItemFromCart 등)
    • 부수 효과(HTTP 요청)만 컴포넌트에서 처리.
  • 정리:

    • 리듀서에서 모든 상태 변환 로직 유지 (addItemToCart, removeItemFromCart).
    • HTTP 요청(부수 효과, 비동기 로직)은 컴포넌트(App.tsx)의 useEffect에서 처리.
    • 리덕스 상태 변화 감지 → useEffect 실행 → 백엔드(Firebase)에 PUT 요청 전송.
    • 이로써 리듀서 순수성 유지, 컴포넌트에서 부수 효과 처리, 구조적이며 유지보수하기 쉬운 패턴을 구현할 수 있음.

286. 고급 리덕스 / useEffect() 문제

현재 우리가 사용하는 방식으로 useEffect를 사용할 때 한 가지 문제에 직면합니다: 앱이 시작될 때 그것이 실행된다는 것입니다.

이것이 왜 문제일까요?

이것은 초기(즉, 비어 있는) 카트를 백엔드로 보내고 거기에 저장된 모든 데이터를 덮어쓰기 때문에 문제입니다.

우리는 이것을 고칠 것입니다, 나는 단지 그것을 지적하고 싶었습니다! (위 예시 코드는 괜찮음. 위 문제 수정한 예시코드임)

287. 고급 리덕스 / 리덕스로 Http State 및 피드백 처리하기

  • 아래 예시는 다음과 같은 상황을 전제로 합니다:
    • 리듀서: 여전히 장바구니 상태 변환 로직을 모두 담고 있으며 순수 로직만 처리(부수 효과 없음, 비동기 없음).
    • 컴포넌트(App.tsx): 장바구니 상태 변경 시 useEffect를 통해 백엔드(Firebase)에 비동기 HTTP 요청을 보내고, 요청 상태(대기, 성공, 실패)에 따라 알림(UI) 상태를 Redux를 통해 관리.
    • UI 알림: uiSlice를 통해 알림 상태를 글로벌로 관리하며, App.tsx에서 HTTP 요청 상태 변화 시 uiActions.showNotification 액션을 디스패치하여 알림 컴포넌트를 렌더링.
    • isInitial 변수: 첫 렌더링 시 장바구니 데이터를 백엔드에 보내지 않도록, isInitial라는 변수를 사용해 첫 렌더링을 구분.

아래는 전체 폴더 구조 및 코드 예시를 모두 제시하며, 코드 생략 없이 완전한 형태를 보여줍니다.

  • 전제 조건:
    • Firebase Realtime Database 사용: https://your-firebase-project.firebaseio.com/cart.json 엔드포인트를 통해 PUT 요청으로 장바구니 데이터 저장
    • Redux Toolkit, React-Redux, TypeScript, React 셋업 완료
    • 장바구니 상태 변화 시 App.tsx의 useEffect에서 백엔드에 동기화 및 알림 표시
사용자 장바구니 변경 액션 (addItemToCart, removeItemFromCart)
       │
       ▼
   Redux Reducer (cartSlice)
  장바구니 상태 변경, changed = true
       │
       ▼
   App.tsx useEffect (cart가 변경될 때 실행)
       │
       ▼ HTTP PUT 요청 (Firebase)
          성공/실패 여부 확인
       │
       ▼ dispatch(uiActions.showNotification(...))
    알림 상태 업데이트 (uiSlice)
       │
       ▼
   Notification 컴포넌트 렌더링, UI에 알림 표시

----- [예시 코드] 고급 리덕스 / 리덕스로 Http State 및 피드백 처리하기 -----

리듀서는 순수 로직만 담당하고, 비동기 로직(HTTP 요청)과 그에 따른 알림 상태 변경은 컴포넌트에서 처리합니다.

  • 정리:
    • 리듀서 내부에서 비즈니스 로직(장바구니 상태 변환)을 그대로 유지.
    • 백엔드 동기화(HTTP 요청)와 오류 처리, 알림 표시 등의 부수 효과는 컴포넌트(App.tsx)에서 useEffect로 처리.
    • 알림 상태는 Redux를 통해 글로벌 관리(UI 슬라이스), HTTP 요청 상태 변화에 따라 알림 표시.
    • isInitial 변수를 사용해 첫 렌더링 시 장바구니 데이터 전송을 방지.
    • 이 패턴을 통해 리듀서 순수성을 유지하고, 컴포넌트 로직 및 부수 효과를 명확히 분리할 수 있다.

288. 고급 리덕스 / 액션 생성자 Thunk 사용하기

아래는 비동기 HTTP 요청(백엔드 동기화)과 부수 효과를 Thunk 액션 크리에이터를 통해 처리하는 패턴에 대한 예시입니다. 이 접근 방식에서는 다음과 같은 특징이 있습니다:

  • 리듀서(Reducer): 상태 변환 로직(장바구니 항목 추가/삭제, 수량 관리)만 담당, 순수함수 유지.
  • Thunk 액션 크리에이터: 비동기 로직(HTTP 요청), 알림 상태 업데이트 등 부수 효과 관련 코드를 Thunk 액션 내부에 배치.
  • 컴포넌트(App.tsx): 매우 단순해져서 단지 Thunk 액션을 디스패치하는 역할만 수행. 구체적인 로직, 데이터 변환, HTTP 요청 처리는 Thunk 액션에서 처리.
  • 정리: 이 방식으로 컴포넌트를 깔끔하고 lean하게 유지하며, 비동기 로직 및 상태 변환 로직을 명확히 분리할 수 있습니다.

아래 예시는 Redux Toolkit, React, TypeScript, Firebase 연동을 가정한 전체 코드 예시입니다. (Firebase 대신 Node.js 서버 사용도 가능하나, URL만 변경하면 됨.)

  • 전제 조건:
    • Firebase를 통한 백엔드 동기화 (장바구니 데이터 PUT 요청)
    • Redux Toolkit 및 React-Redux, TypeScript 셋업 완료
    • 장바구니 데이터 변환 로직은 리듀서(cart-slice) 내부 유지
    • 비동기 로직 및 알림 처리는 Thunk 액션(cart-actions)에서 처리
    • 컴포넌트(App.tsx)는 Thunk 액션을 디스패치하는 단순한 형태로 유지
사용자 장바구니 액션 (addItemToCart 등)
       │
       ▼
   Redux Reducer(순수 상태 변경)
       │
       ▼ cart 상태 변경, changed = true
       │
       ▼
  App.tsx useEffect로 cart 변화 감지
       │
       ▼ dispatch(sendCartData(cart))
         (Thunk 액션)
       │
       ▼ Thunk 액션 내에서 부수 효과(HTTP 요청)
         - 알림 표시(uiActions.showNotification)
         - Firebase PUT 요청
         - 성공/실패 시 알림 상태 변경 액션 디스패치
       │
       ▼
  Notification 컴포넌트 렌더링으로 결과 표시

Thunk 액션(sendCartData)이 비동기 로직, 알림 상태 업데이트를 책임지며, 컴포넌트는 단지 sendCartData(cart)를 디스패치하기만 하면 된다.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  title: string;
  price: number;
  quantity: number;
  totalPrice: number;
}

interface CartState {
  items: CartItem[];
  totalQuantity: number;
  changed: boolean;
}

const initialCartState: CartState = {
  items: [],
  totalQuantity: 0,
  changed: false,
};

interface AddItemPayload {
  id: string;
  title: string;
  price: number;
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: initialCartState,
  reducers: {
    replaceCart(state, action: PayloadAction<CartState>) {
      state.items = action.payload.items;
      state.totalQuantity = action.payload.totalQuantity;
      state.changed = false;
    },
    addItemToCart(state, action: PayloadAction<AddItemPayload>) {
      const newItem = action.payload;
      const existingItem = state.items.find(item => item.id === newItem.id);
      state.totalQuantity++;
      state.changed = true;
      if (!existingItem) {
        state.items.push({
          id: newItem.id,
          title: newItem.title,
          price: newItem.price,
          quantity: 1,
          totalPrice: newItem.price,
        });
      } else {
        existingItem.quantity++;
        existingItem.totalPrice += newItem.price;
      }
    },
    removeItemFromCart(state, action: PayloadAction<string>) {
      const id = action.payload;
      const existingItem = state.items.find(item => item.id === id);
      if (!existingItem) return;
      state.totalQuantity--;
      state.changed = true;
      if (existingItem.quantity === 1) {
        state.items = state.items.filter(item => item.id !== id);
      } else {
        existingItem.quantity--;
        existingItem.totalPrice -= existingItem.price;
      }
    },
  },
});

export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface Notification {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

interface UIState {
  cartIsVisible: boolean;
  notification: Notification | null;
}

const initialUIState: UIState = {
  cartIsVisible: false,
  notification: null,
};

interface ShowNotificationPayload {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

const uiSlice = createSlice({
  name: 'ui',
  initialState: initialUIState,
  reducers: {
    toggleCart(state) {
      state.cartIsVisible = !state.cartIsVisible;
    },
    showNotification(state, action: PayloadAction<ShowNotificationPayload>) {
      state.notification = {
        status: action.payload.status,
        title: action.payload.title,
        message: action.payload.message,
      };
    },
  },
});

export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { AppDispatch, RootState } from './index';
import { uiActions } from './ui-slice';

// 장바구니 데이터를 서버(Firebase)에 전송하는 thunk 액션
export const sendCartData = (cart: RootState['cart']) => {
  return async (dispatch: AppDispatch) => {
    dispatch(uiActions.showNotification({
      status: 'pending',
      title: '보내는 중...',
      message: '장바구니 데이터를 서버에 보내는 중...'
    }));

    try {
      const response = await fetch('https://your-firebase-project.firebaseio.com/cart.json', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' }, // 중요!! 어떤 데이터 타입으로 보내는지 명시 필요!!
        body: JSON.stringify({
          items: cart.items,
          totalQuantity: cart.totalQuantity,
        }),
      });

      if (!response.ok) {
        throw new Error('장바구니 전송 실패!');
      }

      dispatch(uiActions.showNotification({
        status: 'success',
        title: '성공!',
        message: '장바구니 데이터 전송 성공!',
      }));
    } catch (error) {
      dispatch(uiActions.showNotification({
        status: 'error',
        title: '오류!',
        message: '장바구니 데이터 전송에 실패했습니다!',
      }));
    }
  };
};
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './ui-slice';
import cartReducer from './cart-slice';

export const store = configureStore({
  reducer: {
    ui: uiReducer,
    cart: cartReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from './store';
import Layout from './components/Layout';
import Cart from './components/Cart';
import Products from './components/Products';
import Notification from './components/Notification';
import { sendCartData } from './store/cart-actions';

let isInitial = true;

const App: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();
  const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);
  const cart = useSelector((state: RootState) => state.cart);
  const notification = useSelector((state: RootState) => state.ui.notification);

  useEffect(() => {
    // 첫 렌더링 시에는 전송 안 함
    if (isInitial) {
      isInitial = false;
      return;
    }

    if (cart.changed) {
      // 장바구니 데이터 변경 시 Thunk 액션 디스패치
      dispatch(sendCartData(cart));
    }
  }, [cart, dispatch]);

  return (
    <>
      {notification && <Notification status={notification.status} title={notification.title} message={notification.message}/>}
      <Layout>
        {showCart && <Cart />}
        <Products />
      </Layout>
    </>
  );
}

export default App;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartButton from './CartButton';

interface LayoutProps {
  children?: React.ReactNode;
}

const Layout: React.FC<LayoutProps> = (props) => {
  const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);

  return (
    <div>
      <header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
        <h1>리덕스 장바구니 예제</h1>
        <div style={{ display: 'flex', alignItems: 'center' }}>
          <CartButton />
          <span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
        </div>
      </header>
      <main style={{ padding: '1rem' }}>{props.children}</main>
    </div>
  )
}

export default Layout;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartItem from './CartItem';

const Cart: React.FC = () => {
  const items = useSelector((state: RootState) => state.cart.items);

  if (items.length === 0) {
    return <p>장바구니가 비어있습니다!</p>;
  }

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
      <h2>내 장바구니</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {items.map(item => (
          <CartItem 
            key={item.id}
            item={{
              id: item.id,
              title: item.title,
              quantity: item.quantity,
              price: item.price,
              totalPrice: item.totalPrice
            }}
          />
        ))}
      </ul>
    </div>
  );
};

export default Cart;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

interface CartItemProps {
  item: {
    id: string;
    title: string;
    quantity: number;
    price: number;
    totalPrice: number;
  };
}

const CartItem: React.FC<CartItemProps> = (props) => {
  const { id, title, quantity, price, totalPrice } = props.item;
  const dispatch = useDispatch<AppDispatch>();

  const removeItemHandler = () => {
    dispatch(cartActions.removeItemFromCart(id));
  };

  const addItemHandler = () => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  return (
    <li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
      <div><strong>{title}</strong></div>
      <div>단가: {price}</div>
      <div>수량: {quantity}</div>
      <div>총 가격: {totalPrice}</div>
      <div style={{ marginTop: '0.5rem' }}>
        <button onClick={removeItemHandler}>-</button>
        <button onClick={addItemHandler}>+</button>
      </div>
    </li>
  );
};

export default CartItem;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { uiActions } from '../store/ui-slice';

const CartButton: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();

  const toggleCartHandler = () => {
    dispatch(uiActions.toggleCart());
  };

  return <button onClick={toggleCartHandler}>내 장바구니</button>;
};

export default CartButton;
import React from 'react';
import ProductItem from './ProductItem';

const DUMMY_PRODUCTS = [
  { id: 'p1', price: 6, title: 'My First Book', description: 'The first book I ever wrote.' },
  { id: 'p2', price: 5, title: 'My Second Book', description: 'The second book I ever wrote.' },
];

const Products: React.FC = () => {
  return (
    <section>
      <h2>구매할 상품 선택</h2>
      <ul>
        {DUMMY_PRODUCTS.map(product => (
          <ProductItem 
            key={product.id} 
            id={product.id} 
            title={product.title} 
            price={product.price} 
            description={product.description}
          />
        ))}
      </ul>
    </section>
  );
};

export default Products;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

interface ProductItemProps {
  id: string;
  title: string;
  price: number;
  description: string;
}

const ProductItem: React.FC<ProductItemProps> = (props) => {
  const { id, title, price, description } = props;
  const dispatch = useDispatch<AppDispatch>();

  const addToCartHandler = () => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  return (
    <li style={{ marginBottom: '1rem', border: '1px solid #ddd', padding: '1rem' }}>
      <h3>{title}</h3>
      <p>{description}</p>
      <div>가격: {price}</div>
      <button onClick={addToCartHandler}>장바구니에 추가</button>
    </li>
  );
};

export default ProductItem;
import React from 'react';

interface NotificationProps {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

const Notification: React.FC<NotificationProps> = (props) => {
  let backgroundColor = '#ccc';

  if (props.status === 'pending') {
    backgroundColor = 'blue';
  }
  if (props.status === 'success') {
    backgroundColor = 'green';
  }
  if (props.status === 'error') {
    backgroundColor = 'red';
  }

  return (
    <section style={{ backgroundColor, padding: '1rem', position: 'fixed', top: '1rem', right: '1rem', color: '#fff' }}>
      <h2>{props.title}</h2>
      <p>{props.message}</p>
    </section>
  );
};

export default Notification;
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
  • store/cartActions.ts (Thunk 액션)

    • 설명:
    • sendCartData Thunk 액션: 비동기 HTTP 요청 및 알림 상태 업데이트를 모두 여기서 처리.
    • 컴포넌트에서는 단지 dispatch(sendCartData(cart)) 형태로 호출.
  • App.tsx

    • 설명:
    • 컴포넌트에서는 장바구니 변화 감지 후 dispatch(sendCartData(cart)) 호출만 담당.
    • 비동기 로직, 알림 업데이트 모두 Thunk 액션에서 처리.
  • 정리:

    • Thunk 액션(sendCartData)을 통해 비동기 로직과 알림 상태 변경 로직을 한 곳에 모아둬서 컴포넌트(App.tsx)를 깔끔하게 유지.
    • 리듀서(cart-slice)에서는 상태 변환 로직만 유지(순수함수).
    • 컴포넌트는 Thunk 액션을 디스패치하기만 하면 되고, 복잡한 비동기 로직, 알림 표시, 오류 처리 등은 Thunk 내부에서 처리.
    • 이 방식은 이전의 방법(컴포넌트 내부에서 부수 효과 처리)과 대안적이며, 코드 유지보수성과 재사용성을 높여준다.

----- [예시 코드] 고급 리덕스 / 액션 생성자 Thunk 사용하기 -----

289. 고급 리덕스 / 데이터 가져오기 시작하기

아래는 Thunk 액션 크리에이터를 활용하여 애플리케이션 로드시 Firebase에서 장바구니 데이터를 가져온 뒤 상태를 업데이트하고, 장바구니 상태 변경 시 서버에 동기화하는 전체 예시 코드입니다. 이전 예시에서는 장바구니 데이터를 fetch하지 않았으나, 이번 예시에서는 fetchCartData Thunk 액션을 추가해 앱 로드 시 Firebase에서 장바구니 데이터를 가져옵니다.

  • 핵심 포인트:
    • fetchCartData Thunk 액션: 앱 시작 시 백엔드(Firebase)에서 장바구니 데이터를 가져와 리덕스 상태로 교체.
    • sendCartData Thunk 액션: 장바구니 상태 변경 시 자동으로 서버에 동기화. 단, 앱 초기 로딩 시점의 fetch로 인해 장바구니 상태가 교체되었을 때는 changed = false를 설정해서 다시 전송하지 않도록 함.
    • App.tsx:
      • 첫 렌더링 시 fetchCartData를 디스패치해 백엔드에서 장바구니 데이터를 가져옴(once).
      • 장바구니 상태 변화 감지 시 sendCartData를 디스패치하되, changed가 true일 때만 전송(이미 replaceCart로 불러온 데이터는 changed = false이므로 재전송 없음).

이로써 애플리케이션 로드시 백엔드 데이터로 장바구니 상태를 초기화하고, 이후 장바구니 변경 시 자동 동기화를 구현하며, 불필요한 재전송 문제를 피할 수 있습니다.

  • 전제 조건:
    • Firebase Realtime Database를 사용해 PUT/GET 요청으로 장바구니 데이터 동기화.
    • Redux Toolkit, React-Redux, TypeScript 사용.
    • 장바구니 상태 변환 로직은 여전히 리듀서(cart-slice) 내에서 순수 로직으로 유지.
    • 비동기 로직(데이터 fetch, 전송)은 Thunk 액션(cart-actions)에서 처리.
    • 컴포넌트(App.tsx)는 Thunk 액션을 디스패치하는 역할만 담당.
앱 로드 시
       │
       ▼ dispatch(fetchCartData())
         (Thunk 액션)
       │
       ▼ Firebase에서 장바구니 데이터 GET
       │
       ▼ cartActions.replaceCart(...) 디스패치
         changed = false로 설정
       │
       ▼ 리듀서에서 상태 업데이트
       │
       ▼ App.tsx useEffect (cart.changed감지)
         초기 장바구니 대입은 changed=false이므로 sendCartData 미실행
       │
사용자가 장바구니 변경(addItemToCart 등)
       │
       ▼ changed = true
       │
       ▼ cart 상태 변경 감지 → sendCartData(cart) 디스패치
         Firebase에 PUT 요청
       │
       ▼ 성공/실패 여부에 따라 uiActions.showNotification 디스패치
       │
       ▼ Notification 컴포넌트로 알림 표시

fetchCartData로 초기 상태를 로딩한 뒤, changed=false 상태로 시작하므로 불필요한 재전송 없음. 이후 장바구니 변경 시 changed=true가 되면 sendCartData로 서버 동기화.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  title: string;
  price: number;
  quantity: number;
  totalPrice: number;
}

interface CartState {
  items: CartItem[];
  totalQuantity: number;
  changed: boolean;
}

const initialCartState: CartState = {
  items: [],
  totalQuantity: 0,
  changed: false,
};

interface AddItemPayload {
  id: string;
  title: string;
  price: number;
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: initialCartState,
  reducers: {
    replaceCart(state, action: PayloadAction<CartState>) {
      state.items = action.payload.items;
      state.totalQuantity = action.payload.totalQuantity;
      // fetch 후 교체하므로 changed = false
      state.changed = false;
    },
    addItemToCart(state, action: PayloadAction<AddItemPayload>) {
      const newItem = action.payload;
      const existingItem = state.items.find(item => item.id === newItem.id);
      state.totalQuantity++;
      state.changed = true;
      if (!existingItem) {
        state.items.push({
          id: newItem.id,
          title: newItem.title,
          price: newItem.price,
          quantity: 1,
          totalPrice: newItem.price,
        });
      } else {
        existingItem.quantity++;
        existingItem.totalPrice += newItem.price;
      }
    },
    removeItemFromCart(state, action: PayloadAction<string>) {
      const id = action.payload;
      const existingItem = state.items.find(item => item.id === id);
      if (!existingItem) return;
      state.totalQuantity--;
      state.changed = true;
      if (existingItem.quantity === 1) {
        state.items = state.items.filter(item => item.id !== id);
      } else {
        existingItem.quantity--;
        existingItem.totalPrice -= existingItem.price;
      }
    },
  },
});

export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface Notification {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

interface UIState {
  cartIsVisible: boolean;
  notification: Notification | null;
}

const initialUIState: UIState = {
  cartIsVisible: false,
  notification: null,
};

interface ShowNotificationPayload {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

const uiSlice = createSlice({
  name: 'ui',
  initialState: initialUIState,
  reducers: {
    toggleCart(state) {
      state.cartIsVisible = !state.cartIsVisible;
    },
    showNotification(state, action: PayloadAction<ShowNotificationPayload>) {
      state.notification = {
        status: action.payload.status,
        title: action.payload.title,
        message: action.payload.message,
      };
    },
  },
});

export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { AppDispatch, RootState } from './index';
import { uiActions } from './ui-slice';
import { cartActions } from './cart-slice';

// 장바구니 데이터를 서버(Firebase)로 전송하는 thunk 액션
export const sendCartData = (cart: RootState['cart']) => {
  return async (dispatch: AppDispatch) => {
    dispatch(uiActions.showNotification({
      status: 'pending',
      title: '보내는 중...',
      message: '장바구니 데이터를 서버에 보내는 중...'
    }));

    try {
      const response = await fetch('https://your-firebase-project.firebaseio.com/cart.json', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' }, // 중요!! 어떤 데이터 타입으로 보내는지 명시 필요!!
        body: JSON.stringify({
          items: cart.items,
          totalQuantity: cart.totalQuantity
        }),
      });

      if (!response.ok) {
        throw new Error('장바구니 전송 실패!');
      }

      dispatch(uiActions.showNotification({
        status: 'success',
        title: '성공!',
        message: '장바구니 데이터 전송 성공!',
      }));
    } catch (error) {
      dispatch(uiActions.showNotification({
        status: 'error',
        title: '오류!',
        message: '장바구니 데이터 전송에 실패했습니다!',
      }));
    }
  };
};

// 애플리케이션 로드시 서버(Firebase)에서 장바구니 데이터를 가져오는 thunk 액션
export const fetchCartData = () => {
  return async (dispatch: AppDispatch) => {
    const fetchData = async () => {
      const response = await fetch('https://your-firebase-project.firebaseio.com/cart.json');
      if (!response.ok) {
        throw new Error('장바구니 데이터를 가져올 수 없습니다!');
      }
      const data = await response.json();
      return data;
    };

    try {
      const cartData = await fetchData();
      dispatch(cartActions.replaceCart({
        items: cartData?.items || [],
        totalQuantity: cartData?.totalQuantity || 0,
        changed: false,
      }));
      // 필요하다면 성공 알림도 표시 가능하지만 여기서는 생략
    } catch (error) {
      dispatch(uiActions.showNotification({
        status: 'error',
        title: '오류!',
        message: '장바구니 데이터를 가져오지 못했습니다.'
      }));
    }
  };
};
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './ui-slice';
import cartReducer from './cart-slice';

export const store = configureStore({
  reducer: {
    ui: uiReducer,
    cart: cartReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from './store';
import Layout from './components/Layout';
import Cart from './components/Cart';
import Products from './components/Products';
import Notification from './components/Notification';
import { sendCartData, fetchCartData } from './store/cart-actions';

let isInitial = true;

const App: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();
  const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);
  const cart = useSelector((state: RootState) => state.cart);
  const notification = useSelector((state: RootState) => state.ui.notification);

  // 앱 로드 시 장바구니 데이터 fetch
  useEffect(() => {
    dispatch(fetchCartData());
  }, [dispatch]);

  // 장바구니 상태 변경 시 서버에 동기화
  useEffect(() => {
    if (isInitial) {
      isInitial = false;
      return;
    }

    if (cart.changed) {
      dispatch(sendCartData(cart));
    }
  }, [cart, dispatch]);

  return (
    <>
      {notification && <Notification status={notification.status} title={notification.title} message={notification.message}/>}
      <Layout>
        {showCart && <Cart />}
        <Products />
      </Layout>
    </>
  );
}

export default App;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartButton from './CartButton';

interface LayoutProps {
  children?: React.ReactNode;
}

const Layout: React.FC<LayoutProps> = (props) => {
  const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);

  return (
    <div>
      <header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
        <h1>리덕스 장바구니 예제</h1>
        <div style={{ display: 'flex', alignItems: 'center' }}>
          <CartButton />
          <span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
        </div>
      </header>
      <main style={{ padding: '1rem' }}>{props.children}</main>
    </div>
  )
}

export default Layout;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartItem from './CartItem';

const Cart: React.FC = () => {
  const items = useSelector((state: RootState) => state.cart.items);

  if (items.length === 0) {
    return <p>장바구니가 비어있습니다!</p>;
  }

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
      <h2>내 장바구니</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {items.map(item => (
          <CartItem 
            key={item.id}
            item={{
              id: item.id,
              title: item.title,
              quantity: item.quantity,
              price: item.price,
              totalPrice: item.totalPrice
            }}
          />
        ))}
      </ul>
    </div>
  );
};

export default Cart;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

interface CartItemProps {
  item: {
    id: string;
    title: string;
    quantity: number;
    price: number;
    totalPrice: number;
  };
}

const CartItem: React.FC<CartItemProps> = (props) => {
  const { id, title, quantity, price, totalPrice } = props.item;
  const dispatch = useDispatch<AppDispatch>();

  const removeItemHandler = () => {
    dispatch(cartActions.removeItemFromCart(id));
  };

  const addItemHandler = () => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  return (
    <li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
      <div><strong>{title}</strong></div>
      <div>단가: {price}</div>
      <div>수량: {quantity}</div>
      <div>총 가격: {totalPrice}</div>
      <div style={{ marginTop: '0.5rem' }}>
        <button onClick={removeItemHandler}>-</button>
        <button onClick={addItemHandler}>+</button>
      </div>
    </li>
  );
};

export default CartItem;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { uiActions } from '../store/ui-slice';

const CartButton: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();

  const toggleCartHandler = () => {
    dispatch(uiActions.toggleCart());
  };

  return <button onClick={toggleCartHandler}>내 장바구니</button>;
};

export default CartButton;
import React from 'react';
import ProductItem from './ProductItem';

const DUMMY_PRODUCTS = [
  { id: 'p1', price: 6, title: 'My First Book', description: 'The first book I ever wrote.' },
  { id: 'p2', price: 5, title: 'My Second Book', description: 'The second book I ever wrote.' },
];

const Products: React.FC = () => {
  return (
    <section>
      <h2>구매할 상품 선택</h2>
      <ul>
        {DUMMY_PRODUCTS.map(product => (
          <ProductItem 
            key={product.id} 
            id={product.id} 
            title={product.title} 
            price={product.price} 
            description={product.description}
          />
        ))}
      </ul>
    </section>
  );
};

export default Products;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';

interface ProductItemProps {
  id: string;
  title: string;
  price: number;
  description: string;
}

const ProductItem: React.FC<ProductItemProps> = (props) => {
  const { id, title, price, description } = props;
  const dispatch = useDispatch<AppDispatch>();

  const addToCartHandler = () => {
    dispatch(cartActions.addItemToCart({ id, title, price }));
  };

  return (
    <li style={{ marginBottom: '1rem', border: '1px solid #ddd', padding: '1rem' }}>
      <h3>{title}</h3>
      <p>{description}</p>
      <div>가격: {price}</div>
      <button onClick={addToCartHandler}>장바구니에 추가</button>
    </li>
  );
};

export default ProductItem;
import React from 'react';

interface NotificationProps {
  status: 'pending' | 'success' | 'error';
  title: string;
  message: string;
}

const Notification: React.FC<NotificationProps> = (props) => {
  let backgroundColor = '#ccc';

  if (props.status === 'pending') {
    backgroundColor = 'blue';
  }
  if (props.status === 'success') {
    backgroundColor = 'green';
  }
  if (props.status === 'error') {
    backgroundColor = 'red';
  }

  return (
    <section style={{ backgroundColor, padding: '1rem', position: 'fixed', top: '1rem', right: '1rem', color: '#fff' }}>
      <h2>{props.title}</h2>
      <p>{props.message}</p>
    </section>
  );
};

export default Notification;
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
  • store/cartActions.ts

    • 설명:
      • fetchCartData Thunk로 앱 로드 시 Firebase에서 cart 데이터 GET 요청 후 replaceCart 디스패치.
      • replaceCart 후 changed = false이므로 sendCartData 효과로 인한 재전송 없음.
  • 정리:

    • fetchCartData Thunk 액션으로 앱 시작 시 백엔드 데이터 불러오기. replaceCart로 상태 설정시 changed=false로 만들어 재전송 방지.
    • sendCartData Thunk 액션으로 장바구니 변경 시 백엔드 동기화, 상태 변화 감지 후 알림 표시.
    • App.tsx에서는 단순히 Thunk 액션( fetchCartData, sendCartData )을 디스패치하는 로직만 존재, 컴포넌트는 lean한 상태 유지.
    • 이 패턴으로 리듀서, 컴포넌트, 비동기 로직(Thunk) 간 역할이 명확히 분리되어 유지보수 및 가독성이 향상된다.

290. 고급 리덕스 / 가져오기 로직 마무리하기

아래는 이전까지 다룬 문제들을 모두 해결한 최종 패턴의 예시 코드입니다. 이 예제에서는 다음과 같은 개선점을 반영했습니다.

  1. changed 필드 관리:

    • 장바구니 데이터를 로딩(fetchCartData)할 때 replaceCart를 통해 Firebase에서 가져온 데이터를 상태에 반영하되, 이 때 changed = false로 설정합니다.
    • 로컬 애플리케이션에서 항목을 추가하거나 제거할 때만 changed = true로 설정하므로, 앱 초기 로딩 시 가져온 데이터 때문에 불필요한 서버 전송을 하지 않습니다.
  2. 데이터 전송 시 changed 제외:

    • 서버에 전송(sendCartData)할 때는 changed 프로퍼티를 보내지 않고, items와 totalQuantity만 담은 객체를 PUT 요청합니다.
    • 이렇게 하면 Firebase에 changed가 저장되지 않으므로, 불필요한 재전송 문제를 피할 수 있습니다.
  3. removeItemFromCart 로직 수정:

    • 항목 제거 시 totalPrice를 정확히 업데이트하여 가격 계산 문제 해결.
  4. fetchCartData 시 items 기본값 설정:

    • Firebase에서 items를 못 받으면 undefined일 수 있으므로, 기본값을 빈 배열로 설정해 오류 방지.

이로써 애플리케이션이 로드될 때 Firebase에서 장바구니를 가져오고, 이후 장바구니 변경 시에만 서버에 동기화하며, 알림을 통해 상태를 표시하는 완전한 패턴이 완성됩니다.

  • 전제 조건:
    • Firebase Realtime Database를 통해 장바구니 상태 GET/PUT 가능
    • Redux Toolkit, React-Redux, TypeScript 사용
    • 리듀서에서 상태 변환 로직 유지(순수함수), 부수 효과·비동기 로직은 Thunk 액션에서 처리
    • 컴포넌트(App.tsx)는 Thunk 액션 호출로 로직을 분리해 깔끔하게 유지
앱 로드 시
       │
       ▼ dispatch(fetchCartData())
         (Thunk 액션)
       │
       ▼ Firebase GET 요청
       │
       ▼ cartActions.replaceCart(...) 디스패치
         changed = false로 상태 설정
       │
       ▼ 리듀서 상태 업데이트

사용자가 장바구니 변경(addItemToCart, removeItemFromCart)
       │
       ▼ changed = true
       │
       ▼ cart 상태 변경 감지 → sendCartData(cart) 디스패치
         Firebase PUT 요청 (changed 제외)
       │
       ▼ 성공/실패 시 uiActions.showNotification 디스패치
       │
       ▼ Notification 컴포넌트로 알림 표시

로드 시 가져온 데이터는 changed=false 상태이므로 즉시 재전송 없음. 이후 로컬 변경 시 changed=true → 서버 동기화.

  • store/cartActions.ts
    • 설명:
      • sendCartData: changed 제외한 객체 PUT 요청.
      • fetchCartData: items/totalQuantity 기본값 설정, replaceCart로 로컬 장바구니 상태 갱신(changed=false).

----- [예시 코드] 고급 리덕스 / 데이터 가져오기 시작하기, 가져오기 로직 마무리하기 -----

  • 정리:
    • changed 필드와 fetchCartData, sendCartData Thunk 액션을 통해 데이터 로드와 전송을 정교하게 제어.
    • changed=false로 가져온 초기 데이터는 불필요한 재전송 피하고, 이후 로컬 변경 시 changed=true로 설정되어 백엔드 동기화 가능.
    • removeItemFromCart totalPrice 로직 수정으로 가격 업데이트 문제 해결.
    • items 기본값 처리로 비어있는 장바구니 시 오류 방지.
    • 이 모든 조합으로 리덕스, 비동기 로직, UI 알림, 백엔드 동기화까지 완성도 높은 패턴 구현.

291. 고급 리덕스 / 리덕스 DevTools 탐색하기

아래는 Redux DevTools를 활용해 리덕스 상태와 작업(액션)을 디버깅하는 예시를 자세히 정리한 내용입니다. 이 예시는 이전까지 구현한 패턴(Thunk 액션을 통한 비동기 로직 처리, 리듀서에서 상태 변환 로직 유지, 컴포넌트에서 최소한의 역할만 수행)을 그대로 유지한 상태에서, Redux DevTools를 사용하여 상태 변화를 추적하고, 디버그하는 과정을 보여줍니다.

  • 핵심 포인트:

    • Redux DevTools는 브라우저 확장 프로그램(크롬, 파이어폭스 등)으로 설치 가능.
    • Redux Toolkit을 사용하면 추가 설정 없이 DevTools를 바로 사용할 수 있음(기본적으로 configureStore가 DevTools 지원).
    • DevTools를 통해 디스패치된 작업(액션), 해당 작업의 페이로드, 상태 변화(diff) 등을 시각적으로 확인 가능.
    • 시간여행(Time-Travel) 디버깅 기능을 통해 과거 상태로 돌아가서 애플리케이션 상태 변화를 추적 가능.
  • 정리:

    • Redux DevTools 브라우저 확장 프로그램 설치 후 DevTools 패널에서 디스패치된 액션, 상태 변화, 페이로드, 상태 diff를 시각적으로 확인 가능.
    • 시간여행 디버깅: 과거 상태로 돌아가며 상태 변화를 추적 가능.
    • 복잡한 Redux 로직, 여러 Slice, 비동기 로직이 뒤섞인 상황에서도 DevTools로 손쉽게 디버깅하고 문제를 파악할 수 있음.
    • 이로써 리덕스 애플리케이션 개발, 유지보수가 더욱 용이해짐.

292. 고급 리덕스 / 요약

아래는 지금까지 배운 내용을 종합적으로 정리한 내용입니다. 리덕스(Redux)를 활용한 전역 상태 관리, 비동기 작업 처리, 부수 효과 관리, 그리고 리덕스 데브툴(Redux DevTools)의 활용까지 모두 아우르는 핵심 개념을 정리했습니다.

  1. 개념 정리
    • 리덕스 기초 개념 재확인
      • 하나의 전역 스토어: 애플리케이션 전역 상태를 단일 스토어에서 관리한다.
      • 리듀서(Reducer): 상태 변환 로직을 포함하는 순수 함수. 이전 상태와 액션을 받아 새로운 상태를 반환한다.
      • 액션(Action): 상태 변경을 유발하는 이벤트 객체. type 필드를 가져야 하며, 필요한 경우 payload로 추가 데이터 전달.
      • 디스패치(Dispatch): 액션을 스토어에 전달해 리듀서 실행을 유도한다.
      • useSelector & useDispatch: 컴포넌트에서 리덕스 상태 읽기(useSelector)와 액션 디스패치(useDispatch)를 간편히 할 수 있다.
    • 비동기 코드와 부수 효과 처리
      • 리듀서 순수성 유지: 리듀서 내부에서는 비동기 코드, HTTP 요청, 타이머 설정 등 부수 효과를 절대 실행하지 않는다.
      • 부수 효과 처리 위치:
        1. 컴포넌트 내부: useEffect 훅을 활용해 상태 변화 감지 후 비동기 요청을 보내고, 응답 처리 가능. 그러나 컴포넌트에 로직이 많아질 수 있음.
        2. Thunk 액션 크리에이터(Async Action): 액션 크리에이터 함수에서 비동기 로직을 처리하고, 완료 후 결과 액션을 디스패치. 컴포넌트를 깔끔하게 유지하고 로직을 재사용하기 용이하다.
    • 데이터 변환 로직 배치
      • 리듀서 vs 컴포넌트 vs 액션 크리에이터:
        • 상태 변환(데이터 변환) 로직은 리듀서에서 처리하는 것이 이상적.
        • 비동기 로직(HTTP 요청) 및 부수 효과는 컴포넌트나 Thunk 액션 크리에이터 중 한 곳에서 처리할 수 있다.
        • 컴포넌트 내부에 너무 많은 로직(데이터 변환, 비동기 요청)이 들어가면 컴포넌트가 지나치게 비대(Fat)해진다.
        • Thunk 액션 크리에이터를 활용하면 컴포넌트를 lean하게 유지하고 비동기 로직과 상태 변환 로직을 명확히 분리할 수 있다.
    • 리덕스 데브툴(Redux DevTools) 활용
      • 브라우저 확장 프로그램으로 설치 가능.
      • 디스패치된 액션(작업) 목록, 각 액션의 페이로드, 상태 변화(diff) 등을 시각적으로 확인 가능.
      • 시간 여행 디버깅(이전 상태로 이동) 기능을 통해 상태 변화 과정을 추적 및 디버깅에 도움.
      • 복잡한 리덕스 앱에서 디버깅을 크게 단순화해준다.
    • 종합
      • 리덕스를 활용해 전역 상태를 효율적으로 관리하고, 비동기 로직과 부수 효과를 적절한 위치(컴포넌트, Thunk 액션 크리에이터)에 배치함으로써 코드의 유지보수성과 확장성을 높일 수 있다.
      • Redux DevTools를 통해 상태 변화를 시각적으로 파악하고 디버깅하기 쉬워진다.
      • 이로써 단순 상태 관리뿐 아니라 HTTP 요청, 알림 표시, 로컬/서버 상태 동기화 등 실제 애플리케이션에 필요한 다양한 로직을 리덕스 패턴 내에서 깔끔히 처리할 수 있다.
사용자 상호작용 (버튼 클릭, 장바구니 업데이트)
       │
       ▼ dispatch(액션) → Reducer(상태변환)
       │
       ▼
Redux Store 상태 변경
       │
       ▼ (useSelector로 감지)
컴포넌트 로직(예: useEffect) 또는 Thunk 액션 크리에이터
       │
       ├─ 비동기 요청(HTTP)
       ├─ 부수 효과(알림 표시 등)
       ▼
디스패치(결과 액션) → Reducer(상태변환)
       │
       ▼
UI 업데이트 & Redux DevTools로 상태/액션 추적
  • 리듀서는 순수 상태 업데이트 담당

  • 비동기 로직은 컴포넌트나 Thunk 액션에서 처리

  • DevTools로 모든 액션과 상태 변화를 추적 가능

  • 정리:

    • 이로써 리덕스를 통한 전역 상태 관리, 비동기 로직 처리, 컴포넌트 단순화, Redux DevTools 디버깅까지 다룬 고급 개념들을 습득했으며, 이를 통해 복잡한 리덕스 애플리케이션도 체계적으로 구성, 유지, 디버깅할 수 있게 되었다.

293. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 소개

아래는 싱글 페이지 애플리케이션(SPA)에서 라우팅을 도입하는 이유와 방법에 대한 개념을 종합적으로 정리한 내용입니다.

  • 기존 상황: 싱글 페이지 애플리케이션(SPA)

    • 지금까지 만든 React 데모 애플리케이션은 모두 단일 페이지로 이루어져 있습니다.
    • 사용자가 버튼을 클릭하거나 상태가 변경되면 UI는 업데이트되지만, URL은 항상 동일하게 유지되므로 페이지 전환 느낌이 없음.
    • 즉, 현재는 브라우저 주소창의 URL이 변화하지 않고, 특정 부분에 직접 링크할 수 없음.
  • 문제점

    • 웹의 큰 장점: 특정 리소스나 페이지에 대한 직접 링크가 가능해야 함.
    • 현재 SPA 형태에서는 특정 화면 상태를 로딩하는 직접 URL을 제공하기 어렵다.
    • 사용자가 항상 시작 화면에서 시작하고, 원하는 화면으로 수동 이동해야 함.
  • 해결책: 클라이언트 측 라우팅

    • 클라이언트 측 라우팅(Client-Side Routing): 실제로는 하나의 페이지지만, 브라우저 주소창의 URL을 변경해 다양한 "가상의 페이지"를 나타냄.
    • URL 변화에 따라 React가 해당 상태에 맞는 컴포넌트를 렌더링함으로써 마치 여러 페이지가 있는 것처럼 보이게 함.
    • 가장 널리 쓰이는 라이브러리: React Router
      • React Router를 사용하면 SPA에서도 다양한 경로(URL)에 따라 다른 화면을 보여줄 수 있음.
      • 사용자는 특정 URL로 직접 진입하거나, 다른 페이지로 이동하는 링크를 클릭하는 것만으로 원하는 부분에 접근 가능.
  • 결과

    • SPA의 장점을 유지하면서도(페이지 전체 새로고침 없이 부드러운 화면 전환), 멀티 페이지 애플리케이션처럼 다양한 페이지를 제공할 수 있음.
    • 사용자 경험 개선: 원하는 화면을 바로 링크하고, 북마크하고, 공유할 수 있다.
    • 강의 섹션 후반부에서는 React Router를 활용해 데이터 가져오기 및 전송, 경로별로 데이터 처리 등 고급 기능도 살펴볼 예정.
(기존 SPA)
   ┌─────────────────┐
   │ 하나의 페이지    │
   │ URL 변화 없음     │
   │ 한 화면만 렌더링   │
   └───────┬─────────┘
           │
           ▼
 (문제) 특정 화면 상태로 직접 링크 불가,
       사용자가 항상 시작 지점에서 탐색 필요
           │
           ▼
(해결: 클라이언트 측 라우팅)
   ┌─────────────────┐
   │ React Router 활용 │
   │ URL 변화 반영      │
   │ 경로별 컴포넌트 렌더링 │
   └───────┬─────────┘
           │
           ▼
사용자는 /products, /cart 등
특정 URL로 바로 접근 가능
페이지 전환 없이도 마치 멀티 페이지처럼 동작

이로써 하나의 SPA 내에서 URL에 따라 다른 화면을 렌더링하는 로직을 구현할 수 있으며, React Router 등 라이브러리를 활용해 쉽게 클라이언트 측 라우팅을 도입할 수 있다.

294. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 라우팅: 싱글 페이지 애플리케이션에서 다수의 페이지 보여주기

----- [예시 코드] 리액트 라우터가 있는 SPA 다중 페이지 구축하기 1

아래는 라우팅(Routing)의 개념을 이해하고, React Router를 사용하여 싱글 페이지 애플리케이션(SPA)에서도 URL 경로에 따라 다른 화면(컴포넌트)을 렌더링하는 패턴을 예시로 보여준 코드입니다. 이 예시를 통해 서버로부터 매번 새로운 HTML을 로딩하지 않고도 다양한 경로에 따른 페이지 전환을 구현할 수 있습니다. 또한 React Router를 통해 브라우저 주소창의 URL 변화를 감지하고, 해당 URL에 맞는 컴포넌트를 렌더링함으로써 마치 멀티 페이지 애플리케이션처럼 동작하는 SPA를 만들 수 있습니다.

아래 예시는 react-router-dom v6 이상 버전을 사용하며, 가장 많이 검증된 패턴을 따릅니다. 전체 폴더 구조와 모든 파일 코드를 자세하게 제시합니다(코드 생략 없음).

  • 라우팅(Routing) 개념

    • 전통적인 웹 방식: 다른 URL(경로)로 이동하면 서버에서 새로운 HTML 파일을 받아와 페이지 전체가 새로 로딩됨.
    • 싱글 페이지 애플리케이션(SPA): 최초 한 번만 HTML + JS 로딩 후, JS 코드로 UI 변경 관리.
      • 문제: SPA에서는 기본적으로 URL이 변하지 않으므로 특정 화면 상태를 직접 링크할 수 없음.
    • 클라이언트 측 라우팅: React Router 같은 라이브러리를 사용해 URL 변화를 감지하고, 해당 URL에 맞는 컴포넌트를 렌더링.
      • 새 HTML 페이지 요청 없이도 URL에 따른 "페이지" 전환 가능.
      • 사용자는 /welcome, /products 등 경로로 직접 접근 가능하고, 내부 링크로도 다른 경로로 이동 가능.
  • 결과

    • 사용자 경험 개선: 특정 화면 상태를 직접 링크, 북마크, 공유 가능.
    • SPA의 장점(부드러운 전환, 빠른 응답)과 멀티 페이지 UX(다양한 URL에 맞는 화면) 결합.
사용자 URL 접근 (예: /welcome)
       │
       ▼
  React Router 감지
   현재 경로 = /welcome
       │
       ▼
  해당 경로에 매칭되는 컴포넌트 렌더링
       │
       ▼
사용자 화면에 /welcome에 맞는 콘텐츠 표시
       │
       ▼
다른 링크 클릭 → URL 변경 (예: /products)
       │
       ▼
React Router가 다시 감지 → 매칭되는 컴포넌트 렌더링

서버로 새로운 페이지를 요청하지 않고, 클라이언트 측 라우팅으로 URL에 따라 다른 컴포넌트를 렌더링.

  • 정리
    • 라우팅(Routing): URL 경로에 따라 다른 화면을 보여주는 것.
    • 기존 멀티 페이지 웹사이트: URL 변경 시 서버에서 새로운 HTML 응답 → 느리고 사용자 경험 하락 가능.
    • SPA + 클라이언트 측 라우팅: React Router로 브라우저 URL을 감지하고, 새 HTML 요청 없이 JS로 화면 변경 → 부드러운 전환, 다양한 경로 지원.
    • 사용자는 /welcome, /products 같은 경로로 직접 접근하거나, 내부 링크 클릭으로 URL 변경 가능.
    • React Router v6를 사용해 BrowserRouter로 감싸고, Routes와 Route를 통해 경로별로 컴포넌트를 매핑.

이로써 단일 페이지 애플리케이션 환경에서 URL 경로 변경에 맞춰 동적으로 다른 화면(컴포넌트)을 렌더링하는 클라이언트 측 라우팅 기법을 이해하고, 구현할 수 있게 된다.

  • src/App.tsx

    • 설명:
      • useRoutes 훅을 사용해 경로별 컴포넌트를 배열 형태로 정의.
      • useRoutes가 반환하는 routes를 JSX로 렌더링해주면, 현재 URL 경로에 맞는 컴포넌트가 자동으로 렌더링됨.
  • src/main.tsx

    • 설명:
      • BrowserRouter로 전체 앱을 감싸서 URL 변경 사항을 감지하고, useRoutes를 통한 라우팅을 가능하게 함.
사용자 브라우저에서 URL 입력 또는 링크 클릭 (예: /products)
       │
       ▼
BrowserRouter 관찰 URL 변경
       │
       ▼
useRoutes() 훅에서 정의한 경로 배열 확인
       │
       ▼
path="/products"에 매칭되는 element 렌더링 (Products 컴포넌트)
       │
       ▼
화면에 해당 컴포넌트 내용 표시

이로써 서버로 새로운 페이지를 요청하지 않고, 클라이언트 측에서 URL 경로에 따라 다른 컴포넌트를 렌더링하는 클라이언트 측 라우팅을 구현할 수 있습니다.

  • 정리
    • React Router v6에서는 <Routes><Route> 컴포넌트 대신 useRoutes 훅을 사용해 라우트를 선언하는 문법을 지원한다.
    • useRoutes를 사용하면 경로를 배열 형태로 간결하게 선언하고, 조건에 맞는 컴포넌트를 반환받아 렌더링할 수 있다.
    • 이 방식은 라우트를 별도의 설정 파일 형태로 관리하는 등 유연성이 좋고, 코드 가독성과 유지보수성이 향상된다.
    • 결국 클라이언트 측 라우팅을 통해 싱글 페이지 애플리케이션 내에서 다양한 URL 경로에 따른 화면 전환을 구현할 수 있어, 사용자 경험과 접근성을 동시에 개선할 수 있다.

295. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 프로젝트 셋업 & 리액트 라우터 설치하기

아래는 React Router를 사용하여 싱글 페이지 애플리케이션(SPA)에 라우팅을 추가하는 개념을 정리하고, 실제 코드 예시를 제시한 것입니다. 코드 예시는 타입스크립트를 사용하며, react-router-dom v6 버전을 활용합니다. 또한 전체 폴더 구조와 모든 파일의 코드를 구체적으로 제시하고, 코드 생략 없이 제공합니다.

  • 라우팅(Routing)이란?

    • 라우팅이란 브라우저 주소창(URL 경로)에 따라 다른 콘텐츠(컴포넌트)를 로딩하는 것을 의미합니다.
    • 전통적인 멀티 페이지 웹사이트에서는 URL이 변경될 때마다 서버에 새로운 HTML 페이지를 요청하지만, React로 만든 싱글 페이지 애플리케이션(SPA)에서는 최초 한 번만 로딩한 HTML + JS로 모든 UI를 관리합니다.
  • 왜 필요한가?

    • URL로 특정 화면 접근: 사용자가 /welcome, /products 등 특정 경로로 직접 접근 가능
    • 링크 공유 및 북마크: 특정 페이지 상태를 URL로 공유하고 북마크 가능
    • SPA 장점 유지: 전체 페이지를 다시 로딩하지 않고도 부드럽게 화면 전환
  • 어떻게 구현하는가?

    • React에 라우팅 기능은 내장되어 있지 않으므로 추가 패키지인 react-router-dom을 사용합니다.
      • 단계 1: react-router-dom 설치 (npm install react-router-dom)
      • 단계 2: 라우터 활성화 (BrowserRouter 사용)
      • 단계 3: 경로별로 어떤 컴포넌트를 렌더링할지 정의 (Routes와 Route 또는 useRoutes 훅 사용)
      • 단계 4: 컴포넌트에서 <Link>를 사용하여 경로 이동 지원

이렇게 하면 SPA 환경에서 클라이언트 측 라우팅을 구현해, 다양한 페이지로 자연스럽게 이동할 수 있게 됩니다.

사용자 URL 입력 또는 링크 클릭 (예: /welcome)
       │
       ▼
BrowserRouter에서 URL 감지
       │
       ▼
useRoutes(또는 Routes/Route)로 정의한 경로 매칭
       │
       ▼
매칭되는 컴포넌트 렌더링
       │
       ▼
사용자에게 해당 페이지 콘텐츠 표시

전통적인 방식과 달리, 서버에 새 HTML을 요청하지 않고도 경로 변경 시 컴포넌트만 교체하여 화면 전환.

  • src/components/MainHeader.tsx

    • 설명:
      • <Link> 컴포넌트를 사용해 클라이언트 측 경로 이동 가능.
  • src/App.tsx

    • 설명:
      • useRoutes 훅을 사용해 라우트를 배열로 정의하고, 현재 URL에 맞는 컴포넌트를 반환받아 렌더링.
      • / → Home, /welcome → Welcome, /products → Products 화면 표시.
  • 정리

    • React에서는 내장 라우팅 기능이 없어 react-router-dom 패키지를 사용해야 함.
    • BrowserRouter로 앱 전체를 감싸고, useRoutes 훅으로 경로를 정의하면 경로별로 다른 컴포넌트 렌더링 가능.
    • <Link> 컴포넌트를 통해 페이지 전환 시 전체 페이지 리로딩 없이 클라이언트 측 경로 변경 구현.
    • 이로써 SPA에서도 다양한 URL 경로 지원, 특정 상태에 링크하기, 북마크하기가 가능해져 사용자 경험 개선.

296. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 라우트 정의하기

----- [예시 코드] 리액트 라우터가 있는 SPA 다중 페이지 구축하기 2

아래는 React Router v6.4 이상의 버전에서 createBrowserRouter와 RouterProvider를 사용해 기본 라우팅을 설정하는 전체 예시 코드입니다. 이 예시는 다음과 같은 특징을 가집니다.

  • 타입스크립트 기반: CRA(Create React App) 타입스크립트 템플릿을 사용한다고 가정합니다.
  • 폴더 구조: pages 폴더에 HomePage.tsx를 만들어 / 경로로 라우팅되게 설정합니다.
  • React Router 설정: App.tsx에서 createBrowserRouter로 라우트를 정의하고, RouterProvider를 통해 라우터를 제공하여 화면에 라우팅 결과를 렌더링합니다.
  • 추가 설정 없음: Redux나 다른 상태 관리 로직은 예시에 포함하지 않고, 순수한 라우팅 예제만 다룹니다.
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import HomePage from './pages/HomePage';

// createBrowserRouter에 라우트 정의
// 하나의 경로('/')와 element(HomePage)를 설정
const router = createBrowserRouter([
  {
    path: '/',
    element: <HomePage />
  }
]);

const App: React.FC = () => {
  return (
    // RouterProvider로 router를 전달
    <RouterProvider router={router} />
  );
}

export default App;
  • src/App.tsx

    • 설명:
      • createBrowserRouter로 라우트 설정: / 경로일 때 <HomePage /> 렌더링
      • RouterProvider로 App 컴포넌트에서 라우터 활성화
      • 이제 http://localhost:3000으로 접속하면 HomePage 표시
  • 정리

    • 이로써 간단한 라우팅을 구현하는 완전한 예시를 구성했습니다.
      • react-router-dom의 createBrowserRouter와 RouterProvider를 사용해 경로별 컴포넌트를 연결.
      • / 경로 시 HomePage 컴포넌트를 로딩하도록 설정.
      • App.tsx에서 RouterProvider로 라우팅을 제공하고, index.tsx에서 App을 렌더링.
      • 이제 npm start로 개발 서버 실행 후 http://localhost:3000 접속 시 HomePage 표시.
      • 이 과정에서 Redux나 비동기 로직은 사용하지 않았지만, 동일한 패턴으로 다른 페이지와 경로를 추가 가능.
      • 이렇게 라우팅을 적용하면 단일 페이지 애플리케이션(SPA) 내에서 URL 변화에 따라 다른 컴포넌트를 렌더링할 수 있어 사용자 경험 개선.

297. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 두 번째 라우트 추가하기

아래는 React Router v6.4 이상의 버전에서 createBrowserRouter와 RouterProvider를 사용하여 다중 페이지를 지원하는 예시입니다. 이 예제는 이전 예제에서 한 걸음 더 나아가 /products 경로를 추가해 "ProductsPage"를 별도로 렌더링합니다.

  • 핵심 포인트:

    • 기존 / 경로에 HomePage를 렌더링
    • /products 경로 추가, ProductsPage 렌더링
    • 잘못된 경로 접근 시 현재는 오류 발생 (추후 개선 가능)
    • 타입스크립트 기반, pages 폴더에 각 페이지 컴포넌트 배치
  • src/App.tsx

    • 설명:
      • / 경로일 때 HomePage 렌더
      • /products 경로일 때 ProductsPage 렌더
  • 정리

    • 이로써 다음을 구현했습니다:
      • 라우트 설정: createBrowserRouter로 / 및 /products 라우트 정의
      • 페이지 컴포넌트: HomePage, ProductsPage를 pages 폴더에 배치
      • RouterProvider 사용: App.tsx에서 RouterProvider를 렌더링해 현재 경로에 맞는 컴포넌트를 표시
      • 경로에 따른 페이지 전환: http://localhost:3000 → 홈 페이지, http://localhost:3000/products → 제품 페이지 표시

이 패턴은 사람들이 가장 많이 사용하고 검증된 React Router v6+ 패턴으로, 라우팅을 깔끔하게 관리하고 각 경로별 페이지를 별도 컴포넌트로 유지함으로써 코드 구조를 명확히 합니다.

  • react-10 프로젝트 코드 참고하면됨

298. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 라우트를 정의하는 다른 방법들

아래는 React Router v6.4 이상에서 두 가지 방식으로 라우트를 정의하는 예시를 모두 보여준 뒤, 최종적으로 객체 기반 솔루션을 사용하는 방식을 선택하는 코드 예시입니다. 즉, 처음에는 createBrowserRouter()에 직접 객체 배열을 전달하는 방식(객체 기반)을 사용하고, 이후 createRoutesFromElements()와 <Route> JSX를 사용하는 또 다른 방식을 코드에 주석으로 함께 남깁니다. 마지막에 주석 처리한 코드를 제외한 최종 코드는 객체 기반 라우트 정의를 사용하는 패턴으로 돌아옵니다.

  • 핵심 포인트:

    • 객체 기반 라우트 정의: createBrowserRouter()에 라우트 정의 객체 배열을 전달. path와 element 프로퍼티로 직관적 정의
    • JSX 기반 라우트 정의(주석 처리됨): createRoutesFromElements()와 <Route> 컴포넌트 조합을 통해 JSX로 라우트 정의 가능
    • 두 방식 모두 React Router v6.4 이상에서 지원. 개발자가 취향에 따라 선택 가능
  • src/App.tsx

    • 설명:
      • 객체 기반 라우트 정의를 사용 중.
      • 주석 내에 JSX 기반 라우트 정의 방식 예제도 추가했으나 최종 코드는 객체 기반 방식.
  • 정리

    • 객체 기반 라우트 정의: createBrowserRouter() 호출 시 경로와 element를 명확히 담은 객체 배열 전달. 코드가 직관적이고 간단함.
    • JSX 기반 라우트 정의: createRoutesFromElements()와 <Route> 컴포넌트를 사용해 라우트를 JSX 형태로 정의 가능. 기존 React Router v6 이전 방식과 유사.
    • 두 방식 중 하나를 선택할 수 있으며, 상호 주석 처리를 통해 쉽게 전환 가능.
    • 최종 코드에서는 객체 기반 솔루션을 유지.
    • / → HomePage, /products → ProductsPage로 라우팅.
    • 잘못된 경로 접근 시 현재는 에러 발생(추후 별도 에러 페이지 설정 가능).
  • 결론:

    • React Router v6.4 이상에서 라우트를 정의하는 두 가지 방식(객체 기반, JSX 기반)을 모두 알아보았으며, 개발자가 선호하는 방법을 선택할 수 있다. 이러한 유연성으로 유지보수성과 가독성을 높일 수 있다.
  • react-10 프로젝트 코드 참고하면됨

299. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / Link로 페이지들 간에 이동하기

아래는 React Router v6.4 이상에서 Link 컴포넌트를 사용해 페이지 간 내비게이션을 구현하는 예제입니다. 이전 예제에서 / 경로의 HomePage와 /products 경로의 ProductsPage를 정의한 상태이며, 이번에는 HomePage에서 Link를 통해 ProductsPage로 이동하는 링크를 추가합니다.

  • 핵심 포인트:
    • 기존에는 <a href="/products">를 사용했을 때 새로운 HTTP 요청이 서버로 전송되어 SPA(Single Page Application)의 장점이 사라졌습니다.
    • Link 컴포넌트를 사용하면 브라우저의 기본 요청 전송을 방지하고, React Router를 통해 클라이언트 측 페이지 전환을 수행합니다.
    • 이렇게 하면 페이지 전환 시 전체 애플리케이션을 다시 로딩하지 않고 상태를 유지하며 빠른 네비게이션을 할 수 있습니다.
사용자 / URL 접속 (http://localhost:3000/)
      │
      ▼
  React Router: '/' 경로 매칭 → HomePage 렌더
      │
      ▼ HomePage 내 Link 컴포넌트 클릭
        ("/products" 경로로 이동)
      │
      ▼
   React Router: '/products' 경로 매칭 → ProductsPage 렌더
      │
      ▼
브라우저 히스토리 변경, HTTP 요청 없음 (SPA 유지)

Link 컴포넌트를 통해 경로 이동 시, 서버 재요청 없이 클라이언트 측 라우팅으로 페이지 전환.

  • src/pages/HomePage.tsx

    • 설명:
      • Link to="/products"를 통해 /products 경로로 이동하는 링크를 제공.
      • HTTP 요청 없이 클라이언트 측 네비게이션으로 페이지 전환.
  • src/App.tsx

    • 설명:
      • / 경로일 때 HomePage, /products 경로일 때 ProductsPage 렌더.
      • HomePage에서 Link를 통해 /products로 이동 가능.
  • 정리

    • 기존에 /와 /products 경로를 객체 기반 라우트 정의로 설정.
    • HomePage에서 기본 <a> 태그 대신 Link 컴포넌트를 사용하여 /products로 이동하는 링크 추가.
    • Link 컴포넌트 덕분에 새로운 HTTP 요청 없이 클라이언트 측 페이지 전환 가능(SPA 장점 유지).
    • 최종 결과:
      • http://localhost:3000/ → HomePage 렌더
      • HomePage 내 "the list of products" 링크 클릭 → /products로 이동, ProductsPage 렌더
      • 브라우저 네트워크 요청 없이 부드럽게 페이지 전환.

이로써 React Router를 사용해 부드러운 클라이언트 측 라우팅과 링크 기반 네비게이션을 구현하는 전형적이고 검증된 패턴을 제시했습니다.

  • react-10 프로젝트 코드 참고하면됨

300. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 레이아웃 및 중첩된 라우트

아래는 React Router v6.4 이상에서 레이아웃 라우트를 활용해 모든 페이지 상단에 내비게이션 바를 표시하고, 자식 라우트를 Outlet으로 렌더링하는 패턴의 예시 코드입니다. 또한 CSS 모듈을 사용해 간단한 스타일링을 적용합니다.

  • 핵심 포인트:
    • 부모 라우트(RootLayout)를 정의하고 그 안에 Outlet을 배치해 자식 라우트를 렌더링할 위치를 지정합니다.
    • 이렇게 하면 RootLayout에서 공통 UI(예: 네비게이션 바)와 스타일을 적용할 수 있으며, 모든 자식 페이지(HomePage, ProductsPage)에 동일하게 반영됩니다.
    • MainNavigation 컴포넌트로 상단 네비게이션을 구현하고, CSS 모듈을 통해 스타일 적용.
브라우저에서 http://localhost:3000/ 요청
       │
       ▼
   RouterProvider → createBrowserRouter로 정의한 라우트 확인
       │
       ▼
 RootLayout(부모 라우트) 렌더링
       │
       ▼ 
  MainNavigation(네비게이션) + Outlet(자식 라우트)
       │
       ├─ '/' 경로이면 HomePage 렌더
       └─ '/products' 경로이면 ProductsPage 렌더

RootLayout이 상위 레이아웃을 제공하고, Outlet을 통해 자식 라우트를 렌더링함. 모든 자식 페이지에서 네비게이션과 공통 스타일 유지.

  • src/App.tsx

    • 설명:
      • RootLayout 라우트를 부모로 설정하고 children 배열로 HomePage, ProductsPage를 자식 라우트로 추가.
      • / → RootLayout 렌더 + Outlet을 통해 HomePage 표시
      • /products → RootLayout 렌더 + Outlet을 통해 ProductsPage 표시
      • MainNavigation은 RootLayout 내부에서 렌더링되므로 모든 페이지에 공통 표시.
  • 정리

    • RootLayout 라우트를 정의하고 children 배열로 하위 페이지(Home, Products) 경로를 설정.
    • Outlet을 통해 자식 라우트 컴포넌트를 렌더링할 위치 지정.
    • MainNavigation을 RootLayout에 추가하여 모든 페이지에 공통 내비게이션 제공.
    • Link 컴포넌트로 클라이언트 사이드 네비게이션을 구현, 서버 요청 없이 빠른 페이지 전환 가능.
    • CSS 모듈을 통한 스타일링으로 간단한 레이아웃 및 내비게이션 UI 개선.

이로써 React Router를 활용한 레이아웃 라우트, Link 기반 클라이언트 내비게이션, CSS 모듈 스타일링을 모두 적용한 검증된 패턴을 제시했습니다.

  • react-10 프로젝트 코드 참고하면됨

301. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / errorElement로 오류 페이지 표시하기

아래 예시는 React Router v6.4 이상에서 오류 페이지(ErrorPage)를 구현하는 방법을 보여줍니다. 이전에 구현한 라우트(/, /products)에 대해 존재하지 않는 경로 접속 시 React Router가 발생시키는 오류를 커스텀 오류 페이지로 처리하는 패턴을 구현합니다.

  • 핵심 포인트:
    • errorElement 프로퍼티를 라우트 정의에 추가하여 해당 라우트에 대한 오류 발생 시 표시할 컴포넌트(ErrorPage) 지정
    • 루트 라우트(path: '/')에 errorElement를 지정하면 지원하지 않는 경로를 접속했을 때 해당 오류 페이지 표시
    • 오류 페이지에서도 공통 헤더(메인 내비게이션)를 표시하여 일관성 있는 사용자 경험 제공
    • 전역 스타일을 index.css에서 관리하고, Root.module.css는 제거. 모든 페이지와 오류 페이지에서 동일한 레이아웃 스타일을 적용
사용자가 지원하지 않는 경로 접속 (예: http://localhost:3000/abc)
        │
        ▼
   React Router 오류 감지
        │
        ▼
   errorElement (ErrorPage) 렌더링
        │
        ▼
ErrorPage에 MainNavigation 표시 + 오류 메시지 출력

정상 경로: / → HomePage, /products → ProductsPage, 잘못된 경로 → ErrorPage로 처리.

  • src/index.css (전역 스타일)

    • 설명:
      • 전역 스타일로 main 태그에 공통 스타일 적용.
      • RootLayout과 ErrorPage 모두 main 태그를 사용하면 동일한 스타일 적용 가능.
  • src/pages/RootLayout.tsx

    • 설명:
      • RootLayout에서 Outlet을 통해 자식 라우트(HomePage, ProductsPage) 렌더링
      • main 태그는 자식 페이지 내부에서 사용하므로 각 페이지에서 main 스타일 적용
  • src/ErrorPage.tsx

    • 설명:
      • ErrorPage에도 MainNavigation을 표시하고 main 태그 사용해 전역 스타일 적용
      • 잘못된 경로(또는 오류 발생 시) 이 페이지로 전환됨
  • src/App.tsx

    • 설명:
      • 루트 라우트(path: '/')에 errorElement 추가
      • 지원하지 않는 경로 접근 시 React Router가 자동으로 ErrorPage 렌더
  • 정리

    • errorElement를 루트 라우트 정의에 추가해 존재하지 않는 경로 접근 시 ErrorPage 표시
    • ErrorPage에도 MainNavigation을 넣어 UI 일관성 유지
    • 전역 CSS(index.css)로 main 스타일 적용 → ErrorPage, HomePage, ProductsPage에서 동일한 레이아웃 스타일 유지
    • 이제 사용자가 잘못된 경로에 접근하면, 기본 오류 메시지 대신 커스텀 오류 페이지를 렌더링하므로 사용자 경험 향상.

이로써 React Router로 라우트를 정의할 때 오류 발생 상황에 대비한 커스텀 오류 페이지 적용 패턴을 완성했습니다.

  • react-10 프로젝트 코드 참고하면됨

302. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 네비게이션 링크 사용하기(NavLink)

아래 예제는 React Router v6.4 이상에서 NavLink 컴포넌트를 사용해 현재 활성인 페이지를 나타내는 링크 스타일을 적용하는 방법을 보여줍니다. 이전 예제에서는 Link를 사용했지만, NavLink를 사용하면 현재 활성 라우트를 가리키는 링크에 자동으로 활성 상태를 지정할 수 있습니다. 또한 end 속성을 사용해 '/' 경로의 NavLink가 다른 경로에서 활성으로 표시되지 않도록 설정합니다.

  • 핵심 포인트:
    • NavLink를 통해 현재 활성 페이지에 해당하는 링크에 CSS 클래스(.active)를 조건부로 적용
    • className 속성에 함수를 전달해 isActive 값을 받아 활성 상태에 맞는 클래스를 반환 가능
    • end 속성을 사용해 '/' 경로 링크가 정확히 해당 경로일 때만 활성 처리
    • 기존 구조와 스타일링 패턴을 유지하며, NavLink 도입으로 사용자 경험 향상
브라우저에서 http://localhost:3000/ 접속
        │
        ▼
   RootLayout + MainNavigation + HomePage 렌더
        │
        ▼ 사용자가 NavLink "Products" 클릭
          NavLink로 URL 변경, HTTP 요청 없음
        │
        ▼
   Router: /products 경로 매칭 → ProductsPage 렌더
        │
        ▼
NavLink "Products" 링크 isActive=true → .active 클래스 적용

NavLink를 통해 현재 활성 경로에 해당하는 링크를 스타일로 강조.

  • src/components/MainNavigation.tsx
import React from 'react';
import { NavLink } from 'react-router-dom';
import classes from './MainNavigation.module.css';

const MainNavigation: React.FC = () => {
  return (
    <header className={classes.header}>
      <nav>
        <ul className={classes.list}>
          <li>
            <NavLink 
              to="/" 
              end
              className={(navData) => (navData.isActive ? classes.active : '')}
            >
              Home
            </NavLink>
          </li>
          <li>
            <NavLink 
              to="/products"
              className={(navData) => (navData.isActive ? classes.active : '')}
            >
              Products
            </NavLink>
          </li>
        </ul>
      </nav>
    </header>
  );
};

export default MainNavigation;
  • 설명:

    • NavLink 사용, className에 함수 전달해 isActive 여부에 따라 active 클래스 조건부 적용
    • Home 링크에 end 속성 추가해 / 정확히 일치하는 경우에만 활성 처리
  • 정리

    • NavLink를 사용해 현재 활성 링크를 시각적으로 구분하는 패턴 구현
    • className에 함수를 전달해 isActive 값 기반으로 CSS 클래스 적용
    • end 속성으로 '/' 경로 정확히 일치할 때만 활성 처리
    • 이를 통해 사용자는 어떤 페이지에 있는지 직관적으로 확인 가능, SPA 특성 유지
    • 이 방법은 많은 프로젝트에서 검증된 패턴이며, React Router 공식 문서에서도 권장되는 방식.
  • react-10 프로젝트 코드 참고하면됨

303. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 프로그램적으로 네비게이션하기

아래 예시는 React Router v6.4 이상에서 useNavigate 훅을 사용해 프로그램적으로(코드에서) 페이지 이동을 구현하는 방법을 보여줍니다. 이전까지는 주로 <Link><NavLink>를 사용해 사용자가 클릭하는 방식으로 페이지를 전환했지만, 이번 예시에서는 버튼 클릭, 타이머 만료, 폼 제출 등의 이벤트에 따라 코드 내부에서 다른 경로로 이동시키는 방법을 시연합니다.

  • 핵심 포인트:
    • useNavigate 훅 사용: navigate('/products')와 같이 호출해 원하는 경로로 이동 가능
    • 사용자 액션(버튼 클릭)이나 기타 프로그램적 조건(타이머 종료 등) 발생 시 코드로 라우팅 전환 가능
    • 이 방법은 <Link> 컴포넌트 대신, 코드 흐름에 따라 자동으로 페이지를 이동시키고 싶을 때 유용
import React from 'react'
import { useNavigate } from 'react-router-dom'

const HomePage: React.FC = () => {
  const navigate = useNavigate()

  const navigateHandler = () => {
    // 프로그램적으로 /products로 이동
    navigate('/products')
  }

  return (
    <main>
      <h1>My Home Page</h1>
      <p>루트 경로(/)로 접근했을 때 표시되는 홈 페이지입니다.</p>
      <button onClick={navigateHandler}>Go to Products (프로그램적 이동)</button>
    </main>
  )
}

export default HomePage
  • 설명:

    • useNavigate 훅 사용하여 버튼 클릭 시 /products로 이동
    • 이 방식은 폼 제출 후 특정 페이지로 이동시키거나, 일정 시간 후 자동 리다이렉트 등에 유용
  • 정리

    • useNavigate 훅을 사용해 버튼 클릭 등 이벤트 발생 시 코드 내부에서 경로 변경 가능
    • NavLink로 현재 활성 페이지 링크 강조, end 속성으로 '/' 정확 매칭 활성화
    • 전체 코드 구조와 패턴을 유지, CSS 모듈로 스타일 관리
    • 이 패턴은 많은 프로젝트에서 검증된 반응형 내비게이션 패턴으로, SPA 특성 및 UX 향상에 도움을 준다.
  • react-10 프로젝트 코드 참고하면됨

304. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 동적 라우트 정의하고 사용하기

아래 예제는 React Router v6.4 이상에서 동적 경로 파라미터(Dynamic Route Segment)를 사용하여 상품 상세 페이지를 구현하는 패턴을 보여줍니다. 이전까지 /products 페이지에서 단순히 제품 페이지임을 표시했지만, 실제 애플리케이션에서는 특정 제품의 상세 정보 페이지를 구현하고자 할 수 있습니다. 이를 위해 :productId와 같은 경로 파라미터를 사용하고, useParams 훅을 통해 URL에 포함된 값을 추출할 수 있습니다.

  • 핵심 포인트:
    • /products/:productId와 같이 콜론(:)을 사용해 동적 경로 파라미터 정의
    • useParams 훅을 사용해 경로 파라미터(예: productId) 값을 추출
    • URL에 따라 상품 ID가 달라지고, 해당 ID로 백엔드에서 데이터를 가져와 화면에 표시하는 로직을 쉽게 구현 가능
    • 이 방법은 대부분의 전자상거래 사이트나 동적 콘텐츠 웹사이트에서 공통적으로 사용하는 검증된 패턴
사용자가 http://localhost:3000/products/p1 접속
        │
        ▼
  Router: '/products/:productId' 경로 매칭
   productId 파라미터 = 'p1'
        │
        ▼
 ProductDetailPage 렌더
   useParams() → { productId: 'p1' }
        │
        ▼
화면에 productId 값 표시
(나중에 백엔드 호출 등 추가 가능)

URL 뒤에 오는 값(p1, product-1, abc)이 productId 파라미터로 전달되어 화면에 표시됨.

import React from 'react';
import { Link } from 'react-router-dom';

const ProductsPage: React.FC = () => {
  return (
    <main>
      <h1>The Products Page</h1>
      <p>/products 경로로 접근했을 때 이 페이지가 표시됩니다.</p>
      <ul>
        <li><Link to="/products/p1">Product 1</Link></li>
        <li><Link to="/products/p2">Product 2</Link></li>
        <li><Link to="/products/p3">Product 3</Link></li>
      </ul>
    </main>
  );
};

export default ProductsPage;
  • 설명:
    • /products 페이지에 동적 경로로 이동할 링크를 제공
    • 예: /products/p1로 이동하면 ProductDetailPage에 'p1' 표시
import React from 'react';
import { useParams } from 'react-router-dom';

const ProductDetailPage: React.FC = () => {
  const params = useParams();
  // params.productId를 통해 URL에서 추출한 제품 ID 값 확인 가능
  return (
    <main>
      <h1>Product Details</h1>
      <p>제품 ID: {params.productId}</p>
    </main>
  );
};

export default ProductDetailPage;
  • 설명:

    • useParams()로 productId 파라미터 추출
    • /products/p1 접속 시 productId = 'p1' 출력
  • 정리

    • 경로 파라미터(:productId)를 사용해 동적 라우트를 정의하고, URL 일부분을 변수처럼 활용
    • useParams 훅으로 해당 파라미터 값 접근 가능 (params.productId)
    • 이를 통해 다양한 제품(또는 데이터)에 대해 동일한 컴포넌트를 재활용하고, URL에 인코딩된 ID로 백엔드 API를 호출하거나 해당 아이템 정보를 불러올 수 있음
    • 이 패턴은 많은 프로젝트에서 검증되고 널리 사용되는 방식이며, React Router 공식 문서에도 권장하는 접근 방법.
  • react-10 프로젝트 코드 참고하면됨

305. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 동적 라우트에 링크 추가하기

아래 예제는 React Router v6.4 이상에서 동적 경로 파라미터를 활용하는 예시를 더욱 발전시킨 것입니다. 이전 예제에서는 /products/:productId 경로를 사용해 특정 제품 상세 페이지로 이동할 수 있는 동적 경로를 정의했습니다. 이번 예제에서는 백엔드에서 가져온다고 가정한 제품 목록(여기서는 하드코딩)을 기반으로, 각 제품별로 적절한 링크를 동적으로 생성합니다. 이렇게 하면 새로운 제품이 추가되더라도 코드 변경 없이 목록과 링크를 자동으로 갱신할 수 있습니다.

  • 핵심 포인트:
    • 제품 목록을 하드코딩한 배열 PRODUCTS를 정의하고, 이를 .map()을 통해 반복하여 각 제품별 <Link>를 동적으로 생성
    • 각 링크는 /products/:productId 형식의 동적 경로를 사용하여 해당 제품의 상세 페이지로 이동 가능
    • URL 파라미터(예: productId)에 따라 ProductDetailPage 컴포넌트에서 해당 제품 ID 접근 가능
    • 이 패턴은 대부분의 전자상거래 사이트, 동적 콘텐츠 사이트에서 검증된 방식으로 사용됨
사용자 http://localhost:3000/products 접속
       │
       ▼
ProductsPage 렌더 → PRODUCTS 배열 map()으로 링크 리스트 생성
       │
       ▼ 사용자가 특정 제품 링크 클릭 (예: Product 1)
         /products/p1 경로 이동
       │
       ▼
  Router: '/products/:productId' 매칭
   productId = 'p1' 추출
       │
       ▼
ProductDetailPage 렌더, useParams() → { productId: 'p1' }
import React from 'react'
import { Link } from 'react-router-dom'

// 백엔드에서 가져온다고 가정하는 하드코딩된 제품 목록
const PRODUCTS = [
  { id: 'p1', title: 'Product 1' },
  { id: 'p2', title: 'Product 2' },
  { id: 'p3', title: 'Product 3' }
]

const ProductsPage: React.FC = () => {
  return (
    <main>
      <h1>The Products Page</h1>
      <p>/products 경로로 접근했을 때 이 페이지가 표시됩니다.</p>
      <ul>
        {PRODUCTS.map(prod => (
          <li key={prod.id}>
            {/* 동적 경로로 이동하는 Link 생성 */}
            <Link to={`/products/${prod.id}`}>{prod.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  )
}

export default ProductsPage
  • 설명:

    • PRODUCTS 배열을 .map()으로 돌며 각 제품에 대한 <Link> 생성
    • /products/p1, /products/p2, /products/p3로 이동 가능
  • 정리

    • /products 페이지에서 PRODUCTS 배열을 사용해 동적 링크 생성
    • 각 제품별 /products/:productId 경로로 이동 가능
    • ProductDetailPage에서 useParams()로 productId 추출
    • 이 패턴은 많은 프로젝트에서 검증되고 널리 사용되며, 동적으로 증가하는 데이터에 대응하기 쉽다.
    • 나중에 백엔드 연동 시 productId를 이용해 데이터베이스 조회 후 해당 제품 상세 정보 표시 가능.
  • react-10 프로젝트 코드 참고하면됨

306. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 상대 경로와 절대 경로

아래 예제는 React Router v6.4 이상에서 상대 경로와 절대 경로의 차이, 그리고 상대 경로 사용 시 relative 속성을 통해 링크 동작을 제어하는 방법을 보여줍니다. 특히, 제품 상세 페이지(ProductDetailPage)에서 상위 경로로 돌아가는 버튼을 상대 경로로 구현하고, relative="path"를 사용해 현재 URL 경로를 기준으로 상위 경로로 이동할 수 있도록 하는 패턴을 예시합니다.

이전 단계에서 우리는 /products/:productId 형태의 동적 경로를 구현하고, 제품 목록(ProductsPage)에서 동적 링크를 생성했습니다. 이번에 추가로, 제품 상세 페이지(ProductDetailPage)에서 Link 컴포넌트를 상대 경로로 사용해 "뒤로 가기" 기능을 구현하며, relative="path" 설정으로 의도한 페이지로 정확히 이동할 수 있게 합니다.

  • 핵심 포인트:

    • 경로가 /로 시작하면 절대 경로, 그렇지 않으면 상대 경로
    • 상대 경로 링크를 사용할 때 relative 속성을 path나 route로 설정하여 링크 해석 방식을 제어 가능
    • relative="path": 현재 활성 경로 기준으로 상위 디렉토리 개념(..)을 처리
    • relative="route"(기본값): 라우트 정의 구조를 기준으로 상대 경로를 해석
    • 이를 통해 ProductDetailPage에서 ..를 사용해 /products/p1 → /products로 되돌아가는 동작 구현 가능
  • 전제 조건:

    • 이전 단계까지의 코드를 기반으로 동적 경로(/products/:productId) 및 ProductsPage 내 동적 링크 구현 완료
    • 이번에 ProductDetailPage에 상위 경로로 돌아가는 링크 추가
    • relative="path" 속성을 사용해 현재 URL 기준 상위 경로로 이동
사용자 http://localhost:3000/products/p1 접속
        │
        ▼ ProductDetailPage 렌더 (productId = 'p1')
        │
사용자가 "Back" 버튼 클릭
        │
        ▼ Link to=".." relative="path" 해석
          현재 URL: /products/p1
          '..' → 상위 경로: /products
        │
        ▼ 경로 /products로 이동 → ProductsPage 렌더

relative="path"를 통해 ..가 실제 파일 경로와 유사한 방식으로 상위 경로 해석됨.

  • ProductDetailPage.js
import React from 'react'
import { useParams, Link } from 'react-router-dom'

const ProductDetailPage: React.FC = () => {
  const params = useParams()

  return (
    <main>
      <h1>Product Details</h1>
      <p>제품 ID: {params.productId}</p>
      {/* 상대 경로 사용: '..'은 상위 경로로 이동, relative="path"로 현재 URL 기준 경로 해석 */}
      <p><Link to=".." relative="path">Back</Link></p>
    </main>
  )
}

export default ProductDetailPage
  • 설명:

    • Link to=".." relative="path"를 사용해 현재 URL(/products/p1)에서 상위 경로(/products)로 돌아가기 가능
    • relative="path" 없으면 기본값은 route, 라우트 정의 구조 기준으로 경로 해석
    • 이로써 정확한 상위 경로로 복귀 가능
  • 정리

    • 동적 경로 파라미터(:productId)로 제품 상세 페이지 구현
    • 하드코딩된 제품 목록을 .map()으로 순회하며 동적 링크 생성 → /products/p1, /products/p2 등 자동 생성
    • 상대 경로와 절대 경로 차이, relative="path" 설정으로 상위 경로 이동 제어
    • 이 패턴은 많은 프로젝트에서 검증되고 널리 사용되는 방식이며, 동적으로 증가하는 데이터에 대응하기 쉽고, 경로 관리 유연성을 제공한다.
  • react-10 프로젝트 코드 참고하면됨

307. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 인덱스 라우트 사용하기

아래 예제는 React Router v6.4 이상에서 인덱스 라우트(Index Route)를 사용하여 기본 페이지를 설정하는 방법을 보여줍니다. 이전 예시에서, 홈 페이지를 부모 라우트(RootLayout)의 기본 페이지로 설정하기 위해 빈 경로(path: '')로 자식 라우트를 정의했습니다. 하지만 인덱스 라우트는 이 작업을 더욱 명확하고 직관적으로 해줍니다.

  • 핵심 포인트:
    • 인덱스 라우트(Index Route)란, 부모 라우트가 활성일 때 기본적으로 표시될 자식 라우트를 의미합니다.
    • index: true 프로퍼티를 자식 라우트 정의에 추가하면, 해당 라우트가 부모 라우트의 기본 페이지가 됩니다.
    • 인덱스 라우트는 path 없이 정의하며, 이렇게 하면 부모 라우트와 동일한 URL일 때 자동으로 이 인덱스 라우트를 로딩합니다.
    • 이전 방식(빈 경로 '') 대신 index: true를 사용하면 코드 가독성과 의미가 더욱 명확해집니다.
사용자가 http://localhost:3000/ 접속
        │
        ▼
   Router: '/' 경로 매칭 (RootLayout)
        │
   인덱스 라우트(index: true) 확인
        │
        ▼
   HomePage 렌더 (부모 라우트 경로에서 기본 표시될 페이지)
  • 정리

    • 인덱스 라우트(index route)를 사용해 부모 라우트가 활성일 때 기본적으로 표시될 자식 페이지를 명시적으로 지정 가능
    • { index: true }로 설정하면 path 없이도 부모 경로에 해당 페이지가 표시됨
    • 이로써 빈 경로를 추가하는 대신, 더 명확한 의미로 기본 페이지를 설정 가능
    • 많은 프로젝트에서 검증된 패턴으로, 라우팅 구조를 더욱 직관적으로 만들 수 있다.
  • react-10 프로젝트 코드 참고하면됨

308. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 새로운 프로젝트 셋업

아래는 백엔드-프런트엔드 프로젝트 환경에서 리액트 라우팅 연습을 위한 설정 방법과 흐름을 정리한 내용입니다. 이전까지는 리액트 라우팅 기능(동적 경로, 상대 경로, 인덱스 라우트 등)을 학습했으며, 이제 실제 실습을 위해 백엔드 서버와 프런트엔드 서버를 모두 가동하는 상황을 설명합니다.

  • 핵심 포인트:
    • 프로젝트 폴더에 backend-api와 react-frontend 두 폴더가 있다.
    • backend-api 폴더에는 리액트가 아닌 더미 백엔드 API 서버가 들어있다. 이 서버는 Node/Express 기반으로 동작하지만 리액트 코드가 없다.
    • react-frontend 폴더에는 우리가 실제로 작업할 리액트 애플리케이션이 있으며, 라우팅 실습에 활용할 예정.
    • 실습 시 백엔드 서버와 프런트엔드(리액트) 서버를 각각 독립적으로 구동해야 한다.
      1. 터미널 하나에서 backend-api 폴더로 이동 → npm install → npm start 실행해 백엔드 서버 시작
      2. 다른 터미널에서 react-frontend 폴더로 이동 → npm install → npm start로 리액트 개발 서버 시작
    • 백엔드 서버는 계속 실행 상태를 유지해야 하며, 프런트엔드 애플리케이션은 백엔드 API와 통신할 것이다.
    • 이렇게 두 서버를 동시에 실행하여, 프런트엔드 라우팅 실습 시 백엔드와 연동된 실제 사례(더미 데이터, API 요청 등)를 다루게 된다.

309. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 연습

----- [예시 코드] 리액트 라우터가 있는 SPA 다중 페이지 구축하기 3

사용자 http://localhost:3000/ 접속
       │
       ▼
   RootLayout 렌더 (MainNavigation 포함)
   인덱스 라우트(HomePage) 로딩
       │
       ▼ NavLink/Link 이용해 다른 페이지로 이동
         예: /events → EventsRootLayout + EventsPage
             /events/new → NewEventPage
             /events/:eventId → EventDetailPage (useParams로 id 추출)
             /events/:eventId/edit → EditEventPage
  • 정리
    • 페이지 컴포넌트 추가: HomePage, EventsPage, EventDetailPage, NewEventPage, EditEventPage
    • 라우트 정의 완료: 인덱스 라우트, 동적 경로, 중첩 레이아웃, 오류 페이지 활용
    • NavLink, Link, useNavigate, relative="path" 등 다양한 라우팅 기능 활용
    • 이제 고급 라우팅 기능(데이터 가져오기, 제출 등) 학습을 위한 준비 완료.

이 패턴은 사람들이 가장 많이 사용하고 검증된 라우팅 패턴을 담고 있으며, 리액트 라우터 공식 문서와 커뮤니티에서 널리 권장하는 접근 방식입니다.

310. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / loader()를 이용한 데이터 가져오기

아래 예시는 React Router v6.4 이상에서 loader 함수를 사용해 라우트 렌더링 전 데이터를 불러오는 패턴을 정리한 것입니다. 지금까지는 컴포넌트 안에서 useEffect로 데이터를 가져왔다면, 이제 라우트 정의 시 loader 프로퍼티를 사용하여 컴포넌트 렌더링 전에 데이터를 가져오고, 해당 데이터를 컴포넌트에 바로 제공할 수 있습니다.

  • 핵심 포인트:
    • loader 함수: 라우트 정의에 loader 프로퍼티로 함수를 지정하면, 해당 라우트가 활성화되기 전에 loader 함수가 실행됩니다.
    • loader 함수에서 백엔드 API 호출, 데이터 fetch 등을 수행하고, 결과를 반환합니다.
    • 리액트 라우터는 이 반환 값을 해당 라우트 컴포넌트가 렌더링될 때 접근 가능하도록 제공합니다.
    • 이렇게 하면 컴포넌트에서 별도의 useEffect, 상태 관리 없이 미리 로딩된 데이터를 활용할 수 있습니다.
    • 초기 로딩 상태나 오류 처리 로직을 라우터 레벨에서 관리 가능.
사용자가 http://localhost:3000/events 접속
       │
       ▼
   React Router 해당 라우트 매칭
       │
       ▼ loader 함수 실행 (HTTP 요청 → backend-api)
         응답 받은 events 데이터 반환
       │
       ▼
데이터 로딩 완료 후 EventsPage 렌더링
   useLoaderData 훅으로 loader 함수 반환 값 접근
   이벤트 리스트 표시

라우트 접근 → loader 함수 실행 (데이터 로딩) → 데이터 준비 후 컴포넌트 렌더.

  • 정리

    • loader 함수를 통해 라우트 렌더링 전 데이터 로딩 수행
    • useLoaderData 훅으로 loader 반환값 접근
    • 이로써 비동기 데이터 패칭을 라우트 정의 레벨에서 관리, 컴포넌트 로직 단순화
    • React Router 공식 문서와 커뮤니티에서 검증된 고급 패턴, 대규모 프로젝트에서 데이터 로딩 복잡성을 줄이는 데 매우 유용.
  • react-11 프로젝트 코드 참고하면됨

311. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / loader() 데이터를 라우트 컴포넌트에서 사용하기

아래 예시는 useLoaderData 훅을 사용하여 loader 함수로부터 반환된 데이터를 컴포넌트에서 간편하게 접근하는 패턴을 정리한 것입니다. 이전 단계에서는 useEffect와 상태 관리로 백엔드 API에서 데이터를 가져왔지만, 이제 loader 함수와 useLoaderData 훅을 사용함으로써 컴포넌트 로직이 훨씬 단순해집니다.

  • 핵심 포인트:
    • loader 함수에서 데이터를 가져오고 반환하면, React Router가 해당 데이터를 Promise 해제 후 컴포넌트에 제공
    • 컴포넌트에서 useLoaderData()를 통해 loader 함수의 반환값(데이터)에 바로 접근 가능
    • 이를 통해 useEffect나 상태 변수를 따로 둘 필요 없이, 미리 로딩된 데이터를 컴포넌트 렌더링 시점에 즉시 활용할 수 있음
    • 코드가 더 간단하고 깔끔해지며, 로직이 명확해짐
사용자가 http://localhost:3000/events 접속
       │
       ▼
   App.tsx 내 라우트 정의에서 eventsLoader 함수 실행
       │
       ▼ 로더 함수 내 백엔드 요청 (http://localhost:8080/events)
         응답 받은 이벤트 데이터 반환
       │
       ▼
  React Router가 반환값을 useLoaderData로 제공
       │
       ▼
EventsPage 컴포넌트 useLoaderData()로 이벤트 리스트 수신
       │
       ▼
EventsList 컴포넌트에 이벤트 리스트 전달, 렌더링

라우트 접근 → loader로 데이터 로딩 → useLoaderData로 즉시 접근 → 간단한 컴포넌트 코드.

  • 정리

    • loader 함수에서 데이터를 로딩하고 반환하면, useLoaderData로 그 데이터를 컴포넌트에서 바로 활용 가능
    • 컴포넌트 코드가 단순해지고, useEffect/상태 관리가 불필요해짐
    • 이 패턴은 사람들이 많이 사용하고 검증된 라우팅 데이터 관리 방법이며, 대규모 프로젝트에서 유용.
  • react-11 프로젝트 코드 참고하면됨

312. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / loader() 데이터의 다양한 활용법

아래 예시는 useLoaderData 훅을 라우트 계층 구조에서 어떻게 사용할 수 있는지 보여줍니다. 라우트의 loader 함수로 데이터를 가져오면 해당 라우트 및 그 하위 컴포넌트, 라우트에서 useLoaderData로 데이터를 접근할 수 있습니다. 즉, 로더가 정의된 라우트보다 상위에 있는 컴포넌트에서는 해당 로더 데이터에 접근할 수 없습니다.

  • 핵심 포인트:
    • useLoaderData는 현재 라우트나 하위 라우트 수준에서 사용 가능
    • 만약 데이터 로딩 라우트보다 상위 레이아웃(RouteLayout) 컴포넌트에서 useLoaderData를 호출하면 해당 데이터는 undefined가 된다
    • 반면, 같은 라우트 혹은 하위 컴포넌트(예: EventsList)에서는 useLoaderData를 사용할 수 있어, 데이터를 바로 활용 가능
    • 일반적으로 페이지 컴포넌트(라우트 컴포넌트)에서 useLoaderData를 사용하고, 자식 컴포넌트(EventsList)에는 props로 데이터 전달하는 패턴이 흔히 사용되고 검증된 패턴
사용자 http://localhost:3000/events 접속
       │
       ▼ loader 함수 실행 → 백엔드에서 이벤트 데이터 가져옴
       │
       ▼ 이벤트 데이터 로더에서 return
       │
       ▼ React Router가 useLoaderData로 데이터 제공
       │
       ▼ EventsPage에서 useLoaderData()로 데이터 획득
         Props로 EventsList에 이벤트 데이터 전달
       │
       ▼ EventsList에서 props로 받은 이벤트 렌더링

(만약 RootLayout에서 useLoaderData() 시도하면?)
       └─ 해당 데이터 로더는 이보다 하위 라우트에서 정의되어
          있으므로 여기서는 접근 불가 → undefined

라우트 계층 구조: RootLayout > EventsRootLayout > (EventsPage 등)
useLoaderData는 해당 라우트 또는 하위 컴포넌트에서만 데이터 접근 가능.

  • EventsPage.tsx
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';

export async function eventsLoader() {
  const response = await fetch('http://localhost:8080/events');
  if (!response.ok) {
    throw new Error('이벤트를 가져올 수 없습니다.');
  }
  const resData = await response.json();
  return resData.events;
}

const EventsPage: React.FC = () => {
  // EventsPage와 동일 레벨 라우트에서 loader 함수 정의
  // → useLoaderData로 바로 데이터 접근 가능
  const events = useLoaderData() as Array<{id:string; title:string}>;

  // 데이터 props로 EventsList에 전달
  return <EventsList events={events} />;
};

export default EventsPage;
  • 설명:

    • EventsPage 라우트에 loader 함수 정의, useLoaderData로 이벤트 목록 접근
    • 더 이상 useEffect나 상태 관리 필요 없음
    • 상위 컴포넌트(RootLayout)나 전혀 다른 라우트에서 같은 데이터 접근 불가
  • RootLayout.tsx (useLoaderData 사용 실패 예시)

import React from 'react';
import { Outlet } from 'react-router-dom';
import MainNavigation from '../components/MainNavigation';
// 여기서 useLoaderData를 사용할 경우
// 해당 라우트(loader 없음)에 대한 데이터가 없으므로 undefined 반환

const RootLayout: React.FC = () => {
  // const data = useLoaderData();  // 이 줄 활성화 시 data는 undefined
  return (
    <>
      <MainNavigation />
      <Outlet />
    </>
  );
};

export default RootLayout;
  • 설명:
    • RootLayout에 loader 없음
    • 하위 라우트 eventsLoader 데이터를 여기서 접근 불가 (undefined)
    • useLoaderData는 해당 로더 라우트 수준이나 하위 컴포넌트에서만 사용 가능
import React from 'react';

interface Event {
  id: string;
  title: string;
}

interface EventsListProps {
  events: Event[];
}

// 여기서도 useLoaderData 사용 가능하지만, 현재 구조에선 필요 없음
// 이벤트 데이터를 상위 페이지 컴포넌트(EventsPage)에서 props로 받아 처리
const EventsList: React.FC<EventsListProps> = ({ events }) => {
  if (!events || events.length === 0) {
    return <p>No events found!</p>;
  }

  return (
    <ul>
      {events.map(event => (
        <li key={event.id}>{event.title}</li>
      ))}
    </ul>
  );
};

export default EventsList;
  • 설명:

    • EventsList는 props로 데이터 전달받음
    • if 필요하다면 useLoaderData도 사용 가능하지만, 현재는 EventsPage에서 관리
  • 정리

    • useLoaderData를 통해 loader 함수에서 반환한 데이터 접근
    • 해당 로더 라우트 및 하위 컴포넌트에서만 접근 가능하며, 상위 레벨 라우트나 전혀 다른 경로에서는 접근 불가
    • 데이터는 필요 시 상위 페이지 컴포넌트에서 props를 통해 자식 컴포넌트로 전달하는 패턴을 사용
    • 이로써 컴포넌트 로직이 단순해지고, 라우팅 계층에 따른 데이터 접근 범위가 명확해진다.

useLoaderData() 훅은 현재 컴포넌트가 속한 "가장 가까운" 라우트의 loader 함수가 반환한 데이터에만 접근할 수 있습니다. 즉, 해당 컴포넌트를 렌더링하는 라우트에서 정의된 loader 함수가 있다면, 그 loader의 결과를 useLoaderData()로 가져올 수 있습니다. 하지만 부모 라우트에서 정의된 loader 데이터를 자식 라우트 컴포넌트에서 useLoaderData()로 직접 접근할 수는 없습니다. 자식 라우트에서 부모 라우트 loader 데이터를 사용하려면 useRouteLoaderData(routeId) 등의 다른 훅을 사용해야 합니다.

  • 자세한 설명:

    • useLoaderData()는 컴포넌트가 속한 라우트와 연관된 loader 함수의 반환값을 돌려줍니다.
    • 부모 라우트에 loader가 있고, 자식 라우트는 loader가 없는 경우, 자식 라우트 컴포넌트에서 단순히 useLoaderData()를 호출해도 부모 라우트의 loader 데이터에 접근할 수 없습니다.
    • 부모 라우트의 loader 데이터를 자식 라우트 컴포넌트나 더 낮은 수준의 중첩 컴포넌트에서 접근하려면 useRouteLoaderData(routeId)를 사용하거나 useMatches() 훅을 통해 라우트 매칭 결과를 받아 원하는 라우트의 데이터에 접근할 수 있습니다.
  • 정리하자면:

    • useLoaderData()는 오직 해당 라우트의 loader 데이터에만 바로 접근 가능.
    • 하위 라우트(자식 라우트)나 상위 라우트(부모 라우트)의 loader 데이터를 직접 useLoaderData()로 가져오려면 다른 훅을 사용해야 함.
부모 라우트 (loader 있음)
  ├─ 자식 라우트 (loader 없음)
  │   └─ 자식 컴포넌트

자식 컴포넌트에서 useLoaderData() 호출 시 → 자식 라우트 자체의 loader 데이터 반환 (없다면 undefined)
부모 라우트 loader 데이터 접근 필요 → useRouteLoaderData(routeId) 사용
  • react-11 프로젝트 코드 참고하면됨

313. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / loader() 코드를 저장해야 하는 위치

아래 예시는 loader 함수를 컴포넌트 파일(페이지 파일) 내부에 정의하고 export한 뒤, 라우트 정의(App.tsx)에서 import하여 사용하는 검증된 패턴을 보여줍니다. 이렇게 하면 데이터 가져오기 로직(loader)이 해당 데이터와 직접적으로 관련된 컴포넌트(페이지) 파일 내부에 위치하므로, 코드 구조가 더욱 직관적이고 유지보수하기 쉬워집니다.

  • 핵심 포인트:
    • loader 함수를 Pages 폴더 안에 있는 해당 페이지 컴포넌트 파일에 직접 작성하고 export
    • App.tsx(라우트 정의 파일)에서 해당 loader 함수를 import하여 라우트 정의 시 loader 프로퍼티에 할당
    • 이로써 App.tsx에 복잡한 데이터 로직이 섞이지 않고, 각 페이지별로 데이터 로직을 해당 페이지 파일에서 관리 가능
    • 이러한 패턴은 많은 프로젝트에서 검증되었으며 React Router 공식 문서에서도 권장되는 구조
사용자가 http://localhost:3000/events 접속
       │
       ▼
   App.tsx 라우트 정의 확인
   events 라우트 → loader 프로퍼티 → EventsPage.loader 함수 호출
       │
       ▼ 백엔드 (8080 포트)로 요청, 응답받아 events 데이터 반환
       │
       ▼ React Router가 useLoaderData로 events 제공
         EventsPage에서 useLoaderData() 호출, 이벤트 리스트 얻음
       │
       ▼ EventsList로 props 전달 → 이벤트 목록 렌더링

loader 함수는 EventsPage.tsx에서 정의 → App.tsx에서 import → 라우트 로딩 시 자동 호출.

// EventsPage.tsx
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';

export async function eventsLoader() {
  const response = await fetch('http://localhost:8080/events');
  if (!response.ok) {
    throw new Error('이벤트를 가져올 수 없습니다.');
  }
  const resData = await response.json();
  return resData.events; // 이벤트 배열 반환
}

const EventsPage: React.FC = () => {
  // loader 함수 반환값을 useLoaderData로 접근
  const events = useLoaderData() as Array<{id:string, title:string}>;

  return <EventsList events={events} />;
};

export default EventsPage;
  • 설명:
    • eventsLoader 함수 정의 및 export
    • useLoaderData로 loader 반환값 접근
// App.tsx
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage'; // loader import
import EventDetailPage from './pages/EventDetailPage';
import NewEventPage from './pages/NewEventPage';
import EditEventPage from './pages/EditEventPage';
import ErrorPage from './pages/ErrorPage';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'events',
        element: <EventsRootLayout />,
        children: [
          { index: true, element: <EventsPage />, loader: eventsLoader }, // loader 사용
          { path: 'new', element: <NewEventPage /> },
          { path: ':eventId', element: <EventDetailPage /> },
          { path: ':eventId/edit', element: <EditEventPage /> }
        ]
      }
    ]
  }
]);

const App: React.FC = () => {
  return <RouterProvider router={router} />;
};

export default App;
  • 설명:

    • eventsLoader를 EventsPage.tsx에서 import 후, 라우트 정의의 loader 프로퍼티에 할당
    • App.tsx는 데이터 로직 없이 깔끔해짐
  • 정리

    • loader 함수를 페이지 컴포넌트 파일 내부에 정의하고 export한 뒤, App.tsx에서 import하여 loader 프로퍼티에 지정하는 패턴은 유지보수성과 가독성을 모두 향상시킨다.
    • 컴포넌트 파일(EventsPage.tsx)에서 loader 로직을 관리하므로, 관련된 데이터 처리 로직이 해당 페이지와 근접해 있어 명확하다.
    • App.tsx(라우트 정의)에서는 loader 함수를 단순히 import, 할당하는 역할만 하므로 코드가 더 깔끔해진다.
    • 이 패턴은 사람들이 많이 사용하고 검증된 접근 방식으로, React Router 공식 문서에서도 권장하는 구조 중 하나.
  • react-11 프로젝트 코드 참고하면됨

314. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / loader() 함수가 실행되는 시기

아래 예시는 loader 함수 실행 시점과 데이터 로딩 지연 시 발생하는 사용자 경험 변화를 정리한 것입니다. 우리는 백엔드에서 setTimeout을 사용해 응답을 지연시켜, 라우트 전환 시 loader 함수가 호출되고 데이터가 반환되기 전까지 화면 렌더링이 지연되는 상황을 시연합니다. 이를 통해 React Router가 데이터 로딩이 완료될 때까지 기다린 뒤, 해당 데이터를 이용해 페이지를 렌더링한다는 점을 명확히 알 수 있습니다.

  • 핵심 포인트:
    • 라우트 접근 시 해당 라우트의 loader 함수가 즉시 호출되어, 컴포넌트 렌더링 전에 데이터 로딩을 수행
    • 로딩이 지연되면(백엔드 응답 지연) 화면에 아무 변화 없이 대기 상태가 발생 (기본적으로 로딩 표시 없음)
    • 데이터 로딩 후에야 페이지가 렌더링되므로 컴포넌트는 로딩 상태를 별도로 관리하지 않아도 됨
    • 추후 사용자 경험 개선을 위해 React Router가 제공하는 로딩 표시 옵션을 활용할 수 있음
사용자가 http://localhost:3000/events 접속 시도
        │
        ▼ 라우트 전환 시작, React Router → eventsLoader 호출
          eventsLoader : 백엔드 http://localhost:8080/events 요청
        │
(백엔드에서 1.5초 지연 발생 - setTimeout)
        │
        ▼ 1.5초 후 백엔드 응답
          React Router Promise 해제, 데이터 준비
        │
        ▼ 데이터 로딩 완료 후 EventsPage 렌더
          useLoaderData()로 데이터 접근
        │
        ▼ EventsList에 이벤트 전달, 렌더링

라우트 접근 시점에 loader 실행 → 백엔드 지연 시 전환 지연 → 데이터 준비 후 렌더링.

// server.ts
import express, { Request, Response } from 'express'

const app = express()
const PORT = 8080

const DUMMY_EVENTS = [
  { id: 'e1', title: 'Some Event', description: 'First event description' },
  { id: 'e2', title: 'Another Event', description: 'Second event description' }
]

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE')
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
  next()
})

app.get('/events', (req: Request, res: Response) => {
  // 응답 지연 시뮬레이션
  setTimeout(() => {
    res.json({ events: DUMMY_EVENTS })
  }, 1500) // 1.5초 지연
})

app.use((req: Request, res: Response) => {
  res.status(404).json({ message: 'Not Found' })
})

app.listen(PORT, () => {
  console.log(`Backend API server running on http://localhost:${PORT}`)
})
  • 설명:

    • /events 요청 시 1.5초 후 응답 반환
    • 이를 통해 프런트엔드 라우트 전환 시 데이터 로딩 지연 시나리오 재현
  • 정리

    • loader 함수 호출 시점: 라우트 접근 시 즉시 호출, 데이터 준비까지 렌더링 지연
    • 백엔드 응답 지연 상황에서 페이지 전환 시 "정적 대기" 발생 → 추후 로딩 표시 개선 가능
    • 이 방식으로 컴포넌트 로직 단순화, 데이터 항상 준비된 상태로 렌더링 가능
    • React Router 공식 문서와 커뮤니티에서 검증된 고급 패턴, 대규모 프로젝트에 유용.
  • react-11 프로젝트 코드 참고하면됨

315. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 현재 네비게이션 상태를 UI에 반영하기

아래 예시는 React Router v6.4 이상에서 useNavigation 훅을 사용하여 라우트 전환 중에 로딩 상태를 사용자에게 표시하는 방식을 정리한 것입니다. loader 함수를 통해 페이지 진입 전 데이터를 로딩할 때, 백엔드 응답 지연 시 화면에 아무 변화가 없으면 사용자 경험이 좋지 않습니다. useNavigation 훅을 통해 현재 라우트 전환 상태(idle, loading, submitting)를 확인하고, loading 상태일 때 "Loading..." 등 로딩 표시를 보여줄 수 있습니다.

이 방식은 로딩 상태 표시를 전환이 시작되기 전에 있던 페이지(예: RootLayout)에서 처리하므로, 데이터가 도착하기 전 사용자에게 시각적 피드백을 줄 수 있습니다.

  • 핵심 포인트:
    • useNavigation 훅으로 현재 라우트 전환 상태 조회
    • navigation.state가 loading이면 전환 중이므로 로딩 메시지 표시
    • 기존 페이지(루트 레이아웃)에서 로딩 인디케이터 표시 → 데이터 준비 전에 사용자에게 진행 중임을 알림
    • 데이터 준비 후 새 페이지 렌더링
사용자가 http://localhost:3000/events 클릭
       │
       ▼
React Router 라우트 전환 시작 → useNavigation() state = 'loading'
       │
RootLayout에서 navigation.state 확인 후 "Loading..." 표시
       │
백엔드 응답 도착, loader 데이터 준비 완료
       │
navigation.state = 'idle'로 전환
       │
"Loading..." 제거, 새 페이지 렌더링 (EventsPage)

전환 시작 → loading 상태 표시 → 데이터 준비 후 idle 상태로 전환 → 최종 페이지 렌더.

// RootLayout.tsx
import React from 'react';
import { Outlet, useNavigation } from 'react-router-dom';
import MainNavigation from '../components/MainNavigation';

const RootLayout: React.FC = () => {
  const navigation = useNavigation();
  // navigation.state: 'idle', 'loading', 'submitting'

  return (
    <>
      <MainNavigation />
      {navigation.state === 'loading' && <p>Loading...</p>}
      <Outlet />
    </>
  );
};

export default RootLayout;
  • 설명:

    • 라우트 전환 시 navigation.state = 'loading'
    • 로딩 중이면 "Loading..." 표시, 전환 완료 시 자동 숨김
  • 정리

    • useNavigation 훅으로 라우트 전환 상태(idle, loading, submitting) 파악 가능
    • 로딩 상태일 때 기존 페이지(루트 레이아웃)에서 "Loading..." 등 로딩 인디케이터 표시
    • 데이터 준비 후 새 페이지 로딩 → 사용자 경험 개선
    • 이는 사람들이 많이 사용하고 검증된 패턴으로, React Router를 사용한 라우트 전환 UX 개선에 널리 활용됨
  • react-11 프로젝트 코드 참고하면됨

316. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / loader()에서 응답 리턴하기

아래 예시는 loader 함수에서 Response 객체(혹은 react-router-dom의 json() 유틸리티)를 활용하는 패턴을 보여줍니다. 이전까지는 loader 내에서 fetch한 응답 데이터를 직접 파싱(await response.json()) 후 반환했지만, 사실 loader에서는 Response 객체 자체를 반환하거나 json() 함수를 이용해 손쉽게 JSON 응답을 만들 수도 있습니다. 이렇게 하면 useLoaderData()를 사용했을 때 해당 응답이 자동으로 해제되어 데이터에 접근할 수 있습니다.

  • 핵심 포인트:
    • loader 함수는 숫자, 문자열, 객체, 배열 등 어떤 데이터든 반환할 수 있음
    • Response 객체를 직접 반환하거나 json() 함수를 사용해 loader 함수 결과를 반환하면 useLoaderData()가 자동으로 그 데이터를 해제하여 접근 가능
    • 이렇게 하면 loader 코드가 더욱 단순해지고, 데이터 파싱 로직을 생략 가능
    • React Router의 json() 유틸리티는 HTTP 응답 형식의 Response 객체를 쉽게 생성해주며, useLoaderData()에서 해당 JSON 데이터를 자동으로 접근 가능하게 함
사용자가 /events 경로 진입
       │
       ▼ loader 함수 호출 → 백엔드 API fetch
         응답 받으면 json()으로 Response 객체 생성
       │
       ▼ Response 반환 → useLoaderData()로 해당 JSON 데이터 자동 접근
       │
       ▼ EventsPage에서 useLoaderData()로 이벤트 리스트 얻음
       │
       ▼ EventsList에 props로 전달, 화면에 이벤트 출력

loader 반환값(Response, json()) → useLoaderData()에서 자동 추출.

// EventsPage.tsx
import React from 'react';
import { useLoaderData, json } from 'react-router-dom';
import EventsList from '../components/EventsList';

export async function eventsLoader() {
  const response = await fetch('http://localhost:8080/events');
  if (!response.ok) {
    // 여기서도 응답 객체를 json()으로 반환 가능
    // 에러 상태 코드 지정 가능
    throw json({ message: '이벤트를 가져올 수 없습니다.' }, { status: 500 });
  }
  // 응답이 정상이라면 응답을 바로 json()으로 감싸 반환
  const resData = await response.json();
  // json()으로 감싸지 않고 단순 객체 반환도 가능
  //return resData.events;

  // json() 함수를 사용해 응답 객체를 직접 생성 가능
  return json(resData.events, { status: 200 });
  // 이렇게 하면 useLoaderData()에서 이 json 응답이 해제되어 events 배열을 바로 받음
}

const EventsPage: React.FC = () => {
  const events = useLoaderData() as Array<{id:string, title:string}>;

  return <EventsList events={events} />;
};

export default EventsPage;
  • 설명:

    • json() 유틸리티를 사용해 응답 객체를 반환하면 useLoaderData()가 자동으로 그 데이터를 해제
    • 기존 await response.json() 후 객체 반환 대신 json()을 사용
    • 오류 발생 시 throw json({message:'...'},{status:...})로 에러 응답 발생 가능(추후 에러 처리 시 활용)
  • 정리

    • loader 함수에서 직접 Response 객체를 반환하거나 json() 함수를 이용해 응답을 생성하면 useLoaderData()가 이를 자동 해제
    • 별도의 데이터 파싱 로직 없이 바로 데이터에 접근 가능, 코드 간결성 향상
    • 오류 처리도 throw json(...)으로 손쉽게 할 수 있어 추후 에러 처리를 더 단순하게 관리 가능
    • 이 패턴은 많은 프로젝트에서 검증된 방식이며, React Router 공식 문서에서도 적극적으로 소개하는 고급 기능.

react-router-dom v7 json 함수 삭제됨

아래는 React Router v7에서 json() 함수가 제거된 배경과 이를 대체하는 방법에 대한 정리입니다.

  • 핵심 요약:

    • React Router v6.4에서 도입된 json() 함수는 loader 함수에서 JSON 형식의 응답을 쉽게 반환하기 위한 유틸리티였습니다.

    • React Router v7로 넘어오면서 json() 함수가 제거되었습니다(#12146 언급).

    • v7에서는 더 이상 json()을 react-router-dom에서 import할 수 없으며, 대신 표준 Response 객체를 직접 생성하거나, SSR 환경/런타임별로 적절한 JSON 응답 생성 방식을 사용해야 합니다.

    • json() 없이도 loader 함수 내에서 다음과 같이 직접 Response 객체를 반환할 수 있습니다

      return new Response(JSON.stringify(data), {
        status: 200,
        headers: { 'Content-Type': 'application/json' }
      });

      이렇게 하면 useLoaderData()가 이 응답을 인식하고 JSON 데이터에 접근할 수 있습니다.

  • 왜 제거되었나?

    • React Router v7에서는 패키지 구조 개편과 API 단순화를 진행했습니다. Remix와 React Router의 공통 기능이 하나의 패키지로 통합되고, 일부 편의 함수(json(), defer() 등)가 제거되거나 다른 형태로 대체되었습니다.
    • 이는 React Router 팀이 더 명확한 책임 범위를 유지하고, 브라우저 표준인 Response 객체를 사용하는 방향으로 정리했기 때문입니다.
  • 대체 방법:

    1. 직접 Response 생성:

      export async function loader() {
        const response = await fetch('http://localhost:8080/events');
        if (!response.ok) {
          throw new Response(JSON.stringify({message: 'Error fetching events'}), {
            status: 500,
            headers: {'Content-Type': 'application/json'}
          });
        }
        const data = await response.json();
        return new Response(JSON.stringify(data.events), {
          status: 200,
          headers: {'Content-Type': 'application/json'}
        });
      }

      이 경우 useLoaderData()로 접근 시 data는 events 배열을 자동으로 파싱해줍니다.

    2. SSR/서버 환경에서의 대안:

      • Remix 환경 또는 SSR 컨텍스트에서 JSON 응답을 생성하는 다른 API나 유틸리티를 사용할 수 있습니다.
      • v7 릴리즈 노트에서는 json() 유틸리티 제거 후 Response.json()과 같은 브라우저 표준 혹은 직접 Response를 생성하는 방식을 권장합니다.
loader 함수에서 fetch → 백엔드 API 응답 받음
       │
       ▼
응답을 JSON 문자열화 후 new Response(...)로 감싸 반환
       │
       ▼
useLoaderData() 훅 호출 시 반환된 Response 인식
       │
       ▼
JSON 응답 자동 파싱 → 컴포넌트에서 데이터 직접 사용
// EventsPage.tsx
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';

export async function eventsLoader() {
  const response = await fetch('http://localhost:8080/events');
  if (!response.ok) {
    // json() 대신 Response를 직접 생성
    return new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  const data = await response.json();
  return new Response(JSON.stringify(data.events), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  });
}

const EventsPage: React.FC = () => {
  // useLoaderData()는 반환된 Response를 자동으로 해제
  // 단, v7에서 이 부분은 어떻게 동작하는지 공식 문서 참고 필요
  // v7에서는 Response를 직접 반환하면 useLoaderData가 어떻게 처리할지 재확인 필요
  // 만약 Response 해제가 자동으로 안 된다면, 수동 파싱 필요
  const events = useLoaderData() as Array<{id:string; title:string}>;

  return <EventsList events={events} />;
};

export default EventsPage;
  • 주의:

    • React Router v7에서 json() 함수 제거 이후, useLoaderData()가 Response 객체를 자동 해제하는 동작이 변경되었을 수 있습니다.
    • 만약 자동 해제가 지원되지 않는다면, useLoaderData()가 반환하는 값이 Response 객체 그대로일 수 있으므로 수동으로 JSON 파싱이 필요할 수 있습니다.
    • 공식 문서나 마이그레이션 가이드를 참고해 v7에서의 정확한 동작을 확인해야 합니다.
  • 정리

    • React Router v7에서 json() 함수가 제거됨에 따라 loader 함수에서 Response 객체를 직접 생성하는 방식을 권장.
    • Response 객체를 반환하면 useLoaderData()에서 해당 데이터를 자동으로 해제하는지 여부는 v7 변경사항에 따라 재확인 필요.
    • 만약 자동 해제가 지원되지 않으면 loader에서 수동으로 JSON 파싱 뒤 객체를 반환하거나, SSR/런타임 별 JSON 응답 제공 방식을 사용해야 함.
    • 결론적으로 json() 제거 후에는 Response 생성자를 활용하거나 fetch 응답을 직접 처리하는 방식으로 대체 가능하고, useLoaderData()에 기반해 데이터에 접근하는 패턴은 유지됨.
  • react-11 프로젝트 코드 참고하면됨

317. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / loader()로 가는 코드의 종류

아래는 loader 함수에 대한 중요한 특성을 다시 한 번 정리한 내용입니다.

  • 핵심 포인트:
    • loader 함수는 브라우저(클라이언트) 환경에서 실행되며, 서버가 아닌 클라이언트 측 코드입니다.
    • 따라서 브라우저 API(예: localStorage, Cookies, Fetch, etc.)를 자유롭게 사용할 수 있습니다.
    • 다만 React 훅(예: useState, useEffect 등)은 사용할 수 없습니다. 왜냐하면 loader 함수는 React 컴포넌트가 아니기 때문입니다.
    • 이 외에는 브라우저에서 가능한 모든 로직을 loader에서 수행할 수 있습니다. (예: 쿠키 접근, localStorage 접근, 브라우저 내장 API 활용 등)

이러한 특성으로 인해 loader 함수는 React 컴포넌트가 렌더링되기 전 데이터 로딩 로직을 브라우저 환경에서 수행할 수 있는 강력한 도구가 됩니다.

사용자가 /events 페이지 접근
       │
       ▼
   라우트 전환 시작 → loader 함수 호출
       │
       ▼ 브라우저 환경에서 loader 실행
         - localStorage 접근 가능
         - 쿠키 접근 가능
         - fetch, new Response 등 브라우저 API 사용 가능
         단, useState 등 React 훅 불가
       │
       ▼ 데이터 로딩 후 useLoaderData()로 컴포넌트에 데이터 전달

loader는 브라우저 상에서 작동하므로 브라우저 API 활용 가능하나 React 훅 불가.

// src/pages/EventsPage.tsx
import React from 'react'
import { useLoaderData } from 'react-router-dom'
import EventsList from '../components/EventsList'

// loader 함수: 브라우저 환경에서 동작
export async function eventsLoader() {
  // 브라우저 API 사용 예:
  const userToken = localStorage.getItem('user_token');
  // 여기서 useState, useEffect 불가 (컴포넌트 아님)

  const response = await fetch('http://localhost:8080/events', {
    headers: userToken ? { Authorization: `Bearer ${userToken}` } : {}
  })
  if (!response.ok) {
    // json() 대신 Response를 직접 생성
    return new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    })
  }

  const data = await response.json()
  return new Response(JSON.stringify(data.events), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

const EventsPage: React.FC = () => {
  // useLoaderData()는 반환된 Response를 자동으로 해제
  // 단, v7에서 이 부분은 어떻게 동작하는지 공식 문서 참고 필요
  // v7에서는 Response를 직접 반환하면 useLoaderData가 어떻게 처리할지 재확인 필요
  // 만약 Response 해제가 자동으로 안 된다면, 수동 파싱 필요
  const events = useLoaderData() as Array<{ id: string; title: string }>

  return <EventsList events={events} />
}

export default EventsPage
  • 설명:

    • loader 내부에서 localStorage 접근(브라우저 API)
    • fetch를 통한 백엔드 요청
    • 응답 처리 후 events 배열 반환
    • useLoaderData로 이벤트 사용
  • 정리

    • loader 함수는 브라우저 환경에서 실행되므로 브라우저 내장 API(localStorage, 쿠키, fetch, etc.) 활용 가능
    • React 훅(예: useState)은 불가(컴포넌트 아님)
    • 이를 통해 데이터 로딩 전 다양한 브라우저 기능 활용 가능, 예컨대 인증 토큰(localStorage) 활용 가능
    • 이 패턴은 사람들이 검증한 방식으로, 더 풍부한 브라우저 기능과 연계하여 데이터 로딩 로직을 세분화할 수 있음.
  • react-11 프로젝트 코드 참고하면됨

318. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 커스텀 오류를 이용한 오류 처리

아래 예시는 loader 함수에서 오류를 처리하는 방법을 정리한 것입니다. 이전까지는 loader 함수에서 API 요청 실패 시 오류 메시지를 데이터 형태로 반환하거나, useEffect 기반 해결책을 사용했습니다. 이제는 loader 함수 내부에서 throw로 오류를 발생시킬 수 있으며, 이렇게 발생한 오류는 React Router가 감지하여 가장 가까운 errorElement를 렌더링합니다. 즉, 라우트 정의에서 errorElement를 지정한 페이지가 오류 시 대체 렌더링되는 폴백(Fallback) 페이지로 사용됩니다.

  • 핵심 포인트:
    • loader 내부에서 throw new Error('...') 등으로 오류 발생 가능
    • 오류 발생 시 해당 라우트 혹은 상위 라우트에 정의된 errorElement가 렌더링
    • errorElement는 페이지 전환 시 발생하는 모든 오류(404뿐 아니라 loader 오류 등)에 대처 가능
    • 이를 통해 별도의 오류 처리 상태 관리 없이도 라우트 수준에서 오류 UI를 제공할 수 있음
사용자가 /events 페이지 접근
       │
       ▼ loader 함수 실행 중 API 오류 발생 → throw new Error('...')
       │
       ▼ React Router: 오류 감지
         가장 가까운 라우트에서 errorElement 확인
       │
       ▼ errorElement (ErrorPage) 렌더링
사용자에게 오류 UI 표시

loader 내에서 발생한 오류 → errorElement 페이지 렌더링.

// src/pages/EventsPage.tsx
import React from 'react'
import { useLoaderData } from 'react-router-dom'
import EventsList from '../components/EventsList'

// loader 함수: 브라우저 환경에서 동작
export async function eventsLoader() {
  // 브라우저 API 사용 예:
  const userToken = localStorage.getItem('user_token');
  // 여기서 useState, useEffect 불가 (컴포넌트 아님)

  const response = await fetch('http://localhost:8080/events', {
    headers: userToken ? { Authorization: `Bearer ${userToken}` } : {}
  })
  if (!response.ok) {
    // 오류 발생 시 단순히 throw new Error()
    // 이렇게 던진 오류는 errorElement로 처리됨
    throw new Error('이벤트를 가져올 수 없습니다.');
  }

  const data = await response.json()
  return new Response(JSON.stringify(data.events), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

const EventsPage: React.FC = () => {
  // useLoaderData()는 반환된 Response를 자동으로 해제
  // 단, v7에서 이 부분은 어떻게 동작하는지 공식 문서 참고 필요
  // v7에서는 Response를 직접 반환하면 useLoaderData가 어떻게 처리할지 재확인 필요
  // 만약 Response 해제가 자동으로 안 된다면, 수동 파싱 필요
  const events = useLoaderData() as Array<{ id: string; title: string }>

  return <EventsList events={events} />
}

export default EventsPage
  • 설명:
    • fetch 실패 시 throw new Error(...)로 오류 발생
    • useLoaderData()는 오류 발생 시 데이터 접근 불가, 대신 errorElement 렌더링
// src/App.tsx
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage';
import EventDetailPage from './pages/EventDetailPage';
import NewEventPage from './pages/NewEventPage';
import EditEventPage from './pages/EditEventPage';
import ErrorPage from './pages/ErrorPage';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    // 루트 라우트에 errorElement 지정
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'events',
        element: <EventsRootLayout />,
        // errorElement를 여기에 별도로 지정할 수도 있지만
        // 루트 라우트에 지정했으므로 생략 가능
        children: [
          { index: true, element: <EventsPage />, loader: eventsLoader },
          { path: 'new', element: <NewEventPage /> },
          { path: ':eventId', element: <EventDetailPage /> },
          { path: ':eventId/edit', element: <EditEventPage /> }
        ]
      }
    ]
  }
]);

const App: React.FC = () => {
  return <RouterProvider router={router} />;
};

export default App;
  • 설명:

    • 루트 라우트에 errorElement 지정
    • loader 오류 발생 시 ErrorPage 렌더링
    • 필요하다면 이벤트 라우트에 별도의 errorElement 추가 가능
  • 정리

    • loader 함수에서 API 오류 발생 시 throw를 통해 즉시 오류 발생 가능
    • 오류 발생 시 React Router가 errorElement로 지정한 컴포넌트를 대신 렌더링, 별도 상태 관리 불필요
    • 이는 사람들이 많이 사용하고 검증한 패턴으로, 대규모 프로젝트에서 라우팅 수준에서 오류 처리를 일원화하는 데 유용한 접근 방식.
  • react-11 프로젝트 코드 참고하면됨

319. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 오류 데이터를 추출하고 응답 내보내기

아래 예시는 loader 함수에서 Response 객체를 throw하여 오류를 발생시키고, 이를 errorElement를 통해 처리하는 패턴을 보여줍니다. 또한, useRouteError() 훅을 사용해 오류 발생 시 던진 Response 객체나 Error 객체에 접근하여 상황에 따라 다른 오류 메시지를 표시합니다. 이렇게 하면 라우팅 수준에서 오류를 효율적으로 관리할 수 있으며, 다양한 상태 코드(404, 500 등)에 따라 동적으로 오류 메시지를 변경할 수 있습니다.

또한, PageContent라는 헬퍼 컴포넌트를 사용해 오류 페이지의 스타일을 개선하고, MainNavigation을 추가해 사용자 경험을 향상시킬 수 있습니다.

  • 핵심 포인트:
    • loader 함수에서 throw new Response(...)를 통해 HTTP 상태 코드, 메시지 등을 담은 오류 응답을 발생 가능
    • errorElement로 지정한 페이지(ErrorPage)에서 useRouteError() 훅으로 던진 오류 객체를 가져와 상태 코드 및 메시지를 해석
    • 상태 코드별(예: 404 vs 500)로 다른 메시지나 타이틀 표시 가능
    • PageContent 컴포넌트, MainNavigation 컴포넌트를 활용해 오류 페이지 UI 개선
사용자가 /events 페이지 접근
       │
       ▼ loader 함수 실행
         백엔드 요청 실패 시 throw new Response(..., {status:500})
       │
       ▼ React Router 오류 감지
         errorElement로 지정된 ErrorPage 렌더링
       │
       ▼ ErrorPage에서 useRouteError() 사용
         error.status=500, error.data.message 접근
       │
       ▼ 상태 코드 따라 다른 메시지 표시
         PageContent, MainNavigation으로 UI 개선

오류 발생 → errorElement 렌더링 → useRouteError()로 오류 정보 파악 및 동적 메시지 제공.

// src/components/PageContent.tsx
import React from 'react';
import classes from './PageContent.module.css';

interface PageContentProps {
  title: string;
  children?: React.ReactNode;
}

const PageContent: React.FC<PageContentProps> = ({ title, children }) => {
  return (
    <section className={classes.content}>
      <h1>{title}</h1>
      {children}
    </section>
  );
};

export default PageContent;
  • 설명:
    • 단순한 스타일 헬퍼 컴포넌트
    • PageContent.module.css에서 약간의 스타일 적용 가정
import React from 'react'
import { useRouteError } from 'react-router-dom'
import PageContent from '../components/PageContent'
import MainNavigation from '../components/MainNavigation'

// 오류 객체의 형태를 가정하는 인터페이스 정의
interface ErrorResponse {
  status?: number;   // HTTP 상태 코드 (예: 404, 500)
  data?: {
    title: string
    message: string
  };     // JSON 문자열 형태로 응답 바디 가정
}

const ErrorPage: React.FC = () => {
  const err = useRouteError()

  // err를 ErrorResponse | Error 로 가정
  const error = err as ErrorResponse | Error

  let title = '오류가 발생했습니다!'
  let message = '요청한 페이지를 불러오는 도중 문제가 발생했습니다.'

  // error가 ErrorResponse 형태로 status 필드가 있는지 확인
  if (error && 'status' in error && typeof error.status === 'number') {
    if (error.status === 404) {
      title = '찾을 수 없음!'
      message = '리소스 또는 페이지를 찾을 수 없습니다.'
    } else if (error.status === 500) {
      // error.data 가 있는지 확인 후 JSON 파싱 시도
      if (error.data) {
        try {
          const data = error.data
          message = data.message || message
          title = data.title || title
        } catch {
          // JSON 파싱 실패 시 기본 message 유지
        }
      }
    }
  }

  return (
          <>
            <MainNavigation />
            <PageContent title={title}>
              <p>{message}</p>
            </PageContent>
          </>
  )
}

export default ErrorPage
  • 설명:
    • useRouteError()로 throw된 오류(Response 객체) 접근
    • 상태 코드별로 타이틀/메시지 변경, JSON.parse로 error.data 파싱
    • MainNavigation, PageContent로 UI 개선
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';

export async function eventsLoader() {
  const response = await fetch('http://localhost:8080/events');
  if (!response.ok) {
    // 500 에러를 Response 객체로 throw
    return new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }
  const data = await response.json();
  return data.events;
}

const EventsPage: React.FC = () => {
  const events = useLoaderData() as Array<{id:string, title:string}>;
  return <EventsList events={events} />;
};

export default EventsPage;
  • 설명:

    • 오류 발생 시 throw new Response(...) 대신 return new Response(...) 가능하지만, 오류 전파 위해 throw 사용 권장

    • 실제로는 throw new Response(...)를 통해 errorElement로 바로 전파

      if (!response.ok) {
        throw new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.'}), {
          status: 500,
          headers: { 'Content-Type': 'application/json' }
        });
      }
    • 이렇게 throw new Response(...)로 변경하면 errorElement를 통해 오류 처리 가능.

  • 정리

    • loader 함수 내에서 throw new Response(...)로 오류 발생 시, React Router가 errorElement 렌더링
    • useRouteError()로 throw된 객체(access)해 상태 코드, 메시지 파악 후 UI 조건부 렌더링
    • 상태 코드별로 다른 오류 메시지 표시, 기본 메시지를 개선하고 MainNavigation, PageContent로 UI 정돈
    • 이 패턴은 사람들이 많이 사용하고 검증된 방식이며, 대규모 프로젝트에서 라우트 수준 오류 처리에 매우 유용.
  • react-11 프로젝트 코드 참고하면됨

320. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / json() 유틸리티 함수

아래는 React Router v6.4 버전 기준의 json() 메서드 활용 방식과, 최근에 등장한 React Router v7 버전의 변경 사항을 비교하여 정리한 내용입니다. 또한, 이를 활용하는 전체 폴더 구조와 코드 예시를 제시합니다.

  • 핵심 포인트:
    • React Router v6.4:
      • json() 함수 제공: loader 함수에서 JSON 응답(Response 객체)을 간단히 생성 가능
      • json()을 사용하면 useLoaderData()가 데이터 파싱을 자동으로 처리, 추가적인 JSON 파싱 로직 불필요
      • throw json(...) 형식으로 오류 응답도 손쉽게 생성 가능
    • React Router v7 이후 변경사항:
      • json() 함수 제거(#12146): react-router-dom에서 json() 더 이상 export하지 않음
      • 대신 표준 Response 객체나 Response.json() 메서드, 또는 직접 JSON.stringify()를 이용해 응답 생성 필요
      • 에러 처리 시 기존 json() 대신 throw new Response(...) 또는 throw new Error(...) 형태로 수행
      • v7에서는 패키지 구조 개편과 함께 json() 등 일부 편의 함수가 제거되며, React Router와 Remix 관련 기능이 react-router 패키지로 통합
      • 최소 Node 버전 및 React 버전 상향, 일부 future flag가 기본 동작으로 변경, defer 등 일부 API 제거

결론적으로, v6.4에서 json()을 통해 간단히 JSON 응답을 반환하는 패턴은 v7 이후 사라졌으며, v7에서는 표준 브라우저 API나 다른 방식으로 응답을 처리해야 합니다.

  • 정리
    • React Router v6.4: json() 유틸리티로 쉽게 JSON 응답 생성 및 파싱 자동화
    • React Router v7: json() 함수 제거, Response 객체 직접 생성 필요, 공식 문서 및 마이그레이션 가이드 확인 권장
    • v7에서 오류 처리 및 응답 생성 시 더 많은 수동 작업 필요할 수 있음
    • 그러나 React Router는 계속해서 loader, errorElement, useRouteError 등을 제공하므로, v7 환경에서도 기본 로직은 유지되되, json() 대신 표준 Response 사용으로 인한 코드 변화 필요.

결론: json() 함수는 v6.4 에서 편의를 제공했으나 v7에서 제거되었으므로, v7 환경에서는 Response 객체를 직접 다루거나 기타 문서화된 접근법을 따라야 한다.

  • 아래 예시는 React Router v7 기준으로 json() 함수 없이 loader에서 응답을 처리하는 예시 코드입니다.

  • v7에서는 json() 함수가 제거되었으므로, 응답을 직접 Response 객체로 만들거나 단순히 JavaScript 객체를 반환해야 합니다. 오류 발생 시 throw new Response(...)를 사용해 HTTP 상태 코드와 메시지를 담은 응답을 던질 수 있으며, useRouteError() 훅으로 이를 감지하고 ErrorPage에서 상태 코드와 메시지에 따라 조건부 렌더링을 할 수 있습니다.

  • 핵심 포인트 (v7 기준):

    • json() 함수 제거: 직접 new Response(...) 또는 JSON.stringify() 사용
    • 성공 시 단순한 JS 객체 반환 가능: return data.events;
    • 오류 시 throw new Response(JSON.stringify({message:'...'}), {status:...}) 형태로 예외 발생
    • ErrorPage에서 useRouteError()로 error.status, error.data 접근 후 파싱 필요
    • errorElement를 통해 오류 시 대체 페이지 렌더링
사용자가 /events 페이지 접근
       │
       ▼ loader 함수 실행
         fetch 백엔드 → 응답 수신
         if (!ok) throw new Response(JSON.stringify({message:'...'}), {status:500})
         else return data.events (단순 JS 객체)
       │
       ▼ useLoaderData()로 data.events 바로 접근 가능
         오류 발생 시 throw된 Response 감지 → ErrorPage 렌더링
       │
       ▼ ErrorPage에서 useRouteError()로 오류 및 상태 코드 접근
         JSON.parse(error.data)로 메시지 파싱
         상태 코드에 따라 다른 메시지 출력
  • 정리

    • React Router v7에서 json() 제거로 인해 throw new Response(...) 방식으로 오류 처리
    • 성공 시 JS 객체 반환, 오류 시 throw new Response(...)로 상태 코드/메시지 전달
    • ErrorPage에서 useRouteError()로 status/data 파싱 후 상황에 맞는 메시지 표시
    • 기존 json() 방식보다 약간 번거롭지만, 여전히 라우트 수준에서 강력한 오류 처리 가능.
  • react-11 프로젝트 코드 참고하면됨

321. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 동적 라우트와 loader()

  • 아래는 React Router를 활용하여 이벤트 상세 페이지를 구현하는 예시입니다.

    • 사용자가 이벤트 리스트 페이지에서 특정 이벤트를 클릭하면 해당 이벤트의 상세 정보를 로딩하는 EventDetailPage로 이동합니다.
    • 이 때 이벤트 상세 정보를 불러오기 위해 EventDetailPage에 loader 함수를 정의하고, 라우트 정의에서 해당 loader를 등록합니다.
    • loader 함수에서는 params 객체를 통해 eventId에 접근하고, 백엔드 API(http://localhost:8080/events/:eventId)로 요청을 전송합니다.
    • 응답이 성공적이면 데이터를 반환하고, 실패하면 throw new Response(...)를 통해 오류 응답을 던집니다.
    • useLoaderData()로 데이터를 받아와 EventItem 컴포넌트에 전달하고, 오류 발생 시 ErrorPage를 렌더링하여 오류 상태를 표시합니다.
    • EventsList에서는 <a> 대신 <Link>를 사용하고, 상대 경로를 이용해 events/:eventId로 이동할 수 있게 합니다.
  • 주요 포인트:

    • EventDetailPage에서 loader 함수 내 params.eventId를 사용해 특정 이벤트 데이터 로딩
    • 오류 시 throw new Response(...)로 errorElement(ErrorPage) 표시
    • EventItem 컴포넌트로 이벤트 상세 정보 전달
    • EventsList에서 Link를 사용해 상대 경로로 이벤트 상세 페이지 이동
    • json() 함수 대신 Response 및 JSON.stringify()를 사용 (React Router v7 기준)
  • 정리

    • EventDetailPage에 loader 함수를 추가해 이벤트 상세 정보 로딩
    • params 객체를 통해 eventId 접근 가능 (loader({params}))
    • 오류 시 throw new Response(...)로 errorElement(ErrorPage) 표시
    • EventsList에서 Link를 사용해 to={event.id}로 이벤트 상세 페이지 이동
    • React Router v7 기준으로 json() 없이 Response 객체를 직접 생성
    • 전반적으로 사람들이 검증하고 사용한 패턴(라우터 레벨의 데이터 로딩, 오류 처리, UI 개선)을 모두 반영한 코드 예시
  • react-11 프로젝트 코드 참고하면됨

322. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / useRouteLoaderData 훅 및 다른 라우트에서 데이터에 액세스하기

아래는 이벤트 상세 페이지와 이벤트 편집 페이지에 대해 공통 loader를 활용하고 useRouteLoaderData를 사용해 상위 라우트의 loader 데이터를 재사용하는 예시입니다. 이를 통해 같은 이벤트 데이터를 두 페이지에서 공유할 수 있으며, 중복된 로직을 제거하고, 코드 유지보수를 용이하게 합니다.

  • 핵심 포인트:
    • 상위 라우트에 loader를 정의하고, id 프로퍼티를 부여해 loader 데이터에 식별자를 부여
    • 자식 라우트(이벤트 상세 페이지, 이벤트 수정 페이지)에서 useLoaderData를 사용하던 곳은 useRouteLoaderData로 대체
    • useRouteLoaderData('my-loader-id')로 상위 라우트의 loader 데이터 접근
    • 이를 통해 이벤트 상세 정보 로직을 한 곳(상위 라우트의 loader)에서 관리하고, 상세 페이지/편집 페이지 양쪽에서 재사용
사용자가 /events/e1 접속 시도
       │
       ▼ 상위 라우트 (event-detail 라우트) loader 호출
         fetch http://localhost:8080/events/:eventId
         데이터 반환 또는 오류 throw
       │
       ▼ EventDetailPage (index 라우트) useLoaderData()로 상위 loader 데이터 접근
       │
사용자가 "Edit" 버튼 클릭 -> /events/e1/edit 이동
       │
       ▼ EditEventPage에서 useRouteLoaderData('event-detail') 호출
         상위 라우트의 loader 데이터 재사용
       │
       ▼ EventForm event 데이터로 미리 채워진 상태로 표시

이렇게 상위 라우트에 loader 정의 → 하위 페이지들이 loader 데이터 재사용 가능.

// EventDetailLayout.tsx
import React from 'react';
import { Outlet, LoaderFunctionArgs } from 'react-router-dom';

export async function eventDetailLoader({ params }: LoaderFunctionArgs) {
  const id = params.eventId;
  if (!id) {
    throw new Response(JSON.stringify({ message: 'eventId가 누락되었습니다.' }), {
      status: 404,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  const response = await fetch(`http://localhost:8080/events/${id}`);
  if (!response.ok) {
    throw new Response(JSON.stringify({ message: '이벤트 상세 정보를 가져올 수 없습니다.' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  const data = await response.json();
  return data;
}

const EventDetailLayout: React.FC = () => {
  // 단순히 Outlet만 렌더링하는 레이아웃
  return <Outlet />;
};

export default EventDetailLayout;
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage';
import EventDetailLayout, { eventDetailLoader } from './pages/EventDetailLayout';
import EventDetailPage from './pages/EventDetailPage';
import EditEventPage from './pages/EditEventPage';
import NewEventPage from './pages/NewEventPage';
import ErrorPage from './pages/ErrorPage';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    // 루트 라우트에 errorElement 지정
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'events',
        element: <EventsRootLayout />,
        // errorElement를 여기에 별도로 지정할 수도 있지만
        // 루트 라우트에 지정했으므로 생략 가능
        // loader 함수를 events 라우트에 적용,
        // 인덱스 라우트(EventsPage)에서 useLoaderData로 접근 가능
        children: [
          // loader로부터 Response 객체(json()) 반환
          { index: true, element: <EventsPage />, loader: eventsLoader },
          { path: 'new', element: <NewEventPage /> },
          {
            path: ':eventId',
            id: 'event-detail',
            loader: eventDetailLoader,
            // 이 라우트는 event-detail loader로 데이터 로딩 후 자식 라우트에 제공
            element: <EventDetailLayout />,
            children: [
              { index: true, element: <EventDetailPage /> },
              { path: 'edit', element: <EditEventPage /> }
            ]
          }
        ]
      }
    ]
  }
]);

const App: React.FC = () => {
  return <RouterProvider router={router} />
}

export default App
  • 설명:

    • ':eventId' 경로에 이벤트 상세 로더(eventDetailLoader) 적용, id: 'event-detail'로 식별자 부여
    • 자식 라우트 index: true로 EventDetailPage, 'edit'로 EditEventPage 지정
    • EditEventPage에서 useRouteLoaderData('event-detail')로 상위 loader 데이터 재사용
  • 정리

    • 상위 라우트에서 공통 loader 정의 (event-detail 라우트)
    • useRouteLoaderData('event-detail')를 통해 하위 라우트(편집 페이지)에서도 데이터 접근 가능
    • 코드 중복 없이 동일한 이벤트 데이터 로직을 두 페이지(상세/편집)에서 재사용
    • 이 패턴은 사람들이 많이 사용하고 검증된 방식으로, 규모 있는 프로젝트에서 데이터 로직 공유에 유용.
  • react-11 프로젝트 코드 참고하면됨

323. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 데이터 제출 준비하기

아래는 React Router를 활용하여 폼 데이터를 백엔드로 전송하기 위한 전반적인 방식에 대해 정리한 내용입니다. 지금까지는 loader 함수를 통해 데이터를 로딩하는 법을 배웠다면, 이제는 폼을 통해 사용자가 입력한 데이터를 전송(action) 하는 방법을 살펴볼 차례입니다. React Router는 loader와 유사하게, 데이터를 전송하고 처리하기 위한 action 함수를 제공하며, 이를 통해 폼 제출 시 백엔드로 데이터를 손쉽게 전송할 수 있습니다.

  • 핵심 포인트:
    • loader: 데이터 로드(읽기) 전용
    • action: 데이터 전송(쓰기) 전용, 폼 제출 시 자동 호출
    • action 함수 내에서 요청을 처리한 뒤, 필요한 경우 리다이렉션(페이지 전환) 가능 (useNavigate 필요 없음)
    • action을 사용하면 수동으로 폼 데이터 추출, 상태 관리, 네비게이션 로직을 모두 action 함수 내부에 캡슐화 가능

우리는 NewEventPage에서 EventForm을 표시하고, Save 버튼을 누르면 action을 통해 백엔드로 데이터를 전송하도록 할 것입니다.

사용자가 /events/new 페이지 접근
       │
       ▼ NewEventPage 로드, EventForm 표시
         사용자가 폼 작성 후 Save 클릭
       │
       ▼ action 함수 호출 (NewEventPage에서 정의 후 라우트에 action 프로퍼티로 등록)
         action에서 FormData로 폼 값 추출 → 백엔드로 POST 요청
       │
       ▼ 백엔드 응답 성공 시 action에서 redirect('/') 등 리다이렉션 수행
       │
       ▼ 페이지 전환 없이 라우트만 변경, 새 데이터 반영

action 함수로 폼 데이터 전송 및 후처리, 성공 시 리다이렉션까지 처리 가능.

// src/pages/NewEventPage.tsx
import React from 'react';

interface EventData {
  title?: string;
  image?: string;
  date?: string;
  description?: string;
}

interface EventFormProps {
  event?: EventData;
}

const EventForm: React.FC<EventFormProps> = ({ event }) => {
  return (
          <form method="post">
            <p>
              <label htmlFor="title">Title</label>
              <input id="title" name="title" defaultValue={event?.title || ''} required />
            </p>
            <p>
              <label htmlFor="image">Image</label>
              <input id="image" name="image" defaultValue={event?.image || ''} />
            </p>
            <p>
              <label htmlFor="date">Date</label>
              <input type="date" id="date" name="date" defaultValue={event?.date || ''} required />
            </p>
            <p>
              <label htmlFor="description">Description</label>
              <textarea id="description" name="description" defaultValue={event?.description || ''} />
            </p>
            <button type="submit">Save</button>
          </form>
  );
};

export default EventForm;
  • 설명:
    • form에 method="post" 추가하여 폼 제출 시 action 함수 호출 가능
    • defaultValue로 기존 값 설정, NewEventPage에서는 빈 값
// src/pages/NewEventPage.tsx
import React from 'react';
import { useNavigation, redirect } from 'react-router-dom';
import EventForm from '../components/EventForm';
import type { ActionFunctionArgs } from 'react-router-dom';

// action 함수 정의: 폼 제출 시 호출됨
export async function newEventAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const eventData = {
    title: formData.get('title'),
    image: formData.get('image'),
    date: formData.get('date'),
    description: formData.get('description'),
  };

  const response = await fetch('http://localhost:8080/events', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(eventData),
  });

  if (!response.ok) {
    throw new Response(JSON.stringify({ message: '이벤트 생성 실패!' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  return redirect('/events'); // 성공 시 /events 페이지로 이동
}

const NewEventPage: React.FC = () => {
  const navigation = useNavigation();
  // navigation.state 이용해 로딩 상태 표시 가능(생략)
  return <EventForm />;
};

export default NewEventPage;
  • 설명:
    • newEventAction에서 formData 추출 후 백엔드에 POST 요청
    • 성공 시 redirect('/events')로 목록 페이지 이동
    • 폼에 method="post"를 지정했으므로 form submit 시 action 함수 자동 호출
// src/App.tsx
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage';
import { eventDetailLoader, EventDetailLayout } from './pages/EventDetailLayout';
import EventDetailPage from './pages/EventDetailPage';
import EditEventPage from './pages/EditEventPage';
import NewEventPage, { newEventAction } from './pages/NewEventPage';
import ErrorPage from './pages/ErrorPage';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'events',
        element: <EventsRootLayout />,
        children: [
          { index: true, element: <EventsPage />, loader: eventsLoader },
          { path: 'new', element: <NewEventPage />, action: newEventAction }, // action 등록
          {
            path: ':eventId',
            id: 'event-detail',
            loader: eventDetailLoader,
            element: <EventDetailLayout />,
            children: [
              { index: true, element: <EventDetailPage /> },
              { path: 'edit', element: <EditEventPage /> }
            ]
          }
        ]
      }
    ]
  }
]);

const App: React.FC = () => <RouterProvider router={router} />;

export default App;
  • 설명:

    • newEventAction를 action 프로퍼티로 등록
    • 폼 제출 시 action 함수 호출, 데이터 전송 및 리다이렉트 수행
  • 정리

    • 폼 데이터 전송을 위해 action 함수 사용: 폼의 method="post" 설정 시 제출 시 action 호출
    • action 함수 내에서 formData 추출 후 백엔드로 POST 요청
    • 요청 성공 시 redirect 사용하여 다른 페이지로 이동
    • action을 사용하면 useNavigate 등의 훅 없이도 폼 제출 후 페이지 전환 처리 가능
    • 이 접근 방식은 데이터 로딩(loader)와 유사하며, React Router v7 환경에서 널리 검증된 패턴으로 대규모 앱에서 폼 처리 로직을 깔끔하게 관리 가능.
  • 이슈

    1. nodejs 서버
      • JSON 본문 파싱 미들웨어를 라우트 등록 이전에 적용 / app.use(express.json())
      • 이 미들웨어를 적용 안해놔서 req.body에 아무 값도 안들어오는 버그 발생
  • react-11 프로젝트 코드 참고하면됨

324. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / action() 함수 사용하기

아래는 폼 데이터 전송을 위해 React Router v7의 action 함수를 사용하여 폼 제출을 처리하고, 제출 성공 시 redirect를 통해 다른 페이지로 이동하는 과정을 정리한 내용과 예시 코드입니다.

  • 핵심 포인트:
    • <Form method="post">를 사용하여 폼 제출 시 action 함수 자동 호출
    • action 함수에서 request.formData()로 폼 데이터를 추출
    • 추출한 데이터를 JSON 형태로 백엔드로 전송한 뒤, 성공하면 redirect()로 페이지 이동
    • 실패 시 throw new Response(...)로 오류 응답 던져 오류 페이지 표시 가능
사용자가 /events/new 페이지 접근
       │
       ▼ NewEventPage에서 <Form method="post"> 사용
         사용자가 폼 입력 후 Save 클릭
       │
       ▼ action(newEventAction) 호출
         └ request.formData()로 폼 데이터 추출
         └ fetch로 백엔드에 POST 전송
       │
       ▼ 백엔드 이벤트 생성 성공 시 action에서 redirect('/events') 호출
       │
       ▼ /events 페이지로 이동, 새 이벤트 리스트 반영
  • 제출 후 자동으로 action 함수가 호출되어 데이터 처리 및 리디렉트까지 처리.

  • 정리

    • 이렇게 action 함수와 <Form> 컴포넌트를 사용하면 별도의 preventDefault나 useNavigate 없이도 폼 제출을 처리하고, 백엔드에 데이터 전송 후 페이지 이동까지 손쉽게 구현할 수 있습니다.
    • 이는 React Router v7에서 널리 권장되는 검증된 패턴으로, loader와 action을 함께 사용하면 데이터 로딩과 전송 로직을 깔끔하게 관리할 수 있습니다.
  • react-11 프로젝트 코드 참고하면됨

325. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 프로그램적으로 데이터 제출하기

아래는 리액트 라우터 액션 함수를 프로그램적으로 트리거하는 방법을 정리한 내용과 예시 코드입니다. 이제까지 <Form> 컴포넌트를 사용하여 액션을 트리거하는 방법을 배웠다면, 이번에는 useSubmit 훅을 사용해 사용자 확인(prompt) 후 프로그램적으로 액션을 호출하는 패턴을 다룹니다.

  • 핵심 포인트:
    • <Form> 컴포넌트로 폼 제출 시 자동으로 액션 호출 가능
    • useSubmit 훅을 이용하면 자바스크립트 코드에서 조건부로 액션을 트리거 가능
      • 예: 삭제 버튼 클릭 시 confirm()으로 사용자에게 최종 확인 후 submit() 호출
    • submit() 함수는 formData나 method, action 경로 등을 설정 가능
    • action 함수는 request, params 등을 이용해 폼 또는 submit() 호출로 전송된 데이터를 접근하고, 적절한 백엔드 요청을 수행한 뒤 redirect 등 후처리 가능
사용자가 /events/:eventId 페이지 접근
       │
       ▼ EventDetailPage 렌더링, EventItem 표시
         Delete 버튼 클릭 시 confirm() 호출 → 사용자가 OK 선택
       │
       ▼ useSubmit() 훅으로 submit() 함수 호출
         └ method='delete' 설정
         └ action 함수가 실행됨(eventDetailAction)
       │
       ▼ action 함수에서 params.eventId로 이벤트 식별
         fetch로 백엔드 /events/:eventId DELETE 요청 전송
       │
       ▼ 성공 시 action에서 redirect('/events')
       │
       ▼ /events 페이지로 이동, 삭제된 이벤트 반영

confirm → useSubmit() → action 호출 → 백엔드 삭제 요청 → redirect 순으로 진행.

  • 설명:

    • 이벤트 상세 페이지(action, loader)에서 이벤트 삭제 액션 정의
    • EventItem에서 useSubmit() 훅을 사용, confirm 후 submit() 호출 → action 함수 트리거
    • action 함수에서 DELETE 요청 후 성공 시 redirect('/events') 실행
    • 이렇게 프로그램적인 액션 호출로 사용자가 확인 절차를 거친 뒤 삭제 가능
  • 정리

    • 이로써 <Form> 없이도 useSubmit 훅을 사용해 조건부로 액션 함수를 트리거할 수 있음을 알 수 있습니다.
    • 이는 사람들이 검증한 패턴으로, 사용자 확인(prompt) 후, 폼 없이도 액션을 호출하거나 다양한 메서드(DELETE, PUT 등)를 사용하는 상황에서 유용하게 쓰입니다.
  • react-11 프로젝트 코드 참고하면됨

326. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 제출 상태를 이용하여 UI 상태 업데이트하기

아래는 사용자가 이벤트를 추가할 때 useNavigation 훅을 사용해 제출 상태를 파악하고, 폼 제출 중임을 사용자에게 피드백하는 방법에 대한 정리와 예시 코드입니다. 이 방식으로 사용자는 폼을 제출한 뒤 백엔드 처리 완료 전까지 “Submitting…” 상태를 확인할 수 있고, 버튼을 비활성화해 중복 제출을 막을 수 있습니다.

  • 핵심 포인트:
    • useNavigation() 훅 사용 시 navigation.state를 통해 현재 전환 상태 확인 가능
    • navigation.state === 'submitting'이면 액션 함수 실행 중 (데이터 전송 중)
    • 제출 중임을 나타내는 상태 변수 isSubmitting 사용 가능
    • isSubmitting에 따라 버튼을 비활성화하고 버튼 텍스트를 바꿔 사용자에게 피드백 제공
사용자가 /events/new 접근
   │
   ▼ NewEventPage 렌더링, useNavigation 훅 사용
     "Save" 버튼 클릭 → 폼 제출 → action 함수 호출
   │
   ▼ action 함수에서 백엔드로 POST 요청
     navigation.state === 'submitting' 동안 "Submitting..." 표시, Save 비활성화
   │
   ▼ 백엔드 처리 후 action 완성 → redirect('/events')
   │
   ▼ /events로 이동, isSubmitting 상태 해제, UI 정상화
  • 정리

    • 이로써 useNavigation() 훅으로 현재 폼 제출 상태를 감지하고, 사용자에게 로딩 상태("Submitting...")를 보여주며, 버튼을 비활성화하는 패턴을 이해했습니다.
    • 이는 React Router v7에서 널리 검증된 패턴으로, 사용자 경험을 개선하고 전송 중인 상태에서의 중복 제출을 방지하는 실용적인 접근법입니다.
  • react-11 프로젝트 코드 참고하면됨

327. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 사용자 입력을 검증하고 검증 오류 출력하기

아래는 백엔드 검증 오류를 프런트엔드에서 반영하는 방법에 대한 정리와 예시 코드입니다. 이 방법을 통해 서버 측에서 유효성 검증에 실패한 경우, 액션 함수에서 오류 응답을 반환하고, 클라이언트 측에서 useActionData() 훅을 사용해 해당 오류 정보를 폼 위에 표시할 수 있습니다. 이는 사용자가 폼을 제출했을 때 발생하는 오류를 사용자 친화적으로 처리하는 검증된 패턴입니다.

  • 핵심 포인트:
    • 서버 측 유효성 검증 실패 시 422 상태 코드와 함께 오류 정보를 JSON 형태로 응답
    • 액션 함수에서 response.status를 확인, 422면 오류 응답을 return response
    • useActionData() 훅으로 액션이 반환한 데이터(오류 정보) 접근 가능
    • 폼 상단 혹은 해당 위치에서 오류 메시지를 표시해 사용자 경험 개선
사용자가 /events/new 페이지에서 폼 입력 후 Submit
       │
       ▼ action(newEventAction) 호출
         폼 데이터 추출 → 백엔드 /events에 POST 요청
       │
       ▼ 백엔드 검증 실패 (예: title, date 누락)
         status 422 + JSON 오류 정보 반환
       │
       ▼ action 함수에서 response.status 422 확인
         return response로 action 데이터 반환
       │
       ▼ NewEventPage에서 useActionData()로 오류 데이터 수신
       │
       ▼ EventForm에서 오류 메시지 렌더링 (사용자에게 검증 오류 알림)

서버 검증 실패 → action에서 response 반환 → useActionData로 오류 표시.

  • 설명:

    • 백엔드에서 검증 실패 시 422 상태 코드와 오류 정보(json) 응답
    • 액션 함수에서 response.status === 422 시 return response로 action data 반환
    • useActionData() 사용해 액션 반환 데이터(오류 메시지) 획득
    • EventForm에서 오류 메시지를 화면에 표시, 폼 값 유지
  • 정리

    • 위와 같은 패턴으로 서버 측 검증 오류를 액션 함수에 반영하고, useActionData()를 통해 페이지에 표시함으로써 사용자에게 어떤 필드가 잘못되었는지 알려줄 수 있습니다.
    • 이는 양쪽(서버/클라이언트) 검증을 활용하며, 사람들이 검증한 실용적이고 친화적인 사용자 경험을 제공하는 방법입니다.
  • react-11 프로젝트 코드 참고하면됨

328. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / request() 메서드로 액션 재사용하기

아래는 동일한 액션 함수를 신규 이벤트 생성과 기존 이벤트 편집 모두에서 재사용하는 방법을 정리한 내용과 예시 코드입니다. 이를 통해 하나의 액션 함수에서 HTTP 메서드와 URL을 상황에 따라 동적으로 결정하여 공통 로직을 재사용할 수 있습니다. 또한, <Form> 컴포넌트의 method 속성을 조정하거나, 라우트 파라미터를 이용해 URL을 유연하게 변경하는 패턴을 보게 됩니다.

  • 핵심 포인트:
    • 하나의 액션 함수(manipulateEventAction)에서 새로운 이벤트 생성(POST)과 기존 이벤트 편집(PATCH) 모두 처리
    • <Form method="post"><Form method="patch">로 액션 호출 시, action 함수 내부에서 request.method 값을 통해 실제 백엔드 요청 시 메서드를 결정
    • 이벤트 생성 시 /events 경로에 POST 요청, 편집 시 /events/:eventId 경로에 PATCH 요청
    • 라우트마다 별도의 액션을 정의하지 않고, 유연한 단일 액션 함수 재사용
    • 이에 따라 코드 중복 최소화 및 유지보수성 향상
/events/new 페이지 접근 (새 이벤트 추가)
     │
     ▼ <Form method="post"> 사용
       action 함수(manipulateEventAction) 호출
       request.method = 'POST'
     │
     ▼ action 함수에서 'POST' 감지 → /events에 POST 요청 후 redirect('/events')
       새 이벤트 추가 완료

/events/:eventId/edit 페이지 접근 (기존 이벤트 편집)
     │
     ▼ <Form method="patch"> 사용
       action 함수(manipulateEventAction) 호출
       request.method = 'PATCH'
     │
     ▼ action 함수에서 'PATCH' 감지 → /events/:eventId에 PATCH 요청 후 redirect('/events')
       이벤트 편집 완료

새 이벤트 추가와 기존 이벤트 편집 둘 다 동일한 액션 활용.

  • 설명:

    • EventForm 컴포넌트 내에서 manipulateEventAction 정의, POST/PATCH 구분해 이벤트 생성/편집 모두 처리
    • NewEventPage에서는 <Form method="post"> → manipulateEventAction 내부에서 POST /events
    • EditEventPage에서는 <Form method="patch"> → manipulateEventAction 내부에서 PATCH /events/:eventId
    • 공통 액션 함수를 통해 코드 중복 최소화 및 유지보수성 향상
  • 정리

    • 위와 같이 하나의 액션을 사용해 다양한 상황(새 이벤트 생성 vs 기존 이벤트 편집)에 따라 요청 메서드와 URL을 동적으로 결정할 수 있습니다.
    • 이를 통해 중복 코드가 줄어들고, 메서드와 경로만 다르게 설정하면 공통 로직을 재사용할 수 있으며, 이는 리액트 라우터 v7에서 권장되는 검증된 패턴입니다.
  • react-11 프로젝트 코드 참고하면됨

329. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / useFetcher()를 이용한 배후 작업

아래는 useFetcher 훅을 사용해 전환 없이 액션을 트리거하는 방법에 대한 정리와 예시 코드입니다. 이 방법을 통해 <Form>를 사용하더라도 라우트 전환 없이 백엔드 요청(액션/로더 실행)을 수행하고, 반환된 데이터를 이용해 UI를 업데이트할 수 있습니다. 이는 예를 들어 전역 네비게이션 영역에 폼(뉴스레터 구독 폼 등)이 있을 때 특히 유용합니다.

  • 핵심 포인트:
    • useFetcher() 훅을 사용하면 전환 없는 액션/로더 실행 가능
    • fetcher.Form으로 액션 트리거 시 라우트 전환 없이 데이터 송수신
    • 액션 함수에서 데이터 반환 시 fetcher.data로 접근 가능
    • fetcher.state로 로딩 상태 추적 가능
    • 이를 통해 백엔드 연동 및 UI 업데이트를 라우트 변경 없이 처리
메인 네비게이션 영역에 newsletter 폼 (fetcher.Form) 존재
     │
     ▼ 사용자가 email 입력 후 submit
       fetcher.Form → /newsletter 액션 호출 (POST)
       (라우트 전환 없음)
     │
     ▼ 액션 함수 newsletterAction에서 이메일 처리 후 메시지 반환
       return new Response(JSON.stringify({ message: '등록 성공' }), ...)
     │
     ▼ fetcher.data로 액션 반환 데이터 접근
       fetcher.state 통해 로딩 상태 관리
     │
     ▼ useEffect 등으로 fetcher.data 변화 감지
       알림 표시 등 UI 업데이트
  • 정리

    • 이로써 useFetcher를 사용해 액션/로더를 트리거하면서도 라우트 전환 없이 배후에서 데이터 통신을 처리하는 패턴을 습득했습니다.
    • 이 방법은 전역적으로나 모든 페이지에서 공통적으로 사용해야 하는 폼(예: 헤더의 뉴스레터 구독 폼)에 적합하며, 사람들이 검증한 실용적인 방법입니다.
  • react-11 프로젝트 코드 참고하면됨

react-router-dom 의 action 은 loader 와 동작 원리가 다르다

React Router에서 액션 함수 호출 방식은 로더와 다르게, UI 렌더링 계층 구조와는 독립적으로 작동합니다. 즉, 특정 경로에 액션이 정의되어 있으면, 해당 경로를 대상으로 하는 요청(예: 폼 제출)만 이루어지면, 라우트 계층이나 부모/자식 관계에 상관없이 액션이 호출될 수 있습니다.

로더(Loader)와 액션(Action)의 차이를 이해하면 이 동작 원리를 이해하기 쉽습니다.

  1. 로더(Loader)

    • 로더는 사용자가 특정 라우트로 전환할 때 자동으로 호출됩니다.
    • 즉, 해당 라우트 경로로 이동(네비게이션)할 때, 그 라우트에 등록된 로더가 실행되어 데이터를 로딩합니다.
    • 여기서 로더는 "현재 렌더링 중인 라우트"와 밀접히 연관됩니다. 부모/자식 라우트 계층 구조에 따라 가장 가까운 상위 혹은 해당 라우트의 로더가 작동하는 것이 핵심입니다.
  2. 액션(Action)

    • 액션은 폼 제출(Form submission)이나 프로그램적 요청(useSubmit, fetcher.submit 등)을 통해 명시적으로 트리거됩니다.
    • 액션 호출 시 중요한 것은 경로 기반 요청입니다. 폼이나 fetcher.Form에 action="/newsletter"라고 명시하면, 해당 요청은 '/newsletter' 경로의 액션을 실행합니다.
    • 라우팅 계층(부모/자식)과 무관하게, action 속성에 지정된 경로에 해당하는 라우트의 액션이 호출됩니다.
    • 즉, 액션 호출은 "현재 렌더링 중인 라우트"와 강하게 결합되지 않고, 단지 폼이나 fetcher에서 action으로 지정한 경로를 기반으로 해당 경로 라우트의 액션 함수만 실행합니다.
  • 정리하면:
    • 로더는 해당 라우트로 네비게이션할 때 호출되므로, 라우트 계층 구조(부모/자식)와 렌더링 중인 라우트에 의존합니다.
    • 액션은 명시적인 요청(action 속성에 지정된 경로)에 의해 호출되며, 계층 구조나 현재 렌더링 라우트와 상관없이, 단지 그 경로에 해당하는 액션 함수를 실행합니다.

따라서 newsletterAction/newsletter 경로에 라우트로 등록되어 있고, MainNavigation 내의 fetcher.Form에서 action="/newsletter"로 요청을 보내기 때문에, 현재 페이지(루트나 자식 라우트와 상관없이)나 계층과 상관없이 /newsletter 라우트에 등록된 액션(newsletterAction)이 호출되는 것입니다.

  • react-11 프로젝트 코드 참고하면됨

330. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / defer() 함수로 데이터 가져오기를 연기하는 방법 (react-router-dom v6 vs v7)

아래 예시는 React Router v7 기준에서, 과거 버전(6.x)에서 사용하던 defer/Await/Suspense 방식을 더 이상 사용할 수 없게 된 상황을 고려하여, “느린(지연된) 데이터 로딩” 시에 어떻게 대응할 수 있는지 보여줍니다.

v7부터는 defer가 제거되었고, Single Fetch / Turbo-Stream 등의 내부 변경이 생겼습니다. 따라서 예전처럼 loader()에서 defer()<Await><Suspense> 조합을 쓸 수 없습니다. 대신 “하나의 loader가 Promise로 데이터를 반환하면, 그 로더가 resolve될 때까지 전환이 지연되는” (기존과 유사한) 동작이 기본 형태입니다. 결과적으로 페이지가 모든 데이터를 기다린 뒤 렌더링됩니다. 이때 "로딩 중 스피너"를 표시하려면 라우트 전환 중임을 감지(useNavigation)해서 처리하거나, 다른 별도 기법(e.g. "Single Fetch" + React 19 Streaming 기능)으로 부분 렌더링을 적용해야 합니다.

아래 예시 코드는 “느린 응답”을 가정하여 기본 로더가 Promise를 반환하고, 그동안 페이지 전환이 잠시 지연되는 형태를 보여줍니다. 또한, 라우트 전환 시 로딩 상태는 useNavigation을 통해 표시합니다.

  1. React Router v7에서 defer 제거

    • 기존 defer, <Await>, <Suspense>를 통한 부분 렌더링(“부분만 먼저 렌더, 나머지는 지연 로딩”)은 v7에서 공식적으로 제거됨.
    • 대체로 “Single Fetch” 및 “Turbo-Stream” 접근으로 개선됨. 그러나 일반 사용자 입장에서는 기본 로더 + Promise 또는 React 19 스트리밍 등을 고려해야 함.
  2. 지연된 데이터 로딩 시 어떻게?

    • 가장 단순한 방법: 로더(loader)가 Promise를 반환 → 그 Promise가 resolve될 때까지 라우트 전환이 기다림(“blocking”)
    • 사용자 경험 개선 위해 “로딩 중 표시”는 useNavigation().state 등을 이용해서 구현(예: if (navigation.state === 'loading') return <p>로딩중...</p>)
  3. 코드 흐름

    • 서버에서 응답을 늦추는(setTimeout) 방식을 통해 느린 응답 시뮬레이션
    • 로더에서 await fetch(...) + 인위적 지연(서버 측 setTimeout) → loader가 Promise를 반환 → 전환 대기
    • 컴포넌트(예: EventsPage)에서는 useLoaderData로 로더 결과를 기다려 최종 데이터 렌더
    • 로딩 상태: 전환이 발생하면 navigation.state가 'loading' → 'idle'로 바뀜. 이 사이에 스피너나 Loading... 표시
[사용자가 /events 페이지로 전환] → [loader() 함수 호출] → 
  (서버의 느린 응답) → Promise 대기 → 
  [resolve되면 useLoaderData()가 데이터 수신] → [이벤트 목록 렌더]
  • 전환하는 동안: navigation.state === 'loading'

  • 완료 시: navigation.state === 'idle', useLoaderData()로 최종 데이터 사용

  • 요약

    • React Router v7에서는 기존 defer, <Await>, <Suspense> 조합을 통한 부분 렌더링 API가 제거됨.
    • loader 함수가 Promise를 반환하면, 라우트 전환은 해당 Promise가 resolve될 때까지 대기. (기본 Blocking)
    • 부분 렌더링(Partial Rendering)을 위해선 React 19+에서 제공하는 스트리밍과 “Single Fetch” 등 별도 방식이 필요.
    • 본 예시처럼 단순 “로딩 중...” 표시 → 데이터 준비 후 전체 렌더 방식을 사용할 수 있음.
    • “로딩 중 표시”는 useNavigation()의 navigation.state를 참조해 UI를 조건부 렌더링(e.g. 'loading' 시 스피너).
    • 이로써 v7 환경에서의 “느린 데이터 로딩” 대응 방식을 살펴봤습니다. 기존 defer/Await 대신 기본 Promise 대기 + 전환 중 로딩 상태 표시 패턴이 권장됩니다. 사람들이 가장 많이 사용하고 검증된 일반적 접근법이며, 더 고급 기능(React 19 스트리밍)도 선택적으로 활용 가능합니다.
  • react-11 프로젝트 코드 참고하면됨

331. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 연기해야 할 데이터를 제어하는 방법

아래 예시는 React Router v7에서 defer/Await/Suspense가 제거된 상황에서, 두 가지 이상의 느린(지연된) 로딩 시나리오를 어떻게 처리할 수 있는지 보여줍니다.

이전 v6 시절에는 defer(...) → <Await resolve=...><Suspense fallback=...> 조합으로 "일부 데이터 먼저 렌더, 남은 데이터 지연" 같은 접근을 했지만, v7부터는 공식적으로 defer가 제거되었습니다. 예제 설명에서는 “동시에 여러 개의 HTTP 요청을 보내되, 필요한 경우 한쪽만 기다린 뒤 페이지 로딩을 끝내고 다른 요청은 페이지 표시 후에 로딩”하는 방식을 보이려 했으나, v7에서는 “Single Fetch”/“Turbo-Stream” 등 새 아키텍처가 강조되고, 기존 defer API가 없어진 상태입니다.

그러므로 본 문서에선 두 가지 방식을 비교합니다:

  1. v6 스타일(제거된 defer) → 더 이상 쓸 수 없음.
  2. v7에선 “기본 blocking 로딩” + “전환 시 로딩 표기”를 권장하거나, React 19의 스트리밍 등을 고려해야 함.

결론적으로 v7에서 “두 개 이상 HTTP 요청을 지연”하여 부분 렌더링하려면, React 19 스트리밍과 “Single Fetch” 전략을 사용하거나, 따로 Promise를 분리하여 “한 요청만 우선 대기”하고 나머지는 페이지 로딩 후 fetch로 처리하는 식을 수동 구현해야 합니다.

  1. v7의 “defer” 제거

    • 과거 defer(...)/Await/Suspense를 통한 “부분 로딩” API가 사라짐.
    • 대신 “Single Fetch”나 “Turbo-Stream”과 같은 아키텍처가 강조됨.
    • 즉, 한 loader가 여러 Promise를 동시에 처리하고, 부분만 먼저 렌더하는 기존 방식은 공식 API로 지원하지 않음.
  2. 여러 요청 시나리오

    • “이벤트 상세(event) + 모든 이벤트 목록(events)”처럼 2개 HTTP 요청이 필요한 경우:
      • v6: defer({ event: loadEvent(...), events: loadEvents(...) }) → <Await> 각각 표기
      • v7: “Single Fetch” 또는 “별도 fetch”/“로딩 표시”를 수동 구현.
      • 일반적으로 blocking: 모든 Promise가 resolve될 때까지 라우트 전환 대기.
      • 부분 로딩이 필요하면, React 19 스트리밍 같은 방식을 써야 함.
  3. 대안 - “한 요청만 우선 대기, 나머지는 useEffect로 fetch”

    • 예: EventDetailPage에서 “event 상세”는 loader로 blocking, “event 리스트”는 컴포넌트 마운트 후 useEffect로 가져오기.
    • 그러면 상세 정보는 즉시 보여주고, 나머지 목록만 지연 로딩.
  4. 로딩 표기

    • v7에서 “전환 동안 로딩”은 useNavigation().state로 구현. 'loading' 상태에서 스피너/“Loading...” 표기.
    • “부분만 늦게 오면?” → 부분은 별도 fetch + 로딩 표기 (e.g. “LOADING...”).
[사용자 clicks "/events/e1"]
 → [loader() (eventDetailLoader) 호출]
    → [HTTP 요청 1: /api/events/e1] (blocking)
    → (가능하면) [HTTP 요청 2: /api/events] (별도 fetch?)
 → [fetch 완료 시 페이지 전환]
    → [useLoaderData()로 event(단일) 수신]
    → [모든 이벤트 목록은 useEffect 등 별도 방식 or blocking?]
  • 정리
    1. defer 제거
      • v6에서 “부분 로딩”에 쓰이던 defer, <Await>, <Suspense>가 v7에서는 없어짐.
      • 따라서 “다수의 요청 중 일부만 먼저 로딩”은 React 19 스트리밍 또는 수동 fetch등으로 구현.
    2. 다수 요청 시
      • 예제처럼 “단일 이벤트”만 blocking 로더로 처리하고, “전체 이벤트 리스트”는 컴포넌트 마운트 후 useEffect로 fetch → “부분 지연 로딩” 유사 효과.
    3. 로딩 표시
      • “전환 중”을 useNavigation().state로 감지 가능. 'loading' 시 상단에 “로딩중...” 표시.
      • “세부 fetch”는 컴포넌트 내부에서 별도 로딩 스피너/메시지.
    4. Redux/Context vs React Router
      • Router의 로더는 “경로별 blocking 로딩”.
      • 부분만 지연 시 “여전히 useEffect + 로딩 상태” 관리.

이처럼 React Router v7에서 여러 요청을 느리게 로딩하고, 하나만 우선 받거나, 나머지는 페이지 표시 후 처리하려면, 기본 blocking + useEffect 패턴을 적절히 섞는 방식을 써야 합니다.
과거 v6 스타일의 defer를 사용해도 빌드가 되지 않으며, 새로이 공개된 “Single Fetch + React 19 스트리밍” 등을 적용해야만 “부분 로딩”을 라우터 레벨에서 세련되게 수행할 수 있습니다.

  • react-11 프로젝트 코드 참고하면됨

332. 리액트 라우터가 있는 SPA 다중 페이지 구축하기 / 요약

아래 내용은 리액트 라우터(react-router)에서 라우팅과 데이터 로딩/제출(action) 전반을 학습한 뒤, 마지막으로 Homepage 컴포넌트를 조금 다듬어서 섹션을 마무리하는 과정에 대한 요약 정리입니다.

  1. 라우트 설정

    • 다양한 path에 대해 서로 다른 컴포넌트 로딩: createBrowserRouter([...]) 구조.
    • 루트 레이아웃(RootLayout)에서 <Outlet>을 통해 자녀 라우트를 감싸는 중첩 라우팅.
  2. 오류 처리

    • errorElement 프로퍼티 사용
    • throw new Response(...) 또는 throw new Error(...) 식으로 오류 발생 시, 가장 가까운 errorElement 라우트가 렌더링.
  3. 데이터 가져오기

    • loader 함수를 라우트에 지정: loader: someLoaderFn
    • 컴포넌트 내부에서 useLoaderData()로 가져온 데이터 활용
    • 고급:
      • useFetcher(), useFetchers()를 활용하여 페이지 전환 없이 loader/action 호출 가능
      • useRouteLoaderData('특정_라우트_id') 로 다른 라우트의 loader 데이터 재사용 가능
  4. 데이터 제출하기

    • action 함수(라우트에 지정): 폼을 제출할 때 호출
    • 컴포넌트 안에서 <Form method="post"> 또는 <Form method="patch"> 등 사용
    • useNavigation().state로 제출/로딩 상태 확인(버튼 비활성화 등)
    • 제출 후 redirect('/어딘가')로 라우터에서 자동 이동 처리
  5. 부분 로딩 / 지연 로딩

    • v6 시절 defer/Await/Suspense가 있었으나, v7에서 제거
    • 대안: “단일 로더로 한 번에 가져오고, 느린 백엔드는 useNavigation().state로 로딩 표기”
    • 또는 “주요 정보는 loader로 blocking, 나머지는 useEffect로 fetch” 등 수동 접근
  6. Homepage 마무리

    • PageContent를 import하여 타이틀/간단한 문구 작성
    • 섹션이 끝남에 따라 “라우팅 + 데이터 로딩/제출” 전 과정을 한 번 더 복습 권장
[사용자 방문 "/" 경로] 
  ↓ (라우터에서 HomePage 로딩)
  ↓ HomePage 내부에서 PageContent 렌더링

[사용자 "이벤트 목록" 링크 클릭 -> "/events"] 
  → 라우터에서 eventsLoader() 호출
  → 백엔드("/api/events") 요청
     └─ (성공 or 오류)
  ↓ 응답 완료 시 EventsPage 렌더
     └─ (오류 시 ErrorPage 렌더)
  • 마무리
    • 라우팅: 단순 경로 설정부터 중첩 라우팅, 에러 처리, 데이터 로딩/제출(Loader/Action), 그리고 부분적/다단계 로딩(과거 defer)까지 폭넓게 다룸.
    • v7에선 defer(제거) 대신 “단일로더 + useEffect” 방식, 또는 “Single Fetch/React 19 streaming” 권장.
    • Homepage 마무리: PageContent로 감싼 “Welcome!” 출력으로 간단히 섹션 종료.

이로써 긴 섹션을 마무리하며, 리액트 라우터의 다양한 기능(라우트 설정, 에러 페이지, 데이터 로딩/제출, 중첩 라우트, useNavigation 등)을 익혔습니다. 필요할 때마다 한 번씩 복습하여 프로젝트에 적용하시면 됩니다.

  • react-11 프로젝트 코드 참고하면됨

333. 리액트 앱 인증 추가하기 / 인증의 원리

아래는 클라이언트(리액트 앱)와 백엔드 서버 간의 토큰 기반 인증 과정을 요약한 내용입니다.

  1. 인증(Authorization) 필요성

    • 보호가 필요한 백엔드 리소스(예: 특정 이벤트 수정, 삭제 등)에 접근하려면 인증 절차가 필수임
    • 사용자가 이메일/비밀번호를 제출 → 백엔드가 자격 증명을 검증 → 성공 시 토큰(혹은 세션)을 발급
  2. 서버 측 세션 vs 인증 토큰

    • 서버 측 세션: 로그인 정보(“네”)를 서버에 저장, 클라이언트에는 ID만 반환.
      • 풀스택(백엔드+프론트엔드가 긴밀히 결합) 구조에 적합
      • 리액트처럼 프론트엔드/백엔드가 분리된 구조에선 관리가 복잡
    • 인증 토큰(Json Web Token, JWT):
      • 로그인 성공 시 토큰(스트링)을 생성하여 클라이언트에게 전달
      • 서버만이 알고 있는 키(key)로 토큰을 서명(생성)함 → 토큰 위조 방지
      • 클라이언트는 보호된 리소스 요청 시 해당 토큰을 첨부
      • 서버는 토큰 유효성을 검증 → 유효하면 접근 허용
  3. 백엔드 가짜 API 동작 (데모 환경)

    • 회원가입 또는 로그인 시 JWT 토큰 발급
    • 일부 라우트(예: GET /events)는 인증 없이 접근 가능(공개 자원)
    • 일부 라우트(예: POST /events, PATCH /events/:id)는 인증 토큰 필요
    • 토큰이 없거나 유효하지 않으면 오류 응답을 보냄(401 Unauthorized 등)
  4. 리액트 앱(클라이언트)의 해야 할 일

    • 로그인 성공 시:
      • 서버에서 전달된 토큰을 브라우저/애플리케이션 어딘가에 저장(예: localStorage)
      • 이후 요청 시 Authorization 헤더 등에 토큰 첨부 → 서버에서 유효성 재확인
    • UI 처리:
      • 토큰이 존재(유효)하면 “로그인 상태” 표시, 로그아웃 버튼 노출 등
      • 로그아웃 시 토큰 제거 → 다시 인증 없는 상태
  5. 전체 개념

    • 로그인 폼에서 이메일/비밀번호 전송
    • 백엔드가 검증 후 JWT 토큰 생성, 클라이언트에 반환
    • 클라이언트가 토큰을 저장, 인증 필요 요청마다 헤더 등에 토큰을 첨부
    • 서버는 토큰 확인 → 유효 시 승인, 무효 시 오류

이로써 “클라이언트-서버 분리” 구조에서 인증 토큰을 활용해 인증을 구현할 수 있음.

[1] 클라이언트(리액트)에서 로그인 요청 (이메일/비밀번호)
  ↓
[2] 서버 (가짜 백엔드 API)  
    └─ 자격 증명 검증  
    └─ 성공 시 JWT 토큰 생성  
  ↓
[3] 클라이언트로 토큰 반환
  ↓
[4] 클라이언트가 토큰 저장 (localStorage 등)
  ↓
[5] 보호된 리소스 요청 시
    └─ 요청 헤더/파라미터에 토큰 첨부
  ↓
[6] 서버 (미들웨어 등)에서 토큰 유효성 검증
    └─ 유효 → 요청 처리 (데이터 반환, 수정, 삭제 등)
    └─ 무효 → 오류 (401 Unauthorized 등)

위 흐름을 통해 리액트 앱은 로그인 성공 후 받은 JWT 토큰을 계속 활용하여 인증이 필요한 요청을 수행하게 되고, 서버는 매 요청마다 토큰의 신뢰성을 검사해 안전하게 보호 자원을 관리할 수 있다.

334. 리액트 앱 인증 추가하기 / 프로젝트 및 라우트 설정

----- [예시 코드] 리액트 앱 인증 추가하기 -----

아래는 "인증( Auth ) 라우트 추가" 과정을 정리하고, 필요한 전체 폴더 구조와 타입스크립트 기반 예시 코드(생략 없이) 를 보여주는 예시입니다. 라우팅/인증과 관련된 핵심 포인트만 추려서, 가장 많이 사용되고 검증된 패턴으로 작성했습니다.

  1. 백엔드 API 서버: 별도 폴더(backend/)에 Node.js + Express 등을 사용해 동작한다. 이미 npm install 후 npm start로 구동 중이다.
  2. 프론트엔드(리액트) 프로젝트: frontend/ 폴더에서 개발 중이며, 여기에 React Router를 이용한 라우팅이 구성되어 있다.
  3. 인증 페이지 추가
    • AuthenticationPage 라는 새 컴포넌트를 만들었다고 가정.
    • App.tsx(혹은 라우팅을 전담하는 컴포넌트)에 /auth 경로를 새로 추가한다.
    • 메뉴(예: MainNavigation.tsx) 에도 링크를 하나 추가하여, 사용자들이 손쉽게 /auth 경로로 갈 수 있도록 한다.
  4. 라우트 구조
    • 루트 레이아웃(메뉴 등 공통 요소) 내부에 /auth 라우트를 형제 라우트로 추가한다.
    • 예: /, /events, /newsletter 등이 이미 있다면, 여기에 /auth도 같은 레벨로 추가.
  5. 진행 순서
    • 백엔드 서버(npm start) → 계속 실행 중
    • 프론트엔드 서버(npm start) → 라우팅 작성
    • localhost:3000/auth로 접속 → 새 인증 페이지 확인
    • MainNavigation에 링크 추가 → 메뉴에서 클릭 시 /auth로 이동
[사용자] --(브라우저에서 /auth 요청)--> [React Router: App.tsx]
      └─> (auth 라우트 확인) 
            │
            └─> [AuthenticationPage.tsx 컴포넌트 렌더링]
                  └─> 인증 폼 표시

[메인 메뉴: MainNavigation.tsx]
   └─> NavLink("/auth") 
        └─> 클릭 시 -> 브라우저 주소 /auth -> App.tsx -> AuthenticationPage.tsx

335. 리액트 앱 인증 추가하기 / 쿼리 매개변수 추가하기

아래 정리에서는 쿼리 매개변수(Search Params)를 사용하여 로그인/회원가입 모드를 전환하는 AuthForm 로직을 예시로 보여줍니다. 또한 React Router v7createBrowserRouter를 활용하여 라우트를 구성하는 전체 폴더 구조와 모든 파일을 TypeScript 기반으로 작성해 보았습니다.

  1. 페이지별 로그인/회원가입 모드 전환

    • 기존에는 useState를 써서 isLogin 상태를 토글했지만,
    • 쿼리 매개변수(예: ?mode=login 또는 ?mode=signup)로 모드를 관리하면 한 번에 해당 URL로 직접 연결할 수 있어 편리하다.
  2. 쿼리 매개변수 사용

    • useSearchParams 훅을 사용하여 현재 URLmode 파라미터 값을 읽는다.
    • mode 값이 'login'이면 로그인 모드, 그 외면 회원가입 모드로 간주한다.
  3. 링크 전환

    • 기존 버튼 대신 Link 또는 NavLink를 사용, 예: /auth?mode=signup / /auth?mode=login
    • 모드 전환 시 React Router가 쿼리 매개변수를 변경해 주고, AuthFormuseSearchParams로 모드를 읽어 적절한 UI를 보여준다.
  4. 라우팅 구성 (createBrowserRouter)

    • createBrowserRouter를 통해 /auth 라우트 생성.
    • AuthPage 컴포넌트에서 AuthForm을 렌더링하고, 쿼리 매개변수에 따라 로그인/회원가입 UI를 전환.
flowchart LR
    A[사용자: /auth?mode=login] -->|페이지 진입| B(AuthPage)
    B --> C(AuthForm)
    C -->|useSearchParams로 mode 읽기| D{mode==='login'?}
    D -->|Yes| E[로그인 UI]
    D -->|No| F[회원가입 UI]
    E -->|Link: to='?mode=signup'| C
    F -->|Link: to='?mode=login'| C
  • A: 사용자 /auth?mode=login 접근 시

  • B: AuthPage 컴포넌트 렌더링

  • C: AuthForm 안에서 useSearchParamsmode 파라미터 읽기

  • D: 조건 분기(로그인/회원가입 모드)

  • E, F: 각각의 UI 표시 후, 반대 모드로 전환하는 링크를 제공

  • 마무리

    • 이렇게 쿼리 매개변수를 사용하여 로그인/회원가입 모드를 전환하면:
      • URL 공유: /auth?mode=login / /auth?mode=signup 등으로 다른 사용자가 직접 들어올 수 있다.

      • 상태 대신 URL 파라미터: useState로 모드를 관리하던 방식을 보다 웹 친화적인 방식으로 대체할 수 있다.

        실제 백엔드 연동 로직(로그인/회원가입 요청)은 또 다른 액션 혹은 fetcher를 통해 처리하면 된다. 이 예시에서는 간단히 쿼리 파라미터를 읽어 UI만 전환하는 핵심 로직을 보여주었다.

이상으로 라우팅쿼리 매개변수를 이용한 모드 전환 예시를 모두 살펴보았습니다. 원하는 부분을 추가/수정하여 실제 회원가입·로그인 요청을 연동하면 쉽게 인증 기능을 완성할 수 있습니다.

  • 참고:
    • React Router v7+에서 createBrowserRouter 문법은 공식 문서를 확인하세요.
    • mode 외에도 여러 쿼리 파라미터를 다룰 수 있고, searchParams.set(...) 등을 통해 프로그래밍적으로도 업데이트 가능함.

react-12 프로젝트 코드 참고하면됨

336. 리액트 앱 인증 추가하기 / 인증 작업 실행하기

아래 정리는 인증 로직을 간단히 구현해 보는 예시입니다. 백엔드(Node.js + Express)에서 가입(회원 생성) 및 로그인 로직을 처리하고, 프론트엔드(React + react-router-dom v7)에서 AuthForm을 통해 가입/로그인을 요청합니다. 요청 시 중복 이메일 등 유효성 검증 오류가 발생하면 상태 코드(422 등)를 받아서 UI에 표시할 수 있도록 구현합니다.

  1. 백엔드(Node/Express) 구조

    • 라우트 /signup, /login 제공
    • 가입 시 이미 같은 이메일이 존재하면 오류(422) 응답
    • 가입에 성공하면 토큰 등 간단 정보를 JSON으로 반환 (실제로 저장은 users.json 같은 파일 활용)
    • 로그인 시 해당 이메일/비밀번호 검증 후 토큰 반환 (간단히 처리)
  2. 프론트엔드(React) 구조

    • AuthPage: 인증 페이지 컴포넌트, AuthForm 렌더
    • AuthForm: Form(react-router-dom) 통해 가입/로그인 전송
    • action(react-router-domdata submission): 전송된 폼 데이터를 받아 백엔드로 fetch 요청 → 결과(성공/오류)에 따라 UI 처리(리다이렉트 or 오류 데이터 반환)
    • 쿼리파라미터(mode=login or mode=signup)로 가입/로그인 모드 구분
  3. 오류 처리

    • 백엔드에서 상태 코드(422 등)를 응답하면 프론트 action에서 그대로 반환 → AuthFormaction data로 오류 메시지 표시 가능
    • 예: 이미 존재하는 이메일로 가입 시도 시 "이미 존재하는 이메일입니다" 같은 메시지 표시
flowchart LR
    A[AuthForm] -->|사용자 입력 제출 (POST /auth action)| B[authAction 함수]
    B -->|전송된 폼데이터| C{mode = ?}
    C -->|signup| D[fetch('/signup')]
    C -->|login| E[fetch('/login')]
    D -->|결과 JSON| F[백엔드: 이메일 중복검사 → OK or 422]
    E -->|결과 JSON| G[백엔드: 이메일/비번검사 → OK or 401/422]
    F -->|응답| H{응답 상태확인}
    G -->|응답| H
    H -->|정상(200 OK)| I[리다이렉트('/')]
    H -->|오류(422)| J[오류 데이터 반환]
    I -->|리다이렉트 후| K[HomePage]
    J -->|action data| A
    A -->|오류 메시지 표시| A
  • AuthForm: 가입/로그인 전송 (mode 파라미터로 구분)

  • authAction: 폼 데이터를 받아 백엔드 /signup or /loginfetch

  • 백엔드: 가입/로그인 로직 처리 → OK 시 토큰 등 반환, 오류 시 상태 코드(422 등) 반환

  • action: 상태 코드 확인 후 → 정상: redirect('/'), 오류: return response;

  • AuthForm: action data로 오류 메시지 표시

  • 정리

    • React Router의 Form + action으로 백엔드와 HTTP 통신:
      • 폼 제출 시 action 함수가 실행 → fetch → 백엔드 결과 처리
    • 백엔드는 /signup, /login 같은 라우트에서 가입/로그인 로직 처리 후 JSON 응답
    • 사용자 경험:
      • 가입/로그인 성공 시 → redirect('/')
      • 오류 시 → action이 그 response를 반환 → AuthForm에서 useActionData()로 에러 메시지 표시

이렇게 최신 React Router v7 기준으로 가입/로그인 과정을 예시로 구현할 수 있습니다. 실제 사용 시 비밀번호 해싱, 에러 처리(422, 401, 500 등), 토큰 저장/사용(로컬 스토리지 등) 로직을 추가하면 기본 인증 기능을 완성할 수 있습니다.

react-12 프로젝트 코드 참고하면됨

337. 리액트 앱 인증 추가하기 / 사용자 인풋 & 아웃풋 유효성 검증 오류 확인하기

아래는 React Router DOM v7를 사용하여,

  • 유효성 검증 오류와 인증 관련 오류를 useActionData / useNavigation 훅으로 처리하는

  • AuthForm 컴포넌트 및 전체 라우트 구성 예시 코드를 타입스크립트 기반으로 작성한 예시입니다.

  • useActionData

    • <Form method="post">로 전송된 데이터가 라우트의 action 함수에서 특정 형태(예: 오류 응답)로 반환될 때, 이를 받아 화면에 표시할 수 있게 해주는 훅입니다.
    • 보통 422(유효성 검증 실패), 401(인증 실패) 등의 상태 코드를 그대로 return하면, 그 응답을 useActionData()로 받아 UI에서 오류 메시지 등을 표시할 수 있습니다.
  • useNavigation

    • 현재 라우팅 동작(전환, 양식 제출 등)이 일어나는 중인지 등을 판단할 수 있게 해주는 훅입니다.
    • navigation.statesubmitting이면, 지금 양식을 서버로 전송 중임을 의미하므로 로딩 스피너나 버튼 비활성화 등의 UX 개선이 가능해집니다.
  • 유효성 검증 오류나 인증 관련 오류

    • 서버(백엔드)에서 422(유효하지 않은 입력값)나 401(로그인 실패) 등을 응답하면, 해당 응답을 그대로 return함으로써 액션 데이터로 전달합니다.
    • 컴포넌트에서 useActionData를 통해 오류 정보를 화면에 표시할 수 있습니다(예: data.errors, data.message 등).
  • 버튼의 전송 상태 표시

    • useNavigation을 써서 navigation.state === 'submitting'이면 Submitting..., 아니면 Save등으로 표시.
flowchart LR
    A[사용자<br>AuthForm에 입력] --> B(Form 전송)
    B --> C{라우트의<br>action함수}
    C -->|서버 fetch| D[백엔드]
    D -->|응답 OK<br>또는 오류| C
    C -->|OK: redirect("/")| F[홈으로 이동]
    C -->|오류(422/401)| E[return response]
    E -->|useActionData| G[AuthForm에서 오류 표시]
    C -->|기타 오류| H[throw new Response(500)]
  1. 사용자가 AuthForm에 이메일·비밀번호 입력 후 전송
  2. action 함수가 실행됨
  3. fetch로 백엔드 서버에 인증 요청
  4. 백엔드 응답(성공 시 OK, 실패 시 422/401 등)
  5. 성공이면 redirect("/") → 홈으로 이동
  6. 422/401인 경우 return response → 컴포넌트 useActionData로 오류 표시
  7. 그 외 서버 오류시 throw new Response(500)에러 라우트 처리
  • 요약

    1. AuthForm에서
      • useActionData()로 서버 응답(오류 메시지 등) 을 받는다.
      • useNavigation()으로 양식 전송 중인지 상태를 체크해 로딩 UI를 표시한다.
    2. 인증 action(authAction)에서
      • request.formData()로 폼 데이터를 받고, mode=login|signup 쿼리 파라미터를 파악해 백엔드로 fetch 전송.
      • 오류 상태코드(422, 401 등)는 그대로 return response → 프론트의 useActionData로 전송, 그 외 500 등은 throw new Response(...).
    3. 라우트 구성(App.tsx)에서 /auth 라우트에 action: authAction을 연결해,
      • <Form method="post">가 전송될 때 자동으로 인증 로직이 실행되도록 만든다.
  • 이와 같이 최신 React Router DOM v7 + TypeScript 환경에서, 유효성 검증 오류/인증 오류를 표시하고, 로딩 상태(제출 중)도 안내하는 인증 플로우를 구성할 수 있습니다.

  • 중요: 실제 백엔드 API에서 422, 401 응답을 올바르게 구현해야 useActionData로 오류 처리 로직이 정상 동작합니다.
  • 추가적으로 브라우저 콘솔에서 Network 탭 확인시, 전송된 fetch 요청의 Body, Header를 검수하며 문제없게끔 세팅하면 됩니다.

react-12 프로젝트 코드 참고하면됨

338. 리액트 앱 인증 추가하기 / 사용자 로그인 추가하기

아래는 로그인 및 오류 페이지(ErrorPage)를 처리하는 예시를 간단히 정리한 코드 예시입니다.

  • 이미 로그인(인증) 프로세스가 작동하고 있고, 인증 토큰은 다음 단계에서 자세히 다룬다고 가정합니다.
  • 여기서는 오류 발생 시 ErrorPage를 표시하도록 React Router DOM v7에서 지원하는 errorElement를 활용합니다.
  1. 로그인

    • 이미 구현되어 있고, 가입/로그인 라우트로 백엔드에 POST 요청을 보내 자격 증명을 확인합니다.
    • 올바른 자격 증명 시 리디렉트 → 로그인 완료.
    • 잘못된 자격 증명 시 오류 상태(401/422) → useActionData로 오류 메시지 표시.
  2. 오류 페이지

    • createBrowserRouter 사용 시, 상위 라우트 혹은 특정 라우트에 errorElement를 지정할 수 있습니다.
    • 예컨대, 백엔드 호출 등에서 throw new Response(..., { status: 500 }) 처리를 하면, 가장 가까운 errorElement가 렌더링됩니다.
  3. 메인 네비게이션(MainNavigation) 포함 문제

    • 만약 오류 상황에서도 네비게이션을 유지하고 싶다면, ErrorPage 내부에 MainNavigation을 포함하되, 에러 상태에서 사용 불가능한 기능이 있다면 조건부 렌더링하는 식으로 처리 가능합니다.
    • 혹은 별도의 상위 레이아웃(루트 레이아웃)에 errorElement만 할당해서, 오류 시 네비게이션 포함된 페이지를 렌더링해도 됩니다.
flowchart LR
    A[사용자 입력<br>(Login or Signup)] --> B(Form 전송)
    B -->|action 함수| C[백엔드 요청<br>(fetch)]
    C --> D{응답}
    D -->|성공| E[리다이렉트<br>/]
    D -->|오류| F[throw Response()]
    F -->|라우터 감지| G[ErrorPage<br>렌더링]
  1. 사용자 인증 폼 전송
  2. action 함수에서 백엔드에 인증 요청
  3. 성공 시: redirect("/") → 홈 이동
  4. 오류 시: throw new Response(...) → 라우터가 errorElement로 이동 → ErrorPage 표시
  • 요약
    1. 로그인/가입:
      • 이미 백엔드 가입(login/signup) 라우트와 연동
      • 잘못된 자격 증명 시 422/401 응답 → useActionData로 오류 메시지 표시
      • 정상 처리 시 redirect('/')로 홈 이동
    2. 오류 페이지:
      • App.tsx에서 errorElement: <ErrorPage /> 설정
      • throw new Response(...) 발생 시 자동으로 ErrorPage 렌더링
      • useRouteError로 오류 객체 접근
    3. 코드 구조:
      • AuthForm + authAction(백엔드 호출) + ErrorPage
      • 라우터는 createBrowserRouter + RouterProvider
      • 루트 레이아웃(RootLayout) 아래 자식 라우트로 /auth, / 등 구성
    4. 토큰 처리는 다음 단계에서 구현한다 가정.

이렇게 정리하면, **오류 발생 시 ErrorPage**가 표시되고, 인증 로직은 기존대로 작동하며, 오류 응답은 useActionData + throw Response로 처리하게 됩니다.

react-12 프로젝트 코드 참고하면됨

339. 리액트 앱 인증 추가하기 / 내보내는 요청에 인증 토큰 첨부하기

아래는 "로그인/회원가입 시 받은 토큰을 로컬 스토리지에 저장하고, 보호된 API 요청에 토큰을 첨부하는 과정을 React Router(최신 v7) 기반으로 정리한 예시입니다.

  • 목표 요약

    1. 인증(회원가입/로그인) 후 백엔드 응답에서 토큰(token)을 추출하여 로컬 스토리지(localStorage) 에 저장
    2. 이벤트 생성/편집/삭제 등 보호된 라우트로의 요청 시, Authorization 헤더에 Bearer <token> 형태로 토큰 첨부
  • 전체 요약 설명

    1. 회원가입 or 로그인

      • 사용자 이메일·비밀번호를 입력 후 폼 전송
      • 백엔드에서 유효성 검증 → 성공 시 토큰을 응답으로 반환
    2. 토큰 저장

      • 응답에서 response.json()으로 데이터(토큰 등)를 받음
      • 받은 토큰을 localStorage.setItem('token', <받은 토큰>)으로 저장
    3. 토큰 활용

      • 보호된 백엔드 라우트(예: 이벤트 생성/편집/삭제)로 요청을 보낼 때,
      • localStorage.getItem('token')으로 토큰 조회
      • 헤더 Authorization: Bearer <token> 형태로 첨부해 fetch 요청
      • 백엔드에서 토큰을 검증하여 올바른 사용자/권한이면 요청 성공
[1] 사용자: 폼 제출 (로그인 or 가입)
   ↓
[2] AuthPage.action() (React Router의 action)
   - (이메일/비번) -> fetch -> 백엔드('/login' or '/signup')
   ↓
[3] 백엔드: 토큰 생성 후 JSON 응답 { token: "...", ... }
   ↓
[4] AuthPage.action() 내부에서 응답 JSON 파싱
   - 로컬 스토리지에 token 저장
   ↓
[5] 다른 요청(이벤트 편집/삭제/생성 action):
   - localStorage에서 token 읽어옴
   - fetch 시, 헤더에 "Authorization: Bearer <token>"
   ↓
[6] 백엔드: 토큰 검증 → 보호된 리소스 접근 허용 or 오류
  • 마무리
    1. 회원가입/로그인:

      • 백엔드에서 응답으로 토큰을 주면, 프런트가 localStorage.setItem('token', <토큰>) 저장
    2. 보호된 요청:

      • 헤더 Authorization: Bearer <토큰> 형태로 전송
    3. React Router v7 구조:

      • createBrowserRouter + RouterProvider
      • errorElement로 오류 처리 페이지
      • action에서 fetch로 백엔드 호출, 예외 시 new Response(...) 등 사용

이로써 인증 토큰을 활용하여 보호된 리소스에 접근하는 React Router + 백엔드 API 구조가 완성됩니다.

react-12 프로젝트 코드 참고하면됨

340. 리액트 앱 인증 추가하기 / 사용자 로그아웃 추가하기

아래 정리는 로그아웃(토큰 제거) 기능을 추가하고, UI에서 로그인 상태에 따라 표시되는 메뉴를 달리하는 흐름을 예시로 보여줍니다. 이미 이전 단계(회원가입, 로그인, 토큰 저장/전송 등)가 완료된 상태에서, 로그아웃 라우트와 메뉴 표시 로직만 새롭게 추가한다고 생각하시면 됩니다.

  1. 로그아웃 작업(액션)과 라우트를 추가합니다.

    • Logout.tsx(또는 LogoutPage.tsx) 파일을 만들고 컴포넌트 없이 작업 함수만 export 합니다.
    • 작업 함수(logoutAction) 안에서는 로컬 저장소에서 토큰을 제거하고, redirect('/')로 메인 페이지로 보냅니다.
    • 라우터 설정에서 path: 'logout'action: logoutAction을 연결해 줍니다.
  2. 메인 메뉴(MainNavigation)에서 로그아웃 버튼을 누르면, 해당 라우트로 POST 전송되도록 설정합니다.

    • Form 컴포넌트를 사용해 action="/logout", method="post" 형태로 POST 전송합니다.
    • 버튼을 <button type="submit">Logout</button> 형식으로 두면 클릭 시 액션을 호출합니다.
  3. UI 표시:

    • 사용자(토큰)의 존재 여부에 따라 메뉴를 달리 표시하도록 합니다.
    • 예: getAuthToken()으로 토큰이 있으면 “로그인/회원가입” 대신 “로그아웃”을 표시하고, 없는 경우는 반대로 처리.
[클라이언트(브라우저)] --(POST /logout)--> [로그아웃 라우트 액션 (logoutAction)]
      logoutAction에서 localStorage.removeItem('token')
                 ↓
            redirect('/')
                 ↓
[클라이언트는 / 로 이동, 토큰 없는 상태로 UI 재렌더링]
  1. 사용자가 MainNavigationLogout 버튼 클릭
  2. Form/logout으로 POST 전송
  3. logoutAction 함수가 실행되어 토큰 제거 후 리다이렉트 반환
  4. 클라이언트가 / 경로로 리다이렉트되어 토큰이 없는 상태로 렌더링
  5. 로그인 상태 UI → 미로그인 상태 UI로 변경
  • 마무리
    • 토큰 관리: 로그인 성공 시 localStorage.setItem('token', yourToken) / 로그아웃 시 removeItem('token').
    • UI 분기: getAuthToken() 결과에 따라 메뉴 표시.
    • 메인 메뉴: Form으로 로그아웃을 post 전송하면 logoutAction이 실행되어 토큰 삭제 → 리디렉트.

react-12 프로젝트 코드 참고하면됨

341. 리액트 앱 인증 추가하기 / 인증 상태에 따라 UI 업데이트하기

아래 예시 코드는 이미 공유해 주신 코드 기반에서, "토큰 유무를 루트 라우트의 로더로 관리" 하여 모든 페이지(하위 라우트)에서 useRouteLoaderData로 가져올 수 있게끔 수정하는 방법을 정리해본 것입니다. 즉, 토큰 상태를 루트 레이아웃(라우트)에 두고 변경 시 자동으로 UI를 갱신하는 패턴입니다.

  • 다른 컴포넌트/페이지에서 토큰 사용 예시

    • EventDetailPage(또는 EventForm 등)에서도 편집/삭제 버튼 노출 시:
      • useRouteLoaderData('root') as string | null를 사용하여 토큰 유무 확인 가능
      • 토큰이 없으면 버튼 숨김 or disabled 처리
    • EventsNavigation 컴포넌트가 있다면, 마찬가지로 useRouteLoaderData('root')로 토큰을 얻고, 로그인 여부에 따라 UI 구성
  • 다른 파일(생략, 기존 내용 동일)

    • AuthenticationPage.tsx, authAction 등은 기존과 같으며, 토큰 저장(localStorage.setItem('token', token)) 이후 루트 라우트가 재평가되어 UI가 갱신됨
    • LogoutPage.tsx에서 토큰 removeItem('token') → 라우터가 다시 로더 실행 → tokenLoader가 null 리턴 → UI 재렌더링 (로그인 메뉴 보이고 Logout 메뉴 사라짐)
  • 정리

    1. 루트 라우트에 id와 loader(tokenLoader)를 설정한다.
    2. tokenLoader 내부에서 로컬 스토리지에서 토큰을 가져와 반환
    3. 하위 라우트(및 컴포넌트)에서 useRouteLoaderData('root')로 언제든 토큰 상태를 조회
    4. 로그인 시에는 authAction에서 토큰을 로컬 스토리지에 저장 → 라우터가 재평가 → tokenLoader 재실행 → 토큰 존재 시 UI 업데이트
    5. 로그아웃 시에는 logoutAction에서 localStorage.removeItem('token')redirecttokenLoader에서 null을 리턴 → UI 재평가

이 방식으로 로그인/로그아웃 시 UI 자동 갱신을 처리하고, 중앙 집중 관리하는 효과를 얻을 수 있습니다.

react-12 프로젝트 코드 참고하면됨

342. 리액트 앱 인증 추가하기 / loader()가 반드시 null 또는 기타 다른 값을 리턴해야 합니다

중요: 여기서 저는 특정한 상황에서 값을 리턴하지 않는 라우트 로더를 설정했습니다.

여러분은 오류를 피하기 위해, 달리 아무것도 리턴하지 않는 모든 if 구문에 return null 구문을 추가하셔야 합니다.
정확히 말씀 드리자면, 다음 강의에서 추가할 checkAuthLoader() 함수의 if 구문 뒤에 return null 을 추가하셔야 합니다:

export function checkAuthLoader() {
  // 이 함수는 다음 공부에서 추가될 것입니다.
  // 최종적으로 이런 모습이 되도록 하십시오.
  const token = getAuthToken();
  
  if (!token) {
    return redirect('/auth');
  }
 
  return null; // 이 부분은 다음 공부에서 빠져 있고, 여러분이 추가하셔야 합니다.

343. 리액트 앱 인증 추가하기 / 라우트 보호 추가하기

아래 예시 코드는 이미 공유해 주신 코드 기반에서, "특정 라우트를 토큰(로그인) 여부에 따라 보호" 하는 로직을 추가하는 방식입니다. 즉, 토큰이 없으면 해당 라우트에 접근 시 자동으로 /auth 페이지로 리디렉션 하도록 로더(loader) 를 붙이는 것이 핵심입니다.

  • 흐름 (데이터 흐름 도식)

    1. 사용자가 /events/new(or :eventId/edit) 라우트 접속
    2. React Router가 checkAuthLoader 실행
    3. checkAuthLoader:
      • getAuthToken()으로 로컬스토리지의 토큰 검사
      • 토큰이 없으면 redirect('/auth?mode=login')
      • 토큰이 있으면 return null
    4. 토큰이 없으면 → redirect 되어 로그인 페이지로 이동
    5. 토큰이 있으면 → 해당 페이지(NewEventPage 또는 EditEventPage)를 렌더링
    6. 페이지에서 폼 전송 시 → newEventAction or editEventAction도 토큰 확인(헤더에 첨부)
  • 정리

    1. "보호가 필요한 라우트"(로그인 없이 접근 불가)를 loader로 방어 (checkAuthLoader)
    2. loader에서 토큰이 없으면 redirect('/auth') → 실질적 라우트 접근 불가
    3. tokenLoader(루트 라우트) + useRouteLoaderData('root') → “로그인 상태” UI 관리
    4. checkAuthLoader(특정 서브 라우트) → “실제 페이지 접근” 방어

이로써 로그인하지 않은 사용자가 직접 URL 입력으로 이동하려 해도, 실패(리디렉션)하게 되어 보호 라우트가 완성됩니다.

react-12 프로젝트 코드 참고하면됨

344. 리액트 앱 인증 추가하기 / 자동 로그아웃 추가하기

아래 예시 코드는 이미 공유해주신 코드 기반에서, 한 시간 후 토큰이 만료되도록 구현하는 흐름을 보여줍니다. 핵심은 루트 레이아웃(예: AppLayout)에서 토큰이 존재하면 1시간 타이머를 걸어 만료 시 로그아웃 라우트(POST /logout)를 자동 전송하는 것입니다.

  • 데이터 흐름 (도식)

    1. 앱 실행 시 루트 라우트 → tokenLoader 호출
      • 로컬 스토리지에서 토큰 읽어옴
      • AppLayout 컴포넌트에서 useLoaderData()로 토큰 획득
    2. AppLayoutuseEffect: 토큰이 있으면 1시간 setTimeout → 만료 시 submit(null, {method: 'post', action: '/logout'})
    3. 로그아웃 라우트(POST /logout) → 토큰 삭제 → UI 자동 갱신
    4. checkAuthLoader: 토큰 필요 라우트 접근 시 토큰이 없으면 /auth로 강제 이동
  • 정리

    • 한 시간 뒤 토큰 만료:
      • 루트 레이아웃에서 토큰을 감시 및 useEffect로 타이머 설정
      • 1시간 경과 시 자동 로그아웃(토큰 삭제 → UI 반영)
    • 라우트 보호:
      • checkAuthLoader → 토큰 없으면 /auth로 리디렉션
      • tokenLoader + 루트 레이아웃 + useEffect → "자동 만료" & "UI 반영"

이로써 실용적으로 "로그인 상태 유지 + 토큰 만료 시 자동 로그아웃 + 보호 라우트 접근 차단" 과정을 완성할 수 있습니다.

react-12 프로젝트 코드 참고하면됨

345. 리액트 앱 인증 추가하기 / 토큰 만료 관리하기

아래 예시 코드는 이미 공유해주신 코드에 토큰 만료 시점을 반영하여 자동 로그아웃을 처리하는 과정을 추가한 것입니다.

  • 핵심 아이디어: 토큰과 “토큰 만료 시점(만료 시간)”을 함께 로컬 스토리지에 저장하고,

    • 토큰이 만료되었으면 즉시 로그아웃
    • 토큰이 아직 유효하면 남은 시간만큼만 타이머를 걸어 자동 로그아웃
  • 데이터 흐름 (도식)

    1. 로그인 성공 시:
      • 토큰 + 만료시간(현재 시각 +1시간) → localStorage 저장
    2. 루트 라우트(AppLayout)에서 loadertokenLoader()getAuthToken() (토큰 + 만료 여부)
    3. AppLayout useEffect:
      • 토큰 = "EXPIRED" → 즉시 /logout 전송
      • 토큰이 유효 → (만료시간 - 현재시간) 밀리초만큼 setTimeout → 만료시 /logout
    4. 로그아웃(logoutAction) → 토큰/만료시간 삭제 → 메인으로 Redirect
    5. checkAuthLoader: "보호" 라우트 접근 시, 토큰이 없거나 만료면 redirect('/auth')
  • 정리

    • 토큰 만료 시간(1시간)을 로컬 저장소에 같이 저장
    • getAuthToken에서 만료 여부를 체크해 EXPIRED 판단
    • 루트 레이아웃에서 토큰과 만료 시간을 잔여 시간으로 계산해 1시간이 아닌 남은 시간만큼만 setTimeout 후 자동 로그아웃
    • 로그아웃 시 토큰/만료시간 모두 제거
    • 보호 라우트에는 checkAuthLoader를 등록해 토큰 없으면 redirect('/auth')
    • 자동 로그아웃, 수동 로그아웃, 보호 라우트 접근 차단 등 완성

이로써 실제 토큰 만료 시나리오(1시간 중간에 새로고침해도 시간이 재설정되지 않고 "남은 시간"만큼만 유지)를 구현할 수 있으며, 인증 로직이 더욱 현실적으로 개선됩니다.

react-12 프로젝트 코드 참고하면됨

346. 리액트 앱 배포하기 / 소개

  1. 로컬 개발 → 실제 서버

    • 개발 환경에서는 로컬 머신(내 PC)에서 프로젝트를 실행하고 테스트한다.
    • 프로덕션(배포) 환경으로 넘어갈 때는, 완성된 앱을 전 세계 어느 사용자든 접속 가능하도록 온라인 서버에 올린다.
  2. 빌드(Build)

    • 리액트 애플리케이션을 배포하기 전에, 보통 npm run build(혹은 yarn build) 명령으로 최적화된 정적 파일(HTML, JS, CSS 등) 묶음을 만든다.
    • 빌드 결과물은 배포용 파일이며, 이를 웹 서버에 올려 브라우저가 다운받아 해석하도록 한다.
  3. 서버 구성

    • 실제로 웹에 올리려면 호스팅(서버 제공)이 필요하다. 예:
      • Netlify, Vercel, GitHub Pages 등 클라우드 호스팅 서비스
      • AWS, GCP, Azure와 같은 클라우드 플랫폼
      • 개인 서버(VPS 등)에 설정
    • 빌드된 정적 파일을 이 서버에 업로드하면 사용자가 https://내도메인/ 등으로 접속할 수 있다.
  4. 서버 측 라우팅 vs. 클라이언트 측 라우팅

    • 서버 측 라우팅:
      • 전통적인 웹사이트에서 URL이 바뀔 때마다 서버로 요청을 보내고 서버가 해당 페이지(HTML)을 내려준다.
    • 클라이언트 측 라우팅(리액트 라우터):
      • 서버에서 기본 HTML + JS를 한 번 내려받은 뒤, JavaScript(리액트 라우터)가 URL 변화를 감지하고, 컴포넌트만 갈아끼워서 화면을 바꾼다.
      • 배포 시, 서버가 존재하지 않는 라우트를 모두 index.html로 리다이렉트하도록 설정해야 “새로고침 시 404 오류”를 예방할 수 있다.
  5. 배포 과정의 일반 흐름

    1. 코드 작성 & 로컬 테스트
    2. 프로덕션 빌드: npm run build
    3. 결과물(정적 파일) 업로드
      • Netlify/Vercel 등 자동 배포를 지원하는 서비스
      • 혹은 직접 서버에 업로드
    4. 도메인 연결 (원한다면)
    5. 라우팅 설정(서버에서 모든 경로를 index.html로 돌리는 설정 등)
[로컬 개발]
   │
   ▼
(소스 코드 작성/수정)
   │
   ▼
(npm run build) → [정적 빌드 결과물 (HTML/JS/CSS)]
   │
   ▼
[호스팅 서버]
(업로드 & 라우팅설정)
   │
   ▼
사용자가 브라우저로 접속 
(https://내도메인)
   │
   ▼
(서버 측, 정적 파일 서빙)
   │
   ▼
클라이언트 측 라우팅 (리액트가 동작)
  • 개발: 로컬에서 React 코드를 작성 → npm start로 확인
  • 빌드: npm run build로 최적화된 배포용 파일 생성
  • 업로드: 호스팅 서버에 빌드 결과물 배치
  • 실행: 사용자는 웹사이트 URL로 접속 → 서버는 정적 파일 제공 → 리액트 앱 실행 → 라우터 동작

이로써 리액트 애플리케이션을 로컬에서 실제 프로덕션 서버로 배포할 때 거치는 과정을 간략히 정리했다. 특히 라우팅 설정에서, SPA 특성을 고려해 404 설정(모든 경로를 index.html로 향하게)이 필요하다는 점이 핵심이다.

347. 리액트 앱 배포하기 / 배포 과정

아래는 리액트 애플리케이션 배포 과정에 관한 핵심 내용을 한글로 정리한 요약본이며, 전체적인 흐름을 이해하기 위한 데이터 흐름 도식도 포함했습니다.

  1. 코드 작성 및 철저한 테스트

    • 기능 개발, 오류 처리, UI를 꼼꼼히 확인해보며 애플리케이션을 점검한다.
    • 실제로 사용자에게 공개(배포)하기 전까지 모든 주요 기능을 사전에 잘 동작하는지 확인한다.
  2. 코드 최적화(지연 로딩 등)

    • 지연 로딩(Lazy Loading), 불필요한 코드 제거 등으로 페이지 로딩 속도를 높인다.
    • 코드가 작을수록 웹사이트(리액트 앱) 로딩이 빨라, 사용자 경험이 좋아진다.
  3. 프로덕션용 빌드(Production Build)

    • 개발용 서버(npm start)가 아닌, 배포용으로 최적화된 정적 파일(HTML/JS/CSS 등)을 생성한다.
    • 통상 npm run build 명령어가 제공되며, 내부적으로 Webpack/Vite 등이 코드 번들링 및 최적화를 수행한다.
  4. 서버에 업로드(호스팅)

    • 빌드된 정적 파일들을 서버나 호스팅 플랫폼(예: Netlify, Vercel, AWS, GitHub Pages 등)에 업로드한다.
    • 어떤 호스팅을 사용하든, 결국은 생성된 빌드 폴더(예: build/ or dist/)를 업로드하여 사용자가 도메인으로 접근할 수 있게 한다.
  5. 서버/도메인 설정

    • 서버나 호스팅 제공자의 설정 과정을 거치면서, 배포한 파일들이 브라우저를 통해 정상적으로 열리는지 확인한다.
    • 클라이언트 사이드 라우팅(리액트 라우터) 시, 404 설정(모든 경로 index.html로 라우팅 등)을 적용해야 SPA가 올바르게 동작한다.
[1. 코드 작성 & 테스트]
       ↓
(기능 개발, 오류 처리, UI 확인)

//----------------------------------------------

[2. 코드 최적화]
       ↓
(지연 로딩, 코드 크기 감소)

//----------------------------------------------

[3. 프로덕션 빌드]
       ↓
(npm run build → dist/ or build/ 폴더)

//----------------------------------------------

[4. 서버 업로드(호스팅)]
       ↓
(정적 파일을 특정 호스팅 제공자/서버에 업로드)

//----------------------------------------------

[5. 실제 배포 & 설정]
       ↓
(도메인 연결, 404 설정, etc.)

//----------------------------------------------

사용자는 https://내도메인 접근
 → 서버에서 빌드된 정적 파일 전달
 → 브라우저가 리액트 앱 로딩
  • 개발 단계: 기능 완성, 테스트, 최적화
  • 빌드 단계: npm run build로 최적화된 패키지 생성
  • 배포 단계: 호스팅(서버) 선택 → 빌드 파일 업로드 → (도메인 연결)
  • 결과: 사용자가 웹사이트(리액트 앱)에 접속해 서비스 이용

이처럼 코드 작성 → 최적화 → 프로덕션 빌드 → 서버 업로드 → 도메인/설정 순으로 진행하면, 최종적으로 전 세계 사용자들에게 최적화된 리액트 애플리케이션을 제공할 수 있다.

348. 리액트 앱 배포하기 / 지연 로딩 이해하기

아래는 지연 로딩(Lazy Loading) 개념과 적용 이유를 정리한 내용이며, 이에 관한 데이터 흐름 도식과 간단한 타입스크립트 기반 예시 코드를 포함합니다. 지연 로딩은 큰 규모의 리액트 애플리케이션에서 초기 로딩 속도를 개선하는 핵심 테크닉입니다.

  1. 지연 로딩(Lazy Loading)이란?

    • 필요한 시점(사용자가 특정 페이지나 컴포넌트를 요청할 때)에만 해당 컴포넌트/코드 청크(chunk)를 동적으로 로딩하는 기법.
    • 초기에 모든 코드를 한꺼번에 다운로드하지 않음으로써 첫 화면 로딩 속도를 높이고, 사용자 경험(UX)을 개선한다.
  2. 왜 필요한가?

    • 대규모 프로젝트(수많은 라우트/컴포넌트/라이브러리)에서는 초기 로딩 시 모든 코드를 한 번에 다운받으면 불필요한 대기가 길어진다.
    • 사용자 입장에서는 지금 당장 보지 않는 페이지의 코드까지 로딩할 이유가 없으므로, 초기에 필요한 코드만 내려받으면 로딩 시간을 단축할 수 있다.
  3. 작동 방식

    • 특정 컴포넌트를 import() 구문과 함께 동적 import 형태로 감싼 뒤, ReactSuspense & lazy API를 사용해 지연 로딩을 구현한다.
    • 사용자가 해당 컴포넌트를 필요로 할 때(라우트 이동, 버튼 클릭 등) 실제 코드 청크가 불러와짐 → 필요한 시점에서만 JS를 다운로드.
  4. 장점과 주의점

    • 장점: 초기 번들(코드) 크기 감소, 첫 화면 렌더링 빨라짐, UX 개선.
    • 주의점:
      • 지연 로딩 시, 컴포넌트가 로딩되는 동안 대체 UI(Spinner 등)를 제공해야 자연스러운 사용자 경험을 유지할 수 있다.
      • 너무 많은 컴포넌트를 잘게 쪼개도 오히려 관리 비용이 늘어날 수 있음. 주요 화면 단위로 의미 있는 분할을 하는 게 좋다.
  5. 적용 시나리오

    • 라우팅을 통한 페이지/뷰 단위의 지연 로딩 (가장 흔함)
    • 특정 무거운 라이브러리(에디터, 차트 등)나, 흔히 사용되지 않는 컴포넌트를 동적으로 로딩
[사용자: 사이트 접속]
       ↓
[초기 로딩 시]
    - React SPA 초기 코드(필수 부분)만 다운로드
    - lazy 로딩 설정된 컴포넌트는 아직 불러오지 않음

//----------------------------------------------

// (사용자가 특정 페이지/컴포넌트를 요청)

[라우트 이동 or 함수 실행 시]
       ↓
[lazy 로딩된 컴포넌트 필요]
       ↓
동적 import()로 해당 청크 다운로드
       ↓
React Suspense 대체 UI → 실제 컴포넌트 로딩 완료
       ↓
[화면에 컴포넌트 렌더]
  • 핵심: “필요할 때만 코드 청크를 내려받음”

  • 이점: 초기 로딩 속도 단축, 사용자에게 빠른 첫 화면 제공

  • 정리

    • 지연 로딩은 코드 스플리팅(code splitting)과 함께 리액트 앱 최적화를 위한 핵심 기법이다.
    • 애플리케이션 초기 로딩 시 필요한 코드만 먼저 다운로드하고, 특정 페이지(라우트)나 기능을 사용자에게 보여줄 때만 lazy 로딩을 통해 나머지 코드를 가져온다.
    • ReactlazySuspense 컴포넌트를 제공해 구현이 간단하다.
    • 규모가 큰 프로젝트일수록 초기 로딩 속도가 중요해지므로, 적절한 지연 로딩 전략을 적용해 사용자 경험을 개선할 수 있다.

react-12 프로젝트 코드 참고하면됨

349. 리액트 앱 배포하기 / 지연 로딩 추가하기

아래는 지연 로딩(Lazy Loading)을 통해 페이지(또는 컴포넌트)와 loader를 모두 동적으로 불러오는 방법에 대해 정리한 내용입니다. 먼저 개념과 과정을 정리하고, 이어서 데이터 흐름 도식과 타입스크립트 기반 예시 코드(react-router-dom v7 기준)를 제공합니다.

  1. 지연 로딩이란?

    • 사용자가 특정 페이지나 컴포넌트에 접근할 때 그 코드를 동적으로(import) 로딩하는 기법.
    • 초기 로딩 시 모든 코드를 다운로드하는 대신, 필요한 순간에만 코드를 가져와 앱의 첫 화면 로딩을 빠르게 하고 사용자 경험을 개선한다.
  2. 왜 지연 로딩과 loader까지 동적으로 로딩하나?

    • 일반적으로 라우팅 시, 컴포넌트와 함께 데이터 로딩을 위한 loader(리액트 라우터에서 제공)도 동시에 import된다.
    • 지연 로딩을 적용하지 않으면, 해당 라우트(페이지)에 진입 전이라도 코드와 loader가 미리 로드되어 번들 크기가 커질 수 있다.
    • loader 역시 “필요할 때만” import하면, 라우트 진입 전에는 loader 코드도 다운로드되지 않는다.
  3. 구현 핵심

    • (a) 컴포넌트: React.lazy(() => import('./...'))<Suspense fallback={<...}>를 함께 사용.
    • (b) loader: 기존 import가 아닌, 동적 import(import() 함수)loader 코드를 가져오고, 거기서 exportloader 함수를 호출해 반환하도록 만듦.
    • 이런 식으로 라우트 정의에서 loaderelement를 동시에 지연 로딩할 수 있다.
  4. 주요 단계

    • (1) BlogPage & loader 등에서 기존 import BlogPage, import { loader }를 삭제.
    • (2) Lazy 로딩용 함수(예: lazy(() => import('./pages/Blog')))를 만들어 컴포넌트를 동적 import.
    • (3) loader에는 async function() 형태로 작성한 뒤, import('./pages/Blog').then(...)을 통해 module.loader(...)를 호출해 동적으로 반환.
    • (4) 라우트 설정에서 <Suspense>로 컴포넌트를 감싸거나 fallback을 지정해 로딩 표시.
    • (5) 실제로 페이지 접근 시(라우트 진입 시) 해당 JS 청크가 동적 다운로드됨.
  5. 장점 / 주의점

    • 장점: 큰 애플리케이션에서 초기 번들 크기 감소, 첫 화면 로딩 속도 단축, 필요한 로직만 요청 시 다운로드.
    • 주의점:
      • Suspense 컴포넌트를 사용해 로딩 중 대체 UI를 제공해야 자연스럽다.
      • 코드 분할이 지나치면 관리 복잡도가 커질 수 있으므로 의미 있는 단위로 나누는 것이 바람직하다.
(사용자)                               (브라우저)
   |                                        |
   | 1) "/blog" 라우트 진입                |
   |--------------------------------------->|  
   |                                        |
   |      [라우트 설정]                    |
   |      - element: lazy(() => import(...))
   |      - loader: () => import(...).then(...) 
   |                                        |
   | 2) 브라우저가 "Blog" 컴포넌트 & loader 코드 동적 import
   |                                        |
   | <Suspense fallback="로딩중...">        |
   |   Blog 컴포넌트 로딩 완료 후 렌더링     |
   | </Suspense>                            |
   |--------------------------------------->|
   |               화면 갱신                |
   V
  • 핵심: 특정 라우트(‘/blog’)로 접근하면, 그때서야 Blog 컴포넌트와 loader가 동적 import로 로딩된다.

  • 요약

    • 지연 로딩(Lazy Loading) 을 통해 컴포넌트와 loader를 필요 시점에만 동적 import한다.
    • react-router-dom v7에서도 lazy()와 import() 함수를 조합해 element와 loader 둘 다를 지연 로딩할 수 있다.
    • 큰 애플리케이션에서 초기 로딩 속도를 향상하고 사용자 경험(UX)을 개선하는 주요 테크닉이다.
    • <Suspense fallback={<...>}>를 사용해 로딩 중 메시지나 스피너 등을 표시하면 자연스러운 UX를 제공할 수 있다.

react-12 프로젝트 코드 참고하면됨

350. 리액트 앱 배포하기 / 프로덕션용 코드 빌드하기

아래 내용은 "리액트 애플리케이션을 프로덕션 빌드(build)하여 서버에 업로드하기 위한 과정"을 한눈에 보기 좋게 정리한 것입니다.

  1. 개발 코드와 프로덕션 코드의 차이

    • 현재 “개발 환경”에서 작성하는 코드는 JSX, 최신 문법, 개발 서버의 실시간 변환 등 브라우저가 직접 해석할 수 없는 요소들이 포함됨.
    • 개발 서버(npm start)는 실시간 변환을 통해 우리가 작성하는 코드를 미리보기용으로 제공하지만, 최종적으로 배포할 때는 브라우저가 직접 해석 가능한 최적화된 코드를 만들어야 함.
  2. 프로덕션 빌드의 필요성

    • 브라우저가 바로 실행 가능한 코드로 변환해야 함 (JSX → JS).
    • 파일 크기를 최소화하고(압축·최적화), 지연 로딩 코드도 실제로 번들링해 필요한 시점에 분할 로딩 가능하도록 구성해야 함.
    • 일반적으로 npm run build (혹은 yarn build) 스크립트를 통해 수행.
  3. 프로덕션 빌드의 결과물

    • 프로젝트 루트에 build/ 폴더(또는 dist/)가 생성됨.
    • 해당 폴더에 index.html, 정적 자바스크립트·CSS 파일들이 최적화된 형태로 들어 있음.
    • 이 폴더 안의 내용물을 웹 서버에 업로드하면 최종 사용자가 접근 가능.
  4. 서버 업로드(배포) 단계

    • 개발 서버를 종료한 후, npm run build 명령어 실행 → build/ 폴더 생성.
    • build/ 폴더의 정적 파일을 원하는 호스팅(예: Netlify, Vercel, AWS S3+CloudFront 등)에 업로드.
    • 서버 설정(예: SPA 라우팅 처리, 404 fallback 등)을 마치면 사용자가 실제 도메인을 통해 최적화된 React 앱에 접속 가능.
flowchart LR
    A(개발 중<br>코드 작성) -->|npm start<br>(개발 서버)| B(브라우저에서<br>실시간 확인)
    A -->|npm run build<br>(프로덕션 빌드)| C(build/ 폴더 생성)
    C -->|서버 업로드| D(실제 서버<br>또는 호스팅)
    D --> E(사용자 접속<br>최적화된 코드를<br>브라우저에 전달)
  • A: 개발자가 JSX 등 최신 문법으로 코드 작성 (개발 코드)

  • B: 개발 서버를 통해 브라우저가 변환 결과를 실시간 확인

  • C: 프로덕션 빌드 명령어 실행 → 최적화된 정적 파일을 build/ 폴더에 생성

  • D: build/ 폴더의 파일을 배포(서버·호스팅 업로드)

  • E: 최종 사용자가 웹사이트 접속 시, 최적화된 코드를 다운로드받아 React 앱 실행

  • 정리

    • 지연 로딩: 특정 라우트나 컴포넌트를 필요할 때만 다운로드하도록 최적화.
    • 프로덕션 빌드: 코드를 브라우저에 적합한 최적화된 형태로 변환 및 압축.
    • 서버 업로드: 빌드 결과물(build/ 폴더)을 실제 호스팅 환경에 업로드 → 사용자에게 제공.

위 단계를 통해 개발 코드를 프로덕션으로 준비하고, 사용자에게 빠르고 효율적인 React 애플리케이션을 제공하게 됩니다.

351. 리액트 앱 배포하기 / 배포 예시

아래는 “Firebase를 활용해 리액트 애플리케이션을 실제 서버(호스팅)에 배포”하는 과정을 요약하고 정리한 내용입니다.

  1. Firebase 프로젝트 생성

    • Firebase 콘솔에 접속해 새 프로젝트 생성.
    • 예: react-deployment-demo라는 이름으로 생성.
  2. Firebase CLI 도구 설치 및 로그인

    • 글로벌 설치: npm install -g firebase-tools
      • 맥·리눅스 사용 시 sudo npm install -g firebase-tools 필요할 수도 있음.
    • 로그인: firebase login
      • 브라우저 창이 열리며 구글 계정으로 인증 후 CLI 도구가 계정과 연동됨.
  3. Firebase 호스팅 초기화 (firebase init)

    • firebase init hosting 명령어 실행 (또는 firebase init → “Hosting” 기능만 선택).
    • 기존 프로젝트 사용 선택 → 방금 생성한 Firebase 프로젝트 연결.
    • “어느 폴더를 호스팅?” 질문에는 리액트 앱의 빌드 출력 폴더(build/) 입력.
    • Single Page Application(SPA) 설정: “rewrite all urls to index.html?”에 Y.
  4. 리액트 앱 빌드

    • 기존에 npm start는 개발 전용 서버.
    • 프로덕션 빌드: npm run build → 최적화된 빌드 결과물이 build/ 폴더에 생성됨.
  5. Firebase 배포 (deploy)

    • 빌드 후 build/ 폴더가 최신 상태가 되었는지 확인.
    • firebase deploy 명령어 실행 → build/ 폴더의 파일들이 Firebase 호스팅 서버로 업로드됨.
    • 완료 시 표시되는 호스팅 URL을 통해 웹사이트 접속 가능.
  6. 추가 설정

    • 커스텀 도메인 연결: Firebase 콘솔에서 ‘Hosting’ → ‘Add custom domain’ 이용.
    • 배포 취소: firebase hosting:disable (웹사이트가 오프라인 처리됨).
    • 웹사이트가 동작 중인지, 에러 로그는 없는지 Firebase 콘솔에서 확인 가능.
flowchart LR
    A(React App 코드 작성) -->|npm run build| B(build 폴더 생성)
    B --> C{firebase init<br>hosting}
    C -->|설정 완료| D(firebase deploy)
    D -->|업로드| E(Firebase Hosting 서버)
    E -->|생성된 URL로 접속| F(사용자 브라우저)
  • A: 리액트 애플리케이션 개발 (개발 서버 사용)

  • B: 프로덕션 빌드(build/) 생성 (최적화·압축)

  • C: Firebase 호스팅 설정(firebase init hosting)

  • D: 배포 명령(firebase deploy)

  • E: Firebase 서버에 정적 파일이 업로드됨

  • F: 사용자 브라우저가 Firebase 호스팅 URL로 접속 → 최적화된 리액트 앱 이용

  • 핵심 정리

    • 정적 호스팅: Firebase 호스팅은 서버에서 코드를 실행하지 않고, HTML/CSS/JS만 제공
    • CLI 툴: firebase-tools 통해 프로젝트 초기화, 배포 작업 수행
    • 프로덕션 빌드: npm run build로 build/ 폴더를 생성하고 최적화된 파일 배포
    • 접속: 배포 후 제공되는 URL(또는 커스텀 도메인)을 통해 사용자들이 웹사이트 사용 가능

위 과정을 따르면, 리액트 SPA(정적 웹사이트)를 Firebase 서버에서 전 세계 어디서든 접근 가능하게 호스팅할 수 있습니다.

352. 리액트 앱 배포하기 / 서버 측 라우팅 및 필요한 환경설정

아래는 "SPA(싱글 페이지 애플리케이션) 배포 시 서버 측 라우팅 vs. 클라이언트 측 라우팅 이슈를 요약하고 정리한 내용입니다.

  1. 서버 측 라우팅 vs. 클라이언트 측 라우팅

    • 서버 측 라우팅:
      • 사용자가 https://domain.com/posts 같은 URL을 직접 입력하면, 서버가 /posts 경로에 해당하는 파일(또는 라우트 로직)을 찾아서 응답.
      • 서버가 경로별로 다르게 처리하므로, 서버 쪽에서 라우팅 로직이 필요.
    • 클라이언트 측 라우팅(React Router):
      • 브라우저가 요청하는 모든 경로에 대해 서버는 동일한 HTML/CSS/JS(index.html)을 반환만 함.
      • React Router(자바스크립트 코드)가 로딩된 뒤 클라이언트(브라우저) 측에서 경로를 확인하고 해당 컴포넌트를 렌더링.
      • 경로별 페이지 이동이 “서버”가 아닌, “브라우저” 내부에서 이루어짐.
  2. SPA 설정의 필요성

    • SPA(Single Page Application)로 만든 리액트 앱은 브라우저(클라이언트)에서 라우팅을 처리한다.
    • 서버는 모든 요청(예: /posts, /auth)에 대해 index.html을 반환해 주어야만, 자바스크립트가 실행되어 해당 경로를 React Router가 처리할 수 있음.
    • 만약 서버가 /posts 경로를 찾으려 시도하면(폴더나 파일을 찾으려 함) 파일이 없어 404를 내보낼 가능성이 큼.
    • 배포 시 “SPA 모드”로 설정하면, 서버가 항상 index.html을 반환하도록 규칙이 설정됨 → 클라이언트 측 라우팅 정상 작동.
  3. 서버 설정 방법

    • Firebase 예시
      • firebase init hosting 시 “Single Page App?” 질문에 Y를 선택 → 서버는 어떤 경로 요청이 오든 index.html을 반환.
    • 다른 호스팅 제공자
      • .htaccess, rewrite 설정, “fallback” 규칙 등 사용.
      • 예: /* → /index.html 같은 식으로 모든 요청을 index.html로 리다이렉트.
  4. 결론

    • 리액트 라우터 기반의 앱(=SPA)은 서버 측 라우팅이 아니라 클라이언트 측 라우팅을 사용.
    • 따라서 서버는 “항상 index.html”을 반환하는 “SPA 모드” 설정이 필수임.
    • 그렇지 않으면 사용자 주소창에서 “직접 URL 입력” 시 서버가 해당 경로를 찾으려 해 404 오류가 발생할 수 있음.
flowchart LR
    A(사용자 브라우저<br>URL 입력: /posts) --> B(서버)
    B -->|항상 index.html<br>(SPA 설정)| C(Browser JS<br>React Router)
    C -->|경로= /posts<br>컴포넌트 로딩| D(PostsPage)
    style C fill:#E6FFB1,stroke:#888,stroke-width:2px
    style B fill:#ffcccc,stroke:#888,stroke-width:2px
  1. 사용자가 브라우저에 /posts를 입력하면,
  2. 서버는 SPA 모드 설정으로 인해 항상 index.html을 반환,
  3. 브라우저에 로딩된 자바스크립트(React Router)가 현재 경로 /posts를 감지하고,
  4. 해당 경로 전용 컴포넌트(PostsPage)를 렌더링.

이 과정을 통해, 서버 측 라우팅 없이 클라이언트 측만으로 경로 이동이 가능해집니다.

353. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 소개

아래는 TanStack Query(이전 명칭: React Query)에 관한 소개 및 섹션 개요를 정리한 내용입니다.

  • TanStack Query 섹션 요약
    1. TanStack Query(구 React Query)란?
      • 원래 “React Query”로 알려졌던 리액트용 서드파티 라이브러리.
      • 최근 “TanStack Query”라는 이름으로 변경됨.
      • 목적: 리액트 애플리케이션 내 HTTP 요청(데이터 가져오기/변경)을 더 쉽게 처리하고, 효율적인 캐싱과 같은 추가 기능을 제공.
    2. 이전 방식(useEffect + fetch)와의 차이점
      • 기본 리액트만으로도 useEffect 훅과 fetch를 사용해 HTTP 요청 가능.
      • 하지만 반복되는 로직(로딩 상태 관리, 오류 처리, 재요청, 캐싱 등)을 매번 직접 구현해야 함.
      • TanStack Query를 사용하면 이러한 로직이 자동화되고 최적화된 방식으로 제공됨.
    3. TanStack Query 주요 기능
      • 데이터 fetching & mutation: GET, POST, PUT, DELETE 등 모든 요청을 간편하게 처리.
      • 자동 캐싱: 가져온 데이터를 자동으로 저장(caching) → 재요청 시 빠른 응답.
      • 캐시 무효화: 서버 변경(예: POST/PUT/DELETE) 시, 자동으로 캐시를 최신 상태로 갱신(invalidate).
      • 동시성/중복 요청 관리: 여러 컴포넌트가 같은 데이터를 요청해도, 이미 진행 중인 요청을 재사용하여 성능 향상.
      • 오류 & 로딩 상태 자동화: 로딩 중/오류 상태를 쉽게 감지하고 처리.
      • 낙관적 업데이트: 서버에 변경 요청 전, UI를 미리 업데이트(Optimistic Update) → 빠른 사용자 경험 제공.
    4. 이번 섹션에서 배우는 내용
      • TanStack Query 설치 및 기본 설정.
      • 쿼리 훅(useQuery)과 뮤테이션 훅(useMutation) 사용법.
      • 캐시 및 캐시 무효화 방법, 낙관적 업데이트 개념 등 심화 기능.
      • TanStack Query를 이용한 리액트 프론트엔드 ↔ 백엔드 통신 예시 프로젝트.
flowchart LR
    A[컴포넌트] --> B[useQuery or useMutation 훅]
    B -->|자동 요청| C[HTTP 요청 (GET/POST/PUT/DELETE)]
    C -->|응답 데이터| B
    B -->|데이터 반환| A
    B --> D[캐싱/상태 관리]
    D -->|UI 업데이트| A
  1. 컴포넌트가 TanStack Query 훅(useQuery, useMutation)을 호출.
  2. 훅 내부에서 HTTP 요청(GET/POST/PUT/DELETE)을 자동으로 수행.
  3. 서버 응답이 돌아오면 캐싱 & 상태 관리 로직을 수행.
  4. 최종적으로 훅이 컴포넌트로 데이터(또는 상태)를 넘겨주고, UI가 업데이트됨.
  5. 수정 요청 시, 낙관적 업데이트나 캐시 무효화를 통해 UI와 서버 상태를 동기화.

정리: 이번 섹션에서는 TanStack Query를 이용해 리액트 앱에서 HTTP 요청 로직을 더욱 간편하고 강력하게 관리하는 방법을 익힙니다. 캐싱, 로딩/오류 처리 자동화, 낙관적 업데이트 등 다양한 고급 기능을 학습하고, 프로젝트를 통해 실습합니다.

354. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 프로젝트 설정 및 개요

아래는 Tanstack Query 사용을 위한 시작 프로젝트 구조와 준비 과정을 정리한 내용입니다.

  1. Tanstack Query 공식 문서 참고

    • Tanstack Query(이전 명칭: React Query) 최신 버전을 사용.
    • 최신 베타/정식 버전의 변동 사항 및 다양한 기능은 공식 문서에서 확인 가능.
  2. 시작 프로젝트 구성

    • 리액트 프런트엔드 프로젝트: 이미 여러 컴포넌트가 준비된 상태(소스 폴더 내에 존재). 이번 섹션의 목표는 이 컴포넌트들에서 Tanstack Query를 사용해 HTTP 요청을 간편하게 처리하는 것.
    • 더미 백엔드 폴더(노드/Express.js): 리액트 앱이 보내는 요청을 처리하는 간단한 서버 역할.
      • app.js에 백엔드 로직이 있음. Node.js와 Express.js 코드.
      • 깊이 이해할 필요는 없지만, Node.js에 익숙하다면 살펴볼 수 있음.
  • 정리:
    • 프런트엔드 폴더와 백엔드 폴더 각각 npm install 후 서버 두 개를 각각 실행.
    • 앞으로 Tanstack Query를 활용해 리액트 코드에서 HTTP 요청을 효율적으로 처리하고, 자동 캐싱 등 다양한 기능을 배우게 됨.

355. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 리액트 쿼리: 소개 및 이점

----- [예시 코드] 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 -----

아래는 Tanstack 쿼리 도입 전후의 개념과 코드 흐름을 정리한 내용입니다.

  1. Tanstack 쿼리(이전 명칭: React Query)

    • 리액트에서 HTTP 요청을 간편하게 전송하고, 프론트엔드 UI를 백엔드 데이터와 동기화하기 위한 서드파티 라이브러리.
    • 데이터 캐싱, 자동 재요청(refetch), 오류/로딩 상태 관리 등 고급 기능을 기본 내장해, 개발자가 작성해야 할 코드를 크게 줄여 줌.
  2. 기존 방식(useEffect + fetch)와의 차이

    • 리액트 내장 훅(useEffect)과 브라우저 내장 API(fetch)만으로도 백엔드와 연동 가능.
    • 하지만 앱이 복잡해질수록 상태 관리 코드가 늘어나고, 캐싱/리패치/에러 처리 등 부가 로직을 직접 구현해야 함.
    • Tanstack 쿼리는 이러한 기능을 한 번에 제공해, 코드 양 감소 + 개발 편의성 + 확장성을 확보.
  3. 사용 예시

    • 예: NewEventSection 컴포넌트에서 HTTP 요청을 전송해 이벤트 목록을 가져오는 로직.
    • 기존 방식: 로딩 상태, 에러 상태, 데이터 상태 등을 직접 useState로 관리, useEffect 내에서 fetch 호출.
    • Tanstack 쿼리 도입 시: useQuery 훅으로 로딩/에러/데이터 상태를 자동 관리하고, UI에선 간단히 값만 가져와 활용.
  4. 추가 고급 기능

    • 백그라운드 리패치: 탭 전환 후 복귀 시 자동 재요청 등.
    • 데이터 캐싱: 기존에 로드했던 데이터를 필요 시 재활용.
    • 낙관적 업데이트: POST/DELETE/PUT 등의 요청에 대해 UI를 즉각적으로 반영 후, 실제 요청 결과에 따라 조정.
flowchart LR
    A[useQuery 훅] --> B[캐시 확인]
    B -->|캐시에 데이터 있으면| C[즉시 UI 반영]
    B -->|캐시에 데이터 없으면 / 만료됨| D[fetch로 백엔드 호출]
    D --> E[백엔드 응답]
    E --> F[캐시에 데이터 저장/업데이트]
    F --> C[UI에 최신 데이터 반영]
  1. useQuery 훅이 실행되면 캐시를 우선 확인.
  2. 유효한 캐시가 있으면 즉시 UI에 반영(빠른 화면 전환).
  3. 캐시가 없거나 만료됐으면 실제 fetch로 백엔드에서 데이터 요청.
  4. 응답을 받아 캐시에 갱신 후 UI 반영.
import { useQuery } from '@tanstack/react-query'

interface EventItem {
  id: string;
  title: string;
}

async function fetchEvents() {
  const response = await fetch('/api/events')
  if (!response.ok) {
    throw new Error('이벤트 가져오기 실패!')
  }
  const data = await response.json()
  return data.events as EventItem[]
}

function NewEventSection() {
  // useQuery 훅: 'events'라는 key로 식별 & fetchEvents 함수로 데이터 가져옴
  const {
    data: events,
    isLoading,
    error,
    isError,
    refetch
  } = useQuery({
    queryKey: ['events'],
    queryFn: fetchEvents
    // 기타 옵션 가능
  })

  if (isLoading) {
    return <p>로딩 중 (Tanstack Query) ...</p>
  }
  if (isError) {
    return <p style={{ color: 'red' }}>에러 발생: {(error as Error).message}</p>
  }

  return (
    <section>
      <h2>이벤트 목록 (Tanstack Query)</h2>
      <button type="button" onClick={() => refetch()}>다시 불러오기</button>
      <ul>
        {events?.map(evt => (
          <li key={evt.id}>{evt.title}</li>
        ))}
      </ul>
    </section>
  )
}

export default NewEventSection
  • 설명

    • queryKey: 캐시 구분 용도. 여기서는 'events'라는 문자열 배열로 설정.
    • fetchEvents: 실제 HTTP 요청 함수(별도 api/eventsApi.ts 파일로 분리 가능).
    • isLoading / isError / data: 각각 로딩/오류/데이터 상태가 자동 관리됨.
    • refetch(): 수동으로 재요청을 트리거할 수 있는 함수.
  • 마무리

    • Tanstack 쿼리는 React Query의 새로운 이름이지만 동일한 핵심 개념:
      • 데이터 fetching + 캐싱 + 상태 관리를 한 번에 처리.
      • 큰 규모 앱일수록 효율이 극대화.
    • 필수 준비:
      1. @tanstack/react-query 설치
      2. QueryClientProvider로 전체 앱 감싸기
      3. 각 컴포넌트에서 useQuery / useMutation 등 훅으로 데이터 로직 구현.

이 과정을 거치면 HTTP 요청 처리 로직이 매우 간소화되고 캐싱, 리패치, 에러 처리, 로딩 상태 등 다양하고 유용한 기능을 자연스럽게 적용할 수 있습니다.

356. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / Tanstack 쿼리를 설치하고 사용하는 방법과 유용한 이점

아래 예시는 Tanstack 쿼리를 TypeScript 기반 리액트 프로젝트에서 사용하는 간단한 데모를 보여줍니다. 이 예시에서 NewEventsSection 컴포넌트는 기존 useEffect+fetch 코드를 없애고, 대신 Tanstack 쿼리를 통해 서버(백엔드)로부터 이벤트 목록을 가져옵니다.

  1. Tanstack 쿼리란?

    • 리액트 SPA에서 HTTP 요청을 더 쉽게 관리하고,
    • 데이터(서버에서 가져온 응답)를 캐시하여 성능과 개발 편의성을 높여주는 라이브러리.
  2. 코드 구조

    • useQuery 훅을 사용해, "쿼리 함수"(실제 fetch 역할) + 쿼리 키(queryKey)를 설정.
    • useQuery 훅이 제공하는 data, isError, error, isPending(또는 isLoading 등)을 사용해 로딩·오류·데이터 상태를 UI로 표현.
    • QueryClientProvider로 전체 앱을 감싸서 Tanstack 쿼리 기능 활성화.
[NewEventsSection]                [util/http.ts]
      useQuery()  ----------->   fetchEvents() -> (fetch to backend)
         |                                  
         +-- data, isError, isPending ----> React Rendering (UI)
  • (1) NewEventsSection에서 useQuery 훅을 사용해 fetchEvents 함수를 호출.

  • (2) fetchEventsfetch로 백엔드에 HTTP 요청을 전송 → 응답 결과(이벤트 배열) 반환.

  • (3) useQuery 훅이 data / isError / isPending 등 상태를 제공해 UI에서 렌더링 처리.

  • tanstack query 이점

    • 캐시 자동 관리,
    • 오류/로딩 상태 자동 관리,
    • 리패치(refetch) 등 추가 기능,
    • 네트워크 상태·탭 전환 등에 반응하는 고급 기능.

이로써 기존 useEffect + useState 로 작성했던 로직을 Tanstack 쿼리로 대체하여, 더욱 간결하고 유연한 데이터 가져오기(페치) 로직을 구현할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

357. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 쿼리 동작 이해 및 구성 - 캐시 및 만료된 데이터

아래 예시는 Tanstack 쿼리(이전 이름: 리액트 쿼리)의 캐시 처리와 staleTime, gcTime(=cacheTime) 설정을 활용하는 방법을 한글로 정리한 내용입니다.

  1. Tanstack 쿼리는 응답 데이터를 캐시하여, 동일한 쿼리 키(queryKey)로 재실행할 경우 즉시 기존 데이터를 표시(사용자에게 빠른 응답)한 뒤, 내부적으로 새로운 요청을 진행해 업데이트된 데이터가 있다면 갱신합니다.
  2. staleTime(기본값: 0)
    • 캐시에 보관된 데이터가 오래되었다(무효 상태다)고 간주하기까지 걸리는 시간(밀리초).
    • 예: staleTime: 5000 이면, 데이터 가져온 후 5초 동안은 이 데이터를 유효한 것으로 간주 → 5초 이내 다시 요청 시, 추가 요청 없이 캐시 데이터만 사용. 5초가 지나면 새 요청 전송.
  3. gcTime or cacheTime(기본값: 5분 = 300000ms)
    • 캐시 데이터가 메모리에 얼마나 오래 보관될지를 결정.
    • staleTime이 지나면 해당 데이터는 “오래되었다”로 표시되지만, gcTime 동안은 캐시로 남아있을 수 있음.
    • gcTime이 지나면 해당 데이터는 캐시에서 완전히 제거(가비지 컬렉션).
  4. 실제 동작 예시
    • staleTime=0: 매번 컴포넌트 렌더링 때마다 백그라운드로 새 요청 → 하지만 “화면 표시용”으로는 캐시 데이터 즉시 사용.
    • staleTime=5000: 5초 이내 재방문 시 새 요청 없이 캐시 데이터만 사용(네트워크 탭에서 요청이 뜨지 않음). 5초 지난 후 접근 시 새 요청 전송.
    • gcTime=30000: 30초 동안만 캐시에 남음 → 30초가 지나면 캐시에서 제거되어 완전히 새 요청이 필요.
(컴포넌트 렌더링) --> useQuery({ queryKey: ["events"], ... })

        +------------------ 캐시에 [events] 데이터 있는지 확인
        |      ↓ (있으면 즉시 표시)
        |      ↓ (없거나 staleTime 지났다면?)
        |
        +-> [백엔드 서버 요청] (fetch)
              ↓ (응답)
        +-> 캐시에 데이터 저장
        +-> 화면 업데이트
  • 요약
    • staleTime(기본값 0):
      • 데이터가 오래되었다고 간주하기 전까지의 시간을 ms로 지정.
      • 예) staleTime: 5000 → 5초 이내 접근 시 새 요청 없이 캐시 데이터만 사용.
    • cacheTime(기본값 5분 = 300000ms):
      • 데이터가 캐시에 완전히 남아있는 기간. 해당 기간이 지나면 가비지 수집으로 제거.
    • Tanstack 쿼리는 캐시된 데이터로 빠른 UI 업데이트(즉시 표시) + 백그라운드 새 요청을 통해 최신화 가능.
    • 원하는 시간(초/밀리초)으로 설정해 요청 빈도와 사용자 경험을 조절 가능.

이와 같이 Tanstack 쿼리의 캐시 처리와 staleTime, cacheTime 설정 방법을 사용하면, 사용자가 페이지를 여러 번 이동해도 곧바로 캐시된 데이터를 보여주면서, 필요한 시점에 백엔드에서 최신 데이터를 가져오는 훌륭한 UX를 구현할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

358. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 동적 쿼리 함수 및 쿼리 키

아래는 이번 강의에서 FindEventSection 컴포넌트에 TanStack 쿼리를 적용한 흐름 요약입니다.

  1. fetchEvents 함수

    • /api/events에 요청을 보내 이벤트 목록을 가져오는 역할을 하는 함수.
    • 선택적으로 search 쿼리 파라미터를 붙여 백엔드에서 검색 기능도 수행.
    • TypeScript 인터페이스 EventItem을 반환.
    • 내부에서 response.ok가 아니면 throw new Error(...)로 예외 처리.
  2. FindEventSection 컴포넌트

    • 기존에는 ref를 통해 입력값을 얻고, useEffect 등으로 직접 fetch를 했을 수 있으나, TanStack 쿼리를 사용해 간소화.
    • useQuery 훅을 활용해 1 queryKey와 2 queryFn(실제로 fetchEvents 호출)을 설정.
    • searchTerm이라는 로컬 상태를 두어(폼 submit 시 갱신) 이 값이 변할 때마다, 쿼리 함수와 쿼리 키가 달라지도록 하여 동적으로 이벤트를 검색.
    • 쿼리 키 안에 검색어(예: { search: searchTerm })를 포함해 캐시가 섞이지 않도록 유의.
  3. 문제점/버그

    • 검색어가 없는 상황에서 searchTermundefined(또는 '')인 상태로 URL에 쿼리 파라미터가 잘못 붙거나, NewEventsSection도 같은 fetch 함수를 호출함으로써 서로 충돌 가능성.
    • 이를 해결하기 위해:
      • searchTerm이 '' (빈 문자열)일 때, 불필요한 쿼리 파라미터를 붙이지 않도록 http.js(혹은 fetchEvents)에서 조건 분기.
      • 컴포넌트별로 서로 다른 queryKey를 사용(['events'] vs ['events', { search: ... }] 등).
  4. 추가 TanStack 쿼리 옵션

    • staleTime, cacheTime 등을 지정해 데이터 갱신 시점과 캐시 만료 시점을 조절 가능.
    • staleTime=0 (기본값) → 매번 re-fetch,
    • staleTime>0 → 일정 시간 이내에 다시 페이지 방문하면 re-fetch 없이 캐시 사용.
    • cacheTime(기본 5분) 지나면 실제 캐시 메모리에서 제거.
  • 결론
    • TanStack 쿼리의 주요 장점: 로딩/에러 상태 관리 간소화, 자동 캐시 및 상태 동기화 편의성.
    • 검색 기능 등으로 동적 파라미터가 필요한 경우, queryKey를 동적으로 지정하여 요청이 별도 캐시로 관리되도록 한다.
    • fetchEvents 함수도 searchTerm 등 인자를 받아 유연하게 URL을 구성한다.
flowchart LR
    A[사용자 입력 (검색)] --> B(FindEventSection)
    B -->|setSearchTerm(폼 Submit)| C[searchTerm useState]
    B -->|useQuery| D[queryFn(fetchEvents)]
    D --> E[/api/events?search=...]
    E --> D
    D --> B
    B -->|data,isLoading,isError| UI[렌더링 로직]
  • 사용자 입력: 폼에 검색어 입력 → submit 시 searchTerm 상태 갱신.
  • useQuery: queryKey에 { search: searchTerm } 반영, queryFn으로 fetchEvents(searchTerm).
  • fetchEvents: 백엔드 /api/events + ?search=...로 요청 → 응답 or 오류.
  • 응답 데이터: useQuery가 isLoading/isError/data 등으로 컴포넌트에 제공 → UI 렌더.
import { FormEvent, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchEvents, EventItem, HttpError } from '../../util/http.ts'
import LoadingIndicator from '../UI/LoadingIndicator.tsx'
import ErrorBlock from '../UI/ErrorBlock.tsx'
import EventItemComp from './EventItemComp.tsx' // 예: 개별 이벤트를 표시하는 컴포넌트

export default function FindEventSection() {
  const searchRef = useRef<HTMLInputElement>(null)
  const [searchTerm, setSearchTerm] = useState('')

  function handleSubmit(e: FormEvent) {
    e.preventDefault()
    if (!searchRef.current) return
    const val = searchRef.current.value.trim()
    setSearchTerm(val)
  }

  const {
    data,
    isFetching,
    isError,
    error,
    isLoading
  } = useQuery<EventItem[], Error>({
    queryKey: ['find-events', { search: searchTerm }],
    queryFn: () => fetchEvents(searchTerm),
    // 검색어가 없으면 굳이 요청 보내지 않고 싶은 경우 enabled 옵션
    enabled: searchTerm.length > 0,
    staleTime: 0,
    gcTime: 5 * 60 * 1000 // 5분
  })

  let content: JSX.Element = <p>검색어를 입력해주세요.</p>

  if (isLoading || isFetching) {
    content = <LoadingIndicator />
  } else if (isError) {
    const msg = (error as HttpError)?.info?.message || 'Failed to fetch events.'
    content = <ErrorBlock title="An error occurred" message={msg} />
  } else if (data && data.length > 0) {
    content = (
      <ul className="events-list">
        {data.map(ev => (
          <li key={ev.id}>
            <EventItemComp event={ev} />
          </li>
        ))}
      </ul>
    )
  } else if (data && data.length === 0) {
    content = <p>검색 결과가 없습니다.</p>
  }

  return (
    <section>
      <header>
        <h2>Find Events</h2>
        <form onSubmit={handleSubmit}>
          <input type="text" ref={searchRef} />
          <button type="submit">검색</button>
        </form>
      </header>
      {content}
    </section>
  )
}
  • 핵심 포인트

    • queryKey: ['find-events', { search: searchTerm }] 식으로 구분해 “검색 전용 키” 사용.
    • enabled: searchTerm.length > 0로 검색어가 비어 있으면 쿼리 비활성화.
    • isFetching과 isLoading 분리 (isLoading은 첫 로딩, isFetching은 데이터 refetch 중).
  • 요약

    • FindEventSection에서 검색어를 로컬 상태로 관리해 useQuery와 함께 전송, URL을 조합해 백엔드로 요청.
    • queryKey에 검색어를 반영해, 다른 섹션(NewEventsSection)과의 캐시 충돌 회피.
    • enabled 옵션으로 검색어 없을 시 요청 비활성화 가능.
    • TanStack Query가 제공하는 자동 캐싱, staleTime, cacheTime 설정으로 효율적 데이터 재활용 가능.

이상으로, TanStack 쿼리를 활용한 검색(FindEventSection) 구현 예시 및 전체 코드 구조를 정리했습니다.

react-13 프로젝트 코드 참고하면됨

359. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 쿼리 구성 객체 및 요청 취소

React Query(또는 TanStack Query)에서 쿼리 함수(queryFn)를 구성할 때, 라이브러리가 제공하는 기본 객체(signal, meta 등)와 사용자 정의 데이터(예: searchTerm)를 함께 전달할 수 있습니다.

  • 문제 원인

    • NewEventsSectionFindEventSection이 동일한 fetchEvents 함수를 사용하되, 후자의 경우 searchTerm 검색어를 쿼리 파라미터로 추가해야 합니다.
    • useQuery 훅은 내부적으로 queryFn에 객체를 넘기는데, 여기에는 signal(요청 취소 시 사용) 등의 기본 정보가 포함됩니다.
    • 만약 코드에서 fetchEvents가 이 객체를 제대로 처리하지 않으면, searchTerm이 이상한 값(혹은 객체)으로 설정되거나, 빈 문자열로 설정되어버리는 문제가 발생할 수 있음.
  • 해결 아이디어

    1. 쿼리 함수인 fetchEvents에서 (obj: {signal?: AbortSignal; searchTerm?: string;}) 형태의 매개변수를 받도록 수정.
    2. FindEventSection에서 useQuery를 작성할 때, queryFn을 익명 함수로 래핑해 직접 (context) => fetchEvents({ signal: context.signal, searchTerm }) 형태로 호출.
      • searchTerm은 로컬 상태에서 관리.
      • signal은 React Query가 제공하는 abort 신호.
    3. NewEventsSection처럼 검색어 없이 단순히 이벤트를 가져오는 섹션은 useQuery에서 굳이 wrapper 함수 없이 fetchEvents()로 호출하거나, searchTerm을 ''로 전달.
    4. 쿼리 키(queryKey)가 다르도록 설정(예: ['events'] vs ['events', {search: searchTerm}])해 캐시 충돌 방지.
  • 추가 포인트

    • signal: React Query가 요청 취소(페이지 이동 등) 시 내부적으로 fetch 중단 가능.
    • searchTerm: 빈 문자열이면 쿼리 파라미터를 붙이지 않도록 fetchEvents 내에서 분기 처리.
    • 위 로직을 통해 NewEventsSectionFindEventSection이 독립적으로 쿼리 함수를 호출하면서도 캐시나 abort signal을 적절히 활용할 수 있음.
flowchart LR
    A[FindEventSection] -->|useState(searchTerm)| B
    A -->|useQuery| D[queryFn 래핑함수]
    D -->|호출| E[fetchEvents({signal, searchTerm})]
    E -->|fetch /api/events?search=...| F[백엔드]
    F --> E
    E --> D
    D --> A
    subgraph React Query
    D
    end
    A -->|결과(data,isLoading etc.)| UI[렌더링/UI 표시]
  1. FindEventSection에서 searchTerm 상태를 폼 submit 시 갱신.
  2. useQuery 훅이 래핑된 queryFn을 호출 → fetchEvents({signal, searchTerm}) 실행.
  3. 백엔드로 요청 전송, 응답 → React Query 내부 캐시 & state 동기화.
  4. 최종 데이터나 에러, 로딩 상태 등이 FindEventSection 컴포넌트에 전달되어 UI 렌더링.
  • 결론
    • 문제 원인: React Query가 queryFn으로 객체(signal 등)를 자동 전달하는데, fetchEvents에선 이 객체를 제대로 처리 못해 searchTerm 값이 이상해졌다.
    • 해결:
      1. queryFn을 래핑해 signal과 원하는 searchTerm을 함께 전달.
      2. fetchEvents에서 (params) => { ... params.searchTerm ... params.signal } 식으로 처리.
      3. NewEventsSection은 검색어 없이 fetchEvents({ signal: ... })만 호출.
      4. FindEventSection은 검색어 있는 queryKey(예: ['events-search', {searchTerm}])로 중복 캐시 방지.

이렇게 쿼리 함수에는 React Query가 제공하는 정보와 사용자 데이터를 함께 전달해 로직을 유연하게 구성할 수 있다.

react-13 프로젝트 코드 참고하면됨

!! 중요 signal을 사용하는 이유!!

fetchEventssignal을 넘겨주는 이유는 브라우저의 Fetch API가 제공하는 요청 취소(Abort) 기능을 활용하기 위함입니다.

  • signal이 없으면

    • fetch로 전송한 요청은 중단할 수 없고, 요청이 끝날 때까지 기다려야 합니다.
    • 사용자가 컴포넌트에서 빠져나가거나(화면 전환 등) 더 이상 해당 데이터가 필요 없게 돼도, 이미 전송된 요청을 중단하지 못하므로 불필요한 통신이 계속 이뤄집니다.
  • signal이 있으면

    • React Query(혹은 AbortController)에서 Abort 시그널을 발생시키면, 그 즉시 fetch 요청을 중단할 수 있습니다.
    • 사용자가 화면을 바꾸거나 해당 쿼리를 취소할 일이 생겼을 때, 불필요한 요청을 취소해서 자원 낭비를 줄이고, 응답 완료 후에도 UI가 이미 사라진 상황을 막을 수 있습니다.

즉, signal을 넘기면 이 요청을 취소할 수 있다는 의미가 되어, 사용자가 빠르게 이동하거나 요청이 무의미해진 순간에 자동 혹은 수동으로 요청을 중단(Abort)할 수 있게 됩니다. 반대로, signal을 넘기지 않으면 그 요청은 끝까지 진행되고, 다른 페이지로 이동한 뒤 늦게 도착한 응답을 처리해야 할 수도 있어 성능·UX 면에서 부정적인 상황이 생길 수 있습니다.

처음 페이지 진입하면 events api 호출이 2번 감. 왜??
또한, 첫번째 events api 호출은 canceled 됨
이는 strict mode 때문에 events api 호출이 2번가는 듯. 그리고 canceled는 tanstack query의 signal 덕분인듯.

360. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 쿼리 활성화 및 비활성화

아래 정리는 리액트 쿼리(TanStack Query)에서 "검색어를 이용한 쿼리"와 "쿼리 비활성화(enabled)" 그리고 "isLoading vs isPending"를 어떻게 사용하는지에 대한 핵심 요약입니다.

  • 개요 및 동작 원리
    • FindEventSection 컴포넌트에서 검색어 searchTerm을 상태로 관리한다.
    • searchTerm === undefined(초기값) 인 경우, 쿼리를 비활성화(enabled=false)하여 HTTP 요청을 보내지 않도록 설정할 수 있다.
      • 사용자가 아직 검색어를 입력하지 않았다면, 이 섹션은 요청을 전송하지 않고 대기한다.
    • 이후 사용자가 검색어를 입력해서 searchTerm !== undefined가 되면, enabled=true가 되어 리액트 쿼리가 fetch 요청을 전송한다.
      • 검색어가 ''(빈 문자열)이 되어도 enabled=true라면, 모든 이벤트를 조회하도록 fetchEvents를 호출한다.
    • 이때 isLoadingisPending의 차이점이 있다:
      • isLoading: 실제로 쿼리를 실행 중일 때(즉, 활성화된 쿼리의 첫 로딩)만 true.
        • enabled=false 상태라면 쿼리가 실행되지 않으므로 isLoadingtrue가 되지 않는다.
      • isPending: 쿼리 로딩 중이거나 비활성화된 상태 등에서 대기 중으로 볼 때도 true가 될 수 있다.
      • 원하는 시나리오에 맞춰 isLoading/isPending 중 선택하여 로딩 UI를 표시한다.
    • 결과:
      1. 초기엔 searchTerm=undefined, 쿼리 비활성화 → HTTP 요청 없음, 로딩 스피너도 없음.
      2. 검색어 입력 → searchTerm 값이 생김(enabled=true), HTTP 요청을 보내고 isLoading 상태에 맞춰 로딩 표시.
      3. 검색어를 지워서 searchTerm=''이 된 경우에도 enabled=truefetchEvents 호출(전체 이벤트 불러오기).
[FindEventSection] --(searchTerm: undefined)--> { enabled: false } --> [쿼리 비활성, 요청X ]
     |
     | (사용자가 검색어 입력: setSearchTerm('city'))
     v
     { enabled: true } --(useQuery)--> fetchEvents({ searchTerm: 'city' })
         |
         |--> [백엔드 GET /events?search=city ... 결과]
         |--> isLoading -> false, data -> [필터된 이벤트 리스트]
     |
     | (검색어 '' 로 변경)
     v
     { enabled: true } --(useQuery)--> fetchEvents({ searchTerm: '' })
         |
         |--> [백엔드 GET /events -> 모든 이벤트]
  1. 처음: searchTerm=undefined → enabled=false, 쿼리 비활성.
  2. 검색 입력: searchTerm='...' → enabled=true, fetchEvents에 검색어 전달.
  3. 검색어 지움: searchTerm='' (여전히 enabled=true) → 전체 이벤트 조회.
// FindEventSection.tsx
/**
 * - 주요 포인트
 *  - searchTerm의 초기값을 undefined로 설정: enabled: searchTerm !== undefined.
 *    - → 처음에는 HTTP 요청 안 보냄 + 로딩 스피너도 표시 안 함.
 *  - 검색어를 입력 후 Submit → searchTerm이 ''(빈 문자열)이 되더라도 enabled=true 이므로 전체 이벤트 조회.
 *  - isLoading만 사용 (isPending 대신)
 *    - → 비활성화된 쿼리 상태에서 로딩 스피너가 뜨지 않음.
 *
 * ※ 백엔드가 /events로 GET 요청 시 searchTerm이 빈 문자열이면 전체 이벤트, 특정 문자열이면 해당 키워드가 제목/설명에 포함된 이벤트만 반환한다고 가정.
 * */

/**
 * 추가 참고
 * staleTime, cacheTime(gCTime) 등 옵션을 조정해, 캐시/재요청 타이밍을 세부 튜닝 가능.
 * 검색 후 다시 검색어를 undefined로 돌리는 로직(“초기 상태” 복귀)은 필요하다면 UI에 별도 버튼을 추가해 구현 가능.
 * */

/**
 * 이렇게 enabled 설정과 searchTerm의 초기값을 undefined로 해서,
 * (1) 시작 시에는 쿼리를 전송하지 않고,
 * (2) 검색어가 한 번이라도 설정되면('' 포함) 전체/필터된 이벤트를 가져오는 로직을 구현할 수 있습니다.
 * */

react-13 프로젝트 코드 참고하면됨

isLoading, isPending, isFetching 차이

아래는 TanStack Query에서 사용하는 세 가지 플래그(또는 상태)인 isLoading, isPending, isFetching의 차이를 정리한 내용입니다.

  • 각 상태 플래그의 의미

    • (1) isLoading
      • “쿼리가 처음으로(Initial) 데이터를 가져오는 중일 때”
      • “현재 쿼리가 로딩 상태이지만, 구체적으로 첫 번째 fetch 중일 때”
        • 설명
          • isLoading은 isPending && isFetching으로 정의된 파생(derived) 플래그입니다.
          • 즉, 쿼리가 아직 한 번도 성공적으로 데이터를 가져오지 않았고, 현재 fetch가 진행 중인 첫 번째 로딩 단계일 때 true가 됩니다.
          • 이전 버전에서 사용되던 isInitialLoading이 deprecated되었으며, 대신 이제 isLoading을 사용합니다.
        • 사용 예시
          • 최초 로딩 시점(쿼리가 아직 아무 데이터도 없는 상태)에서 로딩 스피너를 표시하고 싶은 경우에 isLoading이 유용합니다.
          • Lazy Query처럼 “처음 쿼리 시작을 지연”시키는 경우, 쿼리가 처음으로 fetch를 실행하면 isLoading이 true가 되어 로딩 중임을 나타낼 수 있습니다.
    • (2) isPending
      • “쿼리/뮤테이션이 실행 중(실제 fetching/실행 중)인지 나타내는 플래그”
      • 설명
        • isPending은 쿼리 혹은 뮤테이션이 현재 진행 중인 경우에 true가 됩니다.
        • 이전에는 isLoading을 이런 용도로 썼지만, 버전5 이상에서는 isPending을 주로 사용해 fetch나 mutation의 진행 상태를 구분합니다.
        • 단, isPending은 쿼리가 아직 첫 데이터를 받았는지 여부와 관계없이, 지금 fetch를 “하고 있다면” true가 됩니다.
      • 사용 예시
        • “어떤 시점에서든 쿼리/뮤테이션이 진행 중”인지 확인하여 로딩 스피너 표시.
        • 예) “데이터 업데이트가 끝나지 않았다면 isPending === true로 ‘로딩 중…’을 표시” 등.
    • isFetching
      • “전체 쿼리 캐시에서 몇 개의 쿼리가 fetching 중인지를 나타내는 (숫자) 상태”
      • 설명
        • isFetching() 메서드는 쿼리 클라이언트(QueryClient) 레벨에서 “현재 몇 개의 쿼리가 백그라운드에서 fetching 중”인지를 정수로 리턴합니다.
        • useIsFetching() 훅을 사용하면, 이 숫자 상태를 컴포넌트에서 구독(subscribe)할 수 있습니다.
      • 사용 예시
        • 전역 로딩 인디케이터: “사용자의 전체 앱에서 쿼리가 하나라도 백그라운드에서 fetch 중이면 전역 로딩 바 표시”
        • 예) if (queryClient.isFetching()) { /* 전역 로딩 표시 */ }
  • 세 플래그의 사용 상황 비교

    • isLoading
      • 초기 쿼리 로딩에 대한 표시.
      • 보통 “첫 fetch” 시점에서 로딩 스피너를 표시하고 싶을 때 사용.
      • 이미 데이터가 있으면 false.
    • isPending
      • 현재 fetch 진행 중(또는 mutation 실행 중)인지.
      • “최초 fetch 뿐 아니라, 재-fetch, 백그라운드 refetch 등 모든 fetch 시점을 파악” 가능.
      • “항상 fetching 중인가?”를 확인해서 로딩 UI를 표시할 때 사용.
    • isFetching
      • “쿼리 개수 관점”에서 fetching 중인 쿼리가 얼마나 있는지 확인(숫자).
      • 보통 전역 단위(글로벌) 로딩 인디케이터를 표시할 때 유용.
쿼리 A: (isPending, isLoading, isFetching)
쿼리 B: ...
------------------------------
[isLoading]   : 처음(=데이터 전무) fetch 중이면 true
[isPending]   : 지금 이 쿼리(혹은 뮤테이션)가 "fetch/실행 중"이면 true
[isFetching]  : QueryClient 전역에서 "fetching 중인 쿼리 개수" (정수)
  1. 처음 쿼리 로딩

    • isLoading === true, isPending === true, 쿼리 수=1 → isFetching() returns 1.
  2. 데이터 최초 로딩 후, 다시 refetch

    • isLoading === false (이미 데이터 존재), isPending === true (refetch 진행 중), isFetching() returns ?.
// src/components/SomeQueryExample.tsx
import { useQuery } from '@tanstack/react-query'

interface RepoData {
  name: string
  description: string
  subscribers_count: number
  stargazers_count: number
  forks_count: number
}

export default function SomeQueryExample() {
  const {
    data,
    isLoading,   // 첫 fetch 시점에만 true
    isPending,   // fetch(또는 refetch) 등 진행 중이면 언제든 true
    error,
  } = useQuery<RepoData, Error>({
    queryKey: ['repo'],
    queryFn: async () => {
      const res = await fetch('https://api.github.com/repos/TanStack/query')
      if (!res.ok) {
        throw new Error('Failed to fetch repo data!')
      }
      return res.json() as Promise<RepoData>
    },
  })

  if (isLoading) {
    // "최초 로딩" 시점에만 true
    return <p>처음 데이터를 가져오는 중...</p>
  }

  if (isPending) {
    // 모든 fetch 시점 (최초, refetch 등)
    return <p>데이터 갱신 중(Background fetch)...</p>
  }

  if (error) {
    // 에러 처리
    return <p style={{color:'red'}}>에러: {error.message}</p>
  }

  // data가 존재 -> 화면 표시
  return (
    <section>
      <h2>{data?.name}</h2>
      <p>{data?.description}</p>
      <p>👀 {data?.subscribers_count}{data?.stargazers_count} 🍴 {data?.forks_count}</p>
    </section>
  )
}
  • 주요 포인트

    • isLoading: 최초 fetch가 일어날 때만 true.
    • isPending: 이후 refetch 등 모든 fetch 상황에서 true.
      • 만약 이 컴포넌트가 “새 버튼”을 클릭해 수동으로 refetch 한다면, refetch 동안 isPending = true가 될 수 있지만 isLoading은 false일 것.
  • 정리

    • isLoading : “최초 로딩 중”에만 true → 빈 데이터 상태에서 시작.
    • isPending : “fetch 진행 중이면 언제든” true → 재요청(refetch) 시에도 true.
    • isFetching : “현재 전체 쿼리 중 fetch 중인 쿼리 개수” (숫자 반환).
      • 전역 로딩 바 or “하나라도 fetching 중이면…” 논리에 쓰기.

361. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 변형을 사용하여 데이터 변경

아래 정리는 리액트 쿼리에서 useMutation 훅을 사용하여 새로운 이벤트(데이터)를 백엔드로 전송(예: POST 요청)하는 방법을 다룹니다.

본격적인 코드 작성 전, 전제 사항은 다음과 같습니다:

  • 이미 @tanstack/react-query 라이브러리가 설치되어 있고,
  • QueryClientProvider로 전체 앱을 감싸는 설정이 되어 있음
  • 백엔드는 /events 경로에 POST 요청을 받으면 새 이벤트를 생성할 수 있는 상태 (예: Node/Express + in-memory data 등)

아래 내용 및 코드는 TypeScript 기반으로 작성했습니다. (React Router는 7버전을 가정)

  • 구현 흐름 개요
    1. 사용자가 “새 이벤트 생성” 버튼을 클릭하면, 모달
    2. 예:<NewEventModal/>이 열림.
    3. 모달 내에서 이벤트 정보를 입력하고 “Create” 버튼을 누르면 → handleSubmit 함수에서 useMutation 훅의 mutate()를 호출해 백엔드로 POST /events 요청을 전송.
    4. 리액트 쿼리가 이 요청을 전송하고, 요청 전/중/후 상태(isPending, isError 등)을 추적 → 로딩 상태나 오류 상태 등을 컴포넌트에서 쉽게 처리.
    5. 응답이 성공이면 → 원하는 후처리(모달 닫기, 목록 리프레시 등).
    6. 응답이 오류이면 → isError === true가 되어, 적절한 오류 메시지를 표시.
사용자
  └─ [새 이벤트 생성 버튼 클릭]
       │
       ▼
<NewEventModal> (폼 입력)
  └─ handleSubmit() {
        // (1) formData 수집
        // (2) useMutation().mutate(formData)
      }

 ┌─────────────────┐
 │ useMutation(...) │
 └─────────────────┘
       │
       ▼
fetch('/events', POST)  -- (createNewEvent)
       │
       ▼
백엔드 (/events) 
 - POST -> in-memory data에 push
       │
응답(성공 or 실패)

       ▼
리액트 쿼리 → isPending / isError / error 등 업데이트
       ▼
<NewEventModal> re-render
 - 로딩UI, 에러메시지, 후처리 등 표시
  • 핵심 포인트 정리

    1. useMutation:
      • 데이터를 새로 생성(POST), 수정(PUT/PATCH), 삭제(DELETE) 등 “백엔드 변경”을 위한 전송 시 사용.
      • useQueryGET(데이터 조회)에 적합, useMutationCreate/Update/Delete 같은 변경(mutation)에 적합.
    2. mutationFn:
      • 실제 HTTP 전송 함수를 작성해 할당 (ex: createNewEvent).
      • mutate(전달데이터)가 호출될 때마다 이 함수를 실행해 백엔드로 전송.
    3. 요청 상태 관리:
      • isPending: 요청 진행 중인지 여부.
      • isError + error: 오류 발생 여부 및 오류 객체.
      • isSuccess: 변형(요청) 성공 여부.
    4. 에러 처리:
      • throw new Error(...)useMutationisError = true로 처리하고 error객체에 해당 에러가 저장됨.
    5. 사용 예
      • 폼 전송 handleSubmitmutate(formData) → 응답 대기 → 성공/실패 UI 반영.
  • 예시 시나리오

    1. 사용자 “새 이벤트” 버튼 클릭 → <NewEventModal> 열림
    2. 사용자 제목, 날짜, 설명 입력 후 “Create”
    3. handleSubmitmutate(newEventData) 호출
    4. 백엔드 /events에 POST 요청 전송 → in-memory data에 푸시
    5. 응답(성공 or 오류)에 따라 isError, isPending 등 갱신
    6. 성공 시 → isSuccess = true, 모달 닫기 or 다른 후처리 가능
    7. 오류 시 → isError = true, error에 메시지. UI 표시
  • 결론

    • TanStack Query는 단순히 GET 요청뿐 아니라, 데이터 변경(Create/Update/Delete)을 위한 useMutation API도 제공해준다.
    • useMutation을 통해 “필요할 때(mutate)만” 요청을 전송하고, 상태(isPending, isError 등)를 편리하게 추적·관리할 수 있다.
    • 에러 처리, 로딩 상태 처리, 후처리 등 로직을 컴포넌트 곳곳에 쉽게 녹일 수 있어 코드 가독성과 유지보수성이 매우 향상된다.

react-13 프로젝트 코드 참고하면됨

362. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 추가 데이터 가져오기 및 변형 테스트하기

아래 정리 내용과 예시 코드는 이전까지 공유한 코드 구조를 그대로 유지하면서, 이미지 리스트 가져오기(useQuery) 기능을 새로 추가하는 방식으로 작성했습니다. 즉, 이미지를 선택하기 위한 ImagePicker 컴포넌트, 그리고 EventForm 컴포넌트에서 useQuery로 이미지를 불러오는 부분이 새로 추가되었습니다.

  • 개념 정리
    • 백엔드 라우터:
      • /events/images 경로로 GET 요청을 보내면 사용자가 선택할 수 있는 이미지들의 목록([{id, imageUrl}, ...] 같은 형식이라고 가정)를 받을 수 있음.
      • 각 이미지 파일 자체는 백엔드 서버가 public/ 폴더에서 제공(정적 호스팅)하므로, 실제 이미지 표시 시에는 <img src="백엔드서버주소/파일경로" ...> 형태로 접근.
    • 프론트엔드 데이터 흐름:
      1. EventForm 컴포넌트가 useQuery를 통해 fetchSelectableImages 함수를 호출.
      2. 이 함수는 /api/events/images(프록시 설정)로 GET 요청을 전송해, 이미지 목록을 JSON으로 받아옴.
      3. EventForm에서 받은 이미지 목록(data)을 ImagePicker 컴포넌트에 images prop으로 넘김.
      4. ImagePicker는 전달받은 images 배열을 렌더링하여 사용자가 이미지 하나를 선택할 수 있게 함.
    • 사용자 시나리오:
      1. 사용자 가령 "새 이벤트 생성" 버튼 클릭 → NewEventModal 열림.
      2. EventForm이 모달 내부에 렌더링되면서, useQuery가 실행돼 이미지 목록을 가져옴.
      3. ImagePicker 컴포넌트에 images prop이 들어오면, 사용자가 원하는 이미지를 클릭(혹은 라디오 버튼) 방식으로 선택.
      4. 최종적으로 Create 버튼 누르면 useMutation으로 새 이벤트 + 선택 이미지를 서버에 전송.
백엔드 (/events/images) → 
  fetchSelectableImages() 
     → useQuery(queryFn=fetchSelectableImages, queryKey=["events-images"])
        → (isLoading / isError / data)
           → EventForm
              → ImagePicker(images=data)
              → [사용자 이미지 선택]
  • useQuery(이미지 목록):

    • queryKey: ['events-images'] (고정)
    • queryFn: fetchSelectableImages
    • isLoading, isError, data 등을 통해 로딩/오류/정상 데이터 처리
  • ImagePicker:

    • props: images: string[] (또는 객체 배열)
    • 이미지를 리스트 렌더링 + 사용자가 선택하도록 UI 제공
  • 코드 작성 시:

    • *.tsx (타입스크립트 리액트)
    • react-router-dom v7
    • @tanstack/react-query v5+ (queryFn, mutationFn)
  • 요약

    • 이미지 목록: 백엔드에서 /events/images(가정)로 GET 요청 → { images: [...] } 형태.
    • fetchSelectableImages: 이 주소에 fetch → JSON 파싱 후 배열 반환.
    • EventForm에서 useQuery로 이미지 목록 가져옴 → 로딩/오류 시 UI 분기 → 성공 시 ImagePicker 컴포넌트로 전달.
    • ImagePicker: images 배열 렌더링, 사용자 선택 이벤트 → 부모 콜백(onSelectImage) 통해 선택 결과 알려줌.
    • 최종적으로 EventForm이 onSubmit 호출 시, 선택된 이미지 ID 포함한 데이터 → 상위(NewEventModal)로 전달.

이로써 이미지 선택 기능을 Tanstack Query와 함께 구현할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

363. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 변형 성공 시 동작 및 쿼리 무효화

아래 예시는 TanStack Query(v5 기준)로 작성된 새 이벤트 생성 로직에서, 변형이 성공했을 때 특정 쿼리 키(예: ['events'])를 무효화하여 UI를 즉각 갱신하는 방법을 보여줍니다. 또한 성공 시 특정 페이지로 이동(또는 모달 닫기 등) 같은 후속 동작도 설명합니다.

  • useMutation + onSuccess
    • POST 요청을 전송한 뒤, 성공 시 특정 쿼리(예: ['events'])를 무효화하여 UI를 즉시 갱신한다.
    • onSuccess 내부에서 queryClient.invalidateQueries(...).
    • 이후 navigate 또는 모달 닫기 등 적절한 후속 조치.
[User Click + Add New Event] → setShowModal(true)
    ↓
[NewEventModal isOpen] → <EventForm> → handleCreate()
    ↓
useMutation.mutate(newEventData) → POST /events
    ↓
onSuccess → queryClient.invalidateQueries(['events'])
          + onClose() [모달 닫기]
          (+ navigate('/events') 등)
    ↓
[NewEventsSection]에서 useQuery(['events']) → 재요청 → 즉시 최신 데이터
  • TypeScript + React Router v7 + TanStack Query v5

    • react-router-dom 7.1.1
    • @tanstack/react-query 5.x
    • ES 모듈 + Vite 기반.
  • 추가 예시 코드/패턴

    • 정적 타이핑: useMutation<SuccessType, ErrorType, VariablesType> 등 제너릭 인자 활용.
    • onSettled 콜백: 성공 여부와 상관없이 특정 동작(ex: 스피너 해제 등) 수행.
    • enabled(useQuery) / invalidateQueries(exact: boolean) 등 고급 옵션.

위 코드를 통해 새 이벤트 생성 시 성공 후 화면 즉시 업데이트(invalidateQueries), 오류 처리, 모달 닫기 또는 페이지 이동 로직까지 모두 구현할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

-- 추가: onSettled란? --

  • onSettled 콜백은 TanStack Query(리액트 쿼리)에서 useMutation 훅을 구성할 때 지정할 수 있는 하나의 옵션입니다.

  • onSuccess, onError와 달리, 변이가 성공했든 실패했든 간에(즉, 결과가 확정되면) 무조건 실행되는 콜백입니다.

  • 따라서 onSuccess와 onError가 각각의 경우(성공 / 실패)만 처리하는 콜백이라면, onSettled는 결과와 상관없이 공통으로 처리해야 할 로직을 담을 때 쓰입니다.

  • onSettled 콜백이란?

    • TanStack Query에서 useMutation 훅을 쓸 때, 다음과 같은 콜백들을 정의할 수 있습니다:
      • onMutate: 변이(mutation) 시작 직전에 실행
      • onSuccess: 변이 성공 시 실행
      • onError: 변이 실패 시 실행
      • onSettled: 변이가 성공이든 실패든 결과가 “확정(settled)”된 경우에 무조건 실행
useMutation({
  mutationFn: someMutationFn,
  onSuccess: (data) => {
    // 성공했을 때만
  },
  onError: (error) => {
    // 에러났을 때만
  },
  onSettled: (data, error) => {
    // 성공/실패 상관없이 "결과 확정" 시 공통 동작
  },
})
  • onSuccess: 성공 시만.

  • onError: 에러 시만.

  • onSettled: 성공 or 에러가 완료된 직후.

  • 예시 용도

    • onSettled에서는 공통으로 처리할 로직(예: 로딩 상태를 해제, 모달 닫기/토글, UI공통 cleanup 등) 을 넣을 수 있습니다.
    • 성공이든 실패든, 결과가 확정되면 항상 수행해야 하는 동작은 onSettled가 적절합니다.
    • 반면에 onSuccess, onError는 상황별로 분기 처리가 필요할 때 쓰기 좋습니다.

따라서 onSettled는 "그냥 onSuccess나 onError에 정의된 함수를 합쳐놓은 개념인가?"라고 볼 수도 있지만, 성공/실패 양쪽에서 공통으로 처리할 로직을 넣기 위한 별도 옵션입니다.
요약하면, onSettled는 무조건 실행되는 “결과 확정 시점” 공통 콜백이며, onSuccess/onError와는 용도 면에서 중첩되지 않습니다.

  • 결론
    • onSettledonSuccess, onError와는 별개로, 변이가 종료된 이후(성공 or 실패)에 공통으로 동작하는 로직을 넣고 싶을 때 사용하는 콜백입니다.
    • 따라서 "그냥 onSuccess/onError 함수와 같다"가 아니라, 성공/실패를 가리지 않고 꼭 실행해야 하는 cleanup 로직 등을 넣을 수 있는 “제3의 콜백”이라는 점이 핵심입니다.

364. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 실습 과제: 문제

아래는 과제 요구사항(이벤트 세부 정보 페이지 로드 및 삭제 기능 구현)에 대한 정리와 함께, 데이터 흐름 도식, 그리고 타입스크립트 기반의 예시 코드(최신 react-router-dom v7 기준)를 포함한 전체 파일 예시입니다.

  1. 이벤트 세부 정보 페이지(EventDetails 컴포넌트)

    • useQuery 훅을 사용해 fetchEvent 함수를 호출하여 특정 이벤트(식별자: eventId)의 상세 데이터를 가져옴
    • 가져온 이벤트 상세 정보(제목, 날짜, 설명, 이미지 등)를 화면에 표시
    • 이미지 표시 시, 백엔드에서 받은 imageFileName(또는 imageId)를 바탕으로 실제 이미지 URL을 구성(예: http://localhost:8080/public/xxx.jpg)
      • 혹은 백엔드 응답에 완성된 imageUrl이 들어있다면 그대로 사용
  2. 삭제 기능(Delete 버튼)

    • useMutation 훅을 통해 deleteEvent 함수를 호출
    • 성공 시에는 쿼리 무효화(invalidateQueries({ queryKey: ['events'] }) 등으로 전체 이벤트 목록 갱신
    • 또는 페이지 이동 / 특정 처리 로직 수행(예: 홈으로 이동)
  3. 라우팅

    • router/index.tsx에서 /events/:eventId 경로로 EventDetails 컴포넌트를 매핑
    • 이벤트 목록(예: NewEventsSection)에 View Details 버튼(혹은 링크)을 배치하여 해당 라우트로 이동 (URL 파라미터로 eventId 전달)
[EventList 페이지] --(ViewDetails 클릭)--> 
  /events/:eventId 라우트 ->
  [EventDetails 컴포넌트]
    -> useQuery({ queryKey: ['event-detail', eventId], queryFn: fetchEvent(eventId) })
    -> 백엔드( /api/events/:eventId )로 GET 요청
    -> 응답된 이벤트 상세 데이터 표시

[Delete 버튼 클릭] ->
  useMutation(deleteEvent) 호출 ->
  백엔드(/api/events/:eventId) DELETE 요청 ->
  onSuccess에서 queryClient.invalidateQueries({ queryKey: ['events'] }) ->
  -> 최신 이벤트 목록 반영 or 라우팅 이동
  • 요약
    • EventDetails.tsx 파일을 추가해, useQuery(fetchEvent)로 특정 이벤트 상세 정보를 로드하고 화면에 표시
    • Delete 버튼을 useMutation(deleteEvent)와 연결해, 삭제 성공 시 invalidateQueries('events') 실행 및 라우팅
    • 이벤트 목록 컴포넌트(NewEventsSection.tsx)에서 View Details 버튼(혹은 링크)로 /events/:eventId 경로로 이동
    • http.tsfetchEvent, deleteEvent 함수를 추가해 각 API 연동 로직 작성
    • router/index.tsx에서 :eventId 경로 매핑

위 코드 예시는 과제(세부정보 페이지 + 삭제 기능) 구현에 필요한 전반적인 흐름을 담고 있습니다. 실무에서는 디자인/구조에 맞춰 스타일을 적용하거나, 에러 처리 로직/유효성 검사 등을 보강할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

365. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 실습 과제: 해설

아래 정리에서는 EventDetails 컴포넌트를 어떻게 작성하면 이벤트 상세 정보(데이터)를 가져오고, Delete 버튼을 눌렀을 때 이벤트를 삭제하여 UI에 반영하는지를 자세히 설명합니다. 또한 리액트 쿼리의 쿼리 무효화(invalidate) 및 라우팅 연동(useNavigate) 과정을 코드 예시로 함께 보여줍니다.

flowchart TB
    A[사용자: EventDetails 페이지 방문] --> B[/events/:eventId 라우트/]
    B --> C[EventDetails 컴포넌트]
    C --> D[useQuery -> fetchEvent(eventId)]
    D --> E[백엔드 GET /events/:eventId]
    E --> D[응답(이벤트 상세 데이터)]
    D --> C[데이터 state 업데이트]
    C --> F[화면에 이벤트 상세 표시]
    F --> G(사용자 Delete 클릭)
    G --> H[useMutation -> deleteEvent(eventId)]
    H --> I[백엔드 DELETE /events/:eventId]
    I --> H[응답(성공 or 실패)]
    H --> C[onSuccess -> queryClient.invalidateQueries({ queryKey: ['events'] }) + navigate('/')]
    C --> B
  1. 사용자가 /events/:eventId URL에 접근하면 EventDetails 컴포넌트가 렌더링된다.
  2. useQueryfetchEvent(eventId)를 호출 → 백엔드 GET /events/:eventId 로 이벤트 정보 가져오기
  3. 데이터 수신 후 화면에 표시
  4. 사용자가 Delete 버튼을 누르면 useMutationdeleteEvent(eventId) → 백엔드 DELETE /events/:eventId
  5. 성공 시 onSuccess 콜백에서
    • invalidateQueries({ queryKey: ['events'] }) → 기존 이벤트 목록 쿼리 데이터 무효화 & 재요청 트리거
    • navigate('/') 로 이동 (또는 다른 동작)
  • 타입스크립트 & React Router v7 최신 버전 기준 예시

    • react-router-dom v7 (최신) 기준이며, createBrowserRouter와 RouterProvider를 사용합니다.
    • EventDetails 컴포넌트는 /events/:eventId 경로로 매핑되며, useParams로 eventId를 받고, useQuery와 useMutation 사용법은 TanStack Query 5.x 기반 코드입니다.
  • 결론

    • EventDetails에서 useQuery로 이벤트 상세 정보 로딩
    • useMutation으로 이벤트 삭제 처리 & 쿼리 무효화 → 즉시 UI 최신화
    • 리액트 라우터 파라미터(useParams)로 이벤트 ID 활용
    • 삭제 완료 후 홈이나 이벤트 목록으로 이동하고자 할 때 useNavigate + onSuccess 콜백

이로써 “이벤트 상세 정보” 페이지에서 데이터 로드와 삭제 기능을 구현할 수 있습니다. TanStack Query를 이용해 로직이 간결해지고, invalidateQueries를 통해 캐시 데이터를 새롭게 갱신할 수 있어 사용자에게 최신 상태가 반영됩니다.

react-13 프로젝트 코드 참고하면됨

366. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 무효화 후 자동 다시 가져오기 비활성화

아래 정리 내용을 보시면, 왜 이벤트를 삭제하고 난 뒤 뒤로 이동했을 때 특정 이벤트에 대한 404 요청이 발생하는지, 그리고 이 문제를 invalidateQueriesrefetchType 옵션으로 어떻게 해결할 수 있는지를 알 수 있습니다.

  • 문제 상황 개요
    • 이벤트 상세 페이지(EventDetails)에서 이벤트를 삭제함
    • 이후 뒤로 이동(브라우저의 뒤로가기 버튼 등)했을 때, 이미 삭제된 이벤트를 다시 가져오려고 하면서 404 요청 발생
    • 이는 리액트 쿼리가 "무효화된 쿼리"를 즉시 다시 가져오도록(재요청) 기본 동작하기 때문

  • 왜 404 요청이 발생하는가?

    • 이벤트가 삭제됨 → invalidateQueries({ queryKey: ['events'] })로 모든 이벤트 관련 쿼리 무효화
    • 현재 페이지(= 이벤트 상세 페이지)의 쿼리 역시 "이벤트 관련"으로 간주되므로 무효화됨
    • 리액트 쿼리의 기본 동작: 무효화된 쿼리는 즉시 재요청(refetch)
    • → 이미 삭제된 이벤트에 대한 ID로 다시 GET 요청 → 당연히 404
  • 원하는 동작

    • 삭제 후, 이벤트 상세 페이지에 남아 있어도 그 상세 쿼리가 즉시 재요청되지 않도록 하고 싶음
    • → 현재 페이지의 qurey(이벤트 상세)는 재요청하지 않고, 다른 페이지(예: 이벤트 전체 목록 페이지)에서만 다시 쿼리 실행되도록
  • 해결 방법: invalidateQueriesrefetchType 옵션

    • refetchTypenone으로 설정하면 무효화되었을 때 즉시 재요청하지 않음
    • qurey가 무효화되기는 하지만, "다음번에 해당 컴포넌트가 렌더링될 때" 재요청이 일어남
    • 따라서 현재 페이지(상세 페이지)에서 즉시 재요청되지 않으므로 404 발생이 사라짐
    • 뒤로가기로 리스트 페이지로 이동하면 그 페이지 컴포넌트가 렌더링되면서 쿼리 재요청 → 변경된(삭제된) 이벤트 목록 반영
flowchart LR
    A[EventDetails 페이지 로딩] -- useMutation( deleteEvent ) -->
    B{이벤트 삭제}
    B --> C[onSuccess: queryClient.invalidateQueries({ queryKey: ['events'], refetchType:'none' })]
    C --> D[모든 "events" 쿼리를 무효화\n(즉시 재요청 X)]
    D --> E[현재 상세페이지: 재요청 X → 404 없음]
    D --> F[다른 페이지 이동 시 → 컴포넌트 렌더링 & 해당 쿼리 재실행]
    F --> G[갱신된 데이터 반영\n(삭제된 이벤트는 목록에서 제외)]
  1. EventDetails 컴포넌트에서 삭제 뮤테이션 실행
  2. onSuccess 시점에 invalidateQueries({ queryKey: ['events'], refetchType: 'none' })
  3. events와 연관된 모든 쿼리가 무효화됨
  4. 기본 동작: refetchTypeactive(기본값)라면, 무효화되자마자 재요청하여 404 발생
  5. refetchType: 'none' → 무효화되지만 즉시 재요청은 안 함
  6. 사용자가 뒤로 이동해도 이미 무효화된 qurey가 재요청되지 않아 404가 발생하지 않음
  7. 다른 페이지(이벤트 목록)에서 해당 쿼리를 다시 렌더링할 때 → 무효화된 캐시를 보고 재요청 자동 수행

이렇게 하면 "이벤트 삭제 후 현재 상세 페이지를 즉시 재요청하지 않도록" 할 수 있어, 삭제된 이벤트에 대한 404 요청을 방지할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

-- refetchType 이란? --

events 쿼리키의 refetchTypenone으로 해도, 뒤로가기 했을 때 이동되는 페이지는 이벤트 상세 페이지임.
그러면 event-detail 쿼리키는 호출되니깐 404가 뜨는게 맞음.
404가 뜨지 않게하려면 event-detail 쿼리키가 호출 안되어야하는데, onSuccess에서 event-detail 쿼리키의 refetchTypenone으로 해도 event-detail 쿼리키는 호출됨.

refetchType의 역할이 뭔지 제대로 알아보자.


아래 설명은 TanStack Query(리액트 쿼리)에서 invalidateQueries()를 호출할 때 사용되는 refetchType 옵션에 대한 자세한 안내입니다.

  • invalidateQueries()refetchType

    • queryClient.invalidateQueries() 메서드는 특정 쿼리(또는 쿼리 집합)를 “무효화(invalidate)”해, 이미 캐시에 저장된 데이터가 더 이상 유효하지 않게 되었음을 알리는 역할을 합니다. 무효화되면 해당 쿼리들은 “기존에 가져왔던 데이터가 오래되었다”고 표시되고, 기본 동작으로는 “액티브(활성) 쿼리”가 자동으로 재요청(refetch) 되면서 새로운 데이터를 받아오게 됩니다.
    • 이때, “어떤 쿼리”를 대상으로 “어떻게” 재요청을 수행할지 결정하는 옵션이 바로 refetchType 입니다.
  • refetchType: 'active' (기본값)

    • 의미: 액티브(활성) 쿼리만 “즉시” 백그라운드에서 재요청한다.
    • “액티브(활성)” 이란? useQuery() 등의 훅을 통해 현재 컴포넌트에서 렌더링되고 있는(구독 중인) 쿼리를 뜻합니다.
    • 무효화 대상 중 “비활성”된 쿼리(화면에 구독 중이지 않은 쿼리)는 “즉시” 재요청하지 않습니다. 비활성 쿼리는 이후에 다시 해당 키로 useQuery()가 마운트되면 새로 요청이 일어납니다.
  • refetchType: 'inactive'

    • 의미: 비활성(inactive) 쿼리만 “즉시” 백그라운드에서 재요청한다.
    • 즉, 현재 화면에서 액티브하게 사용 중인(렌더링 중인) 쿼리는 다시 요청하지 않고, 화면에 없는 쿼리들(비활성)이 재요청을 수행합니다.
    • 이는 다소 특수한 시나리오에서 유용합니다. 예를 들어, 화면에는 안 보이지만 백그라운드에서 데이터만 미리 갱신해 두고 싶은 상황이 있을 수 있습니다.
  • refetchType: 'all'

    • 의미: 무효화 대상인 쿼리가 활성/비활성인지 상관없이, 모두 재요청(refetch)한다.
    • 즉, 화면에 렌더 중인 쿼리이든, 렌더 중이 아니든 한꺼번에 데이터를 다시 불러오도록 합니다.
    • “어떤 쿼리”가 대상이 될지는 queryKey나 다른 필터를 통해 결정됩니다.
  • refetchType: 'none'

    • 의미: 무효화되지만, 즉시 재요청은 하지 않는다.
    • 즉, “무효화”만 수행하고, “지금 당장” 백그라운드 재요청을 일으키지 않음.
    • 그래서 해당 쿼리는 데이터를 “오래됨” 상태로 표시만 하고, 나중에 컴포넌트가 리렌더링되거나, 다시 요청이 필요한 시점(예: useQuery()가 새로 마운트됨)에서 다음 요청을 보냅니다.
    • 예: 이벤트 상세 페이지에서 이벤트를 삭제한 뒤 “즉시 재요청”이 일어나 404를 발생시키는 것을 방지하기 위해 refetchType: 'none'을 사용하면 유용합니다.
  • 정리: 각 refetchType 요약

    • 'active' (기본값): 무효화 후, 활성 쿼리들만 즉시 재요청
    • 'inactive': 무효화 후, 비활성 쿼리들만 즉시 재요청
    • 'all': 무효화 후, 활성 + 비활성 모든 쿼리를 즉시 재요청
    • 'none': 무효화 후, 즉시 재요청은 하지 않음(데이터는 ‘오래됨’으로 표시)

이렇게 refetchType 옵션을 통해 무효화한 쿼리가 언제(지금 당장? 다음 렌더?), 어떤 상태(액티브/비활성)에 있을 때 재요청을 하는지 세밀하게 제어할 수 있습니다.

/**
 * 만약 위의 이벤트 상세를 불러오는 쿼리키가 queryKey: ['event-detail', eventId] 이게 아니라 queryKey: ['events', eventId] 이런 형식이었다면,
 * events 쿼리키의 데이터를 무효화처리하고 events 쿼리키 관련 쿼리를 호출하기 때문에
 * queryKey: ['events', eventId] 이 쿼리키도 호출됐을 것.
 * 즉, 현재 이벤트 상세페이지에서 해당 이벤트를 삭제하고, 삭제된 이벤트 상세를 불러오려하니 404가 발생하는 것
 * 즉, 쿼리키가 겹칠 때, 바로 호출은 하지 말라는 뜻에서 refetchType: 'none' 설정!!!
 * */

367. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 데모 앱 개선 및 변형 개념 반복

아래 내용은 이벤트 상세 페이지를 개선하여 삭제 확인 모달을 띄우고, 진행 중 로딩 상태와 오류 처리를 좀 더 사용자 친화적으로 만드는 과정을 정리한 것입니다. 예시로, 사용자에게 “삭제할까요?”를 묻는 모달을 표시하고, 삭제가 진행되는 동안 로딩 상태를 표시하며, 오류가 생겼을 경우 메시지를 보여주는 흐름입니다.

  1. 삭제 준비 상태(isDeleting State)

    • 이벤트 상세 페이지(EventDetails.tsx)에서 useState를 사용해 isDeleting 여부를 관리한다.
    • 사용자가 "Delete" 버튼(최초의 버튼)을 클릭하면 isDeletingtrue로 설정 → 확인 모달을 열게 된다.
    • 확인 모달 내에서 "취소" 버튼을 누르면 isDeleting = false로 되돌려 모달을 닫는다.
    • 확인 모달 내에서 "삭제" 버튼을 누르면 실제 useMutationmutate 함수를 호출 → HTTP DELETE 요청 전송.
  2. 삭제 로딩/오류 처리

    • 기존 useMutation 객체에 있는 isPending, isError, error를 별칭으로 구조분해 할당

      const {
        mutate: mutateDelete,
        isPending: isDeletingNow,         // 예: isPending -> 별칭 isDeletingNow
        isError: isDeleteError,
        error: deleteError
      } = useMutation<...>({ ... })
    • 삭제 확인 모달 내부에서

      • isDeletingNowtrue라면 "Deleting..." 같은 문구로 로딩상태 표시
      • 오류(isDeleteError === true)가 발생하면 deleteError 활용해 오류 메시지 출력
  3. 모달 표시/숨김

    • isDeleting 값이 true면, Modal 컴포넌트가 렌더링되어 삭제 여부를 묻는다.
    • Modal을 닫으면(onClose) → isDeleting = false.
  4. 전체 흐름

    • 사용자 "Delete" 버튼 클릭 (이벤트 상세 뷰)
    • isDeleting = true → 확인 모달 출력
    • 모달 안에서 "취소" → isDeleting = false 모달 닫기
    • 모달 안에서 "삭제" → mutateDelete(eventId) → 진행 중 상태(isDeletingNow = true) → 완료 후(onSuccess) navigate('/') + invalidateQueries(‘events’) → 홈으로 이동
flowchart TB
    A((EventDetails 컴포넌트)) --> B[Delete 버튼 클릭<br/>setIsDeleting(true)]
    B --> C{조건: isDeleting === true?}
    C -->|Yes| D[확인 모달(Modal) 표시]
    D --> E[취소 버튼 클릭<br/> setIsDeleting(false)]
    D --> F[삭제 버튼 클릭 <br/> mutateDelete(eventId)]
    F --> G[useMutation 진행중 -> isDeletingNow = true -> 로딩 표시]
    G --> H{onSuccess or onError}
    H -->|성공| I[invalidateQueries({ queryKey: ['events'], refetchType: 'none' }) + navigate('/')]
    H -->|오류| J[오류 메시지 표시 (deleteError)]
  • A: 기존 EventDetails 페이지 컴포넌트.

  • B→C: 사용자가 "Delete" 버튼을 누르면 isDeleting state = true로 전환.

  • C: isDeleting이 true면 모달 표시.

  • 모달 내부:

    • 취소: isDeleting = false → 모달 닫힘.
    • 삭제: mutateDelete(eventId).
  • F→G→H: useMutation 진행 → 완료 시 onSuccess/onError.

  • I: 성공 시 전체 이벤트 목록 쿼리 무효화 및 홈으로 이동.

  • J: 오류 발생 시 에러 메시지 표시.

  • 결론

    • 모달을 통해 삭제 확인 단계를 추가하면 사용자 경험이 좋아집니다.
    • useMutation 로직 자체는 그대로이며, 추가로 isDeleting(사용자 UI 상태)와 isDeletingNow(HTTP 진행 여부)로 나누어 각 단계를 명확히 분리 가능합니다.
    • invalidateQueries({ queryKey: ['events'], refetchType: 'none' })를 통해 즉시 재요청을 막고, 페이지 전환과 함께 재로딩이 일어날 때만 데이터가 갱신되도록 조절할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

368. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 리액트 쿼리의 실제 이점

아래 내용은 “이벤트 편집 페이지”(EditEvent)에서 이벤트 데이터를 먼저 로드한 후 폼에 미리 채워진 상태로 표시하는 작업을 설명합니다. 즉, 유효한 이벤트 ID를 통해 백엔드에서 이벤트 정보를 가져오고, 그 데이터를 EventForminputData로 전달해 기존 이벤트 내용을 편집할 수 있게 하는 흐름입니다.

  1. EditEvent 컴포넌트로 이동

    • 이 컴포넌트(모달)에서 useQuery 훅을 사용해 이벤트 ID(params.id)에 해당하는 데이터를 불러옵니다.
    • queryKey['event-detail', params.id] 등으로 설정하고, queryFn에서 fetchEvent 함수를 호출합니다.
    • EventForm에는 inputData 프로퍼티가 있으며, 로드된 이벤트 데이터(data)를 inputData로 넘겨 폼을 미리 채워줍니다.
  2. 로딩·오류 처리

    • isLoading 또는 isPending(버전에 따라)을 통해 로딩 스피너 표시.
    • isError/ error로 오류 블록 표시.
    • 오류 시, Link를 통해 모달을 닫고 다른 페이지로 돌아갈 수 있게 처리.
  3. 캐시 활용

    • EventDetails(상세 보기) 컴포넌트와 EditEvent(편집 모달)에서 동일한 queryKey(['event-detail', id])fetchEvent 함수를 쓰면, 한 컴포넌트에서 이미 로딩했던 데이터가 다른 컴포넌트에서도 즉시 재사용됨.
    • 따라서 상세 보기에서 편집 화면을 열 때 즉시 데이터 표시 가능. (리액트 쿼리가 자동 캐시)
  4. 전체 흐름

    • Edit 버튼 클릭 → EditEvent 모달 열림.
    • useQueryfetchEvent(id) 실행 → 로딩 중(isLoading=true)이면 스피너, 오류시 에러 표시.
    • 데이터 수신 시 EventForminputData로 전달 → 폼 필드 자동 채움.
    • (이후, “Update” 버튼 눌렀을 때 변형(Mutation) 로직은 다음 단계)
flowchart TB
    A((EditEvent 컴포넌트)) --> B[useQuery fetchEvent(id)]
    B -->|데이터 로딩| C{isLoading?}
    C -->|true| D[로딩 스피너 표시]
    C -->|false| E{isError?}
    E -->|true| F[오류 블록 표시 + 모달 닫기 링크]
    E -->|false| G[EventForm inputData=data]
    G --> H(이미 저장된 이벤트 내용 자동 채움)
  1. A: EditEvent (모달 컴포넌트)
  2. B: useQuery 훅으로 이벤트 ID 기반 데이터 로드
  3. C→D: 로딩 중이면 스피너
  4. C→E→F: 오류면 에러 블록 + 복귀 링크
  5. G: 성공 시 EventForminputData=data 전달
  6. H: 폼 필드 자동 채움(기존 이벤트 정보)
  • 결론
    • EditEvent(편집 모달)에서 useQuery로 이벤트를 로드해 기존 데이터로 폼을 미리 채움.
    • 로딩 시 LoadingIndicator, 오류 시 ErrorBlock 표시.
    • 리액트 쿼리 캐시 덕분에, 상세 페이지(EventDetails)에서 편집 모달로 전환 시 즉시 데이터 표시 가능(이미 캐시됨).
    • 잘못된 ID로 접근하면 오류 메시지를 표시, 폼 대신 복귀 버튼을 보여줄 수 있음.
    • “Update” 로직은 useMutation을 추가 구현해 onSubmit에서 호출 가능.

이처럼 이미 존재하는 데이터를 다시 가져와 편집 모달을 미리 채우는 흐름은 리액트 쿼리로 매우 간단히 구현할 수 있습니다.

react-13 프로젝트 코드 참고하면됨

369. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 변형을 사용하여 데이터 업데이트

아래 예시는 “이벤트 편집 기능”을 단순히 Update 요청만 보내고, invalidateQueries 없이 navigate로 돌아가는 형태로 구현한 버전입니다. 즉 updateEvent 함수로 PUT(또는 PATCH) 요청을 전송하고, onSuccess 대신 직접 navigate를 호출하여 모달(또는 페이지)을 닫는 방식을 보여줍니다.

  • 정리
    1. EditEvent 컴포넌트에서 useQuery로 해당 이벤트 정보를 가져와 EventForm에 채워 넣음
    2. 폼에서 “Update” 버튼을 클릭하면 useMutation(updateEvent)을 호출하여 백엔드에 PUT 요청 전송
    3. 요청이 끝나면 navigate를 통해 이전 페이지(세부 정보 페이지 등)로 돌아감
      • 이 시점에 invalidateQueries를 호출하지 않아 UI에 갱신이 반영되지 않음(수동 새로고침 후 바뀐 데이터 확인)

주의: 실제로는 invalidateQueries(또는 Optimistic Update, Query Re-fetch 등)를 통해 편집 후 화면에 새 데이터 반영이 이뤄지도록 하는 편이 좋습니다. 여기서는 invalidateQueries 없이 navigate만 하는 예시임.

[Edit 버튼 클릭] 
   ↓ (라우터 이동: /events/:id/edit)
[EditEvent 컴포넌트] 
   ↓ useQuery(fetchEvent) → 기존 이벤트 데이터 로드
   ↓ useMutation(updateEvent)
   ↓ "Update" 클릭
   ↓ mutate({ id, event: formData }) 전송
   ↓ [백엔드 PUT 요청 -> DB 수정]
   ↓ 완료 후 -> navigate("/events/:id" 등) 
   (InvalidateQueries 없음 -> UI 즉각 반영 X)
  • 요약
    • EditEvent 컴포넌트에서 useQuery로 기존 이벤트 정보를 가져와서 EventForminputData로 전달
    • EventForm에서 입력(수정) 후 onSubmitupdateEvent 뮤테이션을 mutate로 호출
    • 요청 완료 후, navigate(events/:id)로 돌아가지만 invalidateQueries를 하지 않으므로 곧바로 UI가 갱신되진 않음(새로고침 시 반영)

이처럼 updateEvent 요청 이후 즉시 navigate만 하는 예시 코드를 통해, invalidateQueries 없이 화면에 바로 반영되지 않는 시나리오를 확인할 수 있습니다.

(그런데 실습해보니까 되는데..? 난 왜 되는거지..? tanstack query 버전이 달라서 그런건가?)

react-13 프로젝트 코드 참고하면됨

370. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 낙관적 업데이트

아래 정리는 “낙관적 업데이트(Optimistic Updates)” 를 구현하기 위한 전반적인 과정과 예시 코드입니다. 중요 포인트: 백엔드와 프론트엔드 사이에 업데이트 요청을 보내는 동안 사용자 화면에 즉시 새로운 데이터를 적용하여 “퍼포먼스가 매우 빠르게 느껴지는 UX”를 제공하고, 만약 요청이 실패하면 롤백해버리는 기능입니다.

  1. 현재 문제

    • 업데이트 로직에 queryClient.invalidateQueries 방식만 사용하면, 요청이 끝날 때까지 화면에 반영되지 않음 → 사용자 입장에서는 응답을 기다리는 불편함이 있음.
    • 낙관적 업데이트를 이용해 요청이 성공할 것이라고 가정하고 바로 캐시 데이터를 변경해 화면에 반영할 수 있다.
      • 실패 시에는 롤백하여 기존 데이터 복원.
  2. 낙관적 업데이트 기본 흐름

    • onMutate (Mutation 옵션)
      • 요청 직전(응답 받기 전)에 실행되는 콜백
      • queryClient.cancelQueries(queryKey): 해당 쿼리와 관련된 진행 중인 요청을 취소 (충돌 방지)
      • const previousData = queryClient.getQueryData(...): 이전 캐시 데이터 저장 → 롤백용
      • queryClient.setQueryData(...): 응답 기다리지 않고 새 데이터(업데이트 후 상태)로 캐시 수정 → UI 즉시 반영
      • 반드시 return { previousData }: 이 반환 값은 onError에서 사용될 context가 됨
    • onError
      • 업데이트(변경) 요청이 실패했을 때 실행되는 콜백
      • queryClient.setQueryData(...)로 이전 데이터(previousData) 복원 → 낙관적 업데이트 취소(롤백)
    • onSettled
      • 성공/실패 관계없이 mutation이 최종적으로 끝났을 때 실행
      • 여기서 queryClient.invalidateQueries(...) 등을 다시 수행해 백엔드와 최종 데이터 동기화 가능.
  3. 장점과 주의점

    • 장점:
      • UI가 즉시 업데이트되어 사용자 경험이 좋아진다.
      • 백엔드 응답 대기 없이 빠른 퍼포먼스 느낌.
    • 주의점:
      • 실패 시 실제 데이터를 롤백해야 함.
      • 쿼리 키 설계에 주의(어떤 캐시를 변경할지 결정).
      • cancelQueries/rollback 로직 누락 시, 의도치 않은 데이터 충돌 및 UI 오류 발생할 수 있음.
flowchart TB
    A[사용자 "Update" 클릭] --> B[React Query Mutation mutate() 호출]
    B -->|즉시 실행| onMutate
    onMutate -->|1. cancelQueries| queryClient
    onMutate -->|2. getQueryData| previousData
    onMutate -->|3. setQueryData(newData)| 캐시
    캐시 --> UI[화면 즉시 반영]
    B -->|네트워크 요청| 백엔드(업데이트)

    백엔드(업데이트) -->|응답 성공| onSuccess
    onSuccess --> onSettled
    onSettled -->|invalidateQueries| queryClient
    onSettled --> UI

    백엔드(업데이트) -->|응답 실패| onError
    onError -->|setQueryData(previousData)| 캐시
    캐시 --> UI[화면 롤백]
    onError --> onSettled
  • onMutate: 낙관적 업데이트 (cancel + setQueryData)

  • onError: 실패 시 롤백

  • onSettled: 어떤 결과든(성공/실패) 최종 정리 (invalidateQueries로 백엔드와 동기화 등)

  • 요약

    • onMutate에서 cancelQueries + getQueryData + setQueryData로 낙관적 업데이트 실시
    • onError에서 롤백
    • onSettled에서 invalidateQueries로 백엔드 동기화

이렇게 하면, 업데이트 버튼 클릭 → UI 즉시 반영 → 요청 실패 시 롤백 → 최종적으로 성공하면 onSettled에서 재확인 가능.

  • 선택 결론
    1. 낙관적 업데이트로 UX 향상
    2. 실패 시 롤백 (onError 활용)
    3. 성공/실패 후 onSettled로 최종 동기화 가능

이 과정을 통해 응답 대기 시간 없이 UI가 즉시 업데이트되는 “부드러운 사용자 경험”을 제공하면서, 실패 시에는 이전 데이터로 복원할 수 있다.

react-13 프로젝트 코드 참고하면됨

371. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 쿼리 키를 쿼리 함수 입력으로 사용

위 내용에서 설명한 핵심 포인트는 fetchEvents 함수를 더 확장하여 max 쿼리 매개변수를 처리하고, NewEventsSection(또는 다른 컴포넌트)에서 이 max 값을 queryKeyfetchEvents에 함께 넘기는 것입니다. 이때, **검색어(searchTerm)와 max**를 동시에 사용할 수도 있고, 하나만 쓸 수도 있으므로, fetchEvents 함수에서는 다양한 경우(둘 다 설정 / 하나만 설정 / 둘 다 없음)에 맞춰 URL을 적절히 만들도록 처리합니다. 또한, 리액트 쿼리에서 제공하는 쿼리 함수의 인자로 들어오는 context 객체({ queryKey, signal, ... })를 효율적으로 쓰기 위해, queryKey[1]에 저장된 값을 전개(spread)하여 한꺼번에 fetchEvents에 넘기는 방식을 쓰면, 코드 중복을 방지할 수 있습니다.

  1. fetchEvents 함수 확장

    • 기존에 searchTerm만 처리하던 로직을 max 쿼리 매개변수도 추가 처리하도록 수정합니다.
    • searchTermmax 둘 다 있을 수도, 하나만 있을 수도, 없을 수도 있기 때문에, 각각의 경우에 맞춰 URL을 만들거나 혹은 좀 더 깔끔한 로직으로 처리할 수 있습니다. (예: 쿼리 파라미터를 배열에 저장 후 join('&')로 합치는 방식 등)
  2. 리액트 쿼리 queryKey & fetchEvents 함수 파라미터 연결

    • NewEventsSection(또는 다른 컴포넌트)에서 max 값을 3으로 지정한다면
    useQuery({
      queryKey: ['events-limited', { max: 3 }],
      queryFn: (ctx) => {
        const { signal, queryKey } = ctx
        // queryKey[1] = { max: 3 }
        return fetchEvents({ signal, ...queryKey[1] })
      },
      ...
    })
    • fetchEvents 안에서는 max 값이 있으면 ?max=... 쿼리 파라미터로 붙여서 백엔드로 요청.
    • 이렇게 하면 백엔드에서 max 쿼리 파라미터를 인식해, 맨 뒤의 max개만 리턴하도록 만들 수 있습니다.
  3. 코드 중복 방지 (spread 연산자)

    • 리액트 쿼리의 queryFn으로 넘어오는 ctx에서 ctx.queryKey[1](두 번째 배열 요소)을 꺼낸 뒤, fetchEvents에 그대로 전개 연산자를 이용해 넘기면 됩니다.
    queryFn: ({ queryKey, signal }) => {
      return fetchEvents({
        signal,
        ...queryKey[1] // { max: 3 }라든가, { searchTerm: 'abc' } 등
      })
    },
    • 이 방식으로 searchTerm, max, 그 외 필요 옵션들을 한꺼번에 넘길 수 있어 코드가 깔끔해집니다.
  4. 최종 결과

    • NewEventsSection에서는 3개의 최신 이벤트만 표시.
    • FindEventSection에서는 검색 기능 수행.
    • 서로 다른 컴포넌트에서 searchTerm, max 등 여러 옵션을 자유롭게 조합하여 fetchEvents를 재활용할 수 있습니다.
[NewEventsSection.tsx] 
   └─ useQuery → queryKey: ['events-limited', { max: 3 }]
                 queryFn({ queryKey, signal }) {
                    fetchEvents({ signal, ...queryKey[1] });
                 }
                    ↓
                fetchEvents({ signal, max: 3, searchTerm?: '' })
                    ↓
             (백엔드 GET /events?max=3 → 최근 3개의 이벤트 반환)
                    ↓
   └─ Re-render UI with top 3 events
  • 정리
    • 검색(searchTerm) + max 값 등 여러 옵션을 한꺼번에 fetchEvents에 넘기려면,
      1. fetchEvents 함수에서 이 값을 받아, 동적으로 URL 쿼리 파라미터를 구성.
      2. 컴포넌트(NewEventsSection 등)에서는 queryKey['특정-키', { searchTerm, max }] 형태로 만들고, queryFn에서 fetchEvents({ signal, ...queryKey[1] })로 호출.
    • 이렇게 하면 중복되는 코드 없이도 다양한 매개변수를 하나의 객체에 넣어 편리하게 사용할 수 있습니다.
    • 백엔드에서는 req.query.max 혹은 req.query.search를 확인해, 이벤트 배열을 원하는 대로 필터링/슬라이싱 처리하도록 작성합니다.

위와 같은 방식으로 max만 사용해서 “최근 N개” 이벤트만 가져오거나, searchTerm과 조합해 “최근 N개의 검색된 이벤트”만 가져오는 등 유연한 확장이 가능합니다.

react-13 프로젝트 코드 참고하면됨

372. 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 리액트 쿼리와 리액트 라우터

----- [예시 코드] 리액트 쿼리/Tanstack 쿼리: 간단하게 HTTP 요청 처리 / 리액트 쿼리와 리액트 라우터 -----

아래 정리 내용은 리액트 라우터(데이터 로더 & 액션)와 리액트 쿼리를 함께 사용하는 방법을 중심으로, 기존에 학습한 리액트 쿼리의 캐싱 및 스테일 처리와 라우팅을 결합하는 사례를 살펴봅니다. (이 정리는 리액트 라우터 v7+에서 제공하는 데이터 로더/액션 기능, 그리고 리액트 쿼리 최신 버전(예: @tanstack/react-query ^5.x)을 가정하고 있습니다.)

  • 리액트 라우터의 로더(Loader)

    • 리액트 라우터에서 loader 함수를 사용하면, 특정 라우트에 접근할 때 해당 컴포넌트가 렌더링되기 전에 데이터를 미리 가져올 수 있습니다.
    • 즉, 라우터가 컴포넌트를 표시하기 전에 loader가 반환하는 프로미스가 완료될 때까지 기다렸다가 화면을 그립니다.
    • 이 로더 내부에서 리액트 쿼리의 queryClient.fetchQuery(...)를 직접 사용하면, 리액트 쿼리 캐시에 데이터를 미리 로드해 둘 수 있습니다.
  • 리액트 라우터의 액션(Action)

    • 라우터의 action 함수는 양식(form)을 제출할 때 트리거되어, 서버로 데이터를 전송하거나 백엔드 로직을 수행하고, 리다이렉트 등의 후속 동작을 결정합니다.
    • action 내부에서 updateEvent(...) 같은 함수를 직접 호출해 데이터를 전송/변형할 수 있습니다.
    • 이후 queryClient.invalidateQueries(...) 등을 통해 관련 캐시를 무효화하여, 다음 렌더링 시 최신 데이터가 반영되도록 만들 수 있습니다.
  • 리액트 쿼리와 리액트 라우터의 결합 방식

    1. 로더(Loader) 단계
      • 라우터가 컴포넌트를 렌더링하기 전에 loader에서 queryClient.fetchQuery({ queryKey, queryFn })를 호출해 캐시에 미리 데이터를 채워둡니다.
      • 이렇게 하면 컴포넌트가 실제로 렌더링될 때, useQuery가 캐시에 이미 있는 데이터를 사용해 빠르게 화면을 그립니다.
      • (중복 요청 방지를 위해 staleTime을 적절히 설정하면, 방금 fetchQuery로 가져온 데이터가 일정 시간 동안은 stale하지 않으므로 추가 HTTP 요청을 보내지 않게 됩니다.)
    2. 컴포넌트에서 useQuery 계속 사용
      • 컴포넌트는 useQuery로 캐시된 데이터를 사용하되, 로더에서 사전 로드된 캐시가 있으면 즉시 표시합니다.
      • 컴포넌트를 나갔다가 돌아와도, useQuery의 동작 원리에 따라 staleTime 등을 고려해 재검증할 수 있어요.
    3. 액션(Action)
      • 양식(form) 제출 시, 리액트 라우터의 액션이 호출되어 서버에 데이터 변형을 수행합니다.
      • 변형 완료 후 queryClient.invalidateQueries(...) 등으로 캐시를 무효화한 뒤, redirect(...)로 다른 페이지(또는 같은 페이지)로 이동시킵니다.
      • 낙관적 업데이트를 사용하려면, action 대신 (또는 병행) useMutation에서 onMutate/onError 같은 로직을 작성할 수도 있습니다.
      • 반면 액션에선 “동기화 완료 후 결과를 표시하는 전통적인 폼 전송 흐름”을 사용할 수도 있습니다.
  • 중복 요청 방지를 위한 staleTime 설정

    • 로더에서 이미 최신 데이터를 가져왔는데, 컴포넌트에서 useQuery가 중복 요청을 또 보낼 수 있습니다.
    • 이를 방지하려면, useQuery의 옵션으로 staleTime을 설정하면 됩니다(예: staleTime: 10_000).
    • staleTime 동안은 캐시가 stale하지 않으므로, 추가 fetch가 일어나지 않고, 로더에서 받아온 캐시된 데이터를 그대로 사용합니다.
  • 로딩/오류 표시

    • 로더 사용 시, 라우트 진입 전 로딩을 기다리므로, “컴포넌트가 렌더링되기 전”에 기다리는 형태가 됩니다.
    • 추가로 컴포넌트 내에서 useQuery 로딩 표시를 할지, 라우터에서 제공하는 라우트 전 로딩 표시를 할지는 선택 사항입니다.
    • 오류 처리도 마찬가지로, 라우터에서 errorElement로 전환할 수 있거나, 컴포넌트 내부에서 isError로 별도 처리하는 방식을 선택할 수 있습니다.
[사용자가 /events/:eventId/edit 라우트 진입] 
        │
        ↓ (라우터: loader 함수 실행)
(loader) → queryClient.fetchQuery({ queryKey: ['event-detail', eventId], queryFn: fetchEvent })
        │
        ├─> 백엔드 GET /events/:eventId HTTP 요청
        │
        └─> 응답 수신 → 캐시에 저장 (queryKey='event-detail', eventId)
        │
        └─> loader 반환 → 라우터가 컴포넌트 렌더링 시작
                    ↓
[EditEvent 컴포넌트] useQuery( queryKey=['event-detail', eventId], ... )
        │
        ├─ 캐시에 이미 데이터가 있으면 즉시 표시 (staleTime 내면 추가 요청 X)
        ├─ staleTime 만료 등 발생 시 → 추가 fetch
        │
        ▼ (사용자가 폼을 수정 후 “Update” 버튼 클릭)
(action) → form submit → action 함수 호출
        │
        └─ action 내부:
            1) request.formData()로 폼 데이터 파싱
            2) updateEvent({ id: eventId, event: updatedEventData })
            3) queryClient.invalidateQueries(['some-query'])
            4) return redirect('/events/:eventId' etc.)
  • 정리
    1. 리액트 라우터의 loader/action과 리액트 쿼리를 결합하면,
      • loader로 먼저 queryClient.fetchQuery → 사전 로드
      • 컴포넌트에서 useQuery가 캐시 데이터 사용
      • action으로 양식 전송 시 데이터 변형 + 캐시 무효화 + 리다이렉트
    2. 컴포넌트는 기존처럼 useQuery 사용하되, staleTime을 통해 중복 요청 방지 가능
    3. 폼 전송은 useSubmit 훅으로 라우터 action을 호출, action 내부에서 http 요청 → 무효화 → redirect

이러한 구조는 라우트 전 데이터 준비폼 제출 후 처리를 리액트 라우터 방식으로 깔끔히 관리할 수 있다는 장점이 있습니다. 반면 리액트 쿼리의 낙관적 업데이트, isPending 같은 세밀한 상태 제어는 action 만으로 구현하기가 다소 복잡할 수 있으므로, 프로젝트 상황에 따라 선택적으로 병행 활용합니다.


  • 복습

    • 개요
      • 리액트 라우터에는 데이터를 미리 가져오거나(action 함수로) 변경할 수 있는 loader / action 기능이 있음.
      • 이 기능을 리액트 쿼리와 결합하면, 컴포넌트가 마운트되기 전에(또는 폼 제출 시에) 데이터를 미리 가져오거나 수정할 수 있고, 리액트 쿼리 캐시를 통해 중복 요청을 피하거나 낙관적 업데이트와 같은 기능을 계속 활용할 수 있음.
      • 예시로 EditEvent 라우트에서 loader를 사용해 이벤트 상세 정보를 사전에 fetch하고, action을 사용해 PUT(업데이트) 요청을 보낼 수 있음.
      • 컴포넌트 단에서는 계속 useQuery, useMutation 등을 사용할 수도 있지만, staleTime을 신경 써서 중복 요청을 막는 식으로 조합 가능함.
  • 리액트 라우터 + 리액트 쿼리 결합 흐름

    1. loader 함수 작성
      • queryClient.fetchQuery() 등의 API를 통해 리액트 쿼리의 캐시에 미리 데이터를 로드함.
      • loader 함수는 라우터가 컴포넌트를 렌더링하기 직전에 동작하므로, 프로미스를 반환하면 해당 프로미스가 완료될 때까지 렌더링을 대기함(즉, 사용자가 보게 될 페이지가 이미 데이터가 준비된 상태).
    2. useLoaderData (리액트 라우터 API)
      • loader가 반환한 데이터를 훅(useLoaderData)으로 받아서 바로 쓸 수도 있지만, 리액트 쿼리 캐시와 연동하려면, useQuery를 그대로 사용할 수도 있음.
      • loader에서 불러온 데이터가 리액트 쿼리 캐시에 저장되어 있으면, useQuery가 실행되어도 추가 네트워크 요청 없이 캐시가 바로 사용됨.
    3. action 함수 작성
      • form을 제출하거나, 프로그래매틱으로 submit() 함수를 호출하면 action 함수가 실행됨.
      • action 함수 내부에서 updateEvent 같은 HTTP PUT/POST 함수를 직접 호출할 수 있음.
      • 작업이 끝나면 queryClient.invalidateQueries(...)로 필요한 쿼리들을 무효화 → 재동기화.
      • 끝으로 redirect("/some/path")를 반환해서 화면을 전환할 수 있음.
    4. 컴포넌트에서는 loader / action을 이용할지, 기존 useMutation / useQuery를 유지할지 자유.
      • 굳이 useQuery나 useMutation을 안 쓰고 리액트 라우터만으로 해도 되지만, 그럴 경우 리액트 쿼리 캐시나 낙관적 업데이트, refetchType 옵션 등을 쓸 수 없음.
      • 반대로 리액트 쿼리를 쓰되, loader에서 이미 fetch했기 때문에 staleTime 설정으로 중복 요청을 방지 가능.
    5. 중복 HTTP 요청 방지
      • loader에서 먼저 fetch한 후, 컴포넌트 useQuery도 동일한 캐시 키를 쓰면, staleTime을 어느 정도 늘려주면 추가 요청을 하지 않고 캐시를 사용함.
    6. 전역 fetch 상태 표시
      • useIsFetching 훅을 통해 리액트 쿼리가 현재 진행 중인 fetch가 있는지 전역에서 확인 가능 → 로딩 인디케이터 표시.
  • 장단점

    • 장점
      • 초기 데이터 로드 시 router가 미리 fetch → “로딩 화면” 없이 UI를 바로 표시 가능.
      • 리액트 쿼리 캐시에 저장되어, 다른 곳에서 useQuery 실행 시 중복 요청 방지.
      • action / loader만으로도 CRUD가 가능하므로, useMutation이 필요 없을 수 있음.
    • 단점
      • loader/action 로직과 컴포넌트 로직이 분리 → 한 군데서 몰아서 관리하고 싶다면 번거롭거나, 낙관적 업데이트/에러핸들링 등이 조금 더 복잡해질 수 있음.
      • staleTime 설정을 잘못하면, 중복 fetch나 stale data 문제가 발생.
[사용자 → 페이지 로드] 
  ↓
[리액트 라우터] - loader 함수 호출
  ↓ (queryClient.fetchQuery('event-detail', fetchEvent)...)
[백엔드로 GET 요청 → 이벤트 상세] 
  ↓ (데이터 응답)
[리액트 쿼리 캐시에 저장]
  ↓
[컴포넌트(EditEvent) 렌더링]
  ↓ (useQuery('event-detail') 호출) 
[이미 캐시에 데이터 있음 → 즉시 반환(또는 staleTime 지나면 fetch)]
[EditEvent 폼 제출] 
  ↓ (리액트 라우터 - action 함수 호출)
[action() → updateEvent(id, data)]
  ↓ (서버로 PUT 요청)
[완료 후 queryClient.invalidateQueries(...) → 재동기화]
  ↓
[redirect("/events/:eventId")]
  • 요약
    • 리액트 라우터의 loader / action을 통해 컴포넌트가 마운트되기 전(또는 폼 제출 시) 데이터를 불러오거나 수정할 수 있음.
    • 리액트 쿼리의 **queryClient.fetchQuery()**나 invalidateQueries() API를 loader/action 내부에서 호출 → 캐싱 / 무효화를 그대로 활용 가능.
    • 컴포넌트에서는 여전히 useQuery를 사용할 수 있으며, 이미 loader에서 가져온 데이터가 캐시에 있기 때문에 staleTime에 따라 중복 요청 방지.
    • 액션 기반 폼 제출 시, 낙관적 업데이트는 직접 짜야 함(액션이 끝날 때까지 대기). 그렇지 않으면 useMutation 방식으로 여전히 낙관적 업데이트를 쓸 수 있음.
    • 전역 fetch 인디케이터를 표시하고 싶다면 useIsFetching() 훅 사용.

이렇게 리액트 쿼리와 리액트 라우터를 함께 사용하면, 사전에 로드된 데이터와 캐시 사용, 폼 전송 시 액션 함수와 무효화 등을 편리하게 결합해 최적의 사용자 경험을 제공할 수 있습니다.