Skip to content

Commit ec376f7

Browse files
authored
Merge pull request #1342 from Moadong/develop-fe
[release] FE
2 parents c3b82c3 + 80216fa commit ec376f7

File tree

7 files changed

+337
-13
lines changed

7 files changed

+337
-13
lines changed

.github/workflows/frontend-ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,6 @@ jobs:
3535
run: npm run lint
3636

3737
- name: Build
38+
env:
39+
SKIP_DYNAMIC_SITEMAP: 'true'
3840
run: npm run build

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
CLAUDE.md
99
.claude
1010

11-
dailyNote/
11+
dailyNote/
12+

AGENTS.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# AGENTS.md
2+
3+
## 목적
4+
5+
이 문서는 코딩 에이전트가 `moadong` 저장소에 들어왔을 때 바로 작업을 시작할 수 있도록 프로젝트 구조, 실행 방법, 코드 작성 규칙, 검증 방식, 협업 규칙을 전달하기 위한 가이드다.
6+
7+
## 프로젝트 구조
8+
9+
- 루트는 `frontend/``backend/`로 나뉜다.
10+
- 프론트엔드는 React + TypeScript + Vite 기반이다.
11+
- 백엔드는 Spring Boot + Gradle 기반이다.
12+
13+
### 프론트엔드 주요 경로
14+
15+
- `frontend/src/pages/`
16+
- 라우트 단위 페이지
17+
- `frontend/src/components/`
18+
- 공통 UI 컴포넌트
19+
- `frontend/src/hooks/`
20+
- 재사용 훅
21+
- `frontend/src/hooks/Queries/`
22+
- 서버 상태 조회 및 변경 훅
23+
- `frontend/src/apis/`
24+
- API 호출 함수
25+
- `frontend/src/store/`
26+
- 클라이언트 상태
27+
- `frontend/src/styles/`
28+
- 전역 스타일 및 테마
29+
30+
### 백엔드 주요 경로
31+
32+
- `backend/src/main/java/moadong/`
33+
- 도메인별 패키지 루트
34+
- `backend/src/main/java/moadong/global/`
35+
- 공통 설정, 예외, 유틸
36+
- `backend/src/main/java/moadong/club/`
37+
- 동아리 관련 도메인
38+
- `backend/src/main/java/moadong/user/`
39+
- 사용자 관련 도메인
40+
41+
## 개발 환경
42+
43+
### 프론트엔드
44+
45+
- 작업 경로: `/Users/seokyoung-won/Desktop/moadong/frontend`
46+
- Node 버전: `frontend/.nvmrc` 기준
47+
- 현재 확인된 버전: `22.12.0`
48+
- 번들러: Vite
49+
- 설정 파일: `frontend/config/vite.config.ts`
50+
51+
### 백엔드
52+
53+
- 작업 경로: `/Users/seokyoung-won/Desktop/moadong/backend`
54+
- Java 버전: 17
55+
- 빌드 도구: Gradle
56+
57+
## 빌드 및 테스트 명령어
58+
59+
### 프론트엔드
60+
61+
```bash
62+
nvm use
63+
npm install
64+
npm run dev
65+
npm run build
66+
npm run test
67+
npm run typecheck
68+
```
69+
70+
### 백엔드
71+
72+
```bash
73+
./gradlew bootRun
74+
./gradlew test
75+
./gradlew unitTest
76+
./gradlew integrationTest
77+
```
78+
79+
## 작업 원칙
80+
81+
- 기존 구조와 패턴을 먼저 따르고, 필요가 명확할 때만 새 패턴을 추가한다.
82+
- 변경 범위는 가능한 한 작게 유지한다.
83+
- 기능 변경과 대규모 리팩터링을 한 번에 섞지 않는다.
84+
- 학생용 흐름과 관리자용 흐름에 공통으로 영향을 주는 수정은 양쪽 화면을 함께 의식한다.
85+
- API 계약을 바꾸는 수정은 프론트와 백엔드 영향 범위를 같이 확인한다.
86+
87+
## 코드 스타일 규칙
88+
89+
### 프론트엔드
90+
91+
- 먼저 기존 페이지와 인접한 파일의 코드 스타일을 따른다.
92+
- 데이터 패칭은 `frontend/src/hooks/Queries/`의 기존 패턴을 우선 재사용한다.
93+
- API 호출은 `frontend/src/apis/`에 두고, 페이지나 컴포넌트 안에 직접 분산시키지 않는다.
94+
- 공통 UI가 필요하면 `frontend/src/components/`에서 재사용 가능한지 먼저 확인한다.
95+
- 타입이 필요하면 기존 타입 선언 위치와 네이밍 규칙을 따른다.
96+
97+
### 백엔드
98+
99+
- 도메인 패키지 구성을 유지한다.
100+
- controller, service, repository, dto 또는 payload 역할을 섞지 않는다.
101+
- 예외 처리와 검증은 기존 프로젝트 방식과 일관되게 맞춘다.
102+
- 파일 업로드, 알림, 실시간 이벤트처럼 부작용이 있는 로직은 연관 기능까지 같이 확인한다.
103+
104+
## 검증 기준
105+
106+
- 수정 후에는 가능한 한 가장 좁은 범위의 검증부터 실행한다.
107+
- 프론트 UI 수정이면 관련 페이지와 훅, 테스트 가능 여부를 먼저 확인한다.
108+
- 공통 상태나 공통 컴포넌트 변경이면 영향 받는 페이지를 넓게 살핀다.
109+
- 백엔드 수정이면 관련 테스트 태스크가 있는지 먼저 확인한 뒤 필요한 범위만 실행한다.
110+
- 실행하지 못한 테스트가 있으면 결과 보고 시 명시한다.
111+
112+
## 소규모 보안 주의사항
113+
114+
- 비밀키, 토큰, 계정 정보, 민감 설정값을 코드나 문서에 직접 남기지 않는다.
115+
- `.env`나 로컬 설정 파일을 새로 만들거나 수정할 때는 커밋 대상인지 반드시 확인한다.
116+
- 사용자 입력은 신뢰하지 않고 검증 로직을 유지한다.
117+
- 로그에 개인정보나 민감한 식별자를 과도하게 남기지 않는다.
118+
- 인증, 파일 업로드, 외부 스토리지, 푸시, 메일 관련 변경은 영향 범위를 명확히 확인한다.
119+
120+
## 커밋 규칙
121+
122+
- 한글로 작성한다.
123+
- 한 커밋은 하나의 목적을 가지도록 유지한다.
124+
- 커밋 메시지는 짧고 명확하게 작성한다.
125+
- 가능하면 변경 이유가 드러나는 동사를 사용한다.
126+
127+
예시:
128+
129+
```text
130+
feat: 관리자 지원자 상태 변경 UI 추가
131+
fix: 모집 종료일 검증 오류 수정
132+
refactor: 지원서 조회 훅 분리
133+
test: 지원 폼 유효성 검사 케이스 추가
134+
```
135+
136+
## PR 작성 규칙
137+
138+
- 제목만 보고도 변경 목적을 이해할 수 있게 작성한다.
139+
- 본문에는 최소한 다음 내용을 포함한다.
140+
- 변경 내용
141+
- 변경 이유
142+
- 검증 방법
143+
- 영향 범위
144+
- UI 변경이면 가능하면 스크린샷 또는 화면 설명을 첨부한다.
145+
- 학생용 화면 변경인지 관리자용 화면 변경인지 드러나게 적는다.
146+
147+
## 신규 멤버를 위한 메모
148+
149+
- 먼저 `README.md`를 읽고 서비스 맥락을 파악한다.
150+
- 프론트엔드는 `frontend/package.json`, `frontend/src/pages/`, `frontend/src/hooks/Queries/`를 먼저 보면 구조 파악이 빠르다.
151+
- 백엔드는 `backend/build.gradle`과 주요 도메인 패키지를 먼저 보면 된다.
152+
- 처음 수정할 때는 넓은 리팩터링보다 작은 기능 단위로 진입하는 편이 안전하다.
153+
- 운영 데이터나 배포 방식은 문서가 없으면 추측하지 말고 팀의 기존 절차를 확인한다.

