Sharing State Between Components

김동현·2026년 3월 15일

title: 컴포넌트 간에 State 공유하기 (Sharing State Between Components)

가끔은 두 컴포넌트의 state(상태)가 항상 함께 맞물려 변경되기를 원할 때가 있을 거예요. 그럴 때는 두 컴포넌트에서 각각 가지고 있던 state를 제거하고, 그 두 컴포넌트의 가장 가까운 공통 부모 컴포넌트로 state를 옮긴 다음, props를 통해 자식들에게 다시 전달해 주면 됩니다.

이것을 state 끌어올리기(lifting state up) 라고 부르는데요, 리액트로 코드를 작성하면서 앞으로 여러분이 정말 숨 쉬듯이 자연스럽게 하게 될 가장 흔하고 중요한 작업 중 하나랍니다. (리액트는 데이터가 위에서 아래로 흐르는 단방향 데이터 흐름을 가지기 때문에, 형제 컴포넌트끼리 데이터를 주고받으려면 부모를 거쳐야만 하거든요!)

  • State를 위로 끌어올려서 컴포넌트 간에 공유하는 방법
  • 제어(controlled) 컴포넌트와 비제어(uncontrolled) 컴포넌트가 무엇인지 (나중에 실무에서 폼(Form) 요소나 공통 UI 라이브러리를 만들 때 이 두 가지 개념을 구분하는 게 아주 중요해집니다!)

예제로 보는 State 끌어올리기 {/lifting-state-up-by-example/}

자, 이 예제에서는 부모인 Accordion 컴포넌트가 두 개의 분리된 Panel 컴포넌트들을 화면에 그려주고(렌더링하고) 있어요.

  • Accordion
    • Panel
    • Panel

각각의 Panel 컴포넌트는 자신의 내용이 화면에 보일지 말지를 결정하는 isActive라는 불리언(boolean) state를 가지고 있습니다.

아래 코드에서 두 패널의 'Show(보기)' 버튼을 각각 눌러보세요:

import { useState } from 'react';

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>
          Show
        </button>
      )}
    </section>
  );
}

export default function Accordion() {
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel title="About">
        With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
      </Panel>
      <Panel title="Etymology">
        The name comes from <span lang="kk-KZ">алма</span>, the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild <i lang="la">Malus sieversii</i> is considered a likely candidate for the ancestor of the modern domestic apple.
      </Panel>
    </>
  );
}
h3, p { margin: 5px 0px; }
.panel {
  padding: 10px;
  border: 1px solid #aaa;
}

버튼을 눌러보셨나요? 한 패널의 버튼을 눌러도 다른 패널에는 아무런 영향을 주지 않는다는 걸 눈치채셨을 거예요. 두 패널은 완전히 독립적으로 동작하고 있죠.

처음에는 각 PanelisActive state가 false이기 때문에 둘 다 접혀 있는 상태로 보입니다.

어느 Panel이든 버튼을 클릭하면 해당 Panel 자신의 isActive state만 단독으로 업데이트됩니다. 다른 패널은 전혀 신경 쓰지 않아요.

하지만 이제 기획이 바뀌어서, 한 번에 오직 하나의 패널만 열려 있도록 만들고 싶다고 가정해 볼게요. 즉, 두 번째 패널을 열면 첫 번째 패널은 자동으로 닫혀야 하는 구조인 거죠. 아코디언 메뉴에서 흔히 볼 수 있는 동작이죠? 이럴 땐 어떻게 해야 할까요?

이 두 패널을 조화롭게 제어하려면, 컴포넌트들의 state를 부모 컴포넌트로 "끌어올려야(lift up)" 합니다. 이 작업은 다음 세 가지 단계로 나눌 수 있어요. 자, 잘 따라와 보세요!

  1. 자식 컴포넌트들에서 state를 제거(Remove) 합니다.
  2. 공통 부모 컴포넌트에서 하드코딩된 데이터를 props로 전달(Pass) 해봅니다.
  3. 공통 부모 컴포넌트에 진짜 state를 추가(Add) 하고, 이벤트 핸들러와 함께 자식들에게 전달해 줍니다.

