Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ jobs:
- name: Node 설치
uses: actions/setup-node@v3
with:
node-version: 18
# Vite 7은 Node 20.19+ 를 요구합니다.
node-version: 20

- name: 의존성 설치
run: npm install
Expand All @@ -26,6 +27,8 @@ jobs:
env:
VITE_SERVER_URL: ${{ secrets.VITE_SERVER_URL }}
VITE_KAKAO_API: ${{ secrets.VITE_KAKAO_API }}
# canonical / og:url / sitemap에 쓰이는 배포 도메인입니다. (예: https://calio.co.kr)
VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }}

- name: AWS 로그인
uses: aws-actions/configure-aws-credentials@v2
Expand All @@ -34,8 +37,28 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}

- name: S3 업로드
run: aws s3 sync dist s3://${{ secrets.S3_BUCKET_NAME }} --delete
# 파일명에 해시가 붙는 /assets 는 영구 캐시, HTML은 매번 재검증하도록 나눠서 올립니다.
# assets에는 --delete를 쓰지 않습니다. 배포 시점에 열려 있던 구버전 탭이
# 이전 해시의 lazy 청크를 요청하므로, 지우면 라우트 이동이 실패합니다.
# 해시 파일명이라 충돌은 없고, 오래된 청크는 필요 시 수동/수명주기 정책으로 정리합니다.
- name: S3 업로드 (해시 자산 - 장기 캐시)
run: |
aws s3 sync dist/assets s3://${{ secrets.S3_BUCKET_NAME }}/assets \
--cache-control "public, max-age=31536000, immutable"

- name: S3 업로드 (아이콘 / robots / sitemap)
run: |
aws s3 sync dist s3://${{ secrets.S3_BUCKET_NAME }} --delete \
--exclude "assets/*" --exclude "*.html" \
--cache-control "public, max-age=3600"

# 라우트별 프리렌더 결과(index.html, login/index.html, ...)를 한 번에 올립니다.
# HTML은 항상 재검증해야 배포 직후 구버전 문서가 남지 않습니다.
- name: S3 업로드 (HTML - 캐시 재검증)
run: |
aws s3 sync dist s3://${{ secrets.S3_BUCKET_NAME }} \
--exclude "*" --include "*.html" --exclude "assets/*" \
--cache-control "no-cache, must-revalidate" --content-type "text/html; charset=utf-8"

- name: CloudFront 캐시 삭제
run: aws cloudfront create-invalidation --distribution-id "${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}" --paths "/*"
48 changes: 48 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Lighthouse CI

on:
pull_request:
branches:
- develop
- main
workflow_dispatch:

# 같은 PR에 새 커밋이 올라오면 이전 실행은 취소합니다.
concurrency:
group: lighthouse-${{ github.ref }}
cancel-in-progress: true

jobs:
lighthouse:
runs-on: ubuntu-latest

steps:
- name: 코드 가져오기
uses: actions/checkout@v4

- name: Node 설치
uses: actions/setup-node@v4
with:
node-version: 20

- name: 의존성 설치
run: npm install

- name: 빌드
run: npm run build
env:
VITE_SERVER_URL: ${{ secrets.VITE_SERVER_URL }}
VITE_KAKAO_API: ${{ secrets.VITE_KAKAO_API }}
# canonical / og:url이 실제 배포 도메인으로 나와야 SEO 감사 결과가 배포 환경과 같아집니다.
VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }}

# 임계값은 lighthouserc.json에 있고, 측정 기준(2026-07-31, 모바일 프리셋)은 다음과 같습니다.
# SEO 100 / 접근성 94 / 모범사례 96 / 성능 79
# FCP 1.8s · LCP 5.4s · TBT 0ms · CLS 0 · script 147kB · font 598kB
#
# SEO / 접근성 / 스크립트 용량 / CLS는 실패(error)로 막고, 성능은 경고(warn)만 냅니다.
# CI 러너 성능 편차로 점수가 흔들리기 때문에 관계없는 PR까지 막히는 것을 피하기 위함입니다.
# 성능 79의 원인은 전적으로 CDN 웹폰트 598kB(전체 전송량의 78%)입니다.
# 폰트를 서브셋/자체 호스팅하면 임계값을 함께 올려주세요.
- name: Lighthouse CI 실행
run: npx --yes @lhci/cli@0.15.1 autorun
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ dist-ssr
*.sw?
.env
.eslintcache

