본문 바로가기

flutter

flutter social login - google 소셜 로그인 (Android, iOS)

반응형

아키텍처

  • 프론트 엔드: Flutter, 백엔드: Nest.js 조합
  • Firebase 미사용
  • postgres DB 사용

 

GCP 계정 생성 및 설정

아래 페이지로 접속 후, 구글 클라우드 플랫폼 계정 생성

https://cloud.google.com/

 

클라우드 컴퓨팅 서비스 | Google Cloud

데이터 관리, 하이브리드 및 멀티 클라우드, AI와 머신러닝 등 Google의 클라우드 컴퓨팅 서비스로 비즈니스 당면 과제를 해결하세요.

cloud.google.com

 

대한민국 설정 후 계속을 클릭합니다.

 

결제 프로필 및 주소 등을 본인 계정에 맞게 설정합니다.

 

프로젝트 생성

프로젝트 생성을 클릭 후, 프로젝트 이름을 지정하여 새 프로젝트 생성합니다.

 

OAuth 동의 화면 설정

해당 프로젝트에서 > API 및 서비스 > OAuth 동의 화면을 클릭합니다.

 

시작하기를 클릭합니다.

 

앱 이름 및 사용자 지원 이메일 등을 기입합니다.

 

대상은 외부를 선택합니다.

 

 

프로젝트에 관한 연락을 받을 연락처를 기입합니다.

 

마지막으로 만들기를 클릭합니다.

 

사용자 인증 정보 설정

해당 프로젝트 > API 및 서비스 > 사용자 인증 정보 클릭

 

사용자 인증 정보 만들기 > OAuth 클라이언트 ID 클릭.

 

아래 내용과 스크린샷을 참고하여, 설정 후, 만들기를 클릭합니다.

  • 애플리케이션 유형: Android
  • 이름: 플랫폼 구분 가능하도록 원하는 이름 설정
  • 패키지 이름: 실제 안드로이드 패키지 이름 확인 후, 그대로 설정
  • SHA-1 인증서 디지털 지문: 앱에서 아래 명령어 사용 후, SHA-1 그대로 삽입
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
  • 앱 소유권 확인: 개발 단계이므로 PASS

 

ios도 동일한 과정으로 생성해줍니다. 차이점은 번들 ID를 확인 후 삽입하면 되며, 애플 개발자에 등록된 팀 ID를 찾아서 넣어주세요.

 

클라이언트 인증 파일 설정

Android)

android/app/src/google-services.json 위치에 다운로드받은 .json 파일을 대체합니다.

 

iOS)

ios/Runner/info.plist 의 하단에 아래 내용을 추가합니다. (YOUR_REVERSED_CLINET_ID는 다운로드받은 .plist 파일 확인하여 실제 값으로 대체)

    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>YOUR_REVERSED_CLIENT_ID</string>
            </array>
        </dict>
    </array>

 

추가적으로, ios/Runner/GoogleService-Info.plist 위치에 다운로드받은 .plist 파일을 대체합니다.

 

코드 구현

이제 기본적인 설정은 완료되었고, 2가지 방식의 구현 형태를 설명할게요.

  1. 플러터에서 기본적으로 로그인만 하는 방식 (프론트엔드만 활용(로컬 로그인), 백엔드 활용 불가)
  2. 백엔드 연동하여 DB에 저장하여 활용하는 방식 (nest.js 기준으로 설명)

 

1. Flutter에서만 로그인하는 방식 (백엔드 활용 불가, 쉽지만 비추천)

이 방식은 가장 간단한 구현 방법으로, Google Sign-In으로 받은 사용자 정보를 Flutter 앱 내에서만 사용하는 방식입니다. 빠른 프로토타이핑이나 간단한 개인 프로젝트에 적합합니다.

 

전체 인증 플로우

[Flutter App] ←→ [Google OAuth] ←→ [SharedPreferences]

 

간단한 인증 플로우

  1. 사용자 → Google 로그인 버튼 클릭
  2. Flutter → Google Sign-In 팝업 표시
  3. 사용자 → Google 계정으로 로그인
  4. Google → Flutter에 사용자 정보 반환
  5. Flutter → SharedPreferences에 사용자 정보 저장
  6. Flutter → 메인 화면으로 이동

 

Flutter 구현

pubspec.yaml에 패키지 추가

dependencies:
  google_sign_in: ^6.1.5
  shared_preferences: ^2.2.2
  dio: ^5.3.2
  flutter_dotenv: ^5.1.0

 

환경변수 기반 설정 (.env 파일)