이렇게 하면 Accordion 컴포넌트가 두 Panel 컴포넌트를 지휘하면서 한 번에 하나만 열리도록 조율할 수 있게 됩니다.

1단계: 자식 컴포넌트에서 state 제거하기 {/step-1-remove-state-from-the-child-components/}

먼저 Panel 컴포넌트가 가지고 있던 isActive에 대한 통제권을 부모 컴포넌트에게 넘겨줄 거예요. 이 말은, 부모 컴포넌트가 isActive 값을 결정해서 Panel에게 prop으로 내려주겠다는 뜻이죠. 시작해 볼까요? Panel 컴포넌트에서 아래 코드를 과감하게 지워주세요:

const [isActive, setIsActive] = useState(false);

그리고 지운 대신, Panel이 받는 props 목록에 isActive를 추가해 줍니다:

function Panel({ title, children, isActive }) {

자, 이제 Panel의 부모 컴포넌트가 prop으로 값을 내려주어 isActive제어(control) 할 수 있게 되었습니다. 반대로 말하면, Panel 컴포넌트 입장에서는 이제 isActive 값이 뭐가 될지 아무런 통제권이 없어진 거예요. 모든 건 부모님(부모 컴포넌트) 마음대로 결정되는 거죠!

2단계: 공통 부모에서 하드코딩된 데이터 전달하기 {/step-2-pass-hardcoded-data-from-the-common-parent/}

State를 끌어올리기 위해서는 여러분이 조율하고자 하는 자식 컴포넌트의 가장 가까운 공통 부모 컴포넌트를 찾아야 합니다.

  • Accordion (가장 가까운 공통 부모)
    • Panel
    • Panel

우리 예제에서는 바로 Accordion 컴포넌트가 그 역할을 하네요. Accordion은 두 패널 모두의 위에 있고 그들의 props를 통제할 수 있으므로, 현재 어떤 패널이 열려 있는지에 대한 "진실의 원천(source of truth)"이 될 것입니다.

일단 실험 삼아, Accordion 컴포넌트가 두 패널 모두에게 isActive 값을 하드코딩해서(예를 들어, true로 고정해서) 전달하도록 만들어 볼까요?

import { useState } from 'react';

export default function Accordion() {
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel title="About" isActive={true}>
        With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
      </Panel>
      <Panel title="Etymology" isActive={true}>
        The name comes from <span lang="kk-KZ">алма</span>, the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild <i lang="la">Malus sieversii</i> is considered a likely candidate for the ancestor of the modern domestic apple.
      </Panel>
    </>
  );
}

function Panel({ title, children, isActive }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>
          Show
        </button>
      )}
    </section>
  );
}
h3, p { margin: 5px 0px; }
.panel {
  padding: 10px;
  border: 1px solid #aaa;
}

위 코드의 Accordion 컴포넌트 안에서 하드코딩된 isActive 값들을 falsetrue로 이리저리 바꿔가면서 화면에 어떻게 나타나는지 확인해 보세요. 부모가 자식을 완벽하게 제어하고 있는 게 보이시죠?

3단계: 공통 부모에 state 추가하기 {/step-3-add-state-to-the-common-parent/}

State를 위로 끌어올리다 보면 state에 저장해야 하는 데이터의 성격이 아예 바뀌는 경우가 종종 생깁니다. (이 부분이 프론트엔드 설계에서 굉장히 재밌는 지점이에요!)

우리의 목표는 한 번에 하나의 패널만 열리게 하는 거였죠. 즉, 공통 부모인 Accordion 컴포넌트는 어떤 패널이 열려 있는 상태인지를 추적하고 기억해야 합니다. 따라서 각 패널이 가지던 단순한 boolean(참/거짓) 값 대신, 현재 열려 있는 Panel의 순서(인덱스) 를 숫자로 저장하는 state 변수를 사용하는 것이 더 합리적일 거예요.

