카카오_구름/리액트
4기 카카오 프론트 전체 스터디 - 리액트 4주차 (SPA 구조 + Next.js & TS 프리뷰)
코딩의 세계
2025. 5. 17. 23:42
아마 마지막의 카카오 프론트 전체 스터디 글이 될 거 같다. (4기 한정)
이번에는 리액트의 SPA 구조 및 Next.js에 대한 이야기를 할 것이다. (next.js에서는 타입스크립트로 진행함.)
목표는 다음과 같다.
1. SPA의 구조와 "가짜 페이지 전환" 흐름 이해
2. Next.js 구조(app/, page.tsx)와 타입스크립트 적용 맛보기
시작하자.
1. SPA의 구조와 "가짜 페이지 전환" 흐름 이해
1. 싱글 페이지 애플리케이션(SPA) 구조와 “가짜 페이지 전환” 동작 원리
1-1. 전통적 MPA와 SPA의 차이는 다음과 같다.
항목 전통적 MPA(멀티 페이지) / SPA(싱글 페이지)
페이지 전환 | 서버가 새로운 HTML 전체를 응답 → 브라우저가 문서 완전 교체 | 첫 요청에 단일 HTML + JS 번들을 로드 → 이후 전환은 클라이언트 라우터가 JS 로직으로 DOM 일부만 교체 |
새로고침 감각 | 필수 (Flicker 발생) | URL만 바뀌고 화면은 즉시 변해 매끄러운 UX |
초기 로드 속도 | 빠름 (HTML만 수신) | 상대적으로 느림 (JS 번들 다운로드) |
SEO | 서버가 완전한 문서 제공 → 즉시 크롤링 가능 | JS 렌더 필요 → SSR/SSG, 프레임워크 지원이 필요 |
1-2. “가짜 페이지 전환”의 기술적 구성은 다음과 같다.
- Browser History API
history.pushState() · replaceState() · popstate 이벤트를 이용해 URL을 바꿔도 브라우저는 네트워크 요청을 보내지 않는다. - 클라이언트 라우터
- 예: React Router의 <BrowserRouter> / Next.js next/router 내부 라우터.
- URL 변화를 구독해 현재 경로에 대응하는 컴포넌트 트리를 재렌더링한다.
- 코드 스플리팅 & 프리패칭
- import(/* webpackChunkName */) 또는 Next.js의 자동 코드 스플리팅.
- 전환 직전 미리 JS 청크를 받아 두면 순간 로딩 스피너조차 안 보인다.
- 전역 상태 유지
한 문서 안에서 전환이 일어나므로, Recoil/Zustand/Context API로 글로벌 저장소를 두면 탭 간 데이터 손실이 없다.
흐름 시퀀스 다이어그램
사용자 클릭 /posts/42
│
▼
Router intercepts event ───┐
│ │
history.pushState('/posts/42') (주소줄 갱신·뒤로가기 히스토리 추가)
│ │
동적 import(PostPage) 로딩?───┘
│
PostPage 컴포넌트 렌더링
2. Next.js 구조(app/, page.tsx)와 타입스크립트 적용 맛보기
공식 문서 링크 >
Next.js by Vercel - The React Framework
Next.js by Vercel is the full-stack React framework for the web.
nextjs.org
2-1. 프로젝트 생성
# 최신 버전 설치
npm create next-app@latest my-blog \
--typescript \
--tailwind \
--eslint \
--app --src-dir --import-alias "@/*"
cd my-blog
npm dev
옵션 설명 >
옵션 의미
--typescript | tsconfig.json 자동 생성 & 파일 확장자를 .tsx로 |
--app | app router 사용 (pages/ 대신 app/) |
--src-dir | 소스가 src/ 아래로 들어가 구조가 깔끔 |
--import-alias "@/*" | 절대 경로 import → import Button from "@/components/Button" |
2-2. 파일 구조 해부
my-blog/
├─ src/
│ ├─ app/
│ │ ├─ layout.tsx ← 모든 페이지 공통 레이아웃
│ │ ├─ page.tsx ← "/"
│ │ ├─ posts/
│ │ │ ├─ [slug]/
│ │ │ │ └─ page.tsx ← 동적 라우트 "/posts/:slug"
│ │ │ └─ page.tsx ← "/posts"
│ ├─ components/
│ ├─ hooks/
│ └─ styles/
├─ public/
├─ .eslintrc.json
└─ tailwind.config.ts
중요한 개념 >
파일 / 폴더 역할
layout.tsx | 페이지에 공통 UI를 래핑(헤더, Footer, Context Providers) |
page.tsx | 라우트 도착 시 SSR/SSG/ISR(선택) → React 서버 컴포넌트로 실행 |
[slug]/page.tsx | 동적 세그먼트. params.slug를 받아 빌드 시 SSG 가능 |
(.) | () 폴더는 URL 경로에 포함되지 않는 그룹핑용 |
2-3. TypeScript 엄격 설정
// tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"strict": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
strict 모드는 초기엔 귀찮지만, 런타임 에러·null 체크를 컴파일 단계에서 잡아낼 수 있다.
2-4. 예시 페이지 흐름
/posts 리스트 페이지 (서버 컴포넌트)
// src/app/posts/page.tsx
import { PostCard } from '@/components/PostCard'
import { fetchPosts } from '@/lib/api'
export default async function PostsPage() {
const posts = await fetchPosts() // 서버에서 직접 DB/API 호출
return (
<section className="grid gap-4">
{posts.map((p) => (
<PostCard key={p.id} post={p} />
))}
</section>
)
}
- 서버 컴포넌트이므로 데이터 패칭이 서버에서 실행 → 클라이언트 번들 크기 0 KB 증가
- fetchPosts() 내부에서 axios로 외부 API 요청을 해도 API 키가 노출되지 않는다.
/posts/[slug] 상세 페이지 (서버 → 클라이언트 혼합)
// src/app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { fetchPost } from '@/lib/api'
import { LikeButton } from '@/components/client/LikeButton' // 클라 컴포넌트
export default async function PostDetail({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
if (!post) return notFound()
return (
<article className="prose mx-auto">
<h1>{post.title}</h1>
<MDXRemote source={post.body} />
{/* 클라이언트 전용 상호작용 */}
<LikeButton postId={post.id} />
</article>
)
}
클라이언트 컴포넌트 선언
// src/components/client/LikeButton.tsx
'use client'
import { useState } from 'react'
import { likePost } from '@/lib/api'
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
const handleClick = async () => {
await likePost(postId)
setLiked(true)
}
return (
<button onClick={handleClick} className="btn">
{liked ? 'Liked ❤️' : 'Like'}
</button>
)
}
- 'use client' 지시어가 있으면 Next.js가 별도 JS 청크로 번들링하여 브라우저에만 보낸다.
2-5. 데이터 패칭 전략 비교
전략 메서드 특징
SSG | export const dynamic = 'force-static'빌드 시 HTML 생성 | 빌드-타임 데이터 고정, Ultra Fast |
SSR | 기본(서버 컴포넌트) | 요청마다 fresh 데이터, BigQuery 같은 실시간 데이터 |
ISR | revalidate = 60 (초) | N 초 후 백그라운드 재생성 |
2-6. 글로벌 상태 & “가짜 전환” 유지
Next.js 15에서도 내부적으로 여전히 SPA 클라이언트 라우터가 동작한다.
- 같은 layout.tsx 안에서 전환 → React state 보존
- 외부 Link 컴포넌트를 쓰면 history pushState로 처리되어 새로고침 없음
- 서버 컴포넌트만 변경되면 Next.js는 partial hydration으로 필요한 부분만 DOM에 패치 → 성능 향상
2-7. PortOne(아임포트) 결제 모듈 삽입 예시
- SDK 설치
npm add @iamport/react-native-iamport
- 결제 페이지 (클라이언트 컴포넌트)
'use client'
import { useRouter } from 'next/navigation'
import { Iamport } from '@iamport/react-native-iamport'
export default function Checkout() {
const router = useRouter()
const userCode = process.env.NEXT_PUBLIC_PORTONE_CODE!
const data = {
pg: 'html5_inicis',
pay_method: 'card',
merchant_uid: `mid_${Date.now()}`,
name: '1달 구독',
amount: 20000,
buyer_email: 'test@domain.com',
buyer_name: '홍길동'
}
const callback = (response: any) => {
if (response.success) router.push('/success')
else router.push('/fail')
}
return <Iamport userCode={userCode} data={data} callback={callback} />
}
- 환경 변수 분리는 .env.local → NEXT_PUBLIC_ prefix면 클라이언트로 노출됨.
3. 프로젝트 시작 → 배포 전체 플로우
- 레포 생성 & CI 연결
- GitHub → npm dlx @vercel/cli@latest link 또는 Vercel Dashboard 연결
- 개발: npm dev (포트 3000)
Hot-reload, ESLint error overlay 확인 - 커밋 & 푸시 → Vercel Preview Deployment
- 프로덕션 배포 (main 브랜치)
- Vercel이 npm install && pnpm build 후 /.vercel/output 배포
- 도메인 연결 · HTTPS 자동
- 모니터링 → Vercel Analytics, Lighthouse
마무리
- SPA는 History API + 클라이언트 라우터로 “가짜 페이지”를 만든다.
- Next.js 15는 app/ 라우터를 통해 서버·클라이언트 경계를 명시적으로 나누고, 기본적으로 여전히 SPA 방식을 사용하되 SEO · 성능까지 잡는다.
- TypeScript + Tailwind + ESLint는 create next-app 옵션만으로 즉시 세팅 가능.
- 결제·인증 같은 브라우저-전용 로직은 클라이언트 컴포넌트에 'use client'를 선언해 분리한다.