# .env
BASE_URL=http://localhost:8080  # 실제 백엔드 IP 주소 사용
API_TIMEOUT=30000
GOOGLE_WEB_CLIENT_ID=GOOGLE_WEB_CLIENT_ID # 백엔드(웹) 용도의 CLIENT ID를 입력!
GOOGLE_IOS_CLIENT_ID=GOOGLE_IOS_CLIENT_ID # IOS CLIENT ID
GOOGLE_ANDROID_CLIENT_ID=GOOGLE_ANDROID_CLIENT_ID # ANDROID CLIENT ID (사용 안함)

 

.env 초기화 설정

// lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env");
  runApp(MyApp());
}


App Config 클래스

// lib/config/app_config.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';

class AppConfig {
  static String get baseUrl => dotenv.env['BASE_URL'] ?? 'http://localhost:3000';
  static int get apiTimeout => int.parse(dotenv.env['API_TIMEOUT'] ?? '30000');
  static String get googleWebClientId => dotenv.env['GOOGLE_WEB_CLIENT_ID'] ?? '';
  static String get googleIosClientId => dotenv.env['GOOGLE_IOS_CLIENT_ID'] ?? '';
  static String get googleAndroidClientId => dotenv.env['GOOGLE_ANDROID_CLIENT_ID'] ?? '';
}

 

로그인 버튼 구현.
구글 버튼만 일단 구현하였으며 추후 다른 포스팅에서 카카오, 애플 등 추가할 예정입니다. 이 위젯을 실제 사용하는 스크린에서 불러와주면 됩니다.

// lib/widgets/login_button_section.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'social_login_button.dart';
import '../services/auth_service.dart';

class LoginButtonSection extends StatelessWidget {
  const LoginButtonSection({super.key});

  @override
  Widget build(BuildContext context) {
    final authService = AuthService();

    return Column(
      children: [
        SocialLoginButton(
          text: 'Google로 시작하기',
          backgroundColor: Colors.white,
          textColor: Colors.black,
          icon: Icons.g_mobiledata,
          onPressed: () async {
            final success = await authService.signInWithGoogle(context);
            if (success && context.mounted) {
              context.go('/calendar');
            }
          },
          hasBorder: true,
        ),
      ],
    );
  }
}

 

공통 컴포넌트로 쓰이는 로그인 버튼 위젯

// lib/widgets/social_login_button.dart
import 'package:flutter/material.dart';

class SocialLoginButton extends StatelessWidget {
  final String text;
  final Color backgroundColor;
  final Color textColor;
  final IconData icon;
  final VoidCallback onPressed;
  final bool hasBorder;

  const SocialLoginButton({
    super.key,
    required this.text,
    required this.backgroundColor,
    required this.textColor,
    required this.icon,
    required this.onPressed,
    this.hasBorder = false,
  });

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: double.infinity,
      height: 52,
      child: ElevatedButton(
        onPressed: onPressed,
        style: ElevatedButton.styleFrom(
          backgroundColor: backgroundColor,
          foregroundColor: textColor,
          elevation: hasBorder ? 0 : 1,
          shadowColor: Colors.black26,
          side: hasBorder
              ? BorderSide(color: Colors.grey.shade300, width: 1)
              : null,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8),
          ),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, size: 20),
            const SizedBox(width: 12),
            Text(
              text,
              style: const TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.w500,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

 

로컬 로그인 구현

이제 실질적으로 버튼을 눌렀을 때 동작하는 코드를 구현해봅시다.

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:io';
import '../config/app_config.dart';

class AuthService {
  static final AuthService _instance = AuthService._internal();
  factory AuthService() => _instance;
  AuthService._internal();

  final GoogleSignIn _googleSignIn = GoogleSignIn(
    scopes: ['email', 'profile'],
    // iOS에서만 clientId 필요
    clientId: (!kIsWeb && Platform.isIOS)
        ? AppConfig.googleIosClientId
        : null,
  );

  GoogleSignInAccount? _currentUser;
  GoogleSignInAccount? get currentUser => _currentUser;

  Future<bool> signInWithGoogle(BuildContext context) async {
    try {
      final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();
      
      if (googleUser != null) {
        _currentUser = googleUser;

        // 사용자 정보를 로컬에 저장 (백엔드 없이)
        final prefs = await SharedPreferences.getInstance();
        await prefs.setString('user_name', googleUser.displayName ?? '');
        await prefs.setString('user_email', googleUser.email);
        await prefs.setString('user_photo', googleUser.photoUrl ?? '');
        await prefs.setBool('is_logged_in', true);

        if (context.mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('${googleUser.displayName}님, 환영합니다!')),
          );
        }

        return true;
      } else {
        print('❌ googleUser가 null입니다 (사용자가 취소했거나 실패)');
      }

      return false;
    } catch (e, stackTrace) {
      print('❌ 구글 로그인 에러: $e');
      print('❌ 스택 트레이스: $stackTrace');
      if (context.mounted) {
        _showErrorMessage(context, '구글 로그인 실패: $e');
      }
      return false;
    }
  }

  void _showErrorMessage(BuildContext context, String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.red,
      ),
    );
  }

  Future<void> signOut() async {
    try {
      await _googleSignIn.signOut();
      _currentUser = null;

      // SharedPreferences에서 로그인 정보 제거
      final prefs = await SharedPreferences.getInstance();
      await prefs.clear();
    } catch (e) {
      print('로그아웃 실패: $e');
    }
  }

  Future<bool> isLoggedIn() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      final isLoggedIn = prefs.getBool('is_logged_in') ?? false;

      if (isLoggedIn) {
        // Google Sign-In 상태도 확인
        final googleUser = await _googleSignIn.signInSilently();
        if (googleUser != null) {
          _currentUser = googleUser;
          return true;
        } else {
          // Google 로그인 상태가 만료된 경우 SharedPreferences도 업데이트
          await prefs.setBool('is_logged_in', false);
          return false;
        }
      }

      return false;
    } catch (e) {
      print('로그인 상태 확인 실패: $e');
      return false;
    }
  }

  Future<Map<String, String?>> getUserInfo() async {
    final prefs = await SharedPreferences.getInstance();
    return {
      'name': prefs.getString('user_name'),
      'email': prefs.getString('user_email'),
      'photo': prefs.getString('user_photo'),
    };
  }
}

 