const [activeIndex, setActiveIndex] = useState(0);

activeIndex0일 때는 첫 번째 패널이 열려 있는 상태이고, 1일 때는 두 번째 패널이 열려 있는 상태를 뜻하게 됩니다.

이제 어떤 Panel에서든 "Show" 버튼을 누르면 Accordion에 있는 활성 인덱스(active index) 값이 바뀌어야 하겠죠? 그런데 Panel 컴포넌트 안에서는 activeIndex state를 직접 변경할 수가 없어요. 왜냐하면 그 state는 Accordion 안에 정의되어 있으니까요.

그래서 Accordion 컴포넌트는 Panel 컴포넌트가 자신의 state를 변경할 수 있도록 명시적으로 허락을 해줘야 합니다. 이를 위해 이벤트 핸들러를 prop으로 내려보내주는 방식을 사용합니다:

<>
  <Panel
    isActive={activeIndex === 0}
    onShow={() => setActiveIndex(0)}
  >
    ...
  </Panel>
  <Panel
    isActive={activeIndex === 1}
    onShow={() => setActiveIndex(1)}
  >
    ...
  </Panel>
</>

이렇게 하면 Panel 안에 있는 <button>은 부모로부터 받은 onShow prop을 자신의 클릭 이벤트 핸들러로 사용하게 됩니다. 전체 완성된 코드를 확인해 볼까요?

import { useState } from 'react';

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel
        title="About"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
      </Panel>
      <Panel
        title="Etymology"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        The name comes from <span lang="kk-KZ">алма</span>, the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild <i lang="la">Malus sieversii</i> is considered a likely candidate for the ancestor of the modern domestic apple.
      </Panel>
    </>
  );
}

function Panel({
  title,
  children,
  isActive,
  onShow
}) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>
          Show
        </button>
      )}
    </section>
  );
}
h3, p { margin: 5px 0px; }
.panel {
  padding: 10px;
  border: 1px solid #aaa;
}

짠! 드디어 state 끌어올리기가 완벽하게 끝났습니다!

State를 공통 부모 컴포넌트로 옮긴 덕분에 두 패널을 매끄럽게 조율할 수 있게 되었어요. 각각 두 개의 "열림/닫힘" 플래그를 쓰는 대신, 하나의 활성 인덱스(active index)를 사용해서 동시에 오직 하나의 패널만 열리도록 깔끔하게 보장했죠. 또한, 이벤트 핸들러를 자식에게 내려보내줌으로써 자식 컴포넌트가 부모의 state를 손쉽게 변경할 수 있게 되었습니다.

처음에 AccordionactiveIndex0이기 때문에, 첫 번째 PanelisActive = true를 전달받게 됩니다.

클릭을 통해 AccordionactiveIndex state가 1로 변경되면, 이번에는 두 번째 PanelisActive = true를 전달받아 열리게 됩니다.

제어 컴포넌트와 비제어 컴포넌트 (Controlled and uncontrolled components) {/controlled-and-uncontrolled-components/}

로컬 state를 직접 가지고 있는 컴포넌트를 흔히 "비제어(uncontrolled)" 컴포넌트라고 부릅니다. 예를 들어, 처음에 우리가 만들었던 isActive state 변수를 직접 가지고 있던 Panel 컴포넌트는 부모가 그 패널을 열지 말지 영향을 줄 수 없기 때문에 비제어 컴포넌트입니다. 자기 자신의 상태를 오롯이 자기가 통제하니까요.

이와 반대로 컴포넌트 안의 중요한 정보들이 컴포넌트 자체의 로컬 state가 아니라 외부로부터 받는 props에 의해 구동될 때, 우리는 그 컴포넌트를 "제어(controlled)" 컴포넌트라고 부를 수 있습니다. 이렇게 만들면 부모 컴포넌트가 자식 컴포넌트의 동작을 완벽하게 지정하고 제어할 수 있죠. 최종적으로 수정한 isActive prop을 받는 Panel 컴포넌트는 Accordion 컴포넌트에 의해 제어되는 제어 컴포넌트라고 할 수 있습니다.