frontend/.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@ build-storybook.log
1818
/storybook-static
1919
*storybook.log
2020
coverage/
21+
public/sitemap.xml
2122
# Sentry Config File
22-
.env.sentry-build-plugin
23+
.env.sentry-build-plugin
24+
25+
AGENTS.md

frontend/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
"main": "index.js",
55
"scripts": {
66
"dev": "vite --config ./config/vite.config.ts",
7-
"build": "tsc --noEmit && vite build --config ./config/vite.config.ts",
7+
"generate:sitemap": "node ./scripts/generate-sitemap.mjs",
8+
"build": "npm run generate:sitemap && tsc --noEmit && vite build --config ./config/vite.config.ts",
89
"preview": "vite preview --config ./config/vite.config.ts",
9-
"build:dev": "vite build --config ./config/vite.config.ts",
10-
"build:prod": "NODE_ENV=production vite build --config ./config/vite.config.ts",
10+
"build:dev": "npm run generate:sitemap && vite build --config ./config/vite.config.ts",
11+
"build:prod": "npm run generate:sitemap && NODE_ENV=production vite build --config ./config/vite.config.ts",
1112
"clean": "rm -rf dist",
1213
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,css,scss}\"",
1314
"lint": "eslint --fix",

frontend/public/sitemap.xml

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const SITE_URL =
5+
process.env.SITE_URL ||
6+
process.env.VITE_SITE_URL ||
7+
'https://www.moadong.com';
8+
const STATIC_PATHS = [
9+
'/',
10+
'/introduce',
11+
'/club-union',
12+
'/festival-introduction',
13+
];
14+
const ENV_FILES = [
15+
'.env',
16+
'.env.local',
17+
'.env.production',
18+
'.env.production.local',
19+
];
20+
const FETCH_TIMEOUT_MS = 10_000;
21+
const shouldSkipDynamicSitemap = ['1', 'true', 'yes'].includes(
22+
(process.env.SKIP_DYNAMIC_SITEMAP || '').toLowerCase(),
23+
);
24+
25+
const loadEnvFile = async (filePath) => {
26+
try {
27+
const raw = await readFile(filePath, 'utf8');
28+
29+
raw.split(/\r?\n/).forEach((line) => {
30+
const trimmed = line.trim();
31+
32+
if (!trimmed || trimmed.startsWith('#')) {
33+
return;
34+
}
35+
36+
const separatorIndex = trimmed.indexOf('=');
37+
if (separatorIndex === -1) {
38+
return;
39+
}
40+
41+
const key = trimmed.slice(0, separatorIndex).trim();
42+
if (!key || process.env[key]) {
43+
return;
44+
}
45+
46+
let value = trimmed.slice(separatorIndex + 1).trim();
47+
48+
if (
49+
(value.startsWith('"') && value.endsWith('"')) ||
50+
(value.startsWith("'") && value.endsWith("'"))
51+
) {
52+
value = value.slice(1, -1);
53+
}
54+
55+
process.env[key] = value;
56+
});
57+
} catch (error) {
58+
if (error?.code !== 'ENOENT') {
59+
throw error;
60+
}
61+
}
62+
};
63+
64+
const escapeXml = (value) =>
65+
value
66+
.replaceAll('&', '&')
67+
.replaceAll('<', '&lt;')
68+
.replaceAll('>', '&gt;')
69+
.replaceAll('"', '&quot;')
70+
.replaceAll("'", '&apos;');
71+
72+
const formatLastModified = (date) => {
73+
const isoString = date.toISOString();
74+
return `${isoString.slice(0, 19)}+00:00`;
75+
};
76+
77+
const buildUrlEntry = (loc, lastmod) => ` <url>
78+
<loc>${escapeXml(loc)}</loc>
79+
<lastmod>${lastmod}</lastmod>
80+
<changefreq>daily</changefreq>
81+
<priority>${loc === `${SITE_URL}/` ? '1.0' : '0.8'}</priority>
82+
</url>`;
83+
84+
const getDynamicPaths = async (apiBaseUrl) => {
85+
if (shouldSkipDynamicSitemap) {
86+
console.warn(
87+
'[generate-sitemap] SKIP_DYNAMIC_SITEMAP is enabled. Generating static sitemap only.',
88+
);
89+
return [];
90+
}
91+
92+
if (!apiBaseUrl) {
93+
console.warn(
94+
'[generate-sitemap] VITE_API_BASE_URL is not set. Generating static sitemap only.',
95+
);
96+
return [];
97+
}
98+
99+
try {
100+
const searchUrl = new URL('/api/club/search/', apiBaseUrl);
101+
searchUrl.search = new URLSearchParams({
102+
keyword: '',
103+
recruitmentStatus: 'all',
104+
category: 'all',
105+
division: 'all',
106+
}).toString();
107+
108+
const response = await fetch(searchUrl, {
109+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
110+
});
111+
if (!response.ok) {
112+
throw new Error(
113+
`Failed to fetch clubs for sitemap: ${response.status} ${response.statusText}`,
114+
);
115+
}
116+
117+
const payload = await response.json();
118+
const clubs = Array.isArray(payload?.data?.clubs)
119+
? payload.data.clubs
120+
: Array.isArray(payload?.clubs)
121+
? payload.clubs
122+
: [];
123+
124+
return clubs
125+
.map((club) => club?.name)
126+
.filter((name) => typeof name === 'string' && name.trim().length > 0)
127+
.map((name) => `/clubDetail/@${encodeURIComponent(name.trim())}`);
128+
} catch (error) {
129+
console.warn(
130+
'[generate-sitemap] Failed to fetch dynamic club URLs. Falling back to static sitemap only.',
131+
);
132+
console.warn(`[generate-sitemap] ${error.message}`);
133+
return [];
134+
}
135+
};
136+
137+
const main = async () => {
138+
const rootDir = process.cwd();
139+
await Promise.all(
140+
ENV_FILES.map((fileName) => loadEnvFile(path.join(rootDir, fileName))),
141+
);
142+
143+
const apiBaseUrl = process.env.VITE_API_BASE_URL;
144+
const dynamicPaths = await getDynamicPaths(apiBaseUrl);
145+
const timestamp = formatLastModified(new Date());
146+
147+
const allPaths = [...STATIC_PATHS, ...dynamicPaths];
148+
const uniquePaths = [...new Set(allPaths)];
149+
150+
const xml = [
151+
'<?xml version="1.0" encoding="UTF-8"?>',
152+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
153+
...uniquePaths.map((routePath) =>
154+
buildUrlEntry(new URL(routePath, SITE_URL).toString(), timestamp),
155+
),
156+
'</urlset>',
157+
'',
158+
].join('\n');
159+
160+
const outputPath = path.join(rootDir, 'public', 'sitemap.xml');
161+
await mkdir(path.dirname(outputPath), { recursive: true });
162+
await writeFile(outputPath, xml, 'utf8');
163+
164+
console.log(
165+
`Generated sitemap with ${uniquePaths.length} URLs at ${outputPath}`,
166+
);
167+
};
168+
169+
main().catch((error) => {
170+
console.error('[generate-sitemap]', error);
171+
process.exit(1);
172+
});

0 commit comments

Comments
 (0)