로그인 화면 구현

  // lib/screens/simple_login_screen.dart
  class SimpleLoginScreen extends StatelessWidget {
    final AuthService _authService = AuthService();

    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: Center(
          child: ElevatedButton.icon(
            onPressed: () async {
              final success = await _authService.signInWithGoogle();
              if (success) {
                Navigator.pushReplacement(context,
                  MaterialPageRoute(builder: (context) => HomeScreen()));
              }
            },
            icon: Icon(Icons.login),
            label: Text('Google로 로그인'),
          ),
        ),
      );
    }
  }


장점

  • 구현이 매우 간단함 - 몇 줄의 코드로 완성
  • 빠른 개발 - 백엔드 설정 불필요
  • 즉시 테스트 가능 - 복잡한 환경 설정 없음
  • 개인 프로젝트에 적합 - 간단한 앱에 충분

단점

  • 보안 취약 - 클라이언트에서만 검증
  • 확장성 제한 - 추가 기능 구현 어려움
  • 데이터 동기화 불가 - 기기별 데이터 분리
  • 사용자 관리 불가 - 서버 사이드 로직 없음
  • 프로덕션 부적합 - 상용 서비스에 사용 제한

사용 권장 상황

  • 프로토타입 개발 단계
  • 개인 프로젝트나 학습용 앱
  • 단순한 로컬 앱 (일기장, 메모장 등)
  • 빠른 MVP 검증이 필요한 경우

 

결론: 간단한 개인 프로젝트라면 1번 방식으로 충분하지만, 실제 서비스 개발이나 사용자 데이터 관리가 필요하다면 반드시 2번 백엔드 연동 방식을 사용해야 합니다.

 

2. 백엔드 연동하여 DB에 저장하여 활용하는 방식 (NestJS 기준, 어렵지만 추천)

앞서 기본적인 Google OAuth 설정을 완료했다면, 이제 실제 프로덕션에서 사용할 수 있는 백엔드 연동 방식을 구현해보겠습니다. 이 방식은 Google에서 받은 ID Token을 서버에서 검증하고, 자체 JWT 토큰을 발급하여 보안성과 확장성을 모두 확보할 수 있습니다.

 

전체 인증 플로우

  [Flutter App] → Google Sign-In → [Google OAuth Server]
        ↓                                                           ↓
  [ID Token 수신] ←────────[사용자 인증 완료]
        ↓
  [POST /auth/social-login] → [NestJS Backend]
        ↓                                                           ↓
  [JWT Token 수신] ←────[Google ID Token 검증]
        ↓                                                           ↓
  [SharedPreferences 저장] ← [PostgreSQL 사용자 저장]

 

