저번 주에는 바닐라 JS로 화면을 직접 그리는 방법을 봤습니다. document.querySelector, innerHTML, addEventListener 같은 DOM API를 써서 화면을 제어했죠.
const button = document.querySelector("#submit");
const countEl = document.querySelector("#count");
let n = 0;
button.addEventListener("click", () => {
간단한 경우에는 잘 동작합니다. 그런데 화면이 복잡해지고 상태가 여러 개 생기면 어떻게 될까요?
n)가 바뀔 때마다 영향받는 DOM 요소를 직접 찾아서 바꿔야
합니다.이 문제를 해결하려고 나온 것이 React입니다. React는 "상태가 바뀌면 화면도 알아서 바뀐다"는 약속을 가지고 있습니다.
바닐라 JS 방식은 명령형(imperative)입니다. 화면을 어떻게 바꿀지, 단계별로 지시해야 합니다.
// 명령형: 어떻게 바꿀지 직접 지시
countEl.textContent = n;
button.disabled = n >= 10;
badge.classList.toggle("hidden", n === 0);
React는 선언형(declarative)입니다. 화면이 어떻게 보여야 하는지를 작성하면, 상태가 바뀔 때 React가 화면을 알아서 맞춥니다.
// 선언형: 상태에 따라 화면이 어떻게 보여야 하는지 작성
function Counter() {
const [n, setN] = useState(0);
return (
<div>
{n > 0 && <Badge count={n} />}
<p>{n}</p>
<button onClick={() => n 개발자는 "지금 상태가 이렇다면 화면은 이렇게 보여야 한다"만 작성하면 됩니다. 상태를 어떻게 DOM에 반영할지는 React가 담당합니다.
명령형은 "어떻게 바꿀지"를 써야 하고, 선언형은 "어떻게 보여야 하는지"를 씁니다.
React에서 UI는 컴포넌트(Component) 단위로 만들어집니다. 컴포넌트는 간단하게 말하면 UI 조각을 반환하는 함수입니다.
function Button({ label, onClick, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
함수처럼 props(매개변수)로 데이터를 받고, JSX로 화면을 반환합니다. 그리고 함수를 호출하듯이 화면에서 사용합니다.
<Button label="신청하기" onClick={handleApply} disabled={!canApply} />
화면은 컴포넌트를 트리처럼 쌓아서 만들어집니다. 부모 컴포넌트가 자식 컴포넌트에게 props를 내려줍니다.
function StudyPage() {
return (
<Layout>
<Header title="스터디 목록" />
<StudyList>
<StudyCard title="React 스터디" status="OPEN" />
<StudyCard title="알고리즘 스터디" status="CLOSED" />컴포넌트는 상태(state)가 바뀌거나 props가 바뀌면 다시 실행됩니다. 다시 실행되면 새로운 화면을 반환하고, React가 그 결과를 실제 화면에 반영합니다.
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
버튼을 누르면 setCount가 호출됩니다. 상태가 바뀌었으니 React는
Counter를 다시 실행해서 새로운 화면을 만들고, <p> 안의
숫자만 바꿔줍니다.
React는 실제 DOM을 바로 바꾸지 않습니다. 대신 메모리 안에 가상 DOM(Virtual DOM)을 만들어서 이전 상태와 비교한 뒤, 달라진 부분만 실제 DOM에 반영합니다.
이것이 바닐라 JS와의 핵심 차이입니다. 바닐라 JS에서는 DOM을 개발자가 직접 찾아서 바꿔야 했지만, React에서는 상태를 바꾸면 화면은 따라옵니다.
React를 써도 프로젝트는 복잡해집니다. 코드 양이 많아서가 아니라, 책임이 섞일 때 터집니다.
예를 들어 버튼 하나가 있다고 해볼게요. 버튼은 화면에 보여야 하고, 클릭되면 상태가 바뀌어야 하고, 서버에 요청을 보내야 하고, 로그인한 사용자 권한에 따라 막히기도 해야 합니다. 여기까지는 자연스럽습니다. 그런데 이 모든 판단이 버튼 컴포넌트 하나 안에 들어가면, 그때부터 문제가 시작돼요.
function ApplyButton() {
// UI도 있고, 서버 요청도 있고, 권한 규칙도 있고, 상태 변경도 있음
// 버튼 하나를 고치려 했는데 서비스 전체 규칙을 건드리게 됩니다.
}
처음에는 빠릅니다. 파일 하나에 다 있으니까요. 하지만 조금만 지나면 "버튼 색만 바꾸고 싶었는데 API가 깨지고", "문구만 바꾸고 싶었는데 권한 조건이 바뀌고", "서버 응답 구조가 바뀌었는데 화면 파일 20개를 고쳐야 하는" 상황이 옵니다.
코드 양이 아니라, 책임이 섞일 때 프로젝트가 터집니다.
프론트엔드 코드를 볼 때 처음부터 완벽한 폴더 구조를 떠올리려고 하면 어렵습니다. 대신 먼저 책임을 네 가지로 나눠보면 훨씬 쉬워집니다.
UI 컴포넌트의 첫 번째 책임은 보여주는 것입니다. 버튼이면 버튼처럼 보이고, 카드면 카드처럼 보이고, 리스트면 리스트처럼 보이면 됩니다. 문제는 UI가 서비스 규칙까지 알기 시작할 때 생깁니다.
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" :
이 버튼은 단순히 버튼이 아니라 스터디 신청 정책까지 알고 있습니다. 나중에 "운영진은 마감 후에도 신청 가능" 같은 규칙이 추가되면 버튼 컴포넌트를 수정해야 합니다. 같은 규칙을 다른 화면에서도 써야 하면 복사·붙여넣기가 생기고, 어느 한쪽만 고치면서 버그가 납니다.
function StudyApplyButton({ disabled }: { disabled: boolean }) {
return (
<button
disabled={disabled}
className={disabled ? "bg-gray-300" : "bg-blue-500"}
>
신청하기
</button>
);
}
const disabled = !canApplyStudy({ study, user });
UI는 "비활성화 여부"만 받아서 그립니다. "왜 비활성화인지"는 다른 책임으로 분리합니다. 이렇게 하면 버튼은 재사용 가능해지고, 신청 규칙은 따로 테스트할 수 있습니다.
상태는 서버에 영구 저장된 데이터가 아니라, 지금 화면에서 사용자가 보고 있거나 조작 중인 값입니다. 예를 들면 모달이 열려 있는지, 어떤 탭이 선택되어 있는지, 검색어 입력값이 무엇인지 같은 것들이에요.
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를 어떻게 처리할지, 재요청
중에는 어떻게 할지 같은 문제가 화면 컴포넌트에 계속 쌓입니다.
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. 서버 데이터 — 가져오고, 캐시하고, 다시 가져오는 책임
서버 데이터는 화면 상태와 다릅니다. 서버 데이터는 내가 만든 값이 아니라 서버가 소유한 값의 프론트엔드 복사본입니다. 그래서 로딩, 에러, 캐시, 재요청, 만료 시간이 같이 따라옵니다.
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 화면에서는 에러 메시지가 다르게 나오는 식으로 서비스 경험이 흔들립니다.
function NoticeList() {
const { data: notices = [], isLoading, error } = useNoticesQuery();
if (isLoading) return <NoticeSkeleton />;
if (error) return <ErrorMessage />;
return <NoticeCards notices={notices} />;
}
function useNoticesQuery() {
return useQuery({
queryKey: ["notices"],
queryFn: () => noticeApi.getNotices(),
});
}
서버 데이터 책임은 React Query, SWR 같은 도구가 잘 맡아줍니다. CLAB member
app도 @tanstack/react-query를 사용합니다. 핵심은 "서버에서 가져온 값"과
"화면에서 잠깐 쓰는 값"을 같은 상태로 취급하지 않는 것입니다.
비즈니스 규칙은 서비스마다 다른 판단입니다. "누가 신청할 수 있는가", "마감된 모집을 보여줄 것인가", "어떤 상태에서 버튼을 숨길 것인가" 같은 규칙이에요.
function RecruitmentCard({ recruitment, user }) {
return (
<Card>
{recruitment.status === "OPEN" &&
user?.role === "member" &&
!recruitment.applicants.includes(user.id) && <button>지원하기</button>}
</Card>
);
}
이 조건은 화면 코드 안에 숨어 있습니다. 같은 "지원 가능 여부"를 상세 페이지, 목록 페이지, 관리자 페이지에서 모두 써야 하면 조건이 퍼집니다. 나중에 규칙이 바뀌면 어디를 고쳐야 하는지 찾기 어려워져요.
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;
}
function RecruitmentCard({ recruitment, user }) {
const canApply = canApplyRecruitment({ recruitment, user });
return <Card>{canApply && <button>지원하기</button>}</Card>;
}
규칙을 함수로 빼면 이름이 생깁니다. 이름이 생기면 토론할 수 있고, 테스트할 수 있고, 바뀔 때 한 군데를 고치기 쉬워집니다. 구조를 잘 잡는다는 건 결국 바뀔 가능성이 큰 판단에 이름을 붙이는 일이기도 합니다.
폴더 구조는 정답 맞히기 문제가 아닙니다. "책임을 어디에 둘 것인가"에 대한 팀의 합의입니다. 아래 이름들은 외우기보다, 어떤 상황에서 어울리는지 감으로 가져가면 됩니다.
| 구조 | 한 줄 정의 | 어울리는 상황 |
|---|---|---|
| flat | 파일을 얕게 나열하는 구조 | 작은 과제, 페이지 수가 적은 토이 프로젝트 |
| feature | 기능 단위로 관련 파일을 묶는 구조 | 도메인/기능이 분명한 서비스형 프로젝트 |
| layered | components, hooks, api, utils처럼 기술 계층별로 나누는 구조 |
CLAB member app은 완전한 FSD라기보다는 layered + feature 혼합
에 가깝습니다. api, components, pages, hooks, model 같은 계층이
있고, 그 안에서 community, activity, library, auth처럼 기능별
디렉터리가 다시 나뉩니다.
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
이번 장은 한 페이지짜리 지도처럼 보면 됩니다. 프론트엔드 프로젝트는 코드만 있는 게 아니라, 패키지 매니저·빌드 도구·스타일링·상태 관리·테스트 같은 여러 영역이 함께 움직입니다.
{
"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 |
| 언어 | 타입으로 실수를 미리 잡음 |
각 도구가 어떤 책임을 대신 맡고 있는지를 보는 것입니다. 프로젝트 구조는 폴더만이 아니라, 이런 도구 선택까지 포함합니다.
이제 6장에서 본 Bad 코드 중 하나를 골라 페어로 리팩터해보겠습니다. 목표는 완벽한 정답을 만드는 것이 아니라, 책임을 어디까지 나눌지 토론하는 것입니다.
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 경로가 바뀌면 어디를 고치면 되나요?" 이 질문에 바로 답할 수 있으면 좋은 방향입니다.
오늘 다룬 React는 UI를 만드는 라이브러리입니다. React만으로는 라우팅, 서버 렌더링, 파일 기반 페이지 구성 같은 것들을 직접 세팅해야 합니다. Next.js는 이 위에 쌓인 프레임워크입니다.
"서버에서 뭘 그리고, 클라이언트에서 뭘 그리냐"는 질문은 앞으로 점점 중요해지는 감각입니다. 자세한 내용은 나중에 웹 퍼포먼스를 다루는 편에서 제대로 볼 예정이고, 지금은 "React를 더 편하게 쓰기 위한 틀" 정도로 이해해두면 충분합니다.
다음 주에는 오늘 살짝 본 HTTP와 서버 상태 영역을 메인으로 다룹니다. 오늘은 "서버 데이터는 화면 상태와 다르게 봐야 한다"는 감각을 잡았다면, 다음 주에는 실제로 API를 호출하고 응답을 다루는 방법을 더 깊게 보게 됩니다.
특히 fetch, axios, ky 같은 HTTP 클라이언트 선택지와, Promise/async-await
흐름이 자연스럽게 연결됩니다. 서버에서 데이터가 "나중에" 오기 때문에 비동기가
필요하고, 그 데이터를 화면에서 안전하게 쓰기 위해 서버 상태 관리가 필요해지는
구조입니다.
fetch("https://jsonplaceholder.typicode.com/todos/1")를 실행해보고
Promise가 어떻게 보이는지 확인하기| 초반 학습용, 팀원이 구조를 빨리 이해해야 하는 프로젝트 |
| atomic | atoms/molecules/organisms처럼 UI 조립 단위로 나누는 구조 | 디자인 시스템, 공통 UI 컴포넌트가 중요한 프로젝트 |
| FSD | app/pages/widgets/features/entities/shared로 책임 레벨을 나누는 구조 | 큰 규모, 여러 기능 팀이 같이 만지는 프로젝트 |
| TypeScript |
| 스타일링 | UI를 일관되게 표현 | Tailwind CSS v4 |
| HTTP | 서버와 통신 | ky |
| 서버 상태 | 서버 데이터 캐싱·로딩·에러 관리 | React Query |
| 전역 상태 | 여러 화면이 공유하는 클라이언트 상태 관리 | Jotai + Zustand |
| 라우팅 | URL과 화면을 연결 | React Router |
| 린트/포맷 | 코드 스타일과 실수를 자동 점검 | ESLint + Prettier |
| 테스트 | 변경 후 기존 동작 확인 | Vitest + Playwright |