LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

프론트엔드스터디React컴포넌트Virtual DOM프로젝트 구조책임 분리폴더 구조상태 관리

프론트엔드 스터디 대면 7주차: React 입문과 프로젝트 구조

2026년 5월 11일·26분 읽기

1. 저번 주 연결 — 바닐라 JS로 화면 그리기의 한계

저번 주에는 바닐라 JS로 화면을 직접 그리는 방법을 봤습니다. document.querySelector, innerHTML, addEventListener 같은 DOM API를 써서 화면을 제어했죠.

js
const button = document.querySelector("#submit");
const countEl = document.querySelector("#count");
let n = 0;

button.addEventListener("click", () => {
n++;
countEl.textContent = n;
if (n >= 10) button.disabled = true;
});

간단한 경우에는 잘 동작합니다. 그런데 화면이 복잡해지고 상태가 여러 개 생기면 어떻게 될까요?

  • 상태(n)가 바뀔 때마다 영향받는 DOM 요소를 직접 찾아서 바꿔야 합니다.
  • 같은 UI 조각을 여러 곳에 쓰려면 코드를 복사해야 합니다.
  • 상태가 어디서 어떻게 바뀌는지 코드를 전부 읽어야만 알 수 있습니다.

이 문제를 해결하려고 나온 것이 React입니다. React는 "상태가 바뀌면 화면도 알아서 바뀐다"는 약속을 가지고 있습니다.


2. React가 등장한 이유 — UI를 선언적으로

바닐라 JS 방식은 명령형(imperative)입니다. 화면을 어떻게 바꿀지, 단계별로 지시해야 합니다.

js
// 명령형: 어떻게 바꿀지 직접 지시
countEl.textContent = n;
button.disabled = n >= 10;
badge.classList.toggle("hidden", n === 0);

React는 선언형(declarative)입니다. 화면이 어떻게 보여야 하는지를 작성하면, 상태가 바뀔 때 React가 화면을 알아서 맞춥니다.