전체 인증 플로우

  1. 사용자 → Google 로그인 버튼 클릭
  2. Flutter → Google Sign-In 팝업 표시
  3. 사용자 → Google 계정으로 로그인
  4. Google → Flutter에 ID Token 발급
  5. Flutter → 백엔드로 ID Token 전송 (POST /auth/social-login)
  6. 백엔드 → Google에 ID Token 검증 요청
  7. Google → 백엔드에 사용자 정보 응답
  8. 백엔드 → PostgreSQL에 사용자 정보 저장/업데이트
  9. 백엔드 → JWT 토큰 생성 및 Flutter에 응답
  10. Flutter → JWT 토큰 저장 및 메인 화면 이동

 

Backend 용도의 OAuth 클라이언트 ID 생성

이제 GCP에서 웹(백엔드) 용도로 OAuth 클라이언트 ID를 생성해야 합니다.

 

만들기를 클릭하면 아래 모달 창이 뜨는데 절대 닫지 마세요. (JSON도 꼭 다운로드 하세요.) (추후 다운로드 및 조회 불가능)

 

참고로 백엔드 서버용 웹 애플리케이션 클라이언트의 경우, 리디렉션 URI와 JavaScript 원본을 비워두셔도 됩니다.

 

🤔 왜 비워둬도 되는가?

우리의 백엔드 사용 방식

  1. Flutter 앱에서 Google Sign-In으로 ID Token 획득
  2. ID Token을 백엔드로 전송
  3. 백엔드에서 google-auth-library로 토큰 검증만 수행

→ 웹 브라우저 리디렉션을 사용하지 않음이므로 URI 설정이 불필요합니다.

 

⚠️ 만약 나중에 웹 버전을 만든다면?

웹 버전 추가 시에만 설정

 

승인된 JavaScript 원본

  • http://localhost:3000
  • https://yourdomain.com

승인된 리디렉션 URI

  • http://localhost:3000/auth/callback
  • https://yourdomain.com/auth/callback

현재는 모바일 앱 + ID Token 검증 방식이므로 비워두고 생성하세요.

 

백엔드 구현 (NestJS)

1) 필요한 패키지 설치

  npm install google-auth-library @nestjs/jwt @nestjs/passport
  npm install @nestjs/typeorm typeorm pg class-validator class-transformer


2) User 엔티티 생성

// src/entities/user.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';

export enum SocialProvider {
  GOOGLE = 'google',
  KAKAO = 'kakao',
  APPLE = 'apple',
}

export enum DevicePlatform {
  ANDROID = 'android',
  IOS = 'ios',
  WEB = 'web',
  UNKNOWN = 'unknown',
}

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;

  @Column({ nullable: true })
  profileImage: string;

  @Column({
    type: 'enum',
    enum: SocialProvider,
  })
  socialProvider: SocialProvider;

  @Column({ unique: true })
  socialId: string; // Google/Kakao/Apple에서 제공하는 고유 ID

  @CreateDateColumn()
  firstLoginAt: Date; // 최초 접속일시

  @Column({ type: 'timestamp' })
  lastLoginAt: Date; // 최근 접속일시

  @Column({
    type: 'enum',
    enum: DevicePlatform,
    default: DevicePlatform.UNKNOWN,
  })
  lastLoginPlatform: DevicePlatform; // 최근 로그인 플랫폼

  @Column({
    type: 'enum',
    enum: DevicePlatform,
    default: DevicePlatform.UNKNOWN,
  })
  firstLoginPlatform: DevicePlatform; // 최초 로그인 플랫폼

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @Column({ default: true })
  isActive: boolean;
}

 

3) Controller 구현

// src/modules/auth/auth.controller.ts
import { Controller, Post, Body, ValidationPipe } from '@nestjs/common';
import { AuthService } from './auth.service';
import { SocialLoginDto, AuthResponseDto } from './dto/social-login.dto';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('social-login')
  async socialLogin(
    @Body(ValidationPipe) socialLoginDto: SocialLoginDto,
  ): Promise<AuthResponseDto> {
    return this.authService.socialLogin(socialLoginDto);
  }
}

 

4) DTO 구현

// src/modules/auth/dto/social-login.dto.ts

import { IsString, IsNotEmpty, IsEnum, IsOptional } from 'class-validator';
import { SocialProvider, DevicePlatform } from '../../../entities/user.entity';

export class SocialLoginDto {
  @IsString()
  @IsNotEmpty()
  idToken: string;

  @IsEnum(SocialProvider)
  provider: SocialProvider;

  @IsOptional()
  @IsEnum(DevicePlatform)
  platform?: DevicePlatform;
}