비제어 컴포넌트는 부모 입장에서 따로 설정해 줄 게 별로 없기 때문에 단순하게 쓰기에는 훨씬 편합니다. 하지만 여러 컴포넌트들을 하나로 조율해서 함께 동작하게 만들어야 할 때는 유연성이 떨어집니다. 반면, 제어 컴포넌트는 활용도가 무궁무진하고 유연하지만, 부모 컴포넌트 쪽에서 props를 통해 동작을 일일이 설정해 줘야 하는 수고로움이 있죠.

실무에서 "제어"와 "비제어"라는 용어가 아주 엄격한 기술적 제약을 뜻하는 건 아닙니다. 대개의 컴포넌트들은 로컬 state와 props를 적절히 섞어서 가지고 있으니까요. 하지만 컴포넌트가 어떻게 설계되었고 어떤 기능들을 제공하는지 동료들과 소통할 때 아주 유용하게 쓰이는 개념입니다.

여러분이 컴포넌트를 직접 작성할 때, 어떤 정보를 외부에서 통제하게 할지(props를 통해 제어), 그리고 어떤 정보를 내부적으로 알아서 처리하게 할지(state를 통해 비제어) 고민해 보는 습관을 가지세요. 물론 처음부터 완벽할 순 없으니, 나중에 언제든지 마음을 바꿔서 리팩토링(refactoring)하셔도 괜찮습니다!

각각의 state는 단 하나의 진실 공급원(Single source of truth)을 가집니다 {/a-single-source-of-truth-for-each-state/}

리액트 애플리케이션을 만들다 보면 수많은 컴포넌트들이 각자의 state를 가지게 됩니다. 사용자 입력 폼(input) 같이 트리 맨 아래에 있는 말단(leaf) 컴포넌트들 가까이에 state가 "거주"하는 경우도 있고, 반대로 앱의 최상단 가까이에 state가 "거주"하는 경우도 있죠. 재미있는 건, 클라이언트 사이드 라우팅 라이브러리들조차도 보통은 현재 라우트 경로를 리액트 state에 저장한 다음 그걸 밑으로 props로 내려주는 방식으로 구현되어 있다는 사실입니다!

우리는 각각의 고유한 state 데이터에 대해, 그것을 "소유(own)"할 단 하나의 컴포넌트를 선택하게 됩니다. 이 원칙은 "단일 진실 공급원(Single source of truth)"을 가진다는 말로도 잘 알려져 있어요. 이 말이 앱의 모든 state가 한곳에 다 모여 있어야 한다는 뜻은 절대 아닙니다. 단지 각각의 state 데이터 조각마다 그 정보를 쥐고 있는 특정 컴포넌트가 딱 하나씩 존재해야 한다는 뜻이죠. 여러 컴포넌트에 걸쳐 공유되어야 하는 state를 여기저기 중복해서 만들지 마세요. 대신 그들의 공통 부모로 위로 끌어올린(lift it up) 다음, 그 데이터가 필요한 자식들에게 아래로 전달(pass it down) 해 주어야 합니다. Redux나 Zustand 같은 전역 상태 관리 도구를 쓰게 되더라도 이 근본적인 철학은 변하지 않는답니다.

작업을 진행하면서 여러분의 앱 구조는 계속 변할 거예요. 어떤 state 조각이 어디에 "거주"하는 게 가장 좋을지 파악하는 과정에서, state를 아래로 내리기도 하고 다시 위로 끌어올리기도 하는 건 너무나도 당연하고 흔한 일입니다. 겁먹지 마세요. 이 모든 게 훌륭한 리액트 개발자가 되어가는 과정이니까요!

