메인 콘텐츠로 건너뛰기
OAuth 2.0 인증에서는 사용자가 개별 Google Sheets에 액세스 권한을 부여합니다. 사용자별 데이터를 다루는 애플리케이션용입니다.

적용 상황

  • 사용자 중심 앱 — 사용자가 자신의 Google Sheets에 액세스
  • 멀티 테넌트 앱 — 서로 다른 사용자가 서로 다른 스프레드시트를 관리
  • 개인 데이터 액세스 — 사용자 개별 계정의 시트를 읽고 쓰기
  • 데스크톱·Web 앱 — 사용자 인터랙션이 가능

전제 조건

  • Google Cloud Console 프로젝트
  • Google Sheets API와 Google Drive API 활성화
  • Laravel Socialite(권장)

셋업

1

Google Cloud Console 설정

  1. Google Cloud Console에 접속
  2. 프로젝트를 선택하거나 새로 생성
  3. APIs & Services > Library로 이동
  4. 다음 API를 활성화:
    • Google Sheets API
    • Google Drive API
2

OAuth 2.0 인증 정보 생성

  1. APIs & Services > Credentials로 이동
  2. Create Credentials > OAuth client ID 클릭
  3. OAuth 동의 화면 설정(최초 1회):
    • 사용자 타입을 External로 선택
    • 필수 항목 입력(앱 이름, 지원 이메일, 개발자 연락처)
    • 스코프 추가: https://www.googleapis.com/auth/spreadsheetshttps://www.googleapis.com/auth/drive
  4. 애플리케이션 타입을 Web application으로 선택
  5. 인가 리디렉트 URI 추가:
    • 개발 환경: http://localhost:8000/auth/google/callback
    • 프로덕션 환경: https://yourdomain.com/auth/google/callback
  6. Create 클릭
  7. Client IDClient Secret 복사
3

Laravel 환경 설정

.env 파일에 추가:
GOOGLE_CLIENT_ID=your-client-id-here
GOOGLE_CLIENT_SECRET=your-client-secret-here
GOOGLE_REDIRECT=http://localhost:8000/auth/google/callback
config/google.php 업데이트:
'client_id' => env('GOOGLE_CLIENT_ID', ''),
'client_secret' => env('GOOGLE_CLIENT_SECRET', ''),
'redirect_uri' => env('GOOGLE_REDIRECT', ''),
'scopes' => [
    \Google\Service\Sheets::SPREADSHEETS,
    \Google\Service\Drive::DRIVE,
],
'access_type' => 'offline', // 리프레시 토큰에 필수
'prompt' => 'consent select_account',
4

Laravel Socialite 설치

composer require laravel/socialite
config/services.php에 추가:
'google' => [
    'client_id' => env('GOOGLE_CLIENT_ID'),
    'client_secret' => env('GOOGLE_CLIENT_SECRET'),
    'redirect' => env('GOOGLE_REDIRECT'),
],
5

인증 컨트롤러 구현

// app/Http/Controllers/AuthController.php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;

class AuthController extends Controller
{
    public function redirectToGoogle()
    {
        return Socialite::driver('google')
            ->scopes(config('google.scopes'))
            ->with([
                'access_type' => config('google.access_type'),
                'prompt' => config('google.prompt'),
            ])
            ->redirect();
    }

    public function handleGoogleCallback()
    {
        try {
            $googleUser = Socialite::driver('google')->user();
            
            $user = User::updateOrCreate(
                ['email' => $googleUser->email],
                [
                    'name' => $googleUser->name,
                    'email' => $googleUser->email,
                    'google_access_token' => $googleUser->token,
                    'google_refresh_token' => $googleUser->refreshToken,
                    'google_expires_in' => $googleUser->expiresIn,
                    'google_token_created' => now()->timestamp,
                ]
            );

            auth()->login($user);

            return redirect('/dashboard')->with('success', 'Google 인증에 성공했습니다');
        } catch (\Exception $e) {
            return redirect('/login')
                ->with('error', '인증 실패: ' . $e->getMessage());
        }
    }

    public function logout(Request $request)
    {
        auth()->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect('/');
    }
}
6

라우트 추가

// routes/web.php
Route::get('/auth/google', [AuthController::class, 'redirectToGoogle'])
    ->name('google.redirect');
Route::get('/auth/google/callback', [AuthController::class, 'handleGoogleCallback'])
    ->name('google.callback');
Route::post('/logout', [AuthController::class, 'logout'])
    ->name('logout');
7

User 모델 업데이트

