Astro OAuth — Google 로그인부터 캘린더 API 연동까지

11 min read
AstroOAuthGoogleCalendar API인증
Astro OAuth — Google 로그인부터 캘린더 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개다.

.env
CLIENT_ID=your-google-client-id.apps.googleusercontent.com
CLIENT_SECRET=your-google-client-secret
REDIRECT_URL=http://localhost:4321/callback

Astro 프로젝트 설정

Astro는 기본적으로 정적 사이트 생성기다. OAuth 콜백을 서버에서 처리하려면 Node 어댑터서버 출력 모드가 필요하다. 세션 스토리지도 함께 활성화한다.

astro.config.mjs
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 로그인 링크를 보여주고, 이미 로그인한 사용자에게는 캘린더 페이지로 이동하는 링크를 보여준다.

src/pages/index.astro
---
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을 조립하는 부분이 핵심이다. scopecalendar.readonly를 넣으면 Google이 사용자에게 "이 앱이 캘린더를 읽으려 합니다"라는 동의 화면을 보여준다. access_type: 'offline'은 리프레시 토큰을 받기 위한 설정이고, prompt: 'consent'는 매번 동의 화면을 표시하도록 강제한다.

[💡 잠깐! 이 용어는?] scope: OAuth에서 앱이 요청하는 권한의 범위다. calendar.readonly는 캘린더 읽기만 허용한다는 의미이고, 쓰기가 필요하면 calendar.events를 사용한다.

flash 세션은 일회성 메시지를 전달하는 패턴이다. 콜백에서 에러가 발생하면 flash에 메시지를 담고 홈으로 리디렉트한다. 홈 페이지에서 한 번 읽은 뒤 즉시 삭제하므로 새로고침하면 사라진다.

콜백 페이지 — 토큰 교환과 세션 저장

사용자가 Google 동의 화면에서 "허용"을 누르면 code 파라미터와 함께 콜백 URL로 돌아온다. 이 코드를 Google 토큰 엔드포인트에 보내서 액세스 토큰으로 교환한다.

src/pages/callback.astro
---
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 미들웨어에서 세션을 확인하고, 토큰이 없으면 홈으로 돌려보낸다.

src/middleware.ts
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일간의 이벤트를 조회하고, 회의 시간 합계까지 계산한다.

src/pages/events.astro
---
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.astroGoogle 인증 URL 생성, 로그인 링크 제공
2. Google 동의(Google 측)사용자가 캘린더 읽기 권한 허용
3. 토큰 교환callback.astrocodeaccess_token 교환, 세션 저장
4. 인증 보호middleware.ts보호 경로 접근 시 토큰 확인
5. API 호출events.astroGoogle Calendar 이벤트 조회

결과

OAuth 구현이 정상 동작하는지 확인할 포인트:

  • 홈 페이지에서 "Google 로그인" 링크 클릭 시 Google 동의 화면으로 이동
  • 동의 후 /callback을 거쳐 /events로 자동 리디렉트
  • 캘린더 이벤트 목록과 회의 시간 합계가 표시
  • 로그인하지 않은 상태에서 /events 직접 접근 시 홈으로 리디렉트
  • flash 메시지가 한 번만 표시되고 새로고침 시 사라짐
  • 토큰 TTL(1시간) 경과 후 자동 로그아웃

Astro의 세션 API와 미들웨어 조합이 OAuth 흐름과 잘 맞는다. 프레임워크가 제공하는 session.set의 TTL 기능 덕분에 토큰 만료 처리를 별도로 구현할 필요가 없다는 점이 특히 실용적이다.


참고:

관심 있을 만한 포스트

새벽 3시의 탐조등 — Google SRE가 Gemini CLI로 장애를 잡는 법

Google Cloud SRE 팀이 Gemini CLI를 장애 대응 워크플로우에 통합한 방법, 효과, 한계를 분석한다.

SREGoogle

Google WebMCP — 웹사이트가 AI 에이전트에게 '메뉴판'을 건네는 시대

Chrome 146에 탑재된 WebMCP의 Declarative·Imperative API 구조와 웹 개발자가 준비해야 할 변화를 분석한다.

WebMCPAI Agent

SVG 아이콘 — 코드 배포 없이 프로덕트 팀이 직접 관리하는 법

CSS mask-image와 S3를 조합해 개발자 개입 없이 아이콘을 교체하는 패턴을 소개한다.

SVGCSS

VS Code 1.116 — 에이전트 디버깅, 포그라운드 터미널, 내장 Copilot

2026년 4월 VS Code 1.116이 에이전트 경험, 터미널, Chat UX, 내장 브라우저를 개선한 핵심 변경사항을 정리한다.

VS Code1.116

Naver FE News 2026년 4월 — 49MB 웹 페이지부터 Temporal Stage 4까지

Naver FE News 2026년 4월호에서 프론트엔드 개발자가 주목할 6가지 소식을 선별해 정리한다.

Naver FE NewsTemporal

VS Code 에이전트 — 실전 개발에서 쓸 수 있게 만드는 세 가지 축

VS Code 1.110이 도입한 컨텍스트 관리, 에이전트 제어, 확장성 기능이 AI 에이전트를 실무에 투입 가능하게 만든 방식을 분석한다.

VS CodeAI Agent

node:vfs — Node.js에 가상 파일 시스템이 필요한 이유

Matteo Collina가 제안한 node:vfs 모듈이 해결하려는 4가지 문제와 아키텍처를 분석한다.

Node.jsVFS

envscan — .env.example을 손으로 관리하지 말자

코드에서 process.env 참조를 스캔해 .env.example을 자동 생성하는 envscan의 접근 방식과 기존 도구들과의 차이를 정리한다.

Node.js환경 변수