Ver3.0 ๐ PHP ์์ ๋ก๊ทธ์ธ๊ณผ OAuth ์๋ฒฝ ๊ฐ์ด๋

๐ PHP ์์ ๋ก๊ทธ์ธ๊ณผ OAuth ์๋ฒฝ ๊ฐ์ด๋
์น๊ตฌ์ฒ๋ผ ์ฝ๊ฒ ๋ฐฐ์ฐ๋ ์์ ๋ฏธ๋์ด ์ฐ๋ ๋ง์คํฐ ํด๋์ค ๐
๐ฏ ์ ์์ ๋ก๊ทธ์ธ์ด ํ์๊ฐ ๋์์๊น?
์ผ, ์์งํ ๋งํด์ ์์ฆ ์น์ฌ์ดํธ ๊ฐ์
ํ ๋ ๋ ์์ด๋ ๋ง๋ค๊ณ ๋น๋ฐ๋ฒํธ ์ค์ ํ๊ณ ... ์ง์ง ๊ท์ฐฎ์ง ์์? ๐ฉ
๋๋ง ๊ทธ๋ฐ ๊ฒ ์๋๋ผ ํต๊ณ๋ฅผ ๋ณด๋ฉด ์ฌ์ฉ์์ 86%๊ฐ ํ์๊ฐ์
๊ณผ์ ์์ ์ดํํ๋ค๊ณ ํด. ๊ทผ๋ฐ ์์
๋ก๊ทธ์ธ์ ๋์
ํ๋ฉด? ์ดํ๋ฅ ์ด ๋ฌด๋ ค 30%๊น์ง ๋จ์ด์ง๋ค๋ ๊ฑฐ์ผ!
๊ทธ๋์ ์์ฆ ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ๋ค๋ ์นด์นด์ค, ๊ตฌ๊ธ, ๋ค์ด๋ฒ ๋ก๊ทธ์ธ์ ๋ค ์ง์ํ์์? ์ด๊ฒ ๋ฐ๋ก ์ฌ์ฉ์ ๊ฒฝํ(UX)์ ์๊ฐํ ๋๋ํ ์ ํ์ด์ผ. ๐จ
๊ฐ๋ฐ์ ์ ์ฅ: ํ์ ์ ๋ณด ๊ด๋ฆฌ ๋ถ๋ด ๊ฐ์, ๋ณด์ ์ฑ ์ ๋ถ์ฐ
๋น์ฆ๋์ค ์ ์ฅ: ๊ฐ์ ๋ฅ ์ฆ๊ฐ, ์ ๋ขฐ๋ ํฅ์, ๋ง์ผํ ๋ฐ์ดํฐ ํ๋ณด
๐ OAuth 2.0, ๋๋์ฒด ๋ญ๊ธธ๋?
OAuth(Open Authorization)๋ ์ฝ๊ฒ ๋งํด์ "๋ด ์ ๋ณด ์ข ์จ๋ ๋ผ?"๋ผ๊ณ ๋ฌผ์ด๋ณด๋ ํ์คํ๋ ๋ฐฉ๋ฒ์ด์ผ. ๐ค
์๋ฅผ ๋ค์ด๋ณผ๊ฒ. ๋๊ฐ ์ด๋ค ์ฑ์์ "๊ตฌ๊ธ๋ก ๋ก๊ทธ์ธ"์ ๋๋ฅด๋ฉด:
1๋จ๊ณ: ๊ตฌ๊ธ์ด "์ด ์ฑ์ด ๋ค ์ด๋ฆ์ด๋ ์ด๋ฉ์ผ ๋ณด๊ณ ์ถ๋. ๊ด์ฐฎ์?" ๋ฌผ์ด๋ด
2๋จ๊ณ: ๋๊ฐ "์ ๊ด์ฐฎ์" ํ๋ฉด ๊ตฌ๊ธ์ด ์ฑํํ
ํน๋ณํ ์ด์ (ํ ํฐ)๋ฅผ ์ค
3๋จ๊ณ: ์ฑ์ ๊ทธ ์ด์ ๋ก ๋ค ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ด (๋น๋ฐ๋ฒํธ๋ ์ ๋ ์ ๋์ด๊ฐ!)
์ด๊ฒ ๋ฐ๋ก OAuth์ ํต์ฌ์ด์ผ. ๋น๋ฐ๋ฒํธ๋ฅผ ๊ณต์ ํ์ง ์๊ณ ๋ ๊ถํ์ ์์ํ๋ ๊ฑฐ์ง! ๐
๐ ๏ธ PHP๋ก ์์ ๋ก๊ทธ์ธ ๊ตฌํํ๊ธฐ - ์ค๋น ๋จ๊ณ
์, ์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ์ฝ๋ฉ ๋ค์ด๊ฐ ๋ณผ๊น? ๊ฑฑ์ ๋ง, ํ๋์ฉ ์ฐจ๊ทผ์ฐจ๊ทผ ์๋ ค์ค๊ฒ! ๐
๋จผ์ ํ์ํ ๊ฒ๋ค๋ถํฐ ์ฑ๊ฒจ๋ณด์:
PHP 7.4 ์ด์ (8.0 ์ด์ ์ถ์ฒ)
Composer (ํจํค์ง ๊ด๋ฆฌ์)
SSL ์ธ์ฆ์ (HTTPS ํ์! ์์ ๋ก๊ทธ์ธ์ HTTP์์ ์ ๋ผ)
MySQL ๋๋ MariaDB
๊ฐ ์์ ํ๋ซํผ์ ๊ฐ๋ฐ์ ์ฝ์์์ ์ฑ์ ๋ฑ๋กํด์ผ ํด:
โข ๊ตฌ๊ธ: Google Cloud Console
โข ํ์ด์ค๋ถ: Facebook Developers
โข ์นด์นด์ค: Kakao Developers
โข ๋ค์ด๋ฒ: Naver Developers
์ฌ๊ธฐ์ Client ID์ Client Secret์ ๋ฐ๊ฒ ๋ ๊ฑฐ์ผ. ์ด๊ฑด ์ ๋ ๊ณต๊ฐํ๋ฉด ์ ๋ผ! ๐
์์ ๋ก๊ทธ์ธ ํ ๋์์ฌ ์ฃผ์๋ฅผ ์ ํํ ๋ฑ๋กํด์ผ ํด.
์:
https://yourdomain.com/callback/googleํ ๊ธ์๋ผ๋ ํ๋ฆฌ๋ฉด ์๋ ์ ํ๋๊น ์ฃผ์! โ ๏ธ
์ด์ Composer๋ก ํ์ํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์นํด๋ณด์. ํฐ๋ฏธ๋์ ์ด๊ณ :
composer require league/oauth2-client composer require league/oauth2-google composer require league/oauth2-facebook
์ด ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ค์ OAuth 2.0 ๊ตฌํ์ ์์ฒญ ์ฝ๊ฒ ๋ง๋ค์ด์ค. ๋ฐํด๋ฅผ ๋ค์ ๋ฐ๋ช ํ ํ์ ์์์? ๐ก
๐ป ๊ตฌ๊ธ ์์ ๋ก๊ทธ์ธ ๊ตฌํ - ์ค์ ์ฝ๋
๊ฐ์ฅ ๋ง์ด ์ฌ์ฉ๋๋ ๊ตฌ๊ธ ๋ก๊ทธ์ธ๋ถํฐ ๊ตฌํํด๋ณผ๊ฒ. ์ฝ๋ ๋ณด๋ฉด์ ํ๋์ฉ ์ค๋ช ํ ๊ฒ! ๐จโ๐ป
๐ Step 1: ์ค์ ํ์ผ ๋ง๋ค๊ธฐ
๋จผ์ ๋ฏผ๊ฐํ ์ ๋ณด๋ค์ ๋ฐ๋ก ๊ด๋ฆฌํ์. config.php ํ์ผ์ ๋ง๋ค์ด:
<?php
// config.php
return [
'google' => [
'client_id' => 'YOUR_GOOGLE_CLIENT_ID',
'client_secret' => 'YOUR_GOOGLE_CLIENT_SECRET',
'redirect_uri' => 'https://yourdomain.com/callback/google',
'scopes' => ['email', 'profile']
],
'facebook' => [
'client_id' => 'YOUR_FACEBOOK_APP_ID',
'client_secret' => 'YOUR_FACEBOOK_APP_SECRET',
'redirect_uri' => 'https://yourdomain.com/callback/facebook',
'scopes' => ['email', 'public_profile']
]
];
.gitignore์ ๊ผญ ์ถ๊ฐํ๊ณ , ํ๊ฒฝ๋ณ์๋ก ๊ด๋ฆฌํ๋ ๊ฒ ๋ ์ข์. ์ค์ ์๋น์ค์์๋ .env ํ์ผ์ด๋ ์๋ฒ ํ๊ฒฝ๋ณ์๋ฅผ ์ฌ์ฉํ์.
๐ Step 2: ๋ก๊ทธ์ธ ์์ ํ์ด์ง
์ฌ์ฉ์๊ฐ "๊ตฌ๊ธ๋ก ๋ก๊ทธ์ธ" ๋ฒํผ์ ํด๋ฆญํ์ ๋ ์คํ๋ ์ฝ๋์ผ:
<?php
// login.php
session_start();
require_once 'vendor/autoload.php';
$config = require 'config.php';
use League\OAuth2\Client\Provider\Google;
// Google OAuth ํ๋ก๋ฐ์ด๋ ์์ฑ
$provider = new Google([
'clientId' => $config['google']['client_id'],
'clientSecret' => $config['google']['client_secret'],
'redirectUri' => $config['google']['redirect_uri'],
]);
// ์ธ์ฆ URL ์์ฑ
$authUrl = $provider->getAuthorizationUrl([
'scope' => $config['google']['scopes']
]);
// CSRF ๊ณต๊ฒฉ ๋ฐฉ์ง๋ฅผ ์ํ state ์ ์ฅ
$_SESSION['oauth2state'] = $provider->getState();
// ๊ตฌ๊ธ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ
header('Location: ' . $authUrl);
exit;
์ฌ๊ธฐ์ ์ค์ํ ๊ฒ state์ผ. ์ด๊ฑด CSRF(Cross-Site Request Forgery) ๊ณต๊ฒฉ์ ๋ง๊ธฐ ์ํ ๋ณด์ ํ ํฐ์ด์ผ. ๋์ค์ ์ฝ๋ฐฑ์์ ๊ฒ์ฆํ ๊ฑฐ์ผ! ๐ก๏ธ
๐ Step 3: ์ฝ๋ฐฑ ์ฒ๋ฆฌํ๊ธฐ
๊ตฌ๊ธ์์ ์ธ์ฆ ์๋ฃ ํ ๋์์ค๋ ํ์ด์ง์ผ. ์ฌ๊ธฐ๊ฐ ํต์ฌ์ด์ผ!
<?php
// callback.php
session_start();
require_once 'vendor/autoload.php';
$config = require 'config.php';
use League\OAuth2\Client\Provider\Google;
$provider = new Google([
'clientId' => $config['google']['client_id'],
'clientSecret' => $config['google']['client_secret'],
'redirectUri' => $config['google']['redirect_uri'],
]);
// ์๋ฌ ์ฒดํฌ
if (isset($_GET['error'])) {
die('์ธ์ฆ ์คํจ: ' . htmlspecialchars($_GET['error']));
}
// State ๊ฒ์ฆ (CSRF ๋ฐฉ์ง)
if (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
unset($_SESSION['oauth2state']);
die('์๋ชป๋ state ๊ฐ์
๋๋ค.');
}
try {
// Authorization code๋ก ์ก์ธ์ค ํ ํฐ ๋ฐ๊ธฐ
$token = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
// ์ก์ธ์ค ํ ํฐ์ผ๋ก ์ฌ์ฉ์ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
$user = $provider->getResourceOwner($token);
// ์ฌ์ฉ์ ์ ๋ณด ์ถ์ถ
$userData = [
'id' => $user->getId(),
'email' => $user->getEmail(),
'name' => $user->getName(),
'avatar' => $user->getAvatar(),
'provider' => 'google',
'access_token' => $token->getToken(),
'refresh_token' => $token->getRefreshToken(),
'expires' => $token->getExpires()
];
// ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ ๋๋ ์
๋ฐ์ดํธ
saveOrUpdateUser($userData);
// ์ธ์
์ ์ฌ์ฉ์ ์ ๋ณด ์ ์ฅ
$_SESSION['user'] = $userData;
// ๋ฉ์ธ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ
header('Location: /dashboard');
exit;
} catch (Exception $e) {
die('์ค๋ฅ ๋ฐ์: ' . $e->getMessage());
}
function saveOrUpdateUser($userData) {
// ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ
$pdo = new PDO('mysql:host=localhost;dbname=yourdb', 'username', 'password');
// ๊ธฐ์กด ์ฌ์ฉ์ ํ์ธ
$stmt = $pdo->prepare("
SELECT id FROM users
WHERE provider = ? AND provider_id = ?
");
$stmt->execute([$userData['provider'], $userData['id']]);
if ($stmt->rowCount() > 0) {
// ์
๋ฐ์ดํธ
$stmt = $pdo->prepare("
UPDATE users SET
email = ?,
name = ?,
avatar = ?,
access_token = ?,
refresh_token = ?,
token_expires = ?,
updated_at = NOW()
WHERE provider = ? AND provider_id = ?
");
$stmt->execute([
$userData['email'],
$userData['name'],
$userData['avatar'],
$userData['access_token'],
$userData['refresh_token'],
date('Y-m-d H:i:s', $userData['expires']),
$userData['provider'],
$userData['id']
]);
} else {
// ์ ๊ท ์ฝ์
$stmt = $pdo->prepare("
INSERT INTO users (
provider, provider_id, email, name, avatar,
access_token, refresh_token, token_expires, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([
$userData['provider'],
$userData['id'],
$userData['email'],
$userData['name'],
$userData['avatar'],
$userData['access_token'],
$userData['refresh_token'],
date('Y-m-d H:i:s', $userData['expires'])
]);
}
}
๐๏ธ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ
์์ ๋ก๊ทธ์ธ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ ์ฅํ ํ ์ด๋ธ ๊ตฌ์กฐ๋ฅผ ๋ง๋ค์ด๋ณด์. ์ผ๋ฐ ํ์๊ณผ ์์ ํ์์ ํจ๊ป ๊ด๋ฆฌํ ์ ์๋ ๊ตฌ์กฐ์ผ! ๐
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(20) NULL COMMENT '์์
์ ๊ณต์ (google, facebook, kakao, naver)',
provider_id VARCHAR(255) NULL COMMENT '์์
์ ๊ณต์์ ์ฌ์ฉ์ ID',
email VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
avatar VARCHAR(500) NULL COMMENT 'ํ๋กํ ์ด๋ฏธ์ง URL',
password VARCHAR(255) NULL COMMENT '์ผ๋ฐ ํ์๊ฐ์
์ฉ (์์
์ NULL)',
access_token TEXT NULL,
refresh_token TEXT NULL,
token_expires DATETIME NULL,
email_verified BOOLEAN DEFAULT FALSE,
status ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
UNIQUE KEY unique_provider (provider, provider_id),
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ์์
๋ก๊ทธ์ธ ๋ก๊ทธ ํ
์ด๋ธ (์ ํ์ฌํญ, ๋ถ์์ฉ)
CREATE TABLE social_login_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
provider VARCHAR(20) NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
login_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_provider (user_id, provider),
INDEX idx_login_at (login_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
์ด ๊ตฌ์กฐ์ ์ฅ์ ์ ๋ญ๋๋ฉด:
1. ์ผ๋ฐ ํ์๊ฐ์
๊ณผ ์์
๋ก๊ทธ์ธ์ ํ๋์ ํ
์ด๋ธ๋ก ๊ด๋ฆฌ (password๊ฐ NULL์ด๋ฉด ์์
ํ์)
2. ํ ์ฌ์ฉ์๊ฐ ์ฌ๋ฌ ์์
๊ณ์ ์ฐ๋ ๊ฐ๋ฅ (provider + provider_id ์กฐํฉ์ผ๋ก ๊ตฌ๋ถ)
3. ํ ํฐ ์ ๋ณด ์ ์ฅ์ผ๋ก API ํธ์ถ ๊ฐ๋ฅ
4. ๋ก๊ทธ ํ
์ด๋ธ๋ก ๋ณด์ ๊ฐ์ฌ ๋ฐ ์ฌ์ฉ์ ํ๋ ๋ถ์ ๊ฐ๋ฅ ๐
๐จ ์นด์นด์ค ๋ก๊ทธ์ธ ๊ตฌํํ๊ธฐ
ํ๊ตญ์์๋ ์นด์นด์ค ๋ก๊ทธ์ธ์ด ์ง์ง ๋ง์ด ์ฐ์ด์์? ์นด์นด์ค๋ ์กฐ๊ธ ํน๋ณํด์ ๋ฐ๋ก ์ค๋ช ํด์ค๊ฒ! ๐ก
์นด์นด์ค ๊ฐ๋ฐ์ ์ค์
๋จผ์ Kakao Developers์์ ์ฑ์ ๋ง๋ค๊ณ :
1. ๋ด ์ ํ๋ฆฌ์ผ์ด์
โ ์ฑ ๋ง๋ค๊ธฐ
2. ํ๋ซํผ ์ค์ โ Web ํ๋ซํผ ๋ฑ๋ก (์ฌ์ดํธ ๋๋ฉ์ธ ์
๋ ฅ)
3. ์นด์นด์ค ๋ก๊ทธ์ธ ํ์ฑํ
4. Redirect URI ๋ฑ๋ก (์: https://yourdomain.com/callback/kakao)
5. ๋์ํญ๋ชฉ ์ค์ (๋๋ค์, ํ๋กํ ์ด๋ฏธ์ง, ์ด๋ฉ์ผ ๋ฑ)
์นด์นด์ค ๋ก๊ทธ์ธ ์ฝ๋
<?php
// kakao_login.php
session_start();
$config = [
'client_id' => 'YOUR_KAKAO_REST_API_KEY',
'redirect_uri' => 'https://yourdomain.com/callback/kakao'
];
// State ์์ฑ (CSRF ๋ฐฉ์ง)
$state = bin2hex(random_bytes(16));
$_SESSION['kakao_state'] = $state;
// ์นด์นด์ค ์ธ์ฆ URL ์์ฑ
$params = [
'client_id' => $config['client_id'],
'redirect_uri' => $config['redirect_uri'],
'response_type' => 'code',
'state' => $state
];
$authUrl = 'https://kauth.kakao.com/oauth/authorize?' . http_build_query($params);
header('Location: ' . $authUrl);
exit;
<?php
// kakao_callback.php
session_start();
$config = [
'client_id' => 'YOUR_KAKAO_REST_API_KEY',
'client_secret' => 'YOUR_KAKAO_CLIENT_SECRET', // ์ ํ์ฌํญ
'redirect_uri' => 'https://yourdomain.com/callback/kakao'
];
// State ๊ฒ์ฆ
if ($_GET['state'] !== $_SESSION['kakao_state']) {
die('์๋ชป๋ ์์ฒญ์
๋๋ค.');
}
// ํ ํฐ ์์ฒญ
$tokenUrl = 'https://kauth.kakao.com/oauth/token';
$tokenParams = [
'grant_type' => 'authorization_code',
'client_id' => $config['client_id'],
'redirect_uri' => $config['redirect_uri'],
'code' => $_GET['code']
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $tokenUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($tokenParams));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$token = json_decode($response, true);
if (isset($token['error'])) {
die('ํ ํฐ ๋ฐ๊ธ ์คํจ: ' . $token['error_description']);
}
// ์ฌ์ฉ์ ์ ๋ณด ์์ฒญ
$userInfoUrl = 'https://kapi.kakao.com/v2/user/me';
$headers = ['Authorization: Bearer ' . $token['access_token']];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $userInfoUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$userInfo = json_decode($response, true);
// ์ฌ์ฉ์ ๋ฐ์ดํฐ ์ถ์ถ
$userData = [
'id' => $userInfo['id'],
'email' => $userInfo['kakao_account']['email'] ?? null, // ์ด๋ฉ์ผ ์ ํ ๋์
'name' => $userInfo['kakao_account']['profile']['nickname'],
'avatar' => $userInfo['kakao_account']['profile']['profile_image_url'],
'provider' => 'kakao',
'access_token' => $token['access_token'],
'refresh_token' => $token['refresh_token'],
'expires' => time() + $token['expires_in']
];
// ์ด๋ฉ์ผ์ด ์๋ ๊ฒฝ์ฐ ์ฒ๋ฆฌ
if (empty($userData['email'])) {
// ์ต์
1: ์ด๋ฉ์ผ ์
๋ ฅ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ
$_SESSION['temp_user'] = $userData;
header('Location: /email-required');
exit;
// ์ต์
2: ์นด์นด์ค ID๋ฅผ ์ด๋ฉ์ผ๋ก ์ฌ์ฉ
// $userData['email'] = $userData['id'] . '@kakao.user';
}
// DB ์ ์ฅ ๋ฐ ์ธ์
์ฒ๋ฆฌ
saveOrUpdateUser($userData);
$_SESSION['user'] = $userData;
header('Location: /dashboard');
exit;
โข ์นด์นด์คํก ๋ฉ์์ง ๋ณด๋ด๊ธฐ (๋์๊ฒ ๋ณด๋ด๊ธฐ, ์น๊ตฌ์๊ฒ ๋ณด๋ด๊ธฐ)
โข ์นด์นด์ค์คํ ๋ฆฌ ๊ณต์
โข ์นด์นด์คํ์ด ๊ฒฐ์
์ก์ธ์ค ํ ํฐ๋ง ์์ผ๋ฉด ์ด๋ฐ ๊ธฐ๋ฅ๋ค์ ์ฝ๊ฒ ์ฐ๋ํ ์ ์์ด! ํนํ ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ๊ฑฐ๋ ์๋ฆผ์ ์นด์นด์คํก์ผ๋ก ๋ณด๋ด๋ฉด ์ฌ์ฉ์ ๊ฒฝํ์ด ํจ์ฌ ์ข์์ง๊ฒ ์ง? ๐ฌ
๐ต ๋ค์ด๋ฒ ๋ก๊ทธ์ธ ๊ตฌํํ๊ธฐ
๋ค์ด๋ฒ๋ ํ๊ตญ์์ ๋ง์ด ์ฐ์ด๋ ์์ ๋ก๊ทธ์ธ์ด์ผ. ๊ตฌํ ๋ฐฉ์์ ๋น์ทํ๋ฐ ์ฝ๊ฐ์ ์ฐจ์ด๊ฐ ์์ด! ๐ข
<?php
// naver_login.php
session_start();
$config = [
'client_id' => 'YOUR_NAVER_CLIENT_ID',
'client_secret' => 'YOUR_NAVER_CLIENT_SECRET',
'redirect_uri' => 'https://yourdomain.com/callback/naver'
];
$state = bin2hex(random_bytes(16));
$_SESSION['naver_state'] = $state;
$params = [
'response_type' => 'code',
'client_id' => $config['client_id'],
'redirect_uri' => $config['redirect_uri'],
'state' => $state
];
$authUrl = 'https://nid.naver.com/oauth2.0/authorize?' . http_build_query($params);
header('Location: ' . $authUrl);
exit;
<?php
// naver_callback.php
session_start();
$config = [
'client_id' => 'YOUR_NAVER_CLIENT_ID',
'client_secret' => 'YOUR_NAVER_CLIENT_SECRET',
'redirect_uri' => 'https://yourdomain.com/callback/naver'
];
// State ๊ฒ์ฆ
if ($_GET['state'] !== $_SESSION['naver_state']) {
die('์๋ชป๋ ์์ฒญ์
๋๋ค.');
}
// ํ ํฐ ์์ฒญ
$tokenUrl = 'https://nid.naver.com/oauth2.0/token';
$tokenParams = [
'grant_type' => 'authorization_code',
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'code' => $_GET['code'],
'state' => $_GET['state']
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $tokenUrl . '?' . http_build_query($tokenParams));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$token = json_decode($response, true);
if (isset($token['error'])) {
die('ํ ํฐ ๋ฐ๊ธ ์คํจ: ' . $token['error_description']);
}
// ์ฌ์ฉ์ ์ ๋ณด ์์ฒญ
$userInfoUrl = 'https://openapi.naver.com/v1/nid/me';
$headers = ['Authorization: Bearer ' . $token['access_token']];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $userInfoUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
if ($result['resultcode'] !== '00') {
die('์ฌ์ฉ์ ์ ๋ณด ์กฐํ ์คํจ');
}
$userInfo = $result['response'];
$userData = [
'id' => $userInfo['id'],
'email' => $userInfo['email'],
'name' => $userInfo['name'] ?? $userInfo['nickname'],
'avatar' => $userInfo['profile_image'],
'provider' => 'naver',
'access_token' => $token['access_token'],
'refresh_token' => $token['refresh_token'],
'expires' => time() + $token['expires_in']
];
saveOrUpdateUser($userData);
$_SESSION['user'] = $userData;
header('Location: /dashboard');
exit;
๐ญ ํ์ด์ค๋ถ ๋ก๊ทธ์ธ ๊ตฌํํ๊ธฐ
ํ์ด์ค๋ถ์ ์ ์ธ๊ณ์ ์ผ๋ก ๊ฐ์ฅ ๋ง์ด ์ฐ์ด๋ ์์ ๋ก๊ทธ์ธ์ด์ผ. ํ์ง๋ง ์ต๊ทผ ๊ฐ์ธ์ ๋ณด ๋ณดํธ ์ ์ฑ ์ด ๊ฐํ๋๋ฉด์ ์กฐ๊ธ ๊น๋ค๋ก์์ก์ด. ๐
<?php
// facebook_login.php
session_start();
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\Facebook;
$config = [
'client_id' => 'YOUR_FACEBOOK_APP_ID',
'client_secret' => 'YOUR_FACEBOOK_APP_SECRET',
'redirect_uri' => 'https://yourdomain.com/callback/facebook',
'graph_api_version' => 'v18.0' // ์ต์ ๋ฒ์ ์ฌ์ฉ
];
$provider = new Facebook([
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => $config['redirect_uri'],
'graphApiVersion' => $config['graph_api_version']
]);
$authUrl = $provider->getAuthorizationUrl([
'scope' => ['email', 'public_profile']
]);
$_SESSION['facebook_state'] = $provider->getState();
header('Location: ' . $authUrl);
exit;
<?php
// facebook_callback.php
session_start();
require_once 'vendor/autoload.php';
use League\OAuth2\Client\Provider\Facebook;
$config = [
'client_id' => 'YOUR_FACEBOOK_APP_ID',
'client_secret' => 'YOUR_FACEBOOK_APP_SECRET',
'redirect_uri' => 'https://yourdomain.com/callback/facebook',
'graph_api_version' => 'v18.0'
];
$provider = new Facebook([
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => $config['redirect_uri'],
'graphApiVersion' => $config['graph_api_version']
]);
// State ๊ฒ์ฆ
if (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['facebook_state'])) {
unset($_SESSION['facebook_state']);
die('์๋ชป๋ state ๊ฐ์
๋๋ค.');
}
try {
$token = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
// ์ฅ๊ธฐ ํ ํฐ์ผ๋ก ๊ตํ (์ ํ์ฌํญ, 60์ผ ์ ํจ)
$longLivedToken = $provider->getLongLivedAccessToken($token);
$user = $provider->getResourceOwner($longLivedToken);
$userData = [
'id' => $user->getId(),
'email' => $user->getEmail(),
'name' => $user->getName(),
'avatar' => $user->getPictureUrl(),
'provider' => 'facebook',
'access_token' => $longLivedToken->getToken(),
'expires' => $longLivedToken->getExpires()
];
saveOrUpdateUser($userData);
$_SESSION['user'] = $userData;
header('Location: /dashboard');
exit;
} catch (Exception $e) {
die('์ค๋ฅ ๋ฐ์: ' . $e->getMessage());
}
2. ํ๋กํ ์ฌ์ง: ๊ธฐ๋ณธ ์ ๊ณต๋๋ ์ฌ์ง URL์ ์์ URL์ด๋ผ ๋ง๋ฃ๋ ์ ์์ด. ๋ค์ด๋ก๋ํด์ ์์ฒด ์๋ฒ์ ์ ์ฅํ๋ ๊ฒ ์ข์
3. ๋ฐ์ดํฐ ์ญ์ ์ฝ๋ฐฑ: ํ์ด์ค๋ถ ์ ์ฑ ์ ์ฌ์ฉ์๊ฐ ์ฑ ์ฐ๊ฒฐ์ ํด์ ํ๋ฉด ๋ฐ์ดํฐ๋ฅผ ์ญ์ ํด์ผ ํด. ๋ฐ์ดํฐ ์ญ์ ์ฝ๋ฐฑ URL์ ์ค์ ํด์ผ ํจ
๐ ๋ณด์ ๋ฒ ์คํธ ํ๋ํฐ์ค
์์
๋ก๊ทธ์ธ์ ๊ตฌํํ ๋ ๋ณด์์ ์ ๋ง ์ค์ํด! ํดํน๋นํ๋ฉด ์ฌ์ฉ์ ์ ๋ณด๊ฐ ๋ค ํธ๋ฆฌ๋๊น. ๐ฑ
์ค์ ์์ ๊ผญ ์ง์ผ์ผ ํ ๋ณด์ ์์น๋ค์ ์๋ ค์ค๊ฒ:
์์ ๋ก๊ทธ์ธ์ HTTP์์ ์๋ํ์ง ์์. ๋ฐ๋์ SSL ์ธ์ฆ์๋ฅผ ์ค์นํ๊ณ HTTPS๋ฅผ ์ฌ์ฉํด์ผ ํด.
Let's Encrypt๋ก ๋ฌด๋ฃ ์ธ์ฆ์๋ฅผ ๋ฐ์ ์ ์์ผ๋ ํ๊ณ ๋์ง ๋ง! ๐
๋ชจ๋ OAuth ์์ฒญ์ state ๊ฐ์ ํฌํจ์ํค๊ณ ์ฝ๋ฐฑ์์ ๊ฒ์ฆํด์ผ ํด.
์ด๊ฑธ ์ ํ๋ฉด ๊ณต๊ฒฉ์๊ฐ ๋ค๋ฅธ ์ฌ๋์ ๊ณ์ ์ผ๋ก ๋ก๊ทธ์ธ์ํฌ ์ ์์ด!
// ์ข์ ์ $state = bin2hex(random_bytes(32)); // ์ถฉ๋ถํ ๊ธด ๋๋ค ๊ฐ $_SESSION['oauth_state'] = $state; // ๋์ ์ $state = '12345'; // ์์ธก ๊ฐ๋ฅํ ๊ฐ์ ์ํ!
Client Secret์ ์๋ฒ์์๋ง ์ฌ์ฉํ๊ณ ์ ๋ ํด๋ผ์ด์ธํธ(๋ธ๋ผ์ฐ์ )์ ๋ ธ์ถํ๋ฉด ์ ๋ผ.
JavaScript์ ํ๋์ฝ๋ฉํ๋ ๊ฑด ์์ด ํ์์ผ! ๐
// ํ๊ฒฝ๋ณ์๋ก ๊ด๋ฆฌํ๊ธฐ
$clientSecret = getenv('GOOGLE_CLIENT_SECRET');
// ๋๋ .env ํ์ผ ์ฌ์ฉ (vlucas/phpdotenv)
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$clientSecret = $_ENV['GOOGLE_CLIENT_SECRET'];
์ก์ธ์ค ํ ํฐ๊ณผ ๋ฆฌํ๋ ์ ํ ํฐ์ ์ํธํํด์ ์ ์ฅํ๋ ๊ฒ ์ข์.
ํนํ ๋ฆฌํ๋ ์ ํ ํฐ์ ์ฅ๊ธฐ๊ฐ ์ ํจํ๋๊น ๋ ์กฐ์ฌํด์ผ ํด!
function encryptToken($token) {
$key = getenv('ENCRYPTION_KEY'); // 32๋ฐ์ดํธ ํค
$iv = random_bytes(16);
$encrypted = openssl_encrypt($token, 'AES-256-CBC', $key, 0, $iv);
return base64_encode($iv . $encrypted);
}
function decryptToken($encryptedToken) {
$key = getenv('ENCRYPTION_KEY');
$data = base64_decode($encryptedToken);
$iv = substr($data, 0, 16);
$encrypted = substr($data, 16);
return openssl_decrypt($encrypted, 'AES-256-CBC', $key, 0, $iv);
}
ํ์ํ ๊ถํ๋ง ์์ฒญํด. ์ฌ์ฉ์๋ค์ ๊ณผ๋ํ ๊ถํ ์์ฒญ์ ์ซ์ดํ๊ณ ์ ๋ขฐ๋๊ฐ ๋จ์ด์ ธ.
์ด๋ฉ์ผ๊ณผ ํ๋กํ๋ง ํ์ํ๋ฉด ๊ทธ๊ฒ๋ง ์์ฒญํ์!
์ก์ธ์ค ํ ํฐ์ ๋ง๋ฃ๋ผ. ๋ง๋ฃ๋ ํ ํฐ์ผ๋ก API๋ฅผ ํธ์ถํ๋ฉด ์๋ฌ๊ฐ ๋๋๊น
๋ฆฌํ๋ ์ ํ ํฐ์ผ๋ก ์๋ ๊ฐฑ์ ํ๋ ๋ก์ง์ ๊ตฌํํด์ผ ํด.
function refreshAccessToken($provider, $refreshToken) {
try {
$newToken = $provider->getAccessToken('refresh_token', [
'refresh_token' => $refreshToken
]);
// DB์ ์ ํ ํฐ ์ ์ฅ
updateUserToken($newToken->getToken(), $newToken->getExpires());
return $newToken;
} catch (Exception $e) {
// ๋ฆฌํ๋ ์ ํ ํฐ๋ ๋ง๋ฃ๋จ - ์ฌ๋ก๊ทธ์ธ ํ์
logout();
header('Location: /login?expired=1');
exit;
}
}
์ธ์ ํ์ด์ฌํน์ ๋ฐฉ์งํ๊ธฐ ์ํ ์ค์ ๋ค:
// php.ini ๋๋ ์ฝ๋์์ ์ค์
ini_set('session.cookie_httponly', 1); // JavaScript ์ ๊ทผ ์ฐจ๋จ
ini_set('session.cookie_secure', 1); // HTTPS์์๋ง ์ ์ก
ini_set('session.cookie_samesite', 'Lax'); // CSRF ๋ฐฉ์ง
ini_set('session.use_strict_mode', 1); // ์ธ์
๊ณ ์ ๊ณต๊ฒฉ ๋ฐฉ์ง
session_start();
// ๋ก๊ทธ์ธ ์ฑ๊ณต ์ ์ธ์
ID ์ฌ์์ฑ
session_regenerate_id(true);
๋ฌด์ฐจ๋ณ ๋์ ๊ณต๊ฒฉ์ ๋ง๊ธฐ ์ํด ๋ก๊ทธ์ธ ์๋ ํ์๋ฅผ ์ ํํด์ผ ํด.
function checkRateLimit($ip) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = "login_attempts:$ip";
$attempts = $redis->get($key) ?: 0;
if ($attempts >= 5) {
$ttl = $redis->ttl($key);
die("๋๋ฌด ๋ง์ ์๋์
๋๋ค. {$ttl}์ด ํ ๋ค์ ์๋ํ์ธ์.");
}
$redis->incr($key);
$redis->expire($key, 300); // 5๋ถ
}
๐จ ์ฌ์ฉ์ ์ธํฐํ์ด์ค ๋์์ธ
๊ธฐ๋ฅ๋ ์ค์ํ์ง๋ง UI๋ ์ค์ํด! ์ฌ์ฉ์๊ฐ ์ฝ๊ณ ํธํ๊ฒ ๋ก๊ทธ์ธํ ์ ์์ด์ผ ํ๋๊น. ๐
์์๊ณ ์ค์ฉ์ ์ธ ์์
๋ก๊ทธ์ธ ๋ฒํผ์ ๋ง๋ค์ด๋ณด์:
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>์์
๋ก๊ทธ์ธ</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
padding: 40px;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 400px;
width: 90%;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 28px;
}
.social-buttons {
display: flex;
flex-direction: column;
gap: 15px;
}
.social-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
color: white;
}
.social-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
.social-btn svg {
width: 24px;
height: 24px;
margin-right: 12px;
}
.btn-google {
background: #4285f4;
}
.btn-facebook {
background: #1877f2;
}
.btn-kakao {
background: #fee500;
color: #000000;
}
.btn-naver {
background: #03c75a;
}
.divider {
text-align: center;
margin: 25px 0;
position: relative;
}
.divider::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 100%;
height: 1px;
background: #e0e0e0;
}
.divider span {
background: white;
padding: 0 15px;
position: relative;
color: #999;
font-size: 14px;
}
</style>
</head>
<body>
<div class="login-container">
<h1>๐ ๋ก๊ทธ์ธ</h1>
<div class="social-buttons">
<a href="/login/google" class="social-btn btn-google">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z"/>
</svg>
Google๋ก ๊ณ์ํ๊ธฐ
</a>
<a href="/login/facebook" class="social-btn btn-facebook">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2.04C6.5 2.04 2 6.53 2 12.06C2 17.06 5.66 21.21 10.44 21.96V14.96H7.9V12.06H10.44V9.85C10.44 7.34 11.93 5.96 14.22 5.96C15.31 5.96 16.45 6.15 16.45 6.15V8.62H15.19C13.95 8.62 13.56 9.39 13.56 10.18V12.06H16.34L15.89 14.96H13.56V21.96A10 10 0 0 0 22 12.06C22 6.53 17.5 2.04 12 2.04Z"/>
</svg>
Facebook์ผ๋ก ๊ณ์ํ๊ธฐ
</a>
<a href="/login/kakao" class="social-btn btn-kakao">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12 3C6.5 3 2 6.58 2 11C2 13.95 3.67 16.58 6.39 18.09L5.26 21.94C5.17 22.21 5.3 22.5 5.56 22.63C5.66 22.68 5.77 22.7 5.88 22.7C6.03 22.7 6.18 22.65 6.29 22.55L10.77 19.03C11.17 19.07 11.58 19.09 12 19.09C17.5 19.09 22 15.51 22 11.09C22 6.67 17.5 3.09 12 3.09V3Z"/>
</svg>
์นด์นด์ค๋ก ๊ณ์ํ๊ธฐ
</a>
<a href="/login/naver" class="social-btn btn-naver">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M16.273 12.845L7.376 0H0v24h7.726V11.156L16.624 24H24V0h-7.727v12.845z"/>
</svg>
๋ค์ด๋ฒ๋ก ๊ณ์ํ๊ธฐ
</a>
</div>
<div class="divider">
<span>๋๋</span>
</div>
<form action="/login" method="post">
<input type="email" name="email" placeholder="์ด๋ฉ์ผ" style="width:100%;padding:12px;margin-bottom:10px;border:1px solid #ddd;border-radius:8px;">
<input type="password" name="password" placeholder="๋น๋ฐ๋ฒํธ" style="width:100%;padding:12px;margin-bottom:15px;border:1px solid #ddd;border-radius:8px;">
<button type="submit" style="width:100%;padding:15px;background:#667eea;color:white;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;">์ด๋ฉ์ผ๋ก ๋ก๊ทธ์ธ</button>
</form>
</div>
</body>
</html>
2. ์์ ์ผ๊ด์ฑ: ๊ฐ ํ๋ซํผ์ ๊ณต์ ๋ธ๋๋ ์์ ์ฌ์ฉ (์ ๋ขฐ๋ ํฅ์)
3. ์์ด์ฝ ํฌ๊ธฐ: 24x24px ์ ๋๊ฐ ์ ๋น
4. ๋ชจ๋ฐ์ผ ์ต์ ํ: ๋ฒํผ ๋์ด๋ ์ต์ 44px (ํฐ์นํ๊ธฐ ํธํ ํฌ๊ธฐ)
5. ๋ก๋ฉ ์ํ: ๋ฒํผ ํด๋ฆญ ์ ๋ก๋ฉ ์ธ๋์ผ์ดํฐ ํ์
๐ ํ ํฐ ๊ฐฑ์ ์๋ํ
์ก์ธ์ค ํ ํฐ์ ๋ณดํต 1์๊ฐ ์ ๋๋ฉด ๋ง๋ฃ๋ผ. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์งํ๋ ค๋ฉด ํ ํฐ์ ์๋์ผ๋ก ๊ฐฑ์ ํด์ผ ํด! ๐
<?php
class TokenManager {
private $pdo;
private $providers = [];
public function __construct($pdo) {
$this->pdo = $pdo;
$this->initProviders();
}
private function initProviders() {
$config = require 'config.php';
// ๊ตฌ๊ธ ํ๋ก๋ฐ์ด๋
$this->providers['google'] = new \League\OAuth2\Client\Provider\Google([
'clientId' => $config['google']['client_id'],
'clientSecret' => $config['google']['client_secret'],
'redirectUri' => $config['google']['redirect_uri']
]);
// ๋ค๋ฅธ ํ๋ก๋ฐ์ด๋๋ค๋ ์ถ๊ฐ...
}
public function getValidToken($userId, $provider) {
// DB์์ ํ ํฐ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
$stmt = $this->pdo->prepare("
SELECT access_token, refresh_token, token_expires
FROM users
WHERE id = ? AND provider = ?
");
$stmt->execute([$userId, $provider]);
$tokenData = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$tokenData) {
throw new Exception('ํ ํฐ ์ ๋ณด๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.');
}
// ํ ํฐ์ด ์์ง ์ ํจํ์ง ํ์ธ (5๋ถ ์ฌ์ )
$expiresAt = strtotime($tokenData['token_expires']);
$now = time();
if ($expiresAt - $now > 300) {
// ์์ง ์ ํจํจ
return $tokenData['access_token'];
}
// ๋ง๋ฃ๋จ - ๊ฐฑ์ ํ์
return $this->refreshToken($userId, $provider, $tokenData['refresh_token']);
}
private function refreshToken($userId, $provider, $refreshToken) {
try {
$oauthProvider = $this->providers[$provider];
$newToken = $oauthProvider->getAccessToken('refresh_token', [
'refresh_token' => $refreshToken
]);
// DB ์
๋ฐ์ดํธ
$stmt = $this->pdo->prepare("
UPDATE users SET
access_token = ?,
token_expires = ?,
updated_at = NOW()
WHERE id = ? AND provider = ?
");
$stmt->execute([
$newToken->getToken(),
date('Y-m-d H:i:s', $newToken->getExpires()),
$userId,
$provider
]);
return $newToken->getToken();
} catch (Exception $e) {
// ๋ฆฌํ๋ ์ ํ ํฐ๋ ๋ง๋ฃ๋จ
error_log("ํ ํฐ ๊ฐฑ์ ์คํจ: " . $e->getMessage());
throw new Exception('๋ค์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.');
}
}
// API ํธ์ถ ํฌํผ ํจ์
public function callAPI($userId, $provider, $url, $method = 'GET', $data = null) {
$token = $this->getValidToken($userId, $provider);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
]);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 401) {
// ํ ํฐ์ด ๋ฌดํจํจ - ํ ๋ฒ ๋ ๊ฐฑ์ ์๋
$token = $this->refreshToken($userId, $provider, $this->getRefreshToken($userId, $provider));
return $this->callAPI($userId, $provider, $url, $method, $data);
}
return json_decode($response, true);
}
private function getRefreshToken($userId, $provider) {
$stmt = $this->pdo->prepare("
SELECT refresh_token FROM users WHERE id = ? AND provider = ?
");
$stmt->execute([$userId, $provider]);
return $stmt->fetchColumn();
}
}
// ์ฌ์ฉ ์์
$pdo = new PDO('mysql:host=localhost;dbname=yourdb', 'username', 'password');
$tokenManager = new TokenManager($pdo);
// ๊ตฌ๊ธ ์บ๋ฆฐ๋ API ํธ์ถ ์์
try {
$events = $tokenManager->callAPI(
$_SESSION['user']['id'],
'google',
'https://www.googleapis.com/calendar/v3/calendars/primary/events'
);
print_r($events);
} catch (Exception $e) {
echo '์ค๋ฅ: ' . $e->getMessage();
}
์ด๋ ๊ฒ TokenManager ํด๋์ค๋ฅผ ๋ง๋ค์ด๋๋ฉด API ํธ์ถํ ๋๋ง๋ค ํ ํฐ ๋ง๋ฃ๋ฅผ ๊ฑฑ์ ํ ํ์๊ฐ ์์ด! ์๋์ผ๋ก ๊ฐฑ์ ํด์ฃผ๋๊น. ํธํ์ง? ๐
๐ฏ ์ค์ ํ์ฉ ์ฌ๋ก
์ด๋ก ๋ง ์์์๋ ๋ถ์กฑํ์ง! ์ค์ ๋ก ์ด๋ป๊ฒ ํ์ฉํ๋์ง ๋ช ๊ฐ์ง ์ฌ๋ก๋ฅผ ๋ณด์ฌ์ค๊ฒ. ๐ผ
์ฌ๋ก 1: ํ๋กํ ์ ๋ณด ๋๊ธฐํ
์ฌ์ฉ์๊ฐ ์์ ๊ณ์ ์์ ํ๋กํ์ ๋ณ๊ฒฝํ๋ฉด ์ฐ๋ฆฌ ์๋น์ค์๋ ์๋์ผ๋ก ๋ฐ์ํ๊ธฐ:
<?php
function syncUserProfile($userId, $provider) {
global $tokenManager, $pdo;
try {
$userInfo = null;
switch ($provider) {
case 'google':
$userInfo = $tokenManager->callAPI(
$userId,
'google',
'https://www.googleapis.com/oauth2/v2/userinfo'
);
break;
case 'kakao':
$userInfo = $tokenManager->callAPI(
$userId,
'kakao',
'https://kapi.kakao.com/v2/user/me'
);
$userInfo = [
'name' => $userInfo['kakao_account']['profile']['nickname'],
'picture' => $userInfo['kakao_account']['profile']['profile_image_url']
];
break;
}
if ($userInfo) {
$stmt = $pdo->prepare("
UPDATE users SET
name = ?,
avatar = ?,
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$userInfo['name'],
$userInfo['picture'],
$userId
]);
return true;
}
} catch (Exception $e) {
error_log("ํ๋กํ ๋๊ธฐํ ์คํจ: " . $e->getMessage());
return false;
}
}
// ํฌ๋ก ์ก์ผ๋ก ๋งค์ผ ์คํ
// 0 3 * * * php /path/to/sync_profiles.php
์ฌ๋ก 2: ์์ ๊ณต์ ๊ธฐ๋ฅ
์ฌ์ฉ์๊ฐ ์ฐ๋ฆฌ ์๋น์ค์ ์ฝํ ์ธ ๋ฅผ ์์ ๋ฏธ๋์ด์ ๊ณต์ ํ๊ธฐ:
<?php
class SocialSharer {
private $tokenManager;
public function __construct($tokenManager) {
$this->tokenManager = $tokenManager;
}
// ํ์ด์ค๋ถ์ ํฌ์คํธ ๊ณต์
public function shareToFacebook($userId, $message, $link) {
try {
$result = $this->tokenManager->callAPI(
$userId,
'facebook',
'https://graph.facebook.com/v18.0/me/feed',
'POST',
[
'message' => $message,
'link' => $link
]
);
return [
'success' => true,
'post_id' => $result['id']
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
// ์นด์นด์คํก ๋ฉ์์ง ๋ณด๋ด๊ธฐ
public function sendKakaoMessage($userId, $templateId, $templateArgs) {
try {
$result = $this->tokenManager->callAPI(
$userId,
'kakao',
'https://kapi.kakao.com/v2/api/talk/memo/default/send',
'POST',
[
'template_object' => json_encode([
'object_type' => 'text',
'text' => $templateArgs['text'],
'link' => [
'web_url' => $templateArgs['url'],
'mobile_web_url' => $templateArgs['url']
]
])
]
);
return ['success' => true];
} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
}
// ์ฌ์ฉ ์์
$sharer = new SocialSharer($tokenManager);
// ์ฌ๋ฅ๋ท์์ ๊ฑฐ๋ ์๋ฃ ์ ์นด์นด์คํก์ผ๋ก ์๋ฆผ
$result = $sharer->sendKakaoMessage(
$_SESSION['user']['id'],
null,
[
'text' => '๐ ๊ฑฐ๋๊ฐ ์๋ฃ๋์์ต๋๋ค! ์ฌ๋ฅ๋ท์ ์ด์ฉํด์ฃผ์
์ ๊ฐ์ฌํฉ๋๋ค.',
'url' => 'https://www.jaenung.net/transactions/12345'
]
);
์ฌ๋ก 3: ์น๊ตฌ ์ด๋ ๊ธฐ๋ฅ
์์ ์น๊ตฌ ๋ชฉ๋ก์ ๊ฐ์ ธ์์ ์๋น์ค์ ์ด๋ํ๊ธฐ:
<?php
function getFacebookFriends($userId) {
global $tokenManager;
try {
// ์ฃผ์: ํ์ด์ค๋ถ์ ์น๊ตฌ ๋ชฉ๋ก ์ ๊ทผ์ด ์ ํ์ ์
// ์ฑ์ ์ฌ์ฉํ๋ ์น๊ตฌ๋ง ์กฐํ ๊ฐ๋ฅ
$friends = $tokenManager->callAPI(
$userId,
'facebook',
'https://graph.facebook.com/v18.0/me/friends'
);
return $friends['data'];
} catch (Exception $e) {
return [];
}
}
function inviteFriends($userId, $friendIds, $message) {
// ์ด๋ ๋งํฌ ์์ฑ (์ถ์ฒ์ธ ์ฝ๋ ํฌํจ)
$referralCode = generateReferralCode($userId);
$inviteLink = "https://yourdomain.com/invite?ref=$referralCode";
// ์ด๋ฉ์ผ ๋๋ ์์
๋ฉ์์ง๋ก ์ด๋์ฅ ๋ฐ์ก
foreach ($friendIds as $friendId) {
sendInvitation($friendId, $message, $inviteLink);
}
}
๐ ๋ฌธ์ ํด๊ฒฐ ๊ฐ์ด๋
๊ฐ๋ฐํ๋ค ๋ณด๋ฉด ๊ผญ ๋ฌธ์ ๊ฐ ์๊ธฐ์ง. ์์ฃผ ๋ฐ์ํ๋ ๋ฌธ์ ๋ค๊ณผ ํด๊ฒฐ ๋ฐฉ๋ฒ์ ์ ๋ฆฌํด๋ดค์ด! ๐ง
| ๋ฌธ์ | ์์ธ | ํด๊ฒฐ ๋ฐฉ๋ฒ |
|---|---|---|
| Redirect URI mismatch | ๋ฑ๋ก๋ URI์ ์ค์ URI๊ฐ ๋ค๋ฆ | ๊ฐ๋ฐ์ ์ฝ์์์ ์ ํํ URI ๋ฑ๋ก (http/https, ํฌํธ, ๊ฒฝ๋ก ๋ชจ๋ ์ผ์นํด์ผ ํจ) |
| Invalid state parameter | CSRF ํ ํฐ ๋ถ์ผ์น | ์ธ์
์ด ์ ๋๋ก ์ ์ง๋๋์ง ํ์ธ ์ฟ ํค ์ค์ ํ์ธ (SameSite, Secure) |
| Token expired | ์ก์ธ์ค ํ ํฐ ๋ง๋ฃ | ๋ฆฌํ๋ ์ ํ ํฐ์ผ๋ก ๊ฐฑ์ ์๋ ๊ฐฑ์ ๋ก์ง ๊ตฌํ |
| Insufficient permissions | ํ์ํ ๊ถํ์ด ์์ | OAuth scope์ ํ์ํ ๊ถํ ์ถ๊ฐ ์ฌ์ฉ์์๊ฒ ์ฌ์ธ์ฆ ์์ฒญ |
| SSL certificate problem | HTTPS ์ธ์ฆ์ ์ค๋ฅ | ์ ํจํ SSL ์ธ์ฆ์ ์ค์น ๊ฐ๋ฐ ํ๊ฒฝ์์๋ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false) (์ด์์์๋ ์ ๋ ๊ธ์ง!) |
| Rate limit exceeded | API ํธ์ถ ํ๋ ์ด๊ณผ | ์บ์ฑ ๊ตฌํ API ํธ์ถ ์ต์ํ ๋ฐฑ์คํ ์๊ณ ๋ฆฌ์ฆ ์ ์ฉ |
error_log()๋ก ์์ธ ์ ๋ณด ๊ธฐ๋ก2. API ์๋ต ํ์ธ:
var_dump($response)๋ก ์ค์ ์๋ต ๋ด์ฉ ํ์ธ3. ๋คํธ์ํฌ ํญ ํ์ฉ: ๋ธ๋ผ์ฐ์ ๊ฐ๋ฐ์ ๋๊ตฌ์์ ์์ฒญ/์๋ต ํ์ธ
4. Postman ์ฌ์ฉ: API ์๋ํฌ์ธํธ๋ฅผ ์ง์ ํ ์คํธ
5. ๊ณต์ ๋ฌธ์ ์ฐธ์กฐ: ๊ฐ ํ๋ซํผ์ ์ต์ API ๋ฌธ์ ํ์ธ
โก ์ฑ๋ฅ ์ต์ ํ
์ฌ์ฉ์๊ฐ ๋ง์์ง๋ฉด ์ฑ๋ฅ์ด ์ค์ํด์ ธ. ์์ ๋ก๊ทธ์ธ์ ๋ ๋น ๋ฅด๊ณ ํจ์จ์ ์ผ๋ก ๋ง๋๋ ๋ฐฉ๋ฒ๋ค! ๐
1. ํ ํฐ ์บ์ฑ
๋งค๋ฒ DB์์ ํ ํฐ์ ๊ฐ์ ธ์ค๋ ๊ฑด ๋นํจ์จ์ ์ด์ผ. Redis๋ Memcached๋ก ์บ์ฑํ์:
<?php
class CachedTokenManager extends TokenManager {
private $redis;
public function __construct($pdo, $redis) {
parent::__construct($pdo);
$this->redis = $redis;
}
public function getValidToken($userId, $provider) {
$cacheKey = "token:$userId:$provider";
// ์บ์์์ ๋จผ์ ํ์ธ
$cachedToken = $this->redis->get($cacheKey);
if ($cachedToken) {
$tokenData = json_decode($cachedToken, true);
// ๋ง๋ฃ 5๋ถ ์ ๊น์ง๋ ์บ์ ์ฌ์ฉ
if ($tokenData['expires'] - time() > 300) {
return $tokenData['access_token'];
}
}
// ์บ์ ๋ฏธ์ค - DB์์ ๊ฐ์ ธ์ค๊ธฐ
$token = parent::getValidToken($userId, $provider);
// ์บ์์ ์ ์ฅ (TTL: 50๋ถ)
$this->redis->setex(
$cacheKey,
3000,
json_encode([
'access_token' => $token,
'expires' => time() + 3600
])
);
return $token;
}
}
2. ์ฐ๊ฒฐ ํ๋ง
๋งค๋ฒ ์๋ก์ด HTTP ์ฐ๊ฒฐ์ ๋ง๋๋ ๋์ ์ฐ๊ฒฐ์ ์ฌ์ฌ์ฉ:
<?php
// Guzzle HTTP ํด๋ผ์ด์ธํธ ์ฌ์ฉ (๋ ํจ์จ์ )
composer require guzzlehttp/guzzle
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Handler\CurlHandler;
$handler = new CurlHandler();
$stack = HandlerStack::create($handler);
$client = new Client([
'handler' => $stack,
'timeout' => 10,
'connect_timeout' => 5,
'http_errors' => false,
'verify' => true
]);
// API ํธ์ถ
$response = $client->get('https://www.googleapis.com/oauth2/v2/userinfo', [
'headers' => [
'Authorization' => 'Bearer ' . $token
]
]);
$userInfo = json_decode($response->getBody(), true);
3. ๋น๋๊ธฐ ์ฒ๋ฆฌ
ํ๋กํ ๋๊ธฐํ ๊ฐ์ ๋ฌด๊ฑฐ์ด ์์ ์ ๋ฐฑ๊ทธ๋ผ์ด๋์์:
<?php
// ํ ์์คํ
์ฌ์ฉ (์: Beanstalkd, RabbitMQ, Redis Queue)
composer require pda/pheanstalk
use Pheanstalk\Pheanstalk;
$pheanstalk = Pheanstalk::create('127.0.0.1');
// ์์
ํ์ ์ถ๊ฐ
$pheanstalk
->useTube('social-sync')
->put(json_encode([
'user_id' => $userId,
'provider' => $provider,
'action' => 'sync_profile'
]));
// ์์ปค ํ๋ก์ธ์ค์์ ์ฒ๋ฆฌ
// worker.php
while (true) {
$job = $pheanstalk
->watch('social-sync')
->reserve();
$data = json_decode($job->getData(), true);
try {
syncUserProfile($data['user_id'], $data['provider']);
$pheanstalk->delete($job);
} catch (Exception $e) {
$pheanstalk->bury($job);
error_log("์์
์คํจ: " . $e->getMessage());
}
}
4. CDN ํ์ฉ
์์ ๋ก๊ทธ์ธ ๋ฒํผ ์์ด์ฝ ๊ฐ์ ์ ์ ๋ฆฌ์์ค๋ CDN์์:
<!-- ์ง์ ํธ์คํ
๋์ CDN ์ฌ์ฉ -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- ๋๋ SVG ์คํ๋ผ์ดํธ ์ฌ์ฉ -->
<svg class="icon">
<use xlink:href="/icons/social-sprite.svg#google"></use>
</svg>
๐ฑ ๋ชจ๋ฐ์ผ ์ฑ ์ฐ๋
์น๋ฟ๋ง ์๋๋ผ ๋ชจ๋ฐ์ผ ์ฑ์์๋ ์์ ๋ก๊ทธ์ธ์ ๊ตฌํํ ์ ์์ด! ์กฐ๊ธ ๋ค๋ฅธ ์ ๊ทผ์ด ํ์ํด. ๐ฒ
๋ค์ดํฐ๋ธ SDK vs ์น๋ทฐ
โข ๋ ๋์ ์ฌ์ฉ์ ๊ฒฝํ (์ฑ ๊ฐ ์ ํ)
โข ์์ฒด ์ธ์ฆ ์ง์ (์ง๋ฌธ, Face ID)
โข ๋ ๋น ๋ฅธ ๋ก๊ทธ์ธ
๋จ์ :
โข ํ๋ซํผ๋ณ ๊ตฌํ ํ์ (iOS, Android)
โข SDK ์ ๋ฐ์ดํธ ๊ด๋ฆฌ ํ์
โข ์น๊ณผ ๋์ผํ ์ฝ๋ ์ฌ์ฉ
โข ์ ์ง๋ณด์ ์ฌ์
๋จ์ :
โข ์ฌ์ฉ์ ๊ฒฝํ ๋จ์ด์ง
โข ์ผ๋ถ ํ๋ซํผ์์ ์ ํ (์: ํ์ด์ค๋ถ์ ์น๋ทฐ ๋ก๊ทธ์ธ ์ฐจ๋จ)
๋ชจ๋ฐ์ผ API ์๋ํฌ์ธํธ
๋ชจ๋ฐ์ผ ์ฑ์ ์ํ RESTful API:
<?php
// api/social-login.php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$provider = $input['provider']; // google, facebook, kakao, naver
$accessToken = $input['access_token']; // ์ฑ์์ ๋ฐ์ ํ ํฐ
try {
// ํ ํฐ ๊ฒ์ฆ ๋ฐ ์ฌ์ฉ์ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
$userInfo = verifyAndGetUserInfo($provider, $accessToken);
// DB์ ์ ์ฅ ๋๋ ์
๋ฐ์ดํธ
$user = saveOrUpdateUser($userInfo);
// JWT ํ ํฐ ์์ฑ (์ฑ์์ ์ฌ์ฉํ ์ธ์ฆ ํ ํฐ)
$jwtToken = generateJWT($user['id']);
echo json_encode([
'success' => true,
'token' => $jwtToken,
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'avatar' => $user['avatar']
]
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
function verifyAndGetUserInfo($provider, $accessToken) {
switch ($provider) {
case 'google':
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://www.googleapis.com/oauth2/v2/userinfo');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $accessToken]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
if (isset($data['error'])) {
throw new Exception('์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค.');
}
return [
'provider' => 'google',
'provider_id' => $data['id'],
'email' => $data['email'],
'name' => $data['name'],
'avatar' => $data['picture']
];
// ๋ค๋ฅธ ํ๋ก๋ฐ์ด๋๋ค๋ ๋น์ทํ๊ฒ ๊ตฌํ...
}
}
function generateJWT($userId) {
// JWT ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ
// composer require firebase/php-jwt
use Firebase\JWT\JWT;
$key = getenv('JWT_SECRET');
$payload = [
'iss' => 'yourdomain.com',
'aud' => 'yourdomain.com',
'iat' => time(),
'exp' => time() + (60 * 60 * 24 * 30), // 30์ผ
'user_id' => $userId
];
return JWT::encode($payload, $key, 'HS256');
}
Android: Google Play Services ์ต์ ๋ฒ์ ์ ์ง
React Native: react-native-google-signin, react-native-fbsdk ์ฌ์ฉ
Flutter: google_sign_in, flutter_facebook_auth ํจํค์ง ์ฌ์ฉ
๐งช ํ ์คํธ ์ ๋ต
์์ ๋ก๊ทธ์ธ์ ์ธ๋ถ ์๋น์ค์ ์์กดํ๊ธฐ ๋๋ฌธ์ ํ ์คํธ๊ฐ ๊น๋ค๋ก์. ํจ๊ณผ์ ์ธ ํ ์คํธ ๋ฐฉ๋ฒ์ ์๋ ค์ค๊ฒ! ๐ฌ
1. ์ ๋ ํ ์คํธ
<?php
// tests/TokenManagerTest.php
use PHPUnit\Framework\TestCase;
class TokenManagerTest extends TestCase {
private $tokenManager;
private $mockPdo;
protected function setUp(): void {
// Mock PDO ์์ฑ
$this->mockPdo = $this->createMock(PDO::class);
$this->tokenManager = new TokenManager($this->mockPdo);
}
public function testGetValidTokenReturnsTokenWhenNotExpired() {
// ํ
์คํธ ๋ฐ์ดํฐ ์ค๋น
$mockStmt = $this->createMock(PDOStatement::class);
$mockStmt->method('fetch')
->willReturn([
'access_token' => 'valid_token',
'refresh_token' => 'refresh_token',
'token_expires' => date('Y-m-d H:i:s', time() + 3600)
]);
$this->mockPdo->method('prepare')
->willReturn($mockStmt);
// ํ
์คํธ ์คํ
$token = $this->tokenManager->getValidToken(1, 'google');
// ๊ฒ์ฆ
$this->assertEquals('valid_token', $token);
}
public function testRefreshTokenWhenExpired() {
// ๋ง๋ฃ๋ ํ ํฐ ์๋๋ฆฌ์ค ํ
์คํธ
// ...
}
}
// ์คํ: vendor/bin/phpunit tests/TokenManagerTest.php
2. ํตํฉ ํ ์คํธ
<?php
// tests/SocialLoginIntegrationTest.php
class SocialLoginIntegrationTest extends TestCase {
public function testGoogleLoginFlow() {
// ํ
์คํธ ๊ณ์ ์ผ๋ก ์ค์ OAuth ํ๋ก์ฐ ํ
์คํธ
$config = [
'client_id' => getenv('TEST_GOOGLE_CLIENT_ID'),
'client_secret' => getenv('TEST_GOOGLE_CLIENT_SECRET'),
'redirect_uri' => 'http://localhost/test/callback'
];
// 1. ์ธ์ฆ URL ์์ฑ ํ
์คํธ
$provider = new Google($config);
$authUrl = $provider->getAuthorizationUrl();
$this->assertStringContainsString('accounts.google.com', $authUrl);
// 2. ํ ํฐ ๊ตํ ํ
์คํธ (์ค์ code ํ์)
// ์ฃผ์: ์ค์ API ํธ์ถ์ด๋ฏ๋ก rate limit ๊ณ ๋ ค
}
}
3. E2E ํ ์คํธ
// Selenium ๋๋ Cypress ์ฌ์ฉ
// cypress/integration/social-login.spec.js
describe('์์
๋ก๊ทธ์ธ', () => {
it('๊ตฌ๊ธ ๋ก๊ทธ์ธ์ด ์ ์ ์๋ํ๋ค', () => {
cy.visit('/login');
cy.get('.btn-google').click();
// ๊ตฌ๊ธ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ์ด๋ ํ์ธ
cy.url().should('include', 'accounts.google.com');
// ํ
์คํธ ๊ณ์ ์ผ๋ก ๋ก๊ทธ์ธ
cy.get('input[type="email"]').type('test@example.com');
cy.get('#next').click();
cy.get('input[type="password"]').type('testpassword');
cy.get('#submit').click();
// ์ฝ๋ฐฑ ํ ๋์๋ณด๋๋ก ์ด๋ ํ์ธ
cy.url().should('include', '/dashboard');
cy.get('.user-name').should('contain', 'Test User');
});
});
2. Rate Limit ๊ณ ๋ ค: API ํธ์ถ ํ์ ์ ํ ์ฃผ์
3. ๋ฏผ๊ฐ ์ ๋ณด ๋ณดํธ: ํ ์คํธ ๊ณ์ ์ ๋ณด๋ฅผ ์ฝ๋์ ํ๋์ฝ๋ฉํ์ง ๋ง ๊ฒ
4. CI/CD ํตํฉ: GitHub Actions, Jenkins ๋ฑ์์ ์๋ ํ ์คํธ
๐ ๋ชจ๋ํฐ๋ง๊ณผ ๋ถ์
์์ ๋ก๊ทธ์ธ์ด ์ ์๋ํ๋์ง, ์ฌ์ฉ์๋ค์ด ์ด๋ค ๋ฐฉ์์ ์ ํธํ๋์ง ๋ชจ๋ํฐ๋งํ๋ ๊ฒ ์ค์ํด! ๐
๋ก๊ทธ์ธ ํต๊ณ ์์ง
<?php
// ๋ก๊ทธ์ธ ์ฑ๊ณต ์ ํต๊ณ ๊ธฐ๋ก
function recordLoginStats($userId, $provider, $success = true) {
global $pdo;
$stmt = $pdo->prepare("
INSERT INTO login_stats (
user_id, provider, success, ip_address,
user_agent, created_at
) VALUES (?, ?, ?, ?, ?, NOW())
");
$stmt->execute([
$userId,
$provider,
$success ? 1 : 0,
$_SERVER['REMOTE_ADDR'],
$_SERVER['HTTP_USER_AGENT']
]);
}
// ๋์๋ณด๋์์ ํต๊ณ ์กฐํ
function getLoginStatistics($startDate, $endDate) {
global $pdo;
$stmt = $pdo->prepare("
SELECT
provider,
COUNT(*) as total_logins,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_logins,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed_logins,
ROUND(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as success_rate
FROM login_stats
WHERE created_at BETWEEN ? AND ?
GROUP BY provider
ORDER BY total_logins DESC
");
$stmt->execute([$startDate, $endDate]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// ์ฌ์ฉ ์์
$stats = getLoginStatistics('2024-01-01', '2024-01-31');
/*
๊ฒฐ๊ณผ:
[
['provider' => 'kakao', 'total_logins' => 1500, 'success_rate' => 98.5],
['provider' => 'google', 'total_logins' => 1200, 'success_rate' => 97.8],
['provider' => 'naver', 'total_logins' => 800, 'success_rate' => 96.2]
]
*/
์๋ฌ ๋ชจ๋ํฐ๋ง
<?php
// Sentry ๊ฐ์ ์๋ฌ ํธ๋ํน ์๋น์ค ์ฌ์ฉ
composer require sentry/sentry
use Sentry\init;
use Sentry\captureException;
init([
'dsn' => 'YOUR_SENTRY_DSN',
'environment' => getenv('APP_ENV'),
'traces_sample_rate' => 1.0
]);
// OAuth ์๋ฌ ์บก์ฒ
try {
$token = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
} catch (Exception $e) {
// Sentry์ ์๋ฌ ์ ์ก
captureException($e, [
'extra' => [
'provider' => 'google',
'user_id' => $_SESSION['user_id'] ?? null,
'code' => $_GET['code'] ?? null
]
]);
// ์ฌ์ฉ์์๊ฒ ์น์ ํ ๋ฉ์์ง
die('๋ก๊ทธ์ธ ์ค ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค. ์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.');
}
์ฑ๋ฅ ๋ชจ๋ํฐ๋ง
<?php
// ๋ก๊ทธ์ธ ์์ ์๊ฐ ์ธก์
function measureLoginPerformance($provider, $callback) {
$startTime = microtime(true);
try {
$result = $callback();
$duration = (microtime(true) - $startTime) * 1000; // ms
// ์ฑ๋ฅ ๋ก๊ทธ ๊ธฐ๋ก
logPerformance($provider, $duration, true);
return $result;
} catch (Exception $e) {
$duration = (microtime(true) - $startTime) * 1000;
logPerformance($provider, $duration, false);
throw $e;
}
}
function logPerformance($provider, $duration, $success) {
global $pdo;
$stmt = $pdo->prepare("
INSERT INTO performance_logs (
provider, duration_ms, success, created_at
) VALUES (?, ?, ?, NOW())
");
$stmt->execute([$provider, $duration, $success ? 1 : 0]);
// ๋๋ฆฐ ๋ก๊ทธ์ธ ์๋ฆผ (3์ด ์ด์)
if ($duration > 3000) {
error_log("Slow login detected: $provider took {$duration}ms");
}
}
// ์ฌ์ฉ ์์
$token = measureLoginPerformance('google', function() use ($provider) {
return $provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
});
๐ ๋ค๊ตญ์ด ์ง์
๊ธ๋ก๋ฒ ์๋น์ค๋ฅผ ๋ง๋ ๋ค๋ฉด ๋ค๊ตญ์ด ์ง์์ ํ์์ผ! ์์ ๋ก๊ทธ์ธ๋ ์ฌ์ฉ์์ ์ธ์ด์ ๋ง์ถฐ ํ์ํ์. ๐บ๏ธ
<?php
// i18n/ko.php
return [
'login.title' => '๋ก๊ทธ์ธ',
'login.google' => 'Google๋ก ๊ณ์ํ๊ธฐ',
'login.facebook' => 'Facebook์ผ๋ก ๊ณ์ํ๊ธฐ',
'login.kakao' => '์นด์นด์ค๋ก ๊ณ์ํ๊ธฐ',
'login.naver' => '๋ค์ด๋ฒ๋ก ๊ณ์ํ๊ธฐ',
'login.or' => '๋๋',
'login.email' => '์ด๋ฉ์ผ๋ก ๋ก๊ทธ์ธ',
'error.invalid_token' => '์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค.',
'error.network' => '๋คํธ์ํฌ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.'
];
// i18n/en.php
return [
'login.title' => 'Sign In',
'login.google' => 'Continue with Google',
'login.facebook' => 'Continue with Facebook',
'login.kakao' => 'Continue with Kakao',
'login.naver' => 'Continue with Naver',
'login.or' => 'or',
'login.email' => 'Sign in with Email',
'error.invalid_token' => 'Invalid token.',
'error.network' => 'Network error occurred.'
];
// ๋ฒ์ญ ํจ์
function __($key, $lang = 'ko') {
static $translations = [];
if (!isset($translations[$lang])) {
$translations[$lang] = require "i18n/$lang.php";
}
return $translations[$lang][$key] ?? $key;
}
// ์ฌ์ฉ ์์
echo __('login.google', $_SESSION['lang'] ?? 'ko');
์์ ๋ก๊ทธ์ธ ๋ฒํผ๋ ์ฌ์ฉ์์ ์ธ์ด ์ค์ ์ ๋ฐ๋ผ ํ์ ์ธ์ด๊ฐ ๋ฐ๋์ด์ผ ํด:
<?php
// ๊ตฌ๊ธ ๋ก๊ทธ์ธ ์ธ์ด ์ค์
$authUrl = $provider->getAuthorizationUrl([
'scope' => ['email', 'profile'],
'hd' => 'example.com', // G Suite ๋๋ฉ์ธ ์ ํ (์ ํ)
'hl' => $_SESSION['lang'] ?? 'ko' // ์ธ์ด ์ค์
]);
// ์นด์นด์ค ๋ก๊ทธ์ธ ์ธ์ด ์ค์
$params = [
'client_id' => $config['client_id'],
'redirect_uri' => $config['redirect_uri'],
'response_type' => 'code',
'lang' => $_SESSION['lang'] ?? 'ko' // ko, en
];
๐ ์ค์ ํ๋ก์ ํธ: ์์ ํ ์์ ๋ก๊ทธ์ธ ์์คํ
์ง๊ธ๊น์ง ๋ฐฐ์ด ๋ด์ฉ์ ๋ชจ๋ ํฉ์ณ์ ์ค์ ์์ ๋ฐ๋ก ์ฌ์ฉํ ์ ์๋ ์์ ํ ์์คํ ์ ๋ง๋ค์ด๋ณด์! ๐๏ธ
ํ๋ก์ ํธ ๊ตฌ์กฐ
social-login-system/ โโโ config/ โ โโโ database.php โ โโโ oauth.php โ โโโ app.php โโโ src/ โ โโโ Controllers/ โ โ โโโ AuthController.php โ โ โโโ SocialAuthController.php โ โโโ Models/ โ โ โโโ User.php โ โโโ Services/ โ โ โโโ TokenManager.php โ โ โโโ SocialAuthService.php โ โ โโโ UserService.php โ โโโ Middleware/ โ โโโ AuthMiddleware.php โ โโโ RateLimitMiddleware.php โโโ public/ โ โโโ index.php โ โโโ login.php โ โโโ callback.php โโโ views/ โ โโโ login.php โ โโโ dashboard.php โโโ tests/ โ โโโ Unit/ โ โโโ Integration/ โโโ vendor/ โโโ .env.example โโโ composer.json โโโ README.md
ํต์ฌ ํ์ผ ๊ตฌํ
<?php
// src/Services/SocialAuthService.php
namespace App\Services;
use League\OAuth2\Client\Provider\Google;
use League\OAuth2\Client\Provider\Facebook;
class SocialAuthService {
private $providers = [];
private $config;
public function __construct(array $config) {
$this->config = $config;
$this->initializeProviders();
}
private function initializeProviders() {
// ๊ตฌ๊ธ
if (isset($this->config['google'])) {
$this->providers['google'] = new Google([
'clientId' => $this->config['google']['client_id'],
'clientSecret' => $this->config['google']['client_secret'],
'redirectUri' => $this->config['google']['redirect_uri']
]);
}
// ํ์ด์ค๋ถ
if (isset($this->config['facebook'])) {
$this->providers['facebook'] = new Facebook([
'clientId' => $this->config['facebook']['client_id'],
'clientSecret' => $this->config['facebook']['client_secret'],
'redirectUri' => $this->config['facebook']['redirect_uri'],
'graphApiVersion' => 'v18.0'
]);
}
// ์นด์นด์ค, ๋ค์ด๋ฒ๋ ์ปค์คํ
ํ๋ก๋ฐ์ด๋ ๊ตฌํ
}
public function getAuthorizationUrl($provider, $scopes = []) {
if (!isset($this->providers[$provider])) {
throw new \Exception("์ง์ํ์ง ์๋ ํ๋ก๋ฐ์ด๋์
๋๋ค: $provider");
}
$options = [];
if (!empty($scopes)) {
$options['scope'] = $scopes;
}
return $this->providers[$provider]->getAuthorizationUrl($options);
}
public function getState($provider) {
return $this->providers[$provider]->getState();
}
public function getAccessToken($provider, $code) {
return $this->providers[$provider]->getAccessToken('authorization_code', [
'code' => $code
]);
}
public function getUserInfo($provider, $token) {
$user = $this->providers[$provider]->getResourceOwner($token);
return [
'provider' => $provider,
'provider_id' => $user->getId(),
'email' => $user->getEmail(),
'name' => $user->getName(),
'avatar' => $this->getAvatar($provider, $user),
'access_token' => $token->getToken(),
'refresh_token' => $token->getRefreshToken(),
'expires' => $token->getExpires()
];
}
private function getAvatar($provider, $user) {
switch ($provider) {
case 'google':
return $user->getAvatar();
case 'facebook':
return $user->getPictureUrl();
default:
return null;
}
}
}
<?php
// src/Controllers/SocialAuthController.php
namespace App\Controllers;
use App\Services\SocialAuthService;
use App\Services\UserService;
class SocialAuthController {
private $socialAuth;
private $userService;
public function __construct(SocialAuthService $socialAuth, UserService $userService) {
$this->socialAuth = $socialAuth;
$this->userService = $userService;
}
public function redirectToProvider($provider) {
try {
$authUrl = $this->socialAuth->getAuthorizationUrl($provider);
$_SESSION['oauth_state'] = $this->socialAuth->getState($provider);
$_SESSION['oauth_provider'] = $provider;
header('Location: ' . $authUrl);
exit;
} catch (\Exception $e) {
$this->handleError($e);
}
}
public function handleCallback() {
// State ๊ฒ์ฆ
if (empty($_GET['state']) || $_GET['state'] !== $_SESSION['oauth_state']) {
$this->handleError(new \Exception('Invalid state parameter'));
return;
}
$provider = $_SESSION['oauth_provider'];
try {
// ํ ํฐ ๋ฐ๊ธฐ
$token = $this->socialAuth->getAccessToken($provider, $_GET['code']);
// ์ฌ์ฉ์ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
$userData = $this->socialAuth->getUserInfo($provider, $token);
// ์ฌ์ฉ์ ์ ์ฅ ๋๋ ์
๋ฐ์ดํธ
$user = $this->userService->findOrCreateSocialUser($userData);
// ์ธ์
์ ์ ์ฅ
$_SESSION['user_id'] = $user['id'];
$_SESSION['user'] = $user;
// ๋ก๊ทธ์ธ ํต๊ณ ๊ธฐ๋ก
$this->userService->recordLogin($user['id'], $provider);
// ๋์๋ณด๋๋ก ๋ฆฌ๋ค์ด๋ ํธ
header('Location: /dashboard');
exit;
} catch (\Exception $e) {
$this->handleError($e);
}
}
private function handleError(\Exception $e) {
error_log('Social login error: ' . $e->getMessage());
$_SESSION['error'] = '๋ก๊ทธ์ธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.';
header('Location: /login');
exit;
}
}
์ด๋ ๊ฒ ๊ตฌ์กฐํํ๋ฉด ์ฝ๋๊ฐ ๊น๋ํ๊ณ ์ ์ง๋ณด์ํ๊ธฐ ์ฌ์์ ธ! ์๋ก์ด ์์ ๋ก๊ทธ์ธ ์ถ๊ฐ๋ ๊ฐ๋จํ๊ณ . ๐
๐ ๋ฐฐํฌ ์ฒดํฌ๋ฆฌ์คํธ
๊ฐ๋ฐ์ด ๋๋ฌ๋ค๊ณ ๋ฐ๋ก ๋ฐฐํฌํ๋ฉด ์ ๋ผ! ์ค์๋น์ค ์ ์ ๊ผญ ํ์ธํด์ผ ํ ๊ฒ๋ค์ด ์์ด. โ
โ Client ID/Secret์ ํ๊ฒฝ๋ณ์๋ก ๊ด๋ฆฌ
โ .env ํ์ผ์ด .gitignore์ ํฌํจ๋์ด ์๋์ง ํ์ธ
โ ํ๋ก๋์ ํ๊ฒฝ์ ํ๊ฒฝ๋ณ์ ์ค์ ์๋ฃ
โ HTTPS ์ ์ฉ ์๋ฃ
โ ์ธ์ฆ์ ๋ง๋ฃ์ผ ํ์ธ
โ ์๋ ๊ฐฑ์ ์ค์ (Let's Encrypt)
โ ๋ชจ๋ ์์ ํ๋ซํผ์ ๊ฐ๋ฐ์ ์ฝ์์์ ํ๋ก๋์ URI ๋ฑ๋ก
โ ํ ์คํธ URI ์ ๊ฑฐ ๋๋ ๋นํ์ฑํ
โ ๋๋ฉ์ธ ์์ ๊ถ ์ธ์ฆ ์๋ฃ
โ ์ธ์ ๋ณด์ ์ค์ ํ์ฑํ
โ CSRF ํ ํฐ ๊ฒ์ฆ ํ์ฑํ
โ Rate limiting ์ค์
โ SQL Injection ๋ฐฉ์ด ํ์ธ
โ XSS ๋ฐฉ์ด ํ์ธ
โ ํ๋ก๋์ ๋ชจ๋์์ ์์ธ ์๋ฌ ๋ฉ์์ง ์จ๊น
โ ์๋ฌ ๋ก๊น ์ค์
โ ๋ชจ๋ํฐ๋ง ๋๊ตฌ ์ฐ๋ (Sentry, New Relic ๋ฑ)
โ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ธ๋ฑ์ค ์ค์
โ ์บ์ฑ ํ์ฑํ (Redis, Memcached)
โ CDN ์ค์
โ ์ด๋ฏธ์ง ์ต์ ํ
โ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์๋ ๋ฐฑ์ ์ค์
โ ์ฝ๋ ๋ฒ์ ๊ด๋ฆฌ (Git)
โ ๋ณต๊ตฌ ์ ์ฐจ ๋ฌธ์ํ
โ ๋ชจ๋ ์์ ๋ก๊ทธ์ธ ํ๋ก์ฐ ํ ์คํธ
โ ๋ค์ํ ๋ธ๋ผ์ฐ์ ์์ ํ ์คํธ
โ ๋ชจ๋ฐ์ผ ํ๊ฒฝ ํ ์คํธ
โ ์๋ฌ ์๋๋ฆฌ์ค ํ ์คํธ
โ ๊ฐ์ธ์ ๋ณด ์ฒ๋ฆฌ๋ฐฉ์นจ ์์ฑ
โ ์ด์ฉ์ฝ๊ด ์์ฑ
โ ์ฟ ํค ์ ์ฑ ๊ณต์ง
โ GDPR ์ค์ (์ ๋ฝ ์ฌ์ฉ์ ๋์ ์)
โ API ๋ฌธ์ ์์ฑ
โ ๋ฐฐํฌ ๊ฐ์ด๋ ์์ฑ
โ ํธ๋ฌ๋ธ์ํ ๊ฐ์ด๋ ์์ฑ
โ ํ์ ๊ต์ก ์๋ฃ ์ค๋น
๐ฌ ๋ง์น๋ฉฐ
์! ์ฌ๊ธฐ๊น์ง ์ฝ์๋ค๋ฉด ์ ๋ง ๋๋จํด! ๐
์์
๋ก๊ทธ์ธ๊ณผ OAuth๋ ์ฒ์์๋ ๋ณต์กํด ๋ณด์ด์ง๋ง, ํ๋์ฉ ์ดํดํ๊ณ ๋๋ฉด ์ ๋ง ๊ฐ๋ ฅํ ๋๊ตฌ์ผ. ์ฌ์ฉ์ ๊ฒฝํ์ ํฌ๊ฒ ๊ฐ์ ํ ์ ์๊ณ , ๊ฐ๋ฐ์๋ก์๋ ๋ง์ ๊ฑธ ๋ฐฐ์ธ ์ ์์ด.
์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์์
๋ก๊ทธ์ธ์ด ํต์ฌ ๊ธฐ๋ฅ์ด์์? ์ด์ ๋๋ ์ด๋ฐ ์์คํ
์ ์ง์ ๋ง๋ค ์ ์์ด! ๐
๊ธฐ์ตํด์ผ ํ ํต์ฌ ํฌ์ธํธ๋ค:
1. ๋ณด์์ด ์ต์ฐ์ ์ด์ผ - HTTPS, State ๊ฒ์ฆ, ํ ํฐ ์ํธํ๋ ํ์
2. ์ฌ์ฉ์ ๊ฒฝํ์ ์๊ฐํด - ๊ฐ๋จํ๊ณ ์ง๊ด์ ์ธ UI
3. ์๋ฌ ์ฒ๋ฆฌ๋ฅผ ์ฒ ์ ํ - ๋ฌด์์ด ์๋ชป๋๋์ง ๋ช
ํํ๊ฒ
4. ํ
์คํธ๋ฅผ ๊ฒ์๋ฆฌํ์ง ๋ง - ์ธ๋ถ ์๋น์ค ์์กด์ฑ์ด ํฌ๋๊น
5. ๋ชจ๋ํฐ๋ง์ ์ค์ ํด - ๋ฌธ์ ๋ฅผ ๋นจ๋ฆฌ ๋ฐ๊ฒฌํ๊ณ ๋์
์ด์ ์ง์ ํ๋ก์ ํธ์ ์ ์ฉํด๋ด! ๋งํ๋ ๋ถ๋ถ์ด ์์ผ๋ฉด ๊ฐ ํ๋ซํผ์ ๊ณต์ ๋ฌธ์๋ฅผ ์ฐธ๊ณ ํ๊ณ , ์ปค๋ฎค๋ํฐ์ ์ง๋ฌธํ๋ ๊ฒ๋ ์ข์.
ํ์ดํ
! ๋๋ ํ ์ ์์ด! ๐ชโจ
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.