마이그레이션:
// database/migrations/add_google_tokens_to_users_table.php
Schema::table('users', function (Blueprint $table) {
    $table->text('google_access_token')->nullable();
    $table->text('google_refresh_token')->nullable();
    $table->integer('google_expires_in')->nullable();
    $table->integer('google_token_created')->nullable();
});
User 모델:
// app/Models/User.php
protected $fillable = [
    'name',
    'email',
    'password',
    'google_access_token',
    'google_refresh_token',
    'google_expires_in',
    'google_token_created',
];

protected $hidden = [
    'password',
    'remember_token',
    'google_access_token',
    'google_refresh_token',
];

public function getGoogleTokenArray(): array
{
    return [
        'access_token' => $this->google_access_token,
        'refresh_token' => $this->google_refresh_token,
        'expires_in' => $this->google_expires_in,
        'created' => $this->google_token_created,
    ];
}

public function hasValidGoogleToken(): bool
{
    return !empty($this->google_access_token) 
        && !empty($this->google_refresh_token);
}
8

Sheets 사용

use Revolution\Google\Sheets\Facades\Sheets;

public function getSheetData(Request $request)
{
    $user = $request->user();
    
    if (!$user->hasValidGoogleToken()) {
        return redirect()->route('google.redirect');
    }

    try {
        $token = $user->getGoogleTokenArray();

        $values = Sheets::setAccessToken($token)
            ->spreadsheet('user-spreadsheet-id')
            ->sheet('Sheet1')
            ->all();
            
        return view('sheets.data', compact('values'));
    } catch (\Exception $e) {
        // 토큰 만료 시 재인증으로
        if (str_contains($e->getMessage(), 'invalid_grant') 
            || str_contains($e->getMessage(), 'unauthorized')) {
            return redirect()->route('google.redirect');
        }

        throw $e;
    }
}

토큰 리프레시

패키지는 토큰 유효 기간 만료를 자동으로 처리합니다:
$token = [
    'access_token' => $user->google_access_token,
    'refresh_token' => $user->google_refresh_token,
    'expires_in' => $user->google_expires_in,
    'created' => $user->google_token_created,
];

// 만료 시 자동으로 리프레시
Sheets::setAccessToken($token)
    ->spreadsheet('id')
    ->sheet('Sheet1')
    ->all();

// 리프레시 후 갱신 토큰을 취득
$updatedToken = Sheets::getAccessToken();
if ($updatedToken) {
    $user->update([
        'google_access_token' => $updatedToken['access_token'],
        'google_token_created' => time(),
    ]);
}

미들웨어

Google 인증을 필수로 하는 미들웨어:
// app/Http/Middleware/RequireGoogleAuth.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class RequireGoogleAuth
{
    public function handle(Request $request, Closure $next)
    {
        $user = $request->user();

        if (!$user || !$user->hasValidGoogleToken()) {
            if ($request->expectsJson()) {
                return response()->json(
                    ['error' => 'Google 인증이 필수입니다'], 
                    401
                );
            }

            return redirect()->route('google.redirect');
        }

        return $next($request);
    }
}

보안

1. 토큰 보관

  • 토큰은 데이터베이스에 안전하게 저장
  • Laravel의 내장 암호화 사용
  • 클라이언트 측에 토큰을 노출하지 않기

2. 스코프 관리

  • 필요 최소한의 스코프만 요청
  • 최소 권한 원칙 적용
  • 사용자에게 필요한 권한을 명확히 설명

3. 에러 처리

  • 만료된 토큰을 적절히 처리
  • 사용자 친화적인 재인증 흐름 제공
  • 에러 로그 기록·모니터링

트러블슈팅

자주 발생하는 에러

“redirect_uri_mismatch” 에러
  • Google Console의 리디렉트 URI와 애플리케이션이 완전히 일치하는지 확인
  • http vs https 차이 확인
  • 말미의 슬래시 확인
“invalid_grant” 또는 “unauthorized” 에러
  • 토큰 만료로 인해 리프레시에 실패
  • 사용자에게 재인증을 요청
  • 리프레시 토큰이 존재하는지 확인
“access_denied” 에러
  • 사용자가 권한 허용을 거부
  • 적절한 메시지로 대응
  • 재인증을 시도하는 옵션 제공

테스트 라우트

Route::get('/test-oauth', function (Request $request) {
    $user = $request->user();
    
    if (!$user->hasValidGoogleToken()) {
        return 'Google 토큰이 없습니다.'
            . '<a href="' . route('google.redirect') . '">'
            . '인증</a>';
    }
    
    try {
        $token = $user->getGoogleTokenArray();
        $sheets = Sheets::setAccessToken($token)->spreadsheetList();
        
        return 'OAuth가 정상 동작하고 있습니다! 스프레드시트 수: ' 
            . count($sheets);
    } catch (\Exception $e) {
        return 'OAuth 에러: ' . $e->getMessage();
    }
})->middleware('auth');
마지막 수정일 2026년 7월 13일