export class AuthResponseDto {
  accessToken: string;
  user: {
    id: string;
    email: string;
    name: string;
    profileImage?: string;
    socialProvider: SocialProvider;
    firstLoginAt: Date;
    lastLoginAt: Date;
    firstLoginPlatform: DevicePlatform;
    lastLoginPlatform: DevicePlatform;
  };
}

 

5) Auth Module 구현

// src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from '../../entities/user.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { expiresIn: '7d' },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

 

6) Auth Service 구현

// src/modules/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { OAuth2Client } from 'google-auth-library';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { User, SocialProvider, DevicePlatform } from '../../entities/user.entity';
import { SocialLoginDto, AuthResponseDto } from './dto/social-login.dto';

@Injectable()
export class AuthService {
  private googleClient: OAuth2Client;

  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {
    // Google OAuth2 클라이언트 초기화
    this.googleClient = new OAuth2Client(
      this.configService.get<string>('GOOGLE_CLIENT_ID'),
    );
  }

  async socialLogin(socialLoginDto: SocialLoginDto): Promise<AuthResponseDto> {
    const { idToken, provider, platform = DevicePlatform.UNKNOWN } = socialLoginDto;

    let userInfo: any;

    switch (provider) {
      case SocialProvider.GOOGLE:
        userInfo = await this.verifyGoogleToken(idToken);
        break;
      case SocialProvider.KAKAO:
        // TODO: 카카오 토큰 검증 구현
        throw new UnauthorizedException('카카오 로그인은 아직 구현되지 않았습니다.');
      case SocialProvider.APPLE:
        // TODO: 애플 토큰 검증 구현
        throw new UnauthorizedException('애플 로그인은 아직 구현되지 않았습니다.');
      default:
        throw new UnauthorizedException('지원하지 않는 소셜 로그인 제공자입니다.');
    }

    // 사용자 찾기 또는 생성
    let user = await this.userRepository.findOne({
      where: {
        socialProvider: provider,
        socialId: userInfo.socialId,
      },
    });

    const now = new Date();

    if (!user) {
      // 신규 사용자 생성
      user = this.userRepository.create({
        email: userInfo.email,
        name: userInfo.name,
        profileImage: userInfo.profileImage,
        socialProvider: provider,
        socialId: userInfo.socialId,
        firstLoginAt: now,
        lastLoginAt: now,
        firstLoginPlatform: platform,
        lastLoginPlatform: platform,
      });
    } else {
      // 기존 사용자 로그인 시간 및 플랫폼 업데이트
      user.lastLoginAt = now;
      user.lastLoginPlatform = platform;

      // 프로필 정보 업데이트 (이름이나 프로필 이미지가 변경될 수 있음)
      user.name = userInfo.name;
      user.profileImage = userInfo.profileImage;
    }

    await this.userRepository.save(user);

    // JWT 토큰 생성
    const payload = {
      sub: user.id,
      email: user.email,
      socialProvider: user.socialProvider
    };
    const accessToken = this.jwtService.sign(payload);

    return {
      accessToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        profileImage: user.profileImage,
        socialProvider: user.socialProvider,
        firstLoginAt: user.firstLoginAt,
        lastLoginAt: user.lastLoginAt,
        firstLoginPlatform: user.firstLoginPlatform,
        lastLoginPlatform: user.lastLoginPlatform,
      },
    };
  }

  private async verifyGoogleToken(idToken: string) {
    try {
      const ticket = await this.googleClient.verifyIdToken({
        idToken,
        audience: this.configService.get<string>('GOOGLE_CLIENT_ID'),
      });

      const payload = ticket.getPayload();

      if (!payload) {
        throw new UnauthorizedException('유효하지 않은 Google ID 토큰입니다.');
      }

      return {
        socialId: payload.sub,
        email: payload.email,
        name: payload.name,
        profileImage: payload.picture,
      };
    } catch (error) {
      console.error('Google ID 토큰 검증 실패:', error);
      throw new UnauthorizedException('Google ID 토큰 검증에 실패했습니다.');
    }
  }

  async validateUser(payload: any): Promise<User> {
    const user = await this.userRepository.findOne({
      where: { id: payload.sub },
    });

    if (!user || !user.isActive) {
      throw new UnauthorizedException('사용자를 찾을 수 없습니다.');
    }

    return user;
  }
}

 

7) env 설정

# OAuth 설정
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

 

Flutter 구현 업데이트

Google Sign-In에서 ID Token 추출 및 백엔드 전송

// lib/services/auth_service.dart
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:dio/dio.dart';
import 'dart:io';
import '../config/app_config.dart';