좀 더 복잡한 컴포넌트 구조에서 이 과정이 실제로 어떻게 느껴지는지 알고 싶다면 React로 사고하기(Thinking in React) 파트를 꼭 읽어보시는 걸 추천합니다.

  • 두 컴포넌트의 동작을 서로 조율하고 싶다면, 그들의 state를 가장 가까운 공통 부모로 옮기세요.
  • 그런 다음 공통 부모에서 props를 통해 그 정보를 자식들에게 아래로 전달하세요.
  • 마지막으로, 자식들이 부모의 state를 변경할 수 있도록 이벤트 핸들러도 함께 props로 내려주세요.
  • 컴포넌트를 설계할 때 "제어(props에 의해 구동됨)"로 만들지, 아니면 "비제어(state에 의해 구동됨)"로 만들지 고민해 보는 것은 매우 유용한 습관입니다.

동기화된 입력창 만들기 {/synced-inputs/}

아래 예제에 있는 두 개의 입력창(input)은 현재 서로 독립적으로 동작합니다. 이 둘이 동기화되도록 만들어 보세요. 즉, 하나의 입력창을 수정하면 다른 입력창도 똑같은 텍스트로 업데이트되어야 하고, 반대의 경우도 마찬가지여야 합니다.

자식들이 가진 state를 부모 컴포넌트 쪽으로 끌어올려야 할 거예요! 화이팅!

import { useState } from 'react';

export default function SyncedInputs() {
  return (
    <>
      <Input label="First input" />
      <Input label="Second input" />
    </>
  );
}

function Input({ label }) {
  const [text, setText] = useState('');

  function handleChange(e) {
    setText(e.target.value);
  }

  return (
    <label>
      {label}
      {' '}
      <input
        value={text}
        onChange={handleChange}
      />
    </label>
  );
}
input { margin: 5px; }
label { display: block; }

text state 변수를 handleChange 핸들러 함수와 함께 부모 컴포넌트(여기서는 SyncedInputs) 안으로 이동시키면 됩니다. 그런 다음, 이 둘을 Input 컴포넌트들에게 props로 내려보내 주세요. 그러면 두 입력창이 완벽하게 똑같은 값을 보며 동기화될 겁니다!

import { useState } from 'react';

export default function SyncedInputs() {
  const [text, setText] = useState('');

  function handleChange(e) {
    setText(e.target.value);
  }

  return (
    <>
      <Input
        label="First input"
        value={text}
        onChange={handleChange}
      />
      <Input
        label="Second input"
        value={text}
        onChange={handleChange}
      />
    </>
  );
}

function Input({ label, value, onChange }) {
  return (
    <label>
      {label}
      {' '}
      <input
        value={value}
        onChange={onChange}
      />
    </label>
  );
}
input { margin: 5px; }
label { display: block; }

리스트 필터링하기 {/filtering-a-list/}

이 예제에서 SearchBar 컴포넌트는 텍스트 입력을 제어하는 자기 자신만의 query state를 가지고 있습니다. 그리고 부모 컴포넌트인 FilterableList는 아이템들의 List를 화면에 보여주지만, 아쉽게도 검색어(query)를 전혀 반영하지 않고 있어요.

검색어에 맞게 리스트를 필터링하려면 제공된 filterItems(foods, query) 함수를 사용해 보세요. 코드를 수정한 뒤, 입력창에 "s"라고 쳤을 때 리스트가 "Sushi", "Shish kebab", "Dim sum"으로 잘 걸러지는지 확인해 보시면 됩니다.

참고로 filterItems 함수는 이미 완성되어 임포트(import)되어 있으니 함수 자체를 여러분이 직접 작성하실 필요는 없어요!

SearchBar 컴포넌트에서 query state와 handleChange 핸들러 함수를 과감히 지우고, 그것들을 부모인 FilterableList로 옮기고 싶으실 거예요. 그런 다음 그것들을 다시 SearchBar에게 queryonChange라는 이름의 props로 전달해 보세요.

import { useState } from 'react';
import { foods, filterItems } from './data.js';

export default function FilterableList() {
  return (
    <>
      <SearchBar />
      <hr />
      <List items={foods} />
    </>
  );
}

