Astro OAuth — Google 로그인부터 캘린더 API 연동까지
Astro에서 OAuth 2.0으로 Google 로그인을 구현하고 Calendar API까지 연동하는 전체 과정을 코드와 함께 정리한다.
OAuth는 "내 비밀번호를 직접 주는 대신, 믿을 만한 제3자에게 인증을 맡기는 것"이다. 비유하면 호텔 프론트에서 신분증을 보여주면 방 카드키를 발급받는 구조와 같다. 호텔(우리 앱)은 직접 신원 조회를 하지 않고, 정부(Google)가 발급한 신분증을 확인하는 것만으로 충분하다.
Astro에서 Google OAuth를 구현하면 로그인뿐 아니라 Google Calendar 같은 API에도 접근할 수 있다. 이 글에서는 홈 페이지의 로그인 URL 생성부터 콜백 처리, 세션 저장, 미들웨어 인증 보호, 캘린더 이벤트 조회까지 전체 흐름을 코드로 만든다.
사전 준비 — Google Cloud Console 설정
구현에 앞서 Google Cloud Console에서 OAuth 자격증명을 만들어야 한다. APIs & Services > Credentials에서 OAuth 2.0 Client ID를 생성하고, 리디렉트 URI에 http://localhost:4321/callback을 등록한다.
[💡 잠깐! 이 용어는?] OAuth 2.0: 사용자 비밀번호를 공유하지 않고 제3자 애플리케이션이 리소스에 접근할 수 있게 하는 인증 프레임워크다. Google, GitHub, Apple 등 대부분의 로그인 연동이 이 프로토콜을 사용한다.
필요한 환경 변수는 3개다.
CLIENT_ID=your-google-client-id.apps.googleusercontent.com
CLIENT_SECRET=your-google-client-secret
REDIRECT_URL=http://localhost:4321/callbackAstro 프로젝트 설정
Astro는 기본적으로 정적 사이트 생성기다. OAuth 콜백을 서버에서 처리하려면 Node 어댑터와 서버 출력 모드가 필요하다. 세션 스토리지도 함께 활성화한다.
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
session: {
driver: 'memory'
}
})
output: 'server'로 설정하면 모든 페이지가 서버 사이드 렌더링된다. OAuth 콜백처럼 서버에서 토큰 교환을 해야 하는 흐름에는 필수 설정이다.
홈 페이지 — Google 인증 URL 생성
홈 페이지의 역할은 두 가지다. 로그인하지 않은 사용자에게는 Google 로그인 링크를 보여주고, 이미 로그인한 사용자에게는 캘린더 페이지로 이동하는 링크를 보여준다.
---
const session = await Astro.session
const loggedIn = await session?.get('gtoken')
const flashMessage = await session?.get('flash')
if (flashMessage) {
await session?.set('flash', undefined)
}
const params = new URLSearchParams({
client_id: import.meta.env.CLIENT_ID,
redirect_uri: import.meta.env.REDIRECT_URL,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/calendar.readonly',
access_type: 'offline',
prompt: 'consent'
})
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`
---
<html>
<head>
<title>Astro OAuth Demo</title>
</head>
<body>
{flashMessage && <p style="color:red;">{flashMessage}</p>}
{loggedIn ? (
<div>
<p>Google 계정으로 로그인됨</p>
<a href="/events">캘린더 이벤트 보기</a>
</div>
) : (
<div>
<p>Google 계정으로 로그인하세요</p>
<a href={googleAuthUrl}>Google 로그인</a>
</div>
)}
</body>
</html>URLSearchParams로 인증 URL을 조립하는 부분이 핵심이다. scope에 calendar.readonly를 넣으면 Google이 사용자에게 "이 앱이 캘린더를 읽으려 합니다"라는 동의 화면을 보여준다. access_type: 'offline'은 리프레시 토큰을 받기 위한 설정이고, prompt: 'consent'는 매번 동의 화면을 표시하도록 강제한다.
[💡 잠깐! 이 용어는?]
scope: OAuth에서 앱이 요청하는 권한의 범위다. calendar.readonly는 캘린더 읽기만 허용한다는 의미이고, 쓰기가 필요하면 calendar.events를 사용한다.
flash 세션은 일회성 메시지를 전달하는 패턴이다. 콜백에서 에러가 발생하면 flash에 메시지를 담고 홈으로 리디렉트한다. 홈 페이지에서 한 번 읽은 뒤 즉시 삭제하므로 새로고침하면 사라진다.
콜백 페이지 — 토큰 교환과 세션 저장
사용자가 Google 동의 화면에서 "허용"을 누르면 code 파라미터와 함께 콜백 URL로 돌아온다. 이 코드를 Google 토큰 엔드포인트에 보내서 액세스 토큰으로 교환한다.
---
const url = new URL(Astro.request.url)
const code = url.searchParams.get('code')
const session = await Astro.session
if (!code) {
await session?.set('flash', '인증 코드가 없습니다.')
return Astro.redirect('/')
}
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: import.meta.env.CLIENT_ID,
client_secret: import.meta.env.CLIENT_SECRET,
redirect_uri: import.meta.env.REDIRECT_URL,
grant_type: 'authorization_code'
})
})
if (!tokenResponse.ok) {
await session?.set('flash', '토큰 교환에 실패했습니다.')
return Astro.redirect('/')
}
const tokenData = await tokenResponse.json()
await session?.set('gtoken', tokenData.access_token, {
ttl: tokenData.expires_in
})
return Astro.redirect('/events')
---비유하면, code는 공연 티켓 교환권이다. 이걸 매표소(Google 토큰 엔드포인트)에 가져가면 진짜 입장권(access_token)으로 바꿔준다. 교환권은 1회용이라 다시 쓸 수 없다.
session.set의 세 번째 인자로 ttl(Time To Live)을 지정하면 토큰이 만료되는 시점에 세션 데이터도 자동으로 사라진다. Google 액세스 토큰의 기본 만료 시간은 **3,600초(1시간)**이므로, 1시간 후에는 세션에서 토큰이 자동 삭제되어 재로그인이 필요해진다.
미들웨어 — 인증되지 않은 접근 차단
캘린더 이벤트 페이지(/events)는 로그인한 사용자만 접근할 수 있어야 한다. Astro 미들웨어에서 세션을 확인하고, 토큰이 없으면 홈으로 돌려보낸다.
import { defineMiddleware } from 'astro:middleware'
const PROTECTED_ROUTES = ['/events']
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = new URL(context.request.url)
if (PROTECTED_ROUTES.some(route => pathname.startsWith(route))) {
const session = await context.session
const token = await session?.get('gtoken')
if (!token) {
await session?.set('flash', '로그인이 필요합니다.')
return context.redirect('/')
}
}
return next()
})PROTECTED_ROUTES 배열에 보호할 경로를 추가하면 된다. 미들웨어는 모든 요청에서 실행되지만, 보호 대상 경로가 아니면 바로 next()로 넘긴다.
이벤트 페이지 — Google Calendar 연동
세션에 저장된 토큰으로 Google Calendar API를 호출해서 이벤트 목록을 가져온다. 여기서는 다음 30일간의 이벤트를 조회하고, 회의 시간 합계까지 계산한다.
---
const session = await Astro.session
const token = await session?.get('gtoken')
const now = new Date()
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const calendarParams = new URLSearchParams({
timeMin: now.toISOString(),
timeMax: thirtyDaysLater.toISOString(),
singleEvents: 'true',
orderBy: 'startTime',
maxResults: '100'
})
const calendarUrl = `https://www.googleapis.com/calendar/v3/calendars/primary/events?${calendarParams.toString()}`
const calendarResponse = await fetch(calendarUrl, {
headers: { Authorization: `Bearer ${token}` }
})
if (!calendarResponse.ok) {
await session?.set('flash', '캘린더 데이터를 가져올 수 없습니다.')
await session?.set('gtoken', undefined)
return Astro.redirect('/')
}
const calendarData = await calendarResponse.json()
const events = calendarData.items || []
function calculateMeetingHours(eventList: any[]) {
let totalMinutes = 0
for (const event of eventList) {
if (event.start?.dateTime && event.end?.dateTime) {
const start = new Date(event.start.dateTime)
const end = new Date(event.end.dateTime)
totalMinutes += (end.getTime() - start.getTime()) / (1000 * 60)
}
}
const hours = Math.floor(totalMinutes / 60)
const minutes = Math.round(totalMinutes % 60)
return { hours, minutes }
}
const { hours, minutes } = calculateMeetingHours(events)
---
<html>
<head>
<title>캘린더 이벤트</title>
</head>
<body>
<h1>다음 30일 일정</h1>
<p>총 회의 시간: <strong>{hours}시간 {minutes}분</strong></p>
<p>이벤트 수: <strong>{events.length}개</strong></p>
<ul>
{events.map((event: any) => (
<li>
<strong>{event.summary || '(제목 없음)'}</strong>
{event.start?.dateTime && (
<span> — {new Date(event.start.dateTime).toLocaleString('ko-KR')}</span>
)}
{event.start?.date && (
<span> — {event.start.date} (종일)</span>
)}
</li>
))}
</ul>
<a href="/">홈으로</a>
</body>
</html>API 응답이 실패하면 토큰이 만료되었을 가능성이 높으므로 세션에서 토큰을 삭제하고 홈으로 리디렉트한다. singleEvents: 'true'는 반복 일정을 개별 이벤트로 펼쳐서 반환하도록 하는 설정이다.
전체 흐름 정리
| 단계 | 파일 | 역할 |
|---|---|---|
| 1. 로그인 | index.astro | Google 인증 URL 생성, 로그인 링크 제공 |
| 2. Google 동의 | (Google 측) | 사용자가 캘린더 읽기 권한 허용 |
| 3. 토큰 교환 | callback.astro | code → access_token 교환, 세션 저장 |
| 4. 인증 보호 | middleware.ts | 보호 경로 접근 시 토큰 확인 |
| 5. API 호출 | events.astro | Google Calendar 이벤트 조회 |
결과
OAuth 구현이 정상 동작하는지 확인할 포인트:
- 홈 페이지에서 "Google 로그인" 링크 클릭 시 Google 동의 화면으로 이동
- 동의 후
/callback을 거쳐/events로 자동 리디렉트 - 캘린더 이벤트 목록과 회의 시간 합계가 표시
- 로그인하지 않은 상태에서
/events직접 접근 시 홈으로 리디렉트 - flash 메시지가 한 번만 표시되고 새로고침 시 사라짐
- 토큰 TTL(1시간) 경과 후 자동 로그아웃
Astro의 세션 API와 미들웨어 조합이 OAuth 흐름과 잘 맞는다. 프레임워크가 제공하는 session.set의 TTL 기능 덕분에 토큰 만료 처리를 별도로 구현할 필요가 없다는 점이 특히 실용적이다.
참고:
- Raymond Camden의 원문: https://www.raymondcamden.com/2026/03/23/implementing-oauth-in-astro
- Astro Sessions 문서: https://docs.astro.build/en/guides/sessions/
- Google OAuth 2.0: https://developers.google.com/identity/protocols/oauth2
같은 카테고리 · JavaScript
비슷한 주제의 최신 글
태그가 겹치는 글
공통 태그가 많을수록 위에 보인다