class AuthService {
  static final AuthService _instance = AuthService._internal();
  factory AuthService() => _instance;
  AuthService._internal();

  final GoogleSignIn _googleSignIn = GoogleSignIn(
    scopes: ['email', 'profile', 'openid'], // openid 스코프 필수!
    // iOS: iOS Client ID 사용, Android: null (자동 처리 > sha-1)
    clientId: (!kIsWeb && Platform.isIOS) ? AppConfig.googleIosClientId : null,
    // 백엔드 인증용: 웹 Client ID 사용
    serverClientId: AppConfig.googleWebClientId,
  );

  final Dio _dio = Dio();
  String get _baseUrl => AppConfig.baseUrl;

  GoogleSignInAccount? _currentUser;
  GoogleSignInAccount? get currentUser => _currentUser;

  Future<bool> signInWithGoogle(BuildContext context) async {
    try {
      final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();
      if (googleUser != null) {
        // Google ID Token 가져오기
        final GoogleSignInAuthentication googleAuth = await googleUser.authentication;
        final String? idToken = googleAuth.idToken;
        final String? accessToken = googleAuth.accessToken;

        if (idToken == null) {
          if (context.mounted) {
            _showErrorMessage(context, 'Google ID 토큰을 가져올 수 없습니다. 구성을 확인하세요.');
          }
          return false;
        }

        // 백엔드로 ID Token 전송
        final success = await _sendTokenToBackend(idToken, 'google');

        if (success) {
          _currentUser = googleUser;

          if (context.mounted) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('${googleUser.displayName}님, 환영합니다!')),
            );
          }

          return true;
        } else {
          if (context.mounted) {
            _showErrorMessage(context, '서버 인증에 실패했습니다.');
          }
          return false;
        }
      } else {
        print('❌ googleUser가 null입니다 (사용자가 취소했거나 실패)');
      }

      return false;
    } catch (e, stackTrace) {
      if (context.mounted) {
        _showErrorMessage(context, '구글 로그인 실패: $e');
      }
      return false;
    }
  }

  Future<void> signInWithKakao(BuildContext context) async {
    try {
      // TODO: 카카오 로그인 구현
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('카카오 로그인 구현 예정')),
      );
    } catch (e) {
      _showErrorMessage(context, '카카오 로그인 실패: $e');
    }
  }

  Future<void> signInWithApple(BuildContext context) async {
    try {
      // TODO: 애플 로그인 구현
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('애플 로그인 구현 예정')),
      );
    } catch (e) {
      _showErrorMessage(context, '애플 로그인 실패: $e');
    }
  }


  void _showErrorMessage(BuildContext context, String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.red,
      ),
    );
  }

  Future<void> signOut() async {
    try {
      await _googleSignIn.signOut();
      _currentUser = null;

      // SharedPreferences에서 로그인 정보 제거
      final prefs = await SharedPreferences.getInstance();
      await prefs.remove('user_name');
      await prefs.remove('user_email');
      await prefs.remove('user_photo');
      await prefs.setBool('is_logged_in', false);
    } catch (e) {
      print('로그아웃 실패: $e');
    }
  }

  Future<bool> isLoggedIn() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      final isLoggedIn = prefs.getBool('is_logged_in') ?? false;

      if (isLoggedIn) {
        // Google Sign-In 상태도 확인
        final googleUser = await _googleSignIn.signInSilently();
        if (googleUser != null) {
          _currentUser = googleUser;
          return true;
        } else {
          // Google 로그인 상태가 만료된 경우 SharedPreferences도 업데이트
          await prefs.setBool('is_logged_in', false);
          return false;
        }
      }

      return false;
    } catch (e) {
      print('로그인 상태 확인 실패: $e');
      return false;
    }
  }

  Future<Map<String, String?>> getUserInfo() async {
    final prefs = await SharedPreferences.getInstance();
    return {
      'name': prefs.getString('user_name'),
      'email': prefs.getString('user_email'),
      'photo': prefs.getString('user_photo'),
    };
  }

  Future<bool> _sendTokenToBackend(String idToken, String provider) async {
    try {
      // 플랫폼 정보 감지
      String platform = 'unknown';
      if (!kIsWeb) {
        if (Platform.isAndroid) {
          platform = 'android';
        } else if (Platform.isIOS) {
          platform = 'ios';
        }
      } else {
        platform = 'web';
      }

      print('🔍 플랫폼 정보: $platform');

      final response = await _dio.post(
        '$_baseUrl/auth/social-login',
        data: {
          'idToken': idToken,
          'provider': provider,
          'platform': platform,
        },
        options: Options(
          headers: {
            'Content-Type': 'application/json',
          },
        ),
      );

      if (response.statusCode == 201) {
        final data = response.data;
        final user = data['user'];
        final accessToken = data['accessToken'];

        // JWT 토큰과 사용자 정보를 SharedPreferences에 저장
        final prefs = await SharedPreferences.getInstance();
        await prefs.setString('access_token', accessToken);
        await prefs.setString('user_id', user['id']);
        await prefs.setString('user_name', user['name']);
        await prefs.setString('user_email', user['email']);
        await prefs.setString('user_photo', user['profileImage'] ?? '');
        await prefs.setString('social_provider', user['socialProvider']);
        await prefs.setString('first_login_platform', user['firstLoginPlatform'] ?? platform);
        await prefs.setString('last_login_platform', user['lastLoginPlatform'] ?? platform);
        await prefs.setBool('is_logged_in', true);

        return true;
      } else {
        print('❌ 예상치 못한 상태코드: ${response.statusCode}');
      }

      return false;
    } catch (e) {
      print('❌ 백엔드 토큰 전송 실패: $e');
      if (e is DioException) {
        print('❌ DioException 상세정보:');
        print('   - type: ${e.type}');
        print('   - message: ${e.message}');
        print('   - response: ${e.response?.data}');
        print('   - statusCode: ${e.response?.statusCode}');
      }
      return false;
    }
  }

  Future<String?> getAccessToken() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString('access_token');
  }
}

 