function SearchBar() {
  const [query, setQuery] = useState('');

  function handleChange(e) {
    setQuery(e.target.value);
  }

  return (
    <label>
      Search:{' '}
      <input
        value={query}
        onChange={handleChange}
      />
    </label>
  );
}

function List({ items }) {
  return (
    <table>
      <tbody>
        {items.map(food => (
          <tr key={food.id}>
            <td>{food.name}</td>
            <td>{food.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
// src/data.js
export function filterItems(items, query) {
  query = query.toLowerCase();
  return items.filter(item =>
    item.name.split(' ').some(word =>
      word.toLowerCase().startsWith(query)
    )
  );
}

export const foods = [{
  id: 0,
  name: 'Sushi',
  description: 'Sushi is a traditional Japanese dish of prepared vinegared rice'
}, {
  id: 1,
  name: 'Dal',
  description: 'The most common way of preparing dal is in the form of a soup to which onions, tomatoes and various spices may be added'
}, {
  id: 2,
  name: 'Pierogi',
  description: 'Pierogi are filled dumplings made by wrapping unleavened dough around a savoury or sweet filling and cooking in boiling water'
}, {
  id: 3,
  name: 'Shish kebab',
  description: 'Shish kebab is a popular meal of skewered and grilled cubes of meat.'
}, {
  id: 4,
  name: 'Dim sum',
  description: 'Dim sum is a large range of small dishes that Cantonese people traditionally enjoy in restaurants for breakfast and lunch'
}];

query state를 부모인 FilterableList 컴포넌트로 끌어올리셨군요! 아주 잘하셨습니다. 그런 다음 filterItems(foods, query)를 호출해서 필터링된 결과 배열을 얻고, 그것을 자식인 List 컴포넌트에게 전달해 주면 됩니다. 이제 검색어를 입력하면 리스트가 실시간으로 변하는 걸 볼 수 있어요:

import { useState } from 'react';
import { foods, filterItems } from './data.js';

export default function FilterableList() {
  const [query, setQuery] = useState('');
  const results = filterItems(foods, query);

  function handleChange(e) {
    setQuery(e.target.value);
  }

  return (
    <>
      <SearchBar
        query={query}
        onChange={handleChange}
      />
      <hr />
      <List items={results} />
    </>
  );
}

function SearchBar({ query, onChange }) {
  return (
    <label>
      Search:{' '}
      <input
        value={query}
        onChange={onChange}
      />
    </label>
  );
}

function List({ items }) {
  return (
    <table>
      <tbody> 
        {items.map(food => (
          <tr key={food.id}>
            <td>{food.name}</td>
            <td>{food.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
// src/data.js
export function filterItems(items, query) {
  query = query.toLowerCase();
  return items.filter(item =>
    item.name.split(' ').some(word =>
      word.toLowerCase().startsWith(query)
    )
  );
}

export const foods = [{
  id: 0,
  name: 'Sushi',
  description: 'Sushi is a traditional Japanese dish of prepared vinegared rice'
}, {
  id: 1,
  name: 'Dal',
  description: 'The most common way of preparing dal is in the form of a soup to which onions, tomatoes and various spices may be added'
}, {
  id: 2,
  name: 'Pierogi',
  description: 'Pierogi are filled dumplings made by wrapping unleavened dough around a savoury or sweet filling and cooking in boiling water'
}, {
  id: 3,
  name: 'Shish kebab',
  description: 'Shish kebab is a popular meal of skewered and grilled cubes of meat.'
}, {
  id: 4,
  name: 'Dim sum',
  description: 'Dim sum is a large range of small dishes that Cantonese people traditionally enjoy in restaurants for breakfast and lunch'
}];

사이트맵 (Sitemap)

전체 문서 페이지 개요 보기 (Overview of all docs pages)


수고하셨습니다! 혹시 예제 코드를 읽으시면서 이해가 잘 안 되거나, state 끌어올리기에 대해 추가로 궁금한 점이 있으시다면 언제든 편하게 질문해 주세요!

profile
프론트에_가까운_풀스택_개발자

0개의 댓글