# Lighthouse CI
.lighthouseci
32 changes: 23 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
말 한마디로 일정이 완성되는 AI 일정 관리 서비스<br />
<strong>2026 Capstone Frontend</strong>
</p>
<img src="./public/readme-cover.png" alt="Calio 대표 이미지" width="100%" />
<img src="./docs/readme-cover.webp" alt="Calio 대표 이미지" width="100%" />
<br />
<br />
<p>
Expand Down Expand Up @@ -91,12 +91,24 @@ npm run dev
npm run build
```

빌드 마지막 단계에서 `scripts/prerender.mjs`가 라우트별 정적 HTML을 생성합니다.

- `/`, `/login`은 실제 React 트리를 렌더해 본문까지 HTML에 담습니다. JS를 실행하지 않는
크롤러와 링크 미리보기 봇이 읽는 내용이자, 번들 다운로드 전에 보이는 첫 화면입니다.
- 로그인 이후 화면(`/calendar` 등)은 본문 없이 `noindex` 메타만 맞춘 HTML을 만듭니다.
- 라우트별 제목/설명/색인 여부의 원본은 `src/shared/seo/routeMeta.ts` 한 곳입니다.
런타임(`PageMeta`)과 빌드 타임(프리렌더)이 같은 상수를 사용합니다.

> ⚠️ `/login` 등 루트가 아닌 경로의 정적 HTML이 실제로 서빙되려면 CloudFront에
> `infra/cloudfront-spa-router.js` 함수를 연결해야 합니다. 연결 전에는 모든 경로가
> 기존처럼 `/index.html`(랜딩)로 폴백되며, 앱 동작에는 문제가 없습니다.

## 📜 Scripts

| Command | Description |
| ----------------- | --------------------------------------------------- |
| `npm run dev` | Vite 개발 서버를 실행합니다. |
| `npm run build` | TypeScript 빌드 Vite 프로덕션 빌드를 생성합니다. |
| Command | Description |
| ----------------- | -------------------------------------------------------------- |
| `npm run dev` | Vite 개발 서버를 실행합니다. |
| `npm run build` | TypeScript 빌드 Vite 프로덕션 빌드 → 라우트별 프리렌더. |
| `npm run lint` | ESLint로 전체 코드를 검사합니다. |
| `npm run preview` | 빌드 결과를 로컬에서 미리 확인합니다. |
| `npm run prepare` | Husky Git hook을 설치합니다. |
Expand All @@ -108,12 +120,14 @@ npm run build
```bash
VITE_DEV_MODE=true
VITE_SERVER_URL=https://example.com
VITE_SITE_URL=https://calio.co.kr
```

| Key | Description |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `VITE_DEV_MODE` | React Query DevTools 표시 여부를 제어합니다. 로컬에서는 `true`, 배포에서는 `false`를 권장합니다. |
| `VITE_SERVER_URL` | API 서버 URL입니다. |
| Key | Description |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| `VITE_DEV_MODE` | React Query DevTools 표시 여부를 제어합니다. 로컬에서는 `true`, 배포에서는 `false`를 권장합니다. |
| `VITE_SERVER_URL` | API 서버 URL입니다. |
| `VITE_SITE_URL` | 배포 도메인입니다. `canonical`, `og:url`, `sitemap.xml`에 사용되며 배포 워크플로 시크릿과 맞춥니다. |

## 🗂 Project Structure

Expand Down
Binary file added docs/readme-cover.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
128 changes: 125 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,136 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="Calio" content="말하는 대로 움직이는 일정" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>말하는 대로 움직이는 일정, Calio</title>

<!--
아래 블록은 빌드 시 scripts/prerender.mjs가 라우트별로 다시 생성합니다.
운영 기준 원본은 src/shared/seo/routeMeta.ts이고, 여기 값은 `npm run dev`용 기본값입니다.
마커(seo:start / seo:end)를 지우면 프리렌더가 실패합니다.
-->
<!--seo:start-->
<title>Calio(캘리오) | 말 한마디로 일정이 완성되는 AI 일정 관리</title>
<meta
name="description"
content="Calio는 자연어로 말하면 AI가 일정과 할 일을 자동으로 등록해주는 일정 관리 서비스입니다. 반복 일정 추천부터 친구와의 일정 공유까지 한 곳에서 관리하세요."
/>
<link rel="canonical" href="%SITE_URL%/" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#fafafa" />
<!--seo:end-->

<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

<!-- 검색 결과에 서비스 정보를 리치하게 노출하기 위한 구조화 데이터입니다. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Calio",
"alternateName": "캘리오",
"url": "%SITE_URL%/",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"inLanguage": "ko-KR",
"description": "자연어로 말하면 AI가 일정과 할 일을 자동으로 등록해주는 일정 관리 서비스입니다.",
"featureList": [
"말하면 바로 등록돼요",
"캘리오가 먼저 제안해요",
"일정도, 할 일도 여기 다 있어요",
"친구와 함께하는 일정도 쉽게"
],
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "KRW"
}
}
</script>

<!--
웹폰트는 JS 실행 이후가 아니라 HTML 파싱 단계에서 요청되어야 LCP가 빨라집니다.
(이전에는 Emotion GlobalStyle 안에 있어서 번들 실행 후에야 다운로드가 시작됐습니다.)
-->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="preload"
as="font"
type="font/woff"
href="https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2001@1.1/GmarketSansMedium.woff"
crossorigin
/>
<style>
@font-face {
font-family: 'GmarketSansMedium';
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2001@1.1/GmarketSansMedium.woff')
format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'KIMM_Light';
src: url('https://cdn.jsdelivr.net/gh/fontbee/font@main/Kimm/KIMM_Light.woff2')
format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}

/*
프리렌더된 라우트는 Emotion 리셋이 아래 <style>로 함께 들어오지만,
본문 없이 메타만 만드는 라우트에는 그게 없습니다.
그 경우에도 기본 body margin(8px) 때문에 첫 페인트가 밀리지 않도록 최소한만 둡니다.
*/
html,
body {
margin: 0;
padding: 0;
}
body {
background-color: #fafafa;
}

</style>
</head>
<body>
<div id="modal-root"></div>
<div id="root"></div>
<script>
/*
#root의 정적 HTML은 data-prerender-path 경로 전용으로 생성된 내용입니다.
(1) 다른 경로가 이 HTML을 받았거나 (SPA fallback)
(2) 마지막으로 확인된 상태가 로그인이면 (로그인 후 화면은 프리렌더 대상이 아님)
React가 마운트되기 전에 먼저 비워, 잘못된 화면이 잠깐 보이지 않게 합니다.
module 스크립트는 defer라서 이 스크립트가 항상 먼저 실행됩니다.
*/
;(function () {
var root = document.getElementById('root')
if (!root) return

try {
var normalize = function (value) {
return value.length > 1 ? value.replace(/\/$/, '') : value
}
var isLoggedIn = false

try {
/* 키는 useAuthStore.ts의 AUTH_HINT_KEY와 같아야 합니다. 변경 시 함께 수정하세요. */
isLoggedIn = localStorage.getItem('calio.isLoggedIn') === '1'
} catch (error) {
/* localStorage를 못 쓰는 환경이면 로그인 힌트만 포기합니다. */
}

var prerenderPath = normalize(root.getAttribute('data-prerender-path') || '/')

if (isLoggedIn || prerenderPath !== normalize(window.location.pathname)) {
root.textContent = ''
}
} catch (error) {
/* 힌트를 못 읽으면 정적 HTML을 그대로 두고 React 마운트에 맡깁니다. */
}
})()
</script>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>
40 changes: 40 additions & 0 deletions infra/cloudfront-spa-router.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
CloudFront Function (viewer request)

라우트별 정적 HTML(`/login` -> `/login/index.html`)을 서빙하기 위한 URL 재작성입니다.
이 함수를 붙이지 않으면 dist의 라우트별 HTML은 사용되지 않고,
모든 경로가 기존처럼 `/index.html`(랜딩) 하나로 서빙됩니다.

적용 방법
1) CloudFront 콘솔 > 함수 > 함수 생성
- 이름: calio-spa-router (계정 내 유일해야 하고, 생성 후 변경 불가)
- 런타임: cloudfront-js-2.0
2) 이 파일 내용을 붙여넣고 게시(Publish)
3) 배포 > 동작 > 기본 동작 편집 > 뷰어 요청에 이 함수 연결
4) 무효화(/*) 후 확인:
curl -sI https://calio.co.kr/login | head -1 # 200
curl -s https://calio.co.kr/login | grep '<title>' # 로그인 | Calio

동작 규칙
- 확장자가 있는 요청(/assets/x.js, /robots.txt 등)은 그대로 통과시킵니다.
- 그 외 경로는 `<경로>/index.html`로 바꿉니다.
- 존재하지 않는 경로는 S3가 403을 반환하고, 기존 오류 페이지 설정에 따라
`/index.html`로 폴백됩니다. (지금 동작과 동일)
*/
function handler(event) {
var request = event.request
var uri = request.uri

// 파일 요청(확장자 포함)은 재작성하지 않습니다.
if (uri.indexOf('.') !== -1) {
return request
}

if (uri.endsWith('/')) {
request.uri = uri + 'index.html'
} else {
request.uri = uri + '/index.html'
}

return request
}
24 changes: 24 additions & 0 deletions lighthouserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"ci": {
"collect": {
"startServerCommand": "npx vite preview --port 4173",
"startServerReadyPattern": "Local:",
"url": ["http://localhost:4173/"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:seo": ["error", { "minScore": 1 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }],
"categories:performance": ["warn", { "minScore": 0.75 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 200000 }],
"resource-summary:font:size": ["warn", { "maxNumericValue": 650000 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Loading
Loading