Flutter에서 백엔드의 Web Client ID가 필요한 이유

왜 Flutter에서 백엔드용 Web Client ID를 사용해야 할까?


Google Sign-In의 토큰 발급 메커니즘)

Google Sign-In에서 발급하는 토큰에는 두 가지 종류가 있습니다.

 

Access Token (기본 제공)

  // 항상 받을 수 있음
  final String? accessToken = googleAuth.accessToken; // ✅ "ya29.a0AfB_..." (322자)

 

  • 용도: Google API 호출용 (Gmail, Drive, Calendar 등)
  • 검증: Google API 서버에서만 가능
  • 백엔드 사용: ❌ 불가능 (Google API 전용)

 

ID Token (조건부 제공)

  // serverClientId 설정 시에만 받을 수 있음
  final String? idToken = googleAuth.idToken; // ⚠️ null 또는 JWT 토큰

 

  • 용도: 사용자 신원 확인용
  • 검증: 어떤 서버에서든 가능 (google-auth-library 사용)
  • 백엔드 사용: ✅ 가능


ID Token 발급 조건

Google에서 ID Token을 발급하려면 "이 토큰을 누가 검증할 것인가?"를 알아야 합니다.

 

❌ serverClientId 없는 경우

  final GoogleSignIn _googleSignIn = GoogleSignIn(
    scopes: ['email', 'profile'], // serverClientId 설정 안 함
  );

  // 결과:
  // idToken: null ❌
  // accessToken: "ya29.a0AfB_..." ✅

 

Google이 생각하기를: "모바일 앱에서만 사용할 것 같으니 Access Token만 줄게"

 

✅ serverClientId 설정한 경우

  final GoogleSignIn _googleSignIn = GoogleSignIn(
    scopes: ['email', 'profile', 'openid'],
    serverClientId: "abcd1234.apps.googleusercontent.com", // Web Client ID
  );

  // 결과:
  // idToken: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjkyN..." ✅ (1072자)
  // accessToken: "ya29.a0AfB_..." ✅

 

Google이 생각하기를: "아, 백엔드 서버에서 검증할 용도구나. ID Token도 같이 줄게"

 

아키텍처 관점에서 보는 이유

 

보안 검증의 책임 분담
[Flutter App] ──Access Token──> [Google API]
         │
        └──ID Token──> [Your Backend] ──Verify──> [Google Auth Service]

플로우 설명

  1. Access Token 경로: Flutter App → Google API (Gmail, Drive 등 직접 호출)
  2. ID Token 경로: Flutter App → Your Backend → Google Auth Service (토큰 검증)

 

왜 Web Client ID를 사용하는가?
핵심: ID Token의 audience (검증 대상) 필드 때문입니다.

  {
    "iss": "https://accounts.google.com",
    "aud": "abcd-1234.apps.googleusercontent.com", // 이 값이 중요!
    "sub": "1234567890",
    "email": "user@example.com",
    "name": "홍길동"
  }


