프론트엔드 스터디 대면 9주차: Next.js 렌더링 진화와 웹.
Next.js가 무엇이고 왜 필요한지 React와 비교해 이해한 뒤, CSR부터 SSR, SSG, ISR, PPR까지 렌더링 전략과 Web Vitals를 정리하며 스터디를 마무리합니다.
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 요소를 <strong>직접 찾아서</strong> 바꿔야
합니다.
같은 UI 조각을 여러 곳에 쓰려면 코드를 복사해야 합니다. 상태가 어디서 어떻게 바뀌는지 코드를 전부 읽어야만 알 수 있습니다.
---
// 명령형: 어떻게 바꿀지 직접 지시
countEl.textContent = n;
button.disabled = n >= 10;
badge.classList.toggle("hidden", n === 0);// 선언형: 상태에 따라 화면이 어떻게 보여야 하는지 작성
function Counter() {
const [n, setN] = useState(0);
return (
<div>
{n > 0 && <Badge count={n} />}
<p>{n}</p>
<button onClick={() => setN(n + 1)} disabled={n >= 10}>
+1
</button>
</div>
);
}명령형은 "어떻게 바꿀지"를 써야 하고, 선언형은 "어떻게 보여야 하는지"를 씁니다.
---
function Button({ label, onClick, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}<Button label="신청하기" onClick={handleApply} disabled={!canApply} /><strong>재사용</strong> — 버튼, 카드, 입력창 같은 UI 조각을 여러 화면에서 꺼내
씁니다.
<strong>조합</strong> — 작은 컴포넌트를 쌓아서 더 큰 화면을 만듭니다. <strong>책임 분리</strong> — 버튼은 버튼처럼 생긴 것만 신경 씁니다. 나머지는
다른 곳에서 담당합니다.
function StudyPage() {
return (
<Layout>
<Header title="스터디 목록" />
<StudyList>
<StudyCard title="React 스터디" status="OPEN" />
<StudyCard title="알고리즘 스터디" status="CLOSED" />
</StudyList>
</Layout>
);
}---
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}전체를 다시 그리지 않고, 변경된 부분만 업데이트합니다. DOM 조작은 비용이 비쌉니다. 필요한 만큼만 건드리는 것이 빠릅니다. 개발자는 이 과정을 직접 제어하지 않아도 됩니다. React가 처리합니다.
---
function ApplyButton() {
// UI도 있고, 서버 요청도 있고, 권한 규칙도 있고, 상태 변경도 있음
// 버튼 하나를 고치려 했는데 서비스 전체 규칙을 건드리게 됩니다.
}코드 양이 아니라, 책임이 섞일 때 프로젝트가 터집니다.
---
<strong>UI</strong> — 무엇을 어떻게 보여줄 것인가 <strong>상태</strong> — 지금 화면이나 앱이 어떤 상태인가 <strong>서버 데이터</strong> — 서버에서 무엇을 가져오고, 어떻게 캐시하고, 언제
다시 가져올 것인가
<strong>비즈니스 규칙</strong> — 우리 서비스에서만 성립하는 판단 기준은
무엇인가
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" : "bg-blue-500"}
>
신청하기
</button>
);
}function StudyApplyButton({ disabled }: { disabled: boolean }) {
return (
<button
disabled={disabled}
className={disabled ? "bg-gray-300" : "bg-blue-500"}
>
신청하기
</button>
);
}
const disabled = !canApplyStudy({ study, user });
<StudyApplyButton disabled={disabled} />;function MemberPage() {
const [members, setMembers] = useState([]);
const [selectedId, setSelectedId] = useState(null);
useEffect(() => {
fetch("/api/members")
.then((res) => res.json())
.then(setMembers);
}, []);
const selectedMember = members.find((member) => member.id === selectedId);
return <MemberProfile member={selectedMember} />;
}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} />;
}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())
.then(setNotices)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...
}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(),
});
}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처럼 기술 계층별로 나누는 구조 | 초반 학습용, 팀원이 구조를 빨리 이해해야 하는 프로젝트 |
| atomic | atoms/molecules/organisms처럼 UI 조립 단위로 나누는 구조 | 디자인 시스템, 공통 UI 컴포넌트가 중요한 프로젝트 |
| FSD | app/pages/widgets/features/entities/shared로 책임 레벨을 나누는 구조 | 큰 규모, 여러 기능 팀이 같이 만지는 프로젝트 |
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 |
| 언어 | 타입으로 실수를 미리 잡음 | TypeScript |
| 스타일링 | UI를 일관되게 표현 | Tailwind CSS v4 |
| HTTP | 서버와 통신 | ky |
| 서버 상태 | 서버 데이터 캐싱·로딩·에러 관리 | React Query |
| 전역 상태 | 여러 화면이 공유하는 클라이언트 상태 관리 | Jotai + Zustand |
| 라우팅 | URL과 화면을 연결 | React Router |
| 린트/포맷 | 코드 스타일과 실수를 자동 점검 | ESLint + Prettier |
| 테스트 | 변경 후 기존 동작 확인 | Vitest + Playwright |
---
6장의 Bad 코드 중 하나를 고릅니다. 이 코드 안에 섞여 있는 책임을 표시합니다.
UI 화면 상태 서버 데이터 비즈니스 규칙
최소 2개 이상의 책임을 분리합니다. 특히 <strong>서버 상태 vs 화면 상태</strong>를 구분해봅니다. 리팩터 후 "바뀌기 쉬운 지점"이 어디로 이동했는지 설명합니다.
function StudyPage() {
const [studies, setStudies] = useState([]);
const [selectedCategory, setSelectedCategory] = useState("ALL");
useEffect(() => {
fetch("/api/studies")
.then((res) => res.json())
.then(setStudies);
}, []);
const visibleStudies = studies.filter((study) => {
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>
);
}useStudiesQuery() — 서버 데이터 책임
selectedCategory — 화면 상태 책임
getVisibleStudies() — 비즈니스/필터 규칙 책임
StudyCard, CategoryTabs — UI 책임
---
React를 기반으로, 라우팅·빌드·배포·렌더링 방식까지 미리 구성해줍니다. 페이지를 <strong>서버에서 렌더할지, 클라이언트에서 렌더할지</strong> 선택할 수
있습니다.
지금 이 사이트도 Next.js로 만들어져 있습니다.
---
다음 주 학습 자료(week8)의
API/비동기 관련 구간 읽기
콘솔에서 fetch("https://jsonplaceholder.typicode.com/todos/1")를 실행해보고
Promise가 어떻게 보이는지 확인하기
오늘 자료에서 <strong>서버 상태와 화면 상태</strong>를 구분한 예시를 하나 다시
읽어오기
이번 주 학습 자료(week7) — 불변성, 프로토타입, 타입 체크
다음 주 학습 자료(week8) — API와 통신
Post Q&A
프론트엔드 스터디 대면 7주차: React 입문과 프로젝트 구조 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
비동기와 async/await를 배운 뒤, 대면에서는 API를 단순 호출 방법이 아니라 프론트엔드와 백엔드 사이의 계약으로 바라봅니다. HTTP, REST, CORS, 프록시, API 클라이언트 구조, TanStack Query와 파일 배치까지 연결합니다.