tsx
// 선언형: 상태에 따라 화면이 어떻게 보여야 하는지 작성
function Counter() {
  const [n, setN] = useState(0);

  return (
    <div>
      {n > 0 && <Badge count={n} />}
      <p>{n}</p>
      <button onClick={() => n   

개발자는 "지금 상태가 이렇다면 화면은 이렇게 보여야 한다"만 작성하면 됩니다. 상태를 어떻게 DOM에 반영할지는 React가 담당합니다.

명령형은 "어떻게 바꿀지"를 써야 하고, 선언형은 "어떻게 보여야 하는지"를 씁니다.


3. 컴포넌트란 무엇인가 — UI 조각을 함수로 만들기

React에서 UI는 컴포넌트(Component) 단위로 만들어집니다. 컴포넌트는 간단하게 말하면 UI 조각을 반환하는 함수입니다.

tsx
function Button({ label, onClick, disabled }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}

함수처럼 props(매개변수)로 데이터를 받고, JSX로 화면을 반환합니다. 그리고 함수를 호출하듯이 화면에서 사용합니다.

tsx
<Button label="신청하기" onClick={handleApply} disabled={!canApply} />
컴포넌트를 쓰면 세 가지가 좋아집니다.
  • 재사용 — 버튼, 카드, 입력창 같은 UI 조각을 여러 화면에서 꺼내 씁니다.
  • 조합 — 작은 컴포넌트를 쌓아서 더 큰 화면을 만듭니다.
  • 책임 분리 — 버튼은 버튼처럼 생긴 것만 신경 씁니다. 나머지는 다른 곳에서 담당합니다.

컴포넌트 트리

화면은 컴포넌트를 트리처럼 쌓아서 만들어집니다. 부모 컴포넌트가 자식 컴포넌트에게 props를 내려줍니다.

tsx
function StudyPage() {
  return (
    <Layout>
      <Header title="스터디 목록" />
      <StudyList>
        <StudyCard title="React 스터디" status="OPEN" />
        <StudyCard title="알고리즘 스터디" status="CLOSED" />

4. React의 렌더링 방식 — 언제 다시 그리나

컴포넌트는 상태(state)가 바뀌거나 props가 바뀌면 다시 실행됩니다. 다시 실행되면 새로운 화면을 반환하고, React가 그 결과를 실제 화면에 반영합니다.

tsx
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )

버튼을 누르면 setCount가 호출됩니다. 상태가 바뀌었으니 React는 Counter를 다시 실행해서 새로운 화면을 만들고, <p> 안의 숫자만 바꿔줍니다.

Virtual DOM — 왜 빠른가

React는 실제 DOM을 바로 바꾸지 않습니다. 대신 메모리 안에 가상 DOM(Virtual DOM)을 만들어서 이전 상태와 비교한 뒤, 달라진 부분만 실제 DOM에 반영합니다.

  • 전체를 다시 그리지 않고, 변경된 부분만 업데이트합니다.
  • DOM 조작은 비용이 비쌉니다. 필요한 만큼만 건드리는 것이 빠릅니다.
  • 개발자는 이 과정을 직접 제어하지 않아도 됩니다. React가 처리합니다.

이것이 바닐라 JS와의 핵심 차이입니다. 바닐라 JS에서는 DOM을 개발자가 직접 찾아서 바꿔야 했지만, React에서는 상태를 바꾸면 화면은 따라옵니다.


5. 프론트엔드 프로젝트는 왜 복잡해지는가

React를 써도 프로젝트는 복잡해집니다. 코드 양이 많아서가 아니라, 책임이 섞일 때 터집니다.

예를 들어 버튼 하나가 있다고 해볼게요. 버튼은 화면에 보여야 하고, 클릭되면 상태가 바뀌어야 하고, 서버에 요청을 보내야 하고, 로그인한 사용자 권한에 따라 막히기도 해야 합니다. 여기까지는 자연스럽습니다. 그런데 이 모든 판단이 버튼 컴포넌트 하나 안에 들어가면, 그때부터 문제가 시작돼요.

tsx
function ApplyButton() {
  // UI도 있고, 서버 요청도 있고, 권한 규칙도 있고, 상태 변경도 있음
  // 버튼 하나를 고치려 했는데 서비스 전체 규칙을 건드리게 됩니다.
}

처음에는 빠릅니다. 파일 하나에 다 있으니까요. 하지만 조금만 지나면 "버튼 색만 바꾸고 싶었는데 API가 깨지고", "문구만 바꾸고 싶었는데 권한 조건이 바뀌고", "서버 응답 구조가 바뀌었는데 화면 파일 20개를 고쳐야 하는" 상황이 옵니다.

코드 양이 아니라, 책임이 섞일 때 프로젝트가 터집니다.


6. 나눠서 봐야 하는 4가지 책임

프론트엔드 코드를 볼 때 처음부터 완벽한 폴더 구조를 떠올리려고 하면 어렵습니다. 대신 먼저 책임을 네 가지로 나눠보면 훨씬 쉬워집니다.

  • UI — 무엇을 어떻게 보여줄 것인가
  • 상태 — 지금 화면이나 앱이 어떤 상태인가
  • 서버 데이터 — 서버에서 무엇을 가져오고, 어떻게 캐시하고, 언제 다시 가져올 것인가
  • 비즈니스 규칙 — 우리 서비스에서만 성립하는 판단 기준은 무엇인가

6-1. UI — 보여주는 책임

UI 컴포넌트의 첫 번째 책임은 보여주는 것입니다. 버튼이면 버튼처럼 보이고, 카드면 카드처럼 보이고, 리스트면 리스트처럼 보이면 됩니다. 문제는 UI가 서비스 규칙까지 알기 시작할 때 생깁니다.

Bad

tsx
function StudyApplyButton({ study, user }) {
  const disabled =
    !user ||
    user.role !== "member" ||
    study.status !== "OPEN" ||
    study.appliedUserIds.includes(user.id) ||
    study.currentCount >= study.maxCount;

  return (
    <button
      disabled={disabled}
      className={disabled ? "bg-gray-300" : 

왜 깨지나

이 버튼은 단순히 버튼이 아니라 스터디 신청 정책까지 알고 있습니다. 나중에 "운영진은 마감 후에도 신청 가능" 같은 규칙이 추가되면 버튼 컴포넌트를 수정해야 합니다. 같은 규칙을 다른 화면에서도 써야 하면 복사·붙여넣기가 생기고, 어느 한쪽만 고치면서 버그가 납니다.

Good

tsx
function StudyApplyButton({ disabled }: { disabled: boolean }) {
  return (
    <button
      disabled={disabled}
      className={disabled ? "bg-gray-300" : "bg-blue-500"}
    >
      신청하기
    </button>
  );
}

const disabled = !canApplyStudy({ study, user });

  

UI는 "비활성화 여부"만 받아서 그립니다. "왜 비활성화인지"는 다른 책임으로 분리합니다. 이렇게 하면 버튼은 재사용 가능해지고, 신청 규칙은 따로 테스트할 수 있습니다.

6-2. 상태 — 지금 무엇이 선택되어 있는가

상태는 서버에 영구 저장된 데이터가 아니라, 지금 화면에서 사용자가 보고 있거나 조작 중인 값입니다. 예를 들면 모달이 열려 있는지, 어떤 탭이 선택되어 있는지, 검색어 입력값이 무엇인지 같은 것들이에요.

Bad

tsx
function MemberPage() {
  const [members, setMembers] = useState([]);
  const [selectedId, setSelectedId] = useState(null);

  useEffect(() => {
    fetch("/api/members")
      .then((res) => res.json())
      .then(setMembers);
  }, []);

   selectedMember  membersmember  member  selectedId

왜 깨지나

여기서 members는 서버 데이터이고, selectedId는 화면 상태입니다. 둘이 같은 컴포넌트 안에 있으면 처음엔 괜찮지만, 캐싱·로딩·에러·재요청이 붙는 순간 복잡해져요. 서버 데이터가 비어 있을 때 선택된 ID를 어떻게 처리할지, 재요청 중에는 어떻게 할지 같은 문제가 화면 컴포넌트에 계속 쌓입니다.

Good

tsx
function MemberPage() {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const { data: members = [] } = useMembersQuery();

  const selectedMember = members.find((member) => member.id === selectedId);

  return <MemberProfile member={selectedMember} onSelect=setSelectedId 

서버 데이터는 useMembersQuery 같은 훅으로 빼고, 화면은 "무엇이 선택되어 있는가"만 관리합니다. 상태를 바꿀 때는 직접 수정하지 말고, 새 상태를 만들어 넘기는 패턴을 기본으로 가져가야 합니다.

6-3. 서버 데이터 — 가져오고, 캐시하고, 다시 가져오는 책임

서버 데이터는 화면 상태와 다릅니다. 서버 데이터는 내가 만든 값이 아니라 서버가 소유한 값의 프론트엔드 복사본입니다. 그래서 로딩, 에러, 캐시, 재요청, 만료 시간이 같이 따라옵니다.

Bad

tsx
function NoticeList() {
  const [notices, setNotices] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch("/api/notices")
      .then((res) => res.json(

왜 깨지나

화면마다 이 패턴을 반복하면 로딩·에러·재시도·캐시 규칙이 전부 흩어집니다. A 화면에서는 새로고침하면 다시 가져오고, B 화면에서는 캐시가 남고, C 화면에서는 에러 메시지가 다르게 나오는 식으로 서비스 경험이 흔들립니다.

Good

tsx
function NoticeList() {
  const { data: notices = [], isLoading, error } = useNoticesQuery();

  if (isLoading) return <NoticeSkeleton />;
  if (error) return <ErrorMessage />;

  return <NoticeCards notices={notices} />;
}
tsx
function useNoticesQuery() {
  return useQuery({
    queryKey: ["notices"],
    queryFn: () => noticeApi.getNotices(),
  });
}

서버 데이터 책임은 React Query, SWR 같은 도구가 잘 맡아줍니다. CLAB member app도 @tanstack/react-query를 사용합니다. 핵심은 "서버에서 가져온 값"과 "화면에서 잠깐 쓰는 값"을 같은 상태로 취급하지 않는 것입니다.

6-4. 비즈니스 규칙 — 우리 서비스의 판단 기준

비즈니스 규칙은 서비스마다 다른 판단입니다. "누가 신청할 수 있는가", "마감된 모집을 보여줄 것인가", "어떤 상태에서 버튼을 숨길 것인가" 같은 규칙이에요.

Bad

tsx
function RecruitmentCard({ recruitment, user }) {
  return (
    <Card>
      {recruitment.status === "OPEN" &&
        user?.role === "member" &&
        !recruitment.applicants.includes(user.id) && <button>지원하기</button>}
    </Card>
  );
}

왜 깨지나

이 조건은 화면 코드 안에 숨어 있습니다. 같은 "지원 가능 여부"를 상세 페이지, 목록 페이지, 관리자 페이지에서 모두 써야 하면 조건이 퍼집니다. 나중에 규칙이 바뀌면 어디를 고쳐야 하는지 찾기 어려워져요.

Good

ts
export function canApplyRecruitment({ recruitment, user }) {
  if (!user) return false;
  if (user.role !== "member") return false;
  if (recruitment.status !== "OPEN") return false;
  if (recruitment.applicants.includes(user.id)) return false;

  return true;
}
tsx
function RecruitmentCard({ recruitment, user }) {
  const canApply = canApplyRecruitment({ recruitment, user });

  return <Card>{canApply && <button>지원하기</button>}</Card>;
}

규칙을 함수로 빼면 이름이 생깁니다. 이름이 생기면 토론할 수 있고, 테스트할 수 있고, 바뀔 때 한 군데를 고치기 쉬워집니다. 구조를 잘 잡는다는 건 결국 바뀔 가능성이 큰 판단에 이름을 붙이는 일이기도 합니다.


7. 폴더 구조의 종류 — 책임을 어디에 둘 것인가

폴더 구조는 정답 맞히기 문제가 아닙니다. "책임을 어디에 둘 것인가"에 대한 팀의 합의입니다. 아래 이름들은 외우기보다, 어떤 상황에서 어울리는지 감으로 가져가면 됩니다.

구조한 줄 정의어울리는 상황
flat파일을 얕게 나열하는 구조작은 과제, 페이지 수가 적은 토이 프로젝트
feature기능 단위로 관련 파일을 묶는 구조도메인/기능이 분명한 서비스형 프로젝트
layeredcomponents, hooks, api, utils처럼 기술 계층별로 나누는 구조

CLAB member app은 완전한 FSD라기보다는 layered + feature 혼합 에 가깝습니다. api, components, pages, hooks, model 같은 계층이 있고, 그 안에서 community, activity, library, auth처럼 기능별 디렉터리가 다시 나뉩니다.

txt
apps/member/src
├─ api
│  ├─ member
│  ├─ community
│  ├─ recruitment
│  └─ auth
├─ components
│  ├─ home
│  ├─ community
│  ├─ activity
│  ├─ library
│  └─ common
├─ pages
│  ├─ home
│  ├─ community
│  ├─ activity
│  └─ my
├─ app
│  ├─ layout
│  └─ route
├─ hooks
├─ model
├─ types
└─ utils

8. CLAB platforms로 보는 프론트 영역 지도

이번 장은 한 페이지짜리 지도처럼 보면 됩니다. 프론트엔드 프로젝트는 코드만 있는 게 아니라, 패키지 매니저·빌드 도구·스타일링·상태 관리·테스트 같은 여러 영역이 함께 움직입니다.

json
{
  "dependencies": {
    "@tanstack/react-query": "^5.90.21",
    "jotai": "^2.18.0",
    "ky": "^1.7.3",
    "react": "^19.2.0",
    "react-router": "^7.1.5",
    "tailwindcss": "^4.1.18",
    "zustand": "^5.0.11"
  }
}
영역왜 필요하나CLAB member app
패키지 매니저의존성 설치와 버전 고정pnpm
빌드개발 코드를 브라우저용 결과물로 변환Vite
모노레포여러 앱/패키지를 한 저장소에서 관리Turbo + workspace
언어타입으로 실수를 미리 잡음

각 도구가 어떤 책임을 대신 맡고 있는지를 보는 것입니다. 프로젝트 구조는 폴더만이 아니라, 이런 도구 선택까지 포함합니다.


9. 페어 워크 — Bad → Good 리팩터

이제 6장에서 본 Bad 코드 중 하나를 골라 페어로 리팩터해보겠습니다. 목표는 완벽한 정답을 만드는 것이 아니라, 책임을 어디까지 나눌지 토론하는 것입니다.

진행 방식

  1. 6장의 Bad 코드 중 하나를 고릅니다.
  2. 이 코드 안에 섞여 있는 책임을 표시합니다.
    • UI
    • 화면 상태
    • 서버 데이터
    • 비즈니스 규칙
  3. 최소 2개 이상의 책임을 분리합니다.
  4. 특히 서버 상태 vs 화면 상태를 구분해봅니다.
  5. 리팩터 후 "바뀌기 쉬운 지점"이 어디로 이동했는지 설명합니다.

예시 미션

tsx
function StudyPage() {
  const [studies, setStudies] = useState([]);
  const [selectedCategory, setSelectedCategory] = useState("ALL");

  useEffect(() => {
    fetch("/api/studies")
      .then((res) => res.json())
      .then(setStudies);
  }, []);

   visibleStudies  studiesstudy  

이 코드에는 서버 데이터 가져오기, 화면 상태, 필터링 규칙, UI 렌더링이 섞여 있습니다. 페어로 아래처럼 나눠보세요.

  • useStudiesQuery() — 서버 데이터 책임
  • selectedCategory — 화면 상태 책임
  • getVisibleStudies() — 비즈니스/필터 규칙 책임
  • StudyCard, CategoryTabs — UI 책임

리팩터 후에는 서로에게 설명해보세요. "이제 카테고리 정책이 바뀌면 어디를 고치면 되나요?" "API 경로가 바뀌면 어디를 고치면 되나요?" 이 질문에 바로 답할 수 있으면 좋은 방향입니다.


10. Next.js — React로 만든 프레임워크

오늘 다룬 React는 UI를 만드는 라이브러리입니다. React만으로는 라우팅, 서버 렌더링, 파일 기반 페이지 구성 같은 것들을 직접 세팅해야 합니다. Next.js는 이 위에 쌓인 프레임워크입니다.

  • React를 기반으로, 라우팅·빌드·배포·렌더링 방식까지 미리 구성해줍니다.
  • 페이지를 서버에서 렌더할지, 클라이언트에서 렌더할지 선택할 수 있습니다.
  • 지금 이 사이트도 Next.js로 만들어져 있습니다.

"서버에서 뭘 그리고, 클라이언트에서 뭘 그리냐"는 질문은 앞으로 점점 중요해지는 감각입니다. 자세한 내용은 나중에 웹 퍼포먼스를 다루는 편에서 제대로 볼 예정이고, 지금은 "React를 더 편하게 쓰기 위한 틀" 정도로 이해해두면 충분합니다.


11. 다음 주 안내 — 8주차 "API와 통신"

다음 주에는 오늘 살짝 본 HTTP와 서버 상태 영역을 메인으로 다룹니다. 오늘은 "서버 데이터는 화면 상태와 다르게 봐야 한다"는 감각을 잡았다면, 다음 주에는 실제로 API를 호출하고 응답을 다루는 방법을 더 깊게 보게 됩니다.

특히 fetch, axios, ky 같은 HTTP 클라이언트 선택지와, Promise/async-await 흐름이 자연스럽게 연결됩니다. 서버에서 데이터가 "나중에" 오기 때문에 비동기가 필요하고, 그 데이터를 화면에서 안전하게 쓰기 위해 서버 상태 관리가 필요해지는 구조입니다.

다음 주 예습

  • 다음 주 학습 자료(week8)의 API/비동기 관련 구간 읽기
  • 콘솔에서 fetch("https://jsonplaceholder.typicode.com/todos/1")를 실행해보고 Promise가 어떻게 보이는지 확인하기
  • 오늘 자료에서 서버 상태와 화면 상태를 구분한 예시를 하나 다시 읽어오기

관련 포스팅

  • 이번 주 학습 자료(week7) — 불변성, 프로토타입, 타입 체크

  • 다음 주 학습 자료(week8) — API와 통신

포스트 목록

/study/clab-26-1/in-person
파일 11개, 폴더 0개
프론트엔드 스터디 대면 0주차: 프론트엔드 개발자란? 그리고 우리가 배울 것들프론트엔드 스터디 대면 1주차: HTML 마크업과 폼, 그리고 CSS의 시작프론트엔드 스터디 대면 2주차: 폼(Form), CSS 선택자, 그리고 박스 모델프론트엔드 스터디 대면 3주차: 박스 모델 실전, Position과 Flexbox프론트엔드 스터디 대면 6주차(1): HTML/CSS/JS 리마인드와 브라우저 렌더링프론트엔드 스터디 대면 6주차(2): AI 시대의 개발 방식과 프론트엔드 개발자의 위치프론트엔드 스터디 대면 7주차: React 입문과 프로젝트 구조프론트엔드 스터디 대면 8주차: API와 통신 — 프론트엔드와 백엔드의 계약프론트엔드 스터디 대면 9주차: Next.js 렌더링 진화와 웹 퍼포먼스프론트엔드 스터디 대면 10주차: 팀 협업 — Git, PR, 컨벤션, 리뷰 문화프론트엔드 스터디 대면 11주차: 배포와 운영 — localhost 밖의 세계
setN
(
+
1
)
}
disabled
={n >= 10}
>
+1
</button>
</div>
);
}
</StudyList>
</Layout>
);
}
;
}
"bg-blue-500"
}
>
신청하기
</button>
);
}
<
StudyApplyButton
disabled
={disabled}
/>
;
const
=
.
find
(
(
)
=>
.
id
===
)
;
return <MemberProfile member={selectedMember} />;
}
{
}
/>
;
}
)
)
.then(setNotices)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...
}
초반 학습용, 팀원이 구조를 빨리 이해해야 하는 프로젝트
atomicatoms/molecules/organisms처럼 UI 조립 단위로 나누는 구조디자인 시스템, 공통 UI 컴포넌트가 중요한 프로젝트
FSDapp/pages/widgets/features/entities/shared로 책임 레벨을 나누는 구조큰 규모, 여러 기능 팀이 같이 만지는 프로젝트
TypeScript
스타일링UI를 일관되게 표현Tailwind CSS v4
HTTP서버와 통신ky
서버 상태서버 데이터 캐싱·로딩·에러 관리React Query
전역 상태여러 화면이 공유하는 클라이언트 상태 관리Jotai + Zustand
라우팅URL과 화면을 연결React Router
린트/포맷코드 스타일과 실수를 자동 점검ESLint + Prettier
테스트변경 후 기존 동작 확인Vitest + Playwright
const
=
.
filter
(
(
)
=>
{
if (selectedCategory !== "ALL" && study.category !== selectedCategory)
return false;
if (study.status !== "OPEN") return false;
return true;
});
return (
<div>
<CategoryTabs value={selectedCategory} onChange={setSelectedCategory} />
{visibleStudies.map((study) => (
<StudyCard key={study.id} study={study} />
))}
</div>
);
}