백엔드에서 ID Token을 검증할 때

  const ticket = await this.googleClient.verifyIdToken({
    idToken,
    audience: "abcd-1234.apps.googleusercontent.com" // 반드시 일치해야 함
  });

 

Client ID별 역할과 사용 위치

플랫폼 Client ID 타입 설정 위치 실제 사용처
Android Android Client ID Google Cloud Console 등록 SHA-1 기반 자동 인식
iOS iOS Client ID Flutter 'clientId' 앱 식별
Backend Web Client ID Flutter `serverClientId` + 백엔드 `audience` 토큰 생성 및 검증

 

iOS에서 clientId가 필요한 이유 (심화)

iOS 플랫폼의 특수성

Flutter(FrontEnd)에서 Android와 달리 iOS에서는 왜 clientId를 명시적으로 설정해야 할까요?

 

Android의 동작 방식

// Android: clientId 설정 불필요
clientId: null  // SHA-1 지문으로 자동 인식
  • Android는 SHA-1 인증서 지문을 통해 앱을 자동으로 식별
  • google-services.json 파일에서 클라이언트 정보를 자동으로 읽음
  • 별도의 clientId 지정이 필요 없음

 

iOS의 동작 방식

// iOS: clientId 필수 설정
clientId: AppConfig.googleIosClientId  // 명시적 지정 필요
  • iOS는 Bundle ID만으로는 OAuth 클라이언트를 특정할 수 없음
  • 같은 Bundle ID로 여러 OAuth 클라이언트가 존재할 수 있음
  • GoogleService-Info.plist의 CLIENT_ID를 Flutter 코드에서 명시적으로 지정해야 함

 

iOS clientId 설정이 필수인 기술적 이유

 

Google Sign-In SDK의 플랫폼별 구현 차이

  • Android: 패키지명 + SHA-1으로 앱을 고유하게 식별 가능
  • iOS: Bundle ID만으로는 고유 식별 불가 (동일 Bundle ID로 여러 앱 버전 존재 가능)


따라서 iOS에서는 GoogleService-Info.plist의 CLIENT_ID를 Flutter 코드에서 명시적으로 지정해야 정확한 OAuth 클라이언트를 사용할 수 있습니다.

 

실제 구현 시 주의사항

final GoogleSignIn _googleSignIn = GoogleSignIn(
  scopes: ['email', 'profile', 'openid'],
  // 플랫폼별 분기 처리
  clientId: (!kIsWeb && Platform.isIOS) 
    ? AppConfig.googleIosClientId  // iOS만 설정
    : null,  // Android는 자동 처리
  serverClientId: AppConfig.googleWebClientId,  // 모든 플랫폼 공통
);

 

왜 이렇게 복잡한가?

  1. 보안 강화: iOS는 앱 서명 인증서 대신 OAuth 클라이언트 ID로 검증
  2. 멀티 환경 지원: 개발/스테이징/프로덕션 환경별로 다른 OAuth 클라이언트 사용 가능
  3. 명확한 의도: 어떤 OAuth 클라이언트를 사용할지 명시적으로 선택

 

Pro Tip: Client ID 확인 방법

iOS Client ID 찾기

1. GoogleService-Info.plist 파일 열기
2. CLIENT_ID 키의 값 복사
3. 이 값을 .env 파일의 GOOGLE_IOS_CLIENT_ID에 설정


정리하자면

  • Android: SHA-1 기반 자동 인식 → clientId 불필요
  • iOS: OAuth Client ID 명시 필요 → clientId 필수
  • 백엔드 검증: 모든 플랫폼 공통 → serverClientId 사용

이러한 플랫폼별 차이를 이해하고 적절히 처리해야 크로스 플랫폼 앱에서 Google 로그인이 원활하게 작동합니다.

 

보안 및 장점

보안 강화

  • Google ID Token을 서버에서 재검증하여 위조 방지
  • 자체 JWT 토큰으로 API 접근 제어
  • HTTPS 통신으로 데이터 암호화


확장성

  • 사용자 정보를 DB에 저장하여 추가 기능 구현 가능
  • 최초/최근 로그인 시간 추적
  • 사용자 상태 관리 (활성/비활성)
  • 다양한 소셜 로그인 통합 관리

 

데이터 관리

  • 사용자별 일정 데이터 저장 및 동기화
  • 사용자 권한 관리
  • 사용 통계 및 분석 데이터 수집

이 방식을 사용하면 단순한 클라이언트 로그인을 넘어서 실제 서비스에서 활용 가능한 완전한 인증 시스템을 구축할 수 있습니다.

반응형