타임투게더는 그룹 모임 일정 조율을 위한 웹 애플리케이션입니다.
여러 사람의 가능 시간을 한눈에 파악하고, 팀원들과 함께 모임 장소를 투표로 정할 수 있습니다.
AI 추천 장소 기능을 통해 모임 준비 과정을 더욱 빠르고 간편하게 만들어 줍니다.
- 📅 시간 조율 간소화 — When2Meet 방식의 가용 시간 그리드로 최적의 일정을 빠르게 도출
- 📍 장소 선정 자동화 — AI 기반 장소 추천 및 멤버 투표로 모두가 만족하는 장소 결정
- 👥 그룹 중심 설계 — 그룹 단위로 일정을 관리하고 멤버를 초대하여 함께 조율
- 일반 로그인 — 이메일 + 비밀번호 인증
- SNS 로그인 — 네이버, 카카오, 구글 OAuth 2.0
- 단계별 회원가입 (6단계) — 이메일 인증 → 비밀번호 → 닉네임 → 생년월일 → 관심사 → 프로필 이미지
- 그룹 생성 및 목록 조회
- 멤버 초대 (초대 링크 / 다이얼로그)
- 그룹 탈퇴
- 날짜·시간 범위 설정으로 일정 생성
- 개인 가능 시간 입력
- 멤버별 가능 시간 현황 매트릭스 시각화
- 최종 일정 확정
- AI 추천 — 멤버 관심사 기반 자동 장소 추천
- 직접 입력 — 원하는 장소를 직접 추가
- 투표 — 멤버들이 장소에 투표, 득표 수 실시간 표시
- 최종 장소 확정
- FullCalendar 기반 월간 캘린더
- 확정된 일정 시각화
- 프로필 정보 조회
- 참여한 일정 히스토리
| 분류 | 기술 |
|---|---|
| 프레임워크 | Next.js 16 (App Router), React 19, TypeScript 5 |
| 스타일링 | Tailwind CSS 4, Shadcn UI, Radix UI, Lucide React |
| 상태 관리 | Zustand 5 (클라이언트 상태), TanStack React Query 5 (서버 상태) |
| 폼 & 유효성 | React Hook Form 7, Zod 4 |
| HTTP 클라이언트 | Axios 1 |
| 캘린더 | FullCalendar 6 (daygrid, interaction) |
| 날짜 유틸 | date-fns 4 |
| 미디어 업로드 | Cloudinary (next-cloudinary) |
| 보안 | Web Crypto API (PBKDF2 해싱·AES-GCM 암호화), nonce + strict-dynamic CSP |
| API 타입 생성 | swagger-typescript-api |
| SVG 처리 | SVGR (@svgr/webpack) |
| 토스트 알림 | Sonner, react-hot-toast |
- Node.js 18 이상
- npm (또는 yarn / pnpm)
# 1. 저장소 클론
git clone https://github.com/Glyph8/NextTimeTogether-Frontend.git
cd NextTimeTogether-Frontend
# 2. 의존성 설치
npm install
# 3. 환경 변수 설정 (아래 섹션 참고)
cp .env.example .env.local
# 4. 개발 서버 실행
npm run dev개발 서버가 시작되면 브라우저에서 http://localhost:3000 으로 접속합니다.
# 프로덕션 빌드
npm run build
# 프로덕션 서버 실행
npm startnpm run lint프로젝트 루트에 .env.local 파일을 생성하고 아래 변수를 설정합니다.
# 백엔드 API 기본 URL
NEXT_PUBLIC_API_BASE_URL=https://your-api-backend.com
# Cloudinary (이미지 업로드)
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_cloudinary_api_key
CLOUDINARY_API_SECRET=your_cloudinary_api_secret
# 카카오 장소 검색 API (장소 추가 기능)
KAKAO_REST_API_KEY=your_kakao_rest_api_key
⚠️ .env.local파일은.gitignore에 포함되어 있으므로 절대 커밋하지 마세요.
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # 인증 관련 라우트 그룹
│ │ ├── login/ # 로그인
│ │ ├── register/ # 회원가입 (step1 ~ step6)
│ │ ├── sns-signup/ # SNS 로그인 (네이버, 카카오, 구글)
│ │ └── complete-signup/ # 가입 완료
│ ├── (dashboard)/ # 메인 앱 라우트 그룹
│ │ ├── appointment/ # 약속 / 일정 목록
│ │ ├── calendar/ # 캘린더 뷰
│ │ ├── groups/ # 그룹 목록, 생성, 상세
│ │ │ └── detail/[groupId]/ # 그룹 상세 & 일정 관리
│ │ └── my/ # 마이페이지 & 히스토리
│ ├── api/ # Next.js Route Handlers
│ ├── layout.tsx # 루트 레이아웃
│ └── page.tsx # 랜딩 / 홈
│
├── api/ # API 호출 레이어 (Axios 래퍼)
│ ├── index.ts # Axios 인스턴스 (토큰 인터셉터 포함)
│ ├── auth.ts # 인증 API
│ ├── calendar.ts # 캘린더 API
│ ├── when2meet.ts # 시간 조율 API
│ ├── where2meet.ts # 장소 선정 API (AI 포함)
│ ├── group-view-create.ts # 그룹 CRUD API
│ └── ...
│
├── apis/generated/ # Swagger 자동 생성 API 타입
│ └── Api.ts
│
├── components/
│ ├── shared/ # 공통 재사용 컴포넌트
│ │ ├── BottomNav/ # 하단 내비게이션 바
│ │ ├── Dialog/ # 다이얼로그 (PlainDialog, YesNoDialog)
│ │ ├── Input/ # 입력 컴포넌트 (TextInput, RadioButton)
│ │ ├── ToastButton/ # 토스트 알림
│ │ └── Cloudinary/ # 이미지 업로드
│ └── ui/ # Shadcn UI 기반 원자 컴포넌트
│ ├── button/
│ ├── header/
│ ├── dialog.tsx
│ ├── drawer.tsx
│ └── sonner.tsx
│
├── store/ # Zustand 전역 상태
│ ├── auth.store.ts # 인증 상태 (accessToken, isAuthenticated)
│ └── signupStore.ts # 회원가입 단계별 폼 상태
│
├── hooks/ # 커스텀 훅
│ ├── useAuthSession.ts # 앱 마운트 시 토큰 복원
│ ├── useDebounce.ts
│ ├── useGetMembers.ts
│ └── useSelection.ts
│
├── lib/
│ ├── schemas/ # Zod 유효성 스키마
│ ├── server/ # 서버 전용 유틸 (쿠키 정리 등)
│ ├── tokenCookie.ts # RT 쿠키 메타데이터 / Set-Cookie 파서
│ ├── clearClientAuthState.ts # Zustand + localStorage + IndexedDB 일괄 정리
│ ├── logout.ts # 로그아웃 서버 액션
│ └── utils.ts # 공통 유틸리티 (cn 등)
│
├── assets/
│ ├── pngs/ # PNG 이미지 (로고 등)
│ └── svgs/icons/ # SVG 아이콘 (SVGR 처리)
│
├── types/ # TypeScript 타입 정의
├── utils/ # 유틸리티 함수
├── constants.ts # 앱 상수
├── proxy.ts # CSP·보안 헤더 미들웨어 (Next.js 16 명명)
└── providers.tsx # React Query, Toast 프로바이더
로그인 요청
→ 백엔드에서 Access Token / Refresh Token 발급
→ AccessToken: Zustand 메모리 단일 소스 (클라이언트만 보유)
→ RefreshToken: httpOnly 쿠키 단일 소스 (JS 접근 불가)
→ 모든 API 요청에 Authorization 헤더로 자동 첨부
→ 401 응답 시: 응답 인터셉터가 single-flight refresh 후 원 요청 재시도
→ 앱 재마운트 시 useAuthSession 훅이 RT 쿠키로 새 AT 발급 → Zustand 복원
AccessToken 은 메모리에만 두어 새로고침 시 사라지고, RefreshToken 의 httpOnly 쿠키만으로 세션을 유지한다. AT 와 RT 가 한 곳에서 같이 새는 구조를 만들지 않기 위해 의도적으로 단일 소스로 분리했다.
컴포넌트
→ src/api/*.ts (비즈니스 로직 래퍼)
→ clientBaseApi (Axios 인스턴스, 토큰 자동 주입)
→ 백엔드 REST API
Swagger/OpenAPI로부터 자동 생성된 타입(src/apis/generated/Api.ts)을 기반으로, src/api/ 레이어에서 에러 처리와 응답 변환을 담당합니다.
| 항목 | 내용 |
|---|---|
| CSP | src/proxy.ts 미들웨어에서 매 요청마다 nonce 생성 → script-src 'nonce-...' 'strict-dynamic' 적용 |
| 토큰 저장 | AccessToken: Zustand 메모리 단일 소스, RefreshToken: refresh_token httpOnly 쿠키 단일 소스 |
| 자동 토큰 갱신 | Axios 응답 인터셉터에서 401 감지 → single-flight refresh + 재시도 1회 가드 |
| 비밀번호 해싱 | Web Crypto API PBKDF2 (MasterKey 파생 100k iter, 인증 해시 200k iter) |
| 클라이언트 암호화 | AES-GCM (12-byte IV) 로 사용자 식별 정보 암호화 후 localStorage 보관 |
| MasterKey 보관 | IndexedDB 에 extractable:false CryptoKey 로 저장 → JS 로 raw bits 추출 불가 |
| 보안 헤더 | X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=()/microphone=()/geolocation=(self) |
단순한 CRUD를 넘어, 보안·UX·실시간성을 고려해 직접 설계하고 구현한 기술 포인트들입니다.
문제 인식: 사용자 식별 정보를 서버에 평문으로 보내면, 서버 침해 시 사용자 정보가 노출됩니다. 비밀번호도 네트워크 단에서 인터셉트될 수 있습니다.
해결책: 브라우저 표준 Web Crypto API를 활용하여 클라이언트에서 암호화·해싱 후 서버 전송. 원본 자격증명(ID/PW)은 네트워크를 통해 절대 전송되지 않습니다.
전체 흐름:
[사용자 입력: ID + PW]
↓
[PBKDF2로 MasterKey 파생] ← (100,000 iterations, SHA-256)
↓
[HMAC-SHA256으로 해시된 UserId 생성]
[PBKDF2로 해시된 Password 생성] ← (200,000 iterations, 인증용)
↓
[해시값만 서버로 전송 → 인증 성공]
↓
[MasterKey → IndexedDB에 'extractable: false' CryptoKey로 저장]
[UserId → MasterKey로 AES-GCM 암호화 → localStorage 저장]
MasterKey 파생 — src/utils/crypto/generate-key/derive-masterkey.ts
export async function deriveMasterKeyPBKDF2(
userId: string,
password: string
): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
const salt = encoder.encode(userId); // userId를 salt로 사용
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
{ name: "PBKDF2" },
false,
["deriveBits"]
);
const derivedBits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 100_000, hash: "SHA-256" },
keyMaterial,
256 // 256비트 키
);
return derivedBits;
}IndexedDB에 '추출 불가' CryptoKey 저장 — src/utils/client/key-storage.ts
export async function storeMasterKey(masterKey: ArrayBuffer): Promise<void> {
// extractable: false → JS 코드로 키 값을 꺼낼 수 없음 (XSS 방어)
const cryptoKey = await crypto.subtle.importKey(
"raw",
masterKey,
{ name: "AES-GCM" },
false, // ← 핵심: 추출 불가
["encrypt", "decrypt"]
);
const db = await openKeyStoreDB();
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
await new Promise<void>((resolve, reject) => {
const request = store.put(cryptoKey, KEY_ID);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}AES-GCM 암호화 (랜덤 IV 포함) — src/utils/client/crypto/crypto-storage.ts
export async function encryptStringToBase64(
data: string,
key: CryptoKey
): Promise<string> {
// 매 암호화마다 새로운 12바이트 랜덤 IV 생성
const iv = crypto.getRandomValues(new Uint8Array(12));
const encodedData = new TextEncoder().encode(data);
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encodedData
);
// [IV(12바이트)] + [암호문]을 하나의 버퍼로 결합하여 저장
const combinedBuffer = new Uint8Array(iv.length + encryptedBuffer.byteLength);
combinedBuffer.set(iv, 0);
combinedBuffer.set(new Uint8Array(encryptedBuffer), iv.length);
return arrayBufferToBase64(combinedBuffer.buffer);
}이 구조 덕분에 서버 DB나 localStorage가 탈취되더라도 원본 사용자 정보는 복원할 수 없습니다.
문제 인식: AccessToken 을 localStorage 에 두면 XSS 한 번에 토큰이 새고, 같은 토큰을 메모리와 httpOnly 쿠키 양쪽에 두는 흔한 "이중 보관" 패턴은 결국 약한 쪽(메모리) 의 보안 수준이 시스템 전체 수준이 된다.
해결책: AccessToken 은 Zustand 메모리 단일 소스로만 보관, RefreshToken 은 refresh_token httpOnly 쿠키 단일 소스로만 보관. AT 와 RT 가 한 곳에서 같이 새지 않도록 저장 위치를 의도적으로 분리한다. 새로고침 시 Server Action 으로 RT 쿠키를 보내 새 AT 를 받아 Zustand 에 복원한다. 토큰 만료 중 동시 다발 401 이 발생해도 응답 인터셉터의 single-flight 패턴으로 refresh 는 한 번만 일어난다.
세션 복원 흐름 — src/hooks/useAuthSession.ts
export const useAuthSession = () => {
const { accessToken, setAccessToken } = useAuthStore();
useEffect(() => {
const isPublicPath =
pathname === "/" || pathname === "/login" || pathname.includes("/register");
if (accessToken || isPublicPath) {
setIsRestoring(false);
return;
}
const restoreSession = async () => {
try {
const masterKey = await getMasterKey();
if (!masterKey) throw new Error("MasterKey 없음 — 로그인 필요");
const encryptedUserId = localStorage.getItem("encrypted_user_id");
if (!encryptedUserId) throw new Error("암호화된 userId 없음");
// userId 복호화는 결과를 사용하지 않고 MasterKey 유효성 검증으로만 사용한다.
await decryptStringFromBase64(encryptedUserId, masterKey);
const refreshResult = await refreshAccessToken();
if (!refreshResult.success || !refreshResult.accessToken) {
throw new Error(refreshResult.error || "AccessToken 갱신 실패");
}
setAccessToken(refreshResult.accessToken);
} catch (err) {
await clearAuthCookies();
await clearClientAuthState();
if (pathname !== "/login") router.replace("/login");
} finally {
setIsRestoring(false);
}
};
restoreSession();
}, [accessToken, setAccessToken, router, pathname]);
};Server Action (BFF) — src/app/(auth)/login/refresh.action.ts
"use server";
export async function refreshAccessToken(): Promise<RefreshActionState> {
const cookieStore = await cookies();
const refreshToken = cookieStore.get("refresh_token")?.value;
if (!refreshToken) return { success: false, error: "No refresh token found." };
const response = await axios.post(`${MAIN_BACKEND_URL}/auth/refresh`, null, {
headers: { "Refresh-token": refreshToken },
});
const newAccessToken = response.headers["authorization"];
const rotatedRefreshToken = getRefreshTokenFromSetCookie(response.headers["set-cookie"]);
if (response.data.code === 200 && newAccessToken) {
// AccessToken 은 응답으로만 반환 → 클라이언트 Zustand 에 보관 (쿠키에 저장하지 않음).
if (rotatedRefreshToken) {
cookieStore.set("refresh_token", rotatedRefreshToken, { httpOnly: true, path: "/", sameSite: "lax" });
}
return { success: true, accessToken: newAccessToken };
}
await clearAuthTokenCookies();
return { success: false, error: "Backend refresh failed." };
}| 저장 위치 | 데이터 | 이유 |
|---|---|---|
| Zustand (메모리) | AccessToken | 클라이언트 인증의 단일 소스. 새로고침으로 사라지지만 RT 로 즉시 복원 가능 |
| httpOnly 쿠키 | RefreshToken (refresh_token) |
세션 유지 단일 소스, JS 접근 불가 → XSS 로 RT 탈취 방지 |
| IndexedDB | 추출불가 CryptoKey (MasterKey) | extractable:false 로 import → JS 로 raw bits 추출 불가 |
| localStorage | 암호화된 UserId / pseudo_id_index_key |
복호화 키(MasterKey) 없이는 무의미한 데이터 |
401 자동 refresh 인터셉터 (single-flight) — src/api/index.ts
요청 도중 AccessToken 이 만료되어 401 이 떨어졌을 때 사용자에게 한 번 튕기지 않도록, Axios 응답 인터셉터에서 자동 갱신 후 원 요청을 재시도한다. 동시에 여러 요청이 401 을 받아도 refresh 자체는 한 번만 일어나도록 Promise 를 공유한다.
let inflightRefresh: Promise<string | null> | null = null;
clientBaseApi.instance.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const status = error.response?.status;
const original = error.config as RetriableConfig | undefined;
// 401 이 아니거나, 이미 한 번 재시도한 요청이면 그대로 throw (무한루프 방지)
if (status !== 401 || !original || original._retried) throw error;
original._retried = true;
// single-flight: 동시 다발 401 → 같은 refresh Promise 공유
inflightRefresh ??= performRefresh().finally(() => { inflightRefresh = null; });
const newToken = await inflightRefresh;
if (!newToken) {
// refresh 실패 → 인증 상태 정리 + /login
await handleAuthFailure();
throw error;
}
// 새 토큰으로 원 요청 재시도
original.headers.Authorization = newToken;
return clientBaseApi.instance.request(original);
}
);설계 포인트:
- single-flight:
inflightRefresh변수 하나로 동시 다발 401 시 refresh 호출이 1회로 수렴 - retry-once:
_retried플래그로 재시도 후 또 401 이 오면 무한루프 차단 - failure path: refresh 실패 시
clearClientAuthState(Zustand + localStorage + IndexedDB MasterKey 모두 정리) +/login리다이렉트
문제 인식: 여러 멤버가 동시에 가능 시간을 입력하므로 실시간으로 화면을 갱신해야 합니다. 하지만 WebSocket 없이 폴링만 쓰면 사용자가 드래그 중일 때 UI가 덮어씌워지는 문제가 발생합니다.
해결책: TanStack React Query의 refetchInterval을 사용자 입력 상태(isInputMode)에 따라 동적으로 제어합니다. 드래그 시작 시 폴링 중단 → 드래그 완료 후 즉시 재개.
스마트 폴링 훅 — src/app/.../when-components/use-promise-time.ts
export const usePromiseTime = (promiseId: string, isInputMode: boolean = false) => {
const boardQuery = useQuery<TimeBoardResponse>({
queryKey: TIME_KEYS.board(promiseId),
queryFn: () => getPromiseTimeBoard(promiseId),
// 핵심: 드래그 중(isInputMode=true)이면 폴링 중단
refetchInterval: isInputMode ? false : 5000,
// 백그라운드 탭에서는 서버 자원 낭비 방지
refetchIntervalInBackground: false,
// 탭 복귀 시 즉시 최신 데이터 반영
refetchOnWindowFocus: true,
// staleTime 0: 폴링/포커스 이벤트마다 무조건 서버 데이터 신뢰
staleTime: 0,
// 새 데이터 로딩 중에도 기존 데이터 유지 → UI 깜빡임 방지
placeholderData: (previousData) => previousData,
});
const updateMutation = useMutation({
mutationFn: (data: UserTimeSlotReqDTO) => updateMyTimetable(promiseId, data),
onSuccess: () => {
// 내 시간 저장 즉시 보드 데이터 갱신
queryClient.invalidateQueries({ queryKey: TIME_KEYS.board(promiseId) });
toast.success("시간표가 저장되었습니다!");
},
});
return { boardQuery, updateMutation, confirmMutation };
};문제 인식: mousedown / touchstart 이벤트를 개별적으로 처리하면 모바일/데스크탑 환경에서 구현이 복잡해집니다. 또한 드래그로 영역을 선택할 때 스크롤과의 충돌을 막아야 합니다.
해결책: W3C 표준 Pointer Events API (onPointerDown, onPointerEnter, onPointerUp)를 사용하여 마우스·터치·스타일러스를 단일 API로 처리. touchAction: "none"으로 스크롤 충돌 방지.
핵심 구현 — src/app/.../when-components/TimeTableGrid.tsx
// 드래그 시작: 시작 셀 기록
const handlePointerDown = (day: number, time: number, e: React.PointerEvent) => {
if (mode === "view") { onCellClick?.(day, time); return; }
if (disabledSlots?.[day]?.[time]) return; // 비활성화 셀 무시
e.preventDefault(); // 터치 스크롤 방지
setIsDragging(true);
onDragStart?.(); // 부모에 드래그 시작 알림 → 폴링 중단
setStartCell({ day, time });
setCurrentCell({ day, time });
};
// 드래그 완료: 시작~끝 셀 범위의 모든 셀 상태 일괄 토글
const handlePointerUp = () => {
if (!isDragging || !startCell || !currentCell) {
setIsDragging(false); return;
}
onDragEnd?.(); // 부모에 드래그 끝 알림 → 폴링 재개
const newSelection = internalSelection.map((d) => [...d]);
const minDay = Math.min(startCell.day, currentCell.day);
const maxDay = Math.max(startCell.day, currentCell.day);
const minTime = Math.min(startCell.time, currentCell.time);
const maxTime = Math.max(startCell.time, currentCell.time);
// 시작 셀의 기존 값에 따라 전체 범위를 선택/해제
const newValue = !internalSelection[startCell.day][startCell.time];
for (let d = minDay; d <= maxDay; d++) {
for (let t = minTime; t <= maxTime; t++) {
if (disabledSlots?.[d]?.[t]) continue; // 비활성화 셀 건너뜀
newSelection[d][t] = newValue;
}
}
setInternalSelection(newSelection);
onChange?.(newSelection);
setIsDragging(false);
};
// 각 셀에 적용되는 JSX
<div
style={{ ...getCellStyle(dayIndex, timeIndex), touchAction: "none" }}
onPointerDown={(e) => handlePointerDown(dayIndex, timeIndex, e)}
onPointerEnter={() => handlePointerEnter(dayIndex, timeIndex)}
/>멤버 참여 현황 시각화 — 참여 인원 수에 따라 투명도(opacity)가 달라지는 히트맵 색상 계산:
const getCellStyle = (day: number, time: number) => {
if (mode === "view") {
const count = data ? data[day][time] : 0;
const opacity = count === 0 ? 0 : count / maxMembers;
return {
backgroundColor:
count === 0 ? "transparent" : `rgba(139, 92, 246, ${opacity})`,
};
}
// select 모드: 드래그 미리보기 반영
let isSelected = internalSelection[day][time];
if (isDragging && startCell && currentCell) {
const inRange =
day >= Math.min(startCell.day, currentCell.day) &&
day <= Math.max(startCell.day, currentCell.day) &&
time >= Math.min(startCell.time, currentCell.time) &&
time <= Math.max(startCell.time, currentCell.time);
if (inRange) isSelected = !internalSelection[startCell.day][startCell.time];
}
return { backgroundColor: isSelected ? "#FBBF24" : "transparent" };
};문제 인식: XSS·클릭재킹·MIME 스니핑 등 일반적인 웹 공격을 방어하려면 HTTP 응답 헤더를 적절히 설정해야 합니다.
해결책: Next.js 미들웨어에서 모든 요청마다 nonce를 생성하고, CSP(Content Security Policy) 및 보안 헤더를 자동으로 적용합니다.
미들웨어 구현 — src/proxy.ts (Next.js 16 부터 middleware.ts 이름이 proxy.ts 로 바뀜)
export function proxy(request: NextRequest) {
// 매 요청마다 16바이트 random → base64 nonce 생성
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64");
const isDevelopment = process.env.NODE_ENV === "development";
const apiOrigin = getApiOrigin(); // env URL 에서 origin 만 안전 추출
// production: nonce + strict-dynamic → 호스트 출처 자동 무시, nonce 없는 스크립트 일체 차단
// development: HMR/eval 호환을 위해 unsafe-inline / unsafe-eval 허용
const scriptSrc = isDevelopment
? `'self' 'unsafe-inline' 'unsafe-eval'`
: `'nonce-${nonce}' 'strict-dynamic'`;
const cspDirectives = [
`default-src 'self'`,
`connect-src 'self' ${apiOrigin}` + (isDevelopment ? ` ws: wss:` : ``),
`script-src ${scriptSrc}`,
`style-src 'self' 'unsafe-inline'`, // Next.js critical CSS 인라인 호환
`img-src 'self' blob: data: https://res.cloudinary.com`,
`font-src 'self' data:`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`, // 클릭재킹 방지
`frame-src 'none'`, // iframe 미사용 → 명시적 차단
`upgrade-insecure-requests`,
];
// nonce 는 요청 헤더(x-nonce)로만 전달 → 서버 컴포넌트가 headers() 로 읽어
// Provider 의 __webpack_nonce__ 에 세팅. **DOM 어트리뷰트로는 절대 노출하지 않음**.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", cspDirectives.join("; "));
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("Permissions-Policy",
"camera=(), microphone=(), geolocation=(self)");
return response;
}이 프로젝트는 SVG를 두 가지 방식으로 처리합니다.
| 위치 | 처리 방식 | 사용법 |
|---|---|---|
src/assets/svgs/ |
SVGR → React 컴포넌트 변환 | import Icon from '@/assets/svgs/icons/search.svg' → <Icon /> |
| 그 외 폴더 | URL 기반 (asset 처리) | import iconUrl from './icon.svg' → <Image src={iconUrl} /> |
SVG 크기를 변경해야 할 경우, SVG 파일에서
width,height속성을 직접 제거하세요.
설정 파일:src/svgr.d.ts,next.config.ts
- 기본 폰트: Pretendard
- 설정 위치:
src/app/fonts.ts - CSS 변수:
--font-pretendard
- 서버 상태 (API 데이터): TanStack React Query 사용
- 전역 클라이언트 상태 (인증, 다단계 폼): Zustand 사용
- 로컬 UI 상태: React
useState/useReducer사용
src/components/ui/— Shadcn UI 원자 컴포넌트 (직접 수정 가능)src/components/shared/— 페이지를 가리지 않고 재사용 가능한 컴포넌트- 각 페이지 폴더 내
components/— 해당 페이지·기능 전용 컴포넌트
- ESLint +
eslint-config-next기반 - TypeScript strict 모드 활성화
- 경로 별칭:
@/*→src/*
백엔드 Swagger 문서를 기반으로 TypeScript API 클라이언트 코드를 자동 생성합니다.
# 기본 생성
npm run gen:api
# 모듈 분리 생성
npm run gen:api:split
# 로컬 스펙 파일 기반 생성
npm run generate:api:local생성 결과물은 src/apis/generated/Api.ts에 저장됩니다.
API 스펙 소스: https://meetnow.duckdns.org/v3/api-docs
NextTimeTogether Frontend · Built with ❤